PE 文件格式简析
相比于ELF,PE文件中包含了相当多的可读性非常强(更加直观)的信息。包括预设的程序加载基址0x400000, 导入导出表等等都是直接写明文字符串而且独立于section之外单独存储的。用010editor结合一个exe程序简单分析一下。这里记得分清FOA和RVA的转换与记录区别,windows下的PE文件不区分内存和文件视图,而是用两种不同的地址来进行标识。和linux还是有一点差别的。
PE文件头
一个没什么用的DOS头
以5a4d开头,记录了一些DOS16位模式运行需要的信息,兼容的问题保留了。最后一个字段AddressOfNewExeHeader指向PE头(NtHeader)的FOA地址。
NtHeader
以0x4550(PE)开头。记录了很多关键信息,分为文件头FileHeader和可选头OptionalHeader。
FileHeader
记录运行架构,创建时间戳,符号表指针,optionalheader的大小,seciton数目和一个程序相关的属性flag字。包括是否32位等等信息,flag bit形式保存。
OptionalHeader
magic字段,然后一堆记录了各种东西的主版本号和最低版本号的字段,包括子系统,操作系统等等,略过。还有很多别的对齐信息,checksum,预留栈空间、dll属性flag等等乱七八糟的东西。
重点是这里记录了imagebase(内存镜像基地址,一般exe的都是0x400000),入口点rva地址,以及一个datadir结构体数组指向各个section的RVA地址。
这个datadir数组里面的成员只有一个地址和一个大小,没有对应节就置空。寻址对应节的方法是,这个数组里面都是按顺序排好的,下标0(第一个)存放export,第二个存放import等等,获取的时候用宏就行,按顺序寻址因此不用别的连接方法。
导入表和导出表
import代表程序要导入哪些外部函数,export则代表要向外提供哪些函数。一般一个dll的导出表会比较多,而exe一般没有导出表(空)。
值得一提的是,我们其实不需要导入表也能在程序运行的时候导入动态库,使用对应的api动态的进行装载即可。这一点在加壳保护等等会有点用
INT和IAT
IAT(Import Address Table)和INT(Import Name Table)这俩玩意在加载前是类似的,都是引入函数的信息。对于INT,其是IMAGE_IMPORT_BY_NAME
的结构数组,里面就放函数名和函数序号。这个表即使在程序加载入内存后也不变化。
1 | typedef struct _IMAGE_IMPORT_BY_NAME { |
而对于IAT,其是IMAGE_THUNK_DATA32
的结构数组,他是一个4字节联合体
1 | typedef struct _IMAGE_THUNK_DATA32 { |
这个结构决定了函数可以索引名字也可以索引序号。如果最高位为0,则这里存放对应INT的RVA,如果最高位为1,则直接用31位索引导出序号。程序加载后,IAT就会更换为函数的地址信息。
导入表Import
有点复杂,感觉不如linux的got和plt
首先,对于每个库,都有一个IMAGE_IMPORT_DESCRIPTOR
的结构进行维护,导入表存放的是IMAGE_IMPORT_DESCRIPTOR
数组,一个库在导入表中占一个条目。
导入表的descriptor结构体只是一个引导,还没有到函数地址。它维护dll的名字(也是RVA),时间戳,ForwarderChain标志(可以不管)以及比较关键的两个指针:指向INT RVA的OriginalFirstThunk和指向IAT RVA的FirstChunk。
导出表
导出表就可以说的比较简单,它维护一个IMAGE_EXPORT_DIRECTORY
数组,每个条目里面保存了时间戳、版本号、模块名的RVA、api基数(从1开始),函数的数目,函数名数目(很多函数没有名字),以及导出地址表、导出名称表、导出序列号的rva。
重定位表
参考https://www.cnblogs.com/Chary/p/12981261.html
linux的地址在链接过程中就已经将符号全部重定位好了,而且全部都是偏移地址,base在装载的时候再说。而windows我们知道pe头中每个imagebase绝对地址都是写死的而且dll大概率都一样,偏移也写死的话那地址冲突怎么办?那就需要重定位表。他本质存放的是一堆需要修改的代码地址。感觉不如linux的延迟绑定但是似乎是更直观的解法。为了节约,重定位表是按页记录重定位的信息的。每个条目管1000字节的程序,就两个字段(有用),第一个字段指向要重定位的RVA(0x1000 page align),第二个字段sizeofblock则是记录重定位块的大小,后面跟的都是具体偏移什么的。
TLS表
什么是TLS
TLS全程是Thread Local Storage,即线程局部存储,它关联到具体的线程而非进程。顾名思义,它是为了实现相同进程下不同线程间互相独立的地址空间而存在的,总之它能为线程提供一个拥有全局变量或者静态数据的视图,但其实是副本这种感觉。简单来讲它的实现,它为每个线程分配了一个单独的数组,然后根据索引去查找数组的值。对于不同的线程,索引一样但实际地址不同值也不同。
TLS回调函数在main函数之前就会运行,会阻截调试。这是反调试的一个关键。参见反调试技术。
TLS表
在windows下,TLS的使用分为动态和静态两种。动态使用指的是通过tlsalloc
,tlssetvalue
等动态函数,在程序运行期间对tls相关的结构和空间进行初始化,而静态使用则会为pe文件生成tls表。
tls表会保存通过tls访问的数据的初始化其实地址和结束地址,范围里存储程序中tls所使用的全局变量的内容。
1 | typedef struct _IMAGE_TLS_DIRECTORY32 { |
多的不讲了,反调试细说。
延迟导入表
加速加载速度用,这个descriptor结构体每个存储了属性,以及指向dll名字,iat,int和一堆东西的rva。这玩意可以延迟导入不常用的dll函数,类似于linux下的延迟绑定,一次绑定后IAT就被写好后面就不用再绑了。
这个功能需要编译的时候手动开启然后自己制定延迟导入的dll名称。虽然条目也是按dll写,延迟导入是一次一个函数的。
资源和其他表
windows将位图,鼠标指针,各种消息等等都作为资源存储。也是用表存储,这里不再多说,看不下去了没啥意思。
PE的装载和内存视图
反调试技术
TLS反调试
参考https://blog.csdn.net/Joyce_hjll/article/details/136732526
我们知道,自己注册的tls函数会在主线程调用main之前就调用,回调函数触发的时机总共有下面几个:
-
主线程调用main之前
-
子线程启动前
-
子线程结束后
-
主线程结束后一般是用主线程调用main前启动回调函数来阻截调试让程序退出,需要patch。
入口点识别
一般的main函数
32位下从入口开始寻找3个push一个call的函数(三个参数)。release版本主函数一般就在地址开始附近,拉到顶就行了。
WMCTF2024 Ezlearn
第一次看windows的简单逆向分析。程序也是用了自定义section代码数据共存,函数调用直接jmp混淆call reference分析等等的神秘方法。介绍下几个关键点。
破坏堆栈平衡的花指令
刚开始不懂,IDA报错sp分析失败,跟进看一下发现是程序采用了这么一套代码来进行反汇编混淆:
1 | call $+5 |
这样一套流程下来,其实就是代码顺序往下执行,利用call和$符号将代码地址压栈,pop之后借助ebp的push+ret再恢复控制流。但是堆栈的分析就会被破坏。这样的指令有很多,把这种花指令要全nop掉就能反汇编了。
TLS反调试函数
直接用ida动调的时候一开就会退出,用x64dbg看了一眼退出前的最后一个call,发现指向这个函数:
1 | .text:001A10FF |
跟进发现:
1 | int __stdcall TlsCallback_0_0(int a1, int a2, int a3) |
这里利用了TLS回调函数加载先于主模块main入口的特性,检查到调试直接退出了。我们将PE头datadir中的tls部分全部patch掉,然后将文件中rdata部分的tls回调函数表也全patch掉(都写成0),程序就能调试了。
另外,x64dbg首选项开了在tls回调中断,但是这里没有检测,估计是因为用了一个jmp直接跳隐藏call tls函数。
加密函数
加密函数也是用了上面的混淆指令和jmp隐藏的混淆,首先看主函数逻辑,它读取一个长32的字符串然后按照前16和后16分别加密两次,每次加密完后和预设的结果比较。然后patch堆栈花指令后跟进加密函数。
1 | __int64 __cdecl encrypt(int arr1, char *input_0_16_32, _DWORD *arr0) |
在循环之前,主加密函数还调用了另一个函数,跟进去再看,发现是个类似的加密函数:
1 | int __cdecl init_enc(int arr1) |
中间的小函数一路跟进去就能看懂功能,xor_0x12
是将参数123和一个预设的数组按字节异或之后再异或0x12存放至参数4,里面有个异或0没用。我们可以注意到最后一个参数永远是4,其实就是按int进行4字节的异或。
1 | void __cdecl xor_0x12(int a1, int a2, int a3, int a4, int a5, int const_4) |
第二个函数很明显是一个代换,S盒写好了:
1 | int __cdecl Sbox_substitution(int a1, int a2, int a3) |
第三步是代换后的结果进行一系列位运算
1 | v3[0] = ((v4[0] << 23) | ((unsigned __int64)(unsigned int)v4[0] >> 9)) ^ v4[0] ^ ((v4[0] << 13) | ((unsigned __int64)(unsigned int)v4[0] >> 19)); |
最后异或0x34:
1 | void __cdecl xor_0x34(int arr1, int arr_d8d700, int arr_enc5, int const_16) |
然后结果存放到一个数组里。
控制流分析
我们可以注意到,第一个调用的相似结构的加密函数里面没有涉及任何我们的输入操作,其结果存放在数组中后再进行外层的加密处理。因此patch掉反调试之后,我们可以直接过掉这个函数然后把数组里面的东西直接导出来看外层。动调时候获得的:
1 | .data:00D8D730 ; int result[32] |
函数功能逆完之后流程就比较简单了,他是一个流式加密的方法,一共用到36字节空间,但是每次异或加密等操作的时候都按int型处理(连续4字节一个单位),取输入的前4个字节做初始向量,经过以下步骤循环32轮后得到密文:
-
从输入的input[1]算起, 当前的连续三个单位(滚动向前,123)和result数组轮数对应位置的值进行异或得到中间结果a
-
以a为索引(高4位行低4位列)进行S盒代换得到结果b
-
对b进行指定的移位、按位或和异或操作得到结果c
-
将c,input[0](滚动向前,0)算起的一个单位异或,存放至单位4,完成一轮操作
-
循环32次直至拿到最后的4个4字节int值,倒序赋值并返回获得16字节加密结果
解密
我们知道异或操作是可逆的,破解这个加密算法其实并不困难,只需要从最后四字节倒着推回去就行了。我们知道最后新的字节的获得是0号单位和s盒替换后又做了位运算的值异或得到的,有后四个单位的值和运算,我们就要拿到S盒的下标。因此只需要用前三个值和导出的result数组先异或0x12拿到下标,S盒代换再位运算,最后获得的值和0x34与最后一个值异或就能往前推一位,倒着重复32次就能拿到输入了。
EXP如下,很多导出的数据都没有用,因为动调直接跳过了一个大的初始加密环节。
1 | # 由一个encrypt函数定义一轮加密,一轮加密分几个步骤 |
1 | PS D:\BLOG\source> python -u "d:\ctf\wmctf2024\re\ez_learn_d191751e6ada67ca536acd67e16793ca\exp.py" |