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

AttackMechanismBackground required for invisibilitySeverity
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

SkillAudit findings for this attack surface

HIGH SA-CSS-FCR-001: 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.
HIGH SA-CSS-FCR-002: 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.
HIGH SA-CSS-FCR-003: 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.
HIGH SA-CSS-FCR-004: JS mousedown 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.

Related: feComponentTransfer overview (all channels)  |  feFuncG green channel attacks  |  feFuncB blue channel attacks  |  feFuncA alpha channel attacks

← Blog  |  Security Checklist