Solving a CTF challenge with NodeBee

CTF challenges are a great way to learn hacking and also to test the capabilities of various code analysis tools. One of the most interesting CTFs this year was PlaidCTF, with a particularly fascinating ExpressJS challenge Contrived Web Problem. This was only solved by 27 teams out of the registered 1538, which makes it a really good candidate.

In today's post we'll take a look at the "Contrived Web" problem from PlaidCTF 2020 and how we can find the hidden vulnerabilities in it.

First look

The challenge environment consists of 6 different services, 3 of these ("api", "email", "server") are NodeJS Express applications, the rest of them are common backend services (Postgres, rabbitMQ, FTP server). Among these, the api service looks the most interesting.

Let's start with a quick NodeBee fuzzing run with the default settings. NodeBee supports Express-based applications out of the box and automatically hooks into the most common ways an application would interact with its environment (e.g. sockets or http.request calls), therefore there is no need to set up a test environment.

ssrf.png

We found an SSRF in the /image handler.

A closer look at the /image handler code suggests that the application also supports retrieving images via FTP.

code.png

What if it doesn't sanitize newlines (CRLF) in the input properly? That would allow us to inject arbitrary commands into the FTP request. To check this, we will write a custom NodeBee "sanitizer".

Custom sanitizers

NodeBee sanitizers are similar to assertions in testing, but without having to actually write test cases. Instead, we just give an example of a behaviour the application shouldn't exhibit and let NodeBee analyze the program and find a counterexample.

In our case, if we would see the string "\r\nCRLFINJECT" being sent by the application to the FTP server, that would be a clear indication of a CRLF injection vulnerability. Note that we only have to give an example instead of describing CRLF injections in general ("\r\nSOMETHINGELSE" would be equally valid, but "\r\nUSER" would not, since that is a valid FTP command).

To create a custom NodeBee sanitizer, we start with the default NodeBee check file:

'use strict';
❶const {runtime, hooking, sanitizer} = require('nodebee');

function fuzzingCompleted() {
    hooks.unhookAll();
}

❷let hooks = new hooking.HookConfiguration();
❸sanitizer.addBuiltinSanitizers(hooks);
❹hooks.setHooks();
❺runtime.registerFuzzingCompletedCallback(fuzzingCompleted);

We first import the necessary NodeBee components ❶, and create a HookConfiguration object ❷. This class manages hooks on library calls (e.g. http.request) and builtin functions. Most of the time we want to attach sanitizers to hooks, but we can also embed them as comments directly into the application code - this is useful for sanitizers checking business logic. We then enable the built-in sanitizers ❸ (NodeBee contains sanitizers for most of the OWASP TOP 10 vulnerabilities and we continuously add new ones). The next line activates the hooks ❹. The last line removes the hooks when fuzzing is finished ❺.

To show how hooking works, let's create a hook to overwrite bcrypt calls since they are (intentionally) resource-intensive and would slow down fuzzing:

hooks.addModuleHook("bcrypt", { 
    hash: (value) => value, 
    compare: (a, b) => a === b 
}, true);

As you can see, we replace the bcrypt.hash() function with the identity function ( (x) ==> x ) and the secure comparison with "===". Internally, NodeBee handles cryptographic functions in a similar way, but retains how a value is computed (so in future versions you could "verify" a cryptographic scheme directly as implemented just by writing sanitizers!).

Let's create the CRLF injection sanitizer. First, we'll need to hook into the Socket.connect() and write() functions of NodeJS:

const SocketMock = sanitizer.getSocketMock({ connect, write });
hooks.addModuleHook("net", { Socket: SocketMock });

Now we can create a simple mock FTP server for the application to interact with:

function connect(...params) {
    ❶this._nodeBee_on.connect();
    ❷this._nodeBee_on.data("220 Hi there\r\n");
    this._nodeBee_on.data("331 So far so good\r\n");
    this._nodeBee_on.data("230 Come on in\r\n");
    this._nodeBee_on.error();
    this._nodeBee_on.end();
    ❸this._nodeBee_on.close();
}

When Socket.connect() is called, we send the on("connect") event as a normal socket would ❶, then send on("data") events from our imaginary FTP server ❷, then "terminate" the connection ❸. In future NodeBee versions, you could also use a real server as reference for NodeBee to "learn" what a response would look like, or treat it as a potentially malicious service and let NodeBee generate arbitrary inputs.

The final piece to write is the actual sanitizer:

function write(...params) {
    ❶runtime.addToUserDict("\r\nCRLFINJECT");
    ❷if (params[0] !== undefined && 
        params[0].includes("\r\nCRLFINJECT")) {
            ❸runtime.reportVulnerability('CRLF injection',  true);
    }
}

If the application calls write() on a socket, we add "\r\nCRLFINJECT" to the fuzzing dictionary ❶. This helps NodeBee to "guess" more easily what we're looking for. If the first parameter to the write() call contains "\r\nCRLFINJECT" ❷, we report it as a vulnerability ❸ and include the location (the second, "true" parameter ❹).

Let's try it out:

crlfi.png

We were right, the application is indeed vulnerable to CRLF injection.

Obviously there is much more to this challenge (we refer the interested readers to try it out themselves: https://github.com/ZeddYu/Plaid-CTF-2020-Web/blob/master/contrived.tgz ) - but if this was a real application, at this point we were more interested in fixing these flaws, and setting up an automated check in CI/CD to ensure that similar vulnerabilities are not reintroduced subsequently. This will be the topic of part 2.

If you'd like to try out NodeBee on your own code, contact us at beta@nodebee.io! Stay safe and happy fuzzing!

Previous
Previous

Solving a CTF challenge with NodeBee - part 2