DarkZeroReturns
ksu, and finishes with
a cross-domain pass-the-hash against a second domain for the final flag.
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:
| Port | Service | Notes |
|---|---|---|
| 22 | ssh | OpenSSH 9.6p1 |
| 80 | http | nginx 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:
protoAccessControlis on, so prototype-pollution style gadget chains are blocked.- CVE-2021-23383, the well-known Handlebars RCE, is patched.
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:
- Hit the create route (
/character), not the update route. - Submit
campaign_messageas an object rather than a string — theexpress-validatorchain for that field has no.isString(), so an object sails through validation and reachescompile().
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:
- Internal network
172.16.20.0/24; the DC is172.16.20.2:389. sudois blocked (no_new_privs).- Kerberos ticket theft is a dead end — tickets live in the root and
svc-runnerkeyrings, with no group overlap to reach them.
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.
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
08If this were your environment
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.
- Assert types, not just contents. Every validator chain on a field that reaches a parser or compiler needs an explicit
.isString()(or schema-level type enforcement). - Never compile user input as a template. Render data into a fixed template; if user-authored templates are a product requirement, run them in a sandboxed worker with no host access.
- Keep template engines current — a patched old CVE says nothing about the current one.
CWE: CWE-1336 template injection · CWE-20 improper input validation · CWE-843 type confusion
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.
- Restrict SSSD enumeration (
enumerate = false, scopedldap_user_search_base, simple access filters) so the host resolves only the accounts it needs. - Treat AD-joined Linux hosts as Tier-relevant AD assets in threat modelling, patching and monitoring — not as standalone servers.
- Run the web application as a confined service account with no directory visibility, and monitor keytab access.
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.
- Never map a directory principal to local root implicitly. Use explicit, enumerated
.k5login/ PAM mappings, and exclude privileged local names entirely. - Segment trust between domains and hosts. A Domain Admin in one domain should not reach local root on a host, nor a resource in another domain, without crossing an enforced boundary.
- Alert on anomalous LDAP writes — new principals and OUs,
unicodePwdsets, UPNs matching privileged local names. - Constrain CI runners (
svc-runner): a push to a repository should not equal code execution on a domain-joined host with directory access.
CIS Controls v8: 4.x secure configuration · 6.x access control management
09Detection — the blue-team side
- Unauthenticated NSS/
getentenumeration from application service accounts on AD-joined Linux hosts, and any keytab read. - Anomalous LDAP writes: new principals or OUs,
unicodePwdsets, and UPNs that collide with privileged local usernames. - Kerberos principal creation followed quickly by
ksu-to-root on a joined host. - CI-runner abuse — pushes to pipeline repositories outside normal hours or from unusual principals.
- Cross-domain NTLM authentication from a host that bridges two domains.
10Takeaways
- A patched CVE is not a patched class. Prototype access control and the 2021 fix were both in place; a 2026 AST bug walked through anyway.
- Type confusion starts at the validator. Checking what a value contains without checking what it is is how an object became an AST.
- A Linux box joined to AD is AD attack surface. That is the sentence I would put in the report.
- If the directory defines an identity, writing to the directory creates that identity — including root.
- Know what you derived and what you were given. The last third of this box was handed to me, and saying so is worth more than a tidier story.
◆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.
- 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.