CSS unicode-bidi and the Bidirectional Text Reversal Attack: How MCP Servers Make Permission Text Unreadable
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:
normal(default): the UBA's intrinsic character properties govern text direction.embed: creates a new directional embedding level within the parent context. Characters retain their intrinsic direction but the paragraph base direction is overridden for the embedded block.bidi-override: overrides the UBA's character-level rules entirely. Every character in the element is treated as having the element'sdirectionproperty, regardless of its Unicode bidi category. Combined withdirection:rtl, every character is treated as RTL, reversing the visual order of the entire string.plaintext: determines the base direction using the paragraph-level UBA rules P2 and P3 — scanning the text for the first strong directional character (L, R, or AL category) and using it to set the paragraph's base direction.isolate: treats the element's content as a unit from the parent bidi algorithm's perspective. The parent cannot "see into" the element to resolve bidi.isolate-override: combines isolation with full character-direction override — the visual order of characters within the element is the mirror-reverse of their DOM order.
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:
/* 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">‏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">‮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:
| Dimension | Trojan 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:
- unicodeBidi is
'bidi-override'on any consent/disclosure/permission element — HIGH severity finding. There is no legitimate reason for an MCP server to applybidi-overrideto user-facing permission text. - unicodeBidi is
'isolate-override'on any consent element — MEDIUM severity finding. Can be used to spoof the visual display of permission values while DOM APIs return the correct string. - unicodeBidi is
'embed'combined withdirection:'rtl'on a consent element — MEDIUM severity finding. Creates RTL text islands in LTR consent dialogs; less visually obvious than bidi-override but still exploitable for run-order reversal. - unicodeBidi is
'plaintext'on any disclosure element — MEDIUM severity finding. Combined with a hidden RTL Unicode character in the content, achieves full text reversal without additional CSS; the plaintext value itself triggers a lower-severity finding that is escalated if a bidi control character is found in the element's textContent. - Unicode directional control characters in textContent of consent elements (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069, U+061C) — HIGH severity finding independent of CSS. These can trigger RTL rendering via the
plaintextalgorithm or via the Unicode control character's direct bidi effect.
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:
- CSP
style-srcwith per-request nonce. A Content Security Policy that requires a nonce on all<style>blocks and inlinestyleattributes blocks MCP CSS injection ofunicode-bidioverrides. This is the broadest and most effective defence for all CSS-based consent dialog attacks. - 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).
- Strip
dirattributes from injected HTML. The HTMLdirattribute can achieve directional effects without CSS. Sanitizers should stripdirfrom all MCP-provided HTML elements. - Runtime CSS audit: check
getComputedStyle().unicodeBidi. Before presenting the consent dialog to the user, enumerate all consent and disclosure elements and check thatgetComputedStyle(el).unicodeBidi === 'normal'. Any non-normal value on a consent element is a security finding. - 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 security — writing-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.