Ghidra как пользоваться
Перейти к содержимому

Ghidra как пользоваться

Ломаем простую «крякми» при помощи Ghidra — Часть 1

О том, что это за зверь такой — Ghidra («Гидра») — и с чем его едят она ест программки, многие уже, наверняка, знают не понаслышке, хотя в открытый доступ сей инструмент попал совсем недавно — в марте этого года. Не буду докучать читателям описанием Гидры, ее функциональности и т.д. Те, кто в теме, уже, уверен, всё это сами изучили, а кто еще не в теме — могут это сделать в любое время, благо на просторах веба сейчас найти подробную информацию не составит труда. Кстати, один из аспектов Гидры (разработку плагинов к ней) уже освещался на Хабре (отличная статья!) Я же дам только основные ссылки:

Давайте попробуем уже что-нибудь сломать этой Гидрой!

Шаг 1. Находим и изучаем крякми

В качестве «жертвы» найдем простую «крякми» (crackme) программку. Я просто зашел на сайт crackmes.one, указал в поиске уровень сложности = 2-3 («простой» и «средний»), исходный язык программы = «C/C++» и платформу = «Multiplatform», как на скриншоте ниже:

Поиск выдал 2 результата (внизу зеленым шрифтом). Первая крякми оказалась 16-битной и не запустилась на моей Win10 64-bit, а вот вторая (level_2 by seveb) подошла. Вы можете скачать ее по этой ссылке.

Скачиваем и распаковываем крякми; пароль на архив, как указано на сайте, — crackmes.de. В архиве находим два каталога, соответствующие ОС Linux и Windows. На своей машине я перехожу в каталог Windows и встречаю в нем единственную «экзешку» — level_2.exe. Давайте запустим и посмотрим, чего она хочет:

Похоже, облом! При запуске программа ничего не выводит. Пробуем запустить еще раз, передав ей произвольную строку в качестве параметра (вдруг, она ждет ключ?) — и вновь ничего… Но не стоит отчаиваться. Давайте предположим, что и параметры запуска нам тоже предстоит выяснить в качестве задания! Пора расчехлять наш «швейцарский нож» — Гидру.

Шаг 2. Создание проекта в Гидре и предварительный анализ

Предположим, что Гидра у тебя уже установлена. Если еще нет, то все просто.

1) установи JDK версии 11 или выше (у меня 12)

2) скачай Гидру (например, отсюда) и установи ее (на момент написания статьи последняя версия Гидры — 9.0.2, у меня стоит 9.0.1)

Запускаем Гидру и в открывшемся Менеджере проектов сразу создаем новый проект; я дал ему имя crackme3 (т.е.проекты crackme и crackme2 уже у меня созданы). Проект — это, по сути, каталог файлов, в него можно добавлять любые файлы для изучения (exe, dll и т.д.). Мы сразу же добавим наш level_2.exe (File | Import или просто клавиша I):

Видим, что уже до импорта Гидра определила нашу подопытную крякми как 32-разрядный PE (portable executable) для ОС Win32 и платформы x86. После импорта наш ждет еще больше информации:

Здесь, кроме вышеуказанной разрядности, нас может еще заинтересовать порядок байтов (endianness), который в нашем случае — Little (от младшего к старшему байту), что и следовало ожидать для «интеловской» 86-й платформы.

С предварительным анализом мы закончили.

Шаг 3. Выполнение автоматического анализа

Время запустить полный автоматический анализ программы в Гидре. Это делается двойным кликом на соответствующем файле (level_2.exe). Имея модульную структуру, Гидра обеспечивает всю свою основную функциональность при помощи системы плагинов, которые можно добавлять / отключать или самостоятельно разрабатывать. Так же и с анализом — каждый плагин отвечает за свой вид анализа. Поэтому сначала перед нами открывается вот такое окошко, в котором можно выбрать интересующие виды анализа:

Для наших целей имеет смысл оставить настройки по умолчанию и запустить анализ. Сам анализ выполняется довольно быстро (у меня занял около 7 секунд), хотя пользователи на форумах сетуют на то, что для больших проектов Гидра проигрывает в скорости IDA Pro. Возможно, это и так, но для небольших файлов эта разница несущественна.

Итак, анализ завершен. Его результаты отображены в окне браузера кода (Code Browser):

Это окно является основным для работы в Гидре, поэтому следует изучить его более внимательно.

Настройки интерфейса по умолчанию разбивают окно на три части.

В центральной части располагается основное окно — листинг дизассемблера, который более или менее похож на своих «собратьев» в IDA, OllyDbg и т.д. По умолчанию столбцы в этом листинге таковы (слева направо): адрес памяти, опкод команды, ASM команда, параметры ASM команды, перекрестная ссылка (если применимо). Естественно, отображение можно изменить, нажав на кнопку в виде кирпичной стены в тулбаре этого окна. Если честно, подобной гибкой настройки вывода дизассемблера я нигде не видел, это чрезвычайно удобно.

В левой части 3 панели:

  1. Секции программы (для перехода по секциям кликаем мышью)
  2. Дерево символов (импорты, экспорты, функции, заголовки и т.д.)
  3. Дерево типов используемых переменных

В правой части — листинг декомпилированного кода (в нашем случае на языке C).

Кроме окон по умолчанию, в меню Window можно выбрать и расположить в любом месте браузера еще с десяток других окон и отображений. Для удобства я добавил окно просмотра памяти (Bytes) и окно с графом функций (Function Graph) в центральную часть, а в правую часть — строковые переменные (Strings) и таблицу функций (Functions). Эти окна теперь доступны в отдельных вкладках. Также любые окна можно открепить и сделать «плавающими», размещая и изменяя их размер по своего усмотрению — это также очень продуманное, на мой взгляд, решение.

Шаг 4. Изучение алгоритма программы — функция main()

Что ж, приступим к непосредственному анализу нашей крякми-программки. Начинать следует в большинстве случаев с поиска точки входа программы, т.е. основной функции, которая вызывается при ее запуске. Зная, что наша крякми написана на C/C++, догадываемся, что имя основной функции будет main() или что-то в этом духе 🙂 Сказано-сделано. Вводим «main» в фильтр Дерева символов (в левой панели) и видим функцию _main() в секции Functions. Переходим на нее кликом мыши.

Обзор функции main() и переименование непонятных функций

В листинге дизассемблера сразу же отображается соответствующий участок кода, а справа видим декомпилированный C-код этой функции. Здесь стоит отметить еще одну удобную фишку Гидры — синхронизацию выделения: при выделении мышью диапазона ASM-команд выделяется и соответствующий участок кода в декомпиляторе и наоборот. Кроме того, если открыто окно просмотра памяти, выделение синхронизируется и с памятью. Как говорится, все гениальное просто!

Сразу отмечу важную особенность работы в Гидре (в отличие, скажем, от работы в IDA). Работа в Гидре ориентирована, в первую очередь, именно на анализ декомпилированного кода. По этой причине создатели Гидры (мы помним — речь о шпионах из АНБ :)) уделили большое внимание качеству декомпиляции и удобству работы с кодом. В частности, перейти к определению функций, переменных и секций памяти можно просто двойным кликом в коде. Также любую переменную и функцию можно тут же переименовать, что весьма удобно, так как дефолтные имена не несут в себе смысла и могут сбить с толку. Как ты увидишь далее, этим механизмом мы будем часто пользоваться.

Итак, перед нами функция main(), которую Гидра «препарировала» следующим образом:

Вроде бы с виду все нормально — определения переменных, стандартные C-шные типы, условия, циклы, вызовы функций. Но взглянув на код внимательнее, замечаем, что имена некоторых функций почему-то не определились и заменены псевдофункцией _text() (в окне декомпилятора — .text()). Давайте сразу начнем определения, что это за функции.

Перейдя двойным кликом в тело первого вызова

видим, что это — всего лишь функция-обертка вокруг стандартной функции calloc(), служащей для выделения памяти под данные. Поэтому давайте просто переименуем эту функцию в calloc2(). Установив курсор на заголовке функции, вызываем контекстное меню и выбираем Rename function (горячая клавиша — L) и вводим в открывшееся поле новое название:

Видим, что функция тут же переименовалась. Возвращаемся назад в тело main() (кнопка Back в тулбаре или Alt + <—) и видим, что здесь вместо загадочного _text() уже стоит calloc2(). Отлично!

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

Постигаем код функции main()

Ладно, с непонятными функциями разобрались. Начинаем изучать код основной функции. Пропуская объявления переменных, видим, что функция возвращает значение переменной iVar2, которое равно нулю (признак успеха функции) только в случае если выполняется условие, заданное строкой

_Argc — это количество параметров (аргументов) командной строки, передаваемых в main(). То есть, наша программа «кушает» 2 аргумента (первый аргумент, мы помним, — это всегда путь к исполняемому файлу).

ОК, идем дальше. Вот здесь мы создаем C-строку (массив char) из 256 символов:

Дальше у нас цикл из 3 итераций. В нем сначала проверяем, установлен ли флаг bVar1 и если да — копируем следующий аргумент командной строки (строку) в _Dest:

Этот флаг устанавливается при анализе следующего аргумента:

Первая строка вычисляет длину этого аргумента. Далее условие проверяет, что длина аргумента должна равняться 2, предпоследний символ == «-» и последний символ == «f». Обрати внимание, как декомпилятор «перевел» извлечение символов из строки при помощи байтовой маски.

После цикла идет этот код:

Здесь я сразу добавил комментарии. Проверяем правильность аргументов («-f путь_к_файлу») и открываем соответствующий файл (2-й переданный аргумент, который мы скопировали в _Dest). Файл будет читаться в двоичном формате, на что указывает параметр «rb» функции fopen(). При ошибке чтения (например, файл недоступен) выводится сообщение об ошибке в поток stderror и программа завершается с кодом 1.

Далее — самое интересное:

Дескриптор открытого файла (_File) передается в функцию _construct_key(), которая, очевидно, и производит проверку искомого ключа. Эта функция возвращает двумерный массив байтов (char**), который сохраняется в переменную ppcVar3. Если массив оказывается пуст, в консоль выводится лаконичное «Nope» (т.е. по-нашему «Не-а!») и память освобождается. В противном случае (если массив не пуст) — выводится по-видимому верный ключ и память также освобождается. В конце функции закрывается дескриптор файла, освобождается память и возвращается значение iVar2.

Итак, теперь мы поняли, что нам необходимо:

1) создать двоичный файл с верным ключом;
2) передать его путь в крякми после аргумента «-f»

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

Ghidra как пользоваться

Debugger Developer’s Guide

Catalog of Dependencies

In addition to Ghidra’s normal dependencies, you may want the following:

  • WinDbg for Windows x64
  • GDB 8.0 or later for Linux amd64/x86_64
  • LLDB 13.0 for macOS

The others (e.g., JNA) are handled by Gradle via Maven Central.

There are several Eclipse projects each fitting into a larger architectural picture. These all currently reside in the Ghidra/Debug directory, but will likely be re-factored into the Framework and Feature directories later. Each project is listed «bottom up» with a brief description and status.

  • ProposedUtils — a collection of utilities proposed to be moved to other respective projects
  • AnnotationValidator — an experimental annotation processor for database access objects
  • Framework-TraceModeling — a database schema and set of interfaces for storing machine state over time
  • Framework-AsyncComm — a collection of utilities for asynchronous communication (packet formats and completable-future conveniences).
  • Framework-Debugging — specifies interfaces for debugger models and provides implementation conveniences.
  • Debugger — the collection of Ghidra plugins and services comprising the Debugger UI.
  • Debugger-agent-dbgeng — the connector for WinDbg (via dbgeng.dll) on Windows x64.
  • Debugger-agent-dbgmodel — an experimental connector for WinDbg Preview (with TTD, via dbgmodel.dll) on Windows x64.
  • Debugger-agent-dbgmodel-traceloader — an experimental «importer» for WinDbg trace files.
  • Debugger-agent-gdb — the connector for GDB (8.0 or later recommended) on UNIX.
  • Debugger-swig-lldb — the Java language bindings for LLDB’s SBDebugger, also proposed upstream.
  • Debugger-agent-lldb — the connector for LLDB (13.0 required) on macOS, UNIX, and Windows.
  • Debugger-gadp — the connector for our custom wire protocol the Ghidra Asynchronous Debugging Protocol.
  • Debugger-jpda — an in-development connector for Java and Dalvik debugging via JDI (i.e., JDWP).

The Trace Modeling schema records machine state and markup over time. It rests on the same database framework as Programs, allowing trace recordings to be stored in a Ghidra project and shared via a server, if desired. Trace «recording» is a de facto requirement for displaying information in Ghidra’s UI. However, only the machine state actually observed by the user (or perhaps a script) is recorded. For most use cases, the Trace is small and ephemeral, serving only to mediate between the UI components and the target’s model. It supports many of the same markup (e.g., disassembly, data types) as Programs, in addition to tracking active threads, loaded modules, breakpoints, etc.

Every model (or «adapter» or «connector» or «agent») implements the API specified in Framework-Debugging. As a general rule in Ghidra, no component is allowed to access a native API and reside in the same JVM as the Ghidra UI. This allows us to contain crashes, preventing data loss. To accommodate this requirement — given that debugging native applications is almost certainly going to require access to native APIs — we’ve developed the Ghidra Asynchronous Debugging Protocol. This protocol is tightly coupled to Framework-Debugging, essentially exposing its methods via RMI. The protocol is built using Google’s Protobuf library, providing a potential path for agent implementations in alternative languages. GADP provides both a server and a client implementation. The server can accept any model which adheres to the specification and expose it via TCP; the client does the converse. When a model is instantiated in this way, it is called an «agent,» because it is executing in its own JVM. The other connectors, which do not use native APIs, may reside in Ghidra’s JVM and typically implement alternative wire protocols, e.g., JDWP. In both cases, the implementations inherit from the same interfaces.

The Debugger services maintain a collection of active connections and inspect each model for potential targets. When a target is found, the service inspects the target environment and attempts to find a suitable opinion. Such an opinion, if found, instructs Ghidra how to map the objects, addresses, registers, etc. from the target namespace into Ghidra’s. The target is then handed to a Trace Recorder which begins collecting information needed to populate the UI, e.g., the program counter, stack pointer, and the bytes of memory they refer to.

Developing a new connector

So Ghidra does not yet support your favorite debugger? It is tempting, exciting, but also daunting to develop your own connector. Please finish reading this guide, and look carefully at the ones we have so far, and perhaps ask to see if we are already developing one. Of course, in time you might also search the internet to see if others are developing one. There are quite a few caveats and gotchas, the most notable being that this interface is still in quite a bit of flux. When things go wrong, it could be because of, without limitation: 1) a bug on your part, 2) a bug on our part, 3) a design flaw in the interfaces, or 4) a bug in the debugger/API your adapting. We are still in the process of writing up this documentation. In the meantime, we recommend using the GDB and dbgeng.dll agents as examples.

You’ll also need to provide launcher(s) so that Ghidra knows how to configure and start your connector. Please provide launchers for your model in both configurations: as a connector in Ghidra’s JVM, and as a GADP agent. If your model requires native API access, you should only permit launching it as a GADP agent, unless you give ample warning in the launcher’s description. Look at the existing launchers for examples. There are many model implementation requirements that cannot be expressed in Java interfaces. Failing to adhere to those requirements may cause different behaviors with and without GADP. Testing with GADP tends to reveal those implementation errors, but also obscures the source of client method calls behind network messages. We’ve also codified (or attempted to codify) these requirements in a suite of abstract test cases. See the ghidra.dbg.test package of Framework-Debugging, and again, look at existing implementations.

Adding a new platform

If an existing connector exists for a suitable debugger on the desired platform, then adding it may be very simple. For example, both the x86 and ARM platforms are supported by GDB, so even though we’re currently focused on x86 support, we’ve provided the opinions needed for Ghidra to debug ARM platforms (and several others) via GDB. These opinions are kept in the «Debugger» project, not their respective «agent» projects. We imagine there are a number of platforms that could be supported almost out of the box, except that we haven’t written the necessary opinions, yet. Take a look at the existing ones for examples.

In general, to write a new opinion, you need to know: 1) What the platform is called (including variant names) by the debugger, 2) What the processor language is called by Ghidra, 3) If applicable, the mapping of target address spaces into Ghidra’s address spaces, 4) If applicable, the mapping of target register names to those in Ghidra’s processor language. In most cases (3) and (4) are already implemented by default mappers, so you can use those same mappers in your opinion. Once you have the opinion written, you can try debugging and recording a target. If Ghidra finds your opinion applicable to that target, it will attempt to record, and then you can work out the kinds from there. Again, we have a bit of documentation to do regarding common pitfalls.

The most obvious integration path for 3rd-party emulators is to write a «connector.» However, p-code emulation is now an integral feature of the Ghidra UI, and it has a fairly accessible API. Namely for interpolation between machines states recorded in a trace, and extrapolation into future machine states. Integration of such emulators may still be useful to you, but we recommend trying the p-code emulator to see if it suits your needs for emulation in Ghidra before pursuing integration of another emulator.

Whether submitting help tickets and pull requests, please tag those related to the debugger with «Debugger» so that we can triage them more quickly.

To set up your environment, in addition to the usual Gradle tasks, process the Protobuf specification for GADP:

If you already have an environment set up in Eclipse, please re-run gradle prepDev eclipse and import the new projects.

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

Вам когда-нибудь хотелось узнать, как же именно работает программа, которой вы так активно пользуетесь, игра, в которую часто играете, прошивка какого-нибудь устройства, которое что-то делает по расписанию? Если да, то для этого вам потребуется дизассемблер. А лучше — декомпилятор. И если с 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).

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

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

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

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

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

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

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

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

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

Runtime-функции

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

Ссылки

Недостатки

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

Релиз

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

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

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *