Security Deep-Dive · 2026-09-30

CSS feComposite operator="in" as the DOM Ghost Attack

The Porter-Duff "in" compositing operator outputs only the pixels where both inputs have non-zero alpha. When in2 is fully transparent, the output is fully transparent — regardless of what the source graphic contains. Applied via feComposite operator="in" to a consent text element, this creates what we call a DOM Ghost: the element is present in the DOM, has a positive bounding rect, a declared fill color, correct visibility and opacity values, and non-empty text content. Every standard consent-presence check passes. The rendered frame contains zero pixels from that element.

The Porter-Duff "in" operator: compositing math

SVG filter compositing is defined by the Porter-Duff compositing algebra. The feComposite primitive applies one of six operators to two input images. For operator="in", the output at each pixel position (x, y) is:

out(x,y) = in_color(x,y) × in2_alpha(x,y)

The output color is the color of the in input, scaled by the alpha of the in2 input. If in2_alpha = 0 anywhere, the output at that position is zero — fully transparent, regardless of what in contains. If in2_alpha = 0 everywhere across the filter region, the entire output is fully transparent.

This is categorically different from operator="over", which composites in on top of in2. With "over", a fully transparent in means in2 still shows through. With "in", a fully transparent in2 means nothing renders at all — not even in2 itself.

operator="over"

Places in on top of in2. Opaque in → blocks in2. Transparent in → in2 shows through. Standard compositing. Used in feFlood overlay attacks.

operator="in"

Clips in to the alpha mask of in2. Zero-alpha in2 → zero output everywhere. The source graphic vanishes. in2 itself does not appear in output.

operator="atop"

Like "over" but clips to in2's alpha. Transparent in2 → in vanishes but in2 shows through. Hybrid of "over" and "in" characteristics.

operator="out"

Clips in to where in2 is transparent. Opaque in2 → output transparent. The inverse of "in" — useful in different evasion patterns.

Why "in" produces a DOM Ghost

With operator="over", the attack produces a visible artifact: a white (or colored) rectangle covers the consent text. A sufficiently attentive user might notice the absence of text. With operator="in", the attack produces nothing — no rectangle, no overlay, no suspicious visual artifact. The consent element's screen region is simply transparent. If the page background is white, the consent region appears to be part of the background. There is no rectangle to notice.

Three ways to make in2 alpha zero

The "in" attack requires only that the in2 input carries zero alpha across the consent text region. There are three practical ways to achieve this, each with different detection signatures.

1. Transparent feFlood as in2

The most direct method: a feFlood with flood-opacity="0" produces a fully transparent image. This transparent image becomes in2 of the feComposite. Because in2_alpha = 0 everywhere, the output is entirely transparent.

<filter id="ghost-filter">
  <feFlood flood-color="white" flood-opacity="0" result="transparent-mask"/>
  <feComposite in="SourceGraphic" in2="transparent-mask" operator="in"/>
</filter>

The DOM still shows feFlood with flood-color="white" — a value that does not immediately signal an attack. Only reading flood-opacity reveals the zero value. The flood-opacity attack vector is documented separately; when combined with operator="in", the erase mechanism shifts from "cover with opaque color" to "clip to zero-alpha mask."

2. BackgroundImage with zero alpha

The SVG filter special keyword BackgroundImage captures the rendered content behind the filtered element at the time the filter is evaluated. On many modern browser configurations, BackgroundImage is unavailable or returns a fully transparent image (the specification marks it as optional and many implementations have disabled it due to cross-origin privacy risks). When BackgroundImage returns transparent, using it as in2 of feComposite operator="in" erases the consent text.

<filter id="ghost-filter" color-interpolation-filters="sRGB">
  <feComposite in="SourceGraphic" in2="BackgroundImage" operator="in"/>
</filter>

This variant is particularly evasive: there is no explicit zero-alpha source in the filter graph. The zero alpha comes from the browser's handling of BackgroundImage. An audit that does not know the browser's BackgroundImage behavior cannot predict the output alpha. The attack exploits a browser implementation characteristic rather than a filter value.

3. SourceAlpha of a transparent element

The keyword SourceAlpha produces the alpha channel of the filtered element's source graphic, with color replaced by black. For a text element with fill: rgba(0,0,0,0) or color: transparent, SourceAlpha is zero everywhere the text is transparent. If the text itself is transparent (a separate attack vector), using SourceAlpha as in2 of the "in" composite creates a self-referential erasure: the element erases itself because its own alpha channel is zero.

<filter id="ghost-filter">
  <feComposite in="SourceGraphic" in2="SourceAlpha" operator="in"/>
</filter>

When applied to a text element with fill: transparent, this filter outputs nothing — the text had no alpha to begin with, so clipping to its own alpha produces zero. An audit checking getComputedStyle(el).fill and finding rgba(0,0,0,0) may flag the fill as the attack vector without identifying the filter graph's role. The two vectors compound: the fill attack enables the filter attack.

Compounding vectors: The SourceAlpha variant chains two consent attack vectors — a transparent fill (which independently renders zero pixels) with a filter graph that would also produce zero pixels even if the fill were opaque. A remediation that fixes only the fill is incomplete: the filter graph continues to erase anything rendered into SourceGraphic.

Why all 8 DOM checks pass

Standard consent-presence audits check a fixed set of DOM and CSS properties on the consent text element. Here is why each check passes despite zero rendered pixels.

CheckValue observedWhy it passes
getBoundingClientRect() — width, height Positive (e.g., 240×18) CSS filters do not affect layout geometry. The element occupies its normal layout space. The bounding rect reports the layout box, not the rendered pixels.
getComputedStyle(el).display block or inline display is a layout property unaffected by filter application.
getComputedStyle(el).visibility visible visibility controls paint participation, not filter output alpha. visibility:visible + fully transparent filter output = zero pixels.
getComputedStyle(el).opacity 1 The element-level opacity is 1. The filter pipeline's output is transparent, but opacity and filter are composited in sequence: opacity multiplies the filter output, not the source. opacity:1 × transparent filter output = transparent.
getComputedStyle(el).fill (SVG) or color Non-transparent (e.g., rgb(26,26,26)) The fill/color property describes the source graphic color before the filter runs. The filter's in2 alpha erases the output regardless of fill value.
el.textContent / el.innerText Non-empty (e.g., "I agree to the Terms of Service") textContent reflects DOM structure, not rendering. The text node is present. The filter makes it invisible without removing it from the DOM.
el.getBBox() (SVG) Positive area getBBox computes the geometric bounding box of SVG content, not the filter-composited rendering extent.
getComputedStyle(el).filter url(#ghost-filter) The computed filter property reports the filter reference, but not the filter's effect. A check that sees a filter is applied may flag it as suspicious — but only if it knows to inspect the filter graph contents.

The only check that comes close to detecting the attack is checking for the presence of a filter — but a filter is not itself an attack. The attack is in the filter graph contents, specifically the combination of operator="in" with a zero-alpha in2 source. Detection requires resolving the filter reference, enumerating the filter primitives, identifying feComposite nodes with operator="in", tracing the in2 input to its source, and evaluating whether that source produces zero-alpha output across the filter region.

Comparison: "over" versus "in" as attack operators

The feComposite security page covers multiple operators. The "over" and "in" operators are the two most exploited because they represent opposite attack directions: "over" covers the source with an opaque layer; "in" erases the source by clipping to a transparent mask.

Propertyoperator="over" attackoperator="in" attack
MechanismOpaque flood composited on top of text pixelsText pixels clipped to zero-alpha mask
Visual artifactOpaque rectangle (background color) where text wasNothing — transparent region where text was
User detectabilityAbsent text → user might notice the blank areaInvisible erasure → background shows through; no visual cue
Attack in2 requirementOpaque in (foreground) covers in2Zero-alpha in2 (mask) erases in
DOM check evasionPasses all layout/style checksPasses all layout/style checks
Required in2 vectorAny colored feFlood with opacity=1Transparent feFlood, BackgroundImage, or SourceAlpha of transparent element

The timing variant: animated operator

A more sophisticated variant animates the operator attribute between "over" and "in" at the moment of consent interaction. At page load, the filter uses operator="over" with a transparent or low-opacity flood — which renders the text normally (transparent flood + over = text shows through). At the button activation moment (triggered by a JavaScript event or CSS transition), the operator switches to "in" and the flood-opacity increases simultaneously. The user sees the text, moves to click, and at the moment of interaction the text vanishes.

<filter id="timing-ghost">
  <feFlood flood-color="white" flood-opacity="0" result="flood"/>
  <feComposite id="comp" in="SourceGraphic" in2="flood" operator="over"/>
</filter>
<script>
consentButton.addEventListener('mouseenter', () => {
  document.getElementById('comp').setAttribute('operator', 'in');
  document.querySelector('feFlood').setAttribute('flood-opacity', '1');
});
</script>

A static audit at DOMContentLoaded sees operator="over" — which is not an erase operator — and a flood-opacity="0" which, for "over", is harmless (transparent flood over text = text visible). The attack activates only during the interaction window. Detection requires: (1) checking attribute mutation via MutationObserver on filter primitive attributes during the interaction, or (2) re-auditing the filter graph state immediately before and after the consent action fires.

Timing evasion: A one-shot static filter-graph audit is insufficient for the timing variant. The filter graph must be re-evaluated at the moment of consent capture — not only at load time — because the operator and source alpha values may change during the interaction window.

Detection algorithm

Reliable detection of the DOM Ghost Attack requires a multi-pass filter graph traversal that resolves in2 sources to their effective alpha output.

function detectGhostAttack(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;

  // Resolve filter through xlink:href chain
  function resolveFilter(id, root) {
    const f = root.querySelector(`filter#${id}`);
    if (!f) return null;
    const href = f.getAttribute('xlink:href') || f.getAttribute('href');
    if (href) {
      const refId = href.replace(/^#/, '');
      const parent = resolveFilter(refId, root);
      return parent ? { ...parent, inherited: f } : f;
    }
    return f;
  }

  const svgRoot = consentEl.closest('svg') || document;
  const filter = resolveFilter(filterId, svgRoot);
  if (!filter) return null;

  const primitives = Array.from(filter.querySelectorAll('*'));
  const compositeEls = primitives.filter(p => p.tagName === 'feComposite');
  const findings = [];

  for (const comp of compositeEls) {
    const operator = comp.getAttribute('operator') || 'over';
    if (operator !== 'in') continue;

    const in2Name = comp.getAttribute('in2');
    if (!in2Name) continue;

    // Check: is in2 a zero-alpha source?
    if (in2Name === 'BackgroundImage') {
      findings.push({ severity: 'high', comp,
        issue: 'feComposite operator="in" in2="BackgroundImage" — BackgroundImage returns transparent in many browser configurations; zero-alpha in2 erases SourceGraphic entirely' });
      continue;
    }

    if (in2Name === 'SourceAlpha') {
      // SourceAlpha is zero wherever fill is transparent
      const fill = style.fill || style.color;
      const fillAlpha = parseCSSAlpha(fill);
      if (fillAlpha === 0) {
        findings.push({ severity: 'critical', comp,
          issue: 'feComposite operator="in" in2="SourceAlpha" where element fill is transparent — SourceAlpha is zero everywhere; output fully transparent' });
      }
      continue;
    }

    // Resolve named in2 result to its source primitive
    const sourcePrimitive = primitives.find(p => p.getAttribute('result') === in2Name);
    if (!sourcePrimitive) continue;

    if (sourcePrimitive.tagName === 'feFlood') {
      const floodOpacity = parseFloat(
        getComputedStyle(sourcePrimitive)['flood-opacity'] ??
        sourcePrimitive.getAttribute('flood-opacity') ?? '1'
      );
      if (floodOpacity === 0) {
        findings.push({ severity: 'critical', comp, sourcePrimitive,
          issue: `feComposite operator="in" in2="${in2Name}" resolves to feFlood with flood-opacity=0 — fully transparent in2 erases SourceGraphic` });
      }
    }
  }

  // Re-check at interaction time (MutationObserver for operator changes)
  const observer = new MutationObserver(() => {
    const postFindings = detectGhostAttack(consentEl);
    if (postFindings) reportFindings('post-interaction', postFindings);
  });
  for (const comp of compositeEls) {
    observer.observe(comp, { attributes: true, attributeFilter: ['operator', 'in2'] });
  }

  return findings.length ? findings : null;

  function parseCSSAlpha(cssValue) {
    if (!cssValue) return 1;
    const m = cssValue.match(/rgba?\([^)]+,\s*([\d.]+)\)/);
    return m ? parseFloat(m[1]) : 1;
  }
}

Why this is harder to detect than the feFlood "over" attack

No visible artifact

The "over" attack leaves a colored rectangle in place of the text — a blank region a user could notice. The "in" attack leaves nothing. The consent element region is indistinguishable from background.

Evasion: Critical

Benign-looking filter graph

A filter with feFlood flood-opacity="1" + feComposite operator="over" is immediately suspicious. A filter with feFlood flood-opacity="0" + feComposite operator="in" looks like a degenerate no-op at first glance — who would intentionally composite to zero?

Evasion: High

BackgroundImage vector has no explicit zero

The BackgroundImage variant contains no explicit zero-alpha value in the filter markup. The zero alpha is a browser behavior. No static markup analysis can flag it without knowing the browser's BackgroundImage support status.

Evasion: High

Timing variant passes static audit

At DOMContentLoaded, operator="over" with flood-opacity="0" is a no-op filter — text renders normally. The attack activates only at interaction time. One-shot audits cannot detect it.

Evasion: Critical

Remediation

ControlWhat it catches
For every feComposite operator="in" on a consent text element's filter, trace the in2 source through the filter primitive graph and evaluate its effective alpha: resolve named results to their source primitives, check feFlood flood-opacity, and flag BackgroundImage as an environment-dependent zero-alpha risk Transparent feFlood variant and BackgroundImage variant — both produce zero in2 alpha via different mechanisms; only in2-source resolution catches both
Check SourceAlpha as in2 only in conjunction with the consent element's effective fill alpha — if fill is transparent, SourceAlpha is zero and operator="in" erases the output SourceAlpha variant — requires compound analysis of both the fill and the filter; neither alone is conclusive
Install a MutationObserver on filter primitive attributes (operator, in2, flood-opacity) at consent-element registration time and re-run the filter-graph audit on every mutation observed during the interaction window Timing variant — attribute values are legitimate at load time and switch to attack values at interaction; only mutation-aware re-auditing catches the transition
Require all consent text elements to either have no filter applied, or have their filter allowlisted against a known-safe pattern (e.g., no feComposite with operator other than "over", no feFlood with flood-opacity < 0.5) Preventive: blocks all feComposite operator variants from being applied to consent elements without explicit review of the filter's rendering effect

SkillAudit traverses the full SVG filter primitive graph for every consent element, resolves named result references to source primitives, evaluates in2 effective alpha for operator="in" composites, and installs MutationObservers for timing-variant detection. Run a free audit on any MCP server GitHub URL to detect the DOM Ghost Attack and the full feComposite operator attack surface.