Инъекция функции: WinAPI

Аутлог за 01.01.2022, 02:10

В представленном материале будет поднят вопрос инъекции кода в чужой процесс в Windows.

В статье преподносится как практический, так и теоретический материал.

Важно отметить: не будет подниматься тема DLL-Injection. Об этом можно найти много информации в интернете. Речь будет идти именно об инъекции кода функций в чужой процесс.

Весь код (С++, WinAPI) и информация представлены <контактом>.

Весь приведенный материал приводится только для ознакомления и общего просвещения. Не используйте материал во вред любому объекту.

Инъекции: теория

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

Условимся, что описанное выше и ниже является сугубо личным опытом и пониманием автором данной статьи.

Во время вызова функция инициализирует свой стек, в котором хранит данные и с которыми она работает.

Соответственно функция – такой же набор байт, который находится на стеке программы, загружаемой (и выполняемой) пользователем. Эта функция также имеет свой адрес в памяти.

Нужно отметить, что функции, лежащие в глобальной области видимости EXE не требуют танцев с бубном – операционные системы сами загрузят исполняемый файл и выделят вам адрес вашей программы, в которой и будут храниться функции, данные и т.д. Соответственно программисту остается лишь попросить операционную систему “перепрыгнуть” на ту строчку (адрес), которую программист хочет вызвать:

void test() {} // 1

int main() { test(/*go to 1 pls*/);} // 2

В примере выше приведены псевдо-строчки, не отражающие настоящей нумерации команд. Опустим callback-функции, т.к. по сути своей – это лишь указатели на функции, не более.

На разных операционных системах типы имеют разные размеры. Конечно, стандарт гарантирует, что такие типы, как int, char, double, float (и другие) имеют такой размер, который предписан в стандарте, однако есть два вида типов, которые выбиваются из данного правила:

  1. void* - указатель любого типа

2. Типы, используемые интерфейсом для работы с ОС

В частности, void* имеет разный размер в зависимости от разрядности системы: x32 – 4 байта, x64 – 8 байт, но это – предписанная часть стандарта.

Однако с типами, используемыми для работы с ОС (далее – типами ОС) всё немного тяжелее. Если рассматривать официальную документацию MSDN (WinAPI), то можно увидеть DWORD, HANDLE, LPVOID и другие.

В большинстве функций ОС Windows (далее – функции ОС) используется не только тип данных (или структура), но и её размер. Поэтому будет плохой практикой писать так:

CallWindowsFoo(iAmDword, 4);

Лучше писать так:

CallWindowsFoo(iAmDword, sizeof(iAmDword));

Это избавит от неточностей, когда код написан с ориентиром на x32, а запускается под x64, где размер типов может отличаться. Конечно, в зависимости от разрядности могут потребоваться другие функции или реализация API будет отличаться, поэтому какие-то части кода всё равно потребуется переписать.

Вернёмся к теме функций. Было определено, что функции имеют адрес в памяти программы. Тогда зная адрес функции и адрес программы, можно легко вызвать её из чужого процесса, также, как и наоборот: зная адрес программы, можно аллоцировать в ней место и загрузить туда функцию.

Под инъекцией функции подразумевается копирование уже существующей функции из памяти атакующего процесса в аллоцированную область памяти процесса-жертвы.

При работе с локальными переменными встроенного типа проблем не возникнет (если аллоцировать достаточно памяти для стека функции), т.е. с типами int/char/const char* и так далее.
Однако при работе с типами, которые лежат, например, в STD, возникнет проблема отсутствия таких типов в чужих процессах. Эта проблема будет выражаться в прекращении работы атакуемого приложения. Это связано с тем, что функция обращается к такому типу, чей адрес в глобальной видимости не валиден для атакуемого процесса, либо имеет несовместимый с требуемым типом размер в байтах (грубо и очень неточно, но суть ясна). Такая же проблема возникает при попытке вызова функций по их имени (вместо адреса в атакуемом процессе).

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

Так, выделим шаги, которым нужно следовать для инъекции функции в процесс:

  1. Получить хендлер чужой программы

2. Аллоцировать область памяти в чужой программе

3. Скопировать функцию в область памяти

4. Запустить область памяти

Условимся, что тесты будут проводиться на Windows 7. Отмечу, что подход, освещенный в данной статье работает как на Win7, так и на Win10.

Инъекция: progtask

Сначала определимся с именем, которое мы хотим искать среди всех процессов. Во время написания кода рассмотрим самописный калькулятор (calc.exe). Условимся, что TCHAR – это typedef char, поэтому вместо TCHAR будем использовать char.

Также условимся, что мы будем использовать флаг –fpermissive, дословно говорящий компилятору “отказаться от стандарта”. Это позволит нам использовать некоторые грязные трюки, которые не поддерживаются стандартом. Автор статьи использовал MinGW x64 с флагом –fpermissive.

Приведенный ниже код производит инъекцию функции типа int в чужой процесс, запускает её и получает return код:

#include <iostream>

#include <Windows.h>

#include <TlHelp32.h>

#include <tlhelp32.h>

#include <psapi.h>

#include <processthreadsapi.h>

#include <memoryapi.h>

using uint = unsigned int;

const char* CALCULATOR = "calc.exe";

void enableDebugPrivs() {

HANDLE h_token;

LUID luid;

TOKEN_PRIVILEGES tkp;

OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &h_token);

LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &luid);

tkp.PrivilegeCount = 1;

tkp.Privileges[0].Luid = luid;

tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

AdjustTokenPrivileges(h_token, false, &tkp, sizeof(tkp), NULL, NULL);

CloseHandle(h_token);

}

HANDLE openProcess(const char* name) {

PROCESSENTRY32 entry;

entry.dwSize = sizeof(PROCESSENTRY32);

HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);

if (Process32First(snapshot, &entry) == TRUE) {

while (Process32Next(snapshot, &entry) == TRUE) {

if (stricmp(entry.szExeFile, name) == 0) {

return OpenProcess(PROCESS_ALL_ACCESS, FALSE, entry.th32ProcessID);

}

}

}

CloseHandle(snapshot);

return NULL;

}

LPVOID allocateMemory(HANDLE process_handle, uint size) {

return VirtualAllocEx(process_handle, NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);

}

int inject_foo(void) {

volatile int x = 30;

volatile int y = 50 + x;

return y; // 80

}

int main(void) {

enableDebugPrivs();

HANDLE process_handle = openProcess(CALCULATOR);

const uint stack_size = (uint)main - (uint)inject_foo;

if(process_handle != NULL) {

std::cout << "Handle was found, let's do it quick" << std::endl;

LPVOID allocated_segm = allocateMemory(process_handle, stack_size);

std::cout << "Check for nullptr: " << (int)allocated_segm << std::endl;

std::cout << "Segment was allocated with size " << stack_size << std::endl;

// std::memcpy(allocated_segm, (DWORD*)inject_foo, stack_size); DON'T DO THAT

BOOL wpm = WriteProcessMemory(process_handle, allocated_segm, &inject_foo, stack_size, NULL);

if(wpm == 0) {

std::cout << "Function not copied" << GetLastError() << std::endl;

return -1;

}

std::cout << "Copied segment" << std::endl;

DWORD thread_id;

HANDLE nat_thread = CreateRemoteThread(process_handle, NULL, stack_size, (LPTHREAD_START_ROUTINE)allocated_segm, NULL, CREATE_SUSPENDED, &thread_id);

std::cout << "Resume status: " << ResumeThread(nat_thread) << std::endl;

std::cout << "Function had been injected" << std::endl;

DWORD code;

WaitForSingleObject(nat_thread, INFINITE); // Waiting for thread

GetExitCodeThread(nat_thread, &code); // Get exit code

std::cout << "Exit code: " << code << std::endl;

CloseHandle(nat_thread);

VirtualFreeEx(process_handle, allocated_segm, stack_size, MEM_RELEASE | MEM_DECOMMIT);

CloseHandle(process_handle);

} else {

std::cout << "Process not found" << std::endl;

}

return 0;

}

О функции enableDebugPrivs можно сказать кратко: функция включает режим отладчика (дебаггера) для текущего процесса. Тем не менее, функция играет большую роль: она позволяет взаимодействовать с другим процессом с помощью NTAPI (Native API), которые дают намного больше превосходства при работе с ОС (http://undocumented.ntinternals.net/). Автор статьи рекомендует заглянуть в раздел Security.

Функция openProcess принимает в себя имя программы, которую мы хотим открыть для модификации. На некоторых системах (в частности, с чем столкнулся автор), например, на Win10, требуется включение режима отладчика. Без этого режима процессы не открывались с флагом PROCESS_ALL_ACCESS, который и предоставляет необходимую работу над процессом. Всё, что делает эта функция, так это прогоняет все процессы (Process32Next) и заполняет их в entry, после чего идет сравнение имени исполняемого файла (szExeFile) с тем именем, которое мы передали. Если имя совпадает, функция возвращает HANDLE (далее – хендлер) с флагом PROCESS_ALL_ACCESS, открывая процесс с ID совпавшего по имени процесса.

Функция allocateMemory принимает в себя такой хендлер, который открыт с флагом PROCESS_ALL_ACCESS. В этом хендлере идет аллокация участка памяти размером size. Изучив документацию с https://docs.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualallocex, вы можете найти другие флаги для аллокации участка памяти. Для упрощения восприятия информации автор статьи опустил функцию VirtualProtectEx: это нужно, когда происходит подмена (перезапись) какого-то участка памяти. Система безопасности Windows требует таких флагов, которые были выставлены во время инициализации и если флаги в последующем при обращении к этому участку памяти не совпадают с флагами при инициализации этого участка, то Windows может завершить процесс. Автор статьи подмечает, что это происходит не всегда, но имеет место быть и это нужно учитывать при чтении/изменении участков памяти, т.к. читаемый регион может быть закрыт для чтения и потребует изменения флагов через VirtualProtectEx.
В данной функции используется совокупность MEM_COMMIT и MEM_RESERVED. Первый флаг гарантирует то, что аллоцированный участок памяти будет проинициализирован нулями. Второй флаг говорит о том, что участок памяти зарезервирован и не может быть использован для перезаписи извне (например, при объявлении динамического массива через оператор new), т.е. эта область памяти используется и не может быть изменена без обращения к операционной системе и не зная адрес этой области памяти перезаписать его не получится.
Флаг PAGE_EXECUTE_READWRITE говорит о том, что участок памяти подходит как для исполнения (EXECUTE), так и для чтения и записи (READ, WRITE). По этой ссылке можно посмотреть другие флаги: https://docs.microsoft.com/en-us/windows/win32/memory/memory-protection-constants. Также стоит отметить, что не стоит пользоваться std::memcpy – вы не сможете пользоваться этой функцией, т.к. она всегда будет приводить к прекращению работы атакующего приложения. Автор статьи не стал вдаваться в подробности этой проблемы, т.к. изначально использовался WriteProcessMemory, а идея использовать std::memcpy пришла по ходу написания статьи.

Функция inject_foo – функция, которая “вставляется” в чужой процесс. В коде выше локальные переменные x и y имеют ключевое слово volatile. Оно используется здесь лишь потому, что так компилятор не будет производить процесс оптимизации, заменяя переменные на итоговый результат в return. Ключевое слово volatile позволяет растянуть стек функции, место инжектируемой функции в пространстве атакующего процесса.

В точке входа main лишь происходит использование всех этих функций в комплексе: получается хендлер process_handle (openProcess) и вычисляется размер стека инжектируемой функции. Важно отметить и запомнить, что такой подход крайне нестабилен и очень плох: единственный ВЕРНЫЙ способ найти размер инжектируемой функции – смотреть ассемблерные листинги. В зависимости от выбора оптимизации компилятора (в т.ч. от типа компилятора), рядом стоящие функции могут оказаться очень далеко друг от друга, что скажется на размере стека инжектируемой функции, а как следствие – запуск функции закончится провалом.
Изучая документацию WinAPI, можно узнать, что если процесс был открыт успешно, то хенлдер (process_handle) не равен NULL (0). Соответственно, если хендлер не равен нулю, то можно аллоцировать память и копировать туда содержимое функции, после чего – создать поток с флагом CREATE_SUSPENDED (это говорит о том, что поток должен быть создан, но не запущен (заморожен)). Поток продолжает своё исполнение (ResumeThread), после чего нужно подождать, пока поток завершит свое исполнение (WaitForSingleObject). В качестве доказательства работоспособности программы используется функция GetExitCodeThread, возвращающая то, что вернулось из return инжектируемой функции.

Этот код – самый простой пример инъекции. Этот пример можно усовершенствовать, если немного глубже изучить процесс внедрения в чужое пространство.

Инъекции: продвинутая теория

В самом начале я сказал о том, что при работе с типами, которые не являются встроенными, будут проблемы. Теперь я объясню, что делать можно, а что нельзя.

У вас не получится пользоваться типами вроде std::string, std::vector и другими, так как инжектируемая функция будет ссылаться на адреса АТАКУЮЩЕГО приложения, а по итогу функция будет запускаться в контексте АТАКУЕМОГО приложения, что приведет к вылету АТАКУЕМОГО приложения (так автор видит проблему).

Однако вы можете аллоцировать в чужом процессе вашу кастомную структуру (или класс) и передать инжектируемой функции адрес на эту структуру. Таким образом адрес инжектируемой функции и адрес структуры будут находиться в пространстве АТАКУЕМОГО приложения, что не приведёт к каким-либо проблемам.

! Так, если вы объявили структуру, просто передайте указатель на неё и работайте с ней как с набором байт. Не исключено, что вам придётся работать лишь с набором байт БЕЗ интерфейса !

! Хотелось бы также отметить, что процесс получения прав отладчика может отличаться для различных версий Windows !

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

int thread(void) {
int(*MessageBoxW)(HWND, LPCWSTR, LPCWSTR, UINT);

MessageBoxW = (LPVOID)0x00124FF; // example of MessageBoxW address in attacked process

MessageBoxW(NULL, L"Title", L"Message", MB_OK);

return 0;
}

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

Также возможен анализ куч процесса, но об этом нужно писать отдельную статью, а тема данной – инъекции.

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

Аутлог закончен

5646 views·14 shares