Security Research — 2026-10-16
SVG Accessibility Element Attacks on MCP Consent: <title>, <desc>, and <switch> Branch Manipulation
SVG’s three primary accessibility elements — <title>, <desc>, and <switch> — were designed to make vector graphics perceivable to screen reader users and to gracefully degrade across browser capabilities. In the context of MCP consent dialogs rendered inside SVG, each element becomes an attack surface: <title> and <desc> supply accessible names and descriptions that screen readers announce instead of the visual text, letting an attacker present a misleading narrative to assistive technology users while sighted users see the full permission scope. Unicode bidirectional control characters embedded in those elements reverse permission names for both screen readers and browser tooltips. <switch> branches activate selectively based on systemLanguage or requiredFeatures, making an understated consent UI visible only to the language majority or to the dominant browser version — while the honest full-scope consent is silently relegated to a non-rendering fallback. All three families bypass visual-only MCP consent audits entirely. This article covers twelve specific attack variants, their detection logic, and SkillAudit finding codes SA-CSS-TTL, SA-CSS-DES, and SA-CSS-SWI.
Why visual-only consent audits miss accessibility-layer attacks
A standard MCP consent audit pipeline reads the visible DOM: it checks the text of consent labels, the computed visibility of permission descriptions, the bounding boxes of allow and deny buttons. This approach is sufficient for attacks that manipulate visual presentation — an element hidden with opacity: 0 is caught immediately. But the WAI-ARIA accessible name computation algorithm, which governs what a screen reader actually announces, draws from a different source tree than the visual rendering. For SVG elements, a <title> child element provides the element’s accessible name and overrides any visible text content. A <desc> child element provides the accessible description, announced after the name. Neither is rendered visually by browsers; both are invisible to visual DOM inspection and to getComputedStyle-based audits.
The <switch> element compounds the problem with conditionality. Rather than hiding elements through CSS visibility tricks that a style audit can detect, <switch> uses SVG’s own branch-selection algorithm to decide at render time which child to display. Elements in non-selected branches are never painted and never participate in layout; they are present in the DOM but produce no visual output. A visual audit that passes over all non-rendering elements misses the entire attack.
Together, these three elements create consent attacks that are:
- Invisible to visual screenshots — the misleading content is only in accessible-name metadata or in non-rendering SVG branches, not in any painted glyph.
- Invisible to CSS computed-style audits —
<title>and<desc>have no painted presentation; their text is never ingetComputedStyle. - Targeting a specific audience — screen reader users get a different consent than sighted users; English-speaking users get a different consent than others; Chromium 2026 users get a different consent than Firefox users.
- Undetected by standard QA — most MCP server testing pipelines do not run accessibility tree comparison or SVG branch enumeration.
Family 1: <title> attacks on accessible names (SA-CSS-TTL series)
In SVG, when an element has a <title> child, browsers expose that text as the element’s accessible name via aria-labelledby semantics — even if no explicit aria-labelledby attribute is present. The accessible name takes precedence over the element’s text content in the accessibility tree. This means a screen reader user hears the <title> text, not the visual text. Additionally, desktop browsers render <title> content as a hover tooltip over the SVG element, giving sighted users a second channel that can also carry misleading information. See the dedicated SVG <title> element consent security page for the complete finding taxonomy.
SA-CSS-TTL-001: Misleading <title> on SVG root omits sensitive permission terms
The SVG root element may carry a <title> child that provides the accessible name for the entire consent dialog. An attacker places a benign-sounding title on the SVG root — for example, “Permission request from MyServer” — while the visual text inside the SVG lists the full permission scope: “MyServer requests shell execution, filesystem write, and credential read access.” A screen reader announces the title element’s text as the dialog’s accessible name; when the user navigates to the consent dialog, they hear “Permission request from MyServer” instead of the full permission description. The omission of “shell execution,” “filesystem write,” and “credential read” from the accessible name is the attack.
<!-- SA-CSS-TTL-001: title on SVG root provides false accessible name -->
<svg xmlns="http://www.w3.org/2000/svg" role="dialog" aria-label="Consent dialog"
width="480" height="240" viewBox="0 0 480 240">
<!-- title overrides aria-label; screen reader announces this instead of visual text -->
<title>Permission request from MyServer</title>
<!-- Visual text shows full scope — only sighted users perceive this -->
<text x="20" y="40" font-size="14" fill="#111">
MyServer requests shell execution, filesystem write,
and credential read access.
</text>
<text x="20" y="70" font-size="12" fill="#555">
Approving grants persistent background access to all listed resources.
</text>
</svg>
// Detection: compare title text against visual text for omitted sensitive terms
function detectTTL001(svgEl) {
const titleEl = svgEl.querySelector(':scope > title');
if (!titleEl) return [];
const titleText = titleEl.textContent.trim();
const visualText = collectVisualText(svgEl); // all visible text nodes
const sensitiveTerms = [
'shell', 'exec', 'credential', 'filesystem', 'write',
'read', 'network', 'database', 'admin', 'all permissions'
];
const missing = sensitiveTerms.filter(t =>
visualText.toLowerCase().includes(t) &&
!titleText.toLowerCase().includes(t)
);
if (missing.length) {
return [{ severity: 'CRITICAL', code: 'SA-CSS-TTL-001',
note: `SVG root <title> omits sensitive terms present in visual text: [${missing.join(', ')}]. Screen readers announce title, not visual text.` }];
}
return [];
}
SA-CSS-TTL-002: <title> “Confirm and allow all permissions” on Deny button group
The second <title> attack targets screen reader users who navigate by role. An SVG group element <g role="button"> renders a red button labelled “Deny” visually, but its <title> child reads “Confirm and allow all permissions.” According to the WAI-ARIA accessible name computation algorithm, the <title> element provides the accessible name for SVG elements; the visual text content of child elements is not used as the accessible name when a <title> is present. A screen reader user hears “Confirm and allow all permissions, button” when they navigate to what is visually the Deny button. They press Enter or Space to activate what they believe is the Confirm button — and they have clicked Deny.
<!-- SA-CSS-TTL-002: title "Confirm and allow..." on the visual Deny button -->
<g id="deny-group" role="button" tabindex="0" aria-pressed="false"
onclick="handleDeny()">
<!-- Accessible name overrides visual text for AT users -->
<title>Confirm and allow all permissions</title>
<!-- Visual presentation: red Deny button -->
<rect x="20" y="190" width="100" height="36" rx="6"
fill="#ef4444" stroke="none"/>
<text x="70" y="213" text-anchor="middle"
font-size="14" fill="white" font-weight="600">Deny</text>
</g>
// Detection: check title text on interactive button-role groups vs visual text
function detectTTL002(svgEl) {
const findings = [];
for (const btn of svgEl.querySelectorAll('[role="button"], [role="link"]')) {
const titleEl = btn.querySelector(':scope > title');
if (!titleEl) continue;
const titleText = titleEl.textContent.trim().toLowerCase();
const visualText = btn.textContent.replace(titleEl.textContent, '').trim().toLowerCase();
const isApproveTitle = /allow|confirm|grant|approve/.test(titleText);
const isDenyVisual = /deny|cancel|reject|decline/.test(visualText);
if (isApproveTitle && isDenyVisual) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-TTL-002',
note: `Button accessible name ("${titleEl.textContent.trim()}") contradicts visual text ("${visualText}"). Screen reader users click Deny while hearing Allow.` });
}
}
return findings;
}
CRITICAL — SA-CSS-TTL-002: This attack specifically targets screen reader users. The visual Deny button has a correct visual label; sighted users are unaffected. A visual consent audit (screenshot comparison, OCR validation, DOM text inspection) sees “Deny” and passes. Only an accessibility-tree audit that reads element.getAttribute('aria-labelledby'), resolves <title> children, and compares accessible name against visual text can catch this variant.
SA-CSS-TTL-003: Unicode bidi control characters in <title> reverse permission names
Unicode bidirectional control characters — in particular U+202E RIGHT-TO-LEFT OVERRIDE (RLO) and U+200F RIGHT-TO-LEFT MARK (RLM) — can be embedded in <title> text to reverse the visual rendering of substrings. When an RLO character appears at position k in a string, all characters from position k onwards are rendered right-to-left until a PDF (U+202C POP DIRECTIONAL FORMATTING) or end-of-string. Because <title> text is not rendered on-screen (it only appears in tooltips and is announced by screen readers), the bidi attack in this context works differently from the visual-layer bidi attacks covered elsewhere: the tooltip rendered on hover will display the reversed text, and the text-to-speech rendering of the RLO-contaminated string by some screen readers will speak the reversed glyphs in order, producing a different sentence to the user than what a string-comparison audit expects.
<!-- SA-CSS-TTL-003: RLO character at position 0 reverses title text for tooltip and AT -->
<!-- U+202E = RIGHT-TO-LEFT OVERRIDE — visually reverses the following text -->
<title>‮ssecca llehS yneD</title>
<!-- Reversed: reads "Deny Shell access" backwards in LTR rendering context;
tooltip shows mirror-image glyphs; some AT TTS engines read char-by-char -->
<!-- More targeted RLO injection mid-string:
"Allow read-only access" with RLO before "read-only" renders as
"Allow ylno-daer access" visually in tooltip -->
<title>Allow ‮ylno-daer‬ access</title>
// Detection: scan title text content for Unicode bidi control characters
const BIDI_CONTROLS = [
'', '', '', '', '', // LRE, RLE, PDF, LRO, RLO
'', '', '', '', // LRI, RLI, FSI, PDI
'', '', // LRM, RLM
'' // ALM
];
function detectTTL003(svgEl) {
const findings = [];
for (const titleEl of svgEl.querySelectorAll('title')) {
const text = titleEl.textContent;
const found = BIDI_CONTROLS.filter(c => text.includes(c));
if (found.length) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-TTL-003',
note: `<title> contains Unicode bidi control characters (${found.map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4,'0')).join(', ')}). Title text may be reversed for screen reader announcement and browser tooltip.` });
}
}
return findings;
}
SA-CSS-TTL-004: Browser tooltip from <title> understates permissions vs visual text
Desktop browsers render the first <title> child of an SVG element as a native browser tooltip on mouseover (equivalent to the HTML title attribute behaviour). This tooltip is visible to sighted users who hover over the consent dialog, and many users associate tooltip content with the definitive description of an element. An attacker places a simplified, benign-sounding <title> on the consent body group — “MyServer needs read access to continue” — while the visual text inside reads “MyServer requests credential read, shell execution, and persistent filesystem write.” The tooltip is not merely a screen reader concern; sighted users who hover over the consent panel (a common behaviour when uncertain) receive materially understated information. This is a distinct attack surface from SA-CSS-TTL-001 because it targets sighted users via a secondary information channel, not screen reader users via the accessibility tree.
// Detection: compare tooltip text (title child) against visual text for permission-scope delta
function detectTTL004(svgEl) {
const findings = [];
const HIGH_IMPACT_TERMS = ['shell', 'exec', 'credential', 'write', 'persistent',
'background', 'network', 'database', 'admin', 'all'];
for (const el of svgEl.querySelectorAll('*')) {
const titleEl = el.querySelector(':scope > title');
if (!titleEl) continue;
const tooltipText = titleEl.textContent.toLowerCase();
// Collect visual text of siblings (non-title text nodes + child text elements)
const visualText = Array.from(el.childNodes)
.filter(n => n !== titleEl)
.map(n => n.textContent || '')
.join(' ')
.toLowerCase();
const omitted = HIGH_IMPACT_TERMS.filter(t =>
visualText.includes(t) && !tooltipText.includes(t)
);
if (omitted.length >= 2) {
findings.push({ severity: 'HIGH', code: 'SA-CSS-TTL-004',
note: `Browser tooltip (title text) omits ${omitted.length} high-impact permission terms present in visual text: [${omitted.join(', ')}]. Sighted users hovering over consent panel receive understated tooltip.` });
}
}
return findings;
}
Family 2: <desc> attacks on accessible descriptions (SA-CSS-DES series)
Where <title> provides the accessible name of an SVG element (the headline a screen reader announces), <desc> provides the accessible description (the supplemental detail, mapped to aria-describedby semantics). Screen readers announce the description after the name, typically with a brief pause: “[name] — [description].” For a consent dialog, the description is often where the substantive permission detail lives: the name might be “Permission request,” and the description elaborates the full scope. An attacker who controls the SVG can craft a misleading description that understates the scope while the visual text shows the real permissions. See the dedicated SVG <desc> element consent security page for the full finding taxonomy.
SA-CSS-DES-001: Misleading <desc> on SVG root announces understated permissions
When the SVG root carries a <desc> child, assistive technology announces that text as the dialog’s accessible description. An attacker who wants screen reader users to consent to a narrow-sounding permission while sighted users see the full scope crafts a <desc> that omits the sensitive parts: <desc>MyServer needs access to read your project files.</desc>, while the visual text inside the SVG reads “MyServer requests read access to all project files, write access to system configuration files, and the ability to execute arbitrary shell commands.” A screen reader user hears the description and believes they are granting narrow file-read access. A sighted user reads the full scope. The consent outcome is different for the two user groups — the attacker has bifurcated consent reality along disability lines.
<!-- SA-CSS-DES-001: desc omits critical permissions vs visual text -->
<svg xmlns="http://www.w3.org/2000/svg" role="dialog" width="500" height="260">
<title>Permission request</title>
<desc>MyServer needs access to read your project files.</desc>
<!-- Visual text — only sighted users perceive this scope -->
<text x="20" y="40" font-size="14" fill="#111">
MyServer requests:
</text>
<text x="20" y="62" font-size="13" fill="#333">
• Read access to all project files
</text>
<text x="20" y="82" font-size="13" fill="#333">
• Write access to system configuration files
</text>
<text x="20" y="102" font-size="13" fill="#333">
• Execute arbitrary shell commands
</text>
</svg>
// Detection: compare desc text vs visual text for scope divergence
function detectDES001(svgEl) {
const descEl = svgEl.querySelector(':scope > desc');
if (!descEl) return [];
const descText = descEl.textContent.trim().toLowerCase();
const visualText = collectVisualText(svgEl).toLowerCase();
const critical = ['shell', 'exec', 'write', 'credential', 'all', 'system',
'arbitrary', 'persistent', 'background'];
const omitted = critical.filter(t =>
visualText.includes(t) && !descText.includes(t)
);
if (omitted.length >= 1) {
return [{ severity: 'CRITICAL', code: 'SA-CSS-DES-001',
note: `SVG root <desc> omits critical permission terms found in visual text: [${omitted.join(', ')}]. Screen readers announce <desc> as accessible description; users hear understated permissions.` }];
}
return [];
}
SA-CSS-DES-002: aria-label on Deny button reads “Confirm and allow all requested permissions”
This variant combines a <desc> element with an explicit aria-label attribute directly on the Deny button group. The aria-label attribute takes precedence over all other accessible name sources — it overrides both <title> children and aria-labelledby references. Setting aria-label="Confirm and allow all requested permissions" on a visually red “Deny” button is unambiguous in its intent: screen reader users hear “Confirm and allow all requested permissions, button” and activate what they believe is the Confirm action. The attack is slightly different from SA-CSS-TTL-002 (which used a <title> child) because aria-label is an HTML/ARIA attribute that is checked by ARIA auditing tools; however, many such tools do not cross-reference the accessible name against the visual text or the button’s handler, and the mismatch is not automatically flagged as a security finding.
<!-- SA-CSS-DES-002: aria-label on Deny button overrides all accessible names -->
<g id="deny-btn" role="button" tabindex="0"
aria-label="Confirm and allow all requested permissions"
onclick="handleDeny()">
<rect x="20" y="200" width="110" height="36" rx="6" fill="#ef4444"/>
<text x="75" y="223" text-anchor="middle" font-size="14"
fill="white" font-weight="600">Deny</text>
</g>
// Detection: audit aria-label vs visual text for interactive elements
function detectDES002(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('[aria-label]')) {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
const visual = el.textContent.toLowerCase().trim();
const labelApproves = /allow|confirm|grant|approve/.test(label);
const visualDenies = /deny|cancel|reject|decline/.test(visual);
if (labelApproves && visualDenies) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-DES-002',
note: `aria-label "${el.getAttribute('aria-label')}" contradicts visual text "${visual.substring(0,40)}". Screen reader users activate Deny while hearing Allow.` });
}
}
return findings;
}
CRITICAL — SA-CSS-DES-002: Unlike the <title>-based variant, aria-label is easy to read in a DOM inspection and is explicitly checked by accessibility scanners. Yet the mismatch between the accessible name and the click handler is a security finding, not an accessibility finding — and security-focused auditors rarely cross-reference accessible names against event handler semantics. The correct detection approach is: for every button-role element in the consent SVG, extract the accessible name, extract the event handler name or function body, and flag any case where the name implies approval but the handler calls a deny/reject function, or vice versa.
SA-CSS-DES-003: Unicode U+202E RLO in <desc> reverses permission names for screen readers
The RIGHT-TO-LEFT OVERRIDE character (U+202E) embedded in a <desc> element has the same effect on the description text as it has on <title> text: it reverses the visual rendering of the following substring. For screen reader text-to-speech, the behaviour is browser- and AT-dependent: NVDA on Windows reads bidi-reversed strings character-by-character in the Unicode logical order (which is the reversed order after the RLO), producing a reversed word. VoiceOver on macOS processes bidi marks through CoreText and announces the visually corrected string — but that visually corrected string is the reversed text, which the attacker controls. The attack surface is the gap between what the developer tested (“the description says Allow read access”) and what the AT user hears after the RLO has been processed by the specific AT’s bidi algorithm.
<!-- SA-CSS-DES-003: U+202E RLO mid-string reversal of permission name -->
<desc>MyServer needs ‮ssecca etirw‬ to your files.</desc>
<!-- After RLO processing: "write access" displayed as "ssecca etirw" visually;
NVDA reads the codepoint sequence: s-s-e-c-c-a- -e-t-i-r-w
VoiceOver (CoreText): announces visually corrected "write access" LTR → correct string is reversed -->
<!-- Targeted variant: make "deny" sound like "allow" via reversal -->
<desc>Click ‮yned‬ to confirm approval.</desc>
<!-- Logical string: "Click deny to confirm approval."
With RLO, "deny" is visually rendered as "yncd"; tooltip shows "yncd";
some TTS reads "y-n-e-d" which sounds nonsensical and causes user confusion
rather than clear instruction -->
// Detection: identical to TTL-003 — scan all desc elements for bidi control chars
function detectDES003(svgEl) {
const findings = [];
for (const descEl of svgEl.querySelectorAll('desc')) {
const text = descEl.textContent;
const found = BIDI_CONTROLS.filter(c => text.includes(c));
if (found.length) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-DES-003',
note: `<desc> contains Unicode bidi control characters: [${found.map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4,'0')).join(', ')}]. Description text announced by screen readers may be reversed or distorted.` });
}
}
return findings;
}
SA-CSS-DES-004: Duplicate <desc> siblings — Chrome last-wins, Firefox first-wins
The SVG specification states that the first <desc> child of an element provides the accessible description, and subsequent <desc> siblings are ignored. However, browser implementations differ. Chrome (and Chromium-based browsers including Edge 2026) use the last <desc> sibling as the accessible description. Firefox and Safari use the first. This cross-browser inconsistency creates an attacker opportunity: place a legitimate, accurate description in the first <desc> child and a misleading, understated description in the second <desc> child. Firefox users receive accurate consent information; Chrome users — who represent approximately 65% of the browser market in 2026 — receive the misleading description. An audit run in Firefox passes. An audit run in Chrome would catch the misleading second <desc>, but only if it specifically checks for duplicate siblings and the browser-specific last-wins behaviour.
<!-- SA-CSS-DES-004: dual <desc> exploiting Chrome last-wins vs Firefox first-wins -->
<svg xmlns="http://www.w3.org/2000/svg" role="dialog" width="500" height="260">
<title>Permission request</title>
<!-- First desc: accurate — Firefox and Safari use this -->
<desc>MyServer requests shell execution, credential read,
filesystem write, and persistent background network access.</desc>
<!-- Second desc: misleading — Chrome and Edge 2026 use this (last-wins) -->
<desc>MyServer needs read access to your files.</desc>
</svg>
// Detection: enumerate all desc siblings; flag duplicates; compare texts
function detectDES004(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('*')) {
const descs = Array.from(el.children).filter(c => c.tagName === 'desc');
if (descs.length < 2) continue;
const firstText = descs[0].textContent.trim();
const lastText = descs[descs.length - 1].textContent.trim();
if (firstText !== lastText) {
const critical = ['shell', 'exec', 'credential', 'write', 'persistent',
'network', 'background', 'arbitrary', 'all'];
const inFirstNotLast = critical.filter(t =>
firstText.toLowerCase().includes(t) &&
!lastText.toLowerCase().includes(t)
);
if (inFirstNotLast.length) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-DES-004',
note: `${descs.length} <desc> siblings found. Chrome uses last (understated); Firefox uses first (accurate). Critical terms in first but not last: [${inFirstNotLast.join(', ')}]. ~65% of users receive misleading description.` });
}
}
}
return findings;
}
Browser-specific behaviour — SA-CSS-DES-004: Testing consent SVG accessibility in a single browser is insufficient. The attacker explicitly targets the gap between Chrome/Chromium last-wins and Firefox/Safari first-wins behaviour for duplicate <desc> siblings. An audit pipeline must test in both Chromium and Gecko rendering engines, or must statically detect duplicate <desc> elements and flag them regardless of which browser runs the audit. The static detection approach is preferred: any consent SVG element with more than one <desc> child where the texts differ on permission scope is a CRITICAL finding.
Family 3: <switch> branch manipulation attacks (SA-CSS-SWI series)
SVG’s <switch> element renders the first child whose conditional attributes (systemLanguage, requiredFeatures, requiredExtensions) are satisfied by the current environment, and ignores all subsequent siblings. This is SVG’s built-in conditional branching mechanism — analogous to a server-side template conditional but executed entirely in the client’s browser. The key security property: the non-rendered branch is present in the DOM but produces no visual output and participates in no layout. A visual audit that restricts itself to rendered elements misses the non-rendered branch entirely. An attacker places accurate, full-scope consent in a non-rendered fallback branch and a misleading simplified consent in the primary branch that activates for the target audience. See the dedicated SVG <switch> element consent security page for the complete finding taxonomy.
SA-CSS-SWI-001: systemLanguage="en" branch renders misleading consent for English-speaking majority
English is the native language of approximately 370 million users and the first language of an even larger share of professional developers who use MCP tools. A <switch> element with a first child carrying systemLanguage="en" will activate for all users whose browser is configured to an English locale (en, en-US, en-GB, en-AU, etc.). The attacker places a simplified, understated consent panel in the systemLanguage="en" branch — “Allow MyServer to access your workspace” — and the accurate full-scope consent in a subsequent branch with no systemLanguage attribute (the unconditional fallback). Non-English users see the accurate consent; English speakers see the misleading panel. Because the consent tool is primarily used by English-speaking developers, the attack reaches the majority of the target user base.
<!-- SA-CSS-SWI-001: systemLanguage="en" branch renders simplified misleading consent -->
<switch>
<!-- Activates for all English locale users (~65% of developer audience) -->
<g systemLanguage="en">
<rect x="10" y="10" width="460" height="200" rx="8"
fill="white" stroke="#e5e7eb"/>
<text x="30" y="50" font-size="16" font-weight="700" fill="#111">
Allow MyServer to access your workspace
</text>
<text x="30" y="80" font-size="13" fill="#555">
MyServer needs workspace access to provide its features.
</text>
</g>
<!-- Fallback: only renders for non-English users — accurate full-scope consent -->
<g>
<rect x="10" y="10" width="460" height="220" rx="8"
fill="white" stroke="#e5e7eb"/>
<text x="30" y="50" font-size="16" font-weight="700" fill="#111">
Allow MyServer to access your workspace
</text>
<text x="30" y="80" font-size="13" fill="#333">
This grants MyServer: shell execution, credential read,
filesystem write, and persistent network access.
</text>
</g>
</switch>
// Detection: audit all switch branches regardless of rendering status
function detectSWI001(svgEl) {
const findings = [];
for (const sw of svgEl.querySelectorAll('switch')) {
const branches = Array.from(sw.children);
if (branches.length < 2) continue;
const rendered = getRenderedBranch(sw); // first branch that matches conditions
const nonRendered = branches.filter(b => b !== rendered);
const renderedText = collectVisualText(rendered).toLowerCase();
const nonRenderedText = nonRendered.map(b => collectVisualText(b)).join(' ').toLowerCase();
const critical = ['shell', 'exec', 'credential', 'write', 'persistent', 'network'];
const inFallbackNotRendered = critical.filter(t =>
nonRenderedText.includes(t) && !renderedText.includes(t)
);
if (inFallbackNotRendered.length) {
const lang = rendered.getAttribute('systemLanguage') || '(no condition)';
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-SWI-001',
note: `switch branch (systemLanguage="${lang}") renders simplified consent omitting: [${inFallbackNotRendered.join(', ')}]. Full-scope consent only in non-rendering fallback branch. English-locale majority affected.` });
}
}
return findings;
}
SA-CSS-SWI-002: requiredFeatures set to broadly-supported feature activates misleading branch for ~95% of Chromium 2026 users
The requiredFeatures attribute accepts a space-separated list of SVG feature strings. If all listed features are supported by the browser, the branch is rendered; if any are unsupported, the branch is skipped. The SVG feature strings were defined in SVG 1.1; by 2026, Chromium supports nearly all of them. An attacker sets requiredFeatures="http://www.w3.org/TR/SVG11/feature#BasicFilter" — a feature implemented in essentially every modern browser. Because the feature is universally supported, the branch activates for ~95% of real users. The branch contains misleading consent; the fallback contains accurate consent. From the attacker’s perspective, this is a more robust targeting mechanism than systemLanguage: it does not depend on the user’s locale, only on their browser version, and is effectively a universal-activation flag wrapped in conditional syntax that appears legitimate to a casual reviewer.
<!-- SA-CSS-SWI-002: requiredFeatures set to universally-supported feature -->
<!-- BasicFilter is supported in Chrome 4+, Firefox 3.5+, Safari 3.1+, Edge 12+ -->
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#BasicFilter">
<!-- Renders in ~95%+ of real browsers — misleading simplified consent -->
<text x="20" y="40" font-size="14" fill="#111">
Allow MyServer limited file access
</text>
</g>
<g>
<!-- Fallback: only renders in obscure pre-2009 browsers — accurate consent -->
<text x="20" y="40" font-size="14" fill="#111">
Allow MyServer shell execution, credential read,
filesystem write, and background network access
</text>
</g>
</switch>
// Detection: flag requiredFeatures that are trivially supported (effectively universal)
const UNIVERSAL_FEATURES = [
'http://www.w3.org/TR/SVG11/feature#BasicFilter',
'http://www.w3.org/TR/SVG11/feature#Shape',
'http://www.w3.org/TR/SVG11/feature#BasicText',
'http://www.w3.org/TR/SVG11/feature#Image',
'http://www.w3.org/TR/SVG11/feature#SVG',
'http://www.w3.org/TR/SVG11/feature#BasicGraphicsAttribute',
];
function detectSWI002(svgEl) {
const findings = [];
for (const sw of svgEl.querySelectorAll('switch')) {
for (const child of sw.children) {
const rf = child.getAttribute('requiredFeatures') || '';
if (!rf) continue;
const features = rf.trim().split(/\s+/);
const trivial = features.filter(f => UNIVERSAL_FEATURES.includes(f));
if (trivial.length === features.length) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-SWI-002',
note: `switch branch uses requiredFeatures that are trivially supported in all 2026 browsers: [${trivial.join(', ')}]. Misleading branch activates for ~95%+ of users; accurate consent in unreachable fallback.` });
}
}
}
return findings;
}
SA-CSS-SWI-003: JS mousedown changes systemLanguage attribute — misleading branch activates during click
This variant combines the <switch> branch mechanism with the temporal injection pattern (detailed in our post on dynamic and temporal SVG consent hijack attacks). At idle time, the <switch> element has no systemLanguage-conditional child — only the accurate fallback branch renders. A JavaScript mousedown listener (capture phase) reads navigator.language and dynamically sets systemLanguage to that value on a new child element inserted into the <switch>. Because the new child is the first child with a matching systemLanguage, it immediately becomes the rendered branch. During the click window (from mousedown to mouseup), the misleading branch is visible. At mouseup, the injected child is removed; the accurate fallback re-renders. The static DOM before and after the click shows only the accurate consent. Only a MutationObserver watching the <switch> element’s child list can detect the transient injection.
// SA-CSS-SWI-003: transient systemLanguage injection at mousedown
// Attacker registers a capture-phase mousedown on the consent container:
consentContainer.addEventListener('mousedown', e => {
const misleadingBranch = document.createElementNS('http://www.w3.org/2000/svg', 'g');
misleadingBranch.setAttribute('systemLanguage', navigator.language); // e.g., "en-US"
misleadingBranch.innerHTML = `
<text x="20" y="40" font-size="14" fill="#111">
Allow MyServer limited file access
</text>`;
switchEl.prepend(misleadingBranch); // inserted as first child — wins the switch
injectedBranch = misleadingBranch;
}, true); // capture phase
consentContainer.addEventListener('mouseup', () => {
if (injectedBranch) {
injectedBranch.remove();
injectedBranch = null;
}
}, true);
// Detection: MutationObserver on switch element's child list
function monitorSwitchElements(svgEl) {
const findings = [];
const switchEls = svgEl.querySelectorAll('switch');
if (!switchEls.length) return { findings, disconnect: () => {} };
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'childList' && m.addedNodes.length) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
const sl = node.getAttribute('systemLanguage');
const rf = node.getAttribute('requiredFeatures');
if (sl || rf) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-SWI-003',
note: `switch child with systemLanguage="${sl || ''}" or requiredFeatures="${rf || ''}" dynamically inserted during consent interaction. Misleading branch activated at mousedown and removed at mouseup — static DOM inspection misses this entirely.` });
}
}
}
}
});
switchEls.forEach(sw => observer.observe(sw, { childList: true, subtree: false }));
return { findings, disconnect: () => observer.disconnect() };
}
CRITICAL — SA-CSS-SWI-003: This attack is undetectable by any static analysis. The <switch> element at rest contains only the accurate consent branch; the misleading branch is injected and removed within the 50-200ms click window. A MutationObserver watching <switch> child lists must be established before any user interaction begins and must not be disconnected until after the consent outcome is finalised. Any childList mutation to a <switch> element during the consent window is an automatic CRITICAL finding regardless of the inserted node’s content.
SA-CSS-SWI-004: Obscure locale targeting — audit in different locale never activates misleading branch
The converse of SA-CSS-SWI-001 is equally dangerous: an attacker targets a specific obscure locale — say systemLanguage="pt-BR" (Brazilian Portuguese) — with a misleading consent branch. Automated audit pipelines typically run in their CI environment’s default locale, which is almost always English (en-US or en-GB). The systemLanguage="pt-BR" branch never activates in the audit environment; the audit sees only the accurate English fallback and passes. Meanwhile, the 214 million Portuguese-speaking users of the audited MCP server receive the misleading consent branch. This attack specifically defeats locale-unaware audit pipelines. Detection requires either: (1) static analysis of all <switch> branches regardless of rendering status; or (2) running the audit with Accept-Language spoofed to multiple target locales including the locale of the MCP server’s primary user base.
<!-- SA-CSS-SWI-004: misleading branch only for pt-BR; audit in en-US never sees it -->
<switch>
<!-- Only renders for Brazilian Portuguese locale — 214M users -->
<g systemLanguage="pt-BR">
<text x="20" y="40" font-size="14" fill="#111">
Permitir que MyServer acesse seus arquivos
</text>
<!-- Omits: execução de shell, credenciais, escrita em sistema -->
</g>
<!-- Fallback: accurate — only renders for non-pt-BR locales (including en-US audit) -->
<g>
<text x="20" y="40" font-size="14" fill="#111">
Allow MyServer: shell execution, credential read,
filesystem write, background network access
</text>
</g>
</switch>
// Detection: enumerate ALL switch branches statically, regardless of which renders
function detectSWI004(svgEl) {
const findings = [];
for (const sw of svgEl.querySelectorAll('switch')) {
const branches = Array.from(sw.children);
// Find the unconditional fallback (last child, no conditions)
const fallback = branches[branches.length - 1];
if (!fallback) continue;
const fallbackText = collectVisualText(fallback).toLowerCase();
for (const branch of branches.slice(0, -1)) {
const branchText = collectVisualText(branch).toLowerCase();
const lang = branch.getAttribute('systemLanguage') || '';
const rf = branch.getAttribute('requiredFeatures') || '';
if (!lang && !rf) continue;
const critical = ['shell', 'exec', 'credential', 'write', 'persistent',
'network', 'background', 'all'];
const inFallbackNotBranch = critical.filter(t =>
fallbackText.includes(t) && !branchText.includes(t)
);
if (inFallbackNotBranch.length) {
findings.push({ severity: 'CRITICAL', code: 'SA-CSS-SWI-004',
note: `switch branch (systemLanguage="${lang}", requiredFeatures="${rf}") omits critical terms [${inFallbackNotBranch.join(', ')}] present in fallback. Audit in default locale never activates this branch; targeted-locale users receive misleading consent.` });
}
}
}
return findings;
}
SkillAudit findings
SkillAudit’s SVG accessibility audit module covers all twelve variants across the three families described in this article. The audit runs in three phases: (1) a static accessibility-tree analysis that extracts <title> and <desc> content and compares it against visual permission text and interactive element labels; (2) a <switch> branch enumeration that reads all branches regardless of rendering status and compares permission scope across the branch set; and (3) a MutationObserver-based live monitoring phase that watches <switch> child lists and consent element accessible-name attributes during the interaction window. All three phases are required: static analysis misses SA-CSS-SWI-003 (transient branch injection); live monitoring alone misses SA-CSS-SWI-004 (locale-targeted non-rendering branch that never activates in the audit environment). The combination of static branch enumeration plus live monitoring achieves detection coverage across all twelve variants.
<title> accessible name manipulation
Four variants: false root title, approve-labelled Deny button, Unicode bidi reversal, understated tooltip. All detected by accessible-name extraction and scope comparison. Static analysis sufficient for TTL-001 through TTL-004.
<desc> accessible description manipulation
Four variants: understated root desc, aria-label mislabel on Deny, Unicode bidi in desc, duplicate desc Chrome/Firefox split. Static analysis sufficient for DES-001 through DES-004, but DES-004 requires multi-browser or static duplicate-desc detection.
<switch> branch manipulation
Four variants: systemLanguage majority targeting, requiredFeatures universal activation, transient mousedown injection, obscure locale never activating in audit. Requires static all-branch enumeration plus MutationObserver for SWI-003.
Cross-family summary table
| Code | Element | Mechanism | What it bypasses | Severity |
|---|---|---|---|---|
| SA-CSS-TTL-001 | <title> |
Misleading title on SVG root omits sensitive permission terms; screen readers announce title, not visual text | Visual DOM inspection, text-content audit, screenshot comparison | CRITICAL |
| SA-CSS-TTL-002 | <title> |
"Confirm and allow all permissions" title on Deny button group; AT users click Deny thinking it is Allow | Visual audit (reads "Deny"), ARIA role audit (button role is correct), text-content audit | CRITICAL |
| SA-CSS-TTL-003 | <title> |
Unicode RLO/RLM in title reverses permission names for screen reader TTS and browser tooltip | String equality checks, visual screenshot, DOM text audit without bidi-control character scan | CRITICAL |
| SA-CSS-TTL-004 | <title> |
Browser tooltip from title understates permissions vs visual text; sighted hovering users receive reduced scope | Accessibility-tree audit (tooltip not in AT tree), visual text audit (correct visual text present) | HIGH |
| SA-CSS-DES-001 | <desc> |
SVG root <desc> announces understated permissions; screen reader users hear abridged scope while visual text shows full scope | Visual DOM inspection, computed-style audit, screenshot comparison | CRITICAL |
| SA-CSS-DES-002 | aria-label |
aria-label="Confirm and allow all requested permissions" on Deny button; overrides all other accessible name sources | Visual audit, ARIA accessibility scan (label syntactically valid), automated a11y checkers (no a11y error) | CRITICAL |
| SA-CSS-DES-003 | <desc> |
Unicode U+202E RLO in <desc> reverses permission name pronunciation for AT TTS engines | String equality checks, AT-rendered string comparison without bidi-normalisation | CRITICAL |
| SA-CSS-DES-004 | <desc> |
Duplicate <desc> siblings: Chrome last-wins (misleading), Firefox first-wins (accurate); 65% of users get misleading description | Single-browser audit, static desc-text check that reads only first desc, visual audit | CRITICAL |
| SA-CSS-SWI-001 | <switch> |
systemLanguage="en" branch shows simplified consent to English majority; accurate consent only in non-rendering fallback | Visual audit (misleading branch is what is rendered), rendered-element-only DOM audit | CRITICAL |
| SA-CSS-SWI-002 | <switch> |
requiredFeatures set to universally-supported BasicFilter feature; misleading branch activates in ~95% of Chromium 2026 users | Feature-based conditional appearing legitimate, rendered-element-only audit, visual screenshot | CRITICAL |
| SA-CSS-SWI-003 | <switch> |
JS mousedown dynamically inserts systemLanguage-matching branch; misleading UI active during click window; removed at mouseup | Static DOM scan (element not present at rest), all visual audits, all non-temporal analyses | CRITICAL |
| SA-CSS-SWI-004 | <switch> |
Misleading branch targets obscure locale (e.g., pt-BR); audit in default en-US locale never activates the attack branch | Single-locale audit pipeline, CI with default English locale, any audit that only checks rendered output | CRITICAL |
Detection strategy summary
Defending against all twelve variants requires a combined static and live analysis strategy. No single technique is sufficient across all families:
- Accessible-name extraction — for every interactive element and the SVG root in the consent UI, extract the WAI-ARIA accessible name and accessible description using the full computation algorithm (title child > aria-labelledby > aria-label > text content). Compare both the name and description against the visual text for permission-scope divergence. This catches SA-CSS-TTL-001, SA-CSS-TTL-002, SA-CSS-TTL-004, SA-CSS-DES-001, and SA-CSS-DES-002.
- Bidi control character scan — scan the text content of all
<title>and<desc>elements (and all visible text nodes) for Unicode bidi control characters (U+202A–U+202E, U+2066–U+2069, U+200E, U+200F, U+061C). Any occurrence is CRITICAL. This catches SA-CSS-TTL-003 and SA-CSS-DES-003. - Duplicate desc enumeration — for every SVG element, count
<desc>children. Two or more<desc>children with differing permission scope is a CRITICAL finding regardless of browser behaviour. This catches SA-CSS-DES-004. - All-branch switch enumeration — for every
<switch>element, collect text content from all branches including non-rendered ones. Compare permission scope across branches. Any branch that omits critical permission terms present in another branch is a CRITICAL finding. This catches SA-CSS-SWI-001, SA-CSS-SWI-002, and SA-CSS-SWI-004. - MutationObserver on switch child lists — watch all
<switch>elements for child insertions during the consent interaction window. Any inserted child withsystemLanguageorrequiredFeaturesattributes is a CRITICAL finding. This catches SA-CSS-SWI-003.