MCP server CSS feComponentTransfer security: feFuncA alpha zeroing, feFuncR/G/B table remapping to background color, discrete step-function alpha zero, and gamma exponent contrast reduction below legibility threshold
Published 2026-09-30 — SkillAudit Research
The SVG feComponentTransfer filter primitive applies an independent transfer function to each channel of its input image. Child elements feFuncR, feFuncG, feFuncB, and feFuncA each define the transfer function for the red, green, blue, and alpha channels respectively. Each function can be of type identity (no change), linear (slope + intercept), gamma (power curve), table (piecewise linear interpolation), or discrete (step function). Absent a child function element for a channel, the channel passes through as identity.
The transfer function model creates a uniquely versatile attack surface: feFuncA can zero the alpha channel (making the element fully transparent) or remap it arbitrarily, while feFuncR/feFuncG/feFuncB can remap the color channel values to produce any output color regardless of the source color. A dark consent text element (#1a1a1a) with a feComponentTransfer that maps R→0.96, G→0.96, B→0.96 outputs near-white (#f5f5f5) — indistinguishable from the page background. All DOM property checks still report the original dark fill color, because those checks read pre-filter CSS properties.
Pre-filter vs. post-filter color: getComputedStyle(el).fill or getComputedStyle(el).color reports the element's CSS fill/color property value — the color before any filter pipeline transforms it. feComponentTransfer modifies color channel values in the filter output, after CSS properties are resolved. The DOM color check sees the source color; the user sees the filter output color. They are different.
Transfer function math
For each type, the mapping from input value C (in [0,1]) to output value C' is:
- identity: C' = C
- linear: C' = slope × C + intercept
- gamma: C' = amplitude × Cexponent + offset
- table: C' = piecewise linear interpolation between tableValues entries; the interval [k/N, (k+1)/N] maps linearly to [tableValues[k], tableValues[k+1]]
- discrete: C' = tableValues[floor(N × C)], where N = number of tableValues entries; a step function
Attack findings
The linear transfer function with
slope="0" and intercept="0" maps every alpha value C to 0 × C + 0 = 0. Every pixel in the filter output has alpha = 0, regardless of the input alpha. The consent text element is fully transparent. The element's DOM properties report correctly: getBoundingClientRect() returns positive dimensions (layout is unaffected), getComputedStyle(el).fill returns the source fill color (pre-filter), getComputedStyle(el).opacity returns 1. Only inspecting the filter graph's feFuncA parameters reveals the zeroed alpha.
<filter id="f1">
<feComponentTransfer>
<feFuncA type="linear" slope="0" intercept="0"/>
</feComponentTransfer>
</filter>
A
feFuncR type="table" tableValues="0.96 0.96" maps all input red channel values to 0.96 (a flat output — constant regardless of input value, because the table has two identical entries covering the full [0,1] range). Applied identically to G and B channels: all color channel values become 0.96. Output color: rgb(245, 245, 245) = #f5f5f5. Against a white (#ffffff) background: contrast ratio = 1.07:1. The consent text is functionally invisible. The getComputedStyle(el).fill still reports the original dark fill color. Only simulating the feComponentTransfer table mapping against the original fill value reveals the output color.
<filter id="f1">
<feComponentTransfer>
<feFuncR type="table" tableValues="0.96 0.96"/>
<feFuncG type="table" tableValues="0.96 0.96"/>
<feFuncB type="table" tableValues="0.96 0.96"/>
</feComponentTransfer>
</filter>
The discrete type with
tableValues="0 0" produces a step function with two intervals: [0, 0.5) → tableValues[0] = 0; [0.5, 1.0] → tableValues[1] = 0. Both intervals map to 0. All alpha values — regardless of whether they are 0, 0.5, or 1.0 — output 0. The effect is identical to the linear slope=0 attack but uses the discrete type, which may evade audits specifically checking for feFuncA type="linear" slope="0" as a known bad pattern. Detection requires evaluating the discrete step function output across the [0,1] input range.
The gamma function
C' = amplitude × Cexponent + offset with amplitude="0.05", exponent="1", offset="0.90" maps any input C to 0.05 × C + 0.90. For dark consent text with channel values near 0.1 (e.g., #1a1a1a): output = 0.05 × 0.1 + 0.90 = 0.905, producing near-white #e7e7e7. Contrast against white background: ~1.25:1. The gamma parameters amplitude, exponent, offset are less familiar than slope/intercept; audits checking for type="linear" slope=0 may not evaluate gamma function output values.
Detection algorithm
function detectFeComponentTransferAttacks(consentEl) {
const style = getComputedStyle(consentEl);
const filterVal = style.filter;
if (!filterVal || filterVal === 'none') return null;
const filterId = filterVal.match(/url\(["']?#([^"')]+)["']?\)/)?.[1];
if (!filterId) return null;
const svgRoot = consentEl.closest('svg') || document;
const filter = svgRoot.querySelector(`filter#${filterId}`);
if (!filter) return null;
// Get the element's fill color components
const fillColor = style.fill || style.color || 'rgb(26,26,26)';
const [srcR, srcG, srcB] = parseCSSColor(fillColor); // normalized [0,1]
const srcA = 1.0; // assuming non-transparent fill
const findings = [];
for (const ct of filter.querySelectorAll('feComponentTransfer')) {
const funcA = ct.querySelector('feFuncA');
const funcR = ct.querySelector('feFuncR');
const funcG = ct.querySelector('feFuncG');
const funcB = ct.querySelector('feFuncB');
// Check alpha channel
if (funcA) {
const outA = applyTransferFunction(funcA, srcA);
if (outA < 0.05) {
findings.push({ severity: 'critical', ct, funcA,
issue: `feFuncA (type="${funcA.getAttribute('type')}") maps input alpha to ${outA.toFixed(3)} — consent text alpha zeroed, element fully transparent` });
}
}
// Check color channels
if (funcR && funcG && funcB) {
const outR = applyTransferFunction(funcR, srcR);
const outG = applyTransferFunction(funcG, srcG);
const outB = applyTransferFunction(funcB, srcB);
const outLum = 0.2126 * linearize(outR) + 0.7152 * linearize(outG) + 0.0722 * linearize(outB);
const bgLum = 1.0; // white background
const contrast = (bgLum + 0.05) / (outLum + 0.05);
if (contrast < 2.0) {
findings.push({ severity: 'critical', ct, funcR, funcG, funcB,
issue: `feComponentTransfer RGB remapping produces output contrast ${contrast.toFixed(2)}:1 against white — consent text functionally invisible` });
} else if (contrast < 3.0) {
findings.push({ severity: 'high', ct, funcR, funcG, funcB,
issue: `feComponentTransfer RGB remapping produces output contrast ${contrast.toFixed(2)}:1 — below 3:1 threshold` });
}
}
}
return findings.length ? findings : null;
}
function applyTransferFunction(funcEl, inputVal) {
const type = funcEl.getAttribute('type') || 'identity';
switch (type) {
case 'linear': {
const slope = parseFloat(funcEl.getAttribute('slope') ?? '1');
const intercept = parseFloat(funcEl.getAttribute('intercept') ?? '0');
return Math.max(0, Math.min(1, slope * inputVal + intercept));
}
case 'gamma': {
const amp = parseFloat(funcEl.getAttribute('amplitude') ?? '1');
const exp = parseFloat(funcEl.getAttribute('exponent') ?? '1');
const off = parseFloat(funcEl.getAttribute('offset') ?? '0');
return Math.max(0, Math.min(1, amp * Math.pow(inputVal, exp) + off));
}
case 'table': {
const tv = (funcEl.getAttribute('tableValues') || '0 1').split(/\s+/).map(Number);
const n = tv.length - 1;
const k = Math.min(Math.floor(inputVal * n), n - 1);
const t = inputVal * n - k;
return Math.max(0, Math.min(1, tv[k] + t * (tv[k+1] - tv[k])));
}
case 'discrete': {
const tv = (funcEl.getAttribute('tableValues') || '0 1').split(/\s+/).map(Number);
const n = tv.length;
const k = Math.min(Math.floor(inputVal * n), n - 1);
return tv[k];
}
default: return inputVal; // identity
}
}
function linearize(c) { return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
function parseCSSColor(css) {
const m = css.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
return m ? [parseInt(m[1])/255, parseInt(m[2])/255, parseInt(m[3])/255] : [0.1, 0.1, 0.1];
}
Remediation
| Control | How it helps |
|---|---|
For every feComponentTransfer on a consent element's filter, locate the feFuncA child element (if present) and evaluate its transfer function at input = 1.0 (fully opaque alpha); if the output is below 0.05, flag as critical alpha erasure; check all four types (linear, gamma, table, discrete) using the full transfer function formula |
Alpha zeroing variants (linear slope=0 and discrete tableValues="0 0") — both produce zero alpha output but via different function types; type-specific pattern matching misses discrete and gamma variants; only evaluating the transfer function formula at the relevant input value provides type-agnostic detection |
For feFuncR/feFuncG/feFuncB channel transfer functions, evaluate the output color at the consent element's actual CSS fill/color channel values (read from getComputedStyle before applying the filter), compute the output luminance, and calculate the contrast ratio against the page background color |
Channel remapping to near-white and gamma contrast reduction variants — the attack maps the specific input color values (not arbitrary values) to near-background output; simulating the transfer function at the actual source color values produces the output contrast that users see |
Apply the same feComponentTransfer evaluation to elements that use color-interpolation-filters="linearRGB" (the default) vs. sRGB, since the transfer function input and output are in the filter's color space; convert between color spaces before and after the evaluation to get accurate output color values |
Color space interaction variant — feComponentTransfer operates in the color-interpolation-filters space; a transfer function that appears safe in sRGB may produce different output in linearRGB and vice versa; space-agnostic evaluation prevents false negatives |
SkillAudit evaluates all feComponentTransfer function types (linear, gamma, table, discrete) at the consent element's actual fill color values, simulates the output color and luminance, computes post-filter contrast ratios, and flags alpha-zeroing transfer functions regardless of function type. Run a free audit on any MCP server GitHub URL to detect feComponentTransfer channel manipulation and the full SVG filter consent attack surface.