← WebKit Silent-Fix Report — 2026-W25

3e90b93505  Filter primitives don't taint output when flood-color/lighting-color resolves to currentColor

severity medium class CrossOrigin confidence 0.60 WebCore SVG/CSS filters tainting exploitable-grade
Karl Dubost Mon Jun 15 23:59:06 2026 -0700 full: 3e90b93505908113017e1ab04c5f6c89119d0627 bug report ↗ view on GitHub ↗
Primitive: pixel value leak via untainted filter output
Triage note: Filter output tainting prevents leaking currentColor-derived pixel data; taint tracking here guards against cross-origin/visited-style info leaks.
Contents

The bug at a glance

This is a cross-origin information-leak defense, not a memory-safety defect: filter output derived from currentColor (which can encode :visited link state or otherwise-inaccessible computed style) was reaching a canvas/readback path without being marked as origin-tainting, so a page could read back pixels that encode privileged color information. Impact is confined to same-page style-inference / history-sniffing style leaks rather than code execution, which places it at medium.

The interesting angle is that taint tracking is a security boundary implemented in graphics code far from any obvious sink: whether feFlood/feDropShadow/feDiffuseLighting/feSpecularLighting output is readable depends on whether their color resolved through currentColor. The patch retrofits a graph-wide taint propagation channel (taintsOrigin) onto FilterEffect and specialises FEDisplacementMap into a pass-through when its map input (in2) is tainted, matching CSS Filter Effects 1 sections 16.3/16.4.

Root cause

OBSERVED: Prior to this patch, FilterEffect had no notion of origin taint originating from a currentColor-resolved paint. The four primitives whose pixel output is a direct function of a CSS <color> property – feFlood and feDropShadow (flood-color), feDiffuseLighting and feSpecularLighting (lighting-color) – computed their output from the resolved color without recording that the color could have come from currentColor. currentColor resolves against the element’s computed ‘color’, which can itself be influenced by state the page is not allowed to observe directly (for example :visited styling), so an untainted filter output containing that color, once composited and read back, leaks the value.

OBSERVED: The patch adds a boolean m_taintsOrigin to FilterEffect plus taintsOrigin()/setTaintsOrigin() accessors, and a virtual SVGFilterPrimitiveStandardAttributes::taintsOrigin() that each of the four elements overrides to return renderer->style().floodColor().containsCurrentColor() or lightingColor().containsCurrentColor(). SVGFEImageElement overrides it as a final forwarding to the pre-existing renderingTaintsOrigin() (its cross-origin-image check), so feImage now feeds the same channel rather than a parallel one.

OBSERVED: Propagation is centralised in SVGFilterPrimitiveStandardAttributes::updateTaintsOrigin(effect, inputs), invoked from filterEffect() after the effect is created/cached. It sets the effect tainted if the element self-taints OR any input effect is tainted (std::ranges::any_of over inputs calling input->taintsOrigin()). This makes taint monotonic along the filter DAG.

OBSERVED: FEDisplacementMap is handled specially per section 16.4: updateTaintsOrigin dynamicDowncasts the effect and calls setIn2IsTainted(inputs.size() > 1 && inputs[1]->taintsOrigin()). When in2IsTainted() is true, both the software applier (FEDisplacementMapSoftwareApplier::apply) and the Core Image applier (FEDisplacementMapCoreImageApplier::apply) short-circuit: they skip the displacement math entirely and blit the ‘in’ image straight into the result. The rationale is that displacement uses in2’s pixel values as coordinates; a cross-origin/tainted in2 would let the page infer those pixel values from the displaced geometry, so the primitive degrades to a pass-through of in.

OBSERVED: The new m_in2IsTainted member is threaded through FEDisplacementMap::create (new trailing bool param, default false), compared in operator==, and serialised across IPC to the GPU process via a new ‘bool in2IsTainted();’ line in WebCoreArgumentCoders.serialization.in, so the GPU-process applier sees the same flag the web process computed.

OBSERVED: primitiveAttributeChanged() now forces a full filter-graph rebuild (markFilterEffectForRebuild) when the element’s self-taint flips (effect->taintsOrigin() != taintsOrigin()), because a dynamic flood-color change to/from currentColor changes downstream taint that cannot be patched in place on the cached effect.

INFERRED: The security-relevant sink is canvas readback / getImageData or timing on filtered content; marking the FilterEffect tainted causes the surrounding rendering machinery (not shown in this diff) to treat the buffer as origin-tainting and refuse the read. The 18 tainting-*-002/003 WPT tests moved from ImageOnlyFailure to PASS confirm the observable contract is ’tainted filters render but are not readable.’

Key code

Centralised graph-wide taint propagation and the FEDisplacementMap in2 special case (SVGFilterPrimitiveStandardAttributes.cpp)

void SVGFilterPrimitiveStandardAttributes::updateTaintsOrigin(FilterEffect& effect, const FilterEffectVector& inputs) const
{
    // §16.3: output is tainted if this primitive or any input is tainted.
    bool taint = taintsOrigin() || std::ranges::any_of(inputs, [](auto& input) {
        return input->taintsOrigin();
    });
    effect.setTaintsOrigin(taint);
    // §16.4: feDisplacementMap pass-through when in2 is tainted.
    if (auto* displacementMap = dynamicDowncast<FEDisplacementMap>(effect))
        displacementMap->setIn2IsTainted(inputs.size() > 1 && inputs[1]->taintsOrigin());
}

Patch walkthrough

  • Source/WebCore/platform/graphics/filters/FilterEffect.h — Adds the taint state to the base class: a protected m_taintsOrigin{false} plus taintsOrigin()/setTaintsOrigin() accessors. This is the shared channel every primitive’s taint flows through.
  • Source/WebCore/svg/SVGFilterPrimitiveStandardAttributes.cpp — Core of the fix. filterEffect() now calls updateTaintsOrigin() on the (possibly cached) effect. updateTaintsOrigin() ORs the element’s own taintsOrigin() with any input’s taint and stores it, and for FEDisplacementMap sets in2IsTainted from inputs[1]. primitiveAttributeChanged() forces a graph rebuild when self-taint flips.
  • Source/WebCore/svg/SVGFEFloodElement.cpp / SVGFEDropShadowElement.cpp / SVGFEDiffuseLightingElement.cpp / SVGFESpecularLightingElement.cpp — Each overrides taintsOrigin() to report self-taint based on floodColor()/lightingColor().containsCurrentColor(), guarding on a null renderer.
  • Source/WebCore/svg/SVGFEImageElement.h — Overrides taintsOrigin() as final, forwarding to the existing renderingTaintsOrigin() so cross-origin feImage detection reuses the same propagation path instead of a separate one.
  • Source/WebCore/platform/graphics/filters/FEDisplacementMap.{h,cpp} — Adds m_in2IsTainted member, an in2IsTainted()/setIn2IsTainted() pair, a new create() parameter, and includes the flag in operator== so a taint difference invalidates a cached effect.
  • Source/WebCore/platform/graphics/filters/software/FEDisplacementMapSoftwareApplier.cpp and coreimage/FEDisplacementMapCoreImageApplier.mm — Both appliers early-return a pass-through of the ‘in’ image when in2IsTainted(), implementing the section 16.4 degradation so tainted map pixels are never used as displacement coordinates.
  • Source/WebKit/Shared/WebCoreArgumentCoders.serialization.in — Adds ‘bool in2IsTainted();’ to the FEDisplacementMap serialization so the GPU-process applier receives the same flag computed in the web process.
  • LayoutTests/TestExpectations and LayoutTests/ipc/empty-svgfilterrenderer-expression-crash.html — Removes 18 tainting-* WPT entries now that they PASS, and adds in2IsTainted:false to the IPC fuzz-crash test’s serialized FEDisplacementMap structure to match the new field.

Background

currentColor and computed color leaks — currentColor is a CSS keyword that resolves to the element’s computed ‘color’ value. Because computed color can be affected by state a page must not read directly – most notably :visited link styling, which browsers deliberately restrict – any rendering primitive that turns currentColor into readable pixels becomes a side channel. Tainting the output so it cannot be read back is the standard mitigation.

Filter output tainting (CSS Filter Effects 1 section 16.3) — The spec defines that certain filter primitives taint the filter’s output. Once any primitive in a filter chain is tainted, the taint propagates to all downstream primitives, and the final result is treated as origin-unclean, blocking pixel readback (canvas toDataURL/getImageData). feFlood, feDropShadow, feDiffuseLighting and feSpecularLighting taint when their color derives from currentColor; feImage taints on cross-origin references.

feDisplacementMap and in2 (section 16.4) — feDisplacementMap uses the pixel channel values of its second input (in2) as X/Y displacement offsets applied to its first input (in). Because the output geometry directly encodes in2’s pixel values, a tainted in2 would let a page infer those forbidden pixels by observing the displacement. Section 16.4 therefore requires the primitive to ignore a tainted in2 and act as a straight pass-through of in.

FilterEffect graph and appliers — WebCore models an SVG/CSS filter as a DAG of FilterEffect nodes; each has a platform applier (a software PixelBuffer implementation and, on Apple platforms, a Core Image implementation). Filters can be evaluated in the GPU process, so effect parameters – now including in2IsTainted – are serialised over IPC through WebCoreArgumentCoders.serialization.in.

Cached effects and dynamic invalidation — SVGFilterPrimitiveStandardAttributes caches its created FilterEffect in m_effect and patches attribute changes in place via setFilterEffectAttribute. Taint, however, is computed from style (containsCurrentColor) and from input taint, so a flip must either be recomputed on the cached effect (updateTaintsOrigin runs every filterEffect() call) or force a rebuild (primitiveAttributeChanged when self-taint changes).

Vulnerability window

  1. Feature baseline — WebKit implemented SVG/CSS filter primitives and origin-tainting only for the obvious cross-origin cases (feImage’s renderingTaintsOrigin), with no notion of currentColor-derived taint on flood/lighting primitives and no in2 handling on feDisplacementMap.
  2. Gap identified — The corresponding WPT tainting-* tests were failing (listed as ImageOnlyFailure in TestExpectations), signalling WebKit rendered readable output where the spec requires tainting; the underlying concern is a currentColor/:visited-style pixel leak.
  3. Design — Rather than per-primitive ad hoc checks, the fix introduces a single m_taintsOrigin bit on FilterEffect and a virtual taintsOrigin() hook on the SVG elements, with monotonic propagation across the filter DAG in updateTaintsOrigin().
  4. Implementation — The four color-dependent elements report self-taint via containsCurrentColor(); feImage forwards to its existing check; FEDisplacementMap gains m_in2IsTainted, both appliers gain the pass-through short-circuit, and the flag is serialised to the GPU process.
  5. Dynamic correctness — primitiveAttributeChanged forces a graph rebuild when a self-taint flip occurs so a scripted flood-color change to/from currentColor re-propagates taint instead of leaving a stale cached effect.
  6. Landed — Committed as 315273@main on 2026-06-15; 18 tainting-* WPT tests removed from TestExpectations as now PASS and the IPC crash test updated for the new serialized field.

Triggering

OBSERVED: No security PoC is included; the patch ships WPT reference/image tests (tainting-feflood-002.html etc., removed from TestExpectations) rather than an exploit. INFERRED trigger: build a filter chain whose flood-color or lighting-color resolves to currentColor (e.g. an feFlood with flood-color:currentColor on an element whose color reflects :visited state), render it into a canvas via the CSS/SVG filter, then attempt getImageData/toDataURL. Before the patch the read succeeds and returns the currentColor-derived pixels; after the patch the buffer is origin-tainted and the read throws/returns opaque, closing the leak. The feDisplacementMap variant uses a tainted in2 to encode forbidden pixels into displacement geometry.

Exploitation

  1. Setup — Construct an SVG or CSS reference filter using one of the four color primitives with a currentColor-resolved color, or an feDisplacementMap whose in2 chains from a tainted/cross-origin source.
  2. Encode — Arrange for the sensitive value (e.g. the effective color of a :visited link, or cross-origin image content routed through feImage) to determine the primitive’s output pixels or, for feDisplacementMap, the displacement of visible geometry.
  3. Read back — Apply the filter to canvas-backed content and call getImageData/toDataURL, or measure timing/geometry, to recover the encoded value. This is an information-disclosure primitive only; there is no memory corruption and no code-execution path here.
  4. Post-patch — Taint propagation marks the output origin-unclean and the readback fails, and the displacement pass-through removes the geometric channel, so the leak is closed rather than merely made harder.

Detection & hunting

For defenders and SOC / detection engineers:

  • Filter chains combining currentColor with canvas readback
  • feDisplacementMap fed by cross-origin or tainted inputs
  • IPC-level FEDisplacementMap fuzzing

Audit directions

  • Other color-dependent primitives
  • Taint propagation completeness
  • GPU-process trust of the flag
  • Legacy/hardware filter renderers

Before / after

Loading diff…