oRbIt 的专栏

从明天起,做一个幸福的人,喂马、劈柴、周游世界。。。

共享软件加密的一些误区

  本文发表于《电脑软件编程与维护》 2005年12期
  作者:星轨(oRbIt)
  E_Mail :
inte2000@163.com   

Google

  共享软件通常是指那种采用“先使用,后付款”的方式发布的软件,这类软件通常有两个(或多个)不同的版本,公开发布的是一个功能受限制或使用时间和次数受限制的共享版本,只有当用户购买(或注册)之后才可以使用没有限制的正式版本。共享软件从出现的那一天起就深受破解者(Cracker)的“喜爱”,因为相对于防范较为严密或使用硬件加密的商业软件来说,共享软件是比较容易破解的。虽然许多共享软件的作者也采用了时间限制或注册码校验等方法保护自己的软件,但是多数共享软件的作者对软件加密与解密技术不了解,对加密方法的应用和代码编写过程中存在许多漏洞,从而使看似很可靠的加密方法形同虚设。软件破解者(甚至是一些菜鸟级的Cracker)只需要参照网络上的破解教程,按图索骥,就能够去掉一些软件的使用限制,或从内存中抓出正确的注册码,破解高手甚至能够写出注册机,这无疑是宣告了这个软件的死刑。

  本人也发布过共享软件,经历过软件刚刚发布就被破解的尴尬,痛定思痛之后,开始研究软件加密与解密之道。思之靡多,终有所悟,其实软件加密与解密之间的斗争,道高一尺则魔高一丈,但是最终往往不是技术致胜,而是心态致胜,从软件破解者的心态考虑加密问题,常常能够兵出奇处,以奇致胜。从软件破解者的角度考虑,以下是一些软件作者常犯的一些错误,本人把它们列举出来,希望对广大软件作者能有所帮助:

过分相信Windows注册表的复杂性

  注册表是Windows系统存储关于计算机配置信息的数据库,所有的数据都是通过一种树状结构以键和子键的方式组织起来,每个键都包含了一组特定的信息,通过键的名称和路径可以查询和修改这些信息。许多共享软件地作者都将一些比较重要的信息,比如注册信息或时间、次数限制存放在注册表中,以为注册表结构复杂,藏一些数据别人找不到。其实这是一个错误的认识,破解者利用一些注册表操作工具很容易就能获得数据在注册表中的存放位置。RegMon就是一款注册表监视软件,它能够监视系统中运行的程序对注册表的读写操作,一切诡秘在它面前都将大白于天下。还有一个名为RegSnap的软件,它的工作原理就是通过比较程序运行前后注册表的变化确定程序在注册表中的读写位置。由此可见,在注册表中存放信息是不安全的,至少要对存储的信息数据进行加密处理。

 

信息以明文的形式写在程序中


   C/C++语言的编译程序对代码中出现的字符串通常不作特殊地处理,直接将其按照数据出现的顺序排放在数据段中。一些软件作者没有注意到这一点,导致程序中使用的一些敏感信息以明文的形式出现在可执行程序中,比如注册表某个键的路径、某个文件路径以及加载的外部动态库名字等等。破解者使用WinHex之类的十六进制编辑软件打开程序文件就可以看到这些信息。图(1)就是用十六进制编辑器打开某软件的截图,从中可以看到这个软件可能动态加载了RegKey.dll这个动态库,可能还操作了注册表Software/Microsoft/Windows/ArtComp/RegInfo位置下的某些键。


图(1) 可执行文件中的字符串

C/C++程序的设计者稍微修改一下代码就可以避免这种简单的信息泄露,比如字符串“RegKey”,可以在程序中这样声明字符串数据:
static LPCTSTR lpszKey = _T("SfhLfz");
其实就是将每个字母向后移了一位(数值上增加1),使用前稍作处理:
TCHAR szBuffer{32};
lstrcpy(szBuffer,lpszKey);
for(int i = 0; i < lstrlen(lpszKey); i++)
 szBuffer[i]--;
//此时szBuffer中就是“RegKey”的原文

加密或验证部分与软件主体部分耦合度太低

    基于软件工程思想考虑,应该尽量降低软件模块之间的耦合度,但是对于软件加密来说,耦合度越高则加密强度越高,对注册码的验证最好不要封装到独立的函数或模块中。许多软件作者喜欢将加密或注册信息验证部分的代码封装到一个函数中,在程序中这样使用:
if(CheckRegCode(szRegCode) == TRUE)
{
 //注册信息正确,执行正常的功能
}
else
{
 //注册信息不正确,提示错误
}
这种封装看起来使程序代码结构良好,便于代码的组织与维护,但是也为破解者提供了良机。这样的程序通过反汇编之后通常有以下结构:
push 00406070 '字符串szRegCode参数入栈
call 00401050 '调用CheckRegCode函数
test eax,eax '判断CheckRegCode返回值
je 00401029 '跳转到出错位置
' 注册信息正确,顺序执行

从上面的汇编代码可以看到,只需将执行跳转的je指令(机器码是740B)改成两个NOP(CPU空操作指令,机器码是90)就等于忽略了对CheckRegCode函数返回值的判断,也就是说,无论这个函数返回TRUE还是FALSE,真正的功能代码都会执行,对注册码的校验就形同虚设。破解者根据指令的内存偏移地址计算出在可执行程序文件中的文件偏移位置,直接修改可执行文件就达到了破解这个功能的目的。此外,破解者还知道校验函数的位置是00401050,也就可以直接修改这个函数的代码,将函数开始部分的代码改成:
mov eax,1 ' 机器码是B801
ret ' 机器码是C3
也就是说根本不判断注册码,直接返回1(校验成功的标志),就能够使整个软件的注册码校验功能失效,软件被彻底的破解。
  还有一些软件作者为了软件开发的便利,将注册码的计算和校验功能封装到一个单独的动态链接库中,在软件安装过程中将其安装在系统目录中,以为这样就可以神不知鬼不觉了,其实不然,破解者利用SoftICE之类的系统级调试工具在系统的LoadLibrary(根据编码的不同可能有LoadLibraryA和LoadLibraryW两个版本)API上下断点,就可以获知程序所有的动态库加载动作,对注册码进行校验的动态库也不例外。知道这些信息之后,破解就变得简单了,无需修改主程序文件,只要将这个动态库替换掉就可以破解这个软件。比如,某软件使用名为“SysSec.dll”的动态库完成注册码校验功能,这个动态库有一个导出函数,函数原型为:
BOOL WINAPI CheckRegCode(LPCTSTR lpszCode);
破解者只需编写一个同名的动态库,也实现一个同名且同类型的导出函数,函数内容仅仅是返回TRUE:
BOOL WINAPI CheckRegCode(LPCTSTR lpszCode)
{
 return TRUE;
}
然后编译这个动态库,将其复制到系统目录覆盖原来的动态库,这样软件运行过程中会调用破解者的动态库,结果可想而知。网上有很多免费或收费的加密算法库,这些库往往不提供源代码,软件作者在使用这些库的时候一定要慎重,原因很简单:首先,这种用法导致加密模块与主程序耦合度不高,很容易被破解者“切开”;其次,这些公开的库就是软件破解者的众失之的,一旦某个破解组织推出了针对这个加密算法库的破解补丁,那么所有基于这个加密算法库的软件就都被破解了。
  可见,对于软件加密来说,应该尽量增加加密模块与主程序的耦合度,将加密或校验代码嵌入到程序代码中,虽然给代码的组织和维护带来了困难,但是提高了软件的安全性,正所谓有得即有失,鱼和熊掌不可兼得。

对注册码进行明文比较

  现在网上流行的大部分共享软件都是采用“用户信息/注册码”方式进行加密的,它的使用过程一般是用户把自己的用户信息(用户名、机器特征码等)通过网络或其他方式发送给软件作者,软件作者根据用户信息利用预先设计好的算法计算出一个注册码,然后将此注册码发送给用户,用户得到注册码后按照软件说明的注册步骤在软件中输入用户信息和从软件供应者那里得到的注册码,如果注册码正确就取消时间或功能的限制,成为正式版本。这种保护方法实现起来简单,不需要额外的成本,用户不仅购买方便,还可以根据自己的注册信息得到售后服务。
  “用户信息/注册码”方式的验证过程其实就是验证用户信息到注册码之间的数学映射关系,这个映射关系通常是由软件开发者制定的,而且映射关系越复杂就越不容易被破解。以用户信息做自变量,E表示映射函数,则该映射关系可被表示为以下映射函数:

  注册码 = E(用户信息)            映射函数(1)

应该说,只要映射函数E足够复杂,这个映射就是安全的,但是问题往往出在映射函数的两端,也就是作为自变量的用户信息和作为结果的注册码,这两部分都是以明文的形式传递的,如果处理不当就会露出破绽,正所谓良玉其内,败絮其外。以下是软件作者常用的注册信息校验方式:
TCHAR szUserName[32];//存放破解者输入的用户信息(假设是Cracker)
TCHAR szRegCode[64]; //存放破解者输入的注册码(假的,假设是ababababab)
TCHAR szRealRegCode[64];
CalculateRegCode(szUserName,szRealRegCode);//szRealRegCode得到了内部计算的正确注册码
if(lstrcmp(szRegCode,szRealRegCode) == 0)
{
 //输入的注册信息正确
}
else
{
 //输入的注册信息不正确
}
这段代码的问题在于CalculateRegCode函数调用之后,内存中就存在了正确的注册码的明文,保存在内存地址szRealRegCode处,通常情况下破解者不知道这个内存位置,但是借助SoftICE之类的调试软件就很容易跟踪到这个位置。比如,程序为了验证注册信息是否正确需要从注册窗口界面得到用户信息和用户输入的注册码,这些操作最终是通过调用Windows的API GetWindowText(根据编码的不同可能有GetWindowTextA和GetWindowTextW两个版本)或GetDlgItemText(根据编码的不同可能有GetDlgItemTextA和GetDlgItemTextW两个版本)完成的,破解者利用调试软件在这些函数上下断点就可以跟踪到szRegCode(存放破解者输入的假注册码)的内存地址。当然,破解者还可以利用内存搜索得到szRegCode的内存地址,在SoftICE中可以使用以下命令得到szRegCode的内存地址:
s 30: 0 1 FFFFFFFF "ababababab"
在内存地址szRegCode处下内存断点,当有对szRegCode地址进行操作时SoftICE就会中断,以上面的程序为例,就会在lstrcmp调用的时候中断,分析lstrcmp调用前后的代码(通常有以下类似的结构):
push 00408580 'szRealRegCode地址入栈
push 00408560 'szUserName地址入栈
call 00401080 '调用CalculateRegCode计算正确的注册码,存放在szRealRegCode地址00408580处
push 00408580 'szRealRegCode地址入栈(已经得到正确的注册码)
push 00408520 'szRegCode地址入栈(假的注册码)
call dword ptr [00405050] '调用KERNEL32.lstrcmpA比较
test eax, eax '判断结果
jne 0040104E '注册码比较不正确,跳转到出错位置
'信息正确,顺序执行正常的功能

由此可知,在调用lstrcmp中断时,CalculateRegCode已经计算出来正确的注册码,存放在内存地址00408580处,使用SoftICE的D命令查看内存就可以看到注册码,结果如图(2)所示:



图(2) 内存中的注册码

“A12B-457C-120F”就是CalculateRegCode函数根据用户信息计算出来的正确的注册码,破解者绕开CalculateRegCode函数的算法,利用代码中的逻辑漏洞“兵不血刃”就得到了“Cracker”的注册码,重复这个过程就可以得到“张三”或“李四”的注册码,而且这些注册码都是CalculateRegCode函数计算出来的,可谓是“正宗”加“原创”。
  防止这种情况出现的关键是避免注册码以明文的形式出现在内存中,考察映射函数(1),其实只要修改映射函数的输入和输出设计,不返回计算出的正确注册码明文,而是返回一个注册码经过散列(hash)计算之后得到的散列值,就可以避免出现这种情况。例如:

  散列值1 = E1(用户信息)         映射函数(2)
  散列值2 = MD5(用户注册码)

整个注册信息校验过程首先使用映射函数E1根据用户信息在内部计算正确的注册码,但是不直接输出这个注册码,而是利用MD5或CRC32之类的不可逆散列函数计算出注册码的散列值并输出这个散列值1,然后用MD5或CRC32直接计算用户输入的注册码得到散列值2,最后通过比较散列值1和散列值2来判断注册码是否正确。整个过程中正确的注册码只是短暂存在于CalculateRegCode内部,破解者即使通过汇编代码调试跟踪到了比较散列值的地方,也无法得到正确的注册码,只能通过分析CalculateRegCode函数的内部映射方式获得注册码,只要软件设计的映射方式足够复杂就能够让大多数破解者知难而退。  共享软件中负责注册码校验的代码是软件中最关键的代码,也是软件破解者首先要找的地方。通过对校验代码的分析,破解者不仅能够利用前面介绍的方法从内存中抓取正确的注册码,还能了解软件的整个加密策略,甚至写出注册机。所以,尽量隐藏这部分代码,让破解者无法确定它们的位置也就能够极大地提高软件的安全性。

出错提示信息紧跟在加密判断之后出现

     为了方便软件的版本控制,多数共享软件都将共享版和正式版整合在一起发布,只是通过对使用时间或功能进行限制来区分是共享版还是正式版。这样的软件通常都有一个界面让用户输入注册码,通过验证注册码完成从共享版到注册版的转变。比如在软件界面上添加一个“注册”菜单或在关于对话框中添加一个“注册”按钮,通过用户的点击引导用户完成注册过程。这种策略并无不妥,破解者不大可能根据软件界面元素推测出校验代码的位置,但是许多软件作者对编译原理不了解,往往在得到注册校验结果后立即弹出消息窗口提示注册成功或注册码不正确之类的信息,这就给破解者提供了定位注册码校验代码的线索。就以下面的演示代码为例:
BOOL bCheckValid = CheckValue(注册码...);
if(bCheckValid)
{
  MessageBox(..."注册成功!"...);
}
else
{
  MessageBox(..."注册信息不正确!"...);
}

C/C++的编译器在生成这段代码时,两个提示信息字符串作为静态变量被放置在程序的数据段中,在代码段的MessageBox函数调用的地方会引用这两个地址,这两个地址就能够成为破解者的线索。本文前面已经介绍过,C/C++的编译器对代码中出现的字符串不作任何处理,按照顺序连续排放在数据段的某个位置,用16进制编辑器打开编译过的可执行文件,通常可以在数据段看到这些提示信息的明文。破解者使用字符串搜索(很多16进制编辑器都提供强大的搜索功能)功能在编译过的可执行文件中搜索MessageBox弹出的提示信息,就能定位到这些信息的偏移位置(相对地址),搜索相对地址可以进一步找到引用这些信息的位置,通常就是MessageBox函数的调用位置,这就离CheckValue函数的位置很近了,稍有经验的破解者都可以很容易找到它。将提示信息加密存放就可以避免程序在这些16进制编辑器中泄漏天机,不过最好的方法就是让注册提示信息远离关键的加密校验代码。在很多情况下不必在用户输入注册码后立刻提示成功与否,可以在某个位置设置一个标志,然后在软件的关键功能或核心功能执行之前取出这个标志判断,如果不是有效的注册用户就拒绝执行该功能。此外,对于标志存放也要妥善保护,最简单的方法就是使用定时器或辅助线程,每隔一定的时间就对注册码校验一次,重新设置一次标志的状态,以免被内存补丁程序破解。     

其他常见问题

     使用时间限制功能的共享软件,用该避免使用GetSystemTime或GetLocalTime获得时间,因为破解者通常会在这些众所周知的API上下断点来捕捉程序中的破绽,不仅如此,这两个函数还很容易被“欺骗”,用户只需要修改Windows系统的时间就可以控制这两个API函数返回在“有效期内的”时间,从而使软件永不过期。其实有很多种获取时间的方法,Windows系统的一些关键文件通常只是在安装时创建一次,获得这些文件的创建时间就是一个很好的获取时间的方法。
    有些软件作者发布的试用版其实就是完整的正式版,只是修改资源将软件界面上的一些功能菜单或按钮禁止,这样做很省事,但是很不安全,破解者只需将被禁止的菜单或按钮重新改成可用就可以将试用版变成正式版,这样的资源修改工具有很多,很多国外软件的汉化版本就是通过修改程序资源实现的,软件作者应该避免采用这种方式加密软件。
     

总结

    很多网友向我询问软件加密的问题,但是他们通常觉得我给出的方案过于复杂(换句话说就是没有必要)。有网友问我,我的软件是面向计算机初级用户的,他们连注册表是怎么回事都不知道,怎么会破解我的软件?我的回答是永远不要小看别人,就算别人是菜鸟,但是菜鸟的“朋友”呢?即使一个功能简单的软件,也需要开发者付出很多的心血,但是破解只需要几分钟到几天的时间,这对于软件作者来说是非常不公平的。软件的加密与解密技术并不是什么特别难的技术,希望共享软件的作者能够多了解一些常见的加密与解密方法,更好地保护自己的软件。好的加密方法不一定就是什么高级技术,一个简单的方法如果用的巧妙往往也能起到出其不意的效果。希望本文列举出的常见问题以及相应的对策能够对广大共享软件作者有所帮助。

参考文献

[1] 冉林仓.Win32汇编语言实用教程.北京:清华大学出版社,2004.
[2] Matt Pietrek.An In-Depth Look into the Win32 Portable Executable File Format.MSDN Magazine,2002
[3] 段刚.软件加密技术内幕.北京:电子工业出版社,2004.

 

 

阅读更多
个人分类: 加密解密
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

不良信息举报

共享软件加密的一些误区

最多只允许输入30个字

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭