Security Research — 2026-10-17

SVG Bidirectional and Typography Attack Families on MCP Consent: unicode-bidi, writing-mode, and glyph-orientation

SVG exposes three orthogonal text-direction and glyph-orientation control systems that MCP consent auditors almost universally miss. The unicode-bidi presentation attribute overrides the Unicode Bidirectional Algorithm to reverse entire consent strings character by character while leaving textContent intact. The writing-mode presentation attribute renders consent text as an unreadable vertical column or in reversed word order, independent of CSS cascade. The deprecated glyph-orientation-vertical and glyph-orientation-horizontal attributes rotate each glyph individually in ways never exposed by getComputedStyle. All three families share one root detection gap — SVG presentation attributes operating outside the CSS stylesheet. This article covers twelve finding codes across SA-CSS-UBD, SA-CSS-WRM, and SA-CSS-GLO and provides a unified detection algorithm.

Contents

  1. The shared detection gap: SVG presentation attributes and the CSS cascade
  2. Family 1: unicode-bidi string reversal attacks (SA-CSS-UBD-001–004)
  3. Family 2: writing-mode rendering disruption attacks (SA-CSS-WRM-001–004)
  4. Family 3: glyph-orientation per-glyph rotation attacks (SA-CSS-GLO-001–004)
  5. Unified detection algorithm
  6. Detection coverage table
  7. SkillAudit finding codes

The shared detection gap: SVG presentation attributes and the CSS cascade

When an MCP server renders a consent panel as an SVG element, it has access to two entirely separate styling mechanisms. The first is the CSS cascade — properties set via <style> elements, inline style="" attributes, or external stylesheets. Most audit tools cover this path via getComputedStyle(element), which aggregates the cascaded result of all CSS rules that apply to the element. The second mechanism is SVG presentation attributes — attributes set directly on SVG elements like unicode-bidi="bidi-override", writing-mode="tb", or glyph-orientation-horizontal="90".

The CSS specification assigns SVG presentation attributes a specificity equivalent to a zero-specificity author stylesheet — they participate in the cascade but rank below any explicit CSS rule. However, this participation only matters when the property has a corresponding CSS property. For glyph-orientation-horizontal and glyph-orientation-vertical, there is no CSS equivalent at all: these are SVG 1.1 presentation attributes that were deprecated in SVG 2 and were never added to the CSS specification. getComputedStyle returns empty strings for both. An audit that calls getComputedStyle(textEl).glyphOrientationHorizontal receives undefined and incorrectly concludes the attribute is absent.

For unicode-bidi and writing-mode, the situation is more subtle. These do have corresponding CSS properties, and getComputedStyle does return a value. But the value returned is only trustworthy if the audit inspects both the presentation attribute form and the computed style — in the right order. When an attacker sets the attribute and then removes it at mouseup, a static snapshot taken at rest finds no attribute and a computed style of normal. The attack is invisible to a static scan and requires a MutationObserver to catch the transient injection.

This shared detection gap is the organizing principle behind all three families. Each family exploits the same pattern at a different layer of SVG’s typography system.


Family 1: unicode-bidi string reversal attacks (SA-CSS-UBD-001–004)

The Unicode Bidirectional Algorithm (UBA) governs how browsers order characters visually when a text string contains both left-to-right and right-to-left characters. For a consent string composed entirely of Latin characters, the UBA places every character left-to-right. The SVG unicode-bidi presentation attribute lets an author override this algorithm at the element or <tspan> level. See the full SA-CSS-UBD reference page for SVG code examples and JavaScript detection snippets for each pattern.

Critical

SA-CSS-UBD-001: bidi-override + direction="rtl"

unicode-bidi="bidi-override" disables the UBA entirely. With direction="rtl", every character in the consent string is placed right-to-left — a character-by-character mirror. “Allow: shell execution, credential access” renders as “ssecca laitnederc ,noitucexe llehs :wolA”. textContent returns the original unreversed string; keyword extraction passes.

High

SA-CSS-UBD-002: embed in RTL ancestor context

unicode-bidi="embed" on the consent text inside a dir="rtl" HTML ancestor causes the UBA to reverse word order while keeping individual words LTR-readable. “shell execution credential access” appears as “access credential execution shell”. Context-dependent rendering — ancestor direction chain must be traced.

High

SA-CSS-UBD-003: isolate-override on select <tspan>

unicode-bidi="isolate-override" with direction="rtl" on specific tspans reverses only the permission-scope words inline. “Allow:” renders normally; “shell execution” appears character-reversed inline; “credential access” appears character-reversed. Outer text element check is insufficient — per-tspan inspection required.

Critical

SA-CSS-UBD-004: mousedown simultaneous two-attribute inject

JS simultaneously injects both unicode-bidi="bidi-override" and direction="rtl" at mousedown; removes both at mouseup. DOM clean at rest. Setting only one attribute without the other does not produce full reversal — both must be watched simultaneously via MutationObserver.

The key diagnostic property of SA-CSS-UBD attacks is that textContent always returns the original unreversed consent string. Any scanner that checks textContent for permission keywords will pass a UBD-001 consent panel that renders as gibberish to the user. Detection requires checking getAttribute('unicode-bidi') (SVG attribute form) in addition to getComputedStyle(el).unicodeBidi (CSS cascade form). These may differ when an attacker removes the attribute after setting it dynamically.


Family 2: writing-mode rendering disruption attacks (SA-CSS-WRM-001–004)

The SVG writing-mode presentation attribute controls the direction in which text is laid out: horizontally left-to-right (lr), horizontally right-to-left (rl), or vertically top-to-bottom (tb). When applied to consent text in an SVG panel, any non-lr writing mode destroys sentence readability without altering textContent or bounding client rectangles. See the full SA-CSS-WRM reference page for complete SVG code examples and detection snippets.

Critical

SA-CSS-WRM-001: writing-mode="tb" vertical column

writing-mode="tb" renders each Latin character as a stacked row, rotated 90 degrees, in a vertical column. A consent string of 40+ characters produces a narrow sideways stripe of individual glyphs. The textContent string “shell execution credential access” is present but completely unreadable as a sentence.

Critical

SA-CSS-WRM-002: writing-mode="rl" word-order reversal

writing-mode="rl" renders words right-to-left: “shell execution credential access” appears as “access credential execution shell” visually. Individual words are LTR-readable; the sentence structure is reversed. Dangerous permission terms appear at the right end of the visual scan path, counter to Western reading order.

High

SA-CSS-WRM-003: mixed writing-mode per-tspan

Header tspan renders with writing-mode="lr" (legible); permission-scope tspan renders with writing-mode="tb" (vertical column). Root-level writing-mode check on the parent <text> element finds lr (normal); the attack lives in child tspan attributes that override the parent value.

Critical

SA-CSS-WRM-004: mousedown inject writing-mode="tb"

JS injects writing-mode="tb" at mousedown; reverts at mouseup. SVG presentation attribute form — not in any CSS stylesheet. Static analysis finds writing-mode="lr" at rest. MutationObserver on writing-mode attribute of all consent text and tspan elements required.

The writing-mode family differs from the unicode-bidi family in one critical respect: the visual effect is immediately obvious to any human reading the consent panel — vertical columns of rotated Latin characters are unmistakably unreadable. The attack’s value lies not in camouflage but in split-second temporal injection: the consent text renders normally at rest and during the brief period the user is reading, then switches to unreadable during the sub-100 ms click window. A user who reads “Allow: shell execution, credential access” from the static display and then clicks without re-reading at the moment of click-down has consented to something they never saw displayed during the click itself.

An important distinction for detection: writing-mode does appear in getComputedStyle when set via CSS. When set as an SVG presentation attribute on a text element, the computed style also reflects it — but only if the attribute is present at the moment of the getComputedStyle call. Static scans that read computed styles at page load, without simulating interaction, will miss the WRM-004 mousedown injection entirely.


Family 3: glyph-orientation per-glyph rotation attacks (SA-CSS-GLO-001–004)

The glyph-orientation-vertical and glyph-orientation-horizontal attributes are SVG 1.1 presentation attributes that were deprecated in SVG 2 and were never incorporated into the CSS specification. They control the rotation of individual glyphs relative to the text’s baseline direction. glyph-orientation-horizontal="90" rotates each glyph 90 degrees clockwise on a horizontal baseline; glyph-orientation-vertical="90" rotates each glyph 90 degrees in the context of vertical (writing-mode="tb") text. See the full SA-CSS-GLO reference page for SVG code examples and JavaScript detection snippets for each pattern.

Critical

SA-CSS-GLO-001: glyph-orientation-vertical="90" in writing-mode="tb"

In writing-mode="tb" context, glyph-orientation-vertical="90" rotates each glyph 90 degrees relative to the vertical baseline. The compound effect: characters are placed in a vertical column (writing-mode) AND each character is rotated sideways (glyph-orientation). The result is a narrow stripe of sideways individual characters — no sentence structure is discernible.

Critical

SA-CSS-GLO-002: glyph-orientation-horizontal="90" in horizontal text

glyph-orientation-horizontal="90" on normal horizontal text rotates each glyph 90 degrees clockwise on the horizontal baseline. The text element spans the expected width; individual characters are sideways vertical strokes. Each character is rotated independently, so there is no uniform orientation — every glyph occupies its normal advance-width slot but appears as a vertical stroke.

High

SA-CSS-GLO-003: mixed per-tspan orientation

Header tspan has glyph-orientation-horizontal="0" (normal); permission-scope tspan has glyph-orientation-horizontal="90" (rotated). Aggregate check on the parent element finds normal orientation; attack lives in the scope tspan. Per-tspan attribute inspection required.

Critical

SA-CSS-GLO-004: mousedown inject glyph-orientation-horizontal="90"

JS injects glyph-orientation-horizontal="90" at mousedown; removes at mouseup. Deprecated SVG 1.1 attribute — getComputedStyle returns empty string in most browsers regardless of attribute value. Static scan and computed style check both miss this. MutationObserver on glyph-orientation-horizontal and glyph-orientation-vertical attributes required.

The glyph-orientation family represents the most detection-resistant of the three families precisely because of the deprecated status of its attributes. Audit tooling that is written against the CSS specification will never reference glyphOrientationHorizontal as a property to check — it has no CSS equivalent. Tooling written against the SVG specification may reference it but assume it is handled by getComputedStyle — a false assumption in all major browsers as of 2026. The only reliable detection path is a direct getAttribute call on the SVG element, combined with a MutationObserver for the transient injection variant.


Unified detection algorithm

Because all three families exploit the same SVG-presentation-attribute gap, they share a unified detection approach. The algorithm has two phases: static inspection at load time, and dynamic observation during simulated interaction.

// Phase 1: static attribute scan (run at consent panel load)
function scanTextDirectionAttribs(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {

    // SA-CSS-UBD: unicode-bidi + direction
    const ub = el.getAttribute('unicode-bidi') || getComputedStyle(el).unicodeBidi || '';
    if (/bidi-override|isolate-override/i.test(ub)) {
      const dir = el.getAttribute('direction') || getComputedStyle(el).direction || '';
      findings.push({ code: ub.includes('bidi-override') ? 'SA-CSS-UBD-001' : 'SA-CSS-UBD-003',
        severity: 'CRITICAL', el: el.tagName,
        note: `unicode-bidi="${ub}" direction="${dir}"; string reversal or inline reversal attack` });
    }
    const embedUB = el.getAttribute('unicode-bidi') || '';
    if (/^embed$/i.test(embedUB.trim())) {
      findings.push({ code: 'SA-CSS-UBD-002', severity: 'HIGH', el: el.tagName,
        note: `unicode-bidi="embed"; check for RTL ancestor context causing word-order reversal` });
    }

    // SA-CSS-WRM: writing-mode
    const wm = el.getAttribute('writing-mode') || getComputedStyle(el).writingMode || '';
    if (wm && !/^lr$/i.test(wm.trim())) {
      findings.push({ code: 'SA-CSS-WRM-001', severity: 'CRITICAL', el: el.tagName,
        note: `writing-mode="${wm}"; non-LR writing mode renders consent text as vertical column or reversed words` });
    }

    // SA-CSS-GLO: glyph-orientation (deprecated SVG 1.1 — getComputedStyle unreliable)
    const goh = el.getAttribute('glyph-orientation-horizontal') || '';
    const gov = el.getAttribute('glyph-orientation-vertical') || '';
    const gohAngle = parseFloat(goh);
    const govAngle = parseFloat(gov);
    if (!isNaN(gohAngle) && gohAngle % 360 !== 0) {
      findings.push({ code: 'SA-CSS-GLO-002', severity: 'CRITICAL', el: el.tagName,
        note: `glyph-orientation-horizontal="${goh}"; each glyph rotated ${gohAngle}° on horizontal baseline` });
    }
    if (!isNaN(govAngle) && govAngle % 360 !== 0) {
      findings.push({ code: 'SA-CSS-GLO-001', severity: 'CRITICAL', el: el.tagName,
        note: `glyph-orientation-vertical="${gov}"; each glyph rotated ${govAngle}° in vertical text context` });
    }
  }
  return findings;
}

// Phase 2: dynamic MutationObserver (run during simulated click interaction)
function watchTextDirectionInject(svgEl, onFinding) {
  const WATCHED = [
    'unicode-bidi', 'direction',
    'writing-mode',
    'glyph-orientation-horizontal', 'glyph-orientation-vertical'
  ];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el  = mut.target;
      const ub  = el.getAttribute('unicode-bidi') || '';
      const dir = el.getAttribute('direction') || '';
      const wm  = el.getAttribute('writing-mode') || '';
      const goh = el.getAttribute('glyph-orientation-horizontal') || '';
      const gov = el.getAttribute('glyph-orientation-vertical') || '';

      if (/bidi-override/i.test(ub) && /rtl/i.test(dir))
        onFinding({ code:'SA-CSS-UBD-004', severity:'CRITICAL', el:el.tagName,
          note:`Dynamic bidi-override+rtl on <${el.tagName}>; full string reversal during click window` });
      if (wm && !/^lr$/i.test(wm.trim()))
        onFinding({ code:'SA-CSS-WRM-004', severity:'CRITICAL', el:el.tagName,
          note:`Dynamic writing-mode="${wm}" on <${el.tagName}>; vertical column during click window` });
      const gohN = parseFloat(goh);
      if (!isNaN(gohN) && gohN % 360 !== 0)
        onFinding({ code:'SA-CSS-GLO-004', severity:'CRITICAL', el:el.tagName,
          note:`Dynamic glyph-orientation-horizontal="${goh}" on <${el.tagName}>; per-glyph rotation during click` });
      const govN = parseFloat(gov);
      if (!isNaN(govN) && govN % 360 !== 0)
        onFinding({ code:'SA-CSS-GLO-004', severity:'CRITICAL', el:el.tagName,
          note:`Dynamic glyph-orientation-vertical="${gov}" on <${el.tagName}>; per-glyph rotation in vertical context` });
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: WATCHED
  });
  return observer; // caller must disconnect after interaction simulation completes
}

Implementation note: The two phases must both run. Phase 1 catches static attributes present at load time. Phase 2 catches transient injections (UBD-004, WRM-004, GLO-004). Run Phase 2 by programmatically dispatching a mousedown event at the center of the Allow button bounding rect, waiting one frame, then a mouseup event. The MutationObserver will fire synchronously during the attribute change. Disconnect the observer after the simulation completes to avoid false positives from unrelated DOM mutations.


Detection coverage table

Finding code Attack Bypasses getAttribute getComputedStyle MutationObserver Severity
SA-CSS-UBD-001 bidi-override+rtl full string reversal textContent keyword scan; BCR ✓ required ✓ also check — CRITICAL
SA-CSS-UBD-002 embed in RTL context word-order reversal Element-only check; ancestor context ✓ required ✓ also check — HIGH
SA-CSS-UBD-003 isolate-override per-tspan inline reversal Parent text element check; outer scan ✓ required (per-tspan) ✓ also check — HIGH
SA-CSS-UBD-004 Mousedown simultaneous bidi-override+rtl inject Static scan; CSS stylesheet scan — (attrs absent at rest) — ✓ required (both attrs) CRITICAL
SA-CSS-WRM-001 writing-mode="tb" vertical column textContent; BCR; display/visibility ✓ required ✓ also check — CRITICAL
SA-CSS-WRM-002 writing-mode="rl" word-order reversal textContent; BCR ✓ required ✓ also check — CRITICAL
SA-CSS-WRM-003 Mixed writing-mode per-tspan Parent text element check ✓ required (per-tspan) ✓ also check — HIGH
SA-CSS-WRM-004 Mousedown inject writing-mode="tb" Static scan; CSS stylesheet scan — — ✓ required CRITICAL
SA-CSS-GLO-001 glyph-orientation-vertical="90" in writing-mode="tb" textContent; BCR; CSS getComputedStyle ✓ only reliable method ✗ returns empty string — CRITICAL
SA-CSS-GLO-002 glyph-orientation-horizontal="90" in horizontal text textContent; BCR; CSS getComputedStyle ✓ only reliable method ✗ returns empty string — CRITICAL
SA-CSS-GLO-003 Mixed per-tspan glyph-orientation Parent element check; getComputedStyle ✓ only reliable method (per-tspan) ✗ returns empty string — HIGH
SA-CSS-GLO-004 Mousedown inject glyph-orientation-horizontal="90" Static scan; CSS getComputedStyle; stylesheet scan — ✗ unreliable even when present ✓ required CRITICAL

The detection table reveals a critical distinction between the three families. For SA-CSS-UBD and SA-CSS-WRM, getComputedStyle can be used as a secondary check but getAttribute is still required for the SVG attribute form. For SA-CSS-GLO, getComputedStyle returns empty string in all major browsers — getAttribute is the only reliable detection method for static variants, and MutationObserver is the only reliable detection method for dynamic injection. Tools that rely exclusively on getComputedStyle will have zero coverage for the entire glyph-orientation family.


SkillAudit finding codes

CRITICAL SA-CSS-UBD-001: unicode-bidi="bidi-override" with direction="rtl" on SVG consent text — entire string reversed character-by-character; textContent returns original LTR string; DOM keyword scan passes; both getAttribute and getComputedStyle must be checked.
HIGH SA-CSS-UBD-002: unicode-bidi="embed" on SVG consent text in RTL ancestor context — word-order reversal; ancestor direction chain must be traced.
HIGH SA-CSS-UBD-003: unicode-bidi="isolate-override" on select tspans — per-word inline reversal disrupts sentence comprehension; per-tspan inspection required.
CRITICAL SA-CSS-UBD-004: JS mousedown simultaneously injects bidi-override+rtl; both reverted at mouseup; MutationObserver on both unicode-bidi and direction simultaneously required.
CRITICAL SA-CSS-WRM-001: writing-mode="tb" renders consent text as unreadable vertical column; textContent intact; both getAttribute and getComputedStyle required.
CRITICAL SA-CSS-WRM-002: writing-mode="rl" reverses word order visually; dangerous permission terms appear at end of visual scan; both getAttribute and getComputedStyle required.
HIGH SA-CSS-WRM-003: mixed writing-mode per-tspan — header LR (legible), scope TB (unreadable); per-tspan inspection required; parent element check insufficient.
CRITICAL SA-CSS-WRM-004: JS mousedown injects writing-mode="tb"; SVG attribute form not in CSS; MutationObserver on writing-mode required.
CRITICAL SA-CSS-GLO-001: glyph-orientation-vertical="90" in writing-mode="tb" — compound sideways column; getComputedStyle returns empty string; getAttribute only reliable method.
CRITICAL SA-CSS-GLO-002: glyph-orientation-horizontal="90" in horizontal text — each glyph rotated 90° sideways; getComputedStyle returns empty string; getAttribute only reliable method.
HIGH SA-CSS-GLO-003: mixed glyph-orientation per-tspan — header normal, scope rotated 90°; per-tspan getAttribute inspection required.
CRITICAL SA-CSS-GLO-004: JS mousedown injects glyph-orientation-horizontal="90"; deprecated SVG 1.1 attr never exposed by getComputedStyle; MutationObserver on glyph-orientation-horizontal and glyph-orientation-vertical required.

Related: SVG unicode-bidi consent attacks (SA-CSS-UBD)  |  SVG writing-mode consent attacks (SA-CSS-WRM)  |  SVG glyph-orientation consent attacks (SA-CSS-GLO)  |  SVG font-variant consent attacks (SA-CSS-FVR)  |  SVG transform and motion attack families

← Blog  |  Security Checklist