Булавкой по мозгам. Анализируем динамический код при помощи Intel Pin

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Pin — это утилита для динамической бинарной инструментации (DBI). Она на лету перекомпилирует байт‑код в момент исполнения и как бы держит программу в прозрачной виртуальной машине, работающей после первоначальной загрузки с минимальными потерями скорости. Pin позволяет заглянуть внутрь работающего кода или изменить его поведение. Сегодня мы познакомимся с возможностями этого фреймворка.

Итак, Intel Pin позволяет создавать инструменты для анализа кода, собранного под x86. Динамический подход помогает с легкостью анализировать даже самомодифицирующиеся шелл‑коды, что раньше требовало вдумчивой пошаговой трассировки. Больше не нужно часами сидеть в отладчике, пытаясь найти OEP, — теперь распаковку нетрудно автоматизировать.

Бинарная инструментация подразумевает вставку кода в скомпилированную программу. В отличие от статической, похожей на заражение файла через установку jmp-трамплинов, динамическая инструментация не меняет код на диске, а инжектирует правки в момент исполнения. Pin работает как JIT-компилятор. Исходный код перекомпилируется в такие же ассемблерные инструкции, но с произвольными вставками. И помещается в кеш, где будет исполняться, пока не дойдет до следующего участка, который надо перекомпилировать. Это замедляет загрузку, но с точки зрения старого кода делает вставленный код невидимым!

Возьмем для примера вот такую последовательность инструкций:
Код: Скопировать в буфер обмена
Код:
00401000    cmp ecx,A
00401003    jne 00401007
00401005    int 3
00401007    mov eax,0
Вот как должна выглядеть кешированная копия после инструментации:
Код: Скопировать в буфер обмена
Код:
01401000  call instrumentation
01401005  cmp ecx,A
01401008  call instrumentation
0140100D  jne 0140101B
0140100F  call instrumentation
01401014  int 3
01401016  call instrumentation
0140101B  mov eax,0
Но это в теории: реальный кеш сильно оптимизирован и содержит дополнительные вставки из‑за ограниченного количества процессорных регистров, которые могут быть заняты оригинальным кодом.

Установка Pin​

Я использую Pin версии 3.30, но подозреваю, что API в будущем не изменится. Первым делом качай дистрибутив с официального сайта. Страница не пускает с некоторых IP, но прямые ссылки работают (вот версии для Linux и для Windows). Pin обычно ставят в каталог C:\PIN, чтобы избежать ошибок, возникающих из‑за пробелов в названиях стандартных папок Windows.

Инструменты, собранные для работы с Pin, называются PinTools. Их можно рассматривать как плагины, использующие API для управления инструментацией. Лицензия разрешает распространять их только в виде исходников. Поэтому сегодня будет много сборки.

Собираем исходники​

Собирать будем для Windows 10. Поскольку софт в первую очередь ориентирован на Linux, там проблем вообще не будет. Нам потребуется компилятор Visual Studio и GNU Make.

Я использую Community-версию Visual Studio 2022. Нам нужен пакет Desktop development for C++. Советую при установке выбрать английский язык, чтобы не путаться в меню. Ставим Cygwin 64 в корень диска, качаем пакет Make из категории Devel.

Настроим в консоли переменные окружения:
set PATH=%PATH%;C:\cygwin64\bin

Теперь make будет запускаться по имени.

"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars32.bat"

Запускаем 32- или 64-разрядную версию перед каждой сборкой. Вместе с Pin идет куча примеров, собираем всю папку или один конкретный.

Bash: Скопировать в буфер обмена
Код:
cd C:\PIN\source\tools\SimpleExamples

make all TARGET=intel64

make obj-ia32/icount.dll TARGET=ia32

make obj-intel64/icount.dll TARGET=intel64

Тестовые программы из статьи собираются в IDE, остальное — через тот же Make.

Разбираем icount​

Возьмем самый простой пример — icount.cpp из поставки Pin. Я вырезал необязательную часть кода, так что твой файл будет немного отличаться.

C++: Скопировать в буфер обмена
Код:
C:\PIN\source\tools\SimpleExamples\icount.cpp
#include "pin.H"
#include <iostream>
UINT64 ins_count = 0;
VOID docount()
{
    ins_count++;
}
VOID Instruction(INS ins, VOID* v)
{
    INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_END);
}
VOID Fini(INT32 code, VOID* v)
{
    std::cerr << "Count " << ins_count << std::endl;
}
INT32 Usage()
{
    cerr << "Prints out the number of executedinstructions.\n" << std::endl;
    return -1;
}
int main(int argc, char* argv[])
{
    if (PIN_Init(argc, argv))
    {
        return Usage();
    }
    INS_AddInstrumentFunction(Instruction, 0);
    PIN_AddFiniFunction(Fini, 0);
    PIN_StartProgram();
    return 0;
}

Исполнение начинается в main. Давай разберем, что делает API.
  • PIN_Init — инициализация PIN. Тут происходит разбор аргументов командной строки. В случае неудачи возвращает ненулевое значение, а мы выводим справку об использовании.
  • INS_AddInstrumentFunction регистрирует функцию, которая будет вызвана для каждой встречаемой по ходу исполнения ассемблерной инструкции. Это так называемая Instrumentation Routine, она вызывается лишь однажды. Здесь мы вольны анализировать или модифицировать проверяемый код, собственно проводить вставку своего кода.
  • INS_InsertCall принимает переменное число аргументов, доступные константы указаны в документации. Помещает вызов произвольной функции перед инструкцией ins, в данном случае не передавая никаких аргументов.
  • docount вызывается каждый раз перед исследуемой инструкцией. Это Analysis Routine, функция, которая должна быть максимально оптимизирована, чтобы не тормозить процесс. В ней всего одна команда — увеличение глобального счетчика ins_count.
  • PIN_AddFiniFunction регистрирует функцию Fini, она вызывается по завершении процесса или вызову PIN_Detach внутри инструмента.
  • PIN_StartProgram запускает исполнение исследуемого процесса. Никогда не возвращает управление. После вызова регистрация новых функций невозможна.

Запуск инструментации​

После сборки под 32-разрядную архитектуру в папке obj-ia32 появилась DLL. Применим ее на деле.
Код: Скопировать в буфер обмена
Код:
C:\PIN\pin.exe -t obj-ia32\icount.dll -- cmd /c ver

Microsoft Windows [Version 10.0.19043.1889]
Count 5609927

Аргумент -t содержит путь до PinTool. Прочерки отделяют аргументы целевой программы. В данном случае просим консоль сказать текущую версию ОС.

Обе программы пишут в консоль, сначала cmd, затем Fini выводит число инструкций. Надо понимать, что учитывается не один код приложения, а вся трасса, включая библиотеки и загрузчик, то есть часть кода из NTDLL и внутренности WinAPI.
C:\PIN\pin.exe -pid 4956 -t obj-ia32\icount.dll

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

Гранулярность​

В целях оптимизации Pin обрабатывает опкоды группами. Их существует три вида: трасса, базовый блок и одиночная инструкция. Trace — это набор базовых блоков; BBL — набор инструкций, который обрывается командой передачи управления; Ins — любая инструкция.

Лучший способ разобраться в чем‑либо — сделать это на практике. Следующий код записывает в файл tracer.out анализируемые инструкции, визуально разделяя базовые блоки и трассы.

C++: Скопировать в буфер обмена
Код:
#include <stdio.h>
#include "pin.H"
FILE* out;
VOID Trace(TRACE trace, VOID* v)
{
    fprintf(out, "\n----------------------------\n");
    for (BBL bbl = TRACE_BblHead(trace); BBL_Valid(bbl); bbl = BBL_Next(bbl))
    {
        fprintf(out, "\n");
        for (INS ins = BBL_InsHead(bbl); INS_Valid(ins); ins = INS_Next(ins))
        {
            std::string dis = INS_Disassemble(ins);
            fprintf(out, "%p: %s \n", INS_Address(ins), dis.c_str());
        }
    }
}
VOID Fini(INT32 code, VOID* v)
{
    fclose(out);
}
int main(int argc, char* argv[])
{
    out = fopen("tracer.out", "w");
    if (PIN_Init(argc, argv)) return -1;
    TRACE_AddInstrumentFunction(Trace, 0);
    PIN_AddFiniFunction(Fini, 0);
    PIN_StartProgram();
    return 0;
}

Запустив на тестовом файле, видим, что трасса всегда заканчивается на командах безусловной передачи управления (ret, jmp, call, syscall) либо когда достигается максимальное количество базовых блоков, в моем случае на трех.

Код: Скопировать в буфер обмена
Код:
0x7ffa7c57d876: sub ecx, 0x1
0x7ffa7c57d879: jz 0x7ffa7c57da21
0x7ffa7c57d87f: sub ecx, 0x1
0x7ffa7c57d882: jz 0x7ffa7c57d941
0x7ffa7c57d888: sub ecx, 0x1
0x7ffa7c57d88b: jz 0x7ffa7c57d938

Документация просит вставлять свой код на уровне трассы, чтобы не тормозить исследуемое приложение. То есть по возможности использовать TRACE_InsertCall вместо INS_InsertCall. Помимо этого, для оптимизации можно ограничить инструментацию по адресу функции или конкретному модулю.

Пишем универсальный крякер​

Давай разберем простой crackme и извлечем из него пароль в момент сравнения с пользовательским ключом. Сложность состоит в том, чтобы добраться до этого места, преодолев антиотладку и слой упаковщика. Однако Pin сделает это за нас. Нам остается только поймать настоящий пароль.

Напишем тестовый образец, который затем будем взламывать.
C++: Скопировать в буфер обмена
Код:
#include <stdio.h>
#include <windows.h>
int main(int argc, char* argv[])
{
    if (argc == 2)
    {
        if (strcmp(argv[1], "secret") == 0)
        {
            printf("You did it!\n");
        }
        else
        {
            printf("Better luck next time\n");
        }
    }
    else
    {
        printf("Give me the string!\n");
    }
}

Наш crackme принимает пароль и выводит результат сравнения. Вот как выглядит этот участок кода после компиляции:
Код: Скопировать в буфер обмена
Код:
mov rdx,qword ptr ds:[rdx+8]
lea r9,qword ptr ds:[<"secret"...>]
xor r8d,r8d
mov ecx,r8d
movzx eax,byte ptr ds:[rdx+rcx]
inc rcx
cmp al,byte ptr ds:[r9+rcx-1]
Чтобы сравнить строки, надо знать их адрес. В регистр RDX попадает ссылка на пользовательский ввод, а в r9 — на пароль. Нам остается записать все строки, которые будут записаны в регистры во время исполнения. Неподалеку от введенной нами строки будет пароль.

Копируем шаблон C:\PIN\source\tools\MyPinTool в папку MyCracker.

C++: Скопировать в буфер обмена
Код:
#include <stdio.h>
#include "pin.H"
FILE* trace;
BOOL IsAsciiChar(char c)
{
    return (c >= ' ' && c <= '~');
}
int GetValidCount(char* str, int max)
{
    int counter = 0;
    for (int i=0; i<max; i++)
    {
        if(!IsAsciiChar(str[i])) break;
        counter++;
    }
    return counter;
}
#define DATA_SIZE 50
#define STR_MIN_LEN 4
VOID AnalyzeContext(REG reg, CONTEXT *ctxt)
{
    PIN_LockClient();
    ADDRINT reg_val;
    PIN_GetContextRegval(ctxt, reg, (UINT8 *)(&reg_val));
    char buffer[DATA_SIZE+1];
    if(PIN_SafeCopy(buffer, (VOID*)reg_val, DATA_SIZE) == DATA_SIZE)
    {
        int count = GetValidCount(buffer, DATA_SIZE);
        if (count >= STR_MIN_LEN)
        {
            buffer[count] = 0;
            fprintf(trace, "%s: %p = %s (%d) \n",
                REG_StringShort(reg), reg_val, buffer, count);
        }
    }
    PIN_UnlockClient();
}
BOOL IsValidAddr(ADDRINT Address)
{
    IMG img = IMG_FindByAddress(Address);
    return IMG_Valid(img) && IMG_IsMainExecutable(img);
}
VOID Instruction(INS ins, VOID* v)
{
    ADDRINT addr = INS_Address(ins);
    if (IsValidAddr(addr) && INS_IsValidForIpointAfter(ins))
    {
        for (UINT32 i = 0; i < INS_MaxNumWRegs(ins); i++)
        {
            const REG reg = INS_RegW(ins, i);
            if(!REG_is_reg(reg)) continue;
            INS_InsertPredicatedCall(ins,
                IPOINT_AFTER,
                (AFUNPTR)AnalyzeContext,
                IARG_UINT32, reg,
                IARG_CONST_CONTEXT,
                IARG_END);
        }
    }
}
VOID Fini(INT32 code, VOID* v)
{
    fclose(trace);
}
int main(int argc, char* argv[])
{
    trace = fopen("cracker.out", "w");
    if (PIN_Init(argc, argv)) return -1;
    INS_AddInstrumentFunction(Instruction, 0);
    PIN_AddFiniFunction(Fini, 0);
    PIN_StartProgram();
    return 0;
}

Чтобы можно было работать не только с консольными приложениями, вывод пишется в файл cracker.out. Добавляем вызов, проверяющий каждую встречаемую инструкцию. В нем смотрим ее адрес, чтобы не трассировать инструкции вне исследуемого модуля. Если инструкция меняет любой процессорный регистр — вставляем инструментацию сразу после нее. Так получаем значение регистра и пытаемся интерпретировать это значение как ссылку на ASCII-строку. Если удалось и строка длинней четырех символов — заносим в общий лог.

Bash: Скопировать в буфер обмена
Код:
cd C:\PIN\source\tools\MyCracker
make TARGET=intel64

Собираем и пробуем на тестовом образце.

c:\PIN\pin.exe -t MyPinTool.dll -- CrackMe.exe 123456

Заглянем в логи:
Код: Скопировать в буфер обмена
Код:
rdx: 0x2045e1ba933 = 123456 (6)
r9: 0x7ff618312250 = secret (6)
rax: 0x7ff618312268 = Better luck next time (21)
rcx: 0x7ff618312258 = You did it! (11)

Как и предполагалось, пароль лежит по соседству. Теперь попробуем то же самое на реальной crackme с crackmes.one. Программа упакована и обфусцирована. Пароль для большинства архивов — crackmes.one. Будь готов к тому, что у тебя по ошибке сработает антивирус.


Вот что выводит наша крякми.

1723805903921.png



Делаем то же самое с инструментацией и сразу после введенной строки находим в логе следующее:

Код: Скопировать в буфер обмена
Код:
rcx: 0x14017994 = s.h_ (4)
rax: 0x14017880 = 12345 (5)
rcx: 0x14017990 = obfus.h_ (8)
rsp: 0x14fce0 = Auth (4)
Пробуем находку в качестве пароля:

1723805991503.png



Нам не потребовалось даже запускать отладчик!

tiny_tracer​

Самый известный инструмент из публичных PinTools — tiny_tracer. Он помогает антивирусным аналитикам определять, что делает исследуемое приложение. Он записывает вызовы WinAPI, как бы хорошо они ни были спрятаны.

Ставим исходники в C:\PIN\source\tools\tiny_tracer-2.7.1. Документация рекомендует сборку в IDE, но мой компилятор почему‑то падает в отладку. Для сборки через Make необходимо убрать две строчки из TinyTracer.cpp:

C++: Скопировать в буфер обмена
Код:
#define USE_ANTIDEBUG
#define USE_ANTIVM

Либо включить два файла в makefile.rules:

$(OBJDIR)ProcessInfo$(OBJ_SUFFIX) $(OBJDIR)AntiVm$(OBJ_SUFFIX) $(OBJDIR)AntiDebug$(OBJ_SUFFIX) $(OBJDIR)FuncWatch$(OBJ_SUFFIX)
В папке tiny_tracer-2.7.1\install32_64 лежат вспомогательные инструменты: скрипты для запуска через контекстное меню, пример конфигурации и тому подобное. В эту же папку помещаем TinyTracer32.dll и TinyTracer64.dll, если хотим использовать их вместе.

Напишем тестовый образец:
C++: Скопировать в буфер обмена
Код:
#include <stdio.h>
#include <windows.h>
void Test_NtApi()
{
    LPVOID NtClose = GetProcAddress(GetModuleHandleA("ntdll"), "NtClose");
    typedef int (*NtCloseAddr)(int handle);
    ((NtCloseAddr)NtClose)(0);
}
extern "C" void __fastcall NtCloseSyscall(int handle);
void Test_DirectSysCall()
{
    NtCloseSyscall(0);
}
void Test_ShellCode()
{
    DWORD OldProt;
    CHAR shellcode[] = "\x90\xC3"; // just NOP & RET
    DWORD shell_len = sizeof(shellcode);
    LPVOID shell_buf = malloc(shell_len);
    VirtualProtect(shell_buf, shell_len, PAGE_EXECUTE_READWRITE, &OldProt);
    memcpy(shell_buf, shellcode, shell_len);
    typedef void (*ShellType)();
    ((ShellType)shell_buf)();
}
int main(int argc, char* argv[])
{
    Test_NtApi();
    Test_DirectSysCall();
    Test_ShellCode();
}
К сожалению, компилятор MSVC не разрешает ассемблерные вставки внутри кода для x86-64. Добавляем компилятор MASM в Build dependencies. И syscall.asm — через Project → Add New Item.

Код: Скопировать в буфер обмена
Код:
.code
NtCloseSyscall PROC
mov r10,rcx
mov eax,15
syscall
ret
NtCloseSyscall ENDP
END

Первый тест динамически получает адрес NtClose и вызывает его с нулевым аргументом. Следующий тест выполняет то же самое, но вызывая syscall напрямую. Последний запускает произвольный шелл‑код. Он слишком велик для листинга, скопируй его с GitHub.
c:\PIN\pin.exe -t TinyTracer64.dll -- Test4TT.exe

В output.txt получаем:
Код: Скопировать в буфер обмена
Код:
13e0;section: [.text]
101b;kernel32.GetModuleHandleA
102b;kernel32.GetProcAddress
1038;ntdll.NtClose
1148;SYSCALL:0xf
10b3;ucrtbase.malloc
10d4;kernel32.VirtualProtect
10ef;called: ?? [2b51f69a000+1e0]
> 2b51f69a000+205;kernel32.LoadLibraryA
> 2b51f69a000+22e;user32.MessageBoxA
> 2b51f69a000+246;kernel32.FatalExit

Тесты отражены корректно. Видим MessageBox из шелл‑кода. Но не хватает аргументов функций.

Код: Скопировать в буфер обмена
Код:
kernel32;GetModuleHandleA;1
kernel32;GetProcAddress;2
ntdll;NtClose;1
<SYSCALL>;15;1
ucrtbase;malloc;1
kernel32;VirtualProtect;4
kernel32;LoadLibraryA;1
user32;MessageBoxA;4
kernel32;FatalExit;1
Записываем число аргументов в params.txt и запускаем syscall_extract.exe для получения syscalls.txt примерно с таким содержимым:
Код: Скопировать в буфер обмена
Код:
0xd,NtSetInformationThread
0xe,NtSetEvent
0xf,NtClose

Осталось повторно запустить tiny_tracer с новыми параметрами.
-t TinyTracer64.dll -s TinyTracer.ini -l syscalls.txt -b params.txt -- Test4TT.exe

Видим, что настройки приняты.
1723806411703.png



Получаем лог со всеми аргументами.
1723806441773.png



IdaPin​

Напоследок разберем еще один неплохой инструмент. IdaPin — плагин для удаленной отладки в IDA Pro. Его можно собрать из исходников или Скачать
View hidden content is available for registered users!
 
Сверху Снизу