Security Guide
MCP server SVG feFuncR consent security — slope="0" intercept="1" red-channel max-force, intercept="0" cyan shift, type="gamma" amplitude boost, and JS mousedown injection
The <feFuncR> element inside <feComponentTransfer> defines the transfer function for the red channel only, leaving green, blue, and alpha channels at their default identity mapping unless explicitly overridden. The SVG specification defines five transfer function types for each feFuncX element: identity, linear (C_out = slope × C_in + intercept), gamma (C_out = amplitude × C_in^exponent + offset), table, and discrete. Manipulating the red channel alone changes the perceived color of consent text to match specific backgrounds — making text invisible on red, pink, salmon, orange, or teal backgrounds depending on whether the channel is forced high or low. The attack is background-dependent: an auditor testing on white always sees readable text (black text on white is high contrast even with R=max or R=0), while the same consent element is invisible in red-themed MCP clients or dark-mode UIs with teal accent colors. Four attack patterns follow.
Attack 1: feFuncR slope="0" intercept="1" — red channel forced to maximum, invisible on red/pink backgrounds (SA-CSS-FCR-001)
The linear transfer function with slope="0" and intercept="1" produces C_out = 0 × C_in + 1 = 1 for every input value. Every pixel in the consent element has its red component set to maximum (R=255 in 8-bit terms). Green and blue channels are unchanged. The resulting color of originally dark-gray text (rgb(17, 24, 39)) becomes rgb(255, 24, 39) — a vivid red. On a red or pink background, the text color closely matches the background: the dominant red channel creates near-identical perceived colors for text and background, making the consent text effectively invisible.
The attack is specifically effective against MCP clients using red-branded UIs (common in security or danger-themed consent panels designed to draw attention), pink or salmon-tinted backgrounds, or notification-style red banners. getComputedStyle(el).color continues to report the original dark text color — the filter overwrites the rendered red channel values but does not modify the CSS color property. A security scanner that reads only getComputedStyle misses this attack entirely.
<!-- SA-CSS-FCR-001: feFuncR type=linear slope=0 intercept=1 — red channel forced to max
C_out = slope * C_in + intercept = 0 * C_in + 1 = 1 for all C_in ∈ [0,1]
Green and blue channels: implicit identity (unchanged)
Text rgb(17,24,39) → rgb(255,24,39) (vivid red) — invisible on red/pink bg -->
<filter id="fcr-r-max">
<feComponentTransfer>
<feFuncR type="linear" slope="0" intercept="1"/>
<!-- feFuncG, feFuncB: identity by default — G and B unchanged -->
</feComponentTransfer>
</filter>
<div id="consent" style="filter:url(#fcr-r-max)">
<p>Requesting: credential access, filesystem read, network egress.</p>
<button id="allow">Allow</button>
</div>
// Background-dependent invisibility test:
// Text color after filter: rgb(255, 24, 39) (for original rgb(17,24,39) text)
// Background rgb(220, 38, 38) (Tailwind red-600):
// WCAG contrast ≈ (1.0+0.05)/(0.09+0.05) ≈ 7.5:1 — visible. Red on red-600.
// Background rgb(255, 0, 0) (full red):
// All pixels have R=255, G≈24, B≈39 → vs bg R=255, G=0, B=0
// Contrast ≈ (1.05)/(1.05) = 1:1 for pixels where G and B are also small — invisible.
// Background rgb(255, 182, 193) (light pink):
// Both text (R=255, G=24, B=39) and bg (R=255, G=182, B=193) have R=max
// Delta E is low — text washes into background.
// getComputedStyle blind spot:
const color = getComputedStyle(consentEl).color; // "rgb(17, 24, 39)" — original, not filtered
// Rendered red channel overridden to 255 by filter — CSSOM does not expose filter channel values
function auditFeFuncR(funcEl) {
const type = funcEl.getAttribute('type') || 'identity';
if (type === 'linear') {
const slope = parseFloat(funcEl.getAttribute('slope') || '1');
const intercept = parseFloat(funcEl.getAttribute('intercept') || '0');
if (slope === 0 && intercept >= 0.9) {
return { sev: 'HIGH', note: `feFuncR slope=0 intercept=${intercept} → R≥0.9 on all pixels; invisible on red/pink backgrounds` };
}
if (slope === 0 && intercept === 0) {
return { sev: 'HIGH', note: 'feFuncR slope=0 intercept=0 → R=0 on all pixels; text shifts to cyan; invisible on teal backgrounds' };
}
}
return null;
}
HIGH — SA-CSS-FCR-001: feFuncR slope="0" intercept="1" forces all red channel values to maximum. On red, pink, or salmon backgrounds the text color matches the background and consent text is invisible. getComputedStyle.color reports the original text color — only rendering simulation against the actual page background reveals the attack. Detection requires computing the predicted output RGB and evaluating contrast against the rendered background color, not just the CSS color property.
Attack 2: feFuncR slope="0" intercept="0" — red channel zeroed, cyan shift, invisible on teal backgrounds (SA-CSS-FCR-002)
With slope="0" and intercept="0", the linear formula yields C_out = 0 for every red input. Every pixel in the consent element has its red component removed. Text that was dark gray (rgb(17, 24, 39)) becomes rgb(0, 24, 39) — deep cyan (a blue-green color). On teal, cyan, or dark-blue-green backgrounds, this cyan text is invisible because the text and background colors are near-identical in all remaining channels.
The attack is distinct from the intercept=1 variant: instead of making text red, it strips red out entirely, shifting the color toward the opposite end of the spectrum. Teal and cyan UI themes are common in developer tooling (VS Code default theme, many terminal color schemes), making this attack effective against developer-facing MCP consent panels. Note that a pure feFuncR slope=0 intercept=0 on dark text against a light background produces high contrast (dark cyan on white is readable), so the attack is invisible to auditors testing on white backgrounds only.
<!-- SA-CSS-FCR-002: feFuncR type=linear slope=0 intercept=0 — red channel zeroed
C_out = 0 * C_in + 0 = 0 for all C_in ∈ [0,1]
Text rgb(17,24,39) → rgb(0,24,39) — deep cyan
On teal bg rgb(0,128,128): contrast ≈ 1.3:1 — invisible -->
<filter id="fcr-r-zero">
<feComponentTransfer>
<feFuncR type="linear" slope="0" intercept="0"/>
</feComponentTransfer>
</filter>
// Color shift analysis:
// Original text: rgb(17, 24, 39)
// After feFuncR slope=0 intercept=0: rgb(0, 24, 39) — deep blue-green
// Background teal rgb(0, 128, 128): luminance ≈ 0.17
// Text rgb(0, 24, 39): luminance ≈ 0.005
// WCAG contrast: (0.17+0.05)/(0.005+0.05) ≈ 4:1 — borderline
// Background rgb(0, 139, 139) (dark cyan): luminance ≈ 0.18
// Text luminance ≈ 0.005 → contrast ≈ 4.1:1 — near the 4.5:1 AA threshold
// Background rgb(32, 178, 170) (light sea green): better contrast
// For originally light text (white #ffffff → after filter: rgb(0, 255, 255) = cyan)
// On cyan #00ffff background: contrast = 1:1 — perfectly invisible
// Also works with discrete type:
// <feFuncR type="discrete" tableValues="0"/> — equivalent zero via step function
// <feFuncR type="table" tableValues="0 0 0 0 0"/> — zero via table (5 entries all 0)
HIGH — SA-CSS-FCR-002: feFuncR slope="0" intercept="0" zeros the red channel, shifting text to cyan. On teal or dark-blue-green backgrounds the shifted text has insufficient contrast. Equivalent effects are achievable via type="discrete" tableValues="0" or type="table" tableValues="0 0 0 0 0" — auditors must check all non-identity transfer function types, not just type="linear". Testing on white backgrounds produces a false negative since cyan text on white is readable.
Attack 3: feFuncR type="gamma" amplitude="5" exponent="0.1" offset="0" — extreme gamma boost forces red to maximum (SA-CSS-FCR-003)
The gamma transfer function computes C_out = amplitude × C_in^exponent + offset. With amplitude="5" and exponent="0.1", for nearly any non-zero input: C_out = 5 × C_in^0.1. For C_in = 0.1: 5 × 0.1^0.1 = 5 × 0.794 ≈ 3.97 — clamped to 1. For C_in = 0.01: 5 × 0.01^0.1 = 5 × 0.631 ≈ 3.16 — clamped to 1. For any C_in > 0.003, the output exceeds 1 and is clamped to maximum. Only the pure-black pixel channel (C_in = 0) remains at 0. The net effect: every non-black pixel in the consent element has its red channel pushed to maximum.
The attack disguises itself as a gamma correction — an extreme red gamma boost that could be framed as a color calibration filter for high-contrast displays. Unlike the slope=0 variants, the gamma parameters (5, 0.1) may appear to a surface-level auditor as reasonable-looking gamma values rather than a red channel forced to maximum. Detection requires actually evaluating the formula output for representative input values rather than just checking parameter types.
<!-- SA-CSS-FCR-003: feFuncR type=gamma amplitude=5 exponent=0.1 — extreme red boost
C_out = 5 * C_in^0.1
C_in=0.003: 5*(0.003^0.1) = 5*0.563 = 2.82 → clamped to 1
C_in=0.001: 5*(0.001^0.1) = 5*0.501 = 2.51 → clamped to 1
C_in=0: 0^0.1 = 0 → C_out = 0 (only pure black stays at 0)
Net effect: R→1 for all non-black pixels → same as slope=0 intercept=1 in practice -->
<filter id="fcr-r-gamma">
<feComponentTransfer>
<feFuncR type="gamma" amplitude="5" exponent="0.1" offset="0"/>
</feComponentTransfer>
</filter>
// Audit approach: evaluate C_out at several representative C_in values
function evaluateGamma(amplitude, exponent, offset, cin) {
return amplitude * Math.pow(cin, exponent) + offset;
}
function auditFeFuncRGamma(funcEl) {
const amplitude = parseFloat(funcEl.getAttribute('amplitude') || '1');
const exponent = parseFloat(funcEl.getAttribute('exponent') || '1');
const offset = parseFloat(funcEl.getAttribute('offset') || '0');
// Sample at several input values and check if output is near-max (≥ 0.9)
const samples = [0.01, 0.05, 0.1, 0.3, 0.5, 0.8];
const outputsNearMax = samples.every(cin => {
const cout = Math.min(1, Math.max(0, evaluateGamma(amplitude, exponent, offset, cin)));
return cout >= 0.9;
});
if (outputsNearMax) {
return { sev: 'HIGH', note: `feFuncR type=gamma amplitude=${amplitude} exponent=${exponent}: all non-black pixels → R≥0.9` };
}
return null;
}
HIGH — SA-CSS-FCR-003: feFuncR type="gamma" amplitude="5" exponent="0.1" pushes the red channel to maximum for nearly all non-black input values via the gamma power function. The effect is functionally equivalent to slope=0 intercept=1 but is disguised as a gamma correction. Detection must evaluate the gamma formula at representative input values rather than pattern-matching on type attributes or parameter names alone.
Attack 4: JS mousedown injects feFuncR intercept="1" before click fires (SA-CSS-FCR-004)
JavaScript listens for the mousedown event on the consent element at capture phase (before any event handlers on the button itself). On mousedown, the script dynamically modifies the <feFuncR> element's intercept attribute from "0" (no red shift) to "1" (red channel forced to maximum). The consent text immediately shifts to its red-dominant color. If the page background has a high red component at that moment (for example, the button's active state has a red background, or the page uses a red-accented confirmation overlay), the text becomes invisible at the exact moment the user is completing the click.
The attack is particularly effective because users who saw readable text before the mousedown event have already committed to clicking — the visual disappearance happens so close to the click completion that users do not notice the text changed. Unlike page-load filter attacks (which risk detection during passive viewing), the mousedown injection produces the attack state only during the click gesture, creating a very narrow window for observation.
<!-- SA-CSS-FCR-004: JS mousedown injects feFuncR intercept=1 before click fires
Filter element initially has intercept=0 (identity-like red — no effect or minimal effect)
On mousedown: intercept set to 1, red channel forced to max
Click fires after mousedown: consent text color-shifted, may match active-state background -->
<filter id="fcr-dynamic">
<feComponentTransfer>
<feFuncR id="fcr-funcr" type="linear" slope="0" intercept="0"/>
</feComponentTransfer>
</filter>
<div id="consent" style="filter:url(#fcr-dynamic)">
<p>Requesting: credential access, filesystem read, network egress.</p>
<button id="allow">Allow</button>
</div>
<script>
document.getElementById('consent').addEventListener('mousedown', () => {
document.getElementById('fcr-funcr').setAttribute('intercept', '1');
}, { capture: true });
</script>
// Detection: MutationObserver watching feFuncR attribute changes during mousedown
const funcR = document.getElementById('fcr-funcr');
const obs = new MutationObserver(muts => {
for (const m of muts) {
if (m.attributeName === 'intercept' || m.attributeName === 'slope') {
const intercept = parseFloat(funcR.getAttribute('intercept') || '0');
const slope = parseFloat(funcR.getAttribute('slope') || '1');
console.warn('[SA-CSS-FCR-004] feFuncR modified at mousedown:', { slope, intercept });
}
}
});
obs.observe(funcR, { attributes: true, attributeFilter: ['type', 'slope', 'intercept', 'tableValues', 'amplitude', 'exponent', 'offset'] });
// Also monitor feComponentTransfer parent for child additions (new feFuncR appended)
const compTransfer = funcR.closest('feComponentTransfer');
const obsParent = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (node.tagName && node.tagName.toLowerCase() === 'fefuncr') {
console.warn('[SA-CSS-FCR-004] feFuncR appended to feComponentTransfer at mousedown');
}
}
}
});
obsParent.observe(compTransfer, { childList: true });
HIGH — SA-CSS-FCR-004: JS mousedown injection of feFuncR intercept="1" shifts consent text color to red-dominant just as the user completes a click. The attack window is the ~50ms between mousedown and click events. Detection requires installing a MutationObserver on all <feFuncR> elements and their parent <feComponentTransfer> before any user interaction events fire, and treating any attribute modification during mousedown as suspicious.
Summary table
| Attack | Mechanism | Background required for invisibility | Severity |
|---|---|---|---|
SA-CSS-FCR-001: feFuncR slope="0" intercept="1" |
Linear formula forces R=1 on all pixels; green and blue channels identity; text turns red-dominant; getComputedStyle.color reports original color (CSSOM blind spot); equivalent discrete: tableValues="1"; equivalent gamma: amplitude=1 exponent=0 offset=1 (0^0 edge case) or large amplitude/small exponent | Red, pink, salmon, or orange backgrounds where R is already near-maximum; also rose-tinted or coral MCP client themes | High |
SA-CSS-FCR-002: feFuncR slope="0" intercept="0" |
Linear formula zeros R channel; text shifts from dark gray to deep cyan; red-absent text vs red-absent background creates low contrast; equivalent via discrete tableValues="0" or table tableValues="0 0 0 0 0"; getComputedStyle blind spot same as FCR-001 | Teal, cyan, or dark blue-green backgrounds; developer-tooling dark themes with teal accents; also: white text → cyan (R=0,G=255,B=255) on cyan bg #00ffff = 1:1 contrast | High |
SA-CSS-FCR-003: feFuncR type="gamma" amplitude="5" exponent="0.1" |
Gamma power function with extreme amplitude and low exponent (power curve) pushes all non-zero R inputs above 1 (clamped to 1); disguised as color calibration; detection requires evaluating C_out at representative C_in values, not just checking parameter names | Same as FCR-001 (red/pink backgrounds); net effect functionally equivalent to slope=0 intercept=1 for all non-black pixels | High |
| SA-CSS-FCR-004: JS mousedown injection | Intercept attribute changed from 0 to 1 via JS at mousedown (capture phase); attack window ~50ms between mousedown and click; initial page-load state looks correct; active-state background of confirmation button may be red-accented; detection requires pre-interaction MutationObserver on all feFuncR elements | Active-state or hover-state backgrounds that have red dominance; also effective if JavaScript dynamically adds a red background overlay simultaneously with the feFuncR intercept change | High |
Defences
- Evaluate predicted output color against all likely background colors — a
feFuncRthat forces R to maximum or zero produces a shifted text color. Compute the predicted output RGB by applying the transfer function to the current text color, then calculate WCAG contrast against both light and dark backgrounds, and the page's actual rendered background. A filter that shifts text to cyan may pass on white but fail on teal. - Check all five feFuncR types, not just linear —
discrete tableValues="1",table tableValues="1 1 1 1 1", and extreme gamma parameters all produce the same R=max effect. Auditors must evaluate the gamma formula at multiple sample points to catch extreme-parameter variants. - Do not rely on getComputedStyle for filter-modified colors — CSS filters override rendered channel values but do not change the CSSOM color property. The only reliable method is rendering simulation: apply the transfer function formula to the CSS color values and predict the output RGB.
- Install pre-interaction MutationObservers on all feFuncR elements — any attribute change (slope, intercept, tableValues, amplitude, exponent, offset, type) on a
<feFuncR>element during the mousedown-to-click window is a red flag. Also observefeComponentTransferparent elements for dynamically appendedfeFuncRchildren. - Zero-filter policy on consent elements — prohibit any
feComponentTransferwith channel-modifyingfeFuncR/G/B/Achildren on consent elements entirely. Legitimate consent UI does not require per-channel color manipulation via SVG filters.
SkillAudit findings for this attack surface
feFuncR type="linear" slope="0" intercept="1" on consent element — red channel forced to maximum; text turns red-dominant; invisible on red/pink backgrounds; getComputedStyle.color blind spot; also achievable via discrete tableValues="1" or extreme gamma.feFuncR slope="0" intercept="0" — red channel zeroed; text shifts to cyan; invisible on teal/cyan backgrounds; equivalent via discrete tableValues="0" or table all-zeros; testing on white produces false negative.feFuncR type="gamma" amplitude="5" exponent="0.1" — extreme gamma boost forces R→max for all non-black pixels; disguised as color calibration; detection requires formula evaluation at sample input values, not parameter inspection alone.feFuncR intercept="1" injection — attack window ~50ms between mousedown and click; initial page state correct; MutationObserver on all feFuncR elements required; also monitor feComponentTransfer for dynamically appended feFuncR children.