Security Research — 2026-10-15
Dynamic and Temporal SVG Consent Hijack Attacks: mousedown Injection, SMIL Animation, and pointer-events Manipulation
Most MCP consent-bypass research focuses on static SVG attributes — an opacity="0" here, an off-screen position there. Static attacks are the easiest to detect: read the DOM once at consent-display time, check every attribute and computed style, flag anomalies. The attacks described in this article cannot be caught that way. They are temporal — they exist for at most 50–200 milliseconds, activate only during the user’s click, and vanish before any post-click audit can run. Three families: SMIL animation flicker that renders consent text unreadable through persistence of vision, JavaScript mousedown injection that installs cover elements precisely during the click window, and pointer-events manipulation that silently drops the consent button’s click event. All three defeat static DOM scanning. Detecting them requires a fundamentally different two-phase model.
Why static scanning is insufficient for temporal attacks
A static SVG consent audit operates like a photograph: it reads the DOM at a moment in time (typically when the consent UI is first displayed) and checks attributes, computed styles, and bounding boxes against known-bad patterns. This model is entirely adequate for static attacks — an element with opacity="0" is invisible at display time, before the click, and that snapshot accurately represents the attack.
Temporal attacks exploit the gap between the static audit timestamp and the moment the user actually clicks. A SMIL <animate> element with dur="0.1s" flickers the consent-text opacity between 1 and 0 at 10Hz; the static audit reads the instantaneous computed opacity (which is 1 half the time), sees nothing wrong, and passes the element. A JavaScript mousedown listener injects a white cover <rect> at the start of the click and removes it at mouseup; the static audit runs before the click, finds no cover rect, and passes. A pointer-events="none" attribute injected at mousedown drops the click event that fires after mouseup; the audit that ran before the click found no pointer-events attribute.
The three attack families share a common structure:
- The attack element or attribute does not exist in the static DOM. Static audit passes unconditionally.
- The attack activates at or before the user’s
mousedownevent. The consent bypass is in place when the user thinks they are clicking. - The attack deactivates at
mouseup. By the time theclickevent fires (or does not fire), the evidence is gone.
This article documents all three families in depth, explains the specific detection gap each exploits, and presents the two-phase detection model required to catch them.
Family 1: SMIL animation flicker attacks (SA-CSS-ANI)
SVG’s Synchronized Multimedia Integration Language (SMIL) animation elements — <animate>, <animateTransform>, <animateMotion>, and <set> — modify SVG presentation attributes over time without JavaScript. They are declarative: the animation specification is baked into the SVG DOM as child elements of the animated node, and the animation engine runs in the browser without requiring any script execution. From a security review standpoint, they are invisible to CSS-based audits: getComputedStyle returns the instantaneous value of an animated attribute at the moment the call is made, not the animation range. A consent-text element with opacity animated from 1 to 0 to 1 over 0.1s will return opacity: 1 whenever the animation is in its on-phase, giving a static audit a false pass.
Opacity flicker below the critical fusion frequency (SA-CSS-ANI-001)
The human visual system’s critical flicker fusion frequency — the rate at which alternating light/dark is perceived as a continuous flicker rather than a steady average — is approximately 60Hz under photopic (daylight) conditions. SMIL <animate attributeName="opacity" values="1;0;1" dur="0.1s" repeatCount="indefinite"> flickers the consent text at 10Hz. At 10Hz, the text is on for 50ms and off for 50ms. Because 10Hz is well below the CFF, the user perceives a continuous flicker rather than averaging to 50% opacity; the text is unreadable from the motion blur and temporal aliasing, but at no instant is opacity equal to 0 when the flicker is in its on-phase. The static audit’s call to getComputedStyle(el).opacity hits either “1” or “0” depending on timing jitter, but because the test is run at consent display time (before any user interaction), it is more likely to see “1” on average. No anomaly is flagged.
Detection requires inspecting the <animate> child elements of the consent text node directly. Any <animate attributeName="opacity"> with a dur value below 150ms (corresponding to <7Hz) should be flagged as CRITICAL regardless of the instantaneous getComputedStyle value.
<!-- SA-CSS-ANI-001: opacity flicker at 10Hz; static opacity is 1 or 0 depending on timing -->
<text id="consent-text" x="10" y="30" fill="#111" font-size="14">
Allow shell execution, credential access, and filesystem write
<animate attributeName="opacity"
values="1;0;1"
dur="0.1s"
calcMode="discrete"
repeatCount="indefinite"/>
</text>
// Detection: walk all child animate elements; check attributeName + dur
function detectSMILOpacityFlicker(consentEl) {
const findings = [];
for (const anim of consentEl.querySelectorAll('animate, animateTransform, set')) {
const attr = anim.getAttribute('attributeName') || '';
const dur = parseDurationToSeconds(anim.getAttribute('dur') || '');
if (attr === 'opacity' && dur < 0.15) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-ANI-001',
note: `<animate attributeName="opacity" dur="${anim.getAttribute('dur')}"> flickers at ${(1/dur).toFixed(0)}Hz — below CFF; consent text unreadable; getComputedStyle returns instantaneous value, not animation range` });
}
}
return findings;
}
Fill color animation cycling to background color (SA-CSS-ANI-002)
Rather than animating opacity, the attack cycles the consent text fill attribute between the text color and the consent panel background color. At 10Hz, the text alternates between dark (#111) and white (#fff) with calcMode="discrete" (instantaneous switching, no interpolation). Because neither color is transparent and the text’s opacity is 1, an opacity-focused audit finds nothing. The visual effect is identical to opacity flicker: the text is unreadable. The fill values list in the <animate> element must be parsed and checked for color values that match the expected panel background (within a tolerance of about 15% luminance difference, since the attacker will likely use near-white rather than pure white to evade exact-match detection).
Text position displacement animation (SA-CSS-ANI-003)
An <animate attributeName="dx" values="0;500;0" dur="0.5s" repeatCount="indefinite"> moves the consent text 500px to the right and back every 0.5s. At any position between 0 and 500px, the text is partially or wholly off-screen (in a 400px viewport). The text’s layout position as returned by getBoundingClientRect at render time reflects the pre-dx layout position, not the animated position. The displacement occurs entirely within SVG; the BCR check sees the correct on-screen bounding box and passes. Detection must read <animate attributeName="dx"> child elements and check whether the maximum displacement in the values list exceeds the SVG viewport width.
Rotation animation to smear glyphs (SA-CSS-ANI-004)
<animateTransform type="rotate" from="0" to="360" dur="0.3s" repeatCount="indefinite"> rotates the consent text group at 3.3 revolutions per second. At that angular velocity, individual glyph forms blur into a continuous smeared arc in the user’s visual field. No static attribute is wrong: opacity is 1, fill is correct, position is on-screen. The animation element itself is the attack. Detection must enumerate <animateTransform type="rotate"> elements with dur below 500ms as HIGH severity (text rotating faster than 2 rev/sec is unreadable to humans under all practical conditions).
SMIL attack detection requires child element inspection, not getComputedStyle. For any SVG element in the consent UI, enumerate its direct children for <animate>, <animateTransform>, and <set> elements. Check attributeName against the opacity, fill, dx/dy, and transform families. Check dur against a minimum readability threshold (150ms for opacity/fill, 500ms for transform rotation). getComputedStyle returns a snapshot value at the instant of the call and cannot reveal the animation range or frequency.
Family 2: JavaScript mousedown injection attacks
The second family uses JavaScript to install a consent bypass at the precise moment the user begins a click. The common pattern is: a mousedown event listener (registered on the consent container or the document in capture phase) creates an SVG cover element — a white <rect>, a <mask>, a <clipPath>, a <pattern> fill — and injects it into the SVG <defs> or directly into the consent element. A corresponding mouseup listener removes the injected element. The click event fires after mouseup, so the consent button’s click handler sees a clean DOM — the injected element has already been removed. But between mousedown and mouseup (50–200ms), the user’s visual field shows the cover element instead of the consent text, or a misleading overlay instead of the correct button label.
Mask injection at mousedown (SA-CSS-MSK-004)
A mousedown listener creates a <mask id="dm"><rect fill="black" .../></mask> in the SVG <defs> and sets mask="url(#dm)" on the consent text element. In SVG luminance masking, black mask pixels produce full transparency; the consent text becomes invisible during the click. At mouseup, the mask is removed. The static DOM before the click has no mask on the consent text; the static DOM after the click has no mask. Only a MutationObserver watching for mask attribute changes on the consent element — or for <mask> insertions into the SVG <defs> — can detect this. See the dedicated SVG mask element consent security page for the full detection algorithm.
ClipPath injection at mousedown (SA-CSS-CLP-004)
The equivalent attack using <clipPath>: a JS mousedown listener creates a <clipPath id="dc"><rect x="500" y="500" width="1" height="1"/></clipPath> (a clip region that is entirely off-screen) and sets clip-path="url(#dc)" on the consent text, clipping it to a 1×1 pixel region far outside the visible area. During the click window, the consent text is clipped to invisibility. After mouseup, the clip-path attribute is removed. Like mask injection, this leaves no static trace and requires a MutationObserver watching clip-path attribute changes on the consent element and <clipPath> insertions into <defs>.
Pattern fill injection at mousedown (SA-CSS-PTN-004)
A mousedown listener injects a <pattern id="dp"><rect fill="white" width="100%" height="100%"/></pattern> into <defs> and sets fill="url(#dp)" on the consent overlay rect. The consent panel’s background becomes solid white, covering the text beneath. The fill attribute on the overlay rect points to a pattern rather than a named color; fill-color string checks that compare el.getAttribute('fill') against “white” or “#fff” would find “url(#dp)” and not flag it even if the audit ran at click time. At mouseup, the pattern is removed and the fill attribute reverts. See the dedicated SVG pattern element consent security page for the full attack breakdown.
All three injection attacks share the same detection gap: they require a live MutationObserver, not a static scan. The MutationObserver must be established on the consent SVG element and its <defs> subtree before the user has any opportunity to click. The observer must watch for: (1) mask attribute changes on consent elements; (2) clip-path attribute changes on consent elements; (3) fill attribute changes to URL references; (4) <mask>, <clipPath>, and <pattern> child insertions into <defs>. Any of these events during the consent interaction window is CRITICAL.
Family 3: pointer-events manipulation attacks (SA-CSS-PEV-004)
The third family does not hide the consent text from the user; it prevents the consent button’s click event from firing at all. SVG’s pointer-events attribute controls whether an element participates in pointer-event hit-testing. Setting pointer-events="none" on a consent button makes it visually normal — it has the correct fill, stroke, and opacity, and it responds to :hover CSS — but it does not receive click events. Clicks pass through to whatever lies beneath.
pointer-events="none" injected at mousedown (SA-CSS-PEV-004)
A JavaScript mousedown listener (capture phase, so it runs before any non-capture listener) calls consentButton.setAttribute('pointer-events', 'none'). A matching mouseup listener calls consentButton.removeAttribute('pointer-events'). The browser’s event dispatch model guarantees the following sequence: (1) mousedown fires → attribute injected; (2) mouseup fires → attribute removed; (3) click fires. By the time click fires, the attribute is gone. But click does not fire on an element with pointer-events="none" at the time the click was initiated — the browser already resolved the hit-test at mousedown time. If the underlying element beneath the consent button has a different event handler, that handler fires instead (for example, a “deny” action).
A static pre-click audit finds no pointer-events attribute on the button. A static post-click audit also finds nothing. The only detection path is a MutationObserver watching for pointer-events attribute changes on the consent button element during the click window. See the dedicated SVG pointer-events consent security page for the full detection and click-integrity-check implementation.
// SA-CSS-PEV-004: pointer-events="none" injected at mousedown, removed at mouseup
// The click event fires AFTER mouseup — but the consent click handler never fires
// because the button had pointer-events="none" when the click was initiated.
// Attacker code pattern:
consentButton.addEventListener('mousedown', () => {
consentButton.setAttribute('pointer-events', 'none');
}, true); // capture phase
consentButton.addEventListener('mouseup', () => {
consentButton.removeAttribute('pointer-events'); // gone before click fires
}, true);
// Detection:
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.attributeName === 'pointer-events') {
const v = consentButton.getAttribute('pointer-events');
if (v === 'none') {
reportFinding({ severity: 'CRITICAL', code: 'SA-CSS-PEV-004',
note: 'pointer-events="none" injected on consent button during click window' });
}
}
}
});
observer.observe(consentButton, {
attributes: true, attributeFilter: ['pointer-events'], attributeOldValue: true, subtree: true
});
CRITICAL — SA-CSS-PEV-004: pointer-events="none" injected at mousedown and removed at mouseup leaves no static trace. The consent button appears and behaves normally before and after the click (hover cursor changes, visual press feedback may apply) but the click event is silently dropped. A MutationObserver on the pointer-events attribute established before user interaction begins is the only reliable detection path. A secondary defence is to check getComputedStyle(el).pointerEvents !== 'none' inside the click handler and reject the click if the invariant is violated, because a legitimate click event cannot be dispatched on an element with pointer-events="none".
Cross-family detection synthesis: the three-pass model
Because the three attack families require different detection techniques — child-element inspection for SMIL, MutationObserver for JS injection, attribute + geometry analysis for static pointer-events — a complete consent audit must run three concurrent passes:
Static scan (pre-interaction)
Run at consent-display time. Check all computed styles, attributes, bounding boxes, and child element trees. Enumerate SMIL animate elements. Detect static pointer-events misconfigurations. This pass catches static attacks AND SMIL attacks. It cannot catch JS injection attacks (elements not yet present).
SMIL enumeration (continuous)
At consent-display time, enumerate all <animate>, <animateTransform>, and <set> children of consent elements. Check attributeName (opacity, fill, dx, dy, transform) and dur against thresholds. This pass must also cover elements added after display via Pass 3’s MutationObserver.
MutationObserver live monitoring
Established at consent-display time, runs continuously until consent resolves. Watches consent elements and <defs> for: attribute changes to opacity, fill, mask, clip-path, pointer-events; and child insertions of <mask>, <clipPath>, <pattern>, <animate>. Any observed mutation is flagged CRITICAL.
The three passes are not sequential — they run concurrently. Pass 1 and Pass 2 are completed synchronously at consent display time; Pass 3 runs as an asynchronous observer from display time until the consent interaction completes (either the user clicks or the consent is cancelled). The observer is disconnected immediately after the consent outcome is determined, before any consent-data transmission.
// Three-pass consent audit implementation outline
function auditConsentUI(consentSvgEl) {
// Pass 1: static scan
const staticFindings = [
...checkComputedStylesRecursive(consentSvgEl),
...checkBoundingRects(consentSvgEl),
...checkPointerEventsStatic(consentSvgEl),
...checkFillReferences(consentSvgEl),
];
// Pass 2: SMIL enumeration
const smilFindings = checkSMILAnimations(consentSvgEl);
// Pass 3: live MutationObserver
const liveFindings = [];
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
const finding = classifyMutation(m, consentSvgEl);
if (finding) liveFindings.push(finding);
}
});
observer.observe(consentSvgEl, {
attributes: true,
attributeFilter: ['opacity', 'fill', 'mask', 'clip-path', 'pointer-events',
'visibility', 'display', 'transform'],
childList: true,
subtree: true,
attributeOldValue: true
});
// Additionally watch <defs> for inserted cover elements
const defs = consentSvgEl.querySelector('defs');
if (defs) {
observer.observe(defs, { childList: true, subtree: true });
}
return {
static: staticFindings,
smil: smilFindings,
live: liveFindings, // populated asynchronously
disconnect: () => observer.disconnect()
};
}
function checkSMILAnimations(svgEl) {
const findings = [];
const OPACITY_FLICKER_THRESHOLD_S = 0.15; // <150ms = >6.7Hz
const ROTATION_SPEED_THRESHOLD_S = 0.5; // <500ms = >2 rev/sec
for (const anim of svgEl.querySelectorAll('animate, animateTransform, set')) {
const attr = anim.getAttribute('attributeName') || '';
const dur = parseDurToSeconds(anim.getAttribute('dur') || '');
const type = anim.getAttribute('type') || ''; // for animateTransform
if (attr === 'opacity' && dur < OPACITY_FLICKER_THRESHOLD_S) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-ANI-001',
note: `opacity flicker at ${(1/dur).toFixed(1)}Hz — below CFF; static getComputedStyle misses` });
}
if (attr === 'fill' && dur < OPACITY_FLICKER_THRESHOLD_S) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-ANI-002',
note: `fill color cycling at ${(1/dur).toFixed(1)}Hz — background-matching fill unreadable; opacity audit misses` });
}
if ((attr === 'dx' || attr === 'dy') && dur < 2) {
// Check values list for large displacement
const maxDisplacement = Math.max(...(anim.getAttribute('values') || '0')
.split(';').map(v => Math.abs(parseFloat(v) || 0)));
if (maxDisplacement > 200) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-ANI-003',
note: `${attr} displacement animation: max ${maxDisplacement}px — text moves off-screen; BCR reflects layout position not animated position` });
}
}
if (anim.tagName === 'animateTransform' && type === 'rotate' && dur < ROTATION_SPEED_THRESHOLD_S) {
findings.push({ severity: 'HIGH', code: 'SA-CSS-ANI-004',
note: `rotation at ${(1/dur).toFixed(1)} rev/sec — glyph smear; consent text unreadable` });
}
}
return findings;
}
Cross-family summary table
| Attack family | Mechanism | Static DOM detectability | Detection method | Finding codes | Severity |
|---|---|---|---|---|---|
| SMIL opacity flicker (SA-CSS-ANI-001) | <animate attributeName="opacity" dur="0.1s"> flickers consent text at 10Hz — below CFF; perceived as unreadable flicker not averaged transparency | Not detectable by getComputedStyle (returns instantaneous value). Child element inspection of <animate> required. | Enumerate animate children; check attributeName="opacity" + dur < 150ms | SA-CSS-ANI-001 | CRITICAL |
| SMIL fill color cycling (SA-CSS-ANI-002) | <animate attributeName="fill" values="#111;#fff;#111" dur="0.1s"> — opacity is always 1; color alternates to background; text perceptually invisible | Not detectable by opacity audit. Fill values list in animate child must be parsed. | Enumerate animate children; check attributeName="fill" + dur < 150ms; parse values for background-match colors | SA-CSS-ANI-002 | CRITICAL |
| SMIL text displacement (SA-CSS-ANI-003) | <animate attributeName="dx" values="0;500;0"> moves text off-screen; BCR reflects layout position not animated position | Not detectable by BCR check. Animate child dx values list must be parsed. | Enumerate animate children; check attributeName="dx"/"dy" + max(values) > viewport width | SA-CSS-ANI-003 | CRITICAL |
| SMIL rotation smear (SA-CSS-ANI-004) | <animateTransform type="rotate" dur="0.3s"> — 3.3 rev/sec; glyph forms smear to arc; opacity/position audit misses | Not detectable by any computed-style check. animateTransform child with type="rotate" must be enumerated. | Enumerate animateTransform children; check type="rotate" + dur < 500ms | SA-CSS-ANI-004 | HIGH |
| Mask injection at mousedown (SA-CSS-MSK-004) | JS mousedown listener creates <mask> in defs, sets mask= on consent text; removed at mouseup; static DOM before/after click is clean | Not detectable by static scan (element not yet in DOM). MutationObserver required. | MutationObserver: watch mask attribute on consent elements + <mask> insertions in <defs> | SA-CSS-MSK-004 | CRITICAL |
| ClipPath injection at mousedown (SA-CSS-CLP-004) | JS mousedown creates <clipPath> clipping consent text to off-screen 1×1 region; removed at mouseup | Not detectable by static scan. MutationObserver required. | MutationObserver: watch clip-path attribute on consent elements + <clipPath> insertions in <defs> | SA-CSS-CLP-004 | CRITICAL |
| Pattern fill injection at mousedown (SA-CSS-PTN-004) | JS mousedown creates solid-white <pattern> in defs, sets fill="url(#p)" on consent overlay; removed at mouseup | Not detectable by static scan. MutationObserver required. | MutationObserver: watch fill attribute changes to url() references + <pattern> insertions in <defs> | SA-CSS-PTN-004 | CRITICAL |
| pointer-events="none" at mousedown (SA-CSS-PEV-004) | JS mousedown sets pointer-events="none" on consent button; click event dropped; attribute removed at mouseup; static DOM clean before and after | Not detectable by static scan. MutationObserver + click integrity check required. | MutationObserver: watch pointer-events attribute on consent button; click handler: verify getComputedStyle(el).pointerEvents !== "none" at dispatch | SA-CSS-PEV-004 | CRITICAL |
Defences
- Establish MutationObserver before consent is displayed — the observer must be running before any user interaction, so it cannot miss mutations that happen at
mousedown. An observer started at consent display time (before any click) meets this requirement; one started reactively (e.g., on hover) does not. - Enumerate SMIL animation children, do not rely on
getComputedStyle— for every text, tspan, g, and rect element in the consent UI, check for<animate>and<animateTransform>children. ParseattributeNameanddur; check fillvalueslists for background-matching colors; checkdx/dyvalues lists for large displacements. - Disconnect the MutationObserver only after consent outcome is final — disconnecting before the
clickevent fires (e.g., onmouseup) creates a small window where a fast second injection could occur. Disconnect only inside the consent resolution callback, after the consent outcome has been recorded and transmission has begun. - Validate consent-button click integrity at
clickdispatch time — inside theclickhandler (capture phase, highest priority), checkgetComputedStyle(el).opacity,getComputedStyle(el).visibility, andgetComputedStyle(el).pointerEvents. A click dispatched on an element withpointerEvents === "none"is a browser anomaly indicating a temporal injection that was removed betweenmouseupandclickdispatch; reject such clicks. - Treat any MutationObserver callback during the consent window as an automatic audit failure — any DOM mutation to consent elements during the interaction window (between consent display and consent resolution) is abnormal. Legitimate consent UI is static. Present a warning to the user and do not record the consent outcome as valid if mutations are observed.