Security Guide
MCP server SVG gradientUnits consent security — userSpaceOnUse positions gradient in viewport coordinates to fill consent text with background-matching color, objectBoundingBox zero-range produces solid white, degenerate radial gradient, and mousedown injection of white-stop gradient
SVG gradient elements (<linearGradient>, <radialGradient>) have a gradientUnits attribute that controls the coordinate system for gradient geometry. With gradientUnits="userSpaceOnUse", the gradient’s start and end points (or center and radius for radial) are in SVG viewport user-unit space rather than relative to the filled element. An attacker creates a gradient whose two endpoints are at positions where the color is solid white (matching the SVG background), positioning those endpoints at the text element’s location. The text is filled with white paint — visually identical to the background — while the fill attribute references a gradient ID (not none), textContent is intact, BCR is non-zero, and opacity/visibility checks pass. Four attack patterns: userSpaceOnUse white-fill alignment, objectBoundingBox zero-range degenerate gradient, radial gradient focal-point collapse, and mousedown injection.
Attack 1: gradientUnits="userSpaceOnUse" — gradient endpoints at text position produce solid white fill (SA-CSS-GPU-001)
With gradientUnits="userSpaceOnUse", the gradient’s x1/y1 and x2/y2 attributes are SVG viewport coordinates. If the attacker sets both x1 and x2 to the same value (e.g., x1="20" x2="20"), the gradient has zero length along the x-axis. A zero-length linear gradient degenerates to a solid color equal to the last stop. Setting that last stop to stop-color="white" fills the text with solid white.
Alternatively, the attacker positions the gradient endpoints far to the right of the text element (e.g., x1="1000" x2="1001" while the text is at x=20). With spreadMethod="pad" (the default), a gradient sampled at positions before x1 uses the first stop color. If the first stop is white, the text is filled with white because all glyph positions are to the left of the gradient start point. In both cases, the fill attribute shows a valid gradient URL, not none, and scanners that only check for fill="none" or opacity=0 find nothing suspicious.
<!-- SA-CSS-GPU-001: gradientUnits="userSpaceOnUse"; x1=x2 (degenerate zero-length gradient) -->
<!-- degenerate linear gradient: zero length → solid color = last stop → white -->
<!-- OR: gradient starts at x=1000; text at x=20; pad=first stop (white); text = white -->
<!-- fill="url(#grad-gpu-001)": not "none"; fill-check scanner PASSES -->
<!-- textContent intact; BCR non-zero; white on white background: invisible -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Variant A: x1=x2 (degenerate) → solid white; gradientUnits=userSpaceOnUse -->
<linearGradient id="grad-gpu-001" gradientUnits="userSpaceOnUse"
x1="20" y1="50" x2="20" y2="50">
<stop offset="0" stop-color="white"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<!-- Variant B: gradient at x=1000; text at x=20; pad → first stop (white) -->
<linearGradient id="grad-gpu-001b" gradientUnits="userSpaceOnUse"
x1="1000" y1="0" x2="1440" y2="0" spreadMethod="pad">
<stop offset="0" stop-color="white"/>
<stop offset="1" stop-color="black"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- fill references gradient (not "none"); gradient produces white at text location -->
<text x="20" y="50" font-size="13"
fill="url(#grad-gpu-001)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: check gradient stops for background-matching colors + userSpaceOnUse positioning
function checkGradientUnitsWhiteFill(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
const WHITE_COLORS = ['white', '#fff', '#ffffff', 'rgb(255,255,255)', 'rgba(255,255,255,1)'];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const text = textEl.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillAttr = textEl.getAttribute('fill') ?? '';
const gradIdMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!gradIdMatch) continue;
const gradEl = document.getElementById(gradIdMatch[1]);
if (!gradEl || !['linearGradient','radialGradient'].includes(gradEl.tagName)) continue;
const units = gradEl.getAttribute('gradientUnits') || 'objectBoundingBox';
const stops = Array.from(gradEl.querySelectorAll('stop'));
const allStopsWhite = stops.every(s => {
const c = s.getAttribute('stop-color') ?? s.style.stopColor ?? '';
return WHITE_COLORS.some(w => c.toLowerCase().replace(/\s+/g,'') === w.replace(/\s+/g,''));
});
if (units === 'userSpaceOnUse') {
// Check for degenerate gradient (x1=x2 for linear)
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
const y1 = parseFloat(gradEl.getAttribute('y1') ?? '0');
const y2 = parseFloat(gradEl.getAttribute('y2') ?? '0');
const isDegenerate = (x1 === x2 && y1 === y2);
if (isDegenerate || allStopsWhite) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-GPU-001',
note: `SVG <${textEl.tagName}> fill="url(#${gradIdMatch[1]})"; ` +
`gradient is <${gradEl.tagName}>; gradientUnits="${units}"; ` +
`x1=${x1} x2=${x2} y1=${y1} y2=${y2}; ` +
(isDegenerate ? `DEGENERATE (x1=x2 and y1=y2) → solid color = last stop; ` : '') +
(allStopsWhite ? `ALL stops = white (background color); ` : '') +
`fill references gradient (not "none"); fill-none scanner PASSES; ` +
`opacity=1; visibility=visible; text invisible (white on white); ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`detection: resolve gradient fill to effective color at element position; ` +
`flag colors matching SVG background (white)`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-GPU-001: A degenerate linearGradient with gradientUnits="userSpaceOnUse" and x1=x2 (zero-length) resolves to the solid color of its last stop. Setting all stops to white produces a white fill on the consent text. The fill attribute references a valid gradient URL rather than none; all CSS visibility checks pass. Detection must resolve the gradient fill to its effective color at the text element’s position and flag colors that match the SVG background.
Attack 2: objectBoundingBox gradient with zero-range stop at white — gradient sampled at its single color (SA-CSS-GPU-002)
With gradientUnits="objectBoundingBox" (the default), gradient coordinates are fractions of the filled element’s bounding box. Setting x1="0" x2="0" on a linearGradient creates a degenerate zero-length gradient that resolves to a solid color. Setting that solid color to white makes the text invisible. This variant does not require userSpaceOnUse — it exploits the degenerate case in the default coordinate system. The gradientUnits attribute may be absent entirely (defaulting to objectBoundingBox), making the attack harder to identify: there is no unusual attribute value to flag.
A radial gradient equivalent sets r="0" (zero radius), which also degenerates to a solid color (the color at offset=1). Setting that stop to white achieves the same invisible-text effect. The fill attribute value is a gradient URL, r="0" is a subtle attribute that might be overlooked, and the rendered text is white on white.
<!-- SA-CSS-GPU-002: objectBoundingBox (default) with x1=x2=0 (degenerate) → solid white -->
<!-- no gradientUnits attr (defaults to objectBoundingBox); x1=x2=0 → zero-length -->
<!-- degenerate: solid white; fill=url(#grad); text invisible; no unusual attr -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- no gradientUnits attr → default objectBoundingBox; x1=x2=0 → degenerate -->
<linearGradient id="grad-gpu-002" x1="0" y1="0" x2="0" y2="0">
<stop offset="0" stop-color="white"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<!-- Radial variant: r=0 → degenerate → solid color = stop at offset=1 → white -->
<radialGradient id="grad-gpu-002r" r="0">
<stop offset="0" stop-color="#111"/>
<stop offset="1" stop-color="white"/>
</radialGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- objectBoundingBox x1=x2=0 → solid white; text invisible -->
<text x="20" y="50" font-size="13"
fill="url(#grad-gpu-002)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: degenerate gradient check (x1=x2 and y1=y2, or r=0)
function checkDegenerateGradient(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const text = textEl.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillAttr = textEl.getAttribute('fill') ?? '';
const gradIdMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!gradIdMatch) continue;
const gradEl = document.getElementById(gradIdMatch[1]);
if (!gradEl) continue;
let isDegenerate = false;
let reason = '';
if (gradEl.tagName === 'linearGradient') {
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
const y1 = parseFloat(gradEl.getAttribute('y1') ?? '0');
const y2 = parseFloat(gradEl.getAttribute('y2') ?? '0');
isDegenerate = (x1 === x2 && y1 === y2);
reason = `linearGradient x1=${x1} x2=${x2} y1=${y1} y2=${y2} (zero-length vector)`;
} else if (gradEl.tagName === 'radialGradient') {
const r = parseFloat(gradEl.getAttribute('r') ?? '0.5');
isDegenerate = (r === 0);
reason = `radialGradient r=${r} (zero radius → point; degenerate)`;
}
if (isDegenerate) {
const stops = Array.from(gradEl.querySelectorAll('stop'));
const lastStop = stops[stops.length - 1];
const solidColor = lastStop?.getAttribute('stop-color') ?? 'unknown';
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-GPU-002',
note: `SVG <${textEl.tagName}> fill="url(#${gradIdMatch[1]})"; ` +
`DEGENERATE gradient: ${reason}; ` +
`solid color = last stop = "${solidColor}"; ` +
`if solidColor matches SVG background (white/transparent): text invisible; ` +
`gradientUnits="${gradEl.getAttribute('gradientUnits') || 'objectBoundingBox (default)'}"; ` +
`fill attr is gradient URL (not "none"); opacity=1; visibility=visible; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`detection: check for degenerate gradient (x1=x2&y1=y2 or r=0); ` +
`resolve solid color; compare to background`
});
}
}
return findings;
}
CRITICAL — SA-CSS-GPU-002: A linearGradient with x1=x2 and y1=y2 (or a radialGradient with r=0) is degenerate and renders as a solid color equal to the last stop. Setting the last stop to white on a white SVG background makes the consent text invisible. The gradientUnits attribute may be absent (using the default), making the attack attribute-neutral. Detection must check for degenerate gradient geometry and resolve the resulting solid color against the background.
Attack 3: spreadMethod="reflect" with symmetric white endpoints — all positions outside gradient fill with white (SA-CSS-GPU-003)
With spreadMethod="reflect", the gradient repeats by mirroring outside its defined range. If an attacker sets a white-to-white gradient (both stops white), the reflective repetition still produces solid white at all positions. A less obvious variant uses a short gradient (e.g., 1px range at x=1000 in userSpaceOnUse) with spreadMethod="reflect": the gradient’s white stop mirrors at 2px intervals around x=1000. The consent text at x=20 is far to the left; in userSpaceOnUse the text is in the “pad” region before the gradient start — but if spreadMethod="reflect" is set, the gradient reflects backward as well. If the first stop at x=1000 is white, all positions before x=1000 with reflect resolve to white at positions that happen to be at the reflection of a white stop.
The more reliable variant is simply: both stops are white with spreadMethod="pad" (default). Any gradient analysis that resolves all stop colors as white should flag it, regardless of spread method. The spread method variant adds obfuscation by making the stops appear to define a gradient between two different positions, but if both stop colors are the same (white) the effective fill is always that color at any position.
<!-- SA-CSS-GPU-003: both stops = white; spreadMethod=reflect (adds obfuscation) -->
<!-- effective fill = white at all positions; spreadMethod irrelevant for same-color stops -->
<!-- alternative: single stop at offset=0 stop-color=white; all positions → white -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Both stops white; gradient vector at arbitrary position; any spread → white -->
<linearGradient id="grad-gpu-003" gradientUnits="userSpaceOnUse"
x1="100" y1="50" x2="300" y2="50" spreadMethod="reflect">
<stop offset="0" stop-color="white" stop-opacity="1"/>
<stop offset="0.5" stop-color="#f0f0f0"/> <!-- near-white mid-point -->
<stop offset="1" stop-color="white" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- all stop colors = white/near-white; text invisible on white background -->
<text x="20" y="50" font-size="13"
fill="url(#grad-gpu-003)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: resolve all gradient stops and check against background color
function checkGradientStopColors(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
// Parse color to [r,g,b,a] 0-255
function colorToRgba(c) {
if (!c) return null;
const s = c.toLowerCase().trim();
if (s === 'white' || s === '#fff' || s === '#ffffff') return [255,255,255,255];
if (s === 'transparent') return [0,0,0,0];
const hex = s.match(/^#([0-9a-f]{6})$/)?.[1];
if (hex) return [
parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16), 255
];
return null;
}
function colorBrightness([r,g,b]) { return (r*299+g*587+b*114) / 1000; }
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const text = textEl.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillAttr = textEl.getAttribute('fill') ?? '';
const gradIdMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!gradIdMatch) continue;
const gradEl = document.getElementById(gradIdMatch[1]);
if (!gradEl) continue;
const stops = Array.from(gradEl.querySelectorAll('stop'));
const stopColors = stops.map(s => colorToRgba(s.getAttribute('stop-color') ?? 'black'));
const allHighBrightness = stopColors.every(c => c && colorBrightness(c) > 230);
if (allHighBrightness && stops.length > 0) {
const spread = gradEl.getAttribute('spreadMethod') || 'pad';
findings.push({
severity: 'HIGH', code: 'SA-CSS-GPU-003',
note: `SVG <${textEl.tagName}> fill="url(#${gradIdMatch[1]})"; ` +
`gradient has ${stops.length} stops, all high-brightness (>230/255); ` +
`stop-colors: [${stops.map(s=>s.getAttribute('stop-color')).join(', ')}]; ` +
`spreadMethod="${spread}"; effective fill ≈ white at any position; ` +
`text invisible on white SVG background; fill attr = gradient URL (not "none"); ` +
`detection: resolve all stop colors; flag if all stops are background-matching high-brightness`
});
}
}
return findings;
}
HIGH — SA-CSS-GPU-003: A gradient with all stops at high-brightness white/near-white colors produces effectively invisible text on a white background, regardless of spreadMethod or gradient position. The fill attribute references a gradient URL (not none), and no CSS visibility property is set. Detection must resolve all gradient stop colors and flag gradients where all stops match or closely approach the SVG background color.
Attack 4: JS mousedown injects white-stop gradient reference, reverts at mouseup (SA-CSS-GPU-004)
The dynamic variant creates two gradient definitions in the SVG: a visible one (e.g., dark fill) and an invisible white-fill degenerate one. At rest, the consent text references the visible gradient. At mousedown, JavaScript swaps the fill attribute to reference the white gradient. The text becomes invisible during the click window. At mouseup, the fill is reverted. Static inspection always finds the visible gradient reference. A MutationObserver watching the fill attribute on consent text elements is required, combined with gradient resolution to detect the invisible-fill swap.
<!-- SA-CSS-GPU-004: at rest fill=url(#grad-visible) (dark text); -->
<!-- mousedown: fill=url(#grad-white) (degenerate white); text invisible -->
<!-- mouseup: fill restored to url(#grad-visible); static scan sees visible gradient -->
<svg id="consent-svg-gpu" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- visible gradient: dark fill -->
<linearGradient id="grad-gpu-visible">
<stop offset="0" stop-color="#111"/>
<stop offset="1" stop-color="#333"/>
</linearGradient>
<!-- degenerate white gradient: injected at mousedown -->
<linearGradient id="grad-gpu-white" x1="0" y1="0" x2="0" y2="0">
<stop offset="0" stop-color="white"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- at rest: visible dark gradient; mousedown: swapped to white gradient; invisible -->
<text id="consent-text-gpu" x="20" y="50" font-size="13"
fill="url(#grad-gpu-visible)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg-gpu');
const text = document.getElementById('consent-text-gpu');
svg.addEventListener('mousedown', () => {
text.setAttribute('fill', 'url(#grad-gpu-white)'); // swap to white
}, true);
svg.addEventListener('mouseup', () => {
text.setAttribute('fill', 'url(#grad-gpu-visible)'); // restore
}, true);
})();
</script>
// Detection: MutationObserver on fill attr + gradient resolution after mutation
function watchFillGradientSwap(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'fill') continue;
const el = mut.target;
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const newFill = el.getAttribute('fill') ?? '';
const gradIdMatch = newFill.match(/url\(#([^)]+)\)/);
if (!gradIdMatch) continue;
const gradEl = document.getElementById(gradIdMatch[1]);
if (!gradEl) continue;
// Check for degenerate gradient (same logic as GPU-002)
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? (gradEl.tagName === 'linearGradient' ? '1' : '0.5'));
const y1 = parseFloat(gradEl.getAttribute('y1') ?? '0');
const y2 = parseFloat(gradEl.getAttribute('y2') ?? '0');
const r = parseFloat(gradEl.getAttribute('r') ?? '0.5');
const isDegenerate = (gradEl.tagName === 'linearGradient' && x1 === x2 && y1 === y2) ||
(gradEl.tagName === 'radialGradient' && r === 0);
const stops = Array.from(gradEl.querySelectorAll('stop'));
const allWhite = stops.every(s => {
const c = (s.getAttribute('stop-color') ?? '').toLowerCase().trim();
return c === 'white' || c === '#fff' || c === '#ffffff';
});
if (isDegenerate || allWhite) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-GPU-004',
note: `Dynamic fill swap: "${mut.oldValue}" → "${newFill}" at mousedown; ` +
`new gradient "#${gradIdMatch[1]}" is ${isDegenerate ? 'DEGENERATE' : ''} ` +
`${allWhite ? 'ALL-WHITE stops' : ''}; ` +
`text invisible during click window; ` +
`old fill="${mut.oldValue}" (visible); static scan PASSES; ` +
`MutationObserver attributeFilter=['fill'] + gradient resolution required; ` +
`textContent="${text.slice(0,30)}..." intact`
});
}
}
});
for (const el of svgEl.querySelectorAll('text, tspan')) {
observer.observe(el, { attributes: true, attributeFilter: ['fill'], attributeOldValue: true });
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-GPU-004: JavaScript swaps the fill gradient reference from a visible dark gradient to a degenerate white gradient at mousedown, making the consent text invisible during the click window. Static inspection always finds the visible gradient. Detection requires a MutationObserver watching the fill attribute on consent text elements, combined with gradient resolution (degenerate check + all-stops-white check) on each newly referenced gradient.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-GPU-001: userSpaceOnUse degenerate gradient — x1=x2=text_x → solid white | gradientUnits=userSpaceOnUse; x1=x2 → degenerate → solid last-stop color (white); text at that x position = white on white; fill attr = gradient URL not "none" | fill-none scanner; opacity scanner; visibility scanner; requires: detect degenerate (x1=x2,y1=y2); resolve solid color; compare to background | Critical |
| SA-CSS-GPU-002: objectBoundingBox x1=x2=0 (default coords) → solid white | Default gradientUnits=objectBoundingBox; x1=x2=0 → degenerate; last stop = white; no unusual attribute (no gradientUnits); text invisible; fill = gradient URL | gradientUnits-specific scanner (no unusual attr); fill-none scanner; requires: degenerate gradient detection independent of gradientUnits value | Critical |
| SA-CSS-GPU-003: all stops = white / near-white (high-brightness) | All gradient stops are high-brightness white/near-white; effective fill = white at any position; spreadMethod irrelevant; fill = gradient URL; opacity=1 | fill-none scanner; opacity scanner; requires: resolve all stop colors; flag if all stops match background brightness (>230/255) | High |
| SA-CSS-GPU-004: mousedown swaps fill to white-gradient ref; reverts mouseup | At rest: visible dark gradient; mousedown: fill → white degenerate gradient; text invisible during click; mouseup: reverted; static scan PASSES | Static DOM; requires: MutationObserver attributeFilter=['fill'] + gradient resolution (degenerate or all-white stops) on mutation | Critical |