Pull to refresh

Фундаментальная уязвимость HTML при встраивании скриптов

Reading time 9 min
Views 50K

Чтобы описать суть проблемы, мне нужно рассказать, как вообще устроен HTML. Вы наверняка в общих чертах представляли себе, но я все равно коротко пробегусь по основным моментам, которые понадобятся для понимания. Если кому-то не терпится, сразу переходите к сути.


HTML — это язык гипертекстовой разметки. Чтобы говорить на этом языке, нужно соблюдать его формат, иначе тот, кто читает написанное, не сможет вас понять. Например, в HTML у тегов есть атрибуты:


<p name="value">

Тут [name] — это имя атрибута, а [value] — это его значение. В статье я буду использовать квадратные скобки вокруг кода, чтобы было понятно, где он начинается и заканчивается. После имени стои́т знак равенства, а после него — значение, заключенное в кавычки. Значение атрибута начинается сразу после первого символа кавычки и заканчивается сразу перед следующим символом кавычки, где бы он не находился. Это значит, что если вместо [value] вы запишете [OOO "Рога и копыта".], то значение атрибута name будет [OOO ], а еще у вашего элемента будет три других атрибута с именами: [рога], [и] и [копыта"."], но без значений.


<p name="OOO "Рога и копыта"."></p>

Если это не то, чего вы ожидали, вам нужно как-то изменить значение атрибута, чтобы в нем не встречалась кавычка. Самое простое, что можно придумать — просто вырезать кавычки.


<p name="OOO Рога и копыта."></p>

Тогда парсер HTML верно прочтет значение, но беда в том, что это будет другое значение. Вы хотели [OOO "Рога и копыта"], а получили [OOO Рога и копыта.]. В каких-то случаях такое различие может быть критичным.


Чтобы вы могли указать в качестве значения любую строку, формат языка HTML предлагает возможность экранировать значения атрибутов. Вместо кавычки в строке значения вы можете записать последовательность символов [&quot;] и парсер поймет, что в этом месте в исходной строке, которую вы хотите использовать в качестве значения атрибута, была кавычка. Такие последовательности называются HTML entities.


<p name="OOO &quot;Рога и копыта&quot;."></p>

При этом, если в вашей исходной строке действительно была последовательность символов [&quot;], у вас все еще есть возможность записать её так, чтобы парсер не превратил её в кавычку — для этого надо заменить знак [&] на последовательность символов [&amp;], то есть вместо [&quot;] вам нужно будет записать в сыром тексте [&amp;quot;].


Получается, что преобразование из исходной строки в ту, которую мы запишем между двумя символами кавычек, является однозначным и обратимым. Благодаря этим преобразованиям можно записать и прочитать любую строку в качестве атрибута HTML-тега, не вдаваясь в суть её содержимого. Вы просто соблюдаете формат, и все работает.


Собственно, так работает большинство форматов, с которыми мы сталкиваемся: есть синтаксис, есть способ экранирования контента от этого синтаксиса и способ экранирования символов экранирования, если вдруг такая последовательность встречается в исходной строке. Большинство, но не…


Тег <script>


Тег <script> служит для встраивания в HTML фрагментов, написанных на других языках. На сегодняшний день в 99% случаев это Javascript. Скрипт начинается сразу после открывающего тега <script> и заканчивается сразу перед закрывающим тегом </script>. Парсер HTML внутрь тега не заглядывает, для него это просто какой-то текст, который он потом отдает в парсер Javascript.


В свою очередь, Javascript — это самостоятельный язык с собственным синтаксисом, он, вообще говоря, никаким специальным образом не рассчитан на то, что будет встроен в HTML. В нем, как в любом другом языке, есть строковые литералы, в которых может быть что угодно. И, как вы уже должны были догадаться, может встретиться последовательность символов, означающая закрывающий тег </script>.


<script>
  var s = "surprise!</script><script>alert('whoops!')</script>";
</script>

Что тут должно происходить: переменной s должна присваиваться безобидная строка.


Что тут происходит на самом деле: Скрипт, в котором объявляется переменная s на самом деле заканчивается так: [var s = "surprise!], что приводит к ошибке синтаксиса. Весь текст после него интерпретируется как чистый HTML и в него может быть внедрена любая разметка. В данном случае открывается новый тег <script> и выполняется зловредный код.


Мы получили тот же эффект, как когда в значении атрибута присутствует кавычка. Но в отличие от значений атрибута, для тега <script> нет никакого способа экранировать исходный контент. HTML entities внутри тега <script> не работают, они будут переданы в парсер Javascript без изменений, то есть либо приведут к ошибке, либо изменят его смысл. Стандарт HTML прямо говорит, что в содержимом тега <script> не может быть последовательности символов </script> ни в каком виде. А стандарт Javascript не запрещает такой последовательности быть где угодно в строковых литералах.


Получается парадоксальная ситуация: после встраивания валидного Javascript в валидный документ HTML абсолютно валидными средствами мы можем получить невалидный результат.


На мой взгляд это и является уязвимостью разметки HTML, приводящей к уязвимостям в реальных приложениях.


Как эксплуатируется уязвимость


Конечно, когда вы просто пишете какой-то код, трудно представить, что вы напишете в строке </script> и не заметите проблем. Как минимум, подсветка синтаксиса даст вам знать, что тег закрылся раньше времени, как максимум, написанный вами код не запустится и вы будете долго искать, что произошло. Но это не является основной проблемой с этой уязвимостью. Проблема возникает там, где вы вставляете какой-то контент в Javascript, когда генерируете HTML. Вот частый кусок кода приложений на реакте с серверным рендерингом:


<script>
window.__INITIAL_STATE__ = <%- JSON.stringify(initialState) %>;
</script>

В initialState </script> может появиться в любом месте, где данные поступают от пользователя или из других систем. JSON.stringify не будет менять такие строки при сериализации, потому что они полностью соответствуют формату JSON и Javascript, поэтому они просто попадут на страницу и позволят злоумышленнику выполнить произвольный Javascript в браузере пользователя.


Другой пример:


<script>
  analytics.identify(
      '<%- user.id.replace(/(\'|\\)/g, "\\$1") %>',
      '<%- request.HTTP_REFERER.replace(/(\'|\\)/g, "\\$1") %>',
      ...
  );
</script>

Тут в строки с соответствующим экранированием записываются id пользователя и referer, который пришел на сервер. И, если в user.id вряд ли будет что-то кроме цифр, то в referer злоумышленник может запихнуть что угодно.


Но на закрывающем теге </script> приколы не заканчиваются. Опасность представляет и открывающий тег <script>, если перед ним в любом месте есть символы [<!--], которые в обычном HTML обозначают начало многострочного комментария. Причем в этом случае вам уже не поможет подсветка синтаксиса большинства редакторов.


<script>
  var a = 'Consider this string: <!--';
  var b = '<script>';
</script>
<p>Any text</p>
<script>
  var s = 'another script';
</script>

Что видит здоровый человек и большинство подсветок синтаксиса в этом коде? Два тега <script>, между которыми находится параграф.


Что видит больной парсер HTML5? Он видит один (!) незакрытый (!) тег <script>, содержащий весь текст со второй строчки до последней.


Я до конца не понимаю, почему это так работает, мне понятно лишь, что встретив где-либо символы [<!--], парсер HTML начинает считать открывающие и закрывающие теги <script> и не считает скрипт законченным, пока не будут закрыты все открытые теги <script>. То есть в большинстве случаев этот скрипт будет идти до конца страницы (если только кто-то не смог внедрить еще один лишний закрывающий тег </script> ниже, хе-хе). Если вы до этого не сталкивались с подобным, то можете подумать, что я сейчас шучу. К сожалению, нет. Вот скриншот DOM-дерева примера выше:



Самое неприятное, что в отличие от закрывающего тега </script>, который в Javascript может встретиться только внутри строковых литералов, последовательности символов <!-- и <script могут встретиться и в самом коде! И будут иметь точно такой же эффект.


<script>
  if (x<!--y) { ... }
  if ( player<script ) { ... }
</script>

А вы точно спецификация?


Спецификация HTML, помимо того, что запрещает использование легальных последовательностей символов внутри тега <script> и не дает никакого способа их экранирования в рамках HTML, также советует следующее:


The easiest and safest way to avoid the rather strange restrictions described in this section is to always escape "<!--" as "<\!--", "<script" as "<\script", and "</script" as "<\/script" when these sequences appear in literals in scripts (e.g. in strings, regular expressions, or comments), and to avoid writing code that uses such constructs in expressions.

Что можно перевести как «Всегда экранируйте последовательности "<!--" как "<\!--", "<script" как "<\script", а "</script" как "<\/script", когда они встречаются в строковых литералах в ваших скриптах и избегайте этих выражений в самом коде». Эта рекомендация меня умиляет. Тут делается сразу несколько наивных предположений:


  1. Во встраиваемом скрипте (а это не обязательно Javascript) перечисленные выше последовательности символов могут быть либо внутри строковых литералов, либо их можно легко избежать в синтаксисе языка.
  2. Во встраиваемом скрипте в строковых литералах можно экранировать не специальные символы и это не приводит к изменению значений литералов.
  3. Тот, кто встраивает скрипт, знает что это за скрипт, глубоко понимает его синтаксис и способен производить изменения в его структуре.

И, если первые два пункта выполняются хотя бы для Javascript, то последний не выполняется даже для него. Не всегда скрипт в HTML вставляет квалифицированный человек, это может быть какой-то генератор HTML. Вот пример того, как с этим не справляется сам браузер:


var script = document.createElement('script')
script.innerText = 'var s = "</script><script>alert(\'whoops!\')</script>"';
console.log(script.outerHTML);

>>> <script>var s = "</script><script>alert('whoops!')</script>"</script>

Как видите, строка с сериализованным элементом не будет распаршена в элемент, аналогичный исходному. Преобразование DOM-дерево → HTML-текст в общем случает не является однозначным и обратимым. Некоторые DOM-деревья просто нельзя представить в виде исходного HTML-текста.


Как избежать проблем?


Как вы уже поняли, способа безопасно вставить Javascript в HTML нет. Но есть способы сделать Javascript безопасным для вставки в HTML (почувствуйте разницу). Правда для этого нужно быть предельно внимательным всё время, пока вы пишете что-то внутри тега <script>, особенно если вы вставляете любые данные с помощью шаблонизатора.


Во-первых, вероятность того, что у вас в исходном тексте (даже после минификации) не в строковых литералах встретятся символы [<!-- <script>] крайне мала. Сами вы вряд ли напишете что-то такое, а если злоумышленник что-то сможет написать прямо в теге <script>, то внедрение этих символов будет беспокоить вас в последнюю очередь.


Остается проблема внедрения символов в строки. В этом случае, как и написано в спецификации, всего-то нужно заменить все "<!--" на "<\!--", "<script" на "<\script", а "</script" на "<\/script". Но беда в том, что если вы выводите какую-то структуру с помощью JSON.stringify(), то вряд ли вы захотите потом её распарсить еще раз, чтобы найти все строковые литералы и заэкранировать в них что-то. Так же не хочется советовать пользоваться другими пакетами для сериализации, где эта проблема уже учтена, потому что ситуации бывают разными, а защититься хочется всегда и решение должно быть универсальным. Поэтому я бы советовал экранировать символы / и ! с помощью обратного слеша уже после сериализации. Эти символы не могут встречаться в JSON нигде кроме как внутри строк, поэтому простая замена будет абсолютно безопасной. Это не изменит последовательность символов "<script", но она и не представляет опасности, если встречается сама по себе.


<script>
window.__INITIAL_STATE__ = <%- JSON.stringify(initialState).replace(/(\/|\!)/g, "\\$1") %>;
</script>

Точно так же можно экранировать и отдельные строки.


Другой совет — не встраивайте в тег <script> ничего вообще. Храните данные в местах, где трансформации для вставки данных однозначны и обратимы. Например, в атрибутах других элементов. Правда смотрится это довольно грязно и работает только со строками, JSON придется парсить отдельно.


<var id="s" data="surprise!</script><script>alert(&quot;whoops!&quot;)</script>"></var>
<script>
  var s = document.getElementById('s').getAttribute('data');
  console.log(s);
</script>

Но, по-хорошему, конечно, если вы хотите нормально разрабатывать приложения, а не аккуратно ходить по минному полю, нужен надежный способ встраивания скриптов в HTML. Поэтому правильным решением считаю вообще отказаться от тега <script>, как от не безопасного.


Тег <safescript>


Если не использовать встраиваемые скрипты, то что тогда? Конечно, подключать все скрипты извне — не вариант, иногда иметь какой-то Javascript с данными внутри HTML-документа очень удобно: нет лишних HTTP-запросов, не нужно делать дополнительных роутов на стороне сервера.


Поэтому я предлагаю ввести новый тег — <safescript>, содержимое которого будет полностью подчинятся обычным правилам HTML — будут работать HTML entities для экранирования контента — и поэтому встраивание в него любого скрипта будет абсолютно безопасным.


<safescript>
  var s = "surprise!&lt;/script&gt;&lt;script&gt;alert('whoops!')&lt;/script&gt;";
</safescript>

<safescript>
  var a = 'Consider this string: &lt;!--';
  var b = '&lt;script&gt;';
</safescript>

При этом нет необходимости дожидаться реализации этого тега в браузерах. Я написал очень простой полифил safescript, который позволяет использовать его прямо сейчас. Вот всё, что для этого нужно:


<script type="text/javascript" src="/static/safescript.js"></script>
<style type="text/css">safescript {display: none !important}</style>

Код внутри <safescript> выглядит ужасно и непривычно. Но это код, который попадет в сам HTML. В шаблонизаторе, который вы используете, можно сделать простой фильтр, который будет вставлять тег и экранировать все его содержимое. Вот так может выглядеть код в шаблонизаторе Django:


{% safescript %}
  var s = "surprise!</script><script>alert('whoops!')</script>";
{% endsafescript %}

{% safescript %}
  var a = 'Consider this string: <!--';
  var b = '<script>';
{% endsafescript %}

Такой подход позволяет забыть об экранировании Javascript и избежать многих уязвимостей. И было бы очень здорово, если бы ребята, разрабатывающие спецификацию HTML, добавили такой скрипт в базовый набор или придумали какой-то другой способ решения проблемы небезопасного встраивания скриптов в HTML.

Tags:
Hubs:
+32
Comments 67
Comments Comments 67

Articles