从零开始写一个加密壳
(本文仅为个人学习记录,不保证文章中没有学术性错误或笔误)
认识PE文件结构
既然要写壳,那么就不得不了解一下Windows的可执行文件格式——PE文件的内部结构了
这次要写的是32位程序的壳,用到的是PE32结构,64位的PE32+结构和PE32结构大同小异,只是部分字段变宽了而已
DOS头
DOS头是标志着一个DOS文件的开头,而PE文件是DOS文件的一个拓展,因此一个PE文件一定有DOS头
DOS头以magic字符“MZ”开头,是某个人名字的首字母,后面跟了很多内容,我们要关心的只有其中一个字段
其中的e_magic即是MZ刚好凑成的WORD,而e_lfanew记录着NT头的偏移地址,我们读取这个值就能获得NT头的起始地址
NT头
NT头是我们需要重点关注的对象,可以看到NT头是分为32和64版本的
不同位数的不同之处体现在OptionalHeader结构处
其中的Signature始终为“PE\0\0”
FileHeader结构中我们需要关注的只有NumberOfSections这个字段,待会儿会用到
OptionalHeader中,分为2个部分,一个是Standard fields,一个是NT additional fields
我们需要关注的字段为AddressOfEntryPoint、ImageBase、SizeOfImage、DataDirectory
其中,常量IMAGE_NUMBEROF_DIRECTORY_ENTRIES的值为16
代表着有至多16个DataDirectory,每个结构都记录了相应的RVA(相对虚拟地址)和大小
根据下标的不同,有不同的含义,我们需要关注的是IMAGE_DIRECTORY_ENTRY_BASERELOC,涉及到后面手动恢复重定位时定位重定位表
EP
EP即EntryPoint,也就是上面我们看到的AddressOfEntryPoint,它代表了一个程序的入口点,也就是这个程序第一句被执行的代码的地址
这个值不是我们写程序的时候能指定的,也不是main函数的第一行代码的地址,事实上,编译器会在编译的时候添加一些启动代码,在启动代码执行完以后才会调用main函数,而启动代码通常就被设置为EP
我们写壳就是要在程序执行之前做一些我们自己想做的事,因此通常思路就是将EP改到指向我们自己的代码,在我们的代码执行完以后再跳回原EP处执行
ImageBase
每一个程序都有自己在内存中装载的地址,这个值就是指定被运行的程序会被装载到内存的哪个位置的
但是由于各种pwn攻击的存在,诞生了一些对抗攻击的技术,地址随机化就是其中一个
多个DLL如果指定了相同的ImageBase也会导致撞车,因此也只能采用随机地址来装载DLL
因为上述原因,程序真正运行起来后被装载到的位置通常和ImageBase的值不一样,但是ImageBase后面同样是有用的
重定位表
在程序编译的时候,地址是按照ImageBase来计算的,而程序运行的时候地址却往往不同,因此需要有一个将这些地址转换的过程,编译器将需要重定位的代码的地址记录在重定位表里
重定位是发生在PE文件加载的时候,这个时候才刚刚确定程序真正的加载地址,EP的代码还没有运行,PE加载器自动帮我们把重定位表记录的地址的数据执行如下操作:
Data += BaseAddress - ImageBase
这个操作对于我们来说是透明的,绝大多数情况下不需要管它,但是写壳的时候涉及到对代码段的加密解密,因此后面会进行手动还原的操作
Section Table
段表是紧挨着NT头后面的一个结构数组,这个数组的长度可以由上面提到的NumberOfSections来得到
其中常量IMAGE_SIZEOF_SHORT_NAME的值为8
Section的Name是一个char[8],它不必以\0结尾,也就是最多可以有8个字符,以“.”开头也不是必须的
Misc联合体我们通常取VirtualSize,它代表着文件被映射到内存中以后这个Section在内存中的大小
VirtualAddress代表着文件被映射到内存中以后这个Section的起始地址的相对偏移
SizeOfRawData是文件中Section的实际大小
PointerToRawData是文件中Section的起始地址的相对偏移
Characteristics记录了这个Section的属性,比如是否可读可写可执行
因为原程序的代码已经被编译器编译好了,很难加入大量代码到已有的空间中,因此我们的代码也会以新Section的方式添加到PE文件中
PE结构总览
使用WinHex打开一个程序以后可以看到它的内部结构,MZ、PE字符可以很容易的被发现
还能看到一行不能以DOS模式运行的提示,这是为了兼容性,当PE文件在DOS中被运行时就会这样报错
可以看到NT头之后紧跟着几个Section Table,其中最后的.Titvt段是我新增的壳代码段,在我的电脑上,一个由VS2019编译的32位C++程序会有.text .data .rdata .rsrc .reloc五个Section
在最后一个Section Table的后面会有大量用于对齐而填充的00,之后便是每个Section的真正的内容了
绝大多数情况下我们都可以认为最后一个Section Table之后还有大量空余地址可以用于存放新的Section Table
加壳思路
新增Section
我们读入一个程序的文件后,可以解析它的PE结构,我们在原本的基础上新增一个Section用于存放我们的壳代码
NumberOfSections记录了Section的数量,我们将它+1,然后在最后一个Section Table的后面增加一个新的Section Table
length是我们要添加的壳代码的长度,也就是新Section的大小
SizeOfImage也需要增加相应的长度,否则PE加载器会报错
VirtualAddress在内存中是0x1000对齐的,因此需要计算一下我们的新Section的虚拟地址应该是多少
同理,VirtualSize也一样需要对齐
由于这个Section的数据是需要当作代码执行的,因此需要添加可执行属性
更改EP
我们把新增的Section的RVA设置为EP,这样,程序运行的第一句代码就是我们的壳代码了
加密.text段
因为是第一次写壳,就不搞那么复杂了,只是简单的把整个.text段异或一下
在前面我们已经拿到第一个Section Table的指针了,从第一个开始依次遍历所有Section Table,找到名为.text的Section,获取.text段的范围(其实.text通常都是第一个),然后逐字节异或,加密就完成啦
保存ImageBase
因为程序在装载好以后ImageBase会被PE加载器覆写为真正的基址,原来的ImageBase将会丢失,我们不希望再去读文件来确定ImageBase,因此将它保存在壳代码中
在原文件的末尾就是我们需要添加的Section的内容该储存的位置了,我们写入jz+jnz的字节码,跳过后面的4个字节(也可以直接jmp,这里是为了迷惑IDA)
那么后面的4个字节将可以拿来存放东西,我们把ImageBase写入这里,到时候就可以直接读取了
然后写入shellcode,也就是真正的壳代码
最后用一个长jmp跳回原来的EP,开始真正的程序的运行
ShellCode
获取编译好的shellcode
我们另外创建一个项目,用来生成shellcode,在项目属性里关闭安全检查(GS)和编译器优化,否则会有security_cookie检查和常量优化等的干扰
先写一个func()函数,里面用来写壳代码,再在后面写一个main()函数,用来输出字符串形式的shellcode,方便硬编码进加壳器中
编写shellcode
由于是需要直接植入的代码,因此不能有常量区的数据和导入的函数供我们使用(比如字符串、cout、GetModuleHandleW()、printf()等)
我们需要先从当前的PEB(进程环境块)中获得Kernel32.dll的基址,我们手动读取fs:[30h]来得到PEB结构的地址,至于怎么通过PEB得到基址,网上有很多讲解,咱们用代码跟着手动实现一下
因为DLL也是PE文件,于是拿到基址后,我们来解析这个PE结构
有一个函数叫GetProcAddress(),是Kernel32.dll导出的,我们只要遍历它的导出表,即可获得这个函数的地址,我们也就获得了第一个能够调用的函数,而有了这个函数以后,我们就能获得任意函数的地址了
我们依次比较每个导出函数的名字,如果是GetProcAddress的话,则将对应下标的ordinal的值当作下标访问funcAddr,拿到该函数的地址
需要注意的是,如果直接用字符串比较的话,字符串会被保存在常量区,我们的shellcode换个地方就会失效,必须把所有操作和数据都硬编码在代码中,因此转换成short数组来比较
如果开了编译器优化的话,有的会把这种比较直接优化成常量比较,因此要关闭优化
获取所需函数
本次的壳一共需要以上3个函数,我们前面已经找到了第一个函数,接下来用它来获取另外2个函数的地址
由于GetProcAddress()需要传入字符串参数,我们需要想办法避开字符串
随手写了一个生成器,用来将字符串变成硬编码的数据,以为会用到很多次,没想到就只用了2次
用获取到地址的函数拿到主模块的基址
同样,解析PE拿到我们要的数据,然后把另一个函数的地址也获取了
手动修复重定位
先设置.text段为可写,等会再恢复
先讲一讲为什么需要下面的步骤
正常情况下PE加载器会帮我们进行重定位,让我们的代码中的地址与实际装载的地址相符合
但是我们将.text段全部异或了,那么这个时候它重定位的时候可不会管你有没有进行加密,全部执行重定位
这个时候如果直接再次异或的话,结果是不对的(举个例子,((A^C)+B)^C和A+B在绝大多数情况下肯定是不同的)
所以我们需要先把PE加载器重定向的位于.text段内的地址恢复原来的值,然后再进行解密,接着再重新手动重定向回去
(注:后续开发中发现一个错误,下图的 sizeof(PIMAGE_BASE_RELOCATION) 把字符 P 去掉,之前错误的写法没有导致运行出错所以没有及时发现)
最后再恢复.text段的保护属性
大功告成,这个时候就可以直接跳到原EP处开始执行了
集成shellcode
我们运行生成器,将输出的数据复制到加壳器的代码中
在这里,我将它们作为全局变量存放
输入与输出
我比较喜欢拖动进行加壳,所以使用命令行来传参
读入的文件我没有检验其合法性,按理来说是需要检查这是不是一个合法的PE文件的
完整的检查代码在我另一个项目里,也许要用到的时候可以直接copy过来
最后将加完壳的文件写入输出文件中,结束整个加壳过程
成品检验
ExeInfoPE
IDA 7.5
刚用IDA打开时,定位到了EP但是没有将其识别为一个函数,左侧的2个函数是没有任何意义的数据片段
我们在0x409008处按P后按F5
void __usercall sub_409008(char a1@<bh>, int a2@<edi>, int a3@<esi>)
{
unsigned int v3; // eax
unsigned int v4; // ecx
int v5; // edx
int v11; // edx
unsigned int v12; // ecx
int v13; // [esp-24h] [ebp-DCh]
int v14; // [esp-20h] [ebp-D8h]
int v15; // [esp-1Ch] [ebp-D4h]
int v16; // [esp-18h] [ebp-D0h]
int v17; // [esp-14h] [ebp-CCh]
int v18; // [esp-10h] [ebp-C8h]
int v19; // [esp-Ch] [ebp-C4h]
int v20; // [esp-8h] [ebp-C0h]
int v21; // [esp-4h] [ebp-BCh]
char v22[4]; // [esp+0h] [ebp-B8h] BYREF
char v23[20]; // [esp+4h] [ebp-B4h] BYREF
char v24[16]; // [esp+18h] [ebp-A0h] BYREF
int v25; // [esp+28h] [ebp-90h] BYREF
unsigned int v26; // [esp+2Ch] [ebp-8Ch]
unsigned int v27; // [esp+30h] [ebp-88h]
int (__cdecl *v28)(_DWORD); // [esp+34h] [ebp-84h]
int v29; // [esp+38h] [ebp-80h]
int v30; // [esp+3Ch] [ebp-7Ch]
int v31; // [esp+40h] [ebp-78h]
unsigned int v32; // [esp+44h] [ebp-74h]
void (__cdecl *v33)(unsigned int, int, int, int *); // [esp+48h] [ebp-70h]
int v34; // [esp+4Ch] [ebp-6Ch]
int v35; // [esp+50h] [ebp-68h]
int v36; // [esp+54h] [ebp-64h]
int v37; // [esp+58h] [ebp-60h]
_DWORD *v38; // [esp+5Ch] [ebp-5Ch]
_DWORD *v39; // [esp+60h] [ebp-58h]
int v40; // [esp+64h] [ebp-54h]
_DWORD *v41; // [esp+68h] [ebp-50h]
_DWORD *v42; // [esp+6Ch] [ebp-4Ch]
int v43; // [esp+70h] [ebp-48h]
int (__cdecl *v44)(int, char *); // [esp+74h] [ebp-44h]
_DWORD *v45; // [esp+78h] [ebp-40h]
int v46; // [esp+7Ch] [ebp-3Ch]
unsigned int v47; // [esp+80h] [ebp-38h]
_BYTE *m; // [esp+84h] [ebp-34h]
unsigned int v49; // [esp+88h] [ebp-30h]
unsigned int i; // [esp+8Ch] [ebp-2Ch]
unsigned int n; // [esp+90h] [ebp-28h]
unsigned int l; // [esp+94h] [ebp-24h]
_DWORD *j; // [esp+98h] [ebp-20h]
int k; // [esp+9Ch] [ebp-1Ch]
_DWORD *v55; // [esp+A0h] [ebp-18h]
unsigned __int16 *v56; // [esp+A4h] [ebp-14h]
unsigned int v57; // [esp+A8h] [ebp-10h]
int v58; // [esp+ACh] [ebp-Ch]
_DWORD *v59; // [esp+B0h] [ebp-8h]
int v60; // [esp+B4h] [ebp-4h]
int savedregs; // [esp+B8h] [ebp+0h]
void *retaddr; // [esp+BCh] [ebp+4h] BYREF
v60 = *(_DWORD *)(***((_DWORD ***)NtCurrentPeb()->ImageBaseAddress + 7) + 8);
v43 = v60;
v59 = (_DWORD *)(v60 + *(_DWORD *)(v60 + 60));
v45 = (_DWORD *)(v60 + v59[30]);
v32 = v45[6];
v29 = v60 + v45[7];
v31 = v60 + v45[8];
v11 = v60 + v45[9];
v30 = v11;
v44 = 0;
for ( i = 0; ; ++i )
{
v12 = i;
if ( i >= v32 )
break;
v56