通过前两篇文章的学习以及手动操作,读者应该对PE32可执行文件的基本格式有了一个较为熟悉的认识。那么再加上一点点汇编代码的基础知识,就可以来触摸一下软件保护领域的门槛——给PE32程序加一个简单的壳。
PE格式规范非常复杂和庞大,完整的规范不会比什么《JAVA从入门到精通》这样的书薄。且原始的文档定义偏技术手册,用来入门学习可读性较差。
实际上基于PE格式的安全应用,原理上用到的知识点其实不多。
希望这几篇文章能消除读者入门二进制安全的恐惧,在“最小”知识积累的基础上,带来更多的可玩性。
先介绍一下什么是给程序加壳:
壳实际上是一个程序,这个程序改变被加壳的程序,使得逆向分析者或恶意攻击者分析、破解被加壳程序的难度增加。以达到保护被加壳程序的目的。
加壳技术一般包括:加密、压缩、混淆、反调式等等。
学习本文不增加对PE32基本格式的知识点。熟练掌握前面两篇文章就足够。需要补充的知识点就是一点点的汇编知识。为了将基础知识压缩到最低,本文设计的壳就是一个简单的“异或”加密。重点还是加深对PE32格式的理解和应用,以及对加壳技术的基本原理的介绍和展示上面。复杂一点的汇编代码会加上注释和说明。
如果读者对python、asm代码不熟悉,也没关系。直接复制到各种 GPT 中,会获得非常详细的解释。
一、只加密 .code 和 .data 节的壳
我们手动编辑的PE32文件所有字符串都是明文的,为了避免恶意攻击者通过直接查找字符串的方法,在PE32文件中直接找到“敏感”信息,最直接的方法就是加密 .data 节中的数据。同时为了保护我们的代码逻辑,也要对 .code 节加密。这样可以让攻击者的静态扫描分析失效。我们设计一个简单的异或加密逻辑,来实现这种保护。
我们将第一篇文章编辑成功的 PE.exe 文件作为被加壳的目标文件。
1、对 .code 和 .data 节数据按字节异或 0x7F
第一篇文章中规划了 .code 和 .data 节在文件中的位置分别为 0x200 和 0x400。文件中的大小都是 0x200 字节。我们使用 python 写个脚本来执行加密任务。
有大神能直接用二进制编辑器脚本完成这个任务的,欢迎留言指导。
key = 0x7F
with open("PE.exe", 'rb') as f:
pe = bytearray(f.read())
# .code 节 FOA 0x200, size 0x200
for i in range(0x200, 0x400):
pe[i] = pe[i] ^ key
# .data 节 FOA 0x400, size 0x200
for i in range(0x400, 0x600):
pe[i] = pe[i] ^ key
with open('packed_PE.exe', 'wb') as f:
f.write(pe)
执行脚本后,得到的 packed_PE.exe 的 .code 和 .data 节看起来是这个样子的:
显而易见的,代码和数据都被加密了,运行这个程序会出现无法预料的结果。要让这个加密后的程序能正常跑起来,就需要加入一段解密代码。
2、unpack code
针对这里的加壳方式,其 unpack code 需要执行两步:
- 解密:因为是异或加密,所以解密过程和加密过程是一样的。
- 跳转回原始代码入口点
因为 unpack code 需要嵌入加壳的程序中,所以需要编辑成二进制流数据。我们使用汇编来实现对 .code 和 .data 的解密以及跳转。
需要强调的是,unpack code 是程序被加载到内存中后执行的,此时的 .code 和 .data 节的位置是 RVA,0x1000和0x2000。还需要加上image的基地址 0x400000。
; base 0x400000 image 的基地址
; 解密 .code 节
mov eax, 0x401000 ; .code 节在进程虚拟内存中的起始地址
mov ecx, 0x200 ; .code 节加密数据大小。由于需要给 unpack code 预留空间,这个值后期需要调小。
start1:
xor byte ptr[eax], 0x7F ; 将 eax 指向的内存字节值与 0x7F 异或并原地保存
inc eax ; eax 加 1
loop start1 ; loop 命令会将 ecx 减 1,并回到 start1 处执行,直到 ecx 值为 0,停止循环
; 解密 .data 节
mov eax, 0x402000 ; .data 节在进程虚拟内存中的起始地址
mov ecx, 0x200 ; .data 节加密数据大小
start2:
xor byte ptr[eax], 0x7F ; 将 eax 指向的内存字节值与 0x7F 异或并原地保存
inc eax ; eax 加 1
loop start2 ; loop 命令会将 ecx 减 1,并回到 start2 处执行,直到 ecx 值为 0,停止循环
; 跳转到原始入口点 0x401000
mov eax, 0x401000 ; 将原程序入口点地址放入 eax
jmp eax ; 跳转到 eax 的地址处执行
使用网站 Online x86 / x64 Assembler and Disassembler 将以上汇编代码翻译成二进制。(注意去掉其中的注释,否则不能翻译)
0: b8 00 10 40 00 mov eax,0x401000
5: b9 00 02 00 00 mov ecx,0x200
0000000a <start1>:
a: 80 30 7f xor BYTE PTR [eax],0x7f
d: 40 inc eax
e: e2 fa loop a <start1>
10: b8 00 20 40 00 mov eax,0x402000
15: b9 00 02 00 00 mov ecx,0x200
0000001a <start2>:
1a: 80 30 7f xor BYTE PTR [eax],0x7f
1d: 40 inc eax
1e: e2 fa loop 1a <start2>
20: b8 00 10 40 00 mov eax,0x401000
25: ff e0 jmp eax
字节流:{ 0xB8, 0x00, 0x10, 0x40, 0x00, 0xB9, 0x00, 0x02, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0xB8, 0x00, 0x20, 0x40, 0x00, 0xB9, 0x00, 0x02, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0xB8, 0x00, 0x10, 0x40, 0x00, 0xFF, 0xE0 }
一共 0x27=39 个字节,我们把 unpack code 部署到 .code 节的最后部分:unpack code FOA = 0x400-0x27 = 0x3D9。(选择这样的布局是因为原始的代码实际很小,.code 节有大量空余空间)
因为在 .code 最后部署了 unpack code,那我们的解密代码中解密 .code 的部分就不能再对整个 .code 节解密了,否则把最后的 unpack code 都异或了。相当于运行时改动了正在运行的代码,结果必然出错。
缩小解密 .code 节的字节数,实际上就是减少循环次数。而我们的代码设计减少循环次数,并不改变我们的 unpack code 代码的长度。因为我们只需要对 .code 节剩下的 0x1D9 个字节解密,所以调整上面代码第一个循环前的 ecx 值。从 0x200 调整为 0x1D9,只解密前 0x1D9 个字节就可以了。
; base 0x400000 image 的基地址
; 解密 .code 节
mov eax, 0x401000 ; .code 节在进程虚拟内存中的起始地址
mov ecx, 0x1D9 ; 根据 unpack code 大小调整这个值
start1:
xor byte ptr[eax], 0x7F ; 将 eax 指向的内存字节值与 0x7F 异或并原地保存
inc eax ; eax 加 1
loop start1 ; loop 命令会将 ecx 减 1,并回到 start1 处执行,直到 ecx 值为 0,停止循环
; 解密 .data 节
mov eax, 0x402000 ; .data 节在进程虚拟内存中的起始地址
mov ecx, 0x200 ; .data 节加密数据大小
start2:
xor byte ptr[eax], 0x7F ; 将 eax 指向的内存字节值与 0x7F 异或并原地保存
inc eax ; eax 加 1
loop start2 ; loop 命令会将 ecx 减 1,并回到 start2 处执行,直到 ecx 值为 0,停止循环
; 跳转到原始入口点 0x401000
mov eax, 0x401000 ; 将原程序入口点地址放入 eax
jmp eax ; 跳转到 eax 的地址处执行
字节流:{ 0xB8, 0x00, 0x10, 0x40, 0x00, 0xB9, 0xD9, 0x01, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0xB8, 0x00, 0x20, 0x40, 0x00, 0xB9, 0x00, 0x02, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0xB8, 0x00, 0x10, 0x40, 0x00, 0xFF, 0xE0 }
红色部分就是修改的 .code 节需要解密的数据大小。
根据前面的计算,在 packed_PE.exe 的 0x3D9 处,写入 0x27 个字节的 unpack code 二进制流。
不想逐个字节编辑的,可以使用下面的 python 脚本。
unpack_code = [0xB8, 0x00, 0x10, 0x40, 0x00, 0xB9, 0xD9, 0x01, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0xB8, 0x00, 0x20, 0x40, 0x00, 0xB9, 0x00, 0x02, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0xB8, 0x00, 0x10, 0x40, 0x00, 0xFF, 0xE0]
with open("packed_PE.exe", 'rb') as f:
pe = bytearray(f.read())
for i in range(0x27):
pe[i+0x3D9 ] = unpack_code[i]
with open('packed_PE.exe', 'wb') as f:
f.write(pe)
写入后的 .code 节看起来是这样子的:
3、修改 EntryPoint 指向 unpack code。
程序加壳后,执行入口点发生了改变,需要先执行我们的 unpack code,解密处正确的原始代码,才能正确执行。所以这一步需要改变 packed_PE.exe 头部的程序入口点,指向我们的 unpack code。由于 EntryPoint 是 RVA,unpack code 在 .code 节中的 FOA = 0x3D9、按照如下公式转换成 RVA:
EntryPoint RVA = 0x3D9 - 0x200 + 0x1000 = 0x11D9
读者如果认证按照步骤操作,认为完成这3步,似乎就完成了所有步骤,可以成功运行了。实际执行一下发现,并没有出现出息的结果。错误到底在哪里呢?
这里要重新理解一下 Section Header 里面的 Characteristics 字段。读者可以回到前面两篇文章的相关章节,再次深刻理解一下,Characteristics 字段给 Section 内存页施加的安全限制。所以我们需要增加一个步骤。
4、修改 .code 节的 Section Header 中 Characteristics 字段
我们是用第一篇文章编辑的 PE.exe 程序来加壳的。这个程序的 .code 节的 Characteristics 值为 0x60000020。对应安全特性为 :
-
IMAGE_SCN_CNT_CODE(该节包含执行代码)
-
IMAGE_SCN_MEM_EXECUTE(该节可以被执行)
-
IMAGE_SCN_MEM_READ(该节可以被访问)
由于 unpack code 需要对 .code 节进行解密,所以需要有修改该节内存的权限。读者可以参考第二篇文章中三节合一的章节,修改 .code 节 Section Header 的 Characteristics 字段值为 0xE0000060。
保存后,运行 packed_PE.exe。熟悉的对话框就出现了:
二、一个能加密导入表的壳
通过第一个只加密 .code 和 .data 节的壳,我们认识到给PE加壳的一般步骤。为什么三个节,唯独不加密 .idata 导入表节呢?
因为导入表是给 windows Load 使用的,在加载PE文件时,将需要的外部函数地址加载到 IAT 数组中,以便程序中可以正常使用。一旦导入表被加密了,那 Loader 在加载程序时就不能正确加载源代码需要的函数。就算解密了正确的 .code 和 .data,因为没有了MessageBoxA
函数,程序运行会出错。
所以对于原程序的导入表,加密导入表相当于让原导入表失效。必须要由 unpack code 来模拟 Loader 重建导入表。
我们来看一下原始程序的主要代码:
push 0x40
push 0x402000
push 0x40200E
push 0
call DWORD ptr ds:0x403028 ; MessageBoxA IAT 地址
ret
了解一点汇编基础知识的读者可以发现,里面用到的字符串地址 0x402000
,0x40200E
,以及 MessageBoxA 函数指针的存放地址 IAT 0x403028
,都是进程虚拟内存空间的绝对地址。这意味着,如果我们的壳程序抛弃原来的导入表 IAT,选择“异地重建”,那么需要修改call DWORD ptr ds:0x403028
这条调用 MessageBoxA 的指令中的函数绝对地址。如果是一个正常的程序,那么原始代码中所有的导入函数调用地址都需要改变。这复杂度很高,我们的壳需要尽量简化相关操作。
我们选择保留原来导入表的结构直接加密,unpack code 解密后,再根据解密后的导入表,代替Loader执行DLL的加载和函数的导入。这样就不需要改变原始代码。
相比上面的 unpack code,一个加密导入表的壳需要增加导入DLL及其函数的功能。这个实现原理大体上是这样的:
hmodule = LoadLibraryA("user32.dll");
proc = GetProcAddress(hmodule, "MessageBoxA");
// 把 proc 地址写入 IAT 中 MessageBoxA 对应的表项中就可以了。这个例子中就是写入 `0x403028`。
LoadLibraryA
和GetProcAddress
函数都出自kernel32.dll
动态链接库。所以我们的 unpack code 要调用这两个函数,需要为其准备相应的导入表。
unpack code 有自己的导入表,看起来更象一个独立的程序了。
虽然这个例子原始代码只用到一个函数,而 unpack code 却要用到两个函数,似乎 unpack code 比原始代码还要复杂。就这个例子来说,确实原始代码比壳代码要简单。但实际的程序中,可能存在数十个乃至上百个API导入,而壳代码仍然只需要导入这两个函数,其复杂度不会因为原始代码导入函数的多少而改变。
讲了一些前置的知识,下面让我们开始进行尝试吧。为了简化加解密过程,缩短 unpack code 长度,这次我们使用第二篇文章中三合一之后的 min_PE1.1.exe
文件作为目标加壳文件。
1、加密min_PE1.1.exe
的 .mixed 节
key = 0x7F
with open("min_PE1.1.exe", 'rb') as f:
pe = bytearray(f.read())
# .mixed 节 FOA 0x200, size 0x200
for i in range(0x200, 0x400):
pe[i] = pe[i] ^ key
with open('packed_PE1.1.exe', 'wb') as f:
f.write(pe)
用上面的python代码加密 .mixed 节,保存为packed_PE1.1.exe
。因为 .mixed 节是三合一的节,所以相当于把所有的都加密了。这个时候程序已经无法执行,我们需要为其加入 unpack code。
2、给packed_PE1.1.exe
增加一个 .unpack 节
前面讲了能解密 IAT 的壳代码看起来更像一个程序,需要为其构建导入表。那么其所需要的存储空间也相应较大。我们使用的目标程序本身就是一个紧凑程序,为了增加空间,我们可以给这个程序直接增加一个 Section,初始值为全0。壳独自占用一个 Section 也是一个普遍的方案。
通过前面两篇文章对 PE32 结构的学习,手动给packed_PE1.1.exe
增加一个 Section 其实也不难。按照以下几步操作就可以了。
(1)修改NumberOfSections
字段
PE File Header
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x86 | 0x86 | 2 | NumberOfSections |
2 |
.mixed 节 增加一个 .unpack 节 |
(2)在 SectionTable 的末尾增加一个 Section Header
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x130 | 0x130 | 8 | Name | “.unpack” | 字符串”.unpack” |
0x138 | 0x138 | 4 | VirtualSize | 0x1000 | 该节在内存Image中的大小 |
0x13C | 0x13C | 4 | VirtualAddress | 0x2000 | 该节在内存Image中的起始地址RVA |
0x140 | 0x140 | 4 | SizeOfRawData | 0x200 | 该节在磁盘文件中的大小 |
0x144 | 0x144 | 4 | PointerToRawData | 0x400 | 该节在磁盘文件中的起始地址FOA |
0x148 | 0x148 | 12 | 00 | 几个项目中用不到的字段,保留00 | |
0x154 | 0x154 | 4 | Characteristics | 0xE0000060 | IMAGE_SCN_CNT_CODE 该节包含执行代码 IMAGE_SCN_CNT_INITIALIZED_DATA 该节包含初始化数据 IMAGE_SCN_MEM_EXECUTE 该节可以被执行 IMAGE_SCN_MEM_READ 该节可以被访问 IMAGE_SCN_MEM_WRITE 该节可以被写入 |
(3)调整packed_PE1.1.exe
文件的大小
使用二进制编辑器,给packed_PE1.1.exe
增加 0x200 个全为0的字节。
(4)修改SizeOfImage
字段
因为我们增加了一个 .unpack 节,且在该节的 Section Header 的 VirtualSize 字段指定了该节在 image 中大小为 0x1000。也就是我们整个 image 比原来增加了 0x1000 大小。所以需要相应调整 PE Optional Header 中的SizeOfImage
字段。
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0xD0 | 0xD0 | 2 | SizeOfImage |
0x3000 |
程序的内存Image大小 |
3、在 .unpack 节中布置 unpack code 需要的导入表
这一步不清楚的读者,可以复习一下第一篇文章中关于导入表的基础知识。这次我们把导入表结构中的字符串排前面Directory Entry
排最后,Import Address Table
排中间。
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0x400 | 0x2000 | 13 | DLL Name | “kernel32.dll\0" | DLL名称字符串 |
0x40E | 0x200E | 2 | Hint | 0x00 | Hint 需要从偶数地址开始 |
0x410 | 0x2010 | 13 | Function Name | "LoadLibraryA\0\0" | 函数名字符串,包括一个pad \0 |
0x41E | 0x201E | 2 | Hint | 0x00 | Hint 需要从偶数地址开始 |
0x420 | 0x2020 | 15 | Function Name | "GetProcAddress\0" | 函数名字符串 |
0x430 | 0x2030 | 4 | Import Lookup Table / Import Address Table Entry | 0x200E | 指向LoadLibrary的Hint/Name Entry |
0x434 | 0x2034 | 4 | Import Lookup Table / Import Address Table Entry | 0x201E | 指向GetProcAddress的Hint/Name Entry |
0x438 | 0x2038 | 4 | Null Import Lookup Entry | 00 | 表示Import Lookup Table结束 |
0x43C | 0x203C | 4 | Import Lookup Table RVA (Directory Entry) | 0x2030 | import lookup table(ILT) 在内存image中的偏移量 |
0x440 | 0x2040 | 4 | Time/Date Stamp (Directory Entry) | 00 | 初始为0,Loader 会将其更新为DLL加载成功的时间。 |
0x444 | 0x2044 | 4 | Forwarder Chain (Directory Entry) | 00 | 用不上,置0 |
0x448 | 0x2048 | 4 | Name RVA (Directory Entry) | 0x2000 | DLL名称字符串在内存image中的偏移量。 |
0x44C | 0x204C | 4 | Import Address Table RVA (Thunk Table) | 0x2030 | import address table(IAT) 在内存image中的偏移量。这个值和ILT RVA相同 |
0x450 | 0x2050 | 20 | Null Directory Entry | 00 | 表示导入的DLL结束 |
编辑好后看上去是这个样子:
4、在 .unpack 节中布置解密代码
HMODULE LoadLibraryA(
[in] LPCSTR lpLibFileName
);
FARPROC GetProcAddress(
[in] HMODULE hModule,
[in] LPCSTR lpProcName
);
壳代码用到的导入函数都是 Windows API,参数调用规则和MessageBoxA
一样,都是 __stdcall 调用规则,这意味着函数自己会清理堆栈,保持栈平衡。我们可以使用汇编写出 unpack code。
; 先进行解密
mov eax, 0x401000 ; ,mixed 节在虚拟内存中的起始地址
mov ecx, 0x200 ; .mixed 节大小
start:
xor byte ptr[eax], 0x7F
inc eax
loop start
; 加载 user32.dll
push 0x401150 ; "user32.dll"字符串在 FOA 0x350 RVA 0x1150 处,再加上image基地址0x400000
call DWORD ptr ds:0x402030 ; LoadLibraryA IAT Entry,执行函数调用
; 获取 MessageBoxA 函数地址
push 0x40115D ; "MessageBoxA"字符串在 FOA 0x35D RVA 0x115D 处,再加上image基地址0x400000
push eax ; LoadLibraryA 函数的返回值,是 GetProcAddress 函数的参数
call DWORD ptr ds:0x402034 ; GetProcAddress IAT Entry,执行函数调用
mov dword ptr [0x401148], eax ; 0x401148 就是原来程序的 MessageBoxA 函数的 IAT
; 跳转到原始代码入口处
mov eax, 0x401000
jmp eax
使用网站 Online x86 / x64 Assembler and Disassembler 翻译成二进制:
0: b8 00 10 40 00 mov eax,0x401000
5: b9 00 02 00 00 mov ecx,0x200
0000000a <start>:
a: 80 30 7f xor BYTE PTR [eax],0x7f
d: 40 inc eax
e: e2 fa loop a <start>
10: 68 50 11 40 00 push 0x401150
15: ff 15 30 20 40 00 call DWORD PTR ds:0x402030
1b: 68 5d 11 40 00 push 0x40115d
20: 50 push eax
21: ff 15 34 20 40 00 call DWORD PTR ds:0x402034
27: a3 48 11 40 00 mov ds:0x401148,eax
2c: b8 00 10 40 00 mov eax,0x401000
31: ff e0 jmp eax
字节流:{ 0xB8, 0x00, 0x10, 0x40, 0x00, 0xB9, 0x00, 0x02, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0x68, 0x50, 0x11, 0x40, 0x00, 0xFF, 0x15, 0x30, 0x20, 0x40, 0x00, 0x68, 0x5D, 0x11, 0x40, 0x00, 0x50, 0xFF, 0x15, 0x34, 0x20, 0x40, 0x00, 0xA3, 0x48, 0x11, 0x40, 0x00, 0xB8, 0x00, 0x10, 0x40, 0x00, 0xFF, 0xE0 }
使用下面脚本写入packed_PE1.1.exe
。把 unpack code 放到 FOA 0x500 处,对应 RVA 0x2100。
unpack_code = [0xB8, 0x00, 0x10, 0x40, 0x00, 0xB9, 0x00, 0x02, 0x00, 0x00, 0x80, 0x30, 0x7F, 0x40, 0xE2, 0xFA, 0x68, 0x50, 0x11, 0x40, 0x00, 0xFF, 0x15, 0x30, 0x20, 0x40, 0x00, 0x68, 0x5D, 0x11, 0x40, 0x00, 0x50, 0xFF, 0x15, 0x34, 0x20, 0x40, 0x00, 0xA3, 0x48, 0x11, 0x40, 0x00, 0xB8, 0x00, 0x10, 0x40, 0x00, 0xFF, 0xE0]
with open("packed_PE1.1.exe", 'rb') as f:
pe = bytearray(f.read())
for i in range(len(unpack_code)):
pe[i+0x500 ] = unpack_code[i]
with open('packed_PE1.1.exe', 'wb') as f:
f.write(pe)
5、修改文件头,完成最后的拼图
-
修改成EntryPoint 指向 unpack code 入口点
-
修改 data-directory 的第二项指向导入表的 RVA
FOA | RVA | Size | Field | Value | Description |
---|---|---|---|---|---|
0xA8 | 0xA8 | 4 | AddressOfEntryPoint | 0x2100 | 前面我们把 unpack code 部署到了 FOA 0x500 处,对应 RVA = FOA - 0x400 + 0x2000 = 0x2100 |
0x100 | 0x100 | 4 | Import Table RVA | 0x203C | 这个字段需要指向导入表的 Directory Table 的入口处。根据我们的布置 FOA=0x43C,RVA=FOA-0x400+0x2000=0x203C |
保存后运行加壳的程序。熟悉的对话框又出现了:
三、后记
要理解本文,甚至理解第二篇“最小”PE,都需要对第一篇介绍的PE32格式字段有个逐步加深的理解。建议读者对照前面两篇文章,反复练习体会。只有对照着前面的格式表格和说明,不断练习、加深理解,才会有逐渐将PE程序玩弄于手掌之间的感觉。其实我自己写这个壳的时候,也是要不断翻看前文参考,排查错误,理清思路,才能顺利完成的。
读者如果有兴趣,可以将本文介绍的加壳方案写成一个通用的加壳程序。给平时使用的PE32程序加壳玩玩。有这方面兴趣的可以给我留言。
-
-
-