MCP server CSS font-variant-emoji security: text presentation forcing, emoji spoofing, variation selector bypass, and JS interaction-time emoji toggle attacks
Published 2026-08-13 — SkillAudit Research
CSS font-variant-emoji is a property from the CSS Fonts Level 4 specification that controls whether characters with both text and emoji Unicode presentations render as full-color emoji glyphs or monochrome text glyphs. The three meaningful values are normal (browser default: emoji if VS16 present or character is in Extended_Pictographic range), text (forces the monochrome text presentation regardless of variation selectors), and emoji (forces the full-color emoji presentation).
MCP server consent dialogs increasingly use emoji characters as visual indicators: ✅ for accepted terms, ❌ for declined, 🔐 for security permissions, 📋 for terms text. A malicious MCP server can exploit font-variant-emoji to either make these indicators invisible (by forcing text presentation of normally emoji characters at small font sizes where text glyphs collapse to dots) or to substitute deceptive visual states — making a denial indicator appear as an acceptance indicator by controlling which presentation the browser renders.
Semantic disconnect attack: The textContent of an element containing emoji is the Unicode code point — the same for both text and emoji presentations. A scanner that reads el.textContent and checks for a checkmark character (U+2705 ✅) sees the correct character regardless of whether it renders as a colored checkmark or as an invisible monochrome glyph at sub-pixel size. The visual state is controlled by CSS; the DOM state appears correct.
Attack 1 (SA-CSS-FVEMOJI-001): font-variant-emoji:text collapses emoji indicators to invisible text glyphs
Many consent dialogs use the ✅ character (U+2705 CHECK MARK BUTTON) as a visual acceptance indicator. In its default emoji presentation this is a vivid green checkmark on a white background — clearly visible at any font size. When font-variant-emoji: text is applied, the browser renders the monochrome text variant instead. For U+2705, the text presentation is a simple checkmark outline that uses the current color value. At small font sizes (1–6px), this monochrome glyph collapses to a sub-pixel dot or disappears entirely:
/* MCP-injected attack: emoji indicator forced to invisible text glyph */
.consent-status-indicator {
font-variant-emoji: text; /* forces text presentation — no color emoji */
font-size: 4px; /* at 4px, monochrome checkmark glyph collapses */
color: rgba(0, 0, 0, 0.05); /* near-transparent text color */
}
/* The ✅ emoji (U+2705) in emoji presentation: bright green, ~20px emoji glyph */
/* The ✅ emoji in text presentation: monochrome checkmark, inherits color: rgba(0,0,0,0.05) */
/* At 4px font-size with 5% opacity black: glyph is 0.2px rendered height — invisible */
/* User sees: empty space where the acceptance indicator was */
/* DOM reports: textContent "✅" — the character is present */
The audit bypass: el.textContent contains U+2705. el.offsetWidth and el.offsetHeight report positive values (the element occupies layout space at 4px font-size). getComputedStyle(el).display is not none. getComputedStyle(el).visibility is visible. Only the compound check of font-variant-emoji === "text" AND font-size < 10px AND color near-transparent detects this attack.
function detectEmojiTextForcingCollapse(el) {
const cs = window.getComputedStyle(el);
const fontVariantEmoji = cs.fontVariantEmoji;
const fontSize = parseFloat(cs.fontSize);
const color = cs.color; // e.g., "rgba(0, 0, 0, 0.05)"
// Check if element contains emoji characters
const emojiPattern = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}✅❌✔]/gu;
const hasEmoji = emojiPattern.test(el.textContent || '');
if (!hasEmoji) return { collapsed: false };
if (fontVariantEmoji === 'text' && fontSize < 10) {
const alphaMatch = color.match(/rgba\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
const alpha = alphaMatch ? parseFloat(alphaMatch[1]) : 1;
return {
collapsed: true,
reason: 'emoji indicator forced to text presentation at ' + fontSize.toFixed(1) + 'px — monochrome glyph at this size is not legible' + (alpha < 0.3 ? '; color alpha ' + alpha + ' makes text presentation near-transparent' : ''),
fontVariantEmoji,
fontSize,
colorAlpha: alpha,
};
}
return { collapsed: false };
}
Attack 2 (SA-CSS-FVEMOJI-002): emoji presentation forcing to substitute deceptive acceptance indicators
The inverse attack forces emoji presentation on a character that looks like a green checkmark in its emoji form but represents denial or a different character entirely. The attacker places a character in the consent UI that shows a red circle or cross in text presentation, then uses font-variant-emoji: emoji to force it to render as a green checkmark-like emoji:
/* Attacker uses U+1F534 (🔴 RED CIRCLE) in the "permission granted" indicator slot */
/* In emoji presentation: red filled circle */
/* Strategy: swap the character for U+2705 ✅ in emoji presentation via conditional */
/* More subtle: use a character with ambiguous presentation */
/* U+2611 ☑ BALLOT BOX WITH CHECK */
/* Text presentation: simple checkmark in box */
/* Emoji presentation: varies by platform — on many platforms renders as ✅-like green checkmark */
.permission-granted-icon::before {
content: "\2611"; /* ☑ — ballot box with check */
font-variant-emoji: emoji; /* forces emoji presentation */
/* On platforms where ☑ has a vivid green emoji glyph, this looks identical to ✅ */
/* The actual icon used for DENIED state: ✗ */
/* This replaces the denied indicator with a checkmark-like emoji */
}
/* Actual denied state should show ✗ — MCP server CSS overrides it
to show ☑ in emoji presentation, which resembles acceptance */
Detection requires checking whether emoji characters in consent status positions have cross-referenced semantic meaning versus visual appearance:
// Check for suspicious font-variant-emoji:emoji on indicator elements
function detectEmojiPresentationSpoof(el) {
const cs = window.getComputedStyle(el);
const fontVariantEmoji = cs.fontVariantEmoji;
if (fontVariantEmoji === 'emoji') {
// Check if element is in a consent-relevant position (indicator, status, checkbox area)
const isIndicator = el.closest('[class*="indicator"], [class*="status"], [class*="icon"], [class*="check"], [class*="badge"]');
if (isIndicator) {
// Read actual Unicode content
const text = el.textContent?.trim() || '';
const beforeContent = cs.content;
// Characters with ambiguous emoji presentations that could spoof ✅
const ambiguousCheckmarks = ['☑', '✓', '✔', '☒', '☐'];
const hasAmbiguous = ambiguousCheckmarks.some(c => text.includes(c));
if (hasAmbiguous) {
return {
spoofing: true,
reason: 'font-variant-emoji:emoji on ambiguous checkmark character "' + text + '" in indicator position — may visually spoof acceptance status',
fontVariantEmoji,
character: text,
codePoint: text.codePointAt(0)?.toString(16),
};
}
}
}
return { spoofing: false };
}
Attack 3 (SA-CSS-FVEMOJI-003): variation selector bypass — VS15 (text) overridden by font-variant-emoji:emoji
Unicode defines Variation Selector 15 (U+FE0E, VS15) to explicitly request text presentation and Variation Selector 16 (U+FE0F, VS16) to request emoji presentation. A consent dialog that uses VS15 to ensure text presentation of a small indicator (to guarantee it renders as a predictable monochrome glyph at the specified size) can have this preference overridden by font-variant-emoji: emoji in injected CSS:
/* Legitimate consent dialog markup with VS15 text selector */
<span class="terms-checkmark">✔︎</span> <!-- ✔︎ + VS15 = always text -->
/* The VS15 sequence (U+2714 U+FE0E) requests text presentation explicitly.
In browsers that support font-variant-emoji (Chrome 108+, Firefox 108+, Safari 17.4+):
font-variant-emoji: emoji OVERRIDES the variation selector sequence.
CSS font-variant-emoji takes precedence over Unicode variation selectors. */
/* MCP-injected CSS: overrides VS15 request */
.terms-checkmark {
font-variant-emoji: emoji; /* forces emoji presentation despite VS15 in DOM */
/* Now the ✔︎ text is rendered as a vivid colored emoji glyph */
/* At font-size:16px this is large and colorful — appears as strong visual confirmation */
/* But the underlying character is the "text checkmark" the developer intended */
/* to indicate a specific state. The emoji presentation may convey different meaning */
/* (e.g., the emoji version has a different color or appearance than intended) */
}
/* Reverse: using font-variant-emoji: text to override VS16 in the emoji sequence */
/* A consent indicator that uses U+2705 U+FE0F (forced emoji) gets forced back to text */
.consent-badge {
font-variant-emoji: text; /* overrides VS16 — forces text, may collapse indicator */
}
The specification is clear: font-variant-emoji overrides variation selectors. This means a consent UI author who embeds VS15 to guarantee text presentation has no defense against injected font-variant-emoji: emoji CSS — the variation selector in the DOM is ignored at render time.
function detectVariationSelectorOverride(el) {
const cs = window.getComputedStyle(el);
const fontVariantEmoji = cs.fontVariantEmoji;
if (fontVariantEmoji === 'text' || fontVariantEmoji === 'emoji') {
// Check if DOM contains explicit variation selectors
const rawText = el.innerHTML || '';
const hasVS15 = rawText.includes('︎') || rawText.includes('︎') || rawText.includes('︎');
const hasVS16 = rawText.includes('️') || rawText.includes('️') || rawText.includes('️');
if ((hasVS15 && fontVariantEmoji === 'emoji') ||
(hasVS16 && fontVariantEmoji === 'text')) {
return {
override: true,
reason: 'font-variant-emoji:' + fontVariantEmoji + ' overrides explicit Unicode variation selector (VS' + (hasVS15 ? '15' : '16') + ') in DOM — the intended presentation is suppressed',
fontVariantEmoji,
variationSelector: hasVS15 ? 'VS15 (text)' : 'VS16 (emoji)',
};
}
}
return { override: false };
}
Attack 4 (SA-CSS-FVEMOJI-004): JS interaction-time emoji presentation toggle
The most evasion-resistant variant uses JavaScript to change font-variant-emoji at the moment of user interaction, keeping the correct presentation during any static audit window:
/* Initial state: correct emoji presentation visible during audit */
.consent-badge { font-variant-emoji: normal; } /* ✅ renders as colored checkmark */
/* MCP JS: toggle presentation at install click */
document.querySelector('.mcp-install-trigger').addEventListener('mousedown', () => {
// Switch ✅ from emoji to text presentation at mousedown
// Text presentation at 12px with color:transparent collapses the indicator
const badges = document.querySelectorAll('.consent-badge');
badges.forEach(b => {
b.style.fontVariantEmoji = 'text';
b.style.color = 'transparent';
});
}, { capture: true });
/* At audit time: font-variant-emoji:normal, ✅ shows as green checkmark — passes */
/* At install mousedown: font-variant-emoji:text, color:transparent — ✅ invisible */
/* User committed to terms they believed were confirmed by the green checkmark */
function detectEmojiToggleOnInteraction(rootEl) {
const badges = rootEl.querySelectorAll('[class*="badge"], [class*="indicator"], [class*="status"]');
const installBtns = rootEl.querySelectorAll('button[class*="install"], button[class*="approve"], button[type="submit"]');
for (const btn of installBtns) {
// Record emoji variant before
const before = Array.from(badges).map(b => window.getComputedStyle(b).fontVariantEmoji);
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
const after = Array.from(badges).map(b => window.getComputedStyle(b).fontVariantEmoji);
// Restore
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
btn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
for (let i = 0; i < badges.length; i++) {
if (before[i] !== after[i]) {
return {
found: true,
reason: 'font-variant-emoji changed from "' + before[i] + '" to "' + after[i] + '" on simulated mousedown — emoji presentation toggled at interaction time',
button: btn.textContent?.trim(),
badge: badges[i],
};
}
}
}
return { found: false };
}
Attack summary
| ID | Attack | Mechanism | Detection point | Severity |
|---|---|---|---|---|
| SA-CSS-FVEMOJI-001 | Text presentation collapse | font-variant-emoji: text + tiny font-size collapses colored emoji indicator to invisible monochrome glyph |
fontVariantEmoji === "text" on emoji-containing element with fontSize < 10 |
High |
| SA-CSS-FVEMOJI-002 | Deceptive emoji presentation spoof | font-variant-emoji: emoji on ambiguous checkmark character substitutes visual acceptance indicator for denial state |
fontVariantEmoji === "emoji" on indicator position with ambiguous codepoint (U+2611, U+2713, U+2714) |
High |
| SA-CSS-FVEMOJI-003 | Variation selector override | CSS font-variant-emoji overrides explicit Unicode VS15/VS16 variation selectors in the DOM |
Check for VS15/VS16 in DOM text nodes; compare intended presentation with getComputedStyle().fontVariantEmoji |
Medium |
| SA-CSS-FVEMOJI-004 | Interaction-time toggle | JS switches font-variant-emoji to text at mousedown on install button; audit-time state appears correct |
Simulate mousedown and re-read fontVariantEmoji on indicator elements |
High |
Finding blocks
font-variant-emoji: text forces monochrome text rendering of ✅/❌ consent indicators. At small font sizes (<10px) with near-transparent text color, the monochrome glyph is invisible. DOM textContent reports the emoji character present — the attack only shows in the visual rendering. Key: check fontVariantEmoji === "text" combined with fontSize < 10 and color alpha on emoji-containing indicator elements.
font-variant-emoji: emoji on ambiguous checkmark characters (U+2611 ☑, U+2714 ✔) forces colored emoji presentation that visually resembles ✅. A denial or warning indicator appears as an acceptance indicator. The DOM character is present and correct — only the rendered presentation differs. Key: crosscheck indicator position semantics with actual codepoint and forced fontVariantEmoji value.
font-variant-emoji overrides explicit Unicode VS15 (text) or VS16 (emoji) variation selectors. A consent UI that uses VS15 to guarantee text presentation of indicators has that guarantee violated by injected CSS. The DOM variation selector is ignored at render time — only the CSS-specified presentation applies. Key: detect mismatch between DOM variation selector intent and computed fontVariantEmoji.
font-variant-emoji from normal to text on mousedown, making colored emoji indicators collapse to invisible monochrome glyphs at the moment of user commitment. Load-time audit sees correct colored indicators; interaction-time audit sees collapsed invisible indicators. Key: simulate mousedown events and re-read fontVariantEmoji computed values.
← Blog | font-variant-ligatures attacks | Security Checklist