Security Research

CSS break-inside: avoid as a Consent Column Trap — Column Miniaturization, Column-Fill Forcing, and the -webkit Alias Attack

CSS break-inside: avoid-column is a fragmentation hint. It tells the browser: do not split this block across a column boundary. In legitimate use, this keeps figures, code blocks, and blockquotes from being awkwardly divided. In adversarial use inside an MCP server consent dialog, it becomes a trap: force the consent text into a single column, make that column 30 pixels wide, and the text is physically unreadable while remaining present in the DOM. Every DOM-presence check passes. The Accept button is enabled. The user has technically "seen" the consent. They simply cannot read it at 30 pixels wide — and the browser has not scrolled anything.

How CSS multi-column fragmentation works, and why break-inside matters

When a container has column-count or column-width set, the browser divides its content into equal-width columns. Content flows top to bottom through each column before continuing in the next. By default, blocks may be split across column boundaries — a paragraph that does not fit entirely in column one will flow partially into column two.

The break-inside property changes this. A value of avoid-column tells the browser that the affected block must not be split across a column boundary. If the block fits in the remaining space of the current column, it stays there. If it does not fit, the browser advances to the next column and places the entire block there. The block is kept intact.

This seems harmless in isolation. But consider the interaction when an adversary controls both the CSS and the multi-column container parameters:

The DOM-presence bypass: Most naive consent gate implementations check whether the consent element exists in the DOM, whether it has non-zero dimensions, and whether it is not hidden via display:none or visibility:hidden. A column-trap attack satisfies all three conditions. The consent block exists, has a rendered height of several hundred pixels, and is perfectly "visible" — in a 30-pixel column that no human can read. See the full property reference: CSS break-inside: avoid-column MCP security.

The four column-trap attack variants

Attack 1: Column miniaturization via high column-count

CRITICAL break-inside:avoid-column + column-count ≥ 15

The primary attack. A high column-count on the consent container reduces each column to a width that makes text physically unreadable (typically <35px). The break-inside: avoid-column on the consent block ensures the browser does not attempt to distribute the block across multiple columns — it keeps the entire block in its assigned (narrow) column, with overflow clipped by the container's overflow: hidden.

<!-- MCP consent container with adversarial column settings -->
<div style="
  column-count: 20;
  overflow: hidden;
  height: 400px;
  width: 600px;
">
  <!-- Non-consent intro blocks: no break-inside rule, flow freely -->
  <div>SkillAudit MCP Server — Setup Wizard</div>
  <div>Step 3 of 5: Permissions</div>

  <!-- Consent block: kept in single 30px column via break-inside -->
  <div style="break-inside: avoid-column; -webkit-column-break-inside: avoid;">
    <p>By proceeding, you grant this MCP server irrevocable access
    to read and write all files in your home directory, send email
    on your behalf, and share usage data with third-party analytics
    partners. This authorization is permanent and cannot be revoked
    after the 48-hour cooling-off window expires.</p>
    <p>You waive your right to class action and agree to binding
    individual arbitration for any dispute arising from these
    authorized actions, including data loss, financial loss, and
    privacy violations.</p>
  </div>

  <!-- Accept button: rendered in the next free column, fully visible -->
  <button id="accept-btn">Accept and Continue</button>
</div>

The visual result: the intro text ("Setup Wizard", "Step 3 of 5") flows across the first few 30px columns and is readable because it consists of short single-line strings. The consent paragraph block is forced into column 3 or 4 (wherever avoid-column places it) and renders as a tower of text in a 30px sliver — the first character of each line is visible, everything else is clipped. The Accept button flows into the next column and is wide enough to read and click. The user sees a wizard, a partially-visible content block they might interpret as boilerplate, and an Accept button. They click Accept.

Attack 2: Off-screen column exile via non-consent block displacement

Attack 2: Large non-consent blocks push consent to overflow column

HIGH break-inside:avoid-column on non-consent blocks

The subtle variant. Instead of applying break-inside: avoid-column to the consent block, the attacker applies it to non-consent blocks that consume all the available visible columns. The consent block — which has no break-inside rule — flows into the next column, which is already beyond the container's visible width. The consent is not hidden by a hiding rule; it is simply displaced into an overflow column that the container's overflow: hidden clips.

/* Non-consent blocks are large and cannot be split across columns.
   They consume all visible column space. The consent block overflows
   to a column outside the container's visible width. */
.setup-header { break-inside: avoid-column; min-height: 200px; }
.setup-steps  { break-inside: avoid-column; min-height: 180px; }
.setup-footer { break-inside: avoid-column; min-height: 160px; }

/* Container: only 3 columns visible in viewport width */
.consent-modal {
  column-count: 3;
  overflow: hidden;
  width: 600px;  /* 200px per column */
}

/* Consent block: no break-inside rule, may flow anywhere — but
   all three visible columns are consumed by avoid-column blocks.
   The consent block flows to column 4, outside overflow:hidden. */

This attack is harder to detect because the consent element itself carries no suspicious CSS. A scan looking for break-inside: avoid-column on the consent block reports clean. The attack is encoded in the layout of surrounding elements — non-consent blocks that are individually legitimate but collectively designed to displace the consent out of the visible area.

Detection requires a runtime layout check: after the page has rendered, measure whether the consent block's column position exceeds the container's column count. This is not trivially derivable from static CSS inspection.

Static audit limitation: Any CSS audit that only checks properties on the consent element itself will miss Attack 2. The attack relies on the cumulative layout behavior of surrounding elements. Detection must be runtime: compute the effective column index of the consent block after layout.

Attack 3: column-fill:auto vs column-fill:balance — the fill-mode forcing attack

CSS multi-column containers have a column-fill property that controls how content is distributed across columns. The two relevant values are:

The column-fill: auto attack exploits the sequential fill behavior. The attacker sets a fixed height on the container and places large blocks before the consent, all with break-inside: avoid-column. In auto mode, these blocks fill each column completely before overflow. The consent block, having no break-inside protection, ends up in a column that extends beyond the container's fixed width (and is clipped by overflow: hidden).

<div style="
  column-count: 4;
  column-fill: auto;       /* sequential fill, not balance */
  height: 300px;           /* fixed height: stops at 300px */
  overflow: hidden;        /* clips columns beyond viewport */
  width: 600px;            /* 150px per column */
">
  <!-- Large avoid-column blocks consume columns 1-4 (all visible) -->
  <section style="break-inside:avoid-column; height:280px">Header</section>
  <section style="break-inside:avoid-column; height:280px">Step Info</section>
  <section style="break-inside:avoid-column; height:280px">Options</section>
  <section style="break-inside:avoid-column; height:280px">Notes</section>

  <!-- Consent: flows to column 5, clipped by overflow:hidden -->
  <div class="consent-clause">
    <p>You grant irrevocable permissions...</p>
  </div>

  <!-- Accept: stays in visible columns as a short single-line block -->
  <button>Accept</button>
</div>

The column-fill forcing attack is particularly dangerous because column-fill: auto is a legitimate property used in paginated printing workflows. It does not appear suspicious in a CSS audit. The consent displacement is a side effect of the interaction between column-fill: auto, fixed container height, and the relative sizes of avoid-column blocks.

There is also a column-fill: balance variant. With balance mode, the browser distributes content evenly — but a large break-inside: avoid-column block that is taller than the available column height creates an unbalanceable situation. The browser places the block in its own column at full height, which may exceed the container's overflow: hidden clip boundary. The interaction between balance and avoid-column on large blocks produces layout outcomes that are difficult to predict statically and easy to exploit for displacement.

Attack 4: The -webkit-column-break-inside legacy alias

Attack 4: -webkit-column-break-inside: avoid

HIGH Legacy WebKit alias — missed by standard property scans

The -webkit-column-break-inside property is the legacy WebKit alias for break-inside. In Chromium-based browsers (which includes virtually all modern desktop browsers), it is still honored and has identical effect. An MCP server that uses only the prefixed form avoids detection by any audit that scans for break-inside by property name. The visual effect is identical — the consent block is kept in a single (potentially narrow) column — but the static scanner reports no findings.

/* Standard property — detected by most scanners */
.consent-block {
  break-inside: avoid-column;
}

/* Legacy WebKit alias — functionally identical in Chromium browsers
   but missed by scanners that only check 'break-inside' by name */
.consent-block {
  -webkit-column-break-inside: avoid;
}

/* Double-declaration: the prefixed form provides the column-trap
   effect even in scanners that flag and strip the standard form */
.consent-block {
  break-inside: auto;                /* looks clean to scanner */
  -webkit-column-break-inside: avoid; /* actually enforces column trap */
}

The double-declaration form is the most evasive. The standard break-inside: auto at the start of the block declares the benign value. The prefixed form immediately below overrides it in Chromium-based browsers (last-wins). A scanner that reads the first declaration and reports "break-inside: auto — safe" misses that the prefixed form that follows actively enforces column trapping.

Detection requirement: Any column-trap scanner must include -webkit-column-break-inside, -moz-column-break-inside, and the legacy page-break-inside values in its property enumeration. Scanning only for break-inside misses three live synonyms that work in browsers deployed today. SkillAudit checks all four forms.

Proof of concept: Accept button enabled without user scrolling

Many consent gate implementations use a scroll-detection approach: they disable the Accept button until a JavaScript event confirms the user has scrolled to the bottom of the consent block. The theory is that if the user has scrolled to the bottom, they have seen all the consent text. The column-trap attack defeats this check in two ways.

Variant A — Accept button in the same column container as the (trapped) consent:

/* The consent block is in column 1 (30px wide, unreadable).
   The Accept button flows into column 2 (30px wide — also visible).
   The container is not a scroll container at all (overflow:hidden).
   The scroll event never fires. JS gate: is consent in viewport? YES.
   (getBoundingClientRect() reports valid rect for the column container.)
   Is accept-btn visible? YES. Button is enabled immediately. */

const gate = document.querySelector('.consent-clause');
const btn  = document.querySelector('#accept-btn');

// Naive scroll-gate implementation
const observer = new IntersectionObserver(entries => {
  if (entries[0].isIntersecting) {
    btn.removeAttribute('disabled'); // fires immediately — column 1 is in viewport
  }
});
observer.observe(gate); // gate is "visible" — it is in the viewport, just unreadable

The IntersectionObserver callback fires as soon as the consent container enters the viewport. The consent block is inside that container. The observer reports it as intersecting. The Accept button is enabled. The user has never scrolled, has never read the 300-word arbitration waiver, and the JavaScript gate has been fully satisfied by geometry — not by reading.

Variant B — Scroll detection on a fake scroll container:

/* Scroll gate watches the outer wrapper, not the inner consent block.
   Outer wrapper scrolls normally (user scrolls through the visible UI).
   When the outer wrapper is scrolled to its bottom, the gate fires —
   but the consent block, displaced to a hidden column inside the wrapper,
   was never in the scrollable area. The user scrolled past the Accept
   button. The gate is satisfied. The consent was never visible. */

outerWrapper.addEventListener('scroll', () => {
  const atBottom = outerWrapper.scrollTop + outerWrapper.clientHeight
                   >= outerWrapper.scrollHeight - 5;
  if (atBottom) btn.removeAttribute('disabled');
});
// User scrolls outerWrapper to bottom. Consent is in overflow:hidden inner
// column container — not in the scrollable flow at all. Gate fires. Done.

Detection methodology

Detecting column-trap attacks requires a layered approach. Static CSS inspection catches some variants; runtime layout analysis is required for others.

Static checks (catch Attacks 1 and 4)

For every element in the consent DOM tree, extract all four fragmentation property forms:

const props = [
  'break-inside',
  '-webkit-column-break-inside',
  '-moz-column-break-inside',
  'page-break-inside'
];

const avoidValues = ['avoid', 'avoid-column', 'avoid-page', 'avoid-region'];

function hasColumnTrapHint(el) {
  const cs = getComputedStyle(el);
  return props.some(p => avoidValues.includes(cs.getPropertyValue(p).trim()));
}

// Walk consent DOM tree
function checkConsentTree(root) {
  const findings = [];
  root.querySelectorAll('*').forEach(el => {
    if (!hasColumnTrapHint(el)) return;
    const parent = el.closest('[style*="column-count"], [class*="col"]');
    if (!parent) return;
    const cc = parseInt(getComputedStyle(parent).columnCount, 10);
    const cw = parent.getBoundingClientRect().width / cc;
    if (cw < 80) findings.push({ el, columnWidth: cw, severity: 'critical' });
    else if (cw < 150) findings.push({ el, columnWidth: cw, severity: 'high' });
  });
  return findings;
}

Runtime layout checks (catch Attacks 2 and 3)

For every multi-column container that ancestors the consent block, measure the rendered column position of the consent element after layout:

function getColumnIndex(container, element) {
  const containerRect = container.getBoundingClientRect();
  const elementRect   = element.getBoundingClientRect();
  const columnWidth   = containerRect.width / parseInt(
    getComputedStyle(container).columnCount, 10
  );
  // Horizontal offset from left edge of container
  const offsetLeft = elementRect.left - containerRect.left;
  return Math.floor(offsetLeft / columnWidth);
}

function checkColumnExile(consentBlock) {
  const ancestor = consentBlock.closest('[style*="column-count"]');
  if (!ancestor) return null;
  const cc = parseInt(getComputedStyle(ancestor).columnCount, 10);
  const idx = getColumnIndex(ancestor, consentBlock);
  const containerRight = ancestor.getBoundingClientRect().right;
  const elementLeft    = consentBlock.getBoundingClientRect().left;
  // If element starts beyond the container's right edge, it is in overflow
  if (elementLeft >= containerRight) {
    return { severity: 'critical', message: 'Consent block exiled to overflow column' };
  }
  // If element is in a column beyond the last fully visible column, high severity
  if (idx >= cc) {
    return { severity: 'high', message: `Consent in column ${idx} of ${cc}-column container` };
  }
  return null;
}

Effective column width check (catch Attack 1 regardless of implementation form)

function checkEffectiveColumnWidth(consentBlock) {
  // Use getBoundingClientRect() on the element to get its rendered width.
  // In a column-trap, the element's rendered width equals the column width —
  // which may be far narrower than the text requires.
  const rect = consentBlock.getBoundingClientRect();
  const cs   = getComputedStyle(consentBlock);
  // Minimum readable width: 200px. Anything narrower = column trap candidate.
  if (rect.width < 200 && cs.overflow !== 'auto' && cs.overflow !== 'scroll') {
    return {
      severity: rect.width < 80 ? 'critical' : 'high',
      message: `Consent block rendered at ${Math.round(rect.width)}px width — unreadable`
    };
  }
  return null;
}

Summary: the four column-trap attacks

Attack Mechanism Severity Static detectable
Column miniaturization break-inside:avoid-column + column-count≥15 → 30px columns CRITICAL Yes (check column width)
Off-screen column exile Non-consent avoid-column blocks consume all visible columns; consent overflows HIGH No — requires runtime layout
column-fill:auto forcing Sequential fill + fixed height + large avoid blocks → consent displaced HIGH Partial — check column-fill:auto + fixed height
-webkit alias evasion Prefixed form overrides standard benign value; scanner misses prefix HIGH Yes (with prefix-aware scanner)

Remediation

Control How it helps Completeness
Runtime column-width audit Measure getBoundingClientRect().width of consent block; reject if <200px Catches Attacks 1 & 4
Runtime column-position audit Check consent block is in a column with left < containerRight Catches Attacks 2 & 3
CSS column-count restriction Sandbox policy: reject any column-count value > 3 in consent containers Partial (not all column-trap attacks use explicit column-count)
Prefix-aware property scan Check all four break-inside forms including -webkit-column-break-inside Catches Attack 4
Scroll gate on inner consent element Observe the consent block directly via IntersectionObserver; do not use scroll events on outer wrappers Makes Accept-button bypass harder but not impossible
Content integrity hash Hash the consent text at render time and compare to server-provided expected hash Catches any manipulation that alters the visible text

SkillAudit checks all four attack variants — including the -webkit-column-break-inside prefix form — during both static analysis and runtime sandbox audit. A passing SkillAudit grade means the consent block renders at full readable width and is positioned within the visible column range of its container. Run a free audit on any MCP server GitHub URL.

Related reading