Monday, 14 September 2026

The Context-Switching Machine Gun: Weaponizing the HTML5 Parser's State Machine

The Context-Switching Machine Gun: Weaponizing the HTML5 Parser's State Machine
September 2026 · Browser Internals / Filter Evasion · ~12 min read

Every HTML sanitizer on the planet shares one fatal assumption: that the DOM it sanitizes is the same DOM the browser will build. That assumption is wrong, and a seventeen-element payload can prove it in a single paste.

Here is the payload. It's ugly. It looks like an LLM hallucinated it. It is, in fact, a precision instrument — one crafted to fire the browser's parser through more mode transitions in a single document than most pages manage in their entire markup.

<table><tr><td><math><mtext><table><mglyph><style><svg><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><p><b><p><button><p><script>alert(1)</script>

The Core Insight

Sanitizers like DOMPurify and bleach operate on a linear token stream or a DOM they built themselves. The browser operates on a state machine — the tree construction stage of the HTML5 spec, with its insertion modes, integration points, foster parenting, and the adoption agency algorithm.

These two models diverge precisely where the payload is densest. The sanitizer's tokenizer walks the string left-to-right and asks a simple question at each tag: "Is this allowed?" The browser's tree builder asks a much harder one: "Where am I, what rules apply here, and what do I do with this token given everything that came before it?"

When those two questions produce different answers for the same bytes, you get a mutation XSS (mXSS): the sanitizer signs off on a document it considers clean, and the browser — faithfully following a different rulebook — builds a document containing a live script.

Anatomy of the Payload

Each element in the string exists to force the parser through a different set of rules. Stacked back-to-back, they create a machine gun of context switches:

LayerElement(s)What It Triggers
1<table><tr><td>Table mode. Foster parenting becomes active — unexpected children get re-parented before the table, not inside it.
2<math><mtext>MathML text integration point. The parser switches from MathML rules back to HTML rules — temporarily.
3<table><mglyph>Second table inside MathML — a second foster parenting layer, nested inside the first.
4<style><svg><foreignObject>SVG foreignObject: an HTML integration point inside SVG. The parser switches back to HTML rules again, inside a document the sanitizer thinks is pure vector graphics.
5<div><p><b><p><button>A stack of HTML formatting elements. Each <p> and <button> is a scope marker for the adoption agency algorithm.
6<script>alert(1)The payload. Where it ends up depends entirely on which of the above rules the browser applies last.

The sanitizers most likely to choke here are the ones that tokenize linearly — DOMPurify's regex pre-scan, server-side parsers, anything that walks the bytes as a flat sequence. They see a "safe" SVG or MathML context and allow the <style>. Meanwhile the browser's tree builder reconstructs the formatting element stack, runs the adoption agency algorithm, and re-parents the <script> out of its sanitized containment and into the document body.

Field Guide

Try it yourself. The core experiment is a two-parser disagreement: parse the payload in one engine, sanitize, serialize, re-parse in another. The cheapest lab is your own browser:

// In DevTools on any page with DOMPurify loaded: const dirty = '<table><tr><td><math><mtext><table><mglyph><style><svg><foreignObject>' + '<div xmlns="http://www.w3.org/1999/xhtml"><p><b><p><button><p><script>alert(1)</script>'; const clean = DOMPurify.sanitize(dirty); document.body.innerHTML = clean; // Then: does the string "clean" differ from what innerHTML re-parses to? console.log(document.body.innerHTML === clean);

Then change one layer at a time — swap <math><mtext> for <svg><desc>, drop the foster-parenting table, remove the formatting stack — and watch at which depth your target's re-parse diverges. Every divergent layer is a finding. Paste the same payload into CKEditor/Tiptap demo pages, Gmail compose (as an inserted DOM node), and any "safe HTML" preview feature you can find. Also try the serializer round-trip explicitly: new XMLSerializer().serializeToString(dom) then re-set via innerHTML. Serialization is where namespace prefixes and template contents get lost — that gap is the bug.

Sanitizer says "clean." Browser says "execute."
Field Guide

Tooling. DOMPurify ships with a hook API — DOMPurify.addHook('afterSanitizeAttributes', ...) and 'uponSanitizeElement' — which is how you instrument where the sanitizer's model and the browser's diverge. For the research side: fuzz with html5lib-tests vectors and the html5lib Python tokenizer (tree-construction stage) against your target's serializer. When a html5lib tree differs from what the browser produces for identical input, you've found a parser differential. That's the whole genre.

Where This Actually Lives

This isn't a theoretical lab curiosity. The attack surface is anywhere HTML passes through a pipeline where one component parses it and another re-parses it:

TargetWhy It Dies
DOMPurify / bleachAdoption agency re-parenting happens after the allowlist check. The sanitizer walks a DOM it built; the browser rebuilds a different one on re-parse.
Email clients (Gmail, Outlook, Apple Mail, Fastmail)Server-side sanitization before display. MathML/SVG support varies wildly. Conditional comments are still parsed by Exchange.
WYSIWYG editors (CKEditor, TinyMCE, Tiptap)Paste → HTML → sanitized DOM. Deep nesting hits internal DOM walker depth limits before the browser parser even sees it.
Markdown renderers (GitHub, Reddit, StackOverflow)Markdown → HTML → sanitizer pipeline. The sanitizer sees "safe" HTML, but the browser's re-parse of the serialized output triggers adoption agency.
Electron appsChromium backend plus a custom (usually regex-based) sanitizer. webSecurity: false plus naive filtering equals execution.
PDF renderers (headless Chrome, PDF.js, WeasyPrint)HTML → PDF conversion serializes to intermediate XML. Namespace confusion during SVG vectorization drops <script> into an executable context.
SSRF avatar/icon fetchersThe Fastmail pattern: fetch an external resource, "sanitize" it for display as an image. <svg><foreignObject> survives because it's "just vector graphics."
Browser extension content scriptsInject into arbitrary pages, sanitize with innerHTML and manual stripping. Deep formatting stacks break their naive parent-node checks.

Seven Kill Chains

The payload above is the flagship, but it's one weapon in an arsenal. Each of these attack primitives exploits the same fundamental disagreement between the sanitizer's model and the browser's state machine.

1. Adoption Agency Scope Marker Overflow

Deep stacks of <button>, <a>, and <p> — ten or more levels — hit the browser's list of active formatting elements limit (roughly 64–128 entries, implementation-dependent). When the parser drops old entries to make room, it re-parents nodes to the nearest scope marker.

The critical part: if the sanitizer's allowlist check ran before this re-parenting, the <script> gets teleported from a disallowed container into an allowed one — after sanitization.

Result: Sanitizer says "clean." Browser says "execute."

2. Template Content Resurrection

Sanitizers strip <template> from the visible DOM but frequently leave the .content DocumentFragment untouched. Deep nesting makes this worse: <template><svg><foreignObject><template><xmp><script> — the sanitizer's walker sees the outer template, stops recursing, and never touches the inner one.

The target application then calls cloneNode(true) on the "clean" output and injects it. The script executes from the resurrected fragment.

Result: Stored deferred execution. No alert on paste — alert on page re-render.

3. isindex Formaction Auto-Wrap

<isindex> is obsolete, which is exactly why it works. The HTML5 parser auto-wraps it in a <form> during tokenization — the form is created by the browser, not present in the source. Most sanitizers don't have isindex in their attribute blocklist because it's "dead." But the browser creates an implicit form, formaction survives, and submitting it executes JavaScript.

Result: Sanitizer sees a harmless text input. Browser sees an auto-generated form with a JavaScript action.

4. Recursive foreignObject Stack Exhaustion

Nested <svg><foreignObject><div><svg><foreignObject> at 50+ levels forces the renderer to allocate a new compositing layer for each level. Chromium hits the layer tree limit or GPU memory cap; the tab crashes with a SIGSEGV in the renderer process. If this happens during cross-origin navigation or inside a sandboxed iframe, the renderer restart may cross site-isolation boundaries or leak state across the process boundary.

Result: DoS → potential process restart → state leakage.

5. Namespace Prefix Rebinding

<x:body><x:math xmlns:x="http://www.w3.org/1998/Math/MathML">
  <x:mtext>...<x:script>

The prefix x: is bound to XHTML at the root, then rebound to MathML at the math level. XML-aware sanitizers (Python lxml, Java's javax.xml) track namespace context by depth. HTML5 tree builders flatten prefixes. If the sanitizer serializes the document to a string and the browser re-parses it, prefix resolution diverges: the sanitizer sees MathML text (safe), the browser sees XHTML script (executable).

Result: Namespace desync between sanitizer and browser.

6. Custom Element Upgrade Gadgets

Deep <x-foo> nesting with the script stripped but the custom element left intact. The target site has:

customElements.define('x-foo', class extends HTMLElement {
  connectedCallback() { eval(this.getAttribute('data-x')); }
});

The attacker sets data-x="alert(1)". The sanitizer strips inline JavaScript but preserves the custom element and its attributes. When the target's JavaScript upgrades the element, the deferred payload fires.

Result: Stored gadget bypass. A sanitizer can't block what it doesn't know exists.

7. Comment/CDATA State Confusion in <style>

<style><![CDATA[<!--</style><script>alert(1)</script>-->]]></style>

Inside a style block, the sanitizer sees CSS comment or CDATA — inert content. But the HTML5 parser treats <!-- as an HTML comment start that closes the <style> element. The subsequent <script> is parsed as a normal script tag. This is a classic serializer-vs-parser disagreement: the sanitizer serializes what it believes is a safe style block, and the browser re-parses it as HTML with an early style termination.

Result: Style block becomes a script container after round-trip.

The One to Watch

If I had to bet on the next big mXSS primitive, it's this:

<menu><dialog><menu><dialog><menu><dialog>...
  <script>alert(1)</script>...
</dialog></menu></dialog></menu></dialog></menu>

menu is a scoping element. dialog has its own insertion mode in some parser implementations. menuitem is so obsolete that most sanitizers don't even have it in their tag database — it passes through as an unknown element. If the parser treats menuitem as a void element (as Chrome does in some quirks modes) while the sanitizer treats it as a container, the child nodes get re-parented outside the sanitized boundary.

This is the pattern that produced the MathML mXSS class of bugs: an obscure, deprecated element that modern parsers still handle with legacy rules, creating a blind spot in every major sanitizer. The elements change. The blind spot doesn't.

Field Guide

Your homework. The menu/dialog/menuitem chain is genuinely under-fuzzed. Build a corpus of deprecated scoping elements (menu, applet, marquee, isindex, listing, plaintext, xmp, noembed, noframes) crossed with integration points (math, svg descendants, foreignObject, desc, title) and template/CDATA layering, then run it through (target parser) → serialize → (browser parse) and diff the trees. Log every element the target passes through that the browser re-parents. That's a CVE waiting for a write-up with your name on it.

Takeaways

The deeper lesson isn't about any single payload. It's that sanitization and parsing are inseparable — a sanitizer that builds its own DOM, or worse, tokenizes without building one at all, is reasoning about a document that may never exist in the browser.

If you're defending: sanitize at the last possible moment, on the same parser engine that will render the result, and treat any serialization/re-parse round-trip as a new attack surface. DOMPurify's RETURN_TRUSTED_TYPE and hooks that re-walk after mutation exist precisely because of this class of bug.

If you're breaking things: the most fertile ground is the seam between two parsers. Find where HTML is fetched by one component, sanitized by a second, and rendered by a third. That's where the contexts switch — and where the machine gun fires.

Posted September 2026.

0 Comments:

Post a Comment

Subscribe to Post Comments [Atom]

<< Home