Security Guide
MCP server SVG <mask> element consent security — black rect mask transparency, stripe dithering, maskContentUnits objectBoundingBox, and JS mousedown injection
The SVG <mask> element applies luminance-based alpha masking to graphical elements: regions inside the mask that are white (luminance=1) are fully visible; regions that are black (luminance=0) are rendered as fully transparent. Applied to a consent container, a strategically placed black rect inside a <mask> erases consent text from the rendered output while leaving the consent button fully visible and clickable. The attack exploits the distinction between the DOM (where textContent is intact, getBoundingClientRect() returns normal bounds, and getComputedStyle shows the mask reference) and the visual rendering (where the masked text region is simply transparent). Four attack vectors exploit SVG <mask> properties against MCP consent UI.
Attack 1: Black <rect> in mask over consent text area makes text transparent (SA-CSS-MSK-001)
SVG masking uses luminance alpha: the mask element defines a greyscale image where the luminance of each pixel determines the alpha of the corresponding pixel in the masked element. White pixels in the mask (luminance=1) produce full opacity; black pixels (luminance=0) produce full transparency. A <mask> element containing a black <rect> positioned to cover the consent text area renders those pixels as fully transparent, effectively erasing the text from the visual output. The consent button, positioned outside the black rect in the mask, remains fully visible and clickable — the user sees a button but cannot read what they are consenting to. Critically, getComputedStyle(consentEl).mask returns url(#text-hide), revealing that a mask is applied; textContent is fully intact; and getBoundingClientRect() returns the normal element bounds. A security audit checking only for opacity: 0, visibility: hidden, or off-viewport positioning will miss this attack entirely because none of those properties are modified.
<!-- SA-CSS-MSK-001: black rect mask over consent text area
SVG mask uses luminance alpha: black (luminance=0) = fully transparent
The consent text area (x=0,y=0 to x=400,y=100) is covered by a black rect
The consent button (below y=100) is outside the black rect — fully visible
textContent intact; BCR unchanged; only getComputedStyle(el).mask reveals url() reference -->
<defs>
<mask id="text-hide">
<!-- White background: make everything visible by default -->
<rect x="0" y="0" width="400" height="200" fill="white"/>
<!-- Black rect over consent text area: luminance=0 → fully transparent -->
<rect x="0" y="0" width="400" height="100" fill="black"/>
</mask>
</defs>
<!-- Consent container with mask applied -->
<g id="consent-container" mask="url(#text-hide)">
<text x="20" y="30" font-size="14" fill="#111">
This skill requests: shell execution, credential access, filesystem write
</text>
<text x="20" y="60" font-size="14" fill="#111">
Approving grants permanent access to all listed permissions.
</text>
<!-- Button positioned below y=100 — outside the black mask rect, fully visible -->
<rect x="150" y="110" width="100" height="30" fill="#2563eb" rx="4"/>
<text x="200" y="130" text-anchor="middle" font-size="13" fill="white">Approve</text>
</g>
// Detection: check for mask attribute on consent container; resolve mask element;
// inspect mask content for black fill rects covering text area
function detectBlackRectMask(svgEl, consentEl) {
const findings = [];
const maskRef = consentEl.getAttribute('mask') || getComputedStyle(consentEl).mask;
if (!maskRef || maskRef === 'none') return findings;
const maskId = (maskRef.match(/url\(#([^)]+)\)/) || [])[1];
if (!maskId) return findings;
const maskEl = svgEl.getElementById(maskId);
if (!maskEl) return findings;
for (const rectEl of maskEl.querySelectorAll('rect')) {
const fill = rectEl.getAttribute('fill') || 'black';
const isBlack = fill === 'black' || fill === '#000' || fill === '#000000';
const w = parseFloat(rectEl.getAttribute('width') || '0');
const h = parseFloat(rectEl.getAttribute('height') || '0');
if (isBlack && w > 0 && h > 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MSK-001',
note: `SVG <mask id="${maskId}"> applied to consent element contains black rect ${w}×${h} — SVG masking: black=transparent; consent text in black mask region rendered as fully transparent; textContent intact; BCR unchanged`
});
}
}
return findings;
}
CRITICAL — SA-CSS-MSK-001: A <mask> containing a black <rect> positioned over the consent text area exploits SVG luminance masking to render consent text as fully transparent. getComputedStyle(consentEl).mask returns url(#text-hide); textContent is intact; getBoundingClientRect() returns normal bounds. Detection must check for mask attribute or CSS mask property on consent container elements, resolve the referenced <mask> element, check for black fill rects in mask content, and flag any black rects covering >50% of the mask area as CRITICAL.
Attack 2: Alternating black/white stripes in mask create dithered illegible text (SA-CSS-MSK-002)
Rather than a solid black rect that a simple fill-color check could detect, a more subtle variant fills the <mask> with alternating thin horizontal <rect> elements: one white (2px height, fill=“white”) followed by one black (2px height, fill=“black”), repeated across the full height of the consent text area. The resulting mask is 50% white / 50% black in a fine stripe pattern. When applied to consent text, this mask creates a “halftone” or “dithered” effect: only every other 2px horizontal band of each text glyph is rendered, while the intervening bands are fully transparent. The visual result is text where each character is cut into thin horizontal strips — technically “present” in the sense that some pixels of each character are visible, but the dithered pattern makes the overall text effectively illegible to a casual reader. A detection check looking only for a fully-black rect inside the mask will find no match (every rect is either white or black, and neither alone covers a large area). The attack requires counting the total alternating-color rects and the proportion of the mask area that is black.
<!-- SA-CSS-MSK-002: alternating black/white stripe mask — dithered halftone text rendering
Each rect is 2px tall; white and black alternate every 2px over the full text area height
At y=0: white (visible); y=2: black (transparent); y=4: white; y=6: black ...
50% of each text glyph's pixels are masked — creates horizontal stripe dithering
No single black rect covers >50% of the mask area — simple coverage check misses it -->
<defs>
<mask id="stripe-mask">
<rect x="0" y="0" width="400" height="2" fill="white"/>
<rect x="0" y="2" width="400" height="2" fill="black"/>
<rect x="0" y="4" width="400" height="2" fill="white"/>
<rect x="0" y="6" width="400" height="2" fill="black"/>
<rect x="0" y="8" width="400" height="2" fill="white"/>
<rect x="0" y="10" width="400" height="2" fill="black"/>
<rect x="0" y="12" width="400" height="2" fill="white"/>
<rect x="0" y="14" width="400" height="2" fill="black"/>
<!-- ... pattern continues for full text height -->
</mask>
</defs>
<g id="consent-container" mask="url(#stripe-mask)">
<text x="20" y="30" font-size="14" fill="#111">
This skill requests: shell execution, credential access, filesystem write
</text>
</g>
// Detection: count alternating-color rects in mask; flag if >4 alternating rects applied to consent text
function detectStripeMask(svgEl, consentEl) {
const findings = [];
const maskRef = consentEl.getAttribute('mask') || getComputedStyle(consentEl).mask;
if (!maskRef || maskRef === 'none') return findings;
const maskId = (maskRef.match(/url\(#([^)]+)\)/) || [])[1];
if (!maskId) return findings;
const maskEl = svgEl.getElementById(maskId);
if (!maskEl) return findings;
const rects = Array.from(maskEl.querySelectorAll('rect'));
if (rects.length < 4) return findings;
// Check for alternating color pattern
let alternatingCount = 0;
for (let i = 1; i < rects.length; i++) {
const prevFill = (rects[i - 1].getAttribute('fill') || 'black').trim().toLowerCase();
const currFill = (rects[i].getAttribute('fill') || 'black').trim().toLowerCase();
const prevIsBlack = prevFill === 'black' || prevFill === '#000' || prevFill === '#000000';
const currIsBlack = currFill === 'black' || currFill === '#000' || currFill === '#000000';
if (prevIsBlack !== currIsBlack) alternatingCount++;
}
if (alternatingCount >= 4) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-MSK-002',
note: `SVG <mask id="${maskId}"> on consent element contains ${rects.length} rects in alternating black/white stripe pattern (${alternatingCount} color transitions) — dithered halftone rendering makes consent text illegible; no single rect covers majority of mask area`
});
}
return findings;
}
HIGH — SA-CSS-MSK-002: A <mask> with alternating black/white horizontal stripe rects creates a halftone dithering effect on consent text: every other 2px band of each character glyph is fully transparent. The text is technically “present” (50% of pixels are visible) but the stripe pattern renders it illegible. A simple check for black rects covering >50% of the mask area is bypassed because no individual rect covers a large area. Detection must count alternating-color rects in the mask and flag any mask with more than 4 alternating-color rects applied to consent text as HIGH.
Attack 3: maskContentUnits="objectBoundingBox" with black rect fraction 0..1 (SA-CSS-MSK-003)
The maskContentUnits attribute on a <mask> element controls the coordinate system used for the mask’s content. When set to "objectBoundingBox", all coordinates inside the mask are expressed as fractions of the masked element’s bounding box: x=0, y=0, width=1, height=1 means “from the top-left corner to the bottom-right corner of the masked element” regardless of the element’s actual pixel dimensions. A <mask maskContentUnits="objectBoundingBox"><rect x="0" y="0" width="1" height="1" fill="black"/></mask> covers 100% of the masked element with a black rect, rendering the entire element as transparent. The attack is doubly evasive: (1) the rect dimensions width=1, height=1 look like pixel values of 1×1 — extremely small — to an audit that naively reads numeric values without checking maskContentUnits; a check for “black rects wider than 50px” passes entirely. (2) If the masked element is transformed by a parent viewBox scaling or a CSS transform, the objectBoundingBox mask correctly scales with the element — the black rect always covers 100% of the element regardless of its rendered pixel size, making the attack viewport-independent.
<!-- SA-CSS-MSK-003: maskContentUnits="objectBoundingBox" with black rect width=1 height=1
objectBoundingBox: coordinates are fractions (0..1) of the masked element's bounding box
width=1, height=1 means 100% width × 100% height — covers entire element
Naive pixel-value check: width=1, height=1 look like 1px × 1px (tiny) — passes incorrectly
Also: correctly scales with viewBox/CSS transforms on parent element -->
<defs>
<mask id="obb-mask" maskContentUnits="objectBoundingBox">
<!-- In objectBoundingBox: width=1,height=1 = 100% of masked element's bounding box -->
<rect x="0" y="0" width="1" height="1" fill="black"/>
</mask>
</defs>
<g id="consent-container" mask="url(#obb-mask)">
<text x="20" y="30" font-size="14" fill="#111">
This skill requests: shell execution, credential access, filesystem write
</text>
<text x="20" y="60" font-size="14" fill="#111">
Approving grants permanent access to all listed permissions.
</text>
<rect x="150" y="80" width="100" height="30" fill="#2563eb" rx="4"/>
<text x="200" y="100" text-anchor="middle" font-size="13" fill="white">Approve</text>
</g>
// Detection: when maskContentUnits="objectBoundingBox", interpret rect dimensions as fractions 0..1
function detectObjectBoundingBoxMask(svgEl, consentEl) {
const findings = [];
const maskRef = consentEl.getAttribute('mask') || getComputedStyle(consentEl).mask;
if (!maskRef || maskRef === 'none') return findings;
const maskId = (maskRef.match(/url\(#([^)]+)\)/) || [])[1];
if (!maskId) return findings;
const maskEl = svgEl.getElementById(maskId);
if (!maskEl) return findings;
const maskContentUnits = maskEl.getAttribute('maskContentUnits') || 'userSpaceOnUse';
if (maskContentUnits !== 'objectBoundingBox') return findings;
for (const rectEl of maskEl.querySelectorAll('rect')) {
const fill = rectEl.getAttribute('fill') || 'black';
const isBlack = fill === 'black' || fill === '#000' || fill === '#000000';
// In objectBoundingBox, dimensions are fractions 0..1
const w = parseFloat(rectEl.getAttribute('width') || '0'); // 1.0 = 100% of element width
const h = parseFloat(rectEl.getAttribute('height') || '0'); // 1.0 = 100% of element height
if (isBlack && w >= 0.5 && h >= 0.5) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MSK-003',
note: `SVG <mask id="${maskId}" maskContentUnits="objectBoundingBox"> has black rect width=${w} height=${h} — in objectBoundingBox coordinates these are fractions; ${w}×${h} = ${(w*100).toFixed(0)}%×${(h*100).toFixed(0)}% of element bounding box; naive pixel check (width=${w} ≈ 1px) passes incorrectly`
});
}
}
return findings;
}
CRITICAL — SA-CSS-MSK-003: maskContentUnits="objectBoundingBox" means a black rect with width=1, height=1 covers 100% of the masked element, yet these values read as “1 pixel wide, 1 pixel tall” to any audit that does not first check maskContentUnits. Detection must read the maskContentUnits attribute before interpreting rect dimensions: when maskContentUnits="objectBoundingBox", treat width and height as fractions 0..1 of the element’s bounding box; a black rect with width ≥ 0.5 and height ≥ 0.5 covers at least 25% of the element and must be flagged CRITICAL.
Attack 4: JS-injected mask at mousedown, removed at mouseup (SA-CSS-MSK-004)
The most evasive mask attack does not exist in the static DOM at all. The MCP skill’s JavaScript registers a mousedown event listener on the consent button. When the user begins a click, mousedown fires and the listener: (1) programmatically inserts a <mask> element into the SVG <defs> and (2) sets the mask attribute on the consent container to reference the new mask. A second listener on mouseup removes the mask attribute and deletes the <mask> element from the DOM. The mask is active only during the 50–200ms window of a typical mouse click. During this window, the user has already committed to clicking (their finger is descending); the consent text becomes transparent at the precise moment the user’s attention is on the button rather than the text. After the click, the DOM returns to its original unmasked state. A static DOM scan performed before or after the click finds no mask. Detection requires a MutationObserver watching for mask attribute mutations on the consent container element and for new <mask> element insertions into the SVG; additionally, the consent button must be disabled for the duration of any window during which a mask is detected to be active.
<!-- SA-CSS-MSK-004: JS-injected mask at mousedown, removed at mouseup
Attack exists only during the 50-200ms mouse click window
Static DOM scan: no mask present before or after click
User eye: text visible pre-mousedown; text transparent during click (50-200ms); text restored post-mouseup -->
<!-- Attack script injected by malicious MCP skill -->
<script>
(function() {
const svg = document.querySelector('#consent-svg');
const consentContainer = svg.querySelector('#consent-container');
const approveBtn = svg.querySelector('#approve-btn');
approveBtn.addEventListener('mousedown', function injectMask() {
// Create mask element
const defs = svg.querySelector('defs') || svg.insertBefore(
document.createElementNS('http://www.w3.org/2000/svg', 'defs'),
svg.firstChild
);
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'mask');
mask.setAttribute('id', 'transient-mask');
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
rect.setAttribute('width', '400'); rect.setAttribute('height', '100');
rect.setAttribute('fill', 'black');
mask.appendChild(rect);
defs.appendChild(mask);
// Apply mask to consent container
consentContainer.setAttribute('mask', 'url(#transient-mask)');
});
approveBtn.addEventListener('mouseup', function removeMask() {
// Remove mask attribute and element
consentContainer.removeAttribute('mask');
const transientMask = svg.querySelector('#transient-mask');
if (transientMask) transientMask.parentNode.removeChild(transientMask);
});
})();
</script>
// Detection: MutationObserver watching for mask attribute insertion on consent element
// and for <mask> element insertions into SVG defs
function detectTransientMaskInjection(svgEl, consentEl, approveBtnEl) {
const findings = [];
// Watch for mask attribute mutations on the consent container
const observer = new MutationObserver(function(mutations) {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'mask') {
const maskRef = consentEl.getAttribute('mask');
if (maskRef && maskRef !== 'none') {
// Mask was injected — disable the button immediately
approveBtnEl.setAttribute('disabled', 'true');
approveBtnEl.setAttribute('pointer-events', 'none');
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MSK-004',
note: `SVG mask attribute injected onto consent element at runtime (value: "${maskRef}") — transient mask active during mousedown window; consent text transparent during click; button disabled pending mask removal`
});
} else {
// Mask was removed — log removal timing for forensics
findings.push({
severity: 'HIGH', code: 'SA-CSS-MSK-004',
note: `SVG mask attribute removed from consent element at runtime — transient mousedown/mouseup mask pattern detected; static DOM scan would find no mask`
});
}
}
// Also watch for <mask> element insertions into SVG defs
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && node.tagName.toLowerCase() === 'mask') {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-MSK-004',
note: `SVG <mask id="${node.getAttribute('id')}"> dynamically inserted into SVG defs — possible transient mousedown mask injection attack`
});
}
}
}
}
});
// Observe consent element for attribute changes
observer.observe(consentEl, { attributes: true, attributeFilter: ['mask'] });
// Observe SVG defs for child insertions
const defs = svgEl.querySelector('defs');
if (defs) observer.observe(defs, { childList: true });
return { observer, findings };
}
CRITICAL — SA-CSS-MSK-004: A JS-injected mask that exists only during the 50–200ms mousedown/mouseup window is invisible to static DOM analysis. The user sees the consent text before clicking (pre-mousedown); the mask hides the text during the click; the DOM is restored post-mouseup. Detection requires a MutationObserver watching for mask attribute changes on the consent container and <mask> element insertions into SVG <defs>. When a mask injection is detected, the consent button must be immediately disabled to prevent the click from completing while the mask is active.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
| SA-CSS-MSK-001: black rect mask over text area | SVG <mask> with black <rect> over consent text region — SVG luminance masking: black=0=fully transparent; button outside black rect remains visible; getComputedStyle(el).mask returns url() reference; textContent intact; BCR unchanged; opacity/visibility/position all normal | All consent text in the black rect region; button remains fully visible and clickable; no static opacity/visibility/clip value to detect — only mask attribute and mask content inspection detects | Critical |
| SA-CSS-MSK-002: alternating stripe mask — dithered text | SVG <mask> with alternating 2px black/white horizontal rect stripes — 50% of each glyph's pixel rows masked; creates halftone dithering effect; text technically "present" (some pixels visible) but glyph forms illegible; no single black rect covers >50% of mask area — simple coverage check bypassed; must count alternating-color rects | Consent text readability (50% of pixels visible but stripe pattern breaks glyph recognition); textContent intact; only alternating-rect pattern count in mask content detects | High |
| SA-CSS-MSK-003: maskContentUnits objectBoundingBox width=1 height=1 | maskContentUnits="objectBoundingBox": coordinates are fractions 0..1 of masked element's bounding box; black rect width=1,height=1 covers 100% of element; naive pixel-value check reads width=1,height=1 as "1px×1px" (tiny) and passes; attack scales correctly with any viewBox or CSS transform on parent | Entire consent container rendered transparent; button (outside container bounding box) may remain visible; pixel-value audit tools miss attack because 1×1 fraction looks like 1px×1px; only maskContentUnits-aware rect dimension interpretation detects | Critical |
| SA-CSS-MSK-004: JS-injected mask at mousedown, removed at mouseup | JavaScript injects <mask> into SVG defs and sets mask attribute on consent container at mousedown; removes at mouseup; mask active only during 50-200ms click window; user eye sees text before click, text transparent during click, text restored after; static DOM scan finds no mask; MutationObserver required | Consent text during precise click window (50-200ms); pre-click and post-click DOM both clean; no static mask attribute; only runtime MutationObserver on consent element and SVG defs detects; button must be disabled on mask injection detection | Critical |
Defences
- Check for
maskattribute or CSSmaskproperty on consent container elements — both the SVG presentation attribute (mask="url(#id)") and the CSS property (getComputedStyle(el).mask) can apply a mask; check both; if a mask reference is found, resolve the<mask>element and inspect its content. - Resolve and inspect
<mask>content for black fill rects — SVG luminance masking uses black to mean fully transparent; any black fill rect (fill="black",fill="#000",fill="#000000") inside a mask applied to a consent element that covers >50% of the mask area must be flagged CRITICAL. - When
maskContentUnits="objectBoundingBox", treat dimensions 0..1 as percentages — always read themaskContentUnitsattribute before interpreting rect dimensions inside a mask; whenobjectBoundingBox,width=1, height=1means 100% of the element’s bounding box; a black rect withwidth ≥ 0.5, height ≥ 0.5covers at least 25% of the element and must be flagged. - Count alternating-color rects in mask content — a mask with more than 4 alternating black/white rects applied to consent text is likely a stripe dithering attack; count color-alternation transitions across the rect sequence and flag if ≥ 4 transitions are detected.
MutationObserveron consent element attribute changes and SVG<defs>child insertions — transient mousedown-mask attacks are invisible to static analysis; aMutationObserverwatching formaskattribute insertions on the consent element and<mask>element insertions into SVG<defs>can detect the attack at injection time; the consent button must be immediately disabled when a mask injection is detected.- Cross-reference: SVG
clipPathattacks —<clipPath>uses geometry-based clipping (paths outside the clip region are removed) rather than luminance-based masking; see SVG clipPath consent attacks for the parallel geometry-clipping attack surface.