Security Research · Oct 22, 2026
SVG Tspan Positioning, Document-Order Layering, Pointer-Events Deception, and direction:rtl Consent Attacks
Four SVG consent-bypass attack families that exploit the geometric and interaction-model properties of SVG rather than CSS paint operations. In each case, the targeted text element’s own properties — fill color, opacity, display, visibility, BCR — all appear normal. The attack lives in the element’s coordinate position, its relationship to other elements in document order, its pointer-event routing, or its text-directionality model.
Contents
- The structural gap these attacks share
- Family 1 — tspan absolute positioning (SA-CSS-TXO / SA-CSS-TYO)
- Family 2 — document-order white-rect layering (SA-CSS-ZIX)
- Family 3 — pointer-events deception (SA-CSS-PEN)
- Family 4 — direction:rtl displacement (SA-CSS-DIR)
- Unified detection checklist
- Finding coverage table
The structural gap these attacks share
Most SVG consent-bypass research focuses on the paint layer: opacity, fill color, clip-path, mask, filter, and blend mode. These attacks manipulate how glyphs are rendered once they reach the compositing pipeline. The DOM and CSSOM say the text is “present”; only the rendered pixel output is wrong.
The four families in this article attack a different layer: the geometric and interaction model of SVG. The consent text’s own element-level properties are genuinely normal — dark fill, full opacity, no clip-path, no filter. What the scanner misses is the element’s coordinate position relative to the viewport, its z-order relative to other elements, whether it participates in pointer event routing, or whether its writing direction matches the viewport’s coordinate origin.
The consequence is that auditors relying solely on per-element CSS property inspection pass these attacks cleanly. Detecting them requires a broader spatial model: where is the element in the viewport? what element is visually on top of it? what happens when you click it? does its text direction place it inside or outside the visible area?
SA-CSS-TXO / TYO — Tspan absolute positioning
The <tspan> element’s x or y attribute sets an absolute coordinate in SVG user-unit space. x="-5000" places the consent tspan 5000px left of the viewport. SVG overflow:hidden clips it. The parent text BCR is non-zero.
SA-CSS-ZIX — Document-order layering
SVG paints elements in document order (later = on top). A white rectangle that appears after the consent text in the SVG source is painted over it. CSS z-index does not apply to SVG elements; only document order controls z-ordering.
SA-CSS-PEN — pointer-events deception
The Deny button has pointer-events="none": it is visually present (full red fill, readable text) but click-transparent. A near-transparent sibling rectangle at the same coordinates has onclick=confirmAllow() and intercepts all Deny-area clicks.
SA-CSS-DIR — direction:rtl displacement
CSS direction:rtl with x="0" extends text characters leftward from the anchor point at x=0. The text is 280px wide and spans x=−280..0. SVG overflow:hidden clips x<0. textContent intact.
Family 1: tspan absolute positioning (SA-CSS-TXO / SA-CSS-TYO)
SVG text elements have two mechanisms for positioning characters along the baseline: absolute coordinates (attributes x and y on <tspan>) and relative offsets (attributes dx and dy). The absolute attributes — x and y — override the current text cursor position entirely, setting the first character of the tspan at the specified absolute coordinate in SVG user-unit space. They do not add to or interpolate from the current cursor; they reset it.
This matters for consent attacks because the attack can target any absolute coordinate — including coordinates far outside the SVG viewport. With x="-5000" on a tspan element in a 440px-wide SVG, the tspan’s first character is placed at x=−5000 in SVG user-unit space. In a viewport with a 1:1 user-unit-to-pixel mapping (no custom viewBox), this is 5000px to the left of the SVG’s left edge. SVG’s default overflow value on the <svg> element is hidden: content outside the viewport boundary is not rendered. The consent text is entirely absent from the rendered output but is structurally present in the DOM.
The per-element property checks all pass: getComputedStyle(tspan).display returns inline; visibility returns visible; opacity returns 1; fill returns a dark color. The parent <text> element’s BCR is non-zero because the element’s layout box reflects its declared x and y position rather than its clipped rendering extent. The textContent of the parent element includes the consent text in full. Only checking the tspan’s own x attribute value and verifying that it falls within the SVG viewport reveals the attack.
<!-- SA-CSS-TXO-001: tspan x="-5000" places consent tspan 5000px off-screen left -->
<!-- parent text BCR non-zero; textContent intact; getComputedStyle shows no markers -->
<text x="20" y="50" font-size="13" fill="#111">
<tspan x="-5000">Allow: shell execution, credential access</tspan>
</text>
// Detection: parse tspan x attribute; check against viewport dimensions
function checkTspanAbsolutePosition(svgEl) {
const svgWidth = svgEl.viewBox?.baseVal?.width || svgEl.clientWidth || 440;
const svgHeight = svgEl.viewBox?.baseVal?.height || svgEl.clientHeight || 120;
for (const tspan of svgEl.querySelectorAll('tspan')) {
const x = parseFloat(tspan.getAttribute('x') || 'NaN');
const y = parseFloat(tspan.getAttribute('y') || 'NaN');
const xOffScreen = (!isNaN(x)) && (x < -(svgWidth * 2) || x > svgWidth * 10);
const yOffScreen = (!isNaN(y)) && (y < -(svgHeight * 2) || y > svgHeight * 10);
if (xOffScreen || yOffScreen) {
// BCR intersection with SVG container confirms off-screen
const tBcr = tspan.getBoundingClientRect();
const svgBcr = svgEl.getBoundingClientRect();
const overlap =
Math.max(0, Math.min(tBcr.right, svgBcr.right) - Math.max(tBcr.left, svgBcr.left)) *
Math.max(0, Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top, svgBcr.top));
// overlap === 0 confirms tspan fully off-screen
}
}
}
The y-axis variant (SA-CSS-TYO) is structurally identical: y="-500" places the tspan baseline 500px above the SVG top edge (y=0 is the top in SVG’s downward coordinate system; y<0 is above). The detection check is symmetric: absolute y values less than -(svgHeight * 2) or greater than svgHeight * 10 indicate off-screen vertical displacement.
Both variants gain additional severity from their per-tspan scope form (SA-CSS-TXO-003, SA-CSS-TYO-003): when the SVG contains two tspan children — a header tspan with a normal coordinate and a consent tspan with an off-screen coordinate — the parent element’s BCR is non-zero (the header tspan’s coordinate contributes), and any-visible-tspan heuristics pass because the header renders normally. The attack exploits the false assumption that if a <text> element has any visible child tspan, all consent-bearing tspan children are also visible.
Family 2: document-order white-rect layering (SA-CSS-ZIX)
SVG renders elements using the painter’s algorithm: each element in the SVG document is painted in order, with later elements painted on top of earlier ones. CSS z-index has no effect on SVG elements rendered within the same <svg> context — only document order determines z-ordering. This is a fundamental property of SVG’s rendering model that has no CSS equivalent.
The document-order layering attack creates a <rect> element with fill="white" that is positioned over the consent text area and appears after the consent text element in the SVG source. Because the rect is later in document order, it is painted on top of the text, covering it completely. The consent text element is fully present in the DOM — correct fill color, correct coordinates, non-zero BCR, intact textContent — but it is occluded by the white rectangle painted over it.
<!-- SA-CSS-ZIX-001: white rect as last SVG child covers consent text -->
<!-- consent text: fill="#111", x=20, y=50, normal BCR, textContent intact -->
<!-- white rect: fill="white", position covers consent text, later in document order -->
<svg width="440" height="120">
<text x="20" y="50" fill="#111" font-size="13">
Allow: shell execution, credential access
</text>
<!-- Painted AFTER text; covers text area; CSS z-index irrelevant in SVG -->
<rect x="0" y="30" width="440" height="40" fill="white"/>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" fill="white" font-size="13">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" fill="white" font-size="13">Deny</text>
</svg>
Detection requires reasoning about the document-order relationships between elements rather than inspecting individual elements in isolation. The algorithm must: (1) find all consent-bearing text elements; (2) find all other SVG elements that appear later in document order; (3) compute the BCR intersection between each later element and each consent text element; (4) check if the later element’s fill color is the background color (white, or matching the SVG background rect) or near-transparent; (5) if both conditions hold (BCR overlap AND fill matches background), flag the later element as a covering attack.
A particularly subtle variant (SA-CSS-ZIX-002) uses a near-transparent rectangle with opacity="0.001" and an onclick=confirmAllow() handler. This rectangle is later in document order than the Deny button and covers the same screen area. When the user clicks the visible Deny button, the hit-test delivers the click to the topmost element in the hit stack — the near-transparent rect above it — which fires the Allow action. The Deny button is visually present and appears interactive (normal fill color, readable text) but functionally routes all clicks to Allow. Detection requires: find elements with near-zero opacity (opacity < 0.05) appearing later in document order, overlapping BCR with interactive consent controls, and carrying event handler attributes.
Family 3: pointer-events deception (SA-CSS-PEN)
SVG’s pointer-events attribute controls whether an element participates in pointer event hit-testing. The value none makes the element click-transparent: it is rendered normally (fill, stroke, opacity all applied) but does not receive or block any pointer events. Clicks pass through it to underlying elements as if it were not there.
The pointer-events deception attack pairs pointer-events="none" on the Deny button with a transparent or near-transparent sibling rectangle covering the same screen coordinates. The transparent rect has pointer-events="all" (the default for filled elements) and onclick=confirmAllow(). The SVG renders correctly to a human observer: the Deny button is a red rectangle with white text, visually indistinguishable from a normal interactive Deny button. But clicking anywhere on the Deny button area delivers the event to the transparent Allow-routing rect beneath/behind it, not to the Deny button itself.
<!-- SA-CSS-PEN-001: Deny group pointer-events="none" + transparent Allow-routing rect -->
<!-- Deny renders in full red, readable; transparent rect at same coords has onclick=Allow -->
<!-- ALL clicks on Deny area route to Allow; user cannot press Deny -->
<svg width="440" height="120">
<text x="20" y="50" fill="#111" font-size="13">Allow: shell execution</text>
<!-- Allow button: interactive; default pointer-events -->
<g id="allow-group" onclick="confirmAllow()">
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" fill="white" font-size="13">Allow</text>
</g>
<!-- Deny button: VISUAL presence only; pointer-events="none" -->
<g id="deny-group" pointer-events="none">
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" fill="white" font-size="13">Deny</text>
</g>
<!-- near-transparent rect covers Deny area; routes Deny-area clicks to Allow -->
<rect x="120" y="70" width="80" height="30" fill="white" opacity="0.001"
onclick="confirmAllow()" pointer-events="all"/>
</svg>
This attack introduces a new threat model: the consent text may be fully readable, but the user’s ability to decline is compromised. Even if the user reads the permission scope clearly and consciously clicks Deny, the click is hijacked and processed as Allow. Traditional consent-bypass detection focuses on whether the consent text is hidden; this attack requires additionally verifying that the consent controls are interactive and properly wired.
Detection for SA-CSS-PEN requires: (1) find all elements in the SVG with pointer-events="none"; (2) if any such element appears to be a Deny control (by heuristics: red/dark fill, text content matching deny/cancel/reject/no, positioned near Allow); (3) check for sibling elements at overlapping BCR coordinates with onclick handlers; (4) flag the combination of visible-but-non-interactive Deny and overlapping click-interceptor.
Family 4: direction:rtl displacement (SA-CSS-DIR)
SVG text elements support the CSS direction property and the SVG direction presentation attribute, which control the text directionality. In left-to-right (LTR) text, characters advance from the text anchor point to the right. In right-to-left (RTL) text, characters advance from the text anchor point to the left. The text anchor point is set by the element’s x attribute (or the parent text element’s x value).
The direction:rtl attack combines direction="rtl" (or CSS direction:rtl) with x="0" on a consent text element. With RTL directionality, the text anchor is at x=0 and characters extend leftward from that anchor. A 40-character consent string at 7px per character occupies x=[−280, 0]. The SVG’s left viewport boundary is at x=0; the default overflow:hidden clips all content at x<0. The entire consent text is off the left edge of the viewport, clipped, and not rendered.
<!-- SA-CSS-DIR-001: direction="rtl" + x="0" displaces consent text off-screen left -->
<!-- RTL: text anchor at x=0; characters extend leftward; 40-char string ≈ 280px left -->
<!-- overflow:hidden clips x<0; consent text entirely off-screen -->
<text x="0" y="50" font-size="13" fill="#111" direction="rtl">
Allow: shell execution, credential access
</text>
// Detection: check direction attribute/CSS; for RTL, verify text extent left of anchor
function checkDirectionRtl(svgEl) {
for (const textEl of svgEl.querySelectorAll('text')) {
const dir = textEl.getAttribute('direction') ||
getComputedStyle(textEl).direction || 'ltr';
if (dir !== 'rtl') continue;
const anchorX = parseFloat(textEl.getAttribute('x') || '0');
const text = textEl.textContent || '';
// Estimate text extent: len * avg_char_width. Threshold: more than 50% off-screen
const estimatedWidth = text.length * 7; // ~7px/char at 13px font
const visibleWidth = anchorX; // RTL: visible portion = x from 0 to anchorX
if (visibleWidth < estimatedWidth * 0.5) {
// More than 50% of text extends left of x=0 (off-screen)
const tBcr = textEl.getBoundingClientRect();
const svgBcr = svgEl.getBoundingClientRect();
const overlap =
Math.max(0, Math.min(tBcr.right, svgBcr.right) - Math.max(tBcr.left, svgBcr.left)) *
Math.max(0, Math.min(tBcr.bottom, svgBcr.bottom) - Math.max(tBcr.top, svgBcr.top));
// overlap near 0 = text off-screen
}
}
}
The direction:rtl attack is particularly hard to detect because the text element’s own properties all appear normal: fill="#111" (dark), opacity:1, display:inline, visibility:visible. The textContent is the full consent string. Even the BCR is “non-zero” in the sense that the element has a layout box at its declared position. Only computing the text’s RTL extent — knowing that with RTL direction and x=0, the text occupies x=[−width, 0] — reveals the displacement.
A variant (SA-CSS-DIR-002) combines direction="rtl" with unicode-bidi="bidi-override". The bidi-override value forces the visual presentation order to be the reverse of the logical character order, regardless of the Unicode bidirectional algorithm. For a Latin-script consent string like “credential access”, bidi-override renders it as “ssecca laitnederc” — each character present, but in reverse order, visually unreadable. This doubles the attack: the text is both off-screen (displaced leftward by RTL anchoring) and reversed in character order.
Unified detection checklist
The four families share a detection gap: they are all invisible to per-element CSS property inspection. The common thread is that the attack lives outside the element’s own style properties — in its coordinates, its relationships with other elements, its event routing, or its text model. A complete SVG consent-bypass detector must go beyond CSS inspection and check five additional properties:
| Check | Property inspected | What it catches |
|---|---|---|
| 1. Tspan absolute coordinate | Tspan x and y attribute values vs. SVG viewport dimensions (multiplied by viewBox scale) |
SA-CSS-TXO, SA-CSS-TYO — off-screen absolute displacement |
| 2. Tspan relative coordinate | Tspan dx and dy attribute values added to running cursor position; effective position vs. SVG viewport |
SA-CSS-TDX, SA-CSS-TDY — off-screen relative displacement |
| 3. Document-order overlap | BCR intersection between consent text element and all later-document-order elements; fill color of later elements vs. background | SA-CSS-ZIX — white-rect covering, transparent click-interceptor |
| 4. Pointer-events routing | pointer-events attribute on Deny controls; overlapping click-interceptor elements with onclick handlers |
SA-CSS-PEN — non-interactive Deny button; click hijack |
| 5. Text directionality | direction attribute/CSS on text elements; for RTL text, verify effective x range [anchor−width, anchor] is within viewport |
SA-CSS-DIR — RTL anchor at x=0 displaces text off left edge |
All five checks can be integrated into a single SVG consent scanner. The key insight is that each check requires context beyond the element’s own properties: viewport dimensions (checks 1, 2), other elements in the same SVG (check 3), event handler inventory (check 4), or text advance-width estimation (check 5). A scanner that processes each element in isolation will miss all five families.
Finding coverage table
| Finding code | Family | Attack description | Bypasses | Severity |
|---|---|---|---|---|
| SA-CSS-TXO-001 | Tspan x | tspan x="-5000" off-screen left | textContent; parent BCR; getComputedStyle | Critical |
| SA-CSS-TXO-002 | Tspan x | tspan x="5000" off-screen right | textContent; parent BCR; negative-x-only scanner | Critical |
| SA-CSS-TXO-003 | Tspan x | per-tspan scope; header visible; consent tspan x="-5000" | textContent; any-visible-tspan; parent BCR | High |
| SA-CSS-TXO-004 | Tspan x | mousedown injects x="-5000"; reverts mouseup | Static DOM; requires MutationObserver attributeFilter=['x'] | Critical |
| SA-CSS-TYO-001 | Tspan y | tspan y="-500" above viewport | textContent; parent BCR; getComputedStyle | Critical |
| SA-CSS-TYO-002 | Tspan y | tspan y="5000" below viewport | textContent; parent BCR; above-only scanner | Critical |
| SA-CSS-TYO-003 | Tspan y | per-tspan scope; header at y=30; consent tspan y="-500" | textContent; any-visible-tspan; parent BCR | High |
| SA-CSS-TYO-004 | Tspan y | mousedown injects y="-500"; reverts mouseup | Static DOM; requires MutationObserver attributeFilter=['y'] | Critical |
| SA-CSS-ZIX-001 | Doc-order layering | white rect as last SVG child covers consent text | textContent; per-element BCR; fill color of text element | Critical |
| SA-CSS-ZIX-002 | Doc-order layering | transparent onclick rect over Deny button | Deny button visual check; requires overlapping click-interceptor check | Critical |
| SA-CSS-ZIX-003 | Doc-order layering | SVG <image> later in doc order; white PNG over consent text | fill-color check (image has no fill); requires BCR overlap check regardless of element type | High |
| SA-CSS-ZIX-004 | Doc-order layering | mousedown appends white rect as last SVG child | Static DOM; requires MutationObserver childList=true on SVG root | Critical |
| SA-CSS-PEN-001 | Pointer-events | Deny group pointer-events="none" + transparent onclick rect at same coords | Visual Deny check; BCR; requires pointer-events attribute check on Deny controls | Critical |
| SA-CSS-PEN-002 | Pointer-events | pointer-events="none" on consent text; non-selectable | Visual text check; requires pointer-events attribute check on text elements | High |
| SA-CSS-DIR-001 | direction:rtl | direction="rtl" + x="0"; text extends leftward off viewport | textContent; fill; BCR (near-zero but technically non-zero); requires RTL direction check | Critical |
| SA-CSS-DIR-002 | direction:rtl | direction="rtl" + unicode-bidi="bidi-override"; text reversed + off-screen | textContent (reversed); BCR; requires: check bidi-override regardless of BCR | Critical |
Related pages: SA-CSS-TXO reference | SA-CSS-TYO reference | SA-CSS-ZIX reference | SA-CSS-PEN reference | SA-CSS-DIR reference