原文地址:http://www.vckbase.com/document/viewdoc/?id=1886
简介
本文将讨论如何把代码注入不同的进程地址空间,然后在该进程的上下文中执行注入的代码。 我们在网上可以查到一些窗口/密码侦测的应用例子,网上的这些程序大多都依赖 Windows 钩子技术来实现。本文将讨论除了使用 Windows 钩子技术以外的其它技术来实现这个功能。
为了找到解决问题的方法。首先让我们简单回顾一下问题背景。
要“读取”某个控件的内容——无论这个控件是否属于当前的应用程序——通常都是发送 WM_GETTEXT 消息来实现。这个技术也同样应用到编辑控件,但是如果该编辑控件属于另外一个进程并设置了 ES_PASSWORD 式样,那么上面讲的方法就行不通了。用 WM_GETTEXT 来获取控件的内容只适用于进程“拥有”密码控件的情况。所以我们的问题变成了如何在另外一个进程的地址空间执行:
::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );
通常有三种可能性来解决这个问题。
第一部分: Windows 钩子
范例程序——参见HookSpy 和HookInjEx
Windows 钩子主要作用是监控某些线程的消息流。通常我们将钩子分为本地钩子和远程钩子以及系统级钩子,本地钩子一般监控属于本进程的线程的消息流,远程钩子是线程专用的,用于监控属于另外进程的线程消息流。系统级钩子监控运行在当前系统中的所有线程的消息流。
如果钩子作用的线程属于另外的进程,那么你的钩子过程必须驻留在某个动态链接库(DLL)中。然后系统映射包含钩子过程的DLL到钩子作用的线程的地址空间。Windows将映射整个 DLL,而不仅仅是钩子过程。这就是为什么 Windows 钩子能被用于将代码注入到别的进程地址空间的原因。
本文我不打算涉及钩子的具体细节(关于钩子的细节请参见 MSDN 库中的 SetWindowHookEx API),但我在此要给出两个很有用心得,在相关文档中你是找不到这些内容的:
BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { if( ul_reason_for_call == DLL_PROCESS_ATTACH ) { // Increase reference count via LoadLibrary char lib_name[MAX_PATH]; ::GetModuleFileName( hModule, lib_name, MAX_PATH ); ::LoadLibrary( lib_name ); // Safely remove hook ::UnhookWindowsHookEx( g_hHook ); } return TRUE; }那么会发生什么呢?首先我们通过Windows 钩子将DLL映射到远程进程。然后,在DLL被实际映射之后,我们解开钩子。通常当第一个消息到达钩子作用线程时,DLL此时也不会被映射。这里的处理技巧是调用LoadLibrary通过增加 DLLs的引用计数来防止映射不成功。
目前只使用了钩子来从处理远程进程中DLL的映射和解除映射。在此“作用于线程的”钩子对性能没有影响。
下面我们将讨论另外一种方法,这个方法与 LoadLibrary 技术的不同之处是DLL的映射机制不会干预目标进程。相对LoadLibrary 技术,这部分描述的方法适用于 WinNT和Win9x。
但是,什么时候使用这个技巧呢?答案是当DLL必须在远程进程中驻留较长时间(即如果你子类化某个属于另外一个进程的控件时)以及你想尽可能少的干涉目标进程时。我在 HookSpy 中没有使用它,因为注入DLL 的时间并不长——注入时间只要足够得到密码即可。我提供了另外一个例子程序——HookInjEx——来示范。HookInjEx 将DLL映射到资源管理器“explorer.exe”,并从中/解除影射,它子类化“开始”按钮,并交换鼠标左右键单击“开始”按钮的功能。
HookSpy 和 HookInjEx 的源代码都可以从本文的下载源代码中获得。
第二部分:CreateRemoteThread 和 LoadLibrary 技术
范例程序——LibSpy
通常,任何进程都可以通过 LoadLibrary API 动态加载DLL。但是,如何强制一个外部进程调用这个函数呢?答案是:CreateRemoteThread。
首先,让我们看一下 LoadLibrary 和FreeLibrary API 的声明:
HINSTANCE LoadLibrary( LPCTSTR lpLibFileName // 库模块文件名的地址 ); BOOL FreeLibrary( HMODULE hLibModule // 要加载的库模块的句柄 );
现在将它们与传递到 CreateRemoteThread 的线程例程——ThreadProc 的声明进行比较。
DWORD WINAPI ThreadProc( LPVOID lpParameter // 线程数据 );
你可以看到,所有函数都使用相同的调用规范并都接受 32位参数,返回值的大小都相同。也就是说,我们可以传递一个指针到LoadLibrary/FreeLibrary 作为到 CreateRemoteThread 的线程例程。但这里有两个问题,请看下面对CreateRemoteThread 的描述:
第一个问题实际上是由它自己解决的。LoadLibrary 和 FreeLibray 两个函数都在 kernel32.dll 中。因为必须保证kernel32存在并且在每个“常规”进程中的加载地址要相同,LoadLibrary/FreeLibray 的地址在每个进程中的地址要相同,这就保证了有效的指针被传递到远程进程。
第二个问题也很容易解决。只要通过 WriteProcessMemory 将 DLL 模块名(LoadLibrary需要的DLL模块名)拷贝到远程进程即可。
所以,为了使用CreateRemoteThread 和 LoadLibrary 技术,需要按照下列步骤来做:
此外,处理完成后不要忘了关闭所有句柄,包括在第四步和第八步创建的两个线程以及在第一步获取的远程线程句柄。现在让我们看一下 LibSpy 的部分代码,为了简单起见,上述步骤的实现细节中的错误处理以及 UNICODE 支持部分被略掉。
HANDLE hThread; char szLibPath[_MAX_PATH]; // “LibSpy.dll”模块的名称 (包括全路径); void* pLibRemote; // 远程进程中的地址,szLibPath 将被拷贝到此处; DWORD hLibModule; // 要加载的模块的基地址(HMODULE) HMODULE hKernel32 = ::GetModuleHandle("Kernel32"); // 初始化szLibPath //... // 1. 在远程进程中为szLibPath 分配内存 // 2. 将szLibPath 写入分配的内存 pLibRemote = ::VirtualAllocEx( hProcess, NULL, sizeof(szLibPath), MEM_COMMIT, PAGE_READWRITE ); ::WriteProcessMemory( hProcess, pLibRemote, (void*)szLibPath, sizeof(szLibPath), NULL ); // 将"LibSpy.dll" 加载到远程进程(使用CreateRemoteThread 和 LoadLibrary) hThread = ::CreateRemoteThread( hProcess, NULL, 0, (LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32, "LoadLibraryA" ), pLibRemote, 0, NULL ); ::WaitForSingleObject( hThread, INFINITE ); // 获取所加载的模块的句柄 ::GetExitCodeThread( hThread, &hLibModule ); // 清除 ::CloseHandle( hThread ); ::VirtualFreeEx( hProcess, pLibRemote, sizeof(szLibPath), MEM_RELEASE );假设我们实际想要注入的代码——SendMessage ——被放在DllMain (DLL_PROCESS_ATTACH)中,现在它已经被执行。那么现在应该从目标进程中将DLL 卸载:
// 从目标进程中卸载"LibSpy.dll" (使用 CreateRemoteThread 和 FreeLibrary) hThread = ::CreateRemoteThread( hProcess, NULL, 0, (LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32, "FreeLibrary" ), (void*)hLibModule, 0, NULL ); ::WaitForSingleObject( hThread, INFINITE ); // 清除 ::CloseHandle( hThread );进程间通信
第三部分:CreateRemoteThread 和 WriteProcessMemory 技术
范例程序——WinSpy
另外一个将代码拷贝到另一个进程地址空间并在该进程上下文中执行的方法是使用远程线程和 WriteProcessMemory API。这种方法不用编写单独的DLL,而是用 WriteProcessMemory 直接将代码拷贝到远程进程——然后用 CreateRemoteThread 启动它执行。先来看看 CreateRemoteThread 的声明:
HANDLE CreateRemoteThread( HANDLE hProcess, // 传入创建新线程的进程句柄 LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全属性指针 DWORD dwStackSize, // 字节为单位的初始线程堆栈 LPTHREAD_START_ROUTINE lpStartAddress, // 指向线程函数的指针 LPVOID lpParameter, // 新线程使用的参数 DWORD dwCreationFlags, // 创建标志 LPDWORD lpThreadId // 指向返回的线程ID );如果你比较它与 CreateThread(MSDN)的声明,你会注意到如下的差别:
综上所述,我们得按照如下的步骤来做:
ThreadFunc 必须要遵循的原则:
switch( expression ) { case constant1: statement1; goto END; case constant2: statement2; goto END; case constant3: statement2; goto END; } switch( expression ) { case constant4: statement4; goto END; case constant5: statement5; goto END; case constant6: statement6; goto END; } END:或者将它们修改成一个 if-else if 结构语句(参见附录E)。
如果你没有按照这些规则来做,目标进程很可能会崩溃。所以务必牢记。在目标进程中不要假设任何事情都会像在本地进程中那样(参见附录F)。
GetWindowTextRemote(A/W)
要想从“远程”编辑框获得密码,你需要做的就是将所有功能都封装在GetWindowTextRemot(A/W):中。
int GetWindowTextRemoteA( HANDLE hProcess, HWND hWnd, LPSTR lpString ); int GetWindowTextRemoteW( HANDLE hProcess, HWND hWnd, LPWSTR lpString ); 参数说明: hProcess:编辑框控件所属的进程句柄; hWnd:包含密码的编辑框控件句柄; lpString:接收文本的缓冲指针; 返回值:返回值是拷贝的字符数;
下面让我们看看它的部分代码——尤其是注入数据的代码——以便明白 GetWindowTextRemote 的工作原理。此处为简单起见,略掉了 UNICODE 支持部分。
INJDATA typedef LRESULT (WINAPI *SENDMESSAGE)(HWND,UINT,WPARAM,LPARAM); typedef struct { HWND hwnd; // 编辑框句柄 SENDMESSAGE fnSendMessage; // 指向user32.dll 中 SendMessageA 的指针 char psText[128]; // 接收密码的缓冲 } INJDATA;
INJDATA 是一个被注入到远程进程的数据结构。但在注入之前,结构中指向 SendMessageA 的指针是在本地应用程序中初始化的。因为对于每个使用user32.dll的进程来说,user32.dll总是被映射到相同的地址,因此,SendMessageA 的地址也肯定是相同的。这就保证了被传递到远程进程的是一个有效的指针。
ThreadFunc函数
static DWORD WINAPI ThreadFunc (INJDATA *pData) { pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // Get password sizeof(pData->psText), (LPARAM)pData->psText ); return 0; } // 该函数在ThreadFunc之后标记内存地址 // int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc. static void AfterThreadFunc (void) { }
ThradFunc 是被远程线程执行的代码。
范例程序——InjectEx
下面我们将讨论一些更复杂的内容,如何子类化属于另一个进程的控件。
首先,你得拷贝两个函数到远程进程来完成此任务
这里主要的问题是如何将数据传到远程窗口过程 NewProc,因为 NewProc 是一个回调函数,它必须遵循特定的规范和原则,我们不能简单地在参数中传递 INJDATA指针。幸运的是我找到了有两个方法来解决这个问题,只不过要借助汇编语言,所以不要忽略了汇编,关键时候它是很有用的!
方法一:
如下图所示:
在远程进程中,INJDATA 被放在NewProc 之前,这样 NewProc 在编译时便知道 INJDATA 在远程进程地址空间中的内存位置。更确切地说,它知道相对于其自身位置的 INJDATA 的地址,我们需要所有这些信息。下面是 NewProc 的代码:
static LRESULT CALLBACK NewProc( HWND hwnd, // 窗口句柄 UINT uMsg, // 消息标示符 WPARAM wParam, // 第一个消息参数 LPARAM lParam ) // 第二个消息参数 { INJDATA* pData = (INJDATA*) NewProc; // pData 指向 NewProc pData--; // 现在pData 指向INJDATA; // 回想一下INJDATA 被置于远程进程NewProc之前; //----------------------------- // 此处是子类化代码 // ........ //----------------------------- // 调用原窗口过程; // fnOldProc (由SetWindowLong 返回) 被(远程)ThreadFunc初始化 // 并被保存在(远程)INJDATA;中 return pData->fnCallWindowProc( pData->fnOldProc, hwnd,uMsg,wParam,lParam ); }但这里还有一个问题,见第一行代码:
INJDATA* pData = (INJDATA*) NewProc;
这种方式 pData得到的是硬编码值(在我们的进程中是原 NewProc 的内存地址)。这不是我们十分想要的。在远程进程中,NewProc “当前”拷贝的内存地址与它被移到的实际位置是无关的,换句话说,我们会需要某种类型的“this 指针”。
虽然用 C/C++ 无法解决这个问题,但借助内联汇编可以解决,下面是对 NewProc的修改:
static LRESULT CALLBACK NewProc( HWND hwnd, // 窗口句柄 UINT uMsg, // 消息标示符 WPARAM wParam, // 第一个消息参数 LPARAM lParam ) // 第二个消息参数 { // 计算INJDATA 结构的位置 // 在远程进程中记住这个INJDATA // 被放在NewProc之前 INJDATA* pData; _asm { call dummy dummy: pop ecx // <- ECX 包含当前的EIP sub ecx, 9 // <- ECX 包含NewProc的地址 mov pData, ecx } pData--; //----------------------------- // 此处是子类化代码 // ........ //----------------------------- // 调用原来的窗口过程 return pData->fnCallWindowProc( pData->fnOldProc, hwnd,uMsg,wParam,lParam ); }那么,接下来该怎么办呢?事实上,每个进程都有一个特殊的寄存器,它指向下一条要执行的指令的内存位置。即所谓的指令指针,在32位 Intel 和 AMD 处理器上被表示为 EIP。因为 EIP是一个专用寄存器,你无法象操作一般常规存储器(如:EAX,EBX等)那样通过编程存取它。也就是说没有操作代码来寻址 EIP,以便直接读取或修改其内容。但是,EIP 仍然还是可以通过间接方法修改的(并且随时可以修改),通过JMP,CALL和RET这些指令实现。下面我们就通过例子来解释通过 CALL/RET 子例程调用机制在32位 Intel 和 AMD 处理器上是如何工作的。
Address OpCode/Params Decoded instruction -------------------------------------------------- :00401000 55 push ebp ; entry point of ; NewProc :00401001 8BEC mov ebp, esp :00401003 51 push ecx :00401004 E800000000 call 00401009 ; *a* call dummy :00401009 59 pop ecx ; *b* :0040100A 83E909 sub ecx, 00000009 ; *c* :0040100D 894DFC mov [ebp-04], ecx ; mov pData, ECX :00401010 8B45FC mov eax, [ebp-04] :00401013 83E814 sub eax, 00000014 ; pData--; ..... ..... :0040102D 8BE5 mov esp, ebp :0040102F 5D pop ebp :00401030 C21000 ret 0010
这样一来,不管 NewProc 被移到什么地方,它总能计算出其自己的地址。但是,NewProc 的入口点和 “POP ECX”之间的距离可能会随着你对编译/链接选项的改变而变化,由此造成 RELEASE和DEBUG版本之间也会有差别。但关键是你仍然确切地知道编译时的值。
此即为 InjecEx 中使用的解决方案,类似于 HookInjEx,交换鼠标点击“开始”左右键时的功能。
方法二:
对于我们的问题,在远程进程地址空间中将 INJDATA 放在 NewProc 前面不是唯一的解决办法。看下面 NewProc的变异版本:
static LRESULT CALLBACK NewProc( HWND hwnd, // 窗口句柄 UINT uMsg, // 消息标示符 WPARAM wParam, // 第一个消息参数 LPARAM lParam ) // 第二个消息参数 { INJDATA* pData = 0xA0B0C0D0; // 虚构值 //----------------------------- // 子类化代码 // ........ //----------------------------- // 调用原来的窗口过程 return pData->fnCallWindowProc( pData->fnOldProc, hwnd,uMsg,wParam,lParam ); }此处 0xA0B0C0D0 只是远程进程地址空间中真实(绝对)INJDATA地址的占位符。前面讲过,你无法在编译时知道该地址。但你可以在调用 VirtualAllocEx (为INJDATA)之后得到 INJDATA 在远程进程中的位置。编译我们的 NewProc 后,可以得到如下结果:
Address OpCode/Params Decoded instruction -------------------------------------------------- :00401000 55 push ebp :00401001 8BEC mov ebp, esp :00401003 C745FCD0C0B0A0 mov [ebp-04], A0B0C0D0 :0040100A ... .... :0040102D 8BE5 mov esp, ebp :0040102F 5D pop ebp :00401030 C21000 ret 0010因此,其编译的代码(十六进制)将是:
558BECC745FCD0C0B0A0......8BE55DC21000.现在你可以象下面这样继续:
558BECC745FCD0C0B0A0......8BE55DC21000 <- 原来的NewProc (注1) 558BECC745FC00008A00......8BE55DC21000 <- 修改后的NewProc,使用的是INJDATA的实际地址。也就是说,你用真正的 INJDATA(注2) 地址替代了虚拟值 A0B0C0D0(注2)。
何时使用 CreateRemoteThread 和 WriteProcessMemory 技术
与其它方法比较,使用 CreateRemoteThread 和 WriteProcessMemory 技术进行代码注入更灵活,这种方法不需要额外的 dll,不幸的是,该方法更复杂并且风险更大,只要ThreadFunc出现哪怕一丁点错误,很容易就让(并且最大可能地会)使远程进程崩溃(参见附录 F),因为调试远程 ThreadFunc 将是一个可怕的梦魇,只有在注入的指令数很少时,你才应该考虑使用这种技术进行注入,对于大块的代码注入,最好用 I.和II 部分讨论的方法。
WinSpy 以及 InjectEx 请从这里下载源代码。
到目前为止,有几个问题是我们未提及的,现总结如下:
解决方案 | OS | 进程 |
I、Hooks | Win9x 和 WinNT | 仅仅与 USER32.DLL (注3)链接的进程 |
II、CreateRemoteThread & LoadLibrary | 仅 WinNT(注4) | 所有进程(注5), 包括系统服务(注6) |
III、CreateRemoteThread & WriteProcessMemory |
仅 WinNT | 所有进程, 包括系统服务 |
最后,有几件事情一定要了然于心:你的注入代码很容易摧毁目标进程,尤其是注入代码本身出错的时候,所以要记住:权力带来责任!
因为本文中的许多例子是关于密码的,你也许还读过 Zhefu Zhang 写的另外一篇文章“Super Password Spy++” ,在该文中,他解释了如何获取IE 密码框中的内容,此外,他还示范了如何保护你的密码控件免受类似的攻击。
附录A:
为什么 kernel32.dll 和user32.dll 总是被映射到相同的地址。
我的假定:因为Microsoft 的程序员认为这样做有助于速度优化,为什么呢?我的解释是——通常一个可执行程序是由几个部分组成,其中包括“.reloc” 。当链接器创建 EXE 或者 DLL文件时,它对文件被映射到哪个内存地址做了一个假设。这就是所谓的首选加载/基地址。在映像文件中所有绝对地址都是基于链接器首选的加载地址,如果由于某种原因,映像文件没有被加载到该地址,那么这时“.reloc”就起作用了,它包含映像文件中的所有地址的清单,这个清单中的地址反映了链接器首选加载地址和实际加载地址的差别(无论如何,要注意编译器产生的大多数指令使用某种相对地址寻址,因此,并没有你想象的那么多地址可供重新分配),另一方面,如果加载器能够按照链接器首选地址加载映像文件,那么“.reloc”就被完全忽略掉了。
但kernel32.dll 和user32.dll 及其加载地址为何要以这种方式加载呢?因为每一个 Win32 程序都需要kernel32.dll,并且大多数Win32 程序也需要 user32.dll,那么总是将它们(kernel32.dll 和user32.dll)映射到首选地址可以改进所有可执行程序的加载时间。这样一来,加载器绝不能修改kernel32.dll and user32.dll.中的任何(绝对)地址。我们用下面的例子来说明:
将某个应用程序 App.exe 的映像基地址设置成 KERNEL32的地址(/base:"0x77e80000")或 USER32的首选基地址(/base:"0x77e10000"),如果 App.exe 不是从 USER32 导入方式来使用 USER32,而是通过LoadLibrary 加载,那么编译并运行App.exe 后,会报出错误信息("Illegal System DLL Relocation"——非法系统DLL地址重分配),App.exe 加载失败。
为什么会这样呢?当创建进程时,Win 2000、Win XP 和Win 2003系统的加载器要检查 kernel32.dll 和user32.dll 是否被映射到首选基地址(实际上,它们的名字都被硬编码进了加载器),如果没有被加载到首选基地址,将发出错误。在 WinNT4中,也会检查ole32.dll,在WinNT 3.51 和较低版本的Windows中,由于不会做这样的检查,所以kernel32.dll 和user32.dll可以被加载任何地方。只有ntdll.dll总是被加载到其基地址,加载器不进行检查,一旦ntdll.dll没有在其基地址,进程就无法创建。
总之,对于 WinNT 4 和较高的版本中
附录B:
/GZ 编译器开关
在生成 Debug 版本时,/GZ 编译器特性是默认打开的。你可以用它来捕获某些错误(具体细节请参考相关文档)。但对我们的可执行程序意味着什么呢?
当打开 /GZ 开关,编译器会添加一些额外的代码到可执行程序中每个函数所在的地方,包括一个函数调用(被加到每个函数的最后)——检查已经被我们的函数修改的 ESP堆栈指针。什么!难道有一个函数调用被添加到 ThreadFunc 吗?那将导致灾难。ThreadFunc 的远程拷贝将调用一个在远程进程中不存在的函数(至少是在相同的地址空间中不存在)
静态函数和增量链接
增量链接主要作用是在生成应用程序时缩短链接时间。常规链接和增量链接的可执行程序之间的差别是——增量链接时,每个函数调用经由一个额外的JMP指令,该指令由链接器发出(该规则的一个例外是函数声明为静态)。这些 JMP 指令允许链接器在内存中移动函数,这种移动无需修改引用函数的 CALL指令。但这些JMP指令也确实导致了一些问题:如 ThreadFunc 和 AfterThreadFunc 将指向JMP指令而不是实际的代码。所以当计算ThreadFunc 的大小时:
const int cbCodeSize = ((LPBYTE) AfterThreadFunc - (LPBYTE) ThreadFunc)你实际上计算的是指向 ThreadFunc 的JMPs 和AfterThreadFunc之间的“距离” (通常它们会紧挨着,不用考虑距离问题)。现在假设 ThreadFunc 的地址位于004014C0 而伴随的 JMP指令位于 00401020。
:00401020 jmp 004014C0 ... :004014C0 push EBP ; ThreadFunc 的实际地址 :004014C1 mov EBP, ESP ...那么
WriteProcessMemory( .., &ThreadFunc, cbCodeSize, ..);将拷贝“JMP 004014C0”指令(以及随后cbCodeSize范围内的所有指令)到远程进程——不是实际的 ThreadFunc。远程进程要执行的第一件事情将是“JMP 004014C0” 。它将会在其最后几条指令当中——远程进程和所有进程均如此。但 JMP指令的这个“规则”也有例外。如果某个函数被声明为静态的,它将会被直接调用,即使增量链接也是如此。这就是为什么规则#4要将 ThreadFunc 和 AfterThreadFunc 声明为静态或禁用增量链接的缘故。(有关增量链接的其它信息参见 Matt Pietrek的文章“Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools” )
附录D:
为什么 ThreadFunc的局部变量只有 4k?
局部变量总是存储在堆栈中,如果某个函数有256个字节的局部变量,当进入该函数时,堆栈指针就减少256个字节(更精确地说,在函数开始处)。例如,下面这个函数:
void Dummy(void) { BYTE var[256]; var[0] = 0; var[1] = 1; var[255] = 255; }编译后的汇编如下:
:00401000 push ebp :00401001 mov ebp, esp :00401003 sub esp, 00000100 ; change ESP as storage for ; local variables is needed :00401006 mov byte ptr [esp], 00 ; var[0] = 0; :0040100A mov byte ptr [esp+01], 01 ; var[1] = 1; :0040100F mov byte ptr [esp+FF], FF ; var[255] = 255; :00401017 mov esp, ebp ; restore stack pointer :00401019 pop ebp :0040101A ret注意上述例子中,堆栈指针是如何被修改的?而如果某个函数需要4KB以上局部变量内存空间又会怎么样呢?其实,堆栈指针并不是被直接修改,而是通过另一个函数调用来修改的。就是这个额外的函数调用使得我们的 ThreadFunc “被破坏”了,因为其远程拷贝会调用一个不存在的东西。
sub esp, 0x1000 ; "分配" 第一次 4 Kb test [esp], eax ; 承诺一个新页内存(如果还没有承诺) sub esp, 0x1000 ; "分配" 第二次4 Kb test [esp], eax ; ... sub esp, 0x1000 test [esp], eax注意4KB堆栈指针是如何被修改的,更重要的是,每一步之后堆栈底是如何被“触及”(要经过检查)。这样保证在“分配”(承诺)另一页面之前,当前页面承诺的范围也包含堆栈底。
注意事项
“每一个线程到达其自己的堆栈空间,默认情况下,此空间由承诺的以及预留的内存组成,每个线程使用 1 MB预留的内存,以及一页承诺的内存,系统将根据需要从预留的堆栈内存中承诺一页内存区域” (参见 MSDN CreateThread > dwStackSize > Thread Stack Size)
还应该清楚为什么有关 /GS 的文档说在堆栈探针在 Win32 应用程序和Windows NT虚拟内存管理器之间进行谨慎调整。
现在回到我们的ThreadFunc以及 4KB 限制
虽然你可以用 /Gs 防止调用堆栈探测例程,但在文档对于这样的做法给出了警告,此外,文件描述可以用 #pragma check_stack 指令关闭或打开堆栈探测。但是这个指令好像一点作用都没有(要么这个文档是垃圾,要么我疏忽了其它一些信息?)。总之,CreateRemoteThread 和 WriteProcessMemory 技术只能用于注入小块代码,所以你的局部变量应该尽量少耗费一些内存字节,最好不要超过 4KB限制。
为什么要将开关语句拆分成三个以上?
用下面这个例子很容易解释这个问题,假设有如下这么一个函数:
int Dummy( int arg1 ) { int ret =0; switch( arg1 ) { case 1: ret = 1; break; case 2: ret = 2; break; case 3: ret = 3; break; case 4: ret = 0xA0B0; break; } return ret; }编译后变成下面这个样子:
地址 操作码/参数 解释后的指令 -------------------------------------------------- ; arg1 -> ECX :00401000 8B4C2404 mov ecx, dword ptr [esp+04] :00401004 33C0 xor eax, eax ; EAX = 0 :00401006 49 dec ecx ; ECX -- :00401007 83F903 cmp ecx, 00000003 :0040100A 771E ja 0040102A ; JMP 到表***中的地址之一 ; 注意 ECX 包含的偏移 :0040100C FF248D2C104000 jmp dword ptr [4*ecx+0040102C] :00401013 B801000000 mov eax, 00000001 ; case 1: eax = 1; :00401018 C3 ret :00401019 B802000000 mov eax, 00000002 ; case 2: eax = 2; :0040101E C3 ret :0040101F B803000000 mov eax, 00000003 ; case 3: eax = 3; :00401024 C3 ret :00401025 B8B0A00000 mov eax, 0000A0B0 ; case 4: eax = 0xA0B0; :0040102A C3 ret :0040102B 90 nop ; 地址表*** :0040102C 13104000 DWORD 00401013 ; jump to case 1 :00401030 19104000 DWORD 00401019 ; jump to case 2 :00401034 1F104000 DWORD 0040101F ; jump to case 3 :00401038 25104000 DWORD 00401025 ; jump to case 4注意如何实现这个开关语句?
现在,你也许认为出现上述情况只是因为CASE常量被有意选择为连续的(1,2,3,4)。幸运的是,它的这个方案可以应用于大多数现实例子中,只有偏移量的计算稍微有些复杂。但有两个例外:
显然,单独判断每个的CASE常量的话,结果代码繁琐耗时,但使用CMP和JMP指令则使得结果代码的执行就像普通的if-else 语句。
有趣的地方:如果你不明白CASE语句使用常量表达式的理由,那么现在应该弄明白了吧。为了创建地址表,显然在编译时就应该知道相关地址。
现在回到问题!
注意到地址 0040100C 处的JMP指令了吗?我们来看看Intel关于十六进制操作码 FF 的文档是怎么说的:
操作码 指令 描述 FF /4 JMP r/m32 Jump near, absolute indirect, address given in r/m32
原来JMP 使用了一种绝对寻址方式,也就是说,它的操作数(CASE语句中的 0040102C)表示一个绝对地址。还用我说什么吗?远程 ThreadFunc 会盲目地认为地址表中开关地址是 0040102C,JMP到一个错误的地方,造成远程进程崩溃。
附录F:为什么远程进程会崩溃呢?
当远程进程崩溃时,它总是会因为下面这些原因:
:004014C0 push EBP ; ThreadFunc 的入口点 :004014C1 mov EBP, ESP ... :004014C5 call 0041550 ; 这里将使远程进程崩溃 ... :00401502 ret如果 CALL 是由编译器添加的指令(因为某些“禁忌” 开关如/GZ是打开的),它将被定位在 ThreadFunc 的开始的某个地方或者结尾处。
不管哪种情况,你都要小心翼翼地使用 CreateRemoteThread 和 WriteProcessMemory 技术。尤其要注意你的编译器/链接器选项,一不小心它们就会在 ThreadFunc 添加内容。
参考资料: