Security Research  ·  2026-10-22

SVG feComponentTransfer Per-Channel Transfer Functions as MCP Consent Bypass: feFuncR, feFuncG, feFuncB, and feFuncA

SVG's feComponentTransfer filter primitive lets an MCP skill apply independent transfer functions to the red, green, blue, and alpha channels of a consent element. Prior coverage of feComponentTransfer attacks focused on full alpha-zeroing — making the entire consent element transparent. This post covers the other half of the attack surface: per-channel color manipulation via <feFuncR>, <feFuncG>, <feFuncB>, and <feFuncA>, each with its own shift profile, background dependency, and detection gap. Sixteen attack patterns, their luminance implications, and a unified detection algorithm are documented here.

Background: feComponentTransfer and the per-channel primitives

The feComponentTransfer overview established the element's role as a container that applies per-channel transfer functions to its in= input (typically SourceGraphic). Each child element — <feFuncR>, <feFuncG>, <feFuncB>, and <feFuncA> — independently controls one channel through five possible function types:

All output values are clamped to [0, 1]. The critical scanner blind spot: getComputedStyle(el).color returns the pre-filter CSS color regardless of what any applied SVG filter does to the rendered pixels. This is not a bug — CSS properties describe what you declared, not what the GPU composited. But for consent-verification purposes, it is a detection gap that allows every per-channel attack below to pass a naive CSSOM check.

Universal blind spot: getComputedStyle cannot see filter output

Every per-channel attack below passes a CSSOM color check. getComputedStyle(consentEl).color returns the declared text color — rgb(17, 24, 39) or whatever the author set. The SVG filter modifies the rendered pixel values after the CSS paint step, downstream of the CSSOM. Detection always requires computing the predicted output RGB by applying the transfer function formula to the CSS color, then evaluating contrast against the rendered background — not against the CSSOM value.

The luminance hierarchy: why channel choice matters

The four channels do not contribute equally to perceived brightness. WCAG 2.x and ISO 9241 luminance formulas weight the channels as follows for sRGB:

feFuncG — Green channel

Controls the green component. Most impactful for perceived brightness: green contributes 71.5% of linearized luminance.

Luminance weight: 0.7152

feFuncR — Red channel

Controls the red component. Shifts text toward red (intercept=1) or cyan (intercept=0). Luminance impact: 21.3%.

Luminance weight: 0.2126

feFuncB — Blue channel

Controls the blue component. Low luminance weight but targets action-button blue, the dominant color in most "Allow" UI elements.

Luminance weight: 0.0722

feFuncA — Alpha channel

Controls transparency. Near-zero alpha bypasses threshold checks. Alpha inversion (slope=-1 intercept=1) makes partially-transparent elements fully transparent without setting alpha=0.

Not a luminance channel; controls opacity

This hierarchy has concrete attack implications. Zeroing green (feFuncG intercept=0) does not shift text toward a predictable complementary color — it removes the dominant luminance component, making text dark regardless of the R and B values remaining. An attacker can produce a near-black output from any input color by zeroing the green channel alone. Forcing green to maximum (feFuncG intercept=1) can produce a 1:1 contrast against green backgrounds by matching the foreground's green component to the background's dominant channel.

feFuncR: red channel manipulation (SA-CSS-FCR)

The full feFuncR attack documentation covers four patterns:

Red's 21.3% luminance weight means red-only manipulation typically does not make text invisible on a neutral gray background — the green and blue channels remain and carry most of the luminance. The attack is effective specifically against red-themed MCP client UIs. An auditor testing only on a neutral background will miss FCR-001 and FCR-002 every time.

// Predict output color after feFuncR type=linear slope/intercept:
function applyLinearTransfer(cin, slope, intercept) {
  return Math.min(1, Math.max(0, slope * cin + intercept));
}

function predictColorAfterFeFuncR(cssColor, slope, intercept) {
  const [r, g, b] = parseCSSColor(cssColor); // returns [0..1, 0..1, 0..1]
  return {
    r: applyLinearTransfer(r, slope, intercept),
    g, // feFuncG identity by default
    b  // feFuncB identity by default
  };
}

// Check against page background for invisibility:
function checkContrast(fg, bg) {
  const lFg = 0.2126 * linearize(fg.r) + 0.7152 * linearize(fg.g) + 0.0722 * linearize(fg.b);
  const lBg = 0.2126 * linearize(bg.r) + 0.7152 * linearize(bg.g) + 0.0722 * linearize(bg.b);
  const lighter = Math.max(lFg, lBg), darker = Math.min(lFg, lBg);
  return (lighter + 0.05) / (darker + 0.05); // WCAG contrast ratio
}

feFuncG: green channel manipulation (SA-CSS-FCG) — the highest-impact channel

Green's 71.5% luminance contribution makes feFuncG the highest-impact single-channel attack. The full feFuncG attack documentation covers four patterns:

The 72% luminance rule is the key insight for feFuncG detection priority: any scanner that audits feFuncR and feFuncB but does not treat feFuncG with equal or greater urgency has an asymmetric detection gap for the most impactful channel. Forcing G=0 reduces perceived brightness by more than zeroing R and B combined.

Detection asymmetry: Security scanners that sample test backgrounds of only white (#ffffff) or near-white will miss SA-CSS-FCG-001 (lime text on white passes WCAG AA at ~3.2:1 contrast), while the same attack produces 1:1 contrast on rgb(17,153,17) or similar mid-green backgrounds. Audits must evaluate contrast against both the neutral background and the rendered background of the actual page.

feFuncB: blue channel manipulation (SA-CSS-FCB) — targeting action-button color

Blue's 7.2% luminance weight means blue-only manipulation rarely changes perceived brightness enough to hide text against neutral backgrounds. The attack targets a different property: the dominant color of interactive "Allow" button elements in most UI frameworks. The full feFuncB attack documentation covers four patterns:

The active-state attack (FCB-001/004) is particularly dangerous because it requires no specific background color on page load — any MCP client using a standard blue confirmation button creates the necessary background condition automatically at the moment the user presses "Allow." The consent text shifts to blue at the exact moment the user clicks.

Active-state targeting: SA-CSS-FCB-001 and SA-CSS-FCB-004 are designed for the button active/pressed state background, not the page background. Standard audit procedures that test contrast only at page load miss this attack class. Consent verification must be performed both at rest and during simulated interaction (mousedown, :active pseudo-class triggered).

feFuncA: alpha channel manipulation (SA-CSS-FCA) — near-zero and inverted transparency

The full feFuncA attack documentation covers the most technically subtle per-channel family. Unlike feFuncR/G/B which produce color-shift attacks that are background-dependent, feFuncA attacks target transparency — but in ways that evade the most obvious detection technique (checking if alpha=0):

// Near-zero alpha detection: threshold check required, not zero check
function auditFeFuncA(funcEl) {
  const type = funcEl.getAttribute('type') || 'identity';
  const NEAR_ZERO_THRESHOLD = 0.05; // 5% alpha — below human perception threshold

  if (type === 'linear') {
    const slope     = parseFloat(funcEl.getAttribute('slope')     ?? '1');
    const intercept = parseFloat(funcEl.getAttribute('intercept') ?? '0');
    // Test at C_in=1.0 (fully opaque text):
    const cout = Math.min(1, Math.max(0, slope * 1.0 + intercept));
    if (cout < NEAR_ZERO_THRESHOLD) {
      return { sev: 'CRITICAL', note: `feFuncA slope=${slope} intercept=${intercept}: fully-opaque text → alpha=${cout.toFixed(3)} (near-zero)` };
    }
    // Test inversion pattern (slope=-1 intercept=1):
    if (slope < 0 && intercept > 0) {
      return { sev: 'CRITICAL', note: `feFuncA alpha inversion: slope=${slope} intercept=${intercept}` };
    }
  }

  if (type === 'table' || type === 'discrete') {
    const tableValues = (funcEl.getAttribute('tableValues') || '').trim().split(/\s+/).map(Number);
    if (tableValues.length > 0 && tableValues.every(v => v < NEAR_ZERO_THRESHOLD)) {
      return { sev: 'CRITICAL', note: `feFuncA ${type} all tableValues < ${NEAR_ZERO_THRESHOLD}: near-zero alpha` };
    }
  }

  return null; // identity or safe
}

Cross-channel comparison: sixteen SA-CSS-FC patterns

ID Channel Parameters Mechanism Background required
FCR-001Rslope=0, intercept=1R forced to max; text turns red-dominantRed, pink, coral, salmon backgrounds
FCR-002Rslope=0, intercept=0R zeroed; text shifts to cyanTeal, cyan, dark-blue-green backgrounds
FCR-003Rgamma amplitude=5 exponent=0.1Extreme curve forces R≈max for non-black pixelsSame as FCR-001 (red/pink)
FCR-004RJS mousedown → intercept=1Attack window: mousedown-to-click ~50msRed active-state backgrounds
FCG-001Gslope=0, intercept=1G forced to max (71.5% luminance); text turns limeGreen, lime, chartreuse backgrounds
FCG-002Gslope=0, intercept=0G zeroed; -71.5% luminance loss; text turns near-blackDark purple, dark blue, near-black backgrounds
FCG-003Gtable tableValues="1 1 1 1 1"Five-step table all-ones; equivalent to intercept=1 via lookupSame as FCG-001; detection gap: type check alone misses table variant
FCG-004GJS mousedown → slope=0 intercept=1G=max at click; highest luminance impact of per-channel mousedown attacksGreen active-state backgrounds
FCB-001Bslope=0, intercept=1B forced to max; text turns blue; targets "Allow" button active stateBlue active-state backgrounds; button:active blue
FCB-002Bslope=0, intercept=0B zeroed; text shifts to yellow (esp. white text)Yellow, dark-olive backgrounds
FCB-003Bgamma amplitude=10 exponent=0.05Extreme blue gamma boost; B→max for all non-zero inputsSame as FCB-001 (blue button active)
FCB-004BJS mousedown → intercept=1B=max timed to button:active blue background; 40-80ms windowBlue active-state; most "Allow" buttons
FCA-001Aslope=0, intercept=0.015Near-zero alpha (1.5%); passes exact-zero check; below perception thresholdAny background; background-independent
FCA-002Aslope=-1, intercept=1Alpha inversion; fully-opaque text → alpha=0; output=0 only when C_in=1Any background; background-independent
FCA-003Atable tableValues="0.02 0.02 0.02"All table values near-zero; 2% alpha for any input; passes type-only detectionAny background; background-independent
FCA-004AJS mousedown → slope=0 intercept=0Full alpha zeroing at click; initial state correct; MutationObserver requiredAny background; background-independent

Unified detection algorithm

Three properties of this attack family shape a correct detection strategy:

  1. CSSOM blind spot — getComputedStyle returns pre-filter values. Detection must compute the predicted post-filter color by applying transfer function formulas, not reading CSSOM.
  2. Background dependency — FCR/FCG/FCB attacks create invisible text only when the predicted output color matches the background. Detection must evaluate contrast against both the neutral page background and all likely background states (active, hover, focus) — not just the page background at load time.
  3. Near-zero ≠ zero — FCA attacks use near-zero alpha deliberately. Detection must apply a perceptual threshold (≥5% opacity for human perception) not an exact-zero check.
// Unified feComponentTransfer per-channel audit
function auditFeComponentTransfer(filterEl, consentEl, pageBackground) {
  const compTransfer = filterEl.querySelector('feComponentTransfer');
  if (!compTransfer) return [];
  const findings = [];

  const cssColor  = parseCSSColor(getComputedStyle(consentEl).color); // pre-filter
  let predR = cssColor.r, predG = cssColor.g, predB = cssColor.b, predA = cssColor.a ?? 1;

  for (const tag of ['feFuncR', 'feFuncG', 'feFuncB', 'feFuncA']) {
    const funcEl = compTransfer.querySelector(tag);
    if (!funcEl) continue;

    const type = funcEl.getAttribute('type') || 'identity';
    if (type === 'identity') continue;

    let cout;
    const cin = tag === 'feFuncR' ? predR : tag === 'feFuncG' ? predG : tag === 'feFuncB' ? predB : predA;

    if (type === 'linear') {
      const slope     = parseFloat(funcEl.getAttribute('slope')     ?? (tag === 'feFuncA' ? '1' : '1'));
      const intercept = parseFloat(funcEl.getAttribute('intercept') ?? '0');
      cout = Math.min(1, Math.max(0, slope * cin + intercept));
    } else if (type === 'gamma') {
      const amplitude = parseFloat(funcEl.getAttribute('amplitude') ?? '1');
      const exponent  = parseFloat(funcEl.getAttribute('exponent')  ?? '1');
      const offset    = parseFloat(funcEl.getAttribute('offset')    ?? '0');
      cout = Math.min(1, Math.max(0, amplitude * Math.pow(cin, exponent) + offset));
    } else if (type === 'table' || type === 'discrete') {
      const tv = (funcEl.getAttribute('tableValues') || '').trim().split(/\s+/).map(Number);
      cout = applyTableTransfer(cin, tv, type === 'discrete');
    } else {
      continue;
    }

    if (tag === 'feFuncR') predR = cout;
    else if (tag === 'feFuncG') predG = cout;
    else if (tag === 'feFuncB') predB = cout;
    else predA = cout;

    // Alpha check: threshold, not zero
    if (tag === 'feFuncA' && cout < 0.05) {
      findings.push({ id: 'FCA', sev: 'CRITICAL', detail: `feFuncA output alpha=${cout.toFixed(3)} — near-zero; below 5% perception threshold` });
    }

    // Color check: WCAG contrast against all backgrounds
    if (tag !== 'feFuncA') {
      const predictedColor = { r: predR, g: predG, b: predB };
      const contrastVsPage = computeWCAGContrast(predictedColor, pageBackground);
      if (contrastVsPage < 4.5) {
        findings.push({ id: `FC${tag.slice(-1)}`, sev: 'HIGH', detail: `${tag} output: contrast ${contrastVsPage.toFixed(1)}:1 vs page background` });
      }
    }
  }

  return findings;
}

The mousedown gap: why all four channels need pre-interaction observers

The FCR-004, FCG-004, FCB-004, and FCA-004 patterns share a common temporal exploit: the consent element is valid and readable at page load and during passive viewing, then a JavaScript mousedown listener (registered at capture phase) modifies the feFuncX attribute before the click event fires. The sequence:

  1. User positions cursor over consent text (readable)
  2. User presses mouse button — mousedown fires at the target element but capture-phase handlers run first
  3. Capture-phase handler sets feFuncX.setAttribute('intercept', '1') (or equivalent) — filter modifies rendered pixels instantly
  4. User's visual perception has not yet registered the change — the consent text may already be invisible
  5. click event fires — consent registered

The ~50ms window between mousedown and click is below the threshold of change detection for most users, especially when the change is a color shift rather than a disappearance (a color shift may not trigger the same visual alarm as content vanishing). FCA-004 (alpha zeroing at mousedown) is the most severe variant because transparency is a more alarming visual event and users may notice the text disappearing — but in practice, users who have already initiated a click gesture rarely abort it in 50ms.

Detection requires installing a MutationObserver on all <feFuncR>, <feFuncG>, <feFuncB>, and <feFuncA> elements before any user interaction events fire — and observing their parent <feComponentTransfer> for dynamically appended children.

// Pre-interaction observer for all four feFuncX elements
function installFeFuncObservers(filterEl) {
  const ct = filterEl.querySelector('feComponentTransfer');
  if (!ct) return;

  // Observe each existing feFuncX element
  for (const tag of ['feFuncR', 'feFuncG', 'feFuncB', 'feFuncA']) {
    const func = ct.querySelector(tag);
    if (!func) continue;
    new MutationObserver(muts => {
      for (const m of muts) {
        console.warn(`[SA-CSS-FC] ${tag} attribute modified during interaction:`, {
          attr: m.attributeName,
          old: m.oldValue,
          new: func.getAttribute(m.attributeName)
        });
      }
    }).observe(func, {
      attributes: true,
      attributeOldValue: true,
      attributeFilter: ['type', 'slope', 'intercept', 'tableValues', 'amplitude', 'exponent', 'offset']
    });
  }

  // Observe feComponentTransfer for dynamically appended feFuncX children
  new MutationObserver(muts => {
    for (const m of muts) {
      for (const node of m.addedNodes) {
        const tag = node.tagName?.toLowerCase();
        if (['fefuncr', 'fefuncg', 'fefuncb', 'fefunca'].includes(tag)) {
          console.warn(`[SA-CSS-FC] ${tag} dynamically appended to feComponentTransfer`);
        }
      }
    }
  }).observe(ct, { childList: true });
}

Defences and scanner requirements

A complete defence against the feComponentTransfer per-channel attack family requires five scanner capabilities:

  1. Transfer function formula evaluation — compute C_out for each feFuncX type (identity, linear, gamma, table, discrete) at the actual C_in value from the CSS color property. Do not check parameter names or type attributes alone.
  2. Multi-background contrast evaluation — compute WCAG contrast between the predicted output color and both (a) the actual page background, (b) the active/hover state background of the consent element and its container, and (c) both light and dark extremes. A 4.5:1 threshold is the minimum; 3:1 is insufficient for consent text that must be clearly readable.
  3. Alpha threshold checking — flag any feFuncA that produces output <5% (0.05) for the relevant input opacity range, not only exact-zero output. Also flag negative slopes and inversion patterns regardless of the exact output value.
  4. Pre-interaction observer installation — observers must be installed before any user interaction events. Passive page analysis (no interaction simulation) is insufficient; the scanner must set up runtime observers that catch mousedown-phase attribute mutations on all feFuncX elements.
  5. Table and discrete variant scanning — all feFuncX attacks are achievable via table or discrete type in addition to linear. Scanners that check only for type="linear" with specific slope/intercept values miss these equivalent forms.

Related documentation: feComponentTransfer overview  |  feFuncR red channel  |  feFuncG green channel  |  feFuncB blue channel  |  feFuncA alpha channel

SkillAudit coverage

SkillAudit's static analyzer evaluates all five feFuncX types (identity, linear, gamma, table, discrete) for each of the four channel functions. The runtime probe installs MutationObservers on all feFuncX elements and their feComponentTransfer parents before interaction simulation begins, and evaluates WCAG contrast of predicted output colors against both page-load and interaction-state backgrounds. All sixteen SA-CSS-FC patterns are covered by audit rules in the current SkillAudit engine.

← Back to Blog