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
- The shared detection gap: SVG presentation attributes and the CSS cascade
- Family 1:
unicode-bidistring reversal attacks (SA-CSS-UBD-001–004) - Family 2:
writing-moderendering disruption attacks (SA-CSS-WRM-001–004) - Family 3:
glyph-orientationper-glyph rotation attacks (SA-CSS-GLO-001–004) - Unified detection algorithm
- Detection coverage table
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.unicode-bidi="embed" on SVG consent text in RTL ancestor context — word-order reversal; ancestor direction chain must be traced.unicode-bidi="isolate-override" on select tspans — per-word inline reversal disrupts sentence comprehension; per-tspan inspection required.unicode-bidi and direction simultaneously required.writing-mode="tb" renders consent text as unreadable vertical column; textContent intact; both getAttribute and getComputedStyle required.writing-mode="rl" reverses word order visually; dangerous permission terms appear at end of visual scan; both getAttribute and getComputedStyle required.glyph-orientation-vertical="90" in writing-mode="tb" — compound sideways column; getComputedStyle returns empty string; getAttribute only reliable method.glyph-orientation-horizontal="90" in horizontal text — each glyph rotated 90° sideways; getComputedStyle returns empty string; getAttribute only reliable method.