Когда мы разрабатывали модуль ghidra nodejs для инструмента Ghidra, мы поняли, что не всегда получается корректно реализовать опкод V8 (движка JavaScript, используемого Node.js) на языке описания ассемблерных инструкций SLEIGH. В таких средах исполнения, как V8, JVM и прочие, один опкод может выполнять достаточно сложные действия. Для решения этой проблемы в Ghidra предусмотрен механизм динамической инъекции конструкций P-code — языка промежуточного представления Ghidra. Используя этот механизм, нам удалось превратить вывод декомпилятора из такого:
В такой:
Рассмотрим пример с опкодом CallRuntime. Он вызывает одну функцию из списка т. н. Runtime-функций V8 по индексу (kRuntimeId). Также данная инструкция имеет переменное число аргументов (range — номер начального регистра-аргумента, rangedst — число аргументов). Описание инструкции на языке SLEIGH, который Ghidra использует для определения ассемблерных инструкций, выглядит так:
Итого для, казалось бы, не очень сложной операции необходимо проделать целую кучу работы.
Поиск нужного названия функции в массиве Runtime-функций по индексу kRuntimeId.
Поскольку аргументы передаются через регистры, необходимо сохранить их предыдущее состояние.
Передача в функцию переменного количества аргументов.
Вызов функции и сохранение результата вызова в аккумулятор.
Восстановление предыдущего состояния регистров.
Если вы знаете, как сделать такое на 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);
}
// Типы операций. В данном примере мы рассматриваем RUNTIMETYPE
int INTRINSICTYPE = 1;
int RUNTIMETYPE = 2;
int PROPERTYTYPE = 3;
@Override
public PcodeOp[] getPcode(Program program, InjectContext context) {
}
@Override
public 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 ones
Object[] 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);
}
// сохраняем аргументы вызываемой функции в регистры вида aN
Integer 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 и разработку модуля моим коллегам: Владимиру Кононовичу, Наталье Тляповой, Сергею Федонину.