SVG Structural Elements as MCP Consent Bypass Vectors: <use> Shadow DOM, viewBox Scaling, and Gradient Cover Attacks
SVG filter primitives — feFlood, feDisplacementMap, feGaussianBlur — get the most attention in MCP consent-bypass research because they produce dramatic effects (solid color fills, pixel displacement, text blur) that are visible to contrast-ratio scanning tools. But a parallel family of attacks exploits SVG structural elements: <use>, <svg viewBox>, and paint-server elements like <linearGradient> and <radialGradient>. These structural attacks are harder to detect because they work through coordinate system transforms and reference resolution rather than filter graph construction. This post documents three structural attack families, fourteen SA-CSS finding codes, and the detection algorithms required to catch each one.
Why structural elements are harder to detect than filter primitives
Filter-based consent attacks require a filter chain that can be audited by traversing the <filter> element and its child primitives. Even complex chains like feMerge N-node accumulation and feImage cross-origin covers (see feMerge N-node accumulation attacks and feImage cross-origin attacks) can be detected by a filter-graph traversal algorithm that evaluates each primitive in sequence.
Structural attacks operate differently. They manipulate the coordinate system, the element reference graph, or the paint server resolution — not the pixel pipeline. Three structural mechanisms are the most exploitable:
- Shadow DOM isolation via
<use>—<use>renders a clone of a<symbol>element inside a shadow root attached to the<use>element. The shadow subtree is invisible todocument.querySelectorAll,document.getElementById, andgetComputedStylewhen called on the host. A white cover rect inside the<symbol>is rendered over consent text but does not exist in the light DOM traversal that most consent audits use. - viewBox coordinate system scaling — the
viewBoxattribute maps SVG user coordinates to viewport pixels. An extreme ratio (e.g.,viewBox="0 0 1 1"on a 400×300 SVG) scales user coordinates 400× in each axis. Consent text positioned at SVG user coordinates x=1.5 is placed at viewport pixel x=600, outside the 400px-wide SVG element and clipped byoverflow:hidden. The SVG element reports a normalgetBoundingClientRect()and the text node exists in the DOM — it is simply rendered outside the visible area. - Gradient fill URL references — an overlay
<rect>over the consent panel usesfill="url(#grad-id)"pointing to a<linearGradient>with stops set to the consent panel background color. Fill-color string checks (getAttribute('fill') === '#ffffff') produce false negatives because thefillattribute value is a URL, not a color string.
All three mechanisms share a common property: getBoundingClientRect() on the consent container returns accurate values, Intersection Observer reports the element as visible, and textContent returns the full consent text string. Layout-based and text-content-based visibility checks are completely defeated.
Family 1: <use> shadow DOM isolation (SA-CSS-SUE)
The four <use>-element attack patterns are documented in detail at SVG <use> element consent attacks. Here is the core mechanism and why standard audits miss it.
An SVG <use> element references a <symbol> element defined elsewhere in the document (or an external SVG file via href). The browser renders the <symbol>’s content inside a shadow root attached to the <use> element. This shadow root is a separate DOM tree — its nodes are not reachable via document.querySelectorAll.
The attack vector:
<!-- Symbol defines a white cover rect -- invisible to document.querySelectorAll -->
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute">
<defs>
<symbol id="cover-sym" viewBox="0 0 1 1" preserveAspectRatio="none">
<!-- This rect exists in shadow DOM — querySelector('rect') from document root finds nothing -->
<rect x="0" y="0" width="1" height="1" fill="white"/>
</symbol>
</defs>
</svg>
<!-- Overlay SVG positioned over consent panel -->
<svg style="position:fixed;left:0;top:0;width:100%;height:100%;z-index:99999;pointer-events:none">
<!-- use element renders the symbol — cover rect is in use's shadow root, not document DOM -->
<use href="#cover-sym" x="0" y="0" width="100%" height="100%"/>
</svg>
CRITICAL — SA-CSS-SUE-001: The white cover rect inside the <symbol> is rendered over consent text but is invisible to document.querySelectorAll('rect'). Any consent audit that only checks the light DOM misses the cover. Detection requires: (1) find all <use> elements whose getBoundingClientRect() overlaps the consent panel; (2) resolve the href attribute to the referenced <symbol> element; (3) inspect the symbol subtree for opaque shapes positioned to cover the consent text area.
The CSS custom property variant (SA-CSS-SUE-002) adds another layer. Instead of a hardcoded white fill, the symbol’s rect uses fill="var(--cover-color, transparent)". The CSS custom property --cover-color is defined on an ancestor element as white. At the symbol definition, the fill appears to be transparent (the fallback value), but at render time the custom property from the ancestor context resolves to white. Static inspection of the symbol definition finds a transparent fill — the attack is only visible when the ancestor chain is also inspected for custom property values that override the fallback.
Family 2: viewBox coordinate system attacks (SA-CSS-VB)
The four viewBox attack patterns are documented in detail at SVG viewBox consent attacks. The mechanism exploits the mathematical relationship between SVG user coordinates and viewport pixels:
For viewBox="0 0 1 1" on a 400px SVG: scale = 400/1 = 400×
Consent text at user x=1.5 → viewport pixel x = 1.5 × 400 = 600px (outside 400px SVG, clipped)
The most dangerous variant is the viewBox window-shift attack (SA-CSS-VB-004). Instead of scaling, it translates the coordinate system by setting a non-zero min-y. An SVG with viewBox="0 500 400 300" renders the region starting at user y=500. Consent text at user y=10 is outside the viewBox window (which starts at y=500) and is therefore clipped. The Allow button can be placed at user y=600, which is inside the window. The user sees a normal-looking button but the consent text explaining what they’re authorizing is invisible.
<!-- SA-CSS-VB-004: viewBox window shift — consent text at y=10 clipped, button at y=600 visible -->
<svg viewBox="0 500 400 300" width="400" height="300" style="...">
<!-- textBCR.top will be < svgBCR.top — detectable by coordinate comparison -->
<text x="20" y="10" font-size="14">This permission grants shell access</text>
<!-- Button at y=600 is inside the viewBox window [500, 800] — visible and clickable -->
<foreignObject x="160" y="600" width="80" height="32">
<button xmlns="http://www.w3.org/1999/xhtml">Allow</button>
</foreignObject>
</svg>
<!-- Detection: convert all text element positions to viewport pixels via the viewBox transform
then check if any text element maps to a pixel y < svgBCR.top or x < svgBCR.left
(i.e., outside the rendered SVG viewport) -->
CRITICAL — SA-CSS-VB-001 and VB-004: Both the extreme-scale attack and the window-shift attack leave textContent intact and getBoundingClientRect() on the SVG element returning normal dimensions. Detection must compute the viewport-pixel coordinates of each consent text element by applying the viewBox-to-viewport transform and checking whether the result falls within the SVG element’s rendered viewport bounds.
The nested inner SVG attack (SA-CSS-VB-003) compounds two viewBox transforms. The outer SVG has a normal viewBox; the inner SVG, nested as a child element, has an extreme viewBox. The outer SVG’s getBoundingClientRect returns normal values. The inner SVG’s BCR also appears reasonable (it is sized to fill the consent panel area). Only the combined scale of both viewBox transforms, evaluated recursively, reveals the sub-pixel rendering of the consent text. Consent audit implementations that check only the outermost SVG’s viewBox ratio are defeated by the nested inner SVG trick.
Family 3: gradient fill URL reference attacks (SA-CSS-GR)
The four gradient attack patterns are documented in detail at SVG gradient consent attacks. The structural vulnerability is that SVG paint servers (<linearGradient>, <radialGradient>, <pattern>) are referenced by URL from fill attributes, and most consent audits only inspect fill attribute values as color strings.
The full-panel cover pattern:
/* SA-CSS-GR-001: linearGradient background-matching full-panel cover
fill="url(#bg-match)" bypasses fill="white" / fill="#ffffff" color-string checks
Detection must resolve url(#id) references to gradient definitions and check stop colors */
<svg width="0" height="0" style="position:absolute">
<defs>
<linearGradient id="bg-match" x1="0%" y1="0%" x2="100%" y2="0%">
<!-- Both stops at consent panel background color, fully opaque -->
<stop offset="0%" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="1"/>
</linearGradient>
</defs>
</svg>
<svg style="position:fixed;left:${bcr.left}px;top:${bcr.top}px;width:${bcr.width}px;height:${bcr.height}px;z-index:99999;pointer-events:none">
<!-- fill value is "url(#bg-match)" — getAttribute('fill') returns the URL string, not "white" -->
<rect x="0" y="0" width="100%" height="100%" fill="url(#bg-match)"/>
</svg>
A more targeted variant uses a radialGradient to cover only the critical permission scope word. The user reads most of the consent text normally but the highest-risk word — “shell”, “credential”, “write” — is hidden under a spot cover. Partial-cover detection rules that check whether more than 50% of the consent panel area is covered will miss this attack, because the covered area is less than 10% of the total panel.
The dynamic background-color injection variant (mentioned in SA-CSS-GR-001) reads the consent panel’s computed background color at runtime via getComputedStyle(consentPanel).backgroundColor and injects a matching gradient. This makes the gradient effective regardless of the MCP host’s UI theme. Static SVG analysis sees only the gradient URL reference; the stop colors are not present in any static file.
Cross-family patterns: what all three share
Despite targeting different browser mechanisms, all three structural attack families share four detection-evasion properties:
| Property | <use> shadow DOM | viewBox scaling | Gradient fill URL |
|---|---|---|---|
| textContent intact | Yes — cover is in shadow DOM, not over text node | Yes — text node present, just off-screen | Yes — text node present, cover is an overlay |
| BCR on consent container | Normal (cover is a separate element) | Normal (reports SVG element dimensions) | Normal (cover is a separate overlay element) |
| Intersection Observer | Fires (consent container intersects viewport) | Fires (SVG intersects viewport) | Fires (consent container intersects viewport) |
| What defeats the attack | Resolving <use> href → symbol subtree inspection | Applying viewBox transform to text element coordinates | Resolving fill URL → gradient stop-color inspection |
The common theme is reference resolution. Each attack works through an indirection: a shadow DOM reference, a coordinate system transform, or a paint server URL. A consent audit that only evaluates the immediate DOM properties of the consent container element — without following these references — cannot detect any of the three attack families.
Detection algorithm: structural element audit
A complete structural element audit requires three passes after the standard filter-graph and overlay analysis:
/**
* Structural element consent audit — three passes
* Run after: filter-graph audit, overlay detection, CSSOM property checks
*/
async function structuralConsentAudit(consentEl) {
const findings = [];
const consentBCR = consentEl.getBoundingClientRect();
// === PASS 1: <use> shadow DOM ===
// Find all <use> elements with BCR overlapping consent panel
for (const useEl of document.querySelectorAll('use')) {
const useBCR = useEl.getBoundingClientRect();
if (!bcrOverlap(useBCR, consentBCR)) continue;
// Resolve href to symbol
const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href') || '';
const symbolEl = document.querySelector(href);
if (!symbolEl) {
findings.push({ severity: 'HIGH', code: 'SA-CSS-SUE-003',
note: `<use href="${href}"> overlaps consent panel but referenced symbol is not in document — possible cross-document symbol (external SVG href)` });
continue;
}
// Inspect symbol subtree for covering shapes
for (const shape of symbolEl.querySelectorAll('rect, circle, ellipse, path, polygon')) {
const fill = shape.getAttribute('fill') || 'black';
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-SUE-001',
note: `<use> shadow DOM contains <${shape.tagName}> fill="${fill}" — shape in shadow subtree invisible to querySelectorAll; overlaps consent panel BCR; inspect for background-matching cover` });
}
// Check for CSS custom property fill tunnels
for (const shape of symbolEl.querySelectorAll('[fill*="var("]')) {
findings.push({ severity: 'HIGH', code: 'SA-CSS-SUE-002',
note: `Symbol shape uses fill="var(--...)" — CSS custom property fill may resolve to background-matching color via ancestor --cover-color definition` });
}
}
// === PASS 2: viewBox coordinate transforms ===
for (const svgEl of document.querySelectorAll('svg')) {
const viewBox = svgEl.getAttribute('viewBox');
if (!viewBox) continue;
const [minX, minY, vbW, vbH] = viewBox.split(/[\s,]+/).map(Number);
const svgBCR = svgEl.getBoundingClientRect();
if (!svgBCR.width || !svgBCR.height) continue;
// Check for extreme scale ratio
const scaleX = svgBCR.width / vbW;
const scaleY = svgBCR.height / vbH;
if (scaleX > 100 || scaleY > 100) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-VB-001',
note: `SVG viewBox="${viewBox}" with ${svgBCR.width}×${svgBCR.height}px → scale ${scaleX.toFixed(0)}×/${scaleY.toFixed(0)}×; consent text at user coords may render outside visible SVG area` });
}
// Check for window shift (non-zero min-x or min-y)
if (Math.abs(minX) > 10 || Math.abs(minY) > 10) {
findings.push({ severity: 'HIGH', code: 'SA-CSS-VB-004',
note: `SVG viewBox minX=${minX} minY=${minY} — coordinate window shifted; content at user coordinates below min values is outside viewport and clipped` });
}
// Check nested inner SVGs for recursive viewBox compression
for (const innerSvg of svgEl.querySelectorAll('svg')) {
const innerVB = innerSvg.getAttribute('viewBox');
if (!innerVB) continue;
const [,, ivW, ivH] = innerVB.split(/[\s,]+/).map(Number);
const innerBCR = innerSvg.getBoundingClientRect();
const combinedScaleX = (innerBCR.width / ivW) * scaleX;
if (combinedScaleX > 100) {
findings.push({ severity: 'HIGH', code: 'SA-CSS-VB-003',
note: `Nested SVG viewBox compound scale: outer ${scaleX.toFixed(1)}× × inner ${(innerBCR.width/ivW).toFixed(1)}× = ${combinedScaleX.toFixed(0)}× combined; consent text in inner SVG is sub-pixel` });
}
}
}
// === PASS 3: gradient fill URL references ===
// Find all elements with fill="url(#...)" overlapping consent panel
for (const el of document.querySelectorAll('[fill^="url("]')) {
const elBCR = el.getBoundingClientRect();
if (!bcrOverlap(elBCR, consentBCR)) continue;
const fillVal = el.getAttribute('fill');
const gradId = fillVal.match(/url\(#([^)]+)\)/)?.[1];
if (!gradId) continue;
const gradEl = document.getElementById(gradId);
if (!gradEl) continue;
const stops = gradEl.querySelectorAll('stop');
const stopData = Array.from(stops).map(s => ({
color: s.getAttribute('stop-color') || 'black',
opacity: parseFloat(s.getAttribute('stop-opacity') || '1')
}));
const allSameOpaque = stopData.length > 0 &&
stopData.every(s => s.color === stopData[0].color && s.opacity >= 0.9);
if (allSameOpaque) {
const bgColor = getComputedStyle(consentEl).backgroundColor;
findings.push({
severity: colorsMatch(stopData[0].color, bgColor) ? 'CRITICAL' : 'HIGH',
code: 'SA-CSS-GR-001',
note: `<${el.tagName}> fill="url(#${gradId})" resolves to uniform gradient (all stops: ${stopData[0].color}, opacity≥0.9)${colorsMatch(stopData[0].color, bgColor) ? ' — matches consent panel background color' : ''}`
});
}
// Check gradientUnits=userSpaceOnUse for BCR-precision targeting
const gradUnits = gradEl.getAttribute('gradientUnits');
if (gradUnits === 'userSpaceOnUse') {
findings.push({ severity: 'MEDIUM', code: 'SA-CSS-GR-003',
note: `Gradient "${gradId}" uses gradientUnits="userSpaceOnUse" — viewport coordinates may be BCR-precision targeting consent element` });
}
}
return findings;
}
function bcrOverlap(a, b) {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}
function colorsMatch(a, b) {
return a.replace(/\s/g,'').toLowerCase() === b.replace(/\s/g,'').toLowerCase();
}
Interaction-triggered variants
All three structural attack families have mousedown-triggered variants (SA-CSS-SUE-004, SA-CSS-VB-004 runtime-inject, SA-CSS-GR-001 dynamic). In these variants, the cover element is not present in the initial page load; it is injected at mousedown on the Allow button and removed at mouseup. During the 50–200ms of the click press, the cover is active and the user is committed to the click.
Static DOM analysis at page load finds nothing suspicious. The attack is only present for the fraction of a second during the click event. Detection requires:
- A
MutationObserverwatching the document root for SVG element insertions - On each mutation, immediately re-run the structural audit against the consent panel
- A
mousedownevent listener on the consent panel that runs the audit synchronously before the default action
These three approaches are complementary. The MutationObserver catches pre-injected covers; the mousedown listener catches injection during the click press itself.
Combined attack surface
The three structural families can be combined with each other and with filter-primitive attacks. A particularly effective combination: a <use> element whose symbol contains a <rect fill="var(--c)"> where --c is set by a MutationObserver callback that reads the consent panel’s computed background color and injects the matching value via style.setProperty. The attack is:
- Static DOM scan: no suspicious rects in light DOM
- CSSOM scan:
fillis a custom property with a transparent fallback - Gradient resolution: no gradients referenced
- viewBox audit: normal viewBox ratio
- Only the combination of: (1) inspecting the
<use>shadow subtree, (2) resolving the custom property via ancestor chain, (3) detecting the MutationObserver callback exposes the full attack chain
SkillAudit runs all three structural audit passes in addition to the filter-graph analysis, CSSOM property checks, and overlay detection. See the audit methodology for the complete detection pipeline.
Summary
| Attack family | SA-CSS codes | Mechanism | What standard audits miss |
|---|---|---|---|
| SVG <use> shadow DOM | SUE-001 through 004 | Cover rect inside <symbol> subtree; shadow root invisible to querySelectorAll; CSS custom property fill tunnel via ancestor chain; cross-document href serves updated symbol; position:fixed overlay | querySelectorAll('rect') from document root finds nothing; getComputedStyle on shadow element fails; cross-origin symbol update undetectable without re-audit on install |
| SVG viewBox scaling | VB-001 through 004 | Extreme viewBox scale ratio clips consent text outside visible SVG area; window shift (min-x/min-y) pushes text out of viewBox window; nested inner SVG compounds scale; preserveAspectRatio=none creates sub-pixel character width | BCR on SVG element reports normal dimensions; textContent present; Intersection Observer fires on SVG element; only viewBox-to-viewport transform computation detects |
| SVG gradient fills | GR-001 through 004 | Overlay rect fill=url(#gradId) references background-matching gradient; radialGradient spot cover on critical word; userSpaceOnUse BCR-precision coordinates; gradientTransform evasion of orientation heuristics | getAttribute('fill') === '#ffffff' check gets false negative (fill is URL string); spot cover <10% panel area evades full-panel detection; gradientTransform confuses orientation-based rules |