• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Уничтожение потока данных - уязвимость type confusion в Chrome

evilcore

(L3) cache
Забанен
Регистрация
03.10.2018
Сообщения
214
Решения
1
Реакции
146
Депозит
0.0006
Пожалуйста, обратите внимание, что пользователь заблокирован
Перевод https://googleprojectzero.blogspot.com/2019/05/trashing-flow-of-data.html
Опубликовал Стефан Рёттгер (Stephen Röttger)


В данном блоге я хочу представить crbug.com/944062, уязвимость в JavaScript компиляторе TurboFan браузера Chrome, которая была обнаружена независимо Сэмюелем (Samuel, saelo@) при тестировании с помощью fuzzilli и мной при ручном аудите кода. Ошибка была обнаружена в бета-версии, успешно исправлена, и не попала в стабильную версию Chrome, но я думаю, что по ряду причин она поучительна, и о ней стоит написать. Проблема возникла при обработке TurboFan встроенной функции Array.indexOf, и вначале выглядела просто как утечка информации, её превращение в примитив для произвольной записи было неочевидным. Кроме того, это наглядный пример распространенной ошибки JIT-компиляторов: код делает предположения во время компиляции и не использует соответствующие проверки этих предположений во время выполнения.

Уязвимость
Уязвимый код был обнаружен в JSCallReducer::ReduceArrayIndexOfIncludes в одной из оптимизаций TurboFan, которая была призвана заменить Array.prototype.indexOf оптимизированной версией для известного типа элементов массива. Если вы незнакомы с типами элементов, я рекомендую https://v8.dev/blog/elements-kinds . Если коротко, элементы массива могут храниться по-разному, например, как числа с плавающей запятой или как тегированные указатели в массивах С++ фиксированного размера, или, в худшем случае, в качестве словаря. И, если тип элементов известен, для доступа к ним компилятор может выдавать оптимизированный код в каждом конкретном случае.

Давайте посмотрим на уязвимую функцию:
JavaScript:
// For search_variant == kIndexOf:
// ES6 Array.prototype.indexOf(searchElement[, fromIndex])
// #sec-array.prototype.indexof
// For search_variant == kIncludes:
// ES7 Array.prototype.inludes(searchElement[, fromIndex])
// #sec-array.prototype.includes

Reduction JSCallReducer::ReduceArrayIndexOfIncludes(
   SearchVariant search_variant, Node* node) {
CallParameters const& p = CallParametersOf(node->op());

if (p.speculation_mode() == SpeculationMode::kDisallowSpeculation) {
   return NoChange();
}

Node* receiver = NodeProperties::GetValueInput(node, 1);
Node* effect = NodeProperties::GetEffectInput(node);
Node* control = NodeProperties::GetControlInput(node);
ZoneHandleSet<Map> receiver_maps;

NodeProperties::InferReceiverMapsResult result =     NodeProperties::InferReceiverMaps(broker(), receiver, effect,  &receiver_maps);
if (result == NodeProperties::kNoReceiverMaps) return NoChange();

[...]

Для Array.p.indexOf компилятор выводит карту массива, используя функцию InferReceiverMaps. На карте представлена форма объекта, например, какое свойство хранится, где и как хранятся элементы. Как следует из представленного фрагмента, если карта неизвестна, в конце функции код будет принят, и дальнейшей оптимизации функции не будет ([I]result == kNoReceiverMaps[/I]).

На первый взгляд, это кажется разумным, пока вы не заметите, что InferReceiverMaps возвращает три возможных значения:

JavaScript:
enum InferReceiverMapsResult {
kNoReceiverMaps,         // No receiver maps inferred.
kReliableReceiverMaps,   // Receiver maps can be trusted.
kUnreliableReceiverMaps  // Receiver maps might have changed (side-effect).
};

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

Это означает, что если карты массивов ненадежны, и у вас возникает зависимость от чего-либо, кроме типа экземпляра, вам необходима защита от изменений путём проверки во время выполнения. Существует два способа добавить проверку ваших предположений во время выполнения. Например, можно вставить в последовательность эффектов узел CheckMaps, который обработает код при ненадёжной карте.

Или, в качестве альтернативы, можно использовать такое свойство как «зависимости компиляции» (compilation dependencies), которое не добавляет проверку к коду, основанному на предположении, а вместо этого проверяет код, изменяющий это предположение. В этом случае, если возвращенные карты определены как стабильные, то есть ни в одном из объектов с данной картой никогда не наблюдался переход на другие карты, можно добавить StableMapDependency. Это зарегистрирует обратный вызов для отказа от оптимизации текущей функции в случае, если объект когда-либо выйдет из стабильной карты. Здесь возможны ошибки либо, если компилятор выполнит такую проверку без регистрации соответствующей зависимости компиляции, либо, если какой-либо путь к коду лишит законной силы предположение при отсутствии обратных вызовов (интересный пример, см. SSD Advisory).

Уязвимость в данном случае заключается в том, что компилятор вообще не добавляет никаких проверок во время выполнения в случае ненадежных карт, и мы получаем путаницу в типах элементов. Если во время выполнения элементы с плавающей запятой превратятся в словарь, Array.p.indexOf выйдет за пределы, поскольку словарь с 100000 элементами намного короче упакованного массива из 100000 значений с плавающей запятой.

Сэмюель продемонстрировал это на примере. Ниже - немного улучшенная версия кода для иллюстрации:

JavaScript:
function f(idx, arr) {
// Transition to dictionary mode in the final invocation.
arr.__defineSetter__(idx, ()=>{});
// Will then read OOB.
return arr.includes(1234);
}
f('', []);
f('', []);
%OptimizeFunctionOnNextCall(f);
f('1000000', []);

Утечка информации
Первое, что можно сделать для создания эксплойта, - это превратить данную ошибку в утечку информации. Обратите внимание, что Array.p.indexOf проверяет строгое равенство элементов, что в принципе сводится к сравнению указателей для объектов кучи или сравнению значений для примитивов. Поскольку единственное возвращаемое нам значение - это индекс элемента в массиве, наша единственная опция, по-видимому, полный перебор (брутфорс) для указателя.

Идея состоит в том, чтобы превратить массив чисел с плавающей запятой в словарь, заставить indexOf считывать данные за пределами границ и организовать полный перебор по параметру searchElement, пока он не вернет OOB (out-of-band) индекс указателя, утечку которого мы планируем, а именно, указателя на место хранения ArrayBuffer. Нам также нужно поместить значение поиска в память за указателем ArrayBuffer, чтобы все неудачные попытки не выходили за пределы кучи и не вызывали ошибку сегмента. Наш макет памяти будет выглядеть примерно так:

3998


Наш указатель ArrayBuffer будет выровнен по странице, что немного уменьшает объёмы полного перебора. Но чтобы ещё более ускорить атаку, можно использовать дополнительный приём, который позволяет перебирать старшие 32 бита отдельно. Оптимизация V8 позволяет хранить малые целые числа (Smi) и объекты кучи в одном месте, используя теги для представления объектов. На x64 все указатели имеют единицу в самом младшем значащем бите, а Smi - ноль; при этом старшие 32 бита используются для хранения фактического значения. Проверяя самый младший значащий бит , V8 может различать Smi и указатель, и ему не нужно делать обёртку числа Smi в объект кучи. Это означает, что при использовании массива тегированных объектов и чисел Smi для нашего OOB-доступа мы организуем полный перебор для старших 32 бит указателя, используя Smi в качестве параметра searchElement.

При реализации этой атаки я постоянно вызывал сбой, в котором ошибка выходила за пределы кучи, вне диапазона, который я поместил в память. Для меня поначалу оказалось трудной задачей подобрать правильную схему памяти, чтобы мои два шага полного перебора сработали. Причина обнаружилась после небольшой отладки. После примерно 0x2000000 попыток брутфорса, включается сборщик мусора и перемещает объекты, нарушая требуемую компоновку кучи. К счастью, эта проблема легко преодолевается: если брутфорс не удался после 0x1000000, переместите ArrayBuffer и начните сначала, пока указатель не окажется в необходимом диапазоне.

Произвольная запись
Теперь рассмотрим, как эту ошибку можно превратить в произвольную запись. Когда я сказал, что проверка строгого равенства в основном сводится к сравнению указателей, я не упомянул особый случай: строки. Строки в V8 имеют множественное представление. Обычная строка, состоящая из длины и байтов в памяти, представлена классом SeqString. Но есть также SlicedString, которая является фрагментом другой строки, и ConsString, которая представляет собой объединение двух строк и более.

Если V8 сравнивает две строки, даже при строгом равенстве, он сначала проверяет равенство длины и первого символа, и, если они равны, выравнивает обе строки до SeqString, чтобы затем легко их сравнить.

Так как мы уже знаем адрес контролируемой памяти с указателем на наш ArrayBuffer, мы можем создать там поддельный строковый объект и использовать баг чтения OOB, чтобы вызвать type confusion между float и объектом кучи. Это позволит нам вызвать выравнивание для нашей поддельной строки. Моей первой идеей было переполнение буфера, который выделен для результата, создав ConsStrings, в котором левая длина плюс правая длина была бы больше общей длины. Это работает, и я полагаю, что подобный эксплойт возможен, однако после переполнения я всегда оказывался в ситуации, когда следующая запись происходила со смещением, близким к INT_MIN или INT_MAX, и запускала ошибку сегментации.

Существует более общий способ превратить данную ошибку в произвольную запись, эксплуатируя фазу маркировки сборщика мусора (рекомендую почитать https://v8.dev/blog/trash-talk). Чтобы понять этот механизм, сделаем небольшое отступление и посмотрим на структуру памяти в V8. Куча организована в нескольких так называемых пространствах. Большинство новых объектов попадают в новое пространство, которое делится на две части. Память здесь распределяется линейно, что позволяет легко контролировать относительные смещения между объектами. Раунд сборки мусора перемещает объекты из одной части нового пространства в другую, и, если они переживут два раунда сбора, тогда происходит их перемещение в старое пространство. Кроме того, есть несколько специальных пространств для некоторых объектов, например для больших объектов, для карт или для кода. Во время написания пробелы всегда выровнены до 2<<18 или 2<<19 байтов, поэтому вы можете найти специфичные для пространства метаданные для данного объекта кучи, маскируя младшие биты указателя.

Так что же произойдет, если сборщик мусора начнет работать, пока V8 обрабатывает наш поддельный строковый объект? GC пройдет через все живые объекты и пометит, что они живы. Для этого он начнёт со всех глобальных объектов, всех объектов, которые в данный момент находятся в стеке, и, в зависимости от типа GC, может опционально запустить все объекты в старом пространстве с указателями на новое пространство. Поскольку наш поддельный строковый объект в настоящее время находится в стеке, GC попытается пометить его как живой. Для этого он начнёт маскировать младшие биты указателя, чтобы найти метаданные пространства, взять указатель на окно маркировки (marking bitmap) и установить бит, сместившись на расстояние, которое соответствует смещению нашего объекта в пространстве. Однако, поскольку место хранения нашего ArrayBuffer не является объектом кучи V8 и, следовательно, не находится в самом пространстве, метаданные могут также храниться в управляемой пользователем памяти. Установив указатель окна маркировки на произвольное значение, мы получаем примитив для установки бита по любому выбранному адресу. Окончательный макет памяти будет выглядеть примерно так:

3997


В качестве цели для перезаписи я выбрал длину резервного хранилища массива, поскольку это дает очень надёжный примитив, легко превращаемый в утечку объекта и произвольную запись. Единственным оставшимся препятствием является утечка адреса поля длины. Мы можем попытаться повторно организовать брутфорс для этого адреса, но это займет немного больше времени, так как на этот раз указатель не будет выровнен по странице.

Вместо этого мы можем использовать операцию выравнивания строк и получить указатель на новое пространство, если мы запустим её правильно. Создадим поддельную ConsString для выравнивания, снова используя наш OOB-примитив. Напомню, что для запуска выравнивания нам просто нужно правильно задать первый символ и длину. Размещение в памяти будет в конечном итоге либо в новом, либо в старом пространстве, в зависимости от того, где находилась исходная строка, в новом или старом пространстве. Мы контролируем это, устанавливая флаг в наших поддельных метаданных. После того, как произошло выравнивание, V8 заменит левый указатель нашей ConsString указателем на выровненную строку, а правую сторону - на пустую строку, и мы сможем прочитать эти указатели обратно из нашего ArrayBuffer. Поскольку размещение в новом пространстве происходит линейно, наш поврежденный массив будет иметь предсказуемое смещение.

После того, как мы повредили значение поля длины массива, мы можем использовать его для перезаписи других данных в куче, чтобы получить нужные нам примитивы. В Интернете есть множество статей, к которым можно теперь перейти, например, этот замечательный пост Андреа Биондо (Andrea Biondo, @anbiondo). Вы можете найти эксплойт вплоть до повреждения длины массива здесь. Это написано для ручной сборки Chrome 74.0.3728.0.


Вывод
Уже не раз было показано, что ошибки, которые на первый взгляд не кажутся серьезными, можно превратить в эксплойт с произвольным чтением / записью. В рассмотренном случае, в частности, запуск сборщика мусора после размещения нашего поддельного указателя в стеке предоставил нам очень сильный примитив для эксплойта. Команда V8 исправила ошибку очень быстро, как обычно. Более того, они запланировали рефакторинг функции InferReceiverMaps, чтобы предотвратить подобные проблемы в будущем. Когда я впервые обратил внимание на эту функцию, я был убежден в ошибочности одного из вызовов и провёл аудит всех вызовов. В то время я не смог найти уязвимость, но несколько месяцев спустя я наткнулся на этот недавно добавленный код, в котором не хватало необходимых проверок во время выполнения. Оглядываясь назад, пожалуй, стоило бы указать команде на этот сомнительный API ещё до обнаружения уязвимостей.

Кроме того, подобный упреждающий рефакторинг мог бы сделать какой-нибудь внешний участник и стать отличным кандидатом на награду от Patch Reward Program. Так что, если у вас есть идея, как предотвратить возможные ошибки, пусть даже для уязвимостей, обнаруженных другими исследователями, подумайте о том, чтобы представить её в команде V8 и внести свой вклад в код, и, возможно, получить заслуженную награду.
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх