[普通]用C/C++实现代码的动态修改(SMC)

作者(passion) 阅读(1419次) 评论(0) 分类( 软件)

一、什么是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文件的映象结构:

位于文件最开始部位的是一个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文件具有相似的结构,只是各个段因为对齐方式的不同而导致偏移位置略有不同,下图演示了这种差别:

 

     上面只是简单介绍了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(可写)四个属性的组合。

 

     有了新的可读写代码段之后的问题就是如何在程序运行期间定位到这个段的位置,并对其进行修改,这就需要知道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判断语句,缩减函数代码长度,就可以避免长跳转代码块的出现。看来,使用这种方法虽然不是十分安全,不过只要方法得当,也还是值得信赖的,作者在参与的几个软件加密项目中都使用了这种方法,目前都能够可靠地工作,所以,此处推荐使用这种简单的方法。


« 上一篇:wifi共享上网(至尊版wifi)
« 下一篇:PE文件详解三:节表(区块表)IMAGE_SECTION_HEADER结
在这里写下您精彩的评论
  • 微信

  • QQ

  • 支付宝

返回首页
返回首页 img
返回顶部~
返回顶部 img