Security reference · CSS injection · Letter spacing · Consent hiding

MCP server CSS letter-spacing extreme security

CSS letter-spacing accepts viewport units (vw, vh) and absolute pixel values in the hundreds. At extreme positive values, characters are pushed so far apart that only the first character of each word is in the visible viewport — the remaining characters scroll off-screen into overflow that the container doesn't scroll. At extreme negative values equal to the font size, characters completely overlap into a single glyph-stack blob that no longer reads as text. Both attacks leave DOM text intact and pass textContent checks. Four extreme patterns: viewport-unit spacing (off-screen overflow), negative overlap (glyph stacking), custom-property obfuscation (scanner bypass), and JS-triggered extreme spacing at click time.

viewport-unit letter-spacing vs em-based spreading — the key difference

Em-based letter-spacing (e.g., letter-spacing: 10em) spreads characters to 10× the font-size apart within the element's content flow. If the container has overflow: hidden, characters outside the visible area are clipped. If the container has overflow: visible, characters extend past the container but remain in the page's scrollable area — users can scroll to see them. Viewport-unit letter-spacing (letter-spacing: 100vw) works differently: each gap is 100% of the viewport width. On a 1280px screen, each character after the first is pushed 1280px to the right. With most consent dialogs in a fixed-position overlay or a modal with overflow: hidden, all characters after the first are clipped and unreachable — they are beyond the modal's boundary and outside the document's scroll area.

ValueCharacters visible (1280px viewport, 6-char word)Container behaviorAuditor check result
letter-spacing: 10emAll 6 chars, spread across 60em (~960px)May fit in wide containerDetectable via large em value
letter-spacing: 100vwFirst char only; chars 2–6 at +1280px, +2560px…Clipped/off-screenRequires vw-unit check
letter-spacing: 200pxFirst char visible; chars 2–6 at +200px, +400px…May extend beyond containerRequires large-px check
letter-spacing: -1emAll chars visible but overlapping into a blobContainer width unchangedRequires negative-value check

Why 100vw is harder to detect than 10em: A scanner that flags large em values (e.g., >5em) will catch most em-based spreading attacks. But 100vw looks like a reasonable responsive-design value in isolation — viewport units are common in legitimate CSS. A stylesheet scanner must interpret the computed pixel value at runtime (100vw = viewport width in pixels) to detect that each character is being pushed one full screen width apart.

Attack 1: letter-spacing: 100vw — first character only, rest off-screen

At letter-spacing: 100vw, the inter-character gap after each glyph equals the full viewport width. On a 1280px viewport: character 1 is at x=0, character 2 is at x=1280px + char-width, character 3 is at x=2560px + 2×char-widths, and so on. For a 6-character consent keyword like "WRITE" or "DELETE", characters 2–5 are at 1280–5120px from the left edge. Most consent dialog containers are 400–800px wide with overflow: hidden — all characters after the first are invisible:

/* Malicious CSS — SA-CSS-LTSE-001 */
.mcp-permission-scope {
  letter-spacing: 100vw;
  white-space: nowrap; /* prevents wrapping — characters stay on one line */
  overflow: hidden;    /* clips everything past the container edge */

  /* Result on a 1280px viewport with a 600px consent dialog:
     "DELETE" renders as: D[1280px gap]E[1280px gap]L[1280px gap]E[1280px gap]T[1280px gap]E
     Only "D" is visible within the 600px container.
     The rest extends to x=7680px — off-screen and unscrollable inside the modal. */

  /* DOM text: "DELETE" — passes textContent checks
     Accessibility tree: "DELETE" — passes a11y checks
     Container width: 600px — non-zero, passes dimension checks
     letter-spacing computed value: "1280px" (100vw resolved) — detectable if checked */
}

/* Why not overflow:visible? Even with overflow:visible, the characters at x=1280px+
   are beyond the fixed-position modal's boundary and cannot be seen without
   the user scrolling the modal content — which usually has no scrollbar. */

/* Detection: */
function detectViewportUnitLetterSpacing(el) {
  const ls = parseFloat(getComputedStyle(el).letterSpacing);
  const vw = window.innerWidth;
  if (ls > vw * 0.5) { /* spacing > 50vw — only first character visible */
    return { id: 'SA-CSS-LTSE-001', severity: 'critical',
      message: `Consent element letter-spacing is ${ls}px (${(ls/vw*100).toFixed(0)}vw). At this spacing, only the first character of each word is visible in the viewport. Remaining characters are at x=${ls}px, ${ls*2}px, etc — off-screen.` };
  }
}

Attack 2: letter-spacing: -1em — complete character overlap into glyph blob

Negative letter-spacing removes space between characters, allowing characters to overlap. At letter-spacing: -1em (negative one em — equal to the font-size), each character is drawn at the same x position as the previous character (shifted left by exactly one em = one font-size). The result is all characters superimposed: a consent permission word like "EXECUTE" renders as 7 glyphs all drawn at the same point, producing an illegible dark blob. Unlike large positive spacing, negative spacing does not expand the element — the bounding box contracts, which can appear as a normally-sized element with scrambled content:

/* Malicious CSS — SA-CSS-LTSE-002 */
.mcp-consent-permission-label {
  font-size: 16px;
  letter-spacing: -1em; /* -16px: each character shifted 16px to the LEFT */
  /* Characters at 16px font-size, with -16px spacing:
     E at x=0
     X at x=16px + (-16px) = x=0 (overlaps with E)
     E at x=0 again (overlaps)
     C at x=0 (overlaps)
     U at x=0 (overlaps)
     T at x=0 (overlaps)
     E at x=0 (overlaps)
     All 7 characters drawn at x=0: renders as overlapping glyph stack blob */

  /* Why this is effective:
     - Container width does not expand (negative spacing contracts it)
     - The element has non-zero height (line-height applies)
     - The DOM textContent is "EXECUTE" — unchanged
     - The visual output is a dark smear of superimposed characters
     - No transparency (fully opaque dark blob) — passes opacity checks
     - Not hidden (display:block, visibility:visible) — passes visibility checks */
}

/* More extreme variant — larger than -1em collapses characters further left,
   potentially rendering glyphs on the container's left edge or clipping them */
.extreme-overlap { letter-spacing: -2em; }
/* Characters shift 2em left per character — second char is 2em to the LEFT of first */

/* Detection: negative letter-spacing check */
function detectNegativeLetterSpacing(el) {
  const ls = parseFloat(getComputedStyle(el).letterSpacing);
  const fs = parseFloat(getComputedStyle(el).fontSize);
  if (ls < 0 && Math.abs(ls) > fs * 0.5) {
    /* spacing is negative and more than 50% of font-size — characters overlap */
    return { id: 'SA-CSS-LTSE-002', severity: 'high',
      message: `Consent element letter-spacing is ${ls}px (negative) with font-size ${fs}px. Overlap ratio: ${(Math.abs(ls)/fs*100).toFixed(0)}% of font-size. Characters overlap into illegible glyph stack.` };
  }
}

Attack 3: custom-property obfuscation — scanner-bypass via var()

Stylesheet scanners that parse raw CSS declarations looking for suspicious letter-spacing values will miss values injected via CSS custom properties. A rule reading letter-spacing: var(--ui-track) appears innocuous — it could be a legitimate design token. Only resolving the chain at runtime via getComputedStyle() reveals the actual computed pixel value:

/* Malicious CSS — SA-CSS-LTSE-003 */

/* Innocent-looking custom property definition, buried in :root styles */
:root {
  --ui-spacing-base:    0px;
  --ui-track-normal:    0.02em;   /* legitimate appearance */
  --ui-track-expanded:  100vw;    /* extreme value — not obvious in property list */
  --ui-track:           var(--ui-track-expanded);
}

/* Consent element uses what looks like a design token */
.mcp-consent-scope-label {
  letter-spacing: var(--ui-track); /* resolves to 100vw at runtime */
}

/* Multi-hop chain to increase scanner difficulty: */
:root {
  --base: 100;
  --unit: vw;
  /* Direct injection into letter-spacing — not via custom property chains
     (CSS custom properties cannot concatenate values via calc for units)
     so this approach uses a single custom property holding the full value */
  --mcp-track: 100vw;
}
.mcp-consent { letter-spacing: var(--mcp-track); }

/* Detection: always use computed style, not declared style */
function detectObfuscatedLetterSpacing(el) {
  /* getComputedStyle resolves var() chains to final pixel value */
  const ls = parseFloat(getComputedStyle(el).letterSpacing);
  const vw = window.innerWidth;
  const fs = parseFloat(getComputedStyle(el).fontSize);
  if (ls > vw * 0.5 || (ls < 0 && Math.abs(ls) > fs * 0.5)) {
    const declaredValue = el.style.letterSpacing ||
      getComputedStyle(el).getPropertyValue('letter-spacing');
    return { id: 'SA-CSS-LTSE-003', severity: 'high',
      message: `Consent element computed letter-spacing is ${ls}px — suspicious (possibly via custom-property chain). Check --ui-track or similar custom properties. Declared value: "${declaredValue}".` };
  }
}

Attack 4: JS-triggered extreme spacing — normal at load, 100vw at click

The consent element uses normal letter-spacing at page load. A click or mousedown handler adds a class that applies letter-spacing: 100vw, collapsing the consent text to show only the first character of each word at the moment the user interacts with the install button:

/* Malicious CSS — SA-CSS-LTSE-004 */
.mcp-consent-scope-label { letter-spacing: 0.02em; } /* normal at load */

/* Class added by JS at mousedown on install button */
.mcp-consent-scope-label.install-active {
  letter-spacing: 100vw;
  transition: letter-spacing 0s; /* instant — no smooth animation to notice */
}

/* JS: */
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  document.querySelectorAll('.mcp-consent-scope-label').forEach(el => {
    el.classList.add('install-active');
    /* "DELETE • READ • WRITE" → "D    R    W" — only first chars visible */
    /* User sees the permission labels change at the moment they click */
  });
});

/* Why mousedown instead of click:
   mousedown fires as the user presses the button, before mouseup and click.
   The hiding is in place by the time the click event fires.
   The user has already committed to clicking — they don't easily release and re-read. */

/* Detection: MutationObserver on class/style attributes of consent scope labels */
function monitorScopeLabels() {
  document.querySelectorAll('.mcp-consent-scope-label, [data-permission]').forEach(el => {
    new MutationObserver(() => {
      const ls = parseFloat(getComputedStyle(el).letterSpacing);
      if (ls > window.innerWidth * 0.5) {
        reportFinding({ id: 'SA-CSS-LTSE-004', severity: 'critical',
          message: `Permission scope label letter-spacing jumped to ${ls}px (>${window.innerWidth/2}px threshold) during user interaction. Load-time value was normal. Viewport-unit spacing collapse at interaction time.` });
      }
    }).observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
  });
}

container-query units (cqw, cqi) — emerging attack surface: CSS container queries introduce new length units: cqw (container query width), cqi (container query inline size). In browsers that support container queries (Chrome 105+, Firefox 110+, Safari 16+), letter-spacing: 100cqw sets the spacing to 100% of the nearest container's width. This is a variant of the viewport-unit attack but scoped to the consent dialog container itself — making the computed pixel value smaller but the effect identical (all characters after the first pushed past the container's right edge). SkillAudit checks computed pixel values, catching this variant automatically.

SkillAudit findings for extreme CSS letter-spacing attacks

CriticalSA-CSS-LTSE-001 — Consent element letter-spacing computed value exceeds 50% of viewport width. Spacing uses viewport units (vw, vh, cqw) or large absolute values. Only the first character of each word is visible in the viewport; subsequent characters are pushed off-screen into inaccessible overflow. DOM text intact; visual consent is the first letter of each word only.
HighSA-CSS-LTSE-002 — Consent element has negative letter-spacing with absolute value exceeding 50% of the computed font-size. Characters overlap into an illegible glyph-stack blob. Element is fully opaque and in the document flow — only a computed letter-spacing negative-threshold check detects it.
HighSA-CSS-LTSE-003 — Consent element letter-spacing is set via a CSS custom property (var(--x)) that resolves to an extreme positive or negative value. Stylesheet scanners checking for literal values miss it; computed style resolution is required.
CriticalSA-CSS-LTSE-004 — Consent permission-scope labels transition from normal letter-spacing at load time to an extreme value (100vw or similar) at user interaction time via class or style attribute change. Load-time audit passes; MutationObserver on consent element class and style attributes detects the change at interaction time.

Related MCP consent attack research

SkillAudit resolves all computed letter-spacing values (including viewport-unit and custom-property chains) and checks against both positive and negative legibility thresholds. Any consent-bearing element with a spacing value exceeding 10px or a negative value overlapping more than 30% of font-size triggers SA-CSS-LTSE findings. Paste your MCP server URL at skillaudit.dev to scan.