NativeVM: запуск кода на нативных языках в безопасных окружениях

Проблема

  • У нас есть некоторое количество кода, написанное Цопрофилами на языках C и C++, а также прочими разработчиками на некоторых других языках программирования, но какой язык ни возьми, все они предполагают нативность, то есть, будто бы программа работает на реальном процессоре, все структуры данных где–то в памяти выложены и, главное, язык более или менее намеренно не обеспечивает инкапсуляцию памяти.
  • Этот код вдруг возникло желание запускать как можно более безопасно, либо компилировать в ненативный язык, такой, как JavaScript или ActionScript.
  • И при этом, конечно, хочется как можно меньше уступать в производительности.

Решения

Несмотря на NativeVM в названии страницы, виртуальная машина не предполагается. Например, можно компилировать C в Ada, но при этом весь перекомпилированный код будет использовать некий рантайм.

Решение состоит в том, что, несмотря на кажущуюся нативность среды, в которой будет исполняться преобразованный код, NativeVM в целях производительности абстрагирует преобразованный код от прямого доступа к памяти, но при этом позволяет структурам данных прозрачно «проваливаться» в более низкоуровневый режим при необходимости. То есть, на самом нижнем уровне у нас есть некий виртуальный MMU (memory management unit), который, например, в случае JavaScript, может являться WebGL'ным массивом 32битных int. Кроме того, в MMU может быть реализована как бы страничная адресация (память не сплошная, а фрагментами). Далее, если код достаточно переносимый, можно эмулировать для каких–то своих целей сегментную адресацию, но так как такого кода существенно меньше, вначале рассмотрим типичный Цшный код для Win32 или какой–либо другой x86 OS с плоской моделью памяти. Как уже упоминалось, мы стараемся использовать инструменты целевой платформы для повышения производительности, но при любой попытке «поймать Бога за бороду» NativeVM «прячется» от программы, проваливая структуры данных в более низкоуровневый режим. Рассмотрим этот механизм подробнее.

На самом нижнем уровне, куда только может провалиться структура данных, находится плоская память, возможно с постраничной адресацией. Память поделена на ячейки по 32 бита с Little Endian порядком байт. Всё, как на x86. Если идёт запрос на чтение только одного байта, то из ячейки арифметическими операциями извлекается нужный байт. Однако, не всё так просто. Дабы удержать структуры данных от чрезмерного проваливания вниз, массив 32битных ячеек дублируется массивом указателей и ссылок целевой платформы. Если в какой–то ячейке хранится указатель на высокоуровневый объект, то при некоторых обращениях к этой ячейке памяти считывается именно указатель. Можно даже инкрементировать и декрементировать полученный указатель, не вызывая проваливания вниз структуры, на которую этот указатель ссылается. При некоторых других операциях, например, если ячейка содержит указатель, но интерпретируется как число и умножается на 3, либо, например, делается операция чтения одного байта, инкапсуляция указателя разрушается, и структура данных, на которую он ссылался, проваливается в плоскую память. Однако, даже провалившись в плоскую память, структура может остаться высокоуровневой. То есть, например, менеджер памяти выделил под провалившуюся структуру данных кусочек виртуальной памяти, но реально этот кусок помечен как ссылка (не указатель!) на структуру, которая по–прежнему высокоуровневая. Некоторыми операциями, особенно, арифметическими, можно затем провалить вниз уже и эту структуру, как по частям, так и полностью. Например, чтобы структура полностью провалилась в память, мы можем пройтись xor'ом в два прохода. В действительно нативной платформе ничего не случится, а NativeVM прячется от нативной программы, и вынуждена тоже притвориться, что ничего не случилось. Чтобы притвориться, придётся провалить вниз всю структуру данных. После того, как какая–то часть структуры данных провалилась вниз, в высокоуровневом объекте целевой платформы остаётся пометка о том, что такой–то элемент массива (или такое–то поле структуры или класса) уже можно найти в нижней памяти и только там. Таким образом, структуры данных могут иметь неопределённый адрес и, независимо от этого, каждый их компонент может иметь или не иметь определённое представление на уровне байтов. Если у нас есть массив указателей, то, пробежавшись xor'ом в два прохода по указателям, мы разрушим указатели и вынудим те структуры данных, на которые они ссылаются, провалиться в нижнюю память, но сам массив указателей при этом, как и раньше, может не иметь чёткого определённого положения в нижней памяти. Все эти меры должны уменьшить использование памяти нижнего уровня NativeVM. Когда на нижний уровень проваливается указатель на процедуру, по этому указателю будет находиться некая платформозависимая (как бы) четырёхбайтовая инструкция. Эмуляция кодогенерации — это уже за рамками NativeVM, поэтому, разрушить указатель на процедуру и считать первые 4 байта по этому адресу — это максимум, что можно сделать, не считая передачи управления.

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

У каждой структуры данных есть некий корневой объект, как бы сегмент памяти. Этот объект либо был выделен new или malloc, либо это локальная переменная. Корневой объект является корневым только для тех структур данных, которые он внутри себя содержит целиком. То есть, если у нас есть массив структур, и одна из структур проваливается, то на нижнем уровне память выделяется под весь корневой объект, и вся эта память помечается ссылками на этот объект. До того, как структура памяти провалилась на нижний уровень, численное представление указателя на неё неопределено. Кроме того, указатель можно свободно инкрементировать и декрементировать, но при разыменовании он должен быть в границах корневого объекта, а если за границы вышел, можно выбросить Access Violation, как если бы корневой объект со всех сторон окружала нечитаемая память, но положение корневого объекта при этом не раскрывать. После того, как корневой объект провалился в нижний уровень, его расположение фиксировано, и MMU может использовать какие–нибудь трюки, чтобы разыменование байтов, непосредственно окружающих корневой объект, приводило к Access Violation, но в общем случае придётся складывать указатели как обычные числа в плоской модели памяти, а, значит, потенциально, можно залезть в чужую память. Если какие–то из переменных на стеке провалились в нижний уровень, при выходе из процедуры они освобождаются как обычная динамическая память. На некоторых целевых платформах возможно отследить тот факт, что где–то остались высокоуровневые указатели на этот участок памяти и не использовать этот участок памяти до тех пор, пока не исчезнут эти указатели. Но это опционально. Чисто теоретически, компилируемая программа могла прогнать xor'ом по массиву чисел, используя указатель как ключ, а затем оставить неизбежно разрушившийся указатель как ключ от xor'енного массива, то есть, как данные. Предположительно, висячий указатель — более частое явление, чем использование указателей как численных данных.

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

Кстати, когда сравнивают производительность C++ и, например, Delphi или Ada, было бы полезно поставить эти языки в равные условия, включив в компиляторах Delphi и Ada все проверки, а код на C++ за неимением альтернатив исполняя на NativeVM (там, где ему и место), и посмотрим тогда, на каком языке код быстрее работает. Что касается языка Ada, это подходящий язык программирования общего назначения как при использовании проверок, так и без них. Безопасный среди быстрых, быстрый среди безопасных.

QuadroPointers

Отдельно выделен метод QuadroPointers, который не для всего кода подходит по причине того, что память становится как бы сегментной. QuadroPointers нужен больше для защиты Цшного кода от пробоя инкапсуляции памяти, нежели чем для компиляции под ненативные целевые платформы. Суть QP в том, что вместо x86–подобной платформы программа компилируется под замудрёную платформу, на которой кроме 32битного смещения есть 96битный сегмент. Фактически это 4 указателя в одном. Первый — это просто указатель (смещение). При инкрементах и декрементах меняется только он. Второй и третий — это начало и конец области, в которой разрешено разыменование этого указателя. Наконец, четвёртый — это указатель на байтовую ячейку памяти, отвечающую за то, жив ли регион памяти. Для разыменования там должна быть 1, но не факт, что если там 1, то регион памяти не протухший. Отслеживание памяти требуется для динамической памяти и для переменных на стеке. Для динамической памяти, например, заранее выделен буфер, в котором по порядку распределяются ячейки для каждого выделенного участка памяти. Например, пусть выделен 1Мб. Буфер циклический. Если в ячейке уже 1, она занята, нужно пропустить все 1 и найти первую незанятую или в крайнем случае расширить буфер. При освобождении памяти ячейка помечается как 0, что должно на время предотвратить доступ к выделенной памяти. Аналогично со стеком: буфер заполняется циклически (а не по принципу стека, как можно подумать, если не вникать). Нужно, чтобы, если какая–то процедура вызывается неоднократно, но не рекурсивно, каждый следующий экземпляр не мог обратиться к протухшей локальной переменной от прошлого вызова.

Проблема в том, что дополнительные 96бит имеют семантику сегмента, и целочисленную арифметику к сегменту нельзя применять, а программы, написанные непереносимо, могут понимать только плоскую модель памяти и размеры указателей только 32 и 64. Впрочем, если программа поддерживает 64 бит, можно использовать метод DoublePointers, то есть, младшие 32бита — это смещение, а старшие — это максимальное смещение, которое позволено разыменовывать. Остальным придётся пожертвовать. Если проделывать над указателями произвольную целочисленную арифметику (а, если не получилось применить QuadroPointers, значит, эта же особенность помешает и использовать сегментную модель памяти), может происходить всякое, ну, впрочем, если переполнения буфера предотвращены, уже смысл есть.

См. также

Тэги:
Код для вставки: :: :: :: ГОСТ ::
Поделиться: //