Исследование и изменение исполняемого кода в процессе работы программы, что может быть интересней? Intel Pin – фреймворк для динамической бинарной инструментации (Dynamic Binary Instrumentation, DBI) исполняемого кода. Этот фреймворк обладает широкими возможностями по анализу и модификации кода. Мне было очень интересно посмотреть вживую на доступные в нем функции по анализу отдельных инструкций. И наконец подвернулась такая возможность.
В статье будет рассмотрено получение адреса перехода для инструкции jmp
, перехват вызова функции, находящейся за таблицей инкрементальной линковки (Incremental Linking Table, ILT) и все это средствами Pin.
Коротко о Dynamic Binary Instrumentation и его применении
DBI технология нашла широкое применение от поиска уязвимостей в коде до анализа производительности отдельных участков кода. Одним из простых примеров использования DBI можно считать перехват вызова отдельных функций. Иногда он используется для подмены значений параметров целевой функции, а иногда просто для замера времени выполнения этой самой функции. Последний вариант и будет рассматриваться далее.
Для начала нам потребуется функция, вызов которой будет перехватываться. Или даже несколько функций. Например, считающих значение числа π. И пусть они будут объединены в одной динамической библиотеке (файл pi/pi.h):
#pragma once
#include "api_defines.h"
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
double API builtin_constant();
double API viete_formula(unsigned iter_num);
double API wallis_formula(unsigned iter_num);
#ifdef __cplusplus
};
#endif // __cplusplus
Также нужно будет приложение, которое вызывает эти функции (файл app/app.cpp):
#include <iostream>
#include "../pi/pi.h"
int main()
{
const unsigned int iter_num = 25;
std::cout << "builtin_constant() returned: " << builtin_constant() << std::endl;
std::cout << "viete_formula() returned: " << viete_formula(iter_num) << std::endl;
std::cout << "wallis_formula() returned: " << wallis_formula(iter_num) << std::endl;
}
И сам код по перехвату вызова функции использующий Pin API (файл pintool/pintool.cpp):
#include <cstdlib>
#include "pin.H"
#include "log.h"
#include "path.h"
#include "time.h"
namespace pintool
{
using viete_formula_probe_fn = double (*)(unsigned);
viete_formula_probe_fn viete_formula_orig;
const char* viete_formula_func_name = "viete_formula";
double viete_formula_probe(unsigned iter_num)
{
time::timer t;
const double ret = viete_formula_orig(iter_num);
const time::timer::ticks_type elapsed_time = t.elapsed();
TRACE("wall time of " << viete_formula_func_name << " is " << elapsed_time << " us,"
<< " number of iterations is " << iter_num);
return ret;
}
template<typename FuncType>
FuncType set_probe(const IMG& img, const char* funcname, FuncType probe)
{
FuncType orig_func = nullptr;
RTN rtn = RTN_FindByName(img, funcname);
if (RTN_Valid(rtn)) {
if (RTN_IsSafeForProbedReplacement(rtn)) {
orig_func =
reinterpret_cast<FuncType>(RTN_ReplaceProbed(rtn, reinterpret_cast<AFUNPTR>(probe)));
} else {
TRACE("found rtn cannot be replaced");
}
} else {
TRACE("function " << funcname << " is not found");
}
return orig_func;
}
VOID image_load(IMG img, VOID* v)
{
const std::string& filename = path::filename(img);
if (filename == "pi.dll") {
viete_formula_orig =
set_probe(img, viete_formula_func_name, viete_formula_probe);
}
}
} // namespace pintool
int main(int argc, char* argv[])
{
if (PIN_Init(argc, argv)) {
ERROR("cannot initialize Pin");
return EXIT_FAILURE;
}
PIN_InitSymbols();
IMG_AddInstrumentFunction(pintool::image_load, 0);
PIN_StartProgramProbed();
return EXIT_SUCCESS;
}
Кода получилось многовато для легкого старта, но основное с чем нужно разобраться на данном этапе, это шаблонная функция set_probe()
. Собственно, в ней и происходит вся магия по подмене вызова функции. Этот процесс состоит из трех шагов:
поиск функции по имени - вызов
RTN_FindByName()
,проверки, что функция может быть безопасно перехвачена - вызов
RTN_IsSafeForProbedReplacement()
,подмена оригинальной функции вызовом пользовательской - вызов
RTN_ReplaceProbed()
.
Код есть, теперь можно посмотреть, как он работает. Для начала запустим само приложение и посмотрим на его вывод. Достаточно ожидаемый результат:
x64\Release> app.exe
builtin_constant() returned: 3.14159
viete_formula() returned: 3.14159
wallis_formula() returned: 3.11095
И следом запустим приложение с загруженным в него pintool с перехватом вызова функции viete_formula()
:
x64\Release> %PinRoot%\pin.exe -t pintool.dll -- app.exe
builtin_constant() returned: 3.14159
[pintool trace]: wall time of viete_formula is 9 us, number of iterations is 25
viete_formula() returned: 3.14159
wallis_formula() returned: 3.11095
Как можно видеть из вывода приложения, вызов функции viete_formula()
был успешно перехвачен, получено значение аргумента функции и измерено общее время выполнения оригинальной функции.
Давайте посмотрим на то, как меняется исполняемый код при перехвате функции. Для начала взглянем на то, как происходит вызов функции в оригинальном приложении на уровне ассемблерных команд. Откроем дизассемблированный код для функции main()
, которая вызывает функцию viete_formula()
:
int main()
{
0x7FF7C0BF1000 sub rsp, 38h
0x7FF7C0BF1004 movaps xmmword ptr[rsp+20h], xmm6
const unsigned int iter_num = 25;
...
std::cout << "viete_formula() returned: " << viete_formula(iter_num) << std::endl;
0x7FF7C0BF1041 mov ecx, 19h
0x7FF7C0BF1046 call qword ptr [__imp_viete_formula(7FF7C0BF3210h)]
Из кода видно, что вместо непосредственно адреса функции в инструкции call
используется указатель на память, откуда этот адрес нужно взять. Это вариант косвенного вызова процедуры. Проверим что лежит по этому адресу
0x7FF7C0BF3210 20 10 f6 ad fa 7f 00 00 00 00 00 00 00 00 00 00
Как и ожидалось, там находится адрес функции viete_formula()
0x7FFAADF61020
по которому можно найти начало вызываемой функции:
double viete_formula(unsigned iter_num)
{
0x7FFAADF61020 mov rax, rsp
0x7FFAADF61023 mov qword ptr[rax+18h], rbp
0x7FFAADF61027 push rdi
0x7FFAADF61028 sub rsp, 60h
0x7FFAADF6102C movaps xmmword ptr[rax-18h], xmm6
double r = 1;
0x7FFAADF61030 xor edi, edi
И в нем можно видеть некоторые махинации с значением регистра RSP
, это стандартный пролог для функции.
Теперь же запустим программу с загруженным pintool и посмотрим, что происходит в этом случае. Функция main()
как и прежде содержит косвенный вызов функции viete_formula()
:
int main()
{
0x7FF623E31000 sub rsp, 38h
0x7FF623E31004 movaps xmmword ptr[rsp+20h], xmm6
const unsigned int iter_num = 25;
…
std::cout << "viete_formula() returned: " << viete_formula(iter_num) << std::endl;
0x7FF623E31041 mov ecx, 19h
0x7FF623E31046 call qword ptr[__imp_viete_formula(07FF623E33210h)]
А вот исполняемый код самой функции изменился:
double viete_formula(unsigned iter_num)
{
0x7FFABFAB1020 jmp qword ptr[7FFAD1C50018h]
0x7FFABFAB1026 sbb byte ptr[rdi+48h], dl
0x7FFABFAB1029 sub esp, 60h
0x7FFABFAB102C movaps xmmword ptr[rax-18h], xmm6
double r = 1;
0x7FFABFAB1030 xor edi, edi
Теперь первой инструкцией стоит безусловный переход по адресу в памяти. Собственно, это и есть перехват вызова оригинальной функции. Теперь все вызовы функции будут приводить к тому, что управление будет безусловно передаваться по адресу, хранящемуся в памяти начиная с 0x7FFAD1C50018
.
Как видно из дизассемблированного кода, Pin вставляет инструкцию безусловного перехода для перехвата функции, и причем использует вариант инструкции jmp
размером в 6 байт. Если посмотреть на оригинальный код функции, то там первые семь байт функции занимают две инструкции mov
. И поскольку из программы просто так инструкций не выкинешь, а оригинальная функция продолжает работать, то очевидно эти инструкции переехали куда-то в другое место. И это место можно найти по указателю на оригинальную функцию, который возвращает вызов RTN_ReplaceProbed()
:
0x2B084350090 mov rax, rsp
0x2B084350093 mov qword ptr[rax+18h], rbp
0x2B084350097 jmp qword ptr[2B0843500A0h]
0x2B08435009D add byte ptr[rax], al
0x2B08435009F add byte ptr[rdi], ah
0x2B0843500A1 adc byte ptr[rbx+7FFABFh], ch
0x2B0843500A7 add byte ptr[rax-77h], cl
Обе операции mov
теперь находятся в новом месте. Будем называть это место неким code cache для оригинальных инструкций функции. А после них идет безусловный переход обратно в оригинальную функцию. Причем что интересно, снова используется косвенный переход, и адрес возврата находится почти сразу (с поправкой на выравнивание) за инструкцией jmp
. Если посмотреть на адрес перехода:
0x02B0843500A0 27 10 ab bf fa 7f 00 00 48 89 5c 24 08 48 89 6c
То он будет указывать на инструкцию push
, которая находилась за замененными инструкциями mov
. Если изобразить схематически, то попадание в оригинальную функцию после перехвата выглядит следующим образом:
где
viete_formula
– это оригинальная функция, в которой первые две инструкцииmov
были заменены на безусловный переход в функциюviete_formula_probe()
,viete_formula_probe
– функция, вызываемая вместо оригинальной функции,viete_formula_orig
– это участок в code cache, куда были перенесены инструкции из пролога оригинальной функции при вставке тудаjmp
инструкции.
Немного об Incremental Linking Table и совместимости с Pin
В процессе работы программисты очень часто пересобирают проекты для того, чтобы проверить сделанные изменения. И чем больше программа, тем больше тратится времени на сборку, что совсем не радует разработчиков. При этом, очень часто изменяется лишь небольшая часть кода. Поэтому компиляторы и компоновщики предоставляют средства для сокращения времени повторных сборок. Одно из таких средств, которое предоставляет Microsoft Incremental Linker, это инкрементальная линковка.
При изменении даже одной функции может потребоваться обновить и другие функции, которые могут быть с исходной даже и не связаны. Например, при изменении размера функции, также изменяются адреса функций, оказавшихся в исполняемом коде после изменённой. Это приводит к необходимости обновления адресов этих функций в остальном коде, где они вызываются. Чтобы сократить количество изменений, при инкрементальной линковке в исполняемом коде, во-первых, оставляется «зазор» между функциями, чтобы небольшие изменения в размере кода не приводили к смещению других функций. А во-вторых, добавляется Incremental Linking Table:
0x7FFABCF01073 jmp viete_formula (07FFABCF01440h)
0x7FFABCF01078 jmp __scrt_stub_for_acrt_thread_attach (07FFABCF053A0h)
0x7FFABCF0107D jmp _RTC_NumErrors (07FFABCF01D10h)
0x7FFABCF01082 jmp __scrt_initialize_onexit_tables (07FFABCF02470h)
0x7FFABCF01087 jmp builtin_constant (07FFABCF01420h)
Каждый элемент этой таблицы — входная точка отдельной функции. И эта входная точка содержит лишь одну инструкцию: безусловный перехода на тело функции. Такой подход сильно уменьшает количество изменений, если все-таки одна или нескольких функций в результате внесенных правок «съехали» в исполняемом коде. Поскольку в этом случае нужно лишь обновить записи в таблице, а сами инструкции вызовов этих функций изменять не нужно. Цепочка переходов в случае наличия инкрементальной таблицы линковки будет выглядеть следующим образом:
Инкрементальная линковка включена по умолчанию в проектах Microsoft Visual Studio для Debug сборки. Поэтому для проверки того как Pin работает с ILT, достаточно запустить дебажную версию программы с pintool. При этом в каталоге с динамической библиотекой, из которой перехватывается функция, должен отсутствовать pdb файл для этой библиотеки. В этом случае перехват функции не сработает:
x64\Debug>%PinRoot%\pin.exe -t pintool.dll -- app.exe
[pintool trace]: found rtn cannot be replaced
builtin_constant() returned: 3.14159
viete_formula() returned: 3.14159
wallis_formula() returned: 3.11095
А почему должен был отсутствовать pdb файл? К чести Pin, он по умолчанию пытается получить максимум информации о коде, который подвергается инструментации. В том числе он проверяет доступность отладочной информации и если может ее найти, то использует ее для уточнения того, какие функции есть в коде. Поэтому при наличии pdb файла Pin способен понять, что в действительности функция начинается в другом месте и без проблем ее перехватывает.
При отсутствии отладочной информации перехват не срабатывает, поскольку в ILT используется инструкция прямого перехода размером в 5 байт, а Pin для перехвата использует инструкцию косвенного перехода в 6 байт. Ему просто не хватает места куда можно было бы ее вставить. В этом случае можно поступить двумя способами, либо изменить адрес безусловного перехода на функцию, которая должна выполнятся при перехвате, либо попробовать найти реальное начало перехватываемой функции и попробовать перехватить вызов уже там. У первого способа есть определенные ограничения на то, что в 5-ти байтовом варианте jmp
указывается относительное смещение, и функция, вызываемая при перехвате не должна быть сильно удалена от исходного jmp
в ILT. Второй же способ дает возможность посмотреть, что есть у Pin для исследования свойств инструкций, поэтому выберем его.
Исследование инструкций с помощью Pin
Для начала сформулируем проблему: есть функция, вызов которой нужно перехватить, при этом в начале этой функции есть последовательность из одного или нескольких прямых безусловных переходов на фактическое тело функции. Необходимо найти тело этой функции для перехвата ее вызова. А теперь посмотрим, что для этого предлагает Pin.
Найдя функцию по имени, первую инструкции в функции можно получить с помощью вызова RTN_InsHead()
. Он вернет объект типа INS
описывающий отдельную инструкцию. Далее можно проверить является ли эта инструкция безусловным переходом используя вызов INS_HasFallThrough()
. Для них INS_HasFallThrough()
будет возвращать FALSE
. Но также FALSE
будет возвращаться как для jmp инструкций, так и для call инструкций, и для syscall. Поэтому нужно выделить безусловные переходы и при том только прямые. В этом поможет функция INS_IsDirectBranch()
.
Используя приведенные функции, можно определить по первой инструкции перехватываемой функции нужно ли искать ее тело дальше. И если нужно, то следует понять где его искать. Безусловный прямой jmp
делает переход по смещению, указанному в инструкции. Адрес перехода вычисляется как сумма значения регистра IP
при выполнении инструкции и указанного смещения . Значение регистра IP
для исполняемой инструкции равно адресу следующей за ней инструкции. Поэтому, чтобы получить значение IP
для вычисления адреса перехода нужно использовать вызов INS_NextAddress()
для текущей инструкции. А вот для получения смещения из инструкции перехода в Pin нет специального вызова. Но Pin содержит Intel XED библиотеку для декодирования x86 инструкций и позволяет из объекта INS
получить указатель на объект xed_decoded_inst_t
, который можно дальше использовать для получения свойств инструкции, но уже с помощью XED API. Основное назначение XED, это кодирование и декодирование x86 инструкций. Поэтому с помощью XED API можно получить любое свойство инструкции в том числе и смещение для переходов. Для этого надо использовать вызов xed_decoded_inst_get_branch_displacement()
.
Все данные для вычисления адреса предполагаемого тела функции могут быть получены с помощью соответствующих вызовов API. И с помощью вызова RTN_CreateAt()
можно сказать Pin, что по заданному адресу существует точка входа для функции. А после этого попробовать перехватить ее вызов снова. Переложим все выше описанное на код (файл pintool/pintool.cpp):
RTN get_inner_rtn(const RTN& rtn, const std::string& name)
{
if (!RTN_Valid(rtn)) {
TRACE("passed rtn is not valid");
return RTN_Invalid();
}
RTN_Open(rtn);
INS ins = RTN_InsHead(rtn);
if (INS_Valid(ins)) {
if (!INS_HasFallThrough(ins)) {
if (INS_IsDirectBranch(ins)) {
const ADDRINT rip = INS_NextAddress(ins);
xed_decoded_inst_t* xedd = INS_XedDec(ins);
const int jmp_displacement = xed_decoded_inst_get_branch_displacement(xedd);
RTN_Close(rtn);
return RTN_CreateAt(rip + jmp_displacement, name);
} else {
TRACE("first instruction is not direct jmp");
}
} else {
TRACE("first instruction is not uncoditional jmp or call");
}
} else {
TRACE("cannot get first instruction for rtn");
}
RTN_Close(rtn);
return RTN_Invalid();
}
template<typename FuncType>
FuncType set_probe(const IMG& img, const char* funcname, FuncType probe)
{
FuncType orig_func = nullptr;
RTN rtn = RTN_FindByName(img, funcname);
if (RTN_Valid(rtn)) {
RTN outter_rtn = RTN_Invalid();
while (RTN_Valid(rtn) && !RTN_IsSafeForProbedReplacement(rtn) && outter_rtn != rtn) {
TRACE("given rtn cannot be probed, try to find inner rtn");
outter_rtn = rtn;
rtn = get_inner_rtn(outter_rtn, funcname);
}
if (RTN_Valid(rtn) && RTN_IsSafeForProbedReplacement(rtn)) {
orig_func = reinterpret_cast<FuncType>(RTN_ReplaceProbed(rtn, reinterpret_cast<AFUNPTR>(probe)));
} else {
TRACE("found rtn cannot be replaced and inner rtn cannot be obtained");
}
} else {
TRACE("function " << funcname << " is not found");
}
return orig_func;
}
И посмотрим, что из этого вышло. Если снова загрузить pintool в дебажную версию приложения, то можно будет получить следующий вывод:
x64\Debug>%PinRoot%\pin.exe -t pintool.dll -- app.exe
[pintool trace]: given rtn cannot be probed, try to find inner rtn
builtin_constant() returned: 3.14159
[pintool trace]: wall time of viete_formula is 50 us, number of iterations is 25
viete_formula() returned: 3.14159
wallis_formula() returned: 3.11095
Как видно из вывода, pintool смог успешно перехватить вызов функции, а значит выбранный подход сработал.
В качестве заключения
Pin предоставляет два вида API для исследования инструкций. Первый – это “Inspection API for IA-32 and Intel 64 instructions”, который является непосредственно частью Pin API и позволяет получить наиболее часто востребованные свойства инструкций, он покрывает большую часть потребностей. Второй – XED API, который позволяет получить любое свойство инструкции, на случай если в Pin API не нашлось нужного вызова.
Код примера доступен на GitHub. И исследуйте работу программ, это интересно! :)