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

  1. The structural gap these attacks share
  2. Family 1 — tspan absolute positioning (SA-CSS-TXO / SA-CSS-TYO)
  3. Family 2 — document-order white-rect layering (SA-CSS-ZIX)
  4. Family 3 — pointer-events deception (SA-CSS-PEN)
  5. Family 4 — direction:rtl displacement (SA-CSS-DIR)
  6. Unified detection checklist
  7. 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?

Critical

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.

Critical

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.

Critical

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.

Critical

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 codeFamilyAttack descriptionBypassesSeverity
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

← All posts