ad-lanfeust

active-directoryadcskerberosforest-trustdpapiwdacgmsashadow-credentials

From a PDF form to two forests.

A customs declaration form. That’s where this started. By the end I was root on a Linux box in a second Active Directory forest I’d never directly touched. In between: a certificate buried in a config file, a DNS record nobody remembered to delete, a Windows update server I impersonated, a signed maintenance script, a dormant GPO linked to the wrong site, a decoded trust secret, and a Kerberos ticket that let me SSH as root.

Every step handed me the next one. That’s what good AD labs do.

Spoilers ahead — commands, credentials and partial flags included so you can follow the chain.


The setup

Two separate Active Directory forests, each with its own domain controller and certificate authority. A forest is AD’s highest security boundary — accounts from one forest normally can’t touch the other. Except when there’s a trust. And there was.

The first forest (TROY.LAB) had a web application, a file server, a Windows update client, and a domain controller doubling as a CA. The second (DARSHAN.LAB) had its own DC/CA and a Linux server joined to the domain. My job was to chain through both.

The environment was deliberately hardened: NTLM disabled, RC4 Kerberos encryption disabled, SMB signing required, WDAC on one host, and a certificate template that demanded manual approval. None of these stopped the chain — they just forced a more interesting route.


The twelve flags

# Title Method Flag
01 The Indiscreet Scribe PDF iframe LFI adctf{d84ccc...}
02 Cixi’s Confidences LDAP attribute via PKINIT adctf{9d32c3...}
03 The Steward’s Storeroom Authenticated SMB share adctf{c8aa97...}
04 The False Oracle of Kandhar Rogue WSUS server adctf{df3d30...}
05 The Sealed Missive DPAPI machine-key decrypt adctf{e5db5f...}
06 The Secret of Hebus gMSA via Task Scheduler adctf{e951c6...}
07 The Paladin of Eckmul WDAC bypass via enterprise signing adctf{171323...}
08 The Sage Ascendant GPO write → GPLink to site → shadow credential PAC refresh adctf{8fd286...}
09 The Traitor’s Passage Forest trust secret → DARSHAN adctf{8b48b0...}
10 Castel Or-Azur Computer account → Linux root via GSSAPI adctf{69a026...}
11 The Castellan’s Signet Machine keytab → SMB share adctf{ff0c3c...}
12 The Ivory of the Magohamoth Self-approved ADCS certificate adctf{09c599...}

0 — Survey: two forests, one Kerberos problem

Before anything else I mapped the network and synced the clock. Kerberos is unforgiving with time skew — a drift of more than a few minutes turns a valid password into an authentication error that looks like the wrong credentials. Measured offset: 2.4 ms. Fine.

Five hosts came up. Three in TROY, two in DARSHAN. I held all Kerberos tickets in named ccache files and pointed every tool at them with KRB5CCNAME. When a cache picked up a bad ticket I didn’t try to fix it — I just minted a fresh TGT. A clean ticket file is worth more than ten minutes of troubleshooting a corrupted one.


1 — The PDF form that could read local files (Flag 01)

The first interesting service was a customs-declaration web app. You filled in a form, it spat back a PDF. The goods field was the interesting one: the server dropped it straight into an HTML template and fed that to a headless browser to render the PDF.

The browser could open file:/// URLs. That’s the bug — and the lab handed it to you in the field name. I put an iframe pointing at a local file path into the form value. The server printed its own file into my PDF.

curl -sX POST https://<host>/pdf \
  --data-urlencode 'goods=<iframe src="file:///C:/inetpub/portal/App_Data/flag01.txt" ...>'

Flag 01 — adctf{d84ccc...} ✓

The same primitive read directory listings, the PDF renderer source, and — crucially — web.config. That file is where the next flag came from.


2 — A certificate left in a config file (Flag 02)

web.config contained a base64-encoded PKCS#12 bundle and the password to open it. A PFX file holds a certificate together with its private key. This one belonged to cixi@troy.lab and was valid for PKINIT — Kerberos login using a certificate instead of a password.

I extracted the blob, decoded it, and used Certipy to authenticate:

certipy-ad auth -pfx cixi.pfx -password '<redacted>' \
  -username cixi -domain troy.lab -dc-ip <dc>
export KRB5CCNAME="$PWD/cixi.ccache"

PKINIT also recovered cixi’s NT hash — but NTLM was disabled. The Kerberos ticket was the only thing that mattered. I ran a Kerberos-authenticated LDAP query and found Flag 02 sitting in cixi’s description attribute.

Flag 02 — adctf{9d32c3...} ✓


3 — A forgotten dispatch note on a file share (Flag 03)

With a valid Kerberos ticket I browsed the shares cixi could reach. \\GLININ\IT$ was open and held more than Flag 03: an encrypted backup, a PowerShell maintenance script, access logs, and a dispatch note.

The note said the old WSUS update server (wsus.troy.lab) had been decommissioned — but nobody told KANDHAR. That machine was still polling the hostname every two minutes hoping to get updates.

Flag 03 — adctf{c8aa97...} ✓

The dead hostname is the pivot. If I could resurrect it and present a certificate KANDHAR trusted, the server would come to me.


4 — Impersonating a missing update server (Flag 04)

This step had three moving parts, and all three had to work at once.

Step one — resurrect the DNS record. Cixi’s Kerberos ticket had rights to add a DNS entry. I pointed wsus.troy.lab at my VPN address with a signed nsupdate.

Step two — get a server certificate for that hostname. A misconfigured template on the TROY CA accepted requester-supplied hostnames. I enrolled a certificate for wsus.troy.lab. Now I could serve TLS traffic the client would trust.

Step three — run a rogue WSUS service. The tricky part: KANDHAR fetched update metadata over TLS but pulled the executable over plain HTTP on the same port. The listener had to handle both. The update payload had to be a genuine Microsoft-signed binary — KANDHAR verified the Authenticode signature.

I used a legitimate Sysinternals binary with attacker-controlled arguments. The binary ran as SYSTEM and added cixi to the local Administrators group.

psexec.py troy.lab/cixi@kandhar.troy.lab -k -no-pass -dc-ip <dc>
# get C:\flags\flag04.txt

Flag 04 — adctf{df3d30...} ✓


5 — Decrypting a file that only KANDHAR could open (Flag 05)

The encrypted backup from the IT share (sites.ldf.enc) was protected with Windows DPAPI scoped to the local machine. Stealing the file alone wasn’t enough — I needed the machine’s secrets too.

With SYSTEM access I saved the SECURITY, SYSTEM, and SAM registry hives using reg save, then used Impacket to recover KANDHAR’s DPAPI system key. That key unwrapped the master key, which decrypted the LDIF file offline.

The LDIF described an AD Sites entry; its description attribute held Flag 05.

Flag 05 — adctf{e5db5f...} ✓


6 — Borrowing a service account without knowing its password (Flag 06)

The hive dump also yielded KANDHAR’s domain machine password (KANDHAR$). AD permissions showed that KANDHAR$ was the only account that could retrieve the managed password material for gmsa-hebus$ — a Group Managed Service Account.

The domain had RC4 disabled, so the old “pass the NT hash” route was closed. The right answer was the mechanism Windows designed for this: Task Scheduler can start a process as a gMSA using a ServiceAccount logon type without the operator ever knowing the plaintext password.

$p = New-ScheduledTaskPrincipal -UserId 'TROY\gmsa-hebus$' `
     -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName DTB6 -Action $a -Principal $p -Force
Start-ScheduledTask DTB6

Running in that identity made C:\shares\hebus\flag06.txt readable.

Flag 06 — adctf{e951c6...} ✓


7 — Signing a script to bypass application control (Flag 07)

GLININ enforced WDAC — only Microsoft-signed or enterprise-signed code would run. That ruled out dropping an unsigned tool. The interesting question was: what does the enterprise trust?

The WDAC policy trusted code signed by TROY-CA. And gmsa-hebus$ could request a certificate from the TroyCodeSigning template. I enrolled the certificate in the gMSA’s task context, then used it to sign a PowerShell maintenance script.

$cert = Get-PfxCertificate C:\Windows\Temp\hebus-codesign.pfx
Set-AuthenticodeSignature -FilePath maintenance.ps1 -Certificate $cert
Copy-Item maintenance.ps1 '\\GLININ\IT$\scripts\maintenance.ps1'

From GLININ’s point of view: legitimate enterprise signature, trusted policy, runs as TROY\lanfeust. A scheduled task picked up the script two minutes later.

Flag 07 — adctf{171323...} ✓


8 — A dormant GPO, a shadow credential, and owning TROY (Flag 08)

Lanfeust’s write permissions on Nicolede were only half the story. BloodHound also showed that Nicolede held GenericWrite on the Council-of-Sages Policy GPO — a policy that, if applied, would add Nicolede to Domain Admins. But the GPO had never been linked anywhere. No site, no OU, no domain object had ever pointed at it. It sat in SYSVOL with a ScheduledTasks.xml fully written and completely inert.

The chain therefore had three beats, each depending on the one before.

Beat one — get a Nicolede ticket. Rather than reset the password and break something, I wrote a shadow credential from Lanfeust’s context: a public key added to Nicolede’s account that AD accepts for certificate-based login. The original password is never touched.

certipy-ad shadow auto -u 'lanfeust@troy.lab' -k -no-pass \
  -account nicolede -dc-ip <dc> -target <eckmul>
certipy-ad auth -pfx nicolede_shadow.pfx -username nicolede \
  -domain troy.lab -dc-ip <dc>
export KRB5CCNAME=$PWD/nicolede.ccache

Beat two — activate the dormant GPO. With Nicolede’s ticket in hand, I used the GenericWrite ACE to write a gPLink on the AD site containing ECKMUL. That single attribute write told every computer in the site to apply the policy.

bloodyAD \
  -d troy.lab -u nicolede -k ccache="$KRB5CCNAME" \
  -H eckmul.troy.lab -i <dc> \
  set object \
  'CN=Council-of-Sages,CN=Sites,CN=Configuration,DC=troy,DC=lab' \
  gPLink \
  -v '[LDAP://CN={6FE1B759-EEFA-45B7-BE7E-A93F0B5A5C38},CN=Policies,CN=System,DC=troy,DC=lab;0]'

gPLink has been updated. About three minutes later ECKMUL refreshed its site policy and Nicolede appeared in Domain Admins.

Beat three — get a ticket that knows it. Group membership is baked into a Kerberos ticket’s PAC at issuance time. The Nicolede TGT was minted before the GPO ran, so it still showed the old membership. I deleted the stale cache and authenticated again with the shadow certificate to get a fresh PAC:

rm -f nicolede.ccache
certipy-ad auth -pfx nicolede_shadow.pfx -username nicolede \
  -domain troy.lab -dc-ip <dc>

With a PAC that reflected Domain Admin membership, DCSync was one command away.

secretsdump.py -k -no-pass -dc-ip <dc> 'troy.lab/nicolede@eckmul.troy.lab' -just-dc

Flag 08 — adctf{8fd286...} ✓


9 — Decoding the secret that connects two forests (Flag 09)

DCSync gave me everything ECKMUL stored, including the outgoing forest trust secret for the TROY → DARSHAN trust. Forest trusts authenticate with a shared secret, similar to how a machine password works for a domain join.

The raw value was UTF-16LE. Standard Impacket output focuses on derived keys, so I used a small script to preserve and decode the raw bytes:

Decoded trust password: <redacted>

DARSHAN also had RC4 disabled, making the plaintext more useful than the NT hash. I used it to obtain a TGT in DARSHAN’s domain and browse the INTEL$ share — which TROY$ could read via the trust relationship.

Flag 09 — adctf{8b48b0...} ✓

The share also held a note: ORAZUR was a Linux server not yet in production, still waiting for an administrator to set it up.


10 — A computer account called root$ (Flag 10)

DARSHAN allowed any authenticated user to create a computer account. I created one and then renamed it to root$.

That name is the trick. ORAZUR mapped Kerberos principals to local Unix accounts. When it normalised root$@DARSHAN.LAB, it resolved to the local user root. SSH’s GSSAPI authentication accepts a Kerberos ticket instead of a password. A valid ticket for root$ was therefore a login as Linux root — no shell exploit, no password.

getTGT.py 'DARSHAN.LAB/root$:<password>' -dc-ip <dc>
KRB5CCNAME=root$.ccache ssh -o GSSAPIAuthentication=yes \
  -o GSSAPIServerIdentity=ORAZUR.darshan.lab root@<orazur>
cat /root/flag10.txt

Flag 10 — adctf{69a026...} ✓


11 — The keytab that was already there (Flag 11)

A domain-joined Linux server needs long-term Kerberos keys so services can authenticate after a reboot. They live in /etc/krb5.keytab. Root could read them.

The keytab had current AES keys for the real ORAZUR$ computer account. I used them to get a fresh ticket and connected to DARSHANIDE over SMB. The ORAZUR$ share was accessible to that machine account.

kinit -k -t orazur.keytab 'ORAZUR$@DARSHAN.LAB'
smbclient.py -k -no-pass 'darshan.lab/ORAZUR$@darshanide.darshan.lab'
# use ORAZUR$ ; get flag11.txt

Flag 11 — adctf{ff0c3c...} ✓


12 — Approving your own certificate request (Flag 12)

The last flag sat on DARSHAN’s DC. To get there I needed Domain Admin access in DARSHAN.

The path was through ADCS. The DarshanLogin template let the requester choose the certificate subject — but every request went into a pending queue waiting for a certificate officer to approve it. That sounds like a dead end.

Until you check who had officer rights: ORAZUR$ itself.

I submitted a pending request naming the privileged user thanos, then authenticated as ORAZUR$ and approved my own request.

# Submit pending request for thanos
certipy_ecdsa.py req ... -ca DARSHAN-CA -template DarshanLogin -upn 'thanos@darshan.lab'

# Approve it as ORAZUR$
certipy-ad ca ... -ca DARSHAN-CA -issue-request 10

# Authenticate as thanos
certipy-ad auth -pfx thanos.pfx -dc-ip <dc> -domain darshan.lab -ldap-shell

One wrinkle: the domain controller rejected ECDSA pre-auth data in Kerberos. The certificate was still valid for TLS client authentication, so I used Schannel to authenticate directly to LDAP — bypassed the Kerberos path entirely.

From the LDAP shell I added ORAZUR$ to Domain Admins temporarily, grabbed Flag 12, then removed it immediately.

Flag 12 — adctf{09c599...} ✓


What made this lab interesting

Every control was real but narrow. Disabling NTLM and RC4 closed the obvious path but left certificates and modern AES tickets as the supported alternative. WDAC blocked unsigned code but trusted the enterprise CA — which I could reach. Certificate approval stopped a direct request until I found who the officer was. The gMSA password wasn’t directly accessible, but the mechanism that let Task Scheduler use it without a password was working as designed.

The chain worked not by breaking these controls but by reading each one carefully enough to find what it still trusted.

The best clue in the lab was a dead DNS name in a dispatch note. Everything else followed from knowing where to look.


Takeaways

  • A 403 from the proxy means the application never had an opinion — the block lives in one tier, the content in another. Look for path spellings the edge doesn’t recognise.
  • NTLM disabled means certificates matter more, not that authentication is harder. Find where the PKI trusts too broadly.
  • A managed service account’s password is intentionally opaque — but Task Scheduler’s ServiceAccount logon type is designed to use it without it ever being exposed.
  • GenericWrite on a GPO is privilege escalation waiting to be activated. A GPO with no gPLink is inert — check SYSVOL for content that’s already there before writing new tasks. The gap is scope, not content: one attribute write links it to a site and everything the policy targets applies the action on next refresh (~90 s default).
  • Shadow credentials don’t escalate by themselves — they refresh a Kerberos ticket’s PAC. If group membership changed after the last ticket was issued, the old ticket still shows the old membership. Delete it and re-authenticate to pick up the new PAC.
  • Forest trust secrets are credentials. DCSync doesn’t only give you user hashes — it gives you the key that unlocks a completely separate domain.
  • GSSAPI SSH authentication accepts Kerberos tickets. If a Linux host normalises machine-principal names to local Unix accounts, a computer account named root$ is a login as root.
  • Self-approval of certificate requests is possible when the requester and the officer are the same account. Check who holds Manage Certificates on each CA.