CSP Policy and Browser Rendering Side Channels

Disclaimer: This article is a personal technical study note. Any operations described should only be performed in authorized environments. Users bear full responsibility for any misuse.

The hxp 38C3 competition included a web challenge named Chromowana Tęcza, which is a compact study of how a strict Content-Security-Policy interacts with browser behaviour that the policy was never designed to constrain. The challenge sources are reproduced below and can be rebuilt from the published container image.

The challenge ships two files: admin.py, which implements the admin bot, and index.php, which emits the CSP header and renders the page. The application is fronted by nginx (NGINX_PORT in admin.py) rather than talking to PHP-FPM directly, so the document carrying both the CSP header and the rendered secret is fetched and reused by the browser across visits. The response cache is therefore part of the setup, and the interaction between it and a per-response nonce is worth examining separately from the injection itself.

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()

The injection point is straightforward, but three restrictions apply at the same time. The Content-Security-Policy above is nonce-based, so an injected script element cannot execute; the template only renders the injected value when it survives a case check; and a front-end script rewrites the rendered text into one span per character. The CSP design follows the nonce-based approach and the accompanying analysis of host whitelists — see https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/ and https://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/45542.pdf — and the challenge additionally depends on Scroll-to-Text-Fragment behaviour, documented at https://xsleaks.dev/docs/attacks/experiments/scroll-to-text-fragment/.

The Dockerfile also installs a headless Chrome build, which indicates that the intended solution depends on browser behaviour rather than on a server-side bug

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/

The nonce mechanism prevents a whole class of indirect script injection, and base-uri prevents <base> tag hijacking. Injecting plain markup nonetheless shows that the request parameter is reflected into the document body:

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

The reflected markup is rendered as follows:

image-20250203161445450

image-20250203161459162

Submitting the form takes a visibly long time, because the admin bot navigates the page repeatedly while it holds the flag cookie. The technique the organizers built the challenge around is Scroll-to-Text-Fragment (STTF), which is described 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 solution published by the team that solved it during the event combines STTF, a details element and an object element; it is reproduced below in its original wording.

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, so the unusual admin.py wasn’t necessary

The decisive observation is that this attack never executes script. The policy is not defeated; it is simply irrelevant to what is being measured. Because the flag value is rendered into the page body, an external observer can measure how much work the browser performs, and that cost depends on whether a guessed character is present in the document. Two ingredients make the measurement practical. The first is a document whose rendering is expensive: a long run of line breaks followed by a large number of lazy-loaded iframe elements makes layout take a visible amount of time. The second is STTF, which only scrolls a document when the text fragment matches — so the expensive rendering is triggered exactly when the guessed character occurs in the flag.

image-20250203165424699

image-20250203171005548

In the challenge instance used for testing, the flag value was icecliffs. Requesting the prepared URL with a character that occurs in the flag produces a distinctly longer response than a character that does not, which is sufficient to recover the value one character at a time.

image-20250203171203133

image-20250203171254299

image-20250203171350785

The specific payload used to build the expensive document, and the measurement script that compared response times per candidate character, are omitted here: against a real deployment, the same code would be a ready-made tool for extracting a secret from a rendered page. What is worth keeping is the shape of the problem — a correctly configured nonce-based policy, a document that reflects user input, and a browser feature that turns rendering cost into an observable signal.

Reference

防御启示

  • Nonces must be per-response and uncacheable: a nonce is only meaningful if it is fresh. Mark authenticated responses with Cache-Control: no-store (and Vary on the relevant headers) so a cached document cannot outlive the request that produced it, and never reuse a nonce across responses or between users.
  • Do not rely on CSP alone against injection: a nonce-based script-src stops script execution but not HTML injection. Pair the policy with contextual output encoding, add object-src 'none', base-uri 'none', frame-ancestors and form-action, and enforce Trusted Types where the platform supports it.
  • Keep secrets out of the rendered document: the attack worked because the flag was printed into the page body. Session state and secrets belong in HttpOnly, Secure, SameSite cookies or server-side sessions; anything rendered into the DOM should be considered readable by an attacker who can measure the page.
  • Isolate cross-origin documents: the measurement needed a cross-origin handle on the target page. Cross-Origin-Opener-Policy: same-origin together with Cross-Origin-Embedder-Policy: require-corp cuts the window-reference and frame-counting channels, and frame-ancestors/X-Frame-Options constrain embedding.
  • Treat the admin bot as an attack surface: a bot that visits attacker-supplied URLs on demand converts any low-signal side channel into a practical attack. Restrict it to same-origin URLs, rate-limit and cap the number of visits, reset its browser profile between visits, and note that STTF can be triggered without user interaction once same-origin HTML injection is possible.

Support via Solana

Solana

Solana

Solana Pay

Solana Pay

WeChat

WeChat