C++ 风格的函数拦截






4.84/5 (27投票s)
本文介绍一种更安全的 C++ 编程风格来拦截函数。
引言
如果你要修改某个函数,很容易出错。编译器无法帮助你找到这些错误。大多数时候,你会在运行时遇到崩溃,并伴随一个难看的错误框。更糟的是,如果你全局修改函数,可能会对操作系统造成危险。这就是我创建了一个类来帮助我更安全地完成这项工作的原因。我不太确定重写可执行代码如何才能安全,但我认为一些问题可以在编译时避免。
另一方面,你可能希望在执行某个函数时设置一个补丁,在执行其他函数时移除补丁,在退出函数时自动移除补丁,永久设置补丁,立即应用补丁,或者准备稍后设置补丁。
谈论安全地重写可执行代码,甚至使用 OOP 来拦截函数,这有点滑稽。:) 但你会惊讶,这是可行的。C++ 的模板、封装和自动销毁功能确实让这件事容易多了。
Using the Code
你可以通过将 patcher.cpp 和 patcher.h 添加到你的 C++ 项目中来使用此代码。压缩包中不包含示例。
一些背景信息
这类错误可以分为三种(我们称之为3TM)
- 使用错误的调用约定,这与被修补函数的调用约定不同
- 传递错误的函数参数
- 使用错误的返回类型
很容易在编译时避免这些类型的错误。
让我们来看一下 winsock.h 中的 send/recv 函数
int PASCAL FAR recv (
                     IN SOCKET s,
                     OUT char FAR * buf,
                     IN int len,
                     IN int flags);
int PASCAL FAR send (
                     IN SOCKET s,
                     IN const char FAR * buf,
                     IN int len,
                     IN int flags);  
以及它们最简单的修补方法
int (PASCAL FAR *lpfn_send )( IN SOCKET s, IN const char FAR * buf,
				IN int len, IN int flags);
int PASCAL FAR my_send ( IN SOCKET s, IN const char FAR * buf, IN int len, IN int flags)
{
	return lpfn_send (s, buf, len, flags);
}
int (PASCAL FAR *lpfn_recv )( IN SOCKET s, OUT char FAR * buf, IN int len, IN int flags);
int PASCAL FAR my_recv ( IN SOCKET s, OUT char FAR * buf, IN int len, IN int flags)
{
	return lpfn_recv (s, buf, len, flags);
}
规则很简单。如果某个函数 send 的类型是 type_of_send,那么修补函数 my_send 必须使用相同的类型 type_of_send。蹦床函数 lpfn_send 也必须是 type_of_send 类型。如果另一个函数 recv 的类型是 type_of_recv,那么 patch 函数 my_recv 必须使用相同的类型 type_of_recv。trampoline 函数 lpfn_recv 也必须是 type_of_recv 类型。
以及修补本身(这还只是理论上的代码)
patch(send, my_send, lpfn_send);
patch(recv, my_recv, lpfn_recv);
你看,没有强制类型转换,没有警告,没有错误。编写起来容易得多,也根本不会出现 3TM。
该函数的原型是
template<class T> patch(T, T, T&);
这种结构可以帮助你避免 3TM。如果你在传递参数给函数时没有强制类型转换,那么你就无法编译出带有 3TM 的代码。
使用指针而不是引用时,差别不大
patch(send, my_send, &lpfn_send);
patch(recv, my_recv, &lpfn_recv); 
该函数原型是
template<class T> patch(T, T, T*);
这一切都是为了修补的安全性。
函数类型
关于函数类型规则的一些说明。当你通过 GetProcAddress 获取函数地址时,你可能会需要它。让我们看一个简单的 C++ 函数声明
int func(int, char*, void*);  
函数类型 T 将是
int (*)(int, char*, void*)
你必须声明一个正确类型的指针,并在进行 GetProcAddress 时正确地进行类型转换。
int (*func_ptr)(int, char*, void*);
func_ptr = (     int (*)(int, char*, void*)     ) GetProcAddress(hModule, "func_name");
一个更完整的函数声明是
return_type calling_convention function_name (list of types of arguments); 
创建函数类型的规则是
return_type (calling_convention *) (list of types of arguments) 
MSVC 中 C++ 函数的默认调用约定是 __cdecl。你可以省略它。但你也可以在声明中包含它。当你处理具有不同调用约定的函数时,必须包含调用约定。上面带有指针声明和类型转换的 LoadLibrary 的示例函数变为
int __cdecl func(int, char*, void*);
int (__cdecl* func_ptr)(int, char*, void*);
func_ptr = (  int (__cdecl*)(int, char*, void*)   )
		GetProcAddress(hModule, "func_name"); 
对于 send/recv 函数的示例,情况相同,如下所示:
//functions declarations in winsock.h
int PASCAL FAR recv (
                     IN SOCKET s,
                     OUT char FAR * buf,
                     IN int len,
                     IN int flags);
int PASCAL FAR send (
                     IN SOCKET s,
                     IN const char FAR * buf,
                     IN int len,
                     IN int flags);
///////////////////////////////////////////////////
//pointer and patch declarations in our application
//let's omit IN and OUT
int (PASCAL FAR *lpfn_send )( SOCKET , const char * , int , int) = send;
int PASCAL FAR my_send ( SOCKET s, const char * buf, int len, int flags);
int (PASCAL FAR *lpfn_recv )( SOCKET, char FAR *, int, int) = recv;
int PASCAL FAR my_recv ( SOCKET s, char FAR * buf, int len, int flags);
//////////////////////////////////////////////////
//pointer assigning with GetProcAddress:
HINSTANCE hInstanceWs2 = GetModuleHandleA("ws2_32.dll");
lpfn_send = (int(PASCAL FAR*)(SOCKET,const char*,int,int))
			GetProcAddress(hInstanceWs2, "send");
lpfn_recv = (int(PASCAL FAR*)(SOCKET,char*,int,int))
			GetProcAddress(hInstanceWs2, "recv");
//I don't advise to do this, but is ok, not a big deal:
lpfn_send = (int(__stdcall*)(SOCKET,const char*,int,int))
			GetProcAddress(hInstanceWs2, "send");
lpfn_recv = (int(__stdcall*)(SOCKET,char*,int,int))GetProcAddress(hInstanceWs2, "recv");
在大多数 WinAPI 函数的情况下,你不需要调用 GetModuleHandle/GetProcAddress,因为库和函数会加载到某些默认地址。因此,你不需要上面的类型转换。只需在初始化时或稍后分配函数地址即可。
lpfn_send = send;
lpfn_recv = recv; 
实际应用
是时候使用实际代码了,请看 CPatcher.zip。我创建了一个名为 CPatch 的简单类。现在,该类的两个构造函数与上面描述的理论函数 patch(...) 非常相似。
class CPatch
{
...
CPatch(){}
CPatch(CPatch&){}
...
public:
...
template<class TFunction>explicit CPatch
	(TFunction FuncToHook, TFunction MyHook, TFunction& NewCallAddress,
        	 bool patch_now = true, bool set_forever = false)
...
template<class TFunction>explicit CPatch
	(TFunction FuncToHook, TFunction MyHook, TFunction* NewCallAddress,
         bool patch_now = true, bool set_forever = false)
...
template<class TFunction>explicit CPatch(TFunction& NewCallAddress,
	TFunction MyHook, bool patch_now = true, bool set_forever = false)
...
template<class TFunction>explicit CPatch(TFunction* NewCallAddress,
	TFunction MyHook, bool patch_now = true, bool set_forever = false)
...
如何使用
让我们看看我们想要如何使用补丁。
立即在本地应用补丁,并在退出函数时自动移除。C++ 类的析构函数工作得非常好。
#include<winsock2.h>
#include "patcher.h"
int (PASCAL FAR *lpfn_send )( IN SOCKET s,
	IN const char FAR * buf, IN int len, IN int flags);
int PASCAL FAR my_send ( IN SOCKET s, IN const char FAR * buf,
	IN int len, IN int flags)
{
	return lpfn_send (s, buf, len, flags);
}
int (PASCAL FAR *lpfn_recv )( IN SOCKET s, OUT char FAR * buf,
	IN int len, IN int flags);
int PASCAL FAR my_recv ( IN SOCKET s, OUT char FAR * buf,
	IN int len, IN int flags)
{
	return lpfn_recv (s, buf, len, flags);
}
....
{
   CPatch patch_for_send(send, my_send, lpfn_send);
   CPatch patch_for_recv(recv, my_recv, lpfn_recv);
   //now the functions send and recv are patched
   .....
}//the patches are removed automatically
你可以尝试制造一些 3TM 来看看编译器不允许你这样做。试着用 PASCAL 以外的约定。或者尝试改变参数的类型。或者错误地尝试将 recv 用于 send。
patch_for_send(send, my_recv, lpfn_send); 
也许你已经注意到了两对构造函数。一对使用引用。另一对使用指针。所以,如果你喜欢使用指针,你可以这样做:
CPatch patch_for_send(send, my_send, &lpfn_send);
CPatch patch_for_recv(recv, my_recv, &lpfn_recv);   
在两个构造函数调用内部,调用相同的非模板和受保护函数 HookFunction。唯一的区别是,在第一种变体中,使用了 &NewCallAddress,在第二种变体中,使用了 NewCallAddress。
你可以将蹦床指针初始化为实际函数,即 lpfn_send = send 和 lpfn_recv = recv。
int (PASCAL FAR *lpfn_send )( IN SOCKET s, IN const char FAR * buf,
		IN int len, IN int flags)
                     = send;
int (PASCAL FAR *lpfn_recv )( IN SOCKET s, OUT char FAR * buf, IN int len, IN int flags)
                     = recv; 
在这种情况下,你可以使用另外两个构造函数,它们更简短。
CPatch patch_for_send(lpfn_send, my_send);
CPatch patch_for_recv(lpfn_recv, my_recv);
现在,你可以随意多次应用和移除 patch。
  //we do not need patches
  patch_for_send.remove_patch();
  patch_for_recv.remove_patch();
  //we need the patches right now! :)
  patch_for_send.set_patch();
  patch_for_recv.set_patch();
  //we do not need patches
  patch_for_send.remove_patch();
  patch_for_recv.remove_patch();
  //we need patches again
  patch_for_send.set_patch();
  patch_for_recv.set_patch();
}
//if you've forgotten to remove some patches, not a crime.
//Destructor does this automatically for you
如果你想永久移除 patch,请将 true 传递给默认参数,该参数默认为 false。
patch_for_send.remove_patch(true); //true = 4ever 
现在让我们使用一个准备好的 patch。它不会立即应用。
CPatch patch_for_send(send, my_send, lpfn_send, false);
CPatch patch_for_recv(recv, my_recv, &lpfn_recv, false);
//so, patch is ready but is not applied. Call set_patch to apply it.
或
CPatch patch_for_send(&lpfn_send, my_send, false);
CPatch patch_for_recv(lpfn_recv, my_recv, false); 
如果你想永久设置 patch,请将最后一个参数设置为 true。这是默认参数,默认为 false。
//apply immediately, once forever:
CPatch patch_for_send(&lpfn_send, my_send, false, true);
//apply later, once forever:
CPatch patch_for_recv(lpfn_recv, my_recv, true, true); 
现在析构函数不会移除 patch。但你仍然可以通过 remove_patch 函数来完成。
成员函数 patched() 告诉你补丁是否已设置。如果你调用 set_patch,则函数返回 true。如果你移除 patch,函数返回 false。函数 ok() 告诉你补丁生成是否可以创建,以及是否可以重写可执行指令。如果 ok() 返回 false,那么 set_patch/remove_patch 将无效。
顺便说一下,这种方法不影响 patch 的性能。
所有其他函数都是 protected 或 private。我认为你不需要直接调用它们。但如果你确实需要,你可以修改代码使它们变成 public。
拦截类函数
很久以前,我曾想拦截类函数。经过几次修改,我终于通过这个修补器做到了。假设我们有一个我们想要修补的 class 函数。
class aclass
{
public:
	void doSomething(int a, int b)
	{
		wcout<< L"void doSomething("<< a<<L", "<< b<< L")"<< endl;
	}
};
因为成员函数是特殊调用的,并且会传递一个隐藏的参数 this,所以我们需要遵循相同的逻辑。我们将创建一个类,并将补丁放在这里。通过这种简单的方式,我们将避免所有因调用方式不当而产生的问题。
void (aclass::*pfn_doSomething)(int, int) = &aclass::doSomething;
class class_for_patches
{
public:
	void my_doSomething (int a, int b)
	{
		wcout<< L"patch void doSomething("<< a<< L","<< b<< L")"<< flush;
		a += b; //do anything
		b++;
		(reinterpret_cast<aclass*>(this)->*pfn_doSomething)(a, b);
	}
};
//.....
int main()
{
	//Let's see how it works
	CPatch patch_aclass_doSomething       
		( pfn_doSomething,        &bclass::my_doSomething );
	aclass x;
	x.doSomething(123, 234);
	//...
为此,我添加了几个新的构造函数。你可以查看 patcher_defines.h 文件,位于 CPatcher.zip 中。
工作原理
如果你想了解补丁是如何应用/移除的,请查看 CPatch::set_patch/CPatch::remove_patch 函数的实现。这些函数会替换函数开头的可执行指令,并用一个 jmp 指令跳转到钩子函数。我试图让它们尽可能简单。
大部分的“手术”都在 CPatch::HookFunction(...) 中完成。此函数为蹦床生成可执行指令,计算 jmp 指令的偏移量,并在对象打算立即设置 patch 时调用 set_patch。它只在构造函数中调用一次。关于 jmp 指令,你还需要知道的是,jmp 的地址是相对于 jmp 指令末尾第一个字节的。简而言之,它相对于 jmp 的地址 + 5。
函数 okToRewriteTragetInstructionSet 解析被修补函数启动处的指令集。它返回重写所需的最少字节数 N。这个 N 必须不小于 5 字节,因为 jmp 指令需要 5 字节。蹦床函数包含正好 N + 5 字节,这些字节填充了被修补函数启动处的前 N 个字节,以及一个跳转到被修补函数下一条指令的 jmp。这样你就可以调用蹦床函数而不是原始的被修补函数了。我认为 okToRewriteTragetInstructionSet 可能不完整。我只填充了调试时发现的指令。对于大多数 WinAPI 函数,这应该足够了。但如果不够,你可以在 okToRewriteTragetInstructionSet 中添加新的指令。你只需要从调试器中复制/粘贴即可。
出于安全原因,蹦床函数分配在单独的堆中。我们必须设置一些 PAGE_EXECUTE* 属性。否则,蹦床将无法工作。如果我们通过 VirtualProtect 请求此属性,则属性将设置到整个堆。将默认堆的属性更改为 PAGE_EXECUTE* 可能会导致各种漏洞。
修补其他进程(注入 DLL)
此修补器不仅可以在当前应用程序中使用,你还可以创建一个 DLL 并将其注入到某个进程中。
将 DLL 注入到某个进程中非常简单。
首先,你需要打开该进程。你可以在 Windows 任务管理器中找到进程 ID。
你还可以使用 EnumProcesses 来查找 ProcessID,打开每个进程,并使用 GetModuleBaseName 检查名称。
如果你使用 CreateProcess 来启动你的进程,那么你已经在返回的 PROCESS_INFORMATION 中获得了进程 ID。但在这种情况下,你不需要它。你已经有了 PROCESS_INFORMATION 的 hProcess 成员。因为你已经有了 hProcess,所以现在不需要调用 OpenProcess。
既然你已经获得了进程 ID(PID),打开进程,在该进程中分配一个缓冲区,然后使用 CreateRemoteThread 调用 LoadLibrary 到该进程中,这样就完成了。
HANDLE hProcess = OpenProcess(
        PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
         PROCESS_VM_OPERATION |PROCESS_VM_WRITE | PROCESS_VM_READ |
         PROCESS_TERMINATE , FALSE, PID);
if(hProcess)
{
   #define INJECTED_DLL L"InjectDllTest.dll"
   DWORD dwThreadId;
   LPVOID pvProcMem = VirtualAllocEx(hProcess, 0, sizeof(INJECTED_DLL),
                                           MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
   SIZE_T written;
   WriteProcessMemory(hProcess, pvProcMem, INJECTED_DLL, sizeof(INJECTED_DLL), &written);
   DWORD oldProtectRemoteThreadProc;
   HANDLE hThread = CreateRemoteThread(hProcess, 0, 0,
                                          (LPTHREAD_START_ROUTINE)LoadLibraryW,
                                          pvProcMem, 0, &dwThreadId);
   DWORD wtso = WAIT_FAILED;
   wtso = WaitForSingleObject(hThread, 1000);
   //check wtso to be sure what you code is successfully injected
请不要使用 #define 和 sizeof 来传递要注入的 DLL 的名称。这段代码只是为了尽可能简单。
如果你需要为操作系统设置全局补丁,那么你必须重写包含该函数的 DLL 导出表中的函数入口。
待续...


