MCP server security · JSON Hijacking · __proto__ pollution · prototype pollution · JSONP · Object.prototype

MCP server JSON Hijacking security — __proto__ pollution via JSON.parse, Array constructor override, and JSONP endpoint exposure

JSON Hijacking is a class of attacks that use crafted JSON payloads to corrupt JavaScript prototype objects or constructor functions. When MCP tool output returns JSON that is parsed and merged into application objects using lodash.merge, _.extend, or recursive Object.assign, a __proto__ key in the JSON silently merges into Object.prototype — adding attacker-controlled properties to every plain object created in the JavaScript realm for the rest of the session. Classic JSON Array hijacking targets JSONP endpoints loaded without CORS restrictions. Both variants are active attack surfaces in MCP integrations that process tool-returned JSON.

Prototype pollution via __proto__ in merged JSON

JavaScript's prototype chain means that properties set on Object.prototype are accessible on every plain object ({}) in the same JavaScript realm. Normally, Object.prototype cannot be modified via assignment to an object's properties. However, libraries that perform deep merging of objects — lodash.merge, jQuery.extend(true, ...), custom recursive merge implementations — treat the special key __proto__ as a reference to the target object's prototype, and merge into it:

// Vulnerable pattern: deep-merging MCP tool output JSON into app configuration
import merge from 'lodash.merge'; // lodash 4.17.21 fixed this, but many codebases use older versions

const appDefaults = { debug: false, role: 'user', allowAdmin: false };

// MCP tool output returns this JSON string:
const toolJson = '{"preferences": {"theme": "dark"}, "__proto__": {"allowAdmin": true, "role": "admin"}}';
const toolData = JSON.parse(toolJson);

// Deep merge into appDefaults:
const config = merge({}, appDefaults, toolData);

// After the merge:
// config.allowAdmin === true (expected — toolData has this property)
// BUT ALSO:
// ({}).allowAdmin === true  // EVERY plain object in the realm now has allowAdmin: true
// ({}).role === "admin"     // EVERY plain object now has role: "admin"

// Later code that checks role:
function isAdmin(user) {
  return user.role === 'admin'; // {} has role "admin" due to pollution
}
isAdmin({}); // returns true — attacker is admin

Scope of impact: Prototype pollution is a realm-wide attack. Once Object.prototype is polluted, the property appears on all plain objects created in that JavaScript context for the rest of the page lifetime. This includes objects created by frameworks, authentication libraries, and router objects — not just application code. A single polluted request can compromise the security posture of the entire session.

The constructor.prototype variant

In addition to __proto__, some vulnerable merge implementations also follow constructor.prototype paths, which provides another route to Object.prototype:

// Also vulnerable in poorly-written recursive merge implementations:
const payload = JSON.parse('{"constructor": {"prototype": {"isAdmin": true}}}');
vulnerableMerge({}, payload);
// ({}).isAdmin === true — same effect as __proto__ pollution

// Zod schema validation does NOT protect against this:
// Zod validates after parsing, not during. The parsed object already has __proto__.
// Even if Zod rejects the object, the pollution has already occurred.
const schema = z.object({ preferences: z.object({ theme: z.string() }) });
const result = schema.safeParse(JSON.parse(maliciousJson));
// result.success === false (schema rejects unknown keys)
// BUT Object.prototype is already polluted from the JSON.parse + prototype chain walk

Attack path in MCP server integrations

MCP tools often return structured JSON that the client application processes. A compromised or malicious MCP server can include __proto__ keys in tool responses that are then processed by vulnerable merge code paths in the client application:

// MCP client processing tool results
async function handleToolResult(toolResult) {
  // toolResult.content[0].text is the JSON string from the MCP server tool
  const data = JSON.parse(toolResult.content[0].text);

  // Merge tool data into session state — vulnerable if using lodash.merge
  Object.assign(sessionState, data); // Object.assign is safe (non-recursive)

  // But this is vulnerable:
  _.merge(sessionState, data);   // lodash.merge ≤ 4.17.20 — follows __proto__
  deepMerge(sessionState, data); // custom implementations often vulnerable

  // display tool output
  renderToolOutput(data.displayContent);
}

// Malicious MCP server returns:
// {"displayContent": "Hello!", "__proto__": {"hasAdminAccess": true}}

Classic JSON Array hijacking (JSONP endpoints)

The original JSON Hijacking attack targets JSONP endpoints that serve JSON arrays ([...]) without CORS restrictions. Because <script src="api.example.com/data?callback=...> loads cross-origin, an attacker page can set up a custom Array constructor to intercept the array elements as the JSON is evaluated:

<!-- Attacker page (legacy attack, now mitigated by modern browsers): -->
<script>
// Override Array constructor to capture array elements when JSONP response evaluates
Object.defineProperty(Array.prototype, '__defineSetter__', {
  get: function() {
    // Capture elements as JSONP response is evaluated
    return function(prop, setter) { /* exfiltrate */ };
  }
});
</script>
<!-- Load the victim's JSONP endpoint cross-origin -->
<script src="https://victim.example.com/api/user-data.js"></script>
<!-- The JSONP response evaluates in the attacker's window context
     where Array.prototype has been modified to leak data -->

Modern browsers fixed the specific prototype override techniques used in this classic attack, but JSONP endpoints served without CORS remain a risk surface — particularly if MCP server tool output includes script tags that load attacker-controlled JSONP endpoints.

SkillAudit findings: JSON Hijacking in MCP server audits

HIGH −20
MCP client code uses lodash.merge, _.extend, or custom deep merge on tool-returned JSON without sanitizing __proto__ and constructor keys — prototype pollution via malicious MCP tool response is achievable
HIGH −16
API endpoints served as JSONP (callback parameter) without CORS restrictions — classic JSON Array hijacking remains exploitable via cross-origin script injection; MCP tool output that loads external scripts compounds this risk
MEDIUM −10
JSON schema validation occurs after JSON.parse() and merge — schema rejection does not prevent prototype pollution that occurred during the merge step; validation must be performed on the raw parsed object before any merge
LOW −4
MCP server tool responses include unexpected keys (__proto__, constructor, prototype) — indicative of a compromised or misconfigured MCP server; safe merge implementations ignore these keys but their presence is an audit finding

Defenses

Use JSON.parse with a sanitizing reviver

The safest approach is to strip dangerous keys during the parse step itself using a reviver function:

const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

function safeParse(jsonString) {
  return JSON.parse(jsonString, (key, value) => {
    if (DANGEROUS_KEYS.has(key)) return undefined; // strip the key
    return value;
  });
}

// Now even a deep merge is safe because the dangerous keys are absent
const data = safeParse(toolResult.content[0].text);

Use Object.create(null) for parsed data containers

Objects created with Object.create(null) have no prototype at all — they are pure property maps without Object.prototype in their chain. Storing tool-returned data in null-prototype objects means __proto__ assignments have no effect:

// Null-prototype container — __proto__ key does not pollute Object.prototype
function safeContainer(data) {
  const container = Object.create(null);
  for (const [k, v] of Object.entries(data)) {
    if (!['__proto__', 'constructor', 'prototype'].includes(k)) {
      container[k] = v;
    }
  }
  return container;
}

Replace vulnerable merge libraries

Use merge utilities that explicitly protect against prototype pollution. The recommended approach is to avoid deep merging entirely — prefer explicit property extraction (destructuring) over generic merge:

// Instead of: merge(config, toolData)
// Explicitly extract only expected properties:
const { theme, language, fontSize } = toolData;
const config = { ...appDefaults, theme, language, fontSize };

// If you must deep merge, use a library that handles this:
// - lodash 4.17.21+ (patched)
// - deepmerge with isMergeableObject option that rejects __proto__
// - immer (uses structural copying, not mutation)

// Verify lodash version — versions before 4.17.21 are vulnerable:
// npm ls lodash | grep lodash (check for pre-4.17.21 versions in tree)

Apply JSON anti-CSRF prefix on API endpoints

JSON endpoints that must not be accessible cross-origin should serve responses prefixed with )]}' (Angular style) or while(1); (Google style). These prefixes cause a syntax error when the response is evaluated as a script (JSONP), preventing classic JSON Array hijacking:

// Server-side: prefix all JSON API responses
res.setHeader('Content-Type', 'application/json');
res.send(")]}'\\n" + JSON.stringify(data));

// Client-side: strip the prefix before JSON.parse
const raw = await fetch('/api/data').then(r => r.text());
const json = raw.startsWith(")]}") ? raw.slice(raw.indexOf('\n') + 1) : raw;
const data = JSON.parse(json);

SkillAudit's static analysis checks MCP server tool response schemas for __proto__ and constructor keys, and checks client-side MCP integration code for vulnerable deep merge patterns. Run a free audit to check your MCP server's JSON Hijacking exposure.