PE学习——导出表,加载dll并GetProcAddress获取函数地址的内在原理
导出表
一个可执行程序是由多个PE文件组成,这些PE文件依靠倒入表、导出表进行联系,导出表存储着PE文件提供给其他人使用的函数列表,导入表则存储着PE文件所需要用到的PE文件列表。从PE文件的角度去看,任何PE文件都可以有导入、导出表,从一般情况下来看,EXE文件不会提供导出表,也就是不会提供给他人使用的函数,但这并不代表不可以提供。
定位导出表
在PE格式图中,扩展PE头最后一个成员是结构体数组,在这个结构体数组里面有16个结构体,第一个结构体就是导出表相关的信息,它有2个成员,一个表示导出表的地址,一个表示导出表的大小。如下图所示中的_IMAGE_EXPORT_DIRECTORY,就是PE导出表的结构:
virtualaddress和size示例(注意:这里的RVA其实是加了image base的!我看到官方定义也没有严格说要加image base,见:https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_data_director, RVA定义:相对虚拟内存地址 从所在模块(dll)基地址开始的地址,即:模块基地址 + RVA = VA,所i有微软在这里并不严谨):
比如:CFF exp打开一个dll文件,看到的导出目录
我们可以自己编写、发布一个DLL,导出表这样写:
EXPORTS
Add
@10
Sub
@12
Div
@13
NONAME
Mul
@15
接着我们可以手动去定位一下导出表的位置,先找到扩展PE头的最后一个成员结构体数组:
然后找到该结构体数组的第一个结构体,里面就包含了导出表的地址和大小:
VirtualAddress:
0x0002AD80
Size:
0x0000017F
这个地址是RVA,它实际上表示的是相对虚拟地址,我们需要将其转为FOA,也就在文件中的偏移地址,由于在当前PE文件中文件对齐和内存对齐是一样的,即RVA等于FOA,所以我们也不需要进行转换:
接着跟进这个地址就可以找到导出表了:
接着我们来看下导出表的结构,你会发现我们实际上找到的导出表,其整体大小是大于如下结构(40字节),这是因为在这张表中还包含了3个子表,也就是如下结构体的最后三个成员:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
// 未使用
DWORD TimeDateStamp;
// 时间戳,表示当前PE文件(DLL)编译时的时间
WORD MajorVersion;
// 未使用
WORD MinorVersion;
// 未使用
DWORD Name;
// 当前导出表文件名字符串的地址
DWORD Base;
// 导出函数起始序号
DWORD NumberOfFunctions;
// 所有导出函数的个数
DWORD NumberOfNames;
// 以函数名字导出的函数个数
DWORD AddressOfFunctions;
// RVA,导出函数地址表
DWORD AddressOfNames;
// 导出函数名称表RVA
DWORD AddressOfNameOrdinals;
// 导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
所以排除子表,我们的结构体就只有40字节,接着我们从第4个成员,逐步解析。
解析导出表成员
Name
用于表示当前导出表文件名,这是一个字符串,所以我们只需要找到00即停止寻找:
0x0002ADD2
-> DLLDemo.dll
Base
用于表示当前导出函数的起始序号,也就是你定义的.DEF中最小的那个序号:
0x0000000A
->
10
NumberOfFunctions
用于表示所有导出函数的个数,这个数是按照你定义的.DEF中的序号算的,如果在导出时不按序号顺序导出,则空余位也计入总和,例如如下所示,我们实际上只有4个导出函数,但是因为导出函数的序号没有按照顺序来进行导出,导致计算的时候将空缺的也算入进去了:
0x00000006
->
6
NumberOfNames
用于表示以函数名字导出的函数个数,如果你在定义的.DEF中设置了导出函数的属性为NONAME则就是以序号的方式导出:
0x00000003
->
3
AddressOfFunctions
用于表示导出函数地址表的地址,这里的地址是RVA,如果当前PE文件的文件对齐与内存对齐不一致,需要转换为FOA,我们这边是一样的,所以不需要进行转换;通过NumberOfFunctions知道有6个导出函数,所以从该地址开始依次寻找6个4字节的地址即可,我们可以很清楚的看见这里的地址有些就是00填充的:
0x0002ADA8
->
0x0000100A
0x0000100F
0x00000000
0x00001019
0x00000000
0x00001014
如果你想看见具体的这些函数对应的代码,可以根据偏移地址找到对应的字节码,而后将其放入DTDebug之类的调试工具即可看见完整的代码,但是在这里编译器做了一下优化,加了一层JMP跳转,具体的就要跟下去了:
函数名 |
地址 |
字节码 |
Add |
0x0000100A |
E9 61 00 00 00 |
Sub |
0x0000100F |
E9 8C 00 00 00 |
Div |
0x00001019 |
E9 E7 00 00 00 |
Mul |
0x00001014 |
E9 B2 00 00 00 |
AddressOfNames
用于表示导出函数名称表的地址,这张表是按照首字母A-Za-z排序的,同样这里的地址是RVA,需要转换为FOA;通过NumberOfNames知道有3个是以函数名字导出的函数,所以从该地址开始依次寻找3个4字节的地址即可:
0x0002ADC0
->
0x0002ADDE
0x0002ADE2
0x0002ADE6
根据地址就可以找到对应的函数名(字符串见0x00即止):
AddressOfNameOrdinals
用于表示导出函数序号表的地址,同样这里的地址是RVA,需要转换为FOA;这里表中的成员数与AddressOfNames是一致的,但需要注意,序号表中的每个成员为2字节,那也就是从该地址开始依次寻找3个2字节数据即可:
0x0002ADCC
->
0x0000
0x0005
0x0002
至此,我们就解析完所有的导出表成员了。
子表之间的关系
当我们去调用一个DLL文件,使用其中的方法,需要使用到GetProcAddress函数去获取函数的地址然后调用:
FARPROC GetProcAddress(
HMODULE hModule,
// DLL模块句柄
LPCSTR lpProcName
// 函数名/序号
);
它的第二个参数lpProcName可以是函数名也可以是序号,而它的原理也正可以表示导出表中的三张子表之间的关系。
GetProcAddress(hModule, "Mul") ==》本质!!!
当直接使用函数名去寻找时,它的步骤是这样的:
-
先用函数名字去查询函数名称表的索引(每张表都有一个从0开始的索引);
-
根据函数名称表对应的索引去查函数序号表对应索引的序号;
-
根据函数序号表对应索引的序号查询函数地址表对应索引的地址。
GetProcAddress(hModule, 13) ==》本质!!!
当直接使用序号去寻找时,它的步骤是这样的:
-
使用序号减去Base(起始序号)得到的值;
-
将该值代入函数地址表对应索引,获得函数地址。
另外一个博客里对于导出表做了很好的说明:https://www.cnblogs.com/autopwn/p/15304905.html
相关测试代码
代码复用的实现: 1.静态链接库 一,创建静态链接库: (1)在VC6中创建项目:Win32 Static Library (2)在项目中创建两个文件:cntftools.h 和 cntftools.cpp; 这里创建两个文件就是上面操作完成之后,直接新建一个class即可生成这两文件; cntftools.h文件: #if !defined(AFX_TEST_H__DB32E837_3E66_4BE7_B873_C079BC621AF0__INCLUDED_) #define AFX_TEST_H__DB32E837_3E66_4BE7_B873_C079BC621AF0__INCLUDED_ #if _MSC_VER > 1000 #pragma once #endif // _MSC_VER > 1000 int Plus(int x, int y); int Sub(int x, int y); int Mul(int x, int y); int Div(int x, int y); #endif cntftools.cpp文件: int Plus(int x, int y) { return x+y; } int Sub(int x, int y) { return x-y; } int Mul(int x, int y) { return x*y; } int Div(int x, int y) { return x/y; } 3.编译 --- 点击编译即可,不需要执行,调试等其他操作; 二,使用静态链接库: 方式一: (1)将上面编译完成之后,生成的cntftools.h 和 cntflibs.lib复制到要使用的项目中,这里放的位置是生成项目的文件夹,不是debug 文件夹里面; (2)在需要使用的文件中包含:#include "cntftools.h" (3)在需要使用的文件中包含:#pragma comment(lib, "cntflibs.lib") (4)下面是重新生成的一个项目文件,然后放入上面的头文件和lib文件,编译成功可正常执行,下面是对应的代码 // sjlx.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <stdlib.h> #include "cntftools.h" #pragma comment(lib,"cntflibs.lib") int main() { int x = Plus(2,3); printf("%d\r\n",x); system("pause"); return 0; } 方式二: (1)将上面编译完成之后,生成的cntftools.h 和 cntflibs.lib复制到要使用的项目中,这里放的位置是生成项目的文件夹,不是debug 文件夹里面; (2)在需要使用的文件中包含:#include "cntftools.h"; (3)在项目名称中右键-->设置-Link-->找到Object/library Module 在最后面空格一下,添加cntflibs.lib;下面是最终的结果; kernel32.lib user32.lib gdi32.lib winspool.lib comdlg32.lib advapi32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib odbc32.lib odbccp32.lib kernel32.lib user32.lib gdi32.lib winspool.lib comdlg32.lib、 advapi32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib odbc32.lib odbccp32.lib cntflibs.lib (4)再次编译执行,可成功运行; 三,静态链接库的缺点: (1)使用静态链接生成的可执行文件体积较大,造成浪费; (2)我们常用的printf、memcpy、strcpy等就来自这种静态库 ; (3)最重要的原因就是因为生成的函数对应代码是在变成成功之后的exe里面,所以如果要更改lib的话,很麻烦,需要; 需要再次重新编译生成exe文件;验证这一点,可以通过执行此函数的过程中查看反汇编代码;发现生成的反汇编代码; 其对应的内存地址是带入了ImageBase:0x00400000; 2.动态链接库 一,创建DLL (1)在VC6中创建项目:Win32 Dynamic-Link Library ,名称写dtljkcntf (2)再在项目中创建两个文件:mydll.h 和 mydll.cpp; 这里创建两个文件就是,直接新建一个class即可生成这两文件 1.源文件中 int __stdcall Plus(int x,int y) { return x+y; } int __stdcall Sub(int x,int y) { return x-y; } int __stdcall Mul(int x,int y) { return x*y; } int __stdcall Div(int x,int y) { return x/y; } 2.头文件中 extern "C" _declspec(dllexport) __stdcall int Plus (int x,int y); extern "C" _declspec(dllexport) __stdcall int Sub (int x,int y); extern "C" _declspec(dllexport) __stdcall int Mul (int x,int y); extern "C" _declspec(dllexport) __stdcall int Div (int x,int y); 说明: 1.extern 表示这是个全局函数,可以供各个其他的函数调用; 2."C" 按照C语言的方式进行编译、链接 __declspec(dllexport)告诉编译器此函数为导出函数; 二,使用DLL 方式一:隐式连接 步骤1:将 *.dll *.lib 放到工程目录下面 步骤2:将 #pragma comment(lib,"DLL名.lib") 添加到调用文件中 步骤3:加入函数的声明 --这里就是加在项目对应的主程序代码里面,我这里测试就在main函数入口的代码上面; extern "C" __declspec(dllimport) __stdcall int Plus (int x,int y); extern "C" __declspec(dllimport) __stdcall int Sub (int x,int y); extern "C" __declspec(dllimport) __stdcall int Mul (int x,int y); extern "C" __declspec(dllimport) __stdcall int Div (int x,int y); 说明: __declspec(dllimport)告诉编译器此函数为导入函数; 下面是成功执行的主测试代码; #include "stdafx.h" #include <stdlib.h> __declspec(dllimport) int Plus (int x,int y); __declspec(dllimport) int Sub (int x,int y); __declspec(dllimport) int Mul (int x,int y); __declspec(dllimport) int Div (int x,int y); #pragma comment(lib,"dtljkcntf.lib") int main() { int x = Plus(2,3); printf("%d\r\n",x); system("pause"); return 0; } 上面需要注意的地方: 因为我测试编译生成dll和lib文件之前写的代码是没有带入extern "C" 和__stdcall这两个关键字; 当然上面是为了测试,实际情况需要带入是最好的; 测试结果是告诉我们,如果不带入上面两个关键字,那么编译生成的函数名称编译器会给我们重新命名; 简单理解操作就会给添加一些奇怪的字符,目的就是为了防止函数重名,因为在C++里面有重载的说法; 如果函数重名会导致其他异常情况; 所以总结一下:在生成dll和lib之前我们写的什么关键字代码,那么这里就要写什么; 查看dll文件显示函数信息: 使用工具可以是微软VC6.0 ++ 自带的Dependency进行查看; 也可以用OD查看,点击按钮"E"; 使用OD查看的时候,记得要要把上面生成好的dtljkcntf.dll文件放在; 可执行程序exe的相同目录下测试查看验证; 下面是带入extern "C" 和__stdcall关键字的操作; #include "stdafx.h" #include <stdlib.h> extern "C" __declspec(dllimport) __stdcall int Plus (int x,int y); extern "C" __declspec(dllimport) __stdcall int Sub (int x,int y); extern "C" __declspec(dllimport) __stdcall int Mul (int x,int y); extern "C" __declspec(dllimport) __stdcall int Div (int x,int y); #pragma comment(lib,"dtljkcntf.lib") int main() { int x = Plus(998,663); printf("%d\r\n",x); system("pause"); return 0; } 方式二:显示链接 步骤1: //定义函数指针 typedef int (__stdcall *lpPlus)(int,int); typedef int (__stdcall *lpSub)(int,int); typedef int (__stdcall *lpMul)(int,int); typedef int (__stdcall *lpDiv)(int,int); 步骤2: //声明函数指针变量 lpPlus myPlus; lpSub mySub; lpMul myMul; lpDiv myDiv; 步骤3: // //动态加载dll到内存中 HINSTANCE hModule = LoadLibrary("DllDemo.dll"); 步骤4: //获取函数地址 myPlus = (lpPlus)GetProcAddress(hModule, "_Plus@8"); mySub = (lpSub)GetProcAddress(hModule, "_Sub@8"); myMul = (lpMul)GetProcAddress(hModule, "_Mul@8"); myDiv = (lpDiv)GetProcAddress(hModule, "_Div@8"); 步骤5: //调用函数 int a = myPlus(10,2); int b = mySub(10,2); int c = myMul(10,2); int d = myDiv(10,2); 上述操作完成之后,对应的可执行代码如下: #include "stdafx.h" #include <stdlib.h> #include <windows.h> //#include <string.h> //定义函数指针; typedef int (__stdcall *lpPlus)(int,int); typedef int (__stdcall *lpSub)(int,int); typedef int (__stdcall *lpMul)(int,int); typedef int (__stdcall *lpDiv)(int,int); //上面的*lpPlus *lpSub *lpMul *lpDiv都是指针类型; int main() { //在main函数里面声明函数指针变量; lpPlus myPlus; lpSub mySub; lpMul myMul; lpDiv myDiv; //动态加载dll到内存中; HINSTANCE hModule = LoadLibrary("dtljkcntf.dll"); //获取函数地址; myPlus = (lpPlus)GetProcAddress(hModule, "_Plus@8"); mySub = (lpSub)GetProcAddress(hModule, "_Sub@8"); myMul = (lpMul)GetProcAddress(hModule, "_Mul@8"); myDiv = (lpDiv)GetProcAddress(hModule, "_Div@8"); int x = myPlus(998,3); printf("%d\r\n",x); system("pause"); return 0; } 特别说明: Handle 是代表系统的内核对象,如文件句柄,线程句柄,进程句柄。 HMODULE 是代表应用程序载入的模块 HINSTANCE 在win32下与HMODULE是相同的东西 Win16 遗留 HWND 是窗口句柄 其实就是一个无符号整型,Windows之所以这样设计有2个目的: (1)可读性更好 (2)避免在无意中进行运算 3.使用.def导出 下面两步操作之前,先要新建一个动态链接库的文件模板,这里操作跟上面一样,为这里新建的名称是dtljkdef; 然后生成下面.h和.cpp这两个后缀的名字就是新建class操作即可; (1)dtdef.h文件 int Plus (int x,int y); int Sub (int x,int y); int Mul (int x,int y); int Div (int x,int y); (2)dtdef.cpp文件 int Plus(int x,int y) { return x+y; } int Sub(int x,int y) { return x-y; } int Mul(int x,int y) { return x*y; } int Div(int x,int y) { return x/y; } (3)cntfdefs.def文件 ---> 这一步操作就是新建一个文本文件,我这的名称是cntfdefs.def(New - Text File)写入下面文件; 需要注意:这里新建这个文本的时候,要带上后再名称def,然后要勾选上面的添加到项目的复选框,否则编译没问题使用,有问题; EXPORTS Plus @12 Sub @15 NONAME Mul @13 NONAME Div @16 上述的NONAME就是隐藏名字的意思,为这里隐藏了减法和乘法 (4)编译;编译完成之后复制dll文件到需要使用此dll文件的项目文件夹里面即可; (4)使用序号导出的好处: 名字是一段程序就精华的注释,通过名字可以直接猜测到函数的功能 通过使用序号,可以达到隐藏的目的.
将生成的dll和lib文件拷贝到新建的一个项目里面
使用显示链接的方式调用dll
dll内容
好了,我们自己动手实践下:
vs 2017里,创建一个dll项目,然后:
我贴下代码:
// dllmain.cpp : 定义 DLL 应用程序的入口点。 #include "pch.h" BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: case DLL_PROCESS_DETACH: break; } return TRUE; }
mydll.h 因为使用了def导出文件,所以就没有必要再使用_declspec(dllexport) 了!
#pragma once /* extern "C" _declspec(dllexport) int __stdcall Plus(int x, int y); extern "C" _declspec(dllexport) int __stdcall Sub(int x, int y); extern "C" _declspec(dllexport) int __stdcall Mul(int x, int y); extern "C" _declspec(dllexport) int __stdcall Div(int x, int y); */ extern "C" int __stdcall Plus(int x, int y); extern "C" int __stdcall Sub(int x, int y); extern "C" int __stdcall Mul(int x, int y); extern "C" int __stdcall Div(int x, int y);
mydll.cpp
#include "pch.h" #include "mydll.h" int __stdcall Plus(int x, int y) { return x + y; } int __stdcall Sub(int x, int y) { return x - y; } int __stdcall Mul(int x, int y) { return x * y; } int __stdcall Div(int x, int y) { return x / y; }
Source.def文件:
EXPORTS Plus @12 Sub @15 NONAME Mul @13 NONAME Div @16
为了让文件大小对齐和内存对齐一样(主要是偷懒,不想去计算RVA和FVA的转换),都是0x1000(默认是200)
我修改下项目属性:
/FILEALIGN:4096 就是0x1000的十进制表示。
该选项含义:https://learn.microsoft.com/en-us/cpp/build/reference/filealign?view=msvc-150
/FILEALIGN选项使链接器将输出文件中的每个部分对齐到大小值的倍数的边界上。/FILEALIGN选项可用于提高磁盘利用率,或加快从磁盘加载页面的速度。较小的部分大小对于在较小设备上运行的应用程序或保持较小的下载量可能很有用。磁盘上的节对齐不会影响内存中的对齐。
好了,接下来,我们看下生成的DLL导出表:
我们去看下plus函数的函数名位置和代码位置(ImageBase为0x1000000):
我们接下来看看函数的位置1005地方:
验证下看看:
果然一模一样!贴下验证代码:
#include <stdio.h> #include <stdlib.h> #include <windows.h> //#include <string.h> //定义函数指针; typedef int(__stdcall *lpPlus)(int, int); typedef int(__stdcall *lpSub)(int, int); typedef int(__stdcall *lpMul)(int, int); typedef int(__stdcall *lpDiv)(int, int); //上面的*lpPlus *lpSub *lpMul *lpDiv都是指针类型; int main() { //在main函数里面声明函数指针变量; lpPlus myPlus; lpSub mySub; lpMul myMul; lpDiv myDiv; //动态加载dll到内存中; HINSTANCE hModule = LoadLibrary(L"D:\\source\\repos\\dtljkcntf\\Debug\\dtljkcntf.dll"); //获取函数地址; myPlus = (lpPlus)GetProcAddress(hModule, "Plus"); int x = myPlus(998, 3); printf("%d\r\n", x); system("pause"); return 0; }
使用winhex打开存储在硬盘位置的ipmsg.exe
【更新】:补充一个项目实战例子,当rve和foa不一样的时候,应该如何查看函数代码进行分析?
背景:
例如:注入的文件名 LHShield.dll,要看看注入DLL的功能:
base:1000 0000
section:00001000
filealignment:00000200
QAXStart 0014 1D21(名字) ==》0007 E8C0(地址)
文件偏移FOA:
0007E8C0-00001000+00000400=7DCC0(函数地址)
00141D21-0011A000+00119000=140D21(函数名字)
IDA里对应的地址:
函数地址:1007E8C0
我们看下反汇编代码:
为了显示opcode,修改下number of opcode bytes为6:
我们对比下文件内容和IDA,可以看到两边的opcode结果完全一样:
当然,要弄明白这一大段代码的作用,就需要很多耐心了。
你也知道程序员看没有文档的代码是多么蛋疼!