目录
一、实现背景
1.1 前言
壳是软件保护领域里的高端技术,自打出现,就一直处在安全对抗的最前线。这些年不断发展,加密壳的类型越来越多,可不管怎么变,它的核心思路始终是对PE文件做些变形处理,让软件不那么容易被分析和破解。
在我的信息安全专栏中,我们试过一步步脱掉一些常见的壳,相信大家对壳已经有了比较清楚的认识。不熟悉的同学请自行关注学习我的前面的文章和信息安全专栏的文章。而在这个项目里,我们要打造一个加壳软件的框架。等有了这个框架,你就能把自己熟悉的反调试手段加进去,进而拥有一款完全属于自己的壳,想想都让人觉得激动,同时本文末尾将提供完整的代码参考学习。
1.2 前置知识
要实现这个壳框架,得先掌握下面这些知识:
(1)PE文件相关知识:这部分内容在《windows编程原理》的PE文件板块能找到,具体包含:
- PE头
- 导入表
- 导出表
- 重定位表
(2)PEB中LDR模块链相关知识:想了解这方面内容,可以参考学习windows的调试与异常以及内核编程基础。
编程环境:win10或以上,vs2015或以上
1.3 达到目标
(1)弄明白壳是怎么实现的,掌握其基本的实现方法。
(2)学会构建一个用C++编写的壳的实现框架。
(3)给壳增加一些反调试的方法,让壳更难被破解,提升它的保护能力 。
二、壳的实现要点
(1)弄明白壳是怎么实现的,掌握其基本的实现方法。
(2)学会构建一个用C++编写的壳的实现框架。
(3)给壳增加一些反调试的方法,让壳更难被破解,提升它的保护能力 。
2.1 写壳怎么做
壳的原理很简单,就是对目标PE文件动手脚,在里面添加一段壳代码。当目标PE文件开始执行时,会先运行壳代码这部分,等壳代码执行完,才会回到原本程序的入口,继续执行原始程序。加壳前和加壳后的样子,看下面这张图就清楚了。
从这里我们能想到,把EXE文件变成加壳状态的那段代码,和被添加到目标程序里的壳代码,其实是两个不同的程序。所以,编写壳需要完成下面这两项工作:
(1)加壳程序:对PE文件进行加工,然后把解壳代码添加到目标可执行文件上。
(2)解壳代码:附着在目标可执行文件上,保证目标文件可以正常运行起来。
2.2 写壳的困难点
搞清楚编写壳都要做些什么之后,咱就来聊聊编写壳会遇到哪些难题。大概有下面这几个方面:
1. 壳代码具体该怎么写。
2. API函数调用过程中会碰到的麻烦。
3. 重定位相关的问题。
4. 信息交互方面的难题。
5. 调试时可能出现的状况。
6. 目标程序存在随机基址的情况。
7. 目标程序导入表带来的问题。
8. 动态加解密的实现问题。
9. TLS的处理问题。
下面我们会一个一个讨论这些问题,大家思考的时候,别忘了多看看之前那张加壳前后的图。
2.3 如何写壳代码
当把壳代码复制到目标程序里面的时候,这个壳代码得是已经编译好了的二进制代码才行。这种代码本来是既可以用汇编语言来编写的,不过因为我们要做的是一个C++壳,所以就用C++语言来编写。
我们把编写好的C++代码生成一个dll文件,然后只把这个dll文件的代码段复制到目标程序中。因为我们写的代码肯定会用到全局变量,所以把代码段和数据段合并起来就很重要,这样一来我们只需要复制一个区段就行。下面这些代码能够把区段合并起来,还能让这个区段具备可读、可写、可执行的属性:
#pragma comment(linker,"/merge:.data=.text")
#pragma comment(linker,"/merge:.rdata=.text")
#pragma comment(linker,"/section:.text,RWE")
2.4 API函数的调用问题
在原始的PE文件里,差不多99%的时候都会去调用Windows系统的API函数,而我们写的壳代码一般也得用到API函数。不过,那些直接调用API的代码,通常在编译之后就变成了call导入表的形式。所以呢,当我们把壳代码复制到目标程序里的时候,再去调用API就会出错。针对这个问题,有两种解决办法:
(1)动态获取API函数
- 要是想拿到API函数的地址,而且包含这个API的模块还没被加载,那就可以用LoadLibrary和GetProcAddress这两个API函数。先用LoadLibrary把模块加载进来,这样就能得到模块句柄,然后再用GetProcAddress去获取这个模块里所有能导出的函数地址。但要注意,LoadLibrary和GetProcAddress本身也是API函数,不能直接用,得先想办法拿到它们的地址才行。
- 好在LoadLibrary和GetProcAddress都在kernel32.dll这个模块里,而且每个进程启动的时候都会加载kernel32.dll模块。所以,关键就在于找到kernel32.dll的加载基址,找到了之后,再去分析这个模块的导出表,就能得到我们想要的函数地址了。比如说,我们可以模块链表方法来找到kernel32.dll的基址。
下面这段代码就能实现:
int GetKernel32Base() {
int nAddress = 0;
__asm {
push eax;
mov eax, fs:[0x30];
mov eax, [eax + 0xC];
mov eax, [eax + 0xC];
mov eax, [eax];
mov eax, [eax];
mov eax, dword ptr ds:[eax + 0x18];
mov nAddress, eax;
pop eax;
}
return nAddress;
}
- 得到Kernel32.dll的基址后,即可获取kernel32.dll模块中的GetProcAddress和LoadLibrary的地址:
GETPROCADDRESS g_GetProcAddress = 0;
LOADLIBRARYW g_LoadLibraryW = 0;
void MyGetProceAddress() {
char *Kernel32Buf = (char *)GetKernel32Base();
//1获取Ker32的PE基本信息。
PIMAGE_DOS_HEADER m_pDos = (PIMAGE_DOS_HEADER)Kernel32Buf;
PIMAGE_NT_HEADERS m_pNt = (PIMAGE_NT_HEADERS)(m_pDos->e_lfanew + Kernel32Buf);
PIMAGE_OPTIONAL_HEADER m_pOptionalHeader = &m_pNt->OptionalHeader;
//2我们要找Kernel32中的导出函数的地址,我们需要去导出表中寻找
PIMAGE_DATA_DIRECTORY pExportDir = m_pOptionalHeader->DataDirectory + 0;
//3导出表有三张表
PIMAGE_EXPORT_DIRECTOR pExport =
(PIMAGE_EXPORT_DIRECTORY)(pExportDir->VirtualAddress + Kernel32Buf);
PDWORD pEat = (PDWORD)(pExport->AddressOfFunctions + Kernel32Buf);
PWORD pId = (PWORD)(pExport->AddressOfNameOrdinals + Kernel32Buf);
PDWORD pNameRva = (PDWORD)(pExport->AddressOfNames + Kernel32Buf);
DWORD dwNameCount = pExport->NumberOfNames;
for (int i = 0; i < dwNameCount; i++) {
char* pName = (pNameRva[i] + Kernel32Buf);
if (strcmp(pName, "GetProcAddress") == 0) {
DWORD dwId = pId[i];
g_GetProcAddress =
(GETPROCADDRESS)(pEat[dwId] + (DWORD)Kernel32Buf);
g_LoadLibraryW =
(LOADLIBRARYW)g_GetProcAddress(
(HMODULE)Kernel32Buf, "LoadLibraryW");
return;
}
}
}
一旦成功动态获取到这两个函数的地址,后续就能轻松实现动态获取任意模块的API,并且能够顺利调用这些API执行各类操作,像进行内存申请、创建窗口以及创建DialogBox 等操作都不在话下。
(2)在目标程序里建个导入表
我们可以参照之前学过的导入表格式,在目标程序中创建一个导入表,之后让系统来填充这个导入表的内容。
2.5 重定位问题
壳代码肯定会用到全局变量,在汇编里是通过绝对地址(VA)来访问全局变量数据的。要是直接把壳代码贴到目标程序上,程序就会报错。我们之前学PE文件的时候知道,可以通过重定位表找到这些会出错的位置,然后把它们修正成正确的位置。
正确位置的算法是:正确位置 = 原始位置 - 区段的VA + 目标程序ImageBase + 壳代码rva 。具体的数字可以看相关的图。
2.6 信息交互问题
壳代码运行的时候,可能得用到一些在写壳代码时还不清楚的数据。像目标程序原本的入口点(OEP)、被加密区域的大小、加密的起始位置,要是加壳后得输入账号密码才能运行,那账号密码也算。
这些数据得在加壳程序运行的时候才能拿到,得用合适的办法把它们放到壳代码里。有个办法是,让壳部分导出一个全局的结构体变量,加壳的时候通过符号找到这个变量,再把需要的数据写进去。具体怎么做可以看看代码。
2.7 调试问题
把壳编写好并加到程序里之后,可能会碰到下面这些不太好解决的问题:
(1)加壳程序能用VS来调试,可壳代码是在可执行文件里运行的,要是运行出错了,就只能用OD来调试。
(2)如果程序没办法运行,可以按下面的方法来处理:
- 检查加壳程序的代码,看看有没有错误,不过很难直接看出出错的原因。
- 用PE工具打开PE文件,检查所有修改过的地方以及相关的字段,看看数据合不合理。
2.8 关于目标程序的随机基址
要是目标程序采用随机基址,事情就会变复杂:
(1)目标程序的代码段经过加密或者压缩等变形处理后,让系统自动修复重定位会出错。
(2)壳代码通常只把需要重定位的地方调整到适应目标程序固定基址的位置。但当目标程序是随机基址时,壳代码里的虚拟地址(VA)就会出问题,因为目标程序的重定位表中没有壳代码需要重定位的位置信息。
针对这些问题,大致有两种处理办法:
(1)去掉目标程序的随机基址功能。只需把目标程序扩展头中的DllCharacteristics属性里的IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE去掉就行,代码是`DllCharacteristics &= ~IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE;` 。基址固定后,就不用修复重定位了。
(2)在目标程序里创建一个重定位表,让系统能够修复壳代码的重定位,保证壳代码能正常运行。等壳代码把目标程序解密完,再去修复目标程序的重定位。这种方法比较麻烦,但可以保留目标程序的随机基址功能。
2.9 关于目标程序的导入表
要是加密或者压缩操作把目标程序的导入表弄坏了,加壳后的程序运行时,系统就没办法填充导入地址表(关于系统为什么要填充导入地址表,可以参考《windows编程原理》里PE文件的部分)。
这种情况下,在把程序控制权交给目标程序之前,得先填充好它的导入表。填充的方法不难,原始的导入地址表(IAT)里存的是函数名,之前我们讲过怎么根据函数名来获取函数地址,把获取到的函数地址填到对应的位置就可以了。
2.10 关于动态加解密
动态加解密是一种保护强度比较高的壳技术,它能在目标程序运行的时候,对代码段进行动态的加解密操作。程序先运行一部分代码,接着把后面一部分代码解密,同时把已经运行过的部分加密。等运行完解密后的代码,再去解密更后面的代码,并且把前面的代码又加密起来。这样一来,调试的人就只能看到正在运行的代码附近的几条指令。
这有点像Hook技术,我们可以在目标程序里设置多个Hook点,让程序在运行过程中能通过这些Hook点回到壳代码那里,进行动态加解密。我们可以试着通过修改指令、设置异常处理等办法来实现这个功能。
2.11 关于TLS的处理
TLS指的是线程局部存储。有些加壳程序对很多程序区段做了变形处理,这就使得系统找不到目标程序的TLS段,也就没办法调用TLS段里的回调函数。这样一来,那些依赖TLS回调函数才能运行的程序就跑不起来了。
处理TLS的办法挺简单,在程序执行到入口点之前,挨个调用TLS表中的函数就行。要找TLS表的话,可以参考windows编程原理的PE文件那部分内容。
三、实现一个壳的步骤
实现一个壳,大概要按下面这些步骤来做:
(1)先拿到目标文件的PE信息。
(2)再获取Stub文件的PE信息,把必要的信息设置到Stub里。
(3)往被加壳的程序里添加Stub代码段,具体操作如下:
- 读取Stub代码段。
- 修复Stub段里的代码。
- 把被加壳程序的原始入口点(OEP)改一下,让它指向Stub。
(4)从简单到复杂一步步实现功能:
- 在目标程序里添加新的区段,要考虑这些参数:
- 区段的名字
- 区段的大小
- 区段里面装什么内容
- 区段的属性
- 让目标程序先执行壳代码,执行完什么都不做就跳回,而且程序不能报错。关键步骤有:
- 修复壳代码的重定位问题。
- 算出新的入口点(OEP)并设置好。
- 把原始的入口点(OEP)存到壳代码里,这样壳代码运行的时候就能找到原程序的入口。
- 对目标程序的代码段进行简单加密,在解壳的时候能把代码解密,让程序正常运行。
- 对目标程序进行压缩,壳代码负责解压缩,让目标程序没办法被静态分析,但还能正常运行且不报错。具体要注意:
- 选个合适的压缩库。
- 因为壳代码会用到API函数,所以得动态获取API。
- 注意目标程序的代码段默认是不可写的,解压缩或者解密的时候要把它设置成可读、可写、可执行。
四、Windows PE 文件壳的实现例子
在软件保护领域,Windows PE 文件壳技术发挥着关键作用。它能够对目标程序进行保护,增加程序被破解的难度。下面例子包含PE 文件壳的实现功能、附加功能、加壳流程、压缩流程、重定位原理、导入表处理等。
该代码例子实现主要特点:
(1)加密保护:
使用异或加密算法对代码段进行加密
使用随机密钥增加破解难度
(2)压缩保护:
使用aP压缩算法对代码段进行压缩
减小文件体积并增加逆向难度
(3)反调试保护:
检测调试器存在
检测硬件断点
使用VEH异常处理
(4)重定位处理:
处理PE文件的重定位信息
确保加壳后的程序能正确加载
(5)导入表处理:
保存原始导入表信息
在运行时重建导入表
这个加壳程序采用了多层保护机制,包括加密、压缩、反调试等,能有效防止程序被逆向分析。同时,它通过stub代码在运行时进行解密和解压缩,确保程序能正常运行。
4.1 实现功能
(1)向目标程序添加代码
该功能使得我们能够在目标程序中融入自定义的代码逻辑,为程序增添额外的功能或实现特定的保护机制。
(2)加密压缩代码段
对代码段进行加密压缩,不仅能减小程序体积,还能增强程序的安全性,让破解者难以直接分析代码。
(3)程序可运行
加壳后的程序要确保能够正常运行,保证原有功能不受影响。
(4)密码弹框
设置密码弹框,只有输入正确密码,程序才能正常执行,为程序增加了一层访问控制。
4.2 附加功能
(1)修复重定位
由于程序加载基址可能发生变化,修复重定位能保证涉及地址的代码在新的加载基址下正常运行。
(2)加密压缩
进一步增强对代码和数据的保护,提高破解难度。
(3)花指令与混淆
通过插入花指令和进行代码混淆,扰乱破解者的分析思路,增加逆向工程的难度。
(4)反调试与动态非对称加密
反调试功能可检测并阻止调试行为,动态非对称加密则在程序运行过程中动态加密和解密数据,提升安全性。
4.3 加壳后的程序执行流程
(1)保存寄存器环境
在执行壳代码前,先保存当前寄存器的状态,避免影响后续程序的执行。
(2)初始化壳需要的函数
为壳代码的运行初始化必要的函数,确保后续操作能够顺利进行。
(3)解压和解密代码和数据
将之前加密压缩的代码和数据进行解压和解密,恢复其原始状态。
(4)修复 IAT
导入地址表(IAT)可能在加壳过程中被修改,修复 IAT 以保证程序能够正确调用外部函数。
(5)修复重定位
根据新的加载基址,对涉及地址的代码进行重定位修复。
(6)恢复寄存器环境
恢复之前保存的寄存器状态,使程序能够继续正常执行。
(7)跳转到原始 OEP
跳转到原始程序的入口点,开始执行原始程序的代码。
4.4 加壳大概流程
(1)读取被加壳程序并分析 PE 信息
深入了解被加壳程序的结构和相关信息,为后续操作提供基础。
(2)拷贝 NT 头到 stub
将被加壳程序的 NT 头复制到 stub 中,确保 stub 与原程序的兼容性。
(3)加密代码段
对代码段进行加密处理,增强程序的安全性。
(4)设置 OEP 到新区段的 stub 入口函数
将被加壳程序的原始入口点(OEP)设置到新区段的 stub 入口函数,使得程序先执行壳代码。同时,去除被加壳程序的重定位属性。
(5)修复 stub 的重定位数据
在拷贝 stub 之前,对其重定位数据进行修复,确保在新的环境中能够正常运行。
(6)拷贝 stub.dll 的代码段到新区段
将 stub.dll 的代码段复制到被加壳程序的新区段中,完成壳代码的添加。
(7)压缩
对加壳后的程序进行压缩,减小程序体积。
(8)保存
将加壳并压缩后的程序保存到磁盘。
4.5 压缩流程
(1)获取.TEXT 段属性和缓冲区
通过文件偏移和文件大小定位到.TEXT 段的缓冲区,为后续压缩操作做准备。
(2)计算压缩大小并开辟缓冲区
利用 aplib 函数计算压缩后的大小,并开辟相应的缓冲区。
(3)进行压缩
使用 aplib 对缓冲区内容进行压缩。
(4)存储并对齐
将压缩后的数据存入目标缓冲区,并进行文件对齐操作。
(5)修改文件信息
修改文件大小、text 区段文件大小以及其他区段的 PointtoRawData。
(6)释放缓冲区
释放多余的缓冲区,节省系统资源。
4.6 重定位原理
在程序生成时,很多涉及地址的代码使用的是绝对的虚拟内存地址,这个地址是假设程序加载到 0x400000 时才有效的。当程序加载基址发生变化,新的加载基址与默认加载基址不同,涉及地址的代码就无法正常运行。
此时,需要对这些代码的操作数进行修改,公式为:新的加载基址 - 默认加载基址 + 指令中的操作数指针字节数。
4.7 整体加壳流程伪代码
// 加壳主流程
BOOL Pack(CString strFilePath) {
// 1. 打开目标PE文件
PeFileHandling pePacked;
if (!pePacked.open(strFilePath)) {
return FALSE;
}
// 2. 打开stub.dll(壳代码)
PeFileHandling peStub;
if (!peStub.open("stub.dll")) {
return FALSE;
}
// 3. 获取stub的.text段
IMAGE_SECTION_HEADER* pStubText = peStub.getSection(".text");
// 4. 在目标PE中添加新节
IMAGE_SECTION_HEADER* pNewScn = pePacked.addSection("TANGPACK", NULL, pStubText->SizeOfRawData);
// 5. 获取stub配置结构体地址
DWORD dwRvaStubConf = peStub.getProcAddress("g_stubConf");
StubConf* pStubConf = (StubConf*)(peStub.RvaToOffset(dwRvaStubConf) + peStub.getFileBuff());
// 6. 保存目标PE的NT头信息到stub配置中
pStubConf->ntheader = *pePacked.getNtHdr();
// 7. 加密代码段
pStubConf->dwTextSectionRva = pePacked.getSection(".text")->VirtualAddress;
pStubConf->dwTextSectionSize = pePacked.getSection(".text")->Misc.VirtualSize;
pStubConf->key = Uncode1(pePacked.getSection(".text")->PointerToRawData + pePacked.getFileBuff(),
pePacked.getSection(".text")->Misc.VirtualSize,
rand() % 255);
// 8. 设置新的入口点
DWORD dwStubOep = peStub.getProcAddress("start");
dwStubOep -= pStubText->VirtualAddress;
dwStubOep += pNewScn->VirtualAddress;
pePacked.setOep(dwStubOep);
// 9. 禁用动态基址
pePacked.getOptionHdr()->DllCharacteristics &= (~0x40);
// 10. 修复stub重定位
peStub.fixStubRva(peStub.getOptionHdr()->ImageBase,
pePacked.getOptionHdr()->ImageBase,
pStubText->VirtualAddress,
pNewScn->VirtualAddress);
// 11. 将stub代码段复制到新节
pePacked.setSectioData(pNewScn,
(void*)(pStubText->PointerToRawData + peStub.getFileBuff()),
pStubText->SizeOfRawData);
// 12. 压缩代码段
pePacked.compress();
// 13. 保存加壳后的文件
pePacked.saveAs((strFilePath + CString("_pack.exe")).GetBuffer());
return TRUE;
}
// 壳代码执行流程
void start() {
// 1. 获取API函数地址
GetAPIs();
// 2. 反调试检查
CheckDebugger();
if (isVehHardware()) {
MessageBox(0, 0, L"正在被调试", 0);
}
// 3. 解压缩代码段
decompress();
// 4. 解密代码段
decrypt();
// 5. 跳转到原始入口点
jmp to original OEP;
}
4.8 程序运行截图
加壳程序运行界面:
选择文件中选择需要加壳的exe文件,点击加壳,运行成功弹出如下消息弹框
运行被加壳后的exe文件会出现如下:
输入正确密码便能正常执行:
4.9 后续优化工作
(1)调试随机基址重定位问题
确保在随机基址的情况下,程序的重定位功能能够正常工作。
(2)调试 TLS 处理问题
处理好线程局部存储(TLS)相关问题,保证依赖 TLS 回调函数运行的程序能够正常执行。
(3)加密 IAT 并修复
对导入地址表(IAT)进行加密处理,同时确保在运行时能够正确修复。
(4)实现边运行边解密和运行完加密
增强程序的安全性,让破解者难以获取完整的代码信息。
(5)实现全部区段压缩加密
对程序的所有区段进行压缩加密,进一步提高程序的安全性。
4.10 收获
通过本次对 Windows PE 文件壳的研究与实现,我们进一步熟悉了 PE 文件的格式和数据结构,掌握了如何对 PE 文件进行操作。同时,深入了解了壳的制作流程和实现技术,为软件保护领域的学习和实践积累了宝贵的经验。输入正确密码后程序能正常执行,这也验证了我们所实现的功能的有效性。
总之,Windows PE 文件壳技术是一个充满挑战和机遇的领域,后续我们将继续深入研究和优化,为软件安全提供更强大的保障。
完整代码参考:https://download.csdn.net/download/linshantang/90530484