$ 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:
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:
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:
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.
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:
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": [
".*"
]
}
#!/bin/bashif[[$#-ne1]];then/usr/bin/echo"Usage: $0 <task.json>"exit1fijson_file="$1"if[[!-f"$json_file"]];then/usr/bin/echo"Error: File '$json_file' not found."exit1fiallowed_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(){localpath="$1"forallowed_pathin"${allowed_paths[@]}";doif[["$path"==$allowed_path*]];thenreturn0fidonereturn1}fordirin$directories_to_archive;doif!is_allowed_path"$dir";then/usr/bin/echo"Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."exit1fidone/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:
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: