11.4 高级破解:Defender
在掌握了差不多的基本保护概念之后,你就可以进一步深入研究并继续尝试破解一些更强大的保护了。为此,我专门创建了一个crackme程序,接下来你就要拿它做破解实验。这个crackme程序名为Defender,创建它的目的就是为了展示几种强大的保护技术,这些保护技术与你平日里见到的商用保护技术非常相似。我得提醒一下你:如果你在此之前从未接触任何一种真正的保护技术,你可能会觉得要破解Defender简直比登天还难。其实不然,你所需要的就是掌握更多的知识和蓄积足够的耐心。
Defender与底层操作系统紧密集成在一起,且它是专为在基于NT的Windows系统中运行设计的。它可以在当前所有基于NT的系统中运行,包括Windows XP、Windows Server 2003、Windows 2000和Windows NT 4.0,但不能在非基于NT的操作系统中运行,如Windows 98和Windows Me。
我们先运行一下Defender.EXE程序,看看会发生什么事。需要指出的是,Defender是一个控制台模式的应用程序,所以通常它要在命令提示符(Command Prompt)窗口中运行的。我之所将Defender设计成为一个控制台模式的程序,是因为这大大简化了Defender程序的编写。当然,我们也可以在普通的GUI应用程序中创建一个同样强大的保护,但写代码要花更长的时间。有一点需要指出的是,控制台模式的应用程序并不是DOS程序!基于NT的系统可以用NTVDM虚拟机运行DOS程序,但对Defender程序不需要NTVDM虚拟机。像Defender这样的控制台程序是一般的32位Windows程序,它只是避开使用Windows GUI API函数(实际上它可以调用其他任何一个Win32 API函数),只使用简单的文本窗口与用户进行通信。
你可以在命令提示符窗口中运行Defender.EXE,并会收到Defender的使用方法的消息。图11.10显示了Defender的默认使用方法的消息。

图11.10 不带命令行参数运行Defender.EXE
Defender程序可以接收一个用户名和一个16位的十六进制序列号。现在,我们给它输入一些编造的值作为参数,看看会发生什么。图11.11显示了当我们输入用户名为John Doe、序列号为1234567890ABCDEF后Defender的响应。
毫无悬念——Defender只是简单地报告我们的序列号是错误的。在做破解的时候,试试这个步骤是有必要的,至少你能看到程序在接收到错误的注册信息会报告什么样的错误消息。你应该能够在可执行文件的某个地方找到这个错误消息。
我们将Defender.EXE加载到OllyDbg中看看。你要做的第一件事就是查看“Executable Modules”窗口以确定有哪些DLL静态链接到了Defender。图11.12显示了Defender的“Executable Modules”窗口。

图11.11 将用户名John Doe和序列号12345678ABCDEF作为参数运行Defender.EXE

图11.12 (从OllyDbg中看)静态链接到Defender的可执行模块

图11.13 (从OllyDbg中看)Defender.EXE的导入与导出函数
这个列表非常短——只有NTDLL.DLL和KERNEL32.DLL两项。记得我们前边讲的图形用户界面的crackme程序KeygenMe-3吧,它的可执行模块列表要比这个长很多,不过也难怪,Defender只是一个控制台模式的应用程序。我们接着看看“Names”窗口,确定一下Defender调用了哪些API函数。图11.13显示了Defender.EXE的“Names”窗口。
确实非常奇怪,看上去Defender.EXE只是从KERNEL32.DLL中调用了IsDebuggerPresent API函数。我们不需要太多推断就可以得出这个不太可能是真的结论。除了调用IsDebuggerPresent外,程序一定得通过别的方式与操作系统进行通信。比如说,如果没有调用操作系统的函数,那么程序又是怎么在控制台窗口上输出信息的呢?这显然是不可能的。我们再用DUMPBIN处理一下这个程序,看看Defender的导入表中有些什么。列表11.4显示了用/IMPORTS选项的DUMPBIN的输出。

列表11.4 用/IMPORTS选项对Defender.EXE运行DUMPBIN的输出

列表11.4(续)
这儿也没有什么新的发现。DUMPBIN的输出也表明Defender.EXE只调用了IsDebuggerPresent。不过,这里还有个有趣的地方,那就是Summary段,DUMPBIN在这里列出该模块的各个段。从这里我们发现Defender没有.text段(通常PE可执行文件中的代码就放在.text段中)。相反,它有两个奇怪的段:.h3mf85n和.h477w81。这并不是说程序中没有任何代码,这只是说明代码很可能就躲在这两个名字奇怪的段中。
在这个时候,聪明的做法是用/HEADERS选项再运行一次DUMPBIN,来看看Defender是怎样创建的(见列表11.5)。

列表11.5 用/HEADERS选项对Defender.EXE运行DUMPBIN的输出(待续)




列表11.5(续)




列表11.5(续)


列表11.5(续)
在/HEADERS选项条件下DUMPBIN为我们提供有关Defender.EXE程序的更多细节信息。例如,容易看出#1段(即.h3mf85n)就是代码段。它被指定为Code,而且程序的入口点就在这里(入口点在404232,.h3mf85n从地址401000开始,到4042FF结束,所以我们知道入口点就在这个段内)。另一个名字奇怪的段.h477w81看上去像是一个小型的数据段,可能包含一些变量。还需要提一下的是子系统标志(subsystem flag)等于3,说明这是一个Windows控制台用户界面(console user interface,CUI)程序,Windows在运行这个程序的时候会自动为它创建一个控制台窗口。
所有这些奇怪的段名都说明了程序很可能是经过了某种加壳处理(packed)。加壳程序能够创建包含加壳后的代码(packed code)或用于脱壳的代码(unpacking code)的特殊段。有个不错的方法——通过在PEiD中运行程序来看它是否被一个未知的加壳程序做过加壳处理。PEiD程序可以识别常见的可执行文件的特征码(signatures),并显示这个可执行文件是否用流行的可执行文件加壳程序或者拷贝保护产品做过加壳处理。你可以从下面这个网址下载PEiD:http://peid.has.it/。图11.14显示了PEiD处理Defender.EXE时的输出。
不幸的是,PEiD报告“Nothing found”,所以可以有把握地说Defender没有做过加壳处理,或者说是被一个未知的加壳程序做的加壳处理。我们接下来对这个程序进行反汇编,找找“Sorry … Bad key, try again.”这条消息是从哪里来的。

图11.14 用PEiD程序处理Defender.EXE时报告“Nothing found”
11.4.1 逆向Defender的初始化程序
因为程序显然没有直接调用任何API函数,所以你也就无法找到一个可以设置断点的API函数供你找到程序中输出“Sorry … Bad key, try again.”消息的代码。因此除了检查程序的入口点来碰碰运气,看能不能找到一些有所启示的代码外,你没有其他选择。让我们在IDA中加载这个程序,然后仔仔细细地分析它。现在你可以快速浏览一下这个程序入口点处的代码。


列表11.6 由IDA产生的Defender的入口函数的反汇编代码(待续)



列表11.6(续)



列表11.6(续)
列表11.6给出了Defender的入口函数。快速地扫描一下这个函数,你会发现一个很重要的特征——这里的入口点并非一个普通的运行库初始化程序。即使你之前从来没见过运行库初始化程序,你也可以非常肯定它不可能以IsDebuggerPresent函数调用结尾。当程序执行到IsDebuggerPresent调用时,你可以看到函数一返回EAX就与自己做了一次异或操作(XORed)——其返回值居然被忽略了!在网址http://msdn.microsoft.com上快速搜索一下,我们可以得知IsDebuggerPresent函数应该根据当前是否存在调试器而返回一个布尔(Boolean)值。在这个API函数刚刚返回就对EAX做异或意味着这个调用没有任何的实际意义。
不管怎样,我们回到列表11.6中前边的代码,了解一些有关Defender的信息。我们来看看对402EA8处的调用做了些什么。

前面的程序以一段有趣的代码序列开始:从fs:30h中加载了一个值到EAX寄存器。一般来说,在基于NT的操作系统中,fs寄存器用于访问线程本地信息(thread local information)。对任一给定的线程,fs:0指向的都是本地线程环境块(Thread Environment Block,TEB)数据结构,这个数据结构中包含了大量在运行时系统所需的线程私有(thread- private)信息。在这里,函数访问了TEB中偏移地址+30处的信息。幸运的是,你可以得到详细的Windows的符号信息(symbolic information),通过这些符号信息你可以知道TEB中偏移地址+30处存放的是什么信息。通过在WinDbg中加载NTDLL的符号并使用DT命令你就可以得到这些符号信息(关于WinDbg和DT命令的更多信息你可以参考Microsoft Debugging Tools的网页,网址是:www.microsoft.com/whdc/devtools/debugging/default.mspx)。
TEB的结构列表非常长,所以在这里我只列出了其中的前边部分,一直到偏移地址+30处——也就是程序访问的那个偏移地址。


很显然,.h3mf85n:00402EA9这一行是通过TEB访问进程环境块(Process Environment Block,PEB)。PEB是Windows中的进程信息数据结构,就像TEB是线程信息数据结构一样。在地址00402EB5处程序访问了PEB中偏移地址+c处的信息。让我们看看这里存放的是什么。同样,整个PEB的定义也非常长,所以这里我只列出了定义的开始部分。

在这里,偏移地址+c处是_PEB_LDR_DATA,也就是加载程序(loader)信息。我们来看一下这个数据结构,弄清楚它内部到底有些什么。

看起来这个数据结构是用来管理在当前进程中加载的可执行模块的。这个数据结构内有好几个模块列表,每个模块列表中都包含了当前进程加载的不同顺序的可执行模块。这个函数读取偏移地址+c处,也就是访问InLoadOrderModuleList模块。我们看一下模块数据结构LDR_DATA_TABLE_ENTRY,并试着理解这个函数在找什么东西。
下面关于LDR_DATA_TABLE_ENTRY的定义是在WinDbg中使用DT命令生成的。一些Windows的符号文件实际上都可以使用这个命令来转储其中所包含的数据结构定义。你只需键入“DT ModuleName!*”,就可以得到模块中所有名字的列表,然后再键入“DT ModuleName!StructureName”即可看到其成员的完美的列表。

在获取了指向InLoadOrderModuleList的指针后,函数好像读取了第一个模块中偏移地址+0处的信息。从这个结构中可以看出,这个偏移地址+0处是LIST_ENTRY数据结构的一部分。我们转储LIST_ENTRY,看看偏移地址+0处是什么内容。
![]()
偏移地址+0处是Flink,它可能是指的是“前向链接(foreward link)”。这就是说函数通过硬编码的方式跳过第一个入口,而不管这个入口中是什么。这很异乎寻常,因为对一个链表(linked list)来说你会想着应该有个循环才对——这里却没有循环,函数通过硬编码的方式跳过第一个入口。在这之后,函数只是简单地返回第二个入口中偏移地址+18处的值给主调函数。_LDR_DATA_TABLE_ENTRY中偏移地址+18处是DllBase。所以,看上去函数所做的只是为了寻找某个DLL的基地址。这时,用WinDbg加载Defender.EXE是明智之举,我们查看一下加载程序的信息,看看第二个模块是什么。为此,我们使用了“!dlls”命令,“!dlls”可以转储得到一个界面友好的(相对而言)加载程序数据结构的视图。选“-l”参数,命令会以加载顺序转储模块,实际上就是你刚才从PEB_LDR_DATA中取InloadOrderModuleList所遍历的列表。


好了,看来第二个模块就是NTDLL.DLL。00402EA8处函数的作用只是用来获取NTDLL.DLL在内存中的地址。这有很多用处,因为我前面曾经说过,程序如果不使用任何与操作系统的接口的话是不可能与用户进行通信的。显然,获得NTDLL.DLL的地址是创建与操作系统的接口的第一步。
如果再回到列表11.6,可以看到00402EA8处这个函数的返回值直接传给了004033D1,004033D1处这个函数正是下一个被调用的函数。我们来看看004033D1处这个函数。


表11.7 由IDA Pro生成的Defender中4033D1处这个函数的反汇编代码(待续)



表11.7(续)



表11.7(续)
![]()
表11.7(续)
这个函数开头的部分看起来很熟悉,是吧?但在后面却有一处有些奇怪。看一下在指令“JMP EBX”之后地址004034DD处的代码。看上去好像IDA将它看作是数据,而不是代码。这些数据从地址004034DD开始一直延续到地址4041FD(为了节省版面,我在列表中省略掉了大部分的数据)。为什么在程序的中间部分会出现这么一段数据呢?这在拷贝保护代码中是一种常见的情景——子程序在加密后都以二进制的形式存储,并在运行时再将其解密。很可能这些不能识别的数据就是加密后的程序代码,它们会在运行时被解密。
让我们对函数开头部分中未加密的代码做个简单的分析。我们很快就可以确定出这部分“可读的”代码区大体上被分为两大块,很可能是用if语句分开的。在00403405内的条件跳转指令决定了程序接下来要执行哪一个块,但需要注意的是,00403401处的CMP指令将[ebp-8]与0进行比较,尽管[ebp-8]在前一行被置为了1。在循环中这种序列非常常见——修改变量、然后再执行一次代码。通观IDA中的反汇编代码你会发现,这个函数中根本就找不到这种构成循环的跳转。
既然找不到理由让我们相信40346D中的代码曾经被执行过(因为[ebp-8]中存放的变量被硬编码为1),那么现在我们就可以把精力全部放在第一种情况了。简单地说,现在我们看的是一个循环,这个循环在对一块数据区进行迭代处理(将迭代到一个数就将它与常数2BCA6179h做异或运算)。回到指针被第一次初始化的位置,即地址004033E3,就是在这里通过堆栈将[ebp-20h]初始化为4034DD。[ebp-20h]后来被用作开始异或(XOR)运算的起始地址。如果你在看列表11.7的话,可以看出地址4034DD处于这个函数的中间位置——正好是代码部分结束而数据部分开始的地方。
所以,不出意外的话这段代码应该是实现了某个解密算法。加密了的数据就放在这个函数的中间——4034DD处。这个时候,一个好的办法是将调试器中代码切换到现场调试中,看看解密过程的输出结果。要做到这一点,你可以在OllyDbg中运行这个程序,并在解密过程结束的地方(即0040346B处)设置一个断点。在OllyDbg运行到0040346B地址的时候,起初4034DD中的数据看起来好像还不知道是什么数据,因为Olly输出的结果是这样的:

不管怎样,你只能让Olly重新分析这段内存,看能不能找到一些有意义的东西。通过按“Ctrl+A”进行重新分析。显然,情况立即发生了变化。那些无意义的字节变成了汇编语言代码。往下翻几页你就会发现代码可真不少——实际上有好几十页的代码。这其实才是正在研究的函数4033D1真正的函数体。列表11.7中所列出的只是这个解密函数序言(prologue)。函数4033D1完整的解密代码很长,如果全列出来会占很多页,所以这里就不列出来了,我试着介绍一下这个函数的总体结构以及它所完成的功能。当然,我会对其中一些值得研究的关键代码段作进一步的分析。最好是不要关闭OllyDbg,让程序对自己进行解密,这样你就可以一边看书,一边看OllyDbg中解密后的代码了(译注:书上要是列出这些代码你就不用这么费劲了)——这个函数中确实有不少有趣的代码。你一定要知道的是,对于这么大的一个函数,你想要理解其中的每一行是不大现实的,而且也没有那个必要。相反,你必须要尝试着找出代码中的关键区域,并试着理解这些代码的用途。
11.4.2 分析解密后的代码
这个函数开始的时候对你在前面获得的NTDLL基地址执行了某个指针操作。这个函数在NTDLL的PE头部寻找到NTDLL的导出目录(OllyDbg会告诉你这一点,因为如果函数获得指向导出目录的指针,Olly就会将函数注释为ntdll.$$VProc_ ImageExportDirectory)。接下来函数会扫描各个导出函数,并对函数名字符串上执行一个有趣的(也很少会见到的)算术运算。看我们来看一下完成这部分工作的代码。


在调试器中很容易看出[EBP-68]中存放的是当前字符串的长度(在前面计算好的),而[EBP-64]中存放是当前字符串起始地址。然后函数进入一个循环,这个循环依次读取字符串内的每一个字符并将它左移——左移的位数等于[EBP-68]对24求模([EBP-68]中存放的是该字符在字符串中的位置),然后将结果加到[EBP-6C]处的累加器中。这样会得到一个32位的数,这个数好像是该字符串的校验和。至于此处为什么需要这个校验和我们还不清楚。在字符串中所有的字符都处理完后,将会执行下面的代码:
![]()
如果[EBP-6C]不等于39DBA17A,这个函数就接着计算下一个NTDLL导出项(export entry)的校验和;如果相等的话,则终止循环。也就是说其中有一个NTDLL导出项会得到39DBA17A的校验和。你可以在代码中紧跟着JNZ之后的那条指令(在地址004035D9处)上设置一个断点,并让程序开始运行。这样我们就能确定出程序要找的是哪一个函数了。当执行到Olly的这个中断时,就可以到[EBP-64]处查看当前加载的是哪个函数名了——是NtAllocateVirtualMemory。所以,我们可以推测这个函数感兴趣的是NtAllocateVirtualMemory。NtAllocateVirtualMemory这个本地API等价于VirtualAlloc。函数VirtualAlloc是用于分配内存页(memory pages)的公开的(有文档可查)Win32 API。
计算出NtAllocateVirtualMemory的准确地址(存放在[EBP-10]中)之后,这个函数接着就调用了这个API函数。下面列出了调用代码序列:

注意这段代码一开始的指令RDTSC,这是一个不太常用的指令,以前我们没有遇到过。通过参考Intel指令集参考手册[Intel2, Intel3],我们知道RDTSC执行一个读出时间戳计数
器(Read Time-Stamp Counter)的操作。时间戳计数器是一个高速的64位计数器,其值每个时钟周期增加1。也就是说,在3.4GHz的系统中,这个计数器大约每秒钟增加34亿次。RDTSC将计数器加载到EDX:EAX,EDX存放计数器的高32位,而EAX存放计数器的低32位。Defender从EAX中取出低32位,并与7FFF000进行按位与(AND)操作。然后,将计算结果(实际上是指向该计算结果的指针)作为调用NtAllocateVirtualMemory函数的第二个参数传过去。
为什么Defender要把时间戳计数器的低32位部分作为参数传递给NtAllocateVirtualMemory函数呢?让我们看一下NtAllocateVirtualMemory的函数原型,来搞清楚系统要求第二个参数是什么。下面给出的NtAllocateVirtualMemory函数原型是我从网站http://undocumented.ntinternals.net找到的,这是网站有关未公开Windows API函数的非常好的资源。当然,有关本地API函数最权威的著作是Gary Nebbett所写的Windows NT/2000 Native API Reference[Nebbett]。

看上去第二个参数好像是一个指向基地址的指针。参数前面的“IN OUT”表明NtAllocateVirtualMemory函数会读出BaseAddress中的值,然后再对它进行写操作。这个函数完成的工作是试着在指定的地址分配内存,然后把实际分配的内存块地址写入BaseAddress。所以,Defender实际上就是把时间戳计数器当作它所希望的分配内存空间的起始地址了……你可能感觉有些奇怪,但实际上一点儿也不奇怪——这个程序所做的就是在内存中随机找一个地方来分配内存。时间戳计数器是一种获得某个级别随机值(译注:其随机程度待定)的好方法。
这个调用另一个有趣的地方是它的第四个参数,即请求分配的内存块大小。Defender是从[EBP-4]中取得这个块大小参数的。我们再回到代码中,你会发现下面这段代码,这段代码看上去好像参与了生成块大小的计算:
![]()

这段代码首先从[EBP+8]处获取NTDLL基地址,继而访问了PE的头部。然后,将指向PE头部的指针存储到 [EBP-74]中,并访问PE头中偏移地址+1C处。因为PE头部是由好几个数据结构组成,所以弄清楚某个偏移地址处存放的是什么有一定的难度。WinDbg中的DT命令可以很好地解决这个问题。

很显然,偏移地址+1处是OptionalHeader数据结构的一部分,因为OptionalHeader是从偏移地址+18处开始的,显然偏移地址+1C对应到OptionalHeader数据结构中的偏移地址是+4,偏移量+4处存放的是SizeofCode。这里还有另一小段代码好像与需要分配的内存空间大小的计算有关系:
![]()
在这段代码中,Defender从[EBP-7C]处获取一个指针,并从这个指针的偏移地址+18处读取数据。如果你看一下0040363D处的指令读到EAX寄存器中的值,你就会明白
它指向NTDLL头部的某个地方(具体的值很可能会随着每次更新操作系统而改变)。使用DUMPBIN快速地看一下NTDLL头部,你会发现EAX中的地址就是NTDLL导出目录的起始位置。找到IMAGE_EXPORT_DIRECTORY数据结构的定义,你会发现偏移地址+18处是NumberOfFunctions成员。下面给出的代码是计算内存块大小的最后几个操作:
![]()
计算所需分配的内存块大小的完整公式为:Block-Size = NTDLLCodeSize + (TotalExports + 1) * 8。虽然我们还是看不出这里Defender在做什么,但可以确认的是这与NTDLL的代码段及其导出目录有关。
这个函数接着又进入另一个对NTDLL导出列表的迭代,再一次为每个函数名计算了那个奇怪的校验和。在这个循环中,有两行往新分配的内存块中写入数据的代码比较有趣:

前面这两行代码对NTDLL中的每个导出函数都执行一次。这两行代码把这块分配好的内存当作一个数组来对待。第一行代码写的是当前函数的校验和;第二行代码写的是导出函数的RVA(相对虚拟地址,Relative Virtual Address),写入的地址为第一行写入地址加4。这表明,新分配的内存空间被当作一个数据结构数组,每个数组项有8字节长(即该数据结构的长度)。偏移地址+0处存放的是函数名的校验和,而偏移地址+4处存放的是这个函数的RVA。
下面又是一段比较有趣的代码:

这段代码执行内存拷贝,是汇编语言中很常见的“语句”。“REP MOVS”指令反复地从ESI指向的地址中拷贝DWORDs(双字)到EDI指向的内存地址,直到ECX的值减到
0为止。每拷贝一个DWORD,ECX就自动减1,而ESI和EDI都自动增加4(代码每次拷贝32位的数据)。第二个“REP MOVS”指令执行的是对最后三个字节的逐字节拷贝操作(如果需要的话)。只有当数据没有32位对齐的时候才需要使用这个指令。
我们来看看在这段代码中拷贝的是什么内容。ESI中先加载了[EBP+8]中的数据(即NTDLL的基地址),然后又加了[EAX+2C]中的值。我们稍微回过头看看前边的代码,你可以看到EAX中的内容是前边一直在找的同一个PE头部地址。如果你再回过头去看看前面用WinDbg转储的PE头部,你会发现偏移地址+2C处是BaseOfCode。EDI寄存器中加载的是新分配的内存块中的地址,正好就在刚才我们已经搞定的那张存放校验和以及函数RVA的表(即在指导组)后面。实质上,这段代码是将NTDLL中的全部代码拷贝到这个内存缓冲区中。
好,我们来总结一下我们目前所掌握的情况。你知道有一块在运行时分配的内存,代码中还试着借助时间戳计数器来使这块内存分配到一个随机的内存地址上。这段代码中还包含一张表(即数组),表内记录的是从NTDLL的所有导出函数名的校验和以及这些函数的RVA。就在这张表的后面(就在这个内存块中)是整个NTDLL代码段的一份儿拷贝。图11.15用图示的方法给出了这个有趣而又不同寻常的数据结构。
不过,如果我是在一个普通的应用程序里看到这样的代码的话,我可能会想,我看的代码一定是一个疯子科学家的“杰作”。但在重要拷贝保护中这样的代码却非常有用。这是一段将内存块分配到随机虚拟地址并创建一个与操作系统模块“迷惑”接口的机制。你很快就能看到这个接口在干扰逆向工作方面是多么的高效(这大概就是这里使用这样代码的唯一原因吧)。
这个庞大的函数接下来调用了另一个函数(在4030E5处)。这个函数是从两个有趣的循环开始的,其中一个是:

这个循环搜索了导出表,并将每一个字符串(即函数名)的校验和与190BC2进行比较。我们很容易就能看出来这里发生了什么——代码正在NTDLL中查找一个特定的API函数。因为代码并没有通过字符串而是通过校验和进行搜索,所以我们目前还不知道代码要找的是哪一个API函数——这个API函数名正好是我们得不到的。代码在找到要找的API函数的时候执行的代码如下:
![]()

图11.15 Defender的NTDLL内存拷贝的布局
上面的代码读取了找到的函数项中偏移地址+4处(还记得吧,偏移地址+4处是函数的RVA)的数据,并将它加上拷贝NTDLL代码段的地址。稍后在这个函数中调用了在这个地址处的函数。勿庸置疑,这是在调用NTDLL API函数的拷贝版本。你可以在这个地址处看到下面这几行代码:

这段代码调用了7FFE0300处的代码,实质上这是对NTDLL API函数KiFastSystemCall的调用,而KiFastSystemCall正是我们调用系统内核功能的通用接口。需要指出的是,我之所以可以肯定是这个函数,是因为尽管Defender拷贝了NTDLL的整个代码段,代码还是通过地址显式地引用了这个函数。下面是KiFastSystemCall的代码——只有两行:
![]()
实际上,KiFastSystemCall所做的就是调用SYSENTER指令。SYSENTER指令执行的是内核模式切换,这表明该程序执行了一个系统调用。需要说明的是,这在Windows 2000或更早版本的Windows上会略有不同,因为Microsoft在Windows 2000之后改变了其系统调用机制(在Windows 2000及更早版本的Windows中使用INT 2E指令进行系统调用)。Windows XP和Windows Server 2003操作系统都使用了这种新的系统调用机制,更不用说更新版本的操作系统了,比如说目前被称为Longhorn的操作系统。如果你是在老版本的操作系统中进行调试,你所看到的肯定和这里给出的略微有一些区别。
你现在遇到了一点麻烦。因为你使用的是用户模式的调试程序,所以显然无法跟踪到SYSENTER中去。这就意味着我们很难确定程序试图完成的是哪一个系统调用。你现在有下面几种选择:
, 如果你有内核模式的调试器的话,切换过去,然后跟踪到这个系统调用中弄明白Defender在做什么。
, 回到前面的校验和/RVA表,找出当前这个系统调用的RVA——表中的RVA很可能与NTDLL.DLL导出目录中的RVA是一样的。然后你就可以用DUMPBIN处理NTDLL,进而确定出你要找的那个API函数。
, 通过在导出列表中的顺序找到是哪一个系统调用。显然,校验和/RVA表中导出函数的顺序和原来NTDLL导出目录中的顺序是相同的。只要知道了该系统调用的索引,你就可以通过查看NTDLL导出目录来确定这是哪一个系统调用。
在这里,我认为最好的选择是切换到内核模式的调试器。我在这里用的是NuMega公司的SoftICE,因为它非常易于安装,而且只需要一台计算机就够了。如果你手头没有SoftICE,而且因为硬件限制不能安装WinDbg,我建议你试试其他两种方案。可能使用函数的RVA是最容易的方案。无论如何,只要你做逆向不是为了玩玩而已的话,我还是建议
你安装一个内核模式的调试程序——因为有些逆向工作在没有内核模式调试器的情况下是无法完成的。
有了内核模式调试器,我们就在SoftICE中跟入SYSENTER,我们到了NTOSKRNL中的KiFastCallEntry函数。接着又直接进入了KiSystemService,KiSystemService是一个通用的Windows系统调用调度程序(dispatcher)——所有的系统调用都要通过它。快速地执行这个函数中大部分代码,直到在函数接近尾部的地方看到“CALL EBX”指令为止。就是这条“CALL EBX”指令将控制交给了所调用的特定系统服务。此时,我们继续跟踪到这个函数中,发现程序又调用了一次NtAllocateVirtualMemory!按几下F12切换回用户模式,进入Defender的下一个调用。这是另外一个API调用,这个调用也是通过那个怪异的拷贝NTDLL接口实现的。这次,Defender调用的是NtCreateThread。现在你可以忽略这个新线程,继续跟踪我们正在跟踪的这个函数。该函数在创建这个新线程后就立即返回了。
在这个线程创建函数的调用之后的代码又一次对校验和表进行迭代,但这次它要找的是校验和006DEF20。之后,又立即从NTDLL的拷贝中调用了另外一个函数。你可以学着前边的方法跟踪这个函数,你会发现这次调用的是NtDelayExecution函数。如果你不了解NtDelayExecution,我这里简单解释一下:NtDelayExecution是一个本地API,其功能相当于Win32 API中的SleepEx。SleepEx的作用是让线程在所请求的时间段内放弃CPU。在我们这里,NtDelayExecution是在线程创建后立即被调用,看上去Defender好像是想要让新创建的线程立刻就开始运行。
NtDelayExecution函数一返回,Defender就立即调用另外一个在403A41处的(内部)函数。这个地址很有趣,因为这个函数的代码大约是从调用它的地方(4039FA)之后30个字节的地方开始的,而且更重要的是,从这个CALL指令到函数开始处之间的30个字节中SoftICE竟然没有找到任何合法的指令。所以,这里Defender很可能是跳过这个函数中间的一个数据块!事实上,转储4039FA,紧接着这个CALL指令后的这块地址空间存放的内容是:
![]()
好了,看上去这个函数中间存放的数据是Unicode字符串“KERNEL32.DLL”。显然这个CALL指令所做的一切就是为跳过这个字符串,以确保CPU不会试着“执行”它。这个字符串之后的代码又一次搜索了我们的校验和/RVA表,这次要找的是两个值:6DEF20和1974C。你可能还记得6DEF20是NtDelayExecution的函数名校验和吧。不过,我们还不能确定1974C代表的是哪一个API——但我们很快就可以弄清楚了。
11.4.3 SoftICE的消失
在这段代码中的第一个调用依然是对NtDelayExecution函数的调用,但在这里却遇到了一个小小的麻烦。当我们按F10要跳过对NtDelayExecution的调用时,SoftICE却消失了!当你查看命令提示窗口时,你会发现Defender已经退出了,可它还没有给我们打印出任何有关它的信息哪!看上去好像SoftICE的出现在某种程序上改变了Defender的行为。
在SoftICE出人意料地消失时,通过查看程序是怎样调用NtDelayExecution函数的,我们只能做出一种假设。早些时候创建的那个线程一定做了什么手脚,而且,Defender通过NtDelayExecution放弃对CPU的使用权,可能是在设法运行另一个线程。看来我们得把我们的逆向工作重点转移到这个线程上,看看它到底要做什么。
11.4.4 逆向分析第二个线程
让我们回到初始化子程序中的线程创建代码部分,来弄清楚这个线程究竟执行了什么样的代码。在动手之前,你必须先学习一些有关NTCreateThread函数的工作原理。不同于与之相当的Win32 API函数CreateThread,NTCreateThread是一个更加底层的函数。CreateThread函数接收了一个lpStartAddress参数,而NTCreateThread接收的是一个CONTEXT数据结构,CONTEXT数据结构准确地定义了该线程在第一次开始运行时的状态。
CONTEXT数据结构包含了所有的线程状态信息,其中包括了所有CPU寄存器的内容,当然也包括指令指针寄存器(instruction pointer。译注:即EIP寄存器,它指向当前执行的指令)。要告诉新创建的线程做什么,Defender需要初始化CONTEXT数据结构,并将EIP成员指向线程的入口点地址。除了指令指针外,Defender还必须亲自为线程分配堆栈空间,并在CONTEXT数据结构中将ESP寄存器设置指向为线程新创建的堆栈空间的起始地址(这恰好解释了为什么在调用NtCreateThread之前会有一个NtAllocateVirtualMemory函数的调用)。这一大段代码就是要让你知道通过调用Win32的CreateThread API函数来创建线程可以省多少事儿。
在这个线程创建的过程中,你需要在代码中找到Defender设置CONTEXT中EIP成员的地方。查看一下NtCreateThread的原型定义,你可以看到CONTEXT数据结构是作为第六个参数传给NtCreateThread函数的。这个函数把地址[EBP-310]作为第六个参数,所以我们可以确定地址[EBP-310]就是CONTEXT数据结构的起始地址。通过观察CONTEXT在WinDbg中的定义,你可以看到EIP成员位于偏移地址+b8处。这样,你就知道线程子程序应该是被拷贝到了[EBP-258](310 - b8=258,这些都是十六进制数)。下面这条指令似乎就是你要找的东西了:
![]()
查看一下地址402EEF处,你可以看到它里面存放的确实是代码,这一定是我们要找的线程子程序。快速浏览一遍这个函数便可知道这个函数的序言(prologue)和我们前面研究过的列表11.7中的函数是一样的,这表明这个函数也被加密了。让我们重新运行这个程序,并在这个函数上设置一个断点(这次不需要使用内核模式调试器)。最适合设置断点的位置是402FF4处,这里正好就在解密程序(decrypter)开始执行解密代码的之前(译注:代码在这里刚好已经解密出来了,CPU也就可以执行它们了)。当程序执行到这里,你就可以看到解密后的线程程序代码了。这段代码很有意思,所以我把全部代码都列出来了,参见列表11.8。


表11.8 Defender中地址00402FFE处的函数的反汇编代码(待续)


表11.8(续)
这是一个很有意思的函数,它看上去是在运行一个无限次的循环(注意从4030C2到地址402FFE的JMP指令,以及地址00403001处的代码是怎样将EAX置为1然后又测试它的值是否为0的)。这个函数从一个RDTSC指令开始,并将时间戳计数器存放在[EBP-8]中(译注:时间戳计数器的长度是8字节)。代码接着搜索了你已经很熟悉的拷贝版本的NTDLL表,还是为了查找那个倍受关注的6DEF20——现在你已经知道它就是NtDelayExecution函数了。这个函数调用NtDelayExecution时,传给NtDelayExecution的第二个参数是一个指向8个字节的内存区域的指针,而且这8个字节中的内容全为0。这一点很重要,因为NtDelayExecution的第二个参数是延迟间隔时间(一个64位的值)。将其设置为0意味着调用NtDelayExecution是要这个线程放弃CPU的使用权。只有所有的其他线程放弃CPU的使用权或者用完了分配给它们的CPU使用时间后,这个线程才能继续运行。
在NtDelayExecution返回之后,函数又一次调用了RDTSC。这次,RDTSC的输出存放在了[EBP-18]处。接下来在00403063处是一段完成64位的减法运算的代码。首先,用SUB完成低32位字相减,接下来用SBB(subtract with borrow,带借位的减法)完成带
借位的32位字相减。SBB指令的作用是对两个整数做减法,并把进位标记位(carry flag,CF)作为借位指示符(borrow indicator)参与减法运算(以防止漏掉前面减法产生的借位)。更多关于64位算术运算的信息请参考附录B中“64位算术运算”一节。
减法所得的结果与77359400进行了比较。如果结果小于77359400,这个函数就跳回开头部分重新执行(译者:注意是通过连续的两个跳转指令跳回去的),如果大于或等于(或者如果SBB指令产生了一个非0值,说明高32位部分发生了改变),这个函数就往下执行,查找另一个导出函数——这次要找的导出函数的字符串校验和是1BF08AE,找到后就调用这个API函数。这时候我们还不能确定这是哪个API函数,但是如果你具有一定的洞察力你应该跳过这个API函数。结果表明当你单步调试了这个API的话,上面的测试几乎总是不成立(实际上成不成立完全取决于你的CPU有多快以及你是以多快的速度单步执行这段代码的)。当你执行到这个API调用时,在SoftICE中跟踪进入这个API,你可以看到程序调用的实际上是NtTerminateProcess。
到这里,你应该逐渐理解我们这个线程是干什么的了吧。实质上,这个线程是一个定时监视器(timing monitor),其目的是检测这个程序进程是否被“暂停过”,如果是的话就直接当场终止掉进程。为此,Defender利用两条RDTSC指令来测试中间所用的时间是否满足一个合理的时长。如果两次调用RDTSC的间隔时间过长(这里“过长”指的是77359400个时钟周期,亦即十进制的20亿个时钟周期),就直接调用内核函数NtTerminateProcess终止进程。
11.4.5 击败“杀手(Killer)”线程
如果这个定时监视器线程在运行的话,我们就很难对Defender实施调试了,因为它一旦感知到有调试器拖延了程序的执行时间,它就会把进程终止掉。为了继续我们的破解过程,你必须使这个线程无效。一种办法是避开调用这个创建函数,另一种更简单的方法是在内存中对线程创建函数进行修补(当然是在函数被解码之后),让函数永远调用不到NtTerminateProcess函数。修补需要对函数作两处修改:第一处是用NOPs替换00403075处的JNZ指令(这里测试了减法运算结果的高32位是不是零);第二处是用JMP替换地址0040307E处的JNZ指令。这样,修改后的代码就变成这样了:

这也就是说,不管两次执行RDTSC指令之间的间隔时间多长,这个函数都不会调用到NtTerminateProcess。将这个修补保存到可执行文件中,这样以后就不必在每次运行程序的时候都重新实施这个修补了。不过需要指出的是,这里可没那么容易让你保存修补,因
为这个函数是加了密的——你必须或者直接修改加密数据、或者把所有有关加密的东西都统统清除。这两种方法都不容易,所以,暂时你还是将就着不厌其烦地在每次启动程序的时候在内存中重新实施一次修补吧。
11.4.6 加载KERNEL32.DLL
你可能还记得,在使用这个迂回的办法处理RDTSC线程之前,你曾经看到在代码的中间位置有一个“KERNEL32.DLL”字符串。我们来弄清楚程序是怎样处理这个字符串。
紧接着这个字符串出现的位置之后的代码是在搜索指向两个NTDLL函数的指针,其中一个的函数名校验和是1974C,另一个是我们熟悉的6DEF20(NtDelayExecution的校验和)。代码首先调用NtDelayExecution,然后调用校验和为1974C的函数。通过在SoftICE中跟踪第二个函数,你看到的是一幅更加令人费解的景像。这个API并不是像前几个API那样直接去调用内核功能,而它似乎是在NTDLL内部实现的一个函数,也就是说,它现在是在拷贝过来的代码内实现的。这样一来,我们要确定它是哪一个API函数就更困难了。
实际上你可以采取我们前边讨论中提到的一种方法来在这个混乱的接口中确定所调用的API。具体思路是:在初始化校验和/RVA表时,这些API函数拷入表中的顺序是从NTDLL的导出目录中读出这些函数的顺序。你现在所能做的就:一有API函数被通过校验和查找的方法找到,你就要确定其在校验和/RVA表中的编号(entry number)。这个编号应当也是NTDLL的导出目录中的合法索引,这就有望为我们准确地揭示出正在处理的函数是哪一个API。
为此,你必须在Defender查找这个API函数的代码之后放置一个断点(这是在表中查找1974C。译注:原书的这一句话中出现了两处笔误,“放置”一词写成了but,1974C写成了1973C)。当代码执行到这个断点时,你就用指向当前项的指针减去指向表开始位置的指针,再将结果除以8(每一项的长度为8个字节),这样你就得到了这个API在表中的索引。现在,你可以使用DUMPBIN或类似的工具转储NTDLL的导出表,并查找与这个索引对应的API函数。此时,你得到的索引是0x3E(比方说在我做这个工作的时候,这个表是从53830000开始的,而当前项的地址是538301F0,但是我已经告诉过你了,这些都是随机选取的地址——译注:指的是整个表的位置在内存中是随机的,但表内各个项之间的相对位置关系是确定的)。用DUMPBIN快速浏览一下NTDLL.DLL的导出列表,你就会找到答案。

哦,此处调用的API是LdrLoadDll,它等价于LoadLibrary函数的本地API。现在你已经知道加载的是哪个DLL了,因为你在前边已经看到它的名字了:KERNEL32.DLL。
在加载了KERNEL32.DLL后,Defender又执行了一遍我们熟悉的那段代码——在内存中一个随机的地址上分配了一块内存,并在这块内存中为KERNEL32.DLL的所有导出函数生成了函数名校验和/RVA表。在拷贝好的模块可以使用了之后,这个函数又调用了一次NtDelayExecution,然后你会看到另外一个滑稽的跳转,这个跳转跳过了大约30个字节。转储紧接着这个CALL指令之后的内存,你会看到它们的文本,如下所示:

终于看到一些熟悉的东西了,这不就是Defender的欢迎消息吗!显然,Defender是想要把它显示出来。这条CALL指令跳过了这个字符串,把我们带到了下面的这段代码:
![]()
这段代码取出CALL指令压入的“返回地址(return address)”后,又把这个地址压入了堆栈(尽管这个地址已经在堆栈中了),并调用一个函数。你甚至都不需要阅读函数的代码(毫无疑问,这个函数的代码全是对拷贝的KERNEL32.DLL代码的间接调用)就可以猜出这个函数将会输出你刚才压入堆栈的那个欢迎消息。我们就直接执行过这个函数,Defender毫无悬念地输出了那个欢迎消息。
11.4.7 再加密函数
紧接着你就会看到另外一个对6DEF20的调用——即NtDelayExecution函数,然后我们好像已经到了函数的结尾处。OllyDbg为我们显示了如下这段代码:

如果你仔细看一下004041F4处的JMP指令要跳转的地址,你会发现这个地址距离我们目前所在的地址非常远——实际上这个地址在这个函数的开头部分。为了让你回想起来一些东西,我们看看在那个地址的代码:
![]()
不知道你是否还记得这两行代码,不过没关系,就在00403401前面的一行代码把[EBP-8]置为了1,考虑到在00403401处就将它与0作比较,这看上去似乎有点可笑。好了,答案是——正是位于函数末尾的加密代码将这个变量置为零并跳转回00403401(刚好把完成置1的代码给搁在外面了)。既然这次执行了条件跳转,所以就跳转到了40346D处,此处的这段代码看上去与我们刚开始学习的解密代码非常相似。不过,还是有一些不同的地方——在调试器中考察这段代码的执行效果,你会很容易地发现:这段代码是对这个函数中的代码进行再加密。
我们没有必要再深入研究这个逻辑的细节了,但还有几个细节内容需要提一下。在加密代码执行完后,就要执行下面的代码:

其中第一行代码将EAX中的值存放到一个全局变量中。EAX看起来好像存储的是加密代码的某种校验和。而且,这几条PUSH、POP、JMP指令与最初跳转到解密代码中的指令完全是一样的,只是这次跳转的目标地址被修改为这个函数的结尾处。
11.4.8 回到入口点
在我们刚剖析完的这个巨大的函数返回之后,入口点子程序又例行地调用了一次NtDelayExecution函数,然后调用了另一个在404202处的内部函数。下面是这个函数的的全部内容:


这个函数又一次执行了我们所熟悉的拷贝导出表的搜索,不过这次是在拷贝的KERNEL32内存块(指向这块内存的指针存储在406004中)上搜索。然后立即调用那个找到的函数。你还得用前边使用过的函数索引的小技巧来确定这次调用的是哪个API函数。为此,在404227上设置一个断点,并观察加载到ECX寄存器中的地址。然后你用这个地址减去KERNEL32内存块的基地址,并将结果除以8。好啦,你得到了当前API的索引。马上对KERNEL32.DLL运行DUMPBIN /EXPORTS,找出这个API的函数名:SetUnhandledExceptionFilter。看来Defender将0040422作为它的未处理异常过滤程序(unhandled exception filter)。未处理异常过滤程序指的是在进程发生了异常而又无可用的处理程序来处理的情况下调用的程序。我们稍后再来考虑这个异常过滤程序以及它的功能。
我们接着看看另一个对NtDelayExecution的调用,它后面跟着另一个内部函数(401746)的调用。这个内部函数开始部分的代码我们非常熟悉,好像是另一段解密代码;而且,这个函数还被加了密。我不打算再讲述这段解密代码了,但有一个细节必须讲一下。在代码开始解密之前,执行了这样两条语句:
![]()
我之所以提到这两条语句,是因为变量[EBP-9C0]在几行代码之后被作为解密密钥来使用(通过与这个变量进行异或来解密代码)。你可能不记得你前面见过这个全局变量406008。还记得在第一个加密函数快要返回的时候它是怎样对自己进行再加密的吗?在加密过程中,代码计算了加密数据的校验和,并将最终的校验和存储在406008处的全局变量中。我之所以告诉你这些,是因为这是代码中一个异乎寻常的特性——解密密钥是在运行时计算出来的。这样做的一个好处是:任何在加密代码上设置的断点,如果不在此函数再加密前清除掉的话,就会改变这个校验和,从而防止下一个函数正确地解密!我将它命名为Defender,确实名副其实啊:镇守防线不被突破!
我们接着研究这个新的解密函数。这个函数以两个例行的NtDelayExecution调用开始。继而该函数通过这个混乱的接口调用了NtOpenFile函数,字符串“\??\C”以硬编码方式放在代码中。在NtOpenFile执行完后,函数又调用了NtQueryVolumeInformationFile,参数中带有FileFsVolumeInformation信息等级标志(information level flag)。之后,Defender从返回的数据结构中读取偏移地址+8处的数据,并将其存储到局部变量[406020]中。数据结构FILE_FS_VOLUME_INFORMATION中偏移地址+8处的内容是VolumeSerialNumber(这一信息我也是从网站http://undocumented.ntinternals.net上获得的)。
这完全是一段典型的拷贝保护代码,只是稍微有一点不同而已。主分区卷的序列号是一种生成与具体计算机相依赖的特征(码)的好方法。这段代码号是在格式化的时候随机分配给分区的一个32位数。这个值会一直保留到硬盘下一次被格式化。将这个值用于基于序列号的拷贝保护中意味着这段代码号不能在不同计算机上的用户之间共享——每一台计算机都要有一个不同的序列号。这段代码有一个略微有些不同寻常的地方,就是Defender直接使用本地API函数NtQueryVolumeInformationFile来获取主分区卷的序列号。通常程序员会用Win32的GetVolumeInformation这个API来完成这一工作。
我们现在已经快到了当前这个函数的尾声了。在返回前,这个函数又调用了一次NtDelayExecution,执行RDTSC指令,将低32位字作为返回值(好像是无用的返回值)加载到EAX中,然后回到开始处对自己进行再加密。
11.4.9 解析程序的参数
回到程序的主入口点函数,你会发现另一个NtDelayExecution调用,其后还紧跟着一个调用,这好像是程序入口点中(402082)调用的最后一个函数(而不是那个明显毫无用处的IsDebuggerPresent调用)。
当然,402082也是加了密的,所以你要在402198(就在用于解密的解密代码之后)处设置一个断点。在这个断点处,你一下子就会看到一段熟悉的代码(如果Olly此时显示的依然是垃圾数据而非代码,你可以试着跟踪到这些代码中看看它自己是否会自动更正过来,或者你也可以通过右键点击第一行并选择“Analysis”,或者选择“During next analysis, treat selection as Command(在下次分析时,将所选代码当作命令来处理)”,来明确地告诉Olly将这些字节当作代码对待)。在接下来的代码中你会看到一个NtDelayExecution调用,后面紧跟着的代码加载了一个新的DLL:SHELL32.DLL。加载代码后面又是生成混乱的模块接口:在一个随机地址处分配内存空间,为每一个导出的SHELL32.DLL函数名计算校验和,然后将整个代码段拷贝到新分配的内存块中。所有这些工作之后,程序调用一个KERNEL32.DLL中的函数,这个函数是一个纯用户模式的实现,这迫使你只能使用函数索
引的方法来让这个函数“现出原形”。我们最终确定出这个API是GetCommandLineW。实际上,GetCommandLineW返回一个指向我们的测试命令行的指针。
下面是对SHELL32.DLL中的一个API函数的调用。同样,这个SHELL32 API函数也没有直接调用内核函数,所以你又被某个长长的函数挡住了去路,你不知道这是个什么函数。这次你还得使用函数的索引来弄清Defender调用的是哪个API函数。这次查找的结果是CommandLineToArgvW函数。CommandLineToArgvW函数所做的是对命令行字符串进行解析并返回一个字符串数组,数组中的每一项都是一个单独的参数。Defender必须直接调用CommandLineToArgvW函数,因为Defender根本就没有使用通常情况下用来处理这些事情的运行库函数。
在CommandLineToArgvW调用结束后,你终于抵达了Defender中我们期待已久的区域:解析命令行参数的代码。
开始部分是一段比较简单的代码,用以验证参数的合法性。代码检查了参数的总数(由CommandLineToArgvW返回),确保参数的总数为3(Defender.EXE这个名字再加上用户名和序列号)。然后再检查第三个参数的长度是否为16个字符,如果不是16个字符,Defender将会跳转的位置与参数不是三个的所跳转的位置一样。再往后,Defender调用了在401CA8处的内部函数,这个函数验证这个16进制的字符串只包含数字和字母(可以是大写或小写)。该函数返回一个布尔值来指示这段代码号是否是一个合法的十六进制数。同样,如果返回值是0,代码将跳转到同一个位置(40299C),这个位置显然是“bad parameters(错误参数)”的处理代码。接下来的代码调用了另一个函数(401CE3)来确认用户名中只包含字母(可以是大写或小写)。这之后,你将会看到下面三行代码:
![]()
这三行代码执行后,EAX中存放的是用户名验证代码返回的值。如果EAX为0,代码将跳转至输入错误的处理代码(在40299C处)执行,如果EAX不为0,则跳转至402AC4,这里显然是输入正确的处理代码。需要注意的是,跳转到4029EC的这条代码又一次使用了CALL指令来跳过存放在代码中的字符串。在OllyDbg的“data”视图中快速查看一下位于这条CALL指令之后的地址,其内容显示如下:


看来,你显然已经找到了“bad parameters(错误参数)”消息的显示代码。没有必要再研究这段代码了——你应该直接进入“good parameters(参数正确)”的代码中,看看那里做了些什么。看起来你已经越来越接近目标了!
11.4.10 处理用户名
跳转到402AC4,你会发现这里的代码并不那么简单,还有不少代码需要我们进一步分析。代码首先对户名字符串执行了某种数值运算处理:对字符串中的每一个字符进行48求模,然后将这个模用于对该字符执行左移位操作。这里的左移位操作中有一个值得关注的细节,那就是它是在一个专用的、而且有点复杂的函数内实现的。这里是移位函数的代码列表:

这段代码好像是一个64位的左移操作。CL寄存器中是要左移的位数,EDX:EAX中存放的是要完成移位的数。在整个64位左移位的操作中,函数使用了SHLD指令。SHLD指令并不是真正的64位移位指令,因为它不左移EAX,它只是把EAX用作位的“源”移位到EDX中(用EAX的高位来填补EDX左移位后空出来的位)。这就是为什么函数在左移的位数少于32位的情况下(译注:大于等于32位的话EAX中的所有位就都补到EDX中了),还需要在EAX上使用一个SHL指令对EAX进行移位。
在这个64位左移位函数返回之后,程序即进入下面的代码:

图11.16给出了这段代码的数学解释。本质上,Defender是在为用户名准备一个独一无二的64位整数:它取出用户名中的每个字符,然后将它加到这个64位整数中的某个位置上去。
函数继而对输入的序列号做了与处理用户名类似的转化,只是这次转化要稍微简单一些。这次,它只是将这个16位的十六进制数直接转化成64位整数。得到这个整数后,程序就将从用户名和序列号转化来的两个64位数压栈,然后调用401EBC处的函数。此时,你一定希望在401EBC处的代码是你可以轻松理解的某种验证逻辑。若果真是这样的话,破解Defender就指日可待了!
11.4.11 验证用户信息
不出所料,401EBC也是加了密的,但是这段代码有些不同的地方。这个函数的解密密钥不是一个用于XOR操作的硬编码值,也不是从某个全局变量读来的值,它是通过调用另一个函数(在401D18处)获得解密密钥的。一旦从401D18返回,验证函数便将其返回值存储在[EBP-1C],在解密过程中使用。

图11.16 Defender用来将用户名字符串转换为64位整数的公式
让我们跟踪到函数401D18中,来弄清楚解密密钥是怎么生成的。一进入这个函数,你就会意识到碰到了一个不小的问题:它也是加了密的。当然,眼前的问题是401D18这个函数的解密密钥是从哪里来的?这里有两段看上去与这个解密密钥相关的代码。在函数的开始部分执行了下面的代码:
![]()
这段代码读取了前面生成的64位用户名整数中的低32位,并将它与[406020]中的全局变量相乘。如果你回到那个获取卷序列号的函数,你会发现获取的卷序列号就存放在[406020]。也就是说,Defender将用户名整数的低32位与卷序列号相乘,并将结果存储在[EBP-10]。另一段看起来与解密密钥有关的代码是解密循环的一部分:

这段代码从前面相乘的结果(存放在[EBP-10])中减去[EBP+10]中的参数,然后用这个值来对加了密的函数执行异或操作(译注:也就是在解密)!实际上,Defender是在计算解密密钥,公式为:Key=(NameInt * VolumeSerial – LOWPART(SerialNumber)。感觉好像不大对劲!我们让解密子程序完成解密工作,然后试着跟踪进入解了密的代码。下面给出了解了密的代码的开始部分(这里的内容实际上是随机的——你看到的代码可能和这个不一样)。

很容易看出这是一堆毫无意义的垃圾代码。看起来解密失败了。但是,Defender好像仍然要试图去执行这段代码!眼下会发生什么事情,完全依赖于你使用的是哪款调试器,但Defender并不甘心就这样结束,相反,它为我们显示了那条可爱的“Sorry … Bad Key”消息。看起来是前面安装的未处理异常的处理程序产生了这条消息。Defender当掉了(因为我们前边研究过的那段输出错误处理代码),异常处理程序显示打印了这条消息。
11.4.12 解密代码
似乎我们遇到了一点麻烦。原因是我们不知道解密Defender时通向显示“验证通过”的代码所需的解密密钥。Defender看上去是使用用户名和序列号生成这个密钥的,而且用户必须输入正确的信息才能解开代码。当然,通过仔细观察计算解密密钥的代码,你会发现用来解开代码的用户名/序列号并非只有一对。实际上,这个算法可能是所有的用户名都使用一个合法的序列号。问题在于VolumeSerial * NameLowPart与序列号的低位部分的差是多少呢?很可能你一旦得到这个差,便可以成功地破解Defender了,但究竟怎么才能得到它呢?
11.4.13 暴力破解Defender
似乎还没有想出获得解密密钥的捷径,也没有任何迹像表明我们可以在Defender.EXE的什么地方找到这个解密密钥;不过我们应该还没有陷入绝境。因为你要找的差只是一个32位的数,所以我们还有一种笨办法可以用:暴力破解(brute-forcing)。所谓暴力破解,就是说让计算机尝试所有可能的密钥,直到找到一个可以正确破译代码的密钥。因为这只是一个32位的密钥,总共只有4,294,967,296种可能的选择。对你来说这是一个很大的数,但对于你的PC机来说只是小菜一碟。
为了找到这个密钥,你需要创建一个小型的暴力破解器程序,用这个程序将从Defender中的加密数据取出,并尝试把0到4,294,967,296之间的每个数用作密钥来破解这些数据,直到解密出合法的数据为止。问题又出现了:什么是合法数据?答案是没有一个真正可用的方法来判断数据是合法的还是非法的。理论上讲,你可以每解密一次就试着运行解了密的代码块,看看它是否能正常运行,但这种方法实现起来极其复杂,而且创建一个能可靠地完成这项任务的进程也很困难。
这时你需要的是确定一个“特征串(token)”——特征串指的是你确定会出现在解了密的数据中的一个足够长的字符串。有了特征串你就能够识别什么时候找到了正确的密钥。如果这个特征串太常见的话,你可能会找到数千个甚至数百万个解密密钥都能解密出这个特征串,你就无法确定究竟哪一个才是正确的密钥。在这个特定的函数中,因为代码比较短,你不需要很长的特征串。如果你能够找到4个字节的数据肯定会出现在解了密的代码中,我想4个字节就足够了。
你可以考察一下什么东西可能会出现在代码中,比如说Defender中反复调用了NtDelayExecution函数,相信在解了密的代码中肯定有对这个函数的调用,不过我们这里还有一个更简单的做法。还记得在第一个函数中那个先被置为1然后立刻测试它是否为0的奇怪变量吗?后来,你又发现加了密的代码中有几行代码将它重新置为0并跳回到“测
试它是否为0”这条语句。如果你回过头看看我们研究过的每一个加了密的函数,你会发现它们都采用了这一机制。看来这是Defender中的一个通用机制,用于在函数返回之前对自己进行再加密。这个局部变量显然是用来告诉函数序言(prologue)代码——这个函数当前是加密状态还是解密状态。下面给出的是401D18处的那几行代码,也就是你正试着解密的函数。
![]()
像往常一样,有一个局部变量被置为1,然后又测试它是否为0。如果我猜得没错的话,解了密的代码应该包含一个和上面代码中的第一条指令相似的指令,只不过要给这个变量加载0而不是1。我们来检查一下这条指令的代码字节,确定好我们要找的东西。
![]()
这一行是OllyDbg的输出,其中包括该指令的代码字节(机器码)。好像它占用了7个字节——应该足够满足我们寻找密钥的要求了。你所需要做的是将“01”这个字节修改为“00”,这样就得到了这条代码:
![]()
下一步是创建一个包含加了密的代码拷贝的小程序(可以直接从OllyDbg的“data”窗口中截取出来),然后用从0到FFFFFFFF之间每一个可能的密钥去解密这段代码。程序必须在每次解密之后在代码块中搜索特征串——就是那个你准备好的7字节的代码。一旦你在某个解了密的代码块中找到了特征串,你就可以确定你已经找到了正确的解密密钥。因为这段代码非常短,所以你不大可能在没有正确解密的代码块中找到这个特征串。
我们首先要确定加了密的代码块的起始地址及其准确的长度。其实在解密代码中这两个地址(起始地址和结束地址)都已经加载到局部变量中了:

在上面这段代码中,压入堆栈的第一个值是加了密的数据的起始地址,压入堆栈的第二个值是结束地址。你转到Olly的“dump”窗口,转储从401E32开始的数据。现在,你需要创建一个暴力破解器程序,并将这些解了密的数据拷贝进去。
在真正开始写程序之前,你需要更好地理解Defender所使用的加密算法。快速浏览一下解密代码,你会发现它不仅仅是将密钥与代码中的每个DWORD进行异或运算,它又将每个32位的代码块与上一次(循环的上一次)未加密的块进行异或。这一点很重要,因为这意味着解密进程必须从数据开始加密的那个位置开始——否则解密进程会产生损坏了的数据。现在,我们已经获得了足够的信息,可以开始写我们的暴力破解器程序的解密循环了。

每测试一个可能的密钥,就要执行一次这个循环!在解密过程结束后,就在破了解的代码块中搜寻特征串。如果找到了,显然你正在测试的密钥就是正确的密钥。如果没有,就给当前测试的密钥加1,再重新执行解密并查找特征串。下面是特征串的查找逻辑:

你要知道这个过程必须执行4 294 967 296次,你才会理解为什么整个过程执行完所需的时间可不短。你想想看,这只是一个32位的密钥!而一个64位的密钥则需要进行4 294 967 296×4 294 967 296次重复运算才能完成。以每分钟迭代4 294 967 296次计算,大约需要8 000年才能试验完所有可能的密钥。
现在,就差加了密的数据和特征串了。下面是你要处理的两个数组:

此刻,你已经做好创建和运行这个程序的一切准备(最好打开所有的编译器优化选项,尽可能地缩短整个过程的执行时间)。几分钟之后,会得到这样的输出:
![]()
太好了!看来我们已经找到了我们需要的东西。B14AC01A就是我们要找的密钥。这也就是说正确的序列号可以这样计算出来:Serial=LOWPART(NameSerial) * VolumeSerial – B14AC01A。现在的问题是:为什么这个序列号是64位长呢?可能不可能没有使用高32位?
我们以后再考虑这个问题。现在,你可以创建一个小型的密钥生成(keygen)程序,用它计算NameSerial并实现上面公式中给出的算法,最后给你返回(希望如此)一个可以成功注册Defender程序的合法序列号。这个算法很简单。用图11.16给出的公式(算法)就可以完成名字字符串到一个64位数的转化。下面是这个算法在C语言中的实现。

NameToInt64函数的返回值可以传送给下面的代码:

这就是密钥生成程序的代码。当你为它输入用户名为John Doe时,你会得到这样的输出:

当然,因为你的卷序列号与我的不一样,你会看到不同的值。最后一行的那个数就是要提交给Defender的序列号。我们来看看它是否有效!将“John Doe”和000000006482D9C6(你要填写你得到的序列号)作为命令行参数启动Defender程序。不幸的是,你又看到了那个“Sorry”消息。看来我们得跟踪进入那个加了密的函数,看看它做了些什么。
这个加了密的函数又是从NtDelayExecution调用开始,继而调用了我们在前边研究过的64位左移位函数的“共轭函数”——这是个64位的“右”移位函数,所执行的操作与左移位函数几乎是相同的。Defender正在做的工作是我们前面见过的:计算LOWPART(NameSerial) * VolumeSerial – HIGHPART(TypedSerial)。接下来Defender的“所作所为”向我们又发送一条坏消息:Defender将前边计算的结果返回给主调函数。
之所以说这是一个坏消息,是因为该函数返回值被用于解密调用它的那个函数,你可能还记得吧?看来输入的序列号的高位部分也参与了解密过程。这样你就得对这个主调函
数(即401EBC这个函数)也实施暴力破解——这是找到这个密钥的唯一可行的方法。
在这个函数里,加密的代码是从401FED开始,到40207F结束。通过查看那个加密/解密局部变量(用来判断是执行加密还是解密的变量),你会发现它与上一个函数中这个局部变量的偏移地址相同,还是[EBP-4]。这算是一个好消息,因为这也就是说我们要找的特征串可以是同一个:
![]()
当然,因为它们是不同的函数,所以数据也是不同的,所以你要将这个新函数的数据拷贝到暴力破解器程序中并让它运行起来。果然,大约10分钟后,得到下面的答案:
![]()
我们立即修改密钥生成程序,让它正确地计算出序列号的高32位,然后试试对不对。下面是更正后的密钥生成程序代码。

用“John Doe”作为用户名运行更正后的密钥生成程序,你可以得到下面的输出:

如我们所预期的,两次得到的序列号中低32位部分是相同的,但这次得到了完整的结果,高32位也算出来了。用Defender程序立即测试这个数据:‘Defender“John Doe” 86fc941e6482d9c6’(再强调一次,这个数会因卷序列号不同而与我这里的86fc941e6482d9c6不一样)。下面是Defender的输出:
![]()
恭喜你!你已经成功破解了Defender!真是让人永世难忘,要知道,Defender可是采用了非常复杂的保护技术,甚至与最昂贵的商业保护系统相比也毫不逊色。如果你未能理解你刚才所完成过程中的每一步,不用担心。你可能需要多实践一下逆向Defender程序,并再快速看一下这一章。你可以欣然接受这样一个事实,一旦你达到了可以轻易破解Defender的境地,你就是一个世界级的破解高手了。我再强调一次,将这些知识用在正当的地方,而不是盗版。做一个正义的破解者,别做个贪婪的破解者。






