MCP server CSS ::marker security: consent list bullet transparency, false checkmark substitution, oversized marker covering text, and counter corruption attacks
Published 2026-07-25 — SkillAudit Research
The CSS ::marker pseudo-element (fully supported in all modern browsers since 2020) matches the list item marker — the bullet, number, or custom counter-generated string that appears before a <li> element. The ::marker pseudo-element accepts a restricted set of CSS properties: color, content, font-size, font-family, font-style, font-weight, text-decoration, unicode-bidi, white-space, and animation/transition. Of these, color, content, and font-size are the most dangerous for consent manipulation.
Multi-step consent flows are the primary target for ::marker attacks. A typical consent checklist presents items as an ordered or unordered list: "1. We collect your email address", "2. We share data with partners", "3. You can withdraw consent at any time". The ::marker pseudo-element controls the "1.", "2.", "3." prefixes. Corrupting these markers destroys the logical structure of the consent flow without altering the consent text content.
::marker detection gap: Like other pseudo-elements, ::marker styles require getComputedStyle(element, '::marker') to retrieve. Standard consent audits that iterate document.querySelectorAll('li') and check element visibility see the list items as fully visible, with intact text content. The markers — which may be invisible, misleading, or oversized — are not inspected. A content: '✓' rule that changes all step numbers to checkmarks is indistinguishable from a DOM-only audit.
Attack 1: color:transparent hides step numbers from consent disclosure list
An MCP server applies ::marker { color: transparent } to the consent list items. All step numbers or bullets become invisible — the list renders as unnumbered, unmarked paragraphs that are harder to parse as a structured multi-step consent flow. Users lose the ability to count how many consent items there are, which item they are reviewing, and whether they have reached the end of the list. Regulatory requirements for a structured, navigable consent disclosure are undermined without hiding any consent text. The effect is primarily navigational corruption: the user cannot track their position in the consent flow.
/* MCP attack — consent list markers invisible: */
.consent-checklist li::marker,
ol.consent-steps li::marker,
#gdpr-disclosures li::marker {
color: transparent;
/* Step numbers "1.", "2.", "3." become transparent.
List items:
• [invisible 1.] We collect your email address.
• [invisible 2.] We share data with advertising partners.
• [invisible 3.] Data may be transferred to the United States.
User sees:
We collect your email address.
We share data with advertising partners.
Data may be transferred to the United States.
(Appears as unnumbered paragraphs — navigational structure destroyed)
DOM: li.textContent → '1.We collect your email address.' (number is in CSS)
Wait — actually the step number from CSS counter is NOT in textContent.
li.textContent → 'We collect your email address.' (marker not in DOM text)
*/
}
// Detection: check ::marker color on consent lists
function detectMarkerColorAttack() {
const listItems = document.querySelectorAll(
'.consent-checklist li, ol[class*="consent"] li, [class*="disclosure"] li'
);
listItems.forEach((li, index) => {
const pseudoStyle = window.getComputedStyle(li, '::marker');
const markerColor = pseudoStyle.color;
if (markerColor === 'rgba(0, 0, 0, 0)' || markerColor === 'transparent') {
console.error('SECURITY: ::marker color is transparent on consent list item ' + (index+1) + ':', {
li, textContent: li.textContent.substring(0, 80)
});
}
});
// Scan stylesheets for ::marker color rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText && rule.selectorText.includes('::marker')) {
const color = rule.style.color;
if (color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
console.error('SECURITY: ::marker color:transparent rule in stylesheet:', {
selector: rule.selectorText
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
Attack 2: content:'✓' substitutes false acceptance checkmarks for all consent step numbers
The ::marker pseudo-element supports the content property. An MCP server sets ::marker { content: '✓ ' } on all consent list items. All step numbers ("1.", "2.", "3.") or bullets ("•") are replaced with green checkmarks. The visual implication is that all consent items have already been accepted or acknowledged — users who see a list with pre-filled checkmarks may interpret this as confirmation that they have already agreed to all items, and proceed without reading. The deceptive UI pattern exploits the familiar UX convention where checkmarks indicate completed or accepted items.
/* MCP attack — all consent steps show acceptance checkmarks: */
.consent-list li::marker {
content: '✓ ';
color: #22c55e; /* green color reinforces "accepted" visual meaning */
font-weight: bold;
/* User sees:
✓ We collect your email address and browsing history.
✓ We share your data with advertising partners in 47 countries.
✓ We use your data for AI model training without compensation.
✓ Data retention period: indefinite.
All four checkmarks are identical. The user interprets this as:
"I have already accepted all four items" — and doesn't read them,
since they appear to be already-confirmed.
The actual DOM: Attack 3: font-size:3em bloats markers to cover adjacent consent list item text
The ::marker pseudo-element supports font-size. A standard list marker ("•" or "1.") is sized to match the list item's font-size. Setting ::marker { font-size: 3em } makes the marker three times larger than the list item text. The oversized marker glyph — particularly for disc bullets or wide Unicode characters — can physically overlap the beginning of the list item text to its right. The first words of each consent item are covered by the bloated marker glyph, making the critical opening clause of each consent disclosure unreadable. The effect is particularly severe for Arabic, Hebrew, or other RTL consent text where the marker appears on the right side of the item.
/* MCP attack — consent list markers bloated to cover item text: */
.consent-disclosure-list li::marker {
font-size: 3em;
/* Default: marker and list item text same size (1em each)
Attack: marker is 3× the list item text size
A "1." marker at 3em on 14px text = 42px numerals
The "1." digits physically overlap "We collect..." text
For disc bullets:
font-size: 3em makes • a ~42px disc that covers the
first word(s) of the consent item beside it.
For items with ::marker { content: '•'; font-size: 3em }:
The bullet occupies the left marker box AND overflows
into the list item content box, covering opening text. */
}
/* More subtle variant: use a wide Unicode block character: */
.consent-items li::marker {
content: '\25A0'; /* ■ BLACK SQUARE — wider than bullet */
font-size: 2em;
color: var(--bg); /* transparent square covers consent text */
/* The 2em black square matches page background color.
It acts as an invisible rectangle covering the first
word of each consent item.
The marker box extends into the list item content box
when font-size exceeds the normal marker box allocation. */
}
// Detection: check ::marker font-size relative to list item font-size
function detectMarkerFontSizeBloat() {
const listItems = document.querySelectorAll(
'.consent-list li, .consent-checklist li, [class*="consent"] li, [class*="disclosure"] li'
);
listItems.forEach(li => {
const pseudoStyle = window.getComputedStyle(li, '::marker');
const liStyle = window.getComputedStyle(li);
const markerFontSize = parseFloat(pseudoStyle.fontSize);
const liFontSize = parseFloat(liStyle.fontSize);
// Markers larger than 2× the list item font size are suspicious
if (markerFontSize > liFontSize * 2) {
console.error('SECURITY: ::marker font-size (' + markerFontSize + 'px) is',
Math.round(markerFontSize / liFontSize) + '× the list item font-size (' + liFontSize + 'px):', {
li, text: li.textContent.substring(0, 60)
});
}
// Also check for background-colored content
const markerColor = pseudoStyle.color;
const pageBg = window.getComputedStyle(document.body).backgroundColor;
if (markerFontSize > liFontSize * 1.5 && markerColor === pageBg) {
console.error('SECURITY: ::marker is large AND matches page background (invisible bloat):', {
li, markerFontSize, markerColor
});
}
});
}
Attack 4: counter manipulation via ::marker content attr produces wrong step numbers
CSS counters work in conjunction with ::marker. In an ordered list, each <li> auto-increments a counter and ::marker renders it. An MCP server can use a separate counter-reset on a parent element combined with a custom ::marker { content: counter(my-counter) '/' counter(total-steps) } to display step progress indicators ("Step 2 of 5"). By injecting counter-reset: total-steps 99 on a non-visible ancestor, the progress shows "Step 2 of 100" instead of "Step 2 of 5" — making the consent flow appear impossibly long and discouraging users from continuing. Alternatively, setting counter-increment: my-counter 0 on all list items freezes the counter, making all items show "Step 1 of 5".
/* MCP attack — counter corruption makes consent steps appear infinitely long: */
/* Attacker injects this rule (e.g., via a loaded MCP stylesheet): */
body, html, .consent-container {
counter-reset: total-steps 99;
/* The consent framework sets: counter-reset: total-steps 5 (for a 5-step flow).
MCP overrides this on body/html with higher specificity: total-steps = 99.
::marker content: 'Step ' counter(step-num) ' of ' counter(total-steps) '.'
Now renders: "Step 1 of 100", "Step 2 of 100", ..., "Step 5 of 100"
Users believe the consent form has 100 steps — abandon the flow. */
}
/* Variant: freeze all items at step 1: */
.consent-steps li {
counter-increment: step-num 0; /* increment by 0 = no change */
/* All items: "Step 1 of 5", "Step 1 of 5", "Step 1 of 5", "Step 1 of 5"
Users cannot determine which consent category they are reviewing. */
}
/* Variant: reverse count — steps run backwards: */
.consent-items {
counter-reset: step-num 6; /* start at 6 for a 5-step flow */
}
.consent-items li {
counter-increment: step-num -1; /* decrement: 5, 4, 3, 2, 1 */
/* "Step 5 of 5", "Step 4 of 5", "Step 3 of 5", "Step 2 of 5", "Step 1 of 5"
Users start at the "last" step and see it winding down — may think they are
at the end of a consent flow they haven't actually read. */
}
// Detection: check for counter manipulation on consent parent elements
function detectCounterCorruption() {
// Check for counter-reset on non-list ancestors
const potentialAttackers = ['body', 'html', '#app', '.wrapper', '#root', '.page'];
potentialAttackers.forEach(sel => {
const el = document.querySelector(sel);
if (el) {
const style = window.getComputedStyle(el);
// counterReset not directly readable from getComputedStyle in all browsers
// Fall back to stylesheet inspection
}
});
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule) {
const counterReset = rule.style.counterReset || rule.style.getPropertyValue('counter-reset');
const counterIncrement = rule.style.counterIncrement || rule.style.getPropertyValue('counter-increment');
if (counterReset && counterReset !== 'none') {
// Check if the reset value is suspiciously high
const matches = counterReset.match(/(\w+)\s+(\d+)/g);
if (matches) {
matches.forEach(m => {
const parts = m.split(/\s+/);
const value = parseInt(parts[1]);
if (value > 20) {
console.error('SECURITY: suspiciously high counter-reset value (counter-corruption attack):', {
selector: rule.selectorText, counterReset, value
});
}
});
}
}
if (counterIncrement && counterIncrement.includes(' 0')) {
// counter-increment: name 0 freezes counter
console.warn('SECURITY: counter-increment of 0 detected (step freeze attack):', {
selector: rule.selectorText, counterIncrement
});
}
if (counterIncrement && /\s-\d+/.test(counterIncrement)) {
// Negative counter-increment reverses step count
console.error('SECURITY: negative counter-increment (reverse step count attack):', {
selector: rule.selectorText, counterIncrement
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
::marker attacks corrupt multi-step consent navigation: Regulated consent flows (GDPR Art. 7, ePrivacy Directive, CCPA) increasingly require stepped consent presentation where users explicitly acknowledge each disclosure category. ::marker attacks do not hide the consent text — they corrupt the navigational structure of the multi-step flow. Users who cannot determine their position in a stepped consent process may abandon the flow (if counters show 100 steps), accept items they believe are already accepted (if markers show ✓), or fail to count the actual number of consent disclosures (if markers are invisible). All three outcomes serve the MCP server's goal of unconsented data processing.
Attack summary
| Attack | CSS property | Effect on consent | Severity |
|---|---|---|---|
| Color transparency | ::marker { color: transparent } |
Step numbers/bullets invisible; list structure unnavigable | Medium |
| Checkmark substitution | ::marker { content: '✓'; color: green } |
All steps appear pre-accepted; users skip reading | High |
| Font-size bloat | ::marker { font-size: 3em } |
Oversized marker covers adjacent consent item text | High |
| Counter corruption | counter-reset: steps 99 on ancestor |
Step progress shows impossible counts; users abandon flow | Medium |
Consolidated findings
::marker { content: '✓'; color: #22c55e } to consent list items. All step markers are replaced with green checkmarks, creating a visual impression that all consent items have been accepted or acknowledged. Users may interpret the pre-filled checkmarks as confirmation of prior acceptance and skip reading the consent text. The DOM contains no acceptance-state indicators — only the CSS-generated marker content shows the deceptive checkmarks.
font-size: 3em on ::marker, making the bullet or step number three times larger than the list item text. The oversized marker glyph physically overlaps the beginning of each consent item, covering the opening words of each disclosure. First words of items like "We share your data with advertising partners" are covered by the bloated "3." numeral or "•" disc. Detection requires comparing getComputedStyle(li, '::marker').fontSize against the list item's own font-size.
← Blog | CSS ::placeholder attacks | CSS ::backdrop attacks | Security Checklist