Finding a $90,000 Remote Code Execution Vulnerability in Google

Finding a $90,000 Remote Code Execution Vulnerability in Google

How I found it

I was mapping the OAuth flow that Google’s developer docs sites all share. You hit /oauth2authorize, it sets a Flask session cookie and redirects you to Google’s sign-in page. No account needed: curl -s -D headers.txt -o /dev/null 'https://developers.google.com/oauth2authorize?return_url=%2F' The cookie is what caught my eye. Flask’s usual three-part session=<payload>.<timestamp>.<signature>, but about 1,200 bytes, way more than you need for a CSRF token and a return URL. So I decoded it. Flask session cookies are signed, not encrypted, so the contents are right there for anyone holding one. The leading dot means the payload is zlib-compressed.

我是如何发现它的

我当时正在梳理谷歌开发者文档网站所共用的 OAuth 流程。当你访问 /oauth2authorize 时,它会设置一个 Flask 会话 cookie 并将你重定向到谷歌的登录页面。无需账户即可操作:curl -s -D headers.txt -o /dev/null 'https://developers.google.com/oauth2authorize?return_url=%2F'。引起我注意的是那个 cookie。它采用了 Flask 常用的三部分结构 session=<payload>.<timestamp>.<signature>,但大小约为 1200 字节,这远超 CSRF 令牌和返回 URL 所需的长度。于是我对其进行了解码。Flask 会话 cookie 仅经过签名而非加密,因此任何持有它的人都能直接看到其内容。开头的点号表示该负载经过了 zlib 压缩。

That gave me:

{
  "_permanent": true,
  "google_oauth2_csrf_token": "...",
  "google_oauth2_flow_<csrf>": {
    " b": "..."
  }
}

The " b" key is Flask tagging a bytes value. Base64-decoding it gave me a pickle, and pickletools.dis printed an oauth2client.client.OAuth2WebServerFlow object with its fields: client_id = "<redacted>.apps.googleusercontent.com" and client_secret = b"<redacted>". The cookie contained a pickle-serialized payload, which was bad enough on its own. It also carried Google’s OAuth client_id and client_secret in plaintext.

这让我得到了以下信息:

{
  "_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 序列化的负载,这本身就已经很糟糕了。它还以明文形式携带了谷歌的 OAuth client_id 和 client_secret。

One secret, two jobs

A client secret leaking in a cookie is a finding on its own. The part that mattered was that the same value verified the signature, so I nailed that down properly instead of trusting a lucky guess. Flask signs through itsdangerous with the cookie-session key derivation, so I recomputed the signature offline. expected came out byte-for-byte identical to the signature on the cookie I’d been handed seconds earlier. To be sure it wasn’t a fluke I flipped one character of the signature (rejected) and re-signed the original payload with the recovered secret (accepted). Session integrity was gone. I could set every key and value in the session and still produce a signature the server would take.

一个密钥,两种用途

客户端密钥在 cookie 中泄露本身就是一个发现。关键在于,同一个值竟然被用于验证签名,所以我进行了严谨的验证,而不是仅仅依赖运气。Flask 通过 itsdangerous 使用 cookie 会话密钥派生进行签名,因此我在离线状态下重新计算了签名。计算出的 expected 值与我几秒前收到的 cookie 签名在字节上完全一致。为了确保这不是巧合,我修改了签名中的一个字符(被拒绝),并用恢复出的密钥重新对原始负载进行了签名(被接受)。会话完整性已不复存在。我可以设置会话中的每一个键和值,并生成一个服务器会接受的签名。

The callback reads the flow back out of the session and unpickles it: session cookie -> verify Flask signature -> read google_oauth2_flow_<csrf> -> base64-decode the bytes -> pickle.loads(...). That pickle.loads is only “safe” as long as nobody else can forge a valid cookie. Once the key is public, that assumption is worthless. I built my own session with a made-up CSRF token and put whatever pickle I wanted under the matching flow key. The state parameter is mine too, so nothing needs to line up on the server’s end. It checks my signature, finds my flow entry, and passes the bytes to pickle.loads. If you haven’t played with pickle before: the REDUCE opcode names a callable and its arguments, and unpickling just calls it. A public signing key plus this sink is unauthenticated RCE.

回调函数会从会话中读取流程并进行反序列化:session cookie -> 验证 Flask 签名 -> 读取 google_oauth2_flow_<csrf> -> 对字节进行 base64 解码 -> pickle.loads(...)。只有在没有人能伪造有效 cookie 的前提下,pickle.loads 才是“安全”的。一旦密钥公开,这一假设就毫无意义了。我构建了自己的会话,使用了一个虚构的 CSRF 令牌,并将我想要的任何 pickle 数据放在匹配的流程键下。state 参数也由我控制,因此服务器端无需进行任何匹配。它会检查我的签名,找到我的流程条目,并将字节传递给 pickle.loads。如果你以前没玩过 pickle:REDUCE 操作码会指定一个可调用对象及其参数,反序列化过程就是直接调用它。公开的签名密钥加上这个漏洞点,就构成了未经身份验证的远程代码执行(RCE)。

Proof of Concept

I wasn’t about to run commands on Google’s production, so I proved it with time. The payload reduced to time.sleep(N). Same forged session every time, only the number changed. The response time tracked the number in the pickle, which only happens if the server imported time and actually called sleep(N). Then the controls, to rule out a slow endpoint or jitter: No cookie (Invalid request), one flipped byte in the signature (Invalid request), valid signature but no flow entry (Invalid request state), valid signature with the real flow (reached Google’s token exchange), valid signature with the sleep payload (delayed by exactly the number I picked).

概念验证

我并不打算在谷歌的生产环境中运行命令,所以我用时间延迟来证明这一点。负载被简化为 time.sleep(N)。每次使用相同的伪造会话,只是数字发生了变化。响应时间与 pickle 中的数字保持一致,这只有在服务器导入了 time 并实际调用了 sleep(N) 时才会发生。随后进行了对照测试,以排除端点缓慢或抖动的可能性:无 cookie(无效请求)、签名中修改一个字节(无效请求)、签名有效但无流程条目(无效请求状态)、签名有效且使用真实流程(到达谷歌令牌交换)、签名有效且使用 sleep 负载(延迟时间与我选择的数字完全一致)。

How far it went

This isn’t a single app, it’s the shared DevSite platform, so the key from developers.google.com signed valid sessions on: firebase.google.com, developer.android.com, source.android.com, docs.partner.android.com.

影响范围

这不仅仅是一个单一的应用程序,而是共享的 DevSite 平台,因此来自 developers.google.com 的密钥可以为以下网站签署有效的会话:firebase.google.com、developer.android.com、source.android.com 和 docs.partner.android.com。

Fixing it

Four things had to change, and they stack: Rotate both exposed OAuth client secrets and kill every session signed with them. Give each deployment its own high-entropy Flask SECRET_KEY from a server-side store, with nothing to do with an OAuth credential. Keep the OAuth flow object on the server. The cookie should hold an opaque reference and nothing sensitive. Drop pickle for anything crossing a client boundary. A data-only format with a schema check makes a forged cookie inert. Any one of those would have broken the chain. All four lined up, and that’s how two GET requests end up running code on Google’s servers.

修复方案

需要做出四项改变,且它们是叠加的:轮换所有暴露的 OAuth 客户端密钥并终止所有使用这些密钥签署的会话。为每个部署提供来自服务器端存储的、高熵的 Flask SECRET_KEY,且与 OAuth 凭据无关。将 OAuth 流程对象保留在服务器上。Cookie 应仅包含不透明的引用,不应包含任何敏感信息。对于任何跨越客户端边界的数据,放弃使用 pickle。采用带有模式检查的纯数据格式可以使伪造的 cookie 失效。其中任何一项都能打破攻击链。这四项措施同时实施,这就是为什么两个 GET 请求最终能在谷歌服务器上运行代码的原因。

Timeline

  • Aug 6, 2026 · 22:48 Report submitted
  • Aug 7, 2026 · 08:12 Triaged
  • Aug 7, 2026 · 16:23 Accepted
  • Aug 27, 2026 Reward decided: $90,000 (Normal Google Applications (T2), S0 RCE: $75,000 base × 1.2 for exceptional report quality.)
  • Oct 7, 2026 · 15:20 Fixed

时间线

  • 2026 年 8 月 6 日 22:48 提交报告
  • 2026 年 8 月 7 日 08:12 完成分类
  • 2026 年 8 月 7 日 16:23 确认接受
  • 2026 年 8 月 27 日 奖励确定:90,000 美元(常规谷歌应用程序 (T2),S0 RCE:75,000 美元基础奖金 × 1.2 倍,因报告质量卓越。)
  • 2026 年 10 月 7 日 15:20 完成修复