本文共 10059 字,大约阅读时间需要 33 分钟。
试想一下,如果通过修改导入表,能把PE格式文件中的函数入口点,重定向到自己的程序中来,是不是很酷!这样,在自己在程序中,可以过滤掉对某些函数的调用,或者,设置自己的处理程序,Professional Portable Executable (PE) Protector也就是这样做的。另外,某些rootkit也使用了此方法把恶意代码嵌入到正常程序中。在逆向工程的概念里,均称为API重定向技术,让我们一起进入这个神奇的世界吧。
PE文件由MS-DOS头、NT头、节头、节映像组成,如图1所示。MS-DOS头在从DOS至现今Windows的所有微软可执行文件中都存在;NT头的概念抽象自Unix系统的可执行与链接文件格式(ELF)。实际上,PE格式可以说是Linux可执行与链接格式(ELF)的兄弟,PE格式头由PE签名、通用对象文件格式(COFF)头、PE最优化头、节头组成。
NT头的定义可在Visual C++的<winnt.h>头文件中找到,也可使用DbgHelp.dll中的ImageNtHeader()函数得到相关信息,另外,还可以使用DOS头来获取NT头,因为DOS头的最后位置e_lfanew,代表了NT头的偏移量。
typedef struct _IMAGE_NT_HEADERS {
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;
在PE最优化头中,有一些数据目录指明了当前进程虚拟内存中,主信息表的相对位置及大小,这些表可保存资源、导入、导出、重定位、调试、线程本地存储及COM运行时的有关信息。没有导入表的PE可执行文件是不可能存在的,这张表包含了DLL名及函数名,这些都是程序通过虚拟地址调用它们时所必不可少的;控制台可执行文件中没有资源表,然而,对图形用户界面的Windows可执行文件来说,资源表却是至关重要的部分;当某个动态链接库导出了它的函数,此时就需要导出表了,在OLE Active-X容器中也同样;而.NET虚拟机缺少了COM+运行时头则不能被执行。PE格式的详细说明见表1:
|
|
|
|
|
|
|
|
7 Architecture Data(架构数据) |
|
9 Thread Local Storage Table(线程本地存储表) |
10 Load Config Table(加载配置表) |
11 Bound Import Table(边界导入表) |
12 Import Address Table(导入地址表) |
13 Delay Import Descriptor(延误导入描述符) |
14 COM+ Runtime Header(COM+运行时头) |
|
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
typedef struct _IMAGE_OPTIONAL_HEADER {
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 //导出目录
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 //导入目录
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 //资源目录
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 //基重定位表
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 //调试目录
#define IMAGE_DIRECTORY_ENTRY_TLS 9 //TLS目录
可通过简单的几行代码获得导入表的位置及大小,知道了导入表的位置后,就可知道DLL名及函数名了,这将在后面进行讨论。
PIMAGE_NT_HEADERS pimage_nt_headers = ImageNtHeader(pImageBase);
DWORD it_voffset = pimage_nt_headers->OptionalHeader.
DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
PIMAGE_DOS_HEADER pimage_dos_header = PIMAGE_DOS_HEADER(pImageBase);
PIMAGE_NT_HEADERS pimage_nt_headers = (PIMAGE_NT_HEADERS)
(pImageBase + pimage_dos_header->e_lfanew);
DWORD it_voffset = pimage_nt_headers->OptionalHeader.
DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
通过导入表的导入目录项,可获知文件映像内部导入表的位置,且对每个导入的DLL、导入描述符,都有一个相应的容器,其包含了首个thunk(转换程序)地址、原始首个thunk的地址,还有指向DLL名的指针。FirstThunk指向首个thunk的位置,这些thunk在程序运行时,由Windows的PE加载器初始化,如图5所示。OriginalFirstThunk指向这些thunk的第一个存储位置,对每个函数而言,这也是提供Hint(提示)数据地址及函数名数据之处,见图4。在本例中,OriginalFirstThunk不存在,而FirstThunk则指向了提示数据及函数名数据位置之处,见图3。
IMAGE_IMPORT_DESCRIPTOR结构代表了导入描述符,以下是其定义:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
DWORD OriginalFirstThunk;
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;
OriginalFirstThunk指向第一个thunk(IMAGE_THUNK_DATA),thunk中保存了提示数据地址及函数名。
TimeDateStamp包含了绑定的时间日期戳,如果它为0,表示在导入的DLL没有任何绑定。在将来,会设为0xFFFFFFFF以表明有绑定。
ForwarderChain指向API的第一个转发链,设为0xFFFFFFFF表示没有转发。
FirstThunk包含了由IMAGE_THUNK_DATA定义的首个thunk数组的虚拟地址,而thunk由加载器用函数虚拟地址初始化。如果OrignalFirstThunk不存在,它指向了第一个thunk、提示(Hint)thunk及函数名。
typedef struct _IMAGE_IMPORT_BY_NAME {
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
typedef struct _IMAGE_THUNK_DATA {
PIMAGE_IMPORT_BY_NAME AddressOfData;
} IMAGE_THUNK_DATA, *PIMAGE_THUNK_DATA;
图3:带有Orignal First Thunk的导入表视图
这两张导入表(图2、3)清楚地说明了带有及不带Orignal First Thunk时的区别。
可用Dependency Walker,见图5,来查看导入表的所有信息,另外,还有一个小工具Import Table viewer,见图6,也可用来查看此类信息。
图5:Dependency Walker——Visual Studio自带的工具
另外,也可利用下面这段代码,在自己的程序中显示导入DLL及导入函数(只适用于控制台模式的程序)。
//----------------------------------------
DWORD dwImportDirectory= RVA2Offset(pImageBase, pimage_nt_headers->
DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
//----------------------------------------
PIMAGE_IMPORT_DESCRIPTOR pimage_import_descriptor=
(PIMAGE_IMPORT_DESCRIPTOR) (pImageBase+dwImportDirectory);
//----------------------------------------
while(pimage_import_descriptor->Name!=0)
pThunk= pImageBase+pimage_import_descriptor->FirstThunk;
if(pimage_import_descriptor->OriginalFirstThunk!=0)
pHintName+= RVA2Offset(pImageBase,
pimage_import_descriptor->OriginalFirstThunk);
pHintName+= RVA2Offset(pImageBase, pimage_import_descriptor->FirstThunk);
pDllName= pImageBase + RVA2Offset(pImageBase,pimage_import_descriptor->Name);
printf(" DLL Name: %s First Thunk: 0x%x", pDllName,
pimage_import_descriptor->FirstThunk);
PIMAGE_THUNK_DATA pimage_thunk_data= (PIMAGE_THUNK_DATA) pHintName;
while(pimage_thunk_data->u1.AddressOfData!=0)
dwAPIaddress= pimage_thunk_data->u1.AddressOfData;
if((dwAPIaddress&0x80000000)==0x80000000)
dwAPIaddress&= 0x7FFFFFFF;
printf("Proccess: 0x%x", dwAPIaddress);
pAPIName= pImageBase+RVA2Offset(pImageBase, dwAPIaddress)+2;
printf("Proccess: %s", pAPIName);
pimage_import_descriptor++;
在了解了导入表的所有基础知识后,现在就要来看看重定向方法了,其算法非常简单,在当前进程的虚拟内存内部创建一个额外的虚拟空间,并生成相应指令通过JMP重定向到原始函数位置。在这里,可使用绝对跳转或相对跳转,在使用绝对跳转时要非常小心,不能像图7中那么简单地进行,应首先把虚拟地址存到EAX中,接着由JMP EAX跳转。
当然了,也可以使用相对跳转,下面的代码就是这样做的,但要清楚,不能对所有DLL模块都进行API重定向,比如,拿“计算器”CALC.EXE来说,MSVCRT.DLL的某些thunk在运行时初始化期间,是从CALC.EXE代码节内部访问的,因此,重定向的话则不能正常工作。
//NewITaddress=VirtualAlloc(NULL, 0x01D000,
// MEM_COMMIT, PAGE_READWRITE);
add ebx,esi // dwImageBase + dwImportVirtualAddress
_it_fixup_1_get_lib_address_loop:
mov eax,[ebx+0ch] // image_import_descriptor.Name
mov ecx,[ebx+10h] // image_import_descriptor.FirstThunk
mov [ebp-08h],ecx // dwThunk
mov ecx,[ebx] // image_import_descriptor.Characteristics
mov [ebp-0ch],ecx // dwHintName
add eax,esi // image_import_descriptor.Name
// + dwImageBase = ModuleName
push eax // lpLibFileName
call _jmp_LoadLibrary // LoadLibrary(lpLibFileName);
_it_fixup_1_get_proc_address_loop:
mov ecx,[ebp-0ch] // dwHintName
mov edx,[ecx] // image_thunk_data.Ordinal
jz _it_fixup_1_next_module
test edx,080000000h //是否按顺序导入
and edx,07FFFFFFFh //取得顺序
add edx,esi // image_thunk_data.Ordinal +
// dwImageBase = OrdinalName
inc edx // OrdinalName.Name
call _jmp_GetProcAddress // GetProcAddress(hModule,
mov [ebp-14h],eax //_p_dwAPIaddress
//=========================================================
_it_fixup_1_check_dll_redirected:
jz _it_fixup_1_do_normal_it_0
jnz _it_fixup_1_check_dll_redirected
jmp _it_fixup_1_do_normal_it_1
_it_fixup_1_do_normal_it_0:
mov byte ptr [edi], 0e9h // JMP指令
mov word ptr [edi+05], 0c08bh
mov [ecx],edi // -> Thunk
add dword ptr [ebp-04h],07h
_it_fixup_1_do_normal_it_1:
//===================================================
add dword ptr [ebp-08h],004h // dwThunk => next dwThunk
add dword ptr [ebp-0ch],004h // dwHintName =>
jmp _it_fixup_1_get_proc_address_loop
add ebx,014h // sizeof(IMAGE_IMPORT_DESCRIPTOR)
jmp _it_fixup_1_get_lib_address_loop
说点题外话,千万不要认为这样就可以绕过Professional EXE Protector了,这个软件中有一套自己的x86指令生成引擎用于创建重定向代码,有时,这个引擎还带有一个扰乱变形引擎,以使它复杂到难以跟踪分析。
1、 创建一个单独的空间以存储由VirtualAlloc()生成的指令。
2、 通过LoadLibrary()和GerProcAddress()找到函数的虚拟地址。
3、 检查DLL名是否匹配有效DLL列表。在本例中,识别出KERNEL32.DLL、USER32.DLL、GDI32.DLL、ADVAPI32.DLL、SHELL32.DLL为可重定向的有效DLL名。
4、 如果DLL名有效,转到重定向部分;否则,用原始函数虚拟地址初始化thunk。
5、 为重定向API,生成JMP(0xE9)指令并计算函数的相对地址以确定相对跳转。
6、 把生成的指令存储在单独的空间中,并把thunk引用为这些指令的首地址。
如果对CALC.EXE执行了以上步骤,并以OllyDbg进行跟踪,将会看到类似以下的代码:
008E0000 - E9 E6F8177C JMP SHELL32.ShellAboutW
008E0005 8BC0 MOV EAX,EAX
008E0007 - E9 0F764F77 JMP ADVAPI32.RegOpenKeyExA
008E000C 8BC0 MOV EAX,EAX
008E000E - E9 70784F77 JMP ADVAPI32.RegQueryValueExA
008E0013 8BC0 MOV EAX,EAX
008E0015 - E9 D66B4F77 JMP ADVAPI32.RegCloseKey
008E001A 8BC0 MOV EAX,EAX
008E001C - E9 08B5F27B JMP kernel32.GetModuleHandleA
008E0021 8BC0 MOV EAX,EAX
008E0023 - E9 4F1DF27B JMP kernel32.LoadLibraryA
008E0028 8BC0 MOV EAX,EAX
008E002A - E9 F9ABF27B JMP kernel32.GetProcAddress
008E002F 8BC0 MOV EAX,EAX
008E0031 - E9 1AE4F77B JMP kernel32.LocalCompact
008E0036 8BC0 MOV EAX,EAX
008E0038 - E9 F0FEF27B JMP kernel32.GlobalAlloc
008E003D 8BC0 MOV EAX,EAX
008E003F - E9 EBFDF27B JMP kernel32.GlobalFree
008E0044 8BC0 MOV EAX,EAX
008E0046 - E9 7E25F37B JMP kernel32.GlobalReAlloc
008E004B 8BC0 MOV EAX,EAX
008E004D - E9 07A8F27B JMP kernel32.lstrcmpW
008E0052 8BC0 MOV EAX,EAX
008E0000 - B8 EBF8A57C MOV EAX,7CA5F8EBh
下面,就要用重定向技术来改变某个API的功能了,在本例中,将把CALC.EXE的ShellAbout()对话框重定向到“Hello World!”消息框,且只需对前述代码作稍许改动:
//==============================================================