Содержание статьи
Code Injection – процесс инъекции своего кода в память чужого приложения с дальнейшим его выполнением. Вектор применения довольно широкий, начиная от зловредов и заканчивая различными читами и ботами для игр. В частном случае (который и будет рассмотрен в этой статье) мы можем выполнять функции чужого приложения со своими параметрами. Подобная концепция используется в игровых ботах.
Предисловие
Допустим, у нас есть некий бот, который играет за нас в какую-нибудь игру. От него требуется, помимо сбора игровых данных и принятия решений, еще и производить какие-либо действия в игре. Допустим он решил, что ему требуется атаковать противника. Его алгоритм действий может быть таким:
- Найти уникальный идентификатор противника. (параметр idEnemy)
- Принять решение, какой тип атаки использовать. (параметр typeAttack)
- Произвести атаку с учетом идентификатора и типа атаки.
Последний шаг будет выглядеть в общем случае, как вызов функции:
attackEnemy(idEnemy, typeAttack);
По умолчанию игровой клиент никаких API возможностей для вызова своих внутренних функций не предоставляет, поэтому единственным способом вызвать внутриигровую функцию как раз и является Code Injection.
Думаю, не надо напоминать, что игровые клиенты защищены от подобных вещей античитами и их обход — тема уже совершенно другой статьи =)
DLL Injection, DLL Hijacking и Code Injection
Хотя эти понятия довольно близкие, крайне важно понимать, чем они отличаются:
- DLL Hijacking - процесс подмены DLL у приложения. Сильно отличается от DLL/Code Injection, несмотря на схожесть названия. Суть заключается в том, что мы помещаем вредоносные DLL рядом с программой: если приложение уязвимо, оно подгрузит вредоносные DLL вместо оригинальных.
- Code Injection - процесс инъекции кода в память процесса, с целью его дальнейшего выполнения.
- DLL Injection - процесс подгрузки своей DLL в память процесса. На практике проще, чем Code Injection и используется значительно чаще. Но бывают частные случаи, когда приходится использовать Code Injection вместо DLL Injection. Поэтому лучше знать обе техники. =)
Необходимый минимум знаний
Основным мастхэвом является знание языка Си (поскольку это основной язык, используемый в этой статье). Также требуется наличие некоего представления об WinApi, x86 ассемблере и базовые навыки в дебаге с помощью OllyDBG (или любого другого Windows отладчика).
Пишем подопытного
Программа очень примитивна. При нажатие Enter она просто выдаёт содержимое буфера на экран. Содержимое буфера жестко прописано в памяти и нигде не меняется.
#include <stdio.h>
void PrintMessage(char *buffer);
void main()
{
char *buffer = "default message";
while (true)
{
getchar();
PrintMessage(buffer);
}
}
void PrintMessage(char *buffer)
{
printf("%s", buffer);
}
Хакер #199. Как взломали SecuROM
Отлаживаем программу
Теперь нам нужно найти интересующие нас адреса и функции. Использовать будем обычный OllyDbg (или любой другой отладчик). Наиболее интересная для нас функция — PrintMessage. Способов её найти в отладчике масса, приведу самый простой: запускаем программу в отладчике, зажимаем Step Over (F8), в этот момент отладчик начинает бодро бегать по инструкциям. Когда он остановится на каком-то CALL или JMP, проваливаемся в них (Enter) и сразу ставим breakpoint (F2). После этого перезапускаем отладку (Ctrl + F2) и снова зажимаем Step Over (F8). На этот раз отладка перешагнет через предыдущий барьер, но упрётся в следующий. Ставим новый breakpoint, перезапускаем отладку и снова зажимаем Step Over. Так мы повторяем до тех пор, пока не упрёмся в вызов функции getchar. Этот вызов находится в функции main непосредственно перед вызовом функции PrintMessage — как раз то, что нам и нужно!
В итоге вызов PrintMessage у меня получился по адресу 0x001613C0. Само собой, у тебя адреса будут другие и вполне возможны отличия в ассемблерном коде. Ассемблерный листинг моей функции PrintMessage приведён на рисунке 2.
Давай еще найдем нашу константу default message
. Так как мы уже нашли функцию main, можно легко выловить адрес константы из кода. Так же легко можно найти её и в секции: поскольку она является константой, то хранится в секции .rdata. Чтобы выбрать секцию, жмем Alt + M и ищем .rdata (в столбце Owner должен быть test). Для поиска по секции можно использовать поиск (Ctrl + N). У меня получился адрес 0x001658B8.
Листинг самого вызова нашей функции тоже пригодится.
Пишем инжектор
Для начала нам нужно найти нужный процесс. Идентификатором процесса в Windows является его PID (DWORD):
DWORD get_PID(CHAR * PrName)
{
PROCESSENTRY32 entry;
entry.dwSize = sizeof(PROCESSENTRY32);
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
if (Process32First(snapshot, &entry) == TRUE)
{
while (Process32Next(snapshot, &entry) == TRUE)
{
//printf("%s\n", entry.szExeFile);
if (strcmp(entry.szExeFile, PrName) == 0)
{
return entry.th32ProcessID;
}
}
}
CloseHandle(snapshot);
return NULL;
}
Функция вытаскивает все активные процессы и сверяет их имена с аргументом. Если нужный процесс нашелся, то она возвращает его PID. А если ты уберешь комментарий перед printf, функция выведет тебе все текущие процессы.
Теперь пишем основную логику для функции main. Наша задача — найти PID нужного процесса (в нашем случае test.exe) и сохранить его для дальнейших действий:
#include <Windows.h>
#include <stdio.h>
#include <tlhelp32.h>
DWORD get_PID(CHAR * PrName);
void main()
{
DWORD PID;
HANDLE hProcess;
DWORD BaseAddress;
char * PrName = "test.exe";
if (!(PID = get_PID(PrName)))
{
printf("Process does not exist\n");
system("pause");
return;
}
printf("Process founded!\n");
printf("Process name: %s\n", PrName);
printf("PID: %d\n\n", PID);
system("pause");
}
Пробуем.
Так, PID мы получили. Теперь нам нужен HANDLE процесса. Дописываем в main:
if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, PID)))
{
printf("OpenProcess error\n");
return;
}
printf("OpenProcess is ok\n");
printf("Now we have handle of process %s\n", PrName);
printf("Handle: %d\n\n", hProcess);
Результат получен.
Пришло время решить первую проблему: ASLR. Address space layout randomization (ASLR) — это технология, применяемая в операционных системах, при использовании которой случайным образом изменяется расположение в адресном пространстве процесса важных структур, а именно: образа исполняемого файла, подгружаемых библиотек, кучи и стека (© Wikipedia). Другими словами, процесс при каждом новом запуске будет располагаться по разным адресам. Но ведь нам нужно обладать точными адресами внутри чужого процесса для дальнейших манипуляций! Как же быть?
Решается это проблема очень просто, с помощью всего одной функции:
DWORD GetModuleBase(char *lpModuleName, DWORD dwProcessId)
{
MODULEENTRY32 lpModuleEntry = { 0 };
HANDLE hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwProcessId);
if (!hSnapShot)
return NULL;
lpModuleEntry.dwSize = sizeof(lpModuleEntry);
BOOL bModule = Module32First(hSnapShot, &lpModuleEntry);
while (bModule)
{
if (!strcmp(lpModuleEntry.szModule, lpModuleName))
{
CloseHandle(hSnapShot);
return (DWORD)lpModuleEntry.modBaseAddr;
}
bModule = Module32Next(hSnapShot, &lpModuleEntry);
}
CloseHandle(hSnapShot);
return NULL;
}
Функция принимает в себя PID и имя модуля (если мы работаем с памятью процесса, а не памятью библиотек, которые к нему подключены, имя модуля будет аналогично имени процесса). На рисунке 7 приведена карта модулей и секций нашего test.exe (взято из OllyDbg).
Дописываем в main наше определение BaseAddress:
if (!(BaseAddress = GetModuleBase(PrName, PID)))
{
printf("GetModuleBase error\n");
return;
}
printf("GetModuleBase is ok\n");
printf("BaseAddress: %x\n\n", BaseAddress);
Теперь нам нужно получить адреса буфера и функции PrintMessage в абсолютном виде. Для этого нам нужны offset (сдвиги) этих двух адресов по адресу модуля. Например, адрес буфера в OllyDBG равен 0x001658B8. Еще мы знаем, что модуль test.exe в отладчике (см. карту модулей) загрузился по адресу 0x00150000. Вычитаем 0x001658B8 - 0x00150000 и получаем 0x158B8. Это и есть offset для буфера. Аналогично высчитываем оффсет для функции PrintMessage.
Теперь мы можем суммировать эти оффсеты с BaseAddress-ом и получить абсолютные адреса в памяти:
DWORD pBuffer = BaseAddress + 0x158B8;
DWORD pfuncPrintMessage = BaseAddress + 0x113C0;
Убедимся, что это те адреса, которые нам нужны. Попробуем вытащить из памяти значение буффера:
printf("pBuffer: %0.8X\n", pBuffer);
printf("pfuncPrintMessage: %0.8X\n", pfuncPrintMessage);
char local_buffer[16];
ReadProcessMemory(hProcess, (void*)pBuffer, &local_buffer, sizeof(char)*16, 0);
printf("Buffer: %s\n\n", local_buffer);
Результат представлен на рисунке 9.
Отлично, значит, адреса мы нашли верные =) Теперь попробуем вызвать функцию со своими параметрами. Для этого нужно написать небольшой ассемблерный код, поместить его в памяти того процесса и передать на него управление. По сути это небольшой шеллкод, поэтому использовать я буду те же принципы, что используются в шеллкодинге.
Шеллкод для вызова функции с нашим параметром представлен на рисунке 10.
Байт CC используется здесь для отладки (он является брейпоинтом для отладчика) и в самом шеллкоде не фигурирует. Будь очень внимателен с указателем на стек — если ошибешься в расчетах, в момент возврата из нашего кода (RETN) программа может передать управление куда угодно. Это вызовет падение программы, в которую мы инжектимся. Также будь внимателен с соглашением о вызовах функций, поскольку разные компиляторы по-разному вызывают функции. Длина шеллкода задана жестко, поскольку обычные функции для подсчета длины строки ломаются о нулевые байты.
И всё-таки наша задача вызвать функцию со своим аргументом, поэтому продолжаем. Реализуем вызов функции:
HANDLE hProcThread;
DWORD pInjectedFunction = (DWORD)VirtualAllocEx(hProcess, NULL, 128, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
DWORD local_a = pInjectedFunction + 17;
DWORD local_b = pfuncPrintMessage;
char shellcode[128];
while (1)
{
strcpy_s(shellcode, "\xBF");
strcat_s(shellcode, "XXXX");
strcat_s(shellcode, "\xBB");
strcat_s(shellcode, "YYYY");
strcat_s(shellcode, "\x57\xFF\xD3\x83\xC4\x04\xC3");
printf("Your text: ");
fgets(local_buffer, sizeof(local_buffer), stdin);
strcat_s(shellcode, local_buffer);
memcpy(shellcode + 1, &local_a, 4);
memcpy(shellcode + 6, &local_b, 4);
WriteProcessMemory(hProcess, (LPVOID)pInjectedFunction, shellcode, 128, 0);
hProcThread = CreateRemoteThread(hProcess, NULL, NULL, (LPTHREAD_START_ROUTINE)pInjectedFunction, NULL, NULL, NULL);
}
Адреса буфера и функции заполняются изначально символами XXXX и YYYY. Предполагается, что в дальнейшем мы их заменим реальным адресами. Цикл тут для того, чтобы можно было послать несколько сообщений за один запуск инжектора. Вся логика построена в генерации шеллкода «на лету»: адреса берутся из переменных по ходу выполнения программы. Строка, которую мы передаем в качестве аргумента, хранится после шеллкода, её адрес высчитывается как адрес выделенного участка памяти + длина основной нагрузки шеллкода.
Результат работы
Теперь, когда все готово, пришло время на практике проверить работоспособность. Запускаем подопытную программу, а затем стартуем наш инжектор. Как видишь, все прекрасно работает.
Заключение
Пришло время закругляться. Надеюсь, ты понял, что Code Injection — сложный, но довольно мощный инструмент. Если правильно совмещать техники Code Injection, DLL Injection и Hooking, можно буквально творить чудеса. Овладев этими техниками, ты сможешь создавать свои читы, писать ботов и различную продвинутую малварь (последнее я не рекомендую, потому что это попадает по 273 статью УК, не забывай про закон).
Пожалуй это всё, чем я хотел поделиться в рамках данной статьи. Спасибо за внимание =)