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

В качестве кода, который требуется дизасемблировать, будет выступать код виртуальной машины из очень крутого хоррора, который выходил сначала на SNES, потом на PS1, PC и Wonderswan - "Clock Tower - The First Fear". В игре имеется 9 концовок (sic!), атмосфера гнетущая, а в качестве главного злодея выступает "Scissorman" (человек с руками-ножницами). Заинтересовал? Тогда добро пожаловать...

О самой виртуальной машине

Собственно, сам код, выполняющий опкоды виртуальной машины, не будет являться предметом данной статьи, но, вот краткая информация о нём:

  • 115 опкодов на все случаи жизни

  • для анализа VM я анализировал исполняемые файлы для PS1 и PC, на SNES виртуальной машины нет

  • уже написан дизассемблер-декомпилятор для Ghidra (ссылки в конце статьи)

Подготовка

Итак, приступим к написанию кода. Я писал в Visual Studio на C/C++, но вы можете писать и на Python.

Структура проекта

Согласно устоявшимся принципам разработки процессорных модулей для IDA Pro (которые можно найти в IDA SDK), нам понадобится создать следующие пока ещё пустые файлы:

Инклуды:

  • cpu_name.hpp (где cpu_name - краткое имя вашего процессорного модуля, в моём случае это adc)

  • ins.hpp (будет содержать enum со списком всех опкодов)

Файлы с кодом:

  • ana.cpp (здесь будет находиться код анализатора (хорошо, что не как у radare2 - anal)

  • emu.cpp (сюда мы будем писать код эмулятора)

  • ins.cpp (имена опкодов и их feature-флаги - здесь)

  • out.cpp (код, который отвечает за сам вывод мнемоник/операндов и прочей атрибутики, типа запятых, скобок и т.д.)

  • reg.cpp (можно предположить, что это про регистры, но нет - это про регистрацию модуля, его флаги и прочую конфигурацию. Именно здесь начинается модуль)

Об анализаторе, эмуляторе и выводе

Чтобы проще было изучать готовые процессорные модули и, соответственно, писать свои, стоит сначала понять, что же такое анализатор (ana), эмулятор (emu) и вывод (out). Начнём с ana.

Анализатор (ana)

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

Эмулятор (emu)

После того, как анализатор закончил свою работу, в дело вступает эмулятор. Его задача: формирование ссылок и пост-анализ кода. Ссылки могут быть:

  • на следующую инструкцию, если это не return, jump и другие подобные инструкции

  • на адреса в памяти: чтение/запись/обычное смещение

  • на стек и локальные переменные

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

Вывод (out)

И, наконец, финальный этап для процессорного модуля - код для вывода накопленной информации об инструкциях (out). Данная часть вызывается Идой только тогда, когда инструкция попадает в область видимости листинга. И именно здесь кроется причина, почему процессорный модуль для Гидры умеет в декомпиляцию из коробки, а модуль из Иды - нет. Выводилка последней - это, фактически, простая printf того, что вы ей укажете, с возможностями вставлять отступы, делать вывод цветным и т.д. Никакого тебе IR (Intermediate Representation). Поэтому, если хочешь декомпилятор для экзотической платформы, у тебя два варианта:

  1. Использовать Ghidra

  2. Не использовать Ghidra. Вместо этого изощряться через Вывод. Годится лишь для простых виртуальных машин

В случае простой виртуальной машины, каковой является VM из Clock Tower, можно извратиться и выводить сразу псевдо-сишный код (ничего страшного в этом нет). Я так и сделал.

Пишем процессорный модуль

Хватит разглагольствовать, давайте уже писать код. Открываем Visual Studio, создаём пустой проект DLL, прописываем пути к инклудам и библиотекам IDA SDK. Указываем следующие процессорные директивы (все три являются обязательными):

__NT__
__IDP__
__X64__

Если необходима поддержка вашего процессорного модуля Идой с 64-битным адресным пространством, также добавляем флаг:

__EA64__

Теперь открываем reg.cpp и вставляем следующий шаблон:

#include "adc.hpp" // your main VM include

processor_t LPH = {
  IDP_INTERFACE_VERSION,
  0x8000 + 666, // proc ID
  PR_USE32 | PR_DEFSEG32 | PRN_HEX | PR_WORD_INS | PR_BINMEM | PR_NO_SEGMOVE | PR_CNDINSNS, // flags
  0, // flags2
  8, 8, // bits in a byte (code/data)
  shnames, // short processor names
  lnames, // long processor names
  asms, // assembler definitions

  notify, // callback to create our proc module instance

  regnames, // register names
  qnumber(regnames), // registers count

  rVcs, rVds, // number of first/last segment register
  0, // segment register size
  rVcs, rVds, // virtual code/data segment register

  NULL, // typical code start sequences
  retcodes, // return opcodes bytes

  0, ADCVM_last, // indices of first/last opcodes (in enum)
  Instructions, // array of instructions
};

В файл adc.hpp необходимо прописать некоторые важные инклуды:

#pragma once

#include "../idaidp.hpp" // this file is located in IDA_SDK/module/ dir
#include "ins.hpp"

Ключевых моментов здесь 3:

  1. proc ID - идентификатор вашего процессорного модуля. Все пользовательские модули должны иметь номер >= 0x8000

  2. Флаги - их много, и позволяют они достаточно тонко настроить работу процессорного модуля. Список флагов и их описания здесь

  3. rVcs/rVds - в моей виртуальной машине регистры не используются, но Иде всё равно требуется указать хотя бы виртуальные регистры. Ими мы займёмся позднее

Первым идёт shnames:

static const char* const shnames[] = { "ADCVM", NULL };

Здесь мы перечисляем все процессоры, которые поддерживает наш модуль (конечно, их может быть несколько. В конце списка обязательно должен быть NULL.

Далее создаём lnames:

static const char* const lnames[] = { "Clock Tower: Clock Tower ADC VM", NULL };

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

Следующий кусок кода, на котором многие останавливаются (в том числе и я когда-то) - это описание ассемблера. Что он из себя представляет в понимании Иды? Фактически, по большей части, это - солянка из никем не используемых и никому не нужных элементов синтаксиса ассемблера, которые выводятся в листинг самой Идой (далеко не все) без нашего участия. В основном, это касается нераспознанных данных, массивов и других директив (типа: org, end, equ, public, xref).

Нужны они, или нет, а заполнять придётся. Лучшим вариантом будет взять готовый из какого-нибудь SDK-шного модуля и чуть-чуть изменить. У меня получилось следующее:

static const asm_t adcasm = {
  AS_COLON | ASH_HEXF3,
  0,
  "Clock Tower Virtual Machine Bytecode",
  0,
  NULL,         // header lines
  "org",        // org
  "end",        // end

  ";",          // comment string
  '"',          // string delimiter
  '\'',         // char delimiter
  "\"'",        // special symbols in char and string constants

  "dc",         // ascii string directive
  "dcb",        // byte directive
  "dc",         // word directive
  NULL,         // double words
  NULL,         // qwords
  NULL,         // oword  (16 bytes)
  NULL,         // float  (4 bytes)
  NULL,         // double (8 bytes)
  NULL,         // tbyte  (10/12 bytes)
  NULL,         // packed decimal real
  "bs#s(c,) #d, #v", // arrays (#h,#d,#v,#s(...)
  "ds %s",      // uninited arrays
  "equ",        // equ
  NULL,         // 'seg' prefix (example: push seg seg001)
  "*",          // current IP (instruction pointer)
  NULL,         // func_header
  NULL,         // func_footer
  "global",     // "public" name keyword
  NULL,         // "weak"   name keyword
  "xref",       // "extrn"  name keyword
                // .extern directive requires an explicit object size
  NULL,         // "comm" (communal variable)
  NULL,         // get_type_name
  NULL,         // "align" keyword
  '(', ')',     // lbrace, rbrace
  "%",          // mod
  "&",          // and
  "|",          // or
  "^",          // xor
  "!",          // not
  "<<",         // shl
  ">>",         // shr
  "sizeof",         // sizeof
  AS2_BYTE1CHAR,// One symbol per processor byte
};

Из всего, что здесь написано, нам нужны только флаги (они отвечают за то, как будут отображаться числа, строки, смещения), и имя для нашего ассемблера. Подробнее про структуру asm_t можно почитать здесь, ну а мы двигаем дальше.

Как уже было сказано, notify-колбэк отвечает за создание объекта класса процессорного модуля в момент, когда это нужно Иде. Смысла менять его код нет, поэтому берём как есть:

static int data_id;

ssize_t idaapi notify(void* user_data, int notification_code, va_list va) {
  if (notification_code == processor_t::ev_get_procmod) {
    data_id = 0;
    return size_t(SET_MODULE_DATA(adcvm_t));
  }

  return 0;
}

Заменяем только adcvm_t на имя класса вашего будущего модуля. Также, обращаем внимание на макрос SET_MODULE_DATA. Он отвечает за вызов функции set_module_data и создание экземпляра нашего процессора. Требует объявленного выше int data_id;. Тушкой модуля займёмся чуточку позже, а пока перейдём к регистрам.

В случае виртуальной машины из Clock Tower, вместо регистров используются адреса в рабочей (work) памяти. Но Ида, к сожалению, не может избавиться от своего сегментного прошлого и хочет в любом случае указанных cs и ds регистров, даже если они нигде не используются. Поэтому, regnames будет выглядеть следующим образом:

static const char* const regnames[] = {
  "cs", "ds"
};

Здесь вместо NULL в конце списка, Ида принимает количество регистров, которое можно указать через макрос: qnumber(regnames). Далее необходимо создать enum с самими регистрами (которых нет):

static enum adcvm_regs {
  rVcs, rVds
};

Именно на элементы этого enum-а мы и сослались в структуре. С регистрами покончено, и можно переходить к помощи Иде в распознавании типовых участков кода. Прологов в моём случае не имеется, поэтому указываем NULL, а вот коды возврата есть, поэтому пишем:

static const uchar retcode[] = { 0x00, 0xFF };

static const bytes_t retcodes[] = {
  { sizeof(retcode), retcode },
  { 0, NULL }
};

В разбираемой мной VM, все опкоды имеют минимальную длину два байта, а код инструкции return имеет значение { 0x00, 0xFF }. Итак, мы плавно перешли к самим инструкциям.

Инструкции

Как уже было сказано, опкодов в случае Clock Tower VM ровно 115. Их имена были заботливо оставлены разработчиками прямо в исполняемых файлах (для отладочного вывода, который был в итоге выпилен). В общем, берём эти имена, и добавляем к ним префикс в виде имени процессорного модуля: ADCVM, получаем enum, который необходимо расположить в ins.hpp. В итоге имеем вот такую портянку:

Мой ins.hpp
#pragma once

extern const instruc_t Instructions[]; // to reference it from reg.cpp

enum nameNum {
  ADCVM_null = 0,
  ADCVM_ret,
  ADCVM_div,
  ADCVM_mul,
  ADCVM_sub,
  ADCVM_add,
  ADCVM_dec,
  ADCVM_inc,
  ADCVM_mov,
  ADCVM_equ,
  ADCVM_neq,
  ADCVM_gre,
  ADCVM_lwr,
  ADCVM_geq,
  ADCVM_leq,
  ADCVM_cmp_end,
  ADCVM_allend,
  ADCVM_jmp,
  ADCVM_call,
  ADCVM_evdef,
  ADCVM_end,
  ADCVM_if,
  ADCVM_while,
  ADCVM_nop,
  ADCVM_endif,
  ADCVM_endwhile,
  ADCVM_else,
  ADCVM_msginit,
  ADCVM_msgattr,
  ADCVM_msgout,
  ADCVM_setmark,
  ADCVM_msgwait,
  ADCVM_evstart,
  ADCVM_bgload,
  ADCVM_palload,
  ADCVM_bgmreq,
  ADCVM_sprclr,
  ADCVM_absobjanim,
  ADCVM_objanim,
  ADCVM_allsprclr,
  ADCVM_msgclr,
  ADCVM_screenclr,
  ADCVM_screenon,
  ADCVM_screenoff,
  ADCVM_screenin,
  ADCVM_screenout,
  ADCVM_bgdisp,
  ADCVM_bganim,
  ADCVM_bgscroll,
  ADCVM_palset,
  ADCVM_bgwait,
  ADCVM_wait,
  ADCVM_bwait,
  ADCVM_boxfill,
  ADCVM_bgclr,
  ADCVM_setbkcol,
  ADCVM_msgcol,
  ADCVM_msgspd,
  ADCVM_mapinit,
  ADCVM_mapload,
  ADCVM_mapdisp,
  ADCVM_sprent,
  ADCVM_setproc,
  ADCVM_sceinit,
  ADCVM_userctl,
  ADCVM_mapattr,
  ADCVM_mappos,
  ADCVM_sprpos,
  ADCVM_spranim,
  ADCVM_sprdir,
  ADCVM_gameinit,
  ADCVM_continit,
  ADCVM_sceend,
  ADCVM_mapscroll,
  ADCVM_sprlmt,
  ADCVM_sprwalkx,
  ADCVM_allsprdisp,
  ADCVM_mapwrt,
  ADCVM_sprwait,
  ADCVM_sereq,
  ADCVM_sndstop,
  ADCVM_sestop,
  ADCVM_bgmstop,
  ADCVM_doornoset,
  ADCVM_rand,
  ADCVM_btwait,
  ADCVM_fawait,
  ADCVM_sclblock,
  ADCVM_evstop,
  ADCVM_sereqpv,
  ADCVM_sereqspr,
  ADCVM_scereset,
  ADCVM_bgsprent,
  ADCVM_bgsprpos,
  ADCVM_bgsprset,
  ADCVM_slantset,
  ADCVM_slantclr,
  ADCVM_dummy,
  ADCVM_spcfunc,
  ADCVM_sepan,
  ADCVM_sevol,
  ADCVM_bgdisptrn,
  ADCVM_debug,
  ADCVM_trace,
  ADCVM_tmwait,
  ADCVM_bgspranim,
  ADCVM_abssprent,
  ADCVM_nextcom,
  ADCVM_workclr,
  ADCVM_bgbufclr,
  ADCVM_absbgsprent,
  ADCVM_aviplay,
  ADCVM_avistop,
  ADCVM_sprmark,
  ADCVM_bgmattr,
  ADCVM_last, // required item
};

ins.cpp

Теперь нам предстоит наверное самая муторная работёнка, а именно - формирование списка имён опкодов и указание feature-флагов для них (что это - расскажу позднее). Открываем ins.cpp, вставляем в него следующий шаблон, и начинаем заполнять:

#include "adc.hpp" // your main VM include

const instruc_t Instructions[] = {
  { "", 0 }, // dummy empty instruction
};

CASSERT(qnumber(Instructions) == ADCVM_last);

Каждый элемент списка инструкций представляет из себя структуру instruc_t с полями: имя инструкции, feature-флаги инструкции.

Feature-флаги представляют из себя краткое описание того, что представляет из себя конкретная инструкция на совсем примитивном уровне, типа:

  1. CF_STOP - инструкция не передаёт управление на следующую

  2. CF_USE1-CF_USE8 - используются операнды 1-8

  3. CF_CHG1-CF_CHG8 - изменяются операнды 1-8

  4. CF_CALL - вызов функции

  5. CF_JUMP - прыжок на другую инструкцию

Самое интересное, что эти флаги не нужны Иде (а зачем, спрашивается, мы их указываем?). Они будут использоваться только вами (далее увидите как именно), тем не менее данную формальность всё равно необходимо соблюсти. Поэтому, вот вам парочка "типичных" инструкций и их флагов:

{ "return", CF_STOP },
{ "jmp", CF_USE1 | CF_JUMP | CF_STOP },
{ "call", CF_USE1 | CF_JUMP | CF_CALL },
{ "mul", CF_USE1 | CF_USE2 | CF_CHG1 },

Думаю, флаги в этих примерах говорят сами за себя. В случае, если операндов нет, вместо флагов указываем 0.

Класс procmod_t

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

struct adcvm_t : public procmod_t {
  virtual ssize_t idaapi on_event(ssize_t msgid, va_list va) override;
  int idaapi ana(insn_t* _insn);
  int idaapi emu(const insn_t& insn) const;
  void handle_operand(const insn_t& insn, const op_t& op, bool isload) const;
};

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

ssize_t idaapi adcvm_t::on_event(ssize_t msgid, va_list va) {
  int retcode = 1;

  switch (msgid) {
  case processor_t::ev_init: {
    inf_set_be(false); // our vm uses little endian
    inf_set_gen_lzero(true); // we want to align every hex-value with zeroes
  } break;
  case processor_t::ev_term: {
    clr_module_data(data_id);
  } break;
  case processor_t::ev_newfile: {
    auto* fname = va_arg(va, char*); // here we can load additional data from a current dir
  } break;
  case processor_t::ev_is_cond_insn: {
    const auto* insn = va_arg(va, const insn_t*);
    return is_cond_insn(insn->itype);
  } break;
  case processor_t::ev_is_ret_insn: {
    const auto* insn = va_arg(va, const insn_t*);
    return (insn->itype == ADCVM_ret) ? 1 : -1;
  } break;
  case processor_t::ev_is_call_insn: {
    const auto* insn = va_arg(va, const insn_t*);
    return (insn->itype == ADCVM_call) ? 1 : -1;
  } break;
  case processor_t::ev_ana_insn: {
    auto* out = va_arg(va, insn_t*);
    return ana(out);
  } break;
  case processor_t::ev_emu_insn: {
    const auto* insn = va_arg(va, const insn_t*);
    return emu(*insn);
  } break;
  case processor_t::ev_out_insn: {
    auto* ctx = va_arg(va, outctx_t*);
    out_insn(*ctx);
  } break;
  case processor_t::ev_out_operand: {
    auto* ctx = va_arg(va, outctx_t*);
    const auto* op = va_arg(va, const op_t*);
    return out_opnd(*ctx, *op) ? 1 : -1;
  } break;
  default:
    return 0;
  }

  return retcode;
}

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

  1. ev_init - инициализация процессорного модуля. Здесь мы задаём основные свойства, которые нельзя задать флагами. Например, порядок байт (endianness)

  2. ev_term - если пришло данное событие, необходимо выполнитьclr_module_dataдля корректной работы в случае, если запущено несколько экземпляров Иды с нашим модулем

Не все указанные в этом колбэке методы у нас пока созданы, поэтому их созданием как раз и займёмся. И первым у нас идёт анализатор (ana).

ana() и ana.cpp

Открываем файл ana.cpp, и вставляем в него ещё один шаблон:

#include "adc.hpp"

int idaapi adcvm_t::ana(insn_t* _insn) {
  if (_insn == NULL) {
    return 0;
  }

  insn_t& insn = *_insn;
  
  uint16 code = insn.get_next_word();
  
  switch (code) {
    case 0: {
      
    } break;
    default: {
      return 0;
    } break;
  }
  
  return insn.size;
}

Началось... Именно здесь мы будем заполнять структуры insn_t (сама инструкция) и op_t (каждый из операндов). Давайте разбираться.

Для начала мы должны прочитать необходимый токен, который сможет сказать нам, что за опкод у нас на очереди. В моём случае каждая инструкция имеет переменную длину, но сам опкод без операндов имеет длину 2 байта, т.е. word. Его и читаем. Чтение будет происходить согласно указанному ранее порядку байт. К тому же, чтение с использованием конструкции insn.get_next_xxxx() увеличит значение длины формируемой инструкции на соответствующее значение.

Например, инструкция возврата из функции в моём случае имеет значение 0xFF00. Добавим её в наш оператор switch(code), и заполним заодно одно из полей структуры инструкции (insn_t):

switch (code) {
  // ...
  case 0xFF00: {
    insn.itype = ADCVM_ret;
  } break;
  // ...
}

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

Теперь давайте разберём опкод div, который имеет два операнда: dest (делимое) и src (делитель) и сохраняет результат в dest. Так будет выглядеть формирование каждого из операндов (для удобства, были созданы отдельные функции, которые можно использовать в дальнейшем):

static void op_var(insn_t& insn, op_t& x) {
  x.offb = (char)insn.size;

  uint16 ref = insn.get_next_word();
  x.addr = x.value = get_var_addr(ref); // convert short value to a mem address
  x.dtype = dt_word;
  x.type = o_mem;
}

static void op_var_or_val(insn_t& insn, op_t& x) {
  x.offb = (char)insn.size;

  uint16 ref = x.value = insn.get_next_word();
  bool isvar = is_var(ref); // mem variable or just a word value

  if (isvar) {
    x.addr = x.value = get_var_addr(ref);
  }
  
  x.dtype = dt_word;
  x.type = isvar ? o_mem : o_imm;
}

// ...
switch (code) {
  // ...
  case 0xFF0A: {
    insn.itype = ADCVM_div;
		op_var(insn, insn.Op1);
    op_var_or_val(insn, insn.Op2);
  } break;
  // ...
}

Пройдёмся по основным полям операндов, которые здесь заполняются:

  1. offb - смещение на операнд относительно начала инструкции. Если операнды кодируются битами, или значение не известно, можно использовать значение 0. Данное число будет использоваться эмулятором (т.е. нами), и встроенным hex-редактором при отображении операнда

  2. addr - целевой адрес, если данный операнд содержит ссылку

  3. value - значение, если данный операнд содержит число. Сюда же можно поместить дополнительное смещение, если, например, требуется дельта для значения в addr

  4. dtype - тип данных по целевому адресу в addr, либо значения в value. Все основные типы описаны здесь

  5. type - тип собственно операнда. Это может быть: ссылка на данные (o_mem), число (o_imm), ссылка на код (o_near). Другие типы описаны здесь

Ещё можно использовать поле reg, если у вас используются регистры. Если их нет, данное поле можно использовать под свои нужды. Ещё под эти самые нужды можно занимать поля: specval, specflag1-specflag4.

Отдельная история - когда вам нужно больше чем 8 операндов. У меня было именно так (9). Решил данную проблему как раз использованием полей reg и specval. Ещё у меня прямо в некоторые инструкции могут быть закодированы: строки (не ссылки, а сами символы), массивы. Сохранить их все в указанные поля не выйдет, поэтому заполняем хотя бы основные, а в остальные закидываем всю возможную информацию, которую сможем использовать потом в эмуляторе и выводе: количество элементов массива. длину строки и т.д.

На этом работа анализатора завершена и мы переходим к формированию ссылок на код и данные.

emu() и emu.cpp

Ещё один шаблон, который предстоит заполнить (кидать в одноимённый файл):

#include "adc.hpp"

void adcvm_t::handle_operand(const insn_t& insn, const op_t& op, bool isload) const {
  switch (op.type) {
  case o_imm: { // val
    set_immd(insn.ea);
    op_num(insn.ea, op.n);
  } break;
  case o_mem: { // var
    insn.create_op_data(op.addr, op);
    insn.add_dref(op.addr, op.offb, isload ? dr_R : dr_W);
  } break;
  case o_near: { // code
    switch (insn.itype) {
    case ADCVM_call: {
    	insn.add_cref(op.addr, op.offb, fl_CN);
    } break;
    case ADCVM_jmp: {
      insn.add_cref(op.addr, op.offb, fl_JN);
    } break;
    default: {
      insn.add_dref(op.addr, op.offb, dr_O);
    } break;
    }
  } break;
  }
}

int adcvm_t::emu(const insn_t& insn) const {
  uint32 feature = insn.get_canon_feature(ph);
  bool flow = ((feature & CF_STOP) == 0);

  if (feature & CF_USE1) handle_operand(insn, insn.Op1, 1);
  if (feature & CF_USE2) handle_operand(insn, insn.Op2, 1);
  if (feature & CF_USE3) handle_operand(insn, insn.Op3, 1);
  if (feature & CF_USE4) handle_operand(insn, insn.Op4, 1);
  if (feature & CF_USE5) handle_operand(insn, insn.Op5, 1);
  if (feature & CF_USE6) handle_operand(insn, insn.Op6, 1);
  if (feature & CF_USE7) handle_operand(insn, insn.Op7, 1);
  if (feature & CF_USE8) handle_operand(insn, insn.Op8, 1);

  if (feature & CF_CHG1) handle_operand(insn, insn.Op1, 0);
  if (feature & CF_CHG2) handle_operand(insn, insn.Op2, 0);
  if (feature & CF_CHG3) handle_operand(insn, insn.Op3, 0);
  if (feature & CF_CHG4) handle_operand(insn, insn.Op4, 0);
  if (feature & CF_CHG5) handle_operand(insn, insn.Op5, 0);
  if (feature & CF_CHG6) handle_operand(insn, insn.Op6, 0);
  if (feature & CF_CHG7) handle_operand(insn, insn.Op7, 0);
  if (feature & CF_CHG8) handle_operand(insn, insn.Op8, 0);

  if (flow) {
    add_cref(insn.ea, insn.ea + insn.size, fl_F);
  }

  return 1;
}

Имеем несколько совершенно непонятных вызовов API-функций из IDA SDK, которые стоит пояснить.

  • set_immd - указание Иде, что в данной инструкции содержится число. Зачем это ей нужно, не знаю

  • op_num - указание Иде на то, что в данном операнде имеется число, что повлияет на его вывод. Возможно, вызов данной функции также активирует возможности по изменению представления числа через горячие клавиши

  • create_op_data - классная функция, которая автоматически устанавливает для целевого адреса тип данных из операнда. Например, если опкод mov копирует word в переменную в памяти, то тип данных для неё будет установлен как word

  • insn.add_dref - добавление ссылки на данные для конкретной инструкции и операнда. Также можно пометить, это ссылка на чтение (dr_R) или на запись (dr_W), либо простая ссылка (dr_O)

  • insn.add_cref - добавление ссылки на код. Это применяется для прыжков (jump) - fl_JN, вызовов (call) - fl_CN, либо обычного потока исполнения (codeflow) - fl_F

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

out_insn() и out.cpp

Осталось дело за малым - вывести инструкции с операндами на экран. Для этого возьмём ещё один шаблон:

Шаблон out.cpp
#include "adc.hpp"

class out_adcvm_t : public outctx_t {
  out_adcvm_t(void) = delete; // not used
public:
  bool out_operand(const op_t& x);
  void out_insn(void);
};
CASSERT(sizeof(out_adcvm_t) == sizeof(outctx_t));

DECLARE_OUT_FUNCS_WITHOUT_OUTMNEM(out_adcvm_t);

bool out_adcvm_t::out_operand(const op_t& x) {
  switch (x.type) {
  case o_void: {
    return false;
  } break;
  case o_imm: {
    out_value(x, OOFS_IFSIGN | OOFW_IMM);
  } break;
  case o_near:
  case o_mem: {
    if (!out_name_expr(x, x.addr)) {
      out_tagon(COLOR_ERROR);
      out_btoa(x.addr, 16);
      out_tagoff(COLOR_ERROR);
      remember_problem(PR_NONAME, insn.ea);
    }
  } break;
  }

  return true;
}

void out_adcvm_t::out_insn(void) {
  out_mnemonic();

  int n = 0;
  while (n < UA_MAXOP) {
    if (!insn.ops[n].shown()) {
      n++;
      continue;
    }

    if (insn.ops[n].type == o_void) {
      break;
    }

    out_one_operand(n);

    if (n + 1 < UA_MAXOP && insn.ops[n + 1].type != o_void) {
      out_symbol(',');
      out_char(' ');
    }

    n++;
  }

  flush_outbuf();
}

Не всё понятно в данном шаблоне сразу. Например, что это за класс out_adcvm_t, который наследуется от outctx_t, а за ним обращение к какому-то странному макросу DECLARE_OUT_FUNCS_WITHOUT_OUTMNEM. К тому же, здесь объявлен ещё один метод out_insn(void), без аргументов. Последнее может сбить с толку, т.к. в файле reg.cpp мы видели следующее обращение к методу out_insn:

switch (msgid) {
case processor_t::ev_out_insn: {
    auto* ctx = va_arg(va, outctx_t*);
    out_insn(*ctx);
  } break;
}

Видим, что здесь у данного метода есть входной аргумент. В общем, если вкратце, то вся магия как раз в макросе DECLARE_OUT_FUNCS_WITHOUT_OUTMNEM, который создаёт для нас вспомагательные методы out_insn и out_opnd для более удобного вывода данных в листинг, открывая при этом для использования дополнительные функции из класса outctx_t. Более подробно изучить данную логику можно в файле idaidp.hpp вашего IDA SDK.

Финал

Что, уже? Да, действительно, мы только что закончили писать процессорный модуль для IDA Pro для виртуальной машины из игры Clock Tower - The First Fear. На деле, это оказалось не так и сложно.

Конечно, есть нюансы, куда без них (например, мне пришлось повозиться в выводом строк в кодировке SHIFT-JIS), с операндами, которых больше 8, с массивами. Но, всё это уже позади: модуль успешно дизассемблирует весь загруженный бинарь, справляется как VM из версии для PS1, так и для PC.

Да, в моём случае архитектура оказалась относительно простой виртуальной машиной и, в вашем случае, это может быть что-то посложнее, для чего захочется иметь декомпилятор. Тогда, вариант (пока) здесь только один - Ghidra (о том, как я создавал процессорный модуль для неё я расскажу в следующий раз). Я же могу себе позволить выводить около-сишный листинг прямо в окно дизассемблера Иды.

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

Ссылки

P.S.

Прошу прощения за большие куски кода не под спойлером - Хабр при попытке вставить блок кода в спойлер (на новом редакторе) просто виснет.

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


  1. forthuse
    20.08.2021 10:19
    +1

    Есть ещё DIY реверс в особо сложных случаях. :)

    Как пример проект реверса культовой игры StarFlight (изначально код игры написанный на Forth автор проекта странслировал в форму Си для компиляции Си компилятором и запуска с использованием графики на основе библиотеки SDL)

    P.S. Вот ещё топик по «ручному» реверсу игры с использованием IDA 7.5 и с какими сложностями пришлось столкнутся автору топика Дизассемблер IDA Pro 7.5 для восстановления исходного кода игры (C/C++)


  1. VelocidadAbsurda
    20.08.2021 10:47
    +1

    Спасибо!

    Флагам инструкций бывают применения в плагинах общего плана, не знающих деталей конкретного процессора. Например, видел плагин, выделяющий цветами вызовы у любого процессора, распознавая их по CF_CALL.


    1. DrMefistO Автор
      20.08.2021 15:56

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


      1. VelocidadAbsurda
        20.08.2021 18:39
        +1

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

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

        Как-то помогал закрыть баг в отличном процессорном модуле с github, где автор радикально отказался от разного подобного «с виду легаси», среди прочего убрав и перечисления регистров из processor_t (в out просто выводим “R%d”, op.reg) - всё вроде бы отлично работало, но падало при определённых действиях с окном сегментов, пока не добавили эти самые перечисления. Видимо тащить нам все эти легаси до конца времён (но при этом как лихо «модернизируется» Python API, старые скрипты перестают работать чуть ли не после каждого обновления уже).


        1. DrMefistO Автор
          20.08.2021 18:50

          Абсолютно так. Когда-то писал про этот горе-SDK здесь: https://habr.com/ru/post/509678/