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 |
|
Source code
1 | <!DOCTYPE html> |
1 | #!/usr/bin/env python3 |
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 | RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ |
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=?> |
The reflected markup is rendered as follows:


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 itbut 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.


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.



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
- https://docs.google.com/document/d/15HVLD6nddA0OaI8Dd0ayBP2jlGw5JpRD-njAyY1oNZo
- https://xsleaks.dev/docs/attacks/experiments/scroll-to-text-fragment/
- https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/
- https://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/45542.pdf
防御启示
- Nonces must be per-response and uncacheable: a nonce is only meaningful if it is fresh. Mark authenticated responses with
Cache-Control: no-store(andVaryon 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-srcstops script execution but not HTML injection. Pair the policy with contextual output encoding, addobject-src 'none',base-uri 'none',frame-ancestorsandform-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,SameSitecookies 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-origintogether withCross-Origin-Embedder-Policy: require-corpcuts the window-reference and frame-counting channels, andframe-ancestors/X-Frame-Optionsconstrain 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.