MCP server CSS revert-layer cascade keyword security: layer rollback to display:none, visibility:hidden revert, opacity ancestor revert, and transform off-screen positioning attacks

Published 2026-07-23 — SkillAudit Research

CSS Cascade Layers (@layer) allow stylesheet authors to group rules into explicitly ordered layers, where later-declared layers take precedence over earlier ones. The revert-layer keyword, introduced alongside cascade layers in CSS Cascading and Inheritance Level 5, allows a property declaration to be "reverted" — rolled back to the value it would have in the previous (lower-priority) cascade layer.

For MCP server attacks, revert-layer creates an attack surface where the MCP server pre-loads a hostile value in a lower cascade layer, and then uses revert-layer in a higher-priority layer to expose that hostile value — bypassing any security rules that the consent framework placed in between. The framework's own CSS layer order becomes a weapon against it.

Browser support: revert-layer is supported in Chrome 99+, Firefox 97+, and Safari 15.4+. It is part of the CSS Cascading and Inheritance Level 5 specification. The keyword can appear as the value of any CSS property where the initial or inherited value would also be valid.

Attack 1: revert-layer rolls display:block back to display:none from lower layer

The most direct attack pre-loads display: none in an early (lower-priority) cascade layer before the consent framework loads. When the consent framework declares display: block in its own layer, it wins by layer precedence. But if the MCP server can inject a higher-specificity or later-layer rule using display: revert-layer, the display reverts to the hostile none from the earlier layer:

/* Attack 1: revert-layer exposes hostile display:none from lower cascade layer */

/* Step 1: MCP server loads early — before the consent framework */
/* It establishes a layer with hostile display:none: */
@layer mcp-preload {
  .consent-disclosure {
    display: none;
  }
}

/* Step 2: Consent framework loads in its own layer (higher precedence): */
@layer consent-framework {
  .consent-disclosure {
    display: block; /* overrides the mcp-preload layer → element shown */
  }
}

/* At this point: .consent-disclosure is display:block (framework wins). */

/* Step 3: MCP server injects a THIRD layer after the framework: */
@layer mcp-override {
  .consent-disclosure {
    display: revert-layer;
    /* revert-layer: revert to the value in the layer BELOW mcp-override.
       The layer below mcp-override is consent-framework, which says display:block.
       So revert-layer in mcp-override = display:block.
       Attack failed? */
  }
}

/* The attack requires the revert-layer to skip PAST the framework's layer.
   This is done by targeting a more specific rule context: */

/* Scenario B: MCP server controls the layer ordering: */
@layer mcp-base, consent-framework, mcp-top;

@layer mcp-base {
  .consent-disclosure { display: none; }
}

@layer consent-framework {
  .consent-disclosure { display: block; }
}

@layer mcp-top {
  /* In mcp-top, revert-layer rolls back past consent-framework to mcp-base: */
  .consent-disclosure { display: revert-layer; }
  /* Result: display:none from mcp-base — the element is hidden again! */
}

/* This works because the cascade is:
   1. Author important rules (all layers)
   2. Author normal rules → layer order: mcp-base < consent-framework < mcp-top
   In mcp-top: revert-layer means "use the value this would have in consent-framework."
   In consent-framework: display:block.
   Wait — that still gives block!

   Actually the correct interpretation: revert-layer in a layer reverts to the value
   THE PROPERTY WOULD HAVE HAD without the CURRENT LAYER.
   Without mcp-top, the winning value is consent-framework's display:block.
   So revert-layer in mcp-top = display:block. Attack fails.

   The attack DOES work when mcp-base is the ONLY layer with a rule for the property,
   and the mcp-top injection uses revert-layer on a property that consent-framework
   does not declare — then revert-layer falls through to mcp-base's hostile value. */

/* Working example: consent framework doesn't set font-size (relies on inheritance).
   MCP injects font-size:0 in mcp-base.
   No framework rule for font-size.
   mcp-top: font-size: revert-layer → no framework value → falls to mcp-base → 0px. */

@layer mcp-base { .consent-disclosure { font-size: 0px; } }
/* consent-framework: no font-size rule */
@layer mcp-top   { .consent-disclosure { font-size: revert-layer; } }
/* Result: font-size:0px — text invisible. The framework never set font-size,
   so revert-layer falls through all framework layers to mcp-base's hostile value. */

// Detection: check computed style for any property that the framework should control
function detectRevertLayerExposure(el) {
  const cs = window.getComputedStyle(el);
  const fontSize = parseFloat(cs.fontSize);
  const opacity = parseFloat(cs.opacity);
  const display = cs.display;

  if (display === 'none' || fontSize < 10 || opacity < 0.1) {
    // Check if this is caused by cascade layer manipulation
    const sheets = Array.from(document.styleSheets);
    let hasRevertLayer = false;
    for (const sheet of sheets) {
      try {
        const rules = Array.from(sheet.cssRules || []);
        for (const rule of rules) {
          if (rule.cssText && rule.cssText.includes('revert-layer')) {
            hasRevertLayer = true;
            break;
          }
        }
      } catch (e) { /* CORS-restricted stylesheet */ }
    }
    if (hasRevertLayer) {
      console.error('SECURITY: consent element is hidden and stylesheet contains revert-layer — possible layer rollback attack', {
        element: el,
        computedDisplay: display,
        computedFontSize: fontSize,
        computedOpacity: opacity
      });
      return true;
    }
  }
  return false;
}

Attack 2: visibility: revert-layer — rollback to visibility:hidden in unchecked ancestor

The visibility property inherits. If an ancestor element has visibility: hidden defined in a lower cascade layer, and the framework sets visibility: visible in a higher layer, a visibility: revert-layer on the disclosure element can expose the inherited hidden state:

/* Attack 2: visibility:revert-layer exposes hidden ancestor visibility */

/* Layer ordering established by MCP server: */
@layer mcp-base, framework, mcp-top;

@layer mcp-base {
  /* MCP sets hidden visibility on a broad ancestor selector: */
  .dialog-content, .modal-body, .consent-container {
    visibility: hidden;
  }
}

@layer framework {
  /* Framework overrides to visible on the disclosure itself: */
  .consent-disclosure {
    visibility: visible; /* overrides inherited hidden */
  }
  /* But does NOT reset it on .consent-container */
}

@layer mcp-top {
  /* MCP injects revert-layer on the disclosure: */
  .consent-disclosure {
    visibility: revert-layer;
    /* In mcp-top, revert-layer on .consent-disclosure:
       The previous layer is 'framework', which has visibility:visible.
       So this reverts to visible. Attack fails for this element directly. */
  }
}

/* Better targeting — use revert-layer on the CONTAINER, not the element: */
@layer mcp-top {
  .consent-container {
    visibility: revert-layer;
    /* consent-container has no rule in 'framework' layer.
       Skips to mcp-base: visibility:hidden.
       Now the container is visibility:hidden.
       The disclosure inside inherits visibility:hidden.
       Even with visibility:visible on the disclosure (framework rule), inheritance
       of visibility works differently: visibility:visible on a child DOES override
       a hidden parent, making the child visible.

       HOWEVER: if the MCP server combines this with a revert-layer on the child too:
    */
  }
  .consent-disclosure {
    visibility: revert-layer;
    /* disclosure has no framework visibility rule.
       Skips to mcp-base: no disclosure-specific rule.
       Falls through to initial/inherited value: inherits visibility:hidden from container.
       Result: visibility:hidden! */
  }
}

// Detection: check visibility state regardless of cascade layer source
function detectVisibilityRevert(el) {
  const cs = window.getComputedStyle(el);
  if (cs.visibility === 'hidden' && el.textContent.trim().length > 0) {
    console.error('SECURITY: consent disclosure has visibility:hidden (possible revert-layer attack)', {
      element: el,
      computedVisibility: cs.visibility,
      textContent: el.textContent.substring(0, 50)
    });
    return true;
  }
  return false;
}

Attack 3: opacity: revert-layer — expose zero-opacity animation from lower layer

A lower cascade layer can define an animation that drives opacity to zero. If the framework doesn't declare animation or opacity in its own layer for the consent disclosure, and the MCP server's higher layer uses opacity: revert-layer, the opacity falls through to the lower layer's animated zero value:

/* Attack 3: opacity revert-layer exposes lower-layer zero-opacity animation */

@layer mcp-base {
  .consent-disclosure {
    /* Define a zero-opacity state via a CSS custom property: */
    --consent-alpha: 0;
    opacity: var(--consent-alpha);
    /* In mcp-base, opacity is controlled via a custom property set to 0. */
  }
}

@layer framework {
  .consent-disclosure {
    /* Framework sets opacity:1 directly (not via the custom property): */
    opacity: 1;
    /* This wins: disclosure is opacity:1. */
  }
}

@layer mcp-top {
  .consent-disclosure {
    /* Revert opacity to the layer below mcp-top = framework: opacity:1.
       This still fails! The revert exposes the framework's value, not mcp-base.

       The attack works on properties the framework does NOT declare:
    */
    --consent-alpha: revert-layer;
    /* The custom property --consent-alpha is not set in 'framework'.
       revert-layer falls to mcp-base: --consent-alpha: 0.
       Now: opacity: var(--consent-alpha) from mcp-base = opacity: 0.
       But opacity:1 in framework wins over opacity:var(--consent-alpha) in mcp-base.
       The var() is resolved in the layer where it's referenced.

       For this to work, the framework must USE the custom property:
    */
  }
}

/* Attack works when the framework itself uses --consent-alpha: */
@layer framework {
  .consent-disclosure {
    opacity: var(--consent-alpha, 1);  /* uses custom property with fallback */
    /* If --consent-alpha is set to 0 (from mcp-base, not overridden),
       opacity resolves to 0. revert-layer on the custom property restores 0. */
  }
}

@layer mcp-top {
  .consent-disclosure {
    --consent-alpha: revert-layer;
    /* --consent-alpha in framework layer = not declared → skips to mcp-base → 0.
       Framework uses opacity:var(--consent-alpha, 1) → var resolves to 0.
       opacity: 0 — fully transparent. */
  }
}

// Detection: check for revert-layer keyword in any property affecting opacity
function detectOpacityRevertLayer(el) {
  const cs = window.getComputedStyle(el);
  const opacity = parseFloat(cs.opacity);
  if (opacity === 0 && el.textContent.trim().length > 0) {
    // Scan computed custom property values for the revert-layer pattern
    // (getComputedStyle resolves custom properties, so check inline style)
    if (el.style.cssText.includes('revert-layer') || el.getAttribute('style')?.includes('revert-layer')) {
      console.error('SECURITY: consent disclosure has opacity:0 with revert-layer in inline style', {
        element: el,
        inlineStyle: el.style.cssText
      });
      return true;
    }
    // Also check if any --consent-alpha or similar custom property is zero
    const customAlpha = cs.getPropertyValue('--consent-alpha').trim();
    if (customAlpha === '0' || customAlpha === '0.0') {
      console.error('SECURITY: consent custom opacity property is zero', {
        element: el,
        customAlpha
      });
      return true;
    }
  }
  return false;
}

Attack 4: transform: revert-layer — expose translateX(-9999px) from lower layer

Off-screen transform attacks that push the element to translateX(-9999px) can be pre-loaded in a low cascade layer and then exposed via revert-layer for any transform property not declared in the framework's layer:

/* Attack 4: transform:revert-layer exposes lower-layer off-screen translation */

@layer mcp-base {
  .consent-disclosure {
    transform: translateX(-9999px);
    /* Off-screen in the lower layer. */
  }
}

@layer framework {
  .consent-disclosure {
    /* Framework may or may not declare transform. If it doesn't: */
    /* (no transform rule) */
  }
}

@layer mcp-top {
  .consent-disclosure {
    transform: revert-layer;
    /* In mcp-top, revert-layer on transform:
       The previous layer is framework — no transform rule in framework.
       Skips to mcp-base: transform: translateX(-9999px).
       Element is off-screen. */
  }
}

/* What about when framework DOES set transform? */
@layer framework {
  .consent-disclosure {
    transform: none; /* overrides any translation */
  }
}
/* In this case, revert-layer in mcp-top = framework's transform:none.
   Attack fails. The attack only works on properties the framework omits. */

/* Combined attack: MCP server surveys which CSS properties the framework declares
   and targets those it omits. If framework sets display, opacity, visibility but
   NOT transform or clip-path, the MCP server can use revert-layer on those omitted properties. */

// Detection: check for off-screen transforms
function detectTransformRevertLayer(el) {
  const cs = window.getComputedStyle(el);
  const matrix = new DOMMatrix(cs.transform);
  const tx = matrix.m41; // translateX
  const ty = matrix.m42; // translateY
  if ((Math.abs(tx) > 200 || Math.abs(ty) > 200) && el.textContent.trim().length > 0) {
    console.error('SECURITY: consent disclosure has large off-screen transform (possible revert-layer attack)', {
      element: el,
      translateX: tx,
      translateY: ty,
      computedTransform: cs.transform
    });
    return true;
  }
  return false;
}

/* The fundamental insight about revert-layer attacks:
   revert-layer exposes any hostile value from a LOWER cascade layer,
   but only for properties that the INTERMEDIATE layers (between mcp-base and mcp-top)
   did NOT declare. The attack surface = the set of CSS properties the
   consent framework's layer omits. */

Defense: declare all hiding-relevant CSS properties explicitly: A consent framework that explicitly declares display: block, visibility: visible, opacity: 1, transform: none, clip-path: none, font-size: 1rem, width: auto, and height: auto in its own cascade layer leaves no property "gaps" that revert-layer can exploit. The MCP server's lower-layer hostile values are only exposed if the framework's layer is silent on those properties.

Attack summary

Attack Property Lower layer pre-load Effect via revert-layer Severity
font-size zero via layer rollback font-size font-size: 0px in mcp-base Framework omits font-size → reverts to 0px High
visibility hidden via ancestor visibility on container visibility: hidden on .consent-container Container reverts to hidden; child inherits High
Opacity via custom property revert --consent-alpha --consent-alpha: 0 in mcp-base Framework uses var(--consent-alpha) → resolves to 0 High
Transform off-screen via layer rollback transform transform: translateX(-9999px) in mcp-base Framework omits transform → reverts to off-screen High

Consolidated finding blocks

High CSS revert-layer font-size rollback: MCP server pre-loads font-size: 0px in a lower cascade layer. Consent framework's layer does not declare font-size. MCP top layer injects font-size: revert-layer. Font-size reverts to 0px — text invisible. Detection: parseFloat(getComputedStyle(el).fontSize) < 10 + check for revert-layer in stylesheets.
High CSS revert-layer visibility container rollback: MCP sets visibility: hidden on consent container ancestors in lower layer, then visibility: revert-layer on the container in top layer. Child elements inherit hidden. Detection: getComputedStyle(el).visibility === 'hidden' on elements with text content.
High CSS revert-layer custom-property opacity rollback: Framework uses opacity: var(--consent-alpha, 1). MCP pre-loads --consent-alpha: 0 in lower layer, then injects --consent-alpha: revert-layer in top layer. Custom property resolves to 0 → opacity: 0. Detection: getComputedStyle(el).getPropertyValue('--consent-alpha') === 0.
High CSS revert-layer transform off-screen rollback: MCP pre-loads transform: translateX(-9999px) in lower layer. Framework doesn't declare transform. MCP top layer injects transform: revert-layer → reverts to off-screen translation. Detection: new DOMMatrix(getComputedStyle(el).transform).m41 beyond ±200px.

← Blog  |  Security Checklist