Наш браузер Ladybird неплохо справляется с качественно отформатированным веб-контентом, но я решил, что будет полезно проверить его инструментами для исследования безопасности. Поэтому сегодня мы воспользуемся Domato ? — DOM-фаззером из Google Project Zero, чтобы провести стресс-тест Ladybird и устранить найденные в процессе ошибки.
Работает это следующим образом: Domato генерирует рандомизированные веб-страницы со множеством по большей части валидного, но странного HTML, CSS и JavaScript. Я загружу эти страницы в отладочную сборку Ladybird и посмотрю, что получится.
README Domato хвастается кучей багов, обнаруженных во всех популярных браузерах, поэтому я не сомневаюсь, что он обнаружит их и в нашем. Поехали!
Проблема 1: внутри
Меня не удивило, что для нахождения первой проблемы потребовалось меньше секунды! Созданная Domato страница весит 562 КиБ, но мне удалось свести проблему к следующему:
<body>
<script>
let mfrac = document.createElement("mfrac");
mfrac.appendChild(document.createElement("th"));
document.body.appendChild(mfrac);
</script>
Для этого теста я скомпилировал Ladybird с использованием UBSAN (Undefined Behavior SANitizer), и получил очень полезный вывод трассировки:
LibWeb/HTML/HTMLTableCellElement.cpp:55:36: runtime error: member call on null pointer of type 'Web::DOM::Node'
#0 0x7e9bfa1610dd in table_containing_cell LibWeb/HTML/HTMLTableCellElement.cpp:55:36
#1 0x7e9bfa1610dd in Web::HTML::HTMLTableCellElement::apply_presentational_hints(Web::CSS::StyleProperties&) const LibWeb/HTML/HTMLTableCellElement.cpp:101:33
#2 0x7e9bf97c22d1 in Web::CSS::StyleComputer::compute_cascaded_values(Web::CSS::StyleProperties&, Web::DOM::Element&, AK::Optional<Web::CSS::Selector::PseudoElement::Type>, bool&, Web::CSS::StyleComputer::ComputeStyleMode) const LibWeb/CSS/StyleComputer.cpp:1448:17
#3 0x7e9bf97e5899 in Web::CSS::StyleComputer::compute_style_impl(Web::DOM::Element&, AK::Optional<Web::CSS::Selector::PseudoElement::Type>, Web::CSS::StyleComputer::ComputeStyleMode) const LibWeb/CSS/StyleComputer.cpp:2231:5
#4 0x7e9bf97e447e in Web::CSS::StyleComputer::compute_style(Web::DOM::Element&, AK::Optional<Web::CSS::Selector::PseudoElement::Type>) const LibWeb/CSS/StyleComputer.cpp:2202:12
#5 0x7e9bf9a7e60c in Web::DOM::Element::recompute_style() LibWeb/DOM/Element.cpp:575:64
Старое доброе разыменование нулевого указателя!
Оказалось, что мы реализовали элементы <th>
и <td>
с предположением, что над ними всегда в дереве DOM где-то будет <table>
. Вероятно, мы так считали, потому что показанное ниже не допускается парсером HTML:
<mfrac><th>
Если загрузить такую разметку в соответствующий спецификациям браузер, то он создаст один элемент <mfrac>
, внутри которого ничего не будет.
Однако при создании узлов DOM вручную при помощи JavaScript API можно нарушить некоторые из правил, которым должен следовать парсер, и поместить <th>
внутрь <mfrac>
!
Вот наша функция с багом:
HTMLTableElement const& table_containing_cell(HTMLTableCellElement const& node)
{
auto parent_node = node.parent();
while (!is<HTML::HTMLTableElement>(parent_node))
parent_node = parent_node->parent();
return static_cast<HTML::HTMLTableElement const&>(*parent_node);
}
Она реализовывала древнюю фичу: <table border=3>
и <table padding=5>
применяет border и padding CSS к каждой ячейке таблицы, а не только к самому блоку таблицы.
Чтобы исправить ошибку, достаточно просто перестать считать, что элементы <th>
и <td>
всегда содержатся внутри <table>
в цепочке предшественников. Нам не нужна вспомогательная функция table_containing_cell
, достаточно просто заменить это:
auto const& table_element = table_containing_cell(*this);
на это:
auto const table_element = first_ancestor_of_type<HTMLTableElement>();
if (!table_element)
return;
И мы решили проблему 1!
Проблема 2: присвоение обработчиков оконных событий в отсоединённом DOM
Мы продолжили выполнение фаззера и снова менее чем через одну секунду обнаружили новую проблему. Созданная Domato страница весит 472 КиБ, но проблема свелась к следующему к следующему:
<script>
var parser = new DOMParser();
var doc = parser.parseFromString("", "text/html");
var body = doc.createElement("body");
body.onblur = null;
</script>
При открытии в Ladybird мы получаем такую ошибку:
VERIFICATION FAILED: m_ptr at LibJS/Heap/GCPtr.h:174
#1 JS::GCPtr<Web::HTML::Window>::operator* at LibJS/Heap/GCPtr.h:174
#2 Web::DOM::Document::window at LibWeb/DOM/Document.h:320
#3 Web::HTML::HTMLBodyElement::global_event_handlers_to_event_target at LibWeb/HTML/HTMLBodyElement.cpp:104
#4 Web::HTML::GlobalEventHandlers::set_onblur at LibWeb/HTML/GlobalEventHandlers.cpp:24
#5 Web::Bindings::HTMLElementPrototype::onblur_setter at LibWeb/Bindings/HTMLElementPrototype.cpp:1153
Здесь она возникает из-за особого поведения атрибутов обработчика событий onfoo
с элементом <body>
. Для совместимости с древним веб-контентом присвоения document.body.onfoo
должны перенаправляться на window.onfoo
. Однако документы, создаваемые при помощи DOMParser
, не имеют оконного объекта!
Оказалось, мы недопоняли эту подробность реализации и структурировали нашу внутреннюю модель объекта так, как будто каждый документ всегда имеет окно. Упс!
Исправление заключается в том, чтобы Document::window()
возвращало значение, которое может быть нулевым, а затем обрабатывать null в куче разных мест. При присвоении document.body.onblur
в безоконном документе мы теперь просто ничего не делаем, как и другие браузеры.
Проблема 3: бесконечная рекурсия в SVG
Современные браузеры должны поддерживать формат SVG и как встроенный в HTML, и как внешние изображения. Это добавляет целую новую группу пограничных случаев и интересных взаимодействий.
Например, SVG позволяет объявлять градиенты, ссылающиеся на другие градиенты для наследования их цветов. Как оказалось, мы не учли случай, когда градиент ссылается на самого себя:
<svg>
<linearGradient id="oops" href="#oops"/>
<rect fill="url(#oops)" />
</svg>
Представленный выше SVG заставит нашу реализацию войти в бесконечный цикл, потому что она попытается следовать по цепочке из градиентов по ссылке.
Очевидно, что исправить это можно, прекратив двигаться по цепочке градиентов, если текущий градиент ссылается сам на себя. Однако так мы не учтём циклы ссылок, состоящих из нескольких шагов:
<svg>
<linearGradient id="lol" href="#lmao"/>
<linearGradient id="lmao" href="#even"/>
<linearGradient id="even" href="#lol"/>
<rect fill="url(#lol)" />
</svg>
Чтобы правильно обрабатывать циклические ссылки, нам нужно отслеживать все посещённые градиенты, и прерывать цепочку, если встретим градиент, который уже посещали ранее.
Любопытно, что Firefox жалуется на подобные градиенты в своей консоли разработчика:
Проблема 4: попытка получения доступа к свойствам окна удалённого iframe
Это любопытный баг:
<iframe></iframe>
<script>
window.onload = function() {
let iframe = document.querySelector("iframe")
let iframeWindow = iframe.contentWindow;
iframe.remove();
iframeWindow.getSelection();
}
</script>
Приведённый выше тест вылетает следующим образом:
LibWeb/HTML/WindowProxy.cpp:161:136: runtime error: reference binding to null pointer of type 'const BrowsingContext'
#0 0x77367675cadf in Web::HTML::WindowProxy::internal_get(JS::PropertyKey const&, JS::Value, JS::CacheablePropertyMetadata*) const LibWeb/HTML/WindowProxy.cpp:161:5
#1 0x773670c4fcca in JS::Bytecode::get_by_id(JS::VM&, AK::DeprecatedFlyString const&, JS::Value, JS::Value, JS::Bytecode::PropertyLookupCache&) LibJS/Bytecode/CommonImplementations.h:105:18
#2 0x773670c182d5 in JS::Bytecode::Op::GetById::execute_impl(JS::Bytecode::Interpreter&) const LibJS/Bytecode/Interpreter.cpp:1088:28
#3 0x773670bc8e1f in execute LibJS/Bytecode/Op.h:1898:9
#4 0x773670bc8e1f in JS::Bytecode::Interpreter::run_bytecode() LibJS/Bytecode/Interpreter.cpp:409:38
Оказалось, на самом деле это баг спецификации HTML! Когда iframe удаляется из DOM, документ его содержимого открепляется от контекста браузинга. Однако при получении или задании свойства оконному объекту мы выполняем алгоритм спецификации под названием «check if an access between two browsing contexts should be reported», который исследует контекст браузинга в окне, получающем доступ, и в окне, к которому получают доступ. В спецификации некорректно предполагается, что в момент доступа к свойству с обоими окнами связан контекст браузинга.
Я открыл issue спецификации HTML и пропатчил этот баг в Ladybird, добавив проверку на null.
Нахождение багов спецификации — самое моё любимое занятие в процессе работы над Ladybird. Оно позволяет нам совершенствовать спецификации для всех людей при помощи отправки баг-репорта или исправления.
Проблема 5: бесконечные циклы в Element.before()
При открытии показанной ниже страницы её загрузка не прекращается. Она просто продолжается, потребляя 100% CPU.
Проблема сводится к следующему:
<div id="one"></div><div id="two"></div>
<script>
two.before(one);
</script>
Это была ошибка в реализации before()
, который должен найти первый предшествующий элемент <div id=”two”>
того же уровня, не являющийся одним из его аргументов (то есть в этом случае <div id=”one”>
). В реализации у нас была следующая ошибка цикла:
while (auto previous_sibling = node->previous_sibling()) {
// check if previous_sibling is one of the arguments
}
Проблема здесь в том, что мы продолжаем получать node->previous_sibling
, вместо того, чтобы продолжить следовать по цепочке элементов того же уровня через previous_sibling->previous_sibling
.
Вот как я это исправил:
for (auto sibling = node->previous_sibling(); sibling; sibling = sibling->previous_sibling()) {
// check if previous_sibling is one of the arguments
}
Заключение
Продолжать разговор об ошибках можно долго, так что закончим на этом. Мы нашли пять реальных багов, один из которых оказался багом спецификации, и смогли устранить их все.
Хотя примерно таких результатов работы фаззера я и ожидал, всё равно любопытно, насколько быстро начинает всё разваливаться, когда возникают странные и неожиданные входные данные. Фаззеры наподобие Domato — потрясающий ресурс для тех, кто хочет сделать своё ПО более надёжным.
Следующим нашим этапом будет довести Ladybird до такого состояния, чтобы он мог справляться с непрерывным потоком данных фаззинга. А когда браузер станет достаточно стабильным, мы можем начать выполнять его автоматически где-нибудь в облаке, надеясь, что это позволит обнаружить ещё больше проблем.
beduin01
Жаль что нет готовых сборок для Windows. А то интересно было бы потестить.