Qoder CLI 逆向分析与 BYOK 解锁

Qoder 的命令行客户端 qodercli 通过 Homebrew 分发,二进制体积约 95MB。对它进行逆向分析后发现,它的 BYOK(Bring Your Own Key)功能存在两层人为限制——组织邮箱白名单校验和在线模型 HTTP 校验。由于 Bun 编译产物中嵌入了完整的 JS 源码,可以通过字符串级的等长替换来绕过这些限制。

确认目标:Bun 编译的二进制

并非所有叫 qodercli 的二进制都是 Bun 编译的。Qoder.app 内嵌的版本(约 38MB,位于 Qoder.app/Contents/Resources/app/resources/bin/aarch64_darwin/qodercli)实际上是 Go 编译的原生程序——file 显示为标准 Mach-O arm64,strings 可以看到大量 Go 运行时特征。

而通过 Homebrew 安装的版本(/opt/homebrew/bin/qodercli,约 95MB)才是 Bun 编译的。它的 Mach-O flags 中有一个关键标志 HAS_TLV_DESCRIPTORS,这是 Bun 二进制的特征。

反编译:使用 bun-demincer

bun-demincer 是一个开源工具,用于反编译 Bun 编译的独立二进制文件。完整流水线包含提取、拆分、匹配第三方库、去混淆四个自动化步骤。

提取嵌入文件

1
node src/extract.mjs /opt/homebrew/bin/qodercli extracted/

这一步解析 Bun 的二进制格式,从 __BUN Mach-O section 中提取所有嵌入文件。结果包含 39 个文件:

文件 大小 说明
index.js 14.2 MB JS 业务代码的打包 bundle(ESM)
libvips-cpp.8.17.3.dylib 15.3 MB sharp 依赖的图像处理动态库
rg 4.3 MB 内嵌的 ripgrep 二进制
sharp-darwin-arm64.node 256 KB sharp native addon
pty.node 83 KB node-pty native addon
6 个 .sb 文件 ~19 KB macOS sandbox 配置
4 个 SKILL.md ~44 KB 内置 AI 技能定义
chat.proto 8 KB Protobuf 协议定义

所有模块的 bytecode size 均为 0,sourcemap size 也为 0——说明编译时既没有启用字节码选项,也没有将 sourcemap 打包进来。

拆分与去混淆

1
2
3
node src/resplit.mjs extracted/index.js resplit/
cp -r resplit/ decoded/
node src/deobfuscate.mjs --dir decoded/

14.2MB 的 index.js 被拆分为 3712 个独立模块(1750 CJS + 1962 ESM)。经指纹匹配,其中 746 个为第三方库代码(ajv、zod、ink、@anthropic-ai/sdk 等),剩余约 2600 个是应用自身逻辑。

去混淆流水线执行结构变换(!0truevoid 0undefined)、自动重命名、代码格式化等步骤,最终得到可读的 JS 源码。

BYOK 限制机制

BYOK 功能允许用户配置自定义 provider(如 DashScope、自定义 OpenAI 端点),但 qodercli 对其施加了两层限制:

  1. 组织邮箱检查:要求 allow_byok >= 2 或邮箱后缀为 @alibaba-inc.com,否则自定义模型在 TUI 中不可见
  2. 在线模型校验:对自定义模型调用 /algo/api/v2/byok/check 接口进行 HTTP 校验,失败则模型不可用

补丁设计

Bun 编译产物中的 JS 源码以纯文本形式嵌入,因此只需保证替换前后字节数严格相等,即可在不破坏 Mach-O 结构的前提下修改程序逻辑。

P1:绕过邮箱白名单

原代码中存在条件判断:

1
this.authManager.getUserInfo()?.email?.endsWith("@alibaba-inc.com")

补丁将域名装入注释块:

1
2
搜索:  endsWith("@alibaba-inc.com")      (28 bytes)
替换: endsWith(""/* */) (28 bytes)

替换后 "".endsWith("") 始终返回 true,BYOK 界面入口解锁。

P2:阻断在线校验请求

校验函数中,实际 HTTP 请求通过 e_() 发起,其 options 对象包含 path 属性:

1
2
3
4
5
e_({
operation: "checkBYOKModel",
path: "/algo/api/v2/byok/check",
...
})

path 的值替换为一个立即抛出的 IIFE:

1
2
搜索:  path:"/algo/api/v2/byok/check"     (30 bytes)
替换: path:(()=>{throw "......."})() (30 bytes)

对象构造阶段即抛出异常——HTTP 请求从未发起,因为 throw 发生在 e_() 被调用之前。异常被函数已有的 try-catch 捕获。

/algo/api/v2/byok/check 在二进制中出现两次,加上 path: 前缀后仅匹配 options 对象内的那一处,不会误伤 prepareRequest 的参数。

P3:伪造校验结果

P2 让异常被 catch 捕获,但 catch 块返回 !1(Bun minify 的 false),TUI 仍认为校验失败。需要将其改为 !0true):

1
2
3
锚点:  [byok] checkBYOKModel error input: provider=   (唯一出现)
搜索: 锚点后 300 字节内的第一个 !1 (2 bytes)
替换: !0 (2 bytes)

!1 在整个二进制中出现成千上万次,直接全局替换会导致灾难性后果。通过唯一的日志字符串锚点精确定位到 catch 块的返回值。

整体执行流程

1
2
3
4
5
6
7
8
9
10
11
用户输入 BYOK 模型 →
TUI 调用 checkBYOKModel() →
P1: endsWith("") → true,BYOK 界面解锁 ✓

进入校验函数 →
prepareRequest() 正常执行(无副作用)
P2: path IIFE 抛出异常 → HTTP 请求被跳过 ✓

catch 块捕获异常 →
P3: 返回 !0 (true) → TUI 认为校验通过 ✓
BYOK 自定义模型可用 ✓

签名与验证

二进制修改后需要重新签名,否则 macOS 拒绝运行:

1
2
xattr -d com.apple.quarantine qodercli   # 移除隔离标记
codesign --force --sign - qodercli # ad-hoc 重签名

补丁脚本支持幂等执行——检测到已替换的特征时自动跳过,备份仅在首次修改前创建。

意外发现:请求代理与隐私问题

最初我只应用了 P1,绕过邮箱白名单后就能在 TUI 中配置自定义模型了。填入其他云服务商的 URL 和 API Key 时,一切正常。

但当我尝试填写本地 CLIProxyAPI 的地址(http://127.0.0.1:8317)时,始终返回校验失败——而我确认服务已正常启动。于是添加了 P2 和 P3,尝试绕过在线校验环节。模型确实可以选择了,但发送消息时仍然报错:

报错信息中有几个关键细节:

  • Failed to forward request——“转发请求失败”,说明请求并非由客户端直接发往我配置的端点,而是经过了一个中间层转发
  • dial tcp 127.0.0.1:8317: connect: connection refused——连接 localhost 失败。但这个连接失败显然不是发生在我本地(服务确实在跑),而是发生在 Qoder 的服务端——它拿着我配置的 127.0.0.1:8317 在服务器上尝试连接,自然连不上

结合这些现象,结论很明确:Qoder 的 BYOK 并非客户端直连模式,而是将用户配置的 API Key 和端点地址上传至 Qoder 服务端,由服务端代理发起实际的推理请求。这意味着用户的 API Key 会完整地经过 Qoder 的服务器——无论你配置的是 OpenAI、Anthropic 还是其他任何服务商的密钥。

更进一步,既然所有请求都经由 Qoder 服务端代理,那么用户与模型之间的完整对话内容也对 Qoder 完全透明。考虑到 Qoder 背后是阿里巴巴,而阿里同时在训练自家的 Qwen 系列模型,有充分理由怀疑这些对话数据会被用于模型训练。

这是一个严重的隐私问题。建议不要再使用 Qoder