Gynvael Coldwind Twitter Challenge 4
May 19, 2020, 6:31 p.m.Gynvael published another mind-blowing Express.JS challenge last Wednesday. Let's keep our good track and try to solve it. Are you ready?
In level 4, we again obtained the source code without any input.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
const express = require('express') const fs = require('fs') const path = require('path') const PORT = 5004 const FLAG = process.env.FLAG || "???" const SOURCE = fs.readFileSync(path.basename(__filename)) const app = express() app.use(express.text({ verify: (req, res, body) => { const magic = Buffer.from('ShowMeTheFlag') if (body.includes(magic)) { throw new Error("Go away.") } } })) app.post('/flag', (req, res) => { res.statusCode = 200 res.setHeader('Content-Type', 'text/plain;charset=utf-8') if ((typeof req.body) !== 'string') { res.end("What?") return } if (req.body.includes('ShowMeTheFlag')) { res.end(FLAG) return } res.end("Say the magic phrase!") }) app.get('/', (req, res) => { res.statusCode = 200 res.setHeader('Content-Type', 'text/plain;charset=utf-8') res.write("Level 4\n\n") res.end(SOURCE) }) app.listen(PORT, () => { console.log('Challenge listening at port ${PORT}') }) |
There are two endpoints:
- / - the one which prints the task description,
- /flag - the endpoint which prints the flags, accessible through the POST method.
There is also middleware. The use function creates the middleware in Express.JS. It is produced from express.text. By reading the code, we can see that the middleware forbids us to provide the ShowMeTheFlag magic (15-16 line), and in the /flag endpoint, we know that it must be provided to get the flag (29-31 line). Interesting.
But first, we must figure out how to access the /flag endpoint. We will use the Burp tool throughout the whole task. We can just use the Proxy intercept to intercept the request to the /. When the request is intercepted, we can send it to the Repeater, and change the request method to the /flag path.
The code stops at "What?" error.
if ((typeof req.body) !== 'string') { res.end("What?") return }
How can we have strings as req.body? Remember our middleware? It was created for the express.text, and we can read information about this in the documentation. The express.text handles the text/plain, so let's change our Content-Type, and we bumped into the second error handler in the /flag function.
Let's send the magic world "ShowMeTheFlag", unfortunately, in this case, the middleware blocked our request with the Error "Go away." Okay, let's get things together, we must send a request with the "ShowMeTheFlag" in the body, but in a way that middleware doesn't see it? I noticed that in the case of the post function, the body is of type string, but in the middleware, we have a Buffer. Level 3 taught me that I should just jump to the code, which is what we will do.
To achieve that, we download the Gynvael code, install the express framework using npm. In this case, in node_modules, we will also have all the modules that Express.JS depends on. This may be useful.
The first idea was to see how the middleware is handled. We can grep and look for exports.text, we will find that it is set on the bodyParser.text. This takes us to another component called body-parser. ExpressJS uses it to parse the body of the post.
For example, in the body analyzer, we can look for req.body, which is a quick reminder that contains a converted string.
gerp -R req.body
./lib/read.js: req.body = parse(str)
./lib/types/urlencoded.js: req.body = req.body || {}
./lib/types/text.js: req.body = req.body || {}
./lib/types/json.js: req.body = req.body || {}
./lib/types/raw.js: req.body = req.body || {}
We know that we are interested in the text/type, but after a brief analysis, it's not very interesting. In this case, we jumped to a more general function reading in lib/read.js. At this time, I also added some console.log to verify that we are using this function and run the downloaded code. Yep, this function may be interesting.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
/** * Read a request into a buffer and parse. * * @param {object} req * @param {object} res * @param {function} next * @param {function} parse * @param {function} debug * @param {object} options * @private */ function read (req, res, next, parse, debug, options) { var length var opts = options var stream // flag as parsed req._body = true // read options var encoding = opts.encoding !== null ? opts.encoding : null var verify = opts.verify try { // get the content stream stream = contentstream(req, debug, opts.inflate) length = stream.length stream.length = undefined } catch (err) { return next(err) } // set raw-body options opts.length = length opts.encoding = verify ? null : encoding // assert charset is supported if (opts.encoding === null && encoding !== null && !iconv.encodingExists(encoding)) { return next(createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', { charset: encoding.toLowerCase(), type: 'charset.unsupported' })) } // read body debug('read body') getBody(stream, opts, function (error, body) { if (error) { var _error if (error.type === 'encoding.unsupported') { // echo back charset _error = createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', { charset: encoding.toLowerCase(), type: 'charset.unsupported' }) } else { // set status code on error _error = createError(400, error) } // read off entire request stream.resume() onFinished(req, function onfinished () { next(createError(400, _error)) }) return } // verify if (verify) { try { debug('verify body') verify(req, res, body, encoding) } catch (err) { next(createError(403, err, { body: body, type: err.type || 'entity.verify.failed' })) return } } // parse var str = body try { debug('parse body') str = typeof body !== 'string' && encoding !== null ? iconv.decode(body, encoding) : body req.body = parse(str) } catch (err) { next(createError(400, err, { body: str, type: err.type || 'entity.parse.failed' })) return } next() }) } |
Let's analyze what is happening in this function:
- Lines 27-34 decompress the provided body.
- Lines 76-87 calls middleware, and the stream isn't changed.
- Lines 93-95 the stream is converted to the charset
- Line 96, the converted string, is provided to the method function (in our case, the post).
As we can see, the value between calling the middleware and the method has been converted.
Nice!
Express is using iconv-little to convert to the correct charset. The line of iconv.decode(body, encoding) is crucial to us. The body is just the POST body, but how do we control the encoding variable. If we grep the encoded body-types, we may find that it has been set to a charset, and the charset is obtained from the Content-Type header.
encoding: charset, [...] var charset = getCharset(req) || defaultCharset [...] function getCharset (req) { try { return (contentType.parse(req).parameters.charset || '').toLowerCase() } catch (e) { return undefined }
Now we can try to figure out which encodings are supported. Let's use grep (at this point, you can guess I like grepping code) in the iconv-little. I just looked for the UTF-8 and landed into extend-node.js, which has a list of some (not all) supported charsets.
var nodeNativeEncodings = { 'hex': true, 'utf8': true, 'utf-8': true, 'ascii': true, 'binary': true, 'base64': true, 'ucs2': true, 'ucs-2': true, 'utf16le': true, 'utf-16le': true, };
Note that we have several different encodings to work with. The first one pumped out is base64. When we provide the text, it will be encoded using base64.
So maybe we can decode ShowMeTheFlag as base64, and the output provided in the body. The ShowMeTheFlag was even decoded:
$ echo -n "ShowMeTheFlag=" | base64 -d
J01äáxYZg=
Unfortunately, only part of the messages is decoded and encoded back into valid form. I played with this idea for a moment, but it didn't work.
Maybe this is a good idea, but we just chose a bad encoding. I went through a few different encodings and tried to decode ShowMeTheFlag to a separate buffer.
$iconv -f SHIFT_JIS -t utf8
ShowMeTheFlag
ShowMeTheFlag
$iconv -f utf-7 -t utf8
ShowMeTheFlag
ShowMeTheFlag
$ iconv -f utf-16 -t utf8
ShowMeTheFlag
��ShowMeTheFlag
$ iconv -f UTF-16LE -t utf-8
ShowMeTheFlag
桓睯敍桔䙥慬੧%
We can see that we got some weird characters in the encoding, but unfortunately, this also didn't work.
At this point, I looked one more time on the list of available charsets. UTF-7. UTF-7… Hym… right I remember that there was some XSS bypassed if you would change UTF-8 encoding to UTF-7 or when the website was using UTF-7. What was it? I just google "XSS UTF-7", and find out this page. If there is an XSS filter on the '<,' but you were able to change encoding to UTF-7 (or the website is just using it), then you can encode the '<' to the variable +ADw-. Nice! I send it, and it showed in the output that we got '<' instead of the +ADw-. The variable has been converted.
I was so excited that I tried a few random values and guessed the value of F. Later, I learned (thx Gyn for explanation) that the encoding in UTF-7 is simply +base64(utf16be(CHARACTER).remove(‘=’))-. "Simply," right? We can use python to achieve that:
Python 3.7.7 (default, Mar 21 2020, 01:37:51)
>>> import base64
>>> base64.b64encode("F".encode('UTF-16BE'))
b'AEY='
Remove =, add the + and -, and we are ready to go.
Success! In middleware, the text is represented in the binary form so that we will see this text: ShowMeThe+AEY-lag, but when using UTF-7 encoding, it will be converted to ShowMeTheFlag and passed to the function responsible for the post method. Wow, what a journey!