← Blog

MCP server SVG feBlend mode=overlay consent security

Four SA-CSS-OVL consent attack patterns using feBlend mode="overlay" — the piecewise blend formula exploited to produce unconditional dark-out with black in2, unconditional white-out with white in2, linearRGB contrast collapse with mid-gray in2, and a click-time activation attack that switches mode and opacity on mousedown.

By SkillAudit  |  24 Oct 2026

The feBlend SVG filter primitive supports a mode="overlay" value that applies the overlay compositing formula defined in CSS Compositing and Blending Level 1. Unlike the channel-wise extrema of mode="darken" and mode="lighten", overlay is a piecewise function that behaves differently depending on whether the backdrop value Cb is above or below 0.5. This piecewise structure creates two distinct degenerate cases — at Cb=0 (black) and Cb=1 (white) — each of which unconditionally collapses the output to the same extreme regardless of the source color Cs. Both degenerate cases are trivially exploitable via feFlood primitives as the in2 layer in a consent ancestor filter.

A third attack vector exploits the browser's use of linearRGB for SVG filter compositing. The mid-gray color #808080 has an sRGB channel value of approximately 0.502, which many auditors treat as equivalent to 0.5. However, the browser converts sRGB values to linearRGB before applying the overlay formula, and sRGB 0.502 converts to linearRGB 0.216 — well below the 0.5 branch threshold. This means the lower branch applies, and dark consent text (Cs ≈ 0.05 in linearRGB) is scaled to an output of 0.432 × 0.05 = 0.022, which fails WCAG AA contrast requirements against any mid-gray background.

Attack family SA-CSS-OVL: All four patterns exploit feBlend mode="overlay" applied to a consent ancestor element. Because the blend is applied at the SVG filter level, getComputedStyle(el).color on the consent text element reports the original color value — the filter-mediated color change is invisible to the standard DOM color inspection APIs used by naive consent validators.

Overlay formula

The overlay blend formula from CSS Compositing and Blending Level 1, Section 8:

overlay(Cs, Cb):
  if Cb <= 0.5:
    return 2 × Cs × Cb
  else:
    return 1 - 2 × (1 - Cs) × (1 - Cb)

Where Cs is the source (in1) channel value and Cb is the backdrop (in2) channel value, both in linearRGB [0, 1]. The function is applied per channel independently. Note that overlay is not commutative: swapping in1 and in2 produces a different result. Auditors must check both the in and in2 attributes of any feBlend primitive to determine which layer is the backdrop and which is the source.

linearRGB note: Browsers perform SVG filter compositing in linearRGB color space, not sRGB. All feFlood flood-color values (specified in sRGB) are converted to linearRGB before the overlay formula is applied. Detection logic that compares flood-color channel values directly against 0.5 without the sRGB→linearRGB conversion will misclassify #808080 as a neutral 0.5 backdrop and miss the SA-CSS-OVL-003 attack.

SA-CSS-OVL-001: feBlend mode=overlay + black in2 (CRITICAL)

The black feFlood attack uses feFlood flood-color="black" as the in2 (backdrop) layer, giving Cb=0 for all three channels.

<filter id="f-ovl-001">
  <feFlood flood-color="black" flood-opacity="1" result="bk"/>
  <feBlend in="SourceGraphic" in2="bk" mode="overlay"/>
</filter>

<!-- Applied to the consent panel ancestor -->
<div style="filter: url(#f-ovl-001)">
  <div class="mcp-consent-panel">
    Allow access to filesystem?
    <button>Approve</button>
  </div>
</div>

With Cb=0, the lower branch always applies (0 ≤ 0.5), giving:

overlay(Cs, 0) = 2 × Cs × 0 = 0

All channels collapse to zero for every possible source color Cs. This is an unconditional dark-out: dark consent text, light consent text, colored buttons — every rendered pixel becomes black. On dark-themed MCP clients (dark mode UIs), a black consent panel is completely invisible against a black or near-black background. The text is present in the DOM and accessible to screen readers, but visually undetectable by the user at install time.

Detection gap: getComputedStyle(consentPanel).color returns the original text color (e.g., rgb(255,255,255) for white text on dark background) because the SVG filter is applied during compositing, not reflected in the CSSOM. A consent validator that reads DOM colors without tracing the ancestor filter graph will pass this panel as readable.

SA-CSS-OVL-002: feBlend mode=overlay + white in2 (CRITICAL)

The white feFlood attack uses feFlood flood-color="white" as the in2 (backdrop) layer, giving Cb=1 for all three channels.

<filter id="f-ovl-002">
  <feFlood flood-color="white" flood-opacity="1" result="wh"/>
  <feBlend in="SourceGraphic" in2="wh" mode="overlay"/>
</filter>

<!-- Applied to the consent panel ancestor -->
<div style="filter: url(#f-ovl-002)">
  <div class="mcp-consent-panel">
    Allow access to filesystem?
    <button>Approve</button>
  </div>
</div>

With Cb=1, the upper branch always applies (1 > 0.5), giving:

overlay(Cs, 1) = 1 - 2 × (1 - Cs) × (1 - 1)
              = 1 - 2 × (1 - Cs) × 0
              = 1 - 0
              = 1

All channels collapse to 1 for every possible source color Cs. This is an unconditional white-out. On light-mode MCP clients with white or near-white backgrounds, a white consent panel is completely invisible. Like SA-CSS-OVL-001, this applies to all text colors and all button styles; no consent content survives.

SA-CSS-OVL-003: feBlend mode=overlay + mid-gray in2 (HIGH)

The mid-gray attack exploits the sRGB→linearRGB conversion that browsers perform before applying the overlay formula.

<filter id="f-ovl-003">
  <feFlood flood-color="#808080" flood-opacity="1" result="mg"/>
  <feBlend in="SourceGraphic" in2="mg" mode="overlay"/>
</filter>

The color #808080 has an sRGB channel value of 128/255 ≈ 0.502. The sRGB→linearRGB conversion for values above the piecewise threshold is:

linearRGB = ((sRGB + 0.055) / 1.055) ^ 2.4

For #808080 (sRGB = 0.502):
linearRGB = ((0.502 + 0.055) / 1.055) ^ 2.4
          = (0.557 / 1.055) ^ 2.4
          = 0.528 ^ 2.4
          ≈ 0.216

The browser computes Cb = 0.216, which is less than 0.5, so the lower branch applies:

overlay(Cs, 0.216) = 2 × Cs × 0.216 = 0.432 × Cs

For typical dark consent text rendered as #333333, the linearRGB value is approximately 0.05:

#333333 (sRGB = 0.2) → linearRGB ≈ 0.033
#4a4a4a (sRGB = 0.29) → linearRGB ≈ 0.069

overlay(0.05, 0.216) = 0.432 × 0.05 = 0.022
overlay(0.033, 0.216) = 0.432 × 0.033 = 0.014

The WCAG contrast ratio between output luminance 0.022 and a mid-gray background (linearRGB 0.216) is:

contrast = (L_lighter + 0.05) / (L_darker + 0.05)
         = (0.216 + 0.05) / (0.022 + 0.05)
         = 0.266 / 0.072
         ≈ 3.7 : 1

WCAG AA requires 4.5:1 for normal text and 3.0:1 for large text. A 3.7:1 ratio sits in the borderline zone — it passes large-text WCAG AA but fails normal-text WCAG AA. Consent panels typically use normal-size text, so this falls below the accessibility threshold and constitutes a contrast collapse attack even without the dramatic black/white degenerate cases.

Detection requirement: Auditors checking feBlend overlay in2 values must apply the sRGB→linearRGB conversion to the feFlood flood-color before evaluating the branch condition. An auditor that checks flood-color="#808080" against the threshold 0.5 using the raw sRGB value (0.502) will incorrectly classify this as the upper branch (near-neutral) and miss the contrast collapse.

SA-CSS-OVL-004: JS mousedown mode "normal"→"overlay" + feFlood opacity 0→1 (CRITICAL)

The click-time attack combines a mode switch and an opacity switch, both triggered on mousedown, to activate the dark-out exactly when the user attempts to approve the consent dialog.

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="f-ovl-004">
      <feFlood id="fl004" flood-color="black" flood-opacity="0" result="bk"/>
      <feBlend id="fb004" in="SourceGraphic" in2="bk" mode="normal"/>
    </filter>
  </defs>
</svg>

<div style="filter: url(#f-ovl-004)">
  <div class="mcp-consent-panel">
    Allow access to filesystem?
    <button id="approve-btn">Approve</button>
  </div>
</div>

<script>
document.getElementById('approve-btn').addEventListener('mousedown', () => {
  document.getElementById('fb004').setAttribute('mode', 'overlay');
  document.getElementById('fl004').setAttribute('flood-opacity', '1');
});
</script>

In the initial state, feBlend mode="normal" with feFlood flood-opacity="0" produces identity rendering — the consent panel is fully visible and readable during inspection. On mousedown (which fires before click and before the browser processes the button action), both attributes are mutated atomically. The filter switches to mode="overlay" with an opaque black feFlood in2, triggering the SA-CSS-OVL-001 degenerate case: all channels collapse to zero. The consent panel goes visually black at the moment of user interaction.

Timing exploit: The mousedown event fires before the click event and before the browser processes button form submission. The consent panel is readable up until the user initiates the click, then becomes invisible during the brief window when the user's hand is pressing the button. Static analysis of the DOM, screenshots, or pre-interaction audits will not detect the activated state.

Correct detection requires a MutationObserver targeting both the feBlend mode attribute and the feFlood flood-opacity attribute on any filter applied to a consent ancestor:

function watchConsentFilters(consentRoot) {
  // Walk up the DOM to find all ancestor elements with filter applied
  const ancestors = [];
  let el = consentRoot;
  while (el) {
    const style = getComputedStyle(el);
    if (style.filter && style.filter !== 'none') ancestors.push(el);
    el = el.parentElement;
  }

  // For each ancestor filter, resolve the SVG filter element
  ancestors.forEach(ancestor => {
    const filterRef = ancestor.style.filter || getComputedStyle(ancestor).filter;
    const match = filterRef.match(/url\(["']?#([^"')]+)["']?\)/);
    if (!match) return;
    const filterEl = document.getElementById(match[1]);
    if (!filterEl) return;

    // Watch feBlend mode attribute
    filterEl.querySelectorAll('feBlend').forEach(fb => {
      new MutationObserver(muts => {
        muts.forEach(m => {
          if (m.attributeName === 'mode') {
            console.warn('SA-CSS-OVL-004: feBlend mode mutated to', fb.getAttribute('mode'));
          }
        });
      }).observe(fb, { attributes: true, attributeFilter: ['mode'] });
    });

    // Watch feFlood flood-opacity attribute
    filterEl.querySelectorAll('feFlood').forEach(ff => {
      new MutationObserver(muts => {
        muts.forEach(m => {
          if (m.attributeName === 'flood-opacity') {
            console.warn('SA-CSS-OVL-004: feFlood flood-opacity mutated to', ff.getAttribute('flood-opacity'));
          }
        });
      }).observe(ff, { attributes: true, attributeFilter: ['flood-opacity', 'flood-color'] });
    });
  });
}

Attack summary

ID in2 Cb (linearRGB) Output formula Effect Severity
SA-CSS-OVL-001 Black feFlood 0 2×Cs×0 = 0 Unconditional dark-out (all text colors) CRITICAL
SA-CSS-OVL-002 White feFlood 1 1−2×(1−Cs)×0 = 1 Unconditional white-out (all text colors) CRITICAL
SA-CSS-OVL-003 Mid-gray feFlood (#808080) 0.216 (sRGB 0.502→linearRGB) 0.432×Cs (near-zero for dark text) Contrast collapse; 3.7:1 vs gray bg; WCAG AA fail HIGH
SA-CSS-OVL-004 Transparent→black (mousedown) 0→0 identity→2×Cs×0 = 0 Click-time dark-out; consent invisible at moment of approval CRITICAL

Defenses and detection

Detection of SA-CSS-OVL patterns requires tracing the SVG filter graph for any feBlend primitive with mode="overlay" that appears in a filter applied to a consent ancestor element.

Static feFlood analysis (OVL-001, OVL-002, OVL-003):

  1. Walk ancestor chain from consent root to document root; collect all elements with a filter CSS property referencing a <filter> element.
  2. Within each filter, locate feBlend primitives with mode="overlay".
  3. Resolve the in2 input: trace the in2 attribute to the referenced primitive. If in2 is a feFlood (directly or via result chaining), extract flood-color and flood-opacity.
  4. Convert the flood-color sRGB channel values to linearRGB using the IEC 61966-2-1 transfer function.
  5. If any channel's linearRGB Cb ≤ 0.05 and flood-opacity ≥ 0.9: flag dark-out (OVL-001 class).
  6. If any channel's linearRGB Cb ≥ 0.95 and flood-opacity ≥ 0.9: flag white-out (OVL-002 class).
  7. For intermediate Cb values: compute overlay(Cs_dark_text, Cb) where Cs_dark_text ≈ 0.05 (representative of dark consent text). Compute WCAG contrast ratio of output luminance against the page background luminance. Flag if ratio < 4.5:1.

Dynamic / click-time analysis (OVL-004):

  1. At page load, identify the consent ancestor filter graph (as above).
  2. Attach a MutationObserver watching feBlend mode attribute mutations and feFlood flood-color / flood-opacity attribute mutations within those filter elements.
  3. On any mutation: re-evaluate the overlay output formula with updated Cb; if the updated state satisfies the dark-out or white-out conditions above, flag immediately before the click event resolves.
  4. Additionally watch for the filter element ID being replaced or the consent ancestor's filter CSS property being swapped to a different filter ID.

Non-commutativity check:

Overlay is not symmetric. The formula places Cb (backdrop) in2 and Cs (source) in1. An attacker may swap in1 and in2 to use the source color as the modulating layer. Detection must check both in1 and in2 attributes: if in1 is the feFlood and in2 is SourceGraphic, the roles are reversed but the degenerate cases still apply (black in1: 2×0×Cb=0; white in1: 1−2×0×(1−Cb)=1).

Separation from CSS mix-blend-mode:

SVG feBlend mode="overlay" in a filter primitive is a distinct rendering path from CSS mix-blend-mode: overlay applied as a CSS property on an element. An auditor that detects one must independently check for the other; they share the same formula but are applied at different stages of the rendering pipeline and require different detection approaches.

// Detection sketch: locate feBlend mode=overlay in consent ancestor filters
function detectOverlayConsent(consentRoot) {
  const findings = [];
  let el = consentRoot.parentElement;
  while (el) {
    const filterProp = getComputedStyle(el).filter;
    const m = filterProp && filterProp.match(/url\(["']?#([^"')]+)["']?\)/);
    if (m) {
      const filterEl = document.getElementById(m[1]);
      if (filterEl) {
        filterEl.querySelectorAll('feBlend[mode="overlay"]').forEach(fb => {
          const in2Ref = fb.getAttribute('in2');
          // Resolve in2 to a feFlood if possible
          const in2El = filterEl.querySelector(`[result="${in2Ref}"]`);
          if (in2El && in2El.tagName === 'feFlood') {
            const opacity = parseFloat(in2El.getAttribute('flood-opacity') ?? '1');
            const color = in2El.getAttribute('flood-color') ?? 'black';
            const Cb = srgbToLinear(parseColor(color));
            if (opacity >= 0.9) {
              if (Cb.r <= 0.05 && Cb.g <= 0.05 && Cb.b <= 0.05)
                findings.push({ id: 'SA-CSS-OVL-001', severity: 'CRITICAL', el });
              else if (Cb.r >= 0.95 && Cb.g >= 0.95 && Cb.b >= 0.95)
                findings.push({ id: 'SA-CSS-OVL-002', severity: 'CRITICAL', el });
              else {
                const Cs_dark = 0.05;
                const out = Cb.r <= 0.5
                  ? 2 * Cs_dark * Cb.r
                  : 1 - 2 * (1 - Cs_dark) * (1 - Cb.r);
                const contrast = (Math.max(out, Cb.r) + 0.05) / (Math.min(out, Cb.r) + 0.05);
                if (contrast < 4.5)
                  findings.push({ id: 'SA-CSS-OVL-003', severity: 'HIGH', contrast, el });
              }
            }
          }
        });
      }
    }
    el = el.parentElement;
  }
  return findings;
}

SkillAudit findings coverage

CRITICAL SA-CSS-OVL-001 — feBlend mode=overlay + black in2: unconditional dark-out via 2×Cs×0=0. Detects feFlood flood-color with linearRGB ≤ 0.05 as in2 to an overlay feBlend on consent ancestor filter. Reports affected element path and confirms getComputedStyle evasion.
CRITICAL SA-CSS-OVL-002 — feBlend mode=overlay + white in2: unconditional white-out via 1−2×(1−Cs)×0=1. Detects feFlood flood-color with linearRGB ≥ 0.95 as in2 to an overlay feBlend on consent ancestor filter.
HIGH SA-CSS-OVL-003 — feBlend mode=overlay + mid-gray in2 (sRGB #808080 → linearRGB 0.216): applies sRGB→linearRGB conversion before branch evaluation; computes 0.432×Cs output for dark text; checks WCAG contrast ratio against page background; flags if < 4.5:1 for normal text.
CRITICAL SA-CSS-OVL-004 — JS mousedown mode "normal"→"overlay" + flood-opacity 0→1 click-time attack. MutationObserver on feBlend mode and feFlood flood-opacity/flood-color within consent ancestor filters; re-evaluates overlay output formula on every mutation; flags activation before click resolves.

Related: feBlend darken/lighten consent attacks (SA-CSS-BDL)  —  feBlend color-dodge/color-burn consent attacks  —  feBlend luminosity consent attacks  —  feBlend soft-light consent attacks

← Blog  |  Security Checklist