Flask の session cookie は署名だけ、DevSite で client_secret が平文だった
DevSite の Flask session cookie では、pickle 化した OAuth 情報と client_secret の署名鍵への再利用、受信データの unpickle が重なり RCE が成立した。3条件を整理する。
DevSite の Flask session cookie では、pickle 化した OAuth 情報と client_secret の署名鍵への再利用、受信データの unpickle が重なり RCE が成立した。3条件を整理する。
Flask の session cookie は署名されているだけで、暗号化されていない。だから中身は、その cookie を持つ者なら誰でも読める。Google の開発者向けドキュメントサイト(DevSite)が共有する OAuth フローで、この cookie に OAuth の client_secret が pickle のまま平文で入っていて、しかも同じ値が cookie の署名鍵でもあった、という調査結果が公開された。報告は2026年8月6日、修正は2026年10月7日、報奨金は90,000ドルだったと発見者は書いている。
同じ連鎖が起こるのは、アプリ側が pickle 化したデータを session cookie に保存し、外部から読める値を署名鍵にも使い、受け取ったデータを unpickle する場合だ。Flask が任意の Python オブジェクトを自動で pickle 化するわけではない。まず、自分の cookie に秘密情報や、アプリ側で直列化したデータが入っていないかを確かめたい。
発見者は Google の /oauth2authorize を叩くところから始めている。アカウントは要らない。
curl -s -D headers.txt -o /dev/null \
'https://developers.google.com/oauth2authorize?return_url=%2F'
返ってきた cookie は Flask のいつもの三分割 session=<payload>.<timestamp>.<signature> だったが、CSRF トークンと戻り先 URL にしては約1,200バイトと大きかった。Flask の session は署名されるが暗号化されないので、payload はそのまま読める。先頭がドットなら payload は zlib で圧縮されている、という手がかりで発見者はデコードした。
import base64, json, zlib
payload, timestamp, signature = cookie.rsplit(".", 2)
def b64decode(value):
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
compressed = payload.startswith(".")
raw = b64decode(payload[1:] if compressed else payload)
session = json.loads(zlib.decompress(raw) if compressed else raw)
出てきた中身はこうだった。
{
"_permanent": true,
"google_oauth2_csrf_token": "...",
"google_oauth2_flow_<csrf>": { " b": "..." }
}
" b" という key は、Flask が bytes 値を表すために付けるタグだ。その値を base64 デコードすると、DevSite のアプリが保存していた pickle が出てきた。pickletools.dis にかけると oauth2client.client.OAuth2WebServerFlow オブジェクトが現れ、フィールドには client_id と client_secret(bytes)が平文で入っていた。発見者によれば、この secret は調査の時点でまだ有効だったという。
ここまでで分かるのは、DevSite のアプリが pickle 化した OAuth flow を bytes として session に保存していたこと、そして署名だけの cookie に載せた秘密情報は保持者から読めることだ。" b" タグ自体は bytes を示すだけで、任意の Flask アプリが Python オブジェクトを自動で pickle 化することを意味しない。
漏れた client secret だけでも問題だが、発見者はそれが session の署名鍵でもあるかを確かめた。Flask は itsdangerous を通じて cookie-session の鍵導出で署名する。これをオフラインで再計算している。
import hmac
derived_key = hmac.digest(client_secret, b"cookie-session", "sha1")
expected = hmac.digest(derived_key, f"{payload}.{timestamp}".encode(), "sha1")
expected は、受け取ったばかりの cookie の署名とバイト単位で一致した。署名を1文字変えると拒否され、回収した secret で元の payload を署名し直すと受理された。署名鍵が、その鍵で署名されるオブジェクトの中に同梱されていたことになる。
鍵が公開されると、コールバックの pickle.loads(session から flow を読み、bytes を base64 デコードして unpickle する経路)が「正しい cookie を偽造できる者はいない」という前提を失う。発見者は自作の session に任意の pickle を入れ、__reduce__ で time.sleep(N) を返すペイロードを作って、応答時間が N に追従することを示した(0、3、5、8秒を指定し、応答は0.207、3.193、5.194、8.283秒)。本人は、これはサーバーが unpickle 中に sleep を呼んだことと整合すると書くにとどめている。同じ鍵は firebase.google.com、developer.android.com、source.android.com、docs.partner.android.com でも有効な session が作れたという。
この連鎖は、漏れた secret、それを署名鍵に使い回したこと、コールバックが session のデータを unpickle したこと、の三つが揃って成立した。手元のアプリが同じ地雷を踏んでいないかは、上の最初のデコードコードに自分の session= cookie を渡せば分かる。平文の JSON に見えるものが、そのまま cookie の保持者から読める値だ。API キーやトークンがあれば cookie から外す。" b" タグがあれば、アプリがどのデータを bytes に変換したかを追い、pickle かどうかは実際の直列化処理や pickletools.dis で確認する。
発見者が挙げた修正は四つある。露出した OAuth secret を両方ローテーションし、それで署名された session を全て無効化する。各デプロイに、OAuth 資格情報とは無関係な高エントロピーの SECRET_KEY をサーバー側ストアから与える。OAuth フローのオブジェクトはサーバー側に置き、cookie には不透明な参照だけを持たせる。クライアント境界を越えるデータから pickle をやめ、スキーマ検査のあるデータ専用フォーマットにする。最後の一つだけでも、偽造された cookie は無害になると発見者は書いている。