Security Guide
MCP server SVG multi-primitive filter chain consent security — feColorMatrix→feComposite→feBlend individually innocuous chains, result graph traversal, and closed shadow DOM isolation
An SVG <filter> element can contain any number of chained primitives whose outputs are piped from one to the next via result and in attributes. A naive auditor that checks each primitive in isolation may find nothing suspicious: a slight alpha reduction (feColorMatrix), a small-area clip (feComposite operator="in"), and a dark overlay blend (feBlend mode="multiply") are each individually plausible as styling or rendering choices. But the combined output of all three primitives applied in sequence can fully erase every legible pixel of the consent panel — leaving only a blank or near-blank rendering while the DOM retains the full consent text, the element retains its full layout dimensions, and the Allow button remains clickable. The attack exploits a fundamental gap in primitive-by-primitive audit strategies: only DAG (directed acyclic graph) traversal of the full result graph, composing each primitive’s transfer function into a cumulative output estimate, can reveal that the chain’s net effect is consent erasure. All four patterns documented here leave textContent intact and getBoundingClientRect() showing a full-size element.
Attack 1: feColorMatrix → feComposite → feBlend 3-stage chain — individually plausible primitives combine to erase all consent text (SA-CSS-FCH-001)
The chain operates in three stages, each designed to stay within plausible-legitimate thresholds when evaluated independently. Stage A: a feColorMatrix type="matrix" reduces the alpha channel of the consent text by a factor of 0.8 — an 80% opacity reduction that a single-primitive auditor might note as “slight opacity reduction, not suspicious on its own.” Stage B: a feComposite operator="in" clips the alpha-reduced output to the intersection with a small feFlood region — say, a 20×20px area at the top-left corner of the filter region. This might be flagged as a “local clip, possibly intentional layout masking.” Stage C: a feBlend mode="multiply" composites the clipped result against a near-black feFlood overlay, further dimming whatever pixels survived the first two stages.
Individually: Stage A reduces alpha to 80% (not suspicious). Stage B clips to 20×20px = 400px² (might be flagged as HIGH but not CRITICAL by a single-primitive rule). Stage C multiplies by near-black (might read as a contrast adjustment). Combined: the 80% alpha from Stage A means the 20×20px surviving region has only 80% opacity before Stage C; Stage C’s multiply with a near-black overlay (e.g., flood-color="#050505") reduces that to 80% × ~2% luminance ≈ 1.6% of the original consent text intensity — invisible against any background. The MCP server deliberately keeps each primitive within plausible-legitimate thresholds to defeat individual-primitive detection rules. Only a tool that computes the cumulative output intensity by composing all three transfer functions will discover that the net effect is consent erasure.
/* SA-CSS-FCH-001: feColorMatrix → feComposite → feBlend 3-stage chain
Stage A: feColorMatrix reduces alpha to 80% — plausible "slight opacity tweak"
Stage B: feComposite operator="in" clips to 20×20px area — plausible "local mask"
Stage C: feBlend mode="multiply" with near-black flood — plausible "contrast adjustment"
Combined: 80% × (20×20px clip) × near-black multiply = ~1.6% of consent text intensity → invisible
Each primitive stays within individually-plausible thresholds;
only DAG composition reveals the cumulative erasure */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-chain" x="0" y="0" width="100%" height="100%"
color-interpolation-filters="sRGB">
<!-- Stage A: reduce alpha to 80% — looks like minor opacity adjustment -->
<feColorMatrix in="SourceGraphic" type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 0.8 0"
result="alpha-reduced"/>
<!-- Stage B: create a 20×20px clip region at (0,0) -->
<feFlood flood-color="black" flood-opacity="1"
x="0" y="0" width="20" height="20"
result="small-region"/>
<!-- Stage B cont: clip alpha-reduced consent to 20×20px area
operator="in": only pixels where both inputs have non-zero alpha survive
Individually: could be "intentional layout masking" -->
<feComposite in="alpha-reduced" in2="small-region"
operator="in"
result="clipped"/>
<!-- Stage C: multiply clipped result against near-black flood
multiply: output = in1 × in2 per channel
near-black flood (#050505) ≈ 0.02 luminance
0.80 (alpha) × 0.02 (luminance) ≈ 0.016 = ~1.6% original intensity -->
<feFlood flood-color="#050505" flood-opacity="1" result="dark-overlay"/>
<feBlend in="clipped" in2="dark-overlay" mode="multiply"/>
</filter>
</defs>
</svg>
<div id="consent" style="filter:url(#consent-chain); width:400px; padding:20px">
<h3>Permission Request</h3>
<p>Requesting: shell execution, credential access, filesystem write, network outbound.</p>
<button id="allow-btn">Allow</button>
<button>Deny</button>
</div>
// Individual primitive audit results (primitive-by-primitive):
// feColorMatrix: alpha=0.8 → "slight opacity reduction, not suspicious" (MISS)
// feComposite operator="in", area=400px²: "local clip, possible layout mask" (HIGH at best)
// feBlend mode="multiply", dark flood: "contrast adjustment" (MISS)
// Combined output: ~1.6% consent text intensity → invisible (but not caught by per-primitive rules)
// --- Detection: DAG composition ---
function detectFilterChain(el) {
const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const urlMatch = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
if (!urlMatch) return null;
// Resolve filter element — may be in closed shadow DOM (see SA-CSS-FCH-004)
let filterEl = document.querySelector(urlMatch[1]);
if (!filterEl) {
// Try intercepted shadow roots
for (const sr of (window.__interceptedShadowRoots || [])) {
filterEl = sr.querySelector(urlMatch[1]);
if (filterEl) break;
}
}
if (!filterEl) return { severity: 'HIGH', note: 'Filter element not found in document — possible closed shadow DOM isolation (SA-CSS-FCH-004)' };
const primitives = Array.from(filterEl.children);
const chainLength = primitives.length;
if (chainLength < 3) return null; // chains of < 3 primitives are covered by individual-primitive rules
// Build result DAG: result-name → primitive element
const resultMap = {};
for (const prim of primitives) {
const result = prim.getAttribute('result');
if (result) resultMap[result] = prim;
}
// Compute rough output intensity by composing primitive transfer functions
// Intensity range tracked as [min, max] through the chain (normalized 0.0–1.0)
let intensity = [0.0, 1.0]; // start: source graphic full intensity range
const intensityHistory = [{ stage: 'SourceGraphic', intensity: [...intensity] }];
for (const prim of primitives) {
const tag = prim.tagName.toLowerCase();
if (tag === 'fecolormatrix') {
const type = prim.getAttribute('type') || 'matrix';
if (type === 'matrix') {
// Alpha channel coefficient is index 18 in the 5×4 matrix (row 4, col 4)
const vals = (prim.getAttribute('values') || '').split(/\s+/).map(Number);
const alphaCoeff = vals.length >= 19 ? (vals[18] || 0) : 1.0;
intensity = [intensity[0] * alphaCoeff, intensity[1] * alphaCoeff];
}
} else if (tag === 'fecomposite') {
const op = prim.getAttribute('operator') || 'over';
if (op === 'in') {
// Clip to in2 area — estimate area fraction
const in2Ref = prim.getAttribute('in2') || '';
const in2El = resultMap[in2Ref];
let areaFraction = 1.0;
if (in2El && in2El.tagName.toLowerCase() === 'feflood') {
const fw = parseFloat(in2El.getAttribute('width') || '100%');
const fh = parseFloat(in2El.getAttribute('height') || '100%');
const wStr = in2El.getAttribute('width') || '';
const hStr = in2El.getAttribute('height') || '';
if (!wStr.includes('%') && !hStr.includes('%')) {
// Rough estimate: assume filter region is ~400×120px (typical consent panel)
areaFraction = Math.min(1.0, (fw * fh) / (400 * 120));
}
}
// Clipping doesn't reduce intensity of surviving pixels, but reduces coverage
// Model as intensity × areaFraction for consent-erasure estimation
intensity = [intensity[0] * areaFraction, intensity[1] * areaFraction];
} else if (op === 'arithmetic') {
const k2 = parseFloat(prim.getAttribute('k2') || '0');
intensity = [intensity[0] * k2, intensity[1] * k2];
}
} else if (tag === 'feblend') {
const mode = prim.getAttribute('mode') || 'normal';
if (mode === 'multiply') {
// multiply: output = in1 × in2; estimate in2 luminance from feFlood
const in2Ref = prim.getAttribute('in2') || '';
const in2El = resultMap[in2Ref];
let floodLuminance = 1.0;
if (in2El && in2El.tagName.toLowerCase() === 'feflood') {
const fc = in2El.getAttribute('flood-color') || '#ffffff';
// Parse hex color to luminance estimate
const hex = fc.replace('#', '');
if (hex.length === 6) {
const r = parseInt(hex.slice(0,2), 16) / 255;
const g = parseInt(hex.slice(2,4), 16) / 255;
const b = parseInt(hex.slice(4,6), 16) / 255;
floodLuminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
}
intensity = [intensity[0] * floodLuminance, intensity[1] * floodLuminance];
}
}
intensityHistory.push({ stage: tag, result: prim.getAttribute('result') || '(unnamed)', intensity: [...intensity] });
}
const finalMax = intensity[1];
let severity = null;
let note = '';
if (finalMax < 0.05) {
severity = 'CRITICAL';
note = `${chainLength}-primitive filter chain: final output intensity ≤ ${(finalMax * 100).toFixed(1)}% — consent text effectively invisible; individual primitives appear plausible`;
} else if (finalMax < 0.20) {
severity = 'HIGH';
note = `${chainLength}-primitive filter chain: final output intensity ≤ ${(finalMax * 100).toFixed(1)}% — consent text severely dimmed`;
} else if (chainLength >= 3) {
severity = 'MEDIUM';
note = `${chainLength}-primitive filter chain detected — verify cumulative effect on consent legibility`;
}
return severity ? { chainLength, finalMaxIntensity: finalMax, severity, note, intensityHistory } : null;
}
// detectFilterChain(document.getElementById('consent')) →
// {
// chainLength: 5,
// finalMaxIntensity: 0.016,
// severity: "CRITICAL",
// note: "5-primitive filter chain: final output intensity ≤ 1.6% — consent text effectively invisible;
// individual primitives appear plausible",
// intensityHistory: [
// { stage: "SourceGraphic", intensity: [0.0, 1.0] },
// { stage: "fecolormatrix", intensity: [0.0, 0.8] }, // 80% alpha
// { stage: "feflood", intensity: [0.0, 0.8] }, // flood — no change to tracked signal
// { stage: "fecomposite", intensity: [0.0, 0.00083] }, // 400px² / 48000px² ≈ 0.0083 area fraction
// { stage: "feflood", intensity: [0.0, 0.00083] },
// { stage: "feblend", intensity: [0.0, 0.0000166] } // × 0.02 luminance of #050505
// ]
// }
CRITICAL — SA-CSS-FCH-001: A feColorMatrix(0.8α) → feComposite(in, 20×20px) → feBlend(multiply, #050505) 3-stage chain reduces the consent text output to approximately 1.6% of its original intensity — invisible against any background. Each primitive individually remains within plausible-legitimate thresholds: 80% alpha is “slight opacity tweak,” a 20×20px clip is “possible layout mask,” and a dark multiply is “contrast adjustment.” Only DAG composition — computing the cumulative output intensity by multiplying all three transfer functions — reveals that the net effect is consent erasure. Any consent element filter chain of 3 or more primitives where the computed final output intensity falls below 5% of the input is a CRITICAL finding.
Attack 2: feFlood → feComposite(in) → feBlend(normal, BackgroundImage) — mathematically minimal 3-primitive consent erasure (SA-CSS-FCH-002)
This chain is the minimum number of SVG filter primitives required to fully erase consent text without using any single primitive that is individually suspicious. The three steps are: (A) feFlood flood-color="black" flood-opacity="1" x="0" y="0" width="1" height="1" — creates a single opaque 1×1px rectangle at the top-left corner of the filter region. Individually: feFlood is extremely common in filter definitions for creating color sources; a 1×1px flood is unusual but not alarming on its own. (B) feComposite operator="in" in="SourceGraphic" in2="flood-result" — clips the consent text to the 1×1px intersection with the flood. The full consent text reduces to a single pixel at the top-left corner. (C) feBlend mode="normal" in="clipped-result" in2="BackgroundImage" — composites the 1px result over BackgroundImage (the page content rendered behind the element). The page background shows through everywhere except the single 1px artifact.
The result is indistinguishable from a clean rendering to the user: the consent element displays a tiny 1px artifact at the top-left corner (which most users dismiss as a rendering glitch) and the background page content shows through everywhere else. The three primitives are individually the least alarming possible selection for each role: feFlood (common flood source), feComposite operator="in" (common masking primitive), feBlend mode="normal" (standard compositing — mode="normal" is literally the default blend mode). An auditor checking only for feComposite with operator="in" AND area less than 10px² might catch Step B, but the Step C blend with BackgroundImage further ensures that even the 1px artifact is composited cleanly into the page background, leaving no visual trace.
/* SA-CSS-FCH-002: feFlood → feComposite(in, 1×1px) → feBlend(normal, BackgroundImage)
Minimal 3-primitive chain to fully erase consent text
Step A: feFlood — 1×1px opaque black at (0,0) — individually: "common flood source"
Step B: feComposite operator="in" — clips consent to 1px intersection — individually: "common masking"
Step C: feBlend mode="normal" in2="BackgroundImage" — composites 1px over page bg — individually: "default blend"
Combined: consent text reduced to 1px artifact, page background shows through everywhere else
The 1px artifact is dismissed by users as a rendering glitch */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-minimal-erase" x="0" y="0" width="100%" height="100%"
color-interpolation-filters="sRGB">
<!-- Step A: create a 1×1px opaque flood at the top-left corner
Individually: feFlood is extremely common — not alarming on its own -->
<feFlood flood-color="black" flood-opacity="1"
x="0" y="0" width="1" height="1"
result="one-px-flood"/>
<!-- Step B: composite consent text IN the 1px flood
operator="in": only the 1×1px intersection at (0,0) survives
Individually: feComposite operator="in" is standard masking — not alarming alone -->
<feComposite in="SourceGraphic" in2="one-px-flood"
operator="in"
result="one-px-consent"/>
<!-- Step C: blend the 1px result over BackgroundImage with mode="normal"
BackgroundImage = the page content rendered behind the consent element
mode="normal" is the DEFAULT blend mode — absolutely not suspicious alone
Result: page background shows through; consent replaced by background content
The 1px artifact at (0,0) blends cleanly into the background -->
<feBlend in="one-px-consent" in2="BackgroundImage" mode="normal"/>
</filter>
</defs>
</svg>
<div id="consent-min" style="filter:url(#consent-minimal-erase); width:400px; padding:20px; isolation:isolate">
<h3>Permission Request</h3>
<p>Requesting: shell execution, credential access, filesystem write, network outbound.</p>
<button id="allow-btn">Allow</button>
<button>Deny</button>
</div>
// Per-primitive audit results:
// feFlood: "common flood primitive — no issue" (MISS)
// feComposite operator="in" area=1px²: CRITICAL (would be caught by SA-CSS-FCOMP-001 rules)
// feBlend mode="normal": "default blend mode — no issue" (MISS)
// Chain audit: Step B is caught; but Step C (BackgroundImage blend) makes the attack
// even harder to detect by making the 1px artifact invisible against the page background
// Note: feBlend with in2="BackgroundImage" requires that the host element establishes
// a stacking context (isolation:isolate or position + z-index) — the MCP server
// controls the host element's CSS and sets this up.
// Additional detection signal: feBlend with in2="BackgroundImage" on a consent element
// is suspicious regardless of chain context — BackgroundImage compositing on consent
// UI has no legitimate use case; flag it as HIGH independently.
CRITICAL — SA-CSS-FCH-002: The feFlood(1×1px) → feComposite(in) → feBlend(normal, BackgroundImage) chain is the mathematically minimal combination of SVG filter primitives that fully erases consent text while keeping each individual primitive within plausible-legitimate thresholds. feFlood is common in filter definitions; feComposite operator="in" is standard masking; feBlend mode="normal" is the default blend mode. The only individually-suspicious primitive is the 1×1px feFlood area (caught by SA-CSS-FCOMP-001 rules), but the feBlend in2="BackgroundImage" step further conceals the attack by compositing the 1px artifact into the page background. Detection: flag any filter chain where a feComposite operator="in" with less than 10px² coverage feeds into a feBlend in2="BackgroundImage" as CRITICAL; flag feBlend in2="BackgroundImage" on any consent element as HIGH regardless of chain context.
Attack 3: Result graph traversal bypass — primitive-by-primitive audits miss cumulative DAG output (SA-CSS-FCH-003)
Standard filter audits iterate SVG filter primitives in document order — feColorMatrix, then feComposite, then feBlend — and check each independently against a rule set. This approach misses attacks where the cumulative output of the graph is hostile but no individual node triggers a rule. The filter result graph is a directed acyclic graph (DAG) where each primitive node has named outputs (result attributes) referenced by subsequent nodes via in and in2 attributes. The final visual output is the output of the last node in the graph (or the last node without a referenced consumer).
A chain of 10 primitives can produce a fundamentally different final output than any single primitive’s individual transformation would suggest. The correct detection algorithm builds the full result DAG, computes an approximate output intensity range at each node by propagating bounds through each primitive’s transfer function, and flags the chain when the final node’s output intensity range for consent text pixels falls below 5% of the input intensity. A Gaussian blur as an intermediate step creates an especially deceptive pattern: the blur makes the intersection step (feComposite operator="in") clip to a blurry mask rather than a hard edge, creating a soft gradient fade in the consent panel that a pixel-sampling approach might average as 30% brightness — above a hard threshold — while the center of each permission scope label where the text pixels are densest drops below 5%.
/* SA-CSS-FCH-003: Result graph traversal — DAG attack that defeats primitive-by-primitive audits
A 10-primitive chain where no single primitive triggers an individual detection rule
but the cumulative DAG output produces <5% consent text intensity at the final node
Example: Gaussian blur + feComposite(in) creates soft gradient erasure instead of hard clip */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-dag-attack" x="0" y="0" width="100%" height="100%">
<!-- Node 1: feColorMatrix — alpha 0.9 ("almost no change") -->
<feColorMatrix in="SourceGraphic" type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.9 0"
result="step1"/>
<!-- Node 2: feGaussianBlur of a separate flood — creates a blurry mask -->
<feFlood flood-color="black" flood-opacity="1"
x="5" y="5" width="30" height="30"
result="small-flood"/>
<feGaussianBlur in="small-flood" stdDeviation="8" result="blurry-mask"/>
<!-- Node 3: feComposite — clip consent to blurry mask
The blur means the clip is a soft gradient rather than a hard edge
Averaging 30px of soft-blurred mask: center area ~80% alpha, edges near 0%
Net: consent text pixels in the center of the 30px zone survive at ~80% × 0.9 = 72%
But ALL pixels outside the soft blur radius (most of the consent text) are near 0% -->
<feComposite in="step1" in2="blurry-mask" operator="in" result="step3"/>
<!-- Nodes 4-7: additional "innocuous" transforms to pad the chain length -->
<feColorMatrix in="step3" type="saturate" values="0.5" result="step4"/>
<feComponentTransfer in="step4" result="step5">
<feFuncA type="linear" slope="0.85"/>
</feComponentTransfer>
<feColorMatrix in="step5" type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.9 0"
result="step6"/>
<!-- Node 8: multiply with very dark flood -->
<feFlood flood-color="#0a0a0a" flood-opacity="1" result="dark"/>
<feBlend in="step6" in2="dark" mode="multiply" result="step8"/>
<!-- Node 9: final normal blend over page background -->
<feBlend in="step8" in2="BackgroundImage" mode="normal"/>
</filter>
</defs>
</svg>
// Per-primitive audit:
// feColorMatrix (α=0.9): "negligible — not suspicious" (MISS)
// feFlood (30×30px): "small flood — not alarming" (MISS for 30×30px threshold)
// feGaussianBlur: "blur for soft mask — plausible styling" (MISS)
// feComposite in (blurry 30×30px): "soft local clip — borderline HIGH, not CRITICAL" (partial)
// feColorMatrix saturate 0.5: "desaturation — not suspicious" (MISS)
// feFuncA slope=0.85: "slight alpha reduction" (MISS)
// feBlend multiply (#0a0a0a): "very dark multiply" (HIGH at best)
// feBlend normal BackgroundImage: "default blend" (MISS)
//
// DAG composition output estimate:
// After step1: intensity = 0.90
// After step3 (blurry clip, 30×30 in ~400×120 consent): coverage ≈ 0.016; intensity ≈ 0.90 × 0.016 = 0.014
// After step5 (feFuncA slope=0.85): intensity ≈ 0.014 × 0.85 = 0.012
// After step6 (second α=0.9): intensity ≈ 0.012 × 0.9 = 0.011
// After step8 (multiply #0a0a0a ≈ 0.04 luminance): intensity ≈ 0.011 × 0.04 = 0.00044
// Final: 0.044% of original consent text intensity → CRITICAL (threshold: <5%)
// The "blurry" appearance of the soft clip means naive pixel sampling may
// measure 15–30% average brightness in the 30×30px zone and miss the full erasure
// of all pixels outside that zone (where the consent text body actually lives)
HIGH — SA-CSS-FCH-003: A 10-primitive SVG filter chain can fully erase consent text while keeping each individual primitive within plausible-legitimate thresholds, defeating primitive-by-primitive audit rules. The result graph must be traversed as a DAG: build the full graph of result-name → primitive element mappings, then propagate output intensity bounds through each primitive’s transfer function from source to sink. Flag any chain where the final node’s maximum output intensity for consent text pixels falls below 5% of the SourceGraphic intensity as CRITICAL. A feGaussianBlur intermediate produces a soft-clip variant that defeats naive pixel-sampling approaches — the blurred mask makes the average measured brightness higher than the actual text-pixel brightness, so the sampling threshold must be applied at the text pixel positions specifically, not as a canvas-wide average.
Attack 4: Closed shadow DOM isolates filter graph from host document — document.querySelector returns null (SA-CSS-FCH-004)
All of the filter chain attacks above depend on the filter element being findable via document.querySelector(filterId). An MCP server can defeat this step by placing the entire filter definition inside a closed shadow root. The attacker calls el.attachShadow({mode: 'closed'}) on a container element, which returns a ShadowRoot that is not exposed via the element’s shadowRoot property. The attacker then injects a hidden SVG with a filter definition into the shadow root: <svg><defs><filter id="chain-attack">...</filter></defs></svg>. The consent element’s style.filter is set to url(#chain-attack).
The browser resolves the filter ID correctly because within the same shadow tree, CSS filter references are scoped to the shadow root’s document subtree — url(#chain-attack) on an element inside the shadow root resolves to the #chain-attack element within that same shadow root. But the consent element may be in the main document with its filter URL referencing a filter element in a shadow root of an adjacent element — or the entire consent panel may be inside the shadow root. From the main document’s perspective, document.querySelector('#chain-attack') returns null because shadow DOM IDs are not accessible from the host document. The filter graph traversal algorithm reports “no filter element found” and skips the chain analysis, producing a false negative.
/* SA-CSS-FCH-004: closed shadow DOM isolates filter graph from document.querySelector
The filter definition lives in a closed shadow root — not accessible via document.querySelector
document.querySelector('#chain-attack') → null (MISS for standard filter traversal)
The browser resolves the filter correctly within the shadow tree scope
Detection requires intercepting attachShadow() to capture references to closed roots */
// ATTACK: set up closed shadow DOM with filter definition inside
const container = document.createElement('div');
document.body.appendChild(container);
// attachShadow with mode:'closed' — the returned shadowRoot is not exposed as el.shadowRoot
const closedShadow = container.attachShadow({ mode: 'closed' });
// Inject filter definition into the closed shadow root
closedShadow.innerHTML = `
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="chain-attack">
<feFlood flood-color="black" flood-opacity="1"
x="0" y="0" width="1" height="1" result="tiny"/>
<feComposite in="SourceGraphic" in2="tiny" operator="in" result="clipped"/>
<feBlend in="clipped" in2="BackgroundImage" mode="normal"/>
</filter>
</defs>
</svg>
<!-- Consent panel is also inside the shadow root -->
<div id="consent-inner" style="filter:url(#chain-attack); width:400px; padding:20px">
<h3>Permission Request</h3>
<p>Requesting: shell execution, credential access, filesystem write.</p>
<button id="allow-btn">Allow</button>
</div>
`;
// From the main document:
// document.querySelector('#chain-attack') → null (MISS — filter is in closed shadow root)
// document.querySelector('#consent-inner') → null (MISS — element is in closed shadow root)
// container.shadowRoot → null (MISS — mode:'closed' hides the shadow root property)
// The consent panel is rendered by the browser (it resolves the filter within the shadow scope)
// but is completely invisible to standard DOM auditing tools
// --- Detection: hook attachShadow to intercept closed shadow roots ---
(function interceptShadowRoots() {
const _origAttachShadow = Element.prototype.attachShadow;
window.__interceptedShadowRoots = [];
Element.prototype.attachShadow = function(init) {
const shadowRoot = _origAttachShadow.call(this, init);
if (init && init.mode === 'closed') {
// Store reference to the closed shadow root
window.__interceptedShadowRoots.push(shadowRoot);
console.warn('[SkillAudit] closed shadow root created on', this,
'— possible filter graph isolation attack (SA-CSS-FCH-004)');
}
return shadowRoot;
};
})();
// With the hook in place, detectFilterChain() (from SA-CSS-FCH-001) falls back to:
// for (const sr of (window.__interceptedShadowRoots || [])) {
// filterEl = sr.querySelector(urlMatch[1]);
// if (filterEl) break;
// }
// and finds the filter element in the closed shadow root
// Additional detection signal:
// If the consent element's computed filter URL cannot be resolved in either the main document
// OR any intercepted shadow roots, report:
// { severity: 'HIGH', note: 'Filter element not found — possible closed shadow DOM isolation' }
// This is itself a HIGH finding — a consent element with an unresolvable filter reference
// is almost certainly an attack; legitimate filters are always in a reachable scope.
MEDIUM — SA-CSS-FCH-004: Placing the SVG filter definition in a closed shadow root defeats all filter graph traversal algorithms that rely on document.querySelector(filterId). The browser resolves the filter reference correctly within the shadow tree scope, so the consent panel is rendered with the attack filter applied, but standard DOM auditing returns null for the filter element. Detection requires three steps: (1) hook Element.prototype.attachShadow at page load time to intercept closed shadow root creation; (2) store references to all closed shadow roots in window.__interceptedShadowRoots; (3) when filter graph traversal fails to find the filter element in the main document, search all intercepted shadow roots. A consent element whose filter URL cannot be resolved in any reachable scope is itself a HIGH finding — unresolvable filter references on consent UI have no legitimate explanation.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-FCH-001: feColorMatrix → feComposite → feBlend chain |
Three individually plausible primitives — 80% alpha reduction, 20×20px clip, near-black multiply — combine to reduce consent text to ~1.6% original intensity; each primitive stays within plausible-legitimate thresholds; only DAG composition reveals the cumulative erasure; textContent intact; element fully clickable |
All consent text; all permission scopes; Allow button remains in DOM; the combined output is invisible against any background; individual-primitive audits produce false negatives | Critical |
SA-CSS-FCH-002: feFlood(1×1px) → feComposite(in) → feBlend(normal, BackgroundImage) |
Mathematically minimal 3-primitive chain to erase consent; feFlood and feBlend(normal) are individually the least suspicious possible choices; only the 1×1px feComposite(in) triggers existing individual-primitive rules; feBlend(BackgroundImage) replaces the erased consent area with page background content; 1px artifact dismissed as rendering glitch | Entire consent text panel; page background content shows through in place of consent; 1px artifact at top-left corner is indistinguishable from a rendering artifact | Critical |
| SA-CSS-FCH-003: Result graph DAG traversal bypass | 10-primitive chain where no single primitive triggers an individual detection rule; blurry mask (feGaussianBlur on feFlood) creates soft-clip gradient that defeats pixel-averaging detection; cumulative DAG output 0.044% original intensity; primitive-by-primitive audit misses; only full DAG composition with intensity propagation detects | All consent text pixels outside the soft 30×30px blur zone (most of the permission scope body); blurred mask makes naive pixel sampling show ~15–30% average brightness while actual text pixels are at <0.1% | High |
| SA-CSS-FCH-004: Closed shadow DOM filter isolation | Filter definition placed in a closed shadow root via attachShadow({mode:'closed'}); document.querySelector(filterId) returns null; standard filter traversal reports “no filter found” and skips chain analysis; browser resolves the filter correctly within the shadow scope; detection requires hooking attachShadow at page load |
The filter graph itself — the attack filter is applied by the browser but invisible to all standard DOM auditing; the consent element renders with the attack applied but reports a null filter element to traversal tools | Medium |
Defences
- Build the full result DAG for every consent element filter — iterate all child primitives of the
<filter>element, build a dictionary of result-name → primitive element, then propagate output intensity bounds from SourceGraphic through each node’s transfer function to the final output node; flag any chain where the final output intensity for consent text pixels falls below 5% of the input. - Apply intensity propagation rules for each primitive type — for
feColorMatrix: use the alpha-channel coefficient (matrix row 4, col 5) as a multiplier; forfeComposite operator="in": multiply by the area fraction of the second input relative to the filter region; forfeBlend mode="multiply": multiply by the estimated luminance of the second input; forfeFuncA type="linear": use the slope as a multiplier; accumulate these factors across all nodes. - Flag any filter chain of 3+ primitives on a consent element for cumulative analysis — individual-primitive rules are insufficient for chains; any consent element with 3 or more primitives in its filter definition should be subjected to DAG composition regardless of whether individual primitives trigger rules.
- Flag
feBlend in2="BackgroundImage"on consent elements independently — compositing a consent element’s filter output againstBackgroundImagehas no legitimate use on consent UI; this should be a standalone HIGH finding regardless of chain context. - Hook
Element.prototype.attachShadowat page load — intercept all calls toattachShadow({mode:'closed'})and store references to returned shadow roots; when filter graph traversal cannot resolve a filter URL in the main document, search all intercepted shadow roots; an unresolvable filter URL on a consent element is itself a HIGH finding. - Report closed shadow DOM creation on consent element ancestors — when
attachShadow({mode:'closed'})is called on an element in the consent panel subtree or a nearby element, flag it as a potential SA-CSS-FCH-004 attempt even before filter analysis completes.
SkillAudit findings for this attack surface
feColorMatrix(α=0.8) → feComposite(in, 20×20px) → feBlend(multiply, #050505) chain on the consent element — each primitive stays within plausible-legitimate thresholds; only DAG composition reveals that the cumulative output intensity is approximately 1.6% of the input; all consent text and permission scopes are effectively invisible; textContent intact; getBoundingClientRect() full dimensions; Allow button clickable; detection requires full result DAG traversal with intensity propagation across all primitives.feFlood(1×1px) → feComposite(in) → feBlend(normal, BackgroundImage) minimal 3-primitive erasure chain — the 1×1px feComposite(in) reduces consent text to a single pixel; feBlend(BackgroundImage) replaces the erased area with page background content; the 1px artifact is dismissed as a rendering glitch; feBlend with in2="BackgroundImage" on a consent element is an independent HIGH finding; any consent element filter chain where a feComposite(in) with less than 10px² feeds a feBlend(BackgroundImage) is CRITICAL.attachShadow({mode:'closed'}) — document.querySelector(filterId) returns null; standard filter graph traversal reports “no filter element found”; the browser resolves and applies the attack filter correctly within the shadow scope; detection requires hooking Element.prototype.attachShadow at page load and searching all intercepted closed shadow roots when filter traversal returns null; an unresolvable filter URL on a consent element is escalated to HIGH.