最近在学习罗云彬老师写的《Windows环境下32位汇编语言程序设计》书中的PE文件,着重分析了下如何在PE文件中添加可执行代码的问题。因为书上对这个程序没有做过多分析,这里我把自己的分析过程及一些问题进行讲解,希望能给初学者一点帮助。
众所周知,PE文件是Windows下的文件格式,包括exe、dll等,那么如何向PE文件中添加可执行代码呢?我们应该有以下这些问题:
1、 可执行代码应该添加到PE文件的什么位置?
2、 如何让添加的代码最先执行?
3、 执行完添加的代码后如何回到原PE文件的起始地址?
4、 添加的代码如何获取需要用到的API地址?
带着这些问题,我们的主题就开始了。
准备知识:
1、 首先你得知道PE文件结构,不然很多问题你不会很明白,附件中有一张我作的PE文件结构的思维导图,希望能对你有帮助。
2、 新加的代码功能很简单,就是在运行这个PE文件之前弹出一个选择对话框,询问是否继续。代码放在后面附件中,请读者先下载看看,后面的讲解中会涉及到。
3、 了解Windows环境下的程序设计,不然你会不知道我在说什么,呵呵。。
好了,废话不多说,我们进入正题。
1、 使用通用控件GetOpenFileName函数打开一个查找文件的对话框,选择想要添加代码的文件。获得文件句柄后,通过内存映射函数CreateFileMapping和MapViewOfFile获取PE文件的起始地址。然后通过对文件的MZ格式文件头和PE头分析该文件是不是有效的PE文件,如果不是直接退出,如果是则进入下一步。这部分的代码比较简单,就直接写在下面了。
local @stOF:OPENFILENAME
local @hFile,@dwFileSize,@hMapFile,@lpMemory
invoke RtlZeroMemory,addr @stOF,sizeof @stOF
;初始化OPENFILENAME结构,以便使用
mov @stOF.lStructSize,sizeof @stOF
push hWinMain
pop @stOF.hwndOwner
mov @stOF.lpstrFilter,offset szExtPe
mov @stOF.lpstrFile,offset szFileName
mov @stOF.nMaxFile,MAX_PATH
mov @stOF.Flags,OFN_PATHMUSTEXIST or OFN_FILEMUSTEXIST
invoke GetOpenFileName,addr @stOF
.if ! eax
jmp @F
.endif
; 打开文件获得文件句柄
invoke CreateFile,addr szFileName,GENERIC_READ,FILE_SHARE_READ or \
FILE_SHARE_WRITE,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_ARCHIVE,NULL
.if eax != INVALID_HANDLE_VALUE
mov @hFile,eax
invoke GetFileSize,eax,NULL
mov @dwFileSize,eax
.if eax
;用获得的文件句柄建立文件mapping
invoke CreateFileMapping,@hFile,NULL,PAGE_READONLY,0,0,NULL
.if eax
mov @hMapFile,eax
invoke MapViewOfFile,eax,FILE_MAP_READ,0,0,0
.if eax
mov @lpMemory,eax
; 检测 PE 文件是否有效
mov esi,@lpMemory
assume esi:ptr IMAGE_DOS_HEADER
;检查MZ格式文件头
.if [esi].e_magic != IMAGE_DOS_SIGNATURE
jmp _ErrFormat
.endif
add esi,[esi].e_lfanew
assume esi:ptr IMAGE_NT_HEADERS
;检查PE文件头
.if [esi].Signature != IMAGE_NT_SIGNATURE
jmp _ErrFormat
.endif
invoke _ProcessPeFile,@lpMemory,esi,@dwFileSize
判断完是有效的PE文件后,我们进入ProcessPeFile函数的编写,在这个函数中,我们要完成可执行代码的添加功能,下面的具体操作:
1、 将要添加代码的文件复制一份,并重命名为xxx_new.exe,然后用CreateFile函数打开文件,获取文件句柄,下面在代码中进行讲解。
;获取路径及文件名
invoke lstrcpy,addr @szNewFile,addr szFileName
invoke lstrlen,addr @szNewFile
;获取新文件名字符串的地址
lea ecx,@szNewFile
;eax是路径及文件名的长度,eax-4就是除去后面的.exe格式后的长度,然后再加上新文件的名字xxx_new.exe构成一个新的.exe文件。
mov byte ptr [ecx+eax-4],0
invoke lstrcat,addr @szNewFile,addr szExt
;复制文件内容,建立新文件成功。
invoke CopyFile,addr szFileName,addr @szNewFile,FALSE
;打开文件,获取句柄,并保存到@hFile变量中,以便后面使用。
invoke CreateFile,addr @szNewFile,GENERIC_READ or GENERIC_WRITE,FILE_SHARE_READ or \
FILE_SHARE_WRITE,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_ARCHIVE,NULL
.if eax == INVALID_HANDLE_VALUE
invoke SetWindowText,hWinEdit,addr szErrCreate
jmp _Ret
.endif
mov @hFile,eax
2、 接下来我们需要用GlobalAlloc函数分配一个等于原始PE文件的文件头大小的内在块,并使用RtlMoveMemory函数将PE文件头复制到这个内存块中,然后利用节的数目定义两个指针,edx指向最后一个节表,ebx指向新加的节表。下面在代码中进行一些分析。
mov esi,_lpPeHead
assume esi:ptr IMAGE_NT_HEADERS,edi:ptr IMAGE_NT_HEADERS
;分配内存空间
invoke GlobalAlloc,GPTR,[esi].OptionalHeader.SizeOfHeaders
mov @lpMemory,eax
mov edi,eax
;将PE文件头复制到新分配的内存中
invoke RtlMoveMemory,edi,_lpFile,[esi].OptionalHeader.SizeOfHeaders
;下面两句是为了让分配的内存指针指跳过DOS块,指向PE文件头。Edi是新分配内存的起始地址,esi是原文件的PE文件头,_lpFile是原文件的起始地址。
add edi,esi
sub edi,_lpFile
;确定两个指针
movzx eax,[esi].FileHeader.NumberOfSections
dec eax ;注意这减了1
mov ecx,sizeof IMAGE_SECTION_HEADER
mul ecx
;现在eax中的值是节的总大小减去一个节的大小
mov edx,edi
add edx,eax
add edx,sizeof IMAGE_NT_HEADERS
;现在edx指向最后一个节的开始
mov ebx,edx
add ebx,sizeof IMAGE_SECTION_HEADER
;现在ebx指向新加节的开始
assume ebx:ptr IMAGE_SECTION_HEADER,edx:ptr IMAGE_SECTION_HEADER
[ATTACH]67747[/ATTACH]
3、 现在回答第一个问题,要添加的代码应该添加到PE文件的什么地方?
1. 因为节中的内容在磁盘文件中是按照FileAlignment对齐的,在内存中是按照SectionAlignment对齐的,所以在一个节中可能存在一个空闲区,能够将要添加的代码装下,但这种方法有很大的局限性。
2. 我们可以在已有节的基础上,添加新的节。
下面先作判断:
pushad
mov edi,ebx
xor eax,eax
mov ecx,IMAGE_SECTION_HEADER
repz scasb ;这句话的意思可以用一个C语言代码来解释
;while(--ecx)
;{
; if(*(edi++)!=al)
; break;
;}
Popad
下面是在节中插入代码的方法:
1. 依次查找每一个节,判断是否有足够空间。
xor eax,eax
mov ebx,edi
add ebx,sizeof IMAGE_NT_HEADERS
.while ax <= [esi].FileHeader.NumberOfSections
;从第一个节的文件偏移地址开始
mov ecx,[ebx].SizeOfRawData
;要判断的内容不仅看ecx是否为空,还要判断这个节的属性是否为可执行,要不然如果把可执行代码写在了一个只读的节中,那会发生内存错误的。
.if ecx && ([ebx].Characteristics & IMAGE_SCN_MEM_EXECUTE)
sub ecx,[ebx].Misc.VirtualSize
;减去该节的大小后剩下的就是空闲区了,比较该空闲区是否大于代码的长度,如果大于则能够使用,不大于则进行下一个节的判断。
.if ecx > offset APPEND_CODE_END-offset APPEND_CODE
or [ebx].Characteristics,IMAGE_SCN_MEM_READ or IMAGE_SCN_MEM_WRITE
jmp @F
.endif
.endif
add ebx,IMAGE_SECTION_HEADER
inc ax
.endw
;如果所有节都不符合要求,则结束这种方法的使用。
invoke CloseHandle,@hFile
invoke DeleteFile,addr @szNewFile
invoke SetWindowText,hWinEdit,addr szErrNoRoom
jmp _Ret
2.如果其中有可以使用的节,那么就将要添加的代码写到这个节中,如下:
@@:
;获取新加代码的基址,并存入@dwAddCodeBase变量中
mov eax,[ebx].VirtualAddress
add eax,[ebx].Misc.VirtualSize
mov @dwAddCodeBase,eax
;获取新加代码的在磁盘文件中相对于文件头的偏移量
mov eax,[ebx].PointerToRawData
add eax,[ebx].Misc.VirtualSize
mov @dwAddCodeFile,eax
;将这个节的大小加上代码的大小,存在VirtualSize属性中。
add [ebx].Misc.VirtualSize,offset APPEND_CODE_END-offset APPEND_CODE
;下面的代码是将要添加的代码写在这个节的空闲处。
invoke SetFilePointer,@hFile,@dwAddCodeFile,NULL,FILE_BEGIN
mov ecx,offset APPEND_CODE_END-offset APPEND_CODE
invoke WriteFile,@hFile,offset APPEND_CODE,ecx,addr @dwTemp,NULL
下面是在原PE文件中添加新的节的方法:
添加了一个节后,首先你在更新节表信息,就像你去银行存了钱,银行的业务员帮你更新你的账单一样。这里需要更新节的一些重要属性,包括PointerToRawData,SizeOfRawData,VirtualAddress,VirtualAddress,VirtualSize,Characteristics,Name1等属性。
下面是完成这些操作的代码:
inc [edi].FileHeader.NumberOfSections
push edx
@@:
mov eax,[edx].PointerToRawData
; 当最后一个节是未初始化数据时,PointerToRawData和SizeOfRawData等于0
; 这时应该取前一个节的PointerToRawData和SizeOfRawData数据
.if ! eax
sub edx,sizeof IMAGE_SECTION_HEADER
jmp @B
.endif
add eax,[edx].SizeOfRawData
pop edx
mov [ebx].PointerToRawData,eax
;获取按照FileAlignment对齐的磁盘文件大小
mov ecx,offset APPEND_CODE_END-offset APPEND_CODE
invoke _Align,ecx,[esi].OptionalHeader.FileAlignment
mov [ebx].SizeOfRawData,eax
;下面的三句代码是修改OptionHeader中的两个属性SizeOfCode和SizeOfImage,如果不修改SizeOfImage的值,Windows将无法装入修改后的PE文件,系统会报“这不是一个有效的PE文件”。
invoke _Align,ecx,[esi].OptionalHeader.SectionAlignment
add [edi].OptionalHeader.SizeOfCode,eax ;修正SizeOfCode
add [edi].OptionalHeader.SizeOfImage,eax ;修正SizeOfImage
;计算内存偏移地址
invoke _Align,[edx].Misc.VirtualSize,[esi].OptionalHeader.SectionAlignment
add eax,[edx].VirtualAddress
mov [ebx].VirtualAddress,eax
;更新大小
mov [ebx].Misc.VirtualSize,offset APPEND_CODE_END-offset APPEND_CODE
;更新内存属性
mov [ebx].Characteristics,IMAGE_SCN_CNT_CODE\
or IMAGE_SCN_MEM_EXECUTE or IMAGE_SCN_MEM_READ or IMAGE_SCN_MEM_WRITE
;对新加的节进行命名
invoke lstrcpy,addr [ebx].Name1,addr szMySection
下面是向新加的节中写入要添加的代码:
;用SetFilePointer设置写入的超始点
invoke SetFilePointer,@hFile,[ebx].PointerToRawData,NULL,FILE_BEGIN
invoke WriteFile,@hFile,offset APPEND_CODE,[ebx].Misc.VirtualSize,\
addr @dwTemp,NULL
;因为要写入的代码没有按照FileAlignment对齐,所以再次用SetFilePointer将起始点设置到代码结束的地方
mov eax,[ebx].PointerToRawData
add eax,[ebx].SizeOfRawData
invoke SetFilePointer,@hFile,eax,NULL,FILE_BEGIN
invoke SetEndOfFile,@hFile
;保存新加节的内存起始地址和磁盘文件起始地址
push [ebx].VirtualAddress
pop @dwAddCodeBase
push [ebx].PointerToRawData
pop @dwAddCodeFile
4、 现在回答第二个问题,如何让添加进来的代码最先执行。PE结构中的OptionHeader数据结构中的AddressOfEntryPoint属性可以设置文件的入口地址,所以我们可以把“新加节的基址+添加代码的起始地址”设为AddressOfEntryPoint的值。
mov eax,@dwAddCodeBase
;因为新加的代码中含有数据的定义,占据了一定的内存,所以要用代码开始的地址减去数据开始的地址,得到真正的代码地址,再加上VirtualBase的值,就是文件开始的地址。
add eax,(offset _NewEntry-offset APPEND_CODE)
mov [edi].OptionalHeader.AddressOfEntryPoint,eax
;设置文件写入起始地址,写入新的文件头
invoke SetFilePointer,@hFile,0,NULL,FILE_BEGIN
invoke WriteFile,@hFile,@lpMemory,[esi].OptionalHeader.SizeOfHeaders,\
addr @dwTemp,NULL
5、 接下来回答第三个问题,怎样让程序执行完添加的代码后又从原文件的起始部分开始执行呢,下面介绍实现这个功能的操作。
;取出原文件的入口地址,请注意看清这里是esi,是原文件的PE文件头,我之前由于粗心看成了ebx,结果一直没看懂。
push [esi].OptionalHeader.AddressOfEntryPoint
pop @dwEntry
;这里有点复杂,需要认真分析。首先,要弄清楚jmp指令的机器码是如何生成的,jmp指令的机器码由转移类别0e9h+偏移地址组成,偏移地址就是目的地址与jmp下一条指令地址的差,上面我们已经得到了目的地址(就是原PE文件的入口地址),记为a,现在来找jmp下一条指令的地址,观看添加的代码发现_ToOldEntry标号对应的指令是jmp,那么再加上这条指令的长度5,就可以得到下一条指令的地址,记为b。现在用a-b就能得到偏移量,如下面的第三句代码。
[ATTACH]67748[/ATTACH]
mov eax,@dwAddCodeBase
add eax,(offset _ToOldEntry-offset APPEND_CODE+5)
sub @dwEntry,eax
;找到偏移量后,将这个值写到jmp后面的双字中,当程序执行到这里后,会把jmp和后面的数据(偏移量)看成一句指令,由此进行跳转。
mov ecx,@dwAddCodeFile
add ecx,(offset _dwOldEntry-offset APPEND_CODE)
invoke SetFilePointer,@hFile,ecx,NULL,FILE_BEGIN
invoke WriteFile,@hFile,addr @dwEntry,4,addr @dwTemp,NULL
到这里,添加代码的任务就完成了。当然,最后不要忘记关闭文件句柄。
6、 下面回答第四个问题,添加进来的代码如何获取API地址。有人说,直接在要添加的代码中加入导入库不就行了吗?这是平常的程序的写法,请注意我们这里是要向别的文件中添加代码,所以不能在代码中直接使用API函数,因为函数的地址在不同的进程中会随着DLL装入位置的不同而不同,如果在代码中直接调用API函数,那么系统会按照当前进程(即原PE文件)的DLL装入位置填入函数地址,这显然存在错误。正确的方法是在程序中动态获取API地址,即先用LoadLibrary函数装入需要用到的DLL,然后用GetProcAddress函数获取需要用到的函数地址。具体操作请参看我前几天发的“动态获取API地址详解”,这里我也会把这个文档添加到附件,请读者参考前面的一起学习。
教程到这里也算结束了,希望对大家有帮助。
以前看别人做的技术文章觉得挺辛苦的,今天自己试着写了下,确实有点累呀,昨天下午两点开始写,一直到晚上12点过,写了些又改,今天早上起来又继续写了些,才算基本完成。
附件中是《Windows环境下32位汇编语言程序设计》书中的程序+我做的PE文件结构思维导图+动态获取API地址详解DOC文档+本节的DOC文档。
[课程]Android-CTF解题方法汇总!