Windows Hook案例分析与技术探索

这篇具有很好参考价值的文章主要介绍了Windows Hook案例分析与技术探索。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

1. Windows Hook基本介绍

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

3. Windows Hook 案例分析

3.1 消息 Hook

3.1.1 原理
        先来了解下Windows消息过程:
      (1)发生键盘输入事件时,WM_KEYDOWN消息被添加到操作系统消息队列。
      (2)OS判断哪个应用程序中发生了事件,然后从操作系统消息队列取出消息,添加到相应应用序的消息队列中。
      (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所示。  
Windows Hook案例分析与技术探索
        如上图,获取类别为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里面读取信息。 最后,别忘记关闭对话框跟卸载钩子。注意关闭对话框穿的是0,0是一个标记,用来告诉函数把已经安装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所示:
Windows Hook案例分析与技术探索

Windows Hook案例分析与技术探索

        (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所示。

 Windows Hook案例分析与技术探索

// 附加进程, 将目标进程附加在当前进程准备进行调试
// 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所示。
Windows Hook案例分析与技术探索
        然后,获取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已经被修改了。

Windows Hook案例分析与技术探索

        脱钩,将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形式本质上都是通过改写函数的入口地址,使得执行流切换到自定义函数。

1)InLine Hook

        (1)原理。

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

        (2)案例。

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

Windows Hook案例分析与技术探索

测试机器:Win10 x64,测试软件x86

        (3)实现。

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

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

 Windows Hook案例分析与技术探索

        接下来看下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的目的。

2)Hotfix 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所示。

Windows Hook案例分析与技术探索

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

Windows Hook案例分析与技术探索

Windows Hook案例分析与技术探索

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

3)SSDT(内核) Hook

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

Windows Hook案例分析与技术探索

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

4. Windows Hook实践与探索

4.1 项目背景

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

4.2 为什么使用HttpDNS

相比于传统的DNS,HttpDNS主要有以下优势:

(1)域名防劫持。使用Http(Https)协议进行域名解析,域名解析请求直接发送至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握手失败。

(2)Https证书验证问题

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

2)方案二

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

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

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

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

本文的解决方案由于我们项目需要只能使用Windows系统的WinInet网络库,该库不支持修改Host头,也不提供域名解析的回调。但是Windows的域名解析一般使用的gethostbyname,GetAddrInfo,GetAddrInfoEx这些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所示。

Windows Hook案例分析与技术探索

                                                        图15 原始网络请求流程

Windows Hook案例分析与技术探索

                                                         图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 《程序员的自我修养》文章来源地址https://www.toymoban.com/news/detail-400614.html

到了这里,关于Windows Hook案例分析与技术探索的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包