一个 psql 脚本,用于在 Keycloak PostgreSQL 数据库中搜索 CVE-2026-18963 的利用痕迹(通过重置凭据流程实现的未认证账户接管)。
由 KYOS 发布。我们在为所运营的 Keycloak 部署打补丁时编写了它,用于验证在暴露窗口期内没有人被接管。
CVE-2026-18963 是 keycloak-services 重置凭据流程中的一个缺陷(keycloak#51833)。未认证的攻击者无需点击邮件验证链接,即可为任意用户完成密码重置流程,然后在该账户上设置新密码。无需任何用户交互。
在一次性实例上针对 Keycloak 26.7.1 进行了验证。我们不公布复现步骤;以下细节是阅读这些查询所需的内容。
该流程以一种与设置它的步骤无关的方式来跟踪当前处于哪个认证器界面。这使得重置凭据流程可以在邮件验证步骤被重新进入,此时存在漏洞的 ResetCredentialEmail.action() 会在完全不检查 action token 的情况下返回成功。执行随后继续进入更新密码表单。
两个后果塑造了本仓库中的每一条查询:
SEND_RESET_PASSWORD 事件。该事件的缺失并不是特征。action() 在返回之前会将账户的电子邮件地址标记为已验证。 合法路径根本不会到达 action(),因此这是只有绕过才会产生的状态变更。Q3 依赖于此。修复(keycloak#51844)将界面状态与设置它的执行绑定,并且仅当存在针对该确切用户的 action token 时才允许 action() 成功。
严重性:严重,CVSS v3.1 9.1(AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N),依据 Red Hat 和 NVD。根本原因是认证流程中的状态验证不当,已在 keycloak#51844 中修复。
| 版本流 | 修复于 |
|---|---|
| 26.7.x | 26.7.2(发行说明) |
| 26.6.x | 26.6.6 |
| 26.4.x(LTS) | 26.4.15 |
| 26.8 | 26.8.0 |
受支持版本流中低于上述版本的所有版本均存在漏洞。已停止支持的版本(26.5.x、26.3 及更早版本)不会获得修复:请假定已暴露并升级到已修复的版本流。Red Hat 将旧版 RH-SSO 7 列为不受影响;Red Hat Build of Keycloak 26.4/26.6 已在 26.4.15-1 / 26.6.6-1 中修复。
禁用自助密码重置可移除存在漏洞的入口点:管理控制台 > 领域设置 > 登录 > 关闭“忘记密码”,对每个领域执行。通过管理 API:PUT /admin/realms/{realm},请求体为 {"resetPasswordAllowed": false}。
这会阻止 login-actions/reset-credentials 端点,但也会阻止合法用户重置自己的密码,因此请将其视为升级前的临时措施。请注意,Red Hat 并未列出针对此 CVE 的官方认可的缓解措施;打补丁才是唯一真正的修复。
该脚本的第一个版本会将一次已完成的重置标记为可疑——如果该重置之前不久没有 SEND_RESET_PASSWORD。
这是错误的,
vilmosnagy 指出了这一点,
他在自己的实例上复现了该 CVE,并观察到事件仍然出现。原因就是上面的第一个后果:邮件认证器正常运行,因此受害者会收到一封真实的密码重置邮件,Keycloak 也会记录一条普通的 SEND_RESET_PASSWORD。这个检查被写反了。它本会清除它检查过的每一个已失陷账户。现在它已被移除。
基于时序的方案存在一个较小但真实的问题。
thomasdelorge 的狩猎脚本
中的方法将 SEND_RESET_PASSWORD 与 UPDATE_PASSWORD 通过共享的 code_id 关联起来,并标记时间间隔过短的情况。关联键是正确的,作者也明确说明该阈值只是一种启发式判断。我们的测试结论相同:一次被绕过的重置和一次在同一浏览器中完成的合法重置会产生完全相同的事件类型、完全相同的详情键以及相同的 code_id。只有延迟不同,这就使得整个判定都压在了阈值上。不着急的人会一直低于该阈值。真实用户反而会触发它,因为只有当链接在发起流程的浏览器中打开时,code_id 才会保持不变,而这恰恰也是邮件很可能已经处于打开状态的情形。
我们没有发现任何事件、任何事件详情或任何错误能够区分这两种情况。不存在可供查找的“邮件步骤被跳过”事件。确实存在的是一个状态副作用,而这正是 Q3 所利用的。
cve-2026-18963-keycloak-hunt.sql 运行六个只读查询:
| 查询 | 它发现的内容 |
|---|---|
| Q0 | realm 是否存储事件、其 TTL,以及事件详情是否被持久化。Q2 依赖 code_id 详情进行关联,因此如果详情缺失,Q2 就会失明,其沉默毫无意义。 |
| Q1 | 暴露窗口内(credential.created_date)设置的每一个密码凭据。这就是接管本身,并且它在事件过期后仍然存在。 |
| Q2 | 每一次已完成的凭据更新,与同一认证会话的重置邮件相关联,并进行分类。见下文。 |
| Q3 | 确定性的副作用:email_verified 为真但没有合法原因的用户。 |
| Q4 | Admin-API 凭据重置和用户写入,用于解释 Q1 和 Q3 的行,并排除管理员驱动的路径。 |
| Q5 | 原始的重置/凭据事件时间线,用于手动阅读单个案例。 |
| 判定 | 含义 |
|---|---|
NO_MAIL_WAS_SENT | 凭据更新与一次失败的发送共享同一个 code_id:账户上没有地址,或 SMTP 拒绝了它。从未有链接离开服务器,因此不可能有人打开过链接,但流程仍然到达了密码表单。这在合法情况下不可能发生;应视为利用行为。 |
CLEARED_TOKEN_CLICKED | 重置完成时所用的 code_id 与该用户的任何 SEND_RESET_PASSWORD 都不同。只有在一个操作令牌于另一个浏览器或会话中被消费时才会产生这种情况,因此邮件链接确实被点击了。确定性地合法。 |
CANDIDATE | 与发送具有相同的 code_id。这既是绕过行为的样子,也是合法的同浏览器重置的样子。本身不构成发现。 |
NOT_RESET_FLOW | 该用户在窗口内没有 SEND_RESET_PASSWORD;密码通过其他方式更改(账户控制台、admin API、注册)。交叉核对 Q4。 |
secs_after_send 仅作为上下文打印。不要将其用作判定依据。
存在漏洞的 ResetCredentialEmail.action() 会在成功之前将账户的电子邮件地址标记为已验证。合法路径根本不会进入 action(),因为 authenticate() 会在操作令牌上短路返回,因此真正的重置不会改动 email_verified。我们在 26.7.1 上检查了这一点,针对在发起重置的浏览器中以及在不同浏览器中完成的合法重置。两者都让未验证用户保持未验证状态。两者中,被绕过的重置都翻转了该标志。
因此,一个密码在窗口期内被修改、当前为 email_verified、且没有 VERIFY_EMAIL/UPDATE_EMAIL 事件、也没有针对它的管理员写入操作的账户,对于其为何处于已验证状态没有任何无辜的解释。
这里有一个非常重要的限制:在窗口期之前就已经验证的账户对 Q3 是不可见的,因为该标志早已为 true。Q3 是精确的,而非穷尽的。它最强的一行是 verified_with_no_email = t,因为一个完全没有地址的账户既无法接收重置链接,也无法通过打开链接来完成验证。
# defaults: realm 'master', window since 2026-06-01
psql -U keycloak -d keycloak -f cve-2026-18963-keycloak-hunt.sql
# explicit realm and window (run once per realm; plain values, psql quotes them)
psql -U keycloak -d keycloak \
-v realm=myrealm -v since=2026-05-01 \
-f cve-2026-18963-keycloak-hunt.sql
将 since 设置为你的易受攻击版本上线之前的时间点。在 Kubernetes 上:
kubectl exec -it my-postgres-pod -- \
psql -U keycloak -d keycloak -v realm=myrealm -v since=2026-05-01 \
-f - < cve-2026-18963-keycloak-hunt.sql
先检查 Q0。如果 events_in_window 为 0,或者 details_with_code_id 为 0 而事件存在,那么 Q2 无法工作,它的沉默不能证明任何事情。Q1 和 Q3 读取表状态,不受事件保留影响。
然后依次向下处理:
NO_MAIL_WAS_SENT。利用。据此采取行动。CLEARED_TOKEN_CLICKED。清除这些,不要再考虑它们。admin_ops_on_user 为空且 has_verify_email_event = f 的行
属于利用,但需注意:通过不写入管理员事件的导入路径被登记为已验证的账户也会落入此处。CANDIDATE 若 Q3 无法佐证,则未解决。数据库无法裁定这些,因此请查阅访问日志。只要日志仍然存在,这就是确定性的。打开邮件中的链接是一个 HTTP 请求,而这是绕过无法伪造的一件事。在你的反向代理或入口日志中,真正的重置总是显示
GET /realms/<realm>/login-actions/action-token?key=...
来自同一客户端,在邮件发出与密码设置之间。
grep -E "login-actions/action-token" access.log
对于 Q2 中的每个 CANDIDATE,在 SEND_RESET_PASSWORD 和 UPDATE_PASSWORD 之间查找该请求。如果找不到,说明邮件环节从未通过。
有两点需要注意。对 action-token 的 HEAD 请求来自邮件扫描设备,会被 Keycloak 忽略,因此只有 GET 才算数。另外,某些邮件安全产品会自动跟随链接,从而产生用户从未发起的真实 GET 请求;如果你的环境中有这类产品,请结合 user-agent 和源地址进行佐证,而不要仅凭请求本身判断。
最后,最省事的来源往往是用户自己。每位受害者都收到过一封自己从未请求过的密码重置邮件。在时间窗口较短、候选集较小的情况下,直接询问比推断更有效。
Q1 中的行并不自动意味着账号已被入侵。请将每一行与已知的合法原因(自助重置、服务台操作、新注册)进行匹配,并将任何无法解释的情况视为账号被接管的候选。
email_verified 状态的受害者。event_entity.details_json_long_value 中,而 details_json 对每一行都是 NULL。在我们的 26.7.1 实验环境中,29 行中有 29 行如此。请同时读取这两列,否则你会静默地匹配不到任何内容。RESET_PASSWORD。完成事件是 UPDATE_PASSWORD 和 UPDATE_CREDENTIAL;失败时则会出现 RESET_PASSWORD_ERROR。SEND_RESET_PASSWORD 确实会被记录,这促使我们回到源码,并移除了那个被反转的检查。code_id 关联和访问日志检查,本脚本正是基于这两者构建的。MIT,参见 LICENSE。按原样提供,不附带任何担保。