Grok CLI 整仓上传逆向分析:一个 prompt 如何把你的 .env 送上云端
起因
事情的起因是山哥从社区看到一个说法:Grok CLI 可能有风险。具体是什么风险、风险多大,社区里众说纷纭。我手头刚好有新版 0.2.98 的 macOS aarch64 二进制(SHA-256 d5952131...),于是决定自己动手分析一遍——与其猜测,不如把二进制拆开、把流量抓下来、把上传内容恢复出来看个究竟。
分析下来,结论比社区传言更具体:Grok CLI 在每次 turn 开始时,会通过 git bundle 把整个代码库——包括 .gitignore 排除的 .env、.envrc、config.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-Type 是 application/gzip,x-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、.envrc、config.secret、.gitignore、README.md、src/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、.envrc、config.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-traces、GROK_TELEMETRY_GCS_BUCKET 环境变量、无证书绑定。
0.2.98 比 0.2.93 大了 1.83MB,多了 6 个导入符号,全和显示系统相关:CGDisplayCopyDisplayMode、CGDisplayModeGetRefreshRate、CGMainDisplayID……对应的字符串 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。