MCP server CSS feMerge security: feMergeNode ordering attack covering SourceGraphic, near-white flood opacity collapse, animated input substitution, and word-level merge occlusion of consent text
Published 2026-09-30 — SkillAudit Research
The SVG feMerge filter primitive composites multiple input images by stacking them in document order, back-to-front — each feMergeNode child's in input is placed on top of the previous nodes using the Porter-Duff "over" operator. The last feMergeNode is the frontmost layer. This sequential compositing model creates a class of consent text attacks based on layer ordering: place an opaque or near-opaque flood result as the final feMergeNode, and it covers every pixel of the layers below it, including SourceGraphic.
Unlike feComposite attacks that require understanding compositing operator semantics, feMerge attacks rely on intuitive layer stacking — the same model as CSS z-index. The attack is structurally simple but requires traversing the feMergeNode order and evaluating each node's referenced input image. See also the feComposite security page for operator-level attacks and the feBlend security page for mode-based layering attacks.
Layer order is the attack surface: The feMerge element itself is benign — it simply stacks layers. The attack lives in what those layers contain and their order. A feMergeNode referencing SourceGraphic followed by a feMergeNode referencing an opaque white flood is an attack. The same two nodes in reverse order (flood below SourceGraphic) is not. Order determination requires reading feMergeNode child elements in DOM sequence.
Attack findings
A
feFlood flood-color="white" flood-opacity="1" produces an opaque white image named via its result attribute. This result is referenced by the final (last in document order) feMergeNode, placing it as the frontmost layer in the merge stack. SourceGraphic is referenced by an earlier node, below the white layer. The Porter-Duff "over" merge produces a white rectangle: the consent text pixels are occluded. All DOM checks pass because the filter's output covers the text rather than removing it. The consent element bounding rect is positive; the element has non-transparent fill; the text content is present.
<filter id="f1">
<feFlood flood-color="white" flood-opacity="1" result="white-layer"/>
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="white-layer"/> <!-- last = frontmost -->
</feMerge>
</filter>
An
feFlood with flood-color="white" flood-opacity="0.88" produces a near-white layer that, when merged over dark consent text (#1a1a1a), outputs a weighted composite: 0.88 × white + 0.12 × dark = #e2e2e2. The simulated contrast against a white (#ffffff) background is approximately 1.28:1 — below WCAG 3:1 threshold for normal text, far below 4.5:1, and functionally unreadable. The feFlood flood-opacity="0.88" does not immediately suggest an attack — high but not absolute opacity could be legitimate. Only computing the output contrast reveals the attack.
At page load, the final
feMergeNode references a transparent feFlood result (flood-opacity="0") — a no-op layer that leaves SourceGraphic visible. At the moment of consent interaction (button mouseenter or setTimeout triggered by user scroll reaching the consent element), a JavaScript mutation changes the feMergeNode in attribute to reference an opaque feFlood result. A static audit at DOMContentLoaded sees the transparent layer and passes. The attack activates only during the critical interaction window. Detection requires a MutationObserver on feMergeNode in attributes.
A
feFlood with a narrow filter region (x="0" y="0" width="65%" height="100%" of the consent element) covers the first 65% of the element's width — the region containing the acceptance clause ("I agree to be bound by") while leaving the company name and checkbox at the right edge visible. This is merged as the front layer, occluding only the binding clause. Aggregate contrast across the full element may pass (the visible right portion is legible), but the specific consent clause is hidden. Per-region contrast analysis is required to detect word-level occlusion.
Detection algorithm
function detectFeMergeAttacks(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;
const primitives = Array.from(filter.querySelectorAll('*'));
const findings = [];
for (const merge of filter.querySelectorAll('feMerge')) {
const nodes = Array.from(merge.querySelectorAll('feMergeNode'));
// Last node is frontmost layer
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
const inName = node.getAttribute('in');
if (!inName || inName === 'SourceGraphic') continue;
// Find the primitive producing this result
const source = primitives.find(p => p.getAttribute('result') === inName);
if (!source) continue;
if (source.tagName === 'feFlood') {
const floodOpacity = parseFloat(
getComputedStyle(source)['flood-opacity'] ??
source.getAttribute('flood-opacity') ?? '1'
);
if (floodOpacity >= 0.95) {
findings.push({ severity: 'critical', node, source,
issue: `feMergeNode at position ${i} (front layer) references feFlood with opacity=${floodOpacity} — opaque flood covers SourceGraphic in merge stack` });
} else if (floodOpacity > 0.7) {
const floodColor = getComputedStyle(source)['flood-color'] || source.getAttribute('flood-color') || 'black';
findings.push({ severity: 'high', node, source,
issue: `feMergeNode front layer: feFlood opacity=${floodOpacity} color="${floodColor}" — compute output contrast against page background` });
}
}
// Only check the last non-SourceGraphic node as the likely attack layer
break;
}
}
// Set up MutationObserver for animated in= attribute
const mergeNodes = filter.querySelectorAll('feMergeNode');
const observer = new MutationObserver(mutations => {
const postFindings = detectFeMergeAttacks(consentEl);
if (postFindings?.length) reportFindings('mutation', postFindings);
});
mergeNodes.forEach(n => observer.observe(n, { attributes: true, attributeFilter: ['in'] }));
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Read feMergeNode children in document order, identify the last (frontmost) node, resolve its in reference to the source primitive, and check whether that source produces an opaque or near-opaque image (feFlood with opacity ≥ 0.7, or feImage loading a non-transparent image) |
Opaque front-layer and near-white opacity-collapse variants — the attack is in the last feMergeNode; resolving its source and computing the output contrast reveals both the critical and high-severity variants |
Install a MutationObserver on all feMergeNode in attributes within the consent element's filter graph and re-run the layer-order audit on any mutation observed during the consent interaction window |
Animated input substitution variant — the attack activates via attribute mutation at interaction time; static audit passes; only mutation-aware re-auditing catches the transition |
For feMerge filters with calibrated filter region geometry (non-default x/y/width/height), compute the flood's rendered extent as a fraction of the consent element bounding box and check whether it covers specific text regions; perform per-region contrast analysis on covered sub-regions |
Word-level occlusion variant — aggregate contrast may pass while specific consent clause words are hidden; per-region analysis is required |
SkillAudit reads feMergeNode child order, resolves front-layer source primitives, computes output contrast for near-opaque flood layers, installs mutation observers for timing attacks, and performs per-region analysis for word-level occlusion. Run a free audit on any MCP server GitHub URL to detect feMerge layering attacks and the full SVG filter consent attack surface.