SVG feBlend darken, lighten, color-dodge, and color-burn Consent Attacks on MCP Servers: Synthesis and Detection
Four SVG feBlend blend modes — darken, lighten, color-dodge, color-burn — each erase MCP consent panel text via distinct arithmetic paths. CSS property scanners miss all four. This post synthesizes the formula-level mechanics, documents six SA-CSS attack patterns, and presents a unified detection algorithm that evaluates blend output numerically rather than matching strings.
Why feBlend Attacks Evade CSS mix-blend-mode Detectors
The most important architectural fact about SVG feBlend attacks is where they operate: inside the SVG filter primitive pipeline, not on CSS property boxes. When a security scanner checks DOM elements for suspicious mix-blend-mode values, it reads the style attribute or computed CSS of each element. That check is entirely correct for CSS blend modes. It is entirely blind to SVG filter blend modes.
An SVG filter is declared in a <filter> element — typically inside an inline <svg> block that may have width:0;height:0;position:absolute;overflow:hidden to make it invisible. The consent panel element references this filter with a single CSS declaration: filter: url(#f). At that point, rendering is handed to the browser's SVG compositing engine. The SVG filter pipeline processes every pixel of the element independently through a chain of primitives — feFlood, feBlend, feComposite, and so on — and produces a new pixel buffer that replaces what would have been the element's normal painted output.
A scanner that detects mix-blend-mode: lighten on a DOM element will not detect <feBlend mode="lighten"> inside a filter. The CSS property and the SVG filter attribute share no inspection path. This means the entire darken/lighten/color-dodge/color-burn family is invisible to CSS-only auditors — and these are precisely the modes that, with carefully chosen feFlood in2 values, can collapse consent text to a single undifferentiated color.
Detection gap. Checking getComputedStyle(element).mixBlendMode returns "normal" for consent panels that are being attacked by <feBlend mode="darken"> inside a filter. The CSS layer has no knowledge of SVG filter primitives. Auditors must inspect the SVG filter graph directly.
The Four-Mode Family
All four modes share a common attack structure: an feBlend primitive takes two inputs — in (the consent panel's source graphic, Cs) and in2 (a solid-color feFlood, Cb) — and applies a per-channel blend formula. The attacker controls Cb by setting feFlood flood-color and flood-opacity. By choosing Cb at or near an extreme value (0 or 1), the blend formula collapses the output range for text channels, making the text indistinguishable from the background.
SA-CSS-DAR: feBlend mode=darken
The darken formula selects the minimum value per channel: out = min(Cs, Cb). This is the channel-wise minimum: for every R, G, and B channel independently, whichever input is darker wins.
Static attack: darken + black in2
With a black feFlood as in2, Cb = 0 on all channels. The formula becomes min(Cs, 0) = 0 regardless of Cs. Every channel of every pixel collapses to zero — black. Consent text on any background becomes invisible: the output is a uniform black rectangle at exactly the consent panel's bounding box. This is a complete WCAG contrast failure (ratio 1:1).
<filter id="f"> <feFlood flood-color="black" flood-opacity="1" result="dark"/> <feBlend in="SourceGraphic" in2="dark" mode="darken"/> </filter>
Evasion variant: darken + mid-gray in2
With a mid-gray feFlood (Cb = 0.5), the formula becomes min(Cs, 0.5). Dark text (Cs ≈ 0.1) is unaffected: min(0.1, 0.5) = 0.1. But light text (Cs ≈ 0.9) is darkened: min(0.9, 0.5) = 0.5 — mid-gray. On a light background (Cb_bg ≈ 0.95), the background is also darkened to 0.5. Net result: text and background both converge toward 0.5, severely reducing contrast. This variant evades a simple "black in2" check because flood-color is not "black".
On a dark background theme (Cb_bg ≈ 0.1): min(0.1, 0.5) = 0.1 for background (unchanged), but white text (Cs = 1.0): min(1.0, 0.5) = 0.5 — reduced to mid-gray on a dark background. Contrast ratio drops from 21:1 (white on black) to approximately 3.9:1 (mid-gray on dark), falling below WCAG AA threshold for body text.
The JavaScript trigger pattern for SA-CSS-DAR switches the feBlend from a benign mode on page load to darken at the moment of consent presentation, and simultaneously raises feFlood opacity from 0 to 1:
// SA-CSS-DAR JS trigger (mousedown on consent button)
document.querySelector('#consent-btn').addEventListener('mousedown', () => {
document.querySelector('#blend').setAttribute('mode', 'darken');
document.querySelector('#flood').setAttribute('flood-opacity', '1');
});
SA-CSS-LGT: feBlend mode=lighten
The lighten formula selects the maximum value per channel: out = max(Cs, Cb). This is the inverse of darken. With a high Cb, all channels are pushed toward the maximum, erasing dark text by making it indistinguishable from a white or near-white background.
Static attack: lighten + white in2
With a white feFlood (Cb = 1.0): max(Cs, 1) = 1. Every channel of every pixel becomes 1 — white. The entire consent panel outputs as a white rectangle. Dark text on white background: ratio 1:1. Complete white-out.
<filter id="f"> <feFlood flood-color="white" flood-opacity="1" result="lt"/> <feBlend in="SourceGraphic" in2="lt" mode="lighten"/> </filter>
Evasion variant: lighten + near-white in2
With flood-color="#f0f0f0", Cb ≈ 0.941. The formula: dark text (Cs ≈ 0.1): max(0.1, 0.941) = 0.941 — raised to near-white. Light background (Cs ≈ 0.95): max(0.95, 0.941) = 0.95 — unchanged. Both text and background now output near-identical near-white values. This evades a Cb = 1.0 exact-match checker because flood-color is #f0f0f0, not white or #ffffff. The actual luminance difference between text output (0.941) and background output (0.95) is only 0.009, producing a contrast ratio of approximately 1.02:1 — functionally invisible.
SA-CSS-CDG: feBlend mode=color-dodge
The color-dodge formula, per W3C Compositing and Blending Level 1, operates per channel:
- If Cb = 0: output = 0
- If Cs = 1: output = 1
- Otherwise: output = min(1, Cb / (1 − Cs))
The key property is the denominator (1 − Cs). As Cs approaches 1, this denominator approaches 0 and the quotient becomes arbitrarily large, clamped to 1. This is the saturation path: a near-1 Cs value acts as a gain amplifier that pushes any non-zero Cb to 1. Color-dodge is the most powerful of the four modes for white-out attacks because the amplification is non-linear and the threshold behavior is difficult to detect without numerical evaluation.
Channel-selective attack with near-1 in2
Consider flood-color="#fd0000": R channel Cs = 0.992, G = 0, B = 0. For the R channel of any text pixel with R>0: Cb_R / (1 − 0.992) = Cb_R / 0.008 = 125 × Cb_R. Any consent text with R > 0.008 (any non-black R component) has its R channel clamped to 1. The G and B channels are unaffected because Cs = 0 there. This selectively destroys the R channel of dark text (#333 has R ≈ 0.2: output R = 1, G = 0.2, B = 0.2 → tinted red), reducing legibility without a uniform white-out that would trigger a brightness-only detector.
Full white-out via near-1 all-channel in2
With flood-color="#fafafa" (Cs ≈ 0.98 across all channels): for any consent text pixel with channel value Cb > 0.02: output = min(1, Cb / 0.02) = min(1, 50 × Cb) = 1. Any text channel above 2% luminance saturates to 1. In practice, all text and background pixels (which have Cb > 0.02 on at least one channel) output white. This evades a Cs = 1 exact-match check because flood-color is #fafafa, not white. Detecting this requires checking whether Cs is within 0.05 of 1 and then evaluating the amplification factor.
<filter id="f"> <feFlood flood-color="#fafafa" result="cdg"/> <feBlend in="SourceGraphic" in2="cdg" mode="color-dodge"/> </filter>
SA-CSS-CBN: feBlend mode=color-burn
Color-burn is the dual of color-dodge. The formula per W3C Compositing and Blending Level 1:
- If Cb = 1: output = 1
- If Cs = 0: output = 0
- Otherwise: output = max(0, 1 − (1 − Cb) / Cs)
As Cs approaches 0, the denominator in (1 − Cb) / Cs approaches 0 and the quotient becomes arbitrarily large, producing a large negative number that clamps to 0. A near-0 Cs value acts as a dark amplifier: any Cb below a threshold is pushed to 0 (black). Color-burn targets dark-theme consent panels most effectively: on a dark UI, consent text is white or near-white (Cb near 1), but the formula's "if Cb=1: 1" rule means white text is preserved, while all other elements (borders, checkboxes, mid-tone backgrounds) are collapsed to black, creating extreme selective destruction.
Dark-out via near-0 in2
With flood-color="#030303" (Cs ≈ 0.012): for any pixel with Cb < 0.988: output = max(0, 1 − (1 − Cb) / 0.012). For dark consent text (Cb = 0.2 in a dark theme): 1 − (0.8 / 0.012) = 1 − 66.7 = max(0, −65.7) = 0. For mid-gray UI elements (Cb = 0.5): 1 − (0.5 / 0.012) = 1 − 41.7 = 0. Everything below Cb = 0.988 collapses to zero. Only near-white pixels (Cb > 0.988) survive. On a dark-theme panel, the surviving whites may be background highlights rather than text, inverting the normal foreground/background relationship.
<filter id="f"> <feFlood flood-color="#030303" result="cbn"/> <feBlend in="SourceGraphic" in2="cbn" mode="color-burn"/> </filter>
Light in2 variant for soft dark-out
With Cs = 0.9: for dark text (Cb = 0.2): 1 − (0.8 / 0.9) = 1 − 0.889 = 0.111. Dark text on a dark background becomes very dark, but not zero. This variant avoids the complete black-out signature (no uniform black rectangle) while still producing contrast ratios far below WCAG thresholds. A near-0 in2 check at exact Cs = 0 would miss this entirely.
The Cross-Mode Detection Gap: Why Same Output Does Not Mean Same Code Path
A critical insight for scanner design: multiple feBlend modes can produce the same visual output via entirely different code paths. White-out is achievable by at least three distinct mode+in2 combinations:
- lighten + white in2:
max(Cs, 1) = 1 - screen + white in2:
Cs + 1 − Cs × 1 = 1(screen formula: Cs + Cb − Cs×Cb) - color-dodge + Cs=1 in2: formula rule "if Cs=1: 1"
These three SVG filter strings are syntactically distinct: mode="lighten", mode="screen", and mode="color-dodge". A scanner that only flags mode="lighten" will miss the screen and color-dodge paths to the identical visual result. Similarly, dark-out via mode="darken" with black in2 can be replicated by mode="multiply" with black in2 (Cs × 0 = 0) — same result, different mode string, different detection rule required.
Per-mode rules are necessary but not sufficient. A complete scanner must have separate detection rules for every feBlend mode that can produce extreme output. Detecting only one mode string while the rest of the mode family goes unchecked gives attackers a menu of equivalent alternatives, each requiring only a single-character change in the SVG markup.
Unified Detection Algorithm for All Four Modes
The correct detection strategy is not string matching on mode names or flood colors. It is numerical formula evaluation. The algorithm below applies to any feBlend element found inside a filter that is referenced by a consent ancestor element:
- Identify scope. For any
<feBlend>withmodein {darken, lighten, color-dodge, color-burn} inside a filter referenced by a consent panel ancestor (viafilter: url(#id)orfilterattribute): - Resolve in2. Trace the named result chain from the feBlend's
in2attribute back to its source primitive. If it terminates at anfeFlood, extractflood-colorandflood-opacityto obtain {R, G, B} normalized to [0,1]. Apply opacity: effective Cb channel = Cb × flood-opacity. - Evaluate formula per channel.
- darken: compute
min(sample_Cs, Cb)for representative text luminance values (Cs ∈ {0.05, 0.1, 0.2, 0.5, 0.8, 0.9}). If max output < 0.1 on a dark-background page, or if text and background outputs differ by <0.1, flag dark-out. - lighten: compute
max(sample_Cs, Cb). If min output > 0.9 on a light-background page, or if text and background outputs differ by <0.1, flag white-out. - color-dodge: if Cs ≥ 0.95 on any channel, compute amplification factor 1/(1−Cs). If any representative text Cb > 0.05/(1−Cs), output saturates to 1; flag clamp/white-out.
- color-burn: if Cs ≤ 0.05 on any channel, compute amplification factor 1/Cs. If any representative text Cb < 1−Cs, output clamps to 0; flag dark-out.
- darken: compute
- Near-extreme check. Even if Cs is not exactly 0 or 1, flag any Cb within 0.05 of 0 or 1 for further numerical evaluation. Do not rely on exact-value string matching ("black", "white", "#000000", "#ffffff") — attackers use hex codes that fall outside these exact values.
- Output range collapse check. Regardless of mode, if the computed output range [min output across all consent-text sample pixels, max output] has
max − min < 0.1, flag as constant-output attack. This catch-all covers combinations not anticipated by per-mode rules. - Dynamic monitoring. Register a MutationObserver on every feBlend element found inside consent-ancestor filters, watching the
mode,in, andin2attributes. Register a separate MutationObserver on all feFlood elements in those filters, watchingflood-colorandflood-opacity. Trigger re-evaluation on every mutation.
// Unified feBlend detection — numerical formula evaluation
function evaluateFeBlend(feBlend, consentEl) {
const mode = feBlend.getAttribute('mode');
if (!['darken','lighten','color-dodge','color-burn'].includes(mode)) return null;
const in2Name = feBlend.getAttribute('in2');
const flood = resolveFeFlood(feBlend, in2Name); // trace result chain
if (!flood) return null;
const {r, g, b} = parseFloodColor(flood); // normalized [0,1]
const opacity = parseFloat(flood.getAttribute('flood-opacity') ?? '1');
const Cb = { r: r*opacity, g: g*opacity, b: b*opacity };
const samples = [0.05, 0.1, 0.2, 0.5, 0.8, 0.9]; // representative Cs values
const outputs = samples.flatMap(Cs => ['r','g','b'].map(ch => {
const cs = Cs, cb = Cb[ch];
if (mode === 'darken') return Math.min(cs, cb);
if (mode === 'lighten') return Math.max(cs, cb);
if (mode === 'color-dodge') {
if (cb === 0) return 0;
if (cs >= 1) return 1;
return Math.min(1, cb / (1 - cs));
}
if (mode === 'color-burn') {
if (cb >= 1) return 1;
if (cs <= 0) return 0;
return Math.max(0, 1 - (1 - cb) / cs);
}
}));
const range = Math.max(...outputs) - Math.min(...outputs);
return {
mode, Cb,
outputRange: range,
flag: range < 0.1 ? 'CONSTANT_OUTPUT' : checkContrastCollapse(outputs)
};
}
Attack Summary Table
Six SA-CSS patterns across the four-mode family, covering both static (always-on) and dynamic (JS-triggered) variants:
| Pattern ID | Mode | in2 (feFlood) | Severity | Effect | Evasion |
|---|---|---|---|---|---|
| SA-CSS-DAR-001 | darken | black (Cb=0) | CRITICAL | Complete black-out: min(Cs,0)=0 all channels | Trivially detected; use mid-gray variant |
| SA-CSS-DAR-002 | darken | mid-gray (Cb=0.5) | HIGH | All channels clamped to ≤0.5; text/bg converge | Evades flood-color="black" exact-match check |
| SA-CSS-LGT-001 | lighten | white (Cb=1) | CRITICAL | Complete white-out: max(Cs,1)=1 all channels | Trivially detected; use near-white variant |
| SA-CSS-LGT-002 | lighten | near-white #f0f0f0 (Cb=0.941) | HIGH | Dark text raised to 0.941; contrast ratio ≈1.02:1 | Evades Cb=1 exact-match; requires numerical range check |
| SA-CSS-CDG-001 | color-dodge | near-1 #fafafa (Cs=0.98) | CRITICAL | 50x amplifier; any Cb>0.02 saturates to 1; white-out | Evades Cs=1 exact-match; requires saturation formula eval |
| SA-CSS-CBN-001 | color-burn | near-0 #030303 (Cs=0.012) | CRITICAL | 83x amplifier; any Cb<0.988 collapses to 0; dark-out | Evades Cs=0 exact-match; requires burn formula eval |
JavaScript Trigger Patterns
Static always-on attacks are the simplest form, but many feBlend attacks use JavaScript triggers that switch the blend mode only at the moment the user is most likely to act — during a mousedown or touchstart on a consent button. This minimizes the time window during which the filter is active and thus the time window during which a continuous monitoring scanner would detect it.
The general JS pattern for all four modes follows the same structure: the filter is benign (mode="normal" or flood-opacity="0") during page load and accessibility scans, then transitions to the attack mode at the moment of user interaction:
// SA-CSS-LGT JS variant: lighten white-out on mousedown
document.querySelector('#consent-btn').addEventListener('mousedown', e => {
// Switch to lighten mode
document.querySelector('#blend-prim').setAttribute('mode', 'lighten');
// Raise flood opacity to 1 (white feFlood was opacity:0 at load)
document.querySelector('#flood-prim').setAttribute('flood-opacity', '1');
// On mouseup, restore to avoid post-click detection
e.target.addEventListener('mouseup', () => {
document.querySelector('#blend-prim').setAttribute('mode', 'normal');
document.querySelector('#flood-prim').setAttribute('flood-opacity', '0');
}, { once: true });
});
// SA-CSS-CDG JS variant: color-dodge white-out
document.querySelector('#consent-btn').addEventListener('mousedown', () => {
document.querySelector('#blend-prim').setAttribute('mode', 'color-dodge');
document.querySelector('#flood-prim').setAttribute('flood-color', '#fafafa');
});
// SA-CSS-CBN JS variant: color-burn dark-out
document.querySelector('#consent-btn').addEventListener('mousedown', () => {
document.querySelector('#blend-prim').setAttribute('mode', 'color-burn');
document.querySelector('#flood-prim').setAttribute('flood-color', '#030303');
});
The mouseup restoration pattern is particularly dangerous because by the time a post-interaction DOM snapshot is taken, the filter has already been reset to benign state. Only a MutationObserver running continuously during the interaction window — with timestamps on attribute changes — can reliably capture this attack class.
Defenses and Hardening
Effective defense against the four-mode feBlend family requires three overlapping layers, each covering failure modes in the others:
1. Structural detection: enumerate feBlend mode attributes
Walk every <filter> element referenced by consent panel ancestors. For each <feBlend> found, check whether mode is in the sensitive set: darken, lighten, color-dodge, color-burn (and additionally: multiply, screen, hard-light, soft-light, difference, exclusion — all can produce extreme outputs with appropriate in2 values). Structural detection is fast and O(n) in filter primitive count, suitable as a first pass.
2. Numerical formula evaluation: simulate blend output
For every feBlend in a consent ancestor filter with a sensitive mode, resolve the in2 feFlood color to {R,G,B} and numerically evaluate the blend formula across representative consent text luminance values. Check:
- Output range collapse:
max(outputs) − min(outputs) < 0.1triggers constant-output flag - Near-extreme in2: any Cb channel within 0.05 of 0 or 1 triggers amplification analysis
- For color-dodge: if any Cs channel ≥ 0.95, compute effective threshold
Cb_threshold = 0.05 × (1 − Cs); if typical text Cb exceeds threshold, flag - For color-burn: if any Cs channel ≤ 0.05, compute threshold
Cb_threshold = 1 − Cs; if typical text Cb is below threshold, flag
Do not use string matching on flood-color values ("black", "white", "#000000", "#ffffff"). Parse the color numerically. A scanner that checks getAttribute('flood-color') === 'black' misses #000001, rgb(0,0,1), oklch(0% 0 0), and the mid-gray attack (Cb=0.5) entirely.
3. Dynamic monitoring: MutationObserver on all filter primitives
Register MutationObservers on all feBlend and feFlood elements inside consent-ancestor filters, watching these attribute sets:
- feBlend:
mode,in,in2 - feFlood:
flood-color,flood-opacity
On every mutation, re-run the numerical formula evaluation for the affected feBlend's current state. Log the timestamp and attribute delta. The mousedown/mouseup toggle pattern produces two mutations with a sub-100ms gap; a detector that logs all mutations will capture both the attack-active and attack-reset states, even if only the reset state is seen in a post-interaction DOM snapshot.
// MutationObserver registration for all 4 feBlend modes
function monitorConsentFilters(consentEl) {
const filterId = getReferencedFilterId(consentEl);
if (!filterId) return;
const filter = document.getElementById(filterId);
if (!filter) return;
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
if (m.type !== 'attributes') return;
const el = m.target;
const tag = el.tagName.toLowerCase();
if (tag === 'feblend' && m.attributeName === 'mode') {
const mode = el.getAttribute('mode');
if (['darken','lighten','color-dodge','color-burn'].includes(mode)) {
console.warn('[SkillAudit] feBlend mode mutation to sensitive value:', mode, el);
evaluateFeBlend(el, consentEl); // re-run numerical check
}
}
if (tag === 'feflood' &&
['flood-color','flood-opacity'].includes(m.attributeName)) {
// Re-evaluate any feBlend using this flood as in2
const result = el.getAttribute('result');
filter.querySelectorAll('feBlend').forEach(fb => {
if (fb.getAttribute('in2') === result) evaluateFeBlend(fb, consentEl);
});
}
});
});
observer.observe(filter, {
subtree: true,
attributes: true,
attributeFilter: ['mode','in','in2','flood-color','flood-opacity']
});
}
Relationship to the Broader feBlend Mode Family
The four modes covered in this post — darken, lighten, color-dodge, color-burn — form one cohesive group within the full SVG feBlend mode set. They share a common mathematical property: they can achieve constant-output (all pixels identical) with a single extreme feFlood in2 value, making them the highest-priority modes for consent attack detection.
Other feBlend modes in the same family that can achieve similar effects via different arithmetic paths include: multiply (same as darken+black, but via multiplication), screen (same as lighten+white, but via complement multiplication), and hard-light (a conditional mode-switch between multiply and screen). These are covered in companion analyses:
- The darken/lighten dedicated reference page provides per-attribute parameter tables and browser rendering notes for the two min/max modes.
- The color-dodge/color-burn dedicated reference page covers the full W3C saturation formula derivation and edge-case behavior at exact Cb=0 and Cs=1 boundaries.
- The feBlend luminosity mode analysis covers the HSL-space luminosity and color modes, which attack text legibility via hue/saturation manipulation rather than per-channel brightness collapse.
- The feBlend overlay mode analysis (recently published) covers the conditional blend formula and how overlay interacts with dark/light theme consent panels differently.
Synthesis key. The unifying principle across all feBlend consent attacks is output range collapse: any feBlend configuration that compresses the range of output values across a consent panel to below 0.1 on any perceptual contrast metric constitutes a consent attack, regardless of which mode or in2 color value achieves it. Formula-based output simulation catches all variants; string matching on mode names or flood-color values catches only the patterns the scanner author anticipated.
Summary
The four feBlend modes analyzed in this post — darken, lighten, color-dodge, color-burn — each provide a distinct arithmetic path to consent panel erasure in MCP server UI contexts. Darken uses channel-wise minimum to push all pixels toward a dark in2; lighten uses channel-wise maximum to push all pixels toward a light in2; color-dodge uses a saturation amplifier that clamps to white when Cs approaches 1; color-burn uses the dual amplifier that clamps to black when Cs approaches 0. All four operate entirely within the SVG filter pipeline, invisible to CSS mix-blend-mode detectors.
Near-extreme in2 variants (Cb within 5% of 0 or 1) evade exact-match string checkers for all four modes. JavaScript toggle patterns that activate the attack only during a mousedown event evade static DOM snapshot audits. A complete defense requires: structural enumeration of all feBlend modes in consent ancestor filters, numerical formula evaluation across representative consent text luminance samples, output range collapse detection as a catch-all, and continuous MutationObserver coverage on all relevant SVG filter primitive attributes.
The SA-CSS-DAR, SA-CSS-LGT, SA-CSS-CDG, and SA-CSS-CBN pattern families collectively represent a significant attack surface in the SVG filter compositing layer of any MCP server that renders user consent panels. Formula-based detection — not per-mode string matching — is the only approach that scales across the full mode family as new variants are discovered.