Gynvael Coldwind Twitter Challenge 1, 2, and 3...

May 12, 2020, 9:12 p.m.

In the last couple of days, Gynvael Coldwind tweeted out three exciting challenges. All the challenges used the Python and Flask or Express framework implemented in node.js. I decided to accept his challenge, and after struggling a little bit, I was able to solve all of them. In this post (or write-up), we will go over them together.

Challenge 1

The first challenge appeared surprisingly. Nobody was expecting it. It was cold… Not really cold. It was night (at least in my time zone).

Five minutes, huh? Okay, let’s look into it. Gyn provided us with the source code for this task:

Task source:
#!/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:
{{ src }}
          </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")

The challenge is implemented in Python using the Flask framework and with two inputs: the URL and the language code. We have a couple of routes in the application:

  • /secret: This route will print our flag. Unfortunately, if we go there, we will get an error.
  • /: We are here. It easily prints the task description and source code.
  • /fetch: This route handles the post messages from our form.

Let’s start from the beginning. What we have to do is get the flag. Let’s look into the secret function:

@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}"

The flag will only be provided if we will do the request to the loopback interface (127.0.0.1), and if there is a header X-Secret with the value YEAH.

So how can we make a local request? Fortunately, we have a fetch function that creates a connection and generates an HTTP request. This is a classic Server Side Request Forgery. Instead of creating a connection to the internet, you are creating a connection to the internal resources. To do that, we simply paste http://127.0.0.1:5000/secret into the URL field.

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: Sat, 09 May 2020 21:29:20 GMT

Nope.

As we can see, we are taking it a step further. Instead of the Access denied! message, we are getting the Nope. message. Now we have to add the header to the HTTP request. Let’s look at how the HTTP request is generated:

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

Happily, we can control the lang variable. This variable isn’t sanitized in any way. We can provide an input with the new line and a new header that we want to add to the request. The marked value is the one controlled by us:

GET /secret HTTP/1.1\n
Host: 127.0.0.1:5000\n
Connection: close\n
Accept-Language: sth\n
X-Secret: YEAH\n

So in the Lang field we are providing values sth\nX-Secret: YEAH\n and…

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: Sat, 09 May 2020 21:42:19 GMT

Nope. 

And we got the same error. Yes, we have to encode the new lines using the URL encoding scheme so the value should be sth%0AX-Secret: YEAH, but now you have to be careful that the browser does not automatically encode % to %25. TBH I just solve this with burp:

Now looking at this challenge write up I wonder why it didn’t take me 5 minutes...

Challenge 2

The second challenge was implemented in the NODE.js using Express framework, and again we got the source code. No input this time.

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

Concatenating X with the string should throw an exception. I was pretty sure that I would not find a string that would throw an exception, but I still tried at last for the ascii chars. I coded a small method in the developer tools:

for (i = 0; i < 0xFF; i++)  {
    '<' + String.fromCharCode(i);
}

The array concatenated with the string will also succeed. The NaN concatenated with the string is still a string, and undefined concatenated with the string is still a string. I went back to the documentation one more time. Oh, why I didn’t notice this red box?
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.

How can we make X variables not appear in the query? That doesn't make sense in our scenario. And then I noticed the example. Oh, why didn’t I read the documentation much more closely?

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

Instead of the array, I can make a map of the values. That made stuff much easier. Each key is also another attribute. That made stuff much easier, too. Let’s first override the length attribute.
http://challenges.gynvael.stream:5002/?X[length]=900
Level 2

Close, but no cigar.

We bypassed those two first checks. How is that possible? When the code is referring to the req.query.X.length instead of the object length attribute, it’s grabbing the key with the name length from our map. It is returning 900, so the first check is passed. Then it is passed to the stringify, and our map is converted to the string {"length":"900"}, where the length is just 16.

The last part was straightforward for me. The objects that are not the string before concatenating with the strings implicite call a function that converts them to a text representation. In the case of JavaScript, it is a toString function. If we override it, the exception "TypeError: Cannot convert object to primitive value" is thrown, and we will receive the flag:
http://challenges.gynvael.stream:5002/?X[length]=900&X[toString]
Level 2

CTF{WaaayBeyondPHPLikeWTF}

Lesson learned: read the documentation much, much more closely.

Challenge 3

The last challenge was published last weekend, and again, we have the Express.js framework.

The code is very short. Can you still find the solution? I hope so. The code is pretty simple.

// 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 have a route /trucolors/ in which the next parameter is the name of the color. The color is then compared to three different colors, and an output is provided. There is also a very important note that the flag is in the name of the file. So what does that mean? It means that we can have some kind of Path Traversal or some kind of information disclosure.

First, I looked for the Path Traversal. I went back to the documentation. The last challenge taught me to read the documentation very, very closely. I went through documentation of the routing for beginners, advanced users, and PHDs. (The last one I made up. Or did I?) 

There wasn’t any information about missing slashes or the strictness of it. Here are the notes I made:

  • The query string is not part of the route.
  • There is a nice Express Route Tester, which provides information about used regexp.

I provided the routing created by Gyn and used the regexper to visualize it.

It looks solid. I tried a few tricks with .., and %2f, but as you can guess, it didn’t work. 

Maybe it’s information disclosure. Maybe we can crash the app somehow. 

I downloaded the source code and ran it locally. I changed the code to crash, and I got a nice backtrace on the web. Let’s try to crash it here. For example, there is a const color. Maybe if there was a way to create another global variable called color, and because the color is const, the app would crash. Maybe we can do it using query parameters. Unfortunately, that was not the case. I also verified in which scenario the in operator may fail, but that was a dead end.

I was pretty frustrated at this point, so I decided to read the Express source code. First, I tried to go back to the path traversing idea. I look around for the regexp keyword, and I found the match function. The function matches the router to the provided path. I added some debugging and analyzed how it is doing the regex matching. This looks solid.

In the same function, there is also a value parsing method.

  this.params = {};
  this.path = match[0]

  var keys = this.keys;
  var params = this.params;

  for (var i = 1; i < match.length; i++) {
    var key = keys[i - 1];
    var prop = key.name;
    var val = decode_param(match[i])

    if (val !== undefined || !(hasOwnProperty.call(params, prop))) {
      params[prop] = val;
    }
  }

It hit me when I noticed the decode_param function. Maybe there is an issue with decoding the parameters. What if the value is an incorrect character, like 0xFF. Bingo! That was the case.
http://challenges.gynvael.stream:5003/truecolors/%FF
URIError: Failed to decode param '%FF'   at decodeURIComponent (<anonymous>)   at decode_param
(/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/layer.js:172:12)   at Layer.match 
(/usr/src/app/CTF{TurnsOutItsNotRegexFault}/node_modules/express/lib/router/layer.js:148:15)
[...]

Summary

I spent a couple hours on these tasks and had great pleasure solving them. I hope Gynvael continued posting such interesting challenges.