← Blog

MCP server SVG feBlend mode=hard-light consent security

Four SA-CSS-HLT consent attack patterns using feBlend mode="hard-light" — the overlay formula with in1 and in2 roles swapped. Because Cs (the consent element) drives branch selection rather than Cb (the feFlood backdrop), detectors written for overlay cannot catch hard-light, enabling dark-out of all mid-to-dark consent text with a black in2 layer, white-out of all light consent text with a white in2 layer, and click-time activation via JS mousedown mode switching.

By SkillAudit  |  24 Oct 2026

The SVG compositing specification defines feBlend mode="hard-light" as the conjugate of mode="overlay": the two formulas share the same piecewise structure but with the source and backdrop arguments transposed. Formally, hard_light(Cs, Cb) = overlay(Cb, Cs) — the in1 image value Cs now drives the branch condition, while the in2 image value Cb is used in the multiplication. The piecewise formula is: if Cs ≤ 0.5, output = 2×Cs×Cb; if Cs > 0.5, output = 1−2×(1−Cs)×(1−Cb).

This argument transposition has a critical security implication for MCP consent dialog analysis. In overlay, the feFlood value Cb determines which branch executes — a black feFlood (Cb=0) triggers the low branch for all pixels, and the formula simplifies deterministically. In hard-light, the feFlood value Cb is irrelevant to branch selection: it is the per-pixel source value Cs of the consent element that determines which formula applies. Detection tools that identify overlay attacks by inspecting feFlood values alone will not flag hard-light attacks with the same feFlood configurations. The two modes require separate, formula-aware evaluation.

Detector evasion by conjugation: A feBlend with mode="hard-light" and flood-color="black" has the same SVG structure as the overlay+black attack, but with mode="hard-light" instead of mode="overlay". Code that flags mode="overlay" via string match does not catch mode="hard-light". The formula outcome — dark-out of all mid/dark consent text — is identical, but the detection path is completely different because branch selection depends on Cs, not Cb.

The hard-light formula: overlay with arguments swapped

To reason about hard-light attacks, the formula must be evaluated with representative consent text values as Cs (the in1 / SourceGraphic), and the feFlood value as Cb (the in2 / backdrop):

/* hard-light formula (SVG §15.10, linearRGB) */
hard_light(Cs, Cb) = overlay(Cb, Cs)

/* Expanded: */
if (Cs <= 0.5) {
  out = 2 * Cs * Cb          // Cs drives the branch; Cb is the multiplier
} else {
  out = 1 - 2 * (1 - Cs) * (1 - Cb)
}

/* Contrast with overlay: */
overlay(Cs, Cb):
if (Cb <= 0.5) { out = 2 * Cb * Cs }
else            { out = 1 - 2 * (1 - Cb) * (1 - Cs) }
// In overlay, Cb drives the branch; in hard-light, Cs drives the branch

/* Representative Cs values for consent text: */
// Dark text Cs ≈ 0.05  (e.g. #1a1a1a → linearRGB ≈ 0.049)
// Mid-tone  Cs ≈ 0.20  (e.g. #808080 half-way → linearRGB ≈ 0.216 at threshold)
// Light     Cs ≈ 0.90  (e.g. near-white light-on-dark text)

SA-CSS-HLT-001: feBlend mode=hard-light + black in2 (Cb=0)

A feFlood with flood-color="black" sets Cb=0 on all pixels. Applying the hard-light formula: for the Cs≤0.5 branch, output = 2×Cs×0 = 0. Every pixel in the consent element where Cs≤0.5 — which includes all dark text, all mid-tone text, and any text with linearRGB channel value below 0.5 — is forced to zero (black). For the Cs>0.5 branch: output = 1−2×(1−Cs)×(1−0) = 1−2×(1−Cs) = 2Cs−1. Light text at Cs=0.9 gives 0.8 (light gray, darkened); at Cs=0.6 gives 0.2 (dark gray). No Cs value below the threshold escapes dark-out.

<filter id="hlt-001">
  <feFlood flood-color="black" flood-opacity="1" result="blackLayer"/>
  <feBlend in="SourceGraphic" in2="blackLayer" mode="hard-light"/>
</filter>
<!-- Cs=0.05 (dark text #1a1a1a): branch Cs<=0.5 → 2*0.05*0 = 0.000 (black) -->
<!-- Cs=0.20 (mid-tone):           branch Cs<=0.5 → 2*0.20*0 = 0.000 (black) -->
<!-- Cs=0.50 (exactly 0.5):        branch Cs<=0.5 → 2*0.50*0 = 0.000 (black) -->
<!-- Cs=0.60 (above threshold):    branch Cs>0.5  → 1-2*0.4*1 = 0.200 (dark gray) -->
<!-- Cs=0.90 (light text):         branch Cs>0.5  → 1-2*0.1*1 = 0.800 (light gray) -->
<!-- Cs=1.00 (pure white):         branch Cs>0.5  → 1-2*0.0*1 = 1.000 (stays white) -->
<!-- All standard dark consent text (Cs<=0.5) → black; invisible on dark backgrounds -->

Threshold at Cs=0.5 (linearRGB): The linearRGB value 0.5 corresponds to approximately sRGB #bcbcbc (the gamma-expanded midpoint). Nearly all readable dark consent text in both light-mode and dark-mode themes sits well below this threshold — dark grays (#1a1a1a, #374151, #111827) have linearRGB values of 0.05 or less. Only very light text (above sRGB #bcbcbc) exceeds the threshold, and even that is significantly darkened by the Cs>0.5 branch formula rather than preserved.

SA-CSS-HLT-002: feBlend mode=hard-light + white in2 (Cb=1)

A feFlood with flood-color="white" sets Cb=1 on all pixels. For the Cs≤0.5 branch: output = 2×Cs×1 = 2Cs. Dark consent text at Cs=0.05 gives 0.10 — doubled but still very dark; not a white-out. For the Cs>0.5 branch: output = 1−2×(1−Cs)×(1−1) = 1−0 = 1. Every pixel where Cs>0.5 is forced to 1 (white). This is the complement of SA-CSS-HLT-001: it targets light-theme text (Cs>0.5) rather than dark-theme text.

<filter id="hlt-002">
  <feFlood flood-color="white" flood-opacity="1" result="whiteLayer"/>
  <feBlend in="SourceGraphic" in2="whiteLayer" mode="hard-light"/>
</filter>
<!-- Cs=0.05 (dark text):   branch Cs<=0.5 → 2*0.05*1 = 0.100 (dark; remains visible) -->
<!-- Cs=0.20 (mid-dark):    branch Cs<=0.5 → 2*0.20*1 = 0.400 (mid-gray) -->
<!-- Cs=0.51 (just above):  branch Cs>0.5  → 1-2*0.49*0 = 1.000 (white-out) -->
<!-- Cs=0.90 (light text):  branch Cs>0.5  → 1-2*0.10*0 = 1.000 (white-out) -->
<!-- Cs=1.00 (white text):  branch Cs>0.5  → 1-2*0.00*0 = 1.000 (white-out) -->
<!-- All light consent text (Cs>0.5) → white; invisible on white backgrounds -->
<!-- Dark text (Cs<=0.5) is not white-outed — doubled but remains dark -->

The SA-CSS-HLT-002 attack specifically targets dark-themed MCP installer UIs where the consent text is rendered in light colors (near-white, #d1d5db, #e5e7eb — all with sRGB values above ~188, linearRGB above 0.5). On such UIs, hard-light with white in2 white-outs all consent text that is lighter than the linearRGB 0.5 threshold, making the text invisible against a white or light-gray background element that underlies the consent panel.

Context-specific exploitation: SA-CSS-HLT-002 is invisible in a light-mode audit environment where consent text is dark (Cs≤0.5). The attack only manifests on dark-themed clients where consent text is light. Auditors must test with the same theme used by the target MCP client. The hard-light+white combination is a precision attack: it selectively erases light text while leaving dark text intact.

SA-CSS-HLT-003: feBlend mode=hard-light + mid-gray in2 (Cb=0.216 linearRGB)

A feFlood with a mid-gray value (approximately sRGB #808080, linearRGB Cb≈0.216) produces a mixed attack that degrades both dark and light consent text below WCAG contrast thresholds without the all-or-nothing hard-stop of black or white in2. Applying the formula: for Cs=0.05 (dark text), branch Cs≤0.5 gives 2×0.05×0.216 = 0.022 (near-zero, almost black). For Cs=0.9 (light text), branch Cs>0.5 gives 1−2×0.1×(1−0.216) = 1−2×0.1×0.784 = 1−0.157 = 0.843 (near-white).

<filter id="hlt-003">
  <feFlood flood-color="rgb(128,128,128)" flood-opacity="1" result="midGray"/>
  <!-- sRGB 128/255 ≈ 0.502 → linearRGB ≈ 0.216 -->
  <feBlend in="SourceGraphic" in2="midGray" mode="hard-light"/>
</filter>
<!-- Cs=0.05 (dark text, sRGB #1a1a1a):  2*0.05*0.216 = 0.022 (near-black) -->
<!-- Cs=0.20 (mid-dark):                  2*0.20*0.216 = 0.086 (very dark) -->
<!-- Cs=0.50 (threshold):                 2*0.50*0.216 = 0.216 (mid-dark) -->
<!-- Cs=0.90 (light text, sRGB #e5e7eb):  1-2*0.1*0.784 = 0.843 (near-white) -->
<!-- Dark label vs white background: WCAG = (0.022+0.05)/(1+0.05) ≈ 0.069 (fails 4.5:1) -->
<!-- Light label vs dark background: similar WCAG failure (high but low-contrast vs near-white bg) -->

The mid-gray variant is harder to detect than the black or white cases because neither the feFlood value nor the output is at a channel extreme. The flood-color "rgb(128,128,128)" does not match any canonical dark-out or white-out signature. Correct detection requires evaluating the hard-light formula numerically with representative Cs sample values (0.05, 0.2, 0.5, 0.9) and checking whether any output falls below 0.05 (dark-out territory for dark text) or above 0.95 (white-out territory for light text).

SA-CSS-HLT-004: JS mousedown mode "normal"→"hard-light" + feFlood opacity 0→1 (black)

In the initial state, the feBlend mode is "normal" (identity pass-through) and the feFlood flood-opacity is 0 (fully transparent). The consent text renders correctly. On mousedown — triggered when the user clicks the consent button — the script simultaneously sets mode="hard-light" and flood-opacity="1" on the black feFlood. With Cb=0, all consent text pixels where Cs≤0.5 drop to zero at click time, dark-outing the consent text precisely when it matters most.

const filter = document.getElementById("hlt-004-filter");
const blendEl = filter.querySelector("feBlend");
const floodEl = filter.querySelector("feFlood");

consentButton.addEventListener("mousedown", () => {
  // Initial: blendEl.mode="normal", floodEl.floodOpacity="0" (identity; correct render)
  blendEl.setAttribute("mode", "hard-light");     // switch formula: hard_light(Cs, Cb)
  floodEl.setAttribute("flood-opacity", "1");     // Cb=0 (black) now fully opaque
  // Result: all consent text Cs<=0.5 → 2*Cs*0=0; dark-out at click time
  // User sees consent text disappear the instant they click "Install"
});
<filter id="hlt-004-filter">
  <feFlood flood-color="black" flood-opacity="0" result="blackLayer"/>
  <!-- flood-opacity="0": initially transparent; Cb contribution = 0 regardless -->
  <feBlend in="SourceGraphic" in2="blackLayer" mode="normal"/>
  <!-- mode="normal": identity pass-through in initial state -->
</filter>

MutationObserver requirement: Two attributes must be monitored simultaneously: feBlend mode and feFlood flood-opacity (and flood-color). The attack requires both changes to activate — monitoring only one attribute will see a benign initial state (mode=normal, opacity=0) and miss the combined mousedown transition. A MutationObserver configured on both attributes of both primitives within the consent ancestor filter is the minimum runtime defence.

Key distinction: hard-light vs overlay — separate detector required

The overlay and hard-light formulas are structurally identical with arguments transposed. This means a detector designed for overlay will not catch hard-light without explicit adaptation. The critical difference is which input drives branch selection:

/* overlay: Cb (the feFlood / in2 value) drives the branch */
overlay(Cs, Cb):
  if (Cb <= 0.5) → low branch: 2*Cb*Cs    // Cb = feFlood value drives branch
  else           → high branch

/* hard-light: Cs (the source element / in1 value) drives the branch */
hard_light(Cs, Cb):
  if (Cs <= 0.5) → low branch: 2*Cs*Cb    // Cs = consent element value drives branch
  else           → high branch

/* Overlay detector logic (WRONG for hard-light): */
if (feFlood_linearRGB <= 0.5) {
  // Flags overlay+dark correctly: Cb<=0.5 triggers dark multiplication
  // MISSES hard-light+dark: hard-light Cs branch ≠ Cb branch
  flag_dark_out();
}

/* Correct hard-light detector: sample Cs from source text */
const csValues = [0.05, 0.2, 0.5, 0.9];  // representative dark-to-light text range
for (const cs of csValues) {
  const cb = feFlood_linearRGB;
  const out = cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb);
  if (out < 0.05) flag_dark_out_at_cs(cs);
  if (out > 0.95) flag_white_out_at_cs(cs);
}

The overlay detector checking if (feFlood_value ≤ 0.5) flag_darkout will correctly identify overlay+dark-feFlood but will NOT catch hard-light+dark-feFlood. With hard-light+black (Cb=0), the feFlood value is 0 (which is ≤0.5), so a naive Cb-based detector would flag it — but only coincidentally, not for the right reason. A detector that checks if (feFlood_value < 0.1) flag_overlay_darkout without also evaluating the hard-light formula separately will emit false reassurance for hard-light configurations where feFlood is mid-gray but still produces dark-out (SA-CSS-HLT-003).

Attack summary

IDin2 CbBranch conditionEffect on dark text (Cs=0.05)Effect on light text (Cs=0.9)Severity
SA-CSS-HLT-001 Black (Cb=0) Cs≤0.5: 2Cs×0=0; Cs>0.5: 1−2(1−Cs) 0.000 (dark-out) 0.800 (darkened, not white-out) Critical
SA-CSS-HLT-002 White (Cb=1) Cs≤0.5: 2Cs; Cs>0.5: 1−0=1 0.100 (doubled; remains dark) 1.000 (white-out) Critical
SA-CSS-HLT-003 Mid-gray (Cb=0.216) Cs≤0.5: 0.432×Cs; Cs>0.5: mixed 0.022 (near-zero; WCAG fail) 0.843 (near-white; WCAG fail) High
SA-CSS-HLT-004 Transparent→black at mousedown identity→Cs≤0.5: 0 identity→0 (click-time dark-out) identity→0.800 (click-time darken) Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-HLT-001: feBlend mode="hard-light" with feFlood black in2 (Cb=0) — hard-light formula: Cs≤0.5 branch: 2×Cs×0=0; ALL consent text with linearRGB Cs≤0.5 (dark text #1a1a1a→0.05, mid-gray #808080→0.216) dark-outs to zero; Cs>0.5 branch: 1−2(1−Cs) darkens light text; detection: evaluate formula with Cs=0.05 and Cb=0; output=0; flag as dark-out; separate from overlay detector.
CRITICAL SA-CSS-HLT-002: feBlend mode="hard-light" with feFlood white in2 (Cb=1) — Cs>0.5 branch: 1−2×(1−Cs)×0=1; ALL consent text with linearRGB Cs>0.5 white-outs to 1; targets dark-themed MCP clients where light consent text (Cs=0.9, sRGB #e5e7eb) is the norm; dark text (Cs≤0.5) is not erased (output=2Cs) but context-specific attack on light text is CRITICAL; detection: evaluate formula with Cs=0.9 and Cb=1; output=1; flag as white-out.
HIGH SA-CSS-HLT-003: feBlend mode="hard-light" with feFlood mid-gray in2 (Cb=0.216, sRGB #808080) — Cs=0.05: 2×0.05×0.216=0.022 (WCAG dark text vs white: <1.1:1); Cs=0.9: 1−1.568×0.1=0.843 (WCAG light text vs white: ~1.3:1); neither output is at a channel extreme; evades black/white feFlood detectors; detection requires formula evaluation across Cs sample range not feFlood string matching.
CRITICAL SA-CSS-HLT-004: JS mousedown sets feBlend mode "normal"→"hard-light" and feFlood flood-opacity "0"→"1" (black) simultaneously — initial state: identity rendering; all consent text visible; mousedown: Cb=0 activated + hard-light formula; Cs≤0.5 text → 0; click-time dark-out; MutationObserver on both feBlend mode and feFlood flood-opacity attributes within consent ancestor filter required; single-attribute observer insufficient.

Related: feBlend mode=overlay consent attacks  |  feBlend mode=soft-light consent attacks  |  feBlend mode=darken/lighten consent attacks  |  feBlend mode=luminosity consent attacks

← Blog  |  Security Checklist