Динамический анализ инструкций с помощью Intel Pin

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Исследование и изменение исполняемого кода в процессе работы программы, что может быть интересней? 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(). Собственно, в ней и происходит вся магия по подмене вызова функции. Этот процесс состоит из трех шагов:

  1. поиск функции по имени - вызов RTN_FindByName(),

  2. проверки, что функция может быть безопасно перехвачена - вызов RTN_IsSafeForProbedReplacement(),

  3. подмена оригинальной функции вызовом пользовательской - вызов 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. И исследуйте работу программ, это интересно! :)

Источник: https://habr.com/ru/post/666274/


Интересные статьи

Интересные статьи

Защита приложений от обратной разработки - сложный процесс, который может отнимать много сил и нервов. Статья расскажет о нескольких подходах защиты приложений под операционную систему iOS с использов...
Привет, Хабр! На связи Александр Воронцов, технический специалист компании Cloud4Y. Сегодня я расскажу, как можно настроить получение в Zabbix метрик СУБД PostgreSQL, используемой в VMware Cloud Direc...
Полвека назад радио пользовалось популярностью не только у любителей музыки, но и у программистов. В этом материале — рассказываем, кто и как транслировал софт по FM-волнам и какое применение технолог...
Помните как некто cnlohr запустил передачу ТВ сигнала на ESP8266? Недавно мне попалось к просмотру это видео, стало интересно как это возможно и выяснил что автор видео разогнал ча...
Расскажем, как использовать CRD Kubernetes, чтобы автоматизировать безопасность и обеспечить защиту ваших приложений. Перевод от команды журнала «Завтра облачно» Mail.ru Cloud Solutions. И...