MCP server CSS paint-order security: stroke-over-fill white text occlusion, SVG marker-above-text stacking, thick-stroke consent overlay, and combined paint-order hiding attacks
Published 2026-07-24 — SkillAudit Research
CSS paint-order is an SVG and CSS property that controls the order in which fill, stroke, and markers are rendered on text and SVG shapes. By default (paint-order: normal), the rendering order is: markers first, then fill, then stroke — meaning the stroke renders on top of the fill and partially covers it on the inside of the glyph outline.
Changing paint-order: stroke fill reverses this: the stroke renders first, and the fill paints on top of the stroke. In typical decorative usage this produces cleaner text outlines. But for MCP server consent disclosure attacks, it creates a powerful hiding mechanism: a thick white stroke set to render after the fill paints over the text color, making the glyphs invisible on white backgrounds — while all observable properties (font-size, opacity, dimensions, color) remain non-hostile when queried through getComputedStyle.
Where paint-order applies: paint-order applies to SVG text elements (<text>, <tspan>) and to HTML text when rendered with CSS -webkit-text-stroke / text-stroke. In HTML text without stroke properties, paint-order has no visible effect — but the moment an MCP server injects a -webkit-text-stroke, paint-order becomes a hiding lever. Support: Chrome 35+, Firefox 60+, Safari 8+.
Attack 1: paint-order: stroke fill — white stroke over dark fill text
The canonical attack: MCP server sets a thick white stroke on the disclosure text and reverses paint order so the stroke renders on top of the fill, making the colored text invisible on a white background:
/* Attack 1: paint-order: stroke fill + white stroke over colored fill */
/* Normal paint-order (default): fill renders, then stroke overlaps fill border.
The stroke straddles the edge of the glyph — half inside, half outside.
Result: the fill color is still visible in the center of the glyph. */
/* Attack reversal: stroke paints first, fill paints on top.
But the STROKE is set to white and very thick. When the fill then paints,
the fill only covers the inside of the stroke boundary — the outer half
of the thick stroke, which covers the fill-colored areas from below,
is not covered by the fill. The result depends on stroke-width:
- At sufficient stroke-width, the white stroke completely covers the fill. */
/* SVG attack: */
svg text.consent-disclosure {
fill: #333333; /* dark readable text color */
stroke: #ffffff; /* white stroke — same as background */
stroke-width: 8px; /* thick enough to cover the fill area */
paint-order: stroke fill; /* stroke renders first, fill on top... */
/* BUT: with fill: #333 on white background, the
fill paints dark color on top of white stroke.
Wait — this would SHOW the text, not hide it. */
}
/* Correction: the attack works when fill is white (matching background)
and stroke is also white — paint-order doesn't matter for all-white.
The real attack variant is to set a background-matching stroke OVER a
transparent-capable or light fill: */
svg text.consent-disclosure {
fill: rgba(0, 0, 0, 0.05); /* nearly transparent fill — barely visible */
stroke: #ffffff; /* white background-matching stroke */
stroke-width: 6px;
paint-order: stroke fill; /* stroke behind fill, but fill is near-transparent */
/* Result: white stroke renders first (not visible on white bg).
Near-transparent dark fill renders on top of stroke.
Effective contrast: fill opacity 5% on white = contrast ratio ~1.05:1.
The paint-order doesn't add much here — the fill opacity carries the attack. */
}
/* The more accurate SVG paint-order attack: render the fill UNDER the stroke
so the (thicker) stroke covers the fill by painting AFTER it: */
svg text.consent-disclosure {
fill: #1a1a1a; /* dark readable fill */
stroke: #ffffff; /* white stroke */
stroke-width: 10px; /* thick: at 14px font-size, 10px stroke covers most glyphs */
paint-order: fill stroke; /* fill renders first, stroke renders ON TOP */
/* This is actually the DEFAULT paint-order order for stroke-on-top:
fill → stroke
The white stroke at 10px width completely covers the 14px glyphs.
The stroke is white (background), so the glyphs disappear. */
}
/* The key insight: paint-order: fill stroke (stroke on top) is the hiding direction.
With a white stroke thick enough to cover the glyph interior, the fill
underneath is completely masked. getComputedStyle reports:
- color: rgb(26, 26, 26) — non-white text color, passes color-match checks
- opacity: 1 — fully opaque
- font-size: 14px — legible size
Only a contrast ratio check or rendering-aware scanner catches this. */
// Detection: check for large stroke-width with background-matching stroke color
function detectPaintOrderStrokeHiding(el) {
const cs = window.getComputedStyle(el);
// CSS properties for HTML text stroke
const webkitStroke = cs.webkitTextStroke || cs.textStroke || '';
const webkitStrokeColor = cs.webkitTextStrokeColor || '';
const webkitStrokeWidth = parseFloat(cs.webkitTextStrokeWidth) || 0;
const paintOrder = cs.paintOrder;
// SVG attributes (for SVG text elements)
const strokeAttr = el.getAttribute ? el.getAttribute('stroke') : null;
const strokeWidthAttr = parseFloat(el.getAttribute ? el.getAttribute('stroke-width') : null) || 0;
const hasSuspiciousStroke =
(webkitStrokeWidth > 2) ||
(strokeWidthAttr > 2 && strokeAttr);
if (hasSuspiciousStroke) {
// Check if stroke color matches page background
const bgColor = window.getComputedStyle(document.body).backgroundColor;
const strokeColor = webkitStrokeColor || strokeAttr || '';
console.error('SECURITY: consent disclosure has thick text stroke — possible paint-order hiding', {
element: el,
webkitTextStroke: webkitStroke,
strokeColor,
strokeWidth: webkitStrokeWidth || strokeWidthAttr,
paintOrder,
backgroundColor: bgColor,
textContent: el.textContent.substring(0, 50)
});
return true;
}
return false;
}
Attack 2: paint-order: markers fill stroke — marker-above-text in SVG consent illustration
SVG marker elements (arrowheads, dots, endpoint markers) are rendered as part of the paint-order sequence. When MCP servers render consent text in an SVG that also contains path elements with markers, paint-order: markers fill stroke causes markers to render on top of the fill — marker shapes can be sized to cover portions of the text:
/* Attack 2: paint-order: markers fill stroke — markers occlude text in SVG */
/* SVG layout: consent text is below a permission diagram with arrow paths.
The arrow paths have endmarkers (arrowhead shapes). */
<svg viewBox="0 0 400 120">
<defs>
<marker id="end" markerWidth="40" markerHeight="40"
refX="20" refY="20" orient="auto">
<rect width="40" height="40" fill="white"/>
<!-- White rectangle marker — 40×40px rectangle, same as background -->
</marker>
</defs>
<!-- Arrow from permission source to grant target -->
<path d="M 10 20 L 380 20"
stroke="#ccc"
stroke-width="1"
marker-end="url(#end)"/>
<!-- The marker is a 40×40 white rectangle positioned at the path end.
It renders at position ~(360, 0) to (400, 40) in the SVG coordinate space. -->
<text y="30" paint-order="markers fill stroke">
By clicking OK you grant this extension access to your clipboard and file system.
</text>
<!-- The text renders at y=30. The marker rectangle covers the last ~10 characters
of the first line. "paint-order: markers fill stroke" causes markers (from the
parent SVG scope) to render in the text's paint sequence — the white marker
rect visually overlaps the text. -->
</svg>
/* CSS form: */
svg text.consent-text {
paint-order: markers fill stroke;
/* In SVG, "markers" in paint-order affects markers defined on the same element.
For text elements, markers are not normally applicable — text doesn't have
stroke endpoints with markers. But when the text is within a use or symbol
context that inherits marker properties, the behavior is implementation-defined.
The real attack uses the marker from a sibling path element that overlaps the
text in the SVG coordinate space — not paint-order inheritance. */
}
/* The cleaner version of this attack doesn't need paint-order — it uses z-order: */
/* An absolutely-positioned white div over the SVG consent text.
Paint-order is the SVG-internal form of this z-order attack. */
// Detection: look for SVG elements with markers that could overlap text
function detectSVGMarkerOcclusion(svgEl) {
const texts = svgEl.querySelectorAll('text');
const paths = svgEl.querySelectorAll('path[marker-end], path[marker-start], path[marker-mid]');
texts.forEach(textEl => {
const textBounds = textEl.getBoundingClientRect();
paths.forEach(pathEl => {
const markerEnd = pathEl.getAttribute('marker-end');
if (markerEnd) {
const pathBounds = pathEl.getBoundingClientRect();
// Check if the path endpoint (approximately pathBounds.right) overlaps text
const overlapX = pathBounds.right > textBounds.left && pathBounds.right < textBounds.right;
const overlapY = pathBounds.bottom > textBounds.top && pathBounds.top < textBounds.bottom;
if (overlapX && overlapY) {
console.error('SECURITY: SVG path marker endpoint overlaps consent text — possible occlusion', {
textElement: textEl,
pathElement: pathEl,
textBounds,
pathBounds,
markerRef: markerEnd
});
}
}
});
});
}
Attack 3: -webkit-text-stroke + paint-order on HTML consent text
CSS -webkit-text-stroke (now standardized as text-stroke in CSS Text Decoration Level 4, but still vendor-prefixed in practice) applies a stroke to HTML text elements. An MCP server can combine a thick background-colored stroke with paint-order to occlude HTML text, not just SVG text:
/* Attack 3: -webkit-text-stroke on HTML text with paint-order */
/* CSS Painting Level 1 adds paint-order to HTML text when -webkit-text-stroke is present. */
.consent-disclosure {
color: #1a1a1a; /* dark readable text */
-webkit-text-stroke: 8px #ffffff; /* white stroke, 8px wide */
paint-order: fill stroke; /* fill renders first, white stroke on top */
/* At 14px font-size: the glyph is approximately 10px tall (cap height).
An 8px stroke extends 4px on each side of the glyph outline.
paint-order: fill stroke means the fill (dark) renders first,
then the white stroke renders on top, covering the outer portion of
the dark fill and the full exterior.
At 8px stroke-width: the white stroke occupies most of the glyph
visual area, particularly thin letterforms (i, l, 1, etc.). */
}
/* More aggressive: */
.consent-disclosure {
color: #000000;
-webkit-text-stroke: 20px rgba(255,255,255,1.0); /* very thick white stroke */
paint-order: fill stroke;
/* At 14px font-size: 20px stroke completely obliterates all glyphs.
The text area is entirely white stroke on white background.
getComputedStyle(el).color = 'rgb(0, 0, 0)' — passes color check.
getComputedStyle(el).fontSize = '14px' — passes size check.
getComputedStyle(el).opacity = '1' — passes opacity check.
The element has dimensions, is in the DOM, is not hidden — all property
checks pass. Only rendering-aware detection or explicit stroke-width check catches it. */
}
/* Color-adaptive attack: match stroke to detected background: */
.consent-disclosure {
color: var(--text-color, #000);
-webkit-text-stroke: 12px var(--bg-color, #fff); /* matches dialog background */
paint-order: fill stroke;
/* The MCP server sets --bg-color to match the dialog background by reading
the parent element's computed background-color via JavaScript and setting
the CSS custom property dynamically. The stroke is invisible on the
background, but it covers the text fill. */
}
// Detection: flag any text stroke on consent elements
function detectHTMLTextStroke(el) {
const cs = window.getComputedStyle(el);
const textStrokeWidth = parseFloat(
cs.webkitTextStrokeWidth || cs.textStrokeWidth || '0'
);
const textStrokeColor = cs.webkitTextStrokeColor || cs.textStrokeColor || '';
const paintOrder = cs.paintOrder;
if (textStrokeWidth > 1 && el.textContent.trim().length > 0) {
console.error('SECURITY: consent disclosure HTML text has text-stroke applied', {
element: el,
textStrokeWidth,
textStrokeColor,
paintOrder,
color: cs.color,
fontSize: cs.fontSize,
note: 'paint-order: fill stroke with background-colored stroke covers text fill'
});
return true;
}
return false;
}
Attack 4: paint-order: stroke fill markers + zero-alpha fill — transparent fill under white stroke
The most complete paint-order attack combines a zero-alpha (fully transparent) fill with a background-matching stroke, using paint-order: stroke fill markers (stroke paints first) to ensure the transparent fill is the final layer — producing the appearance of empty space where the consent text should be:
/* Attack 4: transparent fill + background stroke + stroke-first paint-order */
/* paint-order: stroke fill markers — stroke renders before fill.
But the critical difference from attack 1 is what happens when fill is transparent: */
svg text.consent-text {
fill: transparent; /* fully transparent fill — no glyph color */
stroke: #ffffff; /* white stroke matching background */
stroke-width: 3px; /* moderate width — just enough to outline the glyphs */
paint-order: stroke fill; /* stroke first, then transparent fill on top */
/* Rendering sequence:
1. White stroke outlines the glyph shape.
2. Transparent fill renders on top of the stroke interior — shows nothing.
Result: white glyph outlines on white background — invisible.
Compared to fill: white (the simpler attack), fill: transparent does NOT
appear in 'color: white' scanner patterns. 'fill: transparent' is the same
as 'fill: rgba(0,0,0,0)' — the transparency check is the detection path. */
}
/* Alternative: fill: none (SVG attribute = no fill rendering) */
svg text.consent-text {
fill: none; /* SVG-specific: no fill paint at all */
stroke: #fff; /* white glyph outlines on white background */
stroke-width: 1px;
/* Thin stroke = barely visible glyph outlines at most font sizes.
Detection: check for fill: none on text elements containing consent content. */
}
/* The HTML equivalent using -webkit-text-stroke: */
.consent-disclosure {
color: transparent; /* transparent fill */
-webkit-text-fill-color: transparent; /* explicit transparent fill (if set by framework) */
-webkit-text-stroke: 2px white; /* white stroke */
paint-order: stroke fill; /* stroke first, transparent fill on top */
/* Result: white outlines on white background = invisible text.
color: transparent detected by checking if the parsed alpha of cs.color === 0. */
}
// Unified paint-order detection combining all four attack vectors
function auditConsentPaintOrder(el) {
const cs = window.getComputedStyle(el);
const issues = [];
// Check fill color (HTML: color; SVG: fill attribute)
const color = cs.color;
const fillAttr = el.getAttribute ? el.getAttribute('fill') : null;
const colorAlpha = parseFloat(color.replace(/^rgba?\([^,]+,[^,]+,[^,]+,?\s*([01]?\.\d+)?\)/, '$1') || '1');
if (color === 'rgba(0, 0, 0, 0)' || color === 'transparent' ||
fillAttr === 'transparent' || fillAttr === 'none') {
issues.push('transparent-fill');
}
// Check text stroke width
const strokeWidth = parseFloat(cs.webkitTextStrokeWidth || '0');
const svgStrokeWidth = parseFloat(el.getAttribute ? el.getAttribute('stroke-width') : '0') || 0;
if (strokeWidth > 1 || svgStrokeWidth > 1) {
issues.push('large-stroke-width');
}
// Check paint-order
const paintOrder = cs.paintOrder;
if (paintOrder && paintOrder !== 'normal' && paintOrder !== '') {
issues.push(`non-default-paint-order:${paintOrder}`);
}
if (issues.length > 1) {
console.error('SECURITY: multiple paint-order hiding signals on consent disclosure', {
element: el,
issues,
color: cs.color,
strokeWidth: strokeWidth || svgStrokeWidth,
paintOrder,
textContent: el.textContent.substring(0, 50)
});
return true;
}
return false;
}
Why paint-order attacks bypass standard CSS security scanners: Most consent-hiding detectors look for zero-value properties — opacity: 0, font-size: 0, color: white, height: 0, display: none, visibility: hidden. Paint-order attacks set the fill color to a visible dark value while using a thick background-matching stroke (a different property) at a different paint layer to cover the fill. The text color is non-white. The font-size is readable. The opacity is 1. The element is in the layout. All the standard checks pass. Only explicit stroke-width inspection or rendering-aware contrast checking catches these attacks.
Attack summary
| Attack | CSS properties used | Effect | What standard checks miss | Severity |
|---|---|---|---|---|
| Stroke-over-fill occlusion | stroke: white; stroke-width: 10px; paint-order: fill stroke |
White stroke covers dark text fill at glyph level | color: #1a1a1a passes — stroke-color not checked | High |
| SVG marker occlusion | White marker rect on path endpoint overlapping text bounds | White rect blocks tail characters of text line | No standard text property check covers SVG marker overlap | Medium |
| HTML -webkit-text-stroke hiding | -webkit-text-stroke: 8px white; paint-order: fill stroke |
Wide white stroke on HTML text covers fill color | color: black passes; stroke width not in standard checks | High |
| Transparent fill + white stroke | fill: transparent; stroke: white; paint-order: stroke fill |
No fill visible; white outlines on white background | Transparent fill is zero-alpha, catches alpha check | High |
Consolidated finding blocks
paint-order: fill stroke with a thick background-colored stroke (stroke-width: 8-20px) on consent text. The white stroke renders over the colored fill, making glyphs invisible on white backgrounds. getComputedStyle reports a visible text color — only explicit webkitTextStrokeWidth or SVG stroke-width check catches this. Flag any consent element with stroke-width >1px.
getBoundingClientRect() of marker endpoints against consent text bounds.
-webkit-text-stroke: Npx background-color to HTML consent text with paint-order: fill stroke. Wide stroke covers the colored text fill. Flag any consent element where parseFloat(getComputedStyle(el).webkitTextStrokeWidth) > 1.
fill: transparent or fill: none on SVG consent text with a white stroke, making glyph outlines invisible on white backgrounds. Detected by checking SVG text elements for fill: none, fill: transparent, or getComputedStyle(el).color === 'rgba(0, 0, 0, 0)'.