Windows Hook案例分析与技术探索

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

1. Windows Hook基本介绍

        Win Hook——Windows中提供的一种用以替换DOS下“中断“的系统机制中文译为“挂钩”或“钩子”。在对特定的系统事件进行Hook后一旦发生Hook事件对该事件进行Hook的程序就会收到系统的通知 这时程序就可以在第一时间对该事件做出响应。
         钩子实际上是一个处理消息的程序段通过系统调用把它挂入系统。每当特定的消息发出在没有到达目的程序前钩子程序就先捕获该消息亦即钩子函数先得到控制权。这时钩子函数即可以加工处理改变该消息也可以不作处理而继续传递该消息还可以强制结束消息的传递。
        Hook 技术按照实现原理来分的话可以分为API Hook和消息Hook按照作用范围来分可以分为全局Hook和局部Hook按照权限来分可以分为应用层Ring3Hook和内核层Ring0Hook。如下图1所示。
      

        
        应用层Hook适用于x86和x64而内核层Hook一般仅在x86平台适用因为从Windows Vista的64版 本开始引入的Patch Guard (一种Windows内核保护机制防止非授权的第三方应用篡改Windows内核) 技术极大地限制了Windows x64内核挂钩的使用。

2. Windows Hook实现原理

        Hook技术被广泛应用于安全的多个领域比如杀毒软件的主动防御功能涉及到对一些敏感API的监控就需要对这些API进行Hook窃取密码的木马病毒为了接收键盘的输入需要Hook键盘消息甚至是操作系统及一些应用程序在打补丁时也是通过Hook技术。接下来我们就来了解下Hook技术的原理。举个例子一般对于线上App出现bug的时候有一种快速解决的方案——热修复底层机制就是下面要说的HotFix Hook。
        提到Hook不得不提一下DLL注入——为了达到某种目的我们通常需要将一个DLL注入到另外一个进程的地址空间中去 一旦注入成功就可以在这个进程中随心所欲肆意妄为了。所谓逆向指的是在没有别人的源代码的情况下去破解。那么在Windows下面DLL注入很自然的成为一种破解手段。
        DLL注入跟Hook有什么关系呢 很显然Hook是原理是实现DLL注入技术手段的一种方式。而DLL注入跟逆向、破解殊途同归。
        我们知道系统函数都是以DLL封装起来的应用程序应用到系统函数时首先把该DLL加载到当前的进程空间中调用的系统函数的入口地址可以通过 GetProcAddress函数进行获取。当系统函数进行调用的时候首先把所必要的信息保存下来包括参数和返回地址等一些别的信息然后就跳转到函数的入口地址继续执行。其实函数地址就是系统函数“可执行代码”的开始地址。那怎么才能让函数首先执行我们的函数呢 很显然把开始的那段可执行代码替换为我们自己定制的一小段可执行代码这样系统函数调用时不就按我们的意图执行了吗
        通常我们这么干把系统函数的入口地方的内容替换为一条Jmp指令目的就是跳到我们的函数来执行。而Jmp后面要求的是相对偏移也就是我们的函数入口地址到系统函数入口地址之间的差异再减去这条指令的大小。用公式表达如下
        
DWORD nLen = UserFuncAddr – SysFuncAddr - 指令大小; 
Jmp nLen;
        函数里做完必要的处理后通常要回调原来的系统函数然后返回因为此时已经达到了目的。调用原来系统函数之前必须先把原来修改的系统函数入口地方给恢复这样对用户无感知。否则每次触发调用我们的Hook函数不但有可能被用户感知到进程异常而且容易被杀毒捕捉到行为异常。
          最终一次完整的执行流是这样的
        1我们的dll "注入" 外部进程
        2保存系统函数入口处的代码
        3替换掉进程中的系统函数入口指向我们的函数等待执行流
        4当系统函数被调用立即跳转到我们的函数
        5我们函数进行处理
        6恢复系统函数入口的代码
        7调用原来的系统函数
        8返回
        注意我们的核心目的只是需要让被注入进程载入我们的dll就可以了我们可以在dll实例化的时候进行API的Hook。举个例子鼠标钩子键盘钩子。我们可以给系统装一个鼠标钩子然后所有响应到鼠标事件的进程就会“自动”其实是系统处理了载入我们的dll然后设置相应的钩子函数。刚刚上面讲了DLL注入跟Hook的关系接下来我们看看DLL注入有哪些实现方式。如图2所示。

3. Windows Hook 案例分析

3.1 消息 Hook

3.1.1 原理
        先来了解下Windows消息过程
      1发生键盘输入事件时WM_KEYDOWN消息被添加到操作系统消息队列。
      2OS判断哪个应用程序中发生了事件然后从操作系统消息队列取出消息添加到相应应用序的消息队列中。
      3应用程序监视自身的消息队列发现新的WM_KEYDOWN消息后调用相应的事件处理程序处理。
        所以我们只需在操作系统消息队列和应用程序消息队列之间安装钩子即可窃取键盘消息并实现恶意操作。那么我们该如何安装这个消息钩子呢很简单Windows提供了一个官方函数
SetWindowsHookEx()用于设置消息Hook编程时只要调用该API就能简单地实现Hook。 消息Hook常被窃密木马用来监听用户的键盘输入程序里只需写入如下代码就能对键盘消息进行 Hook。
1 SetWindowsHookEx( 
2 WH_KEYBOARD,  // 键盘消息
3 KeyboardProc, // 钩子函数处理键盘输入的函数
4 hInstance,    // 钩子函数所在DLL的Handle 
5 0             // 该参数用于设定要Hook的线程ID为0时表示监视所有线程
6 )
        下面看一个案例。此案例使用了窗口挂钩将一个DLL注入到Explorer.exe的地址空间中。
3.1.2 案例
      1背景。
        很多公司平常开周会通常使用自己的电脑来接投影仪自己电脑最舒适的是x*y的分辨率具体以电脑属性为准。而大多数投影仪只支持较低的分辨率。那么在投影开始到结束这个过程中屏幕的分辨率变化过程x*y->x1*y1->x*y。
        于是引发了一个问题再更改显示器分辨率的时候有一件事情让我非常不喜欢桌面上的
图标记不住原来的位置。改了两次分辨率图标的位置都不是原来的位置了。很烦人 这里
也将通过这个案例来说明消息Hook的实现过程。
        方案是通过消息Hook的方式操作注册表这需要对注册表原理有一定的了解。实现了一
个MsgHook.exe跟一个DllInject.DLL当进程启动的时候会触发Save事件创建下面的注册表
\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Des ktop Item Position Saver
        MsgHook会为每个图标保存一个位置。比如周会接投影的时候设法触发Save事件保存之前
的图标开完会断开投影的时候触发Restore事件直接恢复原有图标。
        大多数的公共控件的窗口消息不能够跨进程(Windows的内建控件可以微软做了检查通
过内存映射支持了)为了让MsgHook能够按照上面方案运行我们必须将代码注入到Explorer
进程中因为只有它才能成功的将LVM_GETITEM和LVM_GETITEMPOSITION消息发送到桌面
的ListView控件。核心还是Explorer进程打开注册表项找到那些保存过位置的图标并将它们的
位置恢复到执行Save事件时它们所在的位置。
        测试机器Win10 x64 测试软件x64
      2实现。
         第一步找到桌面的ListView控件窗口我们通过Spy++查看当前系统ListView控件列表如下红框里面的是我们需要的窗口如图3所示。  
        如上图获取类别为ProgMan的窗口即使程序管理器(Program Manager)没有运行
Windows Shell 仍然会创建一个类别为ProgMan的窗口。其子窗口SHELLDLL_DefView-
>SysListView32。这个SysListView32窗口就是桌面的List View控件窗口。
// 获取类别(class)为ProgMan的窗口并校验
HWND hWnd = GetFirstChild(GetFirstChild(FindWindow(TEXT("ProgMan"), NULL))); 
chASSERT(IsWindow(hWnd));
        第二步DLl注入以及隐藏窗口创建。有了ListView控件窗口就可以通过
GetWindowThreadProcessId来确定创建该窗口的的线程的标识符。然后把线程id传给
SetMsgHook函数(DLL内部实现)。
        
// 设置将DLL注入资源管理器地址空间的钩子
chVERIFY(SetMsgHook(GetWindowThreadProcessId(hWnd, NULL)));
        SetMsgHook会给这个线程安装一个WH_GETMESSAGE挂钩并且调PostThreadMessage函数来强制唤醒Windows资源管理器指定线程。
// 这个线程ID是ListView的父线程的线程ID, 也就是Explorer进程的子线程
BOOL WINAPI SetMsgHook(DWORD dwThreadId) { 
    BOOL bOk = FALSE;
    if (dwThreadId != 0) { 
         // 校验是否已经注入
         chASSERT(g_hHook == NULL); 

         // 保存当前DLL线程ID, 当server窗口创建完成GetMsgProc函数会post消息到这个线程                     
         g_dwThreadIdHook = GetCurrentThreadId();

         // 给指定线程安装消息钩子
         g_hHook = SetWindowsHookEx(WH_GETMESSAGE, GetMsgProc, g_hInstDll, dwThreadId);

         bOk = (g_hHook != NULL);
         if (bOk) {
             // 此时, hook已经安装成功; 强行Post Msg到Explorer
             // 进程的子线程的消息队列触发间接调用Hook函数
             bOk = PostThreadMessage(dwThreadId, WM_NULL, 0, 0);
         }
     } else {
         chASSERT(g_hHook != NULL);
         bOk = UnhookWindowsHookEx(g_hHook);
         g_hHook = NULL;
     }

     return(bOk);
}
注意
由于我们已经在这个线程安装了挂钩因此操作系统会自动地将DllInject.dll注入到Windows资源管理器(Explorer.exe)进程的地址空间并调用我们的GetMsgProc函数。
        这个函数首先会检查它是否是第一次被调用如果是第一次那么他会创建一个标题为
Wintellect Hook.的隐藏窗口。并唤醒MsgHook进程。
// 注意这个线程属于Explorer进程
LRESULT WINAPI GetMsgProc(int nCode, WPARAM wParam, LPARAM lParam) {
    static BOOL bFirstTime = TRUE; 

    if (bFirstTime) { 
        bFirstTime = FALSE; 
         // 创建Hook服务窗口处理客户端请求
         CreateDialog(g_hInstDll, MAKEINTRESOURCE(IDD_HOOK), NULL, Dlg_Proc);

         // 唤醒MsgHook进程
         PostThreadMessage(g_dwThreadIdHook, WM_NULL, 0, 0);
    }

     return(CallNextHookEx(g_hHook, nCode, wParam, lParam));
}
        这个隐藏窗口是Windows的资源管理器的线程创建的。在这个过程中MsgHook线程已经从
SetMsgHook调用中返回并接着调用GetMessage函数将线程切换到睡眠状态直到它的消息队
列有消息到达。消息到达之后MsgHook.exe主线程被唤醒此时已经知道了服务器隐藏对话
框已经创建完成于是找到该窗口的句柄。至此就可以通过窗口消息在MsgHook进程跟服务
器隐藏对话框之间进行通信了。
        
// 等待Hook服务窗口创建
MSG msg; 
GetMessage(&msg, NULL, 0, 0);

// 找到隐藏的服务窗口句柄
HWND hWndHook = FindWindow(NULL, TEXT("Wintellect Hook")); 

// 确定窗口是否创建
chASSERT(IsWindow(hWndHook));
        第三步触发效果。界面上用户选择S/R发送SendMessage消息给服务器隐藏对话框
这里使用SendMessage为了让那边处理完数据并且返回。因为后面直接会调用Close跟卸载钩
子。
        
// 告诉服务窗口 ListView 窗口的元素需要Save或者Restore 
BOOL bSave = (cWhatToDo == TEXT('S')); 
SendMessage(hWndHook, WM_APP, (WPARAM) hWnd, bSave);
        Dlg_Pro函数接收到消息对应处理保存/恢复注册表项的逻辑。
 
INT_PTR WINAPI Dlg_Proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
    switch (uMsg) { 
        chHANDLE_DLGMSG(hWnd, WM_CLOSE, Dlg_OnClose); 
        case WM_APP: 
            if (lParam) 
                SaveListViewItemPositions((HWND) wParam); 
            else
                RestoreListViewItemPositions((HWND) wParam);
            break;
    }    

    return(FALSE);
}
        注册表的保存与恢复逻辑代码就不贴出来了都是相关API操作比较简单。 保存是创建一
个key保存原始注册表内部信息恢复的时候从key里面读取信息。 最后别忘记关闭对话框跟卸载钩子。注意关闭对话框穿的是00是一个标记用来告诉函数把已经安装WH_GETMESSAGE挂钩清除。当挂钩清楚后操作系统会自动的从 Windows资源管理器的地址空间中DllInject.Dll同时释放对应空间。注意要先销毁对话框再卸载钩子。否则对话框收到的下一条消息会导致Windows资源管理器崩溃。
        
// 通知Hook窗口关闭, 必须先销毁对话框再清除挂钩
SendMessage(hWndHook, WM_CLOSE, 0, 0);
 
chASSERT(!IsWindow(hWnd)); 

// 卸载钩子, 从Explorer进程的地址空间移除Hook对话框
SetMsgHook(0);
        成功之后最终效果就是屏幕切换->切回分辨率就不会有图标位置变化的情况出
现了。另外杀毒软件必须卸载防火墙关闭x32跟x64程序不能相互注入。

3.2 API Hook

        所谓API Hook就是利用某种技术将API的调用转为我们自己定义的函数的调用。这种技
术在实际项目里面的应用也是很广泛。
3.2.1 调试 Hook
        1原理。
        先了解一点调试器的知识。调试器用来确认被调试者是否正确运行发现未知的错误。调试器能够逐一执行被调试者的指令拥有对调试器与内存的所有访问权限。每当被调试者发生调试事件时OS就会暂停并向调试器报告此事件调试器做适当处理后会使被调试进程继续行。
        调试器可以在被调试进程中执行许多特殊的操作。系统载入一个被调试程序的时候会在被
调试程序的地址空间准备完毕之后但被调试程序的主线程尚未开始执行任何代码之前自动通
知调试器。这时调试器可以将一些代码注入到被调试的程序的地址空间中(比如使用WriteProce
ssMemory)然后让被调试程序的主线程去执行这些代码。在默认情况下如果调试器终止那么Windows会自动终止被调试程序。但是调试器可以通过DebugSetProcessKillOnExit并传入false来改变默认的行为。在不终止一个进程的前提下停止调试改进程也是有可能的这要归功于DebugActiveProcessStop函数。
        调试器能够逐一执行被调试者的指令拥有对调试器与内存的所有访问权限这一句说明了调
试Hook的可行性。我们使用调试Hook就相当于实现了一个最简单的调试器而对被调试进程
进行修改内存是很正常的事件因此被查杀可能性低。不足就是这种Hook方式会导致程序运行
速度变慢不适用于大型程序。
        下面用两张图说明调试器工作原理如图4和图5所示

        2案例。
        思路是伪装成调试器行为进行Hook采用对目标API入口地址下断点当调用时即断下截获参数内容后进行修改。这时可以通过修改eip的值让其跳转到任意地址。这种方式没有文件等其他操作。它就相当于实现了一个最简单的调试器。流程为在“调试器—被调试者”的状态下将被调试者的API起始部分修改为0xCC控制权转移到调试器后执行指定操作最后使被调试者重新进入运行状态。
        具体的实现流程如下
        1、对想要钩取的进程进行附加操作(DebugActiveProcess)使之成为被调试者。
        2、将要钩取的API的起始地址的第一个字节修改为0xcc(或者使用硬件断点)。
        3、当调用目标API的时候控制权就转移到调试器进程。
        4、执行需要的操作。
        5、脱钩将API 函数的第一个字节恢复。
        6、运行相应的API。
        7、再次修改为0xCC为了继续钩取。
        8、控制权返还被调试者。
注意
测试机器Win10 x64测试软件x86
        
        3实战。
        我们以钩取的pcs_subject.exe进程的WriteFile() API为例来说明问题这个API 在pcs_subject.exe进程写本地log的时候会触发。实现功能为在保存文件时操作输入参数将小写字母全部换成'@'字符使得log文件被破坏。也就是说在pcs_subject.exe中写log文件时输入一次的小写字母全部变成'@'字符然后再保存。
        
        首先需要启动进程获得pcs_subject.exe的PID附加为被调试进程。如图PID为11620
控制台输入进程id调用DebugActiveProcess即可等待调试事件触发如图6所示。

 

// 附加进程, 将目标进程附加在当前进程准备进行调试
// DebugActiveProcess是一个函数程序使调试器附加到一个活动进程并且调试它。
if (!DebugActiveProcess(dwProcessID)) { 
    printf("DebugActiveProcess(%d) failed!!!\n" 
    "Error Code = %d\n", dwProcessID, GetLastError()); 
    return 1; 
}
        开始调用WaitForDebugEvent等待调试事件的触发首次会进到创建进程的调试事件。
DEBUG_EVENT DebugEvent; 
DWORD dwContinueStatus; // 等待调试事件

while (WaitForDebugEvent(&DebugEvent, INFINITE)) { 
    dwContinueStatus = DBG_CONTINUE; // 调试事件为创建进程, 首次
    
    if (CREATE_PROCESS_DEBUG_EVENT == DebugEvent.dwDebugEventCode) {                                          
        OnCreateProcessDebugEvent(&DebugEvent); 
    }
}
        此处是核心内容 首先我们需要清楚如何知道以及如何获取 WriteFile函数的这里推荐一
个监视进程动作的工具叫 Process Monitor。我们通过这个工具监视pcs_subject.exe进程在
保存文件的时候调用的API是哪个。以及这个API所属模块。如图7所示。

        然后获取WriteFile函数的地址先保存原函数的首地址写入0xCC(调试中断指令如果
CPU意外执行这样的指令证明程序哪里出错了所以中断。)下软件断点。等待UI触发。
LPVOID WriteFileAddress = NULL;
CREATE_PROCESS_DEBUG_INFO CreateProcessDebugInfomation;
BYTE INT3 = 0xCC, OldByte = 0;

BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pDebugEvent)
{
	// WriteFile()函数地址
	WriteFileAddress = GetProcAddress(GetModuleHandleA("kernelbase.dll"), "WriteFile");                         // 获得WriteFile()的地址

	// 将WriteFile()函数的首个字节改为0xCC
	memcpy(&CreateProcessDebugInfomation, &pDebugEvent->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO));
	ReadProcessMemory(CreateProcessDebugInfomation.hProcess, WriteFileAddress, &OldByte, sizeof(BYTE), NULL); // 保存原函数首地址的首字节
	WriteProcessMemory(CreateProcessDebugInfomation.hProcess, WriteFileAddress, &INT3, sizeof(BYTE), NULL);   // 写入0xCC(调试中断指令)下软件断点。

	return TRUE;
}

        只要pcs_subject.exe有写log操作就会触发调用WriteFile函数从而触发软中断指令。

// 调试事件入口, 需要被调试进程触发 只要触发WriteFile()函数地址就会进来
else if (EXCEPTION_DEBUG_EVENT == DebugEvent.dwDebugEventCode) {
		if (OnExceptionDebugEvent(&DebugEvent))
			continue;
}

        接着就是具体实现了。先恢复以免进入死循环 主要是为了避免多次进入。

BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pDebugEvent)
{
	CONTEXT Context;
	PBYTE lpBuffer = NULL;
	DWORD dwNumOfBytesToWrite, dwAddrOfBuffer, i;
	PEXCEPTION_RECORD pExceptionRecord = &pDebugEvent->u.Exception.ExceptionRecord;

	// 软件终端异常
	if (EXCEPTION_BREAKPOINT == pExceptionRecord->ExceptionCode)
	{
		// 确认发生异常的地方是否为我们要钩取的WriteFile()函数
		if (WriteFileAddress == pExceptionRecord->ExceptionAddress)
		{
			// 1. Unhook 先恢复以免进入死循环 主要是为了避免多次进入
			WriteProcessMemory(CreateProcessDebugInfomation.hProcess, WriteFileAddress,
				&OldByte, sizeof(BYTE), NULL);

        获得线程上下文修改Eip的值来使进程恢复正常运行。

// 2. 获得线程上下文修改Eip的值使进程恢复正常运行
Context.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(CreateProcessDebugInfomation.hThread, &Context);

        根据ESP寄存器来获得WriteFile()函数的参数以达到修改数据的目的。

// 3. 根据ESP寄存器来获得WriteFile()函数的参数以达到修改数据的目的
/* 
BOOL WriteFile(
HANDLE  hFile,//文件句柄
LPCVOID lpBuffer,//数据缓存区指针
DWORD   nNumberOfBytesToWrite,//你要写的字节数
LPDWORD lpNumberOfBytesWritten,//用于保存实际写入字节数的存储区域的指针
LPOVERLAPPED lpOverlapped//OVERLAPPED结构体指针
);
*/
ReadProcessMemory(CreateProcessDebugInfomation.hProcess, (LPVOID)(Context.Esp + 0x8), // 此参数是存缓冲区的起始地址
	&dwAddrOfBuffer, sizeof(DWORD), NULL);
ReadProcessMemory(CreateProcessDebugInfomation.hProcess, (LPVOID)(Context.Esp + 0xC), // 此参数是存缓冲区的大小
	&dwNumOfBytesToWrite, sizeof(DWORD), NULL);

        获取数据缓冲区的地址和大小。

// 4. 获取数据缓冲区的地址和大小
lpBuffer = (PBYTE)malloc(dwNumOfBytesToWrite + 1);
memset(lpBuffer, 0, dwNumOfBytesToWrite + 1);

        将其内容读到调试器进程空间控制台打印。

// 5. 将其内容读到调试器进程空间
ReadProcessMemory(CreateProcessDebugInfomation.hProcess, (LPVOID)dwAddrOfBuffer,
	lpBuffer, dwNumOfBytesToWrite, NULL);
printf("\n### original string ###\n%s\n", lpBuffer);

        修改数据:把小写字母改为'@'字符控制台打印。

//6. 修改数据:把所有小写字母改为'@'字符
for (i = 0; i < dwNumOfBytesToWrite; i++) {
	if (0x61 <= lpBuffer[i] && lpBuffer[i] <= 0x7A)
		//lpBuffer[i] -= 0x20;
		lpBuffer[i] = '@';
}

printf("\n### converted string ###\n%s\n", lpBuffer);

        将修改后的数据写回进程的地址空间如图8所示。

// 7. 然后将修改后的大写字母覆写到原位置。
WriteProcessMemory(CreateProcessDebugInfomation.hProcess, (LPVOID)dwAddrOfBuffer,
	lpBuffer, dwNumOfBytesToWrite, NULL);
free(lpBuffer);

        两次打印对比如下图发先log已经被修改了。

        脱钩将API 函数的第一个字节恢复。把线程上下文的EIP地址修改为WriteFile()的起始地址注意EIP当前的值为0xcc的下一条指令的地址运行相应的API。 

// 设置EIP的值来实现正常运行注意EIP的值为0xCC的下一条指令的地址。
Context.Eip = (DWORD)WriteFileAddress;
SetThreadContext(CreateProcessDebugInfomation.hThread, &Context);
// 运行
ContinueDebugEvent(pDebugEvent->dwProcessId, pDebugEvent->dwThreadId, DBG_CONTINUE);
Sleep(0);

        再次修改为0xCC为了继续钩取

// 再次钩取
WriteProcessMemory(CreateProcessDebugInfomation.hProcess, WriteFileAddress,
	&INT3, sizeof(BYTE), NULL);

        至此我们成功实现了基于调试技术的API Hook。当然通过这种方式可以Hook的进程很多在这里只讲一个基础的例子有时间大家可以自己去尝试。

3.2.2 注入 Hook

        以下三种Hook形式本质上都是通过改写函数的入口地址使得执行流切换到自定义函数。

1InLine Hook

        1原理。

        内联Hook直接修改内存中的任意函数的代码将其劫持至Hook API。它的适用范围更广比较简单因为只要是内存中有的函数它都能Hook。

        2案例。

        效果为以下将用一个demo简单说明Inline Hook的基本原理。很简单没有DLL注入仅仅是Hook了我自己的一个模块的API修改接口计算结果这里先看下demo效果图下面将会贴上代码以及详细解析如图9所示。

测试机器Win10 x64测试软件x86

        3实现。

        add.dll实现add函数返回两个int值相加后的结果 Hook.dll实现了具体Hook细节含安装卸载钩子以及Hook函数的实现CallAdd进程实现了加载dll UI入口。

        首先我们先要找到需要Hook的函数原型(不同的调用约定下的函数修饰后的符号有区别) Windows下可以用这个命令获取Dll所有导出符号找到自己想要的就行dumpbin /exports 目录/文件.dll,结果如图10所示。

 

        接下来看下add.dll的导出接口这个就是我们后面即将Hook的接口导出符号如上图。 

#ifdef ADD_EXPORTS
#define ADD_API __declspec(dllexport)
#else
#define ADD_API __declspec(dllimport)
#endif

#ifdef __cplusplus //如果是c++文件就将endif内的代码用c编译器编译
extern "C" {
#endif
    __declspec(dllexport) int WINAPI add(int a, int b) //__declspec(dllexport)  声明此函数为导出函数
    {
        return a + b;
    }
#ifdef __cplusplus
}
#endif

        接着点击"开启钩子"按钮开始加载Hook.dll

HINSTANCE hinst = NULL;
void CCallAddDlg::OnBnClickedButtonStartHook()
{
	typedef BOOL(CALLBACK* inshook)(); // 函数原型定义
	inshook insthook;

	hinst = LoadLibrary(_T("Hook.dll")); // 加载dll文件
	if (hinst == NULL)
	{
		AfxMessageBox(_T("no Hook.dll!"));
		return;
	}

        dll初始化开始安装钩子。

// CHookApp 初始化
BOOL CHookApp::InitInstance()
{
	CWinApp::InitInstance();

	// 获得dll 实例进程句柄
	hinst = ::AfxGetInstanceHandle();
	DWORD dwPid = ::GetCurrentProcessId();
	hProcess = OpenProcess(PROCESS_ALL_ACCESS, 0, dwPid);

	// 调用注射函数
	Inject();
	return TRUE;
}

        接下来就是比较核心的组织汇编代码、替换函数地址的逻辑了。 保证只注射一次获取_add@8符号对应的地址先保存这个地址将JMP指令0xE9存入NewCode的首地址然后将MyAdd的地址拼接进去。然后就可以开启钩子了。

void Inject()
{
	if (m_bInjected == false)
	{   // 保证只调用1次
		m_bInjected = true;

		// 获取add.dll中的add()函数
		HMODULE hmod = ::LoadLibrary(_T("add.dll"));
		if (hmod == NULL) {
			return;
		}

		add = (AddProc)::GetProcAddress(hmod, "_add@8");
		pfadd = (FARPROC)add;

		if (pfadd == NULL)
		{
			AfxMessageBox(L"cannot locate add()");
		}

		// 将add()中的入口代码保存入OldCode[]
		_asm
		{
			lea edi, OldCode
			mov esi, pfadd
			cld
			/*
			movsd(dword==>四个字节)
			movsw(word==>两个字节)
			movsb(byte==>一个字节)
			*/
			movsd
			movsb
		}

		NewCode[0] = 0xe9; // 实际上0xe9就相当于jmp指令
		
		// 获取Myadd()的相对地址
		_asm
		{
			lea eax, Myadd
			mov ebx, pfadd
			sub eax, ebx
			sub eax, 5
			mov dword ptr[NewCode + 1], eax
		}

		// 填充完毕现在NewCode[]里的指令相当于Jmp Myadd
		HookOn(); // 可以开启钩子了
	}
}

        下面是开启钩子的代码如下

// 开启钩子的函数
void HookOn()
{
	ASSERT(hProcess != NULL);

	DWORD dwTemp = 0;
	DWORD dwOldProtect;

	// 将内存保护模式改为可写,老模式保存入dwOldProtect
	VirtualProtectEx(hProcess, pfadd, 5, PAGE_READWRITE, &dwOldProtect);
	// 将所属进程中add()的前5个字节改为Jmp Myadd 
	WriteProcessMemory(hProcess, pfadd, NewCode, 5, 0);
	// 将内存保护模式改回为dwOldProtect
	VirtualProtectEx(hProcess, pfadd, 5, dwOldProtect, &dwTemp);

	bHook = true;
}

        钩子开启完成之后回来继续点击"执行函数"按钮此时add的地址已经被修改了。

void CCallAddDlg::OnAddBnClickedButton()
{
	HINSTANCE hAddDll = NULL;
	typedef int (WINAPI* AddProc)(int a, int b); // 函数原型定义
	AddProc add;
	
	if (hAddDll == NULL)
	{
		hAddDll = ::LoadLibrary(_T("add.dll")); // 加载dll
	}
	
	if (hAddDll == NULL) {
		return;
	}

	add = (AddProc)::GetProcAddress(hAddDll, "_add@8"); // 获取函数add地址

	int a = 1;
	int b = 2;
	int c = add(a, b); // 调用函数

	CString tem;
	tem.Format(_T("%d+%d=%d"), a, b, c);
	AfxMessageBox(tem);
}

        所以调用会直接跳转到下面这个函数中来注意这里需要先HookOff卸载钩子。不然会自己调自己造成死循环拿到计算结果后再次开启钩子。

// 然后写我们自己的Myadd()函数
int WINAPI Myadd(int a, int b)
{
	// 截获了对add()的调用我们给a加10
	a = a - 10;

	HookOff(); // 关掉Myadd()钩子防止死循环

	int ret;
	ret = add(a, b);

	HookOn(); // 开启Myadd()钩子

	return ret;
}

        然后点击"卸载钩子"按钮卸载钩子。

void CCallAddDlg::OnBnClickedButtonStopHook()
{
	if (hinst == NULL)
	{
		return;
	}

	typedef BOOL(CALLBACK* UnhookProc)(); // 函数原型定义
	UnhookProc UninstallHook;

	UninstallHook = ::GetProcAddress(hinst, "UninstallHook");// 获取函数地址
	if (UninstallHook != NULL)
	{
		UninstallHook();
	}

	if (hinst != NULL)
	{
		::FreeLibrary(hinst);
	}
}

// 卸载鼠标钩子函数
void UninstallHook()
{
	if (hhk != NULL)
	{
		::UnhookWindowsHookEx(hhk);
	}

	HookOff(); // 记得恢复原函数入口
}

        将之前保存的add函数的地址恢复记得修改内存属性否则会失败。

// 关闭钩子的函数
void HookOff() // 将所属进程中add()的入口代码恢复
{
	ASSERT(hProcess != NULL);

	DWORD dwTemp = 0;
	DWORD dwOldProtect;

	VirtualProtectEx(hProcess, pfadd, 5, PAGE_READWRITE, &dwOldProtect);
	WriteProcessMemory(hProcess, pfadd, OldCode, 5, 0);
	VirtualProtectEx(hProcess, pfadd, 5, dwOldProtect, &dwTemp);
	bHook = false;
}

再次点击"执行函数"按钮发现调用原始接口数据恢复为原始结果。

        另外对于c++虚函数Hook虚函数调用是从虚函数表里面获得的函数地址进行调用的。因此对于Hook这类函数就需要改写它的虚函数表了。一般来说对于某个含有虚函数表的C++类this指针指向的地址取值就是虚函数表指针。虚函数表指针指向了虚函数表里面的每一个元素都指向了实际要调用的函数的地址。因此可以按照这样的方式访问虚函数表指针

int** pVTable = (int**)this;

        也就是将指向对象的指针强制转化成指针的指针这样就可以通过取值就可以访问虚函数表:

  (*pVTable)[0] = address of virtual function 1;
  (*pVTable)[1] = address of virtual function 2;
  ...

        因此我们就可以改写虚函数的地址了从而达到Hook的目的。

2Hotfix Hook

        从上节对Inline Hook方法的讲解中我们会发现Inline Hook存在一个效率的问题因为每次Inline Hook都要进行“挂钩+脱钩”的操作也就是要对API的前5字节修改两次这样当我们要进行全局Hook的时候系统运行效率会受影响。而且当一个线程尝试运行某段代码时若另一个线程正在对该段代码进行“写”操作这时就会程序冲突最终引发一些错误。

        因此使用HotFix Hook"热补丁"方法。如app的热修复原理是一样的。

测试机器Win10 x64测试dll: XP系统的

        1原理为API的起始代码上都有这样的特色5个NOP(空)指令1个“MOV EDI,EDI”(占2字节)这7字节的指令实际没有任何意义所以能够经过修改这7字节来实现HOOK操做这种方法可使得进程处于运行状态时临时更改进程内存中的库文件所以被称为打“热补丁”。在上述5字节代码修改技术中脱钩是为了调用原函数但使用HotFix Hook API时在API代码被修改的状态下仍然可以正常的调用原API从[原API起始地址+2]开始仍能正常调用原API且执行动作一致。这种方法因为可以在进程处于运行状态时临时更改进程内存中的库文件所以微软也常用这种方法来打“热补丁”。

        该技术难的地方在于计算偏移地址。由于HotFix Hook需要修改7个字节的代码所以并不是所有API都适用这种方法若不适用请使用5字节代码修改技术。

        2现状为下图是用OllyDbg打开user32.dll这个dll用的是网上下载的Xp的。因为我自己本机是Win10系统这个dll可能是被更新掉了没有找到可用的Hook点。网上下载的Xp的看起来是有的估计是后面windows版本对dll安全性升级了。看来在Windows10系统上面这种Hook方式很难实现了。Win7/Win8没试过有兴趣可以自行尝试如图11所示。

         下面两张图是XP的跟Win10的文件信息的对比如图12与图13所示。

         那么这个Hook类型就不再展示代码案例了。了解一下有这么个方式就行不过Win10肯定有可以Hook的API没有找到而已。不过重在了解原理代码实现跟之前的大同小异。

3SSDT(内核) Hook

        SSDT Hook属于内核层Hook也是最底层的Hook。由于用户层的API最后实质也是调用内核API所以该Hook方法最为强大。不过值得注意的是内核通SSDTSystem Service Descriptor Table调用各种内核函数SSDT就是一个函数表只要得到一个索引值就能根据这个索引值在该表中得到想要的函数地址。本质上其实内核层Hook并没想象中的那么高大上Hook的原理相同只不过Hook的对象不一样罢了。

         当前安全软件很多也用到了SSDT Hook技术来实现对系统的安全防护。例如图14所示是360主动防御进程对 SSDT表的一个HookHook的目的是“取得系统R0权限当有进程要结束自己的时候进行拦截然后给出提示拒绝访问”。比如上图结束进程是由NtTerminateProcess函数来完成的Hook这个内核函数那么在进程结束前就有机会更改结果了可以拒绝被结束。

4. Windows Hook实践与探索

4.1 项目背景

        端上在线安装程序是一个独立的应用程序提供安装功能为了减少安装包体积避免引入第三方网络库使用的是操作系统的WinInet网络库。为了更好的优化网络提高网络连接的成功率避免Local DNS造成的域名劫持等问题采用HttpDNS方式实现域名解析。

4.2 为什么使用HttpDNS

相比于传统的DNSHttpDNS主要有以下优势

1域名防劫持。使用HttpHttps协议进行域名解析域名解析请求直接发送至HttpDNS服务器绕过运营商Local DNS避免域名劫持问题。

2调度精准。由于运营商策略的多样性其 Local DNS 的解析结果可能不是最近、最优的节点HttpDNS能直接获取客户端 IP 基于客户端 IP 获得最精准的解析结果让客户端就近接入业务节点。

3实时生效。配合端上策略热点域名预解析、缓存DNS解析结果、解析结果懒更新实现毫秒级低解析延迟的域名解析效果。

4.2.1 HttpDNS实现方案

使用HttpDNS的通常方法有两个方案

1方案一

发起网络请求之前把域名使用HttpDNS解析为IP地址然后请求的时候把域名替换为IP进行请求但是这种方案存在两个问题需要解决

1虚拟主机问题

从http/1.1开始header中支持Host字段用来实现访问虚拟主机的目的。http请求header中必须配置适当的Host才能正确访问想要的服务默认情况下Host字段是请求地址中的域名。如果直接把请求的域名替换成IP地址则无法正确访问对应服务所以需要所使用的网络库支持自定义Host字段。而WinInet是Windows系统库不支持修改Host字段。所以不能简单的把域名替换为解析后的IP发起请求。另外在https协议中虚拟主机同时带来SNI问题即在TLS握手阶段就需要指定适当的Host信息以保证服务端可以返回正确的证书否则会导致SSL握手失败。

2Https证书验证问题

把域名直接替换为IP地址带来的另一个问题是SSL/TLS握手时候的证书验证问题。主要原因是服务端证书和客户端的peer name不一致导致的。一个简单的解决方案是忽略SSL证书验证失败这个问题但是这样会导致https请求成了不安全的请求。

2方案二

如果第三方网络库提供域名解析的回调可以自定义域名解析也可以实现HttpDNS。本文采用的就是这个方案利用Windows的API Hook机制对域名解析GetAddrInfoEx接口进行Hook以实现自定义DNS解析失败 情况下走默认DNS解析。

常用网络库提供的解决方案如下

1Qt5Network库比如在qt 5.15版本中connectToHostEncrypted这个接口他提供了peer name参数来实现SSL握手阶段需要验证的peer name以解决证书验证域名不匹配的问题

2libcurl库用curl_easy_setopt CURLOPT_RESOLVE提供自定义主机名到IP地址的解析即可以自定义域名解析。

本文的解决方案由于我们项目需要只能使用Windows系统的WinInet网络库该库不支持修改Host头也不提供域名解析的回调。但是Windows的域名解析一般使用的gethostbynameGetAddrInfoGetAddrInfoEx这些API来实现的如果我们Hook这些API来实现HttpDNS解析过程如果失败了再走默认的域名解析过程这样就可以实现了HttpDNS功能了。

4.2.2 使用detours库实现Hook

detours库是微软提供的被广泛使用的用于API Hook的库它封装了Hook的实现细节使用起来非常方便。例如GetAddrInfoEx是我们需要Hook的API声明Old_GetAddrInfoEx保留Hook之前的函数指针New_GetAddrInfoEx为Hook后的函数指针应用程序在适当的时机调用StartHook/StopHook以Hook对应的API。

INT (WSAAPI* Old_GetAddrInfoEx)(
    __in_opt    PCWSTR          pName,
    __in_opt    PCWSTR          pServiceName,
    __in        DWORD           dwNameSpace,
    __in_opt    LPGUID          lpNspId,
    __in_opt    const ADDRINFOEX* hints,
    __deref_out PADDRINFOEXW* ppResult,
    __in_opt    struct timeval* timeout,
    __in_opt    LPOVERLAPPED    lpOverlapped,
    __in_opt    LPLOOKUPSERVICE_COMPLETION_ROUTINE  lpCompletionRoutine,
    __out_opt   LPHANDLE        lpHandle) = GetAddrInfoEx;
 
INT WSAAPI New_GetAddrInfoEx(
    __in_opt    PCWSTR          pName,
    __in_opt    PCWSTR          pServiceName,
    __in        DWORD           dwNameSpace,
    __in_opt    LPGUID          lpNspId,
    __in_opt    const ADDRINFOEX* hints,
    __deref_out PADDRINFOEXW* ppResult,
    __in_opt    struct timeval* timeout,
    __in_opt    LPOVERLAPPED    lpOverlapped,
    __in_opt    LPLOOKUPSERVICE_COMPLETION_ROUTINE  lpCompletionRoutine,
    __out_opt   LPHANDLE        lpHandle
)
{
    // 这里可以实现自己的dns解析逻辑
    // ...
    // 自定义解析失败后调用默认解析以兜底
    return Old_GetAddrInfoEx(pName,
        pServiceName,
        dwNameSpace,
        lpNspId,
        hints,
        ppResult,
        timeout,
        lpOverlapped,
        lpCompletionRoutine,
        lpHandle);
}
 
bool StartHook()
{
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourAttach(&(PVOID&)Old_GetAddrInfoEx, New_GetAddrInfoEx);
    LONG ret = DetourTransactionCommit();
 
    return ret == NO_ERROR;
}
 
bool StopHook()
{
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourDetach(&(PVOID&)Old_GetAddrInfoEx, New_GetAddrInfoEx);
    LONG ret = DetourTransactionCommit();
 
    return ret == NO_ERROR;
}

4.2.3 Hook过程

WinInet网络请求的一般过程如下图所示在发送HttpSendRequest请求的时候会调用域名解析函数GetAddrInfoEx函数完成域名的解析。在域名解析的时候Hook GetAddrInfoEx函数。Hook后的WinInet网络请求过程如右下图所示在Hook域名解析函数GetAddrInfoEx的时候成功以后就不再调用原有的域名解析函数GetAddrInfoEx而是调用自定义的域名解析函数。在调用自定义的域名解析函数失败的时候有个兜底的策略还调回原来的域名解析函数GetAddrInfoEx。下面是自定义的域名解析函数New_GetAddrInfoEx如图15、16所示。

                                                        图15 原始网络请求流程

                                                         图16 Hook后网络请求流程

自定义域名解析函数如下所示

// 从私有堆上分配ADDRINFOEX空间
static void my_addressinfo_alloc(
    __in_opt    PCWSTR          pServiceName,
    __in        DWORD           dwNameSpace,
    __in_opt    LPGUID          lpNspId,
    __in_opt    const ADDRINFOEX* hints,
    __deref_out PADDRINFOEXW* ppResult,
    __in_opt    struct timeval* timeout,
    __in_opt    LPOVERLAPPED    lpOverlapped,
    __in_opt    LPLOOKUPSERVICE_COMPLETION_ROUTINE  lpCompletionRoutine,
    __out_opt   LPHANDLE        lpHandle)
{
    ADDRINFOEX my_hints = *hints;
    my_hints.ai_family = AF_INET;
    my_hints.ai_flags ^= (AI_CANONNAME | AI_FQDN);
    Old_GetAddrInfoEx(L"localhost",
        pServiceName,
        dwNameSpace,
        lpNspId,
        &my_hints,
        ppResult,
        timeout,
        lpOverlapped,
        lpCompletionRoutine,
        lpHandle);
}
 
INT WSAAPI New_GetAddrInfoEx(
    __in_opt    PCWSTR          pName,
    __in_opt    PCWSTR          pServiceName,
    __in        DWORD           dwNameSpace,
    __in_opt    LPGUID          lpNspId,
    __in_opt    const ADDRINFOEX* hints,
    __deref_out PADDRINFOEXW* ppResult,
    __in_opt    struct timeval* timeout,
    __in_opt    LPOVERLAPPED    lpOverlapped,
    __in_opt    LPLOOKUPSERVICE_COMPLETION_ROUTINE  lpCompletionRoutine,
    __out_opt   LPHANDLE        lpHandle
)
{
    do {
        struct in_addr addr;
        // ip和localhost不需要httpdns
        if (pName == nullptr
            || hints == nullptr
            || InetPtonW(AF_INET, pName, (void*)&addr)
            || wcscmp(pName, L"localhost") == 0) {
            break;
        }
 
        // 从缓存或者云服务商获取该域名对应的ip列表
        HttpDNS::IpList ipList = HttpDNS::instance()->getHostByName(pName);
        if (ipList.size() == 0) {
            break;
        }
 
        // 由于GetAddrInfoEx调用时候在私有堆上分配的内存自己new的对象无法正常释放会导致崩溃
        // blog: http://www.youngroe.com/2018/12/01/Windows/windows_client_dns_over_https/
        ADDRINFOEX* pTarget = nullptr;
        for (auto& ip : ipList) {
            // 私有堆上分配ADDRINFOEX空间
            ADDRINFOEX* pTemp = nullptr;
            my_addressinfo_alloc(pServiceName,
                dwNameSpace,
                lpNspId,
                hints,
                &pTemp,
                timeout,
                lpOverlapped,
                lpCompletionRoutine,
                lpHandle);
             
            if (pTemp == nullptr) {
                continue;
            }
             
            if (*ppResult == nullptr) {
                *ppResult = pTemp;
                pTarget = *ppResult;
            }
            else {
                assert(pTarget);
                pTarget->ai_next = pTemp;
                pTarget = pTarget->ai_next;
            }
             
            std::string ipa = CStringUtil::wstring2string(ip);
            struct sockaddr_in* mysock = (struct sockaddr_in*)pTemp->ai_addr;
            mysock->sin_addr.S_un.S_addr = inet_addr(ipa.c_str());
        }
 
        if (*ppResult == nullptr) {
            break;
        }
 
        return NO_ERROR;
    } while (false);
     
    return Old_GetAddrInfoEx(pName,
        pServiceName,
        dwNameSpace,
        lpNspId,
        hints,
        ppResult,
        timeout,
        lpOverlapped,
        lpCompletionRoutine,
        lpHandle);
}

        在Hook GetAddrInfoEx函数的实现过程中遇到了一个小问题GetAddrInfoEx返回结果中的addrinfoexW内存分配问题。正常情况下返回结果中的addrinfoexW由GetAddrInfoEx函数在其私有堆上分配然后调用者使用完结果后使用FreeAddrInfoEx 释放但是当我们自己实现的时候很难获取到私有堆的句柄这样就没办法为addrinfoexW分配内存如果使用new分配内存会在FreeAddrInfoEx 释放时错误产生问题。我实现的时候通过一个简单粗暴的方式是通过调用原始的GetAddrInfoEx解析localhost然后直接使用结果中的addrinfoexW因为是GetAddrInfoEx分配所以最后使用FreeAddrInfoEx 释放也没问题。

5. 总结

        Hook技术也被广泛应用于安全的多个领域Windows xp及其之前的安全机制除了靠定期向病毒木马样本库中添加新样本外还需要辅之以Hook关键的系统函数以方便在用户down文件或者打开exe时查杀木马。此外早期的杀软除了被动扫描之外也还需要主动对一些敏感API进行Hook监控有时Windows系统本身及一些相关应用程序在打补丁时也需要使用Hook技术还有游戏外挂以及一些监控软件可以说这是一把双刃剑。

        微软在Win 10里设立了Secure ETW通道安全软件不再需要像以前那样Hook系统内核来完成对系统内进程的监视。而Hook作为一个比较老的技术也已经越来越少被提起到了但这并不妨碍它曾经的光辉岁月很值得我们去了解。

6. 参考

[1] 参考书籍1 《Windows 核心编程》

[2] 参考书籍2 《逆向工程核心原理》

[3] 参考书籍3 《程序员的自我修养》

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: windows