Русский
Русский
English
Статистика
Реклама

Дизассемблер

Меняем промежуточное представление кода на лету в Ghidra

30.04.2021 14:04:59 | Автор: admin

Когда мы разрабатывали модуль ghidra nodejs для инструмента Ghidra, мы поняли, что не всегда получается корректно реализовать опкод V8 (движка JavaScript, используемого Node.js) на языке описания ассемблерных инструкций SLEIGH. В таких средах исполнения, как V8, JVM и прочие, один опкод может выполнять достаточно сложные действия. Для решения этой проблемы в Ghidra предусмотрен механизм динамической инъекции конструкций P-code языка промежуточного представления Ghidra. Используя этот механизм, нам удалось превратить вывод декомпилятора из такого:

В такой:

Рассмотрим пример с опкодом CallRuntime. Он вызывает одну функцию из списка т.н. Runtime-функций V8 по индексу (kRuntimeId). Также данная инструкция имеет переменное число аргументов (range номер начального регистра-аргумента, rangedst число аргументов). Описание инструкции на языке SLEIGH, который Ghidra использует для определения ассемблерных инструкций, выглядит так:

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

  1. Поиск нужного названия функции в массиве Runtime-функций по индексу kRuntimeId.

  2. Поскольку аргументы передаются через регистры, необходимо сохранить их предыдущее состояние.

  3. Передача в функцию переменного количества аргументов.

  4. Вызов функции и сохранение результата вызова в аккумулятор.

  5. Восстановление предыдущего состояния регистров.

Если вы знаете, как сделать такое на SLEIGH, пожалуйста, напишите комментарий. А мы решили, что все это (а особенно работу с переменным количеством аргументов-регистров) не очень удобно (если возможно) реализовывать на языке описания процессорных инструкций, и применили механизм динамических инъекций p-code, который как раз для таких случаев реализовали разработчики Ghidra. Что это за механизм?

Можно создать в файле описания ассемблерных инструкций (slaspec) специальную пользовательскую операцию, например CallRuntimeCallOther. Далее, изменив конфигурацию вашего модуля (подробнее об этом ниже), вы можете сделать так, чтобы при нахождении в коде данной инструкции Ghidra передавала бы обработку в Java динамически, и уже на языке Java написать обработчик, который будет динамически формировать p-code для инструкции, пользуясь всей гибкостью Java.

Рассмотрим подробно, как это сделать.

Создание служебной операции SLEIGH

Опишем опкод CallRuntime следующим образом. Подробнее об описании процессорных инструкций на языке SLEIGH все можете узнать из статьи Создаем процессорный модуль под Ghidra на примере байткода v8.

Определим служебную операцию:

define pcodeop CallRuntimeCallOther;

И опишем саму инструкцию:

:CallRuntime [kRuntimeId], range^rangedst is op = 0x53; kRuntimeId; range;       rangedst {CallRuntimeCallOther(2, 0);}

Таким образом, любой опкод, начинающийся с байта 0x53, будет расшифрован как CallRuntime При попытке его декомпиляции будет вызываться обработчик операции CallRuntimeCallOtherс аргументами 2 и 0. Эти аргументы описывают тип инструкции (CallRuntime) и позволят нам написать один обработчик для нескольких похожих инструкций (CallWithSpread, CallUndefinedReceiverи т.п.).

Подготовительная работа

Добавим класс, через который будет проходить инъекция кода: V8_PcodeInjectLibrary. Этот класс мы унаследуем от ghidra.program.model.lang.PcodeInjectLibrary который реализует большую часть необходимых для инъекции p-code методов.

Начнем написание класса V8_PcodeInjectLibraryс такого шаблона:

package v8_bytecode;import public class V8_PcodeInjectLibrary extends PcodeInjectLibrary {public V8_PcodeInjectLibrary(SleighLanguage l) {}}

V8_PcodeInjectLibraryбудет использоваться не пользовательским кодом, а движком Ghidra, поэтому нам необходимо задать значение параметра pcodeInjectLibraryClassв файле pspec, чтобы движок Ghidra знал, какой класс задействовать для инъекции p-code.

<?xml version="1.0" encoding="UTF-8"?><processor_spec>  <programcounter register="pc"/>  <properties>  <property key="pcodeInjectLibraryClass" value="v8_bytecode.V8_PcodeInjectLibrary"/>  </properties></processor_spec>

Также нам понадобится добавить нашу инструкцию CallRuntimeCallOtherв файл cspec. Ghidra будет вызывать V8_PcodeInjectLibraryтолько для инструкций, определенных таким образом в cspec-файле.

<callotherfixup targetop="CallRuntimeCallOther"><pcode dynamic="true"><input name=outsize"/> </pcode></callotherfixup>

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

Создадим HashSet, в котором будем хранить реализованные нами инструкции. Также мы создадим и проинициализируем член нашего класса переменную language. Данный код сохраняет операцию CallRuntimeCallOtherв наборе поддерживаемых операций, а также выполняет ряд служебных действий, в которые мы не будем подробно вдаваться.

public class V8_PcodeInjectLibrary extends PcodeInjectLibrary {private Set<String> implementedOps;private SleighLanguage language;public V8_PcodeInjectLibrary(SleighLanguage l) {super(l);language = l;String translateSpec = language.buildTranslatorTag(language.getAddressFactory(),getUniqueBase(), language.getSymbolTable());PcodeParser parser = null;try {parser = new PcodeParser(translateSpec);}catch (JDOMException e1) {e1.printStackTrace();}implementedOps = new HashSet<>();implementedOps.add("CallRuntimeCallOther");}}

Благодаря внесенным нами изменениям Ghidra будет вызывать метод getPayloadнашего класса V8_PcodeInjectLibraryкаждый раз при попытке декомпиляции инструкции CallRuntimeCallOther Создадим данный метод, который при наличии инструкции в списке реализованных операций будет создавать объект класса V8_InjectCallVariadic(этот класс мы реализуем чуть позже) и возвращать его.

@Override/*** This method is called by DecompileCallback.getPcodeInject.*/public InjectPayload getPayload(int type, String name, Program program, String context) {if (type == InjectPayload.CALLMECHANISM_TYPE) {return null;}if (!implementedOps.contains(name)) {return super.getPayload(type, name, program, context);}V8_InjectPayload payload = null; switch (name) {case ("CallRuntimeCallOther"):payload = new V8_InjectCallVariadic("", language, 0);break;default:return super.getPayload(type, name, program, context);}return payload;}

Генерация p-code

Основная работа по динамическому созданию p-code будет происходить в классе V8_InjectCallVariadic. Давайте его создадим и опишем типы операций.

package v8_bytecode;import public class V8_InjectCallVariadic extends V8_InjectPayload {public V8_InjectCallVariadic(String sourceName, SleighLanguage language, long uniqBase) {super(sourceName, language, uniqBase);}// Типы операций. В данном примере мы рассматриваем RUNTIMETYPEint INTRINSICTYPE = 1;int RUNTIMETYPE = 2;int PROPERTYTYPE = 3;@Overridepublic PcodeOp[] getPcode(Program program, InjectContext context) {}@Overridepublic String getName() {return "InjectCallVariadic";}}

Как нетрудно догадаться, нам необходимо разработать нашу реализацию метода getPcode Для начала создадим объект pCode класса V8_PcodeOpEmitter Этот класс будет помогать нам создавать инструкции pCode (позже мы ознакомимся с ним подробнее).

V8_PcodeOpEmitter pCode = new V8_PcodeOpEmitter(language, context.baseAddr, uniqueBase); 

Далее из аргумента context (контекст инъекции кода) мы можем получить адрес инструкции, который нам пригодится в дальнейшем.

Address opAddr = context.baseAddr;

С помощью данного адреса мы получим объект текущей инструкции:

Instruction instruction = program.getListing().getInstructionAt(opAddr);

Также с помощью аргумента contextмы получим значения аргументов, которые ранее описывали на языке SLEIGH.

Integer funcType = (int) context.inputlist.get(0).getOffset();Integer receiver = (int) context.inputlist.get(1).getOffset();

Реализуем обработку инструкции и генерации Pcode.

// проверка типа инструкцииif (funcType != PROPERTYTYPE) {// получаем kRuntimeId  индекс вызываемой функцииInteger index = (int) instruction.getScalar(0).getValue();// сгенерируем Pcode для вызова инструкции cpool с помощью объекта pCode класса V8_PcodeOpEmitter. Подробнее остановимся на нем ниже.pCode.emitAssignVarnodeFromPcodeOpCall("call_target", 4, "cpool", "0", "0x" + opAddr.toString(), index.toString(), funcType.toString());}// получаем аргумент диапазон регистровObject[] tOpObjects = instruction.getOpObjects(2);// get caller args count to save only necessary onesObject[] opObjects;Register recvOp = null;if (receiver == 1) {}else {opObjects = new Object[tOpObjects.length];System.arraycopy(tOpObjects, 0, opObjects, 0, tOpObjects.length);}// получаем количество аргументов вызываемой функцииtry {callerParamsCount = program.getListing().getFunctionContaining(opAddr).getParameterCount();}catch(Exception e) {callerParamsCount = 0;}// сохраняем старые значения регистров вида aN на стеке. Это необходимо для того, чтобы Ghidra лучше распознавала количество аргументов вызываемой функцииInteger callerArgIndex = 0;for (; callerArgIndex < callerParamsCount; callerArgIndex++) {pCode.emitPushCat1Value("a" + callerArgIndex);}// сохраняем аргументы вызываемой функции в регистры вида aNInteger argIndex = opObjects.length;for (Object o: opObjects) {argIndex--;Register currentOp = (Register)o;pCode.emitAssignVarnodeFromVarnode("a" + argIndex, currentOp.toString(), 4);}// вызов функцииpCode.emitVarnodeCall("call_target", 4);// восстанавливаем старые значения регистров со стекаwhile (callerArgIndex > 0) {callerArgIndex--;pCode.emitPopCat1Value("a" + callerArgIndex);}// возвращаем массив P-Code операцийreturn pCode.getPcodeOps();

Теперь рассмотрим логику работы класса V8_PcodeOpEmitter (https://github.com/PositiveTechnologies/ghidra_nodejs/blob/main/src/main/java/v8_bytecode/V8_PcodeOpEmitter.java), который во многом основан на аналогичном классе модуля для JVM. Данный класс генерирует p-code операции с помощью ряда методов. Рассмотрим их в порядке обращения к ним в нашем коде.

emitAssignVarnodeFromPcodeOpCall(String varnodeName, int size, String pcodeop, String... args)

Для понимания работы данного метода сначала рассмотрим понятие Varnodeодин из основных элементов p-code, по сути представляющий собой любую переменную, задействованную в p-code. Регистры, локальные переменные всё это Varnode.

Вернемся к методу. Данный метод генерирует p-code для вызова функции pcodeopс аргументами argsи сохраняет результат работы функции в varnodeName То есть в итоге получается такая конструкция:

varnodeName = pcodeop(args[0], args[1], );

emitPushCat1Value(String valueName) и emitPopCat1Value (String valueName)

Генерирует p-code для аналогов ассемблерных операций push и pop соответственно с Varnode valueName.

emitAssignVarnodeFromVarnode (String varnodeOutName, String varnodeInName, int size)

Генерирует p-code для операции присвоения значения varnodeOutName = varnodeInName

emitVarnodeCall (String target, int size)

Генерирует P-Code для вызова функции target.

Заключение

Благодаря вышеизложенному механизму у нас получилось значительно улучшить вывод декомплилятора Ghidra. В итоге динамическая генерация p-code стала еще одним кирпичиком в нашем большом инструменте модуле для анализа скомпилированного bytenode скриптов Node.JS. Исходный код модуля доступен в нашем репозитории на github.com. Пользуйтесь, и удачного вам реверс-инжиниринга!

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

Большое спасибо за исследование особенностей Node.js и разработку модуля моим коллегам: Владимиру Кононовичу, Наталье Тляповой, Сергею Федонину.

Подробнее..

Декомпиляция node.js в Ghidra

16.04.2021 14:07:36 | Автор: admin


Приветствую,


Вам когда-нибудь хотелось узнать, как же именно работает программа, которой вы так активно пользуетесь, игра, в которую часто играете, прошивка какого-нибудь устройства, которое что-то делает по расписанию? Если да, то для этого вам потребуется дизассемблер. А лучше декомпилятор. И если с x86-x64, Java, Python ситуация известная: этих ваших дизассемблеров и декомпиляторов полным-полно, то с другими языками всё обстоит немного сложнее: поисковые машины уверенно утверждают It's impossible.



Что ж, мы решили оспорить данное утверждение и произвести декомпиляцию NodeJS, а именно выхлоп, который выдаёт npm-пакет bytenode. Об этом подробнее мы и расскажем по ходу статьи. Заметим, что это уже вторая статья в серии о нашем плагине для Ghidra (первый материал был также опубликован в нашем блоге на Хабре). Поехали.


Часть нулевая: забегая вперёд


Да, нам действительно удалось произвести декомпиляцию NodeJS в Ghidra, преодолев путь от этого кода:



К этому:



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


  • Парсинг вывода bytenode. Кроме исполняемого кода, он также парсит пул констант, аргументы функций, области видимости, обработчики исключений, контекстные переменные и многое другое.
  • Полноценная загрузка исследуемого jsc-файла в Ghidra, с отображением пользователю необходимых для реверс-инжиниринга данных.
  • Поддержка всех опкодов, в том числе с различными вариациями их длины расширенных (wide) и экстрарасширенных (extra-wide).
  • Подтягиваются вызовы функций стандартной библиотеки (Intrinsic и Runtime-вызовы).
  • Анализируются все перекрёстные ссылки, даже в обфусцированном коде, что дает возможность исследовать любые NodeJS-приложения.

Конечно, есть и ряд ограничений. О них в конце статьи.

Составные части плагина


Сейчас модуль состоит из четырёх частей:


  1. Загрузчик: парсит файл, создаёт необходимые секции для кода и констант. Тем, кому дизассемблер ближе декомпилятора, пригодится.
  2. Анализатор: работает после загрузчика. Расставляет перекрёстные ссылки, убирает stub-код, сгенерированный компилятором (упрощает анализ), следит за контекстом исполнения.
  3. Дизассемблер и по совместительству декомпилятор. Благодаря технологиям, реализованным в Ghidra, при написании дизассемблера вы одновременно получаете и декомпилятор, что очень удобно. Для этого используется внутренний язык Гидры SLEIGH.
  4. Последняя часть модуля инжектор конструкций на PCode (языке промежуточного представления в Ghidra, аналоге микрокода в IDA). Инжектор формирует промежуточное представление для декомпилятора в тех случаях, когда через SLEIGH это реализовать сложно, или даже невозможно.

Процесс создания


Как это обычно и бывает, когда ты занимаешься реверс-инжинирингом всякой дичи, пришёл один бинарь. В нашем случае он имел расширение .jsc и запускался с помощью node.exe. Гугление по данной связке привело к bytenode пакету для Node.js, позволяющему собрать исходник на JavaScript в виде jsc-файла с байткодом, который гарантированно запустится на той же версии Ноды, если в ней установлен этот самый bytenode.


Бинарь вроде похож на файлы .pyc или .class, где интерпретатор точно так же исполняет собранный под него файл. Проблема только в том, что и для первого, и для второго формата давно существует множество различных файлообменников декомпиляторов, а для формата Ноды нет.


Но ведь в node, скажете вы, уже существует встроенный дизассемблер, и с его помощью можно смотреть на то, что происходит в запускаемом файле. Да, это так. Но он скорее рассчитан на то, что исходный файл у вас всё-таки имеется. У нас же его не было. Поэтому, сделав волевое усилие, мы приняли решение написать этот, будь он проклят, декомпилятор! Знали бы мы тогда, какие мучения нас ждут, взялись бы за проект? Конечно, взялись бы!



(Пример jsc-файла в hex-редакторе. Видны некоторые заголовки и строки)


Погружение и сборка node.js


Итак, что нам необходимо, чтобы начать разбор формата? Теория, в которой рассказывается о принципах работы Ноды? Кто вообще так делает? (Между прочим, данная теория прекрасно изложена в статье по ссылке.) Нам нужны исходники Node! Более того, они должны быть той же версии, в которой собран jsc-файл. Иначе не заработает.


Сделав клон репозитория и попытавшись его собрать, мы наткнулись на первые трудности: не собирается. Конечно, учитывая размеры репозитория и его кроссплатформенность удивляться нечему. Тем более последнюю на момент написания статьи версию Visual Studio 2019 в системе сборки Node.js стали поддерживать не так давно. Поэтому, чтобы собрать именно нужную нам v8.16, пришлось клонировать обе ветки: современную и нужную нам, а затем сравнивать систему сборки.


Система сборки состоит из набора Python-скриптов, батников и sln-проектов. Python-скрипты генерируют проекты для Visual Studio, подставляются дефайны, а затем Студия всё это дело собирает. Действительно, собирает. Но только в режиме сборки Release оно почему-то работает, а в Debug нет. А для наших целей нужна была именно дебажная сборка.


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



(Часть проекта NodeJS в Visual Studio)


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


Парсинг формата


Было установлено огромное количество брейкпоинтов (точек останова) во всех местах, где, по идее, мог начинаться разбор jsc-файла, и наконец-то была запущена отладка.


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


И тут началось.


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


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


Конечно, навигация по JSON (о нём расскажем чуть позже) та ещё весёлая задача, поэтому было принято не менее важное решение, очень сильно повлиявшее на наши дальнейшие запросы, перейти на Ghidra. Это решение влекло за собой следующие проблемы, которые пришлось решать:


  • перенос парсера с Python на Java и написание загрузчика jsc-формата;
  • создание нового процессорного модуля, который позволит дизассемблировать V8 (именно этот движок используется в Node.js);
  • реализация логики самих опкодов V8 с целью получения декомпилированного листинга.

Загрузчик для Ghidra 1


Первый загрузчик был простым: он разбирал JSON, который генерировался Python-версией парсера, создавал все секции, объекты, загружал байткод. Пока одна часть команды писала разборщик, другая её часть занималась реализацией опкодов на SLEIGH, параллельно создавая концепт плагина для Ghidra. Таким образом, к моменту, когда этот вариант загрузчика был готов, вся команда могла работать совместно.


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



(Пример JSON-выхлопа первого варианта загрузчика. Как видно, он хоть и являлся структурированным, но был сильно неудобным при навигации по коду и данным)


Загрузчик для Ghidra 2


На этом варианте мы и остановились. Как уже было сказано, он умеет грузить jsc-формат напрямую, разбирать и создавать структуры, делать перекрёстные ссылки, анализировать всё что нужно и как это нужно Гидре. Да и вообще, этот вариант нам нравился больше, так как у него был выше потенциал уже на старте. Единственный нюанс такой загрузчик пришлось куда дольше писать. Но результат того стоит.


С вводными ознакомились, теперь пора углубиться в настоящий кошмар. И первым нас встречает загрузчик.


Внутрянка загрузчика


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


  • разобрать jsc-файл на структуры, которые будут затем использованы дизассемблером, анализатором, декомпилятором;
  • отобразить пользователю плагина все те структуры, которые потребуются при реверс-инжиниринге: код, строки, числа, контексты, обработчики исключений и многое другое;
  • переименовать все объекты, у которых есть названия, и у которых их нет (обычный код, обёрнутый в скобки);
  • идентифицировать встроенные функции Node.js, которые вызываются исключительно по индексам.

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


Загруженный в Ghidra файл обычно выглядит как-то так:



Что на картинке
  1. Слева сверху видны сегменты. В jsc их нет, но нам пришлось создать их, чтобы сгруппировать сходные типы данных, и отделить их от кода.
  2. Слева идёт список функций. В случае с файлом, изображённым на скриншоте выше, они все обфусцированные, поэтому имеют одинаковые имена. Но это никак не мешает плагину выстраивать перекрёстные ссылки.
  3. В центре скриншота виден дизассемблерный листинг. Вам в любом случае придётся с ним работать, так как декомпилятор не всесилен.
  4. Справа виден декомпилированный C-подобный листинг. Как видим, он значительно облегчает анализ jsc-приложения.

Дизассемблер


Собственно, для того чтобы весь загруженный набор байтов стал кодом, его нужно дизассемблировать. Фактически при создании плагина с помощью специального языка (SLEIGH) вы описываете порядок преобразования одних байтов в одни инструкции, а других в другие.


С точки зрения особенностей написания процессорного модуля можно отметить следующее:


  • Байт-код V8 интерпретируемый, что усложняет процесс реализации. Тем не менее во многих моментах нам очень помогали исходные коды других процессорных модулей, например виртуальной машины Java.
  • При реализации некоторых типов регистров (например, для работы с локальными переменными могут использоваться регистры с индексом от 0 до 0x7FFFFFFF-4) пришлось пойти на компромисс в виде максимально отображаемых в дизассемблере.


(Типичный дизазм V8)


Анализатор


Наверное, второй самой объёмной частью плагина после загрузчика является анализатор. Его основной задачей является наведение порядка после работы загрузчика. Он подразумевает:


  • создание перекрёстных ссылок на код и данные (constant pool),
  • отслеживание контекста исполнения (codeflow) программы,
  • упрощение представления асинхронного кода,
  • накопление информации для декомпилятора.

Создание ссылок и работа с constant pool


Если вы занимались реверс-инжинирингом Java-классов, то наверняка знаете, что самым главным объектом в каждом классе является constant pool. Вкратце, это что-то типа словаря, в котором по индексам в качестве ключей хранятся ссылки или значения в виде абсолютно любых объектов, например:


  • строк, чисел, списков, кортежей и других примитивов;
  • функций, областей видимости и других элементов кода.


(Типичный constant pool. На скриншоте видны размеченные и аллоцированные загрузчиком элементы пула, а также ссылки на них. Можно заметить, что здесь хранятся данные практически любых типов)


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


Из тех типов инструкций, что интересны анализатору, можно выделить следующие категории:


  1. имеют ссылки на constant pool,
  2. имеют ссылки на контекст исполнения или изменяют его,
  3. выполняют runtime-функции (встроенные).

Код для работы с первым типом инструкций достаточно объёмный, так как в constant pool может храниться практически всё. И на это всё нужно проставить ссылки. Но рассказать мы бы хотели только об одном из сложных случаев: SwitchOnSmiNoFeedback.


SwitchOnSmiNoFeedback


По названию может показаться, что это обычные свитчи, только для V8. В действительности это специальная конструкция для работы с асинхронным кодом, то есть с тем, который помечен с помощью ключевых слов await/async. Работает оно так:


  1. В инструкции SwitchOnSmiNoFeedback указываются два индекса: первый начальный индекс в constant pool, по которому лежат ссылки на функции, и второй количество этих функций.
  2. Сами функции представляют из себя автоматически сгенерированные пролог (тело, эпилог) для кода, который требуется исполнять асинхронно (делается обёртка в виде переключения контекста, его сохранения, выгрузки). В нашем плагине этот шаблонный код заменяется на NOP-инструкции (No OPeration).

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


async function handler() {    try {        const a = await 9;        const b = await 10;        const c = await 11;        let d = await 12;    } catch (e) {        return 123;    }    return 666;}console.log(handler());


(Так обычно и выглядит функция с SwitchOnSmiNoFeedback без оптимизации)



(А так с оптимизацией...)


Анализатор контекстов


Самой сложной частью оказалось слежение за контекстом исполнения. Дело в том, что в V8, как и в других языках программирования, есть области видимости переменных (констант, кода и т. д.). Обычно для их разделения используются фигурные скобки, отступы, двоеточия, другие элементы синтаксиса. Но как быть с кодом, который исполняется интерпретатором?


В случае V8 для этого были введены три специальных сущности:


  1. регистр контекста,
  2. значение глубины для обращения к контексту,
  3. сохранение контекста в стек контекстов (выгрузка из него).

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


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


Runtime-функции


Основное, что здесь стоит отметить, это удаление (nop) из листинга обращений к функциям, также связанных со SwitchOnSmiNoFeedback, а именно async_function_promise_release, async_function_promise_create, promise_resolve. То есть плагин просто делает читаемость листинга декомпилятора выше.


Ссылки


GitHub: https://github.com/PositiveTechnologies/ghidra_nodejs/
Релизы: https://github.com/PositiveTechnologies/ghidra_nodejs/releases
Серьёзный разбор формата сериализации V8 движка в NodeJS: http://personeltest.ru/aways/habr.com/ru/company/pt/blog/551540/


Недостатки


Конечно, они есть. Некоторые недостатки всплывают из-за ограничений Ghidra, другие вследствие того, что проект делался под одну конкретную задачу (с которой отлично справился) и большого, полноформатного тестирования на сотнях семплов не было.


Релиз


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


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


Ну и напоследок мне как автору данной статьи и соавтору плагина хотелось бы сказать большущее спасибо Сергею Федонину, Вячеславу Москвину, Наталье Тляповой за крутой проект.


Спасибо за внимание

Подробнее..

Создаем процессорный модуль под Ghidra на примере байткода v8

23.04.2021 18:05:42 | Автор: admin

В прошлом году наша команда столкнулась с необходимостью анализа байткода V8. Тогда еще не существовало готовых инструментов, позволявших восстановить такой код и обеспечить удобную навигацию по нему. Было принято решение попробовать написать процессорный модуль под фреймворк Ghidra. Благодаря особенностям используемого языка описания инструкций на выходе мы получили не только читаемый набор инструкций, но и C-подобный декомпилятор. Эта статья продолжение серии материалов (1, 2) о нашем плагине для Ghidra.

Между написанием процессорного модуля и статьи прошло несколько месяцев. За это время спецификация SLEIGH не изменилась, и описанный модуль работает на версиях 9.1.29.2.2, которые были выпущены за последние полгода.

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

В документации можно прочесть, что процессорные модули для Ghidra пишутся на языке SLEIGH, который произошел от языка SLED (Specification Language for Encoding and Decoding) и разрабатывался целенаправленно под Ghidra. Он транслирует машинный код в p-code (промежуточный язык, используемый Ghidra для построения декомпилированного кода). Как у языка, предназначенного для описания инструкций процессора, у него достаточно много ограничений, которые, однако, можно купировать за счет механизма внедрения p-code в java-коде.

Исходный код созданного процессорного модуля представлен на github. В этой статье будут рассматриваться принципы и ключевые понятия, которые использовались при разработке процессорного модуля на чистом SLEIGH на примере некоторых инструкций. Работа с пулом констант, инъекции p-code, анализатор и загрузчик будут или были рассмотрены в других статьях. Также про анализаторы и загрузчики можно почитать в книге The Ghidra Book: The Definitive Guide.

С чего начать

Для работы понадобится установленная среда разработки Eclipse, в которую нужно добавить плагины, поставляемые с Ghidra: GhidraDev и GhidraSleighEditor. Далее создается Ghidra Module Project с именем v8_bytecode. Созданный проект содержит шаблоны важных для процессорного модуля файлов, которые мы будем модифицировать под свои нужды.

Чтобы получить общее представление о файлах, с которыми предстоит работать, обратимся к официальной документации либо вышедшей недавно книге Криса Игла и Кары Нанс The Ghidra Book: The Definitive Guide. Вот описание этих файлов.

  • *.сspec спецификация компилятора.

  • *.ldefs определение языка. Содержит отображаемые в интерфейсе параметры процессорного модуля. Также содержит ссылки на файлы *.sla, спецификацию процессора и спецификации компилятора.

  • *.pspec спецификация процессора.

  • *.opinion конфигурации для загрузчика; поскольку мы будем описывать только один вид файлов, файл opinion можно оставить пустым: он не пригодится.

  • *.slaspec, *.sinc файлы, описывающие регистры и инструкции процессора на языке SLEIGH.

Также после первого запуска вашего проекта появится файл с расширением .sla, он генерируется на основании slaspec-файла.

Перед тем как приступить к разработке процессорного модуля, необходимо разобраться с тем, какие есть регистры, как происходит работа с ними, со стеком и т.п. для описываемого процессора или интерпретатора.

О регистрах V8

Jsc-файл, который нас интересовал, был собран c использованием среды выполнения JavaScript Node.Js 8.16.0 через bytenode (этот модуль либо будет в поставке Node.Js, либо нужно будет доставить его через npm). По сути, bytenode использует документированный функционал Node.js для создания скомпилированного файла. Вот исходный код функции, компилирующей jsc файлы из js:

Node.js можно скачать как в собранном виде, так и в виде исходников. При детальном изучении исходных файлов и примеров инструкций становится ясно, как кодируются регистры в байткоде (для понимания расчета индекса будут полезны файлы bytecode-register.cc, bytecode-register.h). Примеры инструкций v8 с расчетами индексов регистров в соответствии с Node.js:

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

Тут Х количество аргументов текущей функции без учета передаваемого <this>, aX регистры, содержащие аргументы функции, а rN регистры, используемые как локальные переменные. Регистры могут кодироваться 1-байтовыми значениями для обычных инструкций, 2-байтовыми для инструкций с пометкой Wide- и 4-байтовыми для инструкций с пометкой ExtraWide-. Пример кодировки Wide-инструкции с пояснениями:

Более подробно о Node.js и v8 можно почитать в статье Сергея Федонина.

Стоит заметить, что SLEIGH не совсем подходит для описания подобных интерпретируемых байткодов, поэтому у написанного процессорного модуля есть некоторые ограничения. Например, определена работа не более чем с 124регистрами rN и 125регистрами aX. Была попытка решить эту проблему через стековую модель взаимодействия с регистрами, так как она больше соответствовала концепции. Однако в этом случае дизассемблированный байткод тяжелее читался:

Также без введения дополнительных псевдоинструкций, регистров или областей памяти не представляется возможным высчитывать название регистра аргумента в соответствии с Node.js из-за отсутствия информации о количестве аргументов. В связи с этим нами было принято решение проставлять номера в названии регистров аргументов функций (X в aX) в обратном порядке. Это не мешает разбору кода, что было для нас важным критерием, однако может смущать при сравнении результатов вывода инструкций файла в разных инструментах.

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

CSPEC

Немного текстовой информации о тегах, используемых в cspec-файлах, можно найти в исходниках фреймворка на github. В описании назначения файла говорится:

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

Также становится понятно, что теги используются для следующих целей:

  • Compiler Specific P-code Interpretation;

  • Compiler Datatype Organization (у нас использовался <data_organization>);

  • Compiler Scoping and Memory Access (у нас использовался <global>);

  • Compiler Special Purpose Registers (у нас использовался <stackpointer>);

  • Parameter Passing (у нас использовался <default_proto>).

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

Теги <data_organization> и <stackpointer> достаточно типовые; разберем тег <prototype> в <default_proto>, частично описывающий соглашение о вызове функций. Для него определим: <input>, <output>, <unaffected>.

Как говорилось выше, аргументы в функцию передаются через регистры aX. В модуле регистры должны быть определены как непрерывная последовательность байтов по смещению в некотором пространстве. Как правило, в таких случаях используется специально придуманное для этого пространство register. Однако теоретически не запрещено использовать любое другое. В случае наличия большого количества регистров, выполняющих примерно одни функции, проще всего не прописывать каждый отдельно, а просто указать смещение в пространстве регистров, по которому они будут определены. Поэтому в спецификации компилятора помечаем область памяти в пространстве регистров (space="register") в теге <input> для регистров, через которые происходит передача аргументов в функции, по смещению 0x14000 (0x14000 не несет в себе сакрального смысла, это просто смещение, по которому в *.slaspec далее будут определены регистры aX).

По умолчанию результат вызова функций сохраняется в аккумулятор (acc), что нужно прописать в теге <output>. Для альтернативных вариантов регистров, в которые происходит сохранение возвращаемых функциями значений, можно определить логику при описании инструкций. Отметим в теге <unaffected>, что вызовы функций на регистр, хранящий указатель на стек, не влияют.

Для работы с частью регистров наиболее удобным будет вариант определения их как изменяемых глобально, поэтому в теге <global> определяем диапазон регистров в пространстве register по смещению 0x2000.

LDEFS

Перейдем к определению языка это файл с расширением .ldefs. Он требует немного информации для оформления: порядок байт (у нас le), названия ключевых файлов (*.sla, *.pspec,*.cspec), id и название байткода, которое будет отображаться в списке поддерживаемых процессорных модулей при импорте файла в Ghidra. Если когда-то понадобится добавить процессорный модуль для файла, скомпилированного версией Node.js, существенно отличающейся от текущей, то имеет смысл описать его тут же через создание еще одного тега <language>, как это сделано для описания семейств процессоров в *.ldefs модулей, поставляемых в рамках Ghidra.

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

PSPEC

Сложнее в плане документации дела обстоят со спецификацией процессора (файл с расширением .pspec). В данном случае можно обратиться к готовым решениям в рамках самого фреймворка или к файлу processor_spec.rxg (вариант с полноценным разбором исходных кодов Ghidra мы не рассматривали). Чего-то более подробного на момент написания модуля не было. Вероятно, со временем разработчики опубликуют официальную документацию.

В текущем проекте на данный момент от спецификации процессора может понадобиться только программный счетчик, оставим этот тег из стандартного шаблона свежесозданного проекта (на самом деле можно оставить <processor_spec> пустым).

SLASPEC

Теперь можно приступить к непосредственному описанию инструкций на SLEIGH в файле с расширением .slaspec.

Базовые определения и макросы препроцессора

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

Адресные пространства, которые понадобятся для описания байткода (у нас создаются пространства с именами register и ram), определяются через define space, а регистры через define register. Значение offset в определении регистров не принципиально, главное, чтобы они находились по разным смещениям. Занимаемое регистрами количество байтов определяется параметром size. Стоит помнить, что определенная тут информация должна соответствовать обращениям к аналогичным абстракциям и величинам в рамках *.cspec и анализатора, если вы ссылаетесь на эти регистры.

Описание инструкций

В документации (https://ghidra.re/courses/languages/html/sleigh_constructors.html) можно прочитать, что определение инструкций происходит через таблицы, которые состоят из одного и более конструкторов и имеют имена идентификаторы символов семейства. Таблицы в SLEIGH по сути являются тем, что называется символами семейства, в статье мы не будем углубляться в определения символов, для этих целей проще прочитать Знакомство с символами. Конструкторы состоят из 5частей.

  1. Table Header (заголовок таблицы)

  2. Display Section (секция отображения)

  3. Bit Pattern Sections (секция битового шаблона)

  4. Disassembly Actions Section (секция действий при дизассемблировании инструкций)

  5. Semantics Actions Section (семантическая секция)

Пока что звучит страшно, опишем основной смысл.

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

  2. Display Section шаблон, показывающий как выводить инструкцию в листинг Ghidra.

  3. Bit Pattern Section перечень каких-либо идентификаторов, которые забирают реальные биты программы под инструкцию и выводятcя в листинг по шаблону секции отображения (иногда с использованием следующей секции).

  4. Disassembly Actions Section дополняет секцию битового шаблона какими-то вычислениями, если ее в чистом виде недостаточно.

  5. Semantics Actions Section описывает, что делает эта инструкция по смыслу, чтобы показать это в декомпиляторе.

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

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

Несколько документированных особенностей секции отображения, которые понадобятся дальше:

  • ^ разделяет идентификаторы и/или символы в секции, между которыми не должно быть пробелов;

  • используются, чтобы вставлять жестко закодированные строки, которые не будут считаться идентификатором;

  • пробельные символы обрезаются в начале и конце секции, а их последовательности сжимаются в один пробел;

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

Токены и их поля

Для описания конструкторов инструкций необходимо определить битовые поля. Через них осуществляется привязка битов программы к определенным абстракциям языка, в который будет происходить трансляция. Такими абстракциями могут быть мнемоники, операнды и т.п. Определение полей происходит в рамках задания токенов, синтаксис их определения выглядит так:

Размер токена tokenMaxSize должен быть кратен8. Это может быть неудобно, если операнды или какие-то нюансы для инструкции кодируются меньшим количеством бит. С другой стороны, это компенсируется возможностью создавать поля разных размеров, кодирующих позиционно любые биты в пределах размеров, задаваемых токеном. Для таких полей должны соблюдаться условия: start- и endBitNumX находятся в диапазоне от 0 до tokenMaxSize-1 включительно и startBitNumX <= endBitNumX.

Для разбираемого байткода v8 не было необходимости создавать поля, отличные по размеру от токена. Но, если бы такие поля были и использовались совместно, они бы объединялись через логические операторы & или |.

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

Теперь опишем простейшую инструкцию байткода, не имеющую операндов. Определим поле, которое будет описывать опкод инструкции. Как видно выше в разделе про v8, код инструкции описывается одним байтом (есть также Wide- и ExtraWide- инструкции, но они здесь не будут рассматриваться, по сути они просто используют операнды больших размеров и дополнительные байты под опкод инструкции). Таким образом получаем:

Теперь, используя поле op для идентификации первого и единственного опкода, определяющего инструкции Illegal и Nop, пишем для них конструкторы:

Байт 0xa7 в листинге Ghidra отобразит как инструкцию Illegal, не имеющую операндов. Для этой инструкции в примере использовалось ключевое слово unimpl. Это неимплементированная команда, дальнейшая декомпиляция будет прервана, что удобно для отслеживания нереализованных семантических описаний. Для Nop оставлена пустая семантическая секция, то есть команда не повлияет на отображение в декомпиляторе, что и должна делать эта инструкция. На самом деле Nop не присутствует как инструкция в Node.js нашей версии, мы ввели ее искусственно для реализации функционала SwitchOnSmiNoFeedback, но об этом будет рассказано в статье Владимира Кононовича.

Описываем операнды и семантику

Усложним концепцию: опишем конструктор для операций LdaSmi, в рамках которой происходит загрузка целого числа в аккумулятор (acc в определении пространства регистров), и AddSmi, которая по сути представляет собой сложение значения в аккумуляторе c целым числом.

Для текущих и будущих нужд определим чуть больше полей на манер операндов в bytecodes.h Node.js, создадим их в новом токене с именем operand, поскольку у этих полей будет другое назначение. Создание нескольких полей с одинаковыми битовыми масками может быть обусловлено как удобством восприятия, так и использованием нескольких полей одного токена в рамках одной инструкции (см. пример с AddSmi).

С точки зрения листинга хочется видеть что-то наподобие LdaSmi [-0х2]. Поэтому определяем в секции отображения мнемонику, а в шаблон прописываем имена полей, которые должны подставляться из секции disassembly action или битового шаблона (квадратные скобки тут не обязательны, это просто оформление).

Для инструкции AddSmi в секции битового шаблона, помимо поля op, устанавливающего ограничение на опкод, через ; появляются поля из токена operand. Они будут подставлены в секцию отображения в качестве операндов. Маппинг на реальные биты происходит в том порядке, в котором поля указаны в секции битового шаблона. В семантической секции, используя документированные операции, реализуем логику инструкций (то, что делал бы интерпретатор, выполняя эти инструкции).

Через ; могут также, например, идти регистры, контекстные переменные (о них поговорим позже), комбинации полей одного токена или полей с контекстными переменными.

Вот так выглядит окно листинга с описанными инструкциями со включенным полем PCode в меню изменение полей листинга Ghidra. Окно декомпилятора пока что не будет показательным из-за оптимизации кода, поэтому на данном этапе стоит ориентироваться только на промежуточный p-code.

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

Инструкции возврата значения из функции реализуются с помощью ключевого слова return, и, как уже упоминалось ранее, чаще всего возвращение значения при вызове функции происходит через аккумулятор:

Выводим регистры по битовым маскам

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

В разделе Базовые определения и макросы препроцессора регистры уже были объявлены, но для того, чтобы нужные регистры выбирались в зависимости от представленных в байткоде бит, необходимо привязать их список к соответствующим битовым маскам. Поле kReg имеет размер 8бит. Через конструкцию attach variables последовательно определяем каким битовым маскам от 0b до 11111111b вышеприведенные регистры будут соответствовать в рамках последующего использования полей из заданного списка (в нашем случае только kReg) в конструкторах. Например, в этом описании видно, что операнд, закодированный как 0xfb (11111011b), интерпретируется при описании его через kReg как регистр r0.

Теперь, когда за переменной kReg закреплены регистры, ее можно использовать в конструкторах:

Усложним конструкцию для соответствия конструктора более высокоуровневым описаниям инструкций из interpreter-generator.cc исходников Node.js. Вынесем поле kReg в отдельный конструктор, идентификатор таблицы которого в Table Header назовем src. В его семантической секции появляется новое ключевое слово export. Если не вдаваться в детали построения p-code, то по смыслу export определяет значение, которое должно быть подставлено в семантическую секцию конструктора вместо src. Вывод в Ghidra не изменится.

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

Переходы по адресам с goto

В байткоде встречаются операции условного и безусловного перехода по смещению относительно текущего адреса. Для перехода по адресу или метке в SLEIGH используется ключевое слово goto. Примечательно для определения то, что в секции битового шаблона используется поле kUImm, однако оно не используется в чистом виде. В секцию отображения выводится просчитанное в disassembly action секции значение через идентификатор rel. Величина inst_start предопределена для SLEIGH и содержит адрес текущей инструкции.

Компиляция SLEIGH проходит. Правда, в таком варианте (листинг ниже), судя по выводу, не получается создать варноду (это объекты, которыми манипулирует p-code инструкция), содержащую привязку к конкретному пространству.

Воспользуемся рекомендуемым разработчиками способом и вынесем часть определения через создание дополнительного конструктора с идентификатором dest. Конструкция *[ram]:4 rel не обозначает, что мы берем 4байта по адресу rel. По факту экспортируется адрес rel в пространстве ram. Оператор * в SLEIGH обозначает разыменование, но в данном конкретном случае относится к нюансу создания варнод (подробнее в Dynamic References).

Указание пространства [ram] может быть опущено (пример в комментарии), так как при определении мы указали его пространством по умолчанию. Как видно в инструкциях p-code, смещение было помечено как принадлежащее ram.

Чуть сложнее выглядит инструкция JumpIfFalse из-за использования условной конструкции. В SLEIGH она используется вместе с ключевым словом goto. Для большего соответствия концепциям js величина False ранее была определена как регистр, и можно заметить, что в pspec диапазон пространства регистров, к которому она привязана, помечен как глобальный. Благодаря этому в псевдокоде она отображается в соответствии с именованием регистра, а не численным значением.

В рассмотренных примерах переход осуществляется по рассчитываемому относительно inst_start адресу. Рассмотрим инструкцию TestGreaterThan, в которой происходит переход с помощью goto к метке (<true> в примере ниже) и inst_next. Переход к метке в принципе должен быть интуитивно понятным: если условие истинно, то далее должны выполняться инструкции, следующие за местом ее расположения. Метка действительна в только в пределах ее семантической секции.

Конструкция goto inst_next фактически завершает обработку текущей инструкции и передает управление на следующую. Стоит обратить внимание, что для выполнения знакового сравнения используется s>, см. документацию.

Несколько регистровых операндов

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

Описание однотипных операндов через конструкторы, имеющие разные идентификаторы таблиц (см. конструкторы в примере ниже), может иметь практическое применение для использования в рамках одного конструктора корневой таблицы. Такой вариант применим не только с точки зрения соответствия описанию инструкций v8, но и для преодоления возможных ошибок. Например, 4операнда инструкции CallProperty2 являются регистрами, идентично задаваемыми с точки зрения битовой маски. Попытка определить конструктор как :CallProperty2 kReg, kReg, kReg, kReg, [kIdx] вызовет ошибку в компиляторе Sleigh при попытке открыть файл с помощью процессорного модуля. Поэтому в нашем модуле использовались конструкторы для создания чего-то наподобие алиасов:

Стоит отметить, конечно, что решить эту проблему также можно было без определения новых конструкторов. Например, определив и прописав поля callable, receiver, arg1 и arg2 в рамках какого-либо токена с последующей их привязкой через attach к списку регистров:

Каждое из этих полей работало бы аналогично kReg в предыдущих примерах. Какой именно способ использовать вопрос эстетики.

Вызовы функций

В инструкции CallProperty2 также примечательно то, что она в семантической секции использует конструкцию call [callable];, которую мы не использовали до этого. В v8 аргументы функции хранятся в регистрах aX (как мы и пометили в cspec). Однако, с точки зрения байткода, помещения туда аргументов непосредственно перед вызовом функции не происходит (случаи, когда это происходит, можно посмотреть в sinc-файле, например для x86). Интерпретатор делает это самостоятельно, ведь у него есть вся необходимая информация. Но ее нет у Ghidra, поэтому в семантической секции мы пропишем помещение аргументов в регистры вручную. Однако нам необходимо будет восстановить значения задействованных регистров после вызова, так как в вызывающей функции эти регистры тоже могут хранить какие-то значения, необходимые для потока выполнения. Можно сохранить их через локальные переменные:

Можно также применять вариант с сохранением аргументов в памяти (в данном случае на стеке: sp не используется инструкциями, потому не повлияет на отображение в декомпиляторе) при использовании макросов на примере CallUndefinedReceiver1:

При написании подобных модулей вместе с загрузчиком и анализатором стоит следить, чтобы порядок передаваемых аргументов совпадал с порядком аргументов функции, определяемых при их создании в java-коде. Также стоит отметить, что для нашей ситуации было полезно сохранять не больше аргументов функции, чем их количество в вызывающей функции, но это сложно реализовать на SLEIGH. Вариант решения проблемы можно будет прочитать в статье Вячеслава Москвина про внедрение p-code инструкций.

Определяемые пользователем операции

Чтобы не терять при декомпиляции инструкции, в которых не планируется или нет возможности описывать семантический раздел, можно использовать определяемые пользователем операции. Стоит отметить, что в ситуации с acc не требуется явно указывать размер, поскольку размер регистра определен явно, а использовать его не полностью тут не нужно. Однако при передаче в подобные пользовательские операции, например, числовых констант придется явно указывать размер передаваемого значения (как в примере с CallVariadicCallOther в разделе О диапазонах регистров далее по тексту). Пользовательские операции определяются как define pcodeop OperationName и используются в семантическом разделе конструкторов в формате, напоминающем вызов функции во многих языках программирования.

Эти операции могут использоваться для внедрения p-code-инструкций в анализаторе: вызовы добавляются через тег callotherfixup в cspec-файл и прописывается логика в анализаторе.

Без переопределения в java-коде пользовательские операции в декомпиляторе выглядят так же, как они определены в семантическом разделе:

Тестируем модуль

Уже на этом этапе можно попробовать проверить работу процессорного модуля на практике. Скомпилируем через bytenode jsc-файл из небольшого примера на js:

Попробуем запустить написанный на основании статьи проект и импортировать полученный jsc-файл в Ghidra. Если что-то описано неправильно, Ghidra выдаст ошибку, а в логах eclipse будет локализирован номер строки, вызвавшей ошибку. При исправлении кода не нужно перестраивать проект: sleigh перекомпилируется при следующей попытке открыть файл.

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

Поскольку мы описывали только инструкции, файл не будет проанализирован и разобран по функциям. Код находится по смещению 0х1с0. НажмемD для преобразования байтов в инструкции по этому смещению иF, чтобы создать функцию. Вот так она будет выглядеть:

Более понятным становится вывод при использовании других средств (помимо SLEIGH), доступных разработчикам модулей. Например, при добавлении работы с пулом констант (для обращения к нему в SLEIGH зарезервировано ключевое слово cpool) появится возможность разрезолвить числовой идентификатор в команде LdaGlobal. Вот так в последней версии нашего проекта выглядит функция (для сравнения):

Разумеется, было бы приятнее видеть большее соответствие исходному коду, написанному на JavaScript, однако этого нельзя добиться, описывая инструкции только в файлах .slaspec (и .sinc). Чуть больший простор для воображения откроет статья, в которой будет описан механизм внедрения операций p-code, позволяющий при полном доступе ко всем ресурсам приложения манипулировать инструкциями, из которых собирается дерево p-code. Как раз на основании созданного дерева p-code результат декомпиляции выстраивается и отображается в интерфейсе.

О диапазонах регистров

В байткоде v8 для ряда инструкций реализована работа с диапазонами, парами и/или тройками регистров. С точки зрения кодирования диапазонов присутствует байткод первого регистра и количество используемых в диапазоне регистров. Для пар и троек указывается только начальный регистр, так как интерпретатору заранее известно, сколько регистров надо использовать для данной инструкции.

На основании описания понятно, что достаточно простым решением было бы отобразить при разборе инструкций первый регистр и их количество, то есть примерно так: ForInPrepare r9, r10!3. Чуть большим компромиссом в пользу читаемости было бы выводить первый и последний регистры диапазона, но, забегая вперед, можно сказать, что с точки зрения реализации уже это потребовало бы использования таблиц, состоящих из нескольких конструкторов.

Таблицы, содержащие несколько конструкторов

В рамках проекта для удобства восприятия было решено отображать в листинге весь список передаваемых регистров. Для секции отображения нет готового шаблона для выведения диапазонов регистров. Можно руководствоваться принципами, аналогичными использованным для процессорного модуля ARM: распечатыванием переменных через цепочку конструкторов (только сам принцип, реализация нам не подойдет из-за разности архитектур).

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

Как можно предположить, глядя на побайтовое описание инструкции CallProperty выше, для раскрытия диапазона необходимо распечатать регистры, отталкиваясь от первого вхождения, ориентируясь на известный первый регистр диапазона и количество элементов в нем. С точки зрения секции отображения, диапазон создается из двух конструкторов: rangeSrc и rangeDst. rangeSrc своего рода инициализация, где мы сохраняем входные данные, rangeDst будет распечатывать регистры на основании полученной информации. И как раз для rangeDst понадобится создавать таблицы, содержащие несколько конструкторов: как минимум для отображения диапазонов на регистрах aX и rX отдельно.

Для реализации условий необходимо учесть ряд ограничений. Проверять значения в секции битового шаблона рекомендуется только через =, а уточнять тут значение напрямую регистра нельзя, как и присваивать ему значения в секции disassembly action. Это лишает нас возможности использовать какой-то временный регистр. Стартовый регистр и длина диапазона могут быть любыми, а реализоваться, как уже упоминалось, диапазон может как на регистрах aX, так и на rX, а также быть нулевой длины. Уже на этом этапе понятно: если мы не хотим создавать гигантское количество определений на все случаи жизни, было бы неплохо иметь некие счетчики, чтобы выяснить, сколько регистров выводить и с какой позиции.

Контекстные переменные

Для решения задачи подходят контекстные переменные. Их определение похоже на определение полей токенов. Но поля в данном случае используют не реальные биты программы, а биты указанного регистра (contextreg ниже).

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

Важно отметить, что контекстные переменные должны объявляться до конструкторов.

В документации поясняется, что контекстные переменные, как правило, используются в секции битовых шаблонов для проверки наличия какого-то контекста и изменяются в секции disassembly action. Так что в конструкторе с идентификатором таблицы rangeSrc, которую мы будем использовать для отображения диапазонов, в disassembly action секции сохраняем код первого регистра диапазона в контекстную переменную offStart, а их количество в counter. В секции отображения обозначаем начало диапазона открывающейся скобкой {.

Также стоит отметить, что в v8 не используется регистр range_size: он введен искусственно для хранения размера диапазона, чтобы было удобнее работать с этим значением в рамках семантической секции конструктора инструкции. Именно rangeSrc поставляет стартовый регистр и размер диапазона для семантической секции инструкции.

В рамках таблицы с идентификатором rangeDst описано 5конструкторов для следующих случаев.

  • Код стартового регистра диапазона соответствует a0 и счетчик counter равен 0 (пустой диапазон).

  • Код стартового регистра диапазона соответствует r0 и счетчик counter равен 0 (пустой диапазон).

  • Код регистра диапазона в offStart совпадает с a0, в disassembly action секции счетчик counter уменьшается, код регистра в offStart увеличивается, переход к конструктору rangedst1.

  • Код регистра диапазона в offStart совпадает с r0, в disassembly action секции счетчик counter и код регистра в offStart уменьшаются, переход к конструктору rangedst1.

  • Код стартового регистра диапазона пока не найден, переход к конструктору rangedst1(для этого случая в конструкторе нет никаких условий, как упоминалось выше, он будет браться, когда остальные конструкторы не подошли).

В третьем и четвертом случаях происходит вывод регистра в отображение. Последующие конструкторы rangeDstN, где N натуральное число, состоят из тех же вариантов, только для регистров aN/rN.

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

В примере ниже описаны только rangeDst, rangeDst1, rangeDst2, чтобы не загромождать статью. Для получения представления о виде подобных таблиц этого достаточно, полную версию можно посмотреть в исходниках проекта на github. По сути, при работе с rangeDst будет проходиться цепочка конструкторов по возрастанию индекса Х в rangeDstX, пока не встретится стартовый регистр, а затем цепочка конструкторов, соответствующая по длине размеру выводимого диапазона.

Конструкторы с закрывающими фигурными скобками выглядят похожими, можно попробовать их объединить. Вспоминаем про использование логических операторов & и |.

Конструктор для CallProperty в готовом проекте выглядит так:

Вот что получается в листинге:

Возможно, сейчас сбивает с толку, что в семантической секции используется пользовательская операция CallVariadicCallOther. В проекте на github она была переопределена в java-коде инструкциями p-code. Использование инъекции p-code вместо реализации через операцию call было обусловлено желанием видеть список передаваемых аргументов в декомпиляторе (согласно исходникам Node.js, первый регистр диапазона является приемником, а остальные передаваемыми аргументами). Используя только slaspec, добиться этого было бы, мягко говоря, тяжело:

Если есть желание попробовать повторить реализацию диапазонов самостоятельно, можно описать семантику как:

Затем по аналогии можно доопределить конструкторы rangeDstХ (понадобится до r7 включительно) и уже тогда попробовать посмотреть, как выглядит скомпилированный код console.log(1,2,3,4,5,6). Можно собрать его самостоятельно через bytenode или забрать готовый тут. Функция будет находиться по смещению 0x167, а сама инструкция на 0x18b.

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

Стоит отметить, что в нашем проекте мы вынесли все конструкторы rangeDst в отдельный файл, чтобы не загромождать файл с описанием инструкций (как и расширенные инструкции, работающие операндами размером 2 и 4байта):

Итог

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

Также хочется сказать большое спасибо за исследование Node.js и разработку модуля Вячеславу Москвину, Владимиру Кононовичу, Сергею Федонину.

Автор: Наталья Тляпова

Полезные ссылки:

  1. https://ghidra.re/courses/languages/html/sleigh.html документация на SLEIGH.

  2. https://github.com/NationalSecurityAgency/ghidra/tree/master/Ghidra/Framework/SoftwareModeling/data/languages полезные файлы с описаниями *.cspec, *.pspec, *.opinion, *.ldefs.

  3. https://spinsel.dev/2020/06/17/ghidra-brainfuck-processor-1.htmlхорошая статья о реализации модуля для brainfuck в Ghidra.

  4. https://github.com/PositiveTechnologies/ghidra_nodejs репозиторий с полной версией процессорного модуля для Ghidra с загрузчиком и анализатором.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru