Представление переменных JavaScript в памяти
Введение
В этой серии мы рассмотрели значительное количество тем, связанных с пониманием кода V8. Последняя область, которую нам нужно изучить, — это структура памяти для данных в куче внутри isolate V8. Нам нужно знать, как хранятся объекты, какие метаданные они включают в себя и как исследовать их в памяти. Мы также рассмотрим другие структуры, но понимание объектов является ключом к пониманию основы наших примитивов эксплуатации.Сжатие указателя
В начале 2020 года V8 сделала огромный скачок в сокращении использования памяти благодаря внедрению сжатия указателей . По сути, изменение уменьшает 64-битные указатели до 32 бит, сохраняя некоторый базовый адрес, а затем делая все указатели относительно этого базового.Каждая переменная в JavaScript хранится как указатель на объект (представьте себе объект C++, а не JavaScript). Вы, наверное, заметили многие из них, когда смотрели в Turbolizer. Однако, поскольку над целыми числами приходится много работать, и обычно с относительно простыми операциями, мы можем хранить целое число непосредственно в месте указателя. Раньше в 64-битных процессах это представлялось как
Код:
|----- 32 bits -----|----- 32 bits -----|
Pointer: |________________address______________w1|
Smi: |____int32_value____|0000000000000000000|
Всякий раз, когда указатель разыменовывается, мы можем проверить, является ли младший бит 0 или 1, и определить, действительно ли это указатель или целое число. «w1» в конце указателя символизирует биты, доступные для хранения метаданных, поскольку все адреса указателя выровнены по слову, а также 1 означает, что это указатель. Это пространство все еще существует, когда вы уменьшаете указатель до 32 бит. Однако есть проблема с целыми числами. Если они представлены 32 битами, они могут заканчиваться на 1 и выглядеть как указатель. Исправление заключалось в том, чтобы целые числа представлялись 31 битом, а LSB всегда был равен 0. Таким образом, новый макет будет таким:
Код:
|----- 32 bits -----|
Pointer: |_____address_____w1|
Smi: |___int31_value____0|
При поиске в памяти не забывайте вычитать 1 из указателей и сдвигать целые числа вправо на 1 бит, чтобы получить фактические значения!
Объекты
objects/objects.h показывает нам список всех типов объектов. Почти все в JavaScript является Object, и почти все в V8 является HeapObject. Исключения SMIпесок и TaggedIndex., о которых мы только что говорили.В JavaScript у объектов есть свойства (и методы, но давайте просто рассмотрим эти свойства), и эти свойства можно создавать и изменять в любой момент в течение жизни скрипта. Эта динамическая природа означает, что хранение объекта в памяти может оказаться сложной задачей, потому что мы не знаем, как правильно выделить память для объекта при его инициализации. Давайте обсудим эффективное хранение объектов.
Каждое свойство объекта имеет атрибуты, которые являются значением свойства, и три флага, которые указывают, доступно ли свойство для записи, перечисления и настройки. Например:
JavaScript:
var animal1 = {
type: "Bunny",
name: "Flopsy",
weight: 18,
speak: function() { return "meow"; }
};
console.log(Object.getOwnPropertyNames(animal1));
// type,name,weight,speak
console.log(Object.values(animal1));
// Bunny,Flopsy,18,function() { return "meow"; }
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(animal1, "type")));
// {"value":"Bunny","writable":true,"enumerable":true,"configurable":true}
Свойства в JavaScript рассматриваются как словарь с именем строки в качестве ключа. В общем, нам нужно где-то хранить этот словарь. Однако мы хотим увеличить скорость и сохранить память, что добавит немного сложности. В нашем текущем понимании, чтобы получить доступ к свойству объекта, нам нужно найти имя свойства в словаре, а затем получить атрибут значения. Это занимает место, когда у нас есть похожие объекты с одинаковыми именами свойств, но с разными значениями. Вместо того, чтобы иметь 2 словаря со всеми именами свойств, было бы неплохо просто иметь 1 словарь и хранить фактические значения в другом месте.
Shapes / Hidden Classes (основное название) / Maps (V8 название)
Это достигается путем хранения только значений вместе в одной и той же ячейке памяти и использования «maps» для связывания свойств с атрибутами с использованием смещений в качестве значения, которое ищется. Указатель карты сохраняется в объекте, чтобы он мог найти эти смещения…
Ладно, даже я запутался, вот пример:
JavaScript:
let obj1 = {};
let obj2 = {};
obj1.x = 5;
obj1.y = 6;
obj2.x = 7;
obj2.y = 8;
JavaScript:
// Naive case structure in memory
obj_1 = {"x": {"value": 5, ...}, "y": {"value": 6, ...}}
obj_2 = {"x": {"value": 7, ...}, "y": {"value": 8, ...}}
// Efficient case structure in memory
obj_case1_dict = {"x": {"offset": 0, ...}, "y": {"offset": 1, ...}}
obj1 = [5, 6, &obj_case1_dict] // not an accurate layout, but we'll fix this soon
obj2 = [7, 8, &obj_case1_dict] // not an accurate layout, but we'll fix this soon
// "..." here represents the enumerable, writable, and configurable flags
Примечание. Если говорить о фигурах, скрытых классах, типах, структуре или картах; мы имеем в виду расположение свойств объекта. В статьях используются взаимозаменяемые имена, потому что каждый движок JS использует другое имя. V8 использует термин «maps».
Вы создаете 2 объекта, obj1 и obj2. Вы даете каждому объекту свойства «x» и «y», которым присваиваете целые значения. В наивном случае движок JS создал бы словарь для каждого объекта. В этих словарях ключами будут «x» и «y». Каждый ключ будет иметь атрибуты свойств (опять же, значение и некоторые флаги). Глядя на атрибут «значение», вы увидите фактическое целое число, которое вы сохранили в этом свойстве. Теперь, в эффективном случае, JS-движок создает 1 словарь, который используется совместно объектами. Он по-прежнему имеет «x» и «y» в качестве ключей, но атрибуты свойства теперь содержат смещение вместо фактического значения. Это смещение можно использовать для поиска местоположения значения относительно местоположения объекта в памяти. Это становится еще более эффективным, поскольку мы создаем больше объектов с похожей формой.
Важным выводом здесь является то, что объекты имеют указатели на карту/форму/как бы вы ни называли это, что является словарем для его свойств. Имена свойств являются ключевыми значениями для атрибутов, которые содержат смещение в структуре памяти объекта, где можно найти фактическое значение свойства. Многие объекты могут иметь одну и ту же карту.
… ну, еще одно. Объект точно не указывает на карту, как я его описывал. Вместо этого он указывает на конец дерева переходов, которое является записью карты в цепочке записей. Думайте об этой цепочке точно так же, как о структуре, которую я описывал, за исключением того, что свойства связаны указателями, а не находятся в непрерывной памяти, как большинство словарей. Оказывается, когда свойства добавляются к объекту, создается новая запись с указателем на последнюю, поэтому карты больше не являются словарями, они больше похожи на связанные списки. Это называется деревом, потому что несколько объектов могут иметь общие свойства, но могут разветвляться, добавляя разные свойства. Когда осуществляется доступ к свойству, эта цепочка проходится в обратном направлении, пока не будет найдено желаемое свойство. См. это изображение для некоторой ясности:
Когда мы говорим о картах, мы на самом деле имеем в виду эту структуру, которую легко представить скорее как словарь, чем как связанный список. Несмотря на это, запоминание расположения карт в памяти гораздо менее важно, чем знание того, что объекты содержат указатель карты.
Карты также содержат много информации об объекте. Это далеко не все что важно знать, но вот небольшая симпатичная диаграмма из objects/map.h:
Код:
// All heap objects have a Map that describes their structure.
// A Map contains information about:
// - Size information about the object
// - How to iterate over an object (for garbage collection)
//
// Map layout:
// +---------------+------------------------------------------------+
// | _ Type _ | _ Description _ |
// +---------------+------------------------------------------------+
// | TaggedPointer | map - Always a pointer to the MetaMap root |
// +---------------+------------------------------------------------+
// | Int | The first int field |
// `---+----------+------------------------------------------------+
// | Byte | [instance_size] |
// +----------+------------------------------------------------+
// | Byte | If Map for a primitive type: |
// | | native context index for constructor fn |
// | | If Map for an Object type: |
// | | inobject properties start offset in words |
// +----------+------------------------------------------------+
// | Byte | [used_or_unused_instance_size_in_words] |
// | | For JSObject in fast mode this byte encodes |
// | | the size of the object that includes only |
// | | the used property fields or the slack size |
// | | in properties backing store. |
// +----------+------------------------------------------------+
// | Byte | [visitor_id] |
// +----+----------+------------------------------------------------+
// | Int | The second int field |
// `---+----------+------------------------------------------------+
// | Short | [instance_type] |
// +----------+------------------------------------------------+
// | Byte | [bit_field] |
// | | - has_non_instance_prototype (bit 0) |
// | | - is_callable (bit 1) |
// | | - has_named_interceptor (bit 2) |
// | | - has_indexed_interceptor (bit 3) |
// | | - is_undetectable (bit 4) |
// | | - is_access_check_needed (bit 5) |
// | | - is_constructor (bit 6) |
// | | - has_prototype_slot (bit 7) |
// +----------+------------------------------------------------+
// | Byte | [bit_field2] |
// | | - new_target_is_base (bit 0) |
// | | - is_immutable_proto (bit 1) |
// | | - unused bit (bit 2) |
// | | - elements_kind (bits 3..7) |
// +----+----------+------------------------------------------------+
// | Int | [bit_field3] |
// | | - enum_length (bit 0..9) |
// | | - number_of_own_descriptors (bit 10..19) |
// | | - is_prototype_map (bit 20) |
// | | - is_dictionary_map (bit 21) |
// | | - owns_descriptors (bit 22) |
// | | - is_in_retained_map_list (bit 23) |
// | | - is_deprecated (bit 24) |
// | | - is_unstable (bit 25) |
// | | - is_migration_target (bit 26) |
// | | - is_extensible (bit 28) |
// | | - may_have_interesting_symbols (bit 28) |
// | | - construction_counter (bit 29..31) |
// | | |
// +****************************************************************+
// | Int | On systems with 64bit pointer types, there |
// | | is an unused 32bits after bit_field3 |
// +****************************************************************+
// | TaggedPointer | [prototype] |
// +---------------+------------------------------------------------+
// | TaggedPointer | [constructor_or_backpointer_or_native_context] |
// +---------------+------------------------------------------------+
// | TaggedPointer | [instance_descriptors] |
// +****************************************************************+
// ! TaggedPointer ! [layout_descriptors] !
// ! ! Field is only present if compile-time flag !
// ! ! FLAG_unbox_double_fields is enabled !
// ! ! (basically on 64 bit architectures) !
// +****************************************************************+
// | TaggedPointer | [dependent_code] |
// +---------------+------------------------------------------------+
// | TaggedPointer | [prototype_validity_cell] |
// +---------------+------------------------------------------------+
// | TaggedPointer | If Map is a prototype map: |
// | | [prototype_info] |
// | | Else: |
// | | [raw_transitions] |
// +---------------+------------------------------------------------+
Массивы
Массивы — это всего лишь специализированный тип объектов. Array как «класс» является «подклассом» Object (здесь класс заключен в круглые скобки, потому что в JavaScript на самом деле нет классов, но я не могу придумать лучшего слова…). Например, длина массива — это просто свойство. Он хранится в словаре с ключом "length", а атрибут свойства value является фактической длиной массива.
Все элементы в массиве хранятся с ключом, который является строковым представлением индекса. Ну, не совсем как объект. Массивы уникальны тем, что все значения внутри массива доступны для записи, перечисления и настройки (если, конечно, вы их не меняете, но это не столь важно для эксплуатации). Они также используют числовые индексы для сопоставления значений, что означает, что большое количество свойств - это просто последовательные числа. Поэтому V8 хранит свойства массива, такие как длина, в типичной карте; однако они также включают резервное хранилище. Это резервное хранилище указывает на элементы, которые действительно находятся в массиве, что более эффективно, чем использование карты.
Примечание: я также встречал другие названия, используемые вместо "backing store". Если вы видите, что что-то говорит о "хранилище" или списке элементов, это, вероятно, относится к этому.
Как выяснилось, массивы и объекты настолько тесно связаны, что их структура памяти одинакова. У объектов, имеющих свойства с числовыми ключами, эти значения также будут храниться в резервном хранилище.
Другим важным аспектом массивов в V8 является то, как хранятся их элементы. Если у нас есть простой массив, например arr1 = [1, 2, 3] имеет смысл хранить это линейно в памяти. Однако, если мы сделаем что-то вроде
JavaScript:
arr1 = new Array(100);
arr1[0] = 0;
arr1[99] = 99;
Решая, хранить ли это как 100 линейных значений, помечая только слоты 0 и 99 как действительные, или хранить только наши 2 значения с их индексом. Мы также можем сделать такой массив:
JavaScript:
arr1 = new Array(100);
arr1[0] = 0;
arr1[50] = "50";
arr1[99] = 99.9;
Теперь нам нужно еще больше задуматься о массиве, так как типы значений разные. V8 описывает массивы на основе «видов элементов». Оbjects/elements-kind.h перечисляет все различные виды массивов. Тип массива важен для схемы памяти, а также для возможных оптимизаций. Как и в предыдущем обсуждении типизации, в этой области несоответствие предположений во время компиляции и фактических значений во время выполнения вызвало множество уязвимостей Turbofan. Я снова подниму эту концепцию в наших тематических исследованиях, но если вам интересно узнать больше о том, как V8 обрабатывает типизацию массива, вам следует посмотреть здесь: блог ( слайды ).
Подводя итог, объекты имеют указатель карты, который показывает, как хранятся именованные свойства. Свойства, скорее всего, будут храниться вне очереди, поэтому карта фактически будет указывать на смещения в другой области памяти. Объекты также имеют резервное хранилище, содержащее значения свойств, описываемых числовыми именами. Хранилище также может находится отдельно от объекта или, возможно, непосредственно после объекта в памяти. Мы поговорим о точном расположении памяти позже. В случае, если что-то до сих пор не понятно, я рекомендую прочитать эту статью в качестве дополнительного источника, чтобы понять, как V8 работает с объектами (обратите внимание, что они используют термин «скрытые классы» для описания карт).
Встроенные кеши
Как вы уже поняли, поиск значения свойства может занять много времени. V8 сокращает это время, сохраняя результат в реальных инструкциях этого поиска. Когда генерируются инструкции байт-кода поиска свойств объекта, в двоичном файле есть место для данных, которые нужно сохранить. Во время выполнения эти слоты будут перезаписаны смещением значения (как определено картой) и фактическим значением этого свойства. Код для IC находится в папке src/ic.Я хотел упомянуть встроенные кеши, потому что они влияют на некоторые инструкции и изменяют ход выполнения по сравнению с ожидаемым. Я не думал, что эта тема так уж важна для эксплуатации; однако недавняя ошибка доказала, что я ошибался. Хотя соответствующий эксплойт не был написан, он был помечен как проблема безопасности.
Наследование и прототипы
Еще одна вещь, которую я считаю важной для понимания этих концепций, — это немного знать о JavaScript и объектно-ориентированном программировании. Мы говорили об объектах в целом, а также об объектах JavaScript. Это может сбивать с толку, поэтому я хотел упомянуть, что вы должны понимать, что создание нового объекта наследует свойства его прототипа. Например, массивы наследуют свойства объекта JavaScript, его прототипа. Затем они расширяют Object, добавляя дополнительные свойства, специфичные для массивов. Когда вы создаете экземпляр массива, у него есть прототип объекта Array, который вы расширяете, добавляя элементы. Это создает цепочку прототипов, которые ищутся при поиске свойства объекта. Это может быть актуально, если вы пытаетесь изучить более тонкие детали создания объекта.
Я уверен, что у меня есть любители JavaScript, которые съеживаются от моего ужасного объяснения. Я извиняюсь, если что-то в этом разделе неверно, JavaScript не моя сильная сторона, и это была моя попытка научиться писать. Пожалуйста, просмотрите документы JavaScript для получения правильной информации.
Изучение макета объекта
Итак, я много объяснял, чтобы попытаясь детализировать, как объект JavaScript выглядит в памяти C++, но я не предоставил диаграмму. Я сделаю это сейчас, но просто знайте, что это может измениться в будущем.Просмотр структуры переменных в памяти V8 очень прост, и это можно сделать даже в d8 REPL! Для этого мы используем флаг --allow-natives-syntax на d8 и вызовем функцию %DebugPrint(). Единственная проблема с этим методом заключается в том, что мы не получим четкого представления о точном расположении памяти. К счастью, мы также можем использовать GDB, для этого.
В каталоге установки V8 есть полезный файл для отладки ( tools/gdbinit). Вы можете включить его, поместив в свой домашний каталог .gdbinit. Вы также можете добавить код внутри к существующему .gdbinit-файлу, если у вас уже есть другие модификации, или просто добавьте в него эту строку:
source <path to v8>/tools/gdbinitВы можете увидеть все недавно добавленные команды, выполнив поиск строк, начинающихся с «define» в этом файле. Например, команда job по существу выполнит то, что %DebugPrint делает, когда вы передаете его TaggedPtr.
Примечание: мне нравится использовать pwndbg вместо GDB; однако, похоже, что между ним и V8 есть какой-то конфликт для большинства команд. jobбудет по-прежнему работать, но для большинства других может потребоваться использование vanilla GDB или другого отладчика.
После того, как у вас есть выбранный вами отладчик, вы можете попробовать скрипт, подобный этому:
JavaScript:
//////////////////// script.js ////////////////////
function testing() {
var a = new Array(1.1, 1.2, 1.3);
var b = new Array(2.1, 2.2, 3.3);
var c = new Array(1, 2, 3);
delim = "-".repeat(50);
console.log(delim + delim);
console.log(delim + " A " + delim);
%DebugPrint(a);
console.log(delim + " B " + delim);
%DebugPrint(b);
console.log(delim + " C " + delim);
%DebugPrint(c);
console.log(delim + delim);
return 0;
}
testing();
while(1){} // <- "debugger;" doesn't work so I use this to attach
//////////////////// command line ////////////////////
gdb d8
r --allow-natives-syntax script.js
Ctrl+C
Запрос в %DebugPrint(obj) выведет нужные нам адреса, и вы можете получить более подробную информацию, используя %DebugPrintPtr(tagged_ptr) или вызвав недавно добавленные встроенные функции из терминала отладчика. Я взял указатель из каждого объекта, вычел 1 и использовал GDB для проверки памяти, что дало мне такой макет:
Код:
+--------------------+ <- begin first allocation (a)
- map ptr -
+--------------------+
- properties ptr -
+--------------------+
- backing store ptr -
+--------------------+
- length of array -
+--------------------+ <- begin second allocation (a's backing store)
- map ptr -
+--------------------+
- length of store -
+--------------------+
- a[0] -
+--------------------+
- a[1] -
+--------------------+
- a[2] -
+--------------------+ <- begin third allocation (b)
...
Обратите внимание, что массивы располагаются последовательно, а элементы хранятся между ними. Это будет очень важно для нашего следующего поста о создании примитивов эксплуатации.
Примечание: Джереми Фетиво создал инструмент , который помогает в этом, хотя вам может потребоваться выполнить обновления для более новых версий V8. Вы также можете использовать lldb, для этого в исходном коде V8 есть несколько полезных файлов. подробно описано несколько различных подходов Здесь .
Но на самом деле оказывается, что то, как вы пишете свой код, влияет на порядок распределения. Например, если мы изменим наши объявления массива на
JavaScript:
let a = [1.1, 1.2];
let b = [2.1, 2.2];
let c = [1, 2, 3];
тогда резервные хранилища будут размещены перед соответствующими массивами.
Где CODE?
Приятно иметь возможность видеть структуру памяти и теоретизировать, как происходит ее распределение. Однако я хотел найти, где код на самом деле размещает эти структуры. Мы ищем ключевое слово «New», так как V8 использует автоматическое управление памятью. Папка Src/objects имеет несколько js-[].cc файлов, которые кажутся релевантными и содержат ссылки на регулярные выражения, прокси и т. д., которые я знаю, являющиеся различными типами объектов . Это привело меня к js-objects.h который имел определение для class JSObject. В js-objects.ccесть определение MaybeHandle<JSObject> JSObject::New(Handle<JSFunction> constructor, Handle<JSReceiver> new_target, Handle<AllocationSite> site)что, кажется, где происходит фактическое распределение. К сожалению, большая часть реального творения кажется мне волшебством, но я думаю, что именно здесь мы и будем искать. Однако мой партнер в этой серии упоминал, что на этот процесс также влияет Torque, что имеет смысл, учитывать, сколько операций с массивами он выполняет. Его сообщения могут пролить немного больше света на эту тему.
Более глубокое погружение в код было бы полезно для понимания того, как различные сценарии влияют на структуру кучи. Например, что именно происходит, когда мы объявляем переменные с помощью let против var. Как происходит разделение на Holey-массивы, которые отличаются от packed-массивы как для объекта, так и для резервного хранилища? На данный момент я буду полагаться на анализ runtime, чтобы дать эти ответы. Однако, когда дело доходит до проверки безопасности приложения, глубокое понимание этого кода действительно может помочь. Я, по крайней мере, надеюсь, что предоставил базовую информацию для ответов на эти вопросы и рассказал достаточно, чтобы перейти к следующему посту, который будет включать в себя создание примитивов эксплуатации.
Заключение
Как всегда, никто не объясняет эти концепции лучше, чем команда V8. Если у вас есть 15 минут, я рекомендую посмотреть это видео (начиная с 6:30). Если презентация имеет смысл, это здорово! Если нет, я бы сказал, просмотрите этот пост еще раз. Это будет абсолютно ключом к пониманию наших тематических исследований. Было еще несколько их статей, на которые я ссылался в этом посте, которые помогут закрепить эти концепции. Я надеюсь, что мне удалось объединить все знания, которыми они поделились, в линейный поток этой серии, чтобы она была полезна для изучения эксплуатации V8.
Рекомендации
Основы движка JavaScript: формы и встроенные кэши - Матиас Байненс
Наследование и цепочка прототипов - MDN Web Docs
Перевод вот этой статьи.