Security Research — 2026-10-17
SVG Rendering-Layer Consent Attacks: clip-path, mask, feGaussianBlur, and feColorMatrix as Text-Hiding Mechanisms
Four SVG rendering-layer attack families that hide MCP consent text from users while appearing entirely legitimate to any scanner that reads only DOM attributes or computed styles. Unlike the CSS presentation attribute families covered in previous posts — baseline-shift and dominant-baseline vertical repositioning, letter-spacing and word-spacing scatter, text-decoration ink cover, and the geometric transform families before them — these four families operate entirely below the CSS property layer. They are applied at the rendering pipeline: the geometry clipping stage, the compositing stage, and the filter primitive stage. getComputedStyle reports the text as dark, opaque, and visible; getBoundingClientRect returns non-zero dimensions; display is not none; visibility is not hidden. By every DOM metric the text exists. By every pixel metric the text is gone. This article covers sixteen finding codes across SA-CSS-CLP, SA-CSS-MSK, SA-CSS-FBL, and SA-CSS-CMX, and introduces a unified 5-phase rendering-pipeline detection algorithm.
Contents
- Introduction: the DOM/CSSOM vs. rendering-layer gap
- Family 1: Geometry Clipping —
clip-path(SA-CSS-CLP) - Family 2: Compositing Masks —
mask(SA-CSS-MSK) - Family 3: Gaussian Blur Destruction —
feGaussianBlur(SA-CSS-FBL) - Family 4: Color Channel Remapping —
feColorMatrix(SA-CSS-CMX) - Shared 5-phase detection algorithm
- MutationObserver for all four families
- Detection coverage table (16 attacks)
Introduction: the DOM/CSSOM vs. rendering-layer gap
Most consent-panel auditors work by reading DOM attributes and computed style properties. getComputedStyle(el).fill returns the text color. getComputedStyle(el).opacity returns the alpha. el.style.display and getComputedStyle(el).visibility report whether the element participates in layout. el.getBoundingClientRect() measures the element’s footprint in the viewport. This approach is well-suited to a large class of attacks: invisible text set to opacity: 0, zero-size text with font-size: 0, off-screen positioning with extreme x or y coordinates, and color-matching attacks where fill is set to match the background. All of these attacks leave a fingerprint in the CSS property layer that a getComputedStyle-based scanner can detect.
The rendering-layer attack families covered in this article leave no such fingerprint. getComputedStyle gives you the browser’s computed CSS property values: the values that have been resolved through the cascade, inheritance, and shorthand expansion. It does not give you the output of the rendering pipeline. The rendering pipeline is a separate sequence of operations that begins after CSS properties are resolved and produces the final pixel output painted to the screen. The four families in this article all operate in the rendering pipeline: clip-path operates at the geometry clipping stage, mask operates at the compositing stage, and feGaussianBlur / feColorMatrix operate at the SVG filter primitive stage. A scanner that stops at getComputedStyle never reaches any of these stages and is therefore blind to all four families.
The shared structural property of all four attacks is worth stating explicitly before diving into each family. For any consent text element subjected to one of these attacks: textContent is intact and contains the full permission string; getBoundingClientRect() returns valid, non-zero dimensions; display is not none; visibility is not hidden; fill returns a dark foreground color; opacity is 1.0; font-size is normal; no CSS transform pushes the element off-screen. The text is “there” by every DOM metric. The four families covered here differ in whether they are static (baked into the SVG markup) or dynamic (injected at mousedown). Three can be used as static attacks; all four can also be injected dynamically. The unified insight is that auditing consent panels requires evaluating the full rendering pipeline, not merely the style tree.
Family 1: Geometry Clipping — clip-path (SA-CSS-CLP)
The clip-path attribute defines a clipping region for an SVG element. Pixels that fall outside the clipping region are discarded before compositing: they are never painted regardless of the element’s fill color, opacity, or any other property. This discard happens at the geometry layer, below CSS property resolution. An element with clip-path="inset(100%)" has its entire clipping rectangle collapsed to zero area: every pixel produced by the text glyph rendering falls outside the region and is discarded. The element continues to occupy its normal layout slot, continues to report a non-zero bounding rect (the layout rect, not the visible rect), and continues to report a dark fill color. No CSS property changes. The text is simply not there in the pixel output.
The inset() function specifies the clipping region as insets from the element’s bounding box: top, right, bottom, left, analogous to CSS margin shorthand. inset(100%) applies 100% inset on all four sides simultaneously, which mathematically collapses the rectangle to a degenerate zero-area region. inset(0 0 0 99%) specifies a 99% inset on the left side only, preserving only the rightmost 1% of the element’s width. For a consent text element 300 px wide, this exposes only 3 px of the rightmost column of pixels — a partial edge of the last character, not a readable letter. Both variants are a single attribute value that a naive scanner will never inspect because it is not a CSS property and does not appear in getComputedStyle.
The attack surface is per-element and independent. An attacker can place the header <text> element (“Allow:”) with no clip-path, so it renders normally and anchors the user’s attention, while applying clip-path="inset(100%)" only to the consent scope <text> element (“shell execution, credential access”). The user reads the header, sees blank space where the scope should be, and — if the panel design does not make the blank space visually suspicious — may click Allow. A scanner that reads the parent <svg> element or the panel container finds no clip-path. A scanner that reads only the header text element finds no clip-path. Only a per-element scan that inspects every text node within the consent scope finds the attack. See the full SA-CSS-CLP reference page for SVG code examples and per-variant detection snippets.
SA-CSS-CLP-001: clip-path="inset(100%)" total area collapse
clip-path="inset(100%)" on the consent scope text element collapses the clipping rectangle to zero area. No glyph pixels survive clipping. The element occupies its layout slot, reports a non-zero bounding rect, and has a dark fill color. getComputedStyle does not expose clip-path inset values in a form that a threshold-based CSS scanner can interpret without explicit inset() parsing.
SA-CSS-CLP-002: clip-path="inset(0 0 0 99%)" sliver attack
inset(0 0 0 99%) clips 99% of width from the left, retaining only the rightmost 1% of the element. On a 300 px text element this is 3 px — a non-zero visible area fraction that naive presence-based scanners miss, but far below the 5% legibility threshold. Detection requires computing the visible area fraction from inset percentages.
SA-CSS-CLP-003: per-element scope — header unclipped, consent clipped
The header <text> (“Allow:”) carries no clip-path. The consent scope <text> carries clip-path="inset(100%)". A parent-group or SVG-level clip-path check finds nothing. Per-element inspection of every text node within the consent area is the only reliable detection path.
SA-CSS-CLP-004: mousedown inject clip-path="inset(100%)"
JS injects clip-path="inset(100%)" at mousedown; removes at mouseup. The attribute is absent from any static snapshot. The consent text renders normally at rest and collapses to invisible only during the click window. MutationObserver watching the clip-path and style attributes on all consent text elements is required.
// clip-path detection — SA-CSS-CLP
// Visible area fraction: inset(top right bottom left) reduces area
function parseInsetFraction(insetStr) {
// Returns fraction of area remaining after insets [0, 1]
// inset(100%) → 0.0; inset(0 0 0 99%) → 0.01
const m = insetStr.match(/inset\(\s*([^)]+)\s*\)/i);
if (!m) return 1.0; // unknown shape — assume non-trivial area
const parts = m[1].trim().split(/\s+/);
const toFrac = v => {
if (/%$/.test(v)) return Math.min(parseFloat(v) / 100, 1);
return 0; // absolute lengths require element size — treat as 0 for conservative flag
};
const [t = '0%', r = parts[1] || '0%', b = parts[2] || t, l = parts[3] || r] = parts;
const visH = Math.max(0, 1 - toFrac(t) - toFrac(b));
const visW = Math.max(0, 1 - toFrac(r) - toFrac(l));
return visH * visW;
}
const CLP_LEGIBILITY_THRESHOLD = 0.05; // flag if < 5% of area is visible
function scanClipPath(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text, tspan, g')) {
const cpAttr = el.getAttribute('clip-path') || '';
const cpComp = getComputedStyle(el).clipPath || '';
const cpRaw = cpAttr || cpComp;
if (!cpRaw || cpRaw === 'none') continue;
const frac = parseInsetFraction(cpRaw);
if (frac < CLP_LEGIBILITY_THRESHOLD) {
findings.push({
code: frac === 0 ? 'SA-CSS-CLP-001' : 'SA-CSS-CLP-002',
severity: 'CRITICAL',
el: el.tagName,
note: `clip-path="${cpRaw}"; visible area fraction=${(frac * 100).toFixed(1)}% < threshold ${CLP_LEGIBILITY_THRESHOLD * 100}%`
});
}
}
return findings;
}
// MutationObserver for transient clip-path injection (SA-CSS-CLP-004)
function watchClipPathInject(svgEl, onFinding) {
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
const el = mut.target;
const cp = el.getAttribute('clip-path') || '';
if (cp && cp !== 'none') {
const frac = parseInsetFraction(cp);
if (frac < CLP_LEGIBILITY_THRESHOLD)
onFinding({ code: 'SA-CSS-CLP-004', severity: 'CRITICAL', el: el.tagName,
note: `Dynamic clip-path="${cp}" (area=${(frac*100).toFixed(1)}%) on <${el.tagName}>` });
}
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['clip-path', 'style']
});
return observer;
}
Visible area fraction, not clip-path presence. Flagging every element with a clip-path attribute produces excessive false positives: legitimate SVG consent panels use clip-path to constrain decorative elements within panel borders. The correct signal is a visible area fraction below the 5% legibility threshold, computed by parsing the inset() percentages. For non-inset clip-path shapes (circle(), polygon(), url(#id)), a conservative fallback is to flag any clip-path reference on a consent text element that the scanner cannot evaluate and require human review.
Family 2: Compositing Masks — mask (SA-CSS-MSK)
SVG masks operate at the compositing layer, one stage below geometry clipping. A mask element defines per-pixel alpha (transparency) values that are multiplied against the masked element’s own alpha channel before the element is composited into the scene. The mechanism is the SVG luminance-to-alpha model: by default, the browser converts the luminance of each mask pixel to an alpha value (white = fully opaque, black = fully transparent). A mask element filled entirely with solid black (fill="black") produces an alpha output of zero everywhere: every pixel of the masked text element becomes fully transparent before compositing. The text element’s own fill color, opacity, and all other CSS properties are irrelevant because they are multiplied by zero at the compositing stage.
This is subtler than a clip-path attack. The mask does not change the geometry of the element; it does not collapse the element’s bounding rectangle; it does not remove the element from the layout. The element still occupies its full layout slot and reports a non-zero bounding rect. The element still has a dark fill. The mask attribute appears as a url(#id) reference that points to a <mask> element defined in the SVG <defs>. A scanner that does not resolve this reference and evaluate the mask definition has no way to determine that the text element is transparent. The mask can even be scope-selective: a white fill in the region covering the header text makes that region fully opaque (visible), while a black fill in the region covering the consent scope text makes that region fully transparent (invisible). Both the header and the consent scope element can carry the same mask URL; only the filled region determines what survives compositing.
The dormant mask pattern is particularly dangerous for dynamic attacks. An attacker defines a zero-alpha mask element in <defs> but initially applies no mask attribute to any visible element. The mask definition is invisible to the user and has no visual effect at rest. At mousedown, a single attribute injection — mask="url(#black-mask)" — on the consent text element immediately suppresses all glyph pixels. A scanner that scans only currently-referenced mask elements will never audit the dormant definition. Detection requires scanning all mask elements in <defs> regardless of whether they are currently referenced, flagging any that would produce near-zero alpha output over a text element’s bounds. See the full SA-CSS-MSK reference page for code examples and detection snippets.
SA-CSS-MSK-001: solid black luminance mask, full element coverage
mask="url(#all-black-mask)" where #all-black-mask is a <mask> element with a solid black <rect> covering 100% of the masked element. Black luminance maps to alpha=0 under the SVG luminance-to-alpha model. The text element’s fill still reports dark; opacity is still 1.0. Every glyph pixel is multiplied by alpha=0 before compositing.
SA-CSS-MSK-002: scope-selective mask — white on header, black on consent
A single mask element contains a white fill over the header region and a black fill over the consent scope region. The same mask URL is applied to the whole panel group. Header text survives compositing (alpha=1); consent scope text is zeroed out (alpha=0). The header remains legible to anchor the user’s attention. Evaluation must compute alpha output over the specific bounds of each text element, not just check for mask presence.
SA-CSS-MSK-003: per-element — consent text masked, header unmasked
The header <text> has no mask attribute. The consent scope <text> carries mask="url(#black-mask)". Per-element inspection is required. A group-level or SVG-level scan finds nothing. The zero-alpha mask on the consent element is only visible to a scanner that walks all text nodes in the consent area and resolves their mask references.
SA-CSS-MSK-004: dormant defs + mousedown inject mask attribute
A zero-alpha black mask is defined in <defs> with no current mask attribute on any visible element. JS injects mask="url(#id)" on the consent text element at mousedown; removes at mouseup. The dormant definition must be flagged during the defs pre-scan phase. MutationObserver watching the mask attribute on all consent text elements handles the dynamic injection path.
// mask detection — SA-CSS-MSK
// Resolves mask reference, evaluates luminance output over element bounds
function getMaskAlphaOutput(maskEl) {
// Returns approximate alpha fraction [0, 1] output by the mask over the masked element
// Conservative approach: check fill values of all child rects/paths
let totalAlpha = 0;
let nodeCount = 0;
for (const child of maskEl.querySelectorAll('rect, circle, ellipse, path, polygon, polyline')) {
const fill = child.getAttribute('fill') || getComputedStyle(child).fill || 'black';
// SVG luminance-to-alpha: white (rgb 255,255,255) → alpha 1.0; black → alpha 0.0
// Approximate by checking named colors and rgb values
const isWhite = /^(white|#fff|#ffffff|rgb\(255,\s*255,\s*255\))/i.test(fill.trim());
totalAlpha += isWhite ? 1 : 0;
nodeCount++;
}
// If no children found, or all black → 0.0; all white → 1.0
return nodeCount > 0 ? totalAlpha / nodeCount : 0;
}
const MASK_ALPHA_THRESHOLD = 0.05; // flag if mask produces < 5% alpha output
function scanMasks(svgEl) {
const findings = [];
const svgDoc = svgEl.ownerDocument || document;
// Phase A: scan currently-applied mask attributes
for (const el of svgEl.querySelectorAll('[mask]')) {
const maskRef = el.getAttribute('mask') || '';
const idMatch = maskRef.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const maskEl = svgDoc.getElementById(idMatch[1]);
if (!maskEl) continue;
const alpha = getMaskAlphaOutput(maskEl);
if (alpha < MASK_ALPHA_THRESHOLD) {
findings.push({
code: 'SA-CSS-MSK-001',
severity: 'CRITICAL',
el: el.tagName,
note: `mask="${maskRef}" resolves to near-zero-alpha mask (alpha=${alpha.toFixed(2)}); text composited to transparent`
});
}
}
// Phase B: dormant defs scan — flag unreferenced zero-alpha masks
for (const maskEl of svgEl.querySelectorAll('defs mask')) {
const id = maskEl.getAttribute('id');
if (!id) continue;
const isReferenced = !!svgEl.querySelector(`[mask="url(#${id})"]`);
if (isReferenced) continue; // already checked above
const alpha = getMaskAlphaOutput(maskEl);
if (alpha < MASK_ALPHA_THRESHOLD) {
findings.push({
code: 'SA-CSS-MSK-004',
severity: 'CRITICAL',
el: 'mask#' + id,
note: `Dormant mask "#${id}" in <defs> produces near-zero alpha (alpha=${alpha.toFixed(2)}); mousedown injection candidate`
});
}
}
return findings;
}
// MutationObserver for transient mask injection (SA-CSS-MSK-004)
function watchMaskInject(svgEl, onFinding) {
const svgDoc = svgEl.ownerDocument || document;
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'mask') continue;
const el = mut.target;
const maskRef = el.getAttribute('mask') || '';
const idMatch = maskRef.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const maskEl = svgDoc.getElementById(idMatch[1]);
if (!maskEl) continue;
const alpha = getMaskAlphaOutput(maskEl);
if (alpha < MASK_ALPHA_THRESHOLD)
onFinding({ code: 'SA-CSS-MSK-004', severity: 'CRITICAL', el: el.tagName,
note: `Dynamic mask="${maskRef}" (alpha=${alpha.toFixed(2)}) injected on <${el.tagName}>` });
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['mask']
});
return observer;
}
Resolve the reference; do not flag mask presence alone. Flagging every element with a mask attribute produces false positives on legitimate SVG consent panels that use masks for decorative rounded corners, gradient fades, or icon clipping. The signal is a mask that, when resolved and evaluated, produces near-zero alpha output over the text element’s bounds. The dormant defs scan is equally critical: an unreferenced zero-alpha mask element in <defs> is a mousedown injection candidate that static scanning of currently-applied attributes will miss entirely.
Family 3: Gaussian Blur Destruction — feGaussianBlur (SA-CSS-FBL)
The SVG filter primitive feGaussianBlur operates at the filter primitive stage, which runs after geometry clipping and before compositing. It convolves each pixel with a Gaussian kernel whose radius is approximately 3 × stdDeviation. For text, this means each glyph pixel’s color value is averaged with the surrounding pixels within a radius of three standard deviations in every direction. At low stdDeviation values (relative to font size), this produces a slight softening of glyph edges. At high stdDeviation values, it destroys letterform edges entirely: the high spatial-frequency content of glyph strokes is attenuated to near-zero, and the letter is smeared into an amorphous color haze. A stdDeviation of 20 applied to 13 px text produces a blur kernel radius of 60 px — nearly five times the font size — spreading the glyph’s ink so widely that the peak color intensity at the original glyph position is reduced to a fraction of the original dark fill, leaving only a faint gray smear on a white background.
The legibility threshold is not absolute: it depends on the ratio of stdDeviation to font size. This normalized ratio (stdDeviation / fontSize) is the correct metric for a threshold-based scanner. Empirical testing on representative Latin consent text at 13 px shows that a ratio exceeding 0.15 produces illegibility in the most attack-relevant characters: thin strokes in lowercase i, l, and r are averaged to near-background transparency; character pairs that depend on distinguishing thin strokes (c vs. e, i vs. l, and the notorious rn vs. m pair) become indistinguishable. Permission strings that rely on these characters to communicate scope — “shell”, “credential”, “network” — lose critical distinguishing characters even at the subtle end of the attack range.
The dynamic attack pattern follows the same dormant-defs structure as the mask family. A high-stdDeviation filter element is defined in <defs> but not referenced by any currently-visible element. At mousedown, JS injects filter="url(#blur-id)" on the consent text element. The rendering pipeline applies the blur in the current frame, smearing the letterforms before the user completes the click. A scanner that audits only currently-referenced filter elements will find nothing at rest. Detection requires scanning all filter elements in <defs> for feGaussianBlur primitives with stdDeviation above the legibility threshold, regardless of whether they are currently applied. See the full SA-CSS-FBL reference page for SVG code examples and detection snippets.
SA-CSS-FBL-001: stdDeviation="20" on 13 px text
A blur radius of 60 px on 13 px text fully destroys all letterform edges. The text element occupies its normal layout slot; fill reports dark; bounding rect is non-zero. The rendered output is a faint gray cloud. Normalized ratio: 20/13 = 1.54 — far above the 0.15 legibility threshold. Detection requires resolving the filter reference and evaluating the feGaussianBlur stdDeviation/fontSize ratio.
SA-CSS-FBL-002: subtle attack — stdDeviation="3", ratio 0.23
stdDeviation="3" on 13 px text produces a normalized ratio of 0.23, just above the 0.15 threshold. Thin-stroke characters (i, l, r) become illegible; ambiguous pairs (rn/m, c/e) become indistinguishable. A high-value scanner that only checks large stdDeviation values misses this subtle variant. Threshold-based normalized ratio detection is mandatory.
SA-CSS-FBL-003: per-element blur — consent text blurred, header sharp
The blur filter is applied only to the consent scope text element. The header element has no filter. A panel-level or SVG-level filter scan finds nothing. Per-element inspection, resolving each text element’s filter attribute reference and evaluating the feGaussianBlur stdDeviation/fontSize ratio, is the only reliable detection path.
SA-CSS-FBL-004: dormant defs + mousedown inject filter attribute
A high-stdDeviation feGaussianBlur filter is defined in <defs> with no current filter attribute on any consent text element. JS injects filter="url(#blur-id)" at mousedown; removes at mouseup. Dormant defs pre-scan flags the definition. MutationObserver on filter and style attributes handles the dynamic injection.
// feGaussianBlur detection — SA-CSS-FBL
const BLUR_RATIO_THRESHOLD = 0.15; // stdDeviation / fontSize; flag if exceeds this
function getFilterFontSize(el) {
const cs = getComputedStyle(el);
return parseFloat(cs.fontSize) || 13;
}
function evaluateFilterForBlur(filterEl) {
// Returns max stdDeviation found in any feGaussianBlur primitive
let maxSD = 0;
for (const blur of filterEl.querySelectorAll('feGaussianBlur')) {
const sd = blur.getAttribute('stdDeviation') || '0';
// stdDeviation can be "x y" — take the larger value
const parts = sd.trim().split(/\s+/);
const sdVal = Math.max(...parts.map(Number).filter(n => !isNaN(n)));
if (sdVal > maxSD) maxSD = sdVal;
}
return maxSD;
}
function scanGaussianBlur(svgEl) {
const findings = [];
const svgDoc = svgEl.ownerDocument || document;
// Phase A: currently-applied filter attributes
for (const el of svgEl.querySelectorAll('text, tspan, g')) {
const filterRef = el.getAttribute('filter') || '';
const idMatch = filterRef.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const filterEl = svgDoc.getElementById(idMatch[1]);
if (!filterEl) continue;
const maxSD = evaluateFilterForBlur(filterEl);
if (maxSD === 0) continue;
const fontSize = getFilterFontSize(el);
const ratio = maxSD / fontSize;
if (ratio > BLUR_RATIO_THRESHOLD) {
findings.push({
code: ratio > 1.0 ? 'SA-CSS-FBL-001' : 'SA-CSS-FBL-002',
severity: 'CRITICAL',
el: el.tagName,
note: `feGaussianBlur stdDeviation=${maxSD}; fontSize=${fontSize}px; ratio=${ratio.toFixed(2)} > threshold ${BLUR_RATIO_THRESHOLD}`
});
}
}
// Phase B: dormant defs scan
for (const filterEl of svgEl.querySelectorAll('defs filter')) {
const id = filterEl.getAttribute('id');
if (!id) continue;
const isReferenced = !!svgEl.querySelector(`[filter="url(#${id})"]`);
if (isReferenced) continue;
const maxSD = evaluateFilterForBlur(filterEl);
if (maxSD > 0) {
// Estimate against default font size as worst-case check
const ratio = maxSD / 13;
if (ratio > BLUR_RATIO_THRESHOLD) {
findings.push({
code: 'SA-CSS-FBL-004',
severity: 'CRITICAL',
el: 'filter#' + id,
note: `Dormant feGaussianBlur filter "#${id}" stdDeviation=${maxSD}; ratio=${ratio.toFixed(2)} > threshold; mousedown injection candidate`
});
}
}
}
return findings;
}
Use normalized ratio, not absolute stdDeviation. A fixed stdDeviation threshold (e.g., “flag if stdDeviation > 2”) produces false positives on large-text decorative elements and false negatives on small-text consent panels. The correct metric is stdDeviation divided by the element’s computed font-size in px. A ratio above 0.15 flags attacks across the full range of consent panel font sizes. For dormant defs elements, use the SVG document’s default font size (typically 13–16 px) as a conservative estimate when the filter is not yet applied to any element.
Family 4: Color Channel Remapping — feColorMatrix (SA-CSS-CMX)
The SVG filter primitive feColorMatrix operates at the filter primitive stage alongside feGaussianBlur. Where feGaussianBlur destroys spatial structure, feColorMatrix transforms color structure: it applies a 4 × 5 matrix multiplication to the [R, G, B, A, 1] channel vector of each pixel. The result is a new [R′, G′, B′, A′] vector that becomes the pixel’s output color. A matrix that maps every possible input color to pure white accomplishes complete text concealment: regardless of the text fill color (dark gray, black, navy), every glyph pixel exits the feColorMatrix primitive as pure white (RGB 255, 255, 255), indistinguishable from the panel background. The fill CSS property of the text element still reports the original dark color, because fill is resolved before the filter pipeline runs.
The whiteout matrix has a specific structure: for each of the R, G, and B output rows, all input channel coefficients are set to zero, and the constant column (the fifth column, always multiplied by 1) is set to 1. The matrix row 0 0 0 0 1 produces an output channel value of 0 × R + 0 × G + 0 × B + 0 × A + 1 = 1.0 (in the [0, 1] normalized range), which maps to the maximum channel value regardless of input. Applying this to all three color channels while preserving the alpha channel (row 0 0 0 1 0) produces white pixels with the element’s original alpha. A scanner that inspects the filter chain only for feGaussianBlur primitives will miss a feColorMatrix-only attack entirely.
More challenging for detection is the two-primitive chain attack. An attacker can split the whiteout effect across two sequential primitives: first, a feColorMatrix type="saturate" values="0" that desaturates every pixel to its gray equivalent, removing all color information; second, a feComponentTransfer element with feFuncR slope="0" intercept="1", feFuncG slope="0" intercept="1", and feFuncB slope="0" intercept="1" that maps every gray value to pure white via a linear transfer function with slope=0 (no contribution from input) and intercept=1 (constant output at full white). A scanner that only checks for feColorMatrix elements misses the feComponentTransfer chain. A scanner that checks for feColorMatrix and feComponentTransfer independently misses the attack if it only validates the first primitive in the chain without simulating the full sequential pipeline. See the full SA-CSS-CMX reference page for the full attack taxonomy and detection code.
SA-CSS-CMX-001: whiteout matrix maps all channels to white
type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" maps every pixel to pure white regardless of input. Text fill still reports dark. Output color through the matrix: RGB(1,1,1) = white. Detection requires parsing the 4×5 matrix values and simulating the output color of the text’s fill through the matrix multiplication.
SA-CSS-CMX-002: two-primitive chain (saturate + feComponentTransfer)
feColorMatrix(saturate=0) desaturates to gray; feComponentTransfer(slope=0 intercept=1) maps all gray to white. A scanner that checks only feColorMatrix elements misses the feComponentTransfer step. A scanner that validates only the first primitive in the chain misses the complete transformation. Full pipeline simulation of all primitives in sequence is required.
SA-CSS-CMX-003: per-element whiteout — consent text filtered, header clean
The whiteout filter applies only to the consent scope text element; the header is unfiltered. Per-element filter attribute inspection, resolving each reference and simulating the full filter primitive chain, is the only detection path. A panel-level or SVG-level filter scan finds no filter on the panel group and reports no finding.
SA-CSS-CMX-004: dormant defs + mousedown inject filter attribute
A whiteout feColorMatrix filter is defined in <defs> with no current filter attribute on any consent text element. JS injects filter="url(#id)" at mousedown; removes at mouseup. Dormant defs pre-scan must simulate the filter chain output to flag the definition. MutationObserver on the filter attribute handles the dynamic injection.
// feColorMatrix detection — SA-CSS-CMX
// Simulate filter chain; compute output color of input fill through all primitives
function hexToRgb01(hex) {
const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
|| hex.match(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (!m) return [0.067, 0.067, 0.067, 1]; // assume dark default (#111)
const scale = m[1].length === 1 ? 17 : 1;
return [parseInt(m[1], 16) * scale / 255, parseInt(m[2], 16) * scale / 255, parseInt(m[3], 16) * scale / 255, 1];
}
function applyFeColorMatrix(rgba, el) {
const type = (el.getAttribute('type') || 'matrix').toLowerCase();
const vals = (el.getAttribute('values') || '').trim().split(/[\s,]+/).map(Number);
if (type === 'saturate') {
const s = isNaN(vals[0]) ? 1 : vals[0];
const wr = 0.213 + 0.787 * s, wg = 0.715 - 0.715 * s, wb = 0.072 - 0.072 * s;
const rr = 0.213 - 0.213 * s, rg = 0.715 + 0.285 * s, rb = 0.072 - 0.072 * s;
const br = 0.213 - 0.213 * s, bg = 0.715 - 0.715 * s, bb = 0.072 + 0.928 * s;
return [wr*rgba[0]+wg*rgba[1]+wb*rgba[2], rr*rgba[0]+rg*rgba[1]+rb*rgba[2], br*rgba[0]+bg*rgba[1]+bb*rgba[2], rgba[3]];
}
if (type === 'matrix' && vals.length >= 20) {
const r = vals[0]*rgba[0]+vals[1]*rgba[1]+vals[2]*rgba[2]+vals[3]*rgba[3]+vals[4];
const g = vals[5]*rgba[0]+vals[6]*rgba[1]+vals[7]*rgba[2]+vals[8]*rgba[3]+vals[9];
const b = vals[10]*rgba[0]+vals[11]*rgba[1]+vals[12]*rgba[2]+vals[13]*rgba[3]+vals[14];
const a = vals[15]*rgba[0]+vals[16]*rgba[1]+vals[17]*rgba[2]+vals[18]*rgba[3]+vals[19];
return [r, g, b, a];
}
return rgba;
}
function applyFeComponentTransfer(rgba, el) {
const channels = ['R','G','B','A'];
const out = [...rgba];
for (let i = 0; i < 4; i++) {
const funcEl = el.querySelector(`feFunc${channels[i]}`);
if (!funcEl) continue;
const type = (funcEl.getAttribute('type') || 'identity').toLowerCase();
if (type === 'linear') {
const slope = parseFloat(funcEl.getAttribute('slope') || '1');
const intercept = parseFloat(funcEl.getAttribute('intercept') || '0');
out[i] = Math.min(1, Math.max(0, slope * rgba[i] + intercept));
}
}
return out;
}
function simulateFilterChain(filterEl, inputFill) {
let rgba = hexToRgb01(inputFill);
for (const prim of filterEl.children) {
const tag = prim.tagName.toLowerCase();
if (tag === 'fecolormatrix') rgba = applyFeColorMatrix(rgba, prim);
else if (tag === 'fecomponenttransfer') rgba = applyFeComponentTransfer(rgba, prim);
// feGaussianBlur handled separately; other primitives pass through
}
return rgba; // [r, g, b, a] in [0,1]
}
const WHITEOUT_THRESHOLD = 0.9; // flag if output RGB all above 0.9 (near-white)
function isNearWhite(rgba) {
return rgba[0] >= WHITEOUT_THRESHOLD && rgba[1] >= WHITEOUT_THRESHOLD && rgba[2] >= WHITEOUT_THRESHOLD;
}
function scanColorMatrix(svgEl) {
const findings = [];
const svgDoc = svgEl.ownerDocument || document;
// Phase A: currently-applied filter attributes
for (const el of svgEl.querySelectorAll('text, tspan, g')) {
const filterRef = el.getAttribute('filter') || '';
const idMatch = filterRef.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const filterEl = svgDoc.getElementById(idMatch[1]);
if (!filterEl) continue;
const fillAttr = el.getAttribute('fill') || getComputedStyle(el).fill || '#111';
const outputRgba = simulateFilterChain(filterEl, fillAttr);
if (isNearWhite(outputRgba)) {
findings.push({
code: 'SA-CSS-CMX-001',
severity: 'CRITICAL',
el: el.tagName,
note: `filter="${filterRef}" maps fill "${fillAttr}" to output RGB(${outputRgba.slice(0,3).map(v=>(v*255).toFixed(0)).join(',')}); near-white whiteout detected`
});
}
}
// Phase B: dormant defs scan
for (const filterEl of svgEl.querySelectorAll('defs filter')) {
const id = filterEl.getAttribute('id');
if (!id) continue;
const isReferenced = !!svgEl.querySelector(`[filter="url(#${id})"]`);
if (isReferenced) continue;
const outputRgba = simulateFilterChain(filterEl, '#111111');
if (isNearWhite(outputRgba)) {
findings.push({
code: 'SA-CSS-CMX-004',
severity: 'CRITICAL',
el: 'filter#' + id,
note: `Dormant whiteout filter "#${id}" maps dark input to RGB(${outputRgba.slice(0,3).map(v=>(v*255).toFixed(0)).join(',')}); mousedown injection candidate`
});
}
}
return findings;
}
Simulate the full primitive chain in sequence. The most dangerous feColorMatrix attacks split the whiteout effect across two or more sequential primitives so that no single primitive appears suspicious in isolation. A feColorMatrix(saturate=0) alone is a common and legitimate desaturation effect. A feComponentTransfer(slope=0 intercept=1) alone maps every channel to white but may be dismissed as a test artifact. Only by simulating both primitives in sequence — feeding the output of the first as the input to the second — does the whiteout nature of the chain become apparent. The simulateFilterChain function above implements this sequential simulation for the most common primitive types.
Shared 5-phase detection algorithm
The four rendering-layer attack families share a common detection architecture because they all exploit the same gap: DOM/CSSOM attributes are insufficient and the rendering pipeline must be partially simulated. The following 5-phase algorithm applies uniformly to all four families and serves as the structural specification for a rendering-pipeline-aware consent auditor.
Phase 1: Presence detection. For each consent text element in the panel, check whether it carries any of the four rendering-layer attributes: clip-path, mask, or filter. Also check ancestor groups (<g> elements) that enclose the text, since clip-path and mask can be applied at the group level and will affect all children. Collect the set of (element, attribute, value) triples where a rendering-layer attribute is present and its value is non-trivial (not none, not empty).
Phase 2: Resolve defs references. For every url(#id) reference collected in Phase 1, look up the referenced element in the SVG document by ID. Retrieve the actual <clipPath>, <mask>, or <filter> element from <defs>. If the ID does not resolve (dangling reference), treat this as a potential attack surface and flag it separately: a dangling reference that resolves at mousedown after dynamic DOM modification is a variant of the dormant attack pattern.
Phase 3: Evaluate rendering effect. For clip-path: parse the shape function (inset, circle, polygon, or path), compute the visible area fraction as a percentage of the element’s bounding box, and compare against the 5% legibility threshold. For mask: evaluate the luminance values of the mask element’s fill primitives, compute the average alpha output over the text element’s bounds, and compare against the 5% alpha threshold. For filter: traverse the filter’s child primitives in document order (this is the rendering sequence); for feGaussianBlur, compute the normalized stdDeviation/fontSize ratio; for feColorMatrix and feComponentTransfer, simulate the matrix multiplication or transfer function application; compute the output color of the text element’s fill through the full primitive chain; compare the output RGB against the near-white threshold (each channel above 0.9).
Phase 4: Dormant defs scan. Even if no rendering-layer attribute currently points to a given defs element, scan all <clipPath>, <mask>, and <filter> elements in <defs> regardless of current referencing status. Apply the same evaluation from Phase 3 to each defs element. Any defs element that would hide or destroy consent text if applied is a mousedown injection candidate. Report it as a candidate finding with severity CRITICAL and the note that dynamic injection is the active risk. This phase is what distinguishes a rendering-pipeline auditor from a CSS-property auditor: it proactively identifies the loaded weapon, not just the fired shot.
Phase 5: MutationObserver live monitoring. Install a MutationObserver before simulating any user interaction with the consent panel. Configure it to watch clip-path, mask, filter, and style attributes on all consent text elements and their ancestor groups. Dispatch a synthetic mousedown event at the center of the consent panel’s Allow button bounding rect, wait one animation frame for the DOM to settle, then dispatch a synthetic mouseup. For any attribute mutation that fires during this window, re-run the Phase 3 evaluation for the changed attribute. Disconnect the observer after the simulation completes to avoid accumulating false positives from unrelated page activity. Any mutation that triggers a Phase 3 finding during the interaction simulation is a confirmed dynamic injection attack.
MutationObserver for all four families
The MutationObserver for the rendering-layer families watches three attribute vectors simultaneously: clip-path, mask, and filter. The style attribute is also watched because some injection patterns set rendering-layer properties via inline style (style="clip-path: inset(100%)") rather than as named SVG presentation attributes. On any mutation, the observer re-runs the corresponding Phase 3 evaluation. Because all four families share a single three-attribute surface, a single observer handles the full attack space:
// Combined MutationObserver for all 4 rendering-layer families
function watchAllRenderingLayerAttribs(svgEl, onFinding) {
const svgDoc = svgEl.ownerDocument || document;
const WATCHED = ['clip-path', 'mask', 'filter', 'style'];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
const el = mut.target;
const attr = mut.attributeName;
const val = (attr === 'style')
? el.style.clipPath || el.style.mask || el.style.filter || ''
: el.getAttribute(attr) || '';
if (!val || val === 'none') continue;
// Dispatch to per-family evaluators
if (attr === 'clip-path' || (attr === 'style' && el.style.clipPath)) {
const cpVal = attr === 'clip-path' ? val : el.style.clipPath;
const frac = parseInsetFraction(cpVal);
if (frac < CLP_LEGIBILITY_THRESHOLD)
onFinding({ code: 'SA-CSS-CLP-004', severity: 'CRITICAL', el: el.tagName,
note: `Dynamic clip-path="${cpVal}" (area=${(frac*100).toFixed(1)}%) injected during click window` });
}
if (attr === 'mask') {
const idMatch = val.match(/url\(#([^)]+)\)/);
if (idMatch) {
const maskEl = svgDoc.getElementById(idMatch[1]);
if (maskEl) {
const alpha = getMaskAlphaOutput(maskEl);
if (alpha < MASK_ALPHA_THRESHOLD)
onFinding({ code: 'SA-CSS-MSK-004', severity: 'CRITICAL', el: el.tagName,
note: `Dynamic mask="${val}" (alpha=${alpha.toFixed(2)}) injected during click window` });
}
}
}
if (attr === 'filter') {
const idMatch = val.match(/url\(#([^)]+)\)/);
if (idMatch) {
const filterEl = svgDoc.getElementById(idMatch[1]);
if (filterEl) {
// Check for both blur and color matrix attacks
const fontSize = getFilterFontSize(el);
const maxSD = evaluateFilterForBlur(filterEl);
if (maxSD > 0 && maxSD / fontSize > BLUR_RATIO_THRESHOLD)
onFinding({ code: 'SA-CSS-FBL-004', severity: 'CRITICAL', el: el.tagName,
note: `Dynamic feGaussianBlur filter="${val}" (ratio=${(maxSD/fontSize).toFixed(2)}) during click window` });
const fillAttr = el.getAttribute('fill') || getComputedStyle(el).fill || '#111';
const outputRgba = simulateFilterChain(filterEl, fillAttr);
if (isNearWhite(outputRgba))
onFinding({ code: 'SA-CSS-CMX-004', severity: 'CRITICAL', el: el.tagName,
note: `Dynamic whiteout filter="${val}" maps fill to near-white during click window` });
}
}
}
}
});
observer.observe(svgEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: WATCHED
});
return observer; // disconnect after simulated-click interaction completes
}
// Simulated interaction helper — same pattern as other families
async function simulateRenderingLayerAudit(buttonEl, svgEl, onFinding) {
const rect = buttonEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const obs = watchAllRenderingLayerAttribs(svgEl, onFinding);
try {
buttonEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: cx, clientY: cy }));
await new Promise(r => requestAnimationFrame(r));
buttonEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: cx, clientY: cy }));
await new Promise(r => requestAnimationFrame(r));
} finally {
obs.disconnect();
}
}
Run both phases on every consent panel audit. Phases 1–4 (static pipeline simulation) catch attacks baked into the SVG markup at load time. Phase 5 (MutationObserver + simulated interaction) catches transient mousedown injection variants. The two phases are complementary and must both execute: Phase 5 alone misses statically baked attacks because no mutation fires for markup that was never changed. Phase 1–4 alone misses transient injection attacks because the DOM is clean at rest. Disconnect the MutationObserver immediately after the simulation to prevent accumulation of false positives from normal UI activity.
Detection coverage table (4 families × 4 variants = 16 attacks)
| Finding Code | Family | Mechanism | Bypasses | Severity |
|---|---|---|---|---|
| CLP-001 | SA-CSS-CLP | clip-path="inset(100%)" total area collapse |
getComputedStyle, BCR, fill, opacity, visibility | Critical |
| CLP-002 | SA-CSS-CLP | clip-path="inset(0 0 0 99%)" sliver, 1% visible area |
getComputedStyle, presence checks, BCR | Critical |
| CLP-003 | SA-CSS-CLP | Per-element: header unclipped, consent clipped | Group/SVG-level scan, parent attribute check | High |
| CLP-004 | SA-CSS-CLP | mousedown inject clip-path="inset(100%)", remove at mouseup |
Static attribute scan, getComputedStyle at rest | Critical |
| MSK-001 | SA-CSS-MSK | Solid black luminance mask; entire element alpha=0 | getComputedStyle, fill, opacity, BCR | Critical |
| MSK-002 | SA-CSS-MSK | Scope-selective mask: white over header, black over consent | Mask presence check, group-level scan | Critical |
| MSK-003 | SA-CSS-MSK | Per-element: consent text masked, header unmasked | Group/SVG-level scan, static defs audit without traversal | High |
| MSK-004 | SA-CSS-MSK | Dormant zero-alpha mask in defs; mousedown inject mask |
Currently-referenced-only scan, static attribute scan | Critical |
| FBL-001 | SA-CSS-FBL | feGaussianBlur stdDeviation="20" on 13 px text (ratio 1.54) |
getComputedStyle, fill, opacity, BCR, visibility | Critical |
| FBL-002 | SA-CSS-FBL | stdDeviation="3" subtle blur, ratio 0.23 — thin strokes illegible |
Absolute stdDeviation threshold, visual inspection at normal zoom | Critical |
| FBL-003 | SA-CSS-FBL | Per-element blur: consent text blurred, header sharp | Panel/SVG-level filter scan, parent element check | High |
| FBL-004 | SA-CSS-FBL | Dormant high-blur filter in defs; mousedown inject filter |
Currently-referenced-only filter scan, static audit | Critical |
| CMX-001 | SA-CSS-CMX | Whiteout matrix type="matrix" values="0 0 0 0 1 ..." |
getComputedStyle, fill, opacity, BCR; feGaussianBlur-only scanners | Critical |
| CMX-002 | SA-CSS-CMX | Two-primitive chain: saturate=0 then feComponentTransfer slope=0 intercept=1 | Single-primitive scanners, feColorMatrix-only evaluation | Critical |
| CMX-003 | SA-CSS-CMX | Per-element whiteout filter: consent text filtered, header clean | Group/SVG-level filter scan, parent element check | High |
| CMX-004 | SA-CSS-CMX | Dormant whiteout filter in defs; mousedown inject filter |
Currently-referenced-only scan, static audit without defs traversal | Critical |
The table exposes the shared blind spot that runs across all sixteen attacks: every single attack bypasses getComputedStyle, fill, opacity, and getBoundingClientRect. For twelve of the sixteen attacks, the attack is also invisible to a static attribute scan that only checks currently-applied rendering-layer attributes without resolving and evaluating defs references. For the four dynamic variants (CLP-004, MSK-004, FBL-004, CMX-004), even a fully correct static pipeline simulation fails because the attack attributes are absent at rest. Only the combination of all five phases — presence detection, defs resolution, pipeline simulation, dormant defs scan, and MutationObserver interaction simulation — achieves full coverage across all sixteen variants.
There is one additional asymmetry not visible in the table. The FBL and CMX families share the same filter attribute injection point: a single filter="url(#id)" injection at mousedown can deliver either a feGaussianBlur attack or a feColorMatrix attack depending on which filter definition the ID references. An auditor that evaluates filter references only for blur (FBL) or only for color remapping (CMX) misses the other half of the attack space. The combined Phase 3 evaluation above checks each resolved filter reference for both feGaussianBlur primitives and feColorMatrix/feComponentTransfer primitives, preventing a scanner that covers only one filter primitive type from being bypassed by the other.