back to writeups

DarkZeroReturns

Hack The Box · app.hackthebox.com/machines/DarkZeroReturns · Season 11
Machine rank #799 Windows Hard Season 11 Active Directory SSTI / RCE Cross-domain User Root ● Completed
TL;DR A hard box that starts as a Linux web target and turns into a full Active Directory engagement across two separate domains. The foothold is a modern Handlebars AST type-confusion RCE (CVE-2026-33940) — the classic SSTI gadgets are all patched, and the working vector is a newer engine bug. From the Linux entry host the path pivots into Windows AD, reaches Domain Admin, converts a Kerberos identity into local root via ksu, and finishes with a cross-domain pass-the-hash against a second domain for the final flag.
Attribution — read this first I want this represented honestly rather than as a clean solo solve. Found and derived independently: all recon; the full Handlebars SSTI dead-end investigation; identifying and weaponising CVE-2026-33940 as the RCE; all SRV01 post-exploitation as darkzero (DB credentials, AD confirmation via getent, internal-network and DC02 discovery); the WinRM/SMB tunnel and pivot to DC02 as celia; the Gitea/SPNEGO discovery and CI-pipeline commit; and the hashcat work. Handed to me, not self-derived: josh's password; the celia / root / admin credential set; and the entire final get_flags.py — meaning the LDAP-write-to-mint-a-root-principal technique, the ksu privilege escalation, and the DC01 pass-the-hash pivot. Those last steps are described below because they are the box's solution, but they are not my discovery.
Entry host
SRV01 — Ubuntu, dzcampaigns.htb
Domains
darkzero.ext (DC02) and darkzero.htb (DC01) — separate domains
Foothold
CVE-2026-33940 — Handlebars AST type confusion → RCE
User
LDAP-minted Kerberos principal → ksu → local root on SRV01
Root
Cross-domain pass-the-hash to DC01 (darkzero.htb)

01Recon

A deliberately thin surface — two ports:

PortServiceNotes
22sshOpenSSH 9.6p1
80httpnginx 1.24.0 → dzcampaigns.htb

The application is "DarkZero Campaigns", Node.js / Express with Handlebars templating. The campaign_message field on POST /character is compiled as a Handlebars template server-side — that is the injection point.

02The dead end that mattered

Every classic SSTI gadget fails here, and working out why is what points at the real bug:

A patched engine with a live template-injection point means the answer is a newer engine bug, not a better payload for an old one.

03Foothold — CVE-2026-33940, AST type confusion

The working vector abuses what compile() accepts. Handed an object shaped like a parsed template — {type: 'Program', body: [...]} — parseWithoutProcessing returns it unvalidated, so an attacker-supplied AST goes straight to code generation with none of the checks that parsing a string would apply. JavaScript is then smuggled through a MustacheStatement's path.depth as a string breakout.

Two conditions have to be met to reach it:

That second point is the real finding: the validator constrained the value's content and never its type.

For the callback, /dev/tcp did not return — a mkfifo + nc shell did. It lands as darkzero (uid 996, nologin) on host SRV01, cwd /opt/DarkZero_Campaigns.

04Post-exploitation — a Linux box that is AD attack surface

The interesting discovery on SRV01 is that it is domain-joined via SSSD:

cat ~/.env                 # DB credentials — webapp only, no reuse
realm list                 # sssd-joined to darkzero.ext, KDC dc02
getent passwd josh svc-runner administrator   # resolves REAL AD accounts, unauthenticated

That getent result is the point worth internalising: NSS enumerates domain accounts with no credentials at all. A Linux host joined to AD is AD attack surface, and an unprivileged local shell on it is a directory-enumeration foothold.

Mapping outward from there:

05Pivot to DC02 — Domain Admin on darkzero.ext

With domain credentials in hand (see the attribution note above — the credential set was supplied), the route is a tunnel through SSH and then WinRM:

ssh -L <local>:172.16.20.2:5985 josh@<srv01>      # tunnel via the low-priv domain user
nxc winrm <tunnelled> -u celia -p '<pass>'        # Pwn3d! — Domain Admin on darkzero.ext

DC02 is Server 2025 and celia is in Domain Admins. It also runs Gitea on port 3000, authenticating over SPNEGO/Kerberos only — so a password login gets nowhere and a kinit gets admin and push rights on the DarkZero/DarkZero-Campaigns repository. That is a CI-pipeline foothold, and the route by which a commit reaches the runner.

06Local root — minting a Kerberos principal

This is the step handed to me rather than derived, and it is genuinely clever. PAM on SRV01 pre-trusts the Kerberos principal root@DARKZERO.EXT. That principal does not exist — but a Domain Admin can simply create it:

# as celia (Domain Admin), add a principal whose UPN is root@darkzero.ext
ldapadd ...   # CN=Yarrow, OU=GiteaMigration, unicodePwd=<pass>, userPrincipalName=root@darkzero.ext, uac=512

kinit root@DARKZERO.EXT
ksu            # PAM/Kerberos trusts the principal -> LOCAL ROOT on SRV01

cat /home/svc-runner/user.txt

The directory is treated as authoritative for a local identity, so writing to the directory creates that local identity. No exploit — the trust relationship did exactly what it was configured to do.

user.txt<redacted>

07Root — a second domain

root.txt is not on SRV01 or DC02. SRV01's gateway, 172.16.20.1, is DC01 — a Domain Controller for a separate domain, darkzero.htb. That also explains an earlier confusion: the supplied admin NTLM hash failed against DC02 because it was never meant for that domain.

ssh -D <port> josh@<srv01>        # SOCKS proxy into the internal network
proxychains nxc smb 172.16.20.1 -u Administrator -H <ntlm> -d darkzero.htb \
  --get-file 'Administrator\Desktop\root.txt' root.txt
root.txt<redacted>

08If this were your environment

Finding 1 · Critical

Template injection reachable because validation checked content, not type

The classic SSTI gadgets were patched and prototype access control was enabled — and the application still fell, because express-validator never asserted that campaign_message was a string. An object reached compile() and became an unvalidated AST.

How I would fix it
NIST 800-53 r5: SI-10 input validation · SI-2 flaw remediation · CM-7 least functionality
CWE: CWE-1336 template injection · CWE-20 improper input validation · CWE-843 type confusion
Finding 2 · High

Domain-joined Linux host leaks the directory to any local shell

getent resolved real AD accounts with no authentication, turning a low-privilege web shell into unauthenticated directory enumeration and internal network discovery.

How I would fix it
NIST 800-53 r5: AC-6 least privilege · AC-3 access enforcement · SC-7 boundary protection · AU-6 audit review
Finding 3 · Critical

Directory writes mint local root, and trust chains across domains and hosts

PAM pre-trusted root@DARKZERO.EXT, so creating that principal in AD created local root on SRV01. Separately, a Domain Admin on darkzero.ext chained to a flag on a different domain, darkzero.htb, through a host that bridged both.

How I would fix it
NIST 800-53 r5: AC-3 access enforcement · AC-6 least privilege · SC-7 boundary protection · CM-5 access restrictions for change · AU-6 audit review
CIS Controls v8: 4.x secure configuration · 6.x access control management

09Detection — the blue-team side

10Takeaways

◆If this were my environment

Template injection became operating-system execution and exposed credentials that worked across a multi-domain environment. I would fix the rendering boundary and assume every recovered secret requires domain-wide rotation.

Priority controls
  • Keep template source immutable and pass user input only as data; use a sandboxed, minimal render context without filesystem or process objects.
  • Store service credentials in a managed vault, issue separate secrets per domain and service, and prevent password/hash reuse across trust boundaries.
  • Restrict Kerberos delegation and cross-domain administrative paths; monitor unusual ticket requests, pass-the-hash behavior and privileged logons.
  • Run the web service in a confined identity with no access to domain credentials or administrative network segments.

Hardening guides and benchmarks

These references offer different implementation paths: consensus benchmarks, prescriptive government checklists, vendor baselines and application-security verification requirements. I would select the profile that matches the system's role, document exceptions, test it in staging and continuously measure drift.

Operational note CIS and DISA profiles can affect compatibility. Apply them through version-controlled policy, start in audit mode where supported, test business workloads and record every exception with an owner and review date.

back to writeups