PE结构 | 导出表详解 | 二进制安全

admin 2022年12月5日11:02:11评论75 views字数 7399阅读24分39秒阅读模式

    "人终将被年少不可得之物困其一生"

概述

导出表主要是将对外开放的函数合并成一个表结构,对外部开放一个调用接口,通过查表的方式进行动态的加载调用。导出表位于数据目录表的第一项(索引值为0)

//资源目录表,索引值为零的元素存储导出表结构地址
_IMAGE_DATA_DIRECTORY DataDirectory[0];
  • 一个PE文件可以有多个导入表,因为一个PE文件可能需要调用不同DLL中的函数

  • 一个PE文件只有一个导出表,该表记录了当前文件对外开放的函数接口

  • 导出表$textcolor{yellow}{经常} $出现在DLL文件中


DLL文件

DLL文件骨架:

#include "stdafx.h"
//该函数时dll的入口函数,但是该入口函数不是必须的
BOOL APIENTRY DllMain( HMODULE hModule,//类似WinMain中的hInstance
                      DWORD  ul_reason_for_call,//调用dll文件的原因、时机
                      LPVOID lpReserved//保留字段
                    )
{
   switch (ul_reason_for_call)
  {//调用dll的不同情况
   case DLL_PROCESS_ATTACH://进程创建的时候
   case DLL_THREAD_ATTACH: //线程创建的时候
   case DLL_THREAD_DETACH: //线程销毁的时候
   case DLL_PROCESS_DETACH://进程销毁的时候
       break;
  }
   return TRUE;//必须返回TRUE,表示dll正常加载,如果返回FALSE,dll中的功能不能被使用
}

当功能函数进行导出,声明待导出函数:

//此处的extern "C"主要针对CPP文件
//因为CPP会将函数名称进行重命名(命名倾轧name mangling)
//如果是.c文件,则可省略extern "C"声明
extern "C" _declspec(dllexport) int dllFunAdd(int a, int b);

还需要一个def文件对导出函数声明:

//格式
LIBRARY "dll名称"
EXPORTS
函数名 @ 导出函数编号
//样例
LIBRARY "dllTest"
EXPORTS
dllFunAdd @ 1

加载DLL文件,并使用其中开放的函数:

//第一种加载DLL方式(隐式加载)
#include <iostream>

using namespace std;

#pragma comment(lib,"dllTest.lib")

extern "C" int dllFunAdd(int a, int b);//声明DLL中的函数

int main() {
int c = dllFunAdd(1, 2);//调用

cout << c << endl;

return 0;
}
//显示加载
#include <iostream>
#include <Windows.h>

using namespace std;

typedef int (*dllFunAdd)(int a, int b);

int main() {
//将指定DLL加载到内存中
HMODULE hModuleHandle = LoadLibrary(TEXT("dllTest.dll"));

   //根据函数名称在DLL中搜索函数在内存中的地址
dllFunAdd fun = (dllFunAdd)GetProcAddress(hModuleHandle, "dllFunAdd");

   //调用函数
cout << fun(1,2) << endl;

return 0;
}

 导出表

手动解析上述创建出来的DLL文件

PE结构 | 导出表详解 | 二进制安全

//数据目录表详细构成
typedef struct _IMAGE_DATA_DIRECTORY {
   DWORD   VirtualAddress; //虚拟地址,相对内存的偏移
   DWORD   Size; //表大小,该值可以被修改,但是该值不影响表大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
  • 导出表RVA:0x16CA0

  • 导出表大小:0x156

PE结构 | 导出表详解 | 二进制安全

  • 导出表在rdata节

  • 节起始RVA:0x15000

  • 节起始FOA:0x3E00

//导入表在文件中的偏移
0x16CA0 - 0x15000 + 0x3E00 = 0x5AA0

导出表目录结构

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp; //时间戳
WORD MajorVersion; //没用
WORD MinorVersion; //没用
DWORD Name; //指向导出表文件名 RVA --> FOA + fileBuffer = char * name ***
DWORD Base; //到出函数起始序号(真正的序号 = base + 函数序号) ***
DWORD NumberOfFunctions; //导出函数个数(最大函数序号 - 最小函数序号 + 1) ***
DWORD NumberOfNames; //以名称导出函数个数(没有声明NONAME的导出函数) ***
DWORD AddressOfFunctions; //导出函数地址表 RVA --> FOA +fileBuffer ***
DWORD AddressOfNames; //导出函数名称表 RVA --> FOA +fileBuffer ***
DWORD AddressOfNameOrdinals; //导出函数序号表 RVA --> FOA +fileBuffer ***
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
Offset      0  1  2  3  4  5  6  7   8  9  A  B  C  D  E  F

00005AA0 00 00 00 00 6B 6B 67 63 00 00 00 00 D2 6C 01 00 kkgc 襩
00005AB0 01 00 00 00 01 00 00 00 01 00 00 00 C8 6C 01 00 萳
00005AC0 CC 6C 01 00 D0 6C 01 00 蘬 衛
  • AddressOfFunctions:0x16CC8(FOA:0x5AC8)

  • AddressOfNames:0x16CCC(FOA:0x5ACC)

  • AddressOfNameOrdinals:0x16CD0(FOA:0x5AD0)

PE结构 | 导出表详解 | 二进制安全

三个表之间的关系

表名 成员类型 作用
$textcolor{cyan}{函数名称表} $ DWORD 每一个元素存储一个函数名称的首地址
$textcolor{cyan}{函数序号表} $ WORD 每一个元素存储这函数地址表的索引值
$textcolor{cyan}{函数地址表} $ DWORD 每一个元素存储着一个函数代码的首地址

PE结构 | 导出表详解 | 二进制安全

  • 首先根据函数名称,在$textcolor{yellow}{函数名称表} $中查找对应下标

  • 获取对应下标后,去$textcolor{yellow}{序号表} $中,根据获取的下标查找对应下标所存储的值

  • 将序号表中获取的值当做函数地址表中的索引,在$textcolor{yellow}{函数地址表} $中查找对应的函数地址

具体的寻址过程根据下边图片和上述规则,可以自己跑一遍:

PE结构 | 导出表详解 | 二进制安全

  • 因此,如果知道一个函数的开始,即使我们没有导出表,也能够自建一个导出表,用于外部其他应用进行调用。

  • 导出表的作用就是找到指定函数的开始执行地址


额外的小知识(模块隐藏)

导出表的结构与寻址过程基本上陈述完毕。现在聊聊关于程序的$textcolor{yellow}{模块隐藏} $的奇怪小知识。

  • 如果想要调用DLL中的函数,可以通过lib文件进行定位,也可以通过$textcolor{yellow}{LoadLibrary} $函数加载、$textcolor{yellow}{GetProcAddress} $获取函数地址

  • 两个加载DLL方法有一个共通点,$textcolor{yellow}{最终都是将DLL文件加载到内存中} $(听起来像是一句废话)

  • 如果想要将程序中的某个重要模块隐藏,可以考虑用驱动将其在$textcolor{yellow}{模块链表} $中摘除,达到隐藏链表的效果

  • 但是如果通过遍历内存,最终也能找到隐藏的模块内容,这种情况不是预期中想要得到的

  • 那么是否能够通过手动加载、解析DLL文件,抹除PE特征,最终达到模块隐藏的效果?

在win7虚拟机中运行前边的dllLoad.exe程序加载dll后,用双机调试环境从$textcolor{cyan}{内存角度} $查看程序的DLL加载情况

#寻找进程
1: kd> !process 0 0
....
PROCESS fffffa8003f33060
SessionId: 1 Cid: 0dcc Peb: 7efdf000 ParentCid: 0820
DirBase: 7c0ea000 ObjectTable: fffff8a0019467f0 HandleCount: 15.
Image: loadDll.exe
....
#查看loadDll进程vadRoot值
#valRoot是操作系统对逻辑地址挂载管理的根地址
1: kd> !process fffffa8003f33060 7
PROCESS fffffa8003f33060
SessionId: 1 Cid: 0dcc Peb: 7efdf000 ParentCid: 0820
DirBase: 7c0ea000 ObjectTable: fffff8a0019467f0 HandleCount: 15.
Image: loadDll.exe
VadRoot fffffa80067589b0 Vads 37 Clone 0 Private 218. Modified 0. Locked 0.
....
#查看内存信息
1: kd> !vad fffffa80067589b0
VAD level start end commit
.......
fffffa80067589b0 ( 0) 1140 115a 18 Mapped Exe EXECUTE_WRITECOPY UserswspggwDesktoploadDll.exe
fffffa8004fd8e20 ( 4) 66040 6604a 3 Mapped Exe EXECUTE_WRITECOPY UserswspggwDesktopdllTest.dll
.......

综上可以看出,即使将需要隐藏模块在内核中的$textcolor{yellow}{模块链表} $中摘除,在内存中也会留有痕迹,最根本的原因就是使用LoadLibrary等函数将DLL文件加载到内存,并常驻内存。

考虑通过下述方法达到模块隐藏的目的

#include <iostream>
#include <windows.h>

using namespace std;

int addFun(int x,int y){
return x + y;
}

int main(){
cout << addFun(11,22) << endl;
system("pause");
return 0;
}

其中addFun对应的汇编与生成的硬编码如下:

;函数的开始,提升堆栈
002713B0 55 push ebp
002713B1 8B EC mov ebp,esp
002713B3 81 EC C0 00 00 00 sub esp,0C0h
;保存调用函数现场
002713B9 53 push ebx
002713BA 56 push esi
002713BB 57 push edi
;初始化提升的堆栈
002713BC 8D BD 40 FF FF FF lea edi,[ebp-0C0h]
002713C2 B9 30 00 00 00 mov ecx,30h
002713C7 B8 CC CC CC CC mov eax,0CCCCCCCCh
002713CC F3 AB rep stos dword ptr es:[edi]
;进行加法运算,将结果保存在eax中
002713CE 8B 45 08 mov eax,dword ptr [x]
002713D1 03 45 0C add eax,dword ptr [y]
;恢复现场
002713D4 5F pop edi
002713D5 5E pop esi
002713D6 5B pop ebx
;恢复原堆栈
002713D7 8B E5 mov esp,ebp
002713D9 5D pop ebp
;函数返回
002713DA C3 ret

将其中的硬编码摘取出来,组成一个BYTE类型数组:

BYTE addFun[] = {
0x55,
0x8B,0xEC,
0x81,0xEC,0xC0,0x00,0x00,0x00,
0x53,
0x56,
0x57,
0x8D,0xBD,0x40,0xFF,0xFF,0xFF,
0xB9,0x30,0x00,0x00,0x00,
0xB8,0xCC,0xCC,0xCC,0xCC,
0xF3,0xAB,
0x8B,0x45,0x08,
0x03,0x45,0x0C,
0x5F,
0x5E,
0x5B,
0x8B,0xE5,
0x5D,
0xC3
};

将一开始的代码重构,最终也能实现加法操作(需要将VS的$textcolor{cyan}{数据执行保护} $关闭):

#include <windows.h>

using namespace std;

typedef int (*ADD)(int x,int y);

BYTE addFun[] = {
0x55,
0x8B,0xEC,
0x81,0xEC,0xC0,0x00,0x00,0x00,
0x53,
0x56,
0x57,
0x8D,0xBD,0x40,0xFF,0xFF,0xFF,
0xB9,0x30,0x00,0x00,0x00,
0xB8,0xCC,0xCC,0xCC,0xCC,
0xF3,0xAB,
0x8B,0x45,0x08,
0x03,0x45,0x0C,
0x5F,
0x5E,
0x5B,
0x8B,0xE5,
0x5D,
0xC3
};

int main(){
cout << ((ADD)((DWORD)addFun))(11,22) << endl;
system("pause");
return 0;
}

如果对DLL文件进行手动解析,并将文件中的代码可拷贝到当前执行程序中,最终形成类似上述硬编码的形式进行调用,那么内存中就不会显示加载的DLL信息,因为DLL文件并没有在内存中真正的展开过。

但是另一个问题就需要注意,$textcolor{cyan}{如果关闭了数据执行保护,是否就有了存在栈溢出的情况} $。

那么如果开启了数据执行保护,那么又如何使用上述方式进行函数加载调用?

  • 内存是否能够被访问、执行,都是由页属性进行控制

  • 考虑使用VirtualProtect函数,将所函数所在的部分堆栈属性进行修改,修改后在执行函数调用,就解决了上述问题


一个小总结

想要利用上述原理实现模块隐藏必须考虑的几个问题:

  • 如何将函数的硬编码抠出来

    • 因为有的函数不仅仅只有一个ret,在此处需要搞个模块做专门的判断,准确的截取出完整的函数

  • 如果真的使用这种模块隐藏,最终的效果(时间、空间)是否能够达到预期目标

    • 时间问题:每一次对DLL进行解析都有额外的时耗,因此绝对不能过多或重复的对同一文件进行解析操作,需要考虑自定义一个缓存文件

    • 空间问题:如果因为时间问题,将解析的PE文件常驻内存,那么程序运行后的体积问题也需要考虑

  • 程序安全性问题

    • 当调用关闭数据执行保护模块时,那部分空间是否能够被注入shellCode进行替换执行

    • 当然,如果其本身是一个恶意文件,进行隐藏的话,这条可能不在其考虑的范畴


额外的小知识(EXE导出函数表)

开篇提过,导出表一般都是在DLL文件中,但是并没有说导出表仅仅在DLL文件中。

因此,exe也能对外开放函数接口。

dllExportExe.c文件中代码如下

#include <stdio.h>

_declspec(dllexport) void test(void){
printf("dllExportExeFunn");
}

int main(){

return 0;
}

export.def文件中内容如下

LIBRARY "dllExportExe"
EXPORTS
test @ 1

将代码进行编译,最终生成了exe、lib、pdb等文件

PE结构 | 导出表详解 | 二进制安全

查看最终效果:

PE结构 | 导出表详解 | 二进制安全

比较典型的一个EXE就是ntoskrnl.exe

PE结构 | 导出表详解 | 二进制安全


本文参考内容

此处说明下.dll文件与.lib文件的关系

  1. lib是编译时需要的,dll是运行时需要的。如果要完成源代码的编译,有lib就够了。如果也使动态连接的程序运行起来,有dll就够了。在开发和调试阶段,当然最好都有。

  2. 一般的动态库程序有lib文件和dll文件。lib文件是必须在编译期就连接到应用程序中的,而dll文件是运行期才会被调用的。如果有dll文件,那么对应的lib文件一般是一些索引信息,具体的实现在dll文件中。如果只有lib文件,那么这个lib文件是静态编译出来的,索引和实现都在其中。静态编译的lib文件有好处:给用户安装时就不需要再挂动态库了。但也有缺点,就是导致应用程序比较大,而且失去了动态库的灵活性,在版本升级时,同时要发布新的应用程序才行。

  3. 在动态库的情况下,有两个文件,一个是引入库(.LIB)文件,一个是DLL文件,引入库文件包含被DLL导出的函数的名称和位置,DLL包含实际的函数和数据,应用程序使用LIB文件链接到所需要使用的DLL文件,库中的函数和数据并不复制到可执行文件中,因此在应用程序的可执行文件中,存放的不是被调用的函数代码,而是DLL中所要调用的函数的内存地址,这样当一个或多个应用程序运行是再把程序代码和被调用的函数代码链接起来,从而节省了内存资源。从上面的说明可以看出,DLL和.LIB文件必须随应用程序一起发行,否则应用程序将会产生错误。

  4. 也可以只有lib文件,这样的话,库中的库中的函数和数据也都要写入lib文件中,同时也会在链接阶段合并到exe中,这样做的坏处是使exe很大,就是去了“库”的意义了,因此不建议这么做。

  5. PDB(Program Data  Base),意即程序的基本数据,是VS编译链接时生成的文件。DPB文件主要存储了VS调试程序时所需要的基本信息,主要包括源文件名、变量名、函数名、FPO(帧指针)、对应的行号等等。因为存储的是调试信息,所以一般情况下PDB文件是在Debug模式下才会生成。

上述资料参考:

LIB文件和DLL文件的作用-J.M.Liu-博客园https://www.cnblogs.com/JMLiu/p/6230722.html

原文始发于微信公众号(0x00实验室):PE结构 | 导出表详解 | 二进制安全

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年12月5日11:02:11
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   PE结构 | 导出表详解 | 二进制安全https://cn-sec.com/archives/1445624.html

发表评论

匿名网友 填写信息