win32 PE 文件格式

阅读更多关于《win32 PE 文件格式》

 

桂林电子工业学院

专业文献翻译(译文)

计算机系计算机及应用专业

 

文章Peering Inside the PE:
A Tour of the Win32 Portable
Executable File Format

班级98031414 

学生姓名 雷 鹏

 

二〇〇二年 六 月 十五 日
 
Peering Inside the PE: A Tour of the Win32 Portable Executable File Format

Matt Pietrek March 1994
这篇文章来自 MicroSoft 系统期刊,1994 年 3 月。版权所有,? 1994 Miller Freeman,Inc.保留所有权利!未经 Miller Freeman同意,这篇文章的任何部分不得以任何形式抄袭(除了在论文中或评论中以摘要引用)。
一个操作系统的可执行文件格式在很多方面是这个系统的一面镜子。虽然学习一个可执行文件格式通常不是一个程序员的首要任务,但是你可以从这其中学到大量的知识。在这篇文章中,我会给出 MicroSoft 的所有基于 win32系统(如winnt,win9x)的可移植可执行(PE)文件格式的详细介绍。在可预知的未来,包括 Windows2000 , PE 文件格式在 MicroSoft 的操作系统中扮演一个重要的角色。如果你在使用 Win32 或 Winnt ,那么你已经在使用 PE 文件了。甚至你只是在 Windows3.1 下使用 Visual C++ 编程,你使用的仍然是 PE 文件(Visual C++ 的 32 位 MS-DOS 扩展组件用这个格式)。简而言之,PE 格式已经普遍应用,并且在不短的将来仍是不可避免的。现在是时候找出这种新的可执行文件格式为操作系统带来的东西了。
我最后不会让你盯住无穷无尽的十六进制Dump,也不会详细讨论页面的每一个单独的位的重要性。代替的,我会向你介绍包含在 PE 文件中的概念,并且将他们和你每天都遇到的东西联系起来。比如,线程局部变量的概念,如下所述:
declspec(thread) int i;
我快要发疯了,直到我发现它在可执行文件中实现起来是如此的简单并且优雅。既然你们中的许多人都有使用 16 Windows 的背景,我将把 Win32 PE 文件的构造追溯到和它等价的16 位 NE 文件。
除了一个不同的可执行文件格式, MicroSoft 还引入了一个用它的编译器和汇编器生成的新的目标模块格式。这个新的 OBJ 文件格式有许多和PE 文件共同的东东。我做了许多无用功去查找这个新的 OBJ 文件格式的文档。所以我以自己的理解对它进行解析,并且,在这里,除了 PE 文件,我会描述它的一部分。
大家都知道,Windows NT 继承了 VAX? VMS? 和 UNIX? 的传统。许多 Windows NT 的创始人在进入微软前都在这些平台上进行设计和编码。当他们开始设计 Windows NT 时,很自然的,为了最小化项目启动时间,他们会使用以前写好的并且已经测试过的工具。用这些工具生成的并且工作的可执行和 OBJ 文件格式叫做 COFF (Common Object File Format 的首字母缩写)。COFF 的相对年龄可以用八进制的域来指定。COFF 本身是一个好的起点,但是需要扩展到一个现代操作系统如 Windows 95 和 Windows NT 的需要。这个更新的结果就是(PE格式)可移植可执行文件格式。它被称为”可移植的”是因为在所有平台(如x86,Alpha,MIPS等等)上实现的WindowsNT 都使用相同的可执行文件格式。当然了,也有许多不同的东西如二进制代码的CPU指令。重要的是操作系统的装入器和程序设计工具不需要为任何一种CPU完全重写就能达到目的。
MicroSoft 抛弃现存的32位工具和可执行文件格式的事实证实了他们想让 WindowsNT 升级并且运行的更快的决心。为16位Windows编写的虚拟设备驱动程序用一种不同的32位文件布局–LE 文件格式–WindowsNT出现很早以前就存在了。比这更重要的是对 OBJ 文件的替换!在 WindowsNT 的 C 编译器以前,所有的微软编译器都用 Intel 的 OMF ( Object Module Format ) 规范。就像前面提到的,MicroSoft 的 Win32 编译器生成 COFF 格式的 OBJ 文件。一些微软的竞争者,如 Borland 和 Symentec ,选择放弃了 COFF 格式并坚持 Intel 的 OMF 文件格式。这样的结果是制作 OBJ 和 LIB 的公司为了使用多个不同的编译器,不得不为每个不同的编译器分发这些库的不同版本(如果他们不这么做)。
PE 文件格式在 winnt.h 头文件中文档化了(用最不精确的语言)!大约在 winnt.h 的中间部分标题为”Image Format”的一个快。在把 MS-DOS 的 MZ 文件头和 NE 文件头移入新的PE文件头之前,这个块就开始于一个小栏。WINNT.H提供PE文件用到的生鲜数据结构的定义,但只有很少有助于理解这些数据结构和标志变量的注释。不管谁为PE文件格式写出这样的头文件都肯定是一个信徒无疑(突然持续地冒出Michael J. O’Leary的名字来)。描述名字,连同深嵌的结构体和宏。当你配套winnt.h进行编码时,类似下面这样的表达式并不鲜见:
pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]
.VirtualAddress;
为了有助于逻辑的理解这些winnt.h中的信息,阅读可移植可执行和公共对象文件格式的规格说明,这些在MSDN既看光盘中是可用的,一直包括到2001年8月。
现在让我们转换到COFF格式的OBJ文件的主体上来,WINNT.H包括COFF OBJ和LIB的结构化定义和类型定义。不幸的是,我还没有找到上面提到的可执行文件格式的类似文档。既然PE文件和COFF OBJ文件是如此的相似,我决定是时间把这些文件带到重点上来,并且把它们也文档化。仅仅读过了关于PE文件的组成,你自己也想Dump一些PE文件来看这些概念。如果你用微软基于32位WINDOWS的开发工具,DUMPBIN 程序可以将PE文件和COFF OBJ/LIB文件转化为可读的形式。在所有的PEDump器中,DUMPBIN是最容易理解的。它恰好有一些很好的选项来反汇编它正解析的文件的代码块,Borland用户可以使用tdump来浏览PE文件,但tdump不能解析 COFF OBJ/LIB 文件。这不是一个重要的东西因为Borland的编译器首先就不生成 COFF 格式的OBJ文件。
我写了一个PE和COFF OBJ 文件的Dump程序–PEDUMP(见表1),我想提供一些比DUMPBIN更加可理解的输出。虽然它没有反汇编器以及和LIB库文件一起工作,它在其他方面和DUMPBIN是一样的,并且加入了一些新的特性来使它值得被认同。它的源代码在任何一个MSJ电子公报版上都可以找到,所有我不打算在这里把他全部列出。作为代替,我展示一些从PEDUMP得到的示例输出来阐明我为它们描述的概念。
译注:–说实话,我从这这份代码中几乎唯一学到的东西就是”如何处理命令行”,其它的都没学到。
表 1 PEDUMP.C
file://——————–
// PROGRAM: PEDUMP
// FILE:    PEDUMP.C
// AUTHOR:  Matt Pietrek – 1993
file://——————–
#include <windows.h>
#include <stdio.h>
#include “objdump.h”
#include “exedump.h”
#include “extrnvar.h”

// Global variables set here, and used in EXEDUMP.C and OBJDUMP.C
BOOL fShowRelocations = FALSE;
BOOL fShowRawSectionData = FALSE;
BOOL fShowSymbolTable = FALSE;
BOOL fShowLineNumbers = FALSE;

char HelpText[] =
“PEDUMP – Win32/COFF .EXE/.OBJ file dumper – 1993 Matt Pietrek/n/n”
“Syntax: PEDUMP [switches] filename/n/n”
”  /A    include everything in dump/n”
”  /H    include hex dump of sections/n”
”  /L    include line number information/n”
”  /R    show base relocations/n”
”  /S    show symbol table/n”;

// Open up a file, memory map it, and call the appropriate dumping routine
void DumpFile(LPSTR filename)
{
    HANDLE hFile;
    HANDLE hFileMapping;
    LPVOID lpFileBase;
    PIMAGE_DOS_HEADER dosHeader;
   
    hFile = CreateFile(filename, GENERIC_READ, FILE_SHARE_READ, NULL,
                        OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
                   
    if ( hFile = = INVALID_HANDLE_VALUE )
    {   printf(“Couldn’t open file with CreateFile()/n”);
        return; }
   
    hFileMapping = CreateFileMapping(hFile, NULL,
PAGE_READONLY, 0, 0, NULL);
    if ( hFileMapping = = 0 )
{  
CloseHandle(hFile);
        printf(“Couldn’t open file mapping with CreateFileMapping()/n”);
        return;
}
   
    lpFileBase = MapViewOfFile(hFileMapping, FILE_MAP_READ, 0, 0, 0);
    if ( lpFileBase = = 0 )
    {
        CloseHandle(hFileMapping);
        CloseHandle(hFile);
        printf(“Couldn’t map view of file with MapViewOfFile()/n”);
        return;
    }

    printf(“Dump of file %s/n/n”, filename);
   
    dosHeader = (PIMAGE_DOS_HEADER)lpFileBase;
    if ( dosHeader->e_magic = = IMAGE_DOS_SIGNATURE )
       { DumpExeFile( dosHeader ); }
    else if ( (dosHeader->e_magic = = 0x014C)    // Does it look like a i386
              && (dosHeader->e_sp = = 0) )        // COFF OBJ file???
    {
        // The two tests above aren’t what they look like.  They’re
        // really checking for IMAGE_FILE_HEADER.Machine = = i386 (0x14C)
        // and IMAGE_FILE_HEADER.SizeOfOptionalHeader = = 0;
        DumpObjFile( (PIMAGE_FILE_HEADER)lpFileBase );
    }
    else
        printf(“unrecognized file format/n”);
    UnmapViewOfFile(lpFileBase);
    CloseHandle(hFileMapping);
    CloseHandle(hFile);
}

// process all the command line arguments and return a pointer to
// the filename argument.
PSTR ProcessCommandLine(int argc, char *argv[])
{
    int i;
   
    for ( i=1; i < argc; i++ )
    {
        strupr(argv[i]);
       
        // Is it a switch character?
        if ( (argv[i][0] = = ‘-‘) || (argv[i][0] = = ‘/’) )
        {
            if ( argv[i][1] = = ‘A’ )
            {   fShowRelocations = TRUE;
                fShowRawSectionData = TRUE;
                fShowSymbolTable = TRUE;
                fShowLineNumbers = TRUE; }
            else if ( argv[i][1] = = ‘H’ )
                fShowRawSectionData = TRUE;
            else if ( argv[i][1] = = ‘L’ )
                fShowLineNumbers = TRUE;
            else if ( argv[i][1] = = ‘R’ )
                fShowRelocations = TRUE;
            else if ( argv[i][1] = = ‘S’ )
                fShowSymbolTable = TRUE;
        }
        else    // Not a switch character.  Must be the filename
        {   return argv[i]; }
    }
}

int main(int argc, char *argv[])
{
    PSTR filename;
   
    if ( argc = = 1 )
    {   printf(    HelpText );
        return 1; }
   
    filename = ProcessCommandLine(argc, argv);
    if ( filename )
        DumpFile( filename );
    return 0;
}

 
1 WIN32 与 PE 基本概念
让我们复习一下几个透过PE文件的设计了解到的基本概念(见图1)。我用术语”MODULE”来表示一个可执行文件或一个DLL载入内存的代码(CODE)、数据(DATA)、资源(RESOURCES),除了代码和数据是你的程序直接使用的,一个模块还可以由WINDOWS用来确定数据和代码载入的位置的支撑数据结构组成。在16位WINDOWS中,这些支撑数据结构在模块数据库(用一个HMODULE来指示的段)中。在WIN32里面,这些数据结构在PE文件头中,这些我将会简要地解释一下。
 
图1  PE文件略图

关于PE文件最重要的是,磁盘上的可执行文件和它被WINDOWS调入内存之后是非常相像的。WINDOWS载入器不必为从磁盘上载入一个文件而辛辛苦苦创建一个进程。载入器使用内存映射文件机制来把文件中相似的块映射到虚拟空间中。用一个构造式的分析模型,一个PE文件类似一个预制的屋子。它本质上开始于这样一个空间,这个空间后面有几个把它连到其余空间的机件(就是说,把它联系到它的DLL上,等等)。这对PE格式的DLL是一样容易应用的。一旦这个模块被载入,Windows 就可以有效的把它和其它内存映射文件同等对待。
和16位Windows不同的是。16位NE文件的载入器读取文件的一部分并且创建完全不同的数据结构在内存中表示模块。当数据段或者代码段需要载入时,载入器必须从全局堆中新申请一个段,从可执行文件中找出生鲜数据,转到这个位置,读入这些生鲜数据,并且要进行适当的修正。除此而外,每个16位模块都有责任记住当前它使用的所有段选择器,而不管这个段是否被丢弃了,如此等等。
对Win32来讲,模块所使用的所有代码,数据,资源,导入表,和其它需要的模块数据结构都在一个连续的内存块中。在这种形势下,你只需要知道载入器把可执行文件映射到了什么地方。通过作为映像的一部分的指针,你可以很容易的找到这个模块所有不同的块。
另一个你需要知道的概念是相对虚拟地址(RVA)。PE文件中的许多域都用术语RVA来指定。一个RVA只是一些项目相对于文件映射到内存的偏移。比如说,载入器把一个文件映射到虚拟地址0x10000开始的内存块。如果一个映像中的实际的表的首址是0x10464,那么它的RVA就是0x464。
(虚拟地址 0x10464)-(基地址 0x10000)=RVA 0x00464
为了把一个RVA转化成一个有用的指针,只需要把RVA值加到模块的基地址上即可。基地址是内存映射EXE和DLL文件的首址,在Win32中这是一个很重要的概念。为了方便起见,WindowsNT 和 Windows9x用模块的基地址作为这个模块的实例句柄(HINSTANCE)。在Win32中,把模块的基地址叫做HINSTANCE可能导致混淆,因为术语”实例句柄”来自16位Windows。一个程序在16位Windows中的每个拷贝得到它自己分开的数据段(和一个联系起来的全局句柄)来把它和这个程序其它的拷贝分别开来,就形成了术语”实例句柄”。在Win32中,每个程序不必和其它程序区别开来,因为他们不共享相同的地址空间。术语INSTANCE仍然保持16位windows和32位Windows之间的连续性。在Win32中重要的是你可以对任何DLL调用GetModuleHandle()得到一个指针去访问它的组件(译注)。
译注:如果 dllname 为 NULL,则得到执行体自己的模块句柄。这是非常有用的,如通常编译器产生的启动代码将取得这个句柄并将它作为一个参数hInstance传给WinMain !
你最终需要理解的PE文件的概念是”块(Section)”。PE文件中的一个块和NE文件中的一个段或者资源等价。块可以包含代码或者数据。和段不同的是,块是内存中连续的空间,而没有尺寸限制。当你的连接器和库为你建立,并且包含对操作系统非常重要的信息的其它的数据块时,这些块包含你的程序直接声明和使用的代码或数据。在一些PE格式的描述中,块也叫做对象。术语对象有如此多的涵义,以至于只能把代码和数据叫做”块”。
2 PE首部
和其它可执行文件格式一样,PE文件在众所周知的地方有一些定义文件其余部分面貌的域。首部就包含这样象代码和数据的位置和尺寸的地方,操作系统要对它进行干预,比如初始堆栈大小,和其它重要的块的信息,我将要简短的介绍一下。和微软其它可执行格式相比,主要的首部不是在文件的最开始。典型的PE文件最开始的数百个字节被DOS残留部分占用。这个残留部分是一个可以打印如”这个程序不能在DOS下运行!”这类信息的小程序。所以,你在一个不支持Win32的系统中运行这个程序,便可以得到这类错误信息。当载入器把一个Win32程序映射到内存,这个映射文件的第一个字节对应于DOS残留部分的第一个字节。那是无疑的。和你启动的任一个基于Win32 的程序一起,都有一个基于DOS的程序连带被载入。
和微软的其它可执行格式一样,你可以通过查找它的起始偏移来得到真实首部,这个偏移放在DOS残留首部中。WINNT.H头文件包含了DOS残留程序的数据结构定义,使得很容易找到PE首部的起始位置。e_lfanew 域是PE真实首部的偏移。为了得到PE首部在内存中的指针,只需要把这个值加到映像的基址上即可。
file://忽略类型转化和指针转化 …
pNTHeader = dosHeader + dosHeader->e_lfanew;
一旦你有了PE主首部的指针,游戏就可以开始了!PE主首部是一个IMAGE_NT_HEADERS的结构,在WINNT.H中定义。这个结构由一个双字(DWORD)和两个子结构组成,布局如下:
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
标志域用ASCII表示就是”PE/0/0″。如果在DOS首部中用了e_lfanew域,你得到一个NE标志而不是PE,那么这是16位NE文件。同样的,在标志域中的LE表示这是一个Windows3.x 的虚拟设备驱动程序(VxD)。LX表示这个文件是OS/2 2.0文件。
PE  DWORD标志后的是结构 IMAGE_FILE_HEADER 。这个域只包含这个文件最基本的信息。这个结构表现为并未从它的原始COFF实现更改过。除了是PE首部的一部分,它还表现在微软Win32编译器生成的COFF OBJ 文件的最开始部分。IMAGE_FILE_HEADER的这个域显示在下面:
表2  IMAGE_FILE_HEADER Fields
WORD Machine
表示CPU的类型,下面定义了一些CPU的ID
0x14d Intel i860
0x14c Intel I386 (same ID used for 486 and 586)
0x162 MIPS R3000
0x166 MIPS R4000
0x183 DEC Alpha AXP

WORD NumberOfSections
这个文件中的块数目。

DWORD TimeDateStamp
连接器产生这个文件的日期(对OBJ文件是编译器),这个域保存的数是从1969年12月下午4:00开始到现在经过的秒数。

DWORD PointerToSymbolTable
COFF符号表的文件偏移量。这个域只用于有COFF调试信息的OBJ文件和PE文件,PE文件支持多种调试信息格式,所以调试器应该指向数据目录的IMAGE_DIRECTORY_ENTRY_DEBUG条目。

DWORD NumberOfSymbols
COFF符号表的符号数目。见上面。

WORD SizeOfOptionalHeader
这个结构后面的可选首部的尺寸。在OBJ文件中,这个域是0。在可执行文件中,这是跟在这个结构后的IMAGE_OPTIONAL_HEADER结构的尺寸。

WORD Characteristics
关于这个文件信息的标志。一些重要的域如下:

0x0001 这个文件中没有重定位信息
0x0002 可执行文件映像(不是OBJ或LIB文件)
0x2000 文件是动态连接库,而非程序

其它域定义在WINNT.H中。
PE首部的第三个组成部分是一个IMAGE_OPTIONAL_HEADER型的结构。对PE文件,这一部分当然不是”可选的”。COFF格式允许单独实现来定义一个超出标准IMAGE_FILE_HEADER附加信息的结构。IMAGE_OPTIONAL_HEADER里面的域是PE的实现者感到超出IMAGE_FILE_HEADER基本信息以外非常关键的信息。
并非 IMAGE_OPTIONAL_HEADER 的所有域都是重要的(见图4)。比较重要,需要知道的是ImageBase 和 SubSystem 域。你可以忽略其它域的描述。
表3  IMAGE_FILE_HEADER 的域:
WORD Magic
表现为一些类别的标志字,通常是0X010B 。
BYTE MajorLinkerVersion
BYTE MinorLinkerVersion
生成这个文件的连接器的版本。这个数字以十进制显示比用十六进制好。一个典型的连接器版本是2.23。

DWORD SizeOfCode
所有代码块的进位尺寸。通常大多数文件只有一个代码块,所以这个域和 .TEXT 块匹配。

DWORD SizeOfInitializedData
已初始化的数据组成的块的大小(不包括代码段)。然而,和它在文件中的表现形式并不一致。

DWORD SizeOfUninitializedData
载入器在虚拟内存中申请空间,但在磁盘上的文件中并不占用空间的块的尺寸。这些块在程序启动时不需要指定初值,因此术语名就是”未初始化的数据”。未初始化的数据通常在一个名叫 .bss  的块中。

DWORD AddressOfEntryPoint
载入器开始执行这个程序的地址,即这个PE文件的入口地址。这是一个RVA,通常在  .text  块中。

DWORD BaseOfCode
代码块起始地址的RVA 。在内存中,代码块通常在PE首部之后,数据块之前。在微软的连接器产生的EXE文件中,这个值通常是0x1000 。Borland 的连接器 TLINK32  也一样,把映像第一个代码块的RVA和映像基址相加,填入这个域。
 译注:这个域好像一直没有什么用

DWORD BaseOfData
数据块起始地址的RVA 。在内存中,数据块经常在最后,在PE首部和代码块之后。
译注:这个域好像也一直没有什么用

DWORD ImageBase
连接器创建一个可执行文件时,它假定这个文件被映射到内存中的一个指定的地方,这个地址就存在这个域中,假定一个载入地址可以使连接器优化以便节省空间。如果载入器真的把这个文件映射到了这个地方,在运行之前代码不需要任何改变。在为WindowsNT 创建的可执行文件中,默认的ImageBase 是0x10000。对DLL,默认是0x40000。在Window95中,地址0x10000不能用来载入32位EXE文件,因为这个区域在一个被所有进程共享的线性地址空间中。因此,微软把Win32可执行文件的默认基址改为0x40000,假定基址为0x10000 的老程序坐在Windows95 中需要更长的载入时间,这是因为载入器需要重定位基址。
译注:这个域即”Prefered Load Address”,如果没有什么意外,这就是该PE文件载入内存后的地址。

DWORD SectionAlignment
映射到内存中时,每个块都必须保证开始于这个值的整数倍。为了分页的目的,默认的SectionAlignment 是 0x1000。

DWORD FileAlignment
在PE文件中,组成每个块的生鲜数据必须保证开始于这个值的整数倍。默认值是0x200 字节,也许是为了保证块都开始于一个磁盘扇区(一个扇区通常是 512 字节)。这个域和NE文件中的段/资源对齐(segment/resource alignment)尺寸是等价的。和NE文件不同的是,PE文件通常没有数百个的块,所以,为了对齐而浪费的通常空间很少。

WORD MajorOperatingSystemVersion
WORD MinorOperatingSystemVersion
这个程序运行需要的操作系统的最小版本号。这个域有点含糊,因为Subsystem 域(后面将会说到)可以提供类似的功能。这个域在到目前为止的Win32中默认是1.0。

WORD MajorImageVersion
WORD MinorImageVersion
一个可由用户定义的域。这允许你有不同的EXE和DLL版本。你可以通过链接器的 /version 选项设置这个域的值。例如:”link  /version:2.0  myobj.obj”。

WORD MajorSubsystemVersion
WORD MinorSubsystemVersion
这个程序运行需要的最小子系统版本号。这个域的一个典型值是3.10 (表示WindowsNT 3.1)。

DWORD Reserved1
通常是 0 。

DWORD SizeOfImage
载入器必须关心的这个映像所有部分的大小总和。是从映像的开始到最后一个块结尾这段区域的大小。最后一个块结尾按SectionAlignment进位。
 译注:这个很重要,可以大,但不可以小!

DWORD SizeOfHeaders
PE首部和块表的大小。块的实际数据紧跟在所有首部组件之后。

DWORD CheckSum
这个文件的CRC校验和。在微软可执行格式中,这个域被忽略并且置为0 。这个规则的一个例外情况是信任服务,这类EXE文件必须有一个合法的校验和。

WORD Subsystem
可执行文件的用户界面使用的子系统类型。WINNT.H 定义了下面这些值:
NATIVE  1  不需要子系统(比如设备驱动)
WINDOWS_GUI  2  在Windows图形用户界面子系统下运行
WINDOWS_CUI  3  在Windows字符子系统下运行(控制台程序)
OS2_CUI  5 在OS/2字符子系统下运行(仅对OS/2 1.x)
POSIX_CUI  7  在 Posix 字符子系统下运行

WORD DllCharacteristics
指定在何种环境下一个DLL的初始化函数(比如DllMain)将被调用的标志变量。这个值经常被置为0 。但是操作系统在下面四种情况下仍然调用DLL的初始化函数。

下面的值定义为:
1  DLL第一次载入到进程中的地址空间中时调用
2  一个线程结束时调用
4  一个线程开始时调用
8  退出DLL时调用

DWORD SizeOfStackReserve
为初始线程保留的虚拟内存总数。然而并不是所有这些内存都被提交(见下一个域)。这个域的默认值是0x100000(1Mbytes)。如果你在CreateThread 中把堆栈尺寸指定为 0 ,结果将是用这个相同的值(0x10000)。

DWORD SizeOfStackCommit
开始提交的初始线程堆栈总数。对微软的连接器,这个域默认是0x1000字节(一页),TLINK32 是两页。

DWORD SizeOfHeapReserve
为初始进程的堆保留的虚拟内存总数。这个堆的句柄可以用GetPocessHeap 得到。并不是所有这些内存都被提交(见下一个域)。

DWORD SizeOfHeapCommit
开始为进程堆提交的内存总数。默认是一页。
 
DWORD LoaderFlags
从WINNT.H中可以看到,这些标志是和调试支持相联系的。我从没有见到过在哪个可执行文件中这些位都置位了,清除它让连接器来设置它。下面的值定义为:
1. 在开始进程前调用一个端点指令
2. 进程被载入时调用一个调试器

DWORD NumberOfRvaAndSizes
数据目录数组中的的条目数目(见下面)。当前的工具通常把这个值设为16。
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]
一个IMAGE_DATA_DIRECTORY 结构数组。初始数组元素包含可执行文件的重要部分的起始RVA和大小。这个数组最末的一些元素现在没有使用。这个数组的第一个元素经常时导出函数表的地址和尺寸。第二个数组条目是导入函数表的地址和尺寸,等等。对一个完整的、已定义的数组条目,见IMAGE_DIRECTORY_ENTRY_XXX 在WINNT.H中的定义。这个数组允许载入器迅速查找这个映像的一个指定的块(例如,导入函数表),而不需要遍历映像的每个块,通过比较名字来确定。大部分数组条目描述一整块数据。然而,IMAGE_DIRECTORY_ENTRY_DEBUG项只包括 .rdata 块的一小部分字节。

 
3 块表
在PE首部和映像块之间的是块表。块表本质上是包含映像中每个块信息的电话本。映像中的块以他们的起始地址(RVA)排列,而不是按字母排列。
现在,我进一步澄清什么是一个块。在NE文件中,你的程序代码和数据存储在相互区别开来的段中。NE首部的一部分是一个结构数组,每个对应你的程序用到的一个段。数组中的每个结构包含一个段的信息。这些信息存储了段的类型(代码或数据)、大小、和它在文件中的位置。在PE文件中,块表和NE文件中的段表类似。和NE文件的段表不同,PE块表项不存储一个代码和数据块的选择子。代替的,每个块表项存储文件的生鲜数据映射到内存中以后的地址。于是块就和32位段类似,但他们实际上不是单独的段。它们实际上是进程虚拟空间的一个内存范围。
另一个PE文件和NE文件的不同之处是它怎样管理你的程序不用,但操作系统要用的支持数据;例如可执行文件使用的DLL列表或修正表的位置。在NE文件中,资源不被当作段。甚至分配给他们的选择子,资源的相关信息并未存储在NE文件首部的段表中。代替的,提交给一个分隔表的资源朝向PE首部的结尾。关于导入和导出函数的信息也没有授权给它自己的段;它交织在NE首部中。
PE文件的故事就不一样了。任何可能被认为是关键的代码或数据都存在一个完备的块中。于是,导入函数表的信息就存在它自己的块中,导出表也一样。对重定位数据也是一样的。程序或操作系统可能需要的任何代码或数据都可以得到它们自己的块。
在我讨论特定块之前,我需要先描述操作系统管理这些块的数据。在内存中紧跟在PE首部的是一个IMAGE_SECTION_HEADER数组。数组的元素个数在PE首部中给定(IMAGE_NT_HEADER.FileHeader.NumberOfSections域)。我用PEDUMP来输出块表和块的所有的域及其属性。表5 描述了用PEDUMP输出的一个典型EXE文件的块表,表6 给出了 Obj 文件的块表。
表 4  一个典型EXE文件的块表
01 .text     VirtSize: 00005AFA  VirtAddr:  00001000
    raw data offs:   00000400  raw data size: 00005C00
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00009220  line #’s:      0000020C
    characteristics: 60000020
    CODE  MEM_EXECUTE  MEM_READ

  02 .bss      VirtSize: 00001438  VirtAddr:  00007000
    raw data offs:   00000000  raw data size: 00001600
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: C0000080
    UNINITIALIZED_DATA  MEM_READ  MEM_WRITE

  03 .rdata    VirtSize: 0000015C  VirtAddr:  00009000
    raw data offs:   00006000  raw data size: 00000200
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: 40000040
    INITIALIZED_DATA  MEM_READ

  04 .data     VirtSize: 0000239C  VirtAddr:  0000A000
    raw data offs:   00006200  raw data size: 00002400
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: C0000040
    INITIALIZED_DATA  MEM_READ  MEM_WRITE

  05 .idata    VirtSize: 0000033E  VirtAddr:  0000D000
    raw data offs:   00008600  raw data size: 00000400
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: C0000040
    INITIALIZED_DATA  MEM_READ  MEM_WRITE

  06 .reloc    VirtSize: 000006CE  VirtAddr:  0000E000
    raw data offs:   00008A00  raw data size: 00000800
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: 42000040
    INITIALIZED_DATA  MEM_DISCARDABLE  MEM_READ 
表 5  一个典型OBJ文件的块表
01 .drectve  PhysAddr: 00000000  VirtAddr:  00000000
    raw data offs:   000000DC  raw data size: 00000026
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: 00100A00
    LNK_INFO  LNK_REMOVE

  02 .debug$S  PhysAddr: 00000026  VirtAddr:  00000000
    raw data offs:   00000102  raw data size: 000016D0
    relocation offs: 000017D2  relocations:   00000032
    line # offs:     00000000  line #’s:      00000000
    characteristics: 42100048
    INITIALIZED_DATA  MEM_DISCARDABLE  MEM_READ

  03 .data     PhysAddr: 000016F6  VirtAddr:  00000000
    raw data offs:   000019C6  raw data size: 00000D87
    relocation offs: 0000274D  relocations:   00000045
    line # offs:     00000000  line #’s:      00000000
    characteristics: C0400040
    INITIALIZED_DATA  MEM_READ  MEM_WRITE

  04 .text     PhysAddr: 0000247D  VirtAddr:  00000000
    raw data offs:   000029FF  raw data size: 000010DA
    relocation offs: 00003AD9  relocations:   000000E9
    line # offs:     000043F3  line #’s:      000000D9
    characteristics: 60500020
    CODE  MEM_EXECUTE  MEM_READ

  05 .debug$T  PhysAddr: 00003557  VirtAddr:  00000000
    raw data offs:   00004909  raw data size: 00000030
    relocation offs: 00000000  relocations:   00000000
    line # offs:     00000000  line #’s:      00000000
    characteristics: 42100048
    INITIALIZED_DATA  MEM_DISCARDABLE  MEM_READ
 
每个IAMGE_SECTION_HEADER都有一个如图7 描述的格式。注意每个块中存储的信息缺失了什么是很有趣的。首先,注意没有指明任何预载入的属性。NE文件格式允许你指定应该和模块一起载入的预载入段的属性。OS/2? 2.0 LX 格式有点类似,允许你指定预载入八页(内存页:译注,下同) 。PE格式就没有任何类似的东西。微软必须确保Win32 需求页面的载入性能。
表 6  IMAGE_SECTION_HEADER 的格式
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]
这是一个为块命名的8字节ANSI名字(不UNICODE)。大部分块名开始于一个 “. “(比如”.text”),但这并非必须的,就像你可能相信的一些PE文档一样。你可以在汇编语言中用任何一个段指示你自己的块。或者在微软C/C++编译器中用”#pragma data_seg”来指示。需要注意的是如果块名占满8个字节,就没有NULL结束字节了。如果你热衷于 printf ,你可以用 %8s来避免把这个名字拷贝到一个缓冲区中,然后又在结尾加上一个NULL字节。
union {
DWORD PhysicalAddress
DWORD VirtualSize
} Misc;
在EXE和OBJ中,这个域的意义不同。在EXE中,它保存代码或者数据的实际尺寸。这个尺寸是未经过校准文件对齐尺寸并进位的。后面要讲到的这个结构的SizeOfRawData 域(这个词有点不确切)保存了校准文件对齐尺寸并进位后的尺寸。Borland 的连接器调换了这两个域的意思,于是看上去就是正确的了。对OBJ文件,这个域指示块的物理尺寸。第一个块开始于地址0 。为找到OBJ 文件中的下一个块,把SizeOfRawData加到当前块基址上即可。

DWORD VirtualAddress
在EXE中,这个域保存决定载入器把这个块映射到内存中哪个位置的RVA 。为计算一个给定的块在内存中的实际起始地址,把这个映像的基址加上存储在这个域的VirtualAddress即可。用微软的工具,第一个块的默认RVA是0x1000 。在OBJ文件中,这个域没有意义,被置为0 。

DWORD SizeOfRawData
在EXE中,这个域包含这个块按文件对齐尺寸进位后的尺寸。比如说,假定一个文件的对齐尺寸是0x200 。如果这个块的VirtualAddress域(前面那个域)的是0x35a ,那么这个域就是0x400 。在OBJ文件中,这个域包含由编译器或汇编器提供的块的精确尺寸。换句话说,对OBJ ,它等价于EXE中的VirtualSize域。

DWORD PointerToRawData
这是一个基于文件的偏移,通过这个偏移,可以找到由编译器或汇编器产生的生鲜数据。如果你的程序自己要把一个PE或COFF文件映射到内存(而不是让操作系统来载入),那么这个域比VirtualAddress更重要。在这种情况下你有一个完全线性的文件映射,所以你会在这个偏移处找到块的数据,而不是在VirtualAddress域指定的RVA 处找到。
DWORD PointerToRelocations
在OBJ中,这是指向块的重定位信息的基于文件的偏移值。每个OBJ块的重定位信息紧跟在这个块的生鲜数据之后。在EXE中,这个域(和后面的)是没有意义的,被置为0 。连接器产生EXE时,它解决了大部分的这种修正值,只剩下基址的重定位和导入函数,将在载入时解决。关于基本重定位信息和导入函数保留在他们自己的块中,所以对一个EXE ,没有必要在每个块的生鲜数据之后都紧跟它的重定位信息。

DWORD PointerToLinenumbers
这是行号表基于文件的偏移量。行号表把源文件的一行和(编译器)为这一行产生的(机器)代码的首址联系起来。在如CodeView格式的现代调试格式中,行号信息存储为调试信息的一部分。然而,在COFF调试格式中,行号信息和符号名/型信息的存储是分开的。通常只有代码块(如 .text )有行号信息。在EXE文件中,行号信息在块的生鲜数据之后,朝着文件的结尾方向收集。在OBJ文件中,一个块的行号信息跟在生鲜块数据和这个块的重定位表之后。

WORD NumberOfRelocations
块的重定位表中的重定位项的数目(参考上面的PointerToRelocations域)。这个域似乎只和OBJ文件有关。

WORD NumberOfLinenumbers
块的行号表中的行号项的数目(参考上面的PointerToLinenumbers域)。

DWORD Characteristics
大部分程序员的称之为标志,COFF/PE格式称之为特征。这个域是指示块属性的标志集(如代码/数据,可读,可写)。一个对所有可能的块属性的完整的列表,见WINNT.H中的IMAGE_SCN_XXX_XXX的定义。如下是比较重要的一些标志:

0x00000020  这个块包含代码。通常和可执行标志(0x80000000)一起置位。
0x00000040  这个块包含已初始化的数据。除了可执行块和 .bss 块之外几乎所有的块的这个标志都置位。
0x00000080  这个块包含未初始化的数据(如 .bss 块)
0x00000200  这个块包含注释或其它的信息。这个块的一个典型用法是编译器产生的 .drectve 块,包含链接器命令。
0x00000800  这个块的内容不应放进最终的EXE文件中。这些块是编译器或汇编器用来给连接器传递信息的。0x02000000  这个块可以被丢弃,因为一旦它被载入,其进程就不需要它了。最通常的可丢弃块是基本重定位块( .reloc )。
0x10000000  这个块是可共享的。和DLL一起使用时,这个块的数据可以在使用这个DLL的进程之间共享。默认时数据块是非共享的,这意味着使用这个DLL的各个进程都有自己对这个块的数据的副本。在更专业的术语中,共享块告诉内存管理器把使用这个DLL的所有进程把的这个块的页面映射到内存中相同的物理页面。为使一个块可共享,在连接时用SHARE属性。如:
LINK /SECTION:MYDATA,RWS …
告诉连接器叫做”MYDATA”的块是可读的,可写的,共享的。
0x20000000  这个块是可执行的。这个标志通常在”包含代码”标志(0x00000020)被置位时置位。
0x40000000  这个块是可读的。在EXE文件中,这个域几乎总被置位。
0x80000000  这个块是可写的。如果在一个EXE块中这个块未被置位,载入器会把这块的内存映射页面标为只读或”只执行”。有此属性的典型的块是 .data 和 .bss 。有趣的是,.idata 块也有这个属性。
PE格式中还缺少”页表”的概念。在LX格式中,OS/2的IMAGE_SECTION_TABLE等价物不直接指向文件中的代码或数据块。代替的,它指向一个指示块中特定范围的属性和位置的页查找表。PE格式分配所有的,并且确保所有的块中的数据将连续的存储在文件中。比较这两种格式:LX可以允许更大的灵活性,但PE风格更简单,更容易协同工作。我已经写了这两种文件的Dumper 。
PE格式另一个值得欢迎的改变是所有项目的位置都存储为简单的双字(DWORD)偏移。在NE格式中,几乎所有东西的位置都存储为它们的扇区值。为了得到实际的偏移,你第一步需要查找NE首部的对齐单元尺寸并把它转化为扇区尺寸(典型的是 16 和512 字节)。然后你需要把扇区尺寸乘以指定的扇区偏移才得到实际的文件偏移。如果NE文件的某些东西偶然存储为一个扇区偏移,这可能是相对于NE首部的。因为NE首部并不在文件的开始,你需要在自己的代码中调整这个文件的NE首部。总之,PE格式比NE,LX,或LE格式更容易协同工作(假定你能使用内存映像文件)。

4 通用块
已经看到了大体上块是什么和它们位于何处,让我们看一下你将会在EXE和OBJ文件中找到的通用块。这个列表决不是完整的,但包含了你每天都碰到的块(甚至你没有意识到的)。
.text 块是编译器或汇编器结束时产生的通用代码块。因为PE文件运行在32位模式下,并且没有16位段的限制,没有理由根据分开的源文件把代码分为分开的块。代替的,连接器把从不同的OBJ文件得来的 .text 块连接起来放到EXE文件中的一个大 .text 块中。如果你用 Borland C++ ,编译器把产生的代码放到名为 CODE 的块中。Borland C++ 生成的PE文件有一个名为 CODE 的块而不是名为 .text 。我将会简短的解释一下。
 
Figure 2. Calling a function in another module
对我来说,除了我用编译器创建的或从运行时库中得到的代码外,在 .text 块中找到附加的代码是比较有趣的。在一个PE文件中,当你在另一模块中调用一个函数时(比如在USER32.DLL中的GetMessage ),编译器产生的CALL 指令并不把控制直接转移到在DLL中的这个函数(见图8)。代替的,CALL 指令把把控制转移到一个也在 .text 中的
  JMP DWORD PTR [XXXXXXXX]
指令处。这个 JMP 指令(译注1)通过一个在 .idata 中的DWORD变量间接的转移控制。 .idata 块的DWORD包含操作系统函数入口的实际地址。在对这进行一会儿回想之后,我开始理解为什么DLL调用用这种方式来实现。通过一个位置传送所有的对一个给定的DLL函数的调用,载入器不需要改变每个调用DLL的指令。所有的PE载入器必须做的是把目标函数的正确地址放到 .idata 的一个 DWORD 中。不需要改变任何call指令。在NE文件中就不同了,每个段都包含一个需要应用到这个段上的一个修正表。如果这个段把一个给定的DLL函数调用了20次,载入器必须把这个函数的地址写入到这个段的每个调用指令中。PE方法的缺点是你不能用一个DLL函数的真实地址来初始化一个变量。比如,你要考虑这样的情况:
  FARPROC pfnGetMessage = GetMessage;
将把GetMessage的地址存到变量 pfnGetMessage 中。在16位Windows中,这可以工作,但在Win32中不能。在Win32中,变量pfnGetMessage最终存储的是我前面提到的JMP DWORD PTR [XXXXXXXX] 替换指示(译注2)。如果你想通过函数指针调用一个函数,事情也会如你所预料的一样。但是,如果你想读取 GetMessage 开始的字节,你将不能如愿(除非你自己做跟在 .idata 指针后的工作)。后面我将会返回到这个话题上–在导入表的讨论中。
译注1:英文 thunk,正统的计算机专业术语为”形实转换程序”,类似宏(macro)替换,故我将它译为”替换指示”,指在具体指令中xxxxxxxx 被替换,后面出现的替换指示同。
译注2:现在的编译器如VC6以上等等,产生的导入函数调用代码不再是先来一个相对Call指令到 jmp [xxxx] 处,然后再到 xxxx 处(真正的导入函数入口),而是用了一种效率更高,也更容易让人理解的方式:call [xxxx] 。以前用那种间接的方式多是为兼容编译器。但是现在仍有一些编译器,如MASM,直到版本7.0,还是用前面那种间接的方式,从这里也可以看出微软对ASM的态度了。
虽然 Borland 可以让编译器输出的代码块名为 .text ,但它是选择 NAME 作为默认的段名。为了确定PE文件中的块名,Borland 的连接器(TLINK32.EXE)从OBJ文件中取出段名并把它截断为8字符(如果有必要)。
当块名的不同只是一个小问题时,Borland  PE 文件怎样链接到其它模块就是一个重要的不同。就像我在 .text 的描述中提到的,所有到OBJ的调用通过一个JMP DWORD PTR [XXXXXXXX]替换指示。在微软系统下,这条指令通过一个导入库到达 .text 块。因为库管理器(LIB32)当你链接外部DLL时才创建导入库(和这个替换指示),连接器自己不需要”知道”怎样生成这这个替换指示。导入库实际上只不过是链接到这个PE文件的一些更多的代码和数据。
Borland 处理导入函数的系统只是一个简单的16位NE文件方式扩展。Borland 连接器使用的导入库实际上只不过是一个函数名连同它所在的DLL名的列表。于是TLINK32就有责任确定外部DLL的修正,并生为它成一个适当的JMP DWORD PTR [XXXXXXXX] 替换指示 。TLINK32把这个替换指示存储在它创建的名为 .icode 块中。正像 .text 是默认的代码块,.data 块是已初始化数据的归宿。这些数据包含编译时初始化的全局和静态局部变量。它还包括文字字符串。连接器把从OBJ/LIB文件得来的所有 .data 块组合到EXE文件的一个 .data 块中。局部变量载入到一个线程的堆栈中,在 .data 或 .bss 中不占空间。
.bss 块是存储未初始化的全局和静态局部变量的地方。连接器把 OBJ/LIB 文件中的所有 .bss 块链接到EXE文件的一个 .bss 块中。在块表中,.bss 块的RawDataOffset 域置为0 ,表示这个块在文件中不占用任何空间。TLINK 不产生这个块。代替的,它扩展 DATA 块的虚拟尺寸(virtual size)。
.CRT 块是微软 C/C++ 运行时库利用的另一个已初始化数据的块(从名字)。我不能理解为什么这些数据不放在 .data 中。(译注)
译注:从CRT的字面意思看,应该是”C Run Time”,即C运行时库。
.rsrc 块这个模块的所有资源。在Windows NT的早期,16位RC.EXE输出的RES文件是微软的PE连接器不能识别的格式。CVTRES 程序把这种格式的RES文件转换成COFF格式的OBJ文件,把资源数据放在 OBJ 的 .rsrc 块中。连接器就可以把这个资源OBJ当作另一个OBJ来链接了,允许连接器”知道”关于资源的特殊东西。微软最近发布的更多连接器可以直接处理RES文件。
.idata 块包含关于这个模块从其它DLL导入的函数(和数据)的信息(译注)。这个块和NE文件的模块引用表是等价的。一个关键的不同是PE文件导入的每个函数都明确的列在这个块中。为找到NE文件中的等价信息,你必须去挖掘这个段生鲜数据的结尾的重定位信息。
译注:现在许多编译器产生的EXE文件都没有这个块,然而ImportTable并不是没有了,代替的,ImportTable仅由DataDirectory[1]指示,一般指向.text块或.data块中。
.edata 块是这个PE文件导出到其它模块的函数和数据的列表。它的NE文件等价物是条目表的联合,驻留名表,和非驻留名表,和16位Windows不一样,很少有理由从一个EXE文件导出一些东西,所以你通常只在DLL中看到 .edata 块。当使用微软的工具时,.edata 块中的数据通过EXP文件来到PE文件中。换种方法,连接器不为它自己生成这个信息。代替的,它依赖库管理器(LIB32)来扫描OBJ文件,并创建EXP文件,连接器要把它要链接的模块的列表加入其中。是的,好!这些麻烦的EXP文件实际上只是扩展名不同的OBJ文件而已。
.reloc 块保持一个基本重定位表。基本重定位是一个对一条指令或已初始化的变量值的调整,如果载入器不能把这个文件载入到连接器假定的位置,这就是很重要的了。如果载入器能把这个映像载入到连接器建议(prefer)的基地址,载入器就完全忽略这个块的重定位信息。如果你愿意冒险,并且希望载入器可以始终把这个映像载入到假定的基址,你可以通过 /FIXED 选项告诉链接器去除这个信息。这样可以在可执行文件中节省空间,但会导致这个可执行文件在其它的Win32实现中不能工作。比如,假定你为Windows NT建立了一个EXE文件,并且把基址设为 0x10000 。如果你让连接器去除重定位信息,这个EXE文件在Windows95下将不能运行,因为在这里地址0x10000已被系统使用了。
注意编译器生成的JMP和CALL指令是很重要的,首选它使用相对偏移量的版本,而非32位平坦段中的真实偏移量版本。如果映像需要被载入非连接器假定的基址处,这些指令不需要改变,因为它使用的是相对寻址。结果就是,并不需要你想象的那么多的重定位。重定位通常只需要使用指向一些数据的32位偏移。举个例子,让我们看一下,你有如下的全局变量声明:
 int i;
 int *ptr = &i; 
如果连接器假定一个0x10000的映像基址,变量i的地址将最终是一个特定值如0x12004 。在用来存放指针”ptr”的内存中,连接器将写进0x12004 ,因为这是变量 i 的地址。如果载入器由于某种原因决定把这个文件载入基址0x70000处,变量i的地址将是0x72004 。.reloc 块是映像中的一些内存位置的列表,这些内存位置在连接时连接器假定的载入地址和实际需要的载入地址是不同的,这个因素需要考虑。
当你使用编译器指令 __declspec(thread) 时,你定义的数据不在 .data 和 .bss 块种。它最终在 .tls 块中,这个块指示”线程局部存储”,并且和Win32的TlsAlloc函数族相联系。处理 .tls 块时,内存管理器设置页表以便进程在任何时刻切换线程时,都有一个新的物理内存页集映射到 .tls 块的地址空间。这就允许线程内的全局变量。在大部分情况下,利用这种机制,比基于线程分配内存并把其指针存在一个 “TlsAlloc 过的”(注:原文TlsAlloc’ed)槽(注:原文Slot)中要容易的多。
不幸的是,有一点需要注意–必须深入研究.tls 块和 __declspec(thread) 的变量。在WindowsNT 和Windows95 中,如果DLL是被载入库动态载入的,这种线程局部存储机制将不能在这个DLL中工作。然而在EXE中或一个隐含载入的DLL中,一切都工作正常。如果你不隐含链接到这个DLL ,但需要按线程的数据,你必须会到过去并使用 TlsAlloc 和 TlsGetValue 这种原始方式来设置线程动态内存分配。
虽然 .rdata 块通常在 .data 和 .bss 块之间,你的程序一般看不见并使用这些块中的数据。.rdata 块至少在两种东西中使用。第一,在微软连接器生成的EXE中,.rdata 块存放调试目录,这只在EXE文件中出现。(在 TLINK32 的 EXE 中,调试目录在名为 “.DEBUG”的块中)。调试目录是一个IMAGE_DEBUG_DIRECTORY结构数组。这些结构保持存储在文件中的变量的类型,尺寸,和位置的调试信息。三种主要的调试信息类型显示如下:CodeView?, COFF,和 FPO,表9显示了PEDUMP输出的一个典型的调试目录。
表 7   一个典型的调试目录
Type Size Address FilePtr Charactr TimeDate Version
COFF 000065C5 00000000 00009200 00000000 2CF8CF3D 0.00
??? 00000114 00000000 0000F7C8 00000000 2CF8CF3D 0.00
FPO 000004B0 00000000 0000F8DC 00000000 2CF8CF3D 0.00
CODEVIEW 0000B0B4 00000000 0000FD8C 00000000 2CF8CF3D 0.00

调试目录不必在 .rdata 块的开始找到。为找到调试目录表的开始,使用数据目录的第七个条目(IMAGE_DIRECTORY_ENTRY_DEBUG)的RVA。数据目录在文件的PE首部结尾部分。为确定微软连接器生成的调试目录的条目数,用调试目录的尺寸(在数据目录条目的尺寸域)除以一个IMAGE_DEBUG_DIRECTORY结构的尺寸即可。TLINK32产生一个简单的数目,通常是1 。PEDUMP示例程序描述了这一点。
.rdata 域的另一个有用的部分是”描述串”。如果你在程序的DEF文件中指定一个DESCRIPTION条目,这个指定的描述串就出现在 .rdata 块中。在NE格式中,描述串总是非驻留名表的第一个条目。描述串是用来保持一个描述这个文件的有用的文本串的。不幸的是,我还没找到一条便捷的途径来得到它。我看到有些描述串在PE文件的调试目录之前,在另一些文件中它在调试目录之后。我找不到得到这个描述串的一致的方法(或甚至这种方法根本就不存在)。
.debug$S 和 .debug$T 块只出现在 OBJ 中。他们保存 CodeView 调试符号和类型信息。这些块名是从以前16位编译器($$SYMBOLS 和 $$TYPE)使用的段名继承来的。.debug$T 块的唯一用途是保持包含工程中所有OBJ的CodeView信息的PDB文件的路径。连接器从PDB中读取并且使用它来创建CodeView信息的组成部分,这些CodeView信息放置在PE文件的结尾。
.drectve 块只出现在OBJ文件中。它包含用文本表示的连接器命令。比如,在我用微软编译器编译的任一OBJ中,下面的字符串都出现在 .drectve 块中:
 -defaultlib:LIBC -defaultlib:OLDNAMES
当你在程序中用 __declspec(export) 时,编译器简单的把等价的命令行输出到 .drectve 块中(例如:”-exprot:MyFunction”)。
在玩弄 PEDUMP 的过程中,我不时的遇到其它块。例如,在Window95的KERNEL32.DLL中,有LOCKCODE和LOCKDATA块。大概这是一种特殊的页处理方法,是为了避免缺页(译注)。
译注:缺页,在页式内存管理中,一条指令访问的虚拟内存未映射到物理内存中,此时将发生缺页中断,关于缺页中断,请参阅操作系统相关书籍。
从这里学到两个教训。第一:不要以为有约束而只使用编译器或汇编器提供的标准块。如果由于某种原因你需要一个分开的块,不要犹豫,自己去创建!在C/C++编译器中,使用 #pragma code_seg 和 #pragma data_seg 。在汇编语言中,只不过是创建一个名字和和标准块不同的32位的段(将成为一个块)。如果使用TLINK32 ,你必须使用一个不同的类,或者关掉代码段包装(packing)。其它要记住的东西是使用非标准块名你将会更透彻的理解特殊PE文件的意图和实现。

5 PE文件的导入表
前面,我描述了函数调用怎样到一个外部DLL中而不直接调用这个DLL 。代替的,在执行体中的 .text 块中(如果你用Borland C++ 就是 .icode 块),CALL指令到达一条
JMP DWORD PTR [XXXXXXXX]
指令处。JMP指令寻找的地址把控制转移到实际的目标地址。PE文件的 .idata 会包含一些必要的信息,这些信息是载入器用来确定目标函数的地址以及在执行体映像中去修正他们的。
.idata 块(或称导入表,我更喜欢这样叫)开始于一个IMAGE_IMPORT_DESCRIPTOR数组。每个DLL都有一个PE文件隐含链接上的IMAGE_IMPORT_DESCRIPTOR。没有指定这个数组中结构的数目的域。代替的,这个数组的最后一个元素是一个全NULL的IMAGE_IMPORT_DESCRIPTOR 。IMAGE_IMPORT_DESCRIPTOR的格式显示在表8 。
表 8  IMAGE_IMPORT_DESCRIPTOR Format
DWORD Characteristics
在一个时刻,这可能已是一个标志集。然而,微软改变了它的涵义并不再糊涂地升级WINNT.H 。这个月实际上是一个指向指针数组的偏移(RVA)。其中每个指针都指向一个IMAGE_IMPORT_BY_NAME结构。

DWORD TimeDateStamp
指示这个文件的创建时间。

DWORD ForwarderChain
这个域联系到前向链。前向链包括一个DLL函数向另一个DLL转送引用。比如,在WindowsNT中,NTDLL.DLL就出现了的一些前向的它向KERNEL32.DLL导出的函数。应用程序可能以为它调用的是NTDLL.DLL中的函数,但它最终调用的是KERNEL32.DLL中的函数。这个域还包含一个FirstThunk数组的索引(即刻描述)。用这个域索引得函数会前向引用到另一个DLL 。不幸的是,函数怎样前向引用的格式没有文档,并且前向函数的例子也很难找。

DWORD Name
这是导入DLL的名字,指向以NULL结尾的ASCII字符串。通用例子是KERNEL32.DLL和USER32.DLL 。

PIMAGE_THUNK_DATA FirstThunk
这个域是指向IMAGE_THUNK_DATA联合的偏移(RVA)。几乎在任何情况下,这个域都解释为一个指向的IMAGE_IMPORT_BY_NAME结构的指针。如果这个域不是这些指针中的一个,那它就被当作一个将从这个被导入的DLL的导出序数值。如果你实际上可以从序数导入一个函数而不是从名字导入,从文档看,这是不清楚的。
IMAGE_IMPORT_DESCRIPTOR 的一个重要部分是导入的DLL的名自和两个IMAGE_IMPORT_BY_NAME指针数组。在EXE文件中,这两个数组(由Characteristics域和FirstThunk域指向)是相互平行的,都是以NULL指针作为数组的最后一个元素。两个数组中的指针都指向 IMAGE_IMPORT_BY_NAME 结构。表3以图形显示了这种布局。表12显示了PEDUMP对一个导入表的输出。

 
图 3. 两个平行的指针数组
表 9. 一个EXE文件的导入表
GDI32.dll
  Hint/Name Table: 00013064
  TimeDateStamp:   2C51B75B
  ForwarderChain:  FFFFFFFF
  First thunk RVA: 00013214
  Ordn  Name
    48  CreatePen
    57  CreateSolidBrush
    62  DeleteObject
   160  GetDeviceCaps
    //  Rest of table omitted…

  KERNEL32.dll
  Hint/Name Table: 0001309C
  TimeDateStamp:   2C4865A0
  ForwarderChain:  00000014
  First thunk RVA: 0001324C
  Ordn  Name
    83  ExitProcess
   137  GetCommandLineA
   179  GetEnvironmentStrings
   202  GetModuleHandleA
    //  Rest of table omitted…

  SHELL32.dll
  Hint/Name Table: 00013138
  TimeDateStamp:   2C41A383
  ForwarderChain:  FFFFFFFF
  First thunk RVA: 000132E8
  Ordn  Name
    46  ShellAboutA

  USER32.dll
  Hint/Name Table: 00013140
  TimeDateStamp:   2C474EDF
  ForwarderChain:  FFFFFFFF
  First thunk RVA: 000132F0
  Ordn  Name
    10  BeginPaint
    35  CharUpperA
    39  CheckDlgButton
    40  CheckMenuItem
 
    //  Rest of table omitted…
PE文件的导入表的每一个函数有一个 IMAGE_IMPORT_BY_NAME 结构。IMAGE_IMPORT_BY_NAME结构非常简单,看上去是这样:
 WORD    Hint;
 BYTE    Name[?];
第一个域是导入函数的导出序数的最佳猜测。和NE文件不同,这个值不是必须正确的。于是,载入器指示把它当作一个进行二分查找的建议开始值。下一个是导入函数的名字的ASCIIZ字符串。
为什么有两个平行的指针数组指向结构IMAGE_IMPORT_BY_NAME ?第一个数组(由Characteristics域指向的)单独的留下来,并不被修改。经常被称作提名表。第二个数组(由FirstThunk域指向的)将被PE载入器覆盖。载入器在这个数组中迭代每个指针,并查找每个IMAGE_IMPORT_BY_NAME结构指向的函数的地址。载入器然后用找到的函数地址覆盖这个指向IMAGE_IMPORT_BY_NAME结构的指针。JMP DWORD PTR [XXXXXXXX] 替换指示中的 [XXXXXXXX] 表示 FirstThunk 数组的一个条目。因为由载入器覆盖的这个指针数组实际上保持所有导入函数的地址,叫做”导入地址表”。
对Borland用户,上面的描述有点别扭。由TLINK32产生的PE文件缺少其中一个数组。在这样一个执行体中,IMAGE_IMPORT_DESCRIPTOR(提名数组)中Characteristics域的是0 。于是,仅有的由FirstThunk域(导入地址表)指向的数组在PE文件中就是必须的了。故事到这里应该结束了,除非在我写PEDUMP时深入一个有趣的问题中。在优化上无止境的探索,微软在WindowsNT中”优化”了系统DLL(KERNEL32.DLL等等)的thunk数组。在这个优化中,这个数组中的指针不再指向IMAGE_IMPORT_BY_NAME结构,它们已经包含了导入函数的地址。换句话说,载入器不需要去查找函数的地址并用导入函数的地址覆盖thunk数组(译注)。对希望这个数组包含指向IMAGE_IMPORT_BY_NAME结构的指针的PEDump程序,这导致了一个问题。你可能正在思考,”但是,Matt ,为什么呢不顺便使用提名表数组?”这可能是一个完美的解决方案,除非提名表数组在Borland文件中不存在。PEDUMP处理所有这些情况,但是代码理所当然的就有些杂乱。
译注: 这就是 Bound Import,关于Bound Import,请参阅:
Matt Pietrek “Inside Windows An In-Depth Look into the Win32 Portable Executable File Format, Part 2 ” From MSDN Magazine March 2002 on Internet
URL :http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/PE2.asp
因为导入地址表在一个可写的块中,拦截一个EXE或DLL对另一个DLL的调用就相对容易。只需要修改适当地导入地址条目去指向希望拦截的函数。不需要修改调用者或被调者的任何代码。
注意微软产生的PE文件的导入表并不是完全被连接器同步的,这一点很有趣。所有对另一个DLL中的函数的调用的指令都在一个导入库中。当你连接一个DLL时,库管理器(LIB32.EXE或LIB.EXE)扫描将要被连接的OBJ文件并且创建一个导入库。这个导入库完全不同于16位NE文件连接器使用的导入库。32位库管理器产生的导入库有一个.text块和几个.idata$块。导入库中的.text块包含 JMP [XXXX] 的替换指示,这个替换指示在OBJ文件的符号表中有一个名字来存储它。这个符号名对将从DLL中导出的所有函数名都是唯一的(例如:_Dispatch_Message@4)。导入库中的一个.idata$块包含一个从其中引用的替换指示(译注:即JMP [XXXX]中的XXXX)。另一个.idata$块有一个导入函数名之前的提示序号(hint ordinal)的空间。这两个域就组成了IMAGE_IMPORT_BY_NAME结构。当你晚连接一个使用导入库的PE文件时,导入库的块被加到连接器需要处理的在OBJ文件中的你的块的列表中。一旦导入库中的这个替换指示的名字和和要导入的函数名相同,连接器就假定这个替换指示就是这个导入函数,并修正对这个导入函数,使其指向这个替换指示。导入库中的这个替换指示在本质上就被当作这个导入函数本身了。
除了提供一个导入函数替换指示的代码部分,导入库还提供PE文件的.idata块(或称导入表)的片断。这些片断来自于库管理器放入导入库中的不同的.idata$块。简而言之,连接器实际上不知道出现在不同的OBJ文件中的导入函数和普通函数之间的不同。连接器只是按照它的边框调整规则去建立并结合块,于是,所有的事情就自然顺理成章了。
6 术语
生鲜数据:原文”RawData”,意指未加工过的数据,即原原本本从磁盘上读入而未经过任何改动的数据。
替换指示:原文”thunk”,本质上是一条指令,这条指令中有浮动的地址域。如文中的 jmp [xxxx],其中xxxx是一个浮动地址(floating address),或称可重定位地址(relocatable address)。
OBJ文件:Object文件,即编译器编译产生的目标文件,这种文件只有在(和LIB)连接之后,才能形成可执行文件。
LIB文件:库文件,这种文件中包含一些二进制的代码(数据)及其符号,一般情况下,用到LIB中的哪个符号,连接器连接时,关于那个符号的二进制代码(数据)才会放入最终的执行体中。
RES文件:Widows资源文件,由RC.EXE编译。
EXE文件:不用多说Windows下的可执行文件,这类文件一般有导入表(Import Table)。有少数这类文件有导出表(Export Table)。
DLL文件:Dinamic Link Library ,即动态连接库,用来向其它执行体导出函数(或数据等)。

软件加密技术及实现

阅读更多关于《软件加密技术及实现》

软件加密技术及实现
雷 鹏
( 桂林电子工业学院  计算机系 )
摘  要    当今盗版软件的泛滥成灾几乎已经成为了我们中国民族软件的灾难,为了防止软件的非法复制、盗版,保护软件开发商的利益,就必须对软件进行加密保护。现在市面上有许多反盗版软件,但这类软件多是单机处理,并且只使用简单的加密手段,很容易被解密者破解。
本文描述了一个通过Internet,集加密和电子注册于一身的完善的软件保护方案。该方案基于多种密码学意义上可靠的算法,如对称加密算法,散列算法,数字签名,密钥交换等等。通过对Windows下PE可执行文件的结构及载入机制进行深刻的剖析, 巧妙的使用这些密码学算法及多种反破解方案对PE文件进行加密保护。
在该方案的实现中,使用CryptoAPI中的数字签名算法RSA,加密算法RC2和RC4,散列算法SHA,同时自己编写了使用了MD5算法用于快速计算大量数据的摘要;网络接口使用WinSocket;编程语言选用汇编语言和C++混合编程方式;反破解方案有检测文件完整性、检测代码完整性、反跟踪、反-反汇编、反Dump、代码变形等等。
由于使用了可靠的密码学算法,使软件加密的强度大大提高;由于使用了Internet在线注册方式,用户使用也非常方便。
关键词    加密 ;数字签名 ;散列;反跟踪 ;电子注册
 
Software Protection technique and its realization
LEI  Peng
( GuiLin Institute of Electronic Technology . The Department of Computing )
Abstract    The flooding of pirate software has been a calamity of our national software industry . In order to prevent software from pirate , and protect the profit of the software developer , they must encrypt their software to get a protection . There are several software protection tools in the market currently , but these tools were standalone nine tenths , and they only used simple encryption algorithms , so they could be cracked easily by the crackers .
This thesis describes a perfect software encryption and protection scheme which integrate the encryption and electronic register . This scheme is based on multiple reliable cryptographic algorithms such as symmetric encryption algorithm , digital signature , hashing and key exchange . The PE file format (Portable Executable File Format) and its loading mechanism under Windows are dissected thoroughly in this thesis . Then these cryptographic algorithms and several anti-crack method are used gracefully to encrypt and protect the PE file .
Within the realization of this scheme , the RSA digital signature algorithm , RC2 and RC4 encryption algorithm , SHA hasing algorithm etc in MicroSoft CryptoAPI are used . In order to increase the performace of caculate the digest of large number of data, MD5 hashing algorithm was rewritten . WinSocket API  is used as the network interface . The blend of C++ and assembly are used for easily contoling the bottom layer of the system and simplify the programming . The anti-crack method consits the integralization of the file checking , the integralization of the code checking , and anti-debug , anti-disassembly , anti-dump and code metamorphose etc .
The reliable cyrpto algorithms guarantee the crypto strength . As a result of online register , the retail users and the software developers get convenience .
Key words   Encrypt ; Digital Signature ; Hashing ; Anti-Debug ; Electronic Register

 
目  录
1 概述 1
2 密码学简介
2.1 概念 3
2.2 对称密码算法 6
2.3 公开密码算法 6
2.4 单向散列函数 7
2.5 数字签名 8
3 Windows 环境下 PE 文件简介
3.1 WIN32 与 PE 基本概念 10
3.2 PE首部 12
3.3 PE文件的导入表 14
4 当前流行的一些软件保护技术
4.1 序列号保护 21
4.2 时间限制 22
4.3 Key File 保护 23
4.4 CD-check 23
4.5 反跟踪技术(Anti-Debug) 23
4.6 反-反汇编技术(Anti- Disassmbly) 24
4.7 软件狗 25
4.8 Vbox 保护技术 25
4.9 SalesAgent 保护技术 26
4.10 SecuROM 保护技术 26
4.11 软盘加密 26
4.12 将软件与机器硬件信息结合 26
4.13 加壳 27
5 该软件的设计思想
5.1 传统保护的不足 28
5.2 网络的流行 29
5.3 我的方案 29
5.4 该方案的可行性分析 29
6 该软件的整体构架、开发工具及方法
6.1 需求分析 32
6.2 整体框架 35
6.3 各取所长(汇编与 C/C++ 各取所长) 35
6.4 C/C++ 与汇编语言混合编程时的互调协议 36
6.5 该软件中各模块对语言特性的限制及解决方法 40
6.6 C/C++ 和汇编语言的预编译 45
7 该软件的实现及技术细节
7.1 CryptoAPI 简介 47
7.2 几个公共函数和宏 49
7.3 模块共用的结构体定义 54
7.4 Shield 模块 56
7.4.1 壳程序中API和库函数的处理 59
7.4.2 壳程序主体 62
7.4.3 加密壳程序 63
7.4.4 运行中修改自身代码 64
7.4.5 代码散列校验 64
7.4.6 跳转到客户程序入口 65
7.4.7 载入并销毁 Client 程序的 ImportTable 66
7.4.8 自毁壳程序代码 69
7.4.9 编译方法 70
7.5 Merge 模块 71
7.6 Register 模块 76
7.7 Server 模块 77
7.8 软件授权协议的实现 78
7.9 Client的代码(数据)的加密/解密流程图示 82
8 使用说明及演示
8.1 使用说明 83
8.2 演示及效果 83
9 限制、不足与展望
9.1 使用该软件的限制 86
9.2 该软件的不足 86
9.3 对该软件的展望 87
10 结束语
10.1 总结 91
10.2 致谢 91
参考文献 92
 
1 概述
我引用《应用密码学》作者Bruce Schneier的话:
世界上有两种密码:一种是防止你的小妹妹看你的文件;另一种是防止当局者阅读你的文件资料。
如果把一封信锁在保险柜中,把保险柜藏在纽约的某个地方…,然后告诉你去看这封信。这并不是安全,而是隐藏。相反,如果把一封信锁在保险柜中,然后把保险柜及其设计规范和许多同样的保险柜给你,以便你和世界上最好的开保险柜的专家能够研究锁的装置。而你还是无法打开保险柜去读这封信,这样才是安全的。
意思是说,一个密码系统的安全性只在于密钥的保密性,而不在算法的保密性。
对纯数据的加密的确是这样。对于你不愿意让他看到这些数据(数据的明文)的人,用可靠的加密算法,只要破解者不知道被加密数据的密码,他就不可解读这些数据。
但是,软件的加密不同于数据的加密,它只能是”隐藏”。不管你愿意不愿意让他(合法用户,或 Cracker)看见这些数据(软件的明文),软件最终总要在机器上运行,对机器,它就必须是明文。既然机器可以”看见”这些明文,那么 Cracker,通过一些技术,也可以看到这些明文。
于是,从理论上,任何软件加密技术都可以破解。只是破解的难度不同而已。有的要让最高明的 Cracker 忙上几个月,有的可能不费吹灰之力,就被破解了。
所以,反盗版的任务(技术上的反盗版,而非行政上的反盗版)就是增加 Cracker 的破解难度。让他们花费在破解软件上的成本,比他破解这个软件的获利还要高。这样 Cracker 的破解变得毫无意义–谁会花比正版软件更多的钱去买盗版软件 ?
然而,要做到”难破解”,何尝容易? Sony 曾宣称的超强反盗版(Key 2 Audio音乐 CD反盗版),使用了很尖端的技术,然而最近却被一枝记号笔破解了,成为人们的饭后笑料!
所以,很多看上去很好的技术,可能在 Cracker 面前的确不堪一击。就像马其诺防线一样,Cracker 不从你的防线入手,而是”绕道”。这样,让你的反盗版技术在你做梦也想不到的地方被 Crack 了。
为什么会这样呢 ?归根到底是因为软件在机器上运行,并且软件和机器是分离的–这一点是关键,如果软件和硬件完全绑定,不能分离,是可以做到象 IDEA 之类几乎不可破解的系统的。这将在后面谈传统软件保护技术时详细说明。
对我的这个解决方案,我不能保证Crack高手在几天之内不能破解它,我只能说:”在这个软件中,我尽量堵住了当前破解者普遍使用的方法以及”我想得到”的可能的缺口。”但是我相信,倾注了我三个月心血的反盗版软件,决不是一个”玩具式”的反盗版软件。
2 密码学简介
2.1 概念
(1) 发送者和接收者
假设发送者想发送消息给接收者,且想安全地发送信息:她想确信偷听者不能阅读发送的消息。
(2) 消息和加密
消息被称为明文。用某种方法伪装消息以隐藏它的内容的过程称为加密,加了密的消息称为密文,而把密文转变为明文的过程称为解密。图2-1表明了这个过程。
 
图2-1  加密和解密
明文用M(消息)或P(明文)表示,它可能是比特流(文本文件、位图、数字化的语音流或数字化的视频图像)。至于涉及到计算机,P是简单的二进制数据。明文可被传送或存储,无论在哪种情况,M指待加密的消息。
密文用C表示,它也是二进制数据,有时和M一样大,有时稍大(通过压缩和加密的结合,C有可能比P小些。然而,单单加密通常达不到这一点)。加密函数E作用于M得到密文C,用数学表示为:
E(M)=C.
相反地,解密函数D作用于C产生M
D(C)=M.
先加密后再解密消息,原始的明文将恢复出来,下面的等式必须成立:
D(E(M))=M
(3) 鉴别、完整性和抗抵赖
除了提供机密性外,密码学通常有其它的作用:.
 (a) 鉴别
消息的接收者应该能够确认消息的来源;入侵者不可能伪装成他人。
 (b) 完整性检验
消息的接收者应该能够验证在传送过程中消息没有被修改;入侵者不可能用假消息代替合法消息。
 (c) 抗抵赖
发送者事后不可能虚假地否认他发送的消息。
(4) 算法和密钥
密码算法也叫密码,是用于加密和解密的数学函数。(通常情况下,有两个相关的函数:一个用作加密,另一个用作解密)
如果算法的保密性是基于保持算法的秘密,这种算法称为受限制的算法。受限制的算法具有历史意义,但按现在的标准,它们的保密性已远远不够。大的或经常变换的用户组织不能使用它们,因为每有一个用户离开这个组织,其它的用户就必须改换另外不同的算法。如果有人无意暴露了这个秘密,所有人都必须改变他们的算法。
更糟的是,受限制的密码算法不可能进行质量控制或标准化。每个用户组织必须有他们自己的唯一算法。这样的组织不可能采用流行的硬件或软件产品。但窃听者却可以买到这些流行产品并学习算法,于是用户不得不自己编写算法并予以实现,如果这个组织中没有好的密码学家,那么他们就无法知道他们是否拥有安全的算法。
尽管有这些主要缺陷,受限制的算法对低密级的应用来说还是很流行的,用户或者没有认识到或者不在乎他们系统中内在的问题。
现代密码学用密钥解决了这个问题,密钥用K表示。K可以是很多数值里的任意值。密钥K的可能值的范围叫做密钥空间。加密和解密运算都使用这个密钥(即运算都依赖于密钥,并用K作为下标表示),这样,加/解密函数现在变成:
EK(M)=C
DK(C)=M.
这些函数具有下面的特性(见图2-2):
DK(EK(M))=M.
 
图2-2  使用一个密钥的加/解密

 
图2-3  使用两个密钥的加/解密
有些算法使用不同的加密密钥和解密密钥(见图2-3),也就是说加密密钥K1与相应的解密密钥K2不同,在这种情况下:
EK1(M)=C
DK2(C)=M
DK2 (EK1(M))=M
所有这些算法的安全性都基于密钥的安全性;而不是基于算法的细节的安全性。这就意味着算法可以公开,也可以被分析,可以大量生产使用算法的产品,即使偷听者知道你的算法也没有关系;如果他不知道你使用的具体密钥,他就不可能阅读你的消息。
 密码系统由算法、以及所有可能的明文、密文和密钥组成的。
基于密钥的算法通常有两类:对称算法和公开密钥算法。下面将分别介绍:
2.2 对称密码算法
对称算法有时又叫传统密码算法,就是加密密钥能够从解密密钥中推算出来,反过来也成立。在大多数对称算法中,加/解密密钥是相同的。这些算法也叫秘密密钥算法或单密钥算法,它要求发送者和接收者在安全通信之前,商定一个密钥。对称算法的安全性依赖于密钥,泄漏密钥就意味着任何人都能对消息进行加/解密。只要通信需要保密,密钥就必须保密。
对称算法的加密和解密表示为:
EK(M)=C
DK(C)=M
对称算法可分为两类。一次只对明文中的单个比特(有时对字节)运算的算法称为序列算法或序列密码。另一类算法是对明文的一组比特亚行运算,这些比特组称为分组,相应的算法称为分组算法或分组密码。现代计算机密码算法的典型分组长度为64比特–这个长度大到足以防止分析破译,但又小到足以方便使用(在计算机出现前,算法普遍地每次只对明文的一个字符运算,可认为是序列密码对字符序列的运算)。
2.3 公开密码算法
公开密钥算法(也叫非对称算法)是这样设计的:用作加密的密钥不同于用作解密的密钥,而且解密密钥不能根据加密密钥计算出来(至少在合理假定的长时间内)。之所以叫做公开密钥算法,是因为加密密钥能够公开,即陌生者能用加密密钥加密信息,但只有用相应的解密密钥才能解密信息。在这些系统中,加密密钥叫做公开密钥(简称公钥),解密密钥叫做私人密钥(简称私钥)。私人密钥有时也叫秘密密钥。为了避免与对称算法混淆,此处不用秘密密钥这个名字。
用公开密钥K加密表示为
EK(M)=C.
虽然公开密钥和私人密钥是不同的,但用相应的私人密钥解密可表示为:
DK(C)=M
    有时消息用私人密钥加密而用公开密钥解密,这用于数字签名(后面将详细介绍),尽管可能产生混淆,但这些运算可分别表示为:
EK(M)=C
DK(C)=M
当前的公开密码算法的速度,比起对称密码算法,要慢的多,这使得公开密码算法在大数据量的加密中应用有限。
2.4 单向散列函数
单向散列函数 H(M) 作用于一个任意长度的消息 M,它返回一个固定长度的散列值 h,其中 h 的长度为 m 。
输入为任意长度且输出为固定长度的函数有很多种,但单向散列函数还有使其单向的其它特性:
(1) 给定 M ,很容易计算 h ;
(2) 给定 h ,根据 H(M) = h 计算 M 很难 ;
(3) 给定 M ,要找到另一个消息 M’ 并满足 H(M) = H(M’) 很难。
在许多应用中,仅有单向性是不够的,还需要称之为”抗碰撞”的条件:
要找出两个随机的消息 M 和 M’,使 H(M) = H(M’) 满足很难。
由于散列函数的这些特性,由于公开密码算法的计算速度往往很慢,所以,在一些密码协议中,它可以作为一个消息 M 的摘要,代替原始消息 M,让发送者为 H(M) 签名而不是对 M 签名 。
如 SHA 散列算法用于数字签名协议 DSA中。
2.5 数字签名
提到数字签名就离不开公开密码系统和散列技术。
有几种公钥算法能用作数字签名。在一些算法中,例如RSA,公钥或者私钥都可用作加密。用你的私钥加密文件,你就拥有安全的数字签名。在其它情况下,如DSA,算法便区分开来了??数字签名算法不能用于加密。这种思想首先由Diffie和Hellman提出 。
基本协议是简单的 :
(1) A 用她的私钥对文件加密,从而对文件签名。
(2) A 将签名的文件传给B。
(3) B用A的公钥解密文件,从而验证签名。
这个协议中,只需要证明A的公钥的确是她的。如果B不能完成第(3)步,那么他知道签名是无效的。
这个协议也满足以下特征:
(1) 签名是可信的。当B用A的公钥验证信息时,他知道是由A签名的。
(2) 签名是不可伪造的。只有A知道她的私钥。
(3) 签名是不可重用的。签名是文件的函数,并且不可能转换成另外的文件。
(4) 被签名的文件是不可改变的。如果文件有任何改变,文件就不可能用A的公钥验证。
(5) 签名是不可抵赖的。B不用A的帮助就能验证A的签名。
在实际应用中,因为公共密码算法的速度太慢,签名者往往是对消息的散列签名而不是对消息本身签名。这样做并不会降低签名的可信性。
本章仅对密码学进行了一些简要的介绍,更多的请参阅参考文献[1]。

3 Windows 环境下 PE 文件简介
3.1 WIN32 与 PE 基本概念
只要用过电脑的人都知道什么是 Windows,Windows95 已经是过时的昨日黄花了,Windows98 也已推出将近四年了。2000 年又推出了Windows2000,今年又推出了 WindowsXP,微软的操作系统更新速度是如此的快,以至于昨天还在使用的东西,在今天看来就已经过时了。Windows98 以后,微软传言不在推出 9x 内核的操作系统,但是2000 年下半年却正式推出了 WindowsMillennium,简称 Win.Me 。然而从 WindowsXP 的推出,可以断言,微软不会在升级 Win9x 操作系统了。Windows2000 和 WindowsXP 都是基于 NT 内核的。
所有这些操作系统都使用一种”可移植可执行文件格式”(Portable Executable File Format),简称PE文件格式。
下面简短介绍一下 PE 文件的一些概念。详细内容请参阅参考文献[14]。
Windows NT 继承了 VAX? VMS? 和 UNIX? 的传统。许多 Windows NT 的创始人在进入微软前都在这些平台上进行设计和编码。当他们开始设计 Windows NT 时,很自然的,为了最小化工程的启动时间,他们会使用以前写好的并且已经测试过的工具。用这些工具生成并且工作的可执行文件和 OBJ 文件格式叫做 COFF(Common Object File Format 的首字母缩写)。COFF 的年龄不超过八年。
COFF 本身是一个很好的起点,但是需要扩展到一个现代操作系统如 Windows 95 和 Windows NT 就要进行一些更新。其结果就是产生了(PE格式)可移植可执行文件格式。它被称为”可移植的”是因为在所有平台(如x86,Alpha,MIPS等等)上实现的WindowsNT 都使用相同的可执行文件格式。当然了,也有许多不同的东西如二进制代码的CPU指令。重要的是操作系统的装入器和程序设计工具不需要为任何一种CPU完全重写就能达到目的。
关于PE文件最重要的是,磁盘上的可执行文件和它被WINDOWS载入内存之后(PE文件载入内存之后称为PE映像)是非常相像的(如图 3-1)。WINDOWS载入器不必为从磁盘上载入一个文件而辛辛苦苦创建一个进程。载入器使用内存映射文件机制把文件中相似的块映射到虚拟空间中。构造式的进行分析,一个PE文件类似一个预制的屋子。它本质上开始于这样一个空间,这个空间后面有几个把它连到其余空间的机件(就是说,把它联系到它的DLL上,等等)。这对PE格式的DLL式一样容易应用的。一旦这个模块被载入,Windows 就可以有效的把它和其它内存映射文件同等对待。
 
图 3-1 PE文件和PE映像的布局很相似

对Win32来讲,模块所使用的所有代码,数据,资源,导入表,和其它需要的模块数据结构都在一个连续的内存块中。在这种形势下,你只需要知道载入器把可执行文件映射到了什么地方就可以了。通过作为映像的一部分的指针,你可以很容易的找到这个模块所有不同的块。
另一个你需要知道的概念是相对虚拟地址(RVA)。PE文件中的许多域都用术语RVA来指定。一个RVA只是一些项目相对于文件映射到内存的偏移。比如说,载入器把一个文件映射到虚拟地址0x10000开始的内存块。如果映像中一个实际的表的首址是0x10464,那么它的RVA就是0x464。
(虚拟地址 0x10464)-(基地址 0x10000)=RVA 0x00464
为了把一个RVA转化成一个有用的指针,只需要把RVA值加到模块的基地址上即可。基地址是EXE和DLL内存映射文件的基址,这个基址在Win32中这是一个很重要的概念。为了方便起见,WindowsNT 和 Windows9x用模块的基址作为这个模块的实例句柄(HINSTANCE)。可以对任何DLL调用GetModuleHandle(dllname)得到一个指针去访问它的组件。如果 dllname 为 NULL,则得到执行体自己的模块句柄。这是非常有用的,如通常编译器产生的启动代码将取得这个句柄并将它作为一个参数hInstance传给WinMain 。
3.2 PE首部
和其它可执行文件格式一样,PE文件在众所周知的地方有一些定义文件其余部分面貌的域。首部就包含这样象代码和数据的位置和尺寸的地方,操作系统要对它进行干预,比如初始堆栈大小,和其它重要的块的信息。和微软其它执行体的格式相比,PE格式的执行体的主要的首部不是在文件的最开始。典型的PE文件最开始的数百个字节被DOS残留部分占用。这个残留部分是一个打印如”这个程序不能在DOS下运行!”这类信息的小程序。所以,你在一个不支持Win32的系统中运行这个程序,会得到这类错误信息。当载入器把一个Win32程序映射到内存,这个映射文件的第一个字节对应于DOS残留部分的第一个字节,这是无疑的。于是,和你启动的任一个基于Win32 的程序一起,都有一个基于DOS的程序连带被载入。
和微软的其它可执行格式一样,你可以通过查找它的起始偏移来得到真实首部,这个偏移放在DOS残留首部中。WINNT.H头文件包含了DOS残留程序的数据结构定义(注),使得很容易找到PE首部的起始位置。e_lfanew 域是PE真实首部的偏移。为了得到PE首部在内存中的指针,只需要把这个值加到映像的基址上即可。
// 忽略类型转化和指针转化
pNTHeader = dosHeader + dosHeader->e_lfanew;
注:为了不失简洁,这里未列出这些结构体的完整定义就直接引用,这里直接引用的结构体其定义都在winnt.h中,建议读者在读本章时参考Winnt.h 。
一旦你有了PE主首部的指针,游戏就可以开始了!PE主首部是一个IMAGE_NT_HEADERS的结构,在WINNT.H中定义。这个结构由一个双字(DWORD)和两个子结构组成,布局如下:
DWORD Signature;    // 标志域
IMAGE_FILE_HEADER FileHeader;  
IMAGE_OPTIONAL_HEADER OptionalHeader;
标志域用ASCII表示就是”PE/0/0″。
标志域之后的是结构 IMAGE_FILE_HEADER 。这个域只包含这个文件最基本的信息。这个结构看上去并未从它的原始COFF实现更改过。除了是PE首部的一部分,它还表现在微软Win32编译器生成的COFF OBJ 文件的最开始部分。
这个部分的详细说明请参阅参考文献[14](本人已翻译为中文)。

3.3 PE文件的导入表
因为导入表在该软件的设计中很关键,后面壳程序导入表的构建,对客户程序导入表的载入等,都牵涉到导入表 。所以,在这里有必要说明一下,更详细的说明请参阅参考文献[14][15][16]。
导入表,简单的说,导入表的作用相当于 DOS 的系统中断功能。两者都是操作系统 API 。只不过DOS中断不需要操作系统在载入每个执行体时填入 API 的实际地址,并且,导入表还可以导入除操作系统 API 之外的其它模块中的函数。
在一个PE文件中,当你调用另一模块中的一个函数时(比如在USER32.DLL中的GetMessage ),编译器产生的CALL 指令并不把控制直接转移到在DLL中的这个函数。代替的,CALL 指令把把控制转移到一个也在 .text 中的
  JMP DWORD PTR [XXXXXXXX]
指令处(如图3-2)。 这个 JMP 指令通过一个在导入表中的DWORD变量间接的转移控制。 导入表的这个DWORD包含操作系统函数入口的实际地址。为什么DLL调用用这种方式来实现呢?原来,通过一个位置传送所有的对一个给定的DLL函数的调用,载入器不需要改变每个调用DLL的指令。所有的PE载入器必须做的是把目标函数的正确地址放到导入表的一个 DWORD 中。不需要改变任何call指令本身。
如果你想通过函数指针调用一个函数,事情也会如你所预料的一样。但是,如果你想读取 GetMessage 开始的字节,你将不能如愿。后面讲到反 API 断点时会详细说明。
 
图3-2 一个导入函数调用的图示
前面描述了函数调用怎样到一个外部DLL中而不直接调用这个DLL 。代替的,在执行体中的 .text 块中(如果你用Borland C++ 就是 .icode 块),CALL指令到达一个
JMP DWORD PTR [XXXXXXXX]
指令处。
JMP指令寻找的地址把控制转移到实际的目标地址。PE文件的导入表会包含一些必要的信息,这些信息是载入器用来确定目标函数的地址以及在执行体映像中修正他们的。
导入表开始于一个IMAGE_IMPORT_DESCRIPTOR数组。每个DLL都有一个PE文件隐含链接上的IMAGE_IMPORT_DESCRIPTOR 。没有指定这个数组中结构的数目的域。代替的,这个数组的最后一个元素是一个全NULL的IMAGE_IMPORT_DESCRIPTOR 。IMAGE_IMPORT_DESCRIPTOR的格式显示在表3-1 。
表 3-1  IMAGE_IMPORT_DESCRIPTOR 的格式
DWORD Characteristics
在一个时刻,这可能已是一个标志集。然而,微软改变了它的涵义并不再糊涂地升级WINNT.H 。这个域实际上是一个指向指针数组的偏移(RVA)。其中每个指针都指向一个IMAGE_IMPORT_BY_NAME结构。现在这个域的涵义是 OriginalFirstThunk 。

DWORD TimeDateStamp
表示这个文件的创建时间。

DWORD ForwarderChain
这个域联系到前向链。前向链包括一个DLL函数向另一个DLL转送引用。比如,在WindowsNT中,NTDLL.DLL就出现了的一些前向的它向KERNEL32.DLL导出的函数。应用程序可能以为它调用的是NTDLL.DLL中的函数,但它最终调用的是KERNEL32.DLL中的函数。这个域还包含一个FirstThunk数组的索引(即刻描述)。用这个域索引的函数会前向引用到另一个DLL 。不幸的是,函数怎样前向引用的格式没有文档,并且前向函数的例子也很难找。

DWORD Name
这是导入DLL的名字,指向以NULL结尾的ASCII字符串。通用例子是KERNEL32.DLL和USER32.DLL 。

 

PIMAGE_THUNK_DATA FirstThunk
这个域是指向IMAGE_THUNK_DATA联合的偏移(RVA)。几乎在任何情况下,这个域都解释为一个指向的IMAGE_IMPORT_BY_NAME结构的指针。如果这个域不是这些指针中的一个,那它就被当作一个将从这个被导入的DLL的导出序数值。如果你实际上可以从序数导入一个函数而不是从名字导入,从文档看,这是很含糊的。
IMAGE_IMPORT_DESCRIPTOR 的一个重要部分是导入的DLL的名字和两个IMAGE_IMPORT_BY_NAME指针数组。在EXE文件中,这两个数组(由Characteristics域和FirstThunk域指向)是相互平行的,都是以NULL指针作为数组的最后一个元素。两个数组中的指针都指向 IMAGE_IMPORT_BY_NAME 结构。图 3-3 显示了这种布局。
 
图 3-3 导入表中一个项的结构图示

PE文件导入表中的每一个函数有一个 IMAGE_IMPORT_BY_NAME 结构。IMAGE_IMPORT_BY_NAME结构非常简单,看上去是这样:
 WORD    Hint;
 BYTE    Name[?];
第一个域是导入函数的导出序数的最佳猜测值。和NE文件不同,这个值不是必须正确的。于是,载入器指示把它当作一个进行二分查找的建议开始值。下一个是导入函数的名字的ASCIIZ字符串。
为什么有两个平行的指针数组指向结构IMAGE_IMPORT_BY_NAME ?第一个数组(由Characteristics域指向的)单独的留下来,并不被修改。经常被称作提名表。第二个数组(由FirstThunk域指向的)将被PE载入器覆盖。载入器在这个数组中迭代每个指针,并查找每个IMAGE_IMPORT_BY_NAME结构指向的函数的地址。载入器然后用找到的函数地址覆盖这个指向IMAGE_IMPORT_BY_NAME结构的指针。
JMP DWORD PTR [XXXXXXXX] 中的 [XXXXXXXX] 指向 FirstThunk 数组的一个条目。因为由载入器覆盖的这个指针数组实际上保持所有导入函数的地址,叫做”导入地址表”。
在优化上无止境的探索中,微软在WindowsNT中”优化”了系统DLL(KERNEL32.DLL等等)的thunk数组。在这个优化中,这个数组中的指针不再指向IMAGE_IMPORT_BY_NAME结构,它们已经包含了导入函数的地址。换句话说,载入器不需要去查找函数的地址并用导入函数的地址覆盖thunk数组(译注)。
译注:这就是 Bound Import,关于Bound Import,参考文献[15][16],有详细介绍。不过,在我的软件中,忽略了对Bound Import 的处理,这样会造成一些程序载入速度的减小。但使问题简化了许多。
因为导入地址表在一个可写的块中,拦截一个EXE或DLL对另一个DLL的调用就相对容易。只需要修改适当地导入地址条目去指向希望拦截的函数。不需要修改调用者或被调者的任何代码。
注意微软产生的PE文件的导入表并不是完全被连接器同步的,这一点很有趣。所有对另一个DLL中的函数的调用的指令都在一个导入库中。当你连接一个DLL时,库管理器(LIB32.EXE或LIB.EXE)扫描将要被连接的OBJ文件并且创建一个导入库。这个导入库完全不同于16位NE文件连接器使用的导入库。32位库管理器产生的导入库有一个.text块和几个.idata$块。导入库中的.text块包含 JMP [XXXX] 指令,这条指令的标号在OBJ文件的符号表中用一个符号名来存储。这个符号名对将要从DLL中导出的所有函数名讲都是唯一的(例如:_Dispatch_Message@4)。导入库中的一个.idata$块包含一个从导入库中引用的地址,即导入库的 .text 中的指令:
JMP [XXXX]
中的XXXX 。
另一个.idata$块有一个提示序号(hint ordinal)的空间。这两个域就组成了IMAGE_IMPORT_BY_NAME结构。当你晚期连接一个使用导入库的PE文件时,导入库的块被加到连接器需要处理的块的列表中,这个列表在你的OBJ文件中。一旦导入库中的这个xxxx的名字和和要导入的函数名相同,连接器就假定这条jmp [xxxx]指令就是这个导入函数,并修正其中的xxxx ,使其指向这个.idata$中的一个存储导入函数地址的空间。导入库中的这条jmp [xxxx]指令在本质上就被当作这个导入函数本身了。
除了提供一个导入函数的指令jmp [xxxx],导入库还提供PE文件的.idata块(或称导入表)的片断。这些片断来自于库管理器放入导入库中的不同的.idata$块。简而言之,连接器实际上不知道出现在不同的OBJ文件中的导入函数和普通函数之间的不同。连接器只是按照它的内部规则去建立并结合块,于是,所有的事情就自然顺理成章了。
本文有关导入表的内容,基本上就这么多,要得到更多的信息,请参阅参考文献[14][15][16]。

4 当前流行的一些软件保护技术
4.1 序列号保护
数学算法一项都是密码加密的核心,但在一般的软件加密中,它似乎并不太为人们关心,因为大多数时候软件加密本身实现的都是一种编程的技巧。但近几年来随着序列号加密程序的普及,数学算法在软件加密中的比重似乎是越来越大了。
看看在网络上大行其道的序列号加密的工作原理。当用户从网络上下载某个shareware–共享软件后,一般都有使用时间上的限制,当过了共享软件的试用期后,你必须到这个软件的公司去注册后方能继续使用。注册过程一般是用户把自己的私人信息(一般主要指名字)连同信用卡号码告诉给软件公司,软件公司会根据用户的信息计算出一个序列码,在用户得到这个序列码后,按照注册需要的步骤在软件中输入注册信息和注册码,其注册信息的合法性由软件验证通过后,软件就会取消掉本身的各种限制,这种加密实现起来比较简单,不需要额外的成本,用户购买也非常方便,在互联网上的软件80%都是以这种方式来保护的。
软件验证序列号的合法性过程,其实就是验证用户名和序列号之间的换算关系是否正确的过程。其验证最基本的有两种,一种是按用户输入的姓名来生成注册码,再同用户输入的注册码比较,公式表示如下:
                      序列号 = F(用户名)
但这种方法等于在用户软件中再现了软件公司生成注册码的过程,实际上是非常不安全的,不论其换算过程多么复杂,解密者只需把你的换算过程从程序中提取出来就可以编制一个通用的注册程序。
另外一种是通过注册码来验证用户名的正确性,公式表示如下:
                  用户名称 = F逆(序列号) (如ACDSEE)
这其实是软件公司注册码计算过程的反算法,如果正向算法与反向算法不是对称算法的话,对于解密者来说,的确有些困难,但这种算法相当不好设计。
于是有人考虑到以下的算法:
              F1(用户名称) = F2(序列号)
F1、F2是两种完全不同的的算法,但用户名通过F1算法计算出的特征字等于序列号通过F2算法计算出的特征字,这种算法在设计上比较简单,保密性相对以上两种算法也要好的多。如果能够把F1、F2算法设计成不可逆算法的话,保密性相当的好;可一旦解密者找到其中之一的反算法的话,这种算法就不安全了。一元算法的设计看来再如何努力也很难有太大的突破,那么二元呢?
            特定值 = F(用户名,序列号)
这个算法看上去相当不错,用户名称与序列号之间的关系不再那么清晰了,但同时也失去了用户名于序列号的一一对应关系,软件开发者必须自己维护用户名称与序列号之间的唯一性,但这似乎不是难以办到的事,建个数据库就可以了。当然也可以把用户名称和序列号分为几个部分来构造多元的算法。
特定值 = F(用户名1,用户名2,…序列号1,序列号2…)
现有的序列号加密算法大多是软件开发者自行设计的,大部分相当简单。而且有些算法作者虽然下了很大的功夫,效果却往往得不到它所希望的结果。
4.2 时间限制
  有些程序的试用版每次运行都有时间限制,例如运行10分钟或20分钟就停止工作,必须重新运行该程序才能正常工作。这些程序里面自然有个定时器来统计程序运行的时间。
这种方法使用的较少。
4.3 Key File 保护
Key File(注册文件)是一种利用文件来注册软件的保护方式。Key File一般是一个小文件,可以是纯文本文件,也可以是包含不可显示字符的二进制文件,其内容是一些加密过或未加密的数据,其中可能有用户名、注册码等信息。文件格式则由软件作者自己定义。试用版软件没有注册文件,当用户向作者付费注册之后,会收到作者寄来的注册文件,其中可能包含用户的个人信息。用户只要将该文件放入指定的目录,就可以让软件成为正式版。该文件一般是放在软件的安装目录中或系统目录下。软件每次启动时,从该文件中读取数据,然后利用某种算法进行处理,根据处理的结果判断是否为正确的注册文件,如果正确则以注册版模式来运行。
这种保护方法使用也不多,但是,我个人认为,比时间限制要好。
4.4 CD-check
即光盘保护技术。程序在启动时判断光驱中的光盘上是否存在特定的文件,如果不存在则认为用户没有正版光盘,拒绝运行。在程序运行的过程当中一般不再检查光盘的存在与否。Windows下的具体实现一般是这样的:先用GetLogicalDriveStrings( )或GetLogicalDrives( )得到系统中安装的所有驱动器的列表,然后再用GetDriveType( )检查每一个驱动器,如果是光驱则用CreateFileA( )或FindFirstFileA( )等函数检查特定的文件存在与否,并可能进一步地检查文件的属性、大小、内容等。
4.5 反跟踪技术(Anti-Debug)
好的软件保护都要和反跟踪技术结合在一起。如果没有反跟踪技术,软件等于直接裸露在 Cracker 面前。这里说的反跟踪,指的是反动态跟踪。即防止 Cracker 用 SoftICE 之类的调试器动态跟踪,分析软件。当前的这类软件还有如 TRW 、ICEDUMP 等等。反跟踪技术一般是具有针对性的,即针对某种调试器的反跟踪,而不能防止所有的调试器跟踪,如果有新的破解工具出现,就需要相应的反跟踪技术 。
这种技术一般是检测这些特定的调试器是否驻留内存,如果驻留内存,就认为被跟踪,从而拒绝执行,或进行一些惩罚性措施 。还有一些检测方法,如假设这些调试器在内存中,软件和这些调试器通信,如果结果合乎这些调试器的输出 。就认为被跟踪 。或者在内存中搜寻这些调试器的特征串,如果找到,就认为被跟踪 。有的甚至用中断钩子、SEH(Structural Exception Handle,即结构化异常处理)检测调试器。
4.6 反-反汇编技术(Anti- Disassmbly)
即 Anti-Disassmbly 。可针对专门的反汇编软件设计的”陷阱”,让反汇编器陷入死循环,但这种方法没有通用性。
一般是使用花指令 。这种方法有通用性,即所有的反汇编器都可以用这种方法来抵挡 。这种方法主要是利用不同的机器指令包含的字节数并不相同,有的是单字节指令,有的是多字节指令。对于多字节指令来说,反汇编软件需要确定指令的第一个字节的起始位置,也就是操作码的位置,这样才能正确地反汇编这条指令,否则它就可能反汇编成另外一条指令了。并且,多字节,指令长度不定,使得反汇编器在错误译码一条指令后,接下来的许多条指令都会被错误译码 。所以,这种方法是很有效的 。
实施方法:在程序中加入一些无用的字节来干扰反汇编软件的判断,从而使得它错误地确定指令的起始位置,那么也就达到了干扰反汇编器工作的目的。一般形式如下:

….
….
jmp  L1
dd   012344578h ;这里是一些随机数,用来干扰反汇编器
                ;对指令的译码
L1:
….

4.7 软件狗
软件狗是一种智能型加密工具。它是一个安装在并口、串口等接口上的硬件电路,同时有一套使用于各种语言的接口软件和工具软件。当被狗保护的软件运行时,程序向插在计算机上的软件狗发出查询命令,软件狗迅速计算查询并给出响应,正确的响应保证软件继续运行。如果没有软件狗,程序将不能运行,复杂的软硬件技术结合在一起防止软件盗版。真正有商业价值得软件一般都用软件狗来保护。
平时常见的狗主要有”洋狗”(国外狗)和”土狗”(国产狗)。这里”洋狗”主要指美国的彩虹和以色列的HASP,”土狗”主要有金天地(现在与美国彩虹合资,叫”彩虹天地”)、深思、尖石。总的说来,”洋狗”在软件接口、加壳、反跟踪等”软”方面没有”土狗”好,但在硬件上绝对无法破解(应当说破解难度非常大);而”土狗”在软的方面做的很好,但在硬件上不如”洋狗”,稍有单片机功力的人,都可以复制。
4.8 Vbox 保护技术
Vbox 是一个软件 。它是用来保护其它软件的 。凡被 Vbox 保护的软件,一旦过了试用期,就不能再使用了,删了重装也没用,除非删除整个操作系统再重装。
4.9 SalesAgent 保护技术
SalesAgent 保护的软件一般具有 x 天试用再购买的接口,是一种时间限制保护方式。才用这种保护方式的软件主要有 Macromedia Flash 4 、DreameWaver 等等。
4.10 SecuROM 保护技术
SecuROM (http://www.securom.com)是Sony 开发的一种商业光盘加密技术,它可以阻止用户对加密光盘的复制,被保护的光盘上有 CMS16.dll 、cms32_95.dll 、cms32_nt.dll 这几个文件。很多游戏光盘才用这种保护技术。
4.11 软盘加密
通过在软盘上格式化一些非标准磁道,在这些磁道上写入一些数据,如软件的解密密钥等等。这种软盘成为”钥匙盘”。软件运行时用户将软盘插入,软件读取这些磁道中的数据,判断是否合法的”钥匙盘”。
软盘加密还有其它一些技术,如弱位加密等等。
随着近年来软盘的没落,这种方法基本上退出了历史舞台。
4.12 将软件与机器硬件信息结合
用户得到(买到或从网上下载)软件后,安装时软件从用户的机器上取得该机器的一些硬件信息(如硬盘序列号、BOIS序列号等等),然后把这些信息和用户的序列号、用户名等进行计算,从而在一定程度上将软件和硬件部分绑定。在我的加壳程序中将使用这种方法和其它方法的结合,后面会详细说明。
4.13 加壳
就是在完整的软件上–已编译连接,可以运行的程序上,加上一个”壳”,这个”壳”,对软件进行保护,这些壳一般综合运用了4.1~4.6 节所述的软件保护技术。因为我的设计方案中使用了加壳技术,后面将会详细说明,这里不再赘述。
5 该软件的设计思想
5.1 传统保护的不足
上一章介绍了当前流行的一些软件保护技术,其中有些工作得非常好,如序列号技术,几乎所有的软件都使用了这种技术。
这里我不会指出这些技术如何会被破解,因为前面已经说明了,软件保护都可以被破解。只说出这些方案的”非技术”的缺点。
可以看出,这些技术中,软件和硬件仍然是分离的。
在软件狗保护中,软件和硬件有了一定的结合,但是,还没有把软件和一台特定的机器绑定 。在软盘保护中,软件和硬件也有一定结合。但是用户仍然可以在多台机器上安装同一套软件。只在需要这些硬件的时候时候,如插上软件狗,插入钥匙盘,就可以在多台机器上使用同一套软件了 。并且,要正版用户在使用软件时要插上软件狗,插入钥匙盘,造成很多不必要的麻烦 。如用户的并行口可能用户打印机……还有,由于加入了硬件,这种保护方法的成本较高,对一些比较小的软件,这种方法是不实用的。
而 CD-Check 等光盘加密技术,有个缺点是使用中用户必须插入光盘,而现在的硬盘技术的发展,使得存储容量不再是一个问题,用户往往把光盘上的所有东西都装入硬盘,而要用户在每次运行软件时都插入光盘,有点难以接受。
而比较看好的序列号保护技术,则存在一个通病–算法要软件开发者自己设计,而且,如果一对(序列号,用户名)被 Craker 从 Internet 上发布出去,所有的用户都可以用这对(序列号,用户名)来”注册”软件,从而非法使用。
5.2 网络的流行
现在,我想没有哪一个使用过计算的人没有使用过 Internet。全世界有几亿人在使用 Internet,我们中国有几千万万人在使用 Internet 。许多商业软件也都有 Internet 试用版,共享软件(这里不讨论免费软件)甚至都是在 Internet 上发布的 。而几乎所有的软件在 Internet 上都有破解版 。于是,把软件保护和 Internet 结合起来是自然而然的事 。
要把软件保护和 Internet 结合起来,就自然要保证安全 。要保证安全就离不开密码学,在第一章已经简要介绍了密码学的一些概念 。网络上信息传输的安全很重要,特别是一些敏感信息,如用户资料,密码等等 。
5.3 我的方案
针对前面的一些问题,通过各方面比较,权衡,我提出了这套软件保护方案 。
这套方案集传统的序列号保护 、利用硬件信息保护 、加壳保护 、反跟踪 、反-反汇编 、反 Dump 、反 API 断点等于一身。又加入了密码学中的数字签名 、散列 、密钥交换等等 。形成了我自己独特的 、加密强度更高 、使用上更方便(现在只是作为一个演示,使用上还谈不上方便)、更合用户口味的反盗版 、电子注册解决方案 。
该方案的具体细节将在接下来的两章中进行描述。
5.4 该方案的可行性分析
可以说,没有密码学和网络的话,这个方案只能是纸上谈兵 。我正是看到了这两点,才萌发了这个方案的设计思想 。这个设想在 3 个月之前有了一个模糊的想法,在随后,经过查阅大量相关资料,加上自己多方面的考虑,逐渐地就在我的脑海里有了一个清晰的轮廓,这三个月的时间,只是要把这个设想变成现实 。
现代密码学(特别是非对称密码学),是直到 70 年代才初现端倪的,但是发展很快,到现在,我们已经可以使用许多现成的密码算法 。这些算法甚至成为 Windows 不可分割的一部分,称为 CryptoAPI,如果没有 CryptoAPI,我将不得不把庞大的算法库包括在我的源代码内,甚至有许多算法要自己编写代码实现 。这对我将是一个灾难 !
为了简化设计,并突出主要问题,不拘泥于花俏的外表,我不会去做图形用户接口,而使用简单的 Consol 控制台用户接口 。
踩在巨人的肩膀上,将看的更高,更远。正是有了这么多前人的努力,我才能在此之上进行自己的创新。
Internet 的流行,盗版软件的泛滥,几乎成为我们中国民族软件企业的灾难 。使得该软件具有很积极的现实意义 。–只需要极少的修改,增加图形界面,本系统即可作为商业应用。甚至不需要修改,只编写一些简单的批处理文件,都可以让软件开发者和普通用户方便的使用(后面的演示就是使用简单的批处理文件来简化用户界面的)。
正是基于这些原因,比起传统方案,本方案有以下优点:
(1) 当前许多软件保护技术,大多只求精于使用操作系统本身的特点 、沉溺于繁杂的技术细节,而不考虑使用更好的密码学协议 、算法。本方案不同,首要的是使用可靠的密码学协议 、算法,使加密强度得到了保证。
(2) 通过网络对软件授权 、是当前流行软件保护技术的一个盲点,而综合使用数字签名 、散列技术 、密钥交换等技术的,更是少之又少。据我所知,有一个俄罗斯人编写的非常有名的加壳软件,使用了很多的密码算法–被人们戏称:”用光了世界上所有的密码算法!”但是他仍然没有数字签名以及密钥交换,并且也是单机的。
(3) 通过网络实现软件授权,成本很低,用户使用又非常方便,软件开发者管理软件的销售 、代理 、等等也很方便 。
(4) 本方案不光在密码算法 、协议上设计得很合理,而且,在最终受保护程序执行时,也比传统方案要好,这些将在详述壳程序主要技术时具体说明 。
(5) 本方案有很强的可扩展性,在软件授权 、密码协议的实现上使用了面向对象方法 。在壳程序的开发中使用了结构清晰 、分层次分明的分析、设计 。几个模块相对独立,在此基础上,各个模块在遵循一套规则的前提下可以相对独立设计,如相同的 Merge 、Register 程序,可以用不同的 Shield 壳程序来保护客户软件 。
(6) 本软件使用 VC 和汇编语言组合开发,使得开发成本降低了许多,软件的可靠性 、可用性 、可测试性 、可维护性都有很大的提高 。而当前几乎所有的同类软件都是用汇编语言开发,成本很高 。–如果本软件完全使用汇编语言,在这短短的三个月之间是不可能完成的 。
(7) 使用 CryptoAPI,使该软件的整体设计独立于具体的密码学算法,为了使用不同的算法,只需要修改相关参数即可,而不需要重新编写大量代码;并且,使用 CryptoAPI,使得该软件的体积大大缩小 –如果自己编写密码算法,软件的体积将异常庞大,至少是目前的 3倍以上 。
6 该软件的整体构架、开发工具及方法
6.1 需求分析
本软件的需求,就是一个软件的授权协议,及保护壳应该完成的保护功能,下面将简单说明:
(1) 角色说明(软件授权协议中的各个角色):
P :是一个软件产品;
A :是 P 的开发者;
B :是 A 的一个代理商(也可以是 A 自己) ;
C :是 P的最终用户。
(2) 软件授权的协议执行过程
约定:
(a) Server 、Merge 、Register 都运行在不同的机器上 。Server 运行在 A 的机器上,Merge 运行在 B 的机器上,Register 运行在 C 的机器上 。
(b) 服务器对开发者卖出的软件进行授权—-当然他的软件是用我的软件保护过了的 。
(c) Server 是昼夜不停一直运行着的,它接收来自 Merge 和 Register 的请求 。
(d) 在下面的协议叙述过程中,Server 和 A 、Merge 和 B 、Register 和 C 交叠使用 。它们多数时刻是同义词 。
协议开始执行:
这里对授权协议的说明只是简单的说明该软件的需求 。后面章节将详细地说明软件的授权过程,并给出协议所用的算法 。
(a) A把他的软件 P 给 B,A 运行 Server 。
(b) B 要卖出一套软件 P,就运行 Merge 程序。
Merge 程序产生一个随机的 SN,将此 SN 发往 Server (即 A )。
(c) Server 收到 SN,从注册数据库中查找 SN,如果找到(能找到该 SN 的概率的数量级在   以下),就发回信息给 Merge,它产生了重复的 SN,要它再重新计算一个 SN 。如果未找到(几乎总是找不到的),就把 SN 登进注册数据库,用自己的私人密钥 ASK对 SN 进行数字签名,得到 K1,把 K1 作为解密密码发给B , 同时也将自己的公钥 APK 发给 B 。B 用 K1 对 P 进行加密 。
(d) 软件卖给 C 后,C 可以在本地主机(用户自己的计算机)上,通过网络(拨号上网,或 ADSL,或其它方式)向开发者的网络服务器注册软件,然后才能使用 。C 运行注册程序 Register,Register 从 Q 中取得序列号SN(serial number) , 再取得本地主机的硬件信息 ,计算并存储该硬件信息 HD 的散列值 SAC (System Autentication Code)。 将它发送给服务器 Server 。服务器 Server 从数据库中查找这个 SN,如果找到 , 并且这个序列号的拷贝已经注册 , 并且 它收到的 SAC和以前注册的 SAC相同–相同拷贝可以在同一台被授权的计算机上多次安装/注册–或者找到了 SN,但该 SN 还未注册,就将随 SN 一起发来的 SAC 存入数据库,待以后再验证这台计算机。Server把SN对应的解密密码K1发给用户 C,同时将自己的公钥 APK 也发给 C 。
(e) Register 程序用K1解密 Q,同时用本地主机硬件信息HD的另一个散列值 K2 作为密钥加密 Q,最终得到 R 。
现在,用户C 可以运行软件 R 了(R 就是经过注册的P),R具有了一些反Cracker 功能及其它保护功能。
(3) 最终受保护的软件 P (即上面授权协议执行到最后产生的可执行程序 R )应该具有的功能:
(a)  只能在注册的那太机器上运行(反非法使用) ;
(b)  病毒检测和 Cracker 更改检测(保护功能) ;
(c)  反跟踪功能(Anti-Debug,或称 Anti-Trace );
(d)  反 Dump 功能(Anti-Dump);
(e)  反反汇编功能(Anit-Disassembler);
(f)  其它反 Crack 功能 。
 (4) 协议的图示:
图6-1中方框表示处理的文件,Shield 是”壳程序”, 椭圆表示”处理过程”,也即该软件的模块 。
该图在后面的章节中还要引用到 。
 
6.2 整体框架
从上一节的需求分析可以看到,本软件至少要三个独立的模块:
(1) Server 服务器模块 。
(2) Merge 产生软件拷贝的模块 。
(3) Register 最终用户注册软件的模块 。
然而,还差一个模块,即保护软件的”外壳”模块 。这个模块就叫做 Shield,意思是保护壳 。
整个软件就划分为这四个模块 !
它们的关系如图 6-1,授权协议的过程同时也指出了这四个模块之间的关系。
6.3 各取所长(汇编与 C/C++ 各取所长)
从图中可以看出,Server 、Merge 、Register 模块都没有涉及到底层的操作 。而 Shield 有没有涉及到操作系统底层,由前面的图形及协议过程还不能得出 。
但是,从需求可以知道:Shield 是将要附加到受保护的软件上的,运行受保护的软件时,Shield 程序将首先运行,从文件中提取一些数据,做一些必要的检查,还要解密,还要进行反跟踪,反-反汇编,最终还要在比较容易控制的情况下跳转到原程序的入口等等 。
由于 Shield 程序要进行这些底层的操作,使得它不适用高级语言开发,甚至不可能用高级语言开发 。这时,不得不使用汇编语言–最强大 、最高效,同时也是最难使用的语言 。
但是,从需求中也可以看到,Shield 程序也要使用一些高级的算法,如密码学算法,检验文件完整性算法等等 。这使得如果 Shield 程序完全用汇编语言开发,这么多复杂的,需要较高技巧的算法,可能都要用汇编语言写 。而这些并不是汇编语言所特长的,用汇编语言写的结果只能是:代价高昂,并且可扩展性,可维护性都很差 !
经过认真的考虑,阅读大量的资料,我终于找到了一个折衷的办法,把必须用 、不得不用汇编语言写的部分,如要从文件中提取数据的部分,以及反跟踪,反-反汇编,内存布局设计部分,用汇编语言写,其余部分,涉及到复杂算法的,用 C++ 写 。然后把两部分结合编译。
而 Server 、Merge 、Register,由于不涉及到这些底层的操作,可以全部用高级语言写,我用的是 C++,主要是因为:虽然这三个模块不涉及到最低层的操作,然而它们仍要和 Shield 进行通信(Server 不和 Shield通信),有些算法它们(Merge 、Register和Shield) 还要共用,于是高级语言选择 C++ 是理所当然的 。
至于开发工具,C/C++ 开发工具我选用 VC6.0,不用多说,汇编语言开发工具选用 MASM,版本是7.0,这是程序员使用最多的汇编语言。
6.4 C/C++ 与汇编语言混合编程时的互调协议
既然 Shield 是用 C++ 和汇编语言混合编写的,那么它们之间通信(即互相调用)就是不可避免的。要通信,就要服从共同的协议,经过查阅大量的资料 。我对 C/C++ 和汇编语言之间的通信终于了如指掌 。下面将详细说明:
(1) 命名约定:
VC 编译 C 文件(不是C++文件),产生的目标文件(.obj文件,也即编译器产生的C和可执行文件”之间”的中间文件)中,每个全局符号(函数名、全局变量名)前面都加了一个下划线”_”。也就是说,如果 C 文件中有一个全局函数(C中也只有全局函数)”fun1″,经过编译,在目标文件中,该函数的符号名就是”_fun1″。全局变量也相同。
对 Cpp 文件(C++语言源文件,后面将称为 C++文件)中符号的处理要复杂的多,这里我不打算介绍对类名 、类变量(即类中定义的 static 变量)、类方法(类中的static函数),对象名 、对象变量(类中定义的非 staitc 变量)、对象方法(类中的非 statioc函数)……,这些太多太多,不可能介绍完,并且因为我的设计中也未牵涉到这些方面。下面仅对 C++ 源程序中的全局非重载函数的命名约定进行说明 。
一般的说,C++ 源程序中一个完整的全局函数的声明应该是这样的:
[extern “C”] returntype calling_convension fun_name(paramtype1 [param1] , paramtype2 [param2] ,…);
函数的定义必须和声明完全一致 。对这个函数,定义应该如下:
[extern “C”] returntype calling_convension fun_name(paramtype1 [param1] , paramtype2 [param2] ,…)
{
    …..
    return  v;   // v 的类型应是returntype
}
extern “C” 表示目标文件中函数的命名将按 C 语言的协议,为求简化,我的代码中所有C++ 和汇编语言互相调用的函数都加了 extern “C” 。下面也是假定函数有 extern “C” 声明的 。
对不同的 calling_convension,即调用协议,在目标文件中产生的函数名、以及参数传递的次序,一般都不同。calling_convension 将在下面详细说明。现在只说函数名。
如果 calling_convension 是 __stdcall,产生的函数名,在源程序的函数名之前加一个下划线,在函数名之后加一个”@”,后面在加上该函数形式参数区的字节数(10进制表示)。如对该函数,假设该函数有三个long类型(C语言标准规定long类型在所有的机器中都必须是32位)的形式参数,目标文件中的函数名将是 “_fun_name@12″。
如果 calling_convension 是 __cdecl,产生的函数名,在源程序的函数名之前加一个下划线,在函数名之后没有 “@”,后面也没有该函数形式参数区的字节数(10进制表示)。如对该函数,假设该函数有三个 long形式参数,目标文件中的函数名将是 “_fun_name”。这个命名协议实际上就是C 的标准命名协议 。
其它的 calling_convension,在后面的一个表(表6-1)中示出。
(2) 参数传递协议
对 extern “C” __cdecl 函数,参数传递是从右向左压入堆栈,并且堆栈的恢复由调用者(caller)完成,即对上述函数,如果有以下调用:
fun_name(x,y,z);
产生的汇编指令将是:
push  z
push  y
push  x
call   _fun_name
add   esp , 12
fun_name 的函数体如下:
_fun_name proc
push  ebp
mov   ebp , esp
….
ret     ;这里未跳过参数区(未恢复堆栈)
_fun_name endp
这里只举这个例子 。这类函数由调用者恢复堆栈而不是由被调者(callee)恢复堆栈的一个主要原因是 C 语言允许参数个数可变的函数,如 printf 。这样,callee将”不知道”传递给它的参数到底有几个,而 caller 知道 。这样在一定程度上降低了效率 。后面你将看到 。
对extern “C”  __stdcall 函数,将有如下代码:
函数调用 :
fun_name(x,y,z);
产生的汇编指令将是:
push  z
push  y
push  x
call   _fun_name@12
;;add   esp , 12  ;;没有这条指令,堆栈已由被调函数恢复
fun_name 的函数体如下:
_fun_name@12 proc
push  ebp
mov   ebp , esp
….
ret  12   ;这里跳过了参数区(恢复了堆栈)
_fun_name@12 endp
可以看到,__stdcall 的函数调用产生的指令条数少了一条堆栈恢复指令,代码体积自然小了,执行速度也快了。但丧失了灵活性(一般情况下,传给被调函数的参数必须与函数定义及声明的参数区字节数相同,对该函数,就是 12 个字节,见后面的说明)。
(3) 非 extern “C” 的函数 :
对于非 extern “C” 的函数,参数传递协议差不多,但函数命名协议就有些不同,如对非extern “C” 的 __stdcall 函数 。命名是在函数名前面加上 __imp__ ,函数名后面再加上 @ 和参数区的字节数 。
因为该软件C++语言和汇编语言之间的通信未使用这种调用协议,这种例子不列举了。
(4) 各种函数调用协议列表(表6-1):
 cdecl syscall stdcall basic fortran pascal
前面加下划线 Yes  Yes   
转化为大写    Yes Yes Yes
参数传递方向 ← ← ← → → →
堆栈恢复 caller callee 注 callee callee callee
保存bp(ebp)    Yes Yes Yes
参数个数可变否 ? Yes Yes Yes   
表 6-1  各种函数调用协议
注: 对 stdcall, 当形式参数个数可变时,stdcall 型的函数调用由调用者(caller)来恢复堆栈,而不是被调者(callee),这是显而易见的。在这种情况下,调用协议就和cdecl一样了。
6.5 该软件中各模块对语言特性的限制及解决方法
Server 、Merge 、Register 由于不涉及到系统底层的处理。所以 C++ 的所有语言特性都可以使用 。
然而 Shield 模块不同,因为它附加到其它程序上运行时要进行重定位,所以,所有的”绝对地址”必须进行转化,包括静态变量的地址和函数的地址,C++中用 static 修饰的变量,和全局变量,还有用引号定义的字符串,都是静态变量,它们的绝对地址在程序编译时已经确定 。对函数的调用,编译器一般使用的是函数的相对地址,即相对于 call 指令的下一条指令的地址 。如:
….
….
:01006432    call  fun1   ; 这里的机器码将是 E800001000 :注
:0100643B    mov  eax , 1000
……
:0100743b    push  ebp
:0100743c    mov   ebp , esp
….

注:E8 是这条 call 指令的操作码,00001000 是这条call 指令的操作数,这个操作数是一个相对地址:
0100743b-0100643b == 00001000 。
这样的指令,不管重定位到什么地方都没问题 。但是,C++ 中的虚函数不同(详情请参阅参考文献[13])。它的调用是这样的:假设一个对象anObject ,anObject对象所属的的类有一些虚函数,下面将以图示(图6-2)。
 
从图6-2可以看出,对象 anObject 中有一个指针 vftable,指向一个虚函数表,虚函数表中的每项都是一个函数指针,指向一个函数 。这些指针(地址)都是绝对地址 。
这种机制在一般的程序中工作的很好 。但是,在 Shield 壳程序中,这种机制是不能工作的 。因为 Shield 要附加在任何受保护的程序上都能运行 。再看看 Client (受保护的软件)和 Shield 的部分示意图(图 6-3):
 
可以看到,Client 的基址是 0x01000000,Shield 的基址是 0x00400000,当把 Shield 附加到 Client 上之后,图示如下(图 6-4) :
 
可以看到,vfun1 的地址发生了变化,变成了 0x01101486,而虚函数表中的 pvfun1 仍是 0x00401486 –且不说对象 anObject 中的”虚函数表指针(地址)”的也是错的。这样,运行时当然会出错 。
上面说的只是函数地址的变化,全局数据也一样,地址也会发生变化–因为全局变量的地址也是在编译 Shield 时确定的 。
这样,在 Shield 程序中,就只有两种办法了:
(1) 把虚函数表中的函数地址(和anObject对象中的虚函数表指针)转化成 Shield 附加到 Client 上之后的地址。
(2) 不用虚函数。

因为虚函数机制被 C++ 语言封装起来了,进行地址转化不大可能,所以,在 Shield 程序中”决不能使用虚函数”!
然而,上面也说了,静态变量的地址在 Shield 附加到 Client 上之后也不对了 。由于静态变量没有像虚函数那样被 C++ 封装得那么彻底,所以可以使用静态变量,但要进行地址转化 。地址转化将在后面谈到壳程序的主要技术时详细说明。
上面说了虚函数和静态变量在 Shield 程序中的限制,C++ 中还有一个机制,在 Shield 中也是不能使用的,那就是异常处理机制 。即 try,catch,throw 。异常处理也使用了函数的绝对地址,不能使用的原因和虚函数相同,这里就不再赘述 。
还有,C++ 中的动态内存管理函数,即 new 、delete 也不能使用 。因为它们都和 C++ 库联系起来了 。–除非自己编写 new 、delete处理函数 。但仍受到一些限制,如设置 new handler 要用到库函数 set_new_handle 。
Shield 程序中对 C++ 的使用还有一些限制:不能使用 C++ 库!C++ 库中少不了虚函数,少不了静态变量,绝对地址,不能使用的原因是显而易见的 。
然而理论上 C 库函数可以使用,但不能使用 C 库函数中的 IO 函数,因为其中的IO函数几乎都和全局变量联系起来,不能使用的原因也是显而易见的。但是 C 库函数中的字符串函数,内存操作函数(不包括内存管理函数,如malloc 、free 等)可以使用。但是因为其它一些原因(将在壳程序主要技术中详述),我也没有使用 C 的这些库函数 。
总之,这些限制,使得开发 Shield 程序犹如做一个嵌入式系统,现成的东西大多不能用 。
6.6 C/C++ 和汇编语言的预编译
由于该软件规模较大–大约有 6000 行代码,而且由于 Shield 程序要交叉编译 、调试,使得开发难度很高 。一个小错误可能要反复修改代码,跟踪若干次才能发现,而如果为了找到一个错误而频繁修改代码 –错误更容易积累,–软件工程的原则是:代码的修改弊大于利 。
并且,有好些算法,如 MD5 、和其它加密 、解密算法,Shield 程序要和其它模块共用,然而,在这些算法中调用的其它函数,在 Shield 中和 其它模块中是不同的 。如果就因为这一点而重写这些共用的算法,成本是非常高的,因为算法中出现错误,就要修改,那两处就都要修改,很难保证修改的一致,这将是一个非常棘手的问题 。
幸好,C/C++ 和汇编语言都有强大的预编译功能,利用预编译功能,上面的问题便迎刃而解 。在这里只是先提出这些问题 。第七章将详细说明这些使用预编译的实现方法 。
7 该软件的实现及技术细节
前面介绍了密码学、PE文件格式、传统软件保护方案,及至该软件的设计思想、策略、整体构架。现在到了最后一步,也是关键的实现细节了。
有了前面的基础及整个方案的规划,实现起来就有了明确的目标,有了正确的路子走。然而路仍很多,走哪条呢?
比如说,在密码方面,密码协议的选用、设计,加密算法的选用、设计,密码函数库的选用,是用源代码形式的库,还是用 DLL形式的库?在网络方面,网络接口的选用,是用 WinSocketAPI,还是用 VC已经包装好的 Socket 类 ?是用 TCP协议,还是用 UDP 协议?如何对待待加密的软件?把它加密后存储在硬盘上,运行时把它作为壳程序的一个子进程(或子线程),还是把它和壳程序交织在一起(这一点在前面的章节露出了一点端倪)?……
这些方案选择的痛苦过程我不想多说,我只说最终选了哪一个和为什么选它。
(1) 密码学协议、算法、库的选用:
(a) 密码协议,选用最简单的单向数字签名。–这对本设计方案也足够了。
(b) 密码算法,数字签名算法选用RSA,不用多说,数字签名中的散列算法选用SHA,SHA 到目前还未露出被破解的迹象。加密算法选用 RC2,不用多说。
(c) 密码库(CryptoLib)选用微软 CryptoAPI,因为它在 Win95 ie3.02 以上的版本都有支持。并且,该库是以 DLL 形式提供,生成的代码体积要小许多。在该软件中频繁是用的 MD5 散列算法,因为考虑到效率及灵活性原因,自己编写的代码实现。

(2) 网络接口的选用:
(a) 因为考虑到以后Shield模块也可能要使用网络接口,所以不适用VC的Socket类,而使用WinSocketAPI。
(b) 网络协议,因考虑到实现的简洁性和可靠性,使用TCP协议。
(3) 壳程序与待加密程序的协作:
将壳程序与待加密程序交织在一起,即为待加密的程序加壳,而不是把壳程序和待加密程序分开。
实现整个软件的工作量是巨大的的,代码共计6000多行–不算编译器自动生成的代码。Shield模块中手工写的汇编代码,就有1600多行,还不算测试代码;Shield模块中的C++代码大约有1200行。Merge模块约有1300行代码。Register模块约有600行代码。Server模块有600行代码。其它这些模块共用的算法,结构体定义等等,约有2000行代码。
7.1 CryptoAPI 简介
因为过于复杂的加密算法,–甚至一些看上去比较”简单”的加密算法,如RSA,实现起来都相当困难,所以在过去,许多应用程序只能使用非常简单的加密技术,这样做的结果就是加密强度很低,很容易被破译。
CryptoAPI 的出现,解决了这个问题,使用CryptoAPI,程序员可以方便地在应用程序中加入强大的加密功能,而不必考虑基本的算法。
CryptoAPI是完整的体系结构很复杂,这里只简单的列出CryptoAPI庞大体系结构的一个最常用的子集。
CryptoAPI 本质上是一组函数,这些函数为程序员提供一个访问加密算法的接口。这个接口经过操作系统,最终由底层的CSP实现。
CSP,英文全称Cryptographic Service Providers,即加密服务提供者(模块)。是实现真正的加密服务的独立的模块。概念上,它的实现完全独立于特定的应用程序。以便一个特定的应用程序可以运行在不同的CSP上。然而,事实上,一些有特殊的需求的应用程序就需要一个定制的CSP。
一个CSP至少包括一个DLL和一个带数字签名(一般是微软的数字签名)的DLL文件。为了确保CryptoAPI可以识别这个CSP,签名文件事必须的。
一些CSP可能完全由软件实现,而另外一些则可能通过设备驱动程序由硬件(如智能卡)实现。而那个带数字签名的DLL文件只是作为CSP和操作系统的接口,即SPI,服务提供接口(Service Provider Interface)。这样,应用程序和CSP的底层实现就是完全独立的,它们的耦合达到了最小化。
Microsoft通过捆绑RSA Base Provider在操作系统级提供一个CSP,即RSA公司的公钥加密算法。根据需要,更多的CSP可以增加到应用中。现在国内已有一些公司提供兼容CryptoAPI的硬件设备,如全典网络(iSecureX.com)的UKey300型USB 接口的电子钥匙,iSecureX CSP工具箱,都得到了微软的数字签名。
应用CryptoAPI,可以通过简单的函数调用来加密数据,交换公钥,计算一个消息的摘要以及生成数字签名等等。它还提供高级的管理操作,如从一组可能的CSP中选用一个CSP。此外,CryptoAPI还为许多高级安全性服务提供了基础,包括用于电子商务的SET,用于加密客户机/服务器消息的PCT,用于在各个平台之间来回传递机密数据和密钥的PFX,数字签名等等。
CryptoAPI的体系结构如图7-1:
 
—- 目前支持CryptoAPI的Windows系统有:Windows 95 OSR2、Windows NT SP3及后续版本、Windows 98、Windows 2000等。CryptoAPI的配置信息存储在注册表中,包括如下注册表项:
HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Cryptography/Defaults
HKEY_CURRENT_USER/Software/Microsoft/Cryptography/Providers
7.2 几个公共函数和宏
下面这些函数和宏在Shield 、Merge 、Register模块中都要用到,这些函数理所当然的要调用其它一些函数(如C库函数,WindowsAPI函数等)因此,在声明这些函数的头文件中,都有类似如下预编译指示:

 

#ifdef   SHIELD_PROGRAM
#define  vReadFile         iraReadFile
 #define  vHeapAlloc        iraHeapAlloc
 #define  vHeapFree         iraHeapFree
 #define  vGetProcessHeap   iraGetProcessHeap
#else
#define  vReadFile               ReadFile
 #define  vHeapAlloc              HeapAlloc
 #define  vHeapFree               HeapFree
 #define  vGetProcessHeap         GetProcessHeap
#endif
vXXXX是在下面说明的这些函数中调用的库函数(或API)的别名。宏SHIELD_PROGRAM”告诉”编译器,它现在编译的是Shield模块。要用在Shield模块中声明(定义)的函数 iraXXXX。iraXXXX的接口和功能与XXXX都完全一样,只是Shield模块中的这些函数是我自己声明(定义)的,而Merge和Register模块中的这些函数都是库函数。
还有,在Shield模块中,对所有全局变量的引用都要进行地址转换,地址转换函数是A2IRA,它把编译器编译时确定的全局变量的地址转换为程序运行时的地址(第六章已经说明了Shield模块中的全局变量的地址在编译时和运行时是不同的)。
对这个地址转换函数,在头文件中也有如下预编译指示:
#ifdef   SHIELD_PROGRAM
 #define  vA2IRA(ptr)         A2IRA(ptr)
#else
 #define  vA2IRA(ptr)          (ptr)
#endif

可以看到,在非Shield模块中,vA2IRA什么也不干,而在Shield模块中,它调用A2IRA进行地址转换。
(1)  xxComplexEncrypt, xxComplexDecrypt:
这两个函数使用CryptoAPI,选用RC2块加密算法对输入缓冲中的数据进行加密/解密。
(2)  xxSimpleEncrypt, xxSimpleDecrypt:
这两个函数是符合下述条件的加密/解密函数:
M + k1 + k2 – k1 – k2
即先后用两个密钥加密,解密时可以交换这两个密钥的次序。这一点很重要,可以保证在交换密钥k1和k2的过程中不出现明文M。明文M直到最后用k2解密后才出现。如图 7-2 :
对左边的运算过程:
M1==M+K1
M2==M+K1+K2
M3==M+K1=M1
对右边的运算过程:
M1==M+K1
M2==M+K1+K2
M3==M+K2
当然,xxSimpleEncrypt/xxSimpleDecrypt也符合普通对称密码算法的条件。实际上,这可以扩展到非对称密码算法。只不过K1/K2此时都是密钥对,即加密时用EK1/EK2,而解密时用DK1/DK2。RSA算法就符合K1/K2可交换次序的条件。密码算法的这个特点在有些时候优点,如可进行密钥交换而原始的明文在密钥交换过程中不出现;然而在另外一些时候这又是缺点,如易受到攻击。这要依具体应用而定。
 
(3)  xxSmartEncrypt,xxSmartDecrypt:
这两个函数的对短消息(可能是比密钥还短的消息)的加密/解密速度都很快。用在加密/解密ClientImport中。
(4)  xxGetSysInfo:
取得系统信息,放入参数指定的缓冲区。
(5)  xxGetFAC:
计算指定用受保护文件的FAC(File Authentication Code)。计算FAC时要跳过MyShieldSection的FAC域。

(6)  xxGetFACOffset:
计算受保护文件中MyShieldHeader::FAC的的文件偏移,xxGetFAC中要调用这个函数。
(7)  xxGetLocalKey:
用MD5算法从SysInfo 计算本地密码,用于加密Client。
(8)  xxGetSAC:
用MD5算法从SysInfo计算SAC(System Authentication Code),用于标识本地机器。这个函数对SysInfo进行了一些预处理,使得计算结果和xxGetLocalKey不同。但对同一台机器,这两个函数的计算结果都是相同的。
(9)  xxMD5Digest:
用MD5算法计算一个消息的摘要。
(10) xxGetSNKey:
向Server发出SN,取得从Server返回的SNKey和APK以及其它一些信息(错误代码,错误消息),并用APK验证SNKey。
(11) MD5Init:
初始化MD5_CTX。这个函数中使用了全局变量,当然要用vA2IRA进行地址转换,这里使用的全局变量在Shield模块中是唯一非字符的全局变量。
(12) MD5Update:
更新MD5_CTX。这个函数在计算多个非连续(或缓冲)的内存区域时会多次调用。
(13) MD5Final:
产生最终的MD5摘要。
(14) 宏 Round:
#define Round(x,align)  ((((x)-1)/(align))*(align)+(align))
Round(x,align) 把x按align进位,即,如果x是11,align是3,这个宏的计算结果将是12,等等。这样定义的好处是没有跳转,运行速度快,也容易理解。这个宏在整个软件中多次用到。
7.3 模块共用的结构体定义
typedef struct __RegisterInfo
{
BYTE  SAC[SAC_LENGTH];  // hash of user machine,often HarDware
BYTE  SN[SN_LENGTH];    // the serial number of this copy
}RegisterInfo;
这个结构是用户的注册信息结构。包括SAC,即系统(信息)验证码 System Authentication Code,和SN,即序列号 Serial Number。
typedef struct __SessionStruct
{
   BYTE         PK[MAX_PK_LENGTH]; // developer’s public key
   DWORD        PKLength;  // developer’s public key length
   BYTE         SNKey[SN_KEY_LENGTH];
   DWORD        SNKeyLength;
   RegisterInfo RegInfo;
   DWORD        ErrorCode;
   char         ErrorInfo[MAX_ERROR_INFO_LENGTH];
}SessionStruct;
这个结构是Merge和Register模块与Server通信时的数据结构。各个域的涵义已由注释表明。
typedef struct __MyShieldHeader
{
   DWORD ShieldEntry;      // ;often is 0
   DWORD ShieldImportAddress;
   DWORD ShieldImportSize;
   DWORD ClientEntry;  
   DWORD ClientImportAddress;  
   DWORD ClientImportSize;
   DWORD ClientCodeBase;   // 在 ASM 中,这里存储 Shield
                           // 中要加密的区域基址
   DWORD ClientCodeSize;   // 在 ASM 中,这里存储 Shield
                           // 中要加密的区域大小
   DWORD ClientDataBase; 
   DWORD Signature;        // used to signature my shield
   DWORD EncryptCondition; // 加密其属性是该域的子集的块
                           // 如果这个域是 0,客户程序的块
                           // 就都不加密
   BYTE  FAC[FAC_LENGTH];  // 128位的文件检验码
   BYTE  CAC[CAC_LENGTH];  // 代码散列值,用来验证壳程序代码
                           // 的完整性
   RegisterInfo  RegInfo;
}MyShieldHeader;
这个结构非常重要,它不但是Shield模块和Merge模块通信的链路,而且是从Shield转向Client的桥梁。这个结构的设计,使得整个软件的复杂度大大降低。很难想象,如果没有这个结构,整个软件将会怎样设计,怎样完成。这个结构中各个域的意义也都由注释说的一清二楚,就不再多说。
常量 SHIELD_SIGNATRUE,这个常量的意义相当于PE文件的标志IMAGE_NT_SIGNATURE,作为 Shield文件的标志。
常量MY_SHIELD_HEADER_OFFSET,这个常量指出MyShieldHeader在ShieldSection中的块内偏移,现在是 0x100。
这些模块在汇编语言中的定义和在C++中的定义完全相同,各个域的顺序,类型都完全相同。只是语法不同,就不再多说。
其它各个模块中只有自己使用的结构,类型定义再谈到具体模块时说明。
7.4 Shield 模块
Shield模块可以说是整个软件中最复杂的模块,也是整个软件的灵魂。该模块的内存布局如下(图7-3):
 
图 7-3 表明,壳程序的代码和数据是混合在一起的,该图也从总体上体现了壳程序的执行流程。由于壳程序中的静态变量需要在运行时进行地址转化(个中原因,前面章节已述),即将编译时一个符号的地址转化为运行时的真实地址。采用如下方法:
在Shield的汇编模块中,用一个宏载入一个标号(变量)的运行时地址:
ldira macro   dst,var
 local LL
 call LL
LL:
 pop dst
 add dst,var – LL
 endm

其中,var 和LL的差是确定的,即汇编时和运行时,这两个符号地址的差是相同的,这个差可以在汇编时计算出来(var – LL)。运行时,call 指令的实际作用是把LL的运行时地址压入堆栈,然后再把这个地址弹出给dst,一般是一个寄存器。因为它们的差确定,只要把这个差加给dst,就得到了var的运行时地址。可以图示如下:
 
LL 的运行时地址0x101200算出来存入了dst,只要在加上var和LL的差 0x400,就计算出来了var的运行时地址 0x101600。
为了在C++程序中方便的以同样的方法转换地址,我用汇编语言写了一个函数A2IRA:
 _A2IRA@4  proc near
      call A2IRA@4LL
   A2IRA@4LL:
      pop eax
      sub eax,A2IRA@4LL
      add eax,DWORD PTR [esp+4] ; the parameter
      ret 4
 _A2IRA@4  endp
其中,因A2IRA在C++的头文件中声明为stdcall,所以函数名(标号名)要取名为”_A2IRA@4″。
这种重定位的技术在病毒中经常遇到,我是从 CIH 病毒中学到这一点的。
下面将几节将详细说明壳程序的主要技术:
7.4.1 壳程序中API和库函数的处理
壳程序中对API 的调用,编译器将产生一个jmp [xxxx] 指令,其中的 xxxx 是IAT(导入地址表)中某项的地址,这个地址在编译时就确定了, 但壳程序的代码和数据的地址随客户程序的不同而不同的(这点在前面有过说明),即那个 xxxx 随客户程序的不同而变。
这样加壳时我就要把这些jmp [xxxx] 指令中的 xxxx 修改为对应于客户程序的 yyyy,这样似乎也不是很难,但是,对所有的 jmp [xxxx] 都进行修改,这样做代价太大了,同时,修改指令绝对是一件很危险的事。所以我想出了另一个折衷的方案–
我造了一个表,这个表完全按照 PE Import Table 的定义组织,把壳程序中要用到的 API 登入这个表。并计算表中的地址相对 Section 基址的偏移,将该偏移存入一个称作 MyShieldHeader 的结构中,当然,该结构还保存其它信息。详见源程序。这样做还有一个好处,就是检测API断点变得非常容易,在下面将会看到:
然后,再自己写这些跳转到API的代码。我写了两个汇编语言宏:
apicall macro apiname
 local int3_present,end_this_macro
 ldira eax,ira_&apiname
 mov eax,DWORD PTR [eax]
 ;; 检测 API 入口的 5 个字节,看是否被设置了 int 3 端点指令
 cmp BYTE PTR [eax + 0],0CCH
 jz int3_present
 cmp BYTE PTR [eax + 1],0CCH
 jz int3_present
 cmp BYTE PTR [eax + 2],0CCH
 jz int3_present
 cmp BYTE PTR [eax + 3],0CCH
 jz int3_present
 cmp BYTE PTR [eax + 4],0CCH
 jz int3_present
 jmp dword ptr [eax]
int3_present:
 ldira ecx,ina_&apiname + 2 ;把API的名字载入ecx
 ldira edx,msg_breakpoint_at_api_entry
 jmp show_found_int3_msg_and_exit
end_this_macro:
endm ; macro apicall end

;; 这个宏调用 api_call 产生一个跳转到 API 入口的代码
api_imp macro apiname,arglen
 _ira&apiname&@&arglen equ ira&apiname&@&arglen
 ira&apiname&@&arglen:
  apicall apiname
 endm
调用宏api_imp将生成跳转到指定API的代码,可以看到apicall检测API入口的5个字节有否是断点指令(这里展示的宏和源程序中的略有出入,但功能完全相同)。
这样,检测API断点对C++中的API调用是完全透明的(即C++语言中的API调用不知道它调用的这个API是否检查了断点)。
而在一般情况下,如果我们要检查一个函数入口是否被设置了断点,用的是如下方法:
if(*(char*)fun==0xcc)
{
// 发现了断点
}
这样对导入函数是行不通的,因为调用一个导入函数时,实际上是先把控制转移到一条jmp [xxxx] 指令处,这条jmp [xxxx] 指令才把控制转移到导入函数的入口,其中[xxxx]存放了导入函数的入口地址。如下:
call  _fun    ;在这里调用导入函数 fun
….

_fun:
jmp  [xxxx]
….

:xxxx   0x77febc24  ;0x77febc24是导入函数的入口
….

于是,(*_fun)永远等于jmp指令操作码的第一个字节(0xff),不可能检测到断点。因为断点实际上设置在0x77febc24处。
7.4.2 壳程序主体
壳程序主体是用 C/C++ 写的。在这个 C/C++ 程序中,仅能使用在壳程序框架中导入的 winapi,这些Winapi在C头文件中有声明。这些函数名和Windows自己声明的不同。这里使用与标准WindowsAPI名字不同的函数主要有以下原因:在壳程序主体重包含了Windows头文件,如果和标准WindowsAPI同名,将产生符号名冲突;而如果不包含Windows标准头文件,壳程序中用到的许多Windows头文件中定义的符号将不得不从Windows头文件中复制到壳程序的头文件中,这是一件很麻烦的事情,并且特别容易出错。
在头文件ShieldAPI.h 中声明了壳程序中用到的所有WinAPI函数,这些API函数的命名规则是:在标准WinAPI函数的名字前面加上ira。如标准WinAPI函数MessageBoxA声明为iraMessageBoxA。
对标准C库函数也做类似的处理,如 memcpy,声明为iramemcpy,等等。所不同的就是:
iraMessageBoxA 实际上是我对系统 MessageBoxA 的转调,转调功能实现在用汇编语言写的代码中(见上一节)。
而 iramemcpy(..) 函数是我自己用 C 语言写的,C++ 编译器将为它生成汇编代码。
C++写的壳程序的主体的源文件名是ShieldMain.cpp
(1) 检测 SoftIce 等系统级调试器:
用C++语言写,使用 iraCreateFile(name,…)。
如果文件创建成功,则表示该调试器在内存中。对 softice,name 是”//./NTICE”  或  “//./SICE”,对 winnt 或 win9x。其它调试器只是名字不同而已。
(2) 调用API函数IsDebuggerPresent()来检测是否有调试器存在。
(3) 检测用户级调试器:在壳程序中测试跟踪标志,看是否被置位,如果被置位,表示程序正在被跟踪,主要代码如下:
pushfd
pop eax
test eax,0100h  ; trace flag 在 PSW 的第 9 位
jz trace_flag_not_set
; 发现了调试器
当然,实现反跟踪还有多种方案,如时间差反跟踪,设置SEH进行反跟踪等等。
7.4.3 加密壳程序
大部分加壳软件,它们加密了客户程序代码,但自己的程序代码仍然是明文,可以反汇编出来,即使使用了花指令技术,仍然可以反汇编出来大部分指令。并且壳程序中的字符串都是明文形式,很容易被识破,进而更改。比如壳程序检验 SoftIce,使用了一个字符串 “//./ntice”,这样,用编辑器打开可执行程序,就可以发现这个字符串的明文。如果它没有使用文件完整性检查,那么解密者只需要把”//./ntice” 改成另外一个不存在的名字就可以了,比如 “abcdefghi”,解密时字符个数一定要匹配,否则会出现文件中某项目文件偏移错误。
 我加密了壳程序,当然,加密过程是在Merge模块中实现的。这样,我的壳程序中绝大多数代码的明文都不会出现。为了保证简洁,加密壳程序自己的代码我用的是最简单的直接异或加密。只有极少的代码,如解密壳程序的代码,必须以明文形式存在,即函数 CryptNext,它本来一共只有 20 条指令,加入了花指令,多了差不多一倍。
因为还有一点代码的明文在文件中,所以,对这些明文,我使用了花指令,使得其不能被反汇编。
我经过测试,用这种方法加密的 notepad.exe,明文代码只有两条 !–壳程序入口的跳转代码!
7.4.4 运行中修改自身代码
实现起始也是比较简单的,我的实现是这样的:定义两个函数:CryptAny,CryptNext,用的是最简单的异或加密算法,CryptNext可以指定密钥,CryptAny使用 MyShieldHeader中的一个域作为密钥,不能再指定密钥。
这两个函数的代码都很短,都不到 20 行!
CryptAny 一般是对一个需要变形的代码区加密,也可用于解密。
CryptNext 是对下一条指令开始的一个区块解密,CryptNext中从堆栈中取出下一条指令的地址(在”call CryptNext”指令把它下一条指令的地址压入了堆栈),对该地址开始的区块进行解密。因为CryptNext函数返回时将执行”call CryptNext”的下一条指令,所以该函数只能用于解密。
我编写了一个宏,Anamorph,它对一段代码进行变形,只需要把这段代码插入程序中,并指定变形的终点。变形的起始点就是 Anamorph 的下一条指令。
这使得几乎不可能跟踪,可以使 windasm 的跟踪功能不可用。还没运行到原本用来检测 Trace Flag 代码的地方,就已经出现异常,必须中止。
更详细的请参考源程序。
7.4.5 代码散列校验
如果程序被跟踪,debugger 往往会在程序中写入 int 3 指令,使得代码改变,而我怎么知道它在哪里写了 int 3 指令?不知道!所以,我只能计算代码的校验和,最简单的方法就是把代码的所有内容按 32 位为单位加起来,计算出一个和,存储在文件中(当然存储这个和地那个 32字节要跳过),运行时再重新计算,看是否相等。这样是可行的 !我用了更安全的MD5 散列算法计算代码散列(为了速度,仅计算壳程序的散列,而不是整个 PE Image 的散列–这已经足够了!)来检验。
要计算代码的散列,那么,所有的静态变量必须是只读的 !–我的代码块和数据块石绞和在一个块中的。还有就是,前面也说了,必须把文件中存储这个检验和部分跳过,因为我还计算了文件的散列值,所以也应把文件的散列值跳过,还有,windows 载入程序的时候会把把 API 的地址填入 IAT,从而造成这些域的值发生改变,所以,IAT也要跳过。
实现中我跳过了整个 MyShieldHeader 和整个 ImportTable,因为这样实现起来更容易一点,而且决不会影响保护的强度 !
7.4.6 跳转到客户程序入口
这一项比较关键,因为,一般情况下,Craker 只需要跟踪并在指令指针第一次落到客户代码块时中断程序,这样他就得到了客户程序的入口地址,然后再 Dump 内存,一切 Ok,壳被他脱掉了 !
 所以,不能直接跳转到客户程序的入口,我使用了一种技巧。多次往客户代码块中跳,但是又可以准确的跳回来。是这样的:
 开始时,我先将客户代码的入口压入堆栈,然后–
 我在客户代码中查找 ret 指令,机器码是 0xC3,查找到一个 ret,就把该地址压入堆栈,直到客户代码块结束。
 最后,执行一条 ret 指令,这样,最后的这条 ret 指令将跳转到客户代码中的最后一个 ret 指令处,而这个 ret 指令又跳到客户代码中的倒数第二个 ret 指令。如此反复,客户代码块的第一条 ret 指令将把控制转移到客户代码的入口。
 值得说明的是,在客户代码中找到的ret指令,并不一定就是一个 ret 指令,因为有可能这样的指令中就含有一个 ret 指令的机器码,如:
         mov   eax, 0c3c3c3c3h 
指令中就有4个ret 指令的机器码0xC3,这些0c3h机器码在 ret 链中将被当作
            ret
指令执行,而在客户代码正常运行时却是当成
            mov   eax, 0c3c3c3c3h 
指令执行 !这对 Craker来说的确是一个很大的困惑。
7.4.7 载入并销毁 Client 程序的 ImportTable
壳程序的一切必要的检查工作完成之后,就要载入Client的ImportTable,这是勿庸置疑的。载入Client的ImportTable是一项比较复杂的工作,要遍历整个ImportTable,一项一项的载入,因为每个壳程序都要做这项工作,所以我也就不把如何载入Client的ImportTable作为重点详述。下面只说明如何进行特殊的处理以防止Cracker。
这里如果按普通的方法处理,极容易被破解。因为程序运行起来以后,所有客户块(Client Section)的内容都是明文形式的。这样,程序运行起来进入客户代码以后,解密者只需要把所有客户块的内存 Dump 出来,并找到Client的入口(假设 7.4.6中 那串 ret指令的障碍已被他突破),然后再根据Dump的客户的 ImportTable,重建它即可。这样,解密者不需要了解壳程序是如何工作的,就可以把壳脱掉。
 针对这种解密法,我想出了一个办法,就是在 Merge 程序中,除了把 Import Table 和它所在的块一起加密,在加密这个块之前,先把 Import Table 加密一次。等到壳程序将对块的加密解密掉,再把 Import Table 解密,并同时载入客户 Import Table的 IAT (Import Address Table )。然后将 Import Table 中除 IAT 之外的其它部分清除 !这样,解密者即使 Dump 了内存,它也得不到 Import Table,而一个 EXE 文件没有 Import Table 是不能工作的 !
 但是还有一点问题:如果把 Import Table 作为一个整块加密解密,那么,总有一个时刻,Import Table 在内存中都是完整的 !解密者只要在这个时刻 Dump 内存,仍然可以脱壳 !
 所以,我用了一种方法–逐项加密,在 Merge 中把 Import Table 中的每一项加密,加密时从叶子开始,即从 Import Function 开始,然后是 Import Library,然后是 Import Descriptor。(这个加密过程即MergeNet的方法EncryptImport,但是我感觉在这里说比较合适,后面讲到EncryptImport时画出了流程图。)
 其中还有问题,就是 Import中所有集合(数组,字符串)的结束都是以 NULL 标志结尾,如 Function Name 和 Library Name 都是一个以 zero 字符结束的字符串。加密的时候,可以先计算得到这个字符串的准确长度,这是无疑的 !但是解密时就有问题,有可能在加密时把一个非 0 的字符加密成了 0,这样,解密时计算这个字符串长度就会出错 !发生这种情况的概率是 1/256,在计算机中,这么大的错误是不允许的 !所以,要把字符串长度存起来!Import Function 由一个结构来引导:
IMAGE_IMPORT_BY_NAME 指定,IMAGE_IMPORT_BY_NAME 是这样的结构:
struct  IMAGE_IMPORT_BY_NAME
{
WORD  Hint ; // 提示值 
char    Name[ANY_SIZE];
};
根据 PE 文件的定义,Hint 不必是正确的,它只是用来让载入器在 Import Library 中用二分法查找对应函数时进行优化的一个初值。比如,一个 Import Libray 的 Export Table 中有 1024 个输出函数,如果要导入的函数是第 1 个,且 Hint 也是 1,那么载入器只需要一次查找,即可将它找到,然而,如果 Hint 是 0,那么将需要最多 10 次查找 !这样效率相差了 10 倍,但是,我自己编程载入 Function,根本用不着 Hint,除非用更复杂的其它技术来提高效率 !
然而我的重点是加密,而不是要效率。加密时我把函数名的的长度先计算出来,存在 Hint 中,然后分别加密 Hint 和 Name,注意,是分别加密,不是一起加密 !原因不许多说。解密时先把 Hint 解密,从中取得 Name 的长度,再把 Name 解密。
同样,对Library Name 的加密也用了类似的方法,DLL Name 由 ImportDescriptor 中一个一个域 Name 指定,而ImportDescriptor 中有一个 TimeDataStamp 域,对我来说和 Hint 域一样没用,所以我用它来存 DLL Name 的长度。
然而,对其它项的处理就不一样了,如指向用来指向 IMAGE_IMPORT_BY_NAM 的一个指针(在PE文件中是一个偏移值),叫做 FirstThunk和OrignalFirstThunk,这两个中只有一个有用 (其中详细内情请参阅参考文献[14])。是 DWORD,即 4 个字节,如果这 4 个字节本不为 0,而加密变成了 0,这样的概率是  1/2^32,这么样的概率足以忽略了。并且,也因为 没有其它可以存储它的地方,不过如果怕这个概率仍太大,也有办法,就是把 TimeDateStamp 分成两个 Word,一个 Word 存 Library Name 的长度,一个存该可执行文件从这个Library 导入的Function 个数。
但是,对 ImageImportDescriptor,就实在是没有地方放它的长度了,不过还好,ImageImportDescriptor 足够大,有 20 字节,这样,发生同类错误的概率是 1/2^160。是绝对可以忽略的了,–因为散列表一般也只有 16 字节,是基于忽略 1/2^128 的概率的。
解密时只要逆者这个方向解密就行了 !不在赘述。
并且,解密时,解密一项,导入一项,清除一项 !但是 ImportAddressTable决不能清除 !–因为 Windows API调用是通过它的–只销毁其它项。
不整块加密解密 Import Table 还有一个原因就是 Import Table 的大小不可预知 –虽然 DataDirectory[1] 存了 Import Table 的 RVA 和大小,但大小通常不准却。不过 Windows 也不需要它准确 !
7.4.8 自毁壳程序代码
壳程序运行结束后,会把控制转移到Client 程序,但是,壳程序运行完把控制转移到Client 程序后,自己的代码已全部成了明文,这样,解密者在 Client 程序运行后,把壳程序所在的这个块 Dump 出来,然后就可以进行静态分析了!
为了防止这样的 Cracker,我在把控制转移到 Client 程序之前,也即壳程序运行的最后一条指令 ret 之前,把壳程序自毁掉 !但是,当然自毁壳程程序的这段代码是不能自毁的,谁能抓住自己的头发把自己提起来呢 ?我在自毁代码中也用到了花指令,再次增加Cracker 的难度。代码自毁后这些花指令仍然存在,继续迷惑 Cracker !
为了给 Craker 造成更大的困难,这段自毁代码我也使用了变形技术,并且,为了给解密者留下最小的信息,我使用了一个技巧,即先自毁”自毁代码”后面的代码,然后再自毁”自毁代码”前面的代码,这样,就只有两条指令不能自毁,即下面这两条紧挨着的指令不能自毁。
rep  stosb
ret

 

 

7.4.9 编译方法
用命令行,编译ShieldMain.cpp的命令行为(用VC,要设置环境变量,命令行选项区分大小写但文件名不区分大小写。):
cl  /c /FaMyShield.asm  MyShield.cpp
然后把该 ASM 文件中 的 Extrn 声明的行删除,并把段名_DATA改为_TEXT,我写了一个 UltraEdit 宏,可以做这个工作。该UltraEdit 宏的定义如下:
InsertMode
ColumnModeOff
HexOff
UnixReOff
Find RegExp “%public^t*$”
Replace All ” “
Find RegExp “%extrn^t*$”
Replace All ” “
Find RegExp MatchWord “%end$”
Replace All ” “
Find MatchCase RegExp “%_DATA^t”
Replace All “_TEXT^t”
接下来就是汇编 Shield.Asm,命令行为:
ml /coff  Shield.Asm  /link /subsystem:windows
这样就生成了一个 EXE 文件 Shield.exe。
这个文件不能执行,因为编译器没有为它生成 Import Table。它的 Import Table 是我人工构造的,系统不能识别。
要用 Merge模块把Shield.exe 装配到你要保护的程序(假设它的名字为 P.exe)上之后,Shield才可以运行起来。
7.5 Merge 模块
怎么说呢?Merge 模块实际上是一个装配器,把待加密的软件和Shield装配在一起,使Shield与待加密程序(以后称P)有机的结合在一起。
对Merge 模块,我设计了一个基类,这个基类执行基本的Merge操作,而把一些可能在子类中会改写的函数声明为虚函数。Merge基类主要执行以下操作:
(1) 从Client文件中读取Client的PE首部。
(2) 读取Shield程序,从中提取出Shield程序唯一的Section,即代码块(如果Shield是链接生成的,那么将有两个块,一个代码块,一个数据块,而Merge把这两个块当成一个来处理也没有问题)。
(3) 读取MyShieldSection结构,根据Client的PE首部,修改MyShieldSection其中被汇编器填入的数据。处理这些修改的各个函数:
(a) PatchShieldImport:
将ShieldSection的块偏移加到ShieldImport中各个存储RVA的项上。
(b) PatchMyShieldHeader,这个函数是虚函数:
从Client的PE首部取得相应项,填入ClientEntry,ClientImportAddress,ClientImportSize,ClientCodeBase,ClientDataBase;还将机器的SAC填入MyShieldHeader.SAC。
(4) 根据MyShieldSection中存储的数据,修改Client的PE首部,有以下几个函数:
(a) SetShieldSectionHeader:
在Client的PE首部中增加一个块,即Shield块,将Shield块的属性填入这个ShieldSectionHeader 中,并将Client首部的块数加1,并将下一个SectionHeader全部清零。
(b) UpdateClientHeader:
更新Client的PE首部的其余部分,即:
把入口改为Shield的入口,即原ClientImageBase的大小再加上Shield入口的”块内偏移”成为新的入口地址;
把Shield的ImportAddress和ImportSize设置为Client的ImportAdress和ImportSize。
(5) 为子类中对块进行变换提供接口:
(a) ReadOneSection:
将这个块的所有数据读入缓冲。
(b) TransformOneSection,这个函数是虚函数:
如果这个块的Charicteristics 是MyShieldSection中的EncryptCondition的子集,就调用虚函数Encrypt加密这个块(缓冲中的数据)。因为在子类中可能改写这个函数(如MergeNet子类就改了写此函数。
(c) WriteOneSection:
将缓冲中的数据写入输出文件,再将这个块和下个块之间的数据直接(不加密)写入输出文件。因为根据PE文件的定义,块之间可以有不映射到Image的数据,如下图(图7-5):
因为未映射数据可能被Client程序在执行时从文件读取(最典型的是自解压文件,最后一个块之后的数据是未映射到内存的),所以,不能加密。需要注意的是,未映射数据区0是随Client的PE首部一起读入内存的,这部分将和修改过Client的PE首部一起写入输出文件。
 
(d) WriteOutFile:
先将修改过了的Client的PE首部写入输出文件,再依次调用ReadOneSection 、TransformOneSection 、WriteOneSection将Client的各个块写入输出文件,最后将ShieldSection写入输出文件。这个函数是个”模板函数”(设计模式术语)。

(e) UpdateOutFile:
调用xxGetFAC计算输出文件的MD5散列,写入MyShieldHeader,再将更新后的ShieldSection写入输出文件。
Merge模块的基类到这里就完了。Merge基类的MergeNet子类对基类有如下改写:
(1) 改写虚函数Encrypt:
虚函数Encrypt在基类Merge中是一个空函数。MergeNet需要改写它,因为在Register模块中要交换加密密钥。所以MergeNet::Encrypt执行的动作是:先用xxSimpleEncrypt加密数据,再用xxComplexEncrypt加密数据。
(2) 增加一个产生随机SN的函数GenerateSN,在构造函数中调用GenerateSN产生SN,并向Server发送该SN,再从Server得到SNKey 和APK及其它信息。
(3) 改写PatchMyShieldHeader,计算ShieldSection中代码的MD5散列值存入MyShieldHeader中,然后加密ShieldSection的代码。最后调用Merge:: PatchMyShieldHeader。
(4) 增加一个方法EncryptImport,这个函数以一个IMAGE_SECTION_HEADER为参数。用SmartEncrypt方法加密这个块中的Import,这个函数比较复杂,它的流程图如下 (图7-6):
 
(5) 改写 TransformOneSection,因为要加密ClientImport,所以,在这个函数中判断ClientImport是否在当前块中,如果在,则调用EncryptImport加密ClientImport。
(6) 增加一个方法PatchCAC,这个方法计算ShieldSection的代码(和静态数据)的MD5散列,存入MyShieldHeader中。计算MD5散列时要跳过MyShieldHeader和ShieldImportTable–因为CAC将存放在MyShieldHeader中,而当加密过的Client载入时Windows会向ShieldImportTable中填入导入函数的地址。
(7) 增加一个方法ExtEncrypt,这个方法用简单异或方式加密ShieldSection中90%以上的代码(和静态数据),这些代码(和静态数据)将在程序运行时解密。
7.6 Register 模块
这个模块有很多地方和MergeNet模块的处理相同。
(1) ReadShieldSection:
从输入文件中将ShieldSection读入缓冲。
(2) DeEncrypt:
这个方法对输入的数据做如下处理:
M = xxComplexDecrypt(M,K1)
M = xxSimpleEncrypt (M,K2)
M = xxSimpleDecrypt (M,K1)
M = xxComplexEncrypt(M,K2)
这样与MergeNet中的Encrypt和Shield中的Decrypt合作,可以保证在解密过程中明文不出现在内存中。
(3) 其余方法的功能和MergeNet中的基本相同。如WriteOutFile及其下属方法,但处理要比Merge简单的多。作为类比,WriteOneSection处理了Merge模块中的ReadOneSection、TransformOneSection、WriteOneSection三个模块的功能。因为Register不需要继承,就没有必要分这么多方法。
7.7 Server 模块
Server 的功能主要体现在和Merge与Register通信的过程中。但还有一些特有的技术:
(1) 对数据库文件及其中的数据处理,使用STL(Standard Templete Library ,参考文献[20])。
使用了STL中的Map类模板。Map类的模板参数有三个KeyType 、ValueType 、CompareFunction。
在Server中,KeyType的定义为:
typedef struct  tagUserSN
{
BYTE  SN [SN_LENGTH];
}UserSN;

ValueType的定义为:
typedef struct  tagUserInfo
{
 BYTE  SAC[SAC_LENGTH];
 DWORD IsLisenced;
}UserInfo;

CompareFunction的定义为:
class SNcmp
{
public:
 bool operator()(const UserSN &k1,const UserSN &k2) const
 {
  return memcmp(&k1,&k2,sizeof(UserSN))<0;
 }
};
(2) 因为Server要可以同时处理多个用户的请求,所以要使用多线程技术,而处理用户请求时必须要访问数据库,所以,数据库就成了临界资源,必须保证线程对这个临界资源的互斥访问。
(3) Server要用自己的公钥,即APK对Merge或Register发来的SN进行签名,CryptoAPI中签名的标准用法是先计算数据的散列值,然后对散列值签名。我也依照这个标准,用的签名算法是4096位的RSA,散列算法用SHA。
(4) Server的其它功能将在后面的”授权协议的实现”一节说明。
7.8 软件授权协议的实现
为精确的说明整个软件授权协议的实现,现假定 Server 、Merge 、Register 都运行在不同的机器上。
服务器对开发者卖出的软件进行授权—-当然他的软件是用我的软件保护过了的。
首先,A把他的软件 P 给 B,他就运行 Server,(现假定 Server 的 IP 地址是 202.193.64.34):
Server  datafile.dat  2000  2001
Server 是昼夜不停一直运行着的,它接收来自 Merge 和 Register 的请求。Server 扮演的角色及其运行机制将在下面的协议中说明。
B 要卖出一套软件 P,就运行 Merge 程序:
Merge  P.exe  shield.exe  Q.exe  202.193.64.34  2001
 Merge 程序产生一个随机的 SN –随机数以B 的计算机自开机以来运行时间的毫秒数作为种子,计算一串随机数 — 一共 16 个字节。
 最开始,Merge 程序计算壳程序中除 MyShieldHeader和Import Table之外的其余部分的MD5 散列值 CAC,存入用 MyShieldHeader 中的 CAC 域。
接下来,Merge 程序用SN 直接作为密钥,用简单异或加密算法加密壳程序中的大部分代码(和数据)95% 以上–除了壳程序的 MyShieldHeader 首部和导入表以及解密这层加密的代码不能加密。
 然后,再用 SN 作为密码,用 “SmartEncrypt”算法对 P 的 Import Table 进行逐项加密 ,因为 Import Table 中的每一项都是很小的数据,最小的是两个字节,并且很多项的长度不定。所以使用这个速度特别的快流式加密算法。
 然后,Merge 程序将 SN 发送给 Server,Server 从注册数据库中查找 SN,如果找到(能找到该 SN 的概率的数量级是 1/2^100 以下),提示 Merge,它产生了重复的 SN,要它再重新计算一个 SN。如果未找到(几乎总是找不到的),就把 SN 登记进注册数据库,同时把数据库中该 SN 的 IsLisenced 域置为 False。然后用 SHA 安全散列算法计算 SN的散列 H,用自己的私人密钥 ASK对 H 进行数字签名,得到 SNKey,把 SNKey 作为解密密码发给B,同时也将自己的公钥 APK 发给 B。
 B 收到 APK 之后,即Merge 程序收到服务器返回的 SNKey 和 APK 之后 ,用 APK 对 SNKey 进行验证,验证过程是:用 APK 解密 SNKey,得到 H’  即 “从 Server 得到的SN 的散列 “,再用 SHA 安全散列算法计算 SN 的散列 H,如果 H’ 等于 H,就通过了验证,否则提示错误未通过,将此信息反馈给服务器 Server处理。验证通过后, Merge 程序用 SNKey 的 SHA 安全散列算法的散列值 K1 作为密钥,用一种对称加密算法 — 如 RC2,或 3DES 等,我用的是 RC2,对 P进行加密得到输出文件 Q’。
 然后,Merge 程序将 Q’ 中 MySheildHeader 首部的 SAC 域置为全零。再计算 Q’ 的 MD5 散列值,存入 MySheildHeader 首部的 FAC 域 — 当然计算MD5散列时要跳过 FAC 域 — 不像计算校异或和,可以将这个域置为 0,校验时只需要把文件所有的 FAC_LENGTH部分异或,只要结果为零,就通过了验证,CRC 校验也类似。–然后将改过的 FAC 域写入文件 Q。
软件卖给 C 后,C 运行注册程序 Register:
Register  Q.exe  R.exe  202.193.64.34  2000
首先,Register 验证 Q 的散列值,如果通过,继续,否则认为文件收到损坏,在这时可做一些处理(如再验证一次,或提示 C 去向 B 换一套软件,因为极有可能是光盘收到了物理损坏)。
然后,注册程序再从Q 中取得序列号SN(serial number),再取得本地主机的硬件信息,计算该硬件信息 HD 的散列值 SAC (System Autentication Code)。 将它发送给服务器 Server。
服务器 Server 从数据库中查找这个 SN,如果找到,并且这个序列号的拷贝已经注册,并且 它收到的 SAC和以前注册的 SAC相同–相同的软件拷贝可以在一台被授权的计算机上多次安装/注册–或者找到了 SN,但该 SN 还未注册,就将随 SN 一起发来的 SAC 存入数据库,待以后再验证这台计算机。
接下来用 SHA 安全散列算法计算 SN的散列 H,再用自己的私人密钥 ASK对 H 进行数字签名,得到 SNKey,把 SNKey 作为解密密码发给用户 C,同时将自己的公钥 APK 也发给 C。
如果未找到该 SN,提示客户端,可能有错误,请求重发,多次错误之后可做一些处理(如认为是对服务器的恶意攻击,不在理会从这台客户机上发来的信息)。
注册程序–运行在 C 的计算机上,收到服务器返回的 SNKey 和 APK 之后 ,用 APK 对 SNKey 进行验证,验证过程是:用 APK 解密 SNKey,得到 H’,再用 SHA 安全散列算法计算 SN 的散列 H,如果 H’等于 H,就通过了验证。然后:
Register 程序用 SHA 安全散列算法计算 SNKey 的散列值 K1,一个密码算法把这个 K1 解掉,同时用刚才得到的本地主机硬件信息的另一个散列值作为 LocalKey (得到 LocalKey 的散列算法和得到 SAC 的散列算法不同,但这用两个散列算法计算的输入数据是相同的–都是 C 的硬件信息HD)。
然后, Register 再次计算 LocalKey 的 SHA 安全散列值 K2,再用 K2 对 P 进行加密,在这个过程中。使用SimpleCrypt算法和ComplexCrypt算法相结合和可以保证在解掉 K1并加上 K2 的过程中 P 的明文不出现在内存中。可以防止解密者在这个过程中 dump 内存。
在上述步骤中,同时把加密过的数据就写入了文件 R。
最后注册程序计算文件 R 的MD5 散列值,存入壳程序中的MyShieldHeader结构的 FAC 域。最后再更新文件 R –即把改写了的 MyShieldHeader 中的 FAC 写入文件 R。

 

 

 

7.9 Client的代码(数据)的加密/解密流程图示
图 7-7 描述了Client代码(数据)的加密/解密流程,使用这种方法,可以在整个流程中都不出现原始数据M(这里M指Client中被加密的代码(数据)),只有到最后由Shield解密出Client的代码(数据)。
图 7-7中,SE表示xxSimpleEncrypt,SD表示xxSimpleDecrypt,CE表示xxComplexEncrypt,CD表示xxComplexDecrypt 。

 

8 使用说明及演示
8.1 使用说明
Server、Merge和Register可以在Windows98及WindowsNT4.0 / Windows2000 / WindowsXP下以Consol控制台模式运行。但被该软件加密后的软件只能在WindowsNT4.0 / Windows2000 / WindowsXP下运行。
软件开发者的服务器运行Server程序,命令行如下:
Server  datafile.dat  user_port  seller_port
其中 datafile.dat 就是软件开发者指定的数据库文件(如果该文件不存在或内容非法,server将创建新文件),seller_port是用户 C 注册软件使用的端口,user_port是销售处生成拷贝时向 Server 请求SNKey使用的端口 。
销售处如果要卖出一套软件,执行如下命令行:
Merge  P.exe  shield.exe  Q.exe  serverIP  seller_port
其中p.exe是要加密的软件,serverIP是Server的IP 地址,为销售处开放的端口,Q.exe 是输出文件。
用户买到Q.exe,用如下命令行注册:
Register  Q.exe  R.exe  ServerIP  user_port
其中R.exe是最终的输出文件,其余参数不用多说。
8.2 演示及效果
本软件中有四个 .bat 批处理文件,还有一个 notepad.exe
如果只在一台机器上演示,那么:
(1) 先运行 RunServer.bat –用鼠标双击即可。
(2) 不要关闭 Server,并运行 MakeR.bat –用鼠标双击即可。
最后将生成三个文件  file1.dat  是注册数据库文件, Q.exe 是生成的拷贝,用 Register.exe  注册后才能使用,R.exe 是加密并注册过的 notepad.exe,它只能在你的计算机上使用。
如果要删除在本地生成的文件,请运行 delfiles.bat
为了达到预期的演示效果,按如下步骤操作:
(1) 用UltraEdit打开R.exe ,修改其中一个字节。再运行R.exe,会出现消息框,提示发现自身被改动,拒绝运行。
(2) 用WDASM反汇编R.exe ,可以发现,只有入口的跳转指令反汇编出来是对的,其余几乎所有的指令全部是错误的;用IDAPRO反汇编出来的错误更多。
(3) 在WDASM中载入R.exe ,自动单步运行,由于代码在运行中不断改变自身,使得WDASM马上就出现了异常。
(4) 把R.exe拷贝到另一台机子上,出现提示框,提示一套软件只能在一台机器上,即注册的那台机器上运行。
(5) 把Q.exe拷贝到另一台机子上,运行注册程序,会得到服务器发来的信息:”一个软件拷贝只能注册给一台机器”。生成的R.exe运行时会出现非法操作,这正是我们预期的效果。
(6) 在正确的机器上再次运行注册程序,会得到服务器发来的信息:”机器验证通过”。
(7) 运行SoftICE(正确和非正确的机器均可),再运行R.exe ,会出现消息框,提示在内存中发现了SoftICE,并拒绝运行。
(8) 在SoftICE中设置断点 bpx CreateFileA do “d *(esp+4)”,运行R.exe ,就会提示框,提示在CreateFileA的入口发现了断点指令,拒绝继续运行。(一般情况下,解密者用断点bpx CreateFileA do “d *(esp+4)”来跳过对SoftICE的检测,但是,这里由于检测了断点,解密者是不能用这种方法来解密的。)
(9) 在SoftICE中修改R的Shield块中一个地址的内容,如修改0x1010346的一个字节,就会出现消息框,提示发现自身代码被改动,并拒绝运行。
9 限制、不足与展望
任何事都不能做得完美无缺,本软件当然也不例外。我个人认为,本软件的设计思想比较好,但实现得不是很好。
9.1 使用该软件的限制
(1) 目前只能在WindowsNt/Windows2000/WindowsXP下使用;
(2) 只能对EXE文件加密;
(3) 不能对有自校验功能的软件进行加密。因为有自校验功能的软件和本软件中的检验自身的MD5散列值基于同样的原理,如果文件哪怕是有一位的改动,都会出现校验错误。这类软件典型的是ReadBook,不过ReadBook在发现文件校验出错时仅给出提示,而不阻止用户使用。
9.2 该软件的不足
主要有以下几点:
(1) 对个别文件的加密可能会出现问题,已知的是用本软件加密过的Winzip运行时会出现DLL版本不符的错误。
(2) 因为用本软件加密过的软件在运行时要执行自校验、检查是否盗版、反跟踪、解密源程序代码、填入原程序的Import等操作,使得被加密的软件载入速度会减慢,但载入后,运行性能不受任何影响。在Celeron300A/192RAM/Windows2000环境下,加密过的记事本程序速度慢了大约1秒钟,ACDsee慢了大约3秒钟。在PIII733/192RAM/Windows2000环境下,打开记事本感觉不到速度的减慢。
(3) 因为壳程序将控制转移到原程序后,原程序的代码(和数据)都变成了明文,会被Cracker Dump内存,虽然我清除了原程序的ImportTable,但仍可能被如下破解方案破解:
(4) Cracker扫描内存中所有的 DLL,得出其模块句柄,再扫描其所有导入函数的 RVA,计算出 函数的真实地址,在和这个已加密的程序的 IAT 中的函数地址比较,然后就可以得出IAT中相应项的ImportDescryptor和INT信息。从而重建ImportTable 。整个软件就被破解了。
(5) 在数字签名的实现过程中,如果被中间人攻击,Merge(或Register)会收到不正确的SNKey和APK。如果在Merge和Server通信的过程中被中间人攻击,会造成Server(即软件开发商)的注册数据库被中间人重新构造(相当于获取)。如果中间人还在Register与Server通信过程中窃听,可能会造成用户收到错误的SNKey而验证签名又通过,从而造成解密错误,程序运行时因解密出错误的代码而当机。这不会使软件开发商的软件被破解,但会造成其信誉的损失。使用一些复杂的保密通信协议可以避免这种事情发生,但因早期设计已经定型,后期又没有太多的时间,只能留下这个遗憾了。
9.3 对该软件的展望
如果可能进行后续开发,可以增加以下功能:
(1) 为防止Cracker 用上述 (3) 中的方法破解,可以使用”代码转移”技术,即,把Client程序中的部分代码”转移”到Shield中,而在原来的位置加入一条jmp指令跳转到转移的目标,如图 9-1:
 
但是,如果在Client遇到转移指令,问题就很麻烦。我的设想了两个方案,但实现起来难度都很高。
第一个方案:
约束:只能处理立即数转移–即转移指令中不能有寄存器作为操作数。因为如果有寄存器,那就要对寄存器求值,这就相当于对程序的模拟执行,复杂度很高。
可以处理 :
jxx  xxxx 和 jxx [xxxx] ,其中xxxx 只能是立即数,不能是寄存器。其中jxx表示所有的相对跳转,如可以是jmp(无条件跳转),ja,jna,jb,jnb等等。
还可以处理 call 指令。即:
call  xxxx 和 call [xxxx]; xxxx 只能是立即数,不能是寄存器。
首先,把程序中所有的”基本块”作为图的一个节点。初始状态是只有从入口开始的那个”基本块”(编译原理术语),把它作为图的第一个顶点。
在在遇到跳转指令的时候,把转移的目标–也是一个基本块–作为下一个节点。这即是图的”深度优先”遍历!
当然,要对指令的长度译码,那就必须有整个机器指令集的操作码表,对要处理的转移指令,还必须计算操作数(即转移的目标)等等。
第二个方案:
写一个int 1(trace中断)例程,获取每条指令执行后的机器状态,从而可以获得正确的执行流程。进而可以获得希望转移的代码块。同样,这也需要整个机器指令集的操作码表,等等。
(2) 将Merge和Register与Server的通信协议改为安全通信协议,可以用SSL协议,也可以自己用TCP协议及CryptoAPI设计安全通信协议。阻止中间人攻击。
(3) 为防止Cracker拦截壳程序中调用的API函数,可以只导入两个必要的函数LoadLibraryA和GetProcAddress,其它API函数在使用时才导入。

(4) 为进一步防止Cracker Dump内存,可以把原程序的IAT(导入地址表)拷贝到一个”堆”中,并把原程序中的Import整个(连带IAT)都毁掉,最后把原程序中的所有跳往API的 jmp  [xxxx] 和 call [xxxx]指令中的 xxxx 改为这个堆内存中的 yyyy 。
(5) 此外,还可以使用其它一些更复杂的技术如Shield解密Client后把Client作为一个线程运行,而自己仍在后台监视程序是否被跟踪,等等。
(6) 最后,可以编写友好的图形界面,更容易让用户使用,连接Server不用IP地址而用域名等等。

10 结束语
10.1 总结
通过对PE文件及Windows底层运作机制的深刻剖析,通过Internet,用数字签名,散列,密钥交换等可靠的密码学算法,对软件进行加密保护,加密强度很高,破解难度也很高,在有些方面甚至比一些商业加密软件还要出色。
由于在我国现阶段软件业的特殊需要,加密软件以其特殊的作用,还将在今后很长一段时间中扮演知识产权保护者的角色。加密与解密之间或是说反盗版与盗版之间的斗争还将继续进行下去。我们的目标就是让加密技术在大部分时间里保持对解密技术的技术优势,不断研究新型的加密方法,使解密的技术、时间、资源成本超出被保护软件的研制成本和实用时效,从而在实际意义上保护软件在其生存周期中不被盗版。
10.2 致谢
本文是在古天龙教授和黄源老师的精心指导下完成的。从论文的选题、文章结构的构筑到最后的定稿,都得到了古教授和黄老师的细心指点和提携。古教授和黄老师严谨的治学作风让我受益匪浅。在此仅向古教授和黄老师致以最诚挚的谢意。
本软件在开发过程中也受到古教授和黄老师的精心指导,在古教授和黄老师的指导下,克服了多个技术上难以逾越的障碍。
同时也向关心并支持我的家人、同学和热心的网友致以最衷心的感谢。向我提出宝贵意见的网友有(http://www.CSDN.net):
VeriBigBug ,Handsome ,CoolKiller ,zycat2002(展姚) ,atm2001(松鼠) ,lownr(廖宇雷) ,wowocock(机器猫)等。
 
参考文献
1.  [美] Bruce Schneier . 吴世忠 祝世雄 张文政 等译 . 应用密码学 .
北京:机械工业出版社 ,2000 年1月 第1版
2.  [美] William Stallings . 密码编码学与网络安全原理与实践(第二版) .
北京:电子工业出版社 ,2001 年 4 月
3.  樊麙丰 林东 . 网络信息安全&PGP加密 .
北京:清华大学出版社 ,1998 年 8 月
4.  郑雪 . 软件加密与数据恢复实例 .
北京:人民邮电出版社 ,1997 年 7 月
5.  看雪 . 加密与解密–软件保护技术及完全解决方案 .
北京:电子工业出版社 ,2001 年9月第1版 
6.  A.Menezes , P.van . Handbook of Applied Cryptography .
CRC Press ,1996
7.  吴功宜 徐敬东 韩毅刚 曹勇 . 16位/32位微处理器汇编语言程序设计 .
北京:国防工业出版社 ,1997 年 2 月第1版
8.  [美] Intel Inc . 程荷 武航 译 . 32位系统软件编程指南 .
北京:电子工业出版社 1997年3月第1版
9.  沈美明 温冬婵 . IBM-PC汇编语言程序设计 .
北京:清华大学出版社 ,1996年
10. [美] Young.M.J. Visual C++ 从入门到精通 .
北京:电子工业出版社 ,1999 年 1 月
11. 侯捷 . 深入浅出 MFC 2/e . 武汉:华中科技大学出版社 ,2001 年
12. [美] Scott Meyers . 侯捷 译 . Effective C++ 中文版 2nd Edition .
武汉:华中科技大学出版社 ,2001 年
13. [美] Stanley B.lippman . 侯捷 译 . 深度探索C++对象模型 .
武汉:华中科技大学出版社 ,2001 年
14. Matt Pietrek . Peering Inside the PE: A Tour of the Win32 Portable Executable File Format .
From MSDN Magazine March 1994 on Internet . URL :
http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnwbgen/html/msdn_peeringpe.asp
15. Matt Pietrek . Inside Windows An In-Depth Look into the Win32 Portable Executable File Format, Part 2 .
From MSDN Magazine March 2002 on Internet . URL :
http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/PE2.asp
16. Russ Osterlund . Windows 2000 Loader What Goes On Inside Windows 2000: Solving the Mysteries of the Loader .
From MSDN Magazine March 2002 on Internet . URL :
http://msdn.microsoft.com/msdnmag/issues/02/03/Loader/Loader.asp
17. Portable Executable File Format . From internet . URL :
http://www.windowsitlibrary.com/Content/356/11/1.html
18. Win32ASM Tutorial Resource Kit  Collected and packed by dREAMtHEATER . From Internet . URL : http://www.pediy.com
19. 邹丹 . 关于Windows 95下的可执行文件的加密研究 . 1999 年 6月 .
From Internet . URL : http://www.cqacmm.com/myweb/teach.asp?page=3
20. SGI . Standard Template Library Programmer’s Guide .
From Internet . URL : http://www.sgi.com/tech/stl
21. Chen Ying Hao . CIH v1.4 Source . From Internet .

我的 壳程序 框架

阅读更多关于《我的 壳程序 框架》

;frame.asm

; #########################################################################

      .386
      .model flat , stdcall
      option casemap :none   ; case sensitive

; #########################################################################

; @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
; proto directive can specifier a function type
; but must implement this function use divrective ‘proc’
; and ‘proc’ directive will insert some instructions in your code
; and these instructions are not seen in list file !!
; these code is like below ,
; lines start with ;;;; is assembler auto inserted instructions
;     fun proc x:dword
;;;; push ebp
;;;; mov  ebp , esp
;
; ……
; your function body …
; ….
;
;;;; pop  ebp
;     fun endp

; in api call , this is not alowed , must a direct no condition jump
; to transfer control to api function , code should like below:
;
;     fun:
; jmp DWORD PTR [xxxx]  ; [xxxx] store the address of the api function
;

; because these codes will be used in any different address
; can not use absolute address to addressing data
; in my code , I use IRA (instruction relative address) to every
; gloable variant and API function’s address
; this behavior is defined as a macro ‘ldira’ (LoaD IRA)
; you can see the macro below , thus , in my api fun ,
; this call like this:
;
;     fun:
;       ldira eax , xxxx
; jmp DWORD PTR [eax]
;
; after expand macro ldira , the code is :
;
;     fun:
; call LL
;      LL:
;       pop eax
; add eax , xxxx – LL
; jmp DWORD PTR [eax]
;
; @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

include /masm32/include/windows.inc
include /masm32/include/user32.inc
include /masm32/include/kernel32.inc
include /masm32/include/gdi32.inc

; a void directive
myaddr equ

; ——————————————————————
; macro for put IRA of _ira to _dst
; ——————————————————————
ldira macro   _dst , _ira
 local LL
 call LL
LL:
 pop _dst
 add _dst , myaddr _ira – myaddr LL
 endm

; ——————————————————————
; macro for making STDCALL procedure and API calls.
; ——————————————————————

Scall MACRO fun_name:REQ,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11,p12, /
                     p13,p14,p15,p16,p17,p18,p19,p20,p21,p22

    ;; —————————————
    ;; loop through arguments backwards, push
    ;; NON blank ones and call the function.
    ;; —————————————

    FOR arg,<p22,p21,p20,p19,p18,p17,p16,p15,p14,p13,/
             p12,p11,p10,p9,p8,p7,p6,p5,p4,p3,p2,p1>
      IFNB <arg>    ;; If not blank
        push arg    ;; push parameter
      ENDIF
    ENDM

    call fun_name       ;; call the procedure

ENDM

Section_Start  equ  0 ;1000h ;( 1000h  –  myaddr start  )

MyShieldHeader struc
 ShieldEntry     DD  myaddr start – myaddr _TEXT
 ShieldImportAddress DD  myaddr Start_Import_Table – start + Section_Start
 ShieldImportSize    DD  myaddr End_Import_Table – myaddr Start_Import_Table

 ClientEntry     DD myaddr NoClientProgram@@
 ClientImportAddress DD ?
 ClientImportSize    DD  ?

 ClientCodeBase     DD ? ; need not store , but I reserve it
 ClientDataBase     DD  ?

 Authentication     DB  32 dup(?)  ; 256 bit Authentication Number
MyShieldHeader ends

_TEXT segment public ‘code’
org 0
start:
 jmp EntryPoint

 org start + 10h

WangBaTitle label byte
 db “Íõ°Ëµ°£¡£¡ÏëÆƽ⣿£¿”
 db 0

WangBaMsg label byte
 db “Íõ°Ëµ°£¡£¡ÏëÆƽ⣿£¿” , 0DH , 0AH
 db “ÔÙÐÞÁ¶¼¸Äê°É£¡£¡” , 0DH , 0AH
 db 08h,”ºÇºÇ£¡£¡”  , 0DH , 0AH
 db 08h,”ÒѾ­´ÓÊýѧÉÏÖ¤Ã÷ÁËÕâ¸öÈí¼þ” , 0DH , 0AH
 db 08h,”ÔÚÓîÖæÃðÍö֮ǰÊDz»¿ÉÆƽâµÄ£¡£¡” , 0DH , 0AH
 db 0

 org start + 100h

 shieldHeader MyShieldHeader < > ; here stores MyshieldHeader

 org start + 200h

EntryPoint:

; ####################################################################################
;;;; start of user code

ifdef _MORE_DEBUG
 jmp L1

szDlgTitle db ‘Hello World’,0
szMsg    db ‘Hello World’,0

szDlgTitleIRA DB ‘Addressing by instruction relative address’,0
szMsgIRA      DB ‘Addressing by instruction relative address’,0dh,0ah,0

L1:
 ldira eax , szDlgTitleIRA
 ldira ecx , szMsgIRA

 push MB_OK     ;;;invoke iraMessageBoxA , 0 , eax , ecx , MB_OK
 push ecx
 push eax
 push 0
 call near ptr iraMessageBoxA@16

 ldira eax , WangBaMsg
 ldira ecx , WangBaTitle
 Scall iraMessageBoxA@16,NULL,eax,ecx,MB_OKCANCEL
endif

; ###################################################################################
;               ||||
;   |||| <—– Á÷³Ì˳ÐòÍùÏ£¬ÎÞÌøת
;         /  /
;    ¡Å
; ###################################################################################
; call shield function in C++ source , because shield main and user function it calls
; are in C++ source , it can be very complexcomplex
; extrn _ShieldMain@0:near
 call _ShieldMain@0 ; ShildMain has no parameters

; ###################################################################################

;               ||||
;   |||| <—– Á÷³Ì˳ÐòÍùÏ£¬ÎÞÌøת
;         /  /
;    ¡Å

; ###################################################################################
; Load Client Import Functions

 Scall _LoadClientImport@0
 
; ###################################################################################

;               ||||
;   |||| <—– Á÷³Ì˳ÐòÍùÏ£¬ÎÞÌøת
;         /  /
;    ¡Å

; ###################################################################################
; jump to the client entry point

 ldira ecx , shieldHeader.ClientEntry
 
 Scall iraGetModuleHandleA@4 , NULL ; self module handle put to eax
 
 add DWORD PTR [ecx] , eax ; now [ecx] hold the current client entry point
 
 jmp DWORD PTR [ECX]

; ###################################################################################

 

; ###################################################################################
;;; user code compiled form C++ is int this file
;;; ¿ªÊ¼Ê±Îªµ÷ÊÔÓã¬ÏÖÔÚÕâ¸ö include ÒÑÎÞÓã¬
;;; ½«ÔÚ¡°!¿Ç!Ö÷³ÌÐò¡±Öаüº¬±¾Îļþ¡ª¡ª frame.asm ,
;;; ¿ÉÓÃÓÚ¶à¸ö¡°!¿Ç!Ö÷³ÌÐò¡±£¬Éú³É¶à¸ö¿Ç

; include shield.asm

;;; End of user code
; ###################################################################################

 

; ###################################################################################
; only used when no client , if client is present ,
; execute can not reach here

NoClientProgram@@:
 ldira eax , szMsgNoClient
 ldira ecx , szTitleNoClient
 Scall iraMessageBoxA@16 , NULL , eax , ecx , MB_OK

     push 0
     call near ptr iraExitProcess@4

szTitleNoClient db ‘Have no Client Program!’
  db 0
szMsgNoClient   db ‘Have no Client Program!’,0dh,0ah
  db ‘Shield Terminate!!’
  db 0

; ###################################################################################

 

; ###################################################################################
; This is a function , it convert an address to an IRV , only used in C++ Source
; This function is efficient and perfect ! It Optimized the stack structure
; have only one parameter , this parameter is the address to be converted
 A2IRA@4:
  call A2IRA@4LL
   A2IRA@4LL:
      pop eax
      sub eax , A2IRA@4LL
      add eax , DWORD PTR [esp+4] ; the parameter
      ret 4

; this function return the shield header ,
; can only use in shield MODULE , can not use in other MODULE , such as “merge” MODULE
 GetShieldHeader@0:
  ldira eax , shieldHeader
  ret 0
      
; #############################################################################  

_TEXT ENDS

; #############################################################################
; Import Table Related contents are in this file

  include ShieldImport.asm
 
; this file only contain one function named “LoadClientImport” and have no paramters
; because this function is hard coding with assembly , so i code it with C and
; compile it to assembly 
  include LoadClientImport.asm
 
; #############################################################################

 END start