基于PE程序加壳的软件保护技术的设计与实现


作为本科毕设,这次信息保护理论与技术课上又讲了一次,故进行一波简单的总结。

0x01 加壳的概念

I. 什么是加壳

•壳是是一种将可执行程序文件或动态链接库文件的编码进行改变,以达到缩小文件体积或增强程序安全性的目的。

•通过修改原程序的组织结构,从而能够比源程序代码更早获得程序控制权,类似于病毒的做法,但是不会影响源程序的正常执行。

II. 加壳的意义

•压缩程序大小,减小占用体积。

•为反破解反盗用奠定基础。

对企业对个人都为代码保护带来了巨大的便利。

•软件开发者可以专注于产品功能开发完善,只需要最后对软件进行加壳就能够增强安全性。

III. 加壳的战略

随着计算机技术的不断发展,面向各种应用需求的软件不断地产生,但无论哪种优秀的软件,其内部核心的技术往往是该软件的命脉,一旦被他人窃取或非法复制,受到的经济损失都是无法估计的。因此,软件开发者在发布软件前通常需要对软件进行加密保护。软件保护的相关技术发展到现在,已经越来越成熟,其中包含的方法、手段、内容越来越多,如现在很多软件 使用的序列号、加密狗、KeyFile、功能限制、时间限制。

但要注意的是,没有破不了的壳,无论针对新手还是高手,都是堆叠破解需要的时间成本来劝退恶意人士。

在以前,为了不影响加壳后对程序运行效率产生影响,加壳的手段常常是能想不能用。目前值得乐观的是,随着现代硬件设备的迅速发展,为加壳预留的空间变多了,这意味着今后脱壳所需的技术成本和时间成本将大大增加。

0x02 PE结构

PE结构已经在之前的文章讲解过,这里简单说明一下。

  • PE结构里含有许多关于文件的重要信息,为了达成加壳的目的,我们只需要其中某些信息,同时隐蔽不必要的信息。
  • 需要了解PE结构加载的机制,因为程序在加壳后被运行时(PE被修改后),会让系统帮解壳程序装载运行环境,而我们的解壳程序则需要为源程序恢复必要的运行环境。所以程序被运行时,PE哪些地方会被用到,怎么被使用,我们需要了然于心。

0x03 加壳系统设计

I. 总体设计

II. 加壳程序设计

III. 解壳程序设计

IV. 关键难点问题

1.选择文件载入方式

  • 加载源文件方式就有两种形式:

    一种是根据文件保存在磁盘的格式读取到内存;

    另一种是模拟文件在虚拟内存中的形态来加载读取。

  • 使用第一种方式的优点是加载简单快捷,但是加入壳代码节需要转换地址计算相对位置。

    使用第二种方式在内存中将文件内容进行伸展,之后在对PE文件进行操作的时候不需要再进行转化地址的操作,只需在最后保存文件时进行文件对齐保存即可。

    所以大部分时候使用的是后者的载入方式,加载文件和保存文件的时候进行一次统一的操作即可。

关键代码如下

//获取PE头信息,直接拷贝
for (int i = 0; i < nNumerOfSections; i++)
{
        if ((0 == pSection->VirtualAddress) ||
                (0 == pSection->SizeOfRawData))
        {
                pSection++;
                continue;
        }
        chSrcMem = (char*)((DWORD)pFileBuff + pSection->PointerToRawData);
        chDestMem = (char*)((DWORD)chBaseAddress + pSection->VirtualAddress);
        dwSizeOfRawData = pSection->SizeOfRawData;
        RtlCopyMemory(chDestMem, chSrcMem, dwSizeOfRawData);
        pSection++;
}

2.加壳程序需要为解壳程序收集哪些信息

  • 加壳程序会将解壳程序与处理后的源程序整合,加壳后加壳程序已经脱离运作流程,而我们需要它在离开流程之前为解壳程序提供一些源程序本来的信息。
  • 从技术层面上来讲,解壳程序自己是可以通过解析内存态文件来获取源程序的PE结构信息的;但从工程上来讲,考虑到时间和空间效率,为了尽可能不影响源程序运行的效率,我们让要解壳程序来完成信息收集的工作。

举个自定义结构体的例子:

(1)解壳程序启动函数地址
加壳程序保管,以便加壳程序替换入口函数为解壳程序的函数地址。
(2)源程序入口点
提供给解壳程序,支撑解壳程序结束后转交控制权给程序。
(3)代码恢复信息
提供给解壳程序,支撑解壳程序凭借信息将代码段复原,通常包括密钥、变形区段信息等。
(4)PE文件的映像基址
支撑解壳程序恢复源程序运行环境。
(5)重定位表信息
支撑解壳程序恢复源程序重定位表。
(6)IAT表信息
支撑解壳程序恢复源程序IAT表。
(7)附加信息
支持比如是否显示弹窗等功能提示信息。

记得导出结构体

//导出ShellData结构体
extern"C"  typedef struct _SHELL_DATA
{
        DWORD dwStartFun;                                                       //启动函数
        DWORD dwPEOEP;                                                          //程序入口点
        DWORD dwXorKey;                                                         //解密KEY
        DWORD dwCodeBase;                                                       //代码段起始地址
        DWORD dwXorSize;                                                        //代码段加密大小
        DWORD dwPEImageBase;                                            //PE文件映像基址
        IMAGE_DATA_DIRECTORY    stcPERelocDir;          //重定位表信息
        IMAGE_DATA_DIRECTORY    stcPEImportDir;         //导入表信息
        DWORD                                   dwIATSectionBase;       //IAT所在段基址
        DWORD                                   dwIATSectionSize;       //IAT所在段大小
        BOOL                                    bIsMesBoxShown;         //是否显示MessageBox
}SHELL_DATA, *PSHELL_DATA;
//导出ShellData结构体变量
extern"C" SHELL_API SHELL_DATA g_stcShellData;

3.整合解壳程序和源程序

目标:
从壳系统设计上来说,我们想让程序运行时,系统会为解壳程序准备运行环境。
需要做的事:
重定位修复还原工作、OEP修改为解壳程序

这一步操作需要非常谨慎,否则很可能造成程序加壳后无法被系统PE装载器正常加载,让源程序变得无法正常运行。
我们加载解壳程序时,重定位表的信息是已经修复过的正确的重定位信息,而我们想要的是原始的重定位信息。如果不进行重定位修复复原,那么程序运行时将会重复还原工作,所有需要重定位的代码位置都会出错,非常尴尬,所以必须要还原。

细节就省略了,想要了解需要仔细读项目代码,简单来说看后面重定位表修复方式,然后逆向做一遍即可。

//4.将Shell附加到PE文件
//4.1.读取Shell代码
MODULEINFO modinfo = { 0 };
GetModuleInformation(GetCurrentProcess(), hShell, &modinfo, sizeof(MODULEINFO));
PBYTE  pShellBuf = new BYTE[modinfo.SizeOfImage];
memcpy_s(pShellBuf, modinfo.SizeOfImage, hShell, modinfo.SizeOfImage);
//4.2.设置Shell重定位信息
objPE.SetShellReloc(pShellBuf, (DWORD)hShell);  
//4.3.修改被加壳程序的OEP,指向Shell
DWORD dwShellOEP = pstcShellData->dwStartFun - (DWORD)hShell;
objPE.SetNewOEP(dwShellOEP);
//4.4.合并PE文件和Shell的代码到新的缓冲区
LPBYTE pFinalBuf = NULL;
DWORD dwFinalBufSize = 0;
objPE.MergeBuf(objPE.m_pFileBuf, objPE.m_dwImageSize,
        pShellBuf, modinfo.SizeOfImage, 
        pFinalBuf, dwFinalBufSize);

4.修复导入表

程序在被加壳后,其导入表如果被我们变形破坏,系统无法再为我们填充IAT的函数地址内容,这样的话程序会无法正常调用需要使用的导入函数。所以我们需要模仿系统PE装载器,将源程序所需要使用的导入函数的地址填充到IAT表中,以便源程序在运行时能够正常调用函数。导入表结构如下图所示,想要修复导入表,需要去了解导入表的工作方式,这在PE结构文章中已经介绍过,这里不再赘述。

导入方式分名称导入和序号导入两种,我们需要分情况处理。区别的方式通过u1联合体的最高位是否为1来决定。

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE 
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

//名称导入
typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;                 //编译器决定,不是空的话,就是函数在导出表中的 函数地址表的导出索引.
    CHAR   Name[1];               //函数名称,0结尾.
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

//通过IAT循环获取该模块下的所有函数信息
while (pIAT->u1.AddressOfData)
{
        if (IMAGE_SNAP_BY_ORDINAL(pIAT->u1.Ordinal))//判断是输出函数名还是序号(1){
                DWORD dwFunOrdinal = (pIAT->u1.Ordinal) & 0x7FFFFFFF;//输出序号
                DWORD dwFunAddr = g_pfnGetProcAddress(hMod, (char*)dwFunOrdinal);
                *(DWORD*)pIAT = (DWORD)dwFunAddr;
        }
        else{
                DWORD dwFunNameRVA = pIAT->u1.AddressOfData;//输出函数名
                PIMAGE_IMPORT_BY_NAME pstcFunName = 
(PIMAGE_IMPORT_BY_NAME)(dwImageBase + dwFunNameRVA);
                DWORD dwFunAddr = g_pfnGetProcAddress(hMod, pstcFunName->Name);
                *(DWORD*)pIAT = (DWORD)dwFunAddr;
        }
        pIAT++;
}

5.修复重定位表

我们想要对源程序进行重定位的话,就需要源程序的重定位表信息,以及源程序的ImageBase字段的值。而在我们加壳主程序中通过解壳程序的导出结构体,已经将这些关键信息提供给解壳程序了,所以我们只需要模拟重定位的方法,对源程序进行重定位即可。

重定位表结构如下图所示,想要修复重定位表,需要去了解重定位表的工作方式,这在PE结构文章中已经介绍过,这里不再赘述。

记得重定位表是按照一个物理页 (4kb) 进行存储的。也就是一个 4kb 内存,有一个重定位块,一个重定位表只管自己当前的物理页的重定位.

TypeOffset偏移是 2 个字节存储
12 位存储偏移。高 4 位存储是否进行重定位,高 4 位为 3 则需要进行重定位.
低 12 位 就是要修正的 RVA 偏移
也就是高四位为 3 ,VirtualAddress + 低 12 位偏移就等于真正要修复的 RVA 例如 36b0 高位为 3 低 12 位就是 6b0 要修复的 RVA = VirtualAddress + 6b0
加上当前 DLL 的 ImageBase 才是真正要修复的虚拟地址 (VA)

关键代码如下所示

typedef struct _IMAGE_BASE_RELOCATION {
    DWORD   VirtualAddress;
    DWORD   SizeOfBlock;         //存储的值以字节为单位.字节多大.表示了一个重定位快有多大.
//  WORD    TypeOffset[n];
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

//2.2修复重定位
PTYPEOFFSET pTypeOffset = (PTYPEOFFSET)(pPEReloc + 1);
DWORD dwNumber = (pPEReloc->SizeOfBlock - 8) / 2;
for (DWORD i = 0; i < dwNumber; i++)
{
        if (*(PWORD)(&pTypeOffset[i]) == NULL)
                break;
        //RVA
        DWORD dwRVA = pTypeOffset[i].offset + pPEReloc->VirtualAddress;
        //VA地址
        DWORD AddrOfNeedReloc = *(PDWORD)((DWORD)dwImageBase + dwRVA);
        *(PDWORD)((DWORD)dwImageBase + dwRVA) = 
                AddrOfNeedReloc - g_stcShellData.dwPEImageBase + dwImageBase;
}
//2.4修复下一个区段
pPEReloc = (PIMAGE_BASE_RELOCATION)((DWORD)pPEReloc + pPEReloc->SizeOfBlock);

0x04 加壳系统评估

自己写的壳,没啥标志,查壳工具只知道加壳了,当然不知道加了什么壳。自然也就逃避了许多自动脱壳工具。当然具体能多快被脱壳还是要取决于我们写的壳的强度。

怎么提升壳的强度呢?

分析手段分为静态分析和动态分析,我们要对两方面都下手。

静态分析上可以花指令混淆代码,干扰IDA等自动化反编译工具

动态分析上,要加入多种反调试手段来尽可能预防被调试。

解壳算法尽量不要一次性解壳,防止一次性被Dump内存,暴露得一干二净。

目前强度最高的和最普遍使用的是基于虚拟机的加壳,非常恶心脱壳玩家。

程序被加密的关键部分没法避开去研究分析虚拟机在干什么。

当然一个商业壳,用的人越多,去研究它的人也越多,其安全性也越低。

所以能自己写壳是还是非常美丽而值得赞扬的事情,希望学习二进制的大家能够用心坚持钻研下去。


Author: Luluting
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Luluting !
 Previous
冒冒图色脚本记录 冒冒图色脚本记录
过去两年的时间里冒冒的时间占了一大半和朋友们从国际服玩到日服再到东南亚服再到私服 肝起来可以说是一天48小时都完全不够用,可谓是精神鸦片。好在现在弃坑了回归正常人的生活,将自己以前写的冒冒脚本的一些渣代码拿出来分享分享,以供纪念。
2021-11-21
Next 
PE结构相关 PE结构相关
整理一波PE结构
2021-11-21
  TOC