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. In this blog post, I’m going to walk you through the solution as a practical example of my new technique.
The Challenge
The goal of the challenge is to leak an authentication token from a <script>
tag:
1 |
|
The only interesting thing we can control is the ?theme=
query parameter:
1 | app.get('/articles/:slug', (req, res) => { |
The parameter ends up inside a <style>
tag, giving us CSS Injection!
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>
- no
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
)
- really, really no JS (missing
Our situation now boils down to leaking the content of a text node using only inline styles and remote images.
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:
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.
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
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:
- Make 1 char appear (
L
) and iterate over the possible character fonts- When checking if there is an
L
, the font applies and makes the text bigger- The height difference is detected and a request is sent (
attacker.com/L
)
- The height difference is detected and a request is sent (
- When checking if there is an
- Make 2 chars appear (
LO
) and iterate over the possible character fonts- When checking if there is an
L
, the font applies and makes the text bigger- No request (font already loaded)
- When checking if there is an
O
, the font applies and makes the text bigger- Font is requested (
attacker.com/O
)
- Font is requested (
- When checking if there is an
- Make 3 chars appear (
LOL
) and iterate over the possible character fonts- When checking if there is an
L
, the font applies and makes the text bigger- No request (font already loaded)
- When checking if there is an
O
, the font applies and makes the text bigger- No request (font already loaded)
- When checking if there is an
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?
Measuring Height using Animation Timelines
Some day, my team mate @realansgar sent me this blog post:
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:
1 | @property --y { |
This is a crucial ingredient, as we can now use calc()
and friends to do conditional styling based on the text height!
The Plan
Let’s combine the height measurement with the previous technique:
- Make the text element have 1 char per line
- Configure letters to have unique heights
- Iteratively remove/hide more and more characters from the text
- Calculate the height difference between two steps to find which character was removed
- 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:
1 | script { /* the element we want to leak from */ |
Unique Letter Heights
To give every letter an individual height, we make use of descent-override
(MDN):
1 | @font-face { |
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.
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:
1 | @keyframes iterate { |
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. 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:
1 | @keyframes enable-save { |
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. 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:
1 | script { |
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.
Limitations
Last but not least, we have to talk about the limiations of this technique.
Floating-Point Inaccuracies
Floating-point values are used for two things:
- Calculating the font size of
::first-line
- 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.
Chrome-only (For Now)
Some of the CSS features used are only available in Chromium-based browsers for now:
CSS Feature | Chrome | Firefox | Safari |
---|---|---|---|
timeline-scope |
>= 116 | - | - |
view-timeline |
>= 115 | - | - |
animation-timeline |
>= 115 | - | - |
animation-range |
>= 115 | - | - |
descent-override |
>= 87 | >= 89 | - |
@container syle(--custom: 1) {} |
>= 111 | - | >= 18 |
This will likely improve over time, but if you find a more compatible way to achieve the same, let me know!
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.
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.
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 or Mastodon. I’ll release a GitHub repo with all the details and a tool for generating a payload in the coming days.
Final payload
1 | /**** CONFIG ****/ |