Research
Researchvulnerability7 min read

nekro-agent shares one RPC secret with every sandbox: a disputed CWE-200 finding

nekro-agent injects a single global RPC_SECRET_KEY into every LLM sandbox container, letting any prompt-injected code impersonate other containers or forge cross-channel RPC calls. The maintainer closed the fix PR without merge. Here is why the vulnerability is real regardless.

nekro-agent is an AI agent framework that runs LLM-generated Python code inside Docker sandbox containers. Each sandbox communicates with the host application through an RPC interface, authenticated by a token passed in the X-RPC-Token header. The problem: every sandbox container receives the same token, and that token is the application's global RPC_SECRET_KEY.

I identified this as CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), wrote a fix that replaced the shared secret with per-container ephemeral tokens, and submitted PR #230. The maintainer closed it without merging on 6 April 2026. No technical rebuttal was provided in the PR comments; the only review activity came from the Sourcery automated review bot, whose suggestions I addressed.

This post presents the vulnerability, the fix that was rejected and an honest assessment of the threat model.

How the secret reaches the sandbox

The data flow is straightforward. When nekro-agent spins up a sandbox container, ext_caller.py builds a Python file from a template (ext_caller_code.py) and performs string replacement to inject runtime values:

.replace("{RPC_SECRET_KEY}", OsEnv.RPC_SECRET_KEY)

The resulting api_caller.py is written to a shared directory, mounted into the container and copied to /app/api_caller.py. The template stores the secret as a module-level global:

RPC_SECRET_KEY = "{RPC_SECRET_KEY}"

Any code running inside the sandbox can read this value. That includes the LLM-generated code the sandbox exists to execute:

import api_caller
secret = api_caller.RPC_SECRET_KEY  # the global master secret

The OsEnv.RPC_SECRET_KEY is generated once at process startup using secrets.token_urlsafe(32). It does not rotate. Every container launched during the lifetime of the process receives the same value.

What the secret grants

The host-side RPC endpoint at /ext/rpc_exec uses a single authentication check:

async def verify_rpc_token(x_rpc_token: str = Header(...)):
    if not OsEnv.RPC_SECRET_KEY or x_rpc_token != OsEnv.RPC_SECRET_KEY:
        raise UnauthorizedError
    return True

There is no per-container scoping. There is no user session validation. The container_key and from_chat_key are accepted as query parameters from the caller and trusted at face value. A sandbox that knows the global secret can call the RPC endpoint with any container_key and any from_chat_key, impersonating other containers and operating in other users' chat contexts.

The sandbox containers run on a Docker bridge network with ExtraHosts: ["host.docker.internal:host-gateway"], giving them direct HTTP access to the host API. The API binds on the configured EXPOSE_PORT (default 8021) with no localhost-only restriction on the RPC route.

There is also a secondary concern. The RPC endpoint deserialises request bodies using pickle.loads():

def decode_rpc_request(raw_body: bytes) -> RPCRequest:
    try:
        payload = pickle.loads(raw_body)
    except (pickle.UnpicklingError, EOFError, AttributeError, ValueError) as e:
        raise ValidationError(reason="RPC 请求格式错误") from e

An attacker who can reach this endpoint with a valid token can send a crafted pickle payload. This is a separate finding (closer to CWE-502) and was noted in the PR but not the primary focus of the fix.

Existing mitigations and why they do not help

nekro-agent does apply sandbox hardening. The containers run as User: nobody, have a 512 MB memory limit and a single CPU core, and no Docker socket is mounted. These are sensible defaults for containing resource abuse. None of them prevent the specific attack:

  • User: nobody does not prevent reading Python module globals. The api_caller.py file must be readable by the sandbox process to function.
  • Memory and CPU limits are irrelevant to secret exfiltration. Reading a string and sending an HTTP request consumes negligible resources.
  • No Docker socket prevents container escapes but does not restrict HTTP access to the host API over the bridge network.
  • No no-new-privileges enforcement is noted in the container configuration (the line is commented out in runner.py).

The sandbox has the network access it needs to call the RPC endpoint (that is its designed function) and the secret it needs to authenticate (also designed). The vulnerability is not that the sandbox can call the host. It is that the sandbox holds a credential that authenticates it as every other sandbox simultaneously.

The fix that was rejected

PR #230 introduced a single new file, rpc_token_registry.py, containing a thread-safe singleton registry. The changes across three files totalled 68 insertions and 8 deletions:

  1. rpc_token_registry.py (new): a RPCTokenRegistry class that generates per-container tokens using secrets.token_urlsafe(48) (384 bits of entropy), validates them with secrets.compare_digest (timing-safe) and supports revocation. After addressing the Sourcery review, TTL-based expiry was added with a default of 600 seconds.

  2. ext_caller.py (modified): replaced OsEnv.RPC_SECRET_KEY with rpc_token_registry.create_token(container_key). Each sandbox now receives a unique ephemeral token scoped to its identity.

  3. rpc.py (modified): verify_rpc_token() now validates the incoming X-RPC-Token against the per-container token stored in the registry, keyed by the container_key query parameter.

The result: a compromised sandbox can only authenticate as itself. It cannot impersonate other containers or operate in other chat channels. The global RPC_SECRET_KEY never leaves the host process.

Assessing the maintainer's position

The maintainer, KroMiose, closed the PR without providing a written technical objection. In the absence of stated reasoning, I can construct the most charitable interpretation of why this might be considered acceptable:

"The sandbox is already isolated." This is partially true. The Docker container provides process and filesystem isolation. But the isolation model breaks down at the network layer. The sandbox is explicitly granted network access to the host API, and the shared secret collapses all container identities into one.

"Prompt injection is the real problem." Also partially true. If the LLM never generates malicious code, the shared secret causes no harm. But nekro-agent's entire architecture exists because LLM-generated code is unpredictable. The sandbox is the mitigation for that unpredictability, and the shared secret weakens that mitigation.

"Single-instance deployment limits the blast radius." Fair observation. nekro-agent runs as a single process, so the attack surface is limited to containers running concurrently on the same instance. But "limited blast radius" is not "no blast radius". A multi-tenant deployment (multiple chat channels, multiple users interacting with the bot simultaneously) still has cross-channel impersonation risk.

"The change is too invasive." Three files, 68 lines, no new dependencies, no breaking changes to the sandbox code template (it still receives a token through the same placeholder). This is about as minimal as a scoped-token migration gets.

I do not find the position defensible. The fix addressed a concrete weakness with a minimal, backwards-compatible change. But maintainers have the right to manage their own repositories, and closing a PR without merge is not the same as claiming the vulnerability does not exist.

The broader pattern

This is a recurring pattern in AI/LLM agent tooling: projects that build sophisticated execution sandboxes but treat the control plane between sandbox and host as trusted. The sandbox boundary is carefully constructed, then undermined by a flat authentication model on the very interface the sandbox is designed to use.

The assumption seems to be that because the sandbox exists to run LLM-generated code and because the LLM is "mostly" well-behaved, the control channel does not need the same rigour. This is the same logic that led to MCPHub shipping with hardcoded admin credentials and gptme passing API keys as CLI arguments. The execution environment gets hardened; the plumbing that connects it to everything else does not.

nekro-agent's sandbox can read the master key, reach the host API and impersonate any other sandbox on the system. The fix was 68 lines of Python. It remains available in the PR for anyone running nekro-agent who would like to apply it themselves.

Newsletter

One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.