逆向 ZCode 远程控制:扫码配对的二维码是怎么生成的,认证过程长什么样
ZCode 桌面版有个「远程控制」功能:手机扫一下屏幕上的二维码,就能在手机浏览器里操控电脑上正在跑的 AI 任务。用的时候我忽然好奇三个问题:
- 这个二维码里编码的是什么?有效期多久?
- “扫一次之后手机就不用再扫了”,凭据存在哪、怎么复用的?
- 手机和桌面隔着公网,认证握手具体是怎么做的?
于是花时间把 ZCode(Electron 应用,版本 3.7.6.4691)的代码逆向读了一遍,把整条链路挖了出来。这篇文章记录完整过程和结论——如果你也想逆向一个 Electron 应用,方法论部分应该也有参考价值。
先说结论(TL;DR)
- 二维码内容是一个 HTTPS URL:
https://zcode.z.ai/remote/v4?sid=...&hash=...&t=...&mid=...&name=...&app_version=... hash是配对密码的 SHA256——拿到二维码等于拿到密码,所以「刷新二维码」会把旧凭据作废- 认证是双向对称的 HMAC 挑战应答:桌面端 role 为
device,手机端 role 为terminal,proof 公式完全相同:HMAC-SHA256(key=passHash, msg="${nonce}|${role}|${deviceSid}") - relay(云端中转)按
device_sid把两端配对,配对后做透明桥接 - 凭据持久化在本地,重启、断线重连都复用同一个 sid+hash,所以扫一次就长期有效(客户端侧没有二维码 TTL)
逆向方法论
ZCode 是 Electron 应用,主进程和渲染进程的代码都打包在 app.asar 里:
# 提取 asar
npx @electron/asar extract /Applications/ZCode.app/Contents/Resources/app.asar /tmp/zcode-asar
提取出来是几百个文件,核心就三个:
| 文件 | 内容 |
|---|---|
out/main/index.js | 主进程:relay 传输层、认证 provider、配对状态机 |
out/main/chunk-FQMTTDFW.js | 二维码 URL 构建器、消息协议 schema |
out/renderer/assets/styles-CdEGpc2x.js | 渲染进程:WebRemoteControlDialog 组件(渲染二维码的 UI) |
代码是压缩混淆过的(变量名全是 Te、ZW、Hp 这种),直接读不现实。几个实用的抓手:
- 从 i18n 字符串切入。UI 上的文案「生成二维码失败」「刷新二维码」在
IntlProvider-*.js里都有 key(webRemoteControl.qrAlt、webRemoteControl.refreshQr…),grep 这些 key 就能定位到对应的 React 组件,再从组件的 IPC 调用追到主进程。 - grep 网络协议关键字。
auth_init、device_register、pair_status、nonce这类字符串不会被打包器改名,是定位协议处理逻辑的锚点。 - 顺着消息类型找状态机。找到
case"auth_challenge":这样的 switch 分支,前后读几百行,整个握手流程就出来了。 - 手机端的代码不在 asar 里,但手机 Web App 是从
zcode.z.ai/remote/v4现场加载的——直接抓下来读,逻辑和桌面端对称,读起来反而更简单。
二维码是怎么生成的
渲染端非常朴素,就是 npm 包 qrcode(soldair/node-qrcode):
// WebRemoteControlDialog 组件
Qq.toDataURL(u.qrUrl, { margin: 1, width: 320 })
.then(t => setQrImage(t)) // <img src={dataUrl} />
关键在 qrUrl 是主进程拼出来的(还原自 buildWebRemoteControlExternalQrUrl):
function buildQrUrl({ baseUrl, deviceSid, passHash, timestamp,
deviceMid, deviceName, appVersion }) {
let p = new URL(baseUrl); // https://zcode.z.ai/remote/v4
p.searchParams.set("sid", deviceSid); // relay 分配的设备会话 ID
p.searchParams.set("hash", passHash); // sha256(随机密码),认证凭据
p.searchParams.set("t", String(timestamp)); // Date.now()
p.searchParams.set("mid", deviceMid); // 稳定设备 ID
p.searchParams.set("name", deviceName); // 主机名
p.searchParams.set("app_version", appVersion);
return p.toString();
}
其中两个参数最重要:
sid(device_sid):桌面端向 relay 注册后由服务器下发的会话 ID,是手机和桌面配对的"房间号"hash(pass_hash):sha256(randomBytes(24).toString("base64url")),本地随机生成密码再哈希。relay 存一份、桌面留一份、二维码里明文带一份给手机
这个设计意味着:二维码本身就是凭据的载体。扫码 = 通过线下通道把 passHash 交给手机。所以二维码泄露 = 别人能连你电脑,这也解释了为什么 UI 里「刷新二维码」要弹确认框——刷新即作废旧 sid+hash,所有已配对的手机都要重扫。
有效期:客户端没有 TTL
这是我最意外的发现。逐个检查了代码里所有定时器:
| 定时器 | 时长 | 用途 |
|---|---|---|
| 启动看门狗 | 30 秒 | 30 秒内 relay 没进入可配对状态就报错放弃 |
心跳 pair_status_query | 10 秒 | 双端探活 |
| 心跳 ACK 超时 | 30 秒 | 超时强制重连 |
| 手机断开宽限 | 3 秒 | 短暂抖动不算断开 |
| 重连退避 | 1 秒 | 断线后重连基准 |
没有任何一个定时器负责"二维码过期"。URL 里的 t 参数只是生成时刻的时间戳,客户端代码里没有对它做新鲜度校验——如果服务端有校验策略,那也不在本地代码里。二维码生成一次后只要会话活着就有效,重新生成只有两条路:关掉重开远程控制面板,或手动点「刷新二维码」。
「认证一次」的持久化机制
首次配对成功后,桌面端把凭据写盘:
deviceSid→~/.zcode/v2/setting.json的webRemoteControlExternalRelayDevice字段passHash→ 系统凭证库(key 为web-remote-control:external-relay:pass_hash)
之后每次启动走 persisted 模式:直接复用同一个 sid+hash 做 auth_init,不再重新注册。断线重连、重启应用都一样,所以手机扫一次就长期有效。需要重扫只有三种情况:
- 手动「刷新二维码」(旧凭据作废)
- relay 拒绝持久化凭据(
AUTH_FAILED):桌面端自动清一次本地存储、重新注册拿新 sid - 本地凭据文件被删(两处存储缺一就被视为"半状态",全部清除重来)
hash 没有自动轮换——正常重连永远用同一个 hash。
完整认证握手
这是整条链路最精彩的部分。核心设计:手机和桌面共享同一个 device_sid 和 passHash,各自向 relay 证明"我知道这个房间号的密码",relay 据此把两端桥接起来。
两端用完全相同的挑战应答,唯一区别是 role 字符串:
proof = HMAC-SHA256(key=passHash, msg="${nonce}|${role}|${deviceSid}")
role = "device"(桌面)/ "terminal"(手机)
时序图(从代码还原):
桌面 ZCode (role: device) Relay (wss://zcode.z.ai/ws) 手机 (role: terminal)
──────────────────────── ───────────────────── ──────────────────────
① 首次注册(仅一次;已有持久 sid 则跳过)
──WS connect ?mid=deviceMid──▶
device_register_init{device_mid, pass_hash, meta}──▶
◀──device_register_ack{device_sid}──
持久化 {device_sid, pass_hash}
② 桌面认证(每次连接都走)
auth_init{role:"device", device_sid}────────▶
◀────auth_challenge{nonce}───
proof=HMAC(passHash,"nonce|device|sid")
auth_response{proof}───────────────────────▶
◀────pair_status_ack{status:"waiting"}
状态 = waiting_terminal ← 此刻生成二维码,等手机扫码
③ 手机扫码,从 URL 解析 sid/hash
──WS connect(同一个 relay)──▶
auth_init{role:"terminal", device_sid}──▶
◀────auth_challenge{nonce}───────────
proof=HMAC(passHash,"nonce|terminal|sid")
auth_response{proof}──────────▶
④ relay 按 device_sid 配对两端
◀──pair_status_ack{status:"matched"}─▶
状态 = paired (两端进入 paired,flush 缓冲)
═════════════ data 帧双向桥接 ═════════════════════════════════
◀──bootstrap-request{requestId}────
bootstrap-response{workspaces, tasks}──────────────────────────▶
◀──workspace-bridge-open{workspaceKey}
workspace-bridge-ready─────────────────────────────────────────▶
◀═══ rpc-frame / rpc-frame-ack(分片·CRC32·base64)═══▶
几个细节值得展开:
nonce 由 relay 生成,客户端当不透明 token 用。同一轮 challenge 双端用同一个 nonce,靠 role 字符串区分各自的 proof——relay 存了 passHash,能独立重算并验证两个 role 的证明。
手机端不注册。它从二维码 URL 直接拿到 sid 和 hash,跳过 device_register_init 直接 auth_init。浏览器原生 WebSocket 加不了自定义 header,所以手机连接时不带 X-Device-ID(桌面端带这个 header),只有 URL 上可选的 ?mid=。
配对状态机在 wire 上只有三个值:waiting / matched / paired。桌面端 UI 里的 “waiting_terminal” 是客户端本地状态。配对成功的第一个业务消息是手机发 bootstrap-request 拉工作区和任务列表,桌面回 bootstrap-response。
实时任务 I/O 不走 JSON。打开某个工作区后,流量切换到 rpc-frame 隧道:逻辑消息切片(fragmentIndex/fragmentCount)、每片带序列号和 CRC32 校验、base64 编码、有 ACK 重传机制(断线重连后 replayUnacknowledged)。relay 对这些帧只做透传。
安全性评价
这套设计的聪明之处和值得注意的地方:
✅ passHash 不走网络明文传输(唯一例外是二维码 URL 里那一次,走的是线下扫码通道);网络上一律只交换 HMAC proof,抓包拿不到密码本身。
✅ 挑战应答带 nonce,proof 不可重放(每轮 challenge 换 nonce)。
⚠️ 二维码 = 密码本体。截图发给别人、屏幕被投屏、扫码记录被云端保存(有些扫码 App 会把 URL 上传),任何一种都等于交出电脑控制权。用完随手「刷新二维码」是个好习惯。
⚠️ hash 是 sha256(密码) 而 relay 同时持有 hash 并能用它验证 HMAC——这意味着 relay 服务端有能力冒充任一端,这是纯中继架构的固有信任模型,不是实现缺陷,但值得知道。
复现
如果想自己验证,关键位置都在 /Applications/ZCode.app/Contents/Resources/app.asar 里:
out/main/index.js— relay 传输类、认证 provider(calculateProof)、配对状态机out/main/chunk-FQMTTDFW.js— 二维码 URL 构建器、消息 schema(zod)out/renderer/assets/styles-CdEGpc2x.js—WebRemoteControlDialog组件- 手机端代码:浏览器打开
https://zcode.z.ai/remote/v4后从 sources 里翻,calculateProof用的是 WebCrypto 版 HMAC,和桌面端逐字节等价
另外两个环境变量可以改变默认端点(自建 relay 调试用):ZCODE_WEB_REMOTE_CONTROL_URL(覆盖二维码 base URL)和 ZCODE_WEB_REMOTE_CONTROL_RELAY_WS_URL(覆盖 relay WebSocket 地址)。
总结
逆向下来,ZCode 远程控制的认证设计可以一句话概括:桌面端向云端注册设备拿到"房间号"(sid),本地随机生成"房间密码"(passHash),二维码就是把房间号+密码通过线下扫码交给手机;之后两端各自用同一个密码对云端做 HMAC 挑战应答,云端按房间号把两端桥接起来。
实现质量相当高:持久化凭据的半状态清理、AUTH_FAILED 的一次性兜底防死循环、心跳超时与 stale-waiting 恢复、RPC 帧的分片校验重传,这些边界情况都处理得很干净。作为对照学习 WebSocket 中继认证架构的样本,值得细读。