三种想其他线程地址空间注入自己带吗


●Windows 钩子(Hooks)
●CreateRemoteThread 和LoadLibrary 技术
○进程间通讯
●CreateRemoteThread 和 WriteProcessmemory 技术
○如何使用该技术子类(SubClass)其他进程中的控件
○什么情况下适合使用该技术
●写在最后的话
●附录
●参考
●文章历史
导言:
我们在Code project(www.codeproject.com)上可以找到许多密码间谍程序(译
者注:那些可以看到别的程序中密码框内容的软件),他们都依赖于Windows 钩
子技术。要实现这个还有其他的方法吗?有!但是,首先,让我们简单回顾一下
我们要实现的目标,以便你能弄清楚我在说什么。
要读取一个控件的内容,不管它是否属于你自己的程序,一般来说需要发送
WM_GETTEXT 消息到那个控件。这对edit 控件也有效,但是有一种情况例外。如
果这个edit 控件属于其他进程并且具有 ES_PASSWORD 风格的话,这种方法就不
会成功。只有“拥有(OWNS)”这个密码控件的进程才可以用 WM_GETTEXT 取得
它的内容。所以,我们的问题就是:如何让下面这句代码在其他进程的地址空间
中运行起来:
::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );
一般来说,这个问题有三种可能的解决方案:
1. 把你的代码放到一个DLL 中;然后用 windows 钩子把它映射到远程进程。
2. 把你的代码放到一个DLL 中; 然后用 CreateRemoteThread 和
LoadLibrary 把它映射到远程进程。
3. 不用DLL,直接复制你的代码到远程进程(使用WriteProcessMemory)并
且用CreateRemoteThread 执行之。在这里有详细的说明:
Ⅰ. Windows 钩子
示例程序:HookSpy 和 HookInjEx
Windows 钩子的主要作用就是监视某个线程的消息流动。一般可分为:
1. 局部钩子,只监视你自己进程中某个线程的消息流动。
2. 远程钩子,又可以分为:
a. 特定线程的,监视别的进程中某个线程的消息;
b. 系统级的,监视整个系统中正在运行的所有线程的消息。
如果被挂钩(监视)的线程属于别的进程(情况2a 和2b),你的钩子过程(hook
procedure)必须放在一个动态连接库(DLL)中。系统把这包含了钩子过程的
DLL 映射到被挂钩的线程的地址空间。Windows 会映射整个 DLL 而不仅仅是你的
钩子过程。这就是为什么windows 钩子可以用来向其他线程的地址空间注入代码
的原因了。
在这里我不想深入讨论钩子的问题(请看MSDN 中对SetWindowsHookEx 的说明),
让我再告诉你两个文档中找不到的诀窍,可能会有用:
1. 当SetWindowHookEx 调用成功后,系统会自动映射这个DLL 到被挂钩的线程,
但并不是立即映射。因为所有的Windows 钩子都是基于消息的,直到一个适当的
事件发生后这个DLL 才被映射。比如:
如果你安装了一个监视所有未排队的( nonqueued ) 的消息的钩子
(WH_CALLWNDPROC),只有一个消息发送到被挂钩线程(的某个窗口)后这个DLL
才被映射。也就是说, 如果在消息发送到被挂钩线程之前调用了
UnhookWindowsHookEx 那么这个DLL 就永远不会被映射到该线程( 虽然
SetWindowsHookEx 调用成功了)。为了强制映射,可以在调用SetWindowsHookEx
后立即发送一个适当的消息到那个线程。
同理,调用UnhookWindowsHookEx 之后,只有特定的事件发生后DLL 才真正地从
被挂钩线程卸载。
2. 当你安装了钩子后,系统的性能会受到影响(特别是系统级的钩子)。然而
如果你只是使用的特定线程的钩子来映射DLL 而且不截获如何消息的话,这个缺
陷也可以轻易地避免。看一下下面的代码片段:
BOOL APIENTRY DllMain( HANDLE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved )
{
if( ul_reason_for_call == DLL_PROCESS_ATTACH )
{
//用 LoadLibrary 增加引用次数
char lib_name[MAX_PATH];
::GetModuleFileName( hModule, lib_name, MAX_PATH );
::LoadLibrary( lib_name );
// 安全卸载钩子
::UnhookWindowsHookEx( g_hHook );
}
return TRUE;
}
我们来看一下。首先,我们用钩子映射这个DLL 到远程线程,然后,在DLL 被真
正映射进去后,我们立即卸载挂钩(unhook)。一般来说当第一个消息到达被挂
钩线程后,这DLL 会被卸载,然而我们通过LoadLibrary 来增加这个DLL 的引用
次数,避免了DLL 被卸载。
剩下的问题是:使用完毕后如何卸载这个DLL?UnhookWindowsHookEx 不行了,
因为我们已经对那个线程取消挂钩(unhook)了。你可以这么做:
○在你想要卸载这个DLL 之前再安装一个钩子;
○发送一个“特殊”的消息到远程线程;
○在你的新钩子的钩子过程(hook procedure)中截获该消息,调用
FreeLibrary 和 (译者注:对新钩子调用)UnhookwindowsHookEx。
现在,钩子只在映射DLL 到远程进程和从远程进程卸载DLL 时使用,对被挂钩线
程的性能没有影响。也就是说,我们找到了一种(相比第二部分讨论的
LoadLibrary 技术)WinNT 和Win9x 下都可以使用的,不影响目的进程性能的DLL
映射机制。
但是,我们应该在何种情况下使用该技巧呢?通常是在DLL 需要在远程进程中驻
留较长时间(比如你要子类[subclass]另一个进程中的控件)并且你不想过于干
涉目的进程时比较适合使用这种技巧。我在HookSpy 中并没有使用它,因为那个
DLL 只是短暂地注入一段时间――只要能取得密码就足够了。我在另一个例子
HookInjEx 中演示了这种方法。HookInjEx 把一个DLL 映射进“explorer.exe”
(当然,最后又从其中卸载),子类了其中的开始按钮,更确切地说我是把开始
按钮的鼠标左右键点击事件颠倒了一下。
你可以在本文章的开头部分找到HookSpy 和HookInjEx 及其源代码的下载包链
接。
Ⅱ. CreateRemoteThread 和 LoadLibrary 技术
示例程序:LibSpy
通常,任何进程都可以通过LoadLibrary 动态地加载DLL,但是我们如何强制一
个外部进程调用该函数呢?答案是CreateRemoteThread。
让我们先来看看LoadLibrary 和FreeLibrary 的函数声明:
HINSTANCE LoadLibrary(
HINSTANCE LoadLibrary(
HINSTANCE LoadLibrary(
HINSTANCE LoadLibrary(
LPCTSTR lpLibFileName // address of filename of library module
);
BOOL FreeLibrary(
HMODULE hLibModule // handle to loaded library module
);
再和CreateRemoteThread 的线程过程(thread procedure)ThreadProc 比较一
下:
DWORD WINAPI ThreadProc(
LPVOID lpParameter // thread data
);
你会发现所有的函数都有同样的调用约定(calling convention)、都接受一个
32 位的参数并且返回值类型的大小也一样。也就是说, 我们可以把
LoadLibrary/FreeLibrary 的指针作为参数传递给CrateRemoteThread。
然而,还有两个问题(参考下面对CreateRemoteThread 的说明)
1. 传递给ThreadProc 的lpStartAddress 参数必须为远程进程中的线程过程
的起始地址。
2 . 如果把ThreadProc 的lpParameter 参数当做一个普通的32 位整数
(FreeLibrary 把它当做HMODULE)那么没有如何问题,但是如果把它当做一个
指针(LoadLibrary 把它当做一个char*),它就必须指向远程进程中的内存数据。
第一个问题其实已经迎刃而解了,因为LoadLibrary 和FreeLibrary 都是存在于
kernel32.dll 中的函数,而kernel32 可以保证任何“正常”进程中都存在,且
其加载地址都是一样的。(参看附录A)于是LoadLibrary/FreeLibrary 在任何
进程中的地址都是一样的,这就保证了传递给远程进程的指针是个有效的指针。
第二个问题也很简单: 把DLL 的文件名( LodLibrary 的参数) 用
WriteProcessMemory 复制到远程进程。
所以,使用CreateRemoteThread 和LoadLibrary 技术的步骤如下:
1. 得到远程进程的HANDLE(使用OpenProcess)。
2. 在远程进程中为DLL 文件名分配内存(VirtualAllocEx)。
3. 把DLL 的文件名(全路径)写到分配的内存中(WriteProcessMemory)
4. 使用CreateRemoteThread 和LoadLibrary 把你的DLL 映射近远程进程。
5. 等待远程线程结束(WaitForSingleObject),即等待LoadLibrary 返回。也
就是说当我们的DllMain(是以DLL_PROCESS_ATTACH 为参数调用的)返回时远
程线程也就立即结束了。
6. 取回远程线程的结束码(GetExitCodeThtread),即LoadLibrary 的返回
值――我们DLL 加载后的基地址(HMODULE)。
7. 释放第2 步分配的内存(VirtualFreeEx)。
8. 用CreateRemoteThread 和FreeLibrary 把DLL 从远程进程中卸载。调用时
传递第6 步取得的HMODULE 给FreeLibrary(通过CreateRemoteThread 的
lpParameter 参数)。
9. 等待线程的结束(WaitSingleObject)。
同时,别忘了在最后关闭所有的句柄:第4、8 步得到的线程句柄,第1 步得到
的远程进程句柄。
现在我们看看LibSpy 的部分代码,分析一下以上的步骤是任何实现的。为了简
单起见,没有包含错误处理和支持Unicode 的代码。
HANDLE hThread;
char szLibPath[_MAX_PATH]; // "LibSpy.dll"的文件名
// (包含全路径!);
void* pLibRemote; // szLibPath 将要复制到地址
DWORD hLibModule; //已加载的DLL 的基地址(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 );
//取得DLL 的基地址
::GetExitCodeThread( hThread, &hLibModule );
//扫尾工作
::CloseHandle( hThread );
::VirtualFreeEx( hProcess, pLibRemote, sizeof(szLibPath),
MEM_RELEASE );
我们放在DllMain 中的真正要注入的代码(比如为SendMessage)现在已经被执
行了(由于DLL_PROCESS_ATTACH),所以现在可以把DLL 从目的进程中卸载了。
// 从目标进程卸载LibSpu.dll
// (通过 CreateRemoteThread & FreeLibrary)
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
"FreeLibrary" ),
(void*)hLibModule, 0, NULL );
::WaitForSingleObject( hThread, INFINITE );
// 扫尾工作
::CloseHandle( hThread );
进程间通讯
到目前为止,我们仅仅讨论了任何向远程进程注入DLL,然而,在多数情况下被
注入的DLL 需要和你的程序以某种方式通讯(记住,那个DLL 是被映射到远程进
程中的,而不是在你的本地程序中!)。以密码间谍为例:那个DLL 需要知道包含
了密码的的控件的句柄。很明显,这个句柄是不能在编译期间硬编码(hardcoded)
进去的。同样,当DLL 得到密码后,它也需要把密码发回我们的程序。
幸运的是,这个问题有很多种解决方案:文件映射(Mapping),WM_COPYDATA,
剪贴板等。还有一种非常便利的方法#pragma data_seg。这里我不想深入讨论因
为它们在MSDN(看一下Interprocess Communications 部分)或其他资料中都
有很好的说明。我在LibSpy 中使用的是#pragma data_seg。
你可以在本文章的开头找到LibSpy 及源代码的下载链接。
Ⅲ.CreateRemoteThread 和WriteProcessMemory 技术
示例程序:WinSpy
另一种注入代码到其他进程地址空间的方法是使用WriteProcessMemory API。
这次你不用编写一个独立的DLL 而是直接复制你的代码到远程进程
(WriteProcessMemory)并用CreateRemoteThread 执行之。
让我们看一下CreateRemoteThread 的声明:
HANDLE CreateRemoteThread(
HANDLE hProcess, // handle to process to create thread in
LPSECURITY_ATTRIBUTES lpThreadAttributes, // pointer to security
// attributes
DWORD dwStackSize, // initial thread stack size, in bytes
LPTHREAD_START_ROUTINE lpStartAddress, // pointer to thread
// function
LPVOID lpParameter, // argument for new thread
DWORD dwCreationFlags, // creation flags
LPDWORD lpThreadId // pointer to returned thread identifier
);
和CreateThread 相比,有一下不同:
●增加了hProcess 参数。这是要在其中创建线程的进程的句柄。
●CreateRemoteThread 的lpStartAddress 参数必须指向远程进程的地址空间中
的函数。这个函数必须存在于远程进程中,所以我们不能简单地传递一个本地
ThreadFucn 的地址,我们必须把代码复制到远程进程。
●同样,lpParameter 参数指向的数据也必须存在于远程进程中,我们也必须复
制它。
现在,我们总结一下使用该技术的步骤:
1. 得到远程进程的HANDLE(OpenProcess)。
2. 在远程进程中为要注入的数据分配内存(VirtualAllocEx)、
3. 把初始化后的INJDATA 结构复制到分配的内存中(WriteProcessMemory)。
4. 在远程进程中为要注入的数据分配内存(VirtualAllocEx)。
5. 把ThreadFunc 复制到分配的内存中(WriteProcessMemory)。
6. 用CreateRemoteThread 启动远程的ThreadFunc。
7. 等待远程线程的结束(WaitForSingleObject)。
8. 从远程进程取回指执行结果(ReadProcessMemory 或 GetExitCodeThread)。
9. 释放第2、4 步分配的内存(VirtualFreeEx)。
10. 关闭第6、1 步打开打开的句柄。
另外,编写ThreadFunc 时必须遵守以下规则:
1. ThreadFunc 不能调用除kernel32.dll 和user32.dll 之外动态库中的API
函数。只有kernel32.dll 和 user32.dll(如果被加载)可以保证在本地和目的
进程中的加载地址是一样的。(注意:user32 并不一定被所有的Win32 进程加载!)
参考附录A。如果你需要调用其他库中的函数,在注入的代码中使用LoadLibrary
和GetProcessAddress 强制加载。如果由于某种原因,你需要的动态库已经被映
射进了目的进程,你也可以使用GetMoudleHandle 代替LoadLibrary。同样,如
果你想在ThreadFunc 中调用你自己的函数,那么就分别复制这些函数到远程进
程并通过INJDATA 把地址提供给ThreadFunc。
2.不要使用static 字符串。把所有的字符串提供INJDATA 传递。为什么?编译
器会把所有的静态字符串放在可执行文件的“.data”段,而仅仅在代码中保留
它们的引用(即指针)。这样,远程进程中的ThreadFunc 就会执行不存在的内存
数据(至少没有在它自己的内存空间中)。
3. 去掉编译器的/GZ 编译选项。这个选项是默认的(看附录B)。
4. 要么把ThreadFunc 和AfterThreadFunc 声明为static,要么关闭编译器的
“增量连接(incremental linking)”(看附录C)。
5. ThreadFunc 中的局部变量总大小必须小于4k 字节(看附录D)。注意,当
degug 编译时,这4k 中大约有10 个字节会被事先占用。
6. 如果有多于3 个switch 分支的case 语句,必须像下面这样分割开,或用
if-else if 代替.
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:
(参考附录E)
如果你不按照这些游戏规则玩的话,你注定会使目的进程挂掉!记住,不要妄想
远程进程中的任何数据会和你本地进程中的数据存放在相同内存地址!(参看附
录F)
(原话如此:You will almost certainly crash the target process if you don't
play by those rules. Just remember: Don't assume anything in the target
process is at the same address as it is in your process.)
GetWindowTextRemote(A/W)
所有取得远程edit 中文本的工作都被封装进这个函数:
GetWindowTextRemote(A/W):
int GetWindowTextRemoteA( HANDLE hProcess, HWND hWnd, LPSTR lpString );
int GetWindowTextRemoteW( HANDLE hProcess, HWND hWnd, LPWSTR lpString );
参数:
hProcess
目的edit 所在的进程句柄
hWnd
目的edit 的句柄
lpString
接收字符串的缓冲
返回值:
成功复制的字符数。
让我们看以下它的部分代码,特别是注入的数据和代码。为了简单起见,没有包
含支持Unicode 的代码。
INJDATA
typedef LRESULT (WINAPI *SENDMESSAGE)(HWND,UINT,WPARAM,LPARAM);
typedef struct {
HWND hwnd; // handle to edit control
SENDMESSAGE fnSendMessage; // pointer to user32!SendMessageA
char psText[128]; // buffer that is to receive the password
} INJDATA;
INJDATA 是要注入远程进程的数据。在把它的地址传递给SendMessageA 之前,
我们要先对它进行初始化。幸运的是unse32.dll 在所有的进程中(如果被映射)
总是被映射到相同的地址,所以SendMessageA 的地址也总是相同的,这也保证
了传递给远程进程的地址是有效的。
ThreadFunc
static DWORD WINAPI ThreadFunc (INJDATA *pData)
{
pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // 得到密码
sizeof(pData->psText),
(LPARAM)pData->psText );
return 0;
}
// This function marks the memory address after ThreadFunc.
// int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc.
static void AfterThreadFunc (void)
{
}
ThreadFunc 是远程线程实际执行的代码。
● 注意AfterThreadFunc 是如何计算ThreadFunc 的代码大小的。一般地,这不
是最好的办法,因为编译器会改变你的函数中代码的顺序(比如它会把
ThreadFunc 放在AfterThreadFunc 之后)。然而,你至少可以确定在同一个工程
中,比如在我们的WinSpy 工程中,你函数的顺序是固定的。如果有必要,你可
以使用/ORDER 连接选项,或者,用反汇编工具确定ThreadFunc 的大小,这个也
许会更好。
如何用该技术子类(subclass)一个远程控件
示例程序:InjectEx
让我们来讨论一个更复杂的问题:如何子类属于其他进程的一个控件?
首先,要完成这个任务,你必须复制两个函数到远程进程:
1. ThreadFunc,这个函数通过调用SetWindowLong API 来子类远程进程中的控
件,
2. NewProc, 那个控件的新窗口过程(Window Procedure)。
然而,最主要的问题是如何传递数据到远程的NewProc。因为NewProc 是一个回
调(callback)函数,它必须符合特定的要求(译者注:这里指的主要是参数个
数和类型),我们不能再简单地传递一个INJDATA 的指针作为它的参数。幸运的
我已经找到解决这个问题的方法,而且是两个,但是都要借助于汇编语言。我一
直都努力避免使用汇编,但是这一次,我们逃不掉了,没有汇编不行的。
解决方案1
看下面的图片:
不知道你是否注意到了,INJDATA 紧挨着NewProc 放在NewProc 的前面?这样的
话在编译期间NewProc 就可以知道INJDATA 的内存地址。更精确地说,它知道
INJDATA 相对于它自身地址的相对偏移,但是这并不是我们真正想要的。现在,
NewProc 看起来是这个样子:
static LRESULT CALLBACK NewProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam ) // second message parameter
{
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 会
被复制到远程进程,那样的话,这个地址就错了。
用C/C++没有办法解决这个问题,可以用内联的汇编来解决。看修改后的
NewProc:
static LRESULT CALLBACK NewProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam ) // second message parameter
{
// 计算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 并且对它进行读写的操作码
(OpCode)。然而,EIP 同样可以被JMP,CALL,RET 等指令隐含地改变(事实上
它一直都在改变)。让我们举例说明32 位的Intel 和 AMD 处理器上CALL/RET 是
如何工作的吧:
当我们用CALL 调用一个子程序时,这个子程序的地址被加载进EIP。同时,在
EIP 被改变之前,它以前的值会被自动压栈(在后来被用作返回指令指针[return
instruction-pointer])。在子程序的最后RET 指令自动把这个值从栈中弹出到
EIP。
现在我们知道了如何通过CALL 和RET 来修改EIP 的值了,但是如何得到他的当
前值?
还记得CALL 把EIP 的值压栈了吗?所以为了得到EIP 的值我们调用了一个“假
(dummy)函数”然后弹出栈顶值。看一下编译过的NewProc:
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
a. 一个假的函数调用;仅仅跳到下一条指令并且(译者注:更重要的是)把
EIP 压栈。
b. 弹出栈顶值到ECX。ECX 就保存的EIP 的值;这也就是那条“pop ECX”指令
的地址。
c. 注意从NewProc 的入口点到“pop ECX”指令的“距离”为9 字节;因此把
ECX 减去9 就得到的NewProc 的地址了。
这样一来,不管被复制到什么地方,NewProc 总能正确计算自身的地址了!然而,
要注意从NewProc 的入口点到“pop ECX”的距离可能会因为你的编译器/链接选
项的不同而不同,而且在Release 和Degub 版本中也是不一样的。但是,不管怎
样,你仍然可以在编译期知道这个距离的具体值。
1. 首先,编译你的函数。
2. 在反汇编器(disassembler)中查出正确的距离值。
3. 最后,使用正确的距离值重新编译你的程序。
这也是InjectEx 中使用的解决方案。InjectEx 和HookInjEx 类似,交换开始按
钮上的鼠标左右键点击事件。
解决方案2
在远程进程中把INJDATA 放在NewProc 的前面并不是唯一的解决方案。看一下下
面的NewProc:
static LRESULT CALLBACK NewProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam ) // second message parameter
{
INJDATA* pData = 0xA0B0C0D0; // 一个假设
//-----------------------------
// 子类代码
// ........
//-----------------------------
// 调用以前的窗口过程
return pData->fnCallWindowProc( pData->fnOldProc,
hwnd,uMsg,wParam,lParam );
}
这里,0XA0B0C0D0 仅仅是INJDATA 在远程进程中的地址的占位符(placeholder)。
你无法在编译期得到这个值,然而你在调用 VirtualAllocEx(为INJDATA 分配
内存时)后确实知道INJDATA 的地址!(译者注:就是VirtualAllocEx 的返回值)
我们的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。
现在,你这么做:
1. 把INJDATA,ThreadFunc 和NewFunc 复制到目的进程。
2. 改变NewPoc 的机器码,让pData 指向INJDATA 的真实地址。
比如,假设INJDATA 的的真实地址(VirtualAllocEx 的返回值)为0x008a0000,
你把NewProc 的机器码改为:
558BECC745FCD0C0B0A0......8BE55DC21000
<- 修改前的 NewProc 1
558BECC745FC00008A00......8BE55DC21000
<- 修改后的 NewProc
也就是说,你把假值 A0B0C0D0 改为INJDATA 的真实地址2
3. 开始指向远程的ThreadFunc,它子类了远程进程中的控件。
¹ 你可能会问,为什么A0B0C0D0 和008a0000 在编译后的机器码中为逆序的。这
时因为Intel 和AMD 处理器使用littl-endian 标记法(little-endian notation)
来表示它们的(多字节)数据。换句话说:一个数的低字节(low-order byte)
在内存中被存放在最低位,高字节(high-order byte)存放在最高位。
想像一下,存放在四个字节中的单词“UNIX”,在big-endia 系统中被存储为
“UNIX”,在little-endian 系统中被存储为“XINU”。
² 一些蹩脚的破解者用类似的方法来修改可执行文件的机器码,但是一个程序一
旦载入内存,就不能再更改自身的机器码(一个可执行文件的.text 段是写保护
的)。我们能修改远程进程中的NewProc 是因为它所处的那块内存在分配时给予
了PAGE_EXECUTE_READWRITE 属性。
何时使用CreateRemoteThread 和WriteProcessMemory 技术
通过CreateRemoteThread 和WriteProcessMemory 来注入代码的技术,和其他两
种方法相比,不需要一个额外的DLL 文件,因此更灵活,但也更复杂更危险。一
旦你的ThreadFunc 中有错误,远程线程会立即崩溃(看附录F)。调试一个远程
的ThreadFunc 也是场恶梦,所以你应该在仅仅注入若干条指令时才使用这个方
法。要注入大量的代码还是使用另外两种方法吧。
再说一次,你可以在文章的开头部分下载到WinSpy,InjectEx 和它们的源代码。
写在最后的话
最后,我们总结一些目前还没有提到的东西:
方法
适用的操作系统
可操作的进程进程
I. Windows 钩子
Win9x 和WinNT
连接了USER32.DLL 的进程1
II. CreateRemoteThread & LoadLibrary
仅WinNT2
所有进程3,包括系统服务4
III. CreateRemoteThread & WriteProcessMemory
仅WinNT
所有进程,包括系统服务
1. 很明显,你不能给一个没有消息队列的线程挂钩。同样SetWindowsHookEx
也对系统服务不起作用(就算它们连接了USER32)。
2. 在Win9x 下没有CreateRemoteThread 和VirtualAllocEx(事实上可以在9x
上模拟它们,但是到目前为止还只是个神话)
3. 所有进程 = 所有的Win32 进程 + csrss.exe
本地程序(native application)比如smss.exe, os2ss.exe, autochk.exe,
不使用Win32 APIs,也没有连接到kernel32.dll。唯一的例外是csrss.exe,
win32 子系统自身。它是一个本地程序,但是它的一些库(比如 winsrv.dll)
需要Win32 DLL 包括kernel32.dll.
4. 如果你向注入代码到系统服务或csrss.exe , 在打开远程进程的句柄
( OpenProcess ) 之前把你的进程的优先级调整为“ SeDebugprovilege ”
(AdjustTokenPrivileges)。
大概就这些了吧。还有一点你需要牢记在心:你注入的代码(特别是存在错误时)
很容易就会把目的进程拖垮。记住:责任随权利而来(Power comes with
responsibility)!
这篇文章中的很多例子都和密码有关,看过这篇文章后你可能也会对Zhefu
Zhang(译者注:大概是一位中国人,张哲夫??)写的Supper Password Spy++
感兴趣。他讲解了如何从IE 的密码框中得到密码,也说了如何保护你的密码不
被这种攻击。
最后一点:读者的反馈是文章作者的唯一报酬,所以如果你认为这篇文章有作用,
请留下你的评论或给它投票。更重要的是,如果你发现有错误或bug;或你认为
什么地方做得还不够好,有需要改进的地方;或有不清楚的地方也都请告诉我。
感谢
首先,我要感谢我在CodeGuru(这篇文章最早是在那儿发表的)的读者,正是
由于你们的鼓励和支持这篇文章才得以从最初的1200 单词发展到今天这样
6000 单词的“庞然大物”。如果说有一个人我要特别感谢的话,他就是Rado Picha。
这篇文章的一部分很大程度上得益于他对我的建议和帮助。最后,但也不能算是
最后,感谢Susan Moore,他帮助我跨越了那个叫做“英语”的雷区,让这篇文
章更加通顺达意。
――――――――――――――――――――――――――――――――――
――
附录
A) 为什么kernel32.dll 和user32.dll 中是被映射到相同的内存地址?
我的假定:以为微软的程序员认为这么做可以优化速度。让我们来解释一下这是
为什么。
一般来说,一个可执行文件包含几个段,其中一个为“.reloc”段。
当链接器生成EXE 或DLL 时,它假定这个文件会被加载到一个特定的地址,也就
是所谓的假定/首选加载/基地址(assumed/preferred load/base address)。内
存映像(image)中的所有绝对地址都时基于该“链接器假定加载地址”的。如
果由于某些原因,映像没有加载到这个地址,那么PE 加载器(PE loader)就不
得不修正该映像中的所有绝对地址。这就是“.reloc”段存在的原因:它包含了
一个该映像中所有的“链接器假定地址”与真正加载到的地址之间的差异的列表
(注意:编译器产生的大部分指令都使用一种相对寻址模式,所以,真正需要重
定位[relocation]的地方并没有你想像的那么多)。如果,从另一方面说,加载
器可以把映像加载到链接器首选地址,那么“.reloc”段就会被彻底忽略。
但是,因为每一个Win32 程序都需要kernel32.dll,大部分需要user32.dll,
所以如果总是把它们两个映射到其首选地址, 那么加载器就不用修正
kernel32.dll 和user32.dll 中的任何(绝对)地址,加载时间就可以缩短。
让我们用下面的例子来结束这个讨论:
把一个APP.exe 的加载地址改为kernel32 的(/base:"0x77e80000")或user32
的(/base: "0x77e10000")首选地址。如果App.exe 没有引入UESE32,就强制
LoadLibrary。然后编译App.exe,并运行它。你会得到一个错误框(“非法的系
统DLL 重定位”),App.exe 无法被加载。
为什么?当一个进程被创建时,Win2000 和WinXP 的加载器会检查kernel32.dll
和user32.dll 是否被映射到它们的首选地址(它们的名称是被硬编码进加载器
的),如果没有,就会报错。在 WinNT4 中ole32.dll 也会被检查。在WinNT3.51
或更低版本中,则不会有任何检查,kernel32.dll 和user32.dll 可以被加载到
任何地方。唯一一个总是被加载到首选地址的模块是ntdll.dll,加载器并不检
查它,但是如果它不在它的首选地址,进程根本无法创建。
总结一下:在WinNT4 或更高版本的操作系统中:
● 总被加载到它们的首选地址的DLL 有: kernel32.dll , user32.dll 和
ntdll.dll。
●Win32 程序(连同csrss.exe)中一定存在的DLL:kernel32.dll 和ntdll.dll。
●所有进程中都存在的dll:ntdll.dll。
B) /GZ 编译开关
在Debug 时,/GZ 开关默认是打开的。它可以帮你捕捉一些错误(详细内容参考
文档)。但是它对我们的可执行文件有什么影响呢?
当/GZ 被使用时,编译器会在每个函数,包含函数调用中添加额外的代码(添加
到每个函数的最后面)来检查ESP 栈指针是否被我们的函数更改过。但是,等等,
ThreadFunc 中被添加了一个函数调用?这就是通往灾难的道路。因为,被复制
到远程进程中的ThreadFunc 将调用一个在远程进程中不存在的函数。
C) static 函数和增量连接(Incremental linking)
增量连接可以缩短连接的时间,在增量编译时,每个函数调用都是通过一个额外
的JMP 指令来实现的(一个例外就是被声明为static 的函数!)这些JMP 允许连
接器移动函数在内存中的位置而不用更新调用该函数的CALL。但是就是这个JMP
给我们带来了麻烦:现在ThreadFunc 和AfterThreadFunc 将指向JMP 指令而不
是它们的真实代码。所以,当计算ThreadFunc 的大小时:
const int cbCodeSize = ((LPBYTE) AfterThreadFunc - (LPBYTE) ThreadFunc);
实际得到的将是指向ThreadFunc 和AfterThreadFunc 的JMP 指令之间的“距离”。
现在假设我们的ThreadFunc 在004014C0,和其对应的JMP 指令在00401020
:00401020 jmp 004014C0
...
:004014C0 push EBP ; ThreadFunc 的真实地址
:004014C1 mov EBP, ESP
...
然后,
WriteProcessMemory( .., &ThreadFunc, cbCodeSize, ..);
将把“JMP 004014C0”和其后的cbCodeSize 范围内的代码而不是ThreadFunc
复制到远程进程。远程线程首先会执行“JMP 004010C0”,然后一直执行到这个
进程代码的最后一条指令(译者注:这当然不是我们想要的结果)。
然而,如果一个函数被声明为static,就算使用增量连接,也不会被替换为JMP
指令。这就是为什么我在规则#4 中说把ThreadFunc 和 AfterThreadFunc 声明为
static 或禁止增量连接的原因了。(关于增量连接的其他方面请参看Matt
Pietrek 写的“Remove Fatty Deposits from Your Applications Using Our
32-bit Liposuction Tools”)
D) 为什么ThreadFunc 只能有4K 的局部变量?
局部变量总是保存在栈上的。假设一个函数有256 字节的局部变量,当进入
该函数时(更确切地说是在functions prologue 中),栈指针会被减去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
请注意在上面的例子中ESP(栈指针)是如何被改变的。但是如果一个函数有多
于4K 的局部变量该怎么办?这种情况下,栈指针不会被直接改变,而是通过一
个函数调用来正确实现ESP 的改变。但是就是这个“函数调用”导致了ThreadFunc
的崩溃,因为它在远程进程中的拷贝将会调用一个不存在的函数。
让我们来看看文档关于栈探针(stack probes)和/Gs 编译选项的说明:
“/Gssize 选项是一个允许你控制栈探针的高级特性。栈探针是编译器插入到每
个函数调用中的一系列代码。当被激活时,栈探针将温和地按照存储函数局部变
量所需要的空间大小来移动。
如果一个函数需要大于size 指定的局部变量空间,它的栈探针将被激活。默认
的size 为一个页的大小(在80x86 上为4k)。这个值可以使一个Win32 程序和
Windows NT 的虚拟内存管理程序和谐地交互,在运行期间向程序栈增加已提交
的内存总数。
我能确定你们对上面的叙述(“栈探针将温和地按照存储函数局部变量所需要的
空间大小来移动”)感到奇怪。这些编译选项(他们的描述!)有时候真的让人很
恼火,特别是当你想真的了解它们是怎么工作的时候。打个比方,如果一个函数
需要12kb 的空间来存放局部变量,栈上的内存是这样“分配”的
sub esp, 0x1000 ; 先“分配”4 Kb
test [esp], eax ; touches memory in order to commit a
; new page (if not already committed)
sub esp, 0x1000 ; “分配”第二个 4 Kb
test [esp], eax ; ...
sub esp, 0x1000
test [esp], eax
注意栈指针是如何以4Kb 为单位移动的,更重要的是每移动一步后使用test 对
栈底的处理(more importantly, how the bottom of the stack is "touched"
after each step)。这可以确保了在“分配”下一个页之前,包含栈底的页已经
被提交。
继续阅读文档的说明:
“每一个新的线程会拥有(receives)自己的栈空间,这包括已经提交的内存和
保留的内存。默认情况下每个线程使用1MB 的保留内存和一个页大小的以提交内
存。如果有必要,系统将从保留内存中提交一个页。”(看MSDN 中GreateThread
> dwStackSize > “Thread Stack Size”)
..现在为什么文档中说“这个值可以使一个Win32 程序和Windows NT 的虚拟内
存管理程序和谐地交互”也很清楚了。
E) 为什么我要把多于3 个case 分支的swith 分割开来呢?
同样,用例子来说明会简单些:
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;
}
将会被编译为类似下面的代码:
Address OpCode/Params Decoded instruction
--------------------------------------------------
; 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 to one of the addresses in table <B>***</B>
; note that ECX contains the offset
: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
看到switch-case 是如何实现的了吗?
它没有去测试每个case 分支,而是创建了一个地址表(address table)。我们
简单地计算出在地址表中偏移就可以跳到正确的case 分支。想想吧,这真是一
个进步,假设你有一个50 个分支的switch 语句,假如没有这个技巧,你不的不
执行50 次CMP 和JMP 才能到达最后一个case,而使用地址表,你可以通过一次
查表即跳到正确的case。使用算法的时间复杂度来衡量:我们把O(2n)的算法
替换成了O(5)的算法,其中:
1. O 代表最坏情况下的时间复杂度。
2. 我们假设计算偏移(即查表)并跳到正确的地址需要5 个指令。
现在,你可能认为上面的情况仅仅是因为case 常量选择得比较好,(1,2,3,4,
5)。幸运的是,现实生活中的大多数例子都可以应用这个方案,只是偏移的计算
复杂了一点而已。但是,有两个例外:
●如果少于3 个case 分支,或
●如果case 常量是完全相互无关的。(比如 1, 13, 50, 1000)。
最终的结果和你使用普通的if-else if 是一样的。
有趣的地方:如果你曾经为case 后面只能跟常量而迷惑的话,现在你应该知道
为什么了吧。这个值必须在编译期间就确定下来,这样才能创建地址表。
回到我们的问题!
注意到0040100C 处的JMP 指令了吗?我们来看看Intel 的文档对十六进制操作
码FF 的说明:
Opcode Instruction Description
FF /4 JMP r/m32 Jump near, absolute indirect,
address given in r/m32
JMP 使用了绝对地址!也就是说,它的其中一个操作数(在这里是0040102C)代
表一个绝对地址。还用多说吗?现在远程的ThreadFunc 会盲目第在地址表中
004101C 然后跳到这个错误的地方,马上使远程进程挂掉了。
F) 到底是什么原因使远程进程崩溃了?
如果你的远程进程崩溃了,原因可能为下列之一:
1. 你引用了ThreadFunc 中一个不存在的字符串。
2. ThreadFunc 中一个或多个指令使用了绝对寻址(看附录E 中的例
子)
3. ThreadFunc 调用了一个不存在的函数(这个函数调用可能是编译
器或连接器添加的)。这时候你需要在反汇编器中寻找类似下面的代码:
:004014C0 push EBP ; entry point of ThreadFunc
:004014C1 mov EBP, ESP
...
:004014C5 call 0041550 ; 在这里崩溃了
; remote process
...
:00401502 ret
如果这个有争议的CALL 是编译器添加的(因为一些不该打开的编译开关比如/GZ
打开了),它要么在ThreadFunc 的开头要么在ThreadFunc 接近结尾的地方
不管在什么情况下,你使用CreateRemoteThread & WriteProcessMemory 技术时
必须万分的小心, 特别是编译器/ 连接器的设置, 它们很可能会给你的
ThreadFunc 添加一些带来麻烦的东西。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值