演示版保护技术

​ 本文将介绍一些常用的软件保护技术,对其优缺点进行分析,并给出软件保护的一般性建议 。

序列号保护

​ 首先来看看常见的序列号(又称注册码)保护的工作原理。从网上下载的共享软件(Shareware)一般都有使用时间或功能上的限制,如果超过了共享软件的试用期,就必须到这个软件的公司去注册方能继续使用。注册过程一般是用户把自己的信息(例如用户名、电子邮件地址、机器特征码等)告诉软件公司,软件公司根据用户的信息,利用预先编写的一个用于计算注册码的程序(称为注册机,KeyGen)算出一个序列号,并以电子邮件等形式将其发给用户。用户得到序列号后,在软件中输入注册信息和序列号。当注册信息验证通过后,软件就会取消各种限制,例如时间限制、功能限制等,从而成为完全正式版本。软件每次启动时,会从磁盘文件或系统注册表中读取注册信息并对其进行检查。如果注册信息正确,则以完全正式版的模式运行,否则将作为有功能限制或时间限制的版本来运行。注册用户可以根据所拥有的注册信息得到相应的售后服务。当软件推出新版本后,注册用户还可以向软件作者提供自己的注册信息,以获得版本升级服务。这种保护实现起来比较简单,不需要额外的成本,用户购买也非常方便。网上大部分的软件都是以这种方式实现保护的。

  1. 序列号保护机制

​ 软件验证序列号,其实就是验证用户名和序列号之间的数学映射关系。因为这个映射关系是由软件的设计者制定的,所以各个软件生成序列号的算法是不同的。显然,映射关系越复杂,序列号就越不容易被破解。根据映射关系的不同,程序检查序列号有如下4种基本方法。

​ (1)将用户名等信息作为自变量,通过函数F变换之后得到注册码

​ 将这个注册码和用户输入的注册码进行字符串比较或者数值比较,以确定用户是否为合法用户,公式如下。

1
序列号 = F(用户名)

​ 因为负责验证注册码合法性的代码是在用户的机器上运行的,所以用户可以利用调试器等工具来分析程序验证注册码的过程。由于通过上述方法计算出来的序列号是以明文形式在内存中出现的,我们很容易就能在内存中找到它,从而获得注册码。这种方法在检查注册码合法性的同时,也在用户机器上再现了生成注册码的过程(即在用户机器上执行了函数F)。实际上,这是非常不安全的,因为不论函数F有多么复杂,解密者只需把函数F的实现代码从软件中提取出来,就可编制一个通用的计算注册码程序了。由此可见,这种检查注册码的方法是极其脆弱的。解密者也可通过修改比较指令的方法来通过注册码检查。

​ (2)通过注册码验证用户名的正确性

​ 软件作者在给注册用户生成注册码的时候,使用的仍然是上面那种变换。这里要求F是个可逆变换。而软件在检查注册码的时候,是利用F的逆变换F^-1对用户输人的注册码进行变换的。如果变换的结果和用户名相同,则说明是正确的注册码,即

1
用户名 = F^-1(序列号)

​ 可以看到,用来生成注册码的函数F未直接出现在软件代码中,而且正确注册码的明文也未出现在内存中。所以,这种检查注册码的方法比第1种方法要安全一些。

​ 破解这种注册码检查方法时,除了可以采用修改比较指令的办法,还有如下考虑:

​ 因为F^-1的实现代码是包含在软件中的,所以可以通过F^-1找出其逆变换,即函数F,从而得到正确的注册码或者写出注册机。给定一个用户名,利用穷举法找到一个满足式的用户名 = F^-1(序列号)序列号。这只适用于穷举难度不大的函数。
​ 给定一个序列号,利用式用户名 = F^-1(序列号)变换得出一个用户名(当然,这个用户名中一般包含不可显示字符),从而得到一个正确的用户名序列号对。

​ (3)通过对等函数检查注册码

​ 如果输入的用户名和序列号满足式用户名 = F^-1(序列号),则认为是正确的注册码。采用这种方法,同样可以实现在内存中不出现正确注册码的明文。如果F2是一个可逆函数,则本方法实际上是第2种方法的个推广,解密方法也类似。

1
F1(用户名)=F2(序列号)

​ 上面3种检查注册码的方法采用的自变量都只有1个,自变量是用户名或注册码。

​ (4)同时将用户名和注册码作为自变量(即采用二元函数)

​ 这种检查注册码的方法将采用如下判断规则:当对用户名和序列号进行变换时,如果得出的结果和某个特定的值相等,则认为是合法的用户名序列号对。

1
特定值=F3(用户名,序列号)

​ 这个算法看上去相当不错,用户名与序列号之间的关系不再那么清晰了。但是,同时可能失去了用户名与序列号的一一对应关系,软件开发者很可能无法写出注册机。所以,必须维护用户名与序列号之间的唯一性。建一个数据库就可以了。当然,也可根据这一思路把用户名和序列号分为几个部分来构造多元的算法。

1
特定值=Fn(用户名1,用户名2…序列号1,序列号2)

​ 以上所说的都是注册码与用户名相关的情祝。实际上,注册码也可以与用户名没有关系,这完全取决于软件作者的考虑。
​ 可见,注册码的复杂性问题归根到底是一个数学问题。设计难以求逆的算法。当然,即使检查注册码的算法再复杂,如果可执行程序可以被任意修改,解密者还是可以通过修改比较跳转指令使程序成为注册版。所以,仅有好的算法是不够的,还要结合软件完整性检查等方法。

  1. 如何攻击序列号保护机制

​ 若要找到序列号,或者修改判断序列号之后的跳转指令,最重要的是利用各种工具来定位判断序列号的代码段。

​ 一种办法是通过跟踪输入注册码之后的判断找到注册码。通常用户会在一个编辑框中输入注册码,软件需要调用一些标准的API将用户输人的注册码字符串复制到自己的缓冲区中。利用调试器针对API设置断点的功能,就有可能找到判断注册码的地方。常用的API包括Get WindowTextA(W)、GetDIgItemTextA(W)、GetDlgItemInt,hmemcpy(仅Windows9x/Me)等。程序完成对注册码的判断流程后,一般会显示一个对话框,告诉用户注册码是否正确,这也是一个切人点。MessageBoxA(W)、MessageBoxExA(W)、Show Window、MessageBoxIndirectA(W)、CreateDialogParamA(W)、CreateDialog IndirectParamA(W)、DialogBoxParamA(W)、DialogBoxIndirectParamA(W)等API经常用于显示对话框。
​ 另一种办法是跟踪程序启动时对注册码的判断过程(因为程序每次启动时,都需要将注册码读出并加以判断),从而决定是否以注册版的模式工作。根据序列号存放位置的不同,可以使用不同的API断点。如果序列号存放在注册表中,可以使用RegQuery ValueExA(W)函数;如果序列号存放在INI文件中,可以使用GetPrivateProfileStringA(M、GetPrivateProfileIntA(W)、GetProfileIntA(W)、GetProfileStringA(w)等函数;如果序列号存放在一般的文件中,可以使用CreateFileA(M)、_lopent()等函数。

​ (1)数据约束性的秘诀

​ 这个概念是由+ORC提出的,只在用明文比较注册码的保护方式中使用。在大多数的序列号保护程序中,那个真正的、正确的注册码会于某个时刻出现在内存中。当然,它出现的位置是不定的,但多数情况下它会在一个范围之内,即存放用户输人序列号的内存地址±90h字节的地方。数据约束性(Data constraint)或者密码相邻性(Password proximity)的依据是:加密者在编程的时候需要留意保护功能是否“工作”,必须“看到”用户输入的数字,以及用户输入的转换结果和真正的密码之间的关系,这种联系必须经常地检查以调用这些代码。通常,它们会共同位于一个小的栈区域中(注意:参数或局部变量通常都是存储在栈中的,而软件作者一般会使用局部变量存放临时计算出来的注册码),使它们可以在同一个监视(Watch)窗口中出现。所以,在大多数情况下,真正的密码会在离保存用户输入密码不远的地方露出“马脚”。

​ 运行TraceMe.exe程序,输入用户名“pediy”,序列号“12121212”。单击“Check”按钮,TraceMe将提示序列号错误。不要关闭此提示窗口。运行十六进制工具WinHex,.单击菜单项“Tools’”→“RAM Editor’”或按“Alt+F9”快捷键,打开内存编辑工具。单击“TraceMe”选项,打开Primary Memory内存并查看。按“Ctrl+F”快捷键打开查找对话框,输入假序列号“12121212”,在附近会发现另一个字符串“2470”,这就是真序列号,结果如图所示。

image-20240722153835542

​ OllyDbg也可以实现这种查找功能。用OllyDbg加载TraceMe,输入假序列号,单击“Check’”按钮直到出现错误提示框。按“Alt+M”快捷键打开内存窗口,在上面一行按“Ctl+B”快捷键打开搜索框,搜索刚输人的序列号“12121212”,如图所示。OllyDbg的数据查找功能非常有用,可以在当前进程的整个内存映像里查找数据。

image-20240722154434391

image-20240722154417838

​ (2)hmemcpy函数

​ hmemepy函数(俗称“万能断点”)是Windows9x系统的内部函数,它的作用是将内存中的一块数据复制到另一个地方。由于Windows9x系统频繁使用该函数处理各种字符串,将该函数作为断点是非常实用的,该函数也成为Windows9x/Me平台最常用的断点。Windows NT/2000以上版本的系统中没有这个断点,因为其内核和Windows9x完全不同。

​ (3)利用消息断点

​ 许多序列号保护软件都有一个按钮,当按下和释放鼠标时,将发送WM_LBUTTONDOWN(O201h)和WM LBUTTONUP(O202h)消息。因此,用这个消息下断点很容易就能找到按钮的事件代码。

​ (4)利用提示信息

​ 目前大多数软件在设计时采用了人机对话的方式。所谓人机对话,即软件在执行一段程序之后会显示一串提示信息,以反映该段程序运行后的状态。例如,在TraceMe实例中输入假序列号,会显示“序列号错误,再来一次”。可以用OllyDbg、IDA等反汇编工具查找相应的字符串,定位到相关代码处。

​ 用OllyDbg打开TraceMe..exe实例,单击右键,在弹出的快捷菜单中执行“Search for’”→“AIl referenced text strings”(“查找”→“所有参考文本字串”)命令,OllyDbg将列出程序中出现的字符串。但OllyDbg自带的这个功能对中文支持得不好,因此建议使用Ultra String Reference插件。安装插件后,在右键快捷菜单中执 行“Ultra String Reference ” → “Find ASCII’”命令,即可列出中文字符串,双击相关字符串即可定位到所需代码处。

  1. 字符串比较形式

​ 在序列号分析过程中,字符串处理是一个重点,因此我们必须掌握一定的分析技能。加密者为了有效防止解密者修改跳转指令,往往会采取一些技巧,从而迂回比较字符串。

​ (1)寄存器直接比较

1
2
3
4
mov eax []
mov ebx []
cmp eax,ebx
jz(jnz) xxxx

​ (2)函数比较a

1
2
3
4
5
mov eax []
mov ebx []
call xxxxxxxx
test eax,eax
jz(jnz)

​ 在这种情况下,call指令一般是一个BOOL函数,其结果通过eax返回。在分析时,要关注该call指令返回时处理eax的代码。call指令中的代码如下。

1
2
3
4
5
6
7
	cmp xxx,xxx
jz Lable
xor eax,eax
Lable: pop edi
pop esi
pop ebp
ret

​ (3)函数比较b

1
2
3
4
5
push xxxx
push xxxx
call xxxxxxxx
test eax,eax
jz(jnz)

​ (4)串比较

1
2
3
4
lea edi []
lea esi []
repz cmosd ;比较字符串
jz(jnz)
  1. 制作注册机

​ 软件开发结束后,软件作者很有必要先做攻击测试,找出弱点,避免犯一些低级错误。注册算法一般是一些极为简单的算法,基本上都是明码的,或者是明码相近的,例如查表、异或、换位、移位、累加和等,算法实现都比较容易。

​ (1)对明码比较软件的攻击

​ 只要正确的序列号在内存中曾以明码形式出现(不管比较时是否使用明码),就都属于这一类。有些软件采取了一机一号的保护方式,即软件根据用户硬件等产生唯一的机器号,注册码与机器号对应,有效地防止了序列号被散发。如果是明码比较,攻击还是很容易的。之所以能轻易实现这一目的,就是因为利用了keymake软件,它能够拦载截程序指令并将出现的明码以某种方式直接显示出来。

​ 实例TraceMe.exe的序列号是明码比较的,相关代码如下。

image-20240722162049247

image-20240722162127035

​ 运行keymake后,单击菜单项“其他”→“内存注册机”,打开如图所示的界面。具体操作步骤如下。

  • 单击“浏览”按钮,打开目标程序TraceMe.exe。
  • 设置寄存器为“内存方式”,本例是ebp,即序列号保存在ebp所指向的内存地址中。
  • 中断地址列表。

image-20240722165844708

​ 按上述步骤完成设置,单击“生成”按钮,就可以生成一个注册机。使用时,该注册机和目标程序放在同一目录下。运行时,注册机装载目标程序,在指定地址处插入一个INT3指令,目标程序会在此中断,注册机将内存或寄存器的值读出,再恢复原程序指令。TraceMe被装载后,输人用户名,单击“Check”按钮,注册机将跳出一个窗口告知正确的序列号。

​ (2)非明码比较

​ 实例Serial.exe通过对等函数检查序列号。如果输入的用户名和序列号满足

1
F1(用户名)=F2(序列号)

​ 则认为是正确的序列号。采用这种方法,可以使内存中不出现明码。

​ 单击实例程序Serial.exe的菜单项“Help”→“Register’”,打开注册窗口。这个窗口是用DialogBoxParamA函数建立、用EndDialog函数关闭的。可以用GetDIgItemTextA、EndDialog等函数设断拦截。因为程序关闭对话框后才开始比较序列号,所以要在系统里运行一段时间才能回到Serial.exe的领空。也可以直接从提示信息切人,找到关键点。用OllyDbg装载Serial.exe后,输入姓名“pediy”和序列号“1234”,单击“0K”按钮,将跳出“Incorrect! Try Again”提示窗口。记下这串字符。单击右键,在弹出的快捷菜单中选择“Search for”→“All referenced text strings”选项,交叉参考字符串窗口,找到“Incorrect!Try Again’”并双击,就可以来到关键代码处。很明显,00401228h这段代码处理输入的字符串“pediy”。在此按“F2”键设断,重新运行。运行程序单步分析如下。

image-20240722174619632

​ call 0040137E 函数内部如下

image-20240722174722709

​ 上面的代码用于计算k1=F1(用户名)。用C语言来描述,代码如下。

1
2
3
4
5
6
7
8
9
10
11
int F1 (char *name){
int i,k1 = 0;
char ch;
for(i=0;name[i]!=0;i++){
ch=name[i];
if(ch<'A') break;
k1+=(ch>'Z')?(ch-32):ch;
}
k1 = k1^0x5678;
return k1;
}

​ call 004013D8 函数内部如下

image-20240722181907411

​ 上面的代码用于计算k2=F2(序列号)。用C语言来描述,代码如下。

1
2
3
4
5
6
7
8
int F2 (char *code){
int i,k2=0;
for(i=0;code[i]!=0;i++){
k2= k2*10+code[i]-48;
}
k2=k2^0x1234;
return k2;
}

​ 只要满足关系式k1=k2,注册就成功了。编写注册机时要对函数F1或F2进行逆变换,若F1和F2都不可逆,就只能使用穷举法了。如果要通过用户名算出正确的序列号,只要写出的逆函数k1=F2^-1(序列号)即可。求逆函数F2有多个解,比较复杂,但幸运的是,k1的结果是一个十六进制数,因此,可以将函数F2的功能看成将输入的十讲制数转换成十六讲制数。

​ 注册机代码如下

1
2
3
4
5
6
7
8
9
int keygen(char *name){
int i,k1=0,k2=0;
for(i=0; name[i]!=0;i++){
if(ch<'A') break;
k1+=(ch>'Z')?(ch-32):ch;
}
k2=k1^0x5678^0x1234;
return k2;
}

​ 算法求逆是有难度的,需要有一定的编程基本功。常见的加密配对指令有xorr/xor、add/sub、inc/dec、rol/ror等,这些指令对都是一条用于加密,另一条用于解密的。
还有一种写注册机的方法是不分析其运算过程,用OllyDbg的Asm2Clipboard插件、IDA等工具直接将序列号算法的汇编代码提取出来,嵌人高级语言。这个方法的优点是不用理解算法实现的细节,只要将汇编代码嵌入注册机即可。函数就属于这种情况。将从0040137Eh到004013D6h处的汇编代码转换成asm文件格式,然后嵌人高级语言中调用,在代码转换中要注意栈平衡、数据进制、汇编语法格式、宇符串引用等。直接提取汇编代码并将其嵌人VC的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
int kengen(char *name){
int k1=0,k2=0;
BOOL bIsnum=FALSE;

__asm
{
mov esi,OFFSET cName
push esi
L002:
mov al, byte ptr [esi]
test al, al
je L014
cmp al, 0x41
jb L019
cmp al, 0x5A
jnb L011
inc esi
jmp L002
L011:
call L035
inc esi
jmp L002
L014:
pop esi
call L026
xor edi, 0x5678
mov eax, edi
jmp L025
L019:
pop esi
mov bIsnum,1
L025:
jmp LEND
L026:
xor edi, edi
xor ebx, ebx
L028:
mov bl, byte ptr [esi]
test bl, bl
je L034
add edi, ebx
inc esi
jmp L028
L034:
retn
L035:
sub al, 0x20
mov byte ptr [esi], al
retn
LEND:
mov k1,eax
}
if(bIsnum)
return 0;
k2=k1^0x1234;
return k2;
}

警告窗口

​ “Nag”的本义是“烦人”。警告(Nag)窗口是软件设计者用来不时提醒用户购买正式版本的窗口。软件设计者可能认为,当用户忍受不了软件试用版中这些烦人的窗口时,就会考虑购买正式版。警告窗口可能会在程序启动或退出时弹出,或者在软件运行的某个时刻随机或定时弹出,确实比较烦人。
​ 去除警告窗口常用的3种方法是修改程序的资源、静态分析及动态分析。使用资源修改工具去除警告窗口是个不错的方法,可以通过将可执行文件中警告窗口的属性改成透明或不可见来变相去除警告窗口。若要完全去除警告窗口,只需找到创建该窗口的代码并将其跳过。显示窗口的常用函数有MessageBoxA(W)、MessageBoxExA(W) , DialogBoxParamA(M),Show Window、Create WindowExA(W)等。然而,这些断点对某些警告窗口无效,这时可以尝试利用消息设置断点拦截。
​ 实例Nag.exe是一个用于显示警告窗口的程序,它调用DialogBoxParamA函数来显示资源中的对话框。由于Nag.exe是调用资源来显示对话框的,可以用eXeScope或Resource Hacker打开它。警告窗口的资源如图所示。

image-20240723112710606

​ 启动画面窗口的ID是121,换算成十六进制数就是79h。od打开。具体代码如下。

image-20240723114426459

​ DialogBoxParam函数一般和EndDialog函数配对使用,前者用于打开对话框,后者用于关闭对话框,因此,不能简单地将DialogBoxParam函数屏蔽。DialogBoxParam函数的原型如下。

1
2
3
4
5
6
7
INT_PTR DialogBoxParamA(
[in, optional] HINSTANCE hInstance,
[in] LPCSTR lpTemplateName,
[in, optional] HWND hWndParent,
[in, optional] DLGPROC lpDialogFunc,
[in] LPARAM dwInitParam
);

​ 通过上面的函数可以看出,IpDialogFunc参数很重要,DialogBoxParam函数将跳转到其指向的地址执行,对lpDialogFunc参数(此处为004010C4h)设断。中断后的代码如下。

image-20240723150633267

​ 主程序也是用DialogBoxParam函数显示的,因此有如下两种改法。
​ (1)跳过警告窗口代码。将“00401051 push00000000”改成“00401051 jmp 4010E5”。修改时,在OllyDbg里输入正确的代码。选择修改后的代码,执行右键快捷菜单中 的“复制到可执行文件”功能,即可将修改保存到磁盘文件中。
​ (2)将两个DialogBoxParam函数的参数对调。DialogBoxParam函数有两个参数很重要,一个是主对话框处理函数指针,另一个是对话框ID。这种方法的思路是将主窗 口的这两个参数放到警告窗口的DialogBoxParam函数上。修改代码如下。

image-20240723152335403

​ 在另外一些情况下,对话框不是以资源形式存在的,通过常用断点又拦截不下来,这时可以尝试使用消息断点,例如WM_DESTROY。

时间限制

​ 时间限制程序有两类:一类是限制每次运行的时长;另一类是每次运行时长不限,但是有时间限制,例如使用30天。

  1. 计时器

​ 有一类程序,每次运行时都有时间限制,例如运行10分钟或20分钟就停止,必须重新运行程序才能正常工作。这类程序里有一个计时器来统计程序运行的时间。那么,如何实现计时器呢?在DOS中,应用程序可以通过接管系统的计时器中断(一般为int 8h或int 1Ch)来维护一个计时器,它能每55毫秒发生1次(18.2次/秒)。在Windows中,计时器有如下选择。

​ (1)setTimer()函数

​ 应用程序可在初始化时调用这个API函数,向系统申请一个计时器并指定计时器的时间间隔,同时获得一个处理计时器超时的回调函数。若计时器超时,系统会向申请该计时器的窗口过程发送消息WM_TIMER,或者调用程序提供的那个回调函数。该函数的原型如下。

1
2
3
4
5
6
UINT_PTR SetTimer(
[in, optional] HWND hWnd,
[in] UINT_PTR nIDEvent,
[in] UINT uElapse,
[in, optional] TIMERPROC lpTimerFunc
);

​ hWd:窗口句柄。若计时器到时,系统将向这个窗口发送WM_TIMER消息。

​ nIDEvent:计时器标识。
​ uElapse:指定计时器时间间隔(以毫秒为单位)。
​ TIMERPROC:回调函数。若计时器超时,系统将调用这个函数。如果本参数为NULL,若计时器超时,将向相应的窗口发送WM_TIMER消息。这个回调函数的原型如下。

1
2
3
4
5
6
void Timerproc(
HWND unnamedParam1,
UINT unnamedParam2,
UINT_PTR unnamedParam3,
DWORD unnamedParam4
)

​ (2)高精度的多媒体计时器

​ 多媒体计时器的精度可以达到1毫秒。应用程序可以通过调用timeSetEvent()函数来启动一个多媒体计时器。该函数的原型如下。

1
2
3
4
5
MMRESULT timeSetEvent( UINT uDelay,
UINT uResolution,
LPTIMECALLBACK lpTimeProc,
WORD dwUser,
UINT fuEvent )

​ (3)GetTickCount()函数

​ Windows提供了API函数GetTickCount(),该函数返回的是系统自成功启动以来所经过的时间(以毫秒为单位)。将该函数的两饮返回值相减,就能知道程序已经运行多长时间了。这个函数的精度取决于系统的设置。实际上,也可以在高级语言里利用其各自开发库提供的函数来实现计时,例如在C语言中可以使用time()函数获得系统时间。

​ (4)timeGetTime()函数

​ 多媒体计时器函数timeGetTime0也可以返回Windows自启动后所经过的时间(以毫秒为单位)。一般情况下,不需要使用高精度的多媒体计时器,因为精度太高会对系统性能造成影响。

  1. 时间限制

​ 演示版软件一般都有使用时间的限制,例如试用30天,超过试用期就不能运行,只有向软件作者付费注册之后,才能得到无时间限制的注册版。这种保护的实现方式大致如下。

​ 在安装软件的时候由安装程序取得当前系统日期,或者由主程序在软件第1次运行的时候获得系统日期,并将其记录在系统中的某个地方(可能记录在注册表的某个不显眼的位置,也可能记录在某个文件或扇区中)。这个时间统称为软件的安装日期。程序每次运行时都要取得当前系统日期,并将其与之前记录的安装日期进行比较,当差值超出允许的时间(例如30天)时就停止运行。
​ 这种日期限制的原理很简单,但是在实现的时候,如果对各种情况的处理不够周全,就很容易被绕过。例如,在到期后简单地把机器时间调回去,软件就又可以正常使用了。

​ 如果考虑得比较周全,软件最少要保存两个时间值。一个时间值是上面所说的安装时间。这个时间可以由安装程序在安装软件的时候记录,也可以在软件第1次运行的时候记录(即软件发现该值不存在时,就将当前日期作为其值记录下来)。为了提高解密难度,最好把这个时间值存储在多个地方(解密者可能通过RegMon、FileMon等监视工具轻易地找到存放该值的地方,然后剔除该键值,这样软件就又可以正常使用了。
​ 另一个时间值就是软件最近一次运行的日期,这是防止用户将机器日期改回去而设的。软件每次退出的时候都要将该日期取出,与当前日期进行比较,如果当前日期大于该日期,则用当前日期替换该日期,否则保持该日期。同时,软件每次启动时要把该日期读出,与当前日期进行比较,如果该日期大于当前系统日期,则说明用户修改了机器时间,软件可以拒绝运行。
​ 用于获取时间的API函数有GetSystemTime、GetLocalTime和GetFileTime。软件作者可能不会直接使用这些函数来获得系统时间(例如,采用高级语言中封装好的类来操作系统时间等,但这些封装好的类实际上也调用了这些函数)。解密者在采用动态跟踪方法破解这种日期限制时,最常用的断点也是这几个。

​ 还有一种可以比较方便地获得当前系统日期的方法就是读取需要频繁修改的系统文件(例:Windows注册表文件user.dat、system.dat等)的最后修改日期,利用FileTime ToSystem Timet()函数其转换为系统日期格式,从而得到当前系统日期。
​ 需要指出的是,采用时间限制的软件必须能防范RegMon、FileMon之类的监视软件,否则时间的存放位置会很容易被找到。

  1. 拆解时间限制保护

​ 实例程序Timer.exe采用SetTimert()函数计时,每次运行20秒,运行原理是:先用SetTimer(hwnd,L,1000,NULL)函数设置一个计时器,时间间隔是1000毫秒,这个函数每秒发送1次WM_TIMER消息。当应用程序收到消息时,将执行如下语句。

1
2
3
4
5
6
case WM_TIMER :
if(i<=19)
i++;
else
sendMessage(hDlg, WM_CLOSE,0,0);
return 0;

​ 因此,可以用SetTimer()函数设断拦截,代码如下

image-20240723161541470

​ 去除时间限制有如下两种方法。
​ (1)直接跳过SetTimer(0函数,不产生WM_TIMER消息。来到004010C6h处,输入修改指令”jmp 4010D6”。

image-20240723162909725

​ (2)利用WM_TIMER消息,查看VC的头文件WINUSER.H,得知“#define WM_TIMER Ox0113”。在调试器里查找字串“113”(当然,在实际使用中有可能采取其他形式检查 字串是否为“113”),代码如下。因此,只要修改00401184h处,就能取消时间限制了。可以用2字节替换,例如“9090”或“eb00”。

image-20240723165237624

​ 直接nop即可

image-20240723165838252

​ 另外,辅助工具变速齿轮可以加快和减慢应用程序的时间,一般与动态分析配合使用。例如某软件运行1小时后才退出,可以用变速齿轮让“时间”加速,几分钟后,软件就认为到了1小时而退出,为调试程序带来便利。

菜单功能限制

​ 这类程序一般是Demo版的,其菜单或窗口中的部分选项是灰色的,无法使用。这种功能受限的程序一般分成两种。
​ 一种是试用版和正式版的软件是两个完全不同的版本,被禁止的功能在试用版的程序中根本没有相应的程序代码,这些代码只在正式版中才有,而正式版是无法免费下载的,只能向软件作者购买。对这种程序,解密者要想在试用版中使用和正式版一样的功能几乎是不可能的,除非自己向可执行程序中添加相应的代码。
​ 另一种是试用版和正式版为同一个文件。没有注册的时候按照试用版运行,禁止用户使用某些功能;注册之后就以正式版运行,用户可以使用其全部功能。可见,被禁止的那些功能的程序代码其实是存在于程序之中的,解密者只要通过一定的方法恢复被限制的功能,就能使该Deo软件与正式版一样了。
​ 对比一下就能知道,前一种保护方式更好,因为它使被解难度大大增加。如果采用功能限制的保护方式,强烈建议使用前一种方式。

  1. 相关函数

​ 如果要将软件菜单和窗口变灰(不可用状态),可以使用如下函数

(1)EnableMenultem()函数

1
2
3
4
5
BOOL EnableMenuItem(
[in] HMENU hMenu,
[in] UINT uIDEnableItem,
[in] UINT uEnable
);

​ hMem:菜单句柄。
​ uIDEnableItem:欲允许或禁止的一个菜单条目的标识符。

​ uEnable:控制标志,包括MF_ENABLED(允许,Oh)、MF_GRAYED(灰化,1h)、MF_DISABLED(禁止,2h)、MF_BYCOMMAND和MF_BYPOSITION。
​ 返回值:返回菜单项以前的状态。如果菜单项不存在,就返回FFFFFFFFh。

(2)EnableWindow()函数

​ 允许或禁止指定窗口,原型如下。

1
2
3
4
BOOL EnableWindow(
[in] HWND hWnd,
[in] BOOL bEnable
);

​ hWnd:窗口句柄。
​ bEnable:“TRUE”为允许,“FALSE”为禁止。
​ 返回值:非0表示成功,0表示失败。

  1. 拆解菜单限制保护

image-20240723174223975

​ 当uEnable控制标志为0时,恢复菜单的功能,具体操作为将“0040I1E3 push 0x1”改成“push 0”。

image-20240723174309601

KeyFile保护

​ KeyFile是一种利用文件来注册软件的保护方式。KeyFile一般是一个小文件,可以是纯文本文件,也可以是包含不可显示字符的二进制文件。其内容是一些加密或未加密的数据,其中可能有用户名、注册码等信息,文件格式则由软件作者自己定义。试用版软件没有注册文件。当用户向作者付费注册之后,会收到作者提供的注册文件,其中可能包含用户的个人信息。用户只要将该文件放入指定的目录,就可以让软件成为正式版了。该文件一般放在软件的安装目录或系统目录下。软件每次启动时,从该文件中读取数据,然后利用某种算法进行处理,根据处理的结果判断是否为正确的注册文件。如果正确,则以注册版模式运行。
​ 在实现这种保护的时候,建议软件作者采用稍大一些的文件作为KeyFile(一般在几KB左右),其中可以加人一些垃圾信息以干扰解密者。对注册文件的合法性检查可以分成几部分,分散在软件的不同模块中进行判断。注册文件内的数据处理也要尽可能采用复杂的运算,而不要使用简单的异或运算。这些措施都可以增加解密的难度。和注册码一样,也可以让注册文件中的部分数据和软件中的关键代码或数据发生关系,使软件无法被暴力破解。

  1. 相关API函数

​ KevFile是一个文件,因此,所有与Windows文件操作有关的API函数都可作为动态跟踪破解的断点。这类常用的文件函数如表所示。各API函数的具体含义请参考MSDN或相关API文档。

image-20240723174611079

image-20240723174624871

  1. 拆解KeyFile保护

​ 采用实例文件PacMe

​ (1)拆解KeyFile的一般思路

​ ①用Process Monitor等工具监视软件对文件的操作,以找到KeyFile的文件名,
​ ②伪造一个KevFile文件。用十六进制工具编辑和修改KeyFile(普通的文本编辑工具可能无法完成这项任务)。
​ ③在调试器里用CreateFileA函数设断,查看其打开的文件名指针,并记下返回的句柄。

​ ④用ReadFile函数设断,分析传递给ReadFile函数的文件句柄和缓冲区地址。文件句柄一般和第③步返回的相同(若不同,则说明读取的不是该KeyFile。在这里也可以使用条件断点)。缓冲区地址是非常重要的,因为读取的重要数据就放在这里。对缓冲区中存放的字节设内存断点,监视读取的KeyFile的内容。

​ 当然,上述只是大致步骤,有的程序在判断KevFile时会先判断文件大小和属性、移动文件指针等。总之,对KeyFile的分析深入与否,取决于分析者对Win32 File I/O API的熟悉程度,也就是API编程的水平。

​ (2)监视文件的操作

​ PacMe的注册信息放在某一文件中(可以通过文件监视工具得到)。Process Monitor是一个不错的文件监视工具,使用时建议设置过滤器。
​ 所谓过滤器,其实是一组条件。这组条件用来限制Process Monitor该显示什么、不该显示什么。单击菜单项“Filter’”,打开过滤器,在第1个下拉列表框中选择“Process Name”选项,在第2个下拉列表框中选择“is”选项,在第3个下拉列表框中填写要监控的文件名“PacMe..exe”,单击“Add”按钮,如图所示。

image-20240724094458660

​ Process Monitor启动后会立刻进行监控操作,包括文件系统、注册表、网络、进程及性能分析。在本例中,只需要监控文件系统,其他如注册表、进程等监控可以取消。单击工具栏上的注册表和进度监控等按钮即可取消监控,如图所示。

image-20240724094722969

​ 按“Crl+E”快捷键可以捕捉事件,按“Ctrl+X”快捷键可以清除所有记录。Process Monitor会按时间顺序记录系统中发生的各种文件访问事件,如图所示。

image-20240724094953387

​ (3)分析过程

​ 除了用Process Monitor监视文件获得KeyFile文件名,也可以直接对文件的相关函数设断,从而获得KeyFile的相关信息。用OllyDbg装载PacMe后,按“Fg”键运行PacMe。用CreateFileA函数设断,单击PacMe的“Check”按钮,中断代码如下。

image-20240724095502284

​ OllyDbg会直接把CreateFileA函数读取的文件名显示出来。KeyFile名为“KwazyWeb.bit”。用十六进制工具伪造一个KeyFile,建议将其内容设置为一些有规律的数字,例如1、2、3、4、5…以便在跟踪时进行分析。
​ 重新运行程序,PacMe将打开KwazyWeb.bit文件,读取数据并进行计算比较,代码如下。

image-20240724095859789

image-20240724095955055

image-20240724100026540

​ 再来分析一下验证的核心代码,具体如下。

image-20240724100916977

image-20240724101005804

​ 这是放大后的String2

​ 这就是经典的“吃豆子”游戏,“C”是吃家,“*”是墙壁,“.”是通路,“X”是终点

image-20240724101121367

image-20240724101152993

image-20240724101242596

image-20240724101354753

image-20240724103306528

​ 这是一个标准的迷宫程序,从“C”开始,一共走18次,每次可以走4步(18次大循环和4次小循环)。碰到“*”就中断,直到遇见“X”注册成功。路线非常清楚,就是顺着“.”走。按照上面的分析。“0”代表“↑”,“1”代表“→”,“2”代表“↓”,“3”代表“←”,按图一步步前进,就可以得到一系列数据,如图所示。

image-20240724103354502

​ 图中的数是四进制数,转换成十六进制数为“A9 AB A5 10 54 3F 30 55 65 16 56 BE F3 EA E9 50 55 AF”。然后,程序通过用户名算出一个数,再与上面的十六进制数进行异或运算。
​ 在此以用户名“pediy”推出KeyFile。“pediy”的十六进制数是“70 65 64 69 79”。KeyFile由3部分组成,如图所示。计算步骤如下。

image-20240724103508773

​ ①计算“pediy”字符的和,70h+65h+64h+69h+79h=21Bh,取低8位1Bh。
​ ②用1Bh依次与“A9 AB A5 10 54 3F 30 55 65 16 56 BE F3 EA E9 50 55 AF”进行异或运算,结果是“B2 B0 BE 0B 4F 24 2B 4E 7E 0D 4D A5 E8 F1 F2 4B 4E B4”。

网络验证

​ 网络验证是目前很流行的一种保护技术,其优点是可以将一些关键数据放到服务器上,软件必须从服务器中取得这些数据才能正确运行。拆解网络验证的思路是拦截服务器返回的数据包,分析程序是如何处理数据包的。

  1. 相关函数

​ 当一个连接建立以后,就可以传输数据了。常用的数据传送函数有send()和recv()两个Socket函数,以及微软的扩展函数WSASend()和WSARecv()。

​ (1)send()函数

​ 客户程序一般用send()函数向服务器发送请求,服务器则通常用send()函数向客户程序发送应答,示例如下

1
2
3
4
5
6
int sent(
SOCKET s,
const char FAR *buf,
int len,
int flags
)

​ (2)recv()函数

​ 不论是客户还是服务器应用程序,都使用recv()函数从TCP连接的另一端接收数据。

1
2
3
4
5
6
int recv(
SOCKET s,
char FAR *buf,
int len,
int flags
)
  1. 破解网络验证的一般思路

​ 如果网络验证的数据包内容固定,可以将数据包抓取,写一个本地服务端模来拟服务器。如果验证的数据包内容不固定,则必须分析其结构,找出相应的算法。
​ 实例CrackMeNet.exe是一款网络验证工具。CrackMeNetS.exe是服务端,提供了一组正确的登录账号。因为在实际操作中是接触不到服务端的,所以必须从客户端入手,利用一组正确的账号来击破这个网络验证保护机制。

​ (1)分析发送的数据包

​ 建议用IDA与OllyDbg一起进行分析。IDA能正确识别C函数,分析起来非常方便。OllyDbg加载客户端后,用send()函数设断。输入正确的账号与口令,单击“Register’”按钮,中断并回到当前程序领空,代码如下。

image-20240724152302389

​ send()函数将把Data缓冲区中的数据发送到服务端。查看Data中的数据,发现是加密的。在IDA中向前查看代码,再结合OllyDbg进行分析。这段代码的功能如下。

image-20240724153000159

image-20240724153025948

​ 原来,客户端将输人的Name及Key按如图所示的格式处理,进行异或加密运算,将数据发送给服务端。

image-20240724153611266

​ (2)分析接收的数据包

​ 服务端接收数据后,经过计算,将包括正确数据包在内的数据返回客户端,客户端程序使用recv()函数接收数据,相关代码如下。

image-20240724153939467

上面这段代码表示收到数据并进行解密,解密后的数据存放在41AE68h~41AEC1h这段空间中,如图所示。

image-20240724154225543

​ 接下来,程序会从41AE68h~41AEC1h中读取需要的字节,因此,只要对这段数据下内存读断点,就可以很容易地定位到相关代码处。但在实际应用中,程序读取这部分数据的操作可能比铰隐蔽,例如运行一段时间再比较或使用某功能后再比较等,因此有可能遗漏相关的读取代码。

​ 本实例用全局变量构建缓冲区。由于是以Debug编译的程序,实例程序会直接用如下指令读取缓冲区数据。

image-20240724154343377

​ 一个简单且有效的办法是在整个代码里搜索访问41AE68h~41AEC1h这段缓冲区中的mov指令。这时,IDA的强大就体现出来了。用IDA运行如下Python脚本,可将读取指定内存的代码列出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import idaapi
import idc
import idautils

def Getasm(ea_from, ea_to, range1, range2):
with open("code.txt", "w") as fp:
ea = ea_from
while ea < ea_to:
cmd = idc.print_insn_mnem(ea) # 使用 idc.print_insn_mnem 获取指令助记符
if cmd == "mov" or cmd == "lea":
opcode = idc.get_wide_dword(ea + 4) # 获取指令后面4个字节作为操作码
if opcode < 0: # 如果操作码是负数,进行补码转换
opcode = (~opcode + 1)
idaapi.msg("-> %08X %08X\n" % (ea, opcode))

if range1 <= opcode <= range2:
delta = opcode - range1
idc.set_cmt(ea, "// +0x%04X" % delta, 0) # 在指令上添加注释
fp.write("%08X %s\n" % (ea, idc.generate_disasm_line(ea)))
ea = idc.next_head(ea, ea_to) # 获取下一个指令地址
idaapi.msg("OK!\n")

# 示例调用
Getasm(0x401000, 0x40F951, 0x41AE68, 0x0041AEC1)

单击菜单项“File”一“Script file”,打开getasm.py脚本。这个脚本将程序访问缓冲区中的所有指令列出,在当前目录下生成code.txt文件,内容如下。

image-20240724164650215

​ 本例将向缓冲区中放入全局变量。在一般情祝下,缓冲区中存放的是局部变量。访问缓冲区数据的指令如下,其中“rm32”表示32位寄存器存储器。

1
2
3
mov r/m16, [r/m32+n]
lea r/m16, [r/m32+n]
mov r/m16, [ebp-n]

​ (3)解除网络验证

​ 发送与接收的封包都分析完毕。比较简单的解决方法是编写一个服务端,模拟服务器来接收和发送数据。如果软件是用域名登录服务器的,可以修改hosts文件,使域名指向本地(127.0.0.1)。如果软件是直接用IP地址连接服务器的,可以用inet_addr或connect等设断,将IP地址修改为本地IP地址,或者使用代理软件将IP地址指向本地。

​ 除了编写服务端,也可直接修改客户端程序,将封包中的数据整合进去,步骤如下。

​ ①将实例CrackMeNet.exe复制一份,用OllyDbg打开,然后将开始截取的正确数据粘贴到41AE68h-41AEC1h这段地址中。

​ ②将发包功能(send)去除,再读取随机数并将其放到0041AE76处,代码如下。

image-20240724171213820

​ 此处原是send,现在跳转到0040FAA8h这个空白地址处

image-20240724171405199

​ ③将recv()函数去除并跳过数据解密代码,修改代码如下。

image-20240724172657789

​ 经过这样的处理,再运行实例,单击“Register’”按钮,会跳出一个对话框,提示“Emor:Connectionfailed”。直接强行跳过该对话框。修改代码如下。

image-20240724180206136

​ 从以上分析中可以看出,网络验证的关键就是数据包分析。数据包分析的辅助工具有WPE、iris等。如果数据包是加密的,或者需要彻底分析数据包处理过程,就必须用发送接收函数设断,跟踪程序对数据包的处理过程。

光盘检测

​ 一些采用光盘形式发行的应用软件和游戏,在使用时需要检查光盘是否插在光驱中,如果没有则拒绝运行。这是为了防止用户将软件或游戏的一份正版拷贝安装在多台机器上且同时使用,其思路与DOS时代的钥匙盘保护类似,虽然能在一定程度上防止非法拷贝,但也给正版用户带来了一些麻烦一一旦光盘被划伤,用户就无法使用软件了。本节将介绍常见的光盘检测实现方式,以及如何去除光盘检测的基本知识。一些专业的光盘保护软件(例如SafeDisc等)比较复杂,在本节中就不讲述了。
​ 最简单也最常见的光盘检测就是程序在启动时判断光驱中的光盘里是否存在特定的文件。如果不存在,则认为用户没有使用正版光盘,拒绝运行。在程序运行过程中,一般不再检查光盘是否在光驱中。在Windows下的具体实现一般是:先用GetLogicalDriveStrings()或GetLogicalDrives()函数得到系统中安装的所有驱动器的列表,然后用GetDriveType()函数检查每个驱动器,如果是光驱,则用CreateFile()或FindFirstFile()函数检查特定的文件是否存在,甚至可能进一步检查文件的属性、大小、内容等。

​ 这种光盘检测方式是比较容易被破解的。解密者只要利用上述函数设置断点,找到程序启动时检查光驱的地方,然后修改判断指令,就可以跳过光盘检测。上述保护的一种增强类型就是把程序运行时需要的关键数据放在光盘中。这样,即使解密者能够强行跳过程序启动时的检查,但由于没有使用正版光盘,也就没有程序运行时所需要的关键数据,程序自然会崩溃,从而在一定程度上起到了防破解的作用。
​ 对付这种增强型光盘保护还是有办法的,可以简单地利用刻录和复制工具将光盘复制多份,也可以采用虚拟光驱程序来模拟正版光盘。常用的虚拟光驱程序有Virtual CD、Virtual Drive、Daemon Tools等。值得一提的是Daemon Tools,它不仅是免费的,而且能够模拟一些加密光盘。这些光盘加密工具一般都会在光轨上做文章,例如做暗记等。有的加密光盘可用工作在原始模式(Raw mode)的光盘拷贝程序原样复制,例如Padus公司的DiscJuggler和Elaborate Bytes公司的CloneCD等。

  1. 相关函数

​ 下面介绍与光盘检测有关的函数

​ (1)GetDriveType()函数

​ 该函数用于获取磁盘驱动器的类型,示例如下。

1
2
3
UINT GetDriveTypeA(
LPCSTR lpRootPathName
);

​ 返回值
​ 0:驱动器不能识别
​ 1:根目录不存在
​ 2:移动存储器
​ 3:固定驱动器(硬盘)。
​ 4:远程驱动器(网络)。
​ 5:CD-ROM驱动器
​ 6:RAM disk

​ (2)GetLogicalDrives()函数

​ 该函数用于获取逻辑驱动器符号,没有参数。
​ 返回值:如果失败就返回0,否则返回由位掩码表示的当前可用驱动器,示例如下。

1
2
3
bit 0 	 drive A
bit 1 drive B
bit 2 drive C

​ (3)GetLogicalDriveStrings()函数

​ 该函数用于获取当前所有逻辑驱动器的根驱动器路径,示例如下。

1
2
3
4
DWORD GetLogicalDriveStringsW(
DWORD nBufferLength,
LPWSTR lpBuffer
);

​ 返回值:如果成功就返回实际的字符数,否则返回0。

​ (4)GetFileAttributes()函数

​ 用于判断指定文件的属性,示例如下

1
2
3
DWORD GetFileAttributesW(
[in] LPCWSTR lpFileName
);
  1. 拆解光盘保护

​ 实例文件CD_Check

​ 这个程序先用GetDriveType()函数检测文件是否在光驱里,再用CreateFile()函数尝试打开光盘文件,示例如下。如果存在,则成功。

image-20240725111339082

​ 最后改成“jmp 00401485“即可

只运行一个实例

​ Windows是一个多任务操作系统,应用程序可以多次运行以形成多个运行实例。但有时基于对某些方面的考虑(例如安全性),要求程序只能运行1个实例。

  1. 实现方法

​ (1)查找窗口法

​ 这是最为简单的一种方法。在程序运行前,用FindWindowA、Get WindowText函数查找具有相同窗口类名和标题的窗口,示例如下。如果找到,就说明已经存在一个实例。

1
2
3
4
HWND FindWindowA(
[in, optional] LPCSTR lpClassName,
[in, optional] LPCSTR lpWindowName
);

​ 返回值:如未找到相符的窗口,则返回0。
​ 程序代码的形式如下。

1
2
3
4
TCHAR AppName[] = TEXT ("只运行1个实例");
hWnd=FindWindow(NULL,AppName);
if (hWnd ==0)初始化程序
else 退出

​ (2)使用互斥对象

​ 尽管互斥对象通常用于同步连接,但用在这里也是很方便的。一般用CreateMutexA函数实现,它的作用是创建有名或者无名的互斥对象,示例如下。

1
2
3
4
5
HANDLE CreateMutexW(
[in, optional] LPSECURITY_ATTRIBUTES lpMutexAttributes,
[in] BOOL bInitialOwner,
[in, optional] LPCWSTR lpName
);

​ 返回值:如果函数调用成功,返回值是互斥对象句柄。
​ 程序代码的形式一般如下。

1
2
3
4
5
6
TCHAR AppName[] = TEXT ("只运行1个实例");
Mutex = CreateMutex(NULL,FALSE,AppName)
if GetLastError<>ERROR_ALREADY_EXISTS
初始化
else 退出
ReleaseMutex(Mutex);

​ (3)使用共享区块

​ 创建一个共享区块(Section)。该区块拥有读取、写入和共享保护属性,可以让多个实例共享同一内存块。将一个变量作为计数器放到该区块中,该应用程序的所有实例可以共享该变量,从而通过该变量得知有没有正在运行的实例。

  1. 实例

​ Serial.exe只能同时运行1个实例。该程序利用Find Window函数查找指定字串来确定程序是否运行,示例如下。对付这类保护最有效的办法是修改应用程序的窗口标题。当然,修改Find Window函数的返回值也能取消其限制。

image-20240725115246074

常用断点设置技巧

​ WIN32常用断点

image-20240725115327780