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
Terminal Output
- 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
/etc/hosts
Initial Foothold
Enumerating Port 5000: Web Server
Directory Busting
Feroxbuster:
Attacker Linux
- Results were minimal — only
/loginand/registerendpoints were found. Nothing hidden or interesting.
Dirsearch:
Attacker Linux
- Nothing additional discovered. The attack surface is limited to the web application itself.
GoBuster (vhost):
Attacker Linux
- No virtual hosts found. Let’s dig into the application itself.
Manual Enumeration
Visitinghttp://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
Terminal Output
- 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-inglobals() 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
Web Sandbox Output
dbis a live SQLAlchemy engine instance. The connection string reveals the database is SQLite, stored at/home/app-production/app/instance/database.db.Useris 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.
User model exposes using Python’s dir():
Web Sandbox
Web Sandbox Output
- The attributes
id,username, andpasswordconfirm this is the user table — exactly what we’re after. - The
queryattribute is SQLAlchemy’s query interface, which lets us fetch rows without raw SQL.
Web Sandbox
Web Sandbox Output
- This confirms the table name is
userand it has the three columns we identified:id,username,password. passwordis a separate column fromusername, so this isn’t a single-field hash — the passwords are stored as distinct values. We can retrieve them cleanly.
Web Sandbox
Web Sandbox Output
- Two users:
developmentandmartin. - 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
Credentials
- Both cracked instantly from the public lookup table — unsalted MD5 with weak passwords.
martinis a named system user, they’re the more interesting target.- Note: The intended path likely goes through
app-productionto getuser.txtfirst. We’ll skip ahead tomartinwho has asudoprivilege, and come back to the user flag once we’re root.
Privilege Escalation
Logging in as martin
Attacker Linux
nafeelswordsmaster
Post-ex Enumeration
sudo -l
Victim Linux (code.htb)
- Martin can run
/usr/bin/backy.shas(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)
- It takes a single argument: a path to a
task.jsonfile. That file controls which directories get backed up. Sincemartinowns~/backups/task.json, we control the input entirely. - The underlying
backybinary runs as root (inherited from thesudocontext), so it can read any file on the system. - Two security controls are in place to prevent obvious path traversal:
- Path traversal strip:
jq gsub("\\.\\./"; "")strips all occurrences of the exact string../from every path indirectories_to_archive. - Allowlist prefix check: Each resulting path must start with either
/var/or/home/. Anything else causes the script to exit.
- Path traversal strip:
~/backups/task.json for reference:
~/backups/task.json
- The destination is martin’s own backups folder — we can retrieve the archive via
scpafter the job runs. - The
excludepattern.*hides dotfiles by default. We’ll want to remove this when targeting/rootso we don’t miss.ssh/.
Exploiting the Path Traversal Filter
Thegsub("\\.\\./"; "") 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
....// → after one pass of gsub("../"; "") → produces ../.
Applied to the full path:
Path Traversal Analysis
- The allowlist check runs on the pre-resolution string
/home/../root, which starts with/home/— passes. - The
backybinary then resolves the path and backs up/rootas root.
task.json to target /root, and remove the exclude dotfiles filter so we capture .ssh/:
task.json
Victim Linux (code.htb)
- The script strips
../from....//, leaving../, which resolves to/root. backyruns as root and archives the entire/rootdirectory into~/backups/.
Extracting the Backup
We pull the archive down to our attacker machine:Attacker Linux
- The filename
code_home_.._root_2025_May.tar.bz2reflects the resolved path (home/../root) — confirming the traversal worked.
Attacker Linux
- Extracting the archive reveals the full contents of
/root/, including/root/.ssh/id_rsa— root’s private SSH key.
Attacker Linux
Victim Linux (code.htb)
app-production’s home directory — this is the one we skipped earlier:
Victim Linux (code.htb)
Learning
1. Python Sandbox Escape via globals()
Even when dangerous keywords likeimport, 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.

