Skip to content
OmniRoute ACP Custom-Agent Remote Code Execution (RCE) · CVE-2026-88062

OmniRoute ACP Custom-Agent Remote Code Execution (RCE) · CVE-2026-88062

Github September 10, 2026

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:

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:

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.

The unauthenticated exploit is reachable in either of the following scenarios:

The target instance has requireLogin=false . This is the primary scenario covered by this report and by the reproduction steps below.

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 :

The POST handler at src/app/api/acp/agents/route.ts:58-61 only calls isAuthenticated() :

The handler then stores binary and versionCommand in the custom agent definition without an executable allowlist:

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 :

The core logic of resolveVersionProbe() is in src/lib/acp/registry.ts:261-288 :

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 ):

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 :

refreshAgentCache() is defined at src/lib/acp/registry.ts:366-369 :

detectInstalledAgents() merges built-in and custom agents and calls detectAgent() for each one ( src/lib/acp/registry.ts:342-360 ):

The command execution sink is at src/lib/acp/registry.ts:307-325 :

On Linux containers, shouldUseShellForVersionProbe() returns false for non-Windows platforms ( src/lib/acp/registry.ts:290-301 ):

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 :

isAuthRequired() returns false when requireLogin=false ( src/shared/utils/apiAuth.ts:317-323 ):

The centralized management policy also has the same anonymous allow branch at src/server/authz/policies/management.ts:223-226 :

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/ :

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

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

6.2 Put the Instance in the Login-Disabled State

This step models a self-hosted instance where dashboard login has been disabled:

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:

6.4 Verify Command Execution

Expected output is similar to:

This proves that the anonymous HTTP request executed id and uname -a inside the OmniRoute container.