• XSS.stack #1 – первый литературный журнал от юзеров форума

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

rand

CooL-Lamer
Эксперт
Регистрация
24.05.2023
Сообщения
581
Реакции
1 152
Депозит
0.07 Ł и др.
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. Его можно собрать из исходников или скачать в готовом виде. Для последнего требуется конкретная версия Pin, ссылка на нее есть в описании релиза.

Запуск несложен, меняем Local windows debugger на PIN Tracer и указываем в настройках правильные пути до pin.exe и папку с плагинами. Ожидаем конца загрузки и получаем полноценную отладочную сессию.

Плагин раскрашивает пройденный код на графе: одним цветом пошаговую трассировку, другим — обычное исполнение. Остановившись на breakpoint, можно увидеть, каким путем до него добрался поток команд.

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

Я прогонял IdaPin на антиотладочных трюках. Засыпался он только на этом:
C++:
#include <Windows.h>
#include <stdio.h>
int main()
{
    DWORD64 timer_1 = __rdtsc();
    DWORD64 timer_2 = __rdtsc();
    DWORD64 diff = timer_2 - timer_1;
    printf("diff: %p", diff);
    return 0;
}
Команда RDTSC (Read Time Stamp Counter) возвращает точное количество тактов, пройденных процессором. Собственно, в них измеряется частота процессора. На одну ассемблерную инструкцию может приходиться несколько тактов.

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

1723806505244.png


Это проблема реализации IdaPin; хорошо оптимизированные PinTools дают разницу счетчиков в пределах погрешности и, скорее всего, замечены не будут. К тому же результат работы __rdtsc несложно подменить, как это делает tiny_tracer.

Выводы​

Несмотря на свою «прозрачность», Pin нетрудно обнаружить по добавлению pinvm.dll или множеству других артефактов. Полномочия Pin заканчиваются в конкретном user-mode-процессе, он не ограничивает общение процесса с ядром. Специально спроектированный код может сбежать из виртуальной машины или изменить поведение, заметив ее наличие. В общем, Pin — это не панацея, но еще один хакерский инструмент в наш плотно набитый чемоданчик.

P.S. Ссылки в статье не проверял, если что поправьте.

Источник: https://xakep.ru/2024/08/14/intel-pin/
 
Последнее редактирование:


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх