В 2013 году была опубликована статья Марио Хейдериха (Mario Heiderich), создателя утилиты DOMPurify для защиты от XSS атак, «mXSS Attacks: Attacking well-secured Web-Applications by using innerHTML Mutations». Этот документ стал одним из первых, определившим среди разновидностей XSS атак новую вариацию: XSS с мутациями (mutation-based XSS или mXSS).
На данный момент вариантам таких атак подвержены все основные браузеры. Однако для их реализации требуется достаточно глубокое понимание того, как браузер выполняет оптимизацию и синтаксический анализ узлов DOM-дерева. Новые варианты mXSS атак появляются каждый год и, кажется, универсального средства для защиты от такого типа уязвимости просто нет. Инструменты, направленные на санитизацию (очистку от вредоносного кода) пользовательского ввода, такие как DOMPurify (JavaScript на клиенте), OWASP (Java), Bleach (Python) и т.п., вновь и вновь оказываются бессильны перед вновь обнаруженной уязвимостью.
По своей сути mXSS использует код, который воспринимается HTML-санитайзерами как безопасный, а после прохождения очистки мутирует во вредоносный. В этом и состоит парадоксальность и опасность этого типа XSS атак. Безусловно далекая но забавная аналогия — принцип неопределенности Гейзенберга: в данном случае измеряя (проверяя на безопасность) пользовательский ввод мы меняем его состояние, и итоговое заключение о его зловредности становится неактуальным. Только исполнение итогового варианта кода в конкретной версии конкретного браузера позволяет на 100% судить о безопасности входных данных.
Далее в статье мы сначала посмотрим на примеры mXSS уязвимостей, канувших в лету вместе с устаревшими версиями браузерных движков, но достойных внимания через призму истории. А затем посмотрим на совсем свежие примеры, которые позволили обойти защиту популярных HTML-санитайзеров, и разберемся, как им это удалось.
Исторические примеры
Взглянем на некоторые векторы атак, которые имели место в результате мутаций зловредной HTML-разметки и использования innerHTML на веб-сайтах в прошлом. Сейчас подобные векторы уже не столь актуальны, поскольку могут либо использоваться только в устаревших браузерах, либо успешно исправляются всеми популярными HTML-санитайзерами.
Апострофы в значении атрибута
Возможность такой атаки была обнаружена в 2007 году Йосуке Хасегава (Yosuke Hasegawa) и является одной из первых (если не первой) задокументированной mXSS уязвимостью. Для атаки могла использоваться следующая строка:
<img src="some/image.jpg" alt="``onload=alert(1)" />
Современные движки успешно приводят к строке такой alt целиком, однако в оригинальной ситуации эта строка дробилось на два отдельных атрибута и JavaScript-код исполнялся браузером, выдавая следующую разметку:
<img alt=``onload=alert(1) src="some/image.jpg">
Эту уязвимость можно считать первой из класса XSS с мутациями, получившего уже в последствии свое текущее название.
XML-неймспейсы для неизвестных элементов
По состоянию на 2011 год, браузеры, не поддерживавшие стандарт HTML5 (спойлер: практически все), ничего не знали о таких семантических элементах, как <article>, <aside>, <menu> и т.п. Разработчик в такой ситуации мог указать, как браузер должен интерпретировать неизвестный элемент с помощью атрибута xmlns, сообщая в каком пространстве XML-имен должен находиться элемент. Браузеры обрабатывали этот момент, организуя префикс для article из кода неймспейса.
<article xmlns="urn:img src=x onerror=alert(1)//">content
В результате вставки посредством innerHTML получалось нечто следующее:
<img src=x onerror=alert(1)//:article xmlns="urn:img src=x onerror=alert(1)//">
content
</img src=x onerror=alert(1)//:article>
Соответственно, изначально безопасная разметка "успешно" исполняла сценарий после парсинга и последующей вставки на страницу. Эта проблема также была исправлена браузерами после обнаружения.
Манипуляции с исполняемым кодом в CSS
Ранее поддерживаемые технологии HTC в Internet Explorer и XBL в Mozilla Firefox позволяли небезопасно задавать в css исполняемый код. Например это можно было сделать с помощью оператора expression() в IE. Используя различные манипуляции с экранированием символов и поддержкой ASCII в CSS можно было добиться преобразования значения одного из атрибутов во второй с исполняемым кодом внутри.
<p style="font-family:’ar";x=expression(alert(1))/*ial’"></p>
Такой код выливался в следующую структуру после вставки в DOM:
<p style="font-family:’ar’;x=expression(alert(1))/*ial’"></p>
Впоследствии поддержка подобных технологий была исключена, что, в свою очередь, исключило и этот вектор атаки.
Изменение разметки через значения атрибутов CSS
Опять же с использованием экранированных символов мы можем разбить значение одного из стилей в атрибуте style таким образом, чтобы в структуру HTML добавился новый атрибут (или элемент), который в свою очередь уже и запустит вредоносный код. Пример такой атаки ниже:
<img style="font-fa\42onerror\75alert\501\51\40mily:arial" src=x />
После вставки разметки в DOM элемент обретает новый атрибут и запускает записанный в него JavaScript-код:
<img style="font-fa" onerror="alert(1)" mily:arial"="" src="x">
Такой вектор атаки вполне может эксплуатироваться в современных браузерах, однако все популярные HTML-санитайзеры успешно справятся с ней (например, отфильтровав свойство onerror для примера выше).
Использование свойств <noscript> элемента
В 2019 году исследователь безопасности Масато Кинугава (Masato Kinugawa) обнаружил mXSS-уязвимость в библиотеке Closure, применяемой службой Google Search. Для атаки использовался следующий код:
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
Технически эта строка безопасна для DOM, поскольку теги и кавычки в ней расставлены таким образом, что ее добавление не приводит к запуску сценария. Соответственно санитайзер пропускал ее без изменений, как не несущую риска XSS. Но после загрузки в DOM браузера происходит некоторая оптимизация и код принимает такой вид:
<noscript><p title="</noscript>
<img src="x" onerror="alert(1)">
"">"
Санитайзер (DOMPurify в этом случае) пропустил эту строку, потому что использовал в процессе очистки шаблонный элемент <template>, отлично подходящий для этой цели, так как он анализируется, но не отображается, а выполнение сценариев внутри него отключено. При отключенных сценариях тег <noscript> показывает свое содержимое. В остальных случаях браузер игнорирует все, что в него заключено. По этой причине код становится опасным только после перехода в реальную среду браузера, считаясь безопасным на этапе анализа.
Стоит сказать, что за последние 15 лет были найдены и исправлены масса подобных уязвимостей и их вариаций. Представленные выше примеры - лишь некоторые из них. Многие возможности браузеров, использовавшиеся в атаках, на текущей момент либо стали не актуальны, либо явно не поддерживаются в современных версиях.
Атака через HTML-санитайзеры
Гораздо более интересные примеры уязвимостей появились после повсеместного внедрения HTML-санитайзеров пользовательского ввода, защищавших от классических XSS атак. Разметка, безопасная как в изначальном виде, так и после вставки посредством innerHTML, может тем не менее стать опасной, мутировав, будучи пропущенной через санитайзер. Чтобы понять как такое возможно вначале взглянем на принципы и устройство санитизации HTML, а затем перейдем к конкретным примерам.
Как работают HTML-санитайзеры
Веб-приложения, оправдывая свое название, дают пользователям множество вариантов взаимодействия и, в том числе, широкий простор для форматов ввода. Расширенные редакторы текста позволяют включать в текст различные варианты форматирования (например, жирный шрифт, курсив и т.п.) Эти функции обычно представлены в клиентах веб-почты, социальных сетей, блог-платформ и т.д. Основная проблема безопасности, возникающая здесь, заключается в том, что пользователь может включить вредоносный JavaScript-код в HTML-разметку.
Здесь вступают в игру так называемые HTML-санитайзеры. Их главная цель - взять ненадежный ввод, очистить его и создать безопасный HTML, удалив из него все опасные теги и атрибуты. Обычно это происходит путем синтаксического анализа входных данных (есть несколько способов сделать это и одним из примеров является метод DOMParser.prototype.parseFromString). То есть, санитайзер строит DOM-дерево поданной на вход HTML-разметки, обходит его, удаляя все, что отсутствует в белых списках безопасных значений (это сильно приближённо описывает процесс), а затем формирует итоговый вывод, приведя очищенное дерево к строке.
К примеру имея белый список тегов <div>, <strong> и <img>, а также белый список атрибутов <src> и <style>, пользовательский ввод претерпит следующие трансформации.
<div style="color:red">Немного<b><i>опасного</i>кода</b><img src=1 onerror=alert(1)></div>
После парсинга имеем следующее DOM дерево:
<div style="color:red">
"Немного"
<b>
<i>опасного</i>
"кода"
</b>
<img src="1" onerror="alert(1)">
</div>
Так как элемент <i> и атрибут onerror отсутствуют в списке допустимых значений, они должны быть отброшены. В результате имеем следующее дерево и итоговую строку разметки на выходе.
<div style="color:red">
"Немного"
<b>кода</b>
<img src="1">
</div>
<div style="color:red">Немного<b>кода</b><img src=1></div>
Итого, у нас есть следующий порядок операций: парсинг > синтаксический анализ > фильтрация > сериализация. Интуитивно можно предположить, что сериализация DOM-дерева и его повторный анализ всегда должны возвращать исходное DOM-дерево. Но это совсем не так. В спецификации HTML есть предупреждение в разделе о сериализации фрагментов HTML:
It is possible that the output of this algorithm, if parsed with an HTML parser, will not return the original tree structure. Tree structures that do not roundtrip a serialize and reparse step can also be produced by the HTML parser itself, although such cases are typically non-conforming.
Важным выводом является то, что на выходе HTML-санитайзера не гарантируется возврат исходного DOM-дерева (даже для безопасного кода). Часто такие ситуации являются результатом какой-либо ошибки синтаксического анализатора или сериализатора, но существует по крайней мере два случая мутаций, соответствующих спецификациям.
Вложенный элемент <form>
Это особый элемент в HTML, так как он не может быть вложен сам в себя. В спецификации явно указано, что у формы не может быть потомка, являющегося формой.
Content model: Flow content, but with no form element descendants.
Можно попробовать отобразить в браузере следующую разметку:
<form id=outer>Outer form content <form id=inner>Inner form content
Результат ожидаем, вторая форма схлопнулась, ее содержимое стало относится к внешнему элементу:
<form id="outer">Outer form content Inner form content</form>
Однако, также в спецификации указан пример, как это правило может быть нарушено и получен вложенный элемент <form>.
<form id="outer"><div></form><form id="inner"><input>
Такая разметка будет преобразована в следующее DOM-дерево:
<form id="outer">
<div>
<form id="inner">
<input>
</form>
</div>
</form>
Это не ошибка в движке браузера, она вытекает непосредственно из спецификации HTML и описана в алгоритме синтаксического анализа HTML (причина в обнулении указателя открытых/закрытых тэгов при анализе разметки).
Теперь самое интересное. Если повторить процесс парсинг > анализ > сериализация c разметкой, полученной выше, вложенный элемент <form> в итоге пропадет! Присваивая, например, два раза подряд такую разметку посредством innerHTML мы получаем три разных состояния.
// Первое состояние
const markup1 = '<form id="outer"><div></form><form id="inner"><input>';
body.innerHTML = markup1;
// Второе состояние
const markup2 = body.innerHTML;
body.innerHTML = markup2;
// Третье состояние
const markup3 = body.innerHTML;
Как видим, вследствие последовательных мутаций, итоговый результат теряет предсказуемость. Особую опасность такое поведение приобретает в сочетании со следующей уловкой.
Чередование неймспейсов
Синтаксический анализатор HTML может создать DOM-дерево с тремя типами пространств имен элементов:
HTML namespace (http://www.w3.org/1999/xhtml)
SVG namespace (http://www.w3.org/2000/svg)
MathML namespace (http://www.w3.org/1998/Math/MathML)
По умолчанию все элементы находятся в пространстве имен HTML. Однако, если анализатор встречает элемент <svg> или <math>, он переключается на пространство имен SVG и MathML соответственно, где в каждом случае может вести себя по-разному при анализе одних и тех же тегов.
Хорошая иллюстрация этого — элемент <style>. В неймспейсе HTML этот элемент может содержать только текстовый контент и не может иметь дочерних элементов. Но если элемент <style> расположен в пределах <svg>, он ведет себя уже как полноценный узел дерева.
Есть еще одна загвоздка. Даже если мы находимся внутри <svg> или <math>, то это не гарантирует, что все элементы также находятся в пространстве имен, отличном от HTML. В спецификации HTML есть определенные элементы — точки интеграции (MathML text integration points, HTML integration point). Их дочерние элементы имеют пространство имен HTML. Примеры таких элементов: <mtext> в пространстве MathML, <foreignObject>, <desc>, <title> в пространстве SVG.
<math><style></style><mtext><style></style>
Такая разметка анализируется следующим образом:
<math>
<style>Здесь мы в MathML неймспейсе</style>
<mtext><
style>А здесь в HTML неймспейсе</style>
</mtext>
</math>
И теперь снова неочевидность: не все дочерние элементы точек интеграции MathML (например внутри <mtext>) находятся в HTML-пространстве. Есть два исключения: <mglyph> and <malignmark>.
<math><mtext><mglyph></mglyph><a><mglyph>
<math>
<mtext>
<mglyph>Это MathML</mglyph>
<a>
<mglyph>Это HTML</mglyph>
</a>
</mtext>
</math>
Как видим, один и тот же элемент внутри одной и той же интеграционной точки одного и того же пространства имен, может анализироваться как MathML, так и как HTML-элемент. А теперь перейдем непосредственно к атаке.
Атакуем
Соединим вместе две уловки: создание вложенной формы и смену неймспейса внутри одной интеграционной точки. Посмотрим на такую разметку:
<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>
Она продуцирует DOM дерево, которое абсолютно безобидно, так как содержимое элемента <style>, анализирующегося как HTML-элемент, является простым текстом.
<form>
<math>
<mtext>
<form>
<mglyph>
<style></math><img src onerror=alert(1)></style>
</mglyph>
</form>
</mtext>
</math>
</form>
Именно такой результат выдавали до недавнего времени все популярные HTML-санитайзеры. Это дерево сериализовалось, возвращалось пользователю, тот вставлял его на страницу с помощью innerHTML и получал следующий результат:
<form>
<math>
<mtext>
<mglyph>
<style></style>
</mglyph>
</mtext>
</math>
<img src="" onerror="alert(1)"></form>
Зловредный JavaScript-код исполняется на странице после санитизации ввода пользователя. И, что самое важное, именно вследствие использования санитайзера этот ввод становится опасным.
Эта уязвимость была обнаружена специалистом по безопасности Майклом Бентковски (Michał Bentkowski) в 2020 году. Позднее, после исправления ее в DOMPurify, проблема получила развитие. Новые векторы атаки брали за основу принципы озвученные выше, используя кроме этого, например, комментарии (DOMPurify < 2.1.0) и свойства <svg> элемента (DOMPurify < 2.2.2).
Заключение
Стоит отметить, что сам паттерн использования innerHTML по своей сути является причиной мутаций.
const sanitized = saznitizer(dirty);
div.innerHTML = sanitized;
Поэтому, при возможности со стороны санитайзера, лучшим решением будет иметь на выходе не HTML-строку, а само DOM-дерево, чтобы избежать дополнительного цикла парсинга и сериализации разметки, ведущих к новым мутациям (в DOMPurify, например, для этого используются параметры RETURN_DOM или RETURN_DOM_FRAGMENT).
Итого, проблема, обозначенная как XSS с мутациями, достаточно молода. В Сети можно найти множество экспериментов по эксплуатации этой уязвимости, и их число, скорее всего, будет расти. К сожалению mXSS-уязвимость с нами надолго, потому что, как уже было сказано в начале статьи, универсального средства для защиты от результата мутаций пока нет.