转自:信安之路
IAT 的全称是 ImportAddress Table
。在可执行文件中使用其他 DLL 可执行文件的代码或数据,称为导入或者输入,当 PE 文件载入内存时,windows 加载器会定位所有导入的函数或数据将定位到的内容填写至可执行文件的某个位置供其使用,而这个操作是需要借助导入表来完成的。
导入表中存放了程序所有使用的 DLL 模块名称及导入的函数名称或函数序号。
本文所用文件及源代码下载地址:
https://pan.baidu.com/s/1o9360AI
在脱壳和加壳的研究中,导入表是非常关键的部分,加壳要尽可能的隐藏或破坏原始的导入表。脱壳一定要找到或者还原原本的导入表。
举个简单的例子,我们已经找到了加壳程序的 OEP 并转存了下来,但该程序并不能正常运行,这时我们就要手工修复程序的 IAT。
在反病毒的静态分析中,我们可以通过病毒的导入表,初步确定病毒的行为。
在免杀中也有对 IAT 的操作,比如隐藏导入表,修改导入表描述信息,移动导入表函数等等。
在 Hook 操作中也有相应的 IAT 钩子……
今天我们的目的是找出一个 PE 文件中所有的 DLL 以及每个 DLL 的导入函数并通过编程体会这一过程,先用一张图介绍一下 PE 结构吧:
(emmm…… 从网上找的,觉得做得很不错,如有侵权请联系我)
在 IMAGE_OPTIONAL_HEADER 的 IMAGE_DATA_DIRECTORY 中定位到第二个目录,即 IMAGE_DIRECTORY_ENTRY_IMPORT。该结构体保存了导入函数的 RVA 地址,通过该 RVA 地址可以定位到导入表的具体位置。
描述导入表结构体是 IMAGE_IMPORT_DESCRIPTOR,也就是说每一个导入的 DLL 都有一个对应的 IMAGE_IMPORT_DESCRIPTOR,并且以数组的形式存放在文件中的,结构体的定义如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;//该字段指向导入名称表(INT),该RVA是一个IMAGE_THUNK_DATA结构体
};
DWORD TimeDateStamp;//可以忽略,一般为0
DWORD ForwarderChain;//一般为0
DWORD Name;//指向DLL的名称的RVA地址
DWORD FirstThunk;//该字段包含导入地址表(IAT)的RVA,IAT是一个IMAGE_THUNK_DATA结构体数组
} IMAGE_IMPORT_DESCRIPTOR;
我们可以发现导入信息中并没有指定导入表的个数,而是以一个全 “0” 的 IMAGE_IMPORT_DESCRIPTOR 作为结束标志的。
下面来看一下 IMAGE_THUNK_DATA 结构体的定义:
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; // 一个RVA地址,指向forwarder string
DWORD Function; // PDWORD,被导入的函数的入口地址
DWORD Ordinal; // 该函数的序数
DWORD AddressOfData; // 一个RVA地址,指向IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
每一个 IMAGE_THUNK_DATA 对应一个 DLL 中的导入函数。与 IMAGE_IMPORT_DESCRIPTOR 类似,IMAGE_THUNK_DATA 在文件中也是一个数组,并以一个全为 “0” 的 IMAGE_THUNK_DATA 结束。
当该结构体值的最高位为 0 时,表示函数以函数名字符串的方式导入,这时该 DWORD 的值表示一个 RVA,并指向一个 IMAGE_IMPORT_BY_NAME 结构体:
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; //该函数的导出序数
BYTE Name[1]; // 该函数的名字
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
这里简单介绍一下 IAT 与 INT 的区别:
IMAGE_IMPORT_DESCRIPTOR
结构体中的 OriginalFirstThunk
和 FirstThunk
都指向 IMAGE_THUNK_DATA
结构体。
当文件在磁盘上时,两者指向的是同一个 IMAGE_THUNK_DATA
,而当文件载入内存时 OriginalFirstThunk
中保存的仍然是指向函数的 RVA,而 FirstThunk
指向的内存变成了由装载器填充的导入函数地址,即 IAT。
首先用 C32Asm 以十六进制模式打开 PE.exe,点击工具栏中的查看并单击PE信息,如图
这里要注意一下,给出的是 RVA 地址也就是载入内存后的地址,目前我们没有载入内存,只是在磁盘中打开而已,所以要把 RVA 转换为 FileOffset
,手工转换我就不讲了,我只讲使用工具转换。打开 loadPE 载入 PE.exe,单击位置计算器。
输入 00002244
,可以看到 FileOffset
已经算了出来。
在 C32Asm 中 Ctrl+G
,输入 00001044
。
可以看到,有四个 IMAGE_IMPORT_DESCRIPTOR
的结构体,但是第四个是一个全 “0” 的结构体。做成表格
Name 指向的是 Dll 的名称,注意这里 Name 也是 RVA 转成 FileOffset
为 00001146
、000012B6
、000013E8
,在 C32Asm 里查找
整理成表格
我们就以 KERNEL32.DLL
为例,查看 OriginalFirstThunk
和 FirstThunk
的内容,先查看 OriginalFirstThunk
,将 RVA 转为 FileOffset
,转到 00001094
可以看到有 8 个 IMAGE_THUNK_DATA
结构体,也就说有 8 个导入函数,第一个结构体指向的 RVA 为 000025D8
转为 FileOffset
等于 000013D8
,转到 000013D8
000013D8
处是一个 IMAGE_IMPORT_BY_NAME
结构体,前两个字节是 Hint 的值,所以导入函数的名称为 DecodePointer
。使用 loadPE 查看导入表,发现与我们分析的一致。
至于 FirstThunk
,大家可以参照上面的步骤一步一步查找。
我分享给大家两种方法(源码已经上传到网盘中,供大家下载)
操作系统:win 7
IDE: vs2013
请看这张图
把文件映射入内存之后,要开始寻找 IMAGE_IMPORT_DESCRIPTOR 的位置,并用两层循环把 DLL 的名字和 DLL 中函数的名字输出,核心代码如下:
IMAGE_DOS_HEADER *dosHeader;
IMAGE_NT_HEADERS *ntHeader;
IMAGE_IMPORT_BY_NAME *ImportName;
//lpBase由MapViewOfFile函数返回
dosHeader = (IMAGE_DOS_HEADER*)lpBase;
//检测是否是有效的PE文件
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE)
{
printf("This is not a windows file\n");
return 0;
}
//定位到PE header
ntHeader = (IMAGE_NT_HEADERS*)((BYTE*)lpBase + dosHeader->e_lfanew);//e_lfanew成员定位到PE header
//判断是否是一个有效的win32文件
if (ntHeader->Signature != IMAGE_NT_SIGNATURE)
{
printf("This is not a win32 file\n");
return 0;
}
//定位到导入表
IMAGE_IMPORT_DESCRIPTOR *ImportDec = (IMAGE_IMPORT_DESCRIPTOR*)((BYTE*)lpBase + RVAToOffset(lpBase, ntHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress));
while (ImportDec->FirstThunk)
{
//得到DLL文件名
char *pDllName = (char*)((BYTE*)lpBase + RVAToOffset(lpBase, ImportDec->Name));
printf("\nDLL文件名:%s\n", pDllName);
//通过OriginalFirstThunk定位到PIMAGE_THUNK_DATA结构数组
PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((BYTE*)lpBase + RVAToOffset(lpBase, ImportDec->OriginalFirstThunk));
while (pThunk->u1.Function)
{
//判断函数是用函数名导入的还是序号导入的
if (pThunk->u1.Ordinal& IMAGE_ORDINAL_FLAG32)//高位为1
{
//输出序号
printf("从此DLL模块导出的函数的序号:%x\n", pThunk->u1.Ordinal & 0xFFFF);
}
else//高位为0
{
//得到IMAGE_IMPORT_BY_NAME结构中的函数名
ImportName = (IMAGE_IMPORT_BY_NAME*)((BYTE*)lpBase + RVAToOffset(lpBase, (DWORD)pThunk->u1.AddressOfData));
printf("从此DLL模块导出的函数的函数名:%s\n", ImportName->Name);
}
pThunk++;
}
ImportDec++;
}
要注意计算 FileOffset
,代码如下
//用来实现RVA到FileOffset的转换
DWORD RVAToOffset(LPVOID lpBase, DWORD VirtualAddress)
{
IMAGE_DOS_HEADER *dosHeader;
IMAGE_NT_HEADERS *ntHeader;
IMAGE_SECTION_HEADER *SectionHeader;
int NumOfSections;//Section 的数量
//定位到PE head
dosHeader = (IMAGE_DOS_HEADER*)lpBase;
ntHeader = (IMAGE_NT_HEADERS*)((BYTE*)lpBase + dosHeader->e_lfanew);
NumOfSections = ntHeader->FileHeader.NumberOfSections;
for (int i = 0; i<NumOfSections; i++)
{
SectionHeader = (IMAGE_SECTION_HEADER*)((BYTE*)lpBase + dosHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS)) + i;
//判断RVA是否在这个节区之内
if (VirtualAddress>SectionHeader->VirtualAddress&&VirtualAddress<SectionHeader->VirtualAddress + SectionHeader->SizeOfRawData)
{
DWORD AposRAV = VirtualAddress - SectionHeader->VirtualAddress;
DWORD Offset = SectionHeader->PointerToRawData + AposRAV;
return Offset;
}
}
return 0;
}
程序运行效果如下
由于把文件映射入内存这些操作比较麻烦,我们可以直接通过 LoadLibraryW
或者 GetModuleHandle
,将目标文件直接载入内存直接进行操作,代码如下。
HMODULE hExe = LoadLibraryW(L"c:\\PE.exe");
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData((void*)hExe, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &size);
PIMAGE_IMPORT_DESCRIPTOR Des = pImportDesc;
while (Des->Name)
{
printf("DllName = %s \r\n",(DWORD)hExe+(DWORD)Des->Name);
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(Des->FirstThunk + (DWORD)hExe);
while (thunk->u1.Function)
{
if (thunk->u1.Ordinal & IMAGE_ORDINAL_FLAG)
{
printf("Ordinal = %08x \r\n", thunk->u1.Ordinal & 0xFFFF);
}
else
{
PIMAGE_IMPORT_BY_NAME PimName = (PIMAGE_IMPORT_BY_NAME)thunk->u1.Function;
printf("FuncName = %s\r\n",(DWORD)hExe+PimName->Name);
}
thunk++;
}
Des++;
}
因为 PE 文件已经载入到内存中了,所以我们不需要计算 FileOffset。
程序效果图如下
最后算是给大家留一点思考的空间,先介绍几个 API
fopen(打开文件)
FILE * fopen(const char * path,const char * mode);
mode
有下列几种形态字符串:
r 打开只读文件,该文件必须存在。
r+ 打开可读写的文件,该文件必须存在。
w 打开只写文件,若文件存在则文件长度清为 0,即该文件内容会消失。若文件不存在则建立该文件。
w+ 打开可读写文件,若文件存在则文件长度清为零,即该文件内容会消失。若文件不存在则建立该文件。
a 以附加的方式打开只写文件。若文件不存在,则会建立该文件,如果文件存在,写入的数据会被加到文件尾,即文件原先的内容会被保留。
a+ 以附加方式打开可读写的文件。若文件不存在,则会建立该文件,如果文件存在,写入的数据会被加到文件尾后,即文件原先的内容会被保留。
上述的形态字符串都可以再加一个 b 字符,如 rb、w+b 或 ab+ 等组合,加入 b 字符用来告诉函数库打开的文件为二进制文件,而非纯文字文件。
由 fopen()
所建立的新文件会具有 S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH(0666)
权限,此文件权限也会参考 umask
值。
fseek(重定位流上的文件指针)
int fseek(FILE *stream, long offset, int fromwhere);
函数设置文件指针 stream 的位置。如果执行成功,stream 将指向以 fromwhere 为基准,偏移 offset 个字节的位置。
如果执行失败(比如 offset 超过文件自身大小),则不改变 stream 指向的位置。
fread(读文件数据)
int fread(void *ptr, int size, int nitems, FILE *stream);
用于接收数据的地址(指针)(ptr)
单个元素的大小(size)
元素个数(nitems)
提供数据的文件指针(stream)
思路如下:
我们可以通过 fopen()
打开相应的 PE 文件,接着调用 fseek()
函数并设置相应的偏移,最后调用 fwrite()
把对应偏移的数据读入相应的变量中,以下是代码片段(供大家参考)
pNewFile = fopen(newFileName, "rb+"); //打开方式"rb+"
if (NULL == pNewFile)
{
puts("Open file failed");
exit(0);
}
fseek(pNewFile, 0, SEEK_SET);
fread(&DosHeader, sizeof(IMAGE_DOS_HEADER), 1, pNewFile);//DosHeader是IMAGE_DOS_HEADER类型的变量
后面的操作跟上面两种类似。
如果您还有其他方法欢迎在下方留言……
相对于前两种方法,第三种方法在添加节区插入 stub 数据时会省去一些不必要的操作,简单方便。
这里我想扩展一点,说到读取导入函数的信息,不知大家有没有想到在 shellcode 中动态获取函数地址的操作?
首先查找 kernel32.dll 的基地址
mov ebx,fs:[edx+0x30] //[TEB+0x30]是PEB的位置
mov ecx,[ebx+0xc] //[PEB+0xc]是PEB_LDR_DATA的位置
mov ecx,[ecx+0x1c] //[PEB_LDR_DATA+0x1c]是ntdll.dll的位置
mov ecx,[ecx] //进入链表第一个就是ntdll.dll
mov ebp,[ecx+0x8] //ebp保存的是kernel32.dll的基地址
……
接着在中 kernel32.dll 查找相应的API函数
pushad //保护所有的寄存器中的内容
mov eax,[ebp+0x3c] //PE头
mov ecx,[ebp+eax+0x78] //导入表的指针
add ecx,ebp
mov ebx,[ecx+0x20] //导出函数的名字列表
……
popad
实际上跟上面都是类似的……