OmniRoute CVE-2026-88062 一次自洽校验、两份矛盾的版本元数据
覆盖范围:CVE-2026-88062(GHSA-hf57-cqmx-p4gr / GHSA-jphr-2gw7-xrwp) 三道防线,零个边界
摘要
OmniRoute 的 POST /api/acp/agents 允许注册自定义 ACP agent,并接受请求方提供的 binary 与 versionCommand。保存后同一个请求会立刻触发版本探测,最终执行 execFileSync(probe.command, probe.args, …)。
在修复前,一个请求只要穿过三道防线即可在容器内执行任意代码。而这三道防线,没有一道是边界:
三道防线都在判断"这个请求像不像攻击",没有一道在判断"这个操作该不该被执行"。前者是启发式,后者才是边界。
此外还有一层值得单独记录的事:这份公告的核心卖点——"未认证、一个 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(),随后把 binary 和 versionCommand 原样写入自定义 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 等于 binary 或 path.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 里,于是请求直接走到了匿名放行分支。
这条路径有两种进入方式:
实例本身处于
requireLogin=false(自托管关闭面板登录的常见配置)。全新实例尚未配置管理密码的引导窗口——此时
/api/settings/require-login允许未认证写入,攻击者可以先把它设成false,再打这个端点。
第 2 条尤其值得注意:它把"还没配好"这个短暂状态变成了一个可利用的攻击窗口。
三道防线,逐层拆解
门禁层:前缀黑名单,以及一个扫不到的间接 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。这是三层里最值得记住的一层,因为它看起来像校验,实际是格式检查。
一个真正的校验会问:"这个二进制是否在我允许的清单里?" 而它问的是:"你填的两个字段互相一致吗?" 后者对攻击者毫无约束力——因为攻击者完全可以填一对自洽的恶意值。
把这条规律抽象出来:当一个校验的两个操作数都来自不可信输入时,它就不再是安全控制,只是一个格式检查。 类似的形态在别处也常见:前端传 userId 和 token,后端校验"这个 token 是不是属于这个 userId";或者客户端传 fileType 和 filename,服务端校验"扩展名和类型是否匹配"。形式上都在校验,实质上都可被自洽地伪造。
字符层:元字符黑名单
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 checkPR 描述里维护者明确写了当时的定级:
Exploiting this requires valid auth (session/API key) on an instance, or a fully open
requireLogin=falseinstance — not pre-auth RCE.
同时这份 PR 主动把两项加固列为延后:二进制白名单、以及 Windows 上版本探测的 shell: true。
3.8.50:再关执行能力(PR #11028,2026-08-21)
一个月后,PR #11028 才处理真正的 sink。维护者的描述很准确:
/api/acp/agentsis already LOCAL_ONLY (#7948) so the remote/anonymous vector is closed, but a loopback/LAN caller withrequireLogin=false— or any authenticated caller — could still reach the sink.
修复方式是换判据,而不是补黑名单:
从"参数里不许出现坏东西"改成"参数只能是这一个好东西"——白名单替代黑名单。这一步才真正关掉了 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
# 若两处都无命中,则两项修复均未落地公告本身的三个问题
这一节是本文与公告的主要分歧点,逐项列出核实依据。
"未认证"这个结论,在发布前六周就已关闭
时间线(均为核实过的原始记录):
公告正文写道:
/api/acp/is not included inLOCAL_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,或已认证调用者——风险量级与公告标题给人的印象差别很大。
版本元数据三方矛盾
核实依据:
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 类前缀直接拒绝掉——不要依赖应用内的开关语义。