Windows系统的dll注入
一、什么是dll注入
在Windows操作系统中,运行的每一个进程都生活在自己的程序空间中(保护模式),每一个进程都认为自己拥有整个机器的控制权,每个进程都认为自己拥有计算机的整个内存空间,这些假象都是操作系统创造的(操作系统控制CPU使得CPU启用保护模式)。理论上而言,运行在操作系统上的每一个进程之间都是互不干扰的,即每个进程都会拥有独立的地址空间。比如说进程B修改了地址为0x4000000的数据,那么进程C的地址为0x4000000处的数据并未随着B的修改而发生改变,并且进程C可能并不拥有地址为0x4000000的内存(操作系统可能没有为进程C映射这块内存)。因此,如果某进程有一个缺陷覆盖了随机地址处的内存(这可能导致程序运行出现问题),那么这个缺陷并不会影响到其他进程所使用的内存。
也正是由于进程的地址空间是独立的(保护模式),因此我们很难编写能够与其它进程通信或控制其它进程的应用程序。
所谓的dll注入即是让程序A强行加载程序B给定的a.dll,并执行程序B给定的a.dll里面的代码。注意,程序B所给定的a.dll原先并不会被程序A主动加载,但是当程序B通过某种手段让程序A“加载”a.dll后,程序A将会执行a.dll里的代码,此时,a.dll就进入了程序A的地址空间,而a.dll模块的程序逻辑由程序B的开发者设计,因此程序B的开发者可以对程序A为所欲为。
二、什么时候需要dll注入
应用程序一般会在以下情况使用dll注入技术来完成某些功能:
1.为目标进程添加新的“实用”功能;
2.需要一些手段来辅助调试被注入dll的进程;
3.为目标进程安装钩子程序(API Hook);
三、dll注入的方法
一般情况下有如下dll注入方法:
1.修改注册表来注入dll;
2.使用CreateRemoteThread函数对运行中的进程注入dll;
3.使用SetWindowsHookEx函数对应用程序挂钩(HOOK)迫使程序加载dll;
4.替换应用程序一定会使用的dll;
5.把dll作为调试器来注入;
6.用CreateProcess对子进程注入dll
7.修改被注入进程的exe的导入地址表。
接下来将详细介绍如何使用这几种方式完成dll注入。
四、注入方法详解
(一)、修改注册表
如果使用过Windows,那么对注册表应该不会陌生。整个系统的配置都保存在注册表中,我们可以通过修改其中的设置来改变系统的行为。
首先打开注册表并定位到HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows项,如下图所示,他显示了该注册表项中的条目。
AppInit_DLLs键的值可以是一个dll的文件名或一组dll的文件名(通过逗号或空格来分隔),由于空格是用来分隔文件名的,因此dll文件名不能含有空格。第一个dll的文件名可以包含路径,但其他的dll包含的路径将被忽略。
LoadAppInit_DLLs键的值表示AppInit_DLLs键是否有效,为了让AppInit_DLLs键的值有效,需要将LoadAppInit_DLLs的值设置为1。
这两个键值设定后,当应用程序启动并加载User32.dll时,会获得上述注册表键的值,并调用LoadLibrary来调用这些字符串中指定的每一个dll。这时每个被载入的dll可以完成相应的初始化工作。但是需要注意的是,由于被注入的dll是在进程生命期的早期被载入的,因此这些dll在调用函数时应慎重。调用Kernel32.dll中的函数应该没有问题,因为Kernel32.dll是在User32.dll载入前已被加载。但是调用其他的dll中的函数时应当注意,因为进程可能还未载入相应的dll,严重时可能会导致蓝屏。
这种方法很简单,只需要在注册表中修改两个键的值即可,但是有如下缺点:
1.只有调用了User32.dll的进程才会发生这种dll注入。也就是说某些CUI程序(控制台应用程序)可能无法完成dll注入,比如将dll注入到编译器或链接器中是不可行的。
2.该方法会使得所有的调用了User32.dll的程序都被注入指定的dll,如果你仅仅想对某些程序注入dll,这样很多进程将成为无辜的被注入着,并且其他程序你可能并不了解,盲目的注入会使得其他程序发生崩溃的可能性增大。
3.这种注入会使得在应用程序的整个生命周期内被注入的dll都不会被卸载。注入dll的原则是值在需要的时间才注入我们的dll,并在不需要时及时卸载。
(二)、使用CreateRemoteThread函数对运行中的进程注入dll
这种方法具有最高的灵活性,同时它要求掌握的知识也很多。从根本上说,dll注入技术要求目标进程中的一个线程调用LoadLibrary函数来载入我们想要注入的dll,由于我们不能轻易的控制别人进程中的线程,因此这种方法要求我们在目标进程中创建一个线程并在线程中执行LoadLibrary函数加载我们要注入的dll。幸运的是Windows为我们提供了CreateRemoteThread函数,它使得在另一个进程中创建一个线程变得非常容易。CreateRemoteThread函数的原型如下:
HANDLE WINAPI CreateRemoteThread( _In_ HANDLE hProcess, _In_ LPSECURITY_ATTRIBUTES lpThreadAttributes, _In_ SIZE_T dwStackSize, _In_ LPTHREAD_START_ROUTINE lpStartAddress, _In_ LPVOID lpParameter, _In_ DWORD dwCreationFlags, _Out_ LPDWORD lpThreadId );
该函数与CreateThread仅仅只多出第一个参数hProcess,hProcess表示创建的新线程属于哪一个进程。
参数lpStartAddress表示线程函数的起始地址,注意这个地址在目标进程的地址空间中。
现在问题来了,我们如何调用让创建的线程执行LoadLibrary函数来加载我们要注入的dll呢?答案很简单:只需要创建的线程的线程函数地址是LoadLibrary函数的起始地址即可。我们都知道,每一个线程创建时应该指定一个参数只有4个字节,返回值也只是4个字节的函数即可(从汇编的角度看确实如此,只要保证调用前后栈平衡即可),而LoadLibrary函数就满足这些条件。LoadLibrary函数的原型如下:
HMODULE WINAPI LoadLibrary( _In_ LPCTSTR lpFileName );
可以发现LoadLibrary函数完全满足上述条件,LoadLibrary的参数是dll路径的起始地址,这个参数也就是CreateRemoteThread函数的lpParameter参数。但是参数指向的地址应该是目标进程的地址,并且该地址处应保存被加载dll的路径字符串。但是一开始我们并不知道目标进程是否存在这样一个地址并且这个地址恰好保存了我们的dll的完整路径。解决这一问题的最保险的办法是使用VirtualAllocEx函数在目标进程中开辟一块内存存放我们的dll的路径。VirtualAllocEx函数的原型如下:
LPVOID WINAPI VirtualAllocEx( _In_ HANDLE hProcess, _In_opt_ LPVOID lpAddress, _In_ SIZE_T dwSize, _In_ DWORD flAllocationType, _In_ DWORD flProtect );
VirtualAllocEx函数允许我们在目标进程中开辟一块指定大小(以字节为单位)的内存,并返回这块内存的起始地址。之后就可以用WriteProcessMemory函数将dll文件路径的数据复制到目标进程中。WriteProcessMemory函数的原型如下:
BOOL WINAPI WriteProcessMemory( _In_ HANDLE hProcess, _In_ LPVOID lpBaseAddress, _In_ LPCVOID lpBuffer, _In_ SIZE_T nSize, _Out_ SIZE_T *lpNumberOfBytesWritten );
在开始注入前,还需要确认一件事,就是目标进程使用的字符编码方式。因为我们所调用的LoadLibrary函数在底层实际调用有两种可能:
如果目标程序使用的是ANSI编码方式,LoadLibrary实际调用的是LoadLibraryA,其参数字符串应当是ANSI编码;
如果目标程序使用的是Unicode编码方式,LoadLibrary实际调用的是LoadLibraryW,其参数字符串应当是Unicode编码。
这使得注入过程变得很麻烦,为了减少复杂性,不妨直接使用LoadLibraryA或LoadLibraryW而不是用LoadLibrary函数来避免这一麻烦。另外,即使使用的是LoadLibraryA,LoadLibraryA也会将传入的ANSI编码的字符串参数转换成Unicode编码后再调用LoadLibraryW。综上,不妨一致使用LoadLibraryW函数,并且字符串用Unicode编码即可。
最后,我们可能会为获得目标进程中LoadLibraryW函数的起始地址而头疼,但其实这个问题也很简单,因为目标进程中函数LoadLibraryW的起始地址和我们的进程中的LoadLibraryW函数的起始地址是一样的。因此我们只需要用GetProcAddress即可获得LoadLibraryW函数的起始地址。
经过以上漫长的分析,我们对CreateRemoteThread注入方法的原理有了较为清晰的理解,接下来我们就需要总结一下我们必须采取的步骤:
(1).用VirtualAllocEx函数在目标进程的地址空间中分配一块足够大的内存用于保存被注入的dll的路径。
(2).用WriteProcessMemory函数把本进程中保存dll路径的内存中的数据拷贝到第(1)步得到的目标进程的内存中。
(3).用GetProcAddress函数获得LoadLibraryW函数的起始地址。LoadLibraryW函数位于Kernel32.dll中。
(4).用CreateRemoteThread函数让目标进程执行LoadLibraryW来加载被注入的dll。函数结束将返回载入dll后的模块句柄。
(5).用VirtualFreeEx释放第(1)步开辟的内存。
在需要卸载dll时我们可以在上述第(5)步的基础上继续执行以下步骤:
(6).用GetProcAddress函数获得FreeLibrary函数的起始地址。FreeLibrary函数位于Kernel32.dll中。
(7).用CreateRemoteThread函数让目标进程执行FreeLibrary来卸载被注入的dll。(其参数是第(4)步返回的模块句柄)。
如果不在上述步骤基础上执行操作,卸载dll时你需要这么做:
(1).获得被注入的dll在目标进程的模块句柄。
(2).重复上述步骤的第(6)、(7)两步。
接下来给出编写的参考代码,该程序以控制台应用程序方式运行,并在Windows 10上测试通过。
#include "windows.h" #include "stdio.h" #include "tlhelp32.h" #include "io.h" #include "tchar.h" //判断某模块(dll)是否在相应的进程中 //dwPID 进程的PID //szDllPath 查询的dll的完整路径 BOOL CheckDllInProcess(DWORD dwPID, LPCTSTR szDllPath) { BOOL bMore = FALSE; HANDLE hSnapshot = INVALID_HANDLE_VALUE; MODULEENTRY32 me = { sizeof(me), }; if (INVALID_HANDLE_VALUE == (hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID)))//获得进程的快照 { _tprintf(L"CheckDllInProcess() : CreateToolhelp32Snapshot(%d) failed!!! [%d]\n", dwPID, GetLastError()); return FALSE; } bMore = Module32First(hSnapshot, &me);//遍历进程内得的所有模块 for (; bMore; bMore = Module32Next(hSnapshot, &me)) { if (!_tcsicmp(me.szModule, szDllPath) || !_tcsicmp(me.szExePath, szDllPath))//模块名或含路径的名相符 { CloseHandle(hSnapshot); return TRUE; } } CloseHandle(hSnapshot); return FALSE; } //向指定的进程注入相应的模块 //dwPID 目标进程的PID //szDllPath 被注入的dll的完整路径 BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath) { HANDLE hProcess = NULL;//保存目标进程的句柄 LPVOID pRemoteBuf = NULL;//目标进程开辟的内存的起始地址 DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);//开辟的内存的大小 LPTHREAD_START_ROUTINE pThreadProc = NULL;//loadLibreayW函数的起始地址 HMODULE hMod = NULL;//kernel32.dll模块的句柄 BOOL bRet = FALSE; if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))//打开目标进程,获得句柄 { _tprintf(L"InjectDll() : OpenProcess(%d) failed!!! [%d]\n", dwPID, GetLastError()); goto INJECTDLL_EXIT; } pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize, MEM_COMMIT, PAGE_READWRITE);//在目标进程空间开辟一块内存 if (pRemoteBuf == NULL) { _tprintf(L"InjectDll() : VirtualAllocEx() failed!!! [%d]\n", GetLastError()); goto INJECTDLL_EXIT; } if (!WriteProcessMemory(hProcess, pRemoteBuf, (LPVOID)szDllPath, dwBufSize, NULL))//向开辟的内存复制dll的路径 { _tprintf(L"InjectDll() : WriteProcessMemory() failed!!! [%d]\n", GetLastError()); goto INJECTDLL_EXIT; } hMod = GetModuleHandle(L"kernel32.dll");//获得本进程kernel32.dll的模块句柄 if (hMod == NULL) { _tprintf(L"InjectDll() : GetModuleHandle(\"kernel32.dll\") failed!!! [%d]\n", GetLastError()); goto INJECTDLL_EXIT; } pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(hMod, "LoadLibraryW");//获得LoadLibraryW函数的起始地址 if (pThreadProc == NULL) { _tprintf(L"InjectDll() : GetProcAddress(\"LoadLibraryW\") failed!!! [%d]\n", GetLastError()); goto INJECTDLL_EXIT; } if (!CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pRemoteBuf, 0, NULL))//执行远程线程 { _tprintf(L"InjectDll() : MyCreateRemoteThread() failed!!!\n"); goto INJECTDLL_EXIT; } INJECTDLL_EXIT: bRet = CheckDllInProcess(dwPID, szDllPath);//确认结果 if (pRemoteBuf) VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE); if (hProcess) CloseHandle(hProcess); return bRet; } //让指定的进程卸载相应的模块 //dwPID 目标进程的PID //szDllPath 被注入的dll的完整路径,注意:路径不要用“/”来代替“\\” BOOL EjectDll(DWORD dwPID, LPCTSTR szDllPath) { BOOL bMore = FALSE, bFound = FALSE, bRet = FALSE; HANDLE hSnapshot = INVALID_HANDLE_VALUE; HANDLE hProcess = NULL; MODULEENTRY32 me = { sizeof(me), }; LPTHREAD_START_ROUTINE pThreadProc = NULL; HMODULE hMod = NULL; TCHAR szProcName[MAX_PATH] = { 0, }; if (INVALID_HANDLE_VALUE == (hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID))) { _tprintf(L"EjectDll() : CreateToolhelp32Snapshot(%d) failed!!! [%d]\n", dwPID, GetLastError()); goto EJECTDLL_EXIT; } bMore = Module32First(hSnapshot, &me); for (; bMore; bMore = Module32Next(hSnapshot, &me))//查找模块句柄 { if (!_tcsicmp(me.szModule, szDllPath) || !_tcsicmp(me.szExePath, szDllPath)) { bFound = TRUE; break; } } if (!bFound) { _tprintf(L"EjectDll() : There is not %s module in process(%d) memory!!!\n", szDllPath, dwPID); goto EJECTDLL_EXIT; } if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID))) { _tprintf(L"EjectDll() : OpenProcess(%d) failed!!! [%d]\n", dwPID, GetLastError()); goto EJECTDLL_EXIT; } hMod = GetModuleHandle(L"kernel32.dll"); if (hMod == NULL) { _tprintf(L"EjectDll() : GetModuleHandle(\"kernel32.dll\") failed!!! [%d]\n", GetLastError()); goto EJECTDLL_EXIT; } pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(hMod, "FreeLibrary"); if (pThreadProc == NULL) { _tprintf(L"EjectDll() : GetProcAddress(\"FreeLibrary\") failed!!! [%d]\n", GetLastError()); goto EJECTDLL_EXIT; } if (!CreateRemoteThread(hProcess, NULL, 0, pThreadProc, me.modBaseAddr, 0, NULL)) { _tprintf(L"EjectDll() : MyCreateRemoteThread() failed!!!\n"); goto EJECTDLL_EXIT; } bRet = TRUE; EJECTDLL_EXIT: if (hProcess) CloseHandle(hProcess); if (hSnapshot != INVALID_HANDLE_VALUE) CloseHandle(hSnapshot); return bRet; } int main() { //InjectDll(6836, L"C:\\a.dll"); EjectDll(6836, L"C:\\a.dll"); return 0; }
(三)、使用SetWindowsHookEx函数对应用程序挂钩(HOOK)迫使程序加载dll
消息钩子:Windows操作系统为用户提供了GUI(Graphic User Interface,图形用户界面),它以事件驱动方式工作。在操作系统中借助键盘、鼠标、选择菜单、按钮、移动鼠标、改变窗口大小与位置等都是事件。发生这样的事件时,操作系统会把事先定义好的消息发送给相应的应用程序,应用程序分析收到的信息后会执行相应的动作。也就是说,在敲击键盘时,消息会从操作系统移动到应用程序。所谓的消息钩子就是在此期间偷看这些信息。以键盘输入事件为例,消息的流向如下:
1.发生键盘输入时,WM_KEYDOWN消息被添加到操作系统的消息队列中;
2.操作系统判断这个消息产生于哪个应用程序,并将这个消息从消息队列中取出,添加到相应的应用程序的消息队列中;
3.应用程序从自己的消息队列中取出WM_KEYDOWN消息并调用相应的处理程序。
当我们的钩子程序启用后,操作系统在将消息发送给用用程序前会先发送给每一个注册了相应钩子类型的钩子函数。钩子函数可以对这一消息做出想要的处理(修改、拦截等等)。多个消息钩子将按照安装钩子的先后顺序被调用,这些消息钩子在一起组成了"钩链"。消息在钩链之间传递时任一钩子函数拦截了消息,接下来的钩子函数(包括应用程序)将都不再收到该消息。
像这样的消息钩子功能是Windows提供的最基本的功能,MS Visual Studio中提供的SPY++就是利用了这一功能来实现的,SPY++是一个十分强大的消息钩取程序,它能够查看操作系统中来往的所有消息。
消息钩子是使用SetWindowsHookEx来实现的。函数的原型如下:
HHOOK WINAPI SetWindowsHookEx( _In_ int idHook, _In_ HOOKPROC lpfn, _In_ HINSTANCE hMod, _In_ DWORD dwThreadId );
idHook参数是消息钩子的类型,可以选择的类型在MSDN中可以查看到相应的宏定义。比如我们想对所有的键盘消息做挂钩,其取值将是WH_KEYBOARD,WH_KEYBOARD这个宏的值是2。
lpfn参数是钩子函数的起始地址,注意:不同的消息钩子类型的钩子函数原型是不一样的,因为不同类型的消息需要的参数是不同的,具体的钩子函数原型需要查看MSDN来获得。注意:钩子函数可以在结束前任意位置调用CallNextHookEx函数来执行钩链的其他钩子函数。当然,如果不调用这个函数,钩链上的后续钩子函数将不会被执行。
hMod参数是钩子函数所在的模块的模块句柄。
dwThreadId参数用来指示要对哪一个进程/线程安装消息钩子。如果这个参数为0,安装的消息钩子称为“全局钩子”,此时将对所有的进程(当前的进程以及以后要运行的所有进程)下这个消息钩子。注意:有的类型的钩子只能是全局钩子。
注意:钩子函数应当放在一个dll中,并且在你的进程中LoadLibrary这个dll。然后再调用SetWindowsHookEx函数对相应类型的消息安装钩子。
当SetWindowsHookEx函数调用成功后,当某个进程生成这一类型的消息时,操作系统会判断这个进程是否被安装了钩子,如果安装了钩子,操作系统会将相关的dll文件强行注入到这个进程中并将该dll的锁计数器递增1。然后再调用安装的钩子函数。整个注入过程非常方便,用户几乎不需要做什么。
当用户不需要再进行消息钩取时只需调用UnhookWindowsHookEx即可解除安装的消息钩子,函数的原型如下:
BOOL WINAPI UnhookWindowsHookEx( _In_ HHOOK hhk );
hhk参数是之前调用SetWindowsHookEx函数返回的HHOOK变量。这个函数调用成功后会使被注入过dll的锁计数器递减1,当锁计数器减到0时系统会卸载被注入的dll。
这种类型的dll注入的优点是注入简单,缺点是只能对windows消息进行Hook并注入dll,而且注入dll可能不是立即被注入,因为这需要相应类型的事件发生。其次是它不能进行其他API的Hook,如果想对其它的函数进行Hook,你需要再在被注入的dll中添加用于API Hook的代码。
接下来将给出这一dll注入方案的示例程序的代码,代码包含两部分,一部分是dll的源文件,另一部分是控制台程序的源代码。该程序的功能是屏蔽所有notepad.exe(Windows附带的记事本程序)的按键消息,该程序在Windows xp下测试通过。
#include <stdio.h> #include <tchar.h> #include <windows.h> #pragma warning(disable : 4996) HHOOK ghHook = NULL; HINSTANCE ghInstance = NULL; LRESULT CALLBACK KeyboardProc( _In_ int code, _In_ WPARAM wParam, _In_ LPARAM lParam ) { TCHAR szPath[MAX_PATH] = {0,}; TCHAR sProcessName[MAX_PATH] = {0,}; if (code == 0 && !(lParam & 0x80000000))//如果是释放按键 { GetModuleFileName(NULL, szPath, MAX_PATH); _wsplitpath(szPath, NULL, NULL, sProcessName, NULL); if (0==_wcsicmp(sProcessName, L"notepad"))//如果进程名是notepad { return 1;//删除消息,不再往下传递 } } return CallNextHookEx(ghHook, code, wParam, lParam);//继续传递消息 } BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: ghInstance = hModule;//获得本实例的模块句柄 break; case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: case DLL_PROCESS_DETACH: break; } return TRUE; } extern "C" { __declspec(dllexport) void HookStart() { ghHook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, ghInstance, 0); } __declspec(dllexport) void HookStop() { if (ghHook) { UnhookWindowsHookEx(ghHook); ghHook = NULL; } } }
#include <stdio.h> #include <conio.h> #include <tchar.h> #include <windows.h> typedef void(*PFNHOOKSTART)(); typedef void(*PFNHOOKSTOP)(); int main() { HMODULE Hmod = LoadLibraryA("hookdll.dll"); PFNHOOKSTART pHookStart = (PFNHOOKSTART)GetProcAddress(Hmod, "HookStart"); PFNHOOKSTOP pHookStop = (PFNHOOKSTOP)GetProcAddress(Hmod, "HookStop"); pHookStart(); printf("print 'q' to quite!\n"); while (_getch() != 'q'); pHookStop(); FreeLibrary(Hmod); return 0; }
(四)、替换应用程序一定会使用的dll
这种方法通常被编写恶意代码的人员用来编写木马,因此又被称为使用木马dll来注入dll。通常我们应当首先确认目标进程一定会载入的dll,然后替换掉它。举个例子:比如我们知道目标进程一定会载入Xyz.dll,那么我们可以创建自己的dll并与它起同样的名字。当然,我们必须将原先被替换掉的Xyz.dll改成别的名字,比如改成Xyz_1.dll。
注意:在我们编写的Xyz.dll(将被注入的dll)内部,我们要导出原来的Xyz.dll所导出的所有符号。这一点很容易实现,可以用dll的函数转发器实现(转发到Xyz_1.dll的相同函数),这样一来我们只需要对需要HOOK(挂钩)的函数编写挂钩代码即可,这一过程我们仅仅是多了一些重复工作。看起来这个方法是完美的,并且很多木马程序经常这么干,但是它存在一个很严重的问题:如果被替换的dll后来由于程序升级导致替换的dll添加了新的导出函数,而被注入的dll并未及时添加这些新增导出函数的转发器(或者Hook程序),这将导致使用了新的导出函数的程序不能正常运行。另外,请不要随意的替换系统的dll,因为在dll注入一般应当只注入到目标进程即可,而注入到别的进程之后将带来很大的安全隐患。
(五)、把dll作为调试器来注入
使用过OD(OllyDbg)的人员可能会为OD的强大功能感到惊叹。因为OD可以调试一个程序并任意的修改被调试的程序。OD的工作原理是向目标进程使用了调试功能。调试器可以在被调试进程中执行很多特殊操作,操作系统载入一个被调试程序的时候,会在被调试的主线程尚未开始执行任何代码前,自动通知调试器(用来调试被调试进程的进程),这时调试器可以将一些代码注入到被调试进程的地址空间中,保存被调试进程的CONTEXT结构,修改EIP指向我们注入的代码的起始位置执行这些代码。最后再让被调试的进程恢复原来的CONTEXT,继续执行。整个过程对被调试的进程而言好像没发生任何事情。
这种注入方式需要对调试功能有所研究,并且能够对进程的CONTEXT进行操作,最后还需要对不同的CPU平台进行量身操作。此外,我们可能还需要手工编写一些汇编指令来让被调试的程序执行。这对编写人员的能力要求较高。最后,这种方法在调试器终止后,Windows会自动终止被调试的程序。不过调试器可以通过调用DebugSetProcessKillOnExit函数并传入FALSE,来改变Windows的默认行为。然后调试器就可以调用DebugActiveProcessStop函数来终止调试了。
为什么要在主线程尚未开始执行任何代码前执行代码注入呢?因为这个时候注入最安全,其实你可以在任何时候对被调试的程序下断点并进行以上注入操作,但是为了保证被调试程序的稳定运行你可能需要做更多的工作。
(六)、用CreateProcess对子进程注入dll
这个方法与把dll作为调试器来注入方法有许多相似之处,同样也具有较大的难度。这里要求目标进程是注入者进程的子进程。当使用CreateProcess函数来创建一个子进程时,可以选择创建后立即挂起该进程。这样,创建的子进程并不会开始执行且EIP指向ntdll.dll的RtlUserThreadStart函数的开始位置(在win10上EIP=0X76F9BA60),此时的子进程处于挂起状态。因此,我们可以有目的地修改EIP的值让其从另一个位置继续执行,但随意的修改EIP的值往往使创建的子程序崩溃。为了让创建的子进程载入dll必须调用LoadLibrary函数。在使用CreatRemoteProcess方法中也介绍了一点:必须在目标进程(这里指子进程)中写入载入的dll的完整路径。因此我们在修改EIP指向我们的代码之前需要将一部分代码注入到目标进程中。其中被注入的代码至少应包括如下操作:将dll路径首地址压栈;调用LoadLibrary函数;跳转回原先EIP位置,让程序继续执行,好像什么都没发生过。
但是,为了程序的稳定运行,这样做还不够。注入的代码应该在执行后能恢复执行前的所有状态。因此为了注入dll需要向目标进程注入较为安全的代码应该包含如下操作:
1.保存所有寄存器的值;
2.将dll路径首地址压栈;
3.调用LoadLibrary函数;
4.恢复所有寄存器的值;
5.跳转到原先EIP位置,让程序继续执行,好像什么都没发生。
该方法有如下优点:在程序未开始执行前执行了dll注入,一般比较难以被发现。几乎可以对所有的程序进行注入。
该方法同样具有缺点:首先需要严谨的设计注入的代码,并根据不同的cpu平台进行设计。其次就是目标进程要是注入着创建的子进程。
接下来将给出一段示例代码,该程序以控制台方式运行。并在Windows 10和Windows xp上测试通过。(这段代码参考自看雪论坛的IamHuskar,这里表示感谢!)
#include <windows.h> #include <stdio.h> #pragma warning(disable : 4996) //在子进程创建挂起时注入dll //hProcess 被创建时挂起的进程句柄 //hThread 进程中被挂起的线程句柄 //szDllPath 被注入的dll的完整路径 BOOL StartHook(HANDLE hProcess, HANDLE hThread, TCHAR *szDllPath) { BYTE ShellCode[30 + MAX_PATH * sizeof(TCHAR)] = { 0x60, //pushad 0x9c, //pushfd 0x68,0xaa,0xbb,0xcc,0xdd, //push xxxxxxxx(xxxxxxxx的偏移为3) 0xff,0x15,0xdd,0xcc,0xbb,0xaa, //call [addr]([addr]的偏移为9) 0x9d, //popfd 0x61, //popad 0xff,0x25,0xaa,0xbb,0xcc,0xdd, //jmp [eip]([eip]的偏移为17) 0xaa,0xaa,0xaa,0xaa, //保存loadlibraryW函数的地址(偏移为21) 0xaa,0xaa,0xaa,0xaa, //保存创建进程时被挂起的线程EIP(偏移为25) 0, //保存dll路径字符串(偏移为29) }; CONTEXT ctx; ctx.ContextFlags = CONTEXT_ALL; if (!GetThreadContext(hThread, &ctx)) { printf("GetThreadContext() ErrorCode:[0x%08x]\n", GetLastError()); return FALSE; } //在目标进程内存空间调拨一块可执行的内存 LPVOID LpAddr = VirtualAllocEx(hProcess, NULL, 30 + MAX_PATH * sizeof(TCHAR), MEM_COMMIT, PAGE_EXECUTE_READWRITE); if (LpAddr == NULL) { printf("VirtualAllocEx() ErrorCode:[0x%08x]\n", GetLastError()); return FALSE; } //获得LoadLibraryW函数的地址 DWORD LoadDllAAddr = (DWORD)GetProcAddress(GetModuleHandle(L"kernel32.dll"), "LoadLibraryW"); if (LoadDllAAddr == NULL) { printf("GetProcAddress() ErrorCode:[0x%08x]\n", GetLastError()); return FALSE; } printf("原始EIP=0x%08x\n", ctx.Eip); //写入dllpath memcpy((char*)(ShellCode + 29), szDllPath, MAX_PATH); //写入push xxxxxxxx *(DWORD*)(ShellCode + 3) = (DWORD)LpAddr + 29; //写入loadlibraryA地址 *(DWORD*)(ShellCode + 21) = LoadDllAAddr; //写入call [addr]的[addr] *(DWORD*)(ShellCode + 9) = (DWORD)LpAddr + 21; //写入原始eip *(DWORD*)(ShellCode + 25) = ctx.Eip; //写入jmp [eip]的[eip] *(DWORD*)(ShellCode + 17) = (DWORD)LpAddr + 25; //把shellcode写入目标进程 if (!WriteProcessMemory(hProcess, LpAddr, ShellCode, 30 + MAX_PATH * sizeof(TCHAR), NULL)) { printf("WriteProcessMemory() ErrorCode:[0x%08x]\n", GetLastError()); return FALSE; } //修改目标进程的EIP,执行被注入的代码 ctx.Eip = (DWORD)LpAddr; if (!SetThreadContext(hThread, &ctx)) { printf("SetThreadContext() ErrorCode:[0x%08x]\n", GetLastError()); return FALSE; } printf("修改后EIP=0x%08x\n", ctx.Eip); return TRUE; }; int main() { STARTUPINFO sti; PROCESS_INFORMATION proci; memset(&sti, 0, sizeof(STARTUPINFO)); memset(&proci, 0, sizeof(PROCESS_INFORMATION)); sti.cb = sizeof(STARTUPINFO); wchar_t ExeName[MAX_PATH] = L"C:\\aimprocess.exe";//子进程的名字及启动参数 wchar_t DllName[MAX_PATH] = L"C:\\hookdll2.dll";//被注入的dll的完整路径 if (CreateProcess(NULL, ExeName, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &sti, &proci) ==NULL) { printf("CreateProcess() ErrorCode:[0x%08x]\n", GetLastError()); getchar(); return 0; } if (!StartHook(proci.hProcess, proci.hThread, DllName)) { TerminateProcess(proci.hProcess, 0); printf("Terminated Process\n"); getchar(); return 0; } ResumeThread(proci.hThread); CloseHandle(proci.hProcess); CloseHandle(proci.hThread); return 0; }
现在对以上代码做分析,程序首先调用CreateProcess函数来创建一个挂起的进程。创建成功后,prosic结构体保存了子进程的进程句柄和主线程的线程句柄。接下来调用StartHook函数进行代码注入。
现在我们来详细地分析StartHook函数,首先它创建了一段ShellCode,ShellCode的内容将被会复制到目标进程的空间中。但是当前的ShellCode还不能正常工作。因为它的很多数据要依靠放入目标进程的地址来决定。ShellCode实际上是一段汇编代码后面附带了执行这段代码所需的变量或数据。所有的汇编代码已在注释当中进行标注。ShellCode数组的长度由汇编代码长度和变量的长度的总和。
接下来的工作是修复ShellCode中部分汇编指令引用的地址,这些地址要以目标进程写入的地址作为基础偏移量。那么我们首先应该用VirtualAllocEx在目标进程的空间中调拨一块可执行的物理内存用来保存ShellCode代码。当然,LoadLibraryW函数的地址还是要从本进程中获得。当对ShellCode数据修改完毕后,就可以将ShellCode通过WriteProcessMemory函数将ShellCode复制到目标进程中。接下来需要修改目标进程的EIP指针来使主线程从ShellCode的开始处。最后,恢复目标进程,让其继续运行即可。
通过以上分析,对上述代码的执行步骤做如下总结:
1.创建一个挂起的子进程作为目标进程;
2.准备一份预先设计好的ShellCode(应具有上面所述的基本功能);
3.用VirtualAllocEx在目标进程中调拨一块可执行的内存;
4.以分配的内存为基准修复ShellCode的汇编代码引用的地址和数据;
5.用WriteProcessMemory函数将修复完毕的ShellCode复制目标进程在第3步分配的内存中;
6.修改目标进程的主线程的EIP指向第3步分配的内存的首地址;
7.恢复目标进程的主线程。
此方法的难点是设计好ShellCode代码,这需要编写者具有较高的汇编和分析设计能力。