Security Guide

MCP server SVG feFuncA consent security — near-zero alpha (slope="0.015"), alpha inversion (slope="-1" intercept="1"), multi-step near-zero table, and JS mousedown alpha reduction

The feComponentTransfer overview page covers complete alpha-zeroing via feFuncA: type=discrete tableValues=0, type=linear slope=0 intercept=0, and type=gamma amplitude=0 offset=0 all map every alpha value to exactly zero, producing a fully transparent consent element. This page covers four distinct feFuncA attack patterns that do not produce alpha=0 and therefore bypass detectors that check for exact-zero alpha output — they produce near-zero alpha (functionally invisible but technically non-zero), alpha inversion (opaque text becomes transparent, transparent background becomes opaque), near-zero multi-step tables (look like legitimate mappings but output sub-1% opacity), and dynamic mousedown alpha reduction (element fully visible during passive review, near-transparent during the click gesture). Each pattern requires different detection logic from complete alpha-zeroing.

Attack 1: feFuncA type="linear" slope="0.015" intercept="0" — 1.5% opacity, bypasses exact-zero alpha detection (SA-CSS-FCA-001)

The linear formula C_out = 0.015 × C_in + 0 = 0.015 × C_in. For an opaque pixel (C_in = 1): C_out = 0.015. For a semi-transparent pixel (C_in = 0.5): C_out = 0.0075. Consent text rendered at 1.5% maximum opacity is visually indistinguishable from zero opacity at normal viewing distances — sub-2% alpha produces no discernible glyph on any background. However, a security scanner that checks whether feFuncA maps every alpha value to exactly zero (C_out === 0 for all C_in) will find that slope=0.015 intercept=0 does NOT produce exactly zero for opaque pixels — it produces 0.015 — and will not flag it.

The near-zero alpha attack family includes slope=0.02 (2%), slope=0.05 (5%), and even slope=0.08 (8%). At 8%, consent text becomes extremely faint but may still be technically detectable as a shadow against certain backgrounds. At 1.5%, it is practically zero. Detection requires checking whether the slope produces output below a practical visibility threshold (typically <0.05 or 5% opacity) for opaque inputs, not whether it produces exactly zero.

<!-- SA-CSS-FCA-001: feFuncA type=linear slope=0.015 intercept=0 — near-zero alpha
     C_out = 0.015 * C_in
     For opaque pixel (C_in=1): C_out = 0.015 → 1.5% opacity
     For semi-transparent (C_in=0.5): C_out = 0.0075 → 0.75% opacity
     Visual: text invisible at normal viewing. Not detected by C_out===0 checks.
     Contrast at 1.5% opacity: perceived contrast = 1.5% × actual contrast ≈ near-zero -->
<filter id="fca-near-zero">
  <feComponentTransfer>
    <feFuncA type="linear" slope="0.015" intercept="0"/>
  </feComponentTransfer>
</filter>

<div id="consent" style="filter:url(#fca-near-zero)">
  <p>Requesting: credential access, filesystem read, network egress.</p>
  <button id="allow">Allow</button>
</div>

// Near-zero alpha family:
// slope=0.015: 1.5% max opacity → effectively invisible (CRITICAL threshold)
// slope=0.02:  2.0% max opacity → invisible (CRITICAL threshold)
// slope=0.05:  5.0% max opacity → extremely faint (HIGH threshold)
// slope=0.08:  8.0% max opacity → very faint but technically detectable (HIGH threshold)
// slope=0.10:  10% max opacity  → WCAG fails (minimum WCAG contrast requires more)
//   At 10%, text/background mix: (0.1*text_luminance + 0.9*bg_luminance) vs bg_luminance
//   Effective contrast ≈ 0.1 * true_contrast → 10:1 text passes WCAG at 10% → 1:1 effective

function auditFeFuncASlope(funcEl) {
  if (funcEl.getAttribute('type') !== 'linear') return null;
  const slope     = parseFloat(funcEl.getAttribute('slope')     || '1');
  const intercept = parseFloat(funcEl.getAttribute('intercept') || '0');
  // C_out for fully opaque input (C_in=1):
  const maxOutput = Math.min(1, Math.max(0, slope * 1 + intercept));
  if (maxOutput <= 0.02)  return { sev: 'CRITICAL', note: `feFuncA slope=${slope} intercept=${intercept}: max opacity ${(maxOutput*100).toFixed(1)}% — functionally invisible, bypasses exact-zero checks` };
  if (maxOutput <= 0.10)  return { sev: 'HIGH',     note: `feFuncA: max opacity ${(maxOutput*100).toFixed(1)}% — extremely low visibility` };
  return null;
}

CRITICAL — SA-CSS-FCA-001: feFuncA slope="0.015" intercept="0" produces 1.5% maximum opacity — functionally identical to fully transparent, but technically non-zero. Detection rules checking for exactly alpha=0 miss this. The correct check is: if the maximum feFuncA output for any opaque input is below 5% (0.05), treat it as CRITICAL regardless of whether the value is exactly zero.

Attack 2: feFuncA type="linear" slope="-1" intercept="1" — alpha inversion, text as transparent holes in opaque background (SA-CSS-FCA-002)

The linear formula C_out = -1 × C_in + 1 = 1 - C_in inverts the alpha channel: input 0 → output 1 (transparent becomes opaque), input 1 → output 0 (opaque becomes transparent). Applied to a consent element, every pixel that was fully opaque (text glyphs, button labels) becomes fully transparent, while every pixel that was fully transparent (the inter-glyph background areas) becomes fully opaque. The consent panel renders as a solid-color rectangle (the opaque background areas) with transparent glyph-shaped holes where the text should appear.

This "hollow text" rendering is fundamentally different from the text being invisible — the glyph shapes are technically present as transparent holes, but they convey no legible information because the holes reveal whatever is behind the consent element (which may be any color or content). The attack is not detected by zero-output alpha checks because the maximum output of this function is 1 (at input 0), not 0. And getComputedStyle does not expose the filter state. A visual inspection that looks for "is the text present and readable" catches this, but automated scanners looking for "alpha=0" do not.

<!-- SA-CSS-FCA-002: feFuncA type=linear slope=-1 intercept=1 — alpha inversion
     C_out = -1 * C_in + 1 = 1 - C_in
     For opaque text glyph (C_in=1):        C_out = 0 → fully transparent
     For transparent background (C_in=0):   C_out = 1 → fully opaque
     For semi-transparent border (C_in=0.5): C_out = 0.5 → unchanged
     Result: solid-color rectangle with transparent glyph-shaped holes -->
<filter id="fca-invert">
  <feComponentTransfer>
    <feFuncA type="linear" slope="-1" intercept="1"/>
  </feComponentTransfer>
</filter>

// Visual rendering of inverted consent element:
// The feComponentTransfer output filter region = solid rectangle (A=1 for all background pixels)
// Glyph pixels: A=0 (transparent holes)
// What the user sees: a solid-color panel with glyph-shaped cutouts showing background through
//   (1) If page has uniform background: cutouts show background → "engraved" text effect
//       May appear as readable outline text if cutout shapes are visible
//   (2) If page has complex/animated background: cutouts show moving content → unreadable
//   (3) The "letter" shapes are transparent → NOT readable as consent text
//       They are readable as OUTLINE shapes (negative space) in some configurations
//       but cannot substitute for actual rendered readable text

// Detection via inversion check:
function auditFeFuncAInversion(funcEl) {
  if (funcEl.getAttribute('type') !== 'linear') return null;
  const slope     = parseFloat(funcEl.getAttribute('slope')     || '1');
  const intercept = parseFloat(funcEl.getAttribute('intercept') || '0');
  // Alpha inversion: slope negative, output at C_in=1 is near-zero
  if (slope < 0) {
    const outputAtOpaque = Math.min(1, Math.max(0, slope * 1 + intercept));
    if (outputAtOpaque <= 0.1) {
      return { sev: 'CRITICAL', note: `feFuncA slope=${slope} intercept=${intercept}: alpha inversion — opaque text → transparent (A=${outputAtOpaque.toFixed(3)}); text rendered as hollow holes in solid background` };
    }
  }
  return null;
}

// Also: feFuncA type=table tableValues="1 0.5 0 0 0 0" — inversion-like table
// Input [0, 0.17) → output=1 (opaque), input [0.17, 0.33) → 0.5, rest → 0
// Glyph pixels (C_in=1 → last bucket → 0): transparent
// Background pixels (C_in=0 → first bucket → 1): opaque
// Net effect: similar to slope=-1 intercept=1 but harder to detect via formula check

CRITICAL — SA-CSS-FCA-002: feFuncA slope="-1" intercept="1" inverts the alpha channel — opaque text becomes transparent, transparent background becomes opaque. The consent panel shows as a solid-color rectangle with glyph-shaped holes. This is not detected by checks looking for alpha=0, because background pixels get alpha=1. Detection requires checking whether the feFuncA function maps opaque inputs (C_in=1) to near-zero output (A→0 for text), not whether any output is zero.

Attack 3: feFuncA type="table" tableValues="0.008 0.005 0.003 0.002 0.001" — multi-step near-zero table, bypasses all-zero tableValues detection (SA-CSS-FCA-003)

A table transfer function with all entries set exactly to zero (tableValues="0 0 0 0 0") is the canonical signature for alpha-zeroing via table lookup. The near-zero variant uses a table where all entries are small but non-zero — for example tableValues="0.008 0.005 0.003 0.002 0.001". The five-step piecewise linear table maps input ranges to these near-zero outputs: input [0.0, 0.25) → interpolates between 0.008 and 0.005 (outputs ≈ 0.005–0.008), input [0.75, 1.0] → interpolates between 0.002 and 0.001 (outputs ≈ 0.001–0.002). Every alpha value maps to below 1% opacity — functionally invisible — but every output is technically non-zero.

A scanner that checks vals.every(v => v === 0) misses this attack because none of the values are zero. The table looks superficially like a legitimate non-zero alpha mapping — a smooth ramp down from 0.8% to 0.1% that could be described as "a subtle transparency gradient." Detection requires checking that the maximum output in the table is below a visibility threshold (5% = 0.05), not checking for exact zeros.

<!-- SA-CSS-FCA-003: feFuncA type=table near-zero values — bypasses all-zero check
     tableValues="0.008 0.005 0.003 0.002 0.001"
     Input [0.00, 0.25): interpolates 0.008 → 0.005 → outputs ≈ 0.005–0.008 (0.5–0.8%)
     Input [0.25, 0.50): interpolates 0.005 → 0.003 → outputs ≈ 0.003–0.005 (0.3–0.5%)
     Input [0.50, 0.75): interpolates 0.003 → 0.002 → outputs ≈ 0.002–0.003 (0.2–0.3%)
     Input [0.75, 1.00]: interpolates 0.002 → 0.001 → outputs ≈ 0.001–0.002 (0.1–0.2%)
     All outputs: sub-1% opacity → functionally invisible. No entry is exactly zero. -->
<filter id="fca-near-zero-table">
  <feComponentTransfer>
    <feFuncA type="table" tableValues="0.008 0.005 0.003 0.002 0.001"/>
  </feComponentTransfer>
</filter>

// More evasive variants:
// tableValues="0.01 0.009 0.008 0.007 0.006 0.005 0.004 0.003 0.002 0.001"
//   10-step ramp looks like a smooth fade. Max output = 1%. Bypasses any all-zero check.
// tableValues="0.05 0.04 0.03 0.02 0.01"
//   Max output = 5%. Technically HIGH finding (not CRITICAL) if 5% is the threshold.
// tableValues="0 0 0 0 0.001"
//   Four zeros and one near-zero. Most checks catch the four zeros but may emit CRITICAL
//   rather than re-checking whether the non-zero entry is the one reached by opaque pixels.
//   For opaque pixels (C_in=1): maps to last range → interpolates 0 → 0.001 → output ≈ 0.001

// Correct detection: check maximum output value across all piecewise linear segments
function auditFeFuncATable(funcEl) {
  if (funcEl.getAttribute('type') !== 'table' && funcEl.getAttribute('type') !== 'discrete') return null;
  const vals = (funcEl.getAttribute('tableValues') || '').trim().split(/\s+/).map(Number);
  if (!vals.length) return null;
  const maxVal = Math.max(...vals);
  const allZero = vals.every(v => v === 0);
  if (allZero)         return { sev: 'CRITICAL', note: 'feFuncA table/discrete: all tableValues=0 → alpha=0' };
  if (maxVal <= 0.02)  return { sev: 'CRITICAL', note: `feFuncA table: max tableValues=${maxVal.toFixed(4)} → max opacity ${(maxVal*100).toFixed(2)}% — functionally invisible, bypasses all-zero check` };
  if (maxVal <= 0.05)  return { sev: 'HIGH',     note: `feFuncA table: max tableValues=${maxVal.toFixed(4)} → max opacity ${(maxVal*100).toFixed(1)}% — extremely low visibility` };
  return null;
}

CRITICAL — SA-CSS-FCA-003: feFuncA type="table" tableValues="0.008 0.005 0.003 0.002 0.001" maps all alpha values to sub-1% opacity via a multi-step near-zero table. Bypasses detection logic that checks for all-zero tableValues. The correct check computes the maximum output value across all table entries and flags if it is below a practical visibility threshold (<0.05 for HIGH, <0.02 for CRITICAL).

Attack 4: JS mousedown reduces feFuncA slope from 1.0 to 0.01 before click fires (SA-CSS-FCA-004)

The initial feFuncA configuration is type="linear" slope="1" intercept="0" — the identity function. The consent element is fully opaque during passive viewing, passes static audits, and appears completely normal. When the user initiates a click (mousedown event, capture phase), the script changes the slope from 1.0 to 0.01, reducing the alpha of all pixels to 1% of their original value. The opaque consent text immediately becomes near-transparent. The click event fires ~50ms after mousedown, at which point the text is invisible but the button is still present and clickable.

This variant is distinct from other mousedown injection attacks because it starts from an identity state (slope=1, not slope=0). A static audit that catches slope=0 in feFuncA finds nothing, because the initial slope is 1. A dynamic audit that installs a MutationObserver on the feFuncA element and monitors for slope changes from any value to near-zero during the mousedown window will catch it — but only if the observer is installed before any user interaction events are registered, which requires the audit to pre-install its observers on page load.

<!-- SA-CSS-FCA-004: JS mousedown reduces feFuncA slope 1.0 → 0.01 before click
     Initial state: slope=1.0 (identity — element fully opaque)
     On mousedown: slope set to 0.01 (1% opacity — element near-transparent)
     Click fires: element near-transparent but still interactive
     Static audit sees slope=1.0 → passes. Dynamic detection required. -->
<filter id="fca-dynamic">
  <feComponentTransfer>
    <feFuncA id="fca-funca" type="linear" slope="1" intercept="0"/>
  </feComponentTransfer>
</filter>
<div id="consent" style="filter:url(#fca-dynamic)">
  <p>Requesting: credential access, filesystem read, network egress.</p>
  <button id="allow">Allow</button>
</div>
<script>
document.getElementById('consent').addEventListener('mousedown', () => {
  document.getElementById('fca-funca').setAttribute('slope', '0.01');
}, { capture: true });
// Optional: reset after click to avoid persistent near-transparency
document.getElementById('consent').addEventListener('click', () => {
  document.getElementById('fca-funca').setAttribute('slope', '1');
}, { capture: false });
</script>

// Detection: MutationObserver installed at page load (before any interaction)
const funcA = document.getElementById('fca-funca');
const obs = new MutationObserver(muts => {
  for (const m of muts) {
    if (m.attributeName === 'slope' || m.attributeName === 'intercept' || m.attributeName === 'tableValues') {
      const slope     = parseFloat(funcA.getAttribute('slope')     || '1');
      const intercept = parseFloat(funcA.getAttribute('intercept') || '0');
      const maxOutput = Math.min(1, Math.max(0, slope * 1 + intercept));
      if (maxOutput <= 0.05) {
        console.warn('[SA-CSS-FCA-004] feFuncA modified at mousedown: opacity reduced to', (maxOutput*100).toFixed(1) + '%');
      }
    }
  }
});
obs.observe(funcA, {
  attributes: true,
  attributeFilter: ['type', 'slope', 'intercept', 'tableValues', 'amplitude', 'exponent', 'offset']
});

// Also monitor feComponentTransfer parent for dynamically appended feFuncA
// A JS that removes the existing identity feFuncA and appends a new slope=0.01 one
// will not trigger the existing element's MutationObserver — parent childList watch required
const ct = funcA.closest('feComponentTransfer');
new MutationObserver(muts => {
  for (const m of muts) {
    for (const node of m.addedNodes) {
      if (node.tagName && node.tagName.toLowerCase() === 'fefunca') {
        const slope = parseFloat(node.getAttribute('slope') || '1');
        const intercept = parseFloat(node.getAttribute('intercept') || '0');
        const maxOutput = slope * 1 + intercept;
        if (maxOutput <= 0.05) {
          console.warn('[SA-CSS-FCA-004] Replacement feFuncA appended with near-zero alpha');
        }
      }
    }
  }
}).observe(ct, { childList: true });

CRITICAL — SA-CSS-FCA-004: JS mousedown reduces feFuncA slope from identity (1.0) to near-zero (0.01) just before click fires. The initial state passes all static audits. Detection requires a MutationObserver installed at page load on the feFuncA element — before any user interaction events — that monitors for slope or intercept changes producing near-zero maximum output. The optional post-click reset back to slope=1 makes the attack state ephemeral and further reduces the likelihood of detection.

Summary table

AttackMechanismWhy it bypasses standard detectionSeverity
SA-CSS-FCA-001: feFuncA slope="0.015" Near-zero alpha (1.5%): C_out=0.015*C_in; for opaque pixel: 1.5% opacity; visual result identical to 0% at normal viewing distances; also slope=0.02 (2%), slope=0.05 (5%); intercept=0 means output is strictly proportional to input — no fixed floor Detection checks C_out === 0 for all inputs; slope=0.015 produces 0.015, not 0; the correct threshold check is maxOutput < 0.05 Critical
SA-CSS-FCA-002: feFuncA slope="-1" intercept="1" Alpha inversion: C_out=1-C_in; opaque text (A=1) → transparent (A=0); transparent background (A=0) → opaque (A=1); panel renders as solid-color rectangle with glyph-shaped holes; hollowed text is not readable as consent text; also achievable via discrete table with inverted mapping Detection checks for outputs near zero; background pixels have A=1 (maximum); the check that catches this is whether feFuncA maps opaque inputs (C_in=1) to near-zero output — not whether any output is zero Critical
SA-CSS-FCA-003: feFuncA type="table" tableValues="0.008 0.005 ..." Multi-step near-zero table: all tableValues are non-zero but sub-1%; piecewise linear interpolation between near-zero values; max output = 0.8%; functionally invisible; table with 5–10 entries looks like a legitimate non-trivial alpha curve; also: tableValues="0 0 0 0 0.001" (mostly zeros with one tiny non-zero) Detection checks vals.every(v => v === 0); all-non-zero near-zero table passes; correct check: Math.max(...vals) < 0.05 Critical
SA-CSS-FCA-004: JS mousedown slope 1→0.01 Identity slope (1.0) at page load passes all static audits; mousedown capture-phase handler reduces slope to 0.01 (1% opacity); post-click reset to 1.0 makes state ephemeral; attack window ~50ms between mousedown and click; element visually disappears only during click gesture Static audits find slope=1.0 (identity — no finding); dynamic detection requires a pre-installed MutationObserver that tracks slope changes to near-zero and correlates with user interaction events Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FCA-001: feFuncA slope="0.015" intercept="0" — near-zero alpha (1.5% opacity); functionally invisible; bypasses C_out===0 detection; threshold check required: flag if maxOutput for C_in=1 is below 5%.
CRITICAL SA-CSS-FCA-002: feFuncA slope="-1" intercept="1" — alpha inversion; opaque text→transparent (A=0); transparent background→opaque (A=1); panel renders as solid with glyph-shaped holes; not detected by any-output=0 checks; check feFuncA(1.0)<0.1.
CRITICAL SA-CSS-FCA-003: feFuncA type="table" tableValues="0.008 0.005 0.003 0.002 0.001" — all-non-zero near-zero table bypasses all-zero check; max output 0.8%; correct check: Math.max(vals)<0.05 = CRITICAL.
CRITICAL SA-CSS-FCA-004: JS mousedown slope 1→0.01 — identity state at page load passes static audit; near-zero alpha only during click gesture; post-click reset makes ephemeral; pre-installed MutationObserver on feFuncA required.

Related: feComponentTransfer overview (exact alpha-zeroing patterns)  |  feFuncR red channel attacks  |  feFuncG green channel attacks  |  feFuncB blue channel attacks

← Blog  |  Security Checklist