您的位置:首页 > 其它

Windows Hook经验总结之一:API Hook方法汇总

2016-11-17 14:29 344 查看
HOOK的目的是用我们自己的代码取代一些函数的代码以改变程序的行为。

静态HOOK:在进程运行前挂钩,采用用户级进程即可完成。比如:有些程序会在启动时需要原光盘,如果我们修改获取驱动类型的函数则可以从硬盘启动。

动态HOOK:挂钩系统进程(如服务)时要动态挂钩(运行时挂钩)。

静态HOOK即运行前挂钩

这里修改我们想要修改函数来自的物理模块(大多数时候是.exe或.dll)。在这里我们至少有3种可能的做法。

第一种可能是找到函数的入口点然后重写它的代码。这会因为函数的大小而受限制,但只要能动态加载其它一些模块(API LoadLibrary)就足够了。内核函数(kernel32.dll)是通用的,Windows中每个进程都有其拷贝,如果知道哪些模块在某版本中会修改,就可以在一些API如LoadLibraryA中直接使用指针(因为相同Windows版本中kernel模块在内存中地址是固定的)。

第二种可
4000
能是在模块中被代替的函数只是原函数的扩展
。可以选择修改开始的5个字节(CPU跳转指令长度)为跳转指令或者改写IAT。如果改跳转指令,那么将会改变指令执行流程转为执行我们的代码。如果调用了IAT记录被修改的函数,我们的代码能在调用结束后被执行。

第三种是修改整个模块。即创建自己的模块版本,它能够加载原始的模块并调用原始的函数,当然我们对这个不感兴趣,但重要的函数都是被更新的。这种方法对于有的模块过大有几百个导出函数的很不方便。

动态HOOK即运行时挂钩

在运行前挂钩通常都非常特殊,并且是在内部面向具体的应用程序(或模块)。如果我们更换了kernel32.dll或ntdll.dll里的函数(只在NT操作系统里),我们就能完美地做到在所有将要运行的进程中替换这个函数。但说来容易做起来却非常难,不但得考虑精确性和需要编写比较完善的新函数或新模块,更主要的问题是只有即将运行的进程才能被挂钩(要挂钩所有进程只能重启电脑)。另一个问题是如何进入这些文件,因为NT操作系统保护了它们。比较好的解决方法是在进程运行时挂钩(只针对能够写入它们内存的进程)。为了能写入进程可使用API函数WriteProcessMemory

使用IAT挂钩本进程

这里有很多种可能性。首先介绍如何用改写IAT挂钩函数的方法。接下来这张图描述了PE文件的结构:



这里比较重要的是.idata部分的导入地址表(IAT:Import Address Table)。这个部分包含了导入的相关信息和函数地址。有一点很重要,我们必须知道PE文件是如何创建的。当在编程语言里间接调用任意API(这意味着我们是用函数的名字来调用它,而不是用它的地址),编译器并不直接把调用连接到模块,而是用jmp指令连接调用到IAT,IAT在系统把进程调入内存时时会由进程载入器填满。这就是我们可以在两个不同版本的Windows里使用相同的二进制代码的原因,虽然模块可能会加载到不同的地址。进程载入器会在程序代码里调用所使用的IAT里填入直接跳转的jmp指令。所以只要能在IAT里找到想要挂钩的指定函数,就能很容易改变那里的jmp指令并重定向代码到新的地址。完成之后每次调用都会执行新的代码了。这种方法的缺点是经常有很多函数要被挂钩(比如:要在搜索文件的API中改变程序的行为就得修改函数FindFirstFileFindNextFile,但这些函数都有ANSI和UNICODE版本,所以就不得不修改FindFirstFileAFindFirstFileWFindNextFileAFileNextFileW的IAT地址。但还有其它类似的函数如FindFirstFileExA和它的UNICODE版本FindFirstFileExW,也都是由前面提到的函数调用的。我们知道FindFirstFileW调用FindFirstFileExW,但这是直接调用,而不是使用IAT。再比如说ShellAPI的函数SHGetDesktopFolder也会直接调用FindFirstFilwWFindFirstFileExW)

我们通过使用imagehlp.dll里的ImageDirectoryEntryToData来很容易地找到IAT。

PVOID ImageDirectoryEntryToData(
IN LPVOID           Base,
IN BOOLEAN      MappedAsImage,
IN USHORT           DirectoryEntry,
OUT PULONG      Size
);


在这里Base参数可以用我们程序的Instance(Instance通过调用GetModuleHandle获得):

hInstance = GetModuleHandleA(NULL);

DirectoryEntry我们可以使用恒量IMAGE_DIRECTORY_ENTRY_IMPORT。

#define IMAGE_DIRECTORY_ENTRY_IMPORT 1


函数的结果是指向第一个IAT记录指针。IAT的所有记录是由IMAGE_IMPORT_DESCRIPTOR定义的结构。所以函数结果是指向IMAGE_IMPORT_DESCRIPTOR的指针。

typedef struct _IMAGE_THUNK_DATA {
union {
PBYTE                       ForwarderString;
PDWORD                  Function;
DWORD                   Ordinal;
PIMAGE_IMPORT_BY_NAME   AddressOfData;
} ;
} IMAGE_THUNK_DATA,*PIMAGE_THUNK_DATA;

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD               Characteristics;
PIMAGE_THUNK_DATA   OriginalFirstThunk;
} ;
DWORD                   TimeDateStamp;
DWORD                   ForwarderChain;
DWORD                   Name;
PIMAGE_THUNK_DATA       FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR;


IMAGE_IMPORT_DESCRIPTOR里的Name成员变量是模块名字的指针。如果想要挂钩某个函数比如是来自kernel32.dll就在导入表里找属于名字kernel32.dll的描述符号。先调用ImageDirectoryEntryToData然后找到名字是”kernel32.dll”的描述符号,最后在这个模块记录的函数列表里找到想要的函数(函数地址通过GetProcAddress函数获得)。如果找到了就必须用VirtualProtect函数来改变其内存页面的保护属性,然后就可以在内存中的这部分写入代码了。在改写了地址之后还要把保护属性改回来。在调用VirtualProtect之前要先知道有关页面的信息,可以通过VirtualQuery来实现。还有必要加入一些测试以防某些函数会失败(如果第一次调用VirtualProctect失败了,就没办法继续)。

PCSTR pszHookModName = "kernel32.dll", pszSleepName = "Sleep";
HMODULE hKernel = GetModuleHandle(pszHookModName);
PROC pfnNew = (PROC)0x12345678,       //这里存放新地址
pfnHookAPIAddr = GetProcAddress(hKernel,pszSleepName);

ULONG ulSize;
PIMAGE_IMPORT_DESCRIPTOR pImportDesc =
(PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData(
hKernel, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize );
while (pImportDesc->Name)
{
PSTR pszModName = (PSTR)((PBYTE) hKernel + pImportDesc->Name);
if (stricmp(pszModName, pszHookModName) == 0)
break;
pImportDesc++;
}

PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((PBYTE) hKernel + pImportDesc->FirstThunk);
while (pThunk->u1.Function)
{
PROC* ppfn = (PROC*) &pThunk->u1.Function;
BOOL bFound = (*ppfn == pfnHookAPIAddr); //API Address match
if (bFound)
{
MEMORY_BASIC_INFORMATION mbi;
VirtualQuery(ppfn, &mbi, sizeof(MEMORY_BASIC_INFORMATION) );
VirtualProtect(mbi.BaseAddress, mbi.RegionSize, PAGE_READWRITE, &mbi.Protect);

*ppfn = *pfnNew; // modify address and Hooked

DWORD dwOldProtect;
VirtualProtect(mbi.BaseAddress, mbi.RegionSize, mbi.Protect, &dwOldProtect );
break;
}
pThunk++;
}


调用Sleep(1000)的结果如例子

0407BD8: 68E8030000    push 0000003E8h
00407BDD: E812FAFFFF    call Sleep


Sleep: ;这是跳转到IAT里的地址

004075F4: FF25BCA14000    jmp dword ptr [00040A1BCh]

0040A1BC: 79 67 E8 77 00 00 00 00    ;原始表
0040A1BC: 78 56 34 12 00 00 00 00    ;新表


最后会跳转到0x12345678。

改写入口点挂钩本进程

改写函数入口点开始的一些字节这种方法相对简单。就象改变IAT里的地址一样,也要先修改页面属性。在这里对想要挂钩的函数是一开始的5个字节。使用动态分配MEMORY_BASIC_INFORMATION结构,函数的起始地址也是用GetProcAddress来获得。我们在这个地址里插入指向我们代码的跳转指令。接下来程序调用Sleep(5000)(所以它会等待5秒钟),然后Sleep函数被挂钩并重定向到new_sleep,最后它再次调用Sleep(5000)。因为新的函数new_sleep什么都不做并直接返回,所以整个程序只需要5秒钟而不是10秒种。

.386p
.model flat, stdcall
includelib lib\kernel32.lib
Sleep                   PROTO :DWORD
GetModuleHandleA        PROTO :DWORD
GetProcAddress          PROTO :DWORD,:DWORD
VirtualQuery                PROTO :DWORD,:DWORD,:DWORD
VirtualProtect              PROTO :DWORD,:DWORD,:DWORD,:DWORD
VirtualAlloc                PROTO :DWORD,:DWORD,:DWORD,:DWORD
VirtualFree             PROTO :DWORD,:DWORD,:DWORD
FlushInstructionCache       PROTO :DWORD,:DWORD,:DWORD
GetCurrentProcess           PROTO
ExitProcess                 PROTO :DWORD

.data
kernel_name             db "kernel32.dll",0
sleep_name          db "Sleep",0
old_protect         dd ?

MEMORY_BASIC_INFORMATION_SIZE    equ 28

PAGE_READWRITE              dd 000000004h
PAGE_EXECUTE_READWRITE  dd 000000040h
MEM_COMMIT                  dd 000001000h
MEM_RELEASE                 dd 000008000h

.code
start:
push    5000
call    Sleep

do_hook:
push   offset kernel_name
call    GetModuleHandleA
push   offset sleep_name
push   eax
call    GetProcAddress
mov   edi,eax            ;最后获得Sleep地址

push   PAGE_READWRITE
push   MEM_COMMIT
push   MEMORY_BASIC_INFORMATION_SIZE
push   0
call    VirtualAlloc
test    eax,eax
jz     do_sleep
mov   esi,eax            ;为MBI结构分配内存

push  MEMORY_BASIC_INFORMATION_SIZE
push  esi
push  edi
call   VirtualQuery        ;内存页的信息
test   eax,eax
jz    free_mem

call   GetCurrentProcess
push  5
push  edi
push  eax
call   FlushInstructionCache    ;只是为了确定一下:)

lea   eax,[esi+014h]
push  eax
push  PAGE_EXECUTE_READWRITE
lea   eax,[esi+00Ch]
push  [eax]
push  [esi]
call   VirtualProtect          ;我们要修改保护属性,这样才能够写入代码
test   eax,eax
jz    free_mem

mov  byte ptr [edi],0E9h    ;写入跳转指令
mov  eax,offset new_sleep
sub   eax,edi
sub   eax,5
inc   edi
stosd                ;这里是跳转地址

push  offset old_protect
lea    eax,[esi+014h]
push  [eax]
lea   eax,[esi+00Ch]
push  [eax]
push  [esi]
call   VirtualProtect        ;恢复页保护属性

free_mem:
push   MEM_RELEASE
push   0
push   esi
call    VirtualFree        ;释放内存
do_sleep:
push   5000
call    Sleep
push   0
call    ExitProcess
new_sleep:
ret    004h
end start


第二次调用Sleep的结果是这样:

004010A4: 6888130000    push 000001388h
004010A9: E80A000000    call Sleep
Sleep:                  ;这里是跳转到IAT里的地址
004010B8: FF2514204000  jmp dword ptr [000402014h]


tabulka:

00402014: 79 67 E8 77 6C 7D E8 77


Kernel32.Sleep:

77E86779: E937A95788    jmp 0004010B5h
new_sleep:
004010B5: C20400        ret 004h


保存原始函数

更多时候需要的不仅仅是挂钩函数,比如某些时候并不想取代指定函数而只是想检查一下它的结果,或者仅当使用特定的参数调用时才取代原函数。比如之前提过的通过取代FindXXXFile函数来完成隐藏文件,如果想要隐藏指定的文件并且不想被注意的话,就得对其它所有文件只调用没有被修改过的原始函数。此时采用修改IAT的方法是很简单的,为调用原始函数可以用GetProcAddress获得原始地址,然后直接调用。但修改入口点的方法就会有问题,因为修改了函数入口点的5个字节,破坏了原函数,所以就必须保存开始的那些指令。这将用到以下的技术。

我们知道要修改开始的5个字节,但不知道里面包含多少条指令以及指令的长度,所以得为那些指令保留足够的内存空间。16个字节应该足够了,因为函数开始时通常没有多长的指令,很可能根本就用不到16个字节。整个被保留的内存用0x90(0x90=nop)来填满。之后的5个字节预留给即将填入的跳转指令。至于如何获取开始的指令长度另作讨论。假定现在已经知道了长度,就可以在原始函数的下条指令填入跳转地址。

下一个问题就是诸如ntdll.DbgBreakPoint这样的API,它们太短了,所以不能用这种挂钩方法,并且它是由Kernel32.DebugBreak调用,所以也不能通过修改IAT来挂钩。虽然说没有谁会去挂钩这个只有int 3的函数,但只要认真想想也能找到解决的方法,如挂钩它之后的那个函数(它可能会因为修改了前一个函数的开始5个字节而被破坏),DbgBreakPoint函数长度为2个字节,可以先设置一些标志,然后尝试着在第二个函数的开始写入条件跳转指令。

保存原始函数的问题已经叙述完了,就到解除挂钩(unhook)。解除挂钩就是把被修改的字节恢复为原始状态。修改IAT的方法里,想解除挂钩只需要在表里恢复原始的地址;修改入口点的方法里,则只需要把原始函数的开始指令拷贝回去。

挂钩其它进程

试想,谁会想只挂钩自己进程?显然是非常不实用的。

先介绍CreateRemoteThread,它只在使用了NT技术的Windows版本里有效。。如帮助里所说,这个函数可以在任意进程里创建新线程并运行它的代码。

HANDLE CreateRemoteThread(
HANDLE                      hProcess,
LPSECURITY_ATTRIBUTES       lpThreadAttributes,
DWORD                       dwStackSize,
LPTHREAD_START_ROUTINE      lpStartAddress,
LPVOID                          lpParameter,
DWORD                       dwCreationFlags,
LPDWORD                     lpThreadId
);


句柄hProcess可以通过OpenProcess获得。这里必须获得足够的权限。lpStartAddress是指向目标进程地址空间里存放新线程第一条指令地址的指针,因为新线程是在目标进程里创建,所以它存在于目标进程的地址空间里。lpParameter是指向提交给新线程的参数的指针。

DLL注入

可以在目标进程地址空间里任意地方运行我们的新线程。这看起来没什么用,除非在里面有我们完整的代码。第一种方法就是这么实现。它调用GetProcAddress获取LoadLibrary地址,然后把LoadLibrary赋值给参数lpStartAddress。LoadLibrary函数只有一个参数,就和目标进程里新线程的函数

HINSTANCE LoadLibrary(LPCTSTR lpLibFileName);


用这点相似性,把lpParameter参数赋为我们的DLL库的名字,在新线程运行后lpParameter的位置就是lpLibFileName的位置,在加载了新的模块到目标进程后就开始执行初始化部分。如果在这里放置了能够挂钩其它函数的特殊函数就OK了。在执行了初始化部分后,这个线程就什么都不做并被关闭,但我们的模块仍然在地址空间中。这种方法很不错而且很容易实现,它的名字叫DLL注入。

char *lpLibFileName = "my.dll";
PTHREAD_START_ROUTINE lpStartAddress = (PTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle("Kernel32"), "LoadLibraryA");
CreateRemoteThread( hRemoteProcess, NULL, 0, lpStartAddress, lpLibFileName, 0, NULL);


但是这是远线程,不是在自己的进程里,而lpLibFileName指向的是自己进程里的数据,到了目标进程,这个指针都不知道指向哪儿去了,同样lpStartAddress这个地址上的代码到了目标进程里也不知道是什么了,不知道是不是想要的LoadLibraryA了(其实Windows已经解决了这个问题,Kernel32.dll很特殊,对所有的进程,Windows总是把它加载到相同的地址上去。因此LoadLibraryA及Kernel32.dll内其它所有函数在不同进程中的地址始终相同)。所以要用到在VirtualAllocEx函数在目标进程里分配内存,并调用WriteProcessMemory将自己进程中的数据拷贝到目标进程中。

如果不介意多个DLL库的话,DLL注入是最快的方法(从程序员的角度来看)。

独立的代码

实现独立的代码比较困难,但也容易给人深刻印象。独立的代码是不需要任何静态地址的代码。它里面所有东西都是互相联系地指向代码里面某些特定的地方,即使不知道这段代码开始执行的地址它也能自己完成 。当然,也有可能先获得地址然后重新链接我们的代码,这样它可以完全正常地在新地址工作,但这比编写独立的代码更困难。这如病毒的代码,病毒通过这种方法感染可执行文件,它把它自己的代码加入到可执行文件中的某个地方。在不同的可执行文件中放置病毒代码的位置也不一样,这取决于比方说文件结构的长度。

首先将我们的代码插入目标进程,然后CreateRemoteThread函数就会负责运行我们的代码。所以第一步我们要做的就是通过OpenProcess函数获取目标进程的信息和句柄,接着调用VirtualAllocEx在目标进程地址空间里分配一些内存给我们的代码,最后调用WriteProcessMemory把我们的代码写入分配的内存里并运行它。调用CreateRemoteThread的参数lpStartAddress设置为分配的内存地址,lpParameter可以随便设置。

原始修改

在非NT内核的老版本Windows里是没有CreateRemoteThread函数的,所以不能用以上的方法。其实根本不需要把我们代码放到目标进程里来挂钩它的函数。有两个函数WriteProcessMemoryOpenProcess,它们在所有版本的Windows中都有效。我们还需要的函数是VirtualProtectEx,用来修改进入目标进程的内存页。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: