External Content Injection and Alpha Channel Manipulation: Five SVG Filter Attack Families on MCP Server Consent Dialogs
Five SVG filter primitive attack families — feImage external content replacement, feTurbulence procedural noise overlay, feComponentTransfer discrete alpha erasure, feComponentTransfer linear alpha slope reduction, and feComposite operator=in white glyph fill — destroy the legibility of MCP consent text through fundamentally different mechanisms. What unites them: all operate after the browser resolves computed styles, all evade CSSOM-based detection, and all require filter primitive chain traversal to detect reliably.
Prior posts in this series have focused on spatial coordinate attacks (filter canvas clipping, asymmetric blur) and specific compositing operators (atop, xor, out). This post covers a structurally different set of attacks: those that either replace the consent content entirely with attacker-controlled material (feImage), or manipulate the alpha channel of the consent text pixels through various primitives (feTurbulence, feComponentTransfer, feComposite operator=in).
The alpha channel framing is important for detector design. A naive detector might check: does the element have opacity < 0.5? That question is irrelevant to these attacks because opacity is a CSS property, not an SVG filter attribute, and these attacks do not touch opacity. A more sophisticated detector might resolve the computed filter property, find the referenced <filter> element, and check for known-bad primitives. But even that is insufficient for feTurbulence (which produces noise via a procedural generator, not via a flood fill) and feImage (which loads external raster content via a URL, making detection sensitive to the resource at that URL, not just its presence in the DOM).
SA-CSS-FIM: feImage external content replacement
The feImage filter primitive fetches an image resource (via href) and uses it as the filter input, entirely replacing the upstream pipeline with the fetched image. When used over consent text, the fetched image becomes the rendered content inside the filter region: the original consent text is discarded and the external image is rendered instead.
This is categorically different from opacity or blur attacks: the consent text is not obscured — it is replaced. The user sees the attacker’s chosen content, not the MCP server’s actual permission request.
/* SA-CSS-FIM-001: attacker SVG replaces consent text with opaque white */
<filter id="fim001">
<!-- href points to attacker-controlled SVG: a white rectangle -->
<feImage href="https://attacker.example/white-cover.svg" result="attackerImg"/>
<feBlend in="attackerImg" in2="SourceGraphic" mode="normal"/>
</filter>
/* white-cover.svg content:
<svg xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="white"/>
</svg>
feBlend mode=normal: attackerImg drawn over SourceGraphic (consent text)
Opaque white rectangle covers the consent text entirely.
CSSOM: getComputedStyle(span).color = "rgb(17,17,17)" — unchanged.
Detection: cross-origin feImage href in consent ancestor filters. */
/* SA-CSS-FIM-002: attacker SVG provides semantically reworded consent text */
/* white-cover.svg replaced with decoy-consent.svg:
<svg xmlns="http://www.w3.org/2000/svg">
<text y="1em" font-size="14" fill="#111">
Allow this MCP server to read files. (But NOT write or delete.)
</text>
</svg>
The attacker writes milder permissions than the real consent.
User sees "read files" but grants "admin access to all tools". */
Semantic substitution risk: SA-CSS-FIM-002 is the most dangerous variant because it produces a syntactically valid consent dialog with correctly-styled text that a user can read. There is no blur, no opacity reduction, no contrast failure to detect. The attack is purely semantic: the visible text does not match the permission being granted. Detection requires comparing feImage href content against the original consent text — a dynamic fetch and text-extraction problem, not a static attribute check.
The TOCTOU variant (SA-CSS-FIM-003/004) swaps the feImage href at the moment of interaction, exploiting the browser’s asynchronous image loading pipeline. A MutationObserver on href changes to feImage elements inside consent ancestor filters is the only reliable runtime detector for these dynamic variants.
SA-CSS-TBO: feTurbulence procedural noise overlay
The feTurbulence primitive generates Perlin noise or fractal noise as a filter output, with no external dependencies. Unlike feImage, there is no URL to inspect; the noise pattern is generated entirely from SVG attributes (baseFrequency, numOctaves, seed, type). When piped through feBlend or feComposite onto the consent text, it covers the text with a visually incoherent pattern that destroys character shape recognition while potentially preserving enough dark pixels for a nave WCAG contrast check to pass.
/* SA-CSS-TBO-001: low-frequency noise blobs cover consent words */
<filter id="tbo001">
<feTurbulence type="turbulence" baseFrequency="0.05" numOctaves="2"
seed="42" result="noise"/>
<feBlend in="noise" in2="SourceGraphic" mode="normal"/>
</filter>
/* baseFrequency=0.05 → noise feature size ≈ 1/0.05 = 20 pixels
20px noise blobs cover individual words (average word width: 30-80px)
No complete sentence remains readable as connected character outlines.
Detection evasion: WCAG point-sampling at fixed coordinates may find dark
pixels at noise valleys (Perlin noise has near-black regions). A naive
contrast tool measuring a single pixel reports passing contrast while
the overall text is unreadable. Spatial coherence analysis required:
are character-width-spaced columns all below 0.4 luminance? */
/* SA-CSS-TBO-004: JS ramps baseFrequency at confirmation time */
function tbo004Activate() {
const ramp = (t0, duration) => {
requestAnimationFrame(now => {
const t = (now - t0) / duration;
if (t < 1) {
const bf = 0.001 + t * 0.8; /* 0.001 → 0.8 over `duration` ms */
document.getElementById('tbo004-turb').setAttribute('baseFrequency', bf.toFixed(4));
ramp(t0, duration);
}
});
};
const t0 = performance.now();
ramp(t0, 500); /* 500ms to reach high-frequency unreadable noise */
}
The dynamic ramp attack (SA-CSS-TBO-004) exploits confirmation timing: the consent dialog opens with very low noise (baseFrequency=0.001, barely visible slow-moving patterns) and reaches high-frequency static noise (baseFrequency=0.8) in 500 milliseconds. A MutationObserver watching baseFrequency on feTurbulence elements that feed into consent ancestor filters can flag this mid-interaction change.
WCAG false positive risk: Because Perlin noise has a continuous probability distribution, a randomly chosen pixel in a noise-covered consent element may fall on a dark noise valley with contrast > 4.5:1. Spatial coherence sampling — measuring luminance at N regular intervals across the text region and checking that at least 80% have adequate contrast — is required for reliable detection of feTurbulence attacks.
SA-CSS-CTA: feComponentTransfer discrete alpha erasure
The feComponentTransfer primitive applies a per-channel transfer function to every pixel. The feFuncA child element controls the alpha channel. With type="discrete", the input alpha range [0, 1] is divided into equal-width bins and each bin is mapped to a fixed output value from the tableValues list. This is a step function: abrupt quantization of the alpha channel into a small number of discrete output levels.
/* SA-CSS-CTA-001: tableValues="0 0 0 0" — all alpha steps map to 0 */
<filter id="cta001">
<feComponentTransfer>
<feFuncA type="discrete" tableValues="0 0 0 0"/>
</feComponentTransfer>
</filter>
/* Input alpha range [0,1] divided into 4 bins:
[0, 0.25) → tableValues[0] = 0 (fully transparent)
[0.25,0.50) → tableValues[1] = 0 (fully transparent)
[0.50,0.75) → tableValues[2] = 0 (fully transparent)
[0.75,1.00] → tableValues[3] = 0 (fully transparent)
Result: EVERY pixel in the element becomes alpha=0.
The consent text is fully invisible (not just low-contrast — invisible).
CSSOM: getComputedStyle(span).opacity = "1" — no change reported.
Detection: feFuncA type=discrete with all tableValues = 0 → CRITICAL. */
/* SA-CSS-CTA-002: tableValues="0.05" — single step maps all alpha to 5% */
<feFuncA type="discrete" tableValues="0.05"/>
/* Single-element tableValues: entire input range [0,1] maps to 0.05.
All pixels rendered at 5% opacity.
WCAG: background assumed white (L=1.0); text color rgb(17,17,17) at A=0.05:
perceived luminance = 0.05 × 0.004 + 0.95 × 1.0 ≈ 0.950; contrast ≈ 1.05:1.
Evades: opacity ≤ 0.01 threshold (tableValues="0.05" > 0.01). */
The discrete tableValues attack has an important detection property: the attacker can choose values that produce any desired alpha step quantization. tableValues="0 0.5 0.5 0.05" maps high-alpha pixels (glyphs, A≥0.75) to 5% opacity while mapping mid-alpha antialiasing pixels (A=0.25–0.75) to 50% — producing nearly-invisible glyph bodies with visible antialiasing fringes, creating a ghostly outline effect that looks like the element has opacity:0.05 without triggering a simple opacity detector.
The TOCTOU variant (SA-CSS-CTA-004) swaps tableValues from a near-identity value ("0 0.33 0.67 1") to full erasure ("0 0 0 0") at mousedown. The near-identity allows the consent to be read normally during the pre-click inspection period; the erasure only activates at the confirmation click. MutationObserver on the tableValues attribute of feFuncA elements in consent ancestor filters detects this.
SA-CSS-CTL: feComponentTransfer linear alpha slope reduction
feFuncA type="linear" applies a linear function to the alpha channel: output = slope × input + intercept. This is a continuous (not step-function) transformation that uniformly scales the alpha of every pixel by the slope value. A slope=0.1 reduces all alpha values to 10% of their original: an opaque glyph (A=1.0) becomes A=0.10, rendering it at 10% opacity against the background.
/* SA-CSS-CTL-001: slope=0.1 — 10× alpha reduction across all pixels */
<filter id="ctl001">
<feComponentTransfer>
<feFuncA type="linear" slope="0.1" intercept="0"/>
</feComponentTransfer>
</filter>
/* At opaque glyph pixel (A=1.0):
output_A = 0.1 × 1.0 + 0 = 0.10
Rendered over white background (L=1.0):
perceived_L = 0.10 × 0.004 + 0.90 × 1.0 ≈ 0.900
WCAG: (1.0+0.05)/(0.900+0.05) = 1.05/0.950 ≈ 1.11:1 — FAIL (needs 4.5:1)
CSSOM: getComputedStyle(span).opacity = "1" ← unchanged
getComputedStyle(span).color = "rgb(17,17,17)" ← unchanged
Both CSSOM checks pass. Filter applies post-layout. */
/* SA-CSS-CTL-002: slope=0 intercept=0.05 — flat 5% alpha, independent of glyph */
<feFuncA type="linear" slope="0" intercept="0.05"/>
/* output_A = 0 × input_A + 0.05 = 0.05 (for every pixel, regardless of A)
Every pixel — including transparent non-glyph pixels — gets A=0.05.
The entire element renders as a 5%-opacity flat surface.
Evades slope-based detection (slope=0, not "slope is near-zero glyph reduction"). */
/* SA-CSS-CTL-003: three chained feComponentTransfer stages, each slope=0.5 */
<filter id="ctl003">
<feComponentTransfer result="s1"><feFuncA type="linear" slope="0.5"/></feComponentTransfer>
<feComponentTransfer in="s1" result="s2"><feFuncA type="linear" slope="0.5"/></feComponentTransfer>
<feComponentTransfer in="s2"><feFuncA type="linear" slope="0.5"/></feComponentTransfer>
</filter>
/* Combined: output_A = 0.5³ × input_A = 0.125 × A
Glyph: A=0.125; WCAG ≈ 1.13:1 — fail.
Each individual stage has slope=0.5 — a moderate reduction that may not
individually trigger a detector. The chain compounds the effect.
Detection must multiply slopes across all chained feComponentTransfer stages. */
Detector implication: For SA-CSS-CTL chains, detection requires traversing all feComponentTransfer stages in the filter primitive chain in order and multiplying the effective alpha scale factors. A detector that checks each stage independently and passes on “slope=0.5 is acceptable” misses three-stage chains that compound to 0.125.
SA-CSS-FCI: feComposite operator=in white glyph fill
The feComposite operator="in" implements the Porter-Duff “in” operation: result = in_color × in2_alpha. When the in source is a white flood fill and in2 is the consent text (SourceGraphic), the white fill is clipped to the exact glyph alpha map. The result: opaque white pixels at every glyph position, transparent pixels between glyphs. Rendered over a white dialog background, this produces white glyphs on white background at 1.00:1 contrast.
/* SA-CSS-FCI-001: feFlood white + feComposite operator=in — canonical form */ <filter id="fci001" color-interpolation-filters="sRGB"> <feFlood flood-color="white" flood-opacity="1" result="whiteFill"/> <feComposite in="whiteFill" in2="SourceGraphic" operator="in"/> </filter> /* At glyph pixel (SourceGraphic A=1.0): result_color = white × 1.0 = white (opaque white glyph) At non-glyph pixel (SourceGraphic A=0.0): result_color = white × 0.0 = transparent Rendered over white dialog: white everywhere (1.00:1). CSSOM: color = "rgb(17,17,17)", opacity = "1" — unchanged. Contrast with operator=out (SA-CSS-COU): operator=out → transparent HOLES at glyph positions (white shows through). operator=in → white FILL at glyph positions (also white over white). Both produce 1.00:1 but via complementary mechanisms. Detectors that check operator=out miss operator=in and vice versa. */
The SA-CSS-FCI attack family shares a detection property with SA-CSS-CAT (atop) and SA-CSS-CXO (xor): all three produce invisible consent text by manipulating which pixels survive compositing, rather than by reducing opacity or applying blur. Detection requires checking the operator attribute on every feComposite element in the filter chain and evaluating the compositing outcome analytically. See the SA-CSS-FCI detail page for all four attack variants including near-white and partial-opacity evasion forms.
Unified theme: alpha channel as the attack surface
Five attacks, five mechanisms, one unifying insight: the SVG filter pipeline gives an attacker direct access to the alpha channel of rendered consent text after the browser has resolved all CSS properties. The computed styles that detectors read (color, opacity, visibility, background-color) describe the element’s state in the CSS rendering model. But SVG filters operate in the compositing model, one step further down the rendering pipeline, where the visible pixels have already been rasterized and the attacker can modify individual pixel alpha values with mathematical precision.
| Family | ID | Mechanism | Detection surface |
|---|---|---|---|
| feImage external content | SA-CSS-FIM | Replaces rendered consent pixels with attacker-provided image | Cross-origin feImage href; MutationObserver on href changes |
| feTurbulence noise overlay | SA-CSS-TBO | Covers consent text with procedural noise; destroys character coherence | feTurbulence feeding feBlend/feComposite in consent ancestor; spatial contrast sampling |
| feComponentTransfer discrete alpha | SA-CSS-CTA | Step-function tableValues maps all alpha values to 0 or near-zero | feFuncA type=discrete with all tableValues ≤ 0.05; MutationObserver on tableValues |
| feComponentTransfer linear alpha | SA-CSS-CTL | slope×A linear scaling reduces all glyph alpha to ≤10% | Cumulative slope product across chained feFuncA linear stages; MutationObserver on slope |
| feComposite operator=in | SA-CSS-FCI | White flood fill clipped to glyph alpha map → white glyphs on white background | feComposite operator=in with flood-color source; complement of operator=out (SA-CSS-COU) |
Why CSSOM detection fails for all five families
A CSSOM-based detector reads CSS properties from the browser’s style engine: getComputedStyle(element).opacity, .color, .filter, .visibility. For all five attack families, none of these properties reflect the actual visual state of the consent text:
- opacity: SVG filters do not set the CSS
opacityproperty. ThefeComponentTransferandfeCompositeattacks reduce pixel alpha after rasterization;getComputedStyle(el).opacityreturns"1"unchanged. - color:
getComputedStyle(el).colorreturns the CSS paint color ("rgb(17,17,17)"). This is the color used to rasterize the text into the filter input buffer. The filter then modifies or replaces that rasterized buffer; the original color value is preserved in the style engine. - filter:
getComputedStyle(el).filterreturns the filter reference string ("url(#fim001)"). This tells you a filter exists but not what it does. Detecting the attack requires resolving the referenced<filter>element and analyzing its primitive chain. - visibility / display: all five attacks produce visible elements (non-hidden, non-display:none) that simply render as white-on-white, noise-covered, or low-opacity content.
Unified detection sketch
A single consent element traversal can detect all five families in one pass:
function auditConsentFilter(consentEl) {
let el = consentEl;
while (el && el !== document.body) {
const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const m = filterVal.match(/url\(["']?#([^)"']+)["']?\)/);
if (m) {
const filterEl = document.getElementById(m[1]);
if (filterEl) return inspectFilterChain(filterEl);
}
el = el.parentElement;
}
return null;
}
function inspectFilterChain(filterEl) {
const findings = [];
/* SA-CSS-FIM: cross-origin feImage */
for (const fi of filterEl.querySelectorAll('feImage')) {
const href = fi.getAttribute('href') || fi.getAttribute('xlink:href') || '';
if (href && !href.startsWith('#') && new URL(href, location.href).origin !== location.origin)
findings.push({ id: 'SA-CSS-FIM', sev: 'CRITICAL', detail: 'cross-origin feImage: ' + href });
}
/* SA-CSS-TBO: feTurbulence feeding feBlend/feComposite */
for (const ft of filterEl.querySelectorAll('feTurbulence')) {
const result = ft.getAttribute('result');
const downstream = filterEl.querySelector(
`feBlend[in="${result}"], feComposite[in="${result}"]`
);
if (downstream)
findings.push({ id: 'SA-CSS-TBO', sev: 'CRITICAL',
detail: 'feTurbulence → ' + downstream.tagName + ' on consent element' });
}
/* SA-CSS-CTA: feFuncA discrete with low tableValues */
for (const fa of filterEl.querySelectorAll('feFuncA[type="discrete"]')) {
const vals = (fa.getAttribute('tableValues') || '').split(/\s+/).map(Number);
if (vals.every(v => v <= 0.1))
findings.push({ id: 'SA-CSS-CTA', sev: 'CRITICAL',
detail: 'feFuncA discrete all tableValues ≤ 0.10' });
}
/* SA-CSS-CTL: feFuncA linear — multiply cumulative slope */
let cumulativeSlope = 1.0;
for (const fa of filterEl.querySelectorAll('feFuncA[type="linear"]')) {
const s = parseFloat(fa.getAttribute('slope') || '1');
const i = parseFloat(fa.getAttribute('intercept') || '0');
cumulativeSlope *= s;
if (i > 0 && i < 0.1) cumulativeSlope = Math.min(cumulativeSlope, i);
}
if (cumulativeSlope < 0.15)
findings.push({ id: 'SA-CSS-CTL', sev: 'CRITICAL',
detail: 'cumulative feFuncA linear slope product = ' + cumulativeSlope.toFixed(3) });
/* SA-CSS-FCI: feComposite operator=in with flood source */
for (const fc of filterEl.querySelectorAll('feComposite[operator="in"]')) {
const inAttr = fc.getAttribute('in') || '';
if (filterEl.querySelector(`feFlood[result="${inAttr}"]`))
findings.push({ id: 'SA-CSS-FCI', sev: 'CRITICAL',
detail: 'feFlood → feComposite operator=in (white glyph clip)' });
}
return findings;
}
This sketch handles static cases. The TOCTOU variants (SA-CSS-FIM-004, SA-CSS-TBO-004, SA-CSS-CTA-004, SA-CSS-CTL-004, SA-CSS-FCI-004) require a MutationObserver on the filter primitive attributes (href, baseFrequency, tableValues, slope, flood-opacity) within all <filter> elements referenced by consent ancestors. The key observation: the attacker must mutate a filter attribute within the browser’s event loop between mousedown and the confirmation action, and this mutation is observable via DOM events.
What SkillAudit checks
SkillAudit’s filter chain audit traverses all <filter> elements referenced by consent ancestor elements and runs the five checks above (plus the 40+ additional SVG filter attack checks documented in this series). The audit runs both at page-load time (catching static attacks) and via a synthetic interaction probe (catching TOCTOU attacks that activate at mousedown or click).
Each of the five families has a dedicated detail page with full attack listings and detection code:
- SA-CSS-FIM: feImage external content replacement (4 attack patterns)
- SA-CSS-TBO: feTurbulence noise overlay (4 attack patterns)
- SA-CSS-CTA: feComponentTransfer discrete alpha (4 attack patterns)
- SA-CSS-CTL: feComponentTransfer linear alpha (4 attack patterns)
- SA-CSS-FCI: feComposite operator=in (4 attack patterns)