目 录
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 .