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.
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
| ID | in2 Cb | Branch condition | Effect 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
- Evaluate hard-light formula separately from overlay: Do not reuse an overlay detector for hard-light. The formulas share structure but differ in which argument drives branch selection. Build a dedicated hard-light evaluator that uses Cs (sampled from representative consent text: 0.05, 0.2, 0.5, 0.9 in linearRGB) and Cb (from the resolved feFlood value) to compute the output per the hard-light formula.
- Sample Cs across a range of representative values: Unlike overlay (where Cb alone determines branch and a single feFlood value produces a deterministic result for all pixels), hard-light branches on Cs — so the per-pixel source value of the consent text matters. Test with a minimum of four Cs samples covering dark (0.05), mid-dark (0.2), mid (0.5), and light (0.9) to cover both branches and detect both dark-out (output < 0.05) and white-out (output > 0.95) outcomes.
- Flag when any representative Cs sample produces sub-threshold output: If hard-light formula output for Cs≤0.5 range falls below 0.05 (dark-out territory), flag regardless of feFlood value. If output for Cs>0.5 range exceeds 0.95 (white-out territory), flag separately. The mid-gray SA-CSS-HLT-003 variant requires this range-based approach because neither the feFlood value nor the output is at a channel extreme.
- String-match
mode="hard-light"separately frommode="overlay": Both attributes require explicit detection. A filter that searches for overlay attacks will not flag hard-light unless hard-light is listed as a separate mode to inspect. Enumerate all compositing modes (hard-light, overlay, soft-light, color-dodge, color-burn, multiply, screen) independently. - MutationObserver on feBlend mode attribute AND feFlood flood-opacity/flood-color: The SA-CSS-HLT-004 JS mousedown attack requires both mode and opacity changes to activate. Monitor mode changes on feBlend and both flood-color and flood-opacity changes on feFlood nodes within consent ancestor filters. A single-attribute observer is insufficient.
- Audit in both light and dark themes: SA-CSS-HLT-001 (dark-out) is most harmful in dark-themed clients with dark consent text; SA-CSS-HLT-002 (white-out) targets light consent text in dark-themed clients. A single-theme audit will not expose both attack variants. Test the consent flow with both light and dark system themes applied.
SkillAudit findings for this attack surface
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.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.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.