ad-lanfeust
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
ServiceAccountlogon type is designed to use it without it ever being exposed. GenericWriteon a GPO is privilege escalation waiting to be activated. A GPO with nogPLinkis 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.