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:
identity— C_out = C_in (default when the feFuncX element is absent)linear— C_out = slope × C_in + interceptgamma— C_out = amplitude × C_in^exponent + offsettable— piecewise linear interpolation between tableValues entriesdiscrete— step function with tableValues as step values
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:
- SA-CSS-FCR-001 —
slope="0" intercept="1": R forced to maximum. Text turns red-dominant; invisible on red, pink, salmon, or coral backgrounds. - SA-CSS-FCR-002 —
slope="0" intercept="0": R zeroed. Text shifts to cyan (G+B only); invisible on teal and dark-blue-green backgrounds. Also achievable viadiscrete tableValues="0"ortable tableValues="0 0 0 0 0". - SA-CSS-FCR-003 —
type="gamma" amplitude="5" exponent="0.1": extreme gamma boost forces R near-maximum for all non-black inputs. Disguised as color calibration; detection requires evaluating C_out = 5 × C_in^0.1 at sample input values. - SA-CSS-FCR-004 — JS mousedown injection:
interceptattribute changed from 0 to 1 during the mousedown-to-click window (~50ms) when the page background may temporarily shift to a red active state.
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:
- SA-CSS-FCG-001 —
slope="0" intercept="1": G forced to maximum. Dark text (rgb(17,24,39)) becomes rgb(17,255,39) — vivid lime green. On green-background UIs (success-themed confirmation panels, environmental MCP tools), text matches background and is invisible. On neutral backgrounds, lime text on white is still visible — attack is background-dependent. - SA-CSS-FCG-002 —
slope="0" intercept="0": G zeroed. Text loses 71.5% of its luminance. rgb(17,24,39) becomes rgb(17,0,39) — very dark purple. The predicted luminance drops from ~0.007 to ~0.002. On dark purple or dark blue backgrounds, this shift creates 1:1 contrast. On black backgrounds, already-dark text becomes darker. - SA-CSS-FCG-003 —
type="table" tableValues="1 1 1 1 1": five-step table all-ones forces G=1 for all inputs via step interpolation. Equivalent effect to intercept=1 but disguised as a lookup table. Detection requires evaluating each tableValues entry rather than checking the function type alone. - SA-CSS-FCG-004 — JS mousedown injects G modification. Because green carries the most luminance, a green-channel shift during mousedown produces a larger visible color change than equivalent R or B changes, increasing the chance of successful camouflage against the active-state background.
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:
- SA-CSS-FCB-001 —
slope="0" intercept="1": B forced to maximum. Dark text rgb(17,24,39) becomes rgb(17,24,255) — bright blue. On a blue-background "Allow" button during the active state (many frameworks set background:blue on button:active), text becomes invisible because both text (B=max) and background (B≈max) share the dominant channel. The attack is specifically timed for the active/pressed state of the consent button. - SA-CSS-FCB-002 —
slope="0" intercept="0": B zeroed. Text shifts from dark gray to rgb(17,24,0) — very dark greenish-yellow. On dark olive, dark khaki, or brown backgrounds, this color closely matches. Additionally, white text (rgb(255,255,255)) becomes rgb(255,255,0) — pure yellow — invisible on yellow backgrounds. - SA-CSS-FCB-003 —
type="gamma" amplitude="10" exponent="0.05": extreme blue gamma boost. For C_in=0.01: 10×0.01^0.05 = 10×0.78 = 7.8 → clamped to 1. Effective for all non-zero blue inputs. Text color shifts to blue-dominant; on blue-button active states text becomes invisible. - SA-CSS-FCB-004 — JS mousedown injects B=max precisely when the button active-state fires a blue background. The narrow window between mousedown (filter injected) and click (consent registered) is typically 40-80ms — below the threshold of conscious visual processing for most users.
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):
- SA-CSS-FCA-001 —
slope="0" intercept="0.015": near-zero alpha (1.5%). The element is 98.5% transparent but alpha is not exactly 0. A scanner that checksgetComputedStyle(el).opacity < 0.01orC_out === 0will see alpha=0.015 and report the element as visible. Visually, at 1.5% opacity, the text is invisible against any background. The threshold for human perception is approximately 5-8% opacity; below that, text is effectively invisible on any background. - SA-CSS-FCA-002 —
type="linear" slope="-1" intercept="1": alpha inversion. C_out = -1 × C_in + 1 = 1 - C_in. For fully opaque text (C_in = 1.0): output alpha = 0. For 90% opaque text (C_in = 0.9): output alpha = 0.1. The consent element is rendered fully transparent. Detection via zero-output check (C_out === 0) fails because the output is 0 only when C_in=1 — and if the element's CSS opacity is not exactly 1.0 (e.g., 0.99 due to a transition or rounding), the scanner reports near-zero opacity (0.01) and may not flag it. A scanner that checks the formula at C_in=1 specifically would catch it, but many check C_out at a sampled C_in ≠ 1. - SA-CSS-FCA-003 —
type="table" tableValues="0.02 0.02 0.02": piecewise table with near-zero values. Three entries spanning [0,1] all equal to 0.02. For any C_in, C_out is near 0.02 (2% alpha). Identical practical effect to FCA-001 but using a table rather than linear parameters. Auditors who check only forslope="0" intercept≤0.02will miss the table variant. - SA-CSS-FCA-004 — JS mousedown injects feFuncA
interceptchange. Initial state has identity alpha (C_out = C_in). On mousedown, intercept is set to 0 and slope to 0 (C_out = 0), making the consent element fully transparent at the moment the user clicks. Unlike FCT (full component transfer) mousedown attacks, the FCA variant modifies only the alpha function — feComponentTransfer with only a feFuncA child leaves RGB channels at identity, meaning the element was visible and correctly colored before the mousedown event. Detection requires MutationObserver on all feFuncA elements before interaction begins.
// 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-001 | R | slope=0, intercept=1 | R forced to max; text turns red-dominant | Red, pink, coral, salmon backgrounds |
| FCR-002 | R | slope=0, intercept=0 | R zeroed; text shifts to cyan | Teal, cyan, dark-blue-green backgrounds |
| FCR-003 | R | gamma amplitude=5 exponent=0.1 | Extreme curve forces R≈max for non-black pixels | Same as FCR-001 (red/pink) |
| FCR-004 | R | JS mousedown → intercept=1 | Attack window: mousedown-to-click ~50ms | Red active-state backgrounds |
| FCG-001 | G | slope=0, intercept=1 | G forced to max (71.5% luminance); text turns lime | Green, lime, chartreuse backgrounds |
| FCG-002 | G | slope=0, intercept=0 | G zeroed; -71.5% luminance loss; text turns near-black | Dark purple, dark blue, near-black backgrounds |
| FCG-003 | G | table tableValues="1 1 1 1 1" | Five-step table all-ones; equivalent to intercept=1 via lookup | Same as FCG-001; detection gap: type check alone misses table variant |
| FCG-004 | G | JS mousedown → slope=0 intercept=1 | G=max at click; highest luminance impact of per-channel mousedown attacks | Green active-state backgrounds |
| FCB-001 | B | slope=0, intercept=1 | B forced to max; text turns blue; targets "Allow" button active state | Blue active-state backgrounds; button:active blue |
| FCB-002 | B | slope=0, intercept=0 | B zeroed; text shifts to yellow (esp. white text) | Yellow, dark-olive backgrounds |
| FCB-003 | B | gamma amplitude=10 exponent=0.05 | Extreme blue gamma boost; B→max for all non-zero inputs | Same as FCB-001 (blue button active) |
| FCB-004 | B | JS mousedown → intercept=1 | B=max timed to button:active blue background; 40-80ms window | Blue active-state; most "Allow" buttons |
| FCA-001 | A | slope=0, intercept=0.015 | Near-zero alpha (1.5%); passes exact-zero check; below perception threshold | Any background; background-independent |
| FCA-002 | A | slope=-1, intercept=1 | Alpha inversion; fully-opaque text → alpha=0; output=0 only when C_in=1 | Any background; background-independent |
| FCA-003 | A | table tableValues="0.02 0.02 0.02" | All table values near-zero; 2% alpha for any input; passes type-only detection | Any background; background-independent |
| FCA-004 | A | JS mousedown → slope=0 intercept=0 | Full alpha zeroing at click; initial state correct; MutationObserver required | Any background; background-independent |
Unified detection algorithm
Three properties of this attack family shape a correct detection strategy:
- CSSOM blind spot —
getComputedStylereturns pre-filter values. Detection must compute the predicted post-filter color by applying transfer function formulas, not reading CSSOM. - 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.
- 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:
- User positions cursor over consent text (readable)
- User presses mouse button —
mousedownfires at the target element but capture-phase handlers run first - Capture-phase handler sets
feFuncX.setAttribute('intercept', '1')(or equivalent) — filter modifies rendered pixels instantly - User's visual perception has not yet registered the change — the consent text may already be invisible
clickevent 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:
- 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.
- 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.
- 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.
- 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.
- 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.