Remote Code Execution via Prototype Pollution in Blitz.js

by paul gerste|

Remote Code Execution via Prototype Pollution in Blitz.js

Third-party dependencies are an easy way for developers to add functionality to their applications. This is great for productivity, but it also adds more attack surface and potential for bugs. While relying on battle-proven libraries is better than re-inventing the wheel, it is also important to check this hidden part of your code base for security vulnerabilities.

As part of our commitment to helping secure the open-source world, we decided to take a look at Blitz.js, an upcoming full-stack React framework. It is based on Next.js and includes features such as authentication, an API layer, and code generation out of the box. Promising to be a batteries-included software stack, it gained 11,000 stars on GitHub.

We identified a critical vulnerability in Blitz.js that allowed attackers to take over most instances. In this article, we first give an introduction to a bug class named Prototype Pollution. Then we describe the technical details of the vulnerability we discovered, its impact, and how you can prevent it in your code.

Impact

We discovered a Prototype Pollution vulnerability (CVE-2022-23631) in the serialization library superjson used in the RPC layer of Blitz.js. It leads to Remote Code Execution on the server, and unauthenticated attackers can exploit it over the internet. A Blitz.js-based application is vulnerable if it implements at least one RPC call.

The issue has been fixed in superjson 1.8.1 and Blitz.js 0.45.3, so we recommend updating your dependencies to these versions or higher.

Technical Details

In this section, we will first explain how prototypes work in JavaScript and what Prototype Pollution is. Then will show a real-world example of this in Blitz.js. Finally, we will give recommendations on how to avoid Prototype Pollution vulnerabilities in your JavaScript code.

What is Prototype Pollution?

In JavaScript, classes are implemented using so-called prototypes. Any object's prototype is accessible via the __proto__ property, e.g. the following is true: "abc".__proto__ === String.prototype. An object inherits all properties from its prototype, which is why "abc".substring(1) works: the string inherits the substring function from its prototype, String.prototype.

Prototypes are regular objects, which means that they can be modified. Adding a property to a prototype will cause all existing objects of that type to also have this property:

const obj1 = {};
obj1.__proto__.x = 1;
console.log(obj1.x === 1); // true
const obj2 = {};
console.log(obj2.x === 1); // true

When the JavaScript interpreter encounters the expression obj.x it first looks for x in obj itself, then in obj.__proto__, then in obj.__proto__.__proto__, and so on. It uses the first one it finds and throws an error if it can't find x in any of obj's prototypes. As this demonstrates, prototypes can be chained, just like classes can have multiple levels of ancestors. This MDN article explains JavaScript's inheritance in more detail if you are interested.

Prototype Pollution occurs when attackers can gain control over properties of a prototype. A vulnerable code pattern where this can happen is, for instance, obj[a][b] = c: if the attacker controls the values of a, b and c, they can set "__proto__" for a, the property name for b, and the property value for c. This will cause all objects to have a new property, which can significantly influence the target application.

A common pattern in JavaScript code is to use plain objects to pass optional arguments to a function. In the following example, the function doTask receives an object that can contain several optional arguments:

function doTask(name, options) {
    if (options.delay) {
        // handle delay
    }
    if (options.priority) {
        // handle priority
    }
    
    // do the task
}

doTask('dQw4w9WgXcQ', {
    delay: 100,
});

Setting a new property on the Object prototype would result in all these argument objects having that new property, changing the program's behavior. In the example above, it would be possible to set a new priority property on the Object prototype, causing all tasks to be processed with that priority.

Prototype Pollution in superjson (CVE-2022-23631)

One of the features of Blitz.js is its easy integration of RPC calls. It implements a so-called Zero-API layer, meaning that a piece of business logic can simply be implemented as a function, and a client can call this function without needing to write API code. When the call is made on the client, Blitz.js will transparently make an RPC call to the server, wait for the response and then return it as the result of the function call.

For the deserialization of RPC call arguments, Blitz.js has implemented its own extended version of JSON called superjson. It adds support for more data types, such as dates and regexes, and allows circular dependencies. The latter is implemented by reading a list of assignment operations from a special metadata property and then applying these operations to the data. Let's take the following JSON as an example:

{
  "json": {
    "brands": [
      { "name": "Sonar" }
    ],
    "products": [
      { "name": "SonarQube",  "brand": null }
    ]
  },
  "meta": {
    "referentialEqualities": {
      "brands.0": ["products.0.brand"]
    }
  }
}

The referentialEqualities mapping tells superjson to do the following assignment on the value of json:

products[0].brand = brands[0];

These assignment operations work with any path within the data. Since the path of the assignment's destination can contain any property names, this introduces a Prototype Pollution vulnerability. An attacker could use the path __proto__.x to set the x property on Object.prototype to any value from the data they also control.

Prototype Pollution to RCE

To exploit the Prototype Pollution, an attacker needs to find gadgets that lead to arbitrary code execution or other interesting behavior. We will now look at the three gadgets that make up the final exploit.

Gadget 1: From zero to require()
Since Blitz.js is based on Next.js, it uses the same routing mechanism. At build time, a pages manifest is created that contains a mapping between HTTP and filesystem paths.

When a request arrives, the server will check if the mapping contains an entry that matches the request's path. If there is an entry, it will use its filesystem path and load the JavaScript file it references. That file contains the code that renders the page on that path. The following is an example pages-manifest.json:

{
  "/api/rpc/signup": "pages/api/rpc/signup.js",
  "/forgot-password": "pages/forgot-password.html"
}

The file is loaded using the require() function of Node.js, and the file path is not checked to be within a certain directory. The manifest is loaded from a JSON file, meaning that the resulting object inherits from Object.prototype. This makes the page routing functionality a Prototype Pollution gadget that allows executing any local JavaScript file by inserting a new mapping into the manifest.

Gadget 2: From require() to spawn()
To turn this into arbitrary code execution, an attacker either needs the ability to create files on the server or another gadget to chain with the first one. Since Blitz.js does not have any upload functionality by default, we need to look for existing files with interesting behavior.

The file has to be present in every Blitz.js instance, so looking at Blitz.js itself and its dependencies makes the most sense. One interesting file is the Blitz.js CLI wrapper script. It will spawn the actual CLI script in a new process and exit. However, the command is fixed, and the arguments are not controllable, so how can attackers use this?

Gadget 3: From spawn() to arbitrary code execution
Spawning a new process is a known Prototype Pollution gadget that was made popular by Michał Bentkowski when he used it to exploit Kibana.

Indeed, the spawn() function receives optional arguments via an object: This can be used to set the environment variables for the child process with the env property. This can be used to set NODE_OPTIONS to set more command-line arguments for the node process. Some arguments are not allowed, such as --eval, but --require can be used to include any file. This seems to be the same primitive as the very first gadget allows, but there is a difference. Since a new process is spawned, there are some new files on the file system. The file /proc/self/environ contains the current process's environment variables which are already attacker-controlled through the env option.

The regular way of abusing this is to insert a new environment variable before the NODE_OPTIONS one that contains JavaScript code and has a trailing comment to avoid syntax errors. However, Node.js seems to handle the NODE_OPTIONS differently now, putting it always first in the environ file.

Improving gadget 3
To bypass this, attackers can use two more options of the spawn() function: argv0 and shell. The first one, argv0, controls the first element in the list of arguments passed to the new process. Usually, this is equivalent to the binary that is executed. The whole list of arguments is reflected in the file /proc/self/cmdline so the first element will be at the beginning. If the attacker changes the value of NODE_OPTIONS to --require /proc/self/cmdline and puts their payload in argv0, this should work, right?

Almost, but there is one final hurdle. Because the first argument was changed, the process can not be spawned because it is not a valid command or file path. This can be bypassed with the shell option of the spawn() function. It can be set to the path of a binary that will then be used to spawn the command within a shell. On Linux, the shell is prepended to the command and its arguments like this: /bin/myshell -c "command arg1 arg2 arg3"

To set shell to the path of the node executable, the attacker can use /proc/self/exe without knowing the actual path. The final result is that a node process will be spawned as follows:

execve("/proc/self/exe", ["console.log('pwned!');//", "-c", "node …"], { NODE_OPTIONS: "--require /proc/self/cmdline" })

Chaining them together
The final exploit works like this:

  1. The attacker sends a request that abuses the Prototype Pollution issue in the RPC layer to add properties to the Object prototype.
  2. This creates a new entry pointing to the Blitz.js CLI wrapper script in the pages manifest. It also sets argv0, env, and shell for the spawn() call in step 3.
  3. The attacker triggers the chain by sending a request to the URL of the newly created pages manifest entry. This causes the CLI wrapper script to be executed, spawning a new process with the attacker-controlled argv0, env, and shell options. This finally executes the attacker's payload in a new process.

The final exploit chain

Patch

To fix the root cause of this issue, Blitz.js blocked some property names from being used in a path, namely __proto__, constructor, and prototype. Without these properties, it is not possible to reach and poison a prototype object. This can be generalized to a JavaScript security rule of thumb: when using untrusted inputs to access or modify the properties of an object, always make sure that these three property names are blocked.

Another option is to use Object.create(null) instead of a plain object literal ({}) where possible. The returned object does not inherit from Object.prototype, so it is also not possible to reach that prototype, regardless of any untrusted property names being used for access:

const obj = Object.create(null);
Object.prototype.x = 1;
console.log(obj.x === 1); // false
console.log(obj.__proto__); // undefined

If you want to harden your code base and make the exploitation of Prototype Pollution issues more difficult, there are some ways to do so, but they each come with their drawbacks. The first measure is to make Object.prototype immutable by calling Object.freeze(Object.prototype) as early as possible. The disadvantages are that you would have to repeat that for every class and that some older libraries would break because they modify prototypes.

The second measure only applies to Node.js, not JavaScript running in a browser. If you start the Node.js process with the --disable-proto=delete flag, then the __proto__ property will not exist anymore, and the only way to set an object's prototype is via functions such as Reflect.setPrototypeOf(). As with the previous measure, libraries could break because of this. Also, it is still possible to reach an object's prototype via obj.constructor.prototype, so these property names should still be blocked when validating user-controlled property names.

Timeline

DateAction
2022-02-07We report the issue to the Blitz.js maintainers
2022-02-07The maintainers confirm the issue
2022-02-10A patch is released with superjson 1.8.1 and Blitz.js 0.45.3

Summary

In this publication, we covered the technical details behind a Prototype Pollution vulnerability in Blitz.js; a full-stack React framework. Attackers can use the vulnerability to execute code on servers that run applications based on vulnerable versions of Blitz.js. We also presented ways to prevent such issues in your JavaScript code.

If you are using Blitz.js or superjson in your application, we strongly recommend updating to the fixed versions mentioned above. Finally, we want to thank the maintainers of Blitz.js and superjson for their fast replies and patches.

Related Blog Posts