MCP server SVG feComposite xor consent security
Four SA-CSS-XOR consent attack patterns using feComposite operator="xor" — the Porter-Duff exclusive-or compositing formula out_alpha = αA×(1−αB) + αB×(1−αA) exploited to reduce opaque consent text to near-zero alpha, cancel elements via self-XOR, and trigger erasure precisely at click time via JS flood-opacity override.
The feComposite SVG filter primitive composites two images using a Porter-Duff formula selected by the operator attribute. The operator=xor formula implements exclusive-or compositing: out_alpha = αA×(1−αB) + αB×(1−αA) (SVG §15.13). Geometrically, the XOR operator keeps only the portions of in1 and in2 that do not overlap — pixels covered by both are cancelled, and only the non-overlapping regions survive.
The XOR operator is the least commonly audited feComposite operator precisely because it has no intuitive visual analogue in standard graphic design workflows. Security reviewers familiar with operator=in (intersection-masking), operator=out (difference-masking), and operator=atop (A-in-B) often skip the XOR case — its alpha-cancellation behavior at high in2 opacity creates a near-transparent result that looks like CSS opacity but is invisible to getComputedStyle().opacity.
XOR with near-opaque in2: When αB approaches 1 (e.g., flood-opacity=0.999), the formula for fully opaque in1 pixels (αA=1) gives: out_alpha = 1×(1−0.999) + 0.999×(1−1) = 0.001 + 0 = 0.001. The consent element is rendered at 0.1% alpha. This passes any detection that checks for exact-zero alpha (outputAlpha === 0), but is functionally invisible against any background.
SA-CSS-XOR-001: feComposite operator="xor" with near-opaque in2 (near-zero alpha output)
A feFlood with flood-opacity="0.999" produces a near-opaque image (αB≈1). When used as in2 in an XOR composite with in1=SourceGraphic (opaque consent text, αA=1), the formula gives: out_alpha = 1×(1−0.999) + 0.999×(1−1) = 0.001. Every opaque pixel of the consent element is reduced to 0.1% alpha.
<filter id="xor1"> <feFlood flood-color="white" flood-opacity="0.999" result="nearOpaque"/> <feComposite in="SourceGraphic" in2="nearOpaque" operator="xor"/> </filter> <!-- αA=1 (opaque text), αB=0.999: out_alpha=0.001; element at 0.1% opacity --> <!-- getComputedStyle().opacity still reports "1"; no CSS opacity property set -->
The rendered alpha of 0.001 is functionally invisible — contrast of any color at 0.1% alpha against any background is below 1.01:1 — but passes detection that checks outputAlpha === 0 or alpha < 0.001 with a strict equality threshold. The correct detection threshold is maxOutputAlpha(C_in=1) < 0.05: evaluate the XOR formula for αA=1 against the attacker-controlled αB; if the resulting out_alpha is below 0.05, flag SA-CSS-XOR-001.
The flood-color value is irrelevant for this attack — only flood-opacity matters, because the XOR cancellation acts entirely in the alpha channel. Any flood color (white, black, transparent background, brand color) with flood-opacity=0.999 produces the same near-zero output alpha.
SA-CSS-XOR-002: feComposite operator="xor" with 50% in2 opacity (half-alpha rendering)
A feFlood with flood-opacity="0.5" gives αB=0.5. For opaque consent text (αA=1): out_alpha = 1×0.5 + 0.5×0 = 0.5. The entire consent element renders at 50% alpha. Against a white page background, the dark consent text composites to a medium gray — its effective contrast against the white background drops to half the original value.
<filter id="xor2"> <feFlood flood-color="white" flood-opacity="0.5" result="halfOpaque"/> <feComposite in="SourceGraphic" in2="halfOpaque" operator="xor"/> </filter> <!-- αA=1, αB=0.5: out_alpha=0.5; element at 50% alpha --> <!-- dark text #374151 (rgb 55,65,81) at 50% alpha on white → effective color rgb(155,160,168) --> <!-- WCAG contrast of rgb(155,160,168) vs white ≈ 2.2:1; below 4.5:1 AA threshold -->
WCAG at 50% alpha: Dark gray text at 50% alpha composites against white as a lighter gray. For consent text using #374151 (a common UI dark-gray): the premultiplied composite against white yields approximately rgb(155,160,168) — relative luminance ≈ 0.37; WCAG contrast vs white ≈ 2.2:1. This is below the 4.5:1 WCAG AA threshold for normal text and below 3:1 for large text. The attack degrades readability without triggering zero-alpha detection.
SA-CSS-XOR-003: feComposite operator="xor" with in=in2=SourceGraphic (self-XOR full erasure)
When both in and in2 reference the same image source — in="SourceGraphic" in2="SourceGraphic" — the XOR formula computes out_alpha = αA×(1−αA) + αA×(1−αA) = 2×αA×(1−αA). For any fully opaque pixel (αA=1): out_alpha = 2×1×0 = 0. For any fully transparent pixel (αA=0): out_alpha = 0. Only partially transparent pixels (0<αA<1, anti-aliased edges) survive, at out_alpha = 2×αA×(1−αA) — maximized at αA=0.5 with out_alpha=0.5, and zero at both endpoints.
<filter id="xor3"> <feComposite in="SourceGraphic" in2="SourceGraphic" operator="xor"/> </filter> <!-- αA=αB for all pixels; out_alpha=2×αA×(1-αA) --> <!-- αA=1 (opaque text pixels): out_alpha=0 → fully transparent --> <!-- αA=0 (transparent pixels): out_alpha=0 → remains transparent --> <!-- αA=0.5 (anti-aliased edge): out_alpha=0.5; visible as faint shadow-outline only -->
The result is a full erasure of all opaque content. Only anti-aliased glyph edge pixels leave a faint ghosting trace (peak alpha 0.5 at αA=0.5 edges). The consent element is effectively invisible — the text body disappears and only a barely perceptible outline artifact remains. Detection must recognize the self-reference pattern: any feComposite where in and in2 resolve to the same image (including via result references that cycle back to SourceGraphic) with operator=xor is a guaranteed full erasure.
SA-CSS-XOR-004: JS mousedown sets feFlood flood-opacity from 0 to 0.999 (click-time erasure)
When flood-opacity is 0 (αB=0), the XOR formula gives: out_alpha = αA×(1−0) + 0×(1−αA) = αA. The output alpha equals the input alpha — this is the identity operation for the alpha channel. The consent element renders normally at flood-opacity=0. On mousedown, the script sets flood-opacity to 0.999, activating SA-CSS-XOR-001: out_alpha drops to 0.001 for all opaque pixels.
consentElement.addEventListener("mousedown", () => {
const flood = document.getElementById("xorFilter")
.querySelector("feFlood");
flood.setAttribute("flood-opacity", "0.999"); // αB → 0.999; XOR cancels opaque pixels
// out_alpha = αA×0.001 = 0.001 for αA=1; element near-invisible
// state reset after install flow if needed
setTimeout(() => flood.setAttribute("flood-opacity", "0"), 500);
});
The initial state (flood-opacity=0) is unambiguously legitimate — a feFlood with zero opacity is a no-op in most compositing formulas. Static analysis of the initial filter state sees no attack. The XOR identity property at αB=0 is specific to the XOR operator: for in and out operators, αB=0 also produces identity-like behavior. This cross-operator commonality may mislead static tools that check "flood-opacity=0 is safe" as a blanket rule without considering operator-specific behavior at other αB values.
Attack summary
| ID | in2 state | out_alpha (αA=1) | Effect | Severity |
|---|---|---|---|---|
| SA-CSS-XOR-001 | feFlood flood-opacity=0.999 | 0.001 | Near-full transparency; passes exact-zero detection | Critical |
| SA-CSS-XOR-002 | feFlood flood-opacity=0.5 | 0.5 | 50% alpha; WCAG contrast halved | High |
| SA-CSS-XOR-003 | in=in2=SourceGraphic (self-XOR) | 0 | Full erasure; opaque pixels cancel | Critical |
| SA-CSS-XOR-004 | flood-opacity 0→0.999 mousedown | 1→0.001 | Identity at load; erasure at click | Critical |
Defences
- Evaluate XOR alpha formula explicitly: For each feComposite with operator=xor in a consent ancestor filter, trace the in2 alpha source. Compute
out_alpha = αA×(1−αB) + αB×(1−αA)at αA=1 and at αA=0.5. If out_alpha(αA=1) < 0.05, flag SA-CSS-XOR-001. If out_alpha(αA=1) < 0.8, flag SA-CSS-XOR-002. - Detect self-reference (SA-CSS-XOR-003): Resolve both
inandin2attribute values through the filter primitive graph. If both resolve to the same image source (SourceGraphic, SourceAlpha, or an identical prior result), operator=xor produces guaranteed erasure for all opaque pixels. - MutationObserver on feFlood flood-opacity in consent filters: Any flood-opacity attribute change within a feFlood that feeds operator=xor in a consent ancestor filter must be flagged, regardless of the direction of change. The XOR identity at αB=0 means a change from 0 to any positive value activates an attack.
- Audit all feComposite operators independently: Detection logic for operator=in does not cover operator=xor. The XOR formula has distinct attack conditions (αB→1 for near-erasure vs αB→0 for near-erasure in operator=in). Each operator requires a separate threshold formula.
SkillAudit findings for this attack surface
feComposite operator="xor" with near-opaque in2 (feFlood flood-opacity≥0.95) — XOR alpha formula out_alpha=αA×(1-αB)+αB×(1-αA); αA=1 (opaque consent text): out_alpha=1-αB; at αB=0.999: out_alpha=0.001 (0.1% opacity); functionally invisible; passes alpha===0 exact check; threshold must be out_alpha(αA=1)<0.05.feComposite operator="xor" with feFlood flood-opacity=0.5 — αA=1: out_alpha=0.5; 50% alpha rendering; dark text at 50% alpha on white background produces effective ~2.2:1 WCAG contrast for typical UI grays; below 4.5:1 AA threshold; threshold: out_alpha(αA=1)<0.8 → flag.feComposite operator="xor" with in="SourceGraphic" in2="SourceGraphic" (self-XOR) — out_alpha=2×αA×(1-αA); αA=1 → out_alpha=0; αA=0 → out_alpha=0; all opaque pixels fully transparent; anti-aliased edges survive at reduced alpha; element body invisible; detect via in/in2 same-source resolution.