Введение

21 августа браузер Chrome получил обновление, которое исправило 37 ошибок, связанных с безопасностью. Внимание исследователей по всему миру привлекла уязвимость CVE-2024-7965, описанная как некорректная имплементация в V8. На практике это означает возможность RCE (remote code execution) в рендерере браузера, что открывает простор для последующей эксплуатации. Заинтересованность исследователей повысилась еще сильнее, когда 26 августа Google сообщила об использовании уязвимости «в дикой природе».

Мы проанализировали эту уязвимость, чтобы вам не пришлось.

Первоначальный анализ патча

В отличие от предыдущего нашего исследования, где было необходимо заниматься сравнением исполняемых файлов, сейчас делать ничего подобного не нужно: весь исходный код V8 публичен. Однако провести некоторый анализ все равно надо, чтобы найти необходимый коммит. Спустя некоторое время поисков видим следующее:

Здесь мы сразу обращаем внимание на важную деталь: патч внесен в компонент V8 TurboFan — оптимизирующий компилятор кода JS. TurboFan работает по принципу sea of nodes: сначала он строит граф, проводит на нем оптимизации, а затем выбирает инструкции под конкретную архитектуру и генерирует машинный код.

Исправлена функция ZeroExtendsWord32ToWord64, с помощью которой компилятор проверяет, всегда ли значение, приходящее по разным путям (так называемая цепочка phi-узлов, phi nodes), имеет верхние 32 бита равными 0. Если компилятор не может доказать, что верхние 32 бита числа равны 0, то добавляет дополнительную проверку — сравнивает число с максимальным значением unsigned int (0xffffffff).

Потенциально уязвимая функция работает следующим образом: она рекурсивно обходит граф из phi-узлов в глубину, помечая пройденные ноды как kUpperBitsGuaranteedZero. Отметка узлов происходит через запись в кастомный вектор phi_states_. Если среди потомков находится хоть один узел, верхние биты которого не равны нулю гарантированно, то происходит рекурсивный возврат с пометкой всех узлов на пути к данному как kNoGuarantee. Если узел уже был помечен, то компилятор его не проходит.

Некорректный граф

Если мы присмотримся к патчу, то заметим, что при первом вхождении в узел он обнуляет все ранее сохраненные значения для предыдущих узлов. Это наталкивает нас на мысль, что обход графа делает некорректные пометки. Но каким образом это происходит? Ведь если мы попробуем обойти обычный граф, то заметим, что все работает корректно:

Граф из phi-узлов
Граф из phi-узлов

В процессорной архитектуре x86-64 компилятор считает, что положительная константа имеет верхние 32 бита равными 0, а отрицательная константа — нет. При обходе такого графа phi_states_ будет находиться в полностью корректном состоянии.

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

На картинках с обходом графа зеленым цветом отмечены те вершины, которые определены как имеющие верхние 32 бита равными нулю, красным цветом — как те, верхние 32 бита которых гарантированно нулю не равны. Как мы помним, если хоть один потомок вершины не имеет верхние 32 бита равными нулю, то сама вершина помечается красным.

Однако если граф будет циклическим, то можно получить некорректное состояние phi_states_: например, при условии, что обход пойдет по циклу раньше, чем по другим соседним узлам, в phi_states_ будет храниться некорректное состояние.

Первый этап обхода графа
Первый этап обхода графа
Второй этап обхода графа. Все ноды были помечены, происходит рекурсивный возврат
Второй этап обхода графа. Все ноды были помечены, происходит рекурсивный возврат
Финальный граф (некорректный)
Финальный граф (некорректный)

Можно получить граф из phi-нод, в котором определенный узел помечен в phi_states_ как зеленый, однако верхние 32 бита его потомка могут быть не равны нулю. Таким образом, существует путь, при котором компилятор «поверит» в наше значение, а мы его обманем. Именно это исправляет патч. При каждом повторном вызове функции ZeroExtendsWord32ToWord64 он обнуляет состояние phi_states_.

Что критичного может произойти в такой ситуации? Если мы посмотрим, как компилируется операция обращения к массиву по индексу, то заметим, что в 64-битных архитектурах компилятор сначала пытается убедиться, что индекс, по которому мы обращаемся, имеет верхние 32 бита равными нулю, затем кладет его в 64-битный регистр и производит операцию над памятью. Если компилятор «поверил», что верхние 32 бита равны нулю, то он убирает проверку на то, что число меньше, чем 0xffffffff. Соответственно, мы получаем ситуацию, при которой если в 64-битном регистре верхние биты окажутся не равными нулю в момент обращения по индексу, то произойдет обращение out-of-bounds.

К сожалению, на архитектуре x86-64 получить memory corruption PoC не удалось в силу того, что при попытке привести 64-битное число к 32-битному компиляция происходит так, чтобы гарантированно обнулить верхние 32 бита, поэтому мы не можем получить такой регистр, в котором будут находиться неопределенные значения. Комментарий в начале функции это достаточно хорошо описывает:

Однако в архитектуре ARM64 аналогичная функция оставляет неопределенные значения в верхних битах, что позволяет нам получить их не равными нулю в момент обращения к массиву по индексу:

Так же как серьезный шахматист, не досчитывая вариант до конца, может понять, что надо играть именно так, на этом этапе мы были уверены, что это и есть тот самый правильный способ эксплуатации и приступили к разработке PoC.

Эксплуатация, импакт и вывод

Итак, наш план эксплуатации уязвимости:

  1. Получаем необходимый циклический граф с помощью циклов и условий.

  2. Инициируем некорректное состояние в phi_states_. Наш PoC использует для этого BigInt.

  3. Добавляем в граф вершину TruncateInt64ToWord32 с помощью комбинации Math.min с оператором >>> 0.

  4. Инициируем последующий вызов таким образом, чтобы в качестве индекса было число, в которое мы поместили верхние биты.

Эти шаги позволяют получить memory corruption и словить segmentation fault в V8.

Что же дает эксплуатация этой уязвимости? Так как она позволяет атаковать только устройства с процессорной архитектурой ARM64, то распространяется по большей части на смартфоны Android и ноутбуки Apple, выпущенные после ноября 2020 года. В случае наличия у хакеров эксплоита, позволяющего совершить побег из песочницы браузера, можно получить полный контроль над приложением браузера: читать пароли, красть сессии пользователей.

В то же время отдельная эксплуатация рассмотренной уязвимости также опасна: при наличии XSS на любом из поддоменов сайта можно получить пароли и cookie с основного домена и всех поддоменов. Получить конфиденциальные данные с других сайтов в этом случае нельзя, ведь от этого защищает технология Site Isolation, разделяющая процессы рендереров. Однако даже в таком виде уязвимость критична, и если на ваших устройствах версия Chrome все еще меньше чем 128.0.6613.84, то необходимо обновить браузер в кратчайшие сроки.

Fun fact: поскольку при обращении к массиву по индексу мы контролируем верхние 32 бита, то, скорее всего, возможна ситуация, когда не понадобится дополнительная уязвимость для обхода V8 sandbox и можно будет сразу приступить к эксплуатации песочницы самого браузера.

Ссылка на PoC

Приложение. Граф, полученный в ходе эксплуатации

Автор статьи:

Юрий Паздников, младший специалист по исследованию уязвимостей

Комментарии (0)