覆盖范围:CVE-2026-88062(GHSA-hf57-cqmx-p4gr / GHSA-jphr-2gw7-xrwp) 三道防线,零个边界

摘要

OmniRoute 的 POST /api/acp/agents 允许注册自定义 ACP agent,并接受请求方提供的 binaryversionCommand。保存后同一个请求会立刻触发版本探测,最终执行 execFileSync(probe.command, probe.args, …)

在修复前,一个请求只要穿过三道防线即可在容器内执行任意代码。而这三道防线,没有一道是边界

防线

它在问什么

恶意请求的答案

路由前缀黑名单

这条路由在名单上吗?

没登记,放行

自洽校验

两个字段对得上吗?

两个字段都是我填的

字符黑名单

含危险字符吗?

-e 不是危险字符

三道防线都在判断"这个请求像不像攻击",没有一道在判断"这个操作该不该被执行"。前者是启发式,后者才是边界。

此外还有一层值得单独记录的事:这份公告的核心卖点——"未认证、一个 HTTP 请求即可远程 RCE"——在它发布前六周就已经不成立了。公告描述的是 OmniRoute 3.7.9 的状态,而对应的门禁修复在 2026-07-21 就合入了。这个细节在第三节和第五节展开。

漏洞链条

端点接受用户可控的命令字段

src/app/api/acp/agents/route.ts 的请求 schema 直接放行三个命令相关字段:

const customAgentBodySchema = z.object({
  action: z.string().optional(),
  id: z.string().optional(),
  name: z.string().optional(),
  binary: z.string().optional(),
  versionCommand: z.string().optional(),
  providerAlias: z.string().optional(),
  spawnArgs: z.array(z.string()).optional(),
  protocol: z.enum(["stdio", "http"]).optional(),
});

处理器只调了一次 isAuthenticated(),随后把 binaryversionCommand 原样写入自定义 agent 定义,没有任何可执行文件白名单

const newAgent: CustomAgentDef = {
  id: id.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
  name,
  binary,
  versionCommand,
  // ...
};

唯一的校验是自洽校验

路由里唯一一处命令校验是:

if (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) {
  return NextResponse.json(
    { error: "Invalid versionCommand: use the configured binary with plain arguments only" },
    { status: 400 }
  );
}

resolveVersionProbe()requireBinaryMatch 模式下做的事,是要求 versionCommand 的第一个 token 等于 binarypath.basename(binary)

if (requireBinaryMatch) {
  const normalizedCommand = normalizeCommandToken(command);
  const allowed = new Set([
    normalizeCommandToken(binary),
    normalizeCommandToken(path.basename(binary)),
  ]);
  if (!allowed.has(normalizedCommand)) {
    return null;
  }
}

问题在于——binary 也是请求方传的。这个校验是在拿攻击者的输入去校验攻击者的输入,恒真。于是:

{
  "binary": "node",
  "versionCommand": "node -e \"...任意 JavaScript...\""
}

顺利通过。

同一个请求立即触发执行

保存之后,路由直接调用 refreshAgentCache()

const updated = [...current, newAgent];
await updateSettings({ customAgents: updated });
setCustomAgents(updated);

const agents = refreshAgentCache();   // ← 同一个请求内触发探测

调用链是 refreshAgentCache()detectInstalledAgents()detectAgent(),最终落到:

const output = execFileSync(probe.command, probe.args, {
  timeout: 5000,
  encoding: "utf-8",
  stdio: ["pipe", "pipe", "pipe"],
  ...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
}).trim();

Linux 容器上 shouldUseShellForVersionProbe() 对非 Windows 平台返回 false,所以实际执行的是干净的 execFileSync("node", ["-e", "..."])——不需要任何 shell 元字符。这也是字符黑名单失效的原因。

当时为什么是未认证的

isAuthenticated() 的第一行就短路了:

export async function isAuthenticated(request: Request): Promise<boolean> {
  if (!(await isAuthRequired(request))) {
    return true;      // ← requireLogin=false 时,匿名请求直接视为已认证
  }
  // ...
}

isAuthRequired()requireLogin=false 时返回 false。集中式管理策略里也有一条相同的匿名放行分支。设计上,能启动本地子进程的路由应当先被 LOCAL_ONLY 门禁拦下——/api/acp/ 当时不在 LOCAL_ONLY_API_PREFIXES 里,于是请求直接走到了匿名放行分支。

这条路径有两种进入方式:

  1. 实例本身处于 requireLogin=false(自托管关闭面板登录的常见配置)。

  2. 全新实例尚未配置管理密码的引导窗口——此时 /api/settings/require-login 允许未认证写入,攻击者可以先把它设成 false,再打这个端点。

第 2 条尤其值得注意:它把"还没配好"这个短暂状态变成了一个可利用的攻击窗口。

三道防线,逐层拆解

OmniRoute 三道防线各自问错了问题 FIG 1三道防线,各自问错了问题OmniRoute CVE-2026-88062 · POST /api/acp/agents → execFileSync 它实际问的它本该问的 这条路由在名单上吗? 两个字段对得上吗? 含危险字符吗? 这个操作该对远程开放吗? 这个二进制可信吗? 这串参数能执行代码吗? 路由前缀黑名单 LOCAL_ONLY_API_PREFIXES 自洽校验 resolveVersionProbe(…, true) 字符黑名单 tokenizeVersionCommand 三道防线都按设计工作——没有一道在判断这个请求该不该被执行。

门禁层:前缀黑名单,以及一个扫不到的间接 spawn

LOCAL_ONLY_API_PREFIXES 的作用是:把"能启动本地子进程"的路由限制在回环/局域网访问。同类的兄弟路由都在名单里——/api/mcp//api/services//api/plugins//api/tools/agent-bridge/ 等等——只有 /api/acp/agents 被漏了。

这是一个典型的前缀黑名单维护问题:新增一条能 spawn 的路由,就必须记得去另一个文件里登记。而维护者在修复 PR #7966 里记录了一个更尖锐的细节:

已有的自动化源码扫描门禁抓不到这个缺口,因为 execFileSync 是经 registry.ts 间接调用的,不在路由文件本身里,按文件内容 grep 扫不出来。

也就是说,防线本身(自动化门禁)和它要防的缺陷(间接 spawn)之间存在结构性盲区:用文件级静态扫描去覆盖调用图级的传播,注定漏掉间接调用。这不是配置疏忽,是检测手段与缺陷形态不匹配。

校验层:自洽校验

见 2.2。这是三层里最值得记住的一层,因为它看起来像校验,实际是格式检查

一个真正的校验会问:"这个二进制是否在我允许的清单里?" 而它问的是:"你填的两个字段互相一致吗?" 后者对攻击者毫无约束力——因为攻击者完全可以填一对自洽的恶意值。

把这条规律抽象出来:当一个校验的两个操作数都来自不可信输入时,它就不再是安全控制,只是一个格式检查。 类似的形态在别处也常见:前端传 userIdtoken,后端校验"这个 token 是不是属于这个 userId";或者客户端传 fileTypefilename,服务端校验"扩展名和类型是否匹配"。形式上都在校验,实质上都可被自洽地伪造。

字符层:元字符黑名单

const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;

它拦住了 ;&|、反引号、$()、重定向和换行——这是针对 shell 注入 的防护。但这里的执行路径根本不经 shell:execFileSync("node", ["-e", "..."]) 直接把参数数组交给 execve

node -e 需要的字符是 (, ), ', ., /, , 和空格——全部在白名单之外的黑名单之外,也就是全部放行。这条防线的失效不是"被绕过",而是它防的攻击类型和执行路径不是同一个

修复:两次提交,两个版本

修复不是一次性完成的,而是分两次、落在两个版本上。这一点很关键,因为它是版本元数据混乱的根源。

3.8.49:先关可达性(PR #7966,2026-07-21)

针对 issue #7948,当天合入 release/v3.8.49,改动只有 59 行新增:

Add "/api/acp/agents" to LOCAL_ONLY_API_PREFIXES in routeGuard.ts,
so loopback enforcement runs unconditionally before any auth check

PR 描述里维护者明确写了当时的定级:

Exploiting this requires valid auth (session/API key) on an instance, or a fully open requireLogin=false instance — not pre-auth RCE.

同时这份 PR 主动把两项加固列为延后:二进制白名单、以及 Windows 上版本探测的 shell: true

3.8.50:再关执行能力(PR #11028,2026-08-21)

一个月后,PR #11028 才处理真正的 sink。维护者的描述很准确:

/api/acp/agents is already LOCAL_ONLY (#7948) so the remote/anonymous vector is closed, but a loopback/LAN caller with requireLogin=false — or any authenticated caller — could still reach the sink.

修复方式是换判据,而不是补黑名单

resolveVersionProbe 修复前后的判据变化 FIG 3修复改的不是黑名单,是判据src/lib/acp/registry.ts · resolveVersionProbe 3.8.49 之前 if (requireBinaryMatch) { const allowed = new Set([normalize(binary), normalize(path.basename(binary))]); if (!allowed.has(normalizeCommandToken(command))) return null; // 两个值都来自请求体 } 3.8.50 之后 if (requireBinaryMatch) { if (args.length > 1 || (args.length === 1 && !SAFE_VERSION_PROBE_ARG.test(args[0]))) return null; // 只允许裸二进制或单个版本 flag } const SAFE_VERSION_PROBE_ARG = /^(-v|-V|--version|-version|version|--ver)$/; 3.8.49 关的是可达性;3.8.50 关的是到达之后的执行能力。

从"参数里不许出现坏东西"改成"参数只能是这一个好东西"——白名单替代黑名单。这一步才真正关掉了 node -e / python -c / ruby -e 这条通道。

代码级验证方法

因为版本元数据不可信(见第五节),验证应当落在代码上:

# 修复①:探测参数白名单是否已加(存在即已含 3.8.50 修复)
grep -rn "SAFE_VERSION_PROBE_ARG" ./node_modules/omniroute 2>/dev/null | head

# 修复②:路由是否已登记为本地专用(存在即已含 3.8.49 修复)
grep -rn "api/acp/agents" ./node_modules/omniroute 2>/dev/null | head

# 若两处都无命中,则两项修复均未落地

公告本身的三个问题

这一节是本文与公告的主要分歧点,逐项列出核实依据。

"未认证"这个结论,在发布前六周就已关闭

修复时间线与公告发布时间线对照,远程向量在公告发布前六周已被关闭 FIG 2远程向量在公告发布前六周就被关闭issue #7948 → PR #7966 (3.8.49) → PR #11028 (3.8.50) → 公告 修复公告 issue #7948 报告 PR #11028 合入 3.8.50 进入公告库 PR #7966 合入 3.8.49 仓库公告发布 7-21 8-21 9-10 7-21 9-03 1 2 3 4 5 远程匿名向量 7-21 已关闭;公告 9-03 发布时该路径已失效六周

时间线(均为核实过的原始记录):

日期

事件

2026-07-21

@c111mb3r 提交 issue #7948,针对 OmniRoute 3.7.9

2026-07-21

PR #7966 合入 release/v3.8.49/api/acp/agents 加入 LOCAL_ONLY_API_PREFIXES

2026-08-21

PR #11028 合入 release/v3.8.50resolveVersionProbe 改为版本 flag 白名单

2026-09-03

仓库安全公告 GHSA-hf57-cqmx-p4gr 发布,正文仍称 /api/acp/ 不在 LOCAL_ONLY_API_PREFIXES

2026-09-10

收录进 GitHub 安全公告库

公告正文写道:

/api/acp/ is not included in LOCAL_ONLY_API_PREFIXES … a remote anonymous attacker can execute commands inside the OmniRoute container with a single HTTP request.

这句话在 3.7.9 上成立。但我在 v3.8.50 的 routeGuard.ts 里读到 "/api/acp/agents" 明确在数组中,注释还标注了 #7948。公告发布于 09-03,距门禁合入(07-21)已六周。

结论:公告描述的是一个已经不存在于当前版本的状态。在 ≥ 3.8.49 上,"公网匿名一个请求打进来"不可复现;剩下的是回环/局域网 + requireLogin=false,或已认证调用者——风险量级与公告标题给人的印象差别很大。

版本元数据三方矛盾

CVE-2026-88062 版本元数据在三个来源之间互相矛盾 FIG 4三个来源,三个答案CVE-2026-88062 版本元数据对照 来源 声称受影响 声称已修复 核实结论 仓库公告页 < 3.8.49 3.8.49 不完整 GitHub 公告库 ≤ 3.8.50 错误 源码核实(本文) < 3.8.50 3.8.50 正确 不完整:3.8.49 只关闭了远程可达向量,未关闭 execFileSync 的代码执行 sink。错误:3.8.50 源码中已含 SAFE_VERSION_PROBE_ARG,且为 npm 当前 latest 版本。

来源

声称受影响

声称已修复

核实结论

仓库公告页

< 3.8.49

3.8.49

不完整——3.8.49 只关了可达性,没关 sink

GitHub 公告库 API

<= 3.8.50

null(无)

错误——3.8.50 源码已含修复

源码核实(本文)

< 3.8.50

3.8.50

核实依据:

  • 3.8.50 已修复resolveVersionProbe 中存在 SAFE_VERSION_PROBE_ARG,且代码注释直接引用了 GHSA-jphr-2gw7-xrwp / GHSA-hf57-cqmx-p4gr

  • 3.8.50 是当前最新版:npm registry 的 latest 指向 3.8.50。

  • 3.8.49 不完整:该版本只加了路由门禁,-e 参数仍可通过。

因此公告库的 first_patched_version: null 是错的;仓库页的"3.8.49 已修复"也是错的。完整修复是 3.8.50。

一个漏洞,两个编号

同一个缺陷同时挂着两个公告编号:GHSA-hf57-cqmx-p4gr(本文主线)与 GHSA-jphr-2gw7-xrwp,均由 @c111mb3r 报告。修复提交的注释里也是两个编号并列引用:

Restricting the args to a recognized version flag closes that path —
see GHSA-jphr-2gw7-xrwp / GHSA-hf57-cqmx-p4gr.

做资产与补丁跟踪时注意去重,避免同一问题被计两次或漏掉一次。

关于 requireLogin=false

这是整条链真正的放大器。它的语义是"关闭面板登录",但实现上会一并关掉 spawn 类 API 的鉴权。因此:

  • 不要把 requireLogin=false 的实例放在任何网络可达的接口上,包括局域网。

  • 新实例部署时尽快完成管理密码初始化,缩短 2.4 节所述的引导窗口。

  • 如果确实需要免登录面板,用反向代理在入口层把 /api/acp//api/mcp//api/services//api/plugins/ 等 spawn 类前缀直接拒绝掉——不要依赖应用内的开关语义。