针对 Keycloak 重置凭据流程绕过的概念验证与漏洞检查工具,并附带一个可选的浏览器组件,用于在真实的 Chrome 窗口中渲染最终状态。
该工具为单文件 cve_2026_18963_poc.py,基于 Python 标准库编写。Playwright 是唯一的第三方依赖,且采用延迟导入,因此 HTTP 部分可在未安装任何依赖的原始解释器上运行。
cve_2026_18963_poc.py 1009 lines, Python 3.10 or newer
致谢。 CVE-2026-18963 由 James Paremain 报告给 Red Hat,并在厂商公告中致谢。Orevim 并未发现或报告此漏洞。本仓库是基于已发布公告独立编写的概念验证。
CVE-2026-18963 是 Keycloak 忘记密码流程中的一个认证绕过漏洞。它由两个缺陷共同作用而成。
未限定作用域的标记。 当用户打开认证器选择界面时,Keycloak 会向认证会话写入一条 AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED 标记。该标记是一个裸布尔值。它记录的是某个选择界面被显示过,而非哪个执行流程显示了它。该标记的生命周期长于设置它的执行流程。
未加防护的处理程序。 ResetCredentialEmail.action() 在解析出的用户上设置 emailVerified 并调用 success(),却不检查邮件链接本应携带的操作令牌。该方法假定只能通过跟随该链接才能到达。
将两者串联后,未认证的调用者针对用户名执行流程设置该标记,推进流程使邮件执行流程成为当前执行流程,然后重新进入重置 URL。过期的标记导致选择界面被渲染为绑定到邮件执行流程。向该执行流程的 loginAction 提交空请求体即可直接调用未加防护的处理程序。流程随即推进到 UPDATE_PASSWORD 必需操作,而邮件从未被读取。
| 标识符 | CVE-2026-18963, GHSA-4gv3-mc9p-5wqc |
| 类别 | CWE-640,忘记密码的弱密码恢复机制 |
| CVSS v3.1 | 9.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| 受影响版本 | >= 26.0.0, < 26.4.15, >= 26.5.0, < 26.6.6, >= 26.7.0, < 26.7.2 |
| 修复版本 | 26.4.15、26.6.6、26.7.2。26.8.0 系列自带该修复 |
| 软件包 | org.keycloak:keycloak-services (Maven) |
| 发布日期 | 2026 年 8 月 18 日 |
| 报告者 | James Paremain,在 Red Hat 公告中致谢 |
唯一的前提条件是 realm 向未认证用户提供密码恢复功能。无需任何凭据、令牌或先前会话。
共八个请求,按顺序排列。标签与工具打印的步骤标签以及它在 --out 下写入的文件名一致。
| # | 标签 | 方法 | 作用 |
|---|---|---|---|
| 1 | login | GET | 授权 URL。建立认证会话并生成重置链接。 |
| 2 | reset | GET | 重置凭据页面。包含用户名表单。 |
| 3 | tryanother | POST | tryAnotherWay=on。写入未限定作用域的标记。 |
| 4 | username | POST | 标识符。推进流程使邮件执行流程成为当前执行流程。若标识符可解析,则发送恢复邮件。 |
| 5 | reenter | GET | 再次访问重置 URL。过期标记使选择器被渲染为绑定到邮件执行流程。 |
| 6 | action | POST | 向该执行流程的 loginAction 提交空请求体。调用未加防护的处理程序。 |
| 7 | required-action | GET | 跟随 Location 跳转到 UPDATE_PASSWORD 页面。 |
| 8 | setpassword | POST | 新密码。仅在指定 --set-password 时发送。 |
第 6 步是漏洞利用点,空请求体是关键所在。若改为提交 authenticationExecution=<id>,则是普通的选择器交互:它会切换执行流程并重新发送恢复邮件,永远无法触及该缺陷。空 POST 不响应任何质询,因此 Keycloak 会为过期标记所留下的当前执行流程调用处理程序。
# Decide whether the instance is affected. Changes nothing, sends no mail.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check
# Additionally report which identifiers resolve to a real account.
# Resolving accounts receive a recovery email. See the note below.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check --users-file names.txt --rate 0.25
# Reach the set-password form for one account and stop there.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected]
# Complete the takeover. This locks the account holder out.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected] \
--set-password 'N3w-passw0rd!'
--check 使用形如 <16 hex>@<8 hex>.invalid 的随机标识符进行探测。.invalid 顶级域由 RFC 2606 保留,无法解析,因此该探测不会与真实账户冲突,也不会导致邮件被发送。它能在不接触任何人的情况下确定构建的状态。
添加
--users会改变这一点。 第 4 步是普通的忘记密码提交。在配置了 SMTP 的 realm 上,解析到拥有电子邮件地址的用户的标识符会收到一封真实的密码恢复邮件,受影响和已修补的构建都是如此。裸探测则不会,因为当用户为 null 时ResetCredentialEmail.authenticate()不发送任何内容。请将--check --users视为会通知真实人员的流程,并在运行前确认它在你授权范围内。
该工具根据第 6 步(空 POST)的响应进行分类。
| 响应 | 结果 | 含义 |
|---|---|---|
| 400 | account-absent | 处理程序在没有 action token 的情况下运行,且未解析到任何用户。该构建受影响。 |
302 且带 Location | form-reached | 处理程序运行了,且账户可解析。受影响,且该标识符是真实的。 |
| 其他任何情况 | not-vulnerable | 空 POST 未推进。 |
| 第 5 步无 selector | not-vulnerable | 过期提示未被采纳。 |
| 第 8 步后返回 302 | password-set | 密码已更改,且 realm 签发了会话。 |
| 超过重试预算的 429 | rate-limited | 无判定结果。运行已停止。 |
| 在任何限流之后得到的否定结果 | inconclusive | 见下文。 |
400 是关键的一个。它将该构建受影响与该账户存在区分开来,这使得 --check 能够在不枚举任何真实用户的情况下确定实例的暴露情况。解析标识符则是另一个独立的、需主动选择的问题。
只有在没有任何限流的情况下,否定结果才是可靠的。 Keycloak 按其自身时钟使认证会话过期。链中途出现 429 会带来一次退避,而在长时间休眠后恢复的链返回的页面与已修补构建返回的页面完全相同。将其报告为未受影响是检查器绝不能有的失败模式,因此任何在限流事件之后得到的否定结果都会被降级为 inconclusive,并附带限流次数以及以更慢速度重新运行的建议。
关于部署的任何内容都没有硬编码。该工具读取它所需的信息:
--url 和 --realm 推导得出,或通过 --discovery-url 直接提供。传入 --auth-url 可完全跳过发现。login-actions/reset-credentials 的第一个链接。单页应用主题(Keycloakify 及类似主题)完全不渲染服务端 <form> 元素。action 仅存在于主题嵌入 <script> 块中的 kcContext 对象里。该工具会回退到从该对象中读取 loginAction 和 loginResetCredentialsUrl,并反转义 \/ 和 \uXXXX。没有表单就没有可携带的字段,因此请求体仅包含该步骤覆盖的内容,字段名来自 --username-field 和 --password-fields。
每个协议默认值都暴露为标志,包括 selector 字段本身(--selector-field,默认 tryAnotherWay=on)。
429 从来不是判定结果,而以招致它的速率重试只会再招致一个。节奏控制器会大幅退避并缓慢恢复,其依据是请求之间的间隔而非令牌桶:
--rate 请求/秒开始,并且绝不超出该值。
--rate 是上限,而不是要收敛到的目标。--slow-factor 扩大,并保持扩大后的状态。它不会在下一个请求时立即恢复。Retry-After 时予以遵循,并且仅对该次休眠覆盖计算出的退避时间。--recover-after 次干净响应之后,间隔才会按 --recover-factor 收窄,并且绝不会超过请求的速率。--floor-rate 限制运行可以慢到何种程度。--jitter 随机化,因此请求模式不会像节拍器一样规律。--retries 限制单个被限流请求的重试次数。超过该次数后,运行会以 rate-limited 停止,而不是进行猜测。每次运行都会报告节奏最终稳定在什么状态:
pacing: asked for 0.5 rps, settled at 0.125 rps after 3 throttled request(s);
pass --rate 0.125 next time
这一行正是该机制的关键所在。一次运行测量边界所能容忍的程度,下一次运行便从那里开始。
对于带列表的 --check,每个标识符都会获得一个全新的 Exploit 实例,各自拥有独立的 cookie jar,但它们全部共享同一个 pacer,因此学习到的速率会贯穿整个列表,而不是针对每个标识符重新发现。
--address 将所有连接固定到一个 IP,同时保持 Host 头和 TLS SNI 绑定到主机名。这是一个自定义的 HTTPSConnection,其 connect() 拨号到固定地址并调用 wrap_socket(server_hostname=self.host)。None 的处理器,因为此链中的每个 Location 都是证据。第 6 步的 302 是判定结果,第 8 步的 302 则是授权码所在之处。--insecure。--user-agent 和可重复的 --header NAME:VALUE。不会注入任何内容。没有关联头、没有标记参数、没有合成追踪器,因此请求指纹保持为其所声称客户端的代表性特征。--out DIR 将每个请求和响应写为编号对:
DIR/01-login.headers request line, request headers, status, response headers
DIR/01-login.body the response body, verbatim
DIR/02-reset.headers
...
在 --check 下,这些目录按主体划分命名空间:随机探测使用 DIR/probe/,每个标识符分别使用 DIR/id-001/、DIR/id-002/,因此列表运行会生成按请求划分的清单,而不是单一汇总。
该模块在写入任何内容之前设置 umask(0o077),因此捕获文件仅所有者可访问。它们包含会话 cookie 和完整响应体。请将该目录视为受限材料。
--json 会打印结构化结果而非步骤表:结果、标识符、每个步骤及其状态和 Location、表单 URL、授权码、会话 cookie 名称以及节奏报告。
| 代码 | 含义 |
|---|---|
| 0 | 受影响。--check 发现了该缺陷,或某次运行到达了表单或设置了密码。 |
| 1 | 未受影响,或该次运行未产生任何访问。 |
| 2 | 链路无法继续,或发生传输错误。 |
| 3 | 超出重试预算被限流。无判定结果。 |
HTTP 链路结束时持有 SSO cookie 和一个 URL。这作为证据已经足够,但并不直观:报告读者无法仅凭一个 302 和一个 cookie 名称判断该流程确实落到了属于真实账户的密码表单上。浏览器组件将链路的 cookie 移植到真实的 Chrome 窗口中并加载页面,从而使最终状态可以被看到并捕获。
# Show the set-password form the chain reached, 20 seconds after reaching it.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected] \
--open form --open-delay 20 --open-channel chrome \
--screenshot form.png
# After a takeover, show the account console the new session reaches.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected] \
--set-password 'N3w-passw0rd!' --open session --open-channel chrome