Topic: mcp server environment variable injection security

MCP server environment variable injection security — subprocess env poisoning

MCP tools that spawn child processes inherit or explicitly construct the environment passed to those subprocesses. When a tool allows user-controlled input to influence the environment — either by accepting environment variable names as parameters, by merging caller-supplied key-value pairs into the subprocess env, or by forwarding parent process env without filtering — an attacker can inject loader variables like LD_PRELOAD, interpreter variables like PYTHONPATH, or path variables like PATH that redirect the subprocess to attacker-controlled code.

LD_PRELOAD injection via tool arguments

A build or test runner MCP tool accepts optional environment variables to pass to the build subprocess — a common feature for allowing users to set build-time flags:

// VULNERABLE — merges user-supplied env vars directly into subprocess env
const runBuild = server.tool('run_build', {
  env_vars: z.record(z.string(), z.string())
    .optional()
    .describe('Additional environment variables for the build process'),
}, async ({ env_vars = {} }) => {
  const result = await execa('npm', ['run', 'build'], {
    env: { ...process.env, ...env_vars },  // ← user controls env var names
  });
  return { content: [{ type: 'text', text: result.stdout }] };
});

An injected instruction passes env_vars: { "LD_PRELOAD": "/tmp/injected.so" }. On Linux, LD_PRELOAD causes the dynamic linker to load the specified shared library into every process before any other library, including libc. If an attacker can write to /tmp (often possible if the MCP server has write-tool access to the temp directory), they can install a shared library there first and then trigger the build with the poisoned env. The shared library's constructor function runs in the context of the build process with all of the build process's permissions.

Even without LD_PRELOAD write access, other env variables enable significant attacks: PATH=/tmp:$PATH causes the subprocess to find a crafted executable before the system binary; NODE_OPTIONS=--require /tmp/malicious.js loads an arbitrary script into the Node.js process; PYTHONPATH=/tmp prepends an attacker's directory to Python's module search path.

Env variable name injection: overwriting non-dangerous-looking vars

Beyond the obvious loader variables, tools that merge user-controlled key-value pairs into the subprocess environment are vulnerable to injection via any variable that the subprocess consults:

None of these look dangerous by name, but each creates a meaningful attack surface against tools that perform git operations, package management, or cloud API calls.

Credential leakage via env logging

A second class of env variable vulnerability is disclosure, not injection. Tools that log the full subprocess environment for debugging — console.log('subprocess env:', env) — leak the entire parent process environment including any secrets set as environment variables. In CI/CD environments where secrets are injected via env vars, this means GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY, DATABASE_URL, and other credentials appear in logs accessible to anyone with log viewer access.

The correct patterns

Use an explicit allowlist for user-controlled env var names. Define the exact set of variable names that the tool accepts from callers and reject any key not in that set:

const ALLOWED_BUILD_ENV_VARS = new Set([
  'NODE_ENV', 'BUILD_TARGET', 'VITE_APP_VERSION', 'NEXT_PUBLIC_API_URL'
]);

const BLOCKED_PATTERNS = [
  /^LD_/,           // loader injection
  /^DYLD_/,         // macOS loader injection
  /^NODE_OPTIONS/,  // Node.js flag injection
  /^PYTHON/,        // Python interpreter injection
  /^GIT_/,          // Git behavior modification
  /^NPM_CONFIG/,    // npm configuration injection
  /^PATH$/,         // PATH replacement
  /^HOME$/,         // home directory redirection
  /^SSL_/,          // TLS configuration
  /^AWS_/,          // cloud credential redirection
];

const runBuild = server.tool('run_build', {
  env_vars: z.record(z.string(), z.string()).optional(),
}, async ({ env_vars = {} }) => {
  const safeEnv = {};
  for (const [key, value] of Object.entries(env_vars)) {
    if (!ALLOWED_BUILD_ENV_VARS.has(key)) {
      throw new Error(`Env var not allowed: ${key}`);
    }
    if (BLOCKED_PATTERNS.some(p => p.test(key))) {
      throw new Error(`Env var blocked by security policy: ${key}`);
    }
    safeEnv[key] = value;
  }

  // Build a clean env — don't inherit parent process.env blindly
  const subprocess_env = {
    // Minimal set from parent — only what the subprocess actually needs
    PATH: '/usr/local/bin:/usr/bin:/bin',  // fixed, not inherited
    HOME: SANDBOXED_HOME,
    ...safeEnv,  // only validated user-supplied vars
  };

  return await execa('npm', ['run', 'build'], {
    env: subprocess_env,
    shell: false,
  });
});

Build a clean environment rather than inheriting. The pattern { ...process.env, ...user_vars } exposes all parent process secrets to potential leakage in logs and to potential override by any key the user provides. Instead, construct the subprocess environment explicitly from a minimal base — the binaries the subprocess needs, the non-sensitive configuration it requires — and add only validated user-supplied vars on top.

Redact env logs. If you log subprocess environments for debugging, filter out any key whose name matches a known-sensitive pattern (the BLOCKED_PATTERNS set above, plus any project-specific secret variable names) before logging.

SkillAudit's static analysis flags tools that spread user-controlled objects into subprocess env options and tools that log process.env or subprocess env objects at INFO level or higher.

Audit your subprocess-spawning MCP tools for env variable injection.

Run a free audit →