MCP server CSS feOffset security: large dx/dy viewport displacement, overflow:hidden clip of shifted pixels, subpixel offset glyph rendering disruption, and animated offset triggered at consent interaction
Published 2026-09-30 — SkillAudit Research
The SVG feOffset filter primitive shifts the input image by a specified displacement in filter-primitive coordinates. The dx attribute shifts horizontally; dy shifts vertically. After the shift, pixels that move outside the filter region boundary — defined by the filter's x, y, width, height attributes, which default to x="-10%" y="-10%" width="120%" height="120%" — are clipped.
A key property of feOffset is that it operates on the filter's output pixels, not the element's layout box. The element continues to occupy its original position in the document layout. getBoundingClientRect() still reports the element at its layout position with correct dimensions. The shifted pixels may be anywhere in the filter region — or outside it and clipped — but the layout check sees the original box. This decoupling between layout position and pixel position is the foundation of the feOffset attack class.
Layout-pixel decoupling: feOffset moves pixels, not layout boxes. getBoundingClientRect() tracks the layout box. An element with feOffset(dx=500) has a positive bounding rect at its original position, but its pixels have been shifted 500px to the right and clipped by the filter region boundary. The pixels are not where the bounding rect says they are.
Attack findings
An
feOffset dx="500" dy="0" applied to a consent text element with a standard filter region (width="120%") shifts all pixels 500 filter-primitive units to the right. For a consent element with a 300px bounding width, the filter region extends approximately to 360px. A 500px shift places all pixels outside this boundary. The shifted pixels are clipped. The filter outputs a fully transparent result over the element's layout region. The element's bounding rect remains at its original 300px width position. textContent, visibility, opacity, fill, and bounding rect checks all pass. The rendered frame shows nothing where the text should be.
<filter id="f1"> <feOffset in="SourceGraphic" dx="500" dy="0"/> </filter> <text filter="url(#f1)">I agree to the Terms of Service</text>
A consent element inside a container with
overflow: hidden and a constrained height. An feOffset dy="-50" shifts the consent text 50px upward into negative filter space. Some browsers clip filter output at the parent element's overflow boundary. The consent text pixels emerge above the container edge, outside the visible area. The element's bounding rect is fully within the container's visible region (the layout box did not move). The shifted pixels are above the container edge and invisible. The attack exploits the interaction between the filter pixel space and the CSS overflow model.
A fractional
feOffset dx="0.3" shifts the rendered text 0.3 filter-primitive units horizontally. At a device pixel ratio of 1.0, this is a 0.3-pixel shift — below the rendering threshold. At DPR 2.0, this becomes a 0.6-pixel shift which, combined with subpixel antialiasing, can cause horizontal glyph edges to split across physical pixels. At DPR 3.0 (common on mobile), the 0.9-pixel shift interacts with the font renderer's hinting grid, producing partially-rendered glyph columns that reduce legibility on specific font/size combinations. This variant is device-dependent and DPR-targeted.
At page load,
feOffset dx="0" dy="0" — no shift, text visible. A CSS animation or JavaScript mutation changes dx to 500 at the moment the user's mouse enters the consent button area (via mouseenter event). A static audit at DOMContentLoaded records dx="0" — a no-op offset. The attack activates during the button interaction window: the user sees the text, begins to click, and the text shifts away at the critical moment. Detection requires a MutationObserver on the feOffset dx and dy attributes during the interaction window.
Detection algorithm
function detectFeOffsetAttacks(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 rect = consentEl.getBoundingClientRect();
// Compute filter region extent in pixels
const filterRegionWidth = rect.width * 1.2; // default 120%
const filterRegionHeight = rect.height * 1.2;
const findings = [];
for (const offset of filter.querySelectorAll('feOffset')) {
const dx = parseFloat(offset.getAttribute('dx') || '0');
const dy = parseFloat(offset.getAttribute('dy') || '0');
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx > filterRegionWidth || absDy > filterRegionHeight) {
findings.push({ severity: 'critical', offset,
issue: `feOffset dx=${dx} dy=${dy} moves all pixels outside filter region (width=${filterRegionWidth.toFixed(0)}px height=${filterRegionHeight.toFixed(0)}px) — all pixels clipped, consent text invisible` });
} else if (absDx > rect.width * 0.8 || absDy > rect.height * 0.8) {
findings.push({ severity: 'high', offset,
issue: `feOffset dx=${dx} dy=${dy} shifts most pixels toward filter region boundary — verify pixels remain within visible filter extent` });
} else if (absDx > 0 && absDx < 1 || absDy > 0 && absDy < 1) {
findings.push({ severity: 'medium', offset,
issue: `feOffset subpixel dx=${dx} dy=${dy} may disrupt glyph rendering at specific DPR values — DPR-targeted legibility attack` });
}
}
// MutationObserver for animated offset
const offsets = filter.querySelectorAll('feOffset');
const observer = new MutationObserver(() => {
const postFindings = detectFeOffsetAttacks(consentEl);
if (postFindings?.length) reportFindings('offset-mutation', postFindings);
});
offsets.forEach(o => observer.observe(o, { attributes: true, attributeFilter: ['dx', 'dy'] }));
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
For every feOffset in a consent element's filter graph, compare the absolute value of dx and dy against the element's bounding dimensions multiplied by the filter region multiplier; if the shift exceeds the filter region extent, flag as critical (all pixels clipped) |
Large viewport displacement variant — detects shifts that move all pixels outside the filter boundary; requires computing the effective filter region size from the filter element's x/y/width/height attributes against the element's pixel dimensions |
Check the overflow property of ancestor elements containing the consent element; if overflow: hidden exists on an ancestor, model whether the shifted filter output pixels exit the ancestor's visible bounds at the dx/dy values present |
Overflow-clip variant — the attack requires both a non-zero feOffset and an overflow:hidden ancestor; neither alone is conclusive; compound analysis is required |
Install a MutationObserver on feOffset dx and dy attributes during the consent interaction window and flag any mutation that changes a near-zero offset to a large-displacement value |
Animated offset variant — the attack activates at interaction time; static audit sees dx=0; only mutation observation during interaction reveals the shift |
SkillAudit computes the effective filter region size for every consent element, compares feOffset displacement against the filter boundary, models overflow-clip interactions with ancestor containers, and monitors attribute mutations during the interaction window. Run a free audit on any MCP server GitHub URL to detect feOffset displacement attacks and the full SVG filter consent attack surface.