Topic: mcp server node-serialize RCE

MCP server node-serialize RCE — eval gadgets, serialize-javascript, safe deserialization alternatives

Unsafe deserialization is OWASP A08:2021. In Node.js MCP servers, the most dangerous manifestation is node-serialize (npm) and serialize-javascript: both libraries can reconstruct JavaScript functions from serialized strings using eval(). Any user-controlled input that reaches unserialize() is remote code execution — the attacker embeds a function body in the serialized payload, and the library executes it on deserialization. This vulnerability class is well-documented since 2017 and still appears in MCP servers that use these libraries for session persistence, inter-process state sharing, or caching of callable objects.

The node-serialize eval gadget — how it works

node-serialize stores function objects as strings using a custom marker: _$$ND_FUNC$$_function(){...}(). When unserialize() encounters a value with this prefix, it calls eval() on the function body. The IIFE suffix () triggers immediate execution on deserialization — no further attacker action required.

The attack requires only that a user-controlled string reaches unserialize(). In MCP servers, this appears when: a session state parameter is deserialized from the tool call, a cached computation result is deserialized from a shared store, or an inter-process message is deserialized in a worker.

const serialize = require('node-serialize');

// ATTACK PAYLOAD — what an attacker sends as toolParams.sessionState:
// {"state":"_$$ND_FUNC$$_function(){require('child_process').exec('id > /tmp/pwned')}()"}

// WRONG: unserialize on user-supplied data → executes the function body
async function restoreSession_WRONG(toolParams) {
  const { sessionState } = toolParams;
  // The _$$ND_FUNC$$_ payload executes require('child_process').exec(...)
  // at this line — on the MCP server, as the server's OS user
  const state = serialize.unserialize(sessionState); // RCE
  return state;
}

// RIGHT: use JSON.parse — cannot represent or execute functions
async function restoreSession(toolParams) {
  const { sessionState } = toolParams;

  if (typeof sessionState !== 'string' || sessionState.length > 65536) {
    return { error: 'Invalid sessionState' };
  }

  let parsed;
  try {
    parsed = JSON.parse(sessionState);
  } catch {
    return { error: 'Invalid JSON in sessionState' };
  }

  // Validate structure: only accept known keys with primitive values
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
    return { error: 'sessionState must be a JSON object' };
  }

  const allowed = new Set(['userId', 'projectId', 'step', 'preferences']);
  const safe = {};
  for (const [k, v] of Object.entries(parsed)) {
    if (allowed.has(k) &&
        (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
      safe[k] = v;
    }
  }
  return safe;
}

// RIGHT for in-process deep copies: structuredClone (Node 17+)
// structuredClone rejects functions — throws DataCloneError
const deepCopy = structuredClone(sessionData); // safe, no eval

serialize-javascript — same class, different package

serialize-javascript is used to serialize objects (including functions) to strings for safe HTML embedding. Its companion deserialize() function also uses eval(). The attack payload format differs but the vulnerability class is identical.

// serialize-javascript's deserialize() also calls eval()
// WRONG: deserialize on user-supplied data
const { serialize, deserialize } = require('serialize-javascript');
const config = deserialize(toolParams.configString); // RCE if config contains function

// RIGHT: never deserialize user-supplied strings with eval-based libraries
// If you need to accept a configuration object, accept it as JSON
// and validate the structure before use:
const config = JSON.parse(toolParams.configJson);
// Then validate each field against an expected schema

Joi and ajv: schema from user input is code execution

Joi schemas can include .custom(fn) validators — arbitrary functions that execute during validation. An attacker-controlled Joi schema object can include a custom validator that executes arbitrary code when schema.validate(data) is called. JSON Schema (ajv) can reference custom keywords registered at compile time, and some ajv plugins register keywords that execute code. The rule is the same: schema definitions are code, not data — never compile them from user input.

const Joi = require('joi');
const Ajv = require('ajv');

// WRONG: compiling a Joi schema from user-supplied data
async function validateInput_WRONG(toolParams) {
  const { schemaDefinition, data } = toolParams;
  // schemaDefinition could include: { type: 'string', custom: '() => { exec(...) }' }
  // or reference properties that Joi interprets as validators
  const schema = Joi.object(schemaDefinition); // schema IS code — never from user
  return schema.validate(data);
}

// WRONG: compiling ajv schema from user input
async function validateJson_WRONG(toolParams) {
  const { jsonSchema, data } = toolParams;
  const ajv = new Ajv();
  const validate = ajv.compile(jsonSchema); // never compile user-supplied schema
  return { valid: validate(data) };
}

// RIGHT: schemas are static constants defined in your source code
const Joi = require('joi');
const Ajv = require('ajv');

// All schemas defined once at module load — user never touches schema compilation
const SCHEMAS = {
  repoUrl:     Joi.string().uri({ scheme: ['https'] }).max(500).required(),
  grade:       Joi.string().valid('A', 'B', 'C', 'D', 'F').required(),
  requestType: Joi.string().valid('security', 'full', 'quick').required(),
};

const ajv = new Ajv({ strict: true, loadSchema: undefined });
const AUDIT_REQUEST_SCHEMA = ajv.compile({
  type: 'object',
  properties: {
    repoUrl:     { type: 'string', format: 'uri', maxLength: 500 },
    requestType: { type: 'string', enum: ['security', 'full', 'quick'] }
  },
  required: ['repoUrl', 'requestType'],
  additionalProperties: false
});

// Tool handler: user supplies DATA, not schemas
async function validateInput(toolParams) {
  const { schemaName, data } = toolParams;

  if (!Object.prototype.hasOwnProperty.call(SCHEMAS, schemaName)) {
    return { error: 'Unknown schema name' };
  }

  const { error, value } = SCHEMAS[schemaName].validate(data);
  if (error) return { valid: false, message: error.message };
  return { valid: true, value };
}

SkillAudit detection

SkillAudit's Security axis flags:

The chained finding report shows when a deserialization vulnerability is combined with a prototype pollution path — for example, a server that deserializes via a custom YAML parser and merges the result into a configuration object with _.merge. Both vulnerabilities share the same entry point (the tool parameter), making the combined impact higher than either finding alone.

→ MCP server deserialization security — JSON.parse, prototype pollution, YAML risks
→ MCP server prototype chain security — __proto__, constructor.prototype
→ Input validation patterns for MCP server tool parameters