MCP server SVG feComponentTransfer linear alpha slope/intercept consent security
Four SA-CSS-CTL consent attack patterns using feComponentTransfer feFuncA type="linear" — the linear formula output = slope × input + intercept allows precise reduction of consent text opacity to any level below the WCAG threshold while CSSOM reports the original opacity unchanged.
The feFuncA type="linear" transfer function computes output_A = slope × input_A + intercept for each pixel’s alpha channel. Unlike type="discrete" (step function) or type="table" (piecewise linear), the linear mode maps the full continuous range [0, 1] through a single affine transform. This gives attackers precise control over the final opacity of consent text: slope=0.1 reduces any fully opaque glyph to 10% opacity; slope=0, intercept=0.05 sets every pixel to exactly 5% opacity regardless of original value.
CSSOM completely blind: getComputedStyle(consentEl).opacity returns "1" regardless of what feFuncA linear does to pixel alpha values. CSS opacity and SVG filter alpha transforms operate at different levels of the rendering pipeline.
Linear transfer function mechanics
/* feFuncA type="linear": output = slope × input + intercept */ /* output is clamped to [0, 1] */ /* Identity: slope=1, intercept=0 → output = input (no change) */ /* Full erase: slope=0, intercept=0 → output = 0 (all transparent) */ /* Opacity reduction: slope=0.1, intercept=0 → all A values ×0.1 */ /* Flat opacity: slope=0, intercept=0.05 → all pixels at A=0.05 */ /* Multiple chained feComponentTransfer: each stage multiplies slope */ /* Stage 1: slope=0.5 → A' = 0.5×A */ /* Stage 2: slope=0.5 → A'' = 0.5×A' = 0.25×A */ /* Stage 3: slope=0.5 → A''' = 0.5×A'' = 0.125×A */ /* Combined: 1.0 → 0.125 (12.5% opacity) via three 50%-each stages */
SA-CSS-CTL-001: feFuncA linear slope=0.1 — 90% opacity reduction
A direct single-primitive attack: feFuncA type="linear" slope="0.1" intercept="0" reduces every fully opaque glyph pixel (A=1.0) to A=0.10 (10% opacity). The text remains positioned correctly and has the same color values, but at 10% opacity over white, WCAG contrast is approximately 1.22:1 — far below the 4.5:1 threshold. The filter is a single primitive: no feFlood, no feComposite, no feBlend. Only a feComponentTransfer with a single feFuncA.
<filter id="ctl001" color-interpolation-filters="sRGB">
<feComponentTransfer>
<feFuncA type="linear" slope="0.1" intercept="0"/>
</feComponentTransfer>
</filter>
/* Effect on opaque glyph pixel (input_A = 1.0):
output_A = 0.1 × 1.0 + 0 = 0.10
WCAG calculation:
text L ≈ 0.002 (dark, rgb(17,17,17))
perceived L at A=0.10 over white (L=1.0):
L_perceived = 0.10 × 0.002 + 0.90 × 1.0 = 0.9002
Contrast: (1.0+0.05)/(0.9002+0.05) = 1.05/0.9502 ≈ 1.10:1 — FAIL
Alternate calc (at center of thin stroke, some glyph coverage):
If 50% pixel coverage (subpixel glyph stroke center):
L_perceived = 0.10 × 0.002 + 0.90 × 1.0 → same ≈ 1.10:1
Evades:
- opacity threshold checks (CSSOM opacity=1)
- feFlood/feComposite/feBlend detection (no such primitives)
- filter attribute presence check (filter is present but feComponentTransfer is unremarkable)
Detection:
- Enumerate feComponentTransfer in consent ancestor filters
- For each feFuncA with type=linear: compute max_output for A ∈ [0,1]
- max_output = max(slope×0 + intercept, slope×1 + intercept) = max(intercept, slope+intercept)
- If max_output ≤ 0.25 → CRITICAL (consent text opacity likely below WCAG) */
function detectFeFuncALinear(consentEl) {
let el = consentEl;
while (el && el !== document.body) {
const f = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const m = f.match(/url\(#([^)]+)\)/);
if (m) {
const filterEl = document.getElementById(m[1]);
if (filterEl) {
for (const fct of filterEl.querySelectorAll('feComponentTransfer')) {
const funcA = fct.querySelector('feFuncA');
if (funcA && funcA.getAttribute('type') === 'linear') {
const slope = parseFloat(funcA.getAttribute('slope') || '1');
const intercept = parseFloat(funcA.getAttribute('intercept') || '0');
const maxOutput = Math.min(1, Math.max(intercept, slope + intercept));
if (maxOutput <= 0.25) {
return { risk: 'CRITICAL', slope, intercept, maxOutput };
}
}
}
}
}
el = el.parentElement;
}
return null;
}
SA-CSS-CTL-002: feFuncA linear slope=0 intercept=0.05 — fixed flat opacity
With slope=0 and intercept=0.05, the linear formula produces output_A = 0×input_A + 0.05 = 0.05 for all input values. Every pixel — transparent or opaque — is rendered at exactly 5% opacity. This is functionally identical to the discrete tableValues="0.05" attack (SA-CSS-CTA-002) but uses a different primitive type. Detectors checking only for type=discrete or checking only for opacity=0 exact values will miss this.
<filter id="ctl002">
<feComponentTransfer>
<!-- slope=0: multiply input by 0 (remove all variation)
intercept=0.05: add flat 5% to every pixel -->
<feFuncA type="linear" slope="0" intercept="0.05"/>
</feComponentTransfer>
</filter>
/* output_A = 0 × input_A + 0.05 = 0.05 for all pixels
WCAG at 5% opacity: ≈ 1.05:1 — FAIL
Evades:
- type=discrete specific detection (this is type=linear)
- CSS opacity=0 exact-match rules (A=0.05 ≠ 0)
- opacity≤0.01 threshold (0.05 > 0.01)
- max_output = max(0.05, 0+0.05) = 0.05 → ≤ 0.25 → CRITICAL */
SA-CSS-CTL-003: chained feComponentTransfer — multi-stage opacity reduction evasion
A single feFuncA slope=0.5 produces 50% opacity — which a detection rule might catch (max_output = 0.5, above a 0.25 CRITICAL threshold but worth flagging). To evade a per-stage threshold, the attacker chains three separate feComponentTransfer primitives each with slope=0.5. Each stage reduces opacity by half; the combined product after three stages is 0.53 = 0.125 (12.5% opacity). WCAG ≈ 1.33:1 — fail. Any detector checking only single-stage slope values will find each stage individually acceptable.
<filter id="ctl003">
<!-- Stage 1: A → 0.5×A -->
<feComponentTransfer result="s1">
<feFuncA type="linear" slope="0.5" intercept="0"/>
</feComponentTransfer>
<!-- Stage 2: 0.5A → 0.25A -->
<feComponentTransfer in="s1" result="s2">
<feFuncA type="linear" slope="0.5" intercept="0"/>
</feComponentTransfer>
<!-- Stage 3: 0.25A → 0.125A -->
<feComponentTransfer in="s2">
<feFuncA type="linear" slope="0.5" intercept="0"/>
</feComponentTransfer>
</filter>
/* Combined: A_out = 0.5³ × A_in = 0.125 × A_in
Opaque glyph (A=1.0) → A=0.125 (12.5% opacity)
WCAG: L_perceived ≈ 0.125×0.002 + 0.875×1.0 ≈ 0.875
Contrast: (1.05)/(0.875+0.05) = 1.05/0.925 ≈ 1.13:1 — FAIL
Per-stage check: each feFuncA has slope=0.5 → max_output=0.5 → HIGH at most
But the correct detection is: multiply all chained slope values in linear filter path
Combined slope: 0.5 × 0.5 × 0.5 = 0.125 → ≤ 0.25 → CRITICAL
Detection: trace feComponentTransfer chain via result/in attributes;
multiply all feFuncA linear slopes along the chain;
if product ≤ 0.25 → CRITICAL */
SA-CSS-CTL-004: JS mousedown slope swap — TOCTOU linear attack
This TOCTOU variant begins with feFuncA slope="1" intercept="0" (the identity transform: no visual change) and swaps the slope to 0.05 at mousedown. The 95% opacity reduction activates instantaneously at the moment of click confirmation, while the consent text was readable throughout the review period.
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<filter id="ctl004">
<feComponentTransfer>
<!-- Initially identity: slope=1, intercept=0 → no change -->
<feFuncA id="ctl004-funcA" type="linear" slope="1" intercept="0"/>
</feComponentTransfer>
</filter>
</defs>
</svg>
<span style="filter:url(#ctl004)">
Grant this MCP server write access to your entire file system.
</span>
<button onmousedown="
document.getElementById('ctl004-funcA').setAttribute('slope','0.05')
">I Agree</button>
/* MutationObserver: detect slope/intercept attribute changes */
const funcA = document.getElementById('ctl004-funcA');
const mo = new MutationObserver(muts => {
for (const m of muts) {
if (m.attributeName === 'slope' || m.attributeName === 'intercept') {
const slope = parseFloat(funcA.getAttribute('slope') || '1');
const intercept = parseFloat(funcA.getAttribute('intercept') || '0');
const maxOut = Math.min(1, Math.max(intercept, slope + intercept));
if (maxOut <= 0.25) {
reportFinding('CRITICAL', 'SA-CSS-CTL-004', {
slope, intercept, maxOutput: maxOut,
note: 'feFuncA linear slope reduced at mousedown — TOCTOU opacity attack'
});
}
}
}
});
mo.observe(funcA, { attributes: true, attributeOldValue: true,
attributeFilter: ['slope', 'intercept'] });
Summary of SA-CSS-CTL findings
| ID | Severity | Mechanism | WCAG | Evades |
|---|---|---|---|---|
| SA-CSS-CTL-001 | CRITICAL | feFuncA linear slope=0.1 — 90% opacity reduction | 1.10:1 | CSSOM opacity, feFlood/feComposite detection |
| SA-CSS-CTL-002 | CRITICAL | feFuncA linear slope=0 intercept=0.05 — flat 5% opacity | 1.05:1 | discrete-type detection, opacity≤0.01 threshold |
| SA-CSS-CTL-003 | HIGH | Three chained stages slope=0.5 each — combined 12.5% | 1.13:1 | Per-stage slope threshold checks |
| SA-CSS-CTL-004 | CRITICAL | JS mousedown slope 1→0.05 TOCTOU swap | ~1.05:1 at click | Static analysis, screenshot at rest |