September 16, 2026
Mutation XSS: Attacking the Second Parse
You do not sneak a bad tag past the filter. You submit something harmless that the browser later re-reads as dangerous.

By Nitin yadav
7 min read
Hello, I am Nitin.
Day sixteen. This is the most technically interesting day of the month, and the one that will make you better at everything else.
Yesterday I said a sanitizer is safe only if parsing its output rebuilds the tree it approved. Today we deliberately break that.
The idea in one sentence: you are not trying to sneak a bad tag past the filter. You are submitting something the sanitizer considers completely harmless, which the browser later re-reads as something dangerous.
The sanitizer does nothing wrong. Its allow-list is correct. Its walk is correct. It approves a tree that genuinely contains nothing harmful. Then it serialises that tree to a string, the application writes that string into the page, and the browser โ following the HTML specification exactly โ builds a different tree.
Nobody made a mistake. The two parses simply disagree, and you engineered the disagreement.
Why parses disagree
HTML parsing is not context-free. The same characters mean different things depending on where the parser currently is. There are three mechanisms that create the disagreement, and every mutation attack uses one of them.
Mechanism 1: parsing modes
The tokeniser has several states. In the normal state it looks for tags. In raw text state โ inside style, textarea, title, xmp โ it does not look for tags at all, only for that element's closing tag. In foreign content โ inside SVG or MathML โ different rules apply again.
When the sanitizer parses your input, it starts in one state. When the application inserts the output, the browser may start in a different state, or the content may end up nested differently so the state transitions happen at different points.
Mechanism 2: the serialiser does not re-escape everything
When a tree is converted back to a string, text content gets escaped, but not uniformly across all contexts. Content inside a raw text element is written out as-is, because inside that element it would be text anyway. If that content later gets parsed in a different context, characters that were inert become structural.
Mechanism 3: tree correction
The parser fixes malformed markup according to strict specification rules. It relocates nodes that appear where they are not allowed, implicitly closes elements, and reorders things. Those corrections happen at parse time, which means they happen twice โ once for the sanitizer, once for the browser โ and the two runs can land differently because the starting context differs.
See it happen, in your own console
Before hunting this on a target, watch a mutation occur. This is the exercise that makes the concept concrete.
const d = document.createElement('div');
// give the parser something with a context switch in it
d.innerHTML = '<p><style><!--</style><img src=x onerror=alert(1)>';
// what tree did we get?
console.log(d.innerHTML);const d = document.createElement('div');
// give the parser something with a context switch in it
d.innerHTML = '<p><style><!--</style><img src=x onerror=alert(1)>';
// what tree did we get?
console.log(d.innerHTML);Compare the string you put in with the string that comes out. They differ. That difference is the parser normalising, and every mutation attack is a search for an input where that difference is security-relevant.
Now the important variant. Take the output and feed it back in:
const first = d.innerHTML;
const e = document.createElement('div');
e.innerHTML = first;
console.log(e.innerHTML === first); // false means non-idempotentconst first = d.innerHTML;
const e = document.createElement('div');
e.innerHTML = first;
console.log(e.innerHTML === first); // false means non-idempotentIf parsing the output produces a third different string, the transformation is not stable. Instability is the signal. Where output differs from input, and re-parsing differs again, you have a candidate for engineering the difference into something that executes.
This is the whole research method in miniature: submit, read output, resubmit output, diff.
The four families that produce mutations
Family 1: raw text elements
Content inside style, textarea, title, xmp, and noscript is tokenised as plain text, not markup. The serialiser writes it back out without escaping angle brackets, because within that element they are not structural.
The attack: get your content into one of those elements at sanitise time, arrange for it to end up outside that element at reparse time. Markup that was inert becomes live.
Comments interact with this heavily, because comment parsing inside raw text elements has its own rules and the boundaries can end up in different places on the two passes.
Test every raw text element the sanitizer permits. If it allows style or title in the allow-list, that is where I would start.
Family 2: foreign content
SVG and MathML switch the parser into foreign-content mode. Certain elements act as integration points that switch parsing back to HTML rules mid-subtree โ inside SVG those include the description and title elements and the foreign-object element; inside MathML, the text-annotation elements.
The attack: place content at an integration point so that the sanitizer parses it in one mode and the browser parses it in the other. Elements that were treated as harmless foreign-namespace nodes become HTML elements with live behaviour, or the boundary of the island lands in a different place.
This family is where a large share of published sanitizer bypasses have come from, because the namespace rules are intricate and easy to model incompletely.
Family 3: foster parenting
Inside a table, only certain elements are allowed. When the parser meets something that does not belong there, it does not discard it โ the specification says to relocate it, inserting it before the table in the tree.
That relocation is a node moving to a different parent between what you wrote and what exists. If the sanitizer's parse relocates one way and the browser's relocates another, content escapes the container the sanitizer believed it was inside.
Test tables whenever they are on the allow-list, which they usually are for rich text.
Family 4: attribute and entity handling
Attribute values get parsed, decoded, and re-serialised. Quoting style may change. Entities may be decoded and rewritten. Backslashes and unusual whitespace inside values can survive one pass and be interpreted on the next.
The probe: submit values containing quotes, backticks, entities, and unusual whitespace inside an allowed attribute, then read the exact bytes that come back. Any change is worth chasing.
The hunting method, step by step
This is a search, not a recall exercise. Set it up properly and let it run.
Step one: get the sanitizer locally if you can. If it is client-side, you already have the code from day nine. Load it in a page and you can iterate in milliseconds instead of one HTTP request at a time. This single step is the difference between testing twenty inputs and twenty thousand.
Step two: build the differ. The core loop is short:
function mutates(input) {
const clean = SANITIZER(input); // whatever the app uses
const a = document.createElement('div');
a.innerHTML = clean; // the reparse the app performs
const reserialised = a.innerHTML;
return { clean, reserialised, changed: clean !== reserialised };
}function mutates(input) {
const clean = SANITIZER(input); // whatever the app uses
const a = document.createElement('div');
a.innerHTML = clean; // the reparse the app performs
const reserialised = a.innerHTML;
return { clean, reserialised, changed: clean !== reserialised };
}Anything where changed is true is a mutation. Most are harmless. You are mining for the ones that are not.
Step three: check the result for danger, not just difference. After the reparse, walk the resulting DOM and ask whether anything appeared that the sanitizer would have rejected:
function dangerous(node) {
return node.querySelector('script, iframe, object, embed') ||
[...node.querySelectorAll('*')].some(el =>
[...el.attributes].some(a =>
/^on/i.test(a.name) ||
/^\s*(javascript|data)\s*:/i.test(a.value)));
}function dangerous(node) {
return node.querySelector('script, iframe, object, embed') ||
[...node.querySelectorAll('*')].some(el =>
[...el.attributes].some(a =>
/^on/i.test(a.name) ||
/^\s*(javascript|data)\s*:/i.test(a.value)));
}That predicate is your oracle. Difference plus danger equals a bypass.
Step four: generate candidates systematically. Combine, in every order: allowed tags from the policy, raw text elements, foreign-content elements and their integration points, table elements, comments in various malformed forms, and attributes with awkward values. You are looking for context transitions, so every candidate should contain at least one.
Step five: minimise. When something fires, cut it down to the shortest input that still works. A minimal case is far easier to explain, and the explanation is what makes the report credible.
Step six: verify in a real browser, in the real application. Local reproduction is a lead, not a finding. Parser behaviour differs subtly between engines, and the application may perform additional processing you did not model.
Where mutation matters most
Mutation needs a second parse. That means it is strongest where content is sanitised in one place and rendered in another:
- Server-side sanitisation, client-side rendering. Two different parser implementations, which may disagree even before you try. This is the richest configuration.
- Sanitise on save, render later through a different component or template.
- Sanitised content passed into a markdown or template pipeline afterwards. Day 18.
- Content that round-trips โ sanitised, stored, edited, re-sanitised, re-stored. Each cycle is another parse, and non-idempotent transformations can accumulate.
That last one is genuinely under-explored. Applications with rich text editors often sanitise on save and again on load, and the content passes through the editor's own serialisation in between. Three transformations, none of them guaranteed to be a fixed point. Test the cycle: submit, save, reopen the editor, save again without changing anything, and see whether the stored value drifts.
Impact ladder
- Informational โ mutation observed, nothing dangerous produced. Log the input; it may combine with something later.
- Medium โ mutation produces a policy violation without script execution, for example an element escaping its intended container.
- High โ a working bypass giving script execution in content other users view.
- Critical โ the same on a staff-facing or cross-tenant surface, or a bypass in a widely used library that affects the target and everyone else using it.
Conclusion โ steal this checklist
- You are not sneaking a bad tag past the filter. You are submitting something harmless that the browser later re-reads as dangerous.
- The sanitizer is not making a mistake. The two parses disagree, and you engineered the disagreement.
- Three mechanisms: parsing modes, a serialiser that does not re-escape uniformly, and tree correction that runs on both passes.
- Watch a mutation happen in your own console before hunting one. Input, output, re-input, diff.
- Non-idempotent output is the signal. If reparsing the output changes it again, keep digging there.
- Four families: raw text elements, foreign content and its integration points, foster parenting inside tables, and attribute or entity handling.
- Get the sanitizer running locally. Iterating in milliseconds beats one request at a time by orders of magnitude.
- Build a differ plus a danger oracle. Difference alone is noise; difference plus a policy violation is the bug.
- Generate candidates that contain context transitions, because transitions are where parses diverge.
- Mutation is strongest when sanitisation and rendering happen in different places, especially server-side clean plus client-side render.
- Test the round trip โ save, reopen, save again โ and watch for drift.
- Minimise before reporting, and verify in the real application, not just locally.
- Library bugs go to maintainers as well as the program, and are not published before a fix.
Tomorrow: sanitizer fingerprinting โ identifying which library and version a target runs, so you can check known bypasses instead of doing original research.
If you Love reading my blogs. Check my Youtube Channel too.