Node.js Deserialization: When a Serializer Revives Functions

Node.js Deserialization: When a Serializer Revives Functions

Written by

in

JavaScript objects are easy to turn into text with JSON.stringify and back with JSON.parse, so most developers assume every serializer in the ecosystem is just as safe. That assumption is where node.js deserialization bugs come from. A handful of serialization libraries go beyond JSON and can revive function bodies from the text they parse. When the input is attacker controlled, reviving a function means running attacker code on load.

How node.js deserialization can run attacker code

JSON has no way to represent a function. It stores strings, numbers, booleans, null, arrays, and objects, and that limit is a safety feature. Some libraries added functions back in so you could serialize a richer object, including its methods. To do that, they write the function source into the text, and on the way back they turn that source into a live function again.

The part that makes this a code execution bug is how some of these libraries revive a function. The classic unsafe pattern is a serialized value that holds a function expression, which the library wraps and evaluates. A well known shape is the immediately invoked function expression, a function that calls itself the moment it is created:

{"rce":"_$$ND_FUNC$$_function(){ require('child_process').exec('id'); }()"}

The trailing () is the trick. When the library rebuilds this value, it evaluates the function source, and because the function invokes itself right away, the body runs during deserialization. The marker at the front is how one popular serialize library tags a value as a function; the details differ between libraries, but the shape is the same.

JSON cannot describe a function, and that limit is the safety. A library that adds functions back has turned its parser into an evaluator.

A vulnerable endpoint

Picture an invented service, Acme Tasks, that stores a task object in a cookie and rebuilds it on each request using a function reviving deserializer:

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

app.get('/dashboard', (req, res) => {
  const task = serialize.unserialize(req.cookies.task);  // revives functions
  res.send(renderDashboard(task));
});

An attacker sets the task cookie to the payload above. On the next request the server calls unserialize, the embedded function runs itself, and the attacker has command execution. Nothing in the route looks wrong. The sink is the deserializer, and the route just feeds it untrusted bytes. To see why a loader that rebuilds behavior is always the risk, read the primer on what is insecure deserialization, and for how attackers compose small trusted pieces into a working exploit, see what is a deserialization gadget chain.

Why this is the same bug as in other languages

The surface looks different from a .NET or Python case, but the root cause is identical: the serialized format is allowed to carry behavior, not just data, and the loader faithfully rebuilds that behavior. In PHP, an unserialize call rebuilds objects whose lifecycle methods then fire, which is covered in php object injection. In Node the behavior is even more direct, because the payload can carry a function outright. Once you have seen two or three of these, the lesson is about the feature, a format that encodes code, not about any one language, which is why it sits in the wider injection and input category.

The fix: parse data, do not revive behavior

The rule is simple. For anything that crosses a trust boundary, use a deserializer that can only produce data.

  • Use JSON.parse for untrusted input. It cannot produce a function, so there is no path from the text to execution. If your data is plain values and structures, JSON is all you need.
  • Do not use a function reviving serializer on data from users. Libraries that serialize and revive functions are fine for trusted, internal data you fully control, and dangerous the moment the input can be influenced from outside.
  • Validate the parsed object. Even after JSON.parse, check that the shape matches what you expect with a schema validator. This stops a different class of problem, unexpected fields and types, though it is not what stops code execution. The code execution is stopped by using a parser that cannot build functions in the first place.

The corrected Acme Tasks route reads like this:

app.get('/dashboard', (req, res) => {
  let task;
  try {
    task = JSON.parse(req.cookies.task);   // data only, no functions
  } catch (e) {
    return res.status(400).send('bad task');
  }
  res.send(renderDashboard(validateTask(task)));
});

Now the worst a cookie can carry is malformed JSON, which the try block catches, or a well formed object with wrong values, which validateTask rejects. There is no function marker the parser will honor, so the immediately invoked function trick has nothing to latch onto.

A note on eval and its relatives

The same danger hides in plain sight whenever untrusted text reaches eval, the Function constructor, or vm module calls. A function reviving serializer is just one library that wraps these under a friendly name. If you are hunting for this bug, treat every one of those as a possible sink, not only named serialization libraries.

How to find it in a codebase

  • Search for serializer calls that are not JSON.parse, such as unserialize, and for libraries whose docs mention serializing functions.
  • Grep for eval(, new Function(, and vm.runInContext, then trace their input backward.
  • For each sink, follow the argument back to its source. Can it come from a request body, a query string, a cookie, a header, or a message on a queue? If yes, it is a live finding.
  • Check your dependencies, since a package you trust may call a function reviving deserializer on data you forwarded to it.

The hard part is never spotting the function of unserialize on one line. It is proving that an attacker controlled value, from a cookie or a body, actually reaches it through the middleware and helpers in between. That source to sink reasoning about untrusted bytes arriving at an unsafe loader is exactly what UnboundCompute is built to do. Read more on our about page.

Frequently asked questions

How can deserializing a JavaScript object run code?

JSON cannot represent a function, and that limit is a safety feature. Some serialization libraries add functions back by writing the function source into the text and turning it into a live function on the way back. If the serialized value holds a function that invokes itself, the body runs during deserialization, so an attacker who controls the input gets command execution.

What is the immediately invoked function trick in these payloads?

The payload stores a function expression followed by a pair of parentheses, so the function calls itself the moment it is created. When the library revives the value it evaluates the source, and the trailing parentheses make the body run right away. A marker at the front tells the library to treat the string as a function rather than plain text.

Is JSON.parse safe for untrusted input?

Yes, for the code execution risk. JSON.parse can only produce strings, numbers, booleans, null, arrays, and objects, so there is no path from the text to a running function. You should still validate the parsed object against a schema to reject unexpected fields and types, but that is a separate concern from stopping code execution.

Where else does this bug hide in Node.js?

Anywhere untrusted text reaches eval, the Function constructor, or vm module calls. A function reviving serializer is just one library that wraps those under a friendly name. When hunting for the bug, treat every such call as a possible sink, trace its input backward, and check dependencies that may call a function reviving deserializer on data you forwarded.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.