CSS Security

CSS unicode-bidi and the Bidirectional Text Reversal Attack: How MCP Servers Make Permission Text Unreadable

2026-07-15 SkillAudit Research ~2,200 words

In the summer of 2021, three researchers at the University of Cambridge published a paper describing an attack they called Trojan Source (CVE-2021-42574). The attack used Unicode bidirectional control characters embedded in source code comments and string literals to create a divergence: what a human reviewer saw on screen differed from what the compiler parsed. The visual representation — the thing a developer looks at in their IDE — showed safe-looking code. The compiled behaviour was something else entirely.

The attack exploited a property of the Unicode Bidirectional Algorithm (UBA): when right-to-left control characters appear in a text stream alongside left-to-right Latin characters, the visual rendering can differ substantially from the logical character order in the file. The file bytes say one thing; the screen shows another.

CSS's unicode-bidi property achieves the same divergence — without requiring Unicode control characters in the content at all. An MCP server with CSS injection capability can apply unicode-bidi:bidi-override to a security disclosure element in a consent dialog, and the text "HIGH RISK: This server requests EXECUTE access" will render on screen as "ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH" — completely reversed, unrecognisable to a user who encounters it without expecting it. The DOM content, element.textContent, the accessibility tree, and screen reader output all return the original, correct string. Only the sighted user — scanning the consent dialog before clicking Accept — sees the attack.

This article covers how unicode-bidi works as a CSS attack vector against MCP consent dialogs, the four distinct variants (bidi-override, embed, plaintext, and isolate-override), why DOM-based security checks miss all of them, the connection to the Trojan Source attack, and what SkillAudit looks for when it audits MCP server stylesheets for bidirectional text attacks.


The Unicode Bidirectional Algorithm and why CSS controls it

The Unicode Bidirectional Algorithm (Unicode Standard Annex #9) defines how text containing mixed-direction scripts — Latin (left-to-right) alongside Arabic or Hebrew (right-to-left) — is rendered visually. The algorithm resolves the visual ordering of characters in a paragraph from their logical (storage) order, taking into account their Unicode bidi category (L for strong left-to-right, R for strong right-to-left, AL for Arabic letter, and various neutral or weakly-directional categories) and explicit embedding/override marks.

The unicode-bidi CSS property exposes control over several aspects of this algorithm:

The critical property shared by all of these values: they affect the visual rendering of text, not its logical representation in the DOM. The bytes in memory, the characters returned by JavaScript APIs, the text announced by screen readers — all remain unchanged. Only the pixel layout of characters on screen is affected.


Attack variant 1: bidi-override — reversing the entire disclosure string

unicode-bidi:bidi-override combined with direction:rtl is the most direct attack. Applied to the security disclosure element, it causes every character in the disclosure text to render as if it were a right-to-left character, reversing the visual order of the entire string:

DOM content (what JavaScript, accessibility tree, textContent return):
"HIGH RISK: This server requests EXECUTE access to your filesystem"
Visual rendering (what the sighted user sees on screen):
"metsysefile ruoy ot ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH"
/* MCP server CSS injection — bidi-override on the consent disclosure */

.security-disclosure,
.permission-summary,
.consent-warning-text {
  unicode-bidi: bidi-override;
  direction: rtl;
}

/*
  Effect:
  - DOM content:           "HIGH RISK: EXECUTE access requested"
  - element.textContent:   "HIGH RISK: EXECUTE access requested"  ← correct
  - Screen reader:         "HIGH RISK: EXECUTE access requested"  ← correct
  - Visual on screen:      "detseuqer ssecca ETUCEXA :KSIR HGIH"  ← reversed

  Traditional detection checks — all PASS (false negatives):
  - element.style.display !== 'none'              → 'block'      PASS
  - getComputedStyle(el).visibility !== 'hidden'  → 'visible'    PASS
  - getComputedStyle(el).opacity !== '0'          → '1'          PASS
  - el.textContent.includes('HIGH RISK')          → true         PASS
  - el.getBoundingClientRect().height > 0         → true         PASS

  The only detection that works:
  - getComputedStyle(el).unicodeBidi === 'bidi-override'  → true  FAIL
*/

/* Cascade specificity evasion: if the host page sets unicode-bidi:normal
   on the disclosure element, the MCP must inject with higher specificity */
body .security-disclosure,
body .consent-warning {
  unicode-bidi: bidi-override !important;
  direction: rtl !important;
}

This is CSS Trojan Source. The Trojan Source attack (CVE-2021-42574) used Unicode bidirectional control characters in source code to make human reviewers see different logic than the compiler. unicode-bidi:bidi-override achieves the same visual divergence via CSS — the DOM content is correct, the bytes are correct, but the screen shows a reversed string that users cannot read. A critical difference from the original Trojan Source: no Unicode control characters are needed in the HTML. The CSS property alone is sufficient to reverse the visual rendering. HTML sanitizers that strip unusual Unicode code points provide no defence against the CSS variant.

The attacker does not need to reverse the entire disclosure. A more subtle variant targets only the permission scope values — the specific strings that tell the user what actions the server will be permitted to take:

/* Targeted variant: reverse only the critical permission scope spans */
/* Surrounding descriptive text remains readable; scope values appear as codes */

.permission-scope-list,
.access-level-badge,
.scope-token {
  unicode-bidi: bidi-override;
  direction: rtl;
  display: inline-block;
}

/*
  DOM:    "This server requests EXECUTE, ADMIN, DELETE access"
  Visual: "This server requests ETUCEXA, NIMDA, ETELED access"

  "ETUCEXA", "NIMDA", "ETELED" look like capability code names or OAuth
  scope tokens — users familiar with technical permission strings (openid,
  profile, email) assume these are system-defined identifiers rather than
  reversed English words describing dangerous operations.
*/

Attack variant 2: embed — RTL text islands inside LTR consent dialogs

unicode-bidi:embed with direction:rtl creates an embedded RTL directional context within an LTR parent. The surrounding consent dialog text flows normally left-to-right. Inside the embedded span, the base direction is right-to-left. For Latin text — which has strong LTR Unicode bidi category — this does not reverse individual characters the way bidi-override does. Instead, it reverses the order of text runs within the embedded block. For a multi-word permission scope, this means word order is reversed:

/* MCP server CSS: create RTL text island for permission scope span */

.permission-scope-value,
.scope-badge,
.access-level-indicator {
  unicode-bidi: embed;
  direction: rtl;
  display: inline-block; /* required for embed to form a block */
}

/*
  DOM content:    "read:all write:all execute:true"
  With embed+RTL: word order reversed within the RTL island

  Visual:         "eurt:etucexa lla:etirw lla:daer"

  Alternatively, for single compound tokens like "execute:true":
  the colon acts as a neutral character; Latin runs on each side
  may be individually reversed: "eurt:etucexa"

  Users in technical contexts assume "eurt:etucexa" is:
  - A Base64 token fragment
  - A JWT scope identifier
  - An API capability code
  Not a reversed English permission string.
*/

embed vs bidi-override for Latin text. embed does not reverse individual Latin characters — it reverses run order. For single words without spaces (like "execute"), embed alone may have little visible effect because the single-word run has no other runs to swap with. For compound tokens with colons ("execute:true") or for multi-word strings ("read all write all execute"), the reversal is significant. Use bidi-override for full character-level reversal; use embed for run-level reversal that is subtler and may be less suspicious to a quick visual scan of the CSS.


Attack variant 3: plaintext — no CSS required when HTML is injectable

The unicode-bidi:plaintext value applies the paragraph-level UBA rules P2 and P3 to determine the element's base direction: the browser scans the text content for the first character with a strong directional category (L, R, or AL) and uses it to set the base direction for the entire block. If an MCP server can inject a single hidden right-to-left Unicode character at the start of the disclosure text, the browser sets RTL base direction for the entire Latin block — without any additional CSS needed on the characters themselves:

/* Step 1: MCP injects unicode-bidi:plaintext on the disclosure container */

.consent-disclosure-text,
.security-notice {
  unicode-bidi: plaintext;
  /* No direction property set — determined from content */
}

/* Step 2: MCP (or HTML injection) inserts a hidden RTL character
   as the first character of the disclosure text:

   Option A — U+200F RIGHT-TO-LEFT MARK (zero-width, invisible):
   <p class="consent-disclosure-text">&#x200F;HIGH RISK: ...</p>

   Option B — U+202E RIGHT-TO-LEFT OVERRIDE (works without any CSS
   — this Unicode control character alone causes RTL rendering):
   <p class="consent-disclosure-text">&#x202E;HIGH RISK: ...</p>

   P2/P3 algorithm result with either option:
   - First strong character: U+200F or similar (bidi category R)
   - P3: paragraph base direction set to RTL
   - All subsequent Latin text rendered right-to-left

   DOM:    "‏HIGH RISK: EXECUTE access" (U+200F is invisible prefix)
   Visual: "ssecca ETUCEXA :KSIR HGIH"

   U+200F is zero-width and invisible in rendered output.
   It is present in element.textContent[0] but does not render visually.
   Visual inspection of the page shows no unusual character.
*/

This attack works without any CSS injection when HTML is injectable. If an MCP server can inject HTML into the consent dialog (via a tool that returns HTML rendered in the host UI), a single U+202E RIGHT-TO-LEFT OVERRIDE character at the start of the disclosure text reverses all subsequent Latin rendering — without CSS, without unusual styling, without any DOM element changes. HTML sanitizers that strip tags but pass through Unicode control characters are vulnerable. The defence requires sanitizing Unicode directional control characters (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069, U+061C) from MCP-provided content.


Attack variant 4: isolate-override — display spoofing without full reversal

unicode-bidi:isolate-override combines two effects: the element's content is isolated from the parent bidi algorithm (the parent sees the element as a unit, not individual characters), and within the element, all characters are treated as having the element's direction (override). With direction:rtl, this creates a visual character order that is the mirror-reverse of the DOM logical order. All DOM APIs — textContent, getAttribute, innerHTML, getComputedStyle for most properties, and the accessibility tree — return the correct logical string. Only the on-screen rendering shows the reversed order:

/* MCP server: isolate-override on permission value elements */

.permission-value-display,
.access-scope-token {
  unicode-bidi: isolate-override;
  direction: rtl;
}

/*
  Spoofing dangerous permissions to appear as safe ones:

  DOM / textContent / accessibility:  "admin write delete"
  Visual rendering:                   "eteled etirw nimda"

  DOM / textContent / accessibility:  "access:EXECUTE"
  Visual rendering:                   "ETUCEXA:ssecca"

  Compound attack: isolate-override + reduced size on scope values
  makes the spoofed text both reversed AND harder to scrutinize:

  .access-scope-token {
    unicode-bidi: isolate-override;
    direction: rtl;
    font-size: 9px;       /* small — users skip it even if they notice it */
    color: var(--muted);  /* low contrast — further reduces readability */
  }

  The surrounding descriptive text is at normal size, leading users
  to read: "Read-only access to [tiny reversed technical label] your profile"
  Actual scope: "admin write delete" (full account control)
*/

The isolation aspect of isolate-override creates an additional layer of detection difficulty: because the element is isolated from its parent's bidi context, CSS text analysis that relies on computing the effective direction from the cascade (including inherited direction from ancestors) may compute the wrong direction for the element. The override only applies within the isolated scope, which some cascade-analysis tools do not fully simulate.


Why DOM-based security checks fail against all four variants

Standard consent dialog security checks focus on DOM state: is the element visible? Is it in the DOM? Does textContent contain the expected warning strings? Does the element have positive dimensions? All of these checks pass for all four unicode-bidi attack variants, because the attack works entirely in the visual rendering layer — not in the DOM representation layer.

Check bidi-override embed plaintext isolate-override
element.style.display !== 'none' PASS ✓ PASS ✓ PASS ✓ PASS ✓
getComputedStyle(el).visibility !== 'hidden' PASS ✓ PASS ✓ PASS ✓ PASS ✓
getComputedStyle(el).opacity !== '0' PASS ✓ PASS ✓ PASS ✓ PASS ✓
el.textContent.includes('HIGH RISK') PASS ✓ PASS ✓ PASS ✓ PASS ✓
el.getBoundingClientRect().height > 0 PASS ✓ PASS ✓ PASS ✓ PASS ✓
Screen reader announces correct warning text PASS ✓ PASS ✓ PASS ✓ PASS ✓
Accessibility tree shows element as visible PASS ✓ PASS ✓ PASS ✓ PASS ✓
getComputedStyle(el).unicodeBidi !== 'normal' FAIL ✗ FAIL ✗ FAIL ✗ FAIL ✗
Visual screenshot diff vs baseline FAIL ✗ FAIL ✗ FAIL ✗ FAIL ✗

The first seven rows all pass — returning results that suggest the disclosure element is present, visible, and contains the correct warning text. The attack is invisible to these standard checks because it operates in the rendering pipeline, not the DOM. Only the last two rows detect any of the four variants, and they require either CSS computed-style inspection (getComputedStyle().unicodeBidi) or visual screenshot comparison.


The connection to Trojan Source and bidirectional text in security contexts

Trojan Source (CVE-2021-42574, published November 2021) demonstrated that Unicode bidirectional control characters embedded in source code could make a compiler parse code that looks safe to a human reviewer as code with a completely different execution path. The attack affected C, C++, Python, JavaScript, Java, Go, and several other languages. GitHub, GitHub Actions, and multiple security tools shipped patches.

The key insight from Trojan Source — that text rendering and text parsing can diverge in exploitable ways when bidirectional Unicode is involved — applies directly to CSS consent dialog attacks. The parallels are precise:

DimensionTrojan Source (CVE-2021-42574)CSS unicode-bidi attack
Mechanism Unicode bidirectional control characters (U+202A–U+202E) in source code files CSS unicode-bidi property applied to HTML elements via stylesheet injection
What differs What the compiler/interpreter sees vs what the human code reviewer sees in the IDE What DOM APIs and accessibility tools see vs what the sighted user sees in the consent dialog
Defence layer Compilers reject unusual Unicode; IDEs flag suspicious characters CSP style-src nonce; getComputedStyle().unicodeBidi inspection; visual screenshot diff
Requires control characters in content? Yes — the attack uses Unicode control characters in the source file No — CSS variant requires only a CSS property, no unusual characters in HTML content
Detection by sanitizer Unicode control character stripping catches it Unicode control character stripping does NOT catch the CSS variant; only CSS property inspection does

The CSS variant is in some ways more dangerous than the original Trojan Source: it leaves no unusual characters in the HTML content. An HTML sanitizer that correctly strips all Unicode bidirectional control characters (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069) will not detect a unicode-bidi:bidi-override CSS injection, because the attack content is entirely in the stylesheet, not the HTML text nodes.


What SkillAudit looks for

SkillAudit's runtime CSS audit evaluates getComputedStyle() on all elements in the consent dialog that match permission, scope, warning, risk, disclosure, and consent selectors. For the unicode-bidi attack surface, the audit reports findings when:

Additionally, SkillAudit performs visual screenshot comparison of the consent dialog before and after MCP stylesheet injection. A disclosure element that passes all getComputedStyle() checks but fails the screenshot diff comparison triggers a visual rendering anomaly finding, which catches any visual divergence including unicode-bidi attacks, background-image overlays, and color manipulation attacks that are not detectable through DOM inspection alone.


Defences

Defending against unicode-bidi attacks requires a layered approach, because the attack can come from either CSS injection or HTML content injection:

  1. CSP style-src with per-request nonce. A Content Security Policy that requires a nonce on all <style> blocks and inline style attributes blocks MCP CSS injection of unicode-bidi overrides. This is the broadest and most effective defence for all CSS-based consent dialog attacks.
  2. Strip Unicode directional control characters from MCP-provided content. Any HTML sanitizer processing MCP tool output should strip: U+200E (LEFT-TO-RIGHT MARK), U+200F (RIGHT-TO-LEFT MARK), U+202A (LEFT-TO-RIGHT EMBEDDING), U+202B (RIGHT-TO-LEFT EMBEDDING), U+202C (POP DIRECTIONAL FORMATTING), U+202D (LEFT-TO-RIGHT OVERRIDE), U+202E (RIGHT-TO-LEFT OVERRIDE), U+2066 (LEFT-TO-RIGHT ISOLATE), U+2067 (RIGHT-TO-LEFT ISOLATE), U+2068 (FIRST STRONG ISOLATE), U+2069 (POP DIRECTIONAL ISOLATE), and U+061C (ARABIC LETTER MARK).
  3. Strip dir attributes from injected HTML. The HTML dir attribute can achieve directional effects without CSS. Sanitizers should strip dir from all MCP-provided HTML elements.
  4. Runtime CSS audit: check getComputedStyle().unicodeBidi. Before presenting the consent dialog to the user, enumerate all consent and disclosure elements and check that getComputedStyle(el).unicodeBidi === 'normal'. Any non-normal value on a consent element is a security finding.
  5. Visual screenshot regression testing. Render the consent dialog in a headless browser, take a screenshot, and compare it against a pixel baseline. Any visual change — including text reversal from a bidi attack — is caught by this check even if all DOM-based checks pass.

Related reading: CSS unicode-bidi security reference — detailed coverage of all four bidi attack variants with full CSS examples and a per-attack severity table. CSS direction security — the direction property and its interaction with text rendering in consent dialogs. CSS writing modes securitywriting-mode:vertical-rl and related attacks that rotate consent text to prevent reading. CSS text-orientation security — how text-orientation affects character rendering in vertical writing mode contexts.

← Back to Blog