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-rate5000-oNnmap_full.txt10.129.232.16753/tcp open domain Simple DNS Plus80/tcp open http IIS 10.088/tcp open kerberos-sec Windows KDC389/tcp open ldap Active Directory445/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-uhenry-p'H3nry_987TGV!'--usersAdministrator 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-uhenry-p'H3nry_987TGV!'-dtombwatcher.htb-ns10.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.
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.
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 -ualfred-p'basketball'-dtombwatcher.htb--hostDC01.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)
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-ip10.129.232.167$ export KRB5CCNAME=ansible_dev\$.ccache# use TGT with bloodyAD (-k = Kerberos auth)$faketime-f"+4h" bloodyAD -k -u'ansible_dev$'-dtombwatcher.htb--hostDC01.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-ip10.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-ip10.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!'-dtombwatcher.htb--hostDC01.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.
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 deletedcert_admin account.
certipy — find vulnerable templates
$faketime-f"+4h" certipy-ad find -ujohn@tombwatcher.htb-p'Password123!'-dc-ip10.129.232.167-vulnerable -stdoutTemplate Name : WebServerEnrollee Supplies Subject : TrueSchema Version : 1Client Authentication : FalseEnrollment 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 -Hldap://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_adminobjectSid: ...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.
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).
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.
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
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 -hashes → bloodyAD -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 ReadGMSAPassword → ansible_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