Back Github OmniRoute ACP Custom-Agent Remote Code Execution (RCE) · CVE-2026-88062
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.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
