SVG Filter Consent Attacks: feFlood, feComposite, and feBlend as Text Erasure Primitives
SVG filter primitives — feFlood, feComposite, and feBlend — form a compositing algebra that can erase consent text completely without touching CSS color, opacity, visibility, or any of the properties that first-generation audit engines check. Unlike CSS blend mode attacks, SVG filter attacks are entirely page-background-independent: the attacker constructs the “white erasing layer” explicitly inside the filter pipeline rather than relying on the page being white. Eight patterns across the SA-SVG-FFL and SA-SVG-FBL families demonstrate the full attack surface, and explain why only end-to-end Canvas simulation can reliably detect chained filter pipelines.
Why SVG filters are a distinct threat vector
CSS consent attacks exploit the compositing relationship between an element and its rendering environment: the page background color, adjacent stacking contexts, ancestor overflow clipping. They are contingent — if the page is dark, mix-blend-mode: lighten stops working. If the container is large enough, overflow: hidden doesn't clip. Defenders can sometimes neutralize a CSS blend attack by changing the page background or containment context.
SVG filter attacks are different in a structurally important way. An SVG <filter> element constructs a self-contained image processing pipeline. Inputs are named explicitly as primitive results or reserved keywords like SourceGraphic, SourceAlpha, or BackgroundImage. The “attacker-controlled white layer” in an SVG filter is not the page background: it is a feFlood primitive that generates a solid-color rectangle of any chosen color. This layer is explicitly specified, not borrowed from the environment. That independence is the core of why SVG filter attacks are harder to neutralize and harder to detect.
A consent text element whose text color is #111111 (dark, high-contrast, WCAG-compliant) can have its visual appearance completely destroyed by one of two filter pipelines: feFlood → feComposite (which replaces the glyph pixels with flood-color) or feFlood → feBlend (which composites the glyph pixels with flood-color using a light-emitting blend mode). In both cases, getComputedStyle(el).color reports rgb(17, 17, 17). The CSS opacity property is 1. The visibility is visible. The element is in the DOM with positive layout dimensions. The only way to know the text is invisible is to render it and look at the pixels.
The SVG filter primitive compositing model
An SVG filter applies to the element it is referenced from via the CSS filter property or the SVG filter attribute. The filter takes the element's rendered pixels as SourceGraphic (RGBA) and alpha channel as SourceAlpha, then passes them through a directed acyclic graph of primitives. Each primitive produces a named intermediate image. The final primitive's output replaces the element's visual rendering in the composited page.
Three primitives matter most for consent attacks:
feFlood— generates a solid rectangle of anyflood-colorat anyflood-opacity, covering the filter region. Produces a single uniform RGBA image.feComposite— applies Porter-Duff compositing operators (over,in,out,atop,xor,arithmetic) to two images. Theinoperator clipsinby the alpha ofin2: it restricts the output region to wherein2has non-zero alpha. Theoveroperator paintsinoverin2using standard alpha compositing.feBlend— applies photographic blend modes (normal,multiply,screen,lighten,darken, etc.) to two images. Equivalent to CSSmix-blend-modebut with explicit input control rather than relying on the page backdrop.
Consent erasure attacks combine these three primitives in specific arrangements. Each arrangement exploits a mathematical identity of the compositing formula with a white input source.
SA-SVG-FFL: feFlood + feComposite attack family
The feComposite-based family constructs the attack by first generating a white image via feFlood, then using feComposite to control where and how that white image covers the consent text. Four distinct patterns emerge from the choices of operator and flood-opacity.
SA-SVG-FFL-001: feFlood white + feComposite operator=in (glyph erasure)
The in operator computes the intersection of two images: its output has non-zero alpha only where both inputs have non-zero alpha, and the output color is taken from the first input (in) clipped by the alpha mask of the second (in2). When the first input is a white feFlood and the second input is SourceAlpha, the result is a white image shaped exactly like the text glyphs.
<filter id="ffl-001">
<feFlood flood-color="white" flood-opacity="1" result="white"/>
<feComposite in="white" in2="SourceAlpha" operator="in"/>
</filter>
<!-- What this produces:
- feFlood: solid white rectangle over filter region
- feComposite in="white" in2="SourceAlpha" operator="in":
output_alpha = min(white.alpha, SourceAlpha.alpha)
= min(1.0, glyph_alpha)
= glyph_alpha (1.0 at glyph pixels, 0 at transparent)
output_color = white.color = white at every non-transparent pixel
Result: white glyphs on transparent background.
When rendered over white page background:
white glyphs on white background = contrast 1:1 = invisible.
Detection signal:
getComputedStyle(el).color → rgb(17,17,17) (unchanged, reports pre-filter color)
Rendered pixels: white at every glyph pixel (only detectable via Canvas)
-->
Why getComputedStyle cannot detect this: The CSS color property reflects the element's style declaration, not the rendered pixel color after SVG filter processing. After feFlood + feComposite operator=in, the element's CSS color is still #111111. The browser renders white pixels. No CSS property query can reveal this discrepancy — only reading the rendered output pixels via Canvas.drawImage and sampling the glyph region will show white at every sampled point.
feFlood flood-color="white" + feComposite operator="in" in2="SourceAlpha": white flood clipped to glyph alpha mask; white glyphs on white page background; WCAG contrast 1:1. getComputedStyle(el).color reports original dark color unchanged. Detection requires Canvas pixel sampling after filter rendering.SA-SVG-FFL-002: feFlood + feComposite operator=over (total region coverage)
The over operator is standard alpha compositing: it paints the first input (in) over the second (in2) using the Porter-Duff over formula. When the first input is a fully opaque white feFlood, the output is white everywhere within the filter region, regardless of the source graphic below it. The filter region defaults to the element's bounding box plus a small margin. If the flood-color matches the page background, every pixel in the filter region becomes page-background-colored, completely hiding the consent text beneath an opaque colored rectangle.
<filter id="ffl-002">
<feFlood flood-color="white" flood-opacity="1" result="white"/>
<feComposite in="white" in2="SourceGraphic" operator="over"/>
</filter>
<!-- Porter-Duff "over" formula:
Co = Cs + Cd * (1 - αs)
With αs = 1 (fully opaque flood): Co = Cs (flood color, white)
The SourceGraphic contribution is zero: Cd * (1-1) = 0
Result: the entire filter bounding box is painted white.
The consent text is buried under a white rectangle.
Comparison to FFL-001:
FFL-001 (operator=in): white shaped like glyphs (transparent elsewhere)
FFL-002 (operator=over): white covering the entire filter region
Both produce white-on-white at the glyph positions.
FFL-002 additionally covers any spacing between glyphs and the element border.
-->
feFlood flood-color="white" flood-opacity="1" + feComposite operator="over" in2="SourceGraphic": fully opaque flood paints white over entire filter region; both glyph pixels and inter-glyph spacing become white; total erasure. Detectable only via Canvas pixel sampling of the filter bounding box.SA-SVG-FFL-003: near-white flood-opacity threshold evasion
Using a flood-opacity just below 1.0 — such as 0.97 — produces a white overlay that is not fully opaque. The resulting contrast depends on the blend of white overlay and underlying dark text. With flood-opacity 0.97, the effective output color mixes 97% white with 3% of the original text color: approximately rgb(250, 250, 250) on a text glyph pixel that was rgb(17, 17, 17). The WCAG contrast ratio between rgb(250,250,250) and the white page background rgb(255,255,255) is approximately 1.06:1 — barely above 1:1 and well below the 4.5:1 threshold for normal text.
<filter id="ffl-003">
<feFlood flood-color="white" flood-opacity="0.97" result="near-white"/>
<feComposite in="near-white" in2="SourceGraphic" operator="over"/>
</filter>
<!-- Threshold evasion analysis:
flood-opacity = 0.97, flood-color = white (1.0, 1.0, 1.0, 0.97)
SourceGraphic text pixel: rgba(17/255, 17/255, 17/255, 1.0) = (0.067, 0.067, 0.067, 1.0)
Porter-Duff over with partial alpha:
Co = Cs * αs + Cd * (1 - αs)
= (1.0 * 0.97) + (0.067 * 0.03)
= 0.970 + 0.002
= 0.972 ≈ rgb(248, 248, 248)
Luminance of output: sRGB 0.972 → linear ≈ 0.938 → relative luminance ≈ 0.938
Luminance of white page background: 1.0
Contrast ratio: (1.0 + 0.05) / (0.938 + 0.05) ≈ 1.06:1
Evasion target:
Scanners checking flood-opacity === 1.0 for "fully opaque flood" will miss 0.97.
Scanners checking flood-color === "white" or "#ffffff" will still catch this variant,
but combining with near-white color + reduced opacity produces even lower contrast:
flood-color="#fefefe" flood-opacity="0.97":
Co = (0.996 * 0.97) + (0.067 * 0.03) ≈ 0.968 → contrast ≈ 1.06:1
(effectively indistinguishable from flood-opacity=1.0 with white flood)
-->
feFlood flood-color="white" flood-opacity="0.97": 97% opaque white over dark text; output ~rgb(248,248,248); WCAG ~1.06:1; evades scanners checking flood-opacity === 1.0. Detection: compute blended output luminance for partial-opacity floods using Porter-Duff formula.SA-SVG-FFL-004: chained feFlood pairs — two-step pipeline evasion
Single-pattern detection engines look for one feFlood followed by one feComposite. A chained pipeline applies two flood-composite steps in sequence, where neither step alone would be classified as suspicious by a rule looking for a specific primitive ordering. The first step might use operator=in to produce a glyph-shaped intermediate; the second step uses operator=over to paint a flood over that intermediate. The final output is still white-on-white, but the filter graph contains four primitives instead of two — splitting the “erasure” action across stages.
<filter id="ffl-004">
<feFlood flood-color="#eeeeee" flood-opacity="1" result="light-gray"/>
<feComposite in="light-gray" in2="SourceAlpha" operator="in" result="shaped-gray"/>
<feFlood flood-color="white" flood-opacity="0.85" result="partial-white"/>
<feComposite in="partial-white" in2="shaped-gray" operator="over"/>
</filter>
<!-- Chain analysis:
Step 1: feFlood #eeeeee → feComposite operator=in in2=SourceAlpha
→ light gray image shaped exactly like the consent glyphs
→ not a complete erasure alone: contrast of #eeeeee on white ≈ 1.22:1 (fails WCAG)
→ but also not detectable by "flood-color=white" pattern alone
→ a scanner checking for "flood-color = near-white" might miss #eeeeee
Step 2: feFlood white 0.85 → feComposite operator=over in2=shaped-gray
→ 85% white over light-gray (#eeeeee) glyph shapes:
Co = (1.0 * 0.85) + (0.933 * 0.15) = 0.850 + 0.140 = 0.990 ≈ white
→ output very close to white-on-white; contrast ≈ 1.01:1
Detection challenge:
- Step 1 alone: uses #eeeeee flood (not pure white), not trivially detectable
- Step 2 alone: uses partial-opacity 0.85 flood, not triggering opacity=1 check
- Combined: total erasure; only end-to-end Canvas simulation reveals white output
The canonical detection method: render the element with filter applied to an
off-screen Canvas, sample pixel colors in the text bounding region, compute
WCAG contrast against the element's effective background. Don't trace the filter
graph — render and measure.
-->
End-to-end Canvas simulation is mandatory for chained filters: Analyzing the filter graph structure cannot reliably detect chained filter attacks because individual primitives may not be suspicious in isolation. The attacker deliberately splits the erasure logic across steps, each step using sub-threshold parameters. The only reliable method is: (1) create an off-screen Canvas, (2) render the consent-text element into the canvas using drawImage or a headless browser screenshot API, (3) read pixel data via getImageData, (4) sample the pixels at known glyph positions, (5) compute WCAG contrast. If contrast is below 4.5:1, flag regardless of how the filter is structured.
feFlood → feComposite pairs: individual steps use sub-threshold parameters (#eeeeee flood, 0.85 opacity), but combined output is near-white (~1.01:1 contrast); evades single-pattern scanners. Detection: end-to-end Canvas pixel sampling only — filter graph analysis insufficient.SA-SVG-FBL: feFlood + feBlend attack family
The feBlend-based family exploits the mathematical properties of light-emitting blend modes (lighten, screen) with a white input to produce white output regardless of source text color. Unlike feComposite attacks that replace glyph pixels with flood color, feBlend attacks compute a per-channel blend formula. With a white input, the formulas for lighten and screen converge to the same identity: output is always white, for any non-white input text color.
SA-SVG-FBL-001: feBlend mode=lighten with white feFlood
The lighten blend mode computes max(A, B) per RGB channel. When the second input B is fully white (all channels at 1.0), max(any_text_channel, 1.0) = 1.0 at every position. The text glyphs become white. The key advantage over CSS mix-blend-mode: lighten is independence from page background: the “white” being blended is an explicit feFlood inside the filter, not the page background color. This attack works equally well on dark-mode pages where the page background is #0a0a0a.
<filter id="fbl-001">
<feFlood flood-color="white" flood-opacity="1" result="white-src"/>
<feBlend in="SourceGraphic" in2="white-src" mode="lighten"/>
</filter>
<!-- feBlend mode=lighten formula:
output_R = max(SourceGraphic.R, white-src.R) = max(0.067, 1.0) = 1.0
output_G = max(SourceGraphic.G, white-src.G) = max(0.067, 1.0) = 1.0
output_B = max(SourceGraphic.B, white-src.B) = max(0.067, 1.0) = 1.0
→ All channels become 1.0 (white)
CSS mix-blend-mode: lighten comparison:
CSS: blends element with page backdrop; if backdrop is dark, max(text, dark) = text
→ no erasure on dark pages
SVG feBlend: blends with explicit white feFlood; max(text, white) = white
→ erasure regardless of page background color
This is the structural advantage: page-background-independence.
-->
feBlend mode="lighten" in2=white feFlood: max(text, 1.0) = 1.0 at every channel; text becomes white; effective on all page backgrounds including dark mode. Structural advantage over CSS mix-blend-mode: no dependency on page background color.SA-SVG-FBL-002: feBlend mode=screen with white input
Screen mode computes 1 - (1 - A) × (1 - B) per channel. When B = 1.0 (white), the formula simplifies to 1 - (1 - A) × 0 = 1 for any value of A. Screen with a white input unconditionally produces white at every channel. Attackers use screen instead of lighten to evade rule-based scanners that specifically check for mode="lighten" when they find a feBlend element adjacent to a bright feFlood.
<filter id="fbl-002">
<feFlood flood-color="white" flood-opacity="1" result="white-src"/>
<feBlend in="SourceGraphic" in2="white-src" mode="screen"/>
</filter>
<!-- feBlend mode=screen formula:
output = 1 - (1 - A) * (1 - B)
With B = 1.0 (white):
output = 1 - (1 - A) * (1 - 1.0) = 1 - (1 - A) * 0 = 1 - 0 = 1.0
Same result as lighten with white input: all channels become 1.0.
Extended coverage of erasure modes (all with white in2):
- lighten: max(A, 1) = 1 → white
- screen: 1-(1-A)(0) = 1 → white
- color-dodge: A/(1-B); with B→1: A/0 → ∞ clamped to 1 → white
- overlay (when B > 0.5): 2AB - A² with screen branch → near white
- hard-light (when A > 0.5): same as screen → white
Correct scanner must enumerate all five erasure modes, not just lighten.
-->
feBlend mode="screen" in2=white feFlood: 1-(1-A)×0=1 at every channel; evasion variant against scanners checking only mode=lighten. Detection must enumerate lighten, screen, color-dodge, overlay, hard-light with near-white in2 luminance.SA-SVG-FBL-003: feBlend mode=multiply with white (identity — not an attack)
Darkening blend modes with a white input produce near-identity output. multiply(A, B) = A × B; with B = 1.0: A × 1.0 = A. The filter has zero visual effect. This is documented here because scanners that generically flag “feBlend + adjacent feFlood” without mode-specific analysis will produce false positives on this combination. A false positive on a benign filter creates noise that can be exploited to trigger reviewer fatigue, masking real attacks buried in a long list of false-positive multiply patterns.
Build a mode classification table: Not all feBlend modes with a white input are attacks. Darkening modes (multiply, darken, color-burn) with white input produce near-identity output. Lightening modes (lighten, screen, color-dodge) produce near-white output. Contrast modes (overlay, hard-light, soft-light) depend on input values. Build a lookup table mapping (mode, in2_luminance) to expected output luminance range. Only flag when expected output luminance produces WCAG contrast below 2.0:1 against the effective background.
feBlend mode="multiply" in2=white feFlood: A × 1.0 = A — identity transformation, no visual effect; NOT an attack. Scanners must not flag this. Generic “feBlend + feFlood” detection without mode analysis generates false positives.SA-SVG-FBL-004: near-white feFlood for keyword-based evasion
Using flood-color="#fdfdfd" (R=G=B=253, normalized ~0.992) with feBlend mode="lighten" produces output at channel 0.992, because max(any_text_channel, 0.992) is 0.992 for any text pixel with channel value below 0.992. The WCAG contrast between this output and a white background is approximately 1.003:1. The evasion exploits scanners that check flood-color against the keyword "white" or the hex string "#ffffff" only.
<filter id="fbl-004">
<feFlood flood-color="#fdfdfd" flood-opacity="1" result="near-white"/>
<feBlend in="SourceGraphic" in2="near-white" mode="lighten"/>
</filter>
<!-- Near-white evasion calculation:
flood-color="#fdfdfd": normalized (0.992, 0.992, 0.992)
text pixel (black): (0.0, 0.0, 0.0)
feBlend lighten: max(0.0, 0.992) = 0.992 at every channel
Output color: rgb(253, 253, 253)
Background: rgb(255, 255, 255) — white page
Luminance of output: ≈ 0.984
Luminance of background: 1.0
Contrast: (1.05) / (0.984 + 0.05) ≈ 1.016:1 — near-invisible
Evasion variants (all below WCAG 4.5:1 on white page):
- "#fefefe": contrast ≈ 1.008:1
- "#f8f8f8": contrast ≈ 1.03:1
- "rgb(253, 253, 253)": avoids hex detection
- "hsl(0, 0%, 99%)": avoids hex/rgb detection
- named CSS colors near white: "snow" = #fffafa, "ghostwhite" = #f8f8ff, "whitesmoke" = #f5f5f5
Detection: parse flood-color regardless of representation → normalize to luminance
→ flag feBlend lighten/screen if max(text_luminance, flood_luminance) produces
contrast below 2.0:1 against effective element background.
-->
feFlood flood-color="#fdfdfd" + feBlend lighten: output channel 0.992; contrast ~1.016:1; evades keyword-based flood-color="white" checks. Detection: normalize flood-color to luminance; compute blend output against background luminance; flag if WCAG below 2.0:1.SVG filter attacks vs CSS blend attacks: the key structural difference
| Property | CSS mix-blend-mode attack | SVG feBlend / feComposite attack |
|---|---|---|
| White source | Page background color (borrowed) | Explicit feFlood (constructed) |
| Page-background dependency | Yes — fails on dark backgrounds | No — works on any background |
| Detectable by CSS property audit | Partially (check mix-blend-mode property) |
No — CSS shows original color, opacity:1 |
| Countermeasure: change background | Effective (breaks lighten/screen on dark bg) | Ineffective (feFlood provides own white source) |
| Chainability | Limited to element stacking context | Arbitrary depth in filter primitive graph |
| Detection method | Property scan + background check | Canvas pixel simulation only |
Detection framework: Canvas-based end-to-end verification
The only reliable method to detect SVG filter consent attacks — including chained pipelines (SA-SVG-FFL-004) — is to render the consent text element into a Canvas and measure the resulting pixel luminance. No analysis of the SVG filter graph structure is sufficient alone, because attackers can distribute the erasure logic across multiple primitives with sub-threshold individual parameters.
async function auditConsentElementContrast(element) {
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return { pass: false, reason: 'zero-area' };
// Render page into an off-screen canvas (Puppeteer Page.screenshot or
// a same-origin canvas via window.print HTML API)
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(rect.width);
canvas.height = Math.ceil(rect.height);
const ctx = canvas.getContext('2d');
// In a headless browser context: use Page.captureScreenshot and draw into canvas
// In a same-origin content script: create an ImageBitmap from the page region
// For simplicity, assume we have a rendered ImageData at this region
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data; // RGBA flat array
// Sample 10% of pixels in the central 80% of the element bounding box
// (avoids border/padding region which may be background-colored)
const samplePixels = [];
const margin = 0.1;
for (let y = Math.floor(canvas.height * margin); y < canvas.height * (1 - margin); y += 3) {
for (let x = Math.floor(canvas.width * margin); x < canvas.width * (1 - margin); x += 3) {
const i = (y * canvas.width + x) * 4;
samplePixels.push([pixels[i], pixels[i+1], pixels[i+2]]);
}
}
const bgColor = getEffectiveBackgroundColor(element);
const bgLuminance = sRGBLuminance(bgColor);
const contrastValues = samplePixels.map(([r, g, b]) => {
const L = sRGBLuminance([r/255, g/255, b/255]);
return Math.max(L, bgLuminance) / (Math.min(L, bgLuminance) + 0.05) *
(Math.max(L, bgLuminance) + 0.05) /
(Math.max(L, bgLuminance) + 0.05);
// Simplified: (lighter + 0.05) / (darker + 0.05)
});
const medianContrast = contrastValues.sort((a,b)=>a-b)[Math.floor(contrastValues.length/2)];
return {
pass: medianContrast >= 4.5,
contrastRatio: medianContrast,
reason: medianContrast < 4.5 ? 'filter-erasure-detected' : 'ok'
};
}
This approach detects all eight patterns in this post, regardless of the filter graph topology, the number of primitives used, the choice of operator or mode, or whether individual primitive parameters are below typical per-pattern thresholds. It works because it measures what the user actually sees, not what the DOM says the element's style properties are.
Implementation note for audit engines: The Canvas approach requires a rendered snapshot of the page at audit time. In a browser extension context, use chrome.tabs.captureVisibleTab or browser.tabs.captureTab. In a headless Puppeteer/Playwright context, use page.screenshot() and crop to the element bounding box. In a static-analysis-only context, the SVG filter graph can be partially analyzed for obvious single-step patterns (FFL-001, FFL-002, FBL-001, FBL-002), but chained pipelines (FFL-004) will be missed without rendering.
Summary: all eight SA-SVG-FFL and SA-SVG-FBL patterns
| ID | Severity | Primitive | Technique | Output | Detectable without Canvas? |
|---|---|---|---|---|---|
| SA-SVG-FFL-001 | CRITICAL | feFlood + feComposite | operator=in + SourceAlpha | White glyphs on white bg | No |
| SA-SVG-FFL-002 | CRITICAL | feFlood + feComposite | operator=over SourceGraphic | White rectangle over entire region | Partial (filter attribute present) |
| SA-SVG-FFL-003 | HIGH | feFlood + feComposite | flood-opacity=0.97 threshold evasion | ~1.06:1 contrast | No |
| SA-SVG-FFL-004 | CRITICAL | feFlood + feComposite (chained) | Two-step pipeline, sub-threshold params | ~1.01:1 contrast | No |
| SA-SVG-FBL-001 | CRITICAL | feFlood + feBlend | mode=lighten + white in2 | White (page-bg independent) | Partial (mode+flood check) |
| SA-SVG-FBL-002 | CRITICAL | feFlood + feBlend | mode=screen + white in2 | White (evades lighten-only check) | Partial (if all modes checked) |
| SA-SVG-FBL-003 | INFO | feFlood + feBlend | mode=multiply + white in2 (identity) | No change (NOT an attack) | N/A — not an attack |
| SA-SVG-FBL-004 | HIGH | feFlood + feBlend | near-white #fdfdfd flood-color | ~1.016:1 contrast | No (keyword evasion) |
Related pages
- SVG feFlood + feComposite consent attacks — detail page (SA-SVG-FFL)
- SVG feBlend lighten/screen consent attacks — detail page (SA-SVG-FBL)
- SVG feComposite operator:in whiteout attacks (SA-SVG-FCO)
- CSS mix-blend-mode consent attacks — page-background-dependent analog (SA-CSS-MBM)
- CSS background overlay and pseudo-element consent attacks (SA-CSS-BGO)
- All SkillAudit security research