写一个PE的壳_Part 2:ASLR+修复输入表(IAT)+重定位表支持(.reloc)


系列汇总



重要的事情说3遍:

笔记更新换位置了!
笔记更新换位置了!
笔记更新换位置了!

新位置:我写的新系列,刚开始写没几天,后续文章主要在新地址更新,欢迎支持;写作不易,且看且珍惜(点击跳转,欢迎收藏

Part 2:ASLR+修复输入表(IAT)+重定位表支持(.reloc)

本文主要处理Part 1中遗留的2张表:输入表和重定位表(执行一个ASLR使能的文件不会出错)

当前现状:Part 1中执行loader.exe C:\Windows\SysWOW64\calc.exe没有出现计算器,loader.exe没有处理C:\Windows\SysWOW64\calc.exe的输入表是一个主要原因

处理2个表前,最好先复习一下ASLR的相关知识

1.ASLR预备知识

ASLR(Address Space Layout Randomization,地址空间布局随机化)是一种针对缓冲区溢出的安全保护技术,从Vista开始支持;主要是使exe文件运行时加载到内存中的地址是随机的

  • 产生原因

没有ASLR前,一般情况下,exe文件都会加载在OS给分配的虚拟内存0x400000上,微软自己的DLL总会加载在固定的地址上,这给程序安全带来的很大的隐患;因此需要将PE文件每次加载到内存的地址进行随机化

  • 编译器支持

支持ASLR的前提是OS的内核版本必须是6以上,且编译工具必须支持/DYNMAICBASE;下面的界面中还可以设置PE文件的ImageBase(exe文件默认是0x400000,dll文件默认是0x10000000,默认值都是可以修改的)

在这里插入图片描述

  • .reloc节区

支持ASLR的PE文件多了一个名为.reloc的节区,一般情况下普通的exe文件不存在这个节区,开了ASLR技术的二进制才出现这个节区,是由编译器在编译时指定并保存在可执行文件中的

在这里插入图片描述

PE文件加载进入内存时,.reloc节区主要是做重定位参考的

  • PE header变化

File HeaderCharacteristics属性里,IMGE_FILE_RELOCS_STRIPPED不在支持ASLR时默认是勾选的,支持ASLR时不会勾选;增加ASLR的支持后,同一套源码产生的PE文件区段个数也会多一个

在这里插入图片描述

Optional HeaderDllCharacteristics属性里,IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE不在支持ASLR时默认是不勾选的,支持ASLR时会勾选(是否勾选不要与上面弄混了

在这里插入图片描述

题外话:逆向时,如果一个文件支持ASLR会给逆向增加难度,可以直接将Optional HeaderDllCharacteristics属性里的IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE删掉,这样就不支持ASLR了;这样程序每次运行都会加载到同一地址,方便分析

2.输入表支持

结论:输入表支持就是我们要自己遍历INT,给IAT填写真实内存地址的过程

detail 1:什么是输入表?

简单归纳:就是将程序使用的第三方库的函数放在一个表格里进行统一管理的表

一般情况下,当一个PE二进制用额外的库时,它通常将输入表存储在.idata区段;下面以一个ShellExecuteW(被calc.exe导入)函数为例,说明一下输入表

calc.exe要调用ShellExecuteW,是需要知道ShellExecuteW函数在内存中的位置的;dll文件只有在运行时才会加载进内存,因此在编译阶段,编译器是不知道ShellExecuteW在内存中的确切位置

输入表中的IAT就是为了解决上面的编译时运行时的矛盾的

  • 编译时:在调用ShellExecuteW的地方,ShellExecuteW使用的是中转地址,或者可以理解成指向输入表中的IAT
  • 运行时:当运行程序时,相应的dll文件被加载时,PE装载器会先将ShellExecuteW的真实地址在IAT保留;都处理完后,才会执行用户写的代码,此时执行ShellExecuteW就会通过IAT找到真正的加载地址

结论:PE被装载进入内存后,输入表中真正有用的只有IAT了

下面是x32dbg加载calc.exe的截图,可以看到输入表中IAT的身影

在这里插入图片描述

  • 位置1:外部call指令,调用的是外部模块(shell32.dll),call指令(用FF15操作码)的参数(38306700)来自于IAT,被ShellExecuteW标识;具体是用函数名还是用序号(ordinal)取决取IAT加载的方式
  • 位置2:内部call指令,编译器知道被调用函数的目的地址,因此用E8操作码(“realtive call”

输入表的基本知识介绍完了,下面使用CFF查看一下输入表在PE中的相关部分

detail 2:PE Header描述

输入表信息这么重要,PE头文件哪里描述呢?可以从Data Directories(是Optional Header的一部分)开始学习

在这里插入图片描述

目录的顺序是事先确定的,默认个数是16;与导入相关的有2个directories(都位于名为.idata的section里):

  • Import Directory:编号是01,指向“Import Directory Table” (IDT),告诉我们什么函数要被程序导入,即what
  • Import Address Table Directory:编号是12,指向“Import Address Table” (IAT),将要导入的函数的地址放在IAT里,即where

题外话:Part 4中会看到LIEF建立一个空白PE时,Data Directories数组的个数指定的是15,导致输入表不能识别

detail 3:真实的输入表

CFF查看IDT的内容如下:

在这里插入图片描述

每一个dll都有一个条目记录需要导入的函数,下面通过编程处理输入表

detail 4:编程处理

现在直接说编程处理输入表,估计还是会不知所云,因为还有最后一项没有介绍,即输入表需要的数据结构

数据结构

Data Directories中的Import Directory指向一个数组,数组里每个元素都是IMAGE_IMPORT_DESCRIPTOR的结构体,数组终止条件是一个全为0的IMAGE_IMPORT_DESCRIPTOR的结构

每一个DLL都用一个IMAGE_IMPORT_DESCRIPTOR的结构体进行描述,定义如下:

//每一个DLL都用一个 IMAGE_IMPORT_DESCRIPTOR 的结构体进行描述
typedef struct _IMAGE_IMPORT_DESCRIPTOR
{ _ANONYMOUS_UNION union
  { DWORD         Characteristics;
    DWORD         OriginalFirstThunk;		//真正有用的地方,指向IDT
  }         DUMMYUNIONNAME;
  DWORD         TimeDateStamp;
  DWORD         ForwarderChain;
  DWORD         Name;						//DLL的名称
  DWORD         FirstThunk;					//指向IAT
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;

其中:OriginalFirstThunkFirstThunk都指向一个元素大小是 DWORD的相同结构(IMAGE_THUNK_DATA)的数组,NULL是数组结尾

//DLL中的一个函数对应一个IMAGE_THUNK_DATA
typedef IMAGE_THUNK_DATA32              IMAGE_THUNK_DATA;
typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      //PBYTE 
        DWORD Function;             //PDWORD,被输入的函数的内存地址
        DWORD Ordinal;				//被输入API的序号
        DWORD AddressOfData;        //PIMAGE_IMPORT_BY_NAME构
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;
//IMAGE_THUNK_DATA最高位:
//	是1,函数以序号方式输入,后面位数代表函数序号
//  是0,函数以名称方式输入,DWORD整体是一个RVA,指向_IMAGE_IMPORT_BY_NAME结构

//OriginalFirstThunk和FirstThunk指向相同的数组IMAGE_THUNK_DATA
typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;		//本函数在其驻留的DLL的输出表中的序号,非必须
    CHAR   Name[1];		//指向输入函数名的ASCII的首地址,NULL结尾(定义的是可变长度)
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME; 

编程实现

编程实现的思路:

  • 1.遍历OriginalFirstThunk(变量lookup_table,即IDT)指向的数组,获得要导入的函数名(或者序号)
  • 2.然后将获得的函数地址放置在FirstThunk(变量address_table,即IAT)指向的镜像的位置
IMAGE_DATA_DIRECTORY* data_directory = p_NT_HDR->OptionalHeader.DataDirectory;	//数据目录起始地址

// load the address of the import descriptors array
IMAGE_IMPORT_DESCRIPTOR* import_descriptors = (IMAGE_IMPORT_DESCRIPTOR*) (ImageBase \
                                            + data_directory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);

// this array is null terminated
for (int i=0; import_descriptors[i].OriginalFirstThunk != 0; ++i) {				//一次处理一个DLL
    
    // Get the name of the dll, and import it
    char* module_name = ImageBase + import_descriptors[i].Name;
    HMODULE import_module = LoadLibraryA(module_name);
    if(import_module == NULL) {
        return NULL;
    }

    //先获取IDT和IAT
    // the lookup table points to function names or ordinals => it is the IDT
    IMAGE_THUNK_DATA* lookup_table = (IMAGE_THUNK_DATA*) (ImageBase + import_descriptors[i].OriginalFirstThunk);
    // the address table is a copy of the lookup table at first
    // but we put the addresses of the loaded function inside => that's the IAT
    IMAGE_THUNK_DATA* address_table = (IMAGE_THUNK_DATA*) (ImageBase + import_descriptors[i].FirstThunk);

    // null terminated array, again
    for(int i=0; lookup_table[i].u1.AddressOfData != 0; ++i) {					//一次处理一个函数
        void* function_handle = NULL;

        // Check the lookup table for the adresse of the function name to import
        DWORD lookup_addr = lookup_table[i].u1.AddressOfData;					//获取函数名

        if((lookup_addr & IMAGE_ORDINAL_FLAG) == 0) { //if first bit is not 1
            // [---import by name---] : get the IMAGE_IMPORT_BY_NAME struct
            IMAGE_IMPORT_BY_NAME* image_import = (IMAGE_IMPORT_BY_NAME*) (ImageBase + lookup_addr);
            // this struct points to the ASCII function name
            char* funct_name = (char*) &(image_import->Name);
            // get that function address from it's module and name
            function_handle = (void*) GetProcAddress(import_module, funct_name);//获取内存中的地址
        } else {
            // [---import by ordinal---], directly
            function_handle = (void*) GetProcAddress(import_module, (LPSTR) lookup_addr);//此时lookup_addr是Ordinal
        }

        if(function_handle == NULL) {
            return NULL;
        }

        // change the IAT, and put the function address inside.
        address_table[i].u1.Function = (DWORD) function_handle;					//存储一个函数的真实地址
    }
}

扩展:LIEF中重建Import Table介绍

输入表(或者叫导入表)通常用下面结构,下图是PE的Import Table被LIEF处理前后的示意图

在这里插入图片描述

  • lookup table:含有导入函数名的偏移量;在这个例子中,导入函数是来自kernel32.dll中的SleepGetTickCount
  • address table:大多数情况下,是等于lookup table的;在运行时,address table相应的内容会被替换成导入函数的地址;即Entry1拥有Sleep函数的地址,Entry2拥有GetTickCount函数的地址

如果程序要调用Sleep函数,那么汇编代码应该如下所示

call address_table[entry1] ; call to sleep

LIEF中重建二进制文件时,LIEF不会在任何时候修补汇编代码,这是重建导入表的一个强大约束。因为LIEF不知道原始二进制文件中导入表的确切结构(地址表可能在查找表之前……)等信息;为了保持二进制文件的一致性,LIEF通过上面图片的方式修补原始二进制文件

如果用过detours等第三方库,看到Trampoline这个单词,第一个反应就是inline hook;这里更简单,就是单纯的增加了一层跳板来修补(或者说重建)Import Table

修补后,如果调用一个导入函数,汇编代码通常会有下面结构

call address_table_original[entry1] ; call to trampoline[0]
jmp *address_table[entry1]          ; jump to Sleep address

参考:PE Format — LIEF Documentation (lief-project.github.io)


现在大部分工作已经做完了,为了适应ASLR,处理一下重定位表是很有必要的

3.管理重定位表

detail 1:什么是重定位表?

到目前为止,我们做的工作主要细节如下:

  • 1.打开了calc.exe文件,读取它的头信息进入内存
  • 2.Image Base是PE装载器将calc.exe加载的默认内存的起始位置(现在使用VirtualAlloc实现)
  • 3.ASLR 激活 ( 由“Dll can move” 决定,在IMAGE_NT_HEADER.OptionalHeader.DllCharacteristics中),表示calc.exe可以放在任意可用内存位置
  • 4.用第一个参数是NULLVirtualAlloc分配了一块内存(操作系统会选择一个合适的地址)
  • 5.操作系统给的随机地址现在是calc.exe的真正ImageBase
  • 6.导入calc.exe需要的函数并放置他们的地址在IAT中(Part 2中实现的)

再次回顾一下上面输入表使用的截图,位置1中的操作码是FF15,操作数是0067303800673038就是IAT中ShellExecuteW的真实内存地址

在这里插入图片描述

通过CFF查看一下导入表,shell32.dll中只导入了一个函数,即ShellExecuteW;可以看到IAT的起始地址是0x00003038,是一个相对地址,基准是shell32.dll加载入内存的基址(0x670000)

在这里插入图片描述

这会引出一个问题,如果shell32.dll的加载基址或者PE文件的加载基址不是常规的0x400000时,程序是怎么通过0x00003038找到合适的内容呢?因此引入重定位表的内容;即重定位表可以解决程序加载在任意内存位置的问题

detail 2:重定位表结构

重定位表的结构比输入表的结构简单,我们也不必知道它内部是怎么工作的;脱壳一个软件时,输入表重建通常是必须的,对于重定位表,只要去使能ASLR即可(uncheck “Dll can move”)

Data Directory中有relocation table的入口(RVA),重定位表是由一个个块直接相邻构成,每个块是由一个头和一个元素大小为word的数组组成

  • 头部的结构
typedef struct _IMAGE_BASE_RELOCATION
{ DWORD     VirtualAddress;
  DWORD     SizeOfBlock;		//块的大小,包括头
} IMAGE_BASE_RELOCATION, *PIMAGE_BASE_RELOCATION;

其中:VirtualAddress是一个相对地址,是这个块开始重定位的基址;这是一个真实的页地址,因为重定位的偏移被限定在12 bits(0x1000,4kb,windows的32位中的页大小)

  • 一个元素大小为word的数组

头部信息后面是一个系列word大小的结构,直到块的终止(通过头部的SizeOfBlock可以知道终止的位置),每一个word描述如下:

4位:重定位的类型(只有一个会被使用)
低12位:偏移,相对头部的VirtualAddress的偏移

通过每个块的头部信息中的VirtualAddress可以知道,只要处理好VirtualAddress和模块的加载基址Image Base之间的关系,就可以解决掉ASLR的问题

detail 3:编程处理

重定位表的处理应该在区块映射进内存和改变区块的属性之间进行处理,特别是.text区块更改属性前

下面是Part 1中的load_PE函数的主要逻辑,要明白重定位表的处理位置

void* load_PE (char* PE_data) {
    /** Parse header **/
    /** Allocate Memory **/
    /** Map PE sections in memory **/
    /** Handle imports **/
    /** Handle relocations **/
    /** Map PE sections privileges **/
    return (void*) (ImageBase + entry_point_RVA);
}

处理重定位表的实现:

//this is how much we shifted the ImageBase
DWORD delta_VA_reloc = ((DWORD) ImageBase) - p_NT_HDR->OptionalHeader.ImageBase;

// if there is a relocation table, and we actually shitfted the ImageBase
if(data_directory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress != 0 && delta_VA_reloc != 0) {

    //calculate the relocation table address
    IMAGE_BASE_RELOCATION* p_reloc = (IMAGE_BASE_RELOCATION*) (ImageBase + data_directory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);

    //once again, a null terminated array
    while(p_reloc->VirtualAddress != 0) {				//处理一个block块

        // how any relocation in this block
        // ie the total size, minus the size of the "header", divided by 2 (those are words, so 2 bytes for each)
        DWORD size = (p_reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
        // the first relocation element in the block, right after the header (using pointer arithmetic again)
        WORD* reloc = (WORD*) (p_reloc + 1);			//跳过sizeof(IMAGE_BASE_RELOCATION)
        for(int i=0; i < size; ++i) {
            //type is the first 4 bits of the relocation word
            int type = reloc[i] >> 12;
            // offset is the last 12 bits
            int offset = reloc[i] & 0x0fff;
            //this is the address we are going to change
            DWORD* change_addr = (DWORD*) (ImageBase + p_reloc->VirtualAddress + offset);//核心

            // there is only one type used that needs to make a change
            switch(type){
                case IMAGE_REL_BASED_HIGHLOW :
                    *change_addr += delta_VA_reloc;		//更改地址
                    break;
                default:
                    break;
            }
        }

        // switch to the next relocation block, based on the size
        p_reloc = (IMAGE_BASE_RELOCATION*) (((DWORD) p_reloc) + p_reloc->SizeOfBlock);
    }
}

4.结果展示

输入表和重定位表都处理完了,现在重新编译(x86_64-w64-mingw32-gcc.exe main.c -o loader.exe

注意:这里一定要编译成32位的程序,如果在64位电脑上运行,可以使用x86_64-w64-mingw32-gcc.exe -m32试试,不生效就直接使用i686-w64-mingw32-gcc.exe这个专用32位版本的编译器吧!

运行loader并加载一个32版本的ASLR使能的exe文件,如下命令会弹出windows自带计算器

loader.exe C:\Windows\SysWOW64\calc.exe

到目前为止,我们写了一个简单的执行exe的程序,但是这与调用systemCreateProcess函数所写的代码就多太多了,这么做的意义是什么呢?

  • systemCreateProcess函数:会创建另一个进程,然后执行calc.exe
  • 我们写的加载PE程序loader.exe:加载calc.exe进入我们自己申请的内存,然后转到模块的入口执行

后续教程中,我们不使用从文件系统中获取PE数据,而是写一个壳,从它的一个Section中获取PE文件,详细可以看Part 3

5.参考

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值