scriptPath — on two privileged users, which BloodHound (object-level only) never surfaces. That one write hijacks an NTFS-writable SYSVOL logon script to trigger a ForceChangePassword reset, which chains through an AddSelf into RODC Administrators. From there it's RBCD + a pivot to SYSTEM on the read-only DC, dumping its dedicated krbtgt_8245 key. "Read-only" turns out to be a lie: after rewriting the RODC's Password Replication Policy, that key forges an RODC Golden Ticket (Rubeus 2.3.3) that the writable DC happily accepts — Domain Admin. The whole thing is driven from macOS, which turned the last phase into a second, quieter fight against my own tooling.
A full sweep returns the usual Domain Controller silhouette — DNS, Kerberos, LDAP, SMB, RPC, WinRM. The RDP certificate and SMB banner name the host and domain.
Two facts to file away: SMB signing is required (kills SMB relay), and the box is a Windows Server 2019 DC named DC01.garfield.htb. The clock is also ~8 hours ahead of me — irrelevant now, a recurring headache later for every Kerberos step from macOS.
This is an assume-breach box: you're handed j.arbuckle : <assigned-pass>. The creds work, but the account is nothing special — no Pwn3d!, standard shares, read-only on SYSVOL/NETLOGON.
The user list tells a story. l.wilson and l.wilson_adm are classic admin tiering — a person and their privileged twin. And krbtgt_8245 is not a person at all: it's the dedicated KDC key account for a Read-Only Domain Controller. There's a second DC hiding somewhere, with its own Kerberos key material. File that away too.
SYSVOL, read-only, holds one non-default artifact — a logon script in the scripts folder:
Harmless on its own — a printer inventory. What matters isn't the script's contents; it's who can change what it points to. Hold that thought.
BloodHound is the reflex here, so I collected and imported. The j.arbuckle node was a ghost with a valid badge: member of Users, Domain Users, IT Support — and Outbound Object Control: 0. No GenericAll, no GenericWrite, no ForceChangePassword. "Shortest path to Domain Admins" returned Path not found.
I burned real time on the classics before questioning the map:
GetUserSPNs returned No entries found. Not a single SPN in the domain.Every standard technique agreed: this user is powerless. But a Hard box doesn't hand you creds and leave no road. The right question wasn't "what else can I try?" — it was "what is BloodHound not showing me?"
BloodHound (Legacy and CE) enumerates ACLs at the object level — it catches GenericAll, GenericWrite, WriteDACL, WriteOwner. But AD permissions are far more granular: you can grant write to a single attribute, and unless a tool queries attribute-level DACLs, that permission is invisible.
bloodyAD checks per-attribute write permissions — exactly the fine print BloodHound skips. Its get writable exploded across the terminal with the usual self-service noise (phone numbers, postal addresses, cloud-extension attributes any user can edit on themselves). Buried in it, on objects that were not mine to touch, one attribute stood out:
j.arbuckle can write the scriptPath attribute on both l.wilson and l.wilson_adm. scriptPath is the logon script that runs every time that user signs in. Not GenericWrite, not a password reset — one attribute. And it was enough to unravel the domain.
The plan: point l.wilson's scriptPath at a script I control, wait for the box to simulate that user's logon, and run code as l.wilson. Two things had to be true. First, I needed to write a script into the netlogon scripts folder — and --shares said READ. But share-level and NTFS-level permissions are different things, and I never tested the NTFS ACL. It was writable the whole time:
Second — and this is the part I like — I never needed a reverse shell. A live callback is the loudest thing you can do, and it's unnecessary here. The logon script runs as l.wilson, and l.wilson holds ForceChangePassword over l.wilson_adm (an edge that only appears once you're operating as him). So the script itself abuses that right: reset the admin twin's password to something I know, in-process, request/response, nothing to catch.
From a single attribute write to a controlled privileged account. WinRM as l.wilson_adm gives the user flag:
With l.wilson_adm in hand, a new edge appears that the graph could see: AddSelf on a group called RODC Administrators. Not "add anyone" — just "add yourself". One command:
That grants administrative control over RODC01. But RODC01 lives at <RODC-IP> — an internal segment my host has no route to. DC01 can reach it; I'm on the outside. Title of RODC admin, no way to use it. Yet.
To get SYSTEM on RODC01 I used Resource-Based Constrained Delegation: create a machine account from thin air, tell RODC01 to trust it for delegation (I have write over the RODC01 computer object as its admin), then use that trust to impersonate Administrator.
getST only talks to the KDC (DC01, reachable), so the Administrator-for-cifs/RODC01 ticket falls out without touching the internal segment. That faketime wrapper is the first appearance of the ~8h clock skew — every Kerberos step from macOS gets it, using an absolute DC time string (this build of libfaketime rejects the +8h offset form).
DC01 is the pivot: one foot in my network, one in <internal-net>. A reverse SOCKS proxy through it puts RODC01 in reach. Evil-WinRM's upload is notoriously flaky, so I fetched the binary with certutil:
The goal is RODC01's dedicated KDC key, krbtgt_8245 — the raw material for an RODC Golden Ticket. On Kali this is a one-liner. On macOS, this phase became a three-round fight with my own toolchain, and it's worth documenting because every round is a reusable lesson.
My first instinct was secretsdump -use-vss with the Administrator ticket — snapshot ntds.dit, parse it locally. It got through SAM and LSA (dumping the RODC01$ machine keys and cached secrets) and then died, repeatedly, three different ways:
With all three fixed, VSS finally engaged and started pulling ntds.dit — and then the flaky tunnel dropped the connection mid-transfer (Bad file descriptor). The ntds.dit download was simply too big for a link that flapped every few seconds. So I stopped fighting the transfer size and did what the situation actually called for: run mimikatz on the RODC itself and pull only the one account I needed. Tiny push, tiny output.
Everything through the tunnel used -target-ip <RODC-IP> to avoid name-resolution over SOCKS, while keeping the FQDN in the principal so the Kerberos SPN matched. There it is: the AES256 key, the domain SID, and — from the account's msDS-SecondaryKrbTgtNumber — the RODC branch number 8245. Three pieces to forge a ticket as anyone in the domain.
An RODC Golden Ticket has a catch a normal one doesn't: the Password Replication Policy. When DC01 receives a TGT signed by an RODC's krbtgt, it checks whether that user is allowed to be cached on that RODC. Administrator — and every privileged group — sits in the RODC's deny list by default. So I had to rewrite the rules. As RODC administrator I have write over the two attributes that are the policy: clear the deny list first (deny always beats allow), then add Administrator to the allow list:
Note bloodyAD set overwrites the whole attribute — in a real environment, replacing the allow list with a single entry would break replication for every legitimately cached account. In a lab it's fine, but set is a sledgehammer, not a scalpel.
Now the ticket. Impacket's ticketer can't encode the RODC branch number into the ticket KVNO, so this step needs Rubeus — specifically v2.3.3. v2.2.0 has a PAC-signing bug that produces KRB_AP_ERR_BAD_INTEGRITY even with a perfect key; same key, same parameters, different tool version, completely different result. I ran it on DC01 (correct clock, direct KDC access):
The /ptt injection fails inside Evil-WinRM (a network logon has no session to inject into — Error 1312), so I forged with /nowrap, requested the TGS the same way, then converted and used it from my host. DC01 accepted a ticket signed by a read-only DC's key, because the policy that was supposed to stop it now said "allow":
SYSTEM on DC01. From a domain user with zero outbound control in BloodHound to full Domain Admin — through an attribute-level ACL, a logon-script hijack, a password-reset chain, RBCD, an RODC credential dump, and a forged Golden Ticket.
Garfield broke almost every assumption I walked in with. What stayed with me:
scriptPath write it will never draw. Don't trust a single tool as your only source of truth — bloodyAD get writable is the second opinion that reads the fine print.--shares said READ on SYSVOL; the scripts folder was NTFS-writable the whole time. Share-level and NTFS-level permissions are different things. A single put would have found the foothold hours earlier.msDS-RevealOnDemandGroup / msDS-NeverRevealGroup. And deny always beats allow — clear the deny list first, order matters.BAD_INTEGRITY and a valid ticket. impacket on python 3.14 vs 3.12 is the difference between a crash and a clean SMB3 signing session. When an attack fails and everything looks correct, suspect the tool before yourself.ntds.dit VSS pull was never going to survive a flapping tunnel; mimikatz's single-account dump was tiny and robust. On a bad link, minimise what you move.From macOS the box was really two boxes: the AD chain, and a quieter fight to make Kerberos, SOCKS, and Cryptodome coexist under DYLD_INSERT_LIBRARIES. Both were worth it.