安全研究

Grok CLI 整仓上传逆向分析:一个 prompt 如何把你的 .env 送上云端

#样本分析#代码审计#供应链攻击#开发者安全
近黑深绿蓝背景上,被截获的 git bundle 从拦截流量中解包,明文凭证与密钥材料完整溢出、不受任何脱敏过滤器约束;前景是干净的开发者工具外壳隐藏着后台上传管道,在模型推理调用之前将整个代码库派发出去;中景一条平行管道绕过安全过滤器,原始敏感数据在其中畅流无阻——铜橙强调色点缀,象征 Grok CLI 整仓上传逆向分析

起因

事情的起因是山哥从社区看到一个说法:Grok CLI 可能有风险。具体是什么风险、风险多大,社区里众说纷纭。我手头刚好有新版 0.2.98 的 macOS aarch64 二进制(SHA-256 d5952131...),于是决定自己动手分析一遍——与其猜测,不如把二进制拆开、把流量抓下来、把上传内容恢复出来看个究竟。

分析下来,结论比社区传言更具体:Grok CLI 在每次 turn 开始时,会通过 git bundle 把整个代码库——包括 .gitignore 排除的 .env.envrcconfig.secret——逐字无脱敏地上传到 xAI 的云端存储。上传发生在模型收到推理请求之前,与「Improve the model」开关完全独立,且服务端可通过远程设置强制开启。我用 IDA Pro 9.3 做静态反编译、Frida 做运行时字节验证、mitmproxy 12.2.3 做流量捕获,三路交叉验证,并和社区已分析的 0.2.93 做了版本对比——上传机制两版本完全一致。

由于Grok CLI没有开源代码,在没有源码的情况下通过反编译和动态调试可以获得一些信息,但是很难保证这些信息的100%准确,在这次分析中用了静态分析和动态调试,以及流量分析进行了交叉验证,尽可能让结论不会偏离太多,以下面是复现的情况。

复现环境

我搭了一个最小化的测试项目,专门用来验证上传行为:

/tmp/grok-test-project/
├── .env            ← 6 个假 API 密钥
├── .envrc          ← 2 个密钥
├── config.secret   ← RSA 私钥
├── .gitignore      ← 排除 .env, .envrc, *.secret
├── src/main.py     ← print("hello")
└── README.md       ← # Test Project

.env 里放了 6 个假的 API 密钥(OpenAI、Anthropic、数据库、AWS、Stripe、JWT),.envrc 放了 2 个,config.secret 放了一段假的 RSA 私钥。.gitignore 明确排除了 .env.envrc*.secret——这是关键,因为如果 Grok CLI 遵守 .gitignore,这三个文件就不该被上传。

Grok 的配置(~/.grok/config.toml)我用默认条件下观察完整上传行为:

[features]
telemetry = false -> 默认配置

[telemetry]
trace_upload = false -> 默认配置

[harness]
disable_codebase_upload = false -> 默认配置, 2026.07.13 之后服务端返回的配置变成了 true,这个开关是服务端控制的!

mitmproxy 这边,Grok CLI 用的是 rustls 0.23.37(静态编译 webpki-roots),不走 macOS 系统钥匙串,也没有证书固定(搜 cert.*pin|pinned 全无结果)。于是只要把 SSL_CERT_FILE 指向 mitmproxy 的 CA 证书,所有 HTTPS 流量都能解密:

export GROK_DEPLOYMENT_KEY="fake-deployment-key-for-testing"
export GROK_RESPECT_GITIGNORE=0
export GROK_SANDBOX=none
export HTTPS_PROXY="http://127.0.0.1:8080"
export SSL_CERT_FILE="$HOME/.mitmproxy/mitmproxy-ca-cert.pem"

发送的 prompt 很简单:Hello World!

一个 prompt,45 个请求

prompt 发出去后,5.2 秒内 mitmproxy 捕获了 45 个 HTTP 请求。其中 14 个是 POST /v1/storage——上传请求。

最值得关注的是时间线。上传不是模型调用的副产品,而是先于模型调用的前置步骤:

18:31:29.882  POST /v1/storage  config.json (4,086B)           ← 上传开始
18:31:29.883  POST /v1/storage  config_files.json (1,485B)
18:31:29.895  POST /v1/storage  plugins.json (42B)
18:31:29.896  POST /v1/storage  tool_definitions.json (45,440B)
18:31:29.898  POST /v1/storage  metadata.json (913B)
18:31:29.901  POST /v1/storage  before_session_state.tar.gz (4,689B)
18:31:29.905  POST api.x.ai/v1/responses  (1,252B)            ← 模型调用 (晚 4ms)
18:31:30.179  ← 模型返回 403 (无 credits)
18:31:30.217  ← 上传返回 401 (伪造 key)
18:31:30.458  POST /v1/storage  git bundle (1,120B)           ← 代码库上传
18:31:33.815  POST /v1/traces  (18,272B)                      ← OpenTelemetry trace

6 个初始上传在 19 毫秒内完成(29.882 → 29.901),然后模型调用才开始(29.905)。上传比模型调用早 4 毫秒。换句话说,用户的代码被发往服务端之后,模型才收到推理请求——上传是一个独立的前置步骤,不是模型调用的副作用。

更奇怪的是,即使模型返回 403(账户无 credits),上传请求照样发;即使上传返回 401(我用的是伪造的 deployment key),上传队列仍然持续尝试,直到断路器介入:

Upload queue circuit breaker tripped, pausing dispatch

从流量里恢复出敏感文件

这是整个分析里我最想让人亲眼看到的一步。

我在 14 个上传请求里发现Content-Typeapplication/gzipx-storage-path 指向 turn_0/repo_changes_dedup/v2/bundles/sha256_84aa...bundle。这不是一个文件摘要,而是一个标准的 git bundle——可以通过 git clone 直接恢复出完整的仓库历史。

把它从流量里提取出来,用 git 原生命令验证并克隆:

$ git bundle verify /tmp/captured.bundle
The bundle contains 1 ref:
    refs/heads/main
/tmp/captured.bundle is okay

$ git clone /tmp/captured.bundle grok_bundle_clone

克隆成功,恢复出 6 个文件:.env.envrcconfig.secret.gitignoreREADME.mdsrc/main.py。其中三个是 .gitignore 明确排除的。

.env 的内容,逐字无脱敏:

OPENAI_API_KEY=sk-test-key-1234567890abcdef
ANTHROPIC_API_KEY=sk-ant-test-key-abcdef123456
DATABASE_URL=postgresql://user:password@localhost:5432/db
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STRIPE_SECRET_KEY=sk_live_test_stripe_secret_key_12345
JWT_SECRET=my-super-secret-jwt-key-1234567890

6 个 API 密钥、2 个环境变量密钥、一段 RSA 私钥,全部原样躺在 git bundle 里。脱敏过滤器(如果它生效的话)本该把这些替换成 [REDACTED_SECRET]——但它没有。

脱敏过滤器为什么没拦住

Grok CLI 确实内置了一套脱敏系统。从 IDA 里能提取出完整的 12 种正则(基于 Rust regex::RegexSet):Bearer 令牌、GitHub 令牌、GitLab/Slack 令牌、Stripe/xAI 密钥、AWS 密钥、Google API 密钥、PEM 私钥、JWT、用户主目录路径……覆盖面不算窄,匹配后替换为 [REDACTED_SECRET]

但这套脱敏只作用于 trace 日志和 JSON 元数据里的键值对。源文件路径是 xai-grok-shell/src/upload/trace.rs。而 git bundle 上传走的是另一条路径——xai-data-collector/src/queue.rs,一个独立的上传队列。

于是出现了一个割裂:同一个 .env 文件里的 sk-test-key-1234567890abcdef,在 trace 日志里会被脱敏成 [REDACTED_SECRET],但在 git bundle 里会原样上传。两条路径,两种待遇。一次 turn 里上传的 12 类内容中,git bundle 是唯一携带原始文件内容的那一类,恰好也是唯一不经过脱敏的那一类。

发现了 8 个独立开关

trace_upload 的启停不由单一开关控制。IDA 里有一段连续字符串,明确列出了 8 个独立启用来源。

最后一个来源 in_remote_trace_upload_enabled 配合 has_remote_settings,这似乎意味着 xAI 后端可以通过远程配置强制开启上传——即使用户在本地关掉了所有环境变量和配置文件。配置优先级是 env > config > remote,但远程设置本身就是独立的启用来源。

更要紧的是,trace_upload 和界面上那个「Improve the model」开关(coding_data_retention_opt_out)完全独立。关掉「Improve the model」不会禁用 trace_upload。二进制里有一条日志说得很直白:

Telemetry disabled but trace uploads enabled: session artifacts will be uploaded, analytics events will not

respect_gitignore 默认 false

.gitignore 排除的文件为什么还是被上传了?因为 respect_gitignore 的默认值是 false

IDA 反汇编 0.2.98 的配置解析函数,地址 0x10372EB5C

loc_10372EB5C:
    MOV  W27, #0              ; respect_gitignore 默认值 = 0 (false)
0x10372EBAC:
    AND  W8, W27, #1          ; 提取布尔位
0x10372EBB0:
    STRB W8, [X19, #0xC8]     ; 写入结构体偏移 0xC8
0x10372EBC4:
    ADRL X0, aGrokRespectGit  ; 加载 "GROK_RESPECT_GITIGNORE"

MOV W27, #0 把默认值设为 0(false)。为了排除 IDA 分析误差,我用 Frida spawn 进程后直接读运行时内存:

var base = Process.enumerateModules()
    .find(m => m.name === "grok").base;
// base = 0x100f0c000, ASLR slide = 0xf0c000
// IDA 地址 0x10372EBB0 + slide = 0x10463abb0
var bytes = Memory.readByteArray(ptr("0x10463abb0"), 4);
// 返回: 68 22 03 39 (little-endian)

读到的字节是 68 22 03 39,little-endian 解码为 0x39032268,即 ARM64 指令 STRB W8, [X19, #0xC8]——和 IDA 反汇编完全一致。二进制里的帮助文本也写明了:respect_gitignore = false # default: false

默认 false 意味着 Grok CLI 在创建 git bundle 时不会遵守 .gitignore。这就是 .env.envrcconfig.secret 出现在上传内容里的直接原因。

(我在 0.2.93 上也做了同样的验证)

版本对比

社区白帽子 cereblab 之前在 Gist 上分析了 0.2.93,列了 10 项声明。我逐项在 0.2.98 上做了验证。

通过 IDA find_regex 搜 0.2.98、strings | grep 搜 0.2.93,以下方面两版本完全一致:8 个 trace_upload 来源、12 种脱敏正则、8 个存储端点、断路器的状态、GCS 存储桶 grok-code-session-tracesGROK_TELEMETRY_GCS_BUCKET 环境变量、无证书绑定。

0.2.98 比 0.2.93 大了 1.83MB,多了 6 个导入符号,全和显示系统相关:CGDisplayCopyDisplayModeCGDisplayModeGetRefreshRateCGMainDisplayID……对应的字符串 grok-shell-display_refresh_probe 证实这是显示刷新率探测功能。体积增长与上传机制无关。

(顺带一提,mitmproxy 还抓到 0.2.98 运行时会自动检查 x.ai/cli/stable 获取最新版本号,发现 0.2.101 后自动分 7 片下载新版本二进制。自动更新本身不意外,但它意味着即使你今天分析透了手里这个版本,明天它可能就换了,自动更新可能会引入新版本的功能,但是也会有新的风险。)

缓解措施

如果要继续用 Grok CLI 同时减少数据上传,建议三层并用——因为远程设置可覆盖本地配置,单一防护层不够。具体配置见左侧 note。

写在最后

这次分析让我比较在意的,不是单个漏洞,而是「一致性缝隙」——脱敏系统存在但不覆盖 git bundle、8 个开关里有一个是远程可控、「Improve the model」关了但 trace_upload 照传。每一处单独看都有解释(脱敏是给 trace 用的、远程设置是为了运营、「Improve the model」管的是另一回事),但拼在一起,用户的 .env等敏感数据就这么被上传到了云端。

CLI 编码工具的隐私边界,本质上是信任边界。你把整个仓库交给一个会在 turn 开始时就打包上传的二进制,等于把信任交给了它的每一个开关、每一条上传路径、每一次远程配置下发。而信任该持续验证,不该一次性授予。

大家也多注意安全。

本文基于 IDA Pro 9.3 反编译、Frida 动态插桩、mitmproxy 12.2.3 流量捕获的交叉验证。分析的二进制版本为 grok-0.2.98 和 grok-0.2.93(macOS aarch64)。参考资源:cereblab 的 0.2.93 分析grok-build-privacy-hardening