5 in the row - Gynvael Coldwind Twitter Challenge

May 23, 2020, 7:47 p.m.

We already learned a lot about Express.JS, although, Gynvael surprised us with another exciting challenge. A few more and we may learn JavaScript (just kidding nobody knows it).

We are already used to getting code sniped without any inputs.

 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
const http = require('http')
const express = require('express')
const fs = require('fs')
const path = require('path')

const PORT = 5005
const FLAG = process.env.FLAG || "???"
const SOURCE = fs.readFileSync(path.basename(__filename))

const app = express()

app.use(express.urlencoded({extended: false}))

app.post('/flag', (req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain;charset=utf-8')

  if (req.body.secret !== 'ShowMeTheFlag') {
    res.end("Say the magic phrase!")
    return
  }

  if (req.youAreBanned) {
    res.end("How about no.")
    return
  }

  res.end(FLAG)
})

app.get('/', (req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain;charset=utf-8')
  res.write("Level 5\n\n")
  res.end(SOURCE)
})

const proxy = function(req, res) {
  req.youAreBanned = false
  let body = ''
  req
    .prependListener('data', (data) => { body += data })
    .prependListener('end', () => {
      const o = new URLSearchParams(body)
      req.youAreBanned = o.toString().includes("ShowMeTheFlag")
    })
  return app(req, res)
}

const server = http.createServer(proxy)
server.listen(PORT, () => {
  console.log('Challenge listening at port ${PORT}')
})

I will assume you’ve read the previous write-up, because this one is based on a similar idea. If you didn’t, do your homework, it will make things clearer ☺

This time, the app.use (22 line) is set to use the middleware to urlencoded. This means the Content-Type should be set to the application/x-www-form-urlencoded. Let’s also note that the extended flag is set to false, it may be useful.

Then the a /flag endpoint is created (14-29 lines). We have to provide a secret variable in the body set to ‘ShowMeTheFlag’. Ok, we can do it. But if the youAreBaned will be set, we will not get the flag.

Let’s ignore the / endpoint, we know the drill already.

And at the end, we are creating a new proxy function which sets youAreBaned to false. Then we add two events data and end to the listener. The data event is called multiple times when new data is available. And the end is called when all the data is already read. In the end method, we are decoding the body using the URLSearchParams, and we are looking for any occurrence of the 'ShowMeTheFlag' text in the whole body. If there is a 'ShowMeTheFlag' in a body, the youAreBanned variable is set. At the end, we are passing the modified req to our Express.JS app.

The first idea to hack this app is to check how the URLSearchParams and the Express.JS are handling the URL encoding. Maybe they are doing something in a different way which we can use. Hey, you can’t judge me for trying, right? Let’s start source code hunting.

Let’s jump to the bodyParser responsible for decoding the body in Express.JS (which you already know). The urlencoded is handled in the urlencoded function and its urlencodedParser method in the lib/types/urlencoded.js file.

 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
function urlencoded (options) {
  var opts = options || {}

  // notice because option default will flip in next major
  if (opts.extended === undefined) {
    deprecate('undefined extended: provide extended option')
  }

  var extended = opts.extended !== false
  var inflate = opts.inflate !== false
  var limit = typeof opts.limit !== 'number'
    ? bytes.parse(opts.limit || '100kb')
    : opts.limit
  var type = opts.type || 'application/x-www-form-urlencoded'
  var verify = opts.verify || false

  if (verify !== false && typeof verify !== 'function') {
    throw new TypeError('option verify must be function')
  }

  // create the appropriate query parser
  var queryparse = extended
    ? extendedparser(opts)
    : simpleparser(opts)

  // create the appropriate type checking function
  var shouldParse = typeof type !== 'function'
    ? typeChecker(type)
    : type

  function parse (body) {
    return body.length
      ? queryparse(body)
      : {}
  }

  return function urlencodedParser (req, res, next) {
    if (req._body) {
      debug('body already parsed')
      next()
      return
    }

    req.body = req.body || {}

    // skip requests without bodies
    if (!typeis.hasBody(req)) {
      debug('skip empty body')
      next()
      return
    }

    debug('content-type %j', req.headers['content-type'])

    // determine if request should be parsed
    if (!shouldParse(req)) {
      debug('skip parsing')
      next()
      return
    }

    // assert charset
    var charset = getCharset(req) || 'utf-8'
    if (charset !== 'utf-8') {
      debug('invalid charset')
      next(createError(415, 'unsupported charset "' + charset.toUpperCase() + '"', {
        charset: charset,
        type: 'charset.unsupported'
      }))
      return
    }

    // read
    read(req, res, next, parse, debug, {
      debug: debug,
      encoding: charset,
      inflate: inflate,
      limit: limit,
      verify: verify
    })
  }

In lines 22-24 the query parser is chosen between simple parser and extended parser. We already know we are using the simple one. The simple parser is using the querystring parser.

function simpleparser (options) {
  var parameterLimit = options.parameterLimit !== undefined
    ? options.parameterLimit
    : 1000
  var parse = parser('querystring')
[...]

Then we can notice in line 64 that we are limited only to UTF-8 charset, so our last hacks with UTF-7 will not work. At the end, the read function is called.

We can use node to get the source code of querystring:
$ node
> console.log(require('querystring').decode.toString())

What about the Node? To get the source code of the URLSearchParams we can again use nodejs: 
$ node
> console.log(URLSearchParams.toString.toString())

After a short analysis we can notice it is using the initSearchParams. I’m not sure how you can get the source code of it from nodejs itself (if you know please leave a comment), so I view the nodejs source code on Github. We will land in the node/lib/internal/url.js. The initSearchParams calls the parseParams. And finally we can spend a moment comparing the parse and parseParams functions.

Long moments… At this point, I started to wonder if this was the right idea. Maybe there is a problem, not with decoding, but with converting it back to String?

And then it shocked me. How I didn’t connect the dots? I just wrote a solution a few hours earlier in my previous post. 

Remember the read function from the previous post is also called at the end of the urlencoded. We already know that only the charset is UTF-8 in with the content type application/x-www-form-urlencoded. Let’s bring our previous analysis of the read function from the previous post:

  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()
  })
}
  1. Lines 27-34 decompress the provided body.
  2. Lines 76-87 calls middleware, and the stream isn't changed.
  3. Lines 93-95 the stream is converted to the charset
  4. Line 96, the converted string, is provided to the method function (in our case, the post).

It is decompressing the body first! Let’s try it:
$ echo -n 'secret=ShowMeTheFlag' | gzip | \
  curl -i --data-binary @- -H "Content-Encoding: gzip" http://challenges.gynvael.stream:5005/flag

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/plain;charset=utf-8
Date: Sat, 23 May 2020 18:24:33 GMT
Connection: keep-alive
Content-Length: 33

CTF{||SameAsLevel4ButDifferent||}

BINGO! curl already added the right Content-Type for us (you may also add it using "-H Content-Type: application/x-www-form-urlencoded"). How did this work?

When the data event is running, it is gathering the compressed data. When the URLSearchParams in the end event is called, it looks for the "ShowMeTheFlag" in compressed data. Then the urlencoded function is called, which forbids us to change the charset, and it sets the parser method. Then it calls the read function, which will decompress data, parse it, and provide it back to the Express.js /flag method. Victoria!

This shows that sometimes you have to take a step back and look into the bigger picture before going into the rabbit hole. Sometimes you have an answer in front of you, but you don’t see it.