目录 Windows 钩子 CreateRemoteThread 和 LoadLibrary 技术 ――进程间通信 CreateRemoteThread 和 WriteProcessMemory 技术 ――如何用该技术子类化远程控件 ――何时使用 CreateRemoteThread 和 WriteProcessMemory 技术 结束语 附录A 附录B 附录C 附录D 附录E 附录F 参考资料 简介 本文将讨论如何把代码注入不同的进程地址空间,然后在该进程的上下文中执行注入的代码。 我们在网上可以查到一些窗口/密码侦测的应用例子,网上的这些程序大多都依赖 Windows 钩子技术来实现。本文将讨论除了使用 Windows 钩子技术以外的其它技术来实现这个功能。如图一所示: 图一 WinSpy 密码侦测程序 为了找到解决问题的方法。首先让我们简单回顾一下问题背景。 要“读取”某个控件的内容――无论这个控件是否属于当前的应用程序――通常都是发送 WM_GETTEXT 消息来实现。这个技术也同样应用到编辑控件,但是如果该编辑控件属于另外一个进程并设置了 ES_PASSWORD 式样,那么上面讲的方法就行不通了。用 WM_GETTEXT 来获取控件的内容只适用于进程“拥有”密码控件的情况。所以我们的问题变成了如何在另外一个进程的地址空间执行: 1.
::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );
通常有三种可能性来解决这个问题。 1.将你的代码放入某个 DLL,然后通过 Windows 钩子映射该DLL到远程进程; 2.将你的代码放入某个 DLL,然后通过 CreateRemoteThread 和 LoadLibrary 技术映射该DLL到远程进程; 3.如果不写单独的 DLL,可以直接将你的代码拷贝到远程进程――通过 WriteProcessMemory――并用 CreateRemoteThread 启动它的执行。本文将在第三部分详细描述该技术实现细节; 第一部分: Windows 钩子 范例程序――参见HookSpy 和HookInjEx Windows 钩子主要作用是监控某些线程的消息流。通常我们将钩子分为本地钩子和远程钩子以及系统级钩子,本地钩子一般监控属于本进程的线程的消息流,远程钩子是线程专用的,用于监控属于另外进程的线程消息流。系统级钩子监控运行在当前系统中的所有线程的消息流。 如果钩子作用的线程属于另外的进程,那么你的钩子过程必须驻留在某个动态链接库(DLL)中。然后系统映射包含钩子过程的DLL到钩子作用的线程的地址空间。Windows将映射整个 DLL,而不仅仅是钩子过程。这就是为什么 Windows 钩子能被用于将代码注入到别的进程地址空间的原因。 本文我不打算涉及钩子的具体细节(关于钩子的细节请参见 MSDN 库中的 SetWindowHookEx API),但我在此要给出两个很有用心得,在相关文档中你是找不到这些内容的: 1.在成功调用 SetWindowsHookEx 后,系统自动映射 DLL 到钩子作用的线程地址空间,但不必立即发生映射,因为 Windows 钩子都是消息,DLL 在消息事件发生前并没有产生实际的映射。例如: 如果你安装一个钩子监控某些线程(WH_CALLWNDPROC)的非队列消息,在消息被实际发送到(某些窗口的)钩子作用的线程之前,该DLL 是不会被映射到远程进程的。换句话说,如果 UnhookWindowsHookEx 在某个消息被发送到钩子作用的线程之前被调用,DLL 根本不会被映射到远程进程(即使 SetWindowsHookEx 本身调用成功)。为了强制进行映射,在调用 SetWindowsHookEx 之后马上发送一个事件到相关的线程。 在UnhookWindowsHookEx了之后,对于没有映射的DLL处理方法也一样。只有在足够的事件发生后,DLL才会有真正的映射。 2.当你安装钩子后,它们可能影响整个系统得性能(尤其是系统级钩子),但是你可以很容易解决这个问题,如果你使用线程专用钩子的DLL映射机制,并不截获消息。考虑使用如下代码: 01.
BOOL APIENTRY DllMain( HANDLE hModule,
02.
DWORD ul_reason_for_call,
03.
LPVOID lpReserved )
04.
{
05.
if ( ul_reason_for_call == DLL_PROCESS_ATTACH )
06.
{
07.
// Increase reference count via LoadLibrary
08.
char lib_name[MAX_PATH];
09.
::GetModuleFileName( hModule, lib_name, MAX_PATH );
10.
::LoadLibrary( lib_name );
11.
12.
// Safely remove hook
13.
::UnhookWindowsHookEx( g_hHook );
14.
}
15.
return TRUE;
16.
}
那么会发生什么呢?首先我们通过Windows 钩子将DLL映射到远程进程。然后,在DLL被实际映射之后,我们解开钩子。通常当第一个消息到达钩子作用线程时,DLL此时也不会被映射。这里的处理技巧是调用LoadLibrary通过增加 DLLs的引用计数来防止映射不成功。 现在剩下的问题是如何卸载DLL,UnhookWindowsHookEx 是不会做这个事情的,因为钩子已经不作用于线程了。你可以像下面这样做: 就在你想要解除DLL映射前,安装另一个钩子; 发送一个“特殊”消息到远程线程; 在钩子过程中截获这个消息,响应该消息时调用 FreeLibrary 和 UnhookWindowsHookEx; 目前只使用了钩子来从处理远程进程中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 的声明: 1.
HINSTANCE LoadLibrary(
2.
LPCTSTR lpLibFileName // 库模块文件名的地址
3.
);
4.
5.
BOOL FreeLibrary(
6.
HMODULE hLibModule // 要加载的库模块的句柄
7.
);
现在将它们与传递到 CreateRemoteThread 的线程例程――ThreadProc 的声明进行比较。 1.
DWORD WINAPI ThreadProc(
2.
LPVOID lpParameter // 线程数据
3.
);
你可以看到,所有函数都使用相同的调用规范并都接受 32位参数,返回值的大小都相同。也就是说,我们可以传递一个指针到LoadLibrary/FreeLibrary 作为到 CreateRemoteThread 的线程例程。但这里有两个问题,请看下面对CreateRemoteThread 的描述: 1.CreateRemoteThread 的 lpStartAddress 参数必须表示远程进程中线程例程的开始地址。 2.如果传递到 ThreadFunc 的参数lpParameter――被解释为常规的 32位值(FreeLibrary将它解释为一个 HMODULE),一切OK。但是,如果 lpParameter 被解释为一个指针(LoadLibraryA将它解释为一个串指针)。它必须指向远程进程的某些数据。 第一个问题实际上是由它自己解决的。LoadLibrary 和 FreeLibray 两个函数都在 kernel32.dll 中。因为必须保证kernel32存在并且在每个“常规”进程中的加载地址要相同,LoadLibrary/FreeLibray 的地址在每个进程中的地址要相同,这就保证了有效的指针被传递到远程进程。 第二个问题也很容易解决。只要通过 WriteProcessMemory 将 DLL 模块名(LoadLibrary需要的DLL模块名)拷贝到远程进程即可。 所以,为了使用CreateRemoteThread 和 LoadLibrary 技术,需要按照下列步骤来做: 1.获取远程进程(OpenProcess)的 HANDLE; 2.为远程进程中的 DLL名分配内存(VirtualAllocEx); 3.将 DLL 名,包含全路径名,写入分配的内存(WriteProcessMemory); 4.用 CreateRemoteThread 和 LoadLibrary. 将你的DLL映射到远程进程; 5.等待直到线程终止(WaitForSingleObject),也就是说直到 LoadLibrary 调用返回。另一种方法是,一旦 DllMain(用DLL_PROCESS_ATTACH调用)返回,线程就会终止; 6.获取远程线程的退出代码(GetExitCodeThread)。注意这是一个 LoadLibrary 返回的值,因此是所映射 DLL 的基地址(HMODULE)。 7.用 CreateRemoteThread 和 FreeLibrary从远程进程中卸载 DLL。传递在第六步获取的 HMODULE 句柄到 FreeLibrary(通过 CreateRemoteThread 的lpParameter参数); 8.注意:如果你注入的 DLL 产生任何新的线程,一定要在卸载DLL 之前将它们都终止掉; 9.等待直到线程终止(WaitForSingleObject); 此外,处理完成后不要忘了关闭所有句柄,包括在第四步和第八步创建的两个线程以及在第一步获取的远程线程句柄。现在让我们看一下 LibSpy 的部分代码,为了简单起见,上述步骤的实现细节中的错误处理以及 UNICODE 支持部分被略掉。 01.
HANDLE hThread;
02.
char szLibPath[_MAX_PATH]; // “LibSpy.dll”模块的名称 (包括全路径);
03.
void * pLibRemote; // 远程进程中的地址,szLibPath 将被拷贝到此处;
04.
DWORD hLibModule; // 要加载的模块的基地址(HMODULE)
05.
HMODULE hKernel32 = ::GetModuleHandle( "Kernel32" );
06.
07.
// 初始化szLibPath
08.
//...
09.
// 1. 在远程进程中为szLibPath 分配内存
10.
// 2. 将szLibPath 写入分配的内存
11.
pLibRemote = ::VirtualAllocEx( hProcess, NULL, sizeof (szLibPath),
12.
MEM_COMMIT, PAGE_READWRITE );
13.
::WriteProcessMemory( hProcess, pLibRemote, ( void *)szLibPath,
14.
sizeof (szLibPath), NULL );
15.
16.
// 将"LibSpy.dll" 加载到远程进程(使用CreateRemoteThread 和 LoadLibrary)
17.
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
18.
(LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
19.
"LoadLibraryA" ),
20.
pLibRemote, 0, NULL );
21.
::WaitForSingleObject( hThread, INFINITE );
22.
23.
// 获取所加载的模块的句柄
24.
::GetExitCodeThread( hThread, &hLibModule );
25.
26.
// 清除
27.
::CloseHandle( hThread );
28.
::VirtualFreeEx( hProcess, pLibRemote, sizeof (szLibPath), MEM_RELEASE );
假设我们实际想要注入的代码――SendMessage ――被放在DllMain (DLL_PROCESS_ATTACH)中,现在它已经被执行。那么现在应该从目标进程中将DLL 卸载: 01.
// 从目标进程中卸载"LibSpy.dll" (使用 CreateRemoteThread 和 FreeLibrary)
02.
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
03.
(LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
04.
"FreeLibrary" ),
05.
( void *)hLibModule, 0, NULL );
06.
::WaitForSingleObject( hThread, INFINITE );
07.
08.
// 清除
09.
::CloseHandle( hThread );
进程间通信 到目前为止,我们只讨论了关于如何将DLL 注入到远程进程的内容,但是,在大多数情况下,注入的 DLL 都需要与原应用程序进行某种方式的通信(回想一下,我们的DLL是被映射到某个远程进程的地址空间里了,不是在本地应用程序的地址空间中)。比如秘密侦测程序,DLL必须要知道实际包含密码的控件句柄,显然,编译时无法将这个值进行硬编码。同样,一旦DLL获得了秘密,它必须将它发送回原应用程序,以便能正确显示出来。 幸运的是,有许多方法处理这个问题,文件映射,WM_COPYDATA,剪贴板以及很简单的 #pragma data_seg 共享数据段等,本文我不打算使用这些技术,因为MSDN(“进程间通信”部分)以及其它渠道可以找到很多文档参考。不过我在 LibSpy例子中还是使用了 #pragma data_seg。细节请参考 LibSpy 源代码。 第三部分:CreateRemoteThread 和 WriteProcessMemory 技术 范例程序――WinSpy 另外一个将代码拷贝到另一个进程地址空间并在该进程上下文中执行的方法是使用远程线程和 WriteProcessMemory API。这种方法不用编写单独的DLL,而是用 WriteProcessMemory 直接将代码拷贝到远程进程――然后用 CreateRemoteThread 启动它执行。先来看看 CreateRemoteThread 的声明: 01.
HANDLE CreateRemoteThread(
02.
HANDLE hProcess, // 传入创建新线程的进程句柄
03.
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全属性指针
04.
DWORD dwStackSize, // 字节为单位的初始线程堆栈
05.
LPTHREAD_START_ROUTINE lpStartAddress, // 指向线程函数的指针
06.
LPVOID lpParameter, // 新线程使用的参数
07.
DWORD dwCreationFlags, // 创建标志
08.
LPDWORD lpThreadId // 指向返回的线程ID
09.
);
如果你比较它与 CreateThread(MSDN)的声明,你会注意到如下的差别: 在 CreateRemoteThread中,hProcess是额外的一个参数,一个进程句柄,新线程就是在这个进程中创建的; 在 CreateRemoteThread中,lpStartAddress 表示的是在远程进程地址空间中的线程起始地址。线程函数必须要存在于远程进程中,所以我们不能简单地传递一个指针到本地的 ThreadFunc。必须得先拷贝代码到远程进程; 同样,lpParameter 指向的数据也必须要存在于远程进程,所以也得将它拷贝到那。 综上所述,我们得按照如下的步骤来做: 1.获取一个远程进程的HANDLE (OpenProces) ; 2.在远程进程地址空间中为注入的数据分配内存(VirtualAllocEx); 3.将初始的 INDATA 数据结构的一个拷贝写入分配的内存中(WriteProcessMemory); 4.在远程进程地址空间中为注入的代码分配内存; 5.将 ThreadFunc 的一个拷贝写入分配的内存; 6.用 CreateRemoteThread启动远程的 ThreadFunc 拷贝; 7.等待远程线程终止(WaitForSingleObject); 8.获取远程来自远程进程的结果(ReadProcessMemory 或 GetExitCodeThread); 9.释放在第二步和第四步中分配的内存(VirtualFreeEx); 10.关闭在第六步和第一步获取的句柄(CloseHandle); ThreadFunc 必须要遵循的原则: 1.除了kernel32.dll 和user32.dll 中的函数之外,ThreadFunc 不要调用任何其它函数,只有 kernel32.dll 和user32.dll被保证在本地和目标进程中的加载地址相同(注意,user32.dll并不是被映射到每个 Win32 的进程)。如果你需要来自其它库中的函数,将LoadLibrary 和 GetProcAddress 的地址传给注入的代码,然后放手让它自己去做。如果映射到目标进程中的DLL有冲突,你也可以用 GetModuleHandle 来代替 LoadLibrary。 同样,如果你想在 ThreadFunc 中调用自己的子例程,要单独把每个例程的代码拷贝到远程进程并用 INJDATA为 ThreadFunc 提供代码的地址。 2.不要使用静态字符串,而要用 INJDATA 来传递所有字符串。之所以要这样,是因为编译器将静态字符串放在可执行程序的“数据段”中,可是引用(指针)是保留在代码中的。那么,远程进程中ThreadFunc 的拷贝指向的内容在远程进程的地址空间中是不存在的。 3.去掉 /GZ 编译器开关,它在调试版本中是默认设置的。 4.将 ThreadFunc 和 AfterThreadFunc 声明为静态类型,或者不启用增量链接。 5.ThreadFunc 中的局部变量一定不能超过一页(也就是 4KB)。 注意在调试版本中4KB的空间有大约10个字节是用于内部变量的。 1.如果你有一个开关语句块大于3个case 语句,将它们像下面这样拆分开: 01.
switch ( expression ) {
02.
case constant1: statement1; goto END;
03.
case constant2: statement2; goto END;
04.
case constant3: statement2; goto END;
05.
}
06.
switch ( expression ) {
07.
case constant4: statement4; goto END;
08.
case constant5: statement5; goto END;
09.
case constant6: statement6; goto END;
10.
}
11.
END:
或者将它们修改成一个 if-else if 结构语句(参见附录E)。 如果你没有按照这些规则来做,目标进程很可能会崩溃。所以务必牢记。在目标进程中不要假设任何事情都会像在本地进程中那样 (参见附录F)。 GetWindowTextRemote(A/W) 要想从“远程”编辑框获得密码,你需要做的就是将所有功能都封装在GetWindowTextRemot(A/W):中。 1.
int GetWindowTextRemoteA( HANDLE hProcess, HWND hWnd, LPSTR lpString );
2.
int GetWindowTextRemoteW( HANDLE hProcess, HWND hWnd, LPWSTR lpString );
3.
4.
参数说明:
5.
hProcess:编辑框控件所属的进程句柄;
6.
hWnd:包含密码的编辑框控件句柄;
7.
lpString:接收文本的缓冲指针;
8.
返回值:返回值是拷贝的字符数;
下面让我们看看它的部分代码――尤其是注入数据的代码――以便明白 GetWindowTextRemote 的工作原理。此处为简单起见,略掉了 UNICODE 支持部分。 01.
INJDATA
02.
typedef LRESULT (WINAPI *SENDMESSAGE)( HWND , UINT , WPARAM , LPARAM );
03.
04.
typedef struct {
05.
HWND hwnd; // 编辑框句柄
06.
SENDMESSAGE fnSendMessage; // 指向user32.dll 中 SendMessageA 的指针
07.
08.
char psText[128]; // 接收密码的缓冲
09.
} INJDATA;
INJDATA 是一个被注入到远程进程的数据结构。但在注入之前,结构中指向 SendMessageA 的指针是在本地应用程序中初始化的。因为对于每个使用user32.dll的进程来说,user32.dll总是被映射到相同的地址,因此,SendMessageA 的地址也肯定是相同的。这就保证了被传递到远程进程的是一个有效的指针。 ThreadFunc函数 01.
static DWORD WINAPI ThreadFunc (INJDATA *pData)
02.
{
03.
pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // Get password
04.
sizeof (pData->psText),
05.
( LPARAM )pData->psText );
06.
return 0;
07.
}
08.
09.
// 该函数在ThreadFunc之后标记内存地址
10.
// int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc.
11.
static void AfterThreadFunc ( void )
12.
{
13.
}
ThradFunc 是被远程线程执行的代码。 注释:注意AfterThreadFunc 是如何计算 ThreadFunc 大小的。通常这样做并不是一个好办法,因为链接器可以随意更改函数的顺序(也就是说ThreadFunc可能被放在 AfterThreadFunc之后)。这一点你可以在小项目中很好地保证函数的顺序是预先设想好的,比如 WinSpy 程序。在必要的情况下,你还可以使用 /ORDER 链接器选项来解决函数链接顺序问题。或者用反汇编确定 ThreadFunc 函数的大小。 如何使用该技术子类化远程控件 范例程序――InjectEx 下面我们将讨论一些更复杂的内容,如何子类化属于另一个进程的控件。 首先,你得拷贝两个函数到远程进程来完成此任务 1.ThreadFunc实际上是通过 SetWindowLong子类化远程进程中的控件; 2.NewProc是子类化控件的新窗口过程; 这里主要的问题是如何将数据传到远程窗口过程 NewProc,因为 NewProc 是一个回调函数,它必须遵循特定的规范和原则,我们不能简单地在参数中传递 INJDATA指针。幸运的是我找到了有两个方法来解决这个问题,只不过要借助汇编语言,所以不要忽略了汇编,关键时候它是很有用的! 方法一: 如下图所示: 在远程进程中,INJDATA 被放在NewProc 之前,这样 NewProc 在编译时便知道 INJDATA 在远程进程地址空间中的内存位置。更确切地说,它知道相对于其自身位置的 INJDATA 的地址,我们需要所有这些信息。下面是 NewProc 的代码: 01.
static LRESULT CALLBACK NewProc(
02.
HWND hwnd, // 窗口句柄
03.
UINT uMsg, // 消息标示符
04.
WPARAM wParam, // 第一个消息参数
05.
LPARAM lParam ) // 第二个消息参数
06.
{
07.
INJDATA* pData = (INJDATA*) NewProc; // pData 指向 NewProc
08.
pData--; // 现在pData 指向INJDATA;
09.
// 回想一下INJDATA 被置于远程进程NewProc之前;
10.
11.
//-----------------------------
12.
// 此处是子类化代码
13.
// ........
14.
//-----------------------------
15.
16.
// 调用原窗口过程;
17.
// fnOldProc (由SetWindowLong 返回) 被(远程)ThreadFunc初始化
18.
// 并被保存在(远程)INJDATA;中
19.
return pData->fnCallWindowProc( pData->fnOldProc,
20.
hwnd,uMsg,wParam,lParam );
21.
}
但这里还有一个问题,见第一行代码: 1.
INJDATA* pData = (INJDATA*) NewProc;
这种方式 pData得到的是硬编码值(在我们的进程中是原 NewProc 的内存地址)。这不是我们十分想要的。在远程进程中,NewProc “当前”拷贝的内存地址与它被移到的实际位置是无关的,换句话说,我们会需要某种类型的“this 指针”。 虽然用 C/C++ 无法解决这个问题,但借助内联汇编可以解决,下面是对 NewProc的修改: 01.
static LRESULT CALLBACK NewProc(
02.
HWND hwnd, // 窗口句柄
03.
UINT uMsg, // 消息标示符
04.
WPARAM wParam, // 第一个消息参数
05.
LPARAM lParam ) // 第二个消息参数
06.
{
07.
// 计算INJDATA 结构的位置
08.
// 在远程进程中记住这个INJDATA
09.
// 被放在NewProc之前
10.
INJDATA* pData;
11.
_asm {
12.
call dummy
13.
dummy:
14.
pop ecx // <- ECX 包含当前的EIP
15.
sub ecx, 9 // <- ECX 包含NewProc的地址
16.
mov pData, ecx
17.
}
18.
pData--;
19.
20.
21.
//-----------------------------
22.
// 此处是子类化代码
23.
// ........
24.
//-----------------------------
25.
26.
// 调用原来的窗口过程
27.
return pData->fnCallWindowProc( pData->fnOldProc,
28.
hwnd,uMsg,wParam,lParam );
29.
}
那么,接下来该怎么办呢?事实上,每个进程都有一个特殊的寄存器,它指向下一条要执行的指令的内存位置。即所谓的指令指针,在32位 Intel 和 AMD 处理器上被表示为 EIP。因为 EIP是一个专用寄存器,你无法象操作一般常规存储器(如:EAX,EBX等)那样通过编程存取它。也就是说没有操作代码来寻址 EIP,以便直接读取或修改其内容。但是,EIP 仍然还是可以通过间接方法修改的(并且随时可以修改),通过JMP,CALL和RET这些指令实现。下面我们就通过例子来解释通过 CALL/RET 子例程调用机制在32位 Intel 和 AMD 处理器上是如何工作的。 当你调用(通过 CALL)某个子例程时,子例程的地址被加载到 EIP,但即便是在 EIP杯修改之前,其旧的那个值被自动PUSH到堆栈(被用于后面作为指令指针返回)。在子例程执行完时,RET 指令自动将堆栈顶POP到 EIP。 现在你知道了如何通过 CALL 和 RET 实现 EIP 的修改,但如何获取其当前的值呢?下� |