API Hook完全手册

注:本文是根据我两年前写的一个系统行为监测程序写成(参考了一些书籍和文章)。最近在论坛上看到有不少人在问关于API Hook的问题,便写成此文,希望能对朋友们在写API Hook代码的时候能够有所帮助。

1 基本原理

API Hook是什么我就不多说了,直接进入正题。API Hook技术主要有下面的技术难点:

1. 如何将自己的的代码Inject到其他进程

2. 如何HookAPI

1.1 代码的Injection

常用的方法有:

1. 使用注册表HKLM\Software\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs

这种方法可以指定多个DLL,用空格隔开。这些DLL会被任何用到User32.dll的所有程序自动加载。当User32.dll加载的时候,User32.dllDllMain会收到一个DLL_PROCESS_ATTACH通知,User32在这个时候读取注册表项中的值,调用LoadLibrary加载各个DLL

显然使用这种方法要求设置注册表之后立刻重起系统,不过一般情况下这不是大问题。这种方法的主要问题在于,只有用到User32.dll的应用程序才会被Inject。所有的GUI和少部分CUI程序会用到User32.dll,所以如果你的API Hook程序不打算监视CUI程序的话,那么可能问题并不太大。但是如果你的API Hook程序需要监视系统中所有进程的话,这种方法的限制将是非常致命的。

2. 调用SetWindowsHookEx(WH_GETMESSAGE, …, 0)

可以使用SetWindowsHookEx(WH_GETMESSAGE, …, 0) 设置全局的消息钩子,虽然可能你的程序并不用到消息钩子,但是钩子的一个副作用是会将对应的DLL加载到所有的GUI线程之中。类似的,只有用到GUI的进程才会被挂接。虽然有这种限制,这种方法仍然是最常用的挂接进程的方法。

3. 使用CreateRemoteThread函数在目标进程中创建远程线程

这种方法可以在任意的目标进程中创建一个远程线程,远程线程中可以执行任意代码,这样便可以做到把我们的代码Inject到目标进程中。这种方法具有最大的灵活性,但是难度也最高:

a) 远程线程代码必须可以自重定位

b) 要能够监视进程的启动和结束,这样才可以挂接到所有进程

这两个问题都是可以解决的,在本文中我将重点讲述如何创建远程线程和解决这两个问题。

4. 如果你只是要挂接某个特定进程的并且情况允许你自己来创建此进程,你可以调用CreateProcess(…, CREATE_SUSPENDED)创建子进程并暂停运行,然后修改入口代码使之调用LoadLibrary加载自己的DLL。该方法在不同CPU之间显然是无法移植的。

1.2 Hook API

常用的方法有:

1. 找到API函数在内存中的地址,改写函数头几个字节为JMP指令跳转到自己的代码,执行完毕再执行API开头几个字节的内容再跳回原地址。这种方法对CPU有较大的依赖性,而且在多线程环境下可能出问题,当改写函数代码的时候有可能此函数正在被执行,这样做可能导致程序出错。

2. 修改PE文件的IAT (Import Address Table),使之指向自己的代码,这样EXE/DLL在调用系统API的时候便会调用你自己的函数

2 PE文件结构和输入函数

Windows9xWindows NTWindows 2000/XP/2003等操作系统中所使用的可执行文件格式是纯32PEPortable Executable)文件格式,大致如下:

API Hook完全手册

<shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="[email protected]@[email protected]@[email protected]@[email protected]@5xe" filled="f" stroked="f"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"></path><lock v:ext="edit" aspectratio="t"></lock></shapetype><shape id="_x0000_i1025" style="WIDTH: 354pt; HEIGHT: 272.25pt" type="#_x0000_t75" o:ole=""><imagedata src="file:///D:%5Ctmp%5Cmsohtmlclip1%5C01%5Cclip_image001.emz" o:title=""></imagedata></shape>

文件中数据被分为不同的节(Section)。代码(.code)、初始化的数据(.idata),未初化的数据(.bss)等被按照属性被分类放到不同的节中,每个节的属性和位置等信息用一个IMAGE_SECTION_HEADER结构来描述。所有的这些IMAGE_SECTION_HEADER结构组成一个节表(Section Table),这个表被放在所有节数据的前面。由于数据按照属性被放在不同的节中,那么不同用途但是属性相同的数据可能被放在同一个节中,因此PE文件中还使用IMAGE_DATA_DIRECTORY数据目录结构来指明这些数据的位置。数据目录和其他描述文件属性的数据和在一起称为PE文件头。PE文件头被放在节和节表的前面。PE文件中的数据位置使用RVARelative Virtual Address)来表示。RVA指的是相对虚拟地址,也就是一个偏移量。当PE文件被装入内存中的时候,WindowsPE文件装入到某个特定的位置,称为映像基址(Image Base)。而某个RVA值表示某个数据在内存中相对于映像基址的偏移量。

输入表(Import Table)是来放置输入函数(Imported functions)的一个表。输入函数就是被程序调用的位于外部DLL的函数,这些函数称为输入函数。它们的代码位于DLL之中,程序通过引用其DLL来访问这些函数。输入表中放置的是这些函数的名称(或者序号)以及函数所在的DLL路径等有关信息。程序通过这些信息找到相应的DLL,从而调用这些外部函数。这个过程是在运行过程中发生的,因此属于动态链接。由于操作系统的API也是在DLL之中实现的,因此应用程序调用API也要通过动态连接。在程序的代码中,当需要调用API的时候,就执行类似下面语句:

0040100E CALL 0040101A

可以看到这是一个call语句。Call语句则调用下面的语句:

0040101A JMP DWORD PTR [00402000]

上面的代码称为桩代码(Stub code),jmp语句中的目标地址[00402000]才是API函数的地址。这段Stub code位于.lib输入库中。如果加以优化,那么调用代码是下面这样:

XXXXXXXX CALL DWORD PTR [XXXXXXXX]

其中[XXXXXXXX]指向IATImport Address Table)即输入地址表中的表项。表项中指定了API的目标地址。这是经过编译器优化过的调用方法,通常速度要比原来的CALL+JMP快一些。

3 挂接API

从上面的PE文件结构可知,当我们知道了IAT中的地址所在位置,便可以把原来的API 的地址修改为新的API的地址。这样,进程在调用API的时候就会调用我们所提供的新的API的地址。修改输入表可以通过调用ImageDirectoryEntryToData API函数得到内存中模块的输入表的地址:

ULONG ulSize;

PIMAGE_IMPORT_DESCRIPTOR pid = (PIMAGE_IMPORT_DESCRIPTOR)

ImageDirectoryEntryToData(

hModule,

TRUE,

IMAGE_DIRECTORY_ENTRY_IMPORT,

&ulSize );

这个函数返回一个IMAGE_IMPORT_DESCRIPTOR的指针,指向输入描述符数据。然后,遍历该描述符表通过比较DLL名称查找到相应的DLL所对应的IMAGE_IMPORT_DESCRIPTOR

// if this image has no import section, just simply return and do nothing

if( pid == NULL )

return;

// find the corresponding item

while( pid->Name )

{

// pid->Name contains the RVA addr of the module name string

PSTR pszModName = (PSTR) ( (PBYTE)hModule + pid->Name );

if( lstrcmpiA( pszModuleName, pszModName ) == 0 )

{

// found

break;

}

pid++;

}

if( pid->Name == 0 )

{

// not found, just return

return;

}

找到相应的DLL之后,遍历其IAT表,根据地址pfnCurrentFuncAddr找到相应的表项,修改之

// get caller's import address table(IAT) for the callee's functions

PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA) ( (PBYTE)hModule + pid->FirstThunk );

while( pThunk->u1.Function )

{

PROC *ppfnEntry = (PROC*) &(pThunk->u1.Function);

if( *ppfnEntry == pfnCurrentFuncAddr )

{

// …

// Modify IAT

// …

}

pThunk++;

}

修改的时候,需要改变该块内存的保护为可读写,需要通过VirtualQuery获得内存的信息,然后通过VirtualProtectEx修改为可读写。之后可以通过WriteProcessMemory修改内存,修改完毕之后还要通过VirtualProtectEx再改回来。

SIZE_T sBytesWritten;

BOOL bProtectResult = FALSE;

DWORD dwOldProtect = 0;

MEMORY_BASIC_INFORMATION memInfo;

if( ::VirtualQuery( ppfnEntry, &memInfo, sizeof( memInfo ) ) > 0 )

{

// change the pages to read/write

bProtectResult =

::VirtualProtect(

memInfo.BaseAddress,

memInfo.RegionSize,

PAGE_READWRITE,

&dwOldProtect );

// then write it

::WriteProcessMemory( ::GetCurrentProcess(),

ppfnEntry, &pfnReplacementFuncAddr, sizeof( PROC * ), &sBytesWritten

);

// restore the page to its old protect status

bProtectResult =

::VirtualProtect(

memInfo.BaseAddress,

memInfo.RegionSize,

PAGE_READONLY,

&dwOldProtect );

}

3 远程线程

远程线程是Win2000以上才支持的技术。简单来讲,CreateRemoteThread函数会在其他进程中创建一个线程,执行指定的代码。因为这个线程并非在调用进程之中,而是在其他进程,因此称之为远程线程(Remote Thread)CreateRemoteThread的原型如下:

HANDLE WINAPI CreateRemoteThread(

HANDLE hProcess,

LPSECURITY_ATTRIBUTES lpThreadAttributes,

SIZE_T dwStackSize,

LPTHREAD_START_ROUTINE lpStartAddress,

LPVOID lpParameter,

DWORD dwCreationFlags,

LPDWORD lpThreadId

);

虽然概念上非常简单,但是使用CreateRemoteThread还会有一些问题:

1. lpStartAddress必须是其他进程的地址,但是我们又如何把代码放到另外一个进程中呢?幸运的是,有两个函数可以做到这一点:VirtualAllocExWriteProcessMemory,前者可以在指定进程中分配一块内存,WriteProcessMemory可以修改指定进程的代码。因此,先调用VirtualAllocEx在指定进程中分配内存,再调用WriteProcessMemory将代码写入到分配好的内存中,再调用CreateRemoteThread创建远程线程执行在事先准备好的代码。

2. 此外,这些代码必须得是自重定位的代码。在解释自重定位之前,先解释一下什么是重定位。在程序访问数据的时候,必须得访问某个绝对地址,如:

MOV EAX, DWORD PTR [00400120H]

[00400120] 便是一个绝对地址。但是,由于程序实际上可以任意地址加载(这句话其实是不准确的,后面会解释),因此这个地址不可能是固定的,而是会在加载的时候改变的。假如程序在0x00400000地址加载,访问地址是0x00400120,那么如果程序在0x00800000加载的话,那么地址应该会变成0x00800120,否则便会访问到错误的地址。因此,有必要在程序加载的时候修正这些地址,这个工作是由WindowsPE Loader,也就是程序的加载器负责的。当编译连接的时候,在EXE/DLL中会保存那些地方的数据需要重定位,并把这些位置的RVA和数据本身的RVA保存在.reloc重定位节中,从而在加载的时候,PE Loader会自动检查重定位节的内容并在程序执行之前对这些数据进行修正。

实际上,并非所有EXE/DLL都需要重定位。由于在单个地址空间中只有一个EXE,而这个EXE必然最先加载,因此这个EXE的加载地址总是不变的。因此,一般情况下EXE并不需要重定位信息,编译器一般在编译链接的时候会将EXE中的重定位信息去掉,以减少程序大小加快加载速度和运行速度。EXE一般在0x40000000的地址加载,一般没有特别原因无需修改。而DLL因为一般无法保证预先设置好的加载地址总能够满足。比如DLL可能指定在0x10000000地址加载,但是有可能此地址已经有其他DLL占据或者被EXE占据,DLL必须得在另外的地址加载,因此一般在DLL中总是保存重定位信息。

一段代码,一般情况下无法在任意地址执行。假设我们有下面的代码:

00400120 12h, 34h, 56h, 78h

00400124 MOV EAX, DWORD PTR [00400120H]

如果我们手动把这段代码copy到另外一个地方,如00500000,那么显然00400120H这个地址需要被修改,我们当然可以仿照自重定位的方法来手动修改这个地址值,但是通常较简单的方法是写自重定位代码,这样的代码可以在任意地址执行,具体做法如下:

call @F

@@:

pop ebx

sub ebx,offset @B

DATA db 12h, 34h, 56h, 78h

MOV EAX, [EBX + DATA]

可以看到,该段代码通过使用call指令压入当前地址eip并弹出从而得到当前地址。然后,用当前地址减去其标号的偏移量就得到重定位修正值,存入ebx之中。之后,就可以使用ebx作为一个基准来访问数据,以后访问数据可以用EBX + ???来访问,这样由于EBX会根据当前的地址值而变化,所以这段代码是自重定位的。

下面给出一段代码,这段代码中的InjectRemoteCode函数负责将RemoteThread这个函数的自重定位代码Copy到其他进程中执行:

;=============================================================================

; RemoteThread.ASM

; Author : ATField

; Description :

; This assembly file contains a InjectRemoteCode function

; which injects remote code into a process

; History :

; 2004-3-8 Start

; 2004-3-9 Completed and tested.

; 2004-3-26 bug fix:

; not all clients connected

; Wait for completion of the remote thread

;=============================================================================

.386

.MODEL FLAT, STDCALL ; must be stdcall here,

; or link error will occur

OPTION CASEMAP:NONE

INCLUDE WINDOWS.INC

INCLUDE USER32.INC

INCLUDELIB USER32.LIB

INCLUDE KERNEL32.INC

INCLUDELIB KERNEL32.LIB

;INCLUDE MACRO.INC

.DATA

hRemoteThread dd 0

szKernel32 db 'Kernel32.dll',0

hmodKernel32 dd 0

szGetProcAddress db 'GetProcAddress',0

szLoadLibraryA db 'LoadLibraryA',0

lpRemoteCode dd 0

lpGetProcAddress dd 0

lpLoadLibraryA dd 0

.CODE

;=============================================================================

; remote code starts here

;=============================================================================

REMOTE_CODE_START equ this byte

;=============================================================================

; data

;=============================================================================

lpRemoteGetProcAddress dd 0

lpRemoteLoadLibraryA dd 0

szRemoteDllPathName db 255 dup(0)

lpRemoteDllHandle dd 0

lpRemoteInitDll dd 0

szRemoteInitDllFuncName db 'InitializeDll',0

;=============================================================================

RemoteThread PROC uses ebx lParam

;=====================================================================

; relocation

;=====================================================================

; just for debug

;int 3

call @F

@@:

pop ebx

sub ebx,offset @B

; LoadLibraryA szRemoteDllPathName

lea ecx, [ebx + offset szRemoteDllPathName]

push ecx

call [ebx + offset lpRemoteLoadLibraryA]

test eax, eax

jz error

mov [ebx + offset lpRemoteDllHandle], eax

; GetProcAddress hModule InitializeDll

lea ecx, [ebx + offset szRemoteInitDllFuncName]

push ecx ; 'InitializeDll'

push [ebx + offset lpRemoteDllHandle] ; hmodule

call [ebx + offset lpRemoteGetProcAddress]

test eax, eax

jz error

; InitializeDll()

call eax

ret

error:

mov eax, -1

ret

RemoteThread endp

REMOTE_CODE_END equ this byte

REMOTE_CODE_LENGTH equ offset REMOTE_CODE_END - offset REMOTE_CODE_START

;=============================================================================

; remote code ends

;=============================================================================

; BUG FIX: do not use FAR here!

InjectRemoteCode PROC C, hProcess : HANDLE, szDllPathName : DWORD

INVOKE GetModuleHandleA, offset szKernel32