逆向 ZCode 远程控制:扫码配对的二维码是怎么生成的,认证过程长什么样

ZCode 桌面版有个「远程控制」功能:手机扫一下屏幕上的二维码,就能在手机浏览器里操控电脑上正在跑的 AI 任务。用的时候我忽然好奇三个问题:

  1. 这个二维码里编码的是什么?有效期多久?
  2. “扫一次之后手机就不用再扫了”,凭据存在哪、怎么复用的?
  3. 手机和桌面隔着公网,认证握手具体是怎么做的?

于是花时间把 ZCode(Electron 应用,版本 3.7.6.4691)的代码逆向读了一遍,把整条链路挖了出来。这篇文章记录完整过程和结论——如果你也想逆向一个 Electron 应用,方法论部分应该也有参考价值。

先说结论(TL;DR)

逆向方法论

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)

代码是压缩混淆过的(变量名全是 TeZWHp 这种),直接读不现实。几个实用的抓手:

  1. 从 i18n 字符串切入。UI 上的文案「生成二维码失败」「刷新二维码」在 IntlProvider-*.js 里都有 key(webRemoteControl.qrAltwebRemoteControl.refreshQr…),grep 这些 key 就能定位到对应的 React 组件,再从组件的 IPC 调用追到主进程。
  2. grep 网络协议关键字auth_initdevice_registerpair_statusnonce 这类字符串不会被打包器改名,是定位协议处理逻辑的锚点。
  3. 顺着消息类型找状态机。找到 case"auth_challenge": 这样的 switch 分支,前后读几百行,整个握手流程就出来了。
  4. 手机端的代码不在 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();
}

其中两个参数最重要:

这个设计意味着:二维码本身就是凭据的载体。扫码 = 通过线下通道把 passHash 交给手机。所以二维码泄露 = 别人能连你电脑,这也解释了为什么 UI 里「刷新二维码」要弹确认框——刷新即作废旧 sid+hash,所有已配对的手机都要重扫。

有效期:客户端没有 TTL

这是我最意外的发现。逐个检查了代码里所有定时器:

定时器时长用途
启动看门狗30 秒30 秒内 relay 没进入可配对状态就报错放弃
心跳 pair_status_query10 秒双端探活
心跳 ACK 超时30 秒超时强制重连
手机断开宽限3 秒短暂抖动不算断开
重连退避1 秒断线后重连基准

没有任何一个定时器负责"二维码过期"。URL 里的 t 参数只是生成时刻的时间戳,客户端代码里没有对它做新鲜度校验——如果服务端有校验策略,那也不在本地代码里。二维码生成一次后只要会话活着就有效,重新生成只有两条路:关掉重开远程控制面板,或手动点「刷新二维码」。

「认证一次」的持久化机制

首次配对成功后,桌面端把凭据写盘:

之后每次启动走 persisted 模式:直接复用同一个 sid+hash 做 auth_init,不再重新注册。断线重连、重启应用都一样,所以手机扫一次就长期有效。需要重扫只有三种情况:

  1. 手动「刷新二维码」(旧凭据作废)
  2. relay 拒绝持久化凭据(AUTH_FAILED):桌面端自动清一次本地存储、重新注册拿新 sid
  3. 本地凭据文件被删(两处存储缺一就被视为"半状态",全部清除重来)

hash 没有自动轮换——正常重连永远用同一个 hash。

完整认证握手

这是整条链路最精彩的部分。核心设计:手机和桌面共享同一个 device_sidpassHash,各自向 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 里:

另外两个环境变量可以改变默认端点(自建 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 中继认证架构的样本,值得细读。

← 返回首页