摘要:所谓SMC(Self Modifying Code)技术,就是一种将可执行文件中的代码或数据进行加密,防止别人使用逆向工程工具(比如一些常见的反汇编工具)对程序进行静态分析的方法,只有程序运行时才对代码和数据进行解密,从而正常运行程序和访问数据。计算机病毒通常也会采用SMC技术动态修改内存中的可执行代码来达到变形或对代码加密的目的,从而躲过杀毒软件的查杀或者迷惑反病毒工作者对代码进行分析。由于该技术需要直接读写对内存中的机器码,所以多采用汇编语言实现,这使得很多想在自己的程序中使用SMC技术进行软件加密的C/C++程序员望而却步。针对这种现状,本文提出了几种基于C/C++语言的机器指令定位方法,从而用C/C++语言实现了动态代码修改技术。
一、什么是SMC技术
所谓SMC(Self Modifying Code)技术,就是一种将可执行文件中的代码或数据进行加密,防止别人使用逆向工程工具(比如一些常见的反汇编工具)对程序进行静态分析的方法,只有程序运行时才对代码和数据进行解密,从而正常运行程序和访问数据。计算机病毒通常也会采用SMC技术动态修改内存中的可执行代码来达到变形或对代码加密的目的,从而躲过杀毒软件的查杀或者迷惑反病毒工作者对代码进行分析。现在,很多加密软件(或者称为“壳”程序)为了防止Cracker(破解者)跟踪自己的代码,也采用了动态代码修改技术对自身代码进行保护。以下的伪代码演示了一种SMC技术的典型应用:
proc main:
............
IF .运行条件满足
CALL DecryptProc (Address of MyProc);对某个函数代码解密
........
CALL MyProc ;调用这个函数
........
CALL EncryptProc (Address of MyProc);再对代码进行加密,防止程序被Dump
......
end main
在自己的软件中使用SMC(代码自修改)技术可以极大地提高软件的安全性,保护私有数据和关键功能代码,对防止软件破解也可以起到很好的作用。但是,SMC技术需要直接读写对内存中的机器码,需要对汇编语言和机器码有相当的了解,具体的实现一般都是采用汇编语言。由于汇编语言晦涩难懂,不容易掌握,这使得很多想在自己的程序中使用SMC技术进行软件加密的C/C++程序员望而却步。难道只能用汇编语言实现SMC技术?其实不然,从理论上讲,只要支持指针变量和内存直接访问,象C/C++这样的高级语言一样可以使用SMC技术。本文就是利用C/C++语言的一些特性,比如函数地址和变量地址直接访问等特性,实现了几种对运行中的代码和数据进行动态加密和解密的方法。首先是利用Windows可执行文件的结构特性,实现了一种对整个代码段进行动态加密解密的方法;接着又利用C/C++语言中函数名称就是函数地址的特性,实现了一种对函数整体进行加密解密的方法;最后采用在代码中插入特征代码序列,通过查找匹配特征代码序列定位代码的方式,实现了一种对任意代码片断进行解密解密的方法。下面就分别介绍这几种方法。
二、对整个代码段使用SMC方式加密解密
在程序中使用SMC最简单的方法就是修改(或加密)整个数据段或代码段,这里首先要讲一下“段”的概念。这个“段”有两层含义,第一层含义是程序在内存中的分布,老的16位操作系统对内存使用分段映射的方式,使用不同的段分别存放代码、数据和堆栈,使用专用的基址寄存器访问这些段,于是就有了代码段、数据段和堆栈段等等区分。随着32位Windows的兴起,一种新的32位平坦(Flat)内存模式被引入Windows内存管理机制,在平坦模式下对段的区分已经没有意义了,但是段的概念依然被保留下来,这些同名的基址寄存器现在被成为“段选择器”,只是它们的作用和普通的寄存器已经没有区别了。段的另一层含义是指保存在磁盘上的Windows可执行文件中的数据结构(就是PE文件中的Section),是Windows在装载这个可执行文件时对代码和数据定位的参考。不过要真正理解段的概念,还需要了解Windows
可执行文件的结构和Windows将可执行文件加载到内存中的方式。
Microsoft为它的32位Windows系统设计了一种全新的可执行文件格式,被成为“Portable Executable”,也就是PE格式,PE格式的可执行文件适用于包括Windows
9X、Windows NT、Windows 2000、Windows XP以及Windows 2003在内的所有32位操作系统,估计以后的Windows新版本也将继续支持PE格式。PE文件格式将文件数据组织成一个线性的数据结构,图2-1展示了一个标准PE文件的映象结构:
图2-1 Windows PE文件映像结构
位于文件最开始部位的是一个MS-DOS头部和一段DOS stub代码,在PE文件中保留这一部分是为了DOS和Windows系统共存那一段时期设计的,当程序运行在DOS系统时,DOS系统按照DOS可执行文件的格式调用DOS
stub代码,一个典型的DOS stub代码就是在控制台上输出一行提示:“This program cannot be run
in MS-DOS mode”,当然不同的编译器产生的DOS stub代码也各不相同。曾经有一段时间很流行一种既可以在DOS系统上运行,又可以在Windows上运行的程序,其原理就是人为地替换这段DOS
stub代码。紧跟在DOS stub代码之后的就是PE文件的内容了,首先是一个PE文件标志,这个标志有4个字节,也就是“PE\0\0”。这之后紧接着PE文件头(PE
Header)和可选头部(Optional Header,也可以理解为这个PE文件的一些选项和参数),这两个头结构存放PE文件的很多重要信息,比如文件包含的段(Sections)数、时间戳、装入基址和程序入口点等信息。这些之后是所有的段头部,段头部之后跟随着所有的段实体。PE文件的尾部还可能包含其它一些混杂的信息,包括重分配信息、调试符号表信息、行号信息等等,这些信息并不是一个PE文件必须的部分,比如正常发布的Release版本的程序就没有调试符号表信息和行号信息,所以图2-1
表示的结构图中省略了这些信息。
在整个头结构中,我们关心的仅仅是各个段的段头部,因为段头部包含这个段在文件中的起始位置、长度以及该段被映射到内存中的相对位置,在对内存中的代码修改时,需要这些信息定位内存读写地址和读写区域长度。下面来看看winnt.h中对段首部的定义,
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
在这个头结构中我们关心的是Name、VirtualSize、VirtualAddress和Characteristics四个属性。Name是这个段的名称,长度是8个字节,段名称一般以“.”开始,如“.text”,“.data”等等,但是这并不意味着段名称必须以“.”开始,这只是Microsoft的编译器的一个约定,很多编译器并不遵循这个约定。段名称对直接修改内存代码和数据是一个很重要的属性。因为在内存中定位段头部是通过搜索这个Name字符串来实现的。VirtualSize是一个段的真实长度,它有别于SizeOfRawData,SizeOfRawData是文件对齐后的长度,通常PE文件是以200H字节对齐的,所以SizeOfRawData是200H的整数倍。但是被Windows装入内存中就不一定是按照200H字节对齐了,所以要用VirtualSize来确定段的长度。VirtualAddress是这个段在内存中的相对虚地址(RVA),这个相对虚地址加上程序加载的基地址就是这个段在内存中的真正地址。最后是段属性Characteristics,操作这个段属性的目的是为这个段增加可写入的属性,因为Windows不允许向一个只读的段写数据。段属性由一些标志位组成,各个常用标志位的含义以及它们的值如下表所示:
Flag |
意义 |
0x00000020 |
这是一个代码段 |
0x00000040 |
这个段包含已初始化数据 |
0x00000080 |
这个段包含未初始化数据 |
0x02000000 |
这个段的数据可被丢弃(EXE文件装载完成后,进程就不需要这些数据了) |
0x10000000 |
该段可以执行 |
0x20000000 |
该段为共享段 |
0x40000000 |
该段可读 |
0x80000000 |
该段可写 |
表 2-1常用段属性标志位
通常编译器生成的程序的代码段具有0x00000020、0x10000000和0x40000000属性,如果我们要修改代码段的代码,就需要为其添加0x80000000标志,否则会引起Windows报告非法访问的异常。
PE格式文件的使用,使得Windows加载可执行文件不用再象以前一样将可执行文件拆开,在内存中东一块西一块地放置,取而代之的是一种简单的加载方式,就是按照顺序将PE文件读取到内存中,这也使得加载到内存中的PE文件和存放在磁盘上的PE文件具有相似的结构,只是各个段因为对齐方式的不同而导致偏移位置略有不同,下图演示了这种差别:
图2-2 PE文件磁盘逻辑结构和那粗映象逻辑结构
上面只是简单介绍了PE文件的格式以及加载方式,如果想更加深入了解PE文件,可以查阅本文的参考文献[2],下面本文就通过一个简单的例子介绍一下如何通过直接访问内存实现对代码的动态加密和解密。首先要说明的是不能对编译器生成的默认代码段进行全代码段加密,这是很显然的,因为整个程序的入口代码也在默认代码段,如果对整个默认代码段加密,你将没有机会对其解密,从而造成程序加载运行失败。不同的编译器生成的默认代码名称是不一样的,一般Microsoft的编译器会将所有的代码放置在一个名为“.text”的默认代码段中,而Borland的编译器的默认代码段名为“CODE”,其它的编译器可能有其它的代码生成策略,不过有一点是相通的,就是不能对程序入口点所在的代码段实行整段加密。针对这种情况,本文介绍的策略就是将需要加密的重要代码或数据放置在一个单独的代码段中,然后通过内存查找定位到这个段并对其进行加密解密操作。首先是通知编译器在生成代码时生成一个新的代码段,并将我们指定的代码放置在这个代码段中,对于做到这一点,不同的编译器有不同的实现方法,本文的例子使用的编译器是Visual
C++,可以使用预编译指令#pragma为程序添加一个代码段。首先用VC的向导生成一个Win32应用程序框架,然后添加如下代码:
#pragma code_seg(".scode")
int CalcRegCode(const char *pszUserName, char *pCodeBuf, int nbufSize)
{
if(!pszUserName || !pCodeBuf)
return 0;
int nLength = strlen(pszUserName);
if(nLength <= 0 || nLength >= nbufSize)
return 0;
if(::IsBadReadPtr(pszUserName,nLength) || ::IsBadWritePtr(pCodeBuf,nbufSize))
return 0;
for(int i = 0; i < nLength; i++)
pCodeBuf[i] = pszUserName[i] + 1;//为了演示,仅仅是作个移位变换
pCodeBuf[nLength] = 0;
return nLength;
}
#pragma code_seg()
#pragma comment(linker, "/SECTION:.scode,ERW")
CalcRegCode()函数根据用户名生成一个合法的注册码,这是一个应该受到重点保护的函数,所以要对其进行加密,此处的CalcRegCode()函数代码非常简单,只是为了演示之用,其功能就是把用户名向后移一位形成注册码。#pragma
code_seg(".scode")指令是告诉编译器为程序生成一个名为“.scode”的代码段,另一个不带参数的预编译指令#pragma
code_seg()告诉编译器此处是新代码段的结束位置,这两个预编译指令之间的代码将被编译器放置在这个名为“.scode”的新代码段中。段的名称“.scode”可以根据自己的意愿随意命名,但是长度(不包括结尾的\0结束符)不能超过8个字节,这是由Windows
PE文件的结构所决定的。最后一行#pragma comment(linker, "/SECTION:.scode,ERW")是告诉链接程序最终在生成代码时添加这个名为“.scode”的代码段,段属性为“ERW”,分别表示可执行、可读和可写。也可以不使用预编译指令#pragma
comment,直接在编译选项中添加“/SECTION:.scode,ERW”选项也可以达到相同的目的。现在编译这个程序,使用PE文件查看工具可以看到程序中已经有了一个名为“.scode”的代码段,段属性为0xE0000020,也就是0x00000020(代码段)、0x10000000(可执行)、0x40000000(可读)和0x80000000(可写)四个属性的组合。
图2-3 演示程序的Section Table
有了新的可读写代码段之后的问题就是如何在程序运行期间定位到这个段的位置,并对其进行修改,这就需要知道PE文件加载以后在内存中的位置。当一个可执行程序被Windows加载以后,Windows的虚拟内存管理机制就为其映射了一个单独的4GB内存空间(当然应用程序只能使用其中的一部分,另一部分被操作系统占用),应用程序中的地址都被映射到这个虚拟的内存空间中,整个PE文件被映射到这个虚拟空间的某一段中,开始的位置就被称为映象基地址(Image
Base),这个地址当然也是一个“虚地址”(区别于在内存硬件中的真实地址)。Windows提供了一个API用于获得应用程序的基地址,这个API就是GetModuleHandle(),它的函数原型是:
HMODULE GetModuleHandle(LPCTSTR lpModuleName);
参数lpModuleName用于指定模块的名字,如果是获得当前可执行文件加载的基地址,只需传递一个NULL就可以了,返回值类型HMODULE看起来有些神秘,其实可以将其强制转换成一个void类型的指针使用,它指向的位置就是我们需要的基地址。找到映象基地址以后,就可以根据PE文件的结构依次遍历所有的Section(段)表,找到名为“.scode”的段,然后通过段表中的VirtualAddress属性得到“.scode”段在内存中的起始地址,实际上这个VirtualAddress只是相对于映象基地址的一个偏移量,,“.scode”段的真正位置要通过VirtualAddress加上映象基地址获得。“.scode”段的大小通过VirtualSize属性得到,这个大小是对齐前的大小,也就是全部代码的真正大小,不包括为对齐而填充的0字节。在前面对PE文件介绍的基础上,不难写出这个查找程序,下面就给出一个查找某个段的虚地址和大小的通用函数:
bool GetSectionPointer(void *pModuleBase,const char *lpszSection,void**
ppPos,LPDWORD lpSize)
{
IMAGE_DOS_HEADER *pDosHead;
IMAGE_FILE_HEADER *pPEHead;
IMAGE_SECTION_HEADER *pSection;
*ppPos = NULL;
*lpSize = 0;
if(::IsBadReadPtr(pModuleBase,sizeof(IMAGE_DOS_HEADER)) || ::IsBadReadPtr(lpszSection,8))
return false;
if(strlen(lpszSection) >= 16)
return false;
char szSecName[16];
memset(szSecName,0,16);
strncpy(szSecName,lpszSection,IMAGE_SIZEOF_SHORT_NAME);
unsigned char *pszModuleBase = (unsigned char *)pModuleBase;
pDosHead = (IMAGE_DOS_HEADER *)pszModuleBase;
//跳过DOS头不和DOS stub代码,定位到PE标志位置
DWORD Signature = *(DWORD *)(pszModuleBase + pDosHead->e_lfanew);
if(Signature != IMAGE_NT_SIGNATURE) //"PE\0\0"
return false;
//定位到PE header
pPEHead = (IMAGE_FILE_HEADER *)(pszModuleBase + pDosHead->e_lfanew
+ sizeof(DWORD));
int nSizeofOptionHeader;
if(pPEHead->SizeOfOptionalHeader == 0)
nSizeofOptionHeader = sizeof(IMAGE_OPTIONAL_HEADER);
else
nSizeofOptionHeader = pPEHead->SizeOfOptionalHeader;
bool bFind = false;
//跳过PE header和Option Header,定位到Section表位置
pSection = (IMAGE_SECTION_HEADER *)((unsigned char *)pPEHead + sizeof(IMAGE_FILE_HEADER)
+ nSizeofOptionHeader);
for(int i = 0; i < pPEHead->NumberOfSections; i++)
{
if(!strncmp(szSecName, (const char*)pSection[i].Name,IMAGE_SIZEOF_SHORT_NAME))
//比较段名称
{
*ppPos = (void *)(pszModuleBase + pSection[i].VirtualAddress);//计算实际虚地址
*lpSize = pSection[i].Misc.VirtualSize;//实际大小
bFind = true;
break;
}
}
return bFind;
}
虽然对CalcRegCode()函数做了很多手脚,但是在程序中对CalcRegCode()函数的使用方式和调用其它的函数没有区别,只是需要在调用之前对“.scode”段解密。由于本文介绍的方法需要较多的内存直接操作,特别是对程序要运行的代码进行读写操作,很可能会引起代码的异常,比如对代码解密失败将导致程序运行不可预料的指令,如果你不想让你的程序死的很难看,最好使用异常处理。以下就是对CalcRegCode()函数的使用方法:
try
{
bool bFind = GetSectionPointer((void *)hImageBase,".scode",&pSecAddr,&dwSecSize);
if(!bFind || !pSecAddr)
throw "Not find special section!";
//注意,解密和加密函数也是重要的函数,这两个函数的调用最好放在距离CalcRegCode()函数调用
//远一点的位置,避免被发现
DecryptBlock(pSecAddr,dwSecSize,0x5A);//首先解密代码段
CalcRegCode("system",szBuff,128);//调用注册码计算函数
EncryptBlock(pSecAddr,dwSecSize,0x5A);//调用后加密代码段
}
....//异常处理
到现在为止所有的动态准备工作已经做完,只差最后一道工序,那就是在程序生成之后对“.scode”代码段预先加密。由于编译器生成的代码是不加密的代码,为了使本文介绍的方法能够正常使用,必须手工对PE文件中的“.scode”段进行加密处理。本文的例子代码中有一个小程序CryptExe.exe,这是个命令行工具,可以加密指定PE文件的某个位置。剩下的工作就是在磁盘文件中定位“.scode”段的偏移位置。在磁盘文件中定位“.scode”段和在内存映象中定位“.scode”段的方法一样,也是查找Section表中的“.scode”段,然后通过段相应的属性定位这个段在文件中的偏移位置和大小(此时需要访问的属性是PointerToRawData和SizeOfRawData)。不过还有更简单的方式,那就是使用PE文件查看工具直接查看偏移位置和大小,以前面的Section
Table为例(图3),演示程序的“.socde”段在文件中的偏移位置是6000H,大小是1000H,换成成十进制分别是24576和4096,使用以下命令行就可以对演示程序进行初始加密:
CryptExe.exe CrkTest.exe 24576 4096
现在运行CrkTest.exe,会弹出一个OK消息框,显示的内容就是根据字符串“system”计算出来的注册码“tztufn”,如果在CrkTest.exe生成之后忘记了对其进行预先加密,就会出现一个Error消息框,显示错误信息。至此,就完整地实现了对真个代码段进行SMC加密解密的功能。
三、对整个函数体使用SMC方式加密
上一节本文介绍了一种动态加密代码的方法,就是在程序运行期间对整个代码段进行加密和解密操作,可以保护一些对软件防破解至关重要的代码,但是这样的方法也有一些弊端,那就是需要一个额外的代码段,这有点儿“此地无银三百两”的感觉,这个额外的代码段无疑会成为破解者重点“照顾”的对象。这一节本文将介绍一种对某个函数的代码进行加密解密的方法,这种方法不需要创建额外的代码段,使用上比较隐蔽,不易觉察。
对单个函数进行加密和对整个代码段加密的原理一样,也需要在内存映象和PE文件中定位代码的起始位置和代码块的大小,只是代码定位方式不同。首先介绍一下如何在程序的内存映象中定位函数的起始位置和函数代码块的大小。C/C++语言有一个特性,那就是函数名就代表函数的开始地址,所以根据函数名可以得到代码块在内存中的位置,剩下的问题就是如何确定函数代码块的大小,也就是如何找到函数的最后一条指令的位置。很不幸,对于这个问题除了直接查看汇编代码之外确实没有很完美的解决方法,不过,如果我仅仅说:去查看汇编代码吧,找到最后的ret指令就行了,那就太“不负责任”了,也违背了本文的初衷。“行走江湖”,第一招不行肯定要有“Plan
B”,备用方案当然是一些不太“完美”的方法,比如本文使用的方法就是计算与这个函数相邻的下一个函数的起始位置与这个函数的起始位置的差,这个差值就可大致认为是函数代码块的大小。尽管很多资料也都介绍了这种方法,但是这种方法的不完美性还是表现在以下两个方面:一方面是编译器不能保证两个C/C++代码相邻的函数在最终生成的机器代码中也是相邻的,没有任何编译器做了这个承诺,所以使用这种方法是有风险的。另一方面的不完美性是因为这种方法对函数有很多的约束,这种约束体现在编译器生成代码的策略上,很多资料对此都有特别的说明,比如函数中最好不要使用longjmp()之类的函数,也不要使用switch...case语句,当然更不能使用异常处理机制了,这是因为当代码中出现上述情况时,编译器不能保证生成的代码会在一个连续的代码块中,特别是异常处理这种情况。尽管这种方法有这样那样的不完美性,但它还是得到了广泛应用,因为对于第一个不完美性,除非出现意外情况,很多编译器都会尽力做到代码的连续性,至于第二个不完美性,只要巧妙地构造代码,避免上述语句的使用,同时合理设置if判断语句,缩减函数代码长度,就可以避免长跳转代码块的出现。看来,使用这种方法虽然不是十分安全,不过只要方法得当,也还是值得信赖的,作者在参与的几个软件加密项目中都使用了这种方法,目前都能够可靠地工作,所以,此处推荐使用这种简单的方法。
现在还是用一个例子来看看具体的效果吧。首先使用VC创建一个基于对话框的项目,然后将上一个例子中的CalcRegCode()函数复制到这个项目中,并紧跟其后添加一个空函数,函数类型和名称随便,比如:
void CalcRegCodeEnd();
然后在程序中就可以通过下面一行程序得到CalcRegCode()函数的长度:
int nFuncSize = ((char *)CalcRegCodeEnd - (char *)CalcRegCode);
不要急着编译运行这个程序,因为有个特殊情况需要了解,那就是这行代码只在Release版本的程序中才能得到正确的结果,因为Visual
C++编译器生成的Debug版本通常将一些调试信息放在函数代码开始之前,所以函数开始位置被转向到了一条跳转指令jmp(0xE9),这样VC的调试器就可以根据函数名定位到函数的调试信息,而这条跳转指令又能保证函数体代码被正确地执行,真是一举两得,但是也给我们的方法带来了小小的麻烦。不过既然知道原因,就不难想出对策,下面的代码就是针对这种情况做一下调整,通过简单的计算得到函数代码的真正开始位置:
char *pFuncAddr = (char *)CalcRegCode;
if(*((unsigned char*)pFuncAddr) == 0xE9)//判断是否是跳转指令
{
pFuncAddr++; //跳过0xE9指令
i =* (int *)pFuncAddr;//这个jmp指令的操作数,也就是跳转的距离
pFuncAddr += (i + 4); //修正到正确的位置,多加4是因为这个操作数也是4个字节
}
上面代码的判断依据就是函数的第一条指令通常不是跳转指令(除非程序已经被破解了),调整的方法上面注释已经有详细说明,此处就不再赘述。至此,函数在内存映象中的定位问题已经解决,剩下的事情就是如何定位函数开始位置在PE文件中的偏移量,以便我们的外部工具CryptExe.exe能够对其进行初始加密。不过,这次我们同样没有完美的解决方法,更糟糕的是,对于这个问题我们甚至连“不完美”的方法都没有,那为什么还要浪费时间写这些没用的东西?因为我们还有最后一条“救命稻草”,这根所谓的“救命稻草”就是:代码的内存映象地址和代码在PE文件中的偏移位置存在线性关系。在本文前面“对代码段加密”一节曾经提到Windows加载PE可执行文件的方式是一种简单的按照PE文件字节顺序的方式,加载到内存中的PE文件和存放在磁盘上的PE文件具有相似的结构,只是各个段因为对齐方式的不同而导致段的偏移位置略有不同,这也就是说,这种不同是指的段偏移位置不同,而代码在段内相对于段首的偏移量是不变的,当链接器生成可执行文件时,代码的段内偏移量就已经固定下来了,并且不会因为Windows加载可执行文件到内存中的不同位置而改变。也就是说,代码在内存映象中的地址与PE文件中的偏移量存在以下线性关系:
代码内存虚拟地址 - 代码段内存虚拟地址 = 代码文件偏移量 - 代码段的文件偏移
转换这个等式就可以得到文件偏移的计算公式:
代码文件偏移量 = 代码内存虚拟地址 - (代码段内存虚拟地址 - 代码段的文件偏移)
公式中的“代码段的文件偏移”就存在与段头部表中的PointerToRawData属性,在介绍PE文件的段头部结构时没有提到这个属性,这个属性给出了段的原始数据在文件中的开始位置,这也是段头部信息中一个很重要的属性。“代码段内存虚拟地址”可以通过段头部的VirtualAddress属性获得,前面已经介绍过,这个属性是一个相对虚地址,需要加上PE内存映象的基地址才是代码段的内存虚拟地址。有了这些信息,就可以很容易地根据虚拟地址计算出PE文件偏移位置,具体的算法和上一节介绍的GetSectionPointer()函数相似,就是遍历段头部信息表,找到代码段“.text”(这是VC编译器生成的默认代码段名称,如果是Borland的编译器,可能是“CODE”),然后根据PointerToRawData属性、VirtualAddress属性和程序的基地址计算出文件偏移位置,下面给出这个计算函数的代码:
int VAtoFileOffset(void *pModuleBase,void *pVA)
{
IMAGE_DOS_HEADER *pDosHead;
IMAGE_FILE_HEADER *pPEHead;
IMAGE_SECTION_HEADER *pSection;
if(::IsBadReadPtr(pModuleBase,sizeof(IMAGE_DOS_HEADER)) || ::IsBadReadPtr(pVA,4))
return -1;
unsigned char *pszModuleBase = (unsigned char *)pModuleBase;
pDosHead = (IMAGE_DOS_HEADER *)pszModuleBase;
//跳过DOS头不和DOS stub代码,定位到PE标志位置
DWORD Signature = *(DWORD *)(pszModuleBase + pDosHead->e_lfanew);
if(Signature != IMAGE_NT_SIGNATURE) //"PE\0\0"
return -1;
unsigned char *pszVA = (unsigned char *)pVA;
int nFileOffset = -1;
//定位到PE header
pPEHead = (IMAGE_FILE_HEADER *)(pszModuleBase + pDosHead->e_lfanew
+ sizeof(DWORD));
int nSizeofOptionHeader;
if(pPEHead->SizeOfOptionalHeader == 0)
nSizeofOptionHeader = sizeof(IMAGE_OPTIONAL_HEADER);
else
nSizeofOptionHeader = pPEHead->SizeOfOptionalHeader;
//跳过PE header和Option Header,定位到Section表位置
pSection = (IMAGE_SECTION_HEADER *)((unsigned char *)pPEHead + sizeof(IMAGE_FILE_HEADER)
+ nSizeofOptionHeader);
for(int i = 0; i < pPEHead->NumberOfSections; i++)
{
if(!strncmp(".text", (const char*)pSection[i].Name,5))
//比较段名称
{
//代码文件偏移量 = 代码内存虚拟地址 - (代码段内存虚拟地址 - 代码段的文件偏移)
nFileOffset = pszVA - (pszModuleBase + pSection[i].VirtualAddress
- pSection[i].PointerToRawData);
break;
}
}
return nFileOffset;
}
现在,已经得到了函数代码在文件中的偏移位置和大小,似乎可以收工庆祝了,不过如果你仔细研究可这种方法就会发现事情没有那么简单,这种方法除了前面提到的几个不够“完美”的手段之外,还存在一个重大“缺陷”。好吧,现在就坦白这个“缺陷”,那就是到,代码在文件中的偏移位置和大小是在自己的程序中计算得到的,外部加密工具CryptExe.exe如何得到这两个值?这确实是个棘手的问题,不过既然这种对函数代码的加密方式已经有这么多的“不完美”,也不在乎再多一个,本文采用的方法是给程序加一个隐蔽功能,这个隐蔽功能不向用户公开,开发者利用这个隐蔽功能获取这两个重要的参数。这看起来很“ungainly”,不过现在在软件中添加隐蔽功能的的软件也不少啊,想到这里应该可以心理平衡一点了吧。本文的例子程序将这个隐蔽功能添加到“关于...”对话框中,只要在打开“关于...”对话框的时候按住键盘右侧的“Ctrl”键,此时显示出来的关于对话框就会多两个控件,分别显示函数代码块在PE文件中的偏移位置和代码块长度,所用的方法就是通过计算函数地址得到函数代码块的长度,通过VAtoFileOffset()函数计算出函数代码块在PE文件中的偏移位置。本例程序运行结果如图3-1所示:
图3-1 通过“关于...”对话框获得文件偏移位置
然后就可以使用如下命令对例子程序CrkTest3.exe中的CalcRegCode()函数进行加密:
CryptExe.exe CrkTest3.exe 5584 112
下面就是加密后的CalcRegCode()函数在某反汇编工具中显示的代码,因为代码已经经过异或加密,而反汇编工具不知道,还按照加密后的机器码生成汇编代码,所以结果看起来很奇怪,当然也不能正常运行。
图3-2 加密后的CalcRegCode()函数代码
在程序中使用CalcRegCode()函数的方法和上一节介绍的方法一样,就是先调用DecryptBlock()对函数代码块解密,然后正常调用CalcRegCode()函数,最后再调用EncryptBlock()加密,当然,异常处理也是必不可少的。除此之外,还有一些细节需要注意,首先是代码段的读写问题,上一节的例子创建了一个单独的数据段,编译器创建这个段的时候就为其指定了“可写”属性,所以没有特别强调对段属性的修改,但是这一节的例子是直接修改“.text”段的代码,而这个段默认属性是只读的,所以需要考虑段属性的修改问题。有两种方法可以解决这个问题,一种是采用上一节介绍的方法,使用以下预处理指令为“.text”段添加“可写”属性:
#pragma comment(linker, "/SECTION:.text,ERW")
另一种方法是在程序生成以后使用peeditor之类的工具修改“.text”的段属性,涉及到具体的软件操作,这里就不再赘述。下面来介绍一下使用这种方法的注意事项,首先要注意的就是CalcRegCodeEnd()函数的设计,这个函数看似无关紧要,但是它却有一个检查加密是否越界的功能,最好不要将其设计成空函数,而是为其填充一些有意义的代码,这样就可以在隐蔽功能中调用这个函数,如果这个函数出现代码异常就说明通过函数地址方法计算出来的函数代码块大小不正确,导致这个函数受到破坏,这时就要重新设计CalcRegCode()函数的结构。另一个需要注意的地方是编译器的优化方式,很多C/C++编译器都提供了优化代码的功能,比如VC的编译器就有对生成代码大小和运行速度进行优化的选项,这里需要提醒大家的是尽量不要使用对可执行文件代码大小进行优化的选项,理由很简单,编译器对代码大小进行优化的时候可能影响到生成代码的连续性,本节提到的这种对函数代码块加密的方法很依赖于代码块的连续性,所以,尽量不要使用减少可执行文件大小的优化选项。最后一个需要注意的事项就是每次编译程序后都要重新通过隐蔽功能重新获得偏移位置,因为编译代码就涉及到重新生成机器代码,当然会影响到代码的位置。只要编译完成以后,这个偏移位置就固定下了,即使程序加载过程中被Windows重定位了,也不会影响到计算出来的偏移位置,因为本方法计算采用的内存虚拟地址就是根据程序的内存映象基地址计算出来的,基地址变了,这个虚拟地址也会跟着改变。况且很多EXE文件都没有地址重定位段,Windows默许这种情况出现就意味着Windows会尽力保证将程序加载到默认的基地址位置,不过对于DLL(动态链接库)来说就没有这么幸运了,它在加载的时候被重定位几乎是“家常便饭”,虽然从理论上讲DLL的加载方式和EXE的加载方式没有区别,但是本人依然不推荐在DLL中使用这种方法。
四、对代码块使用SMC方式加密
本节将在前面介绍的方法的基础上,再介绍一种直接对函数内的代码块(片断)进行加密的方法,这种方法将更加隐蔽,使破解者更难以定位。通过图3-2可以看出,加密后的函数代码与没有加密的函数代码有明显的不同,主要使函数入口部分的代码,通常正常的函数入口部分代码是栈操作,比如基地址寄存器(ebp)的值入栈,为局部变量预置空间等等,破解者看到如图3-2那样的函数代码,肯定可以判断出这个函数已经被做过手脚了。如果能够避开函数入口处和结尾部分的敏感代码,直接修改函数内部某个位置的代码片断,就可以极大地提高隐蔽性。直接对代码块加密的难点在于代码块的定位,本文前面介绍的两种方法可以通过段属性或函数地址定位到代码块的开始位置,但是直接对任意函数内的代码块进行加密,无论是在内存映象中还是在PE文件中都没有很好的方法可以用来定位代码块的开始位置。当然,没有好的方法并不等于没有方法,人们在实践中还是探索出了一些比较实用的方法。目前最广泛采用的方法是使用对某个特征代码序列的查找来定位代码块的开始位置,就是在程序设计的过程中,人为地构造一个特殊的代码序列,编译器会根据这个代码序列生成相应的机器码,然后就可以在内存映象中或文件中搜索一段机器代码序列,从而实现代码片断的定位。可见,这种方法的重点就是构造一个代码序列,所谓的代码序列就是一条或几条连续的代码,这些代码应该不具备一般性,也就是越特殊,程序的其它地方越不容易出现重复越好,目的是为了防止查找使出现多重匹配,不能唯一定位到人为构造的代码序列的位置。
以往的资料在介绍这种方法的时候多采用汇编语言为例子,构造特征代码序列也是使用汇编代码,这是因为从汇编语言到机器语言比较直观,而且基本上是一一对应,不会产生变形,同时也便于设计人员通过汇编代码手工翻译出机器代码。象C/C++这样的高级语言,编译器根据C/C++代码生成机器代码的过程中需要经过多个步骤,这中间有很多不确定因素都可能导致编译器不能产生预想的机器代码序列,这也就是大家都“不约而同”地采用汇编语言的原因。但是,这并不说明就不能使用C/C++构造特征代码序列,使用嵌入式汇编就是最简单的方法,如果对汇编语言不了解,或者担心嵌入的汇编代码影响了寄存器的正常使用,也可以简单地使用_emit指令在当前代码位置嵌入一些特殊的数据构成特征数据组,特征数据组和特征代码序列一样可以通过查找的方法定位代码块的位置。下面的例子就是使用嵌入式汇编在当前代码中添加了一个字符串“HelloWorld\0”:
__asm
{
_emit 'H';
_emit 'e';
_emit 'l';
_emit 'l';
_emit 'o';
_emit 'W';
_emit 'o';
_emit 'r';
_emit 'l';
_emit 'd';
_emit 0x00;
}
在文件中定位这个位置时就可以使用一些16进制编辑器在文件中查找这个特征字符串,找到开始位置后向后偏移11个字节就是代码块的开始位置。不过本节介绍的方法不使用嵌入式构造特征代码序列,而是利用C/C++语言中与汇编语言对应性最好的赋值语句实现了一种特征代码构造方法。
C/C++语言中将常数赋值给某个变量的简单赋值语句,通常可以被翻译成一条简单的汇编代码,以下面的C/C++代码为例:
DWORD dwSignVar = 0;//定义一个全局变量
dwSignVar = 0x5A5A5A5A;
这条赋值语句汇编成机器代码后就是:
mov DWORD PTR [AAAAAAAAH], 5A5A5A5AH
最终生成的机器码就是:C7 05 AA AA AA AA 5A 5A 5A 5A,C7 05是mov指令的机器码,紧跟其后的四个字节是mov指令的第一个操作数,就是变量的dwSignVar的地址AAAAAAAA,再后面的四个字节是mov指令的第二个操作数,也就是常数0x5A5A5A5A。如果在需要加密的关键代码块的开始位置和结束位置使用几条这样精心构造的赋值语句,就可以在关键代码块前后各形成一个比较长的特征代码序列,从而实现代码块的查找定位。这种使用C/C++语言构在特征代码序列的方法同样有很多需要注意的地方,首先是变量要使用全局变量,因为通常将全局变量安排在数据段,这样可以保证程序被加载到内存中执行的时候它的虚拟地址是固定的,这一点很重要,因为这个地址(就是上面例子中的AA
AA AA AA)是特征代码序列的重要组成部分,它必须是固定的,不随程序每次加载运行而改变。其次是赋值语句的使用数量问题,一般连续的一至两条赋值语句就可以了,如果太多反而会起副作用,这是因为编译器进行代码优化的时候为了对寄存器访问进行优化,通常会调整代码的顺序,这样就很可能在我们的赋值语句中间插入其它代码,从而影响特征代码序列。最后一点需要注意的是必须是直接常数赋值,这个常数不能使用变量替代,也就是说不能用一个值是0x5A5A5A5A的变量代替这个常数,因为那样会导致mov指令的第二个操作数发生变化,无法得到预期的mov指令。
剩下的问题就是如何定位代码块的位置以及如何将代码块的文件偏移位置显示出来。定位代码块的位置很简单,就是从一个指定的内存位置开始,搜索特征字符串,因为特征代码块一般是位于某个函数内部,所以通常从一个函数的开始位置搜索特征代码序列。例子中的函数FindCodeTag()就是负责在一块内存区域中定位特征代码序列的位置:
int FindCodeTag(void *pStartAddr, unsigned long *pTagLoc, unsigned
long lTagValue, int nSerachLength)
{
int nPos = -1;
int i = 0;
unsigned char *pAddr = (unsigned char *)pStartAddr;
while(i < nSerachLength)
{
if((*pAddr == 0xC7) && (*(pAddr + 1) == 0x05))//查找mov指令
{
unsigned long *Loc = (unsigned long *)((unsigned char*)pAddr + 2);
if(*Loc == (unsigned long)pTagLoc)//此处的数据*Loc就是全局静态变量的地址
{
unsigned long *Val = (unsigned long *)((unsigned char*)pAddr + 6);
if(*Val == lTagValue)//此处的数据*Val就是常数lTagValue值
{
nPos = i;
break;//find tag
}
}
}
pAddr++;
i++;
}
return nPos;
}
第一个参数pStartAddr是开始位置,第二个参数就是赋值语句中使用的全局变量的地址,第三个常数是赋值语句中常数的值,最后一个参数是搜索区间的长度,如果从pStartAddr开始超过nSerachLength长度的区域中没有找到特征代码序列,就返回-1表示没有找到,否则就返回特征代码序列现对于pStartAddr的偏移量。如果在某个函数内部使用了如下赋值语句作为特征代码序列:
void SomeFunction()
{
......
dwSignVar = 0x5A5A5A5A;
......//关键代码块
dwSignVar2 = 0x61616161;
}
那么就可以这样找到它的开始位置:
int nStartPos = FindCodeTag((void *)SomeFunction,&dwSignVar,0x5A5A5A5A,1000);//1000是个大致估计的值
nStartPos += 10;//10 是特征代码序列(也就是mov指令)的长度
返回值只是特征代码序列的开始位置,还要向后偏移10各字节(这条mov指令的长度)才是代码块的真正开始位置,这里的搜索长度1000只是一个估计值,也可以使用上一节介绍的方法通过函数地址差值计算出搜索长度的大小。同样的方法可以得到另一个特征代码序列的开始位置(也就是关键代码块的结束位置):
int nEndPos = FindCodeTag((void *)SomeFunction,&dwSignVar2,0x61616161,1000);
计算nEndPos和nStartPos的差值就是关键代码块的大小。从内存地址计算出文件偏移位置的方法和上一节介绍的方法一样,使用VAtoFileOffset()函数计算出这个偏移量,然后使用“关于...”对话框的隐蔽功能显示给软件开发人员。其它问题,比如异常处理、Debug版函数地址修正以及修改代码段的读写属性等等问题都已经在上一节介绍了,此处不再赘述,具体内容可参考CrkTest2的例子代码,演示程序CrkTest2的使用方法和上一节的例子程序CrkTest3类似,程序编译完成以后要使用CryptExe.exe对关键代码加密,否则会出现指令异常。
五、总结
本文介绍了三种使用SMC动态修改代码技术实现的代码加密方法,这些方法采用动静结合的方式,通过对可执行程序文件的静态加密,提高了程序反静态分析的能力,在运行过程中对装载到内存中的可执行程序代码进行动态修改,对动态反跟踪,反调试也很有帮助,如果能够在程序中合理地应用这些方法,可以提高软件的安全性,增加破解难度。本文还使用具体的例子程序演示了每种方法的具体使用,这里例子都是用C/C++语言实现,大大降低了程序员在自己的软件中使用这些技术的门槛。
关于演示程序代码
演示程序的代码有四部分组成,CryptExe是外部加密工具CryptExe.exe的源代码,CrkTest是第二节介绍的对整个代码段加密的演示程序,CrkTest3是对函数进行加密的演示程序,CrkTest2是对内部代码片断进行修改加密的演示程序,所有的代码都在VC6和Visual
Studio 2003下编译测试通过,演示程序的使用也很简单,首先编译生成演示程序(如果是CrkTest2和CrkTest3,请使用Release方式生成程序,如果要在Debug版本中使用,请参考本文第三节介绍的方法修正函数地址),然后按照本文介绍的方法使用CryptExe.exe工具加密生成的应用程序,最后就可以运行演示程序看结果了。对这些演示程序感兴趣的朋友还可以访问以下链接获取代码的最新修改和勘误:
http://blog.csdn.net/orbit/
参考文献
[1] 段刚.软件加密技术内幕.北京:电子工业出版社,2004.
[2] Matt Pietrek.Peering Inside the PE: A Tour of the Win32 Portable
Executable File Format.MSDN Magazine,1994
|