CSS attacks · Layout engines · Displacement taxonomy
CSS Layout Displacement Attacks on MCP Consent: Grid Placement, Flex Collapse, and Table Spacing
Three CSS layout engines — CSS Grid, Flexbox, and CSS Tables — each provide displacement vectors that move consent outside the viewport without removing it from the DOM. No display:none, no visibility:hidden, no opacity. The consent element is present, its text is in the accessibility tree, and a display:none scanner returns clean. But it is rendered 500px below the fold, collapsed to zero width, or placed in row 100 of a 3-row grid — invisible in practice.
In this post
- The displacement-without-hiding taxonomy
- Three layout engines, three displacement vectors
- CSS Grid: explicit placement displacement
- Flexbox: zero-basis and overflow collapse
- CSS Tables: border-spacing row displacement
- Detection comparison: what each scanner misses
- Unified layout displacement detector
- Safe consent layout patterns
The displacement-without-hiding taxonomy
Most MCP consent security research focuses on the hiding axis: display:none, visibility:hidden, opacity:0, color matching the background. These are direct visibility attacks — the consent element becomes invisible or is removed from rendering.
Displacement attacks operate on a different axis. The consent element remains fully visible in CSS terms — display:block, visibility:visible, opacity:1 — but it is positioned outside the painted area. The browser renders it to a coordinate that falls outside the container's overflow-clipped region, beyond the viewport, or at a dimension that collapses its content to zero pixels. The distinction matters for auditors: every visibility check passes, but users never see the text.
The three CSS layout engines that produce displacement attacks differ in their mechanism:
CSS Grid
grid-column / grid-rowExplicit placement puts consent in a row or column that exists outside the visible grid track range. With grid-auto-rows: 100px and overflow:hidden, row 100 starts at y=9900px — far below any viewport.
Flexbox
flex-basis / flex-growA zero flex-basis combined with flex-grow:0 and overflow:hidden collapses consent to zero main-size. Consent text is clipped entirely. The install form item receives all available flex space via flex-grow:1.
CSS Tables
border-spacingVertical border-spacing injects pixel gaps between table rows. A 500px row gap means the second row (containing consent) starts 500px below the first row (containing the install form) — far outside any fixed-height container's visible area.
Displacement attacks pass all display:none / visibility:hidden checks: The computed display value is block (or table-row, or inline-flex). The computed visibility is visible. The element appears in document.querySelectorAll('*'), in the accessibility tree, in element.innerText, and in the DOM. Only getBoundingClientRect() — which returns the rendered screen coordinates — reveals that consent is off-screen or zero-size.
Three layout engines, three displacement vectors
The displacement attacks covered in this post were described in detail across three reference pages. This synthesis extracts the common structure and builds a unified detection approach on top of it. The three reference pages are:
- CSS grid-column consent displacement attacks — four patterns using explicit grid-row and grid-column placement to position consent outside the visible grid
- CSS flex-basis consent collapse attacks — four patterns using flex-basis, flex-grow, and flex-shrink interactions to collapse consent to zero or push it off the flex line
- CSS border-spacing consent displacement attacks — four patterns using vertical and horizontal border-spacing to push table rows and cells outside overflow-clipped containers
Each engine has its own displacement lever: grid uses coordinate-based placement, flex uses size allocation algorithms, and tables use inter-cell gap properties. But all three arrive at the same result: consent is rendered at coordinates the user never reaches.
CSS Grid: explicit placement displacement
CSS Grid's placement algorithm allows any item to be placed at any grid coordinate — including coordinates that don't correspond to any explicitly defined track. When an item is placed at grid-row: 100 and the grid has grid-auto-rows: 100px, the browser creates 99 implicit rows to accommodate it. Each implicit row is 100px tall, so row 100 starts at y = 9900px.
/* SA-CSS-GRIDCOL-001 — row 100 placement displacement */
.mcp-install-container {
display: grid;
grid-template-rows: auto auto auto; /* only 3 explicit rows */
grid-auto-rows: 100px; /* implicit rows are 100px each */
overflow: hidden; /* clips row 100 entirely */
height: 400px;
}
.mcp-install-form { grid-row: 1; } /* visible — row 1 at y=0 */
.mcp-install-btn { grid-row: 2; } /* visible — row 2 at y=auto */
.mcp-consent-disclosure { grid-row: 100; } /* row 100 at y=9900px — clipped */
The zero-column-span variant is more subtle. Setting grid-column: 2 / 2 defines a span from line 2 to line 2 — a zero-width column span that renders consent at zero width in the second column gap. Combined with overflow:hidden, the consent element is technically on the grid but occupies no visual space:
/* SA-CSS-GRIDCOL-002 — zero-span column collapse */
.mcp-consent-disclosure {
grid-column: 2 / 2; /* start: line 2, end: line 2 → zero-width span */
overflow: hidden;
}
The named-area miss variant uses grid-area: consent where consent is never defined in grid-template-areas. Items placed in undefined named areas are sent to the implicit grid — pushed to a new implicit row beyond all explicitly defined rows, outside the overflow-clipped container.
grid-row: -1 is not always safe: Negative grid line numbers reference from the end of the explicit grid. In a 3-row grid, grid-row: -1 is the last explicit row — visible. But in combination with grid-column: -1 in a multi-column grid with a narrow container, the negative-column reference places consent in the last defined column, which may be off-screen horizontally.
Flexbox: zero-basis and overflow collapse
Flexbox displacement attacks exploit the three-part space distribution algorithm: flex-basis sets the item's hypothetical size, flex-grow distributes positive free space, and flex-shrink absorbs negative free space when the container is too small. By setting these three values to create a zero-size consent item while the install form takes all available space, consent collapses to zero rendered width or height.
/* SA-CSS-FLEXBASIS-001 — zero-basis collapse */
.mcp-install-row {
display: flex;
flex-direction: row;
overflow: hidden;
width: 100%;
}
.mcp-install-form {
flex: 1 1 auto; /* takes all available space */
}
.mcp-consent-disclosure {
flex: 0 1 0; /* basis:0, grow:0, shrink:1 — starts at 0, can't grow */
min-width: 0; /* allows collapse below content minimum width */
overflow: hidden;
}
The min-width: 0 detail is critical to understand. By default, flex items have min-width: auto, which prevents them from shrinking below their minimum content size (the width of the longest unbreakable word). Setting min-width: 0 removes this floor, allowing full zero-collapse. This property appears frequently in legitimate flex layouts as a performance pattern — it is not suspicious in isolation.
The viewport-unit variant operates in column direction. A consent item with flex-basis: 100vh in a column flex container with a fixed pixel height will always exceed the container, and overflow:hidden clips it entirely. This works at any screen size because 100vh is always larger than any typical fixed install dialog height:
/* SA-CSS-FLEXBASIS-002 — viewport-unit overflow in column container */
.mcp-install-col {
display: flex;
flex-direction: column;
height: 300px; /* dialog height */
overflow: hidden;
}
.mcp-consent-disclosure {
flex: 0 0 100vh; /* 100vh > 300px always — clipped */
}
The auto-inherit variant splits the attack across two CSS properties. flex-basis: auto is the CSS default — it resolves to the item's width property. Setting width: 0 separately on the consent element passes the basis zero through the auto-resolution chain. Neither property looks suspicious; the interaction creates the collapse.
CSS Tables: border-spacing row displacement
CSS table layout has been available since CSS 2.1. The border-spacing property controls the gap between table cells — horizontally for column gaps, vertically for row gaps. A large vertical border-spacing value creates a pixel gap between every pair of adjacent rows. With the install form in row 1 and consent in row 2, a border-spacing: 0 500px pushes consent 500px below the install form:
/* SA-CSS-BSPC-001 — vertical row gap displacement */
.mcp-install-table {
display: table; /* or <table> HTML element */
border-spacing: 0 500px; /* 500px vertical gap between rows */
height: 300px;
overflow: hidden;
}
/* Row 1: install form — rendered at y=0 */
/* Gap: 500px blank space */
/* Row 2: consent — rendered at y = (row-1-height + 500px) — outside overflow-hidden */
The viewport-adaptive variant uses calc(100vh - 50px) as the vertical gap. This creates a gap that is always slightly smaller than the viewport height — meaning consent is always just below the fold regardless of screen size. The install form is at the top; consent appears at exactly one viewport height below it, guaranteed to be off-screen in any browser window:
/* SA-CSS-BSPC-003 — viewport-adaptive gap */
.mcp-install-table {
display: table;
border-spacing: 0 calc(100vh - 50px); /* always pushes consent below the fold */
overflow: hidden;
}
border-spacing doesn't affect element box dimensions: Unlike margin, border-spacing is not a property of any individual element — it applies to the table container and affects the space between all cell boxes. This means checking the computed styles on the consent <td> element itself will not reveal the attack; you must check the parent table container's borderSpacing property.
Detection comparison: what each scanner misses
| Attack | display:none check | visibility:hidden check | opacity check | color/bg match check | getBoundingClientRect |
|---|---|---|---|---|---|
grid-row: 100 |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✗ y=9900px, off-screen |
grid-column: 2/2 zero-span |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✗ width=0px |
flex: 0 1 0 zero-basis |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✗ width=0px |
flex-basis: 100vh column |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✗ top > container bottom |
border-spacing: 0 500px |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✗ y=500px+ off-screen |
border-spacing: 0 calc(100vh) |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✗ always below fold |
The pattern is consistent: all four standard visibility checks pass for every displacement attack. The only reliable detection primitive is getBoundingClientRect(), which returns the actual rendered coordinates of the element on the screen. A consent element with a bounding rect that falls outside the viewport (or has zero width/height) is displaced, regardless of what any style-property check shows.
Unified layout displacement detector
The unified detector checks two conditions for any element containing consent-related text: (1) its rendered bounding rect is partially or fully outside the viewport, or (2) its rendered width or height is zero. Both conditions are checked against the element's computed style to confirm it is not display:none (which would make a displacement check irrelevant — a different attack type).
/* Unified layout displacement detector — covers grid, flex, and table attacks */
function detectLayoutDisplacementAttacks() {
const findings = [];
const vw = window.innerWidth;
const vh = window.innerHeight;
/* Match any element that looks like consent text */
const candidates = [...document.querySelectorAll('*')].filter(el => {
const text = el.textContent || '';
return /consent|disclosure|terms|privacy|by installing|by clicking/i.test(text)
&& el.children.length < 8 /* not a wrapper div with many children */
&& text.length > 10; /* not an empty element */
});
for (const el of candidates) {
const style = getComputedStyle(el);
/* Skip elements already caught by display/visibility checks */
if (style.display === 'none') continue;
if (style.visibility === 'hidden' || style.visibility === 'collapse') continue;
const rect = el.getBoundingClientRect();
/* Check 1: zero or near-zero dimensions */
if (rect.width < 5 || rect.height < 5) {
/* Determine which layout engine caused the collapse */
const parent = el.parentElement;
const parentStyle = parent ? getComputedStyle(parent) : null;
let engine = 'unknown';
let detail = '';
if (style.flexBasis !== undefined) {
engine = 'flex';
detail = `flex:${style.flexGrow}/${style.flexShrink}/${style.flexBasis}, min-width:${style.minWidth}`;
}
if (parentStyle && (parentStyle.display === 'grid' || parentStyle.display === 'inline-grid')) {
engine = 'grid';
detail = `grid-column:${style.gridColumnStart}/${style.gridColumnEnd}, grid-row:${style.gridRowStart}/${style.gridRowEnd}`;
}
findings.push({
id: 'SA-LAYOUT-DISPLACEMENT',
severity: 'critical',
engine,
message: `Consent element has near-zero rendered dimensions: ${Math.round(rect.width)}×${Math.round(rect.height)}px. Layout engine: ${engine}. ${detail}`
});
}
/* Check 2: off-screen rendering (any axis) */
const offRight = rect.left >= vw;
const offBottom = rect.top >= vh;
const offLeft = rect.right <= 0;
const offTop = rect.bottom <= 0;
if (offRight || offBottom || offLeft || offTop) {
/* Check parent for table border-spacing */
let spacing = '';
let parent = el.parentElement;
while (parent) {
const ps = getComputedStyle(parent);
if (ps.display === 'table' || ps.display === 'table-row-group') {
spacing = `border-spacing:${ps.borderSpacing}`;
break;
}
parent = parent.parentElement;
}
findings.push({
id: 'SA-LAYOUT-DISPLACEMENT',
severity: 'critical',
engine: spacing ? 'table' : 'layout',
message: `Consent element rendered off-screen: (${Math.round(rect.left)},${Math.round(rect.top)}) to (${Math.round(rect.right)},${Math.round(rect.bottom)}). Viewport: ${vw}×${vh}. ${spacing}`
});
}
}
return findings;
}
This single function detects all grid-column, flex-basis, and border-spacing displacement attacks without needing to know which layout engine is in use. It reads actual rendered coordinates and flags anything that ends up off-screen or zero-size. The parent traversal to detect border-spacing is needed because the spacing property lives on the table container, not on the consent element itself.
Safe consent layout patterns
Safe consent layout avoids using any layout property to size or position the consent element. The simplest approach is block layout with no flex/grid participation:
/* Safe consent layout — no flex/grid sizing, no displacement risk */
.consent-disclosure {
display: block;
position: static; /* no absolute/fixed/sticky positioning */
width: auto; /* auto — content-derived, not zero */
min-width: unset; /* don't override content minimum */
height: auto;
overflow: visible; /* don't clip content */
font-size: 14px;
color: #374151; /* real contrast against white background */
margin: 12px 0;
padding: 8px 0;
/* No flex-basis, flex-grow, flex-shrink, grid-row, grid-column, border-spacing */
}
/* If flex layout is required, keep consent outside the flex container: */
.mcp-install-wrapper {
display: flex;
/* install form and button are flex items */
}
/* consent is a sibling outside the flex container, not a flex item */
.mcp-consent-section {
display: block;
margin-top: 12px;
}
When consent must live inside a flex or grid container for layout reasons, the safe constraint is to avoid setting any of flex-basis, flex-grow:0, grid-row, or grid-column values that could position consent outside the visible area. In a grid, consent should always be in a row with a positive explicit track size. In flex, flex-basis should be auto and the element should have a natural content-derived minimum width or height.
SkillAudit's audit engine runs the unified displacement detector above as part of every scan. It catches grid, flex, and table displacement attacks in a single pass — no per-engine configuration needed. Run a free scan at skillaudit.dev to check your MCP server's consent layout for displacement attack patterns.
Related research
- CSS grid-column explicit placement consent attacks — four patterns
- CSS flex-basis zero-collapse and overflow attacks — four patterns
- CSS border-spacing table row displacement attacks — four patterns
- CSS gap attacks — row-gap and column-gap as displacement levers
- CSS overflow attacks — how overflow:hidden enables all displacement attacks
- CSS flex-flow attacks — direction and wrap interactions
- CSS Absence-Condition Attacks on MCP Consent