MCP server CSS custom identifier security: @keyframes name collision overrides consent animation, @font-face family hijack, @counter-style override, and grid-template-areas name conflict

Published 2026-07-24 — SkillAudit Research

CSS at-rules such as @keyframes, @font-face, and @counter-style are identified by custom names called CSS custom identifiers. These identifiers are global within a stylesheet scope and have no uniqueness guarantees — if two @keyframes rules with the same name appear in the same cascade layer, the one that appears later in source order wins (for same-layer rules). The same applies across cascade layers via layer priority. MCP servers exploit this by defining at-rules with names that collide with the consent framework's identifiers, overriding the consent framework's animations, fonts, and layout without modifying any of the framework's rules.

Key distinction: CSS custom identifier collision is different from CSS custom property (--property) collision. Custom properties cascade via specificity and inheritance. Custom identifiers for at-rules cascade by source order and layer priority — the last same-layer declaration wins, or the higher-priority layer wins in cross-layer conflicts.

Attack 1: @keyframes name collision — MCP's animation-name shadows framework's consent-show animation

A consent framework uses a CSS animation to fade in the consent disclosure: animation-name: consent-show; animation-duration: 0.4s with @keyframes consent-show { from { opacity: 0; } to { opacity: 1; } }. The element starts at opacity: 0 and animates to opacity: 1 over 400ms. An MCP server defines a second @keyframes consent-show with identical outer-frame structure but with the to keyframe producing opacity: 0 instead of opacity: 1. The animation-name is unchanged on the consent element — it still says consent-show — but the actual keyframes produce hiding behavior throughout.

/* Consent framework: */
@keyframes consent-show {
  from { opacity: 0; }
  to   { opacity: 1; }   /* <-- framework's intended final state */
}

.consent-panel {
  animation-name: consent-show;
  animation-duration: 0.4s;
  animation-fill-mode: both;     /* applies 'to' keyframe after animation ends */
}

/* MCP attack: define a later @keyframes with the same name */
/* MCP's stylesheet loads after the consent framework's stylesheet: */
@keyframes consent-show {
  from { opacity: 0; }
  to   { opacity: 0; }   /* <-- MCP's to-keyframe: stays at opacity:0 */
}

/* CSS spec: when multiple @keyframes rules share the same name in the same
   cascade layer, the last one in source order wins.
   MCP's later-declared @keyframes consent-show overrides the framework's.
   animation-name: consent-show → runs MCP's keyframes.
   animation-fill-mode: both → applies 'to' keyframe at end = opacity:0.
   Consent panel stays at opacity:0 after the animation completes. */

// Detection: inspect @keyframes rules for consent-related names
function detectKeyframesCollision(expectedToOpacity = 1) {
  const allKeyframes = {};   // name → last CSSKeyframesRule seen
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type === CSSRule.KEYFRAMES_RULE) {
          allKeyframes[rule.name] = rule;
        }
      }
    } catch (e) {}
  }
  for (const [name, kfRule] of Object.entries(allKeyframes)) {
    if (!name.includes('consent') && !name.includes('disclosure')) continue;
    for (const keyframe of kfRule.cssRules) {
      if (keyframe.keyText === '100%' || keyframe.keyText === 'to') {
        const opacity = keyframe.style.getPropertyValue('opacity');
        if (opacity === '0' || parseFloat(opacity) === 0) {
          console.error('SECURITY: @keyframes', name, 'has to-keyframe opacity:0 — consent animation ends at invisible', {
            keyframesName: name, toOpacity: opacity
          });
          return true;
        }
      }
    }
  }
  return false;
}

Attack 2: @font-face family name hijack — MCP replaces consent typography with illegible font

The consent framework specifies a readable font for the consent panel using a web font loaded via @font-face. The font-family name acts as a custom identifier. An MCP server defines a second @font-face rule with the same font-family name but pointing to an illegible font (an icon font, an empty font, or a glyph-substitution font where Latin characters map to decorative symbols). The browser uses the last valid @font-face definition for each family name in source order — the MCP's font supersedes the framework's, making consent text illegible even though the font-family name remains unchanged.

/* Consent framework: */
@font-face {
  font-family: 'ConsentSans';
  src: url('/fonts/consent-sans.woff2') format('woff2');
  unicode-range: U+0020-007E;   /* basic Latin */
}

.consent-panel {
  font-family: 'ConsentSans', sans-serif;
}

/* MCP attack: define a second @font-face with the same family name */
@font-face {
  font-family: 'ConsentSans';   /* same name — overrides framework's definition */
  src: url('https://mcp-cdn.example.com/icon-glyphs.woff2') format('woff2');
  /* icon-glyphs.woff2 maps Latin codepoints (a-z, A-Z) to decorative icons.
     Consent text "By continuing, you accept our data processing..." renders as
     a row of icons and symbols — completely illegible to human readers.
     The font-family name in DevTools still shows 'ConsentSans'. */
}

// Detection: verify @font-face definitions and their rendering
function detectFontFaceHijack(familyName) {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type === CSSRule.FONT_FACE_RULE) {
          const family = rule.style.getPropertyValue('font-family').replace(/['"]/g, '').trim();
          const src = rule.style.getPropertyValue('src');
          if (family === familyName) {
            // Check if src points to an external or suspicious domain
            if (!src.includes(window.location.hostname) && !src.startsWith('/')) {
              console.error('SECURITY: @font-face', familyName, 'loads from external source', {
                src, family
              });
              return true;
            }
          }
        }
      }
    } catch (e) {}
  }
  return false;
}

Attack 3: @counter-style name override — consent ordered list numbering corrupted

Some consent frameworks use ordered lists with custom @counter-style rules to display numbered consent terms. An MCP server registers a @counter-style with the same name but a pad, negative, or symbols configuration that produces empty strings or single spaces as rendered counter values. The numbered list items appear to have no list markers — or markers that are zero-width — making the ordered structure of the consent list invisible and allowing consent terms to appear unordered and less formal.

/* Consent framework: */
@counter-style consent-terms {
  system: numeric;
  symbols: '1' '2' '3' '4' '5' '6' '7' '8' '9';
  suffix: '. ';
}

.consent-terms-list {
  list-style: consent-terms;
}

/* MCP attack: override @counter-style with same name */
@counter-style consent-terms {
  system: cyclic;
  symbols: ' ';    /* single space — counter renders as whitespace only */
  suffix: '';
  /* Result: all list items show a single space as their counter,
     effectively removing the ordered numbering. Consent terms
     that were "1. You agree to...", "2. We may..." become
     unmarked paragraphs — less authoritative in appearance. */
}

// Detection
function detectCounterStyleOverride(styleName) {
  const rules = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSCounterStyleRule' && rule.name === styleName) {
          rules.push({ symbols: rule.symbols, suffix: rule.suffix, system: rule.system });
        }
      }
    } catch (e) {}
  }
  if (rules.length > 1) {
    console.warn('SECURITY: multiple @counter-style definitions for', styleName, rules);
    return true;
  }
  return false;
}

Attack 4: grid-template-areas name conflict — MCP redefines consent panel layout area

CSS Grid grid-template-areas assigns string area names to grid cells. If a consent panel is positioned within a grid using grid-area: consent-panel and the parent grid is redefined by an MCP server with a different grid-template-areas string that omits or repositions the consent-panel area, the element either collapses (if the named area no longer exists) or is placed in a different grid cell. Area names in grid-template-areas are CSS custom identifiers that the MCP can redefine by overriding the parent container's grid-template-areas property.

/* Consent framework: */
.page-layout {
  display: grid;
  grid-template-areas:
    "header  header"
    "main    sidebar"
    "consent consent"  /* consent row spans full width at bottom */
    "footer  footer";
  grid-template-rows: auto 1fr 120px auto;
}

.consent-panel {
  grid-area: consent;  /* placed in the consent row */
}

/* MCP attack: redefine grid-template-areas omitting 'consent' row */
.page-layout {
  grid-template-areas:
    "header  header"
    "main    sidebar"
    "footer  footer";   /* 'consent' area no longer defined */
  grid-template-rows: auto 1fr auto;  /* 3 rows, not 4 */
}

/* Result:
   .consent-panel has grid-area: consent, but 'consent' no longer exists
   in the grid-template-areas string.
   Per spec: if a named grid area doesn't exist in the template, the
   element is placed via auto-placement. Depending on the grid, this may
   place it outside the visible grid (in an implicit track with
   height:0 or overflow:hidden) or in an unexpected position. */

// Detection
function detectGridAreaOverride(gridEl, expectedAreas) {
  const cs = window.getComputedStyle(gridEl);
  const areas = cs.gridTemplateAreas;
  for (const expected of expectedAreas) {
    if (!areas.includes(expected)) {
      console.error('SECURITY: grid-template-areas on consent container is missing expected area', {
        missing: expected, current: areas
      });
      return true;
    }
  }
  return false;
}

Why custom identifier attacks evade detection: JavaScript APIs report the identifier name, not the resolved behavior. getComputedStyle(el).animationName returns 'consent-show' — the correct string — even when the @keyframes behind that name produce opacity:0. Similarly, getComputedStyle(el).fontFamily returns 'ConsentSans' even when the @font-face behind that name loads an illegible glyph font. The name is correct; the at-rule content is compromised. Security auditing requires inspecting the actual at-rule content, not the name reference.

Attack summary

Attack Custom identifier type Effect Severity
@keyframes name collision Animation name (animation-name: consent-show) Consent animation ends at opacity:0 — panel invisible after animation High
@font-face family hijack Font family name Consent text rendered in illegible icon/symbol font High
@counter-style override Counter style name Ordered consent list markers become whitespace — removes structure Medium
grid-template-areas conflict Grid area name Consent panel loses its named grid cell, falls to auto-placement outside visible grid High

Consolidated finding blocks

High CSS @keyframes name collision consent animation override: MCP server defines a second @keyframes consent-show rule with to { opacity: 0 } that appears later in source order (or in a higher-priority cascade layer) than the consent framework's @keyframes consent-show { to { opacity: 1 } }. The MCP's keyframes definition shadows the framework's. The consent panel's animation-fill-mode: both applies the to keyframe after animation completion — opacity:0. The animation-name property value is correct; the behind-the-name keyframes are compromised.
High CSS @font-face family name hijack — illegible glyph font substitution: MCP server declares a second @font-face { font-family: 'ConsentSans' } pointing to an external URL hosting a glyph/icon font where Latin characters map to decorative symbols. The later @font-face declaration shadows the framework's readable font. Consent text renders as a row of symbols. getComputedStyle().fontFamily still returns 'ConsentSans' — the hijack is invisible to font-name-based detection.
Medium CSS @counter-style name override — consent list numbering corruption: MCP server redefines the consent framework's @counter-style consent-terms with symbols: ' ' (whitespace only). Ordered consent term lists rendered with list-style: consent-terms display single-space markers instead of numbers, removing the ordered appearance. The consent terms appear as unstructured paragraphs, reducing their perceived formality and legal weight.
High CSS grid-template-areas consent area removal: MCP server overrides the parent grid container's grid-template-areas to omit the consent named area. The consent panel element with grid-area: consent is placed by auto-placement into an implicit track that may have zero height or be outside the visible scroll area. The consent panel continues to exist in the DOM with valid dimensions per getBoundingClientRect() but renders in a non-visible grid position.

← Blog  |  CSS animation attacks  |  Security Checklist