ZCode 会把你整个仓库连同 Git 历史静默上传:我抓到了现行
本文记录一次在自己机器、自己账号、测试仓库上做的验证。目标只有一个:确认 ZCode 是否真的把整个仓库打包上传,并拿到原始字节作为证据。
结论:确认。 触发时机是一轮对话正常结束;上传内容是工作区文件 + 完整
.git历史 + 当次提示词 + 全局配置清单;加密公钥由服务端下发,私钥只在云端;界面上的隐私开关不控制这件事。本文把复现所需的命令、脚本思路和踩过的坑都写出来,方便你自己验证。请只在自己的机器和账号上验证。
0. 原始来源
- 英文:https://blog.ferstar.org/en/posts/zcode-silent-workspace-snapshot-upload/
- 中文:https://blog.ferstar.org/posts/zcode-silent-workspace-snapshot-upload/
- ZCode 隐私政策(对照用):https://zcode.z.ai/cn/privacy
本文是在原博文基础上的独立复现,并补上了原博文没有的部分:抓到原始上传字节并用我们自己的密钥解密出内容,以及复现所需的完整步骤。
1. 结论摘要
- 会上传整个工作区:工作区当前文件(源码、文档、脚本、配置)+
.git完整历史 + 当次提示词 + 全局配置清单。 - 触发时机:一轮对话正常结束(turn completed)时触发一次。不是实时、不是每条消息、不是定时。第一次全量(
baseline),之后增量(increment)。 - 加密:信封加密。正文
AES-256-CTR(密文前 16 字节是 nonce),对称密钥用RSA-OAEP-SHA256包裹;RSA 公钥由服务端在凭据接口下发,私钥只在云端。 - 界面开关无效:采集链路上不读「优化体验」,也不读「仓库索引」开关。
- 当前状态:
GET https://zcode.z.ai/api/v1/snapshot/upload-credential现在返回 404(空 body),同域其他接口正常。也就是说,在我写这篇文章的时候这条上传通道已经不可用;但历史上它是可用的(本机有服务端受理记录)。 - 本文拿到的证据:在被拦截并替换凭据后,客户端照常打包并上传了 100MB 级的密文;用我们自己的私钥解密成功,SHA-256 与密文信封里记录的
plaintextSha256完全一致。
2. 我自己机器上早就有的痕迹
ZCode 把快照状态放在:
~/.zcode/v2/checkpoints/<sha256(工作区路径)[:12]>/
├── state.json # 记录最近一次压缩大小、已受理的 manifest 哈希
├── manifests/<hash>.json # 明文文件清单(路径 + 大小)
├── extra-manifests/<hash>.json# 全局配置清单
├── tmp/ # 明文 tar.gz(加密后即删)
└── pending/ # 密文 + 信封(上传成功后即删)state.json 里 lastAcceptedManifestHash 非空,就代表服务端已经受理过一次上传。我这台机器上多个工作区都有这个记录。
明文 manifests/<hash>.json 是理解"它传了什么"最省事的入口:里面按路径列出每个文件。
3. 机制(静态分析)
从 ZCode.app/Contents/Resources/app.asar 与 Resources/glm/zcode.cjs 可以直接读出:
-
凭据接口
GET /api/v1/snapshot/upload-credential?workspace_id=<sha256(工作区路径)[:12]> Authorization: Bearer *** JWT>响应体是
{code, msg, data},data至少包含:snapshot.snapshot_id snapshot.base_snapshot_id # 空 = baseline(全量) encryption.algorithm # 必须 "RSA-OAEP-256" encryption.public_key # SPKI PEM,服务端下发 encryption.key_version max_size oss.host / oss.path / oss.policy / oss.x_oss_* callback.url / callback.body / callback.content_type客户端校验很严:缺任一字段直接抛错。这段校验逻辑(
resolveUploadCredentialData)是本文复现的关键——伪造凭据必须给齐。 -
本地产物命名
groupId = <manifestHash>.<extraManifestHash>.<createdAt> tmp/<groupId>.tar.gz # 明文 pending/<groupId>.tar.gz.enc # 密文 = nonce(16B) || AES-256-CTR(明文) pending/<groupId>.envelope.json # 信封 -
加密:随机 32 字节 AES key + 随机 16 字节 nonce;
encryptedDataKey = RSA-OAEP-SHA256(public_key, aesKey)。 -
上传:以 multipart 表单 POST 到
oss.host(阿里云 OSS),表单含policy、x-oss-signature和 base64 的callback;上传成功后本地产物立即删除。
4. 复现:怎么逼它把东西交出来
难点有两个:上游接口已经 404(正常路径不产生任何东西),以及本地产物"上传成功即删"。
思路:自己当中间人。让 ZCode 走我们自己的本地代理,我们自己给它一份合法的凭据——公钥换成我们自己的,上传地址指向本地。这样它照常打包加密,而密文落进我们手里,且只有我们能解密。
4.1 生成你自己的证书与密钥(不要用别人的)
mkdir -p ~/zcode-forensics && cd ~/zcode-forensics
# 用来解密快照的密钥对
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out snap-priv.pem
openssl pkey -in snap-priv.pem -pubout -out snap-pub.pem
# 本地 CA + zcode.z.ai 叶子证书(给代理做 TLS 终止)
openssl genrsa -out ca.key 2048
openssl req -x509 -new -key ca.key -days 365 -subj "/CN=zc-forensics-ca" -out ca.pem
openssl req -new -newkey rsa:2048 -nodes -keyout leaf.key -subj "/CN=zcode.z.ai" -out leaf.csr
printf "subjectAltName=DNS:zcode.z.ai\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n" > leaf.ext
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 365 -out leaf.pem -extfile leaf.ext4.2 极简 MITM 代理(伪凭据 + 本地捕获)
# proxy.py —— 127.0.0.1:8899 代理;127.0.0.1:9999 捕获端
import json, os, socket, ssl, threading, datetime
PUB = open(os.path.expanduser("~/zcode-forensics/snap-pub.pem")).read().strip() + "\n"
LEAF_PEM = os.path.expanduser("~/zcode-forensics/leaf.pem")
LEAF_KEY = os.path.expanduser("~/zcode-forensics/leaf.key")
OUT = os.path.expanduser("~/zcode-forensics/captured"); os.makedirs(OUT, exist_ok=True)
def credential():
ts = int(datetime.datetime.now().timestamp() * 1000)
return {"code": 0, "msg": "", "data": {
"snapshot": {"snapshot_id": f"snap-{ts}", "base_snapshot_id": ""}, # 空 => 全量
"encryption": {"algorithm": "RSA-OAEP-256", "public_key": PUB, "key_version": 1},
"max_size": 1 << 30,
"oss": {"host": "http://127.0.0.1:9999", "path": f"x/{ts}.tar.gz.enc",
"policy": "p", "x_oss_signature": "s",
"x_oss_signature_version": "OSS4-HMAC-SHA256",
"x_oss_credential": "c", "x_oss_security_token": "t",
"x_oss_date": "2026-01-01T00:00:00Z"},
"callback": {"url": "http://127.0.0.1:9999/cb", "body": "{}",
"content_type": "application/json"},
}}
def read_head(f):
h = b""
while b"\r\n\r\n" not in h:
c = f.readline()
if not c: return None
h += c
return h
def handle_zcode(raw):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain(LEAF_PEM, LEAF_KEY)
tls = ctx.wrap_socket(raw, server_side=True); f = tls.makefile("rb")
while True:
head = read_head(f)
if not head: break
line = head.decode("latin-1").split("\r\n")[0]
method, path, _ = line.split(" ")
headers = {}
for l in head.decode("latin-1").split("\r\n")[1:]:
if ":" in l:
k, v = l.split(":", 1); headers[k.strip().lower()] = v.strip()
n = int(headers.get("content-length", "0") or 0)
body = f.read(n) if n else b""
if "/api/v1/snapshot/upload-credential" in path:
payload = json.dumps(credential()).encode()
tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
b"Content-Length: " + str(len(payload)).encode() +
b"\r\nConnection: keep-alive\r\n\r\n" + payload)
continue
up = ssl._create_unverified_context().wrap_socket(
socket.create_connection(("zcode.z.ai", 443), timeout=20), server_hostname="zcode.z.ai")
up.sendall(head + body)
up_head = read_head(up.makefile("rb"))
if up_head:
tls.sendall(up_head)
uh = {k.strip().lower(): v.strip() for k, v in
(l.split(":", 1) for l in up_head.decode("latin-1").split("\r\n")[1:] if ":" in l)}
m = int(uh.get("content-length", "0") or 0)
if m: tls.sendall(up.makefile("rb").read(m))
up.close()
def proxy_conn(conn):
f = conn.makefile("rb"); head = read_head(f)
if not head: conn.close(); return
parts = head.decode("latin-1").split("\r\n")[0].split(" ")
if parts[0] == "CONNECT":
host, _, port = parts[1].partition(":"); port = int(port or 443)
conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
if host == "zcode.z.ai": handle_zcode(conn)
else:
up = socket.create_connection((host, port), timeout=20)
threading.Thread(target=lambda: [up.sendall(d) for d in iter(lambda: conn.recv(65536), b"")], daemon=True).start()
for d in iter(lambda: up.recv(65536), b""): conn.sendall(d)
conn.close()
def capture_conn(conn):
f = conn.makefile("rb"); head = read_head(f); n = 0
for l in head.decode("latin-1").split("\r\n")[1:]:
if l.lower().startswith("content-length:"): n = int(l.split(":", 1)[1])
body = f.read(n) if n else b""
ts = datetime.datetime.now().strftime("%H%M%S-%f")
open(os.path.join(OUT, f"upload-{ts}.bin"), "wb").write(body)
print("captured", len(body), "->", f"upload-{ts}.bin")
conn.sendall(b"HTTP/1.1 500 blocked\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
conn.close()
def serve(port, handler):
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port)); s.listen(64)
while True:
c, _ = s.accept(); threading.Thread(target=handler, args=(c,), daemon=True).start()
threading.Thread(target=serve, args=(9999, capture_conn), daemon=True).start()
serve(8899, proxy_conn)要点:
- 代理对
zcode.z.ai做 TLS 终止,命中/api/v1/snapshot/upload-credential就返回 200 + 伪造凭据;其它请求原样转发。 - 伪造凭据里
encryption.public_key换成你自己的公钥(snap-pub.pem),oss.host指向本地捕获端。
4.2.1 关键一步:把上传目标从阿里云改成本地
这一步是整个实验最不能省的地方,目的有两个:
- 不让内容真的离开你的机器——正常运行时,凭据里的
oss.host是服务端下发的阿里云 OSS 地址,打包好的密文会被真传到阿里云。我们伪造凭据时把它改成本地地址,客户端就只会往你本机发,不会进阿里云。 - 顺便拿到完整密文——密文落到本地,我们才能用自己的私钥解密验证。
具体做法:
- 伪造凭据里写死
"oss": { "host": "http://127.0.0.1:9999", ... }(其余path/policy/x_oss_*随便填非空值即可,因为目标是我们自己的服务器,签名不会被校验)。 - 本地起一个捕获端监听
127.0.0.1:9999,收到 multipart 上传就把 body 落盘(就是上面代理代码里的capture_conn/serve(9999, ...))。 - ZCode 配置里加
"httpProxyNoProxy": "127.0.0.1,localhost",让这条上传直连本地、不绕经代理(不然它会又走回127.0.0.1:8899的代理)。 - 捕获端收到后故意返回 500,客户端会认为上传失败并保留本地产物(
pending/*.tar.gz.enc+*.envelope.json),这样即使你没开目录监听也还有一份可解密。
一句话:把"要送去阿里云的那一包"半路截到你自己的机器上。没有这一步,要么内容真被传出去,要么你手上什么都不剩。
4.3 让 ZCode 走这个代理
编辑 ~/.zcode/v2/setting.json,加入:
{
"httpProxy": "http://127.0.0.1:8899",
"httpProxyNoProxy": "127.0.0.1,localhost",
"httpProxyCaCertPath": "<绝对路径>/ca.pem"
}两个要点:
- 这个值在启动时解析并缓存,必须完全退出 ZCode 再打开才生效。
httpProxy是全局的。Electron 渲染进程和 CLI agent 不认你的自签 CA,会报certificate unknown,导致模型等请求失败。验证完立刻把这三个字段删掉并重启,不要长期挂着。
4.4 抓本地产物(可选,防止删太快)
上传成功时 pending/ 里的密文会被立刻删除。想留一份本地产物,可以监听目录变化,命中即冻结进程再拷贝:
# 思路:kqueue 监听 checkpoints/*/pending 的写入事件,
# 事件一到就 os.kill(<zcode-host pid>, SIGSTOP),cp 出产物,再 SIGCONT。
# 注意:SIGSTOP 之后一定要 SIGCONT;进程被 kill -9 时会一直冻着。其实有了 4.2 的本地捕获端,已经能拿到完整密文,这一步只是多一份保险。
4.5 触发
在 ZCode 里打开目标工作区,发一条消息并等这一轮正常跑完。代理日志会出现:
MITM GET /api/v1/snapshot/upload-credential?workspace_id=<hash>
-> synthetic credential
CAPTURE POST / len=104511017 -> upload-200933-033354.raw4.6 解密
信封 *.envelope.json 里给出算法与哈希;密文的前 16 字节是 nonce:
ENV=...envelope.json
ENC=...tar.gz.enc
# 1) 用你的私钥解开信封里的 AES key
python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['encryptedDataKey'])" "$ENV" \
| base64 -d > /tmp/wrap.bin
KEY_HEX=$(openssl pkeyutl -decrypt -inkey ~/zcode-forensics/snap-priv.pem -in /tmp/wrap.bin \
-pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256 | xxd -p -c 64)
# 2) 前 16 字节是 IV,其余是 AES-256-CTR 密文
IV_HEX=$(xxd -p -l 16 "$ENC")
tail -c +17 "$ENC" | openssl enc -d -aes-256-ctr -K "$KEY_HEX" -iv "$IV_HEX" -out unpacked.tar.gz
# 3) 校验(必须与信封里的 plaintextSha256 一致)
shasum -a 256 unpacked.tar.gz
tar tzf unpacked.tar.gz | head解密成功且 SHA-256 与信封里的 plaintextSha256 一致,就是原始字节级证据。
5. 我抓到了什么
(以下为脱敏后的结构;具体文件名/哈希属于我自己的仓库,已隐去。)
归档解出来的目录结构:
snap-<snapshot_id>/meta/prompt.json # 当次提示词
snap-<snapshot_id>/meta/manifest.json # 打包文件清单
snap-<snapshot_id>/extra-meta/manifest.json # 全局配置清单(hooks / mcp / settings…)
snap-<snapshot_id>/files/<your files> # 工作区当前文件:源码、文档、脚本、配置
snap-<snapshot_id>/files/.git/config
snap-<snapshot_id>/files/.git/FETCH_HEAD
snap-<snapshot_id>/files/.git/objects/** # 完整提交历史清单统计(明文 manifest,可直接读):363 个文件 / 106,210,009 字节,其中
| 顶层 | 文件数 | 字节 |
|---|---|---|
.git |
262 | 105,327,961(约 99%) |
docs |
37 | 566,363 |
tools |
21 | 154,478 |
services |
14 | 44,255 |
skills |
7 | 26,630 |
| 其它(配置、说明、脚本等) | 22 | ~90,000 |
也就是说:不只是 Git 仓库,工作区里的代码和文档同样在包里;node_modules/target 这类大目录会跳过,但一些被 gitignore 的目录(比如编辑器/Agent 的计划、记忆文件)反而被打进去了。
信封(脱敏,只保留结构):
{
"schema": "repo_snapshot_encrypted_artifact/v2",
"contentAlgorithm": "aes-256-ctr",
"keyWrapAlgorithm": "rsa-oaep-sha256",
"nonceEncoding": "ciphertext-prefix-16-byte",
"aad": {
"schema": "repo_snapshot_encryption_aad/v2",
"workspaceKeyHash": "<workspace-hash>",
"kind": "baseline",
"manifestHash": "<sha256>",
"compression": "tar.gz"
},
"encryptedDataKey": "<base64>",
"plaintextSha256": "<sha256>"
}6. 踩过的坑(省你时间)
httpProxyCaCertPath在启动时缓存,改完必须重启 App。- undici 的
ca是替换语义;不过 ZCode 的实现是与你机器上 Node 的内置根证书合并,所以加你自己的 CA 不会让公网站点验不过。 httpProxy全局生效,会让渲染进程和 CLI 的请求失败(certificate unknown)。这是最大的副作用,验证完立刻还原。- 用 Quantumult X 之类的现成代理软件做这件事会很别扭:
script-response-body改不了状态码(客户端if(!res.ok) throw),script-echo-response又会丢 body。自己写代理反而最快。 - 客户端凭据校验很严,伪造时那十来个字段一个都不能少。
- “文件被秒删"多半是错觉:多数时候它根本没打包(上游 404 或服务端返回空凭据,客户端直接放弃)。
7. 时间线与现状
- 2026 年 7 月:ZCode 上线;随后出现关于其数据边界的公开讨论。
- 2026-09-17/18:原博文发布"静默上传整仓快照"的分析。
- 2026-09-18:本机复现。当天 09:59 还有成功的上传受理记录;之后
upload-credential接口开始返回{code:0,data:null},再后来直接 404。 - 现在:该接口 404,正常路径不再产生上传。历史上传记录与本次抓到的原始字节都可以核对。
8. 隐私开关为什么不管用
采集与上传链路上不读 optimizeAgentExperienceEnabled,也不读 repoSnapshotIndexingEnabled。这两个开关分别管"是否授权训练"和"是否让服务端索引”,跟"是否打包上传"是两件事。
如果只想彻底断掉这条链路:退出 ZCode 的官方账号登录——采集的第一步就是取账号 JWT,取不到就直接返回。只用第三方模型订阅的话,退出登录并不影响你调用模型。
9. 免责声明
- 以上全部操作在我自己的机器、我自己的账号、我自己的测试仓库上完成。
- 本文的目的是验证一个客户端的数据行为,属于对自己设备与账号的安全研究。
- 请勿在他人设备或账号上做同样的事。用自定义 CA 做 TLS 中间人对他人流量是另一回事,不在本文范围。
- 如你也在自己的机器上得到同样结果,建议同时向厂商反馈(原博文提到的联系邮箱在 ZCode 隐私政策里:https://zcode.z.ai/cn/privacy)。