Hacker News 中文摘要

RSS订阅

I Found a $90k Remote Code Execution Vulnerability in Google

我在梳理谷歌(Google)开发者文档站点共用的 OAuth 流程时发现,你访问 /oauth2authorize 时,它会设置一个 Flask session cookie,并重定向到谷歌登录页面,无需账号。引起我注意的是这个 cookie:它采用 Flask 常见的三段式 session=<payload>.<timestamp>.<signature>,但大小约 1,200 字节,远超只存放 CSRF token 和 return URL 所需,于是我解码了它。Flask session cookie 只签名不加密,开头为点表示载荷经 zlib 压缩;解压后得到 session 内容,包含 _permanent、google_oauth2_csrf_token 与 google_oauth2_flow_<csrf>,其中 " b" 键是 Flask 标记 bytes 值的标志,Base64 解码后便得到了对应数据。

正文

我是如何发现的

当时我在梳理 Google 开发者文档站点共用的 OAuth 流程。访问 /oauth2authorize(例如请求 https://developers.google.com/oauth2authorize?return_url=%2F)会设置一个 Flask 会话 Cookie,并跳转到 Google 的登录页,整个过程不需要账号。

引起我注意的是这个 Cookie。它符合 Flask 常见的三段式 session=<payload>.<timestamp>.<signature>,但大约有 1200 字节,远超过只存一个 CSRF token 和 return URL 所需的长度。于是我把它解了出来。

Flask 的会话 Cookie 只签名、不加密,拿到 Cookie 的人直接就能看到内容。开头的点表示 payload 经过 zlib 压缩:按 Flask 的规则拆分出 payload、timestamp 和 signature,对 payload 做 URL-safe base64 解码,若有压缩再用 zlib 解压,最后 json.loads,就还原出了会话内容。

解出来是:_permanent 为 true,google_oauth2_csrf_token 为一段字符串,还有一个 google_oauth2_flow_<csrf> 项,其值是 {" b": "..."}。

" b" 这个键是 Flask 用来标记 bytes 值的。把它 base64 解码后,我拿到了一个 pickle;用 pickletools.dis 反汇编,看到的是一个 oauth2client.client.OAuth2WebServerFlow 对象,字段里包含 client_id 和 client_secret:其中 client_id 是 <redacted>.apps.googleusercontent.com,client_secret 是 b"<redacted>"。

也就是说,Cookie 里不只有 pickle 序列化的载荷,还明文带着 Google OAuth 的 client_id 和 client_secret(这里的 secret 已脱敏,在报告撰写时仍然有效)。

我把 client secret 当作 Flask 的 SECRET_KEY 试了一下,用它去验证服务端下发的那个 Cookie 的签名,结果验证通过。用于签名的密钥,竟然存在在它所签名的对象里。

一个密钥,两种用途

client secret 出现在 Cookie 里,本身已经是一个漏洞。但更关键的是,同一个值还能用来验证签名,所以我没有停留在“碰巧猜对”,而是把它彻底确认了一遍。

Flask 通过 itsdangerous 签名,并使用 cookie-session 做密钥派生。我在线下重算了签名:先用 hmac.digest(client_secret, b"cookie-session", "sha1") 得到派生密钥,再用它对 {payload}.{timestamp} 做一次 hmac.digest(..., "sha1")。算出来的结果和几秒前拿到的 Cookie 签名逐字节一致。为了排除偶然,我把签名里的一个字符改掉(被拒绝),又用恢复出来的 secret 重新给原始 payload 签名(被接受)。会话完整性就此失效:我可以任意设置会话里的每个键和值,并仍然生成服务器认可的签名。

从伪造 Cookie 到代码执行

回调会把 flow 从会话里读出来并反序列化,整条链路是:校验 Flask 签名 → 读取 google_oauth2_flow_<csrf> → base64 解码字节 → pickle.loads(...)。

只要别人无法伪造有效 Cookie,这个 pickle.loads 才算“安全”。一旦密钥公之于众,这个前提就荡然无存。我构造了自己的会话,CSRF token 随便编了一个,并在对应的 flow 键下放入任意 pickle:会话里 _permanent 为 True,google_oauth2_csrf_token 设为 attacker-chosen,google_oauth2_flow_attacker-chosen 的 " b" 字段是 base64 编码后的攻击载荷。

state 参数也由我控制,所以服务端不需要有任何东西对得上。它校验我的签名,找到我的 flow 条目,然后把字节交给 pickle.loads。

如果你没玩过 pickle:REDUCE 操作码会指定一个可调用对象及其参数,反序列化时直接调用它。公开的签名密钥加上这个 sink,就是未授权 RCE。

我不想真的在 Google 生产环境上执行命令,于是用时间来证明。载荷简化为 time.sleep(N):我写了一个 Sleep 类,在 __reduce__ 里返回 (time.sleep, (self.seconds,))。每次都用同样的伪造会话,只改数字:

time.sleep(0) 耗时 0.207 秒,time.sleep(3) 耗时 3.193 秒,time.sleep(5) 耗时 5.194 秒,time.sleep(8) 耗时 8.283 秒。响应时间随 pickle 里的数字变化,这只有可能是服务端导入了 time 并真的调用了 sleep(N)。

为了排除端点本来就慢或者网络抖动,我又做了对照:

  • 不带 Cookie:Invalid request。
  • 签名里改动一个字节:Invalid request。
  • 签名有效,但没有 flow 条目:Invalid request state。
  • 签名有效且是真实 flow:会走到 Google 的 token exchange,随后因为我的假 auth code 报 invalid_grant 失败。
  • 签名有效且带 sleep 载荷:延迟正好等于我选的数字。

影响范围

这不是单个应用,而是共享的 DevSite 平台,因此 developers.google.com 的那个密钥能给下列站点签出有效会话:

  • firebase.google.com
  • developer.android.com
  • source.android.com
  • docs.partner.android.com

修复

需要改的有四件事,它们层层叠加:

  1. 轮换两个泄露的 OAuth client secret,并让所有用它们签名的会话失效。
  2. 每个部署使用来自服务端存储的高熵 Flask SECRET_KEY,且与 OAuth 凭据完全无关。
  3. OAuth flow 对象保留在服务端;Cookie 只保存不透明的引用,不包含任何敏感信息。
  4. 任何跨越客户端边界的数据都不要再使用 pickle;改用带 schema 校验的纯数据格式,让伪造 Cookie 变得无害。

其中任何一项做到,都足以打断这条链。但这四项问题同时凑齐,才导致两次 GET 请求最终能在 Google 服务器上执行代码。

时间线

  1. 2026年8月6日 · 22:48:提交报告
  2. 2026年8月7日 · 08:12:已分诊
  3. 2026年8月7日 · 16:23:已受理
  4. 2026年8月27日:奖励确定:90,000 美元

    Normal Google Applications (T2), S0 RCE:75,000 美元基础 × 1.2(报告质量极高)。

  5. 2026年10月7日 · 15:20:已修复

评论

讨论聚焦在「这个远程代码执行与博文到底算作者还是 Claude 的功劳」:多数人吐槽大语言模型代笔腔调和用「我」冒领功劳,少数人反驳「用大语言模型就等于没有人类主体性」,并建议可披露所用模型与工具。其余讨论则散见对 Claude 代笔博文太赶客、横跨七个 Google 域的远程代码执行只换来寒酸回报的讽刺,以及对如此低级根因如何通过评审的震惊;也有人借楼请教漏洞挖掘入门方法,但未形成交锋。

认为成果和博文实际由 Claude 完成,作者用「我」冒领功劳

  • 漏洞是 Claude 发现、博文也明显由 Claude 以第一人称写成,作者反复用「我」掩盖了 Claude 的贡献,等于把 Claude 输出包装成个人成果。(较多人)

反复用「我」而不是「Claude」很冒险,因为明明是 Claude 发现并以第一人称写了这篇博文(@EdwardDiego)

嫌大语言模型代笔的博文腔调赶客,呼吁作者自己写或公开提示词

  • 读者识别到大语言模型套路(例如堆砌「四件事必须改变,而且它们叠加」这类套话)后就开始跳读、不再认真看;大家宁愿读作者亲手写、哪怕解释得更差,或直接分享提示词与原始笔记,也不想读 Claude 润色稿。(较多人)

发现本身不错,但文章糟透了,不如分享提示词或原始笔记,读起来会舒服得多(@nerder92)

反驳「用大语言模型就没有人类功劳」,主张保留人的主体性并可披露工具

  • 使用大语言模型不等于人类毫无主体性;合理的规范可能是像「我用某工具辅助挖到远程代码执行」这样披露模型与工具,尽管某些情况下也有不披露的理由。(少数人)

「Claude 做的、不是你」这种说法,难道意味着人用大语言模型时完全没有主体性吗?(@consumer451)

讽刺七个 Google 域的远程代码执行只换来寒酸回报

  • 拿下跨七个 Google 域的远程代码执行,厂商给出的回报却像微软火鸡优惠券一样苛刻难兑现,或用一句「真划算」讽刺:严重漏洞的影响与所获奖励严重不匹配。(较多人)

横跨七个 Google 域的代码执行,可真是「划算」(@Quitschquat)

有价值的补充

  • 漏洞根因低级到令人震惊:用来签名的密钥竟被放在它自己所签名的 cookie 里,且使用 pickle 做反序列化;pickle 并不安全,连机器学习模型权重的存储都在弃用 pickle,因此有人怀疑这段问题代码也是大语言模型生成,并质问它如何通过代码与设计评审。