← Blog

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.

By SkillAudit  |  29 Oct 2026

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;
}
CRITICAL
SA-CSS-CXO-001 — feFlood white + feComposite operator="xor" on consent text. Mutual knockout at glyph pixels. Transparent holes over white background = 1.00:1 contrast. CSSOM blind. Functionally equivalent to SA-CSS-COU-001 but via symmetric formula.

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.

CRITICAL
SA-CSS-CXO-002 — feFlood flood-opacity=0.5 + feComposite operator="xor". Partial mutual knockout: WCAG ~1.80:1 at glyph pixels. Evades flood-opacity=1 detection rules. Detection: compute output luminance by tracing formula with actual flood-opacity value.

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 */
CRITICAL
SA-CSS-CXO-003 — feComposite in=SourceGraphic in2=SourceGraphic operator="xor". Self-xor of any opaque element produces full transparency. Minimal 1-primitive attack, no feFlood needed. Entire consent container invisible; layout and click-target behavior unchanged.

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']
});
CRITICAL
SA-CSS-CXO-004 — JS mousedown transitions feComposite operator from "over" to "xor" at click time. TOCTOU: text visible during review, transparent at confirmation. MutationObserver on operator, in, in2 attributes of all feComposite elements in consent ancestor filters required.

Comparison: xor vs out vs atop knockouts

OperatorFormula at glyph (A=1)WCAGDetectable 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

Related pages