4.5 地址转换器

前面介绍了关于PE文件的3种地址,分别是VA(虚拟地址)、RVA(相对虚拟地址)和FileOffset(文件偏移地址)。3种地址的转换如果始终使用手动来计算那是非常累的,因此通常的做法是借助工具来完成。前面介绍了使用LordPE来计算这3种地址的转换,现在来编写一个对这3种地址进行转换的工具。该工具如图4-33所示。

图4-33 地址转换器

这个工具是在前两个工具的基础上完成的。因此,在进行计算的时候,应该先要进行“查看”,然后再进行“计算”。否则,该获取的指针还没有获取到。

在界面上,左边的3个按钮是“单选框”,单选框的设置方法如图4-34所示。

图4-34 对单选框的设置

3个单选框中只能有一个是选中状态,为了记录哪个单选框是选中状态,在类中定义一个成员变量m_nSelect。分别对3个单选框使m_nSelect为1、2和3三个值。关于界面的编程大家自己参考源代码,这里就不进行过多的介绍了。来看主要的代码。

在单击“计算”按钮后,响应该按钮的代码如下:

分别看一下GetAddr()、GetAddrInSecNum()和 CalcAddr()的实现。

获取在编辑框中输入的地址内容的代码:

获取该地址所属的第几个节的代码:

计算其他地址的代码:

代码都不复杂,关键就是在CalcAddr()中3种地址的转换。如果没能理解代码,那么请参考前面手动转换3种地址的方法。这里就不进行介绍了。

4.6 添加节区

添加节区在很多场合都会用到,比如在加壳中,在免杀中都会经常使用到对PE文件添加一个节区。添加一个节区的方法有4个步骤,第1个步骤是在节表的最后面添加一个 IMAGE_SECTION_HEADER,第 2个步骤是更新 IMAGE_FILE_HEADER中的 NumberOfSections字段,第3步是更新IMAGE_OPTIONAL_HEADER中的SizeOfImage字段,最后一步则是添加文件的数据。当然了,前3个步骤是没有先后顺序的,但是最后一个步骤一定要明确如何改变。

4.6.1 手动添加一个节区

先来进行一次手动添加节区的操作,手动添加节区的过程是一个让我们熟悉上述步骤的过程。在网上有很多现成的添加节区的工具,我们自己编写工具的目的是掌握和了解其实现方法,锻炼我们的编程能力;手动添加节区是为了巩固前面的知识,熟悉添加节区的步骤。

接下来还是使用前面的那个测试程序。使用C32ASM用十六进制编辑方式打开这个程序,并定位到其节表处,如图4-35所示。

图4-35 节表位置信息

从图中可以看到该PE文件有3个节表,直接看十六进制信息可能很不方便,为了直观方便地查看节表中IMAGE_SECTION_HEADER的信息,那么使用LordPE来进行查看,如图4-36所示。

图4-36 使用LordPE查看该节表信息

用LordPE工具查看的确直观多了。对照LordPE显示的节表信息来添加一个节区。回顾一下IMAGE_SECTION_HEADER结构体的定义,定义如下:

IMAGE_SECTION_HEADER结构体的成员很多,但是真正要使用的就只有6个,分别是Name、VirtualSize、VritualAddress、SizeOfRawData、PointerToRawData和 Characteristics。这6项刚好与LordPE显示的6项相同。其实IMAGE_SECTION_HEADER结构体中其余的成员几乎不被使用。下面分别来介绍如何添加这些内容。

IMAGE_SECTION_HEADER的长度为40个字节,是十六进制的0×28。在C32ASM中占用2行半的内容,我们一次把这两行半的内容手动添加进去。回到C32ASM中,在最后一个节表的位置处开始添加内容,首先把光标放到右边的ASCII字符中,输入“.test”,如图4-37所示。

图4-37 添加".test"节名

接下来在00000240位置处添加节的大小,该大小直接是对齐后的大小即可。由于文件对齐是0×1000字节,也就是4096个字节。那么就采用最小值即可,使该值为0×1000。不知道大家是否还记得前面提到的字节顺序的问题,在C32ASM中添加时,正确的添加应当是“00 10 00 00”,以后添加时也要注意字节顺序,在添加后面几个成员时,不再提示注意字节顺序,大家应时刻清楚这点。在添加该值时,应当将光标定位在十六进制编辑处,而不是刚才所在的ASCII字符处。顺便要把VirutalAddress也添加上,VirtualAddress的值是前一个节区的起始位置加上上一个节对齐后的长度的值,上个节区的起始位置为0×5000,上个节区对齐后的长度为0×1000,因此新节区的起始位置为0×6000。添加VirtualSize和VirtualAddress后如图4-38所示。

图4-38 添加VirtualSize和 VirtualAddress

接下来的两个字段分别是SizeOfRawData和PointerToRawData,这两个字段的添加方法类似前面两个字段的添加方法,这里就不细说了,分别要添加“0×6000”和“0×1000”两个值,如图4-39所示。

图4-39 添加SizeOfRawData和PointerToRawData

PointerToRawData后面的12个字节都可以为0,只要修改最后4个字节的内容,也就是 Characteristics的值即可,这个值直接使用上个节区的值即可,实际添加时应根据所要节的属性给值,这里为了省事而直接使用上个节区的属性,如图4-40所示。

图4-40 添加Characteristics

整个节表需要添加的地方就添加完成了,接下来需要修改该PE文件的节区数量了,当前节区数量是3,这里要修改为4。虽然可以通过LordPE等修改工具完成,但是这里仍然使用手动修改。对于修改的位置请大家自行定位找到,修改如图4-41所示。

图4-41 修改节区个数为4

除了节区数量以外还要修改文件映像的大小,也就是前面提到的SizeOfImage的值。由于新添加了节区,那么应该把该节区的大小加上SizeOflmage的大小,即为新的SizeOflmage的大小。现在的SizeOflmage的大小为0×6000,加上新添加节区的大小为0×7000。SizeOflmage的位置请自行查找,修改如图4-42所示。

图4-42 修改SizeOflmage的值为0×70000

对于修改PE结构字段的内容都已经做完了,最后一步就是要添加真实的数据了,由于这个节区不使用,因此填充0值就可以了,文件的起始位置为0×6000,长度为0×1000。把光标移到文件的末尾,单击菜单“编辑”-> “插入数据”命令,在插入数据大小文本框中输入十进制的4096,也就是十六进制的0×1000,如图4-43所示。

图4-43 “插入数据”对话框的设置

单击“确定”按钮,可以看到在刚才的光标处插入了很多0值,这样我们的工作也完成了,单击“保存”按钮进行保存,提示是否备份,选择“是”。然后用LordPE查看一下添加节区的情况,如图4-44所示。

图4-44 添加新的节区信息

对比一下前后两个文件的大小,如图4-45所示。

图4-45 添加节区前后文件的大小

从图4-45中可以看出,添加节区后的文件比原来的文件大了4KB,也就是添加了4096个字节的0值的原因。也许最关心的不是大小问题,而在于软件添加了大小后是否真的可以运行,我们试运行一下,是可以运行的。

上面的整个过程就是手动添加一个新节区的全部过程,除了特有的几个步骤以外,就是要注意新节区的内存起始位置和文件起始位置的值。相信通过上面手动添加节区对此已经非常熟悉了。那么下面就开始通过编程来完成添加节区的任务吧。

4.6.2 通过编程添加节区

通过编程添加一个新的节区无非就是文件相关的操作,只是多了一个对PE文件的解析而已。添加节区的步骤和手动添加节区的步骤是一样的,只要一步一步按照上面的步骤写代码就可以了。在开始写代码前,首先修改一下FileCreate()这个函数中的部分代码,修改代码如下:

在这里,要把SEC_IMAGE这个值注释掉,因为要修改内存文件映射,有这个值会使添加节区失败,因此要将其注释掉或者删除掉。

程序的界面如图4-46所示。

图4-46 添加节区界面

首先来编写“添加”按钮响应事件,代码如下:

按钮事件中最关键的地方是AddSec()这个函数,该函数有两个参数,分别是添加节的名称与添加节的大小。这个大小无论输入多大,最后都会按照对齐方式进行向上对齐。我们看一下AddSec()函数的代码。

代码中每一步都按照相应的步骤来完成,其中用到了两个函数分别是AlignSize()和AddSecData()。前者是用来进行对齐的,后者是在文件中添加实际的数据内容的。这两个函数非常简单,代码如下所示:

整个添加节区的代码就完成了,仍然使用最开始的那个简单程序进行测试,看是否可以添加一个节区,如图4-47所示。

从图4-47中可以看出,添加节区是成功的。试着运行一下添加节区后的文件,可以正常运行,而且添加节区的文件比原文件大了4KB,和前面手动添加的效果是一样的。

至此,对PE文件结构的介绍就结束了。其实PE文件结构还有很多比较重要的内容,但是这里只介绍了一些基础的知识,至于其他的内容请大家自行学习。PE结构查看器最关键的是兼容性,PE结构是可以进行各种变形的,常规的PE结构也许比较好解析,但是经过变形的PE结构解析起来就可能会出错,因此要不断地尝试去解析不同的PE文件结构,PE查看器兼容性才会不断地完善。前面介绍了通过C32ASM进行手动分析PE文件结构,这种方法是有助于完善PE查看器的。这就好比数据恢复一样,数据恢复高手绝对不是通过数据恢复工具来进行的,虽然高手们也在使用工具,但是如果遇到较为复杂的情况,数据恢复工具可能就会显得无力,那么手动分析分区格式将是唯一的方法。

图4-47 添加节区

4.7 破解基础知识及调试API函数的应用

在介绍完PE文件结构以后,接下来要介绍调试API。调试API是系统留给用户进行程序调试的接口,其功能非常强大。在介绍调试API以前,先来介绍一下OD的使用。OD是强大的用来调试应用程序的工具。在第1章中对其进行了简单的介绍,在本章中将通过实例来演示一下OD的强大功能。同样,为了后续的部分较容易理解,这里我们自己写一个简单程序,用OD来进行调试。除了介绍调试API以外,还会介绍一些简单的与破解相关的内容。当然,破解是一个技术的积累,也是要靠多方面技术的综合应用,希望这些简单的基础知识能给大家起到一个抛砖引玉的作用。

4.7.1 CrackMe程序

下面将写一个CrackMe程序,CrackMe的意思就是“来破解我”的意思。这里提到的破解是针对软件方面来说的,不是网络中的破解。对于软件的破解来说,主要是取消软件中的各种限制,比如时间限制、序列号限制……对于破解来说,无疑与逆向工程有着密切的关系,想要突破任何一种限制都要去了解该种限制的保护方式或保护机制。

破解别人的软件属于侵权行为,尽管有很多人在做这样的事情,但是大多人是为了进行学习研究而非用于牟取商业利益。但是,为了尊重他人的劳动成果,也为了避免给自己带来不必要的麻烦,大家尽可能找一些CrackMe来进行学习和研究。

下面来写一个非常简单的CrackMe程序供我们学习研究,并进行“破解”。自己写CrackMe并自己破解,虽然这样省去了很多问题的思考,但是对于初学者来说这仍然是一件非常有乐趣的事情。

这个程序使用MFC来编写,界面如图4-48所示。

图4-48 自己编写的CrackMe程序

从图4-48中可以看出,整个程序只有两个可以输入内容的编辑框,和两个可以单击的按钮,除此之外什么都没有了,更不会有什么提示。基本上这就是一个CrackMe的样子。不过有的人习惯在CrackMe中添加一个美女的照片,让界面显得美观诱惑,有的人喜欢给CrackMe加层壳来增加破解的难度,不过这些对于我们都不重要,关键是要进行学习。在界面上输入一个账号和一个密码,当单击“确定”按钮后,该按钮会执行以下代码:

整个代码非常简单。这段代码是通过输入的账号来生成密码的,而不是有固定的账号和固定的密码进行一一对应的。生成密码的算法非常简单,把输入的账号的每一个ASCⅡ码进行加1运算,但是有几个ASCⅡ值除外。如果该ASCⅡ码是字符大写 “Z”、小写 “z”或者是数字“9”,那么,就不会进行加1运算。除了这点外,要求账号的长度必须大于7位,这也算是一个小小的要求了。

测试一下。输入一个小于7位的账号,再随便输入一个密码,单击“确定”按钮,这时程序不会有任何反应。那么,这次输入一个超过7位的账号,单击“确定”按钮来试试,如图4-49所示。

图4-49 CrackMe提示错误

CrackMe提示“密码错误”。当然了,密码是根据账号算出来的,而且跟账号的长度是相等的。那么,该如何获得这个CrackMe的密码呢?如果这个CrackMe不是我们写的,那我们该怎么办?接下来的工作,就交给OD来完成吧。

4.7.2 用OD破解CrackMe

对于破解来说,总是要找到一个突破点的。而对于这个简单的CrackMe来说,突破点是非常多的。下面会以不同的方式来开始这次破解之旅,不需要有太多的汇编知识,毕竟这里是基础性的知识,要想深入学习破解,对破解有所了解的话,那么学习和掌握汇编是必修课。

1.破解方法一

现在用OD打开所编写的CrackMe,如图4-50所示。

大家还记得OD中各个窗口的作用吧?如果忘记请参考第1章的内容。用OD打开CrackMe以后会看到很多汇编代码,这部分内容大家可以通过学习汇编语言和逆向知识来进行学习。在这里会介绍一些基本的破解技巧,通过这些技巧同样可以完成破解工作。

图4-50 用OD打开CrackMe后的界面

首先来梳理一下思路,梳理思路的时候可以参考上面写的代码来进行梳理。输入了“账号”及“密码”后,首先程序会从编辑框处获得“账号”的字符串及“密码”的字符串,然后进行一系列的比较验证,再通过“账号”来计算出正确的“密码”,最后来匹配正确的“密码”与输入的“密码”是否一致,根据匹配结果,给出相应的提示。

上面是编写代码的流程和思路,我们也可根据这个思路合理地设置断点(设置断点也叫下断点)。“断点”就是产生中断的位置。通过下断点,可以让程序中断在需要调试或分析的地方。下断点在调试中起着非常大的作用,学会在合理的地方下断点也是一个技巧性的知识,合理地下断点有助于对软件进行分析和调试,大家应该学着掌握它。断点的分类很多,有内存断点、硬件断点、INT 3断点……关于断点的知识,将在稍后的内容中进行介绍,这里就不介绍了。

现在就可以选择合适的地方设置断点了。可以在API函数上设置断点,比如在GetDlgItemText()行设置断点,也可以选择在strlen()上设置断点,还可以在strcmp()上设置断点,甚至可以在MessageBox()上设置断上设置断点。上面的这些API函数都是可以设置断点的,但是对于GetDlgItemText()和MessageBox()这两个API函数来说,需要下断点的时候指定是ANSI版本,还是UNICODE版本。也就是说系统中是没有这两个函数的,根据版本的不同存在系统的函数只有GetDlgItemTextA()、GetDlgItemTextW()、MessageBoxA()和MessageBoxW()这样的函数。通常使用ANSI版本的即可。

上面有如此多的API函数可供我们设置断点,那么要选择哪个进行设置呢?最好的选择是strcmp()这个函数,因为在比较函数处肯定会出现正确的“密码”。而在GetDlgItemTextA()和strlen()上设置断点,需要使用F8进行跟踪。如果在MessageBoxA()上设置断点,那么就不容易找到正确的“密码”存放的位置了。所以选择在strcmp()上设置断点。在“命令”窗口中输入“bp strcmp”,然后按回车键,如图4-51所示。

图4-51 在“命令”窗口设置断点

如何知道断点是否设置成功呢?按下Alt + B组合键打开断点窗口可以查看,如图4-52所示。

图4-52 在断点窗口查看所下断点

断点已经设置好了,那么就按F9键来运行程序吧。CrackMe启动了,输入一个长度大于等于7位的“账号”为“testtest”,然后随便输入一个“密码”为“123456”,单击“确定”按钮,OD中断在断点,如图4-53所示。

图4-53 OD断下响应了断点

从图4-53中可以看到,OD断在了strcmp这个函数的首地址处,地址为10217570。断在这里如何找到真正的密码呢?其实在提示的地方已经显示出了正确的密码,我们可以看栈窗口。函数参数的传递是依赖于栈的,对于C语言来说,函数的参数是从右往左依次入栈的。对于strcmp()函数来说,该函数有两个参数,这两个参数分别是要进行比较的字符串。那么在栈窗口中可以看到输入的密码及正确的密码,如图4-54所示。

图4-54 栈窗口中显示出的两个密码

可以看到,在调用strcmp()时,传递的两个参数的值分别是“123456”和“uftuuftu”两个字符串。前面的字符串肯定是输入的密码,那么后面的字符串肯定就是正确的密码了。按F9键运行程序,会出现那个错误对话框,提示密码错误。现在关闭OD,直接打开CrackMe,现在仍然用刚才的账号“testtest”,然后输入密码“uftimftu”,单击“确定”按钮,会提示“密码正确”,如图4-55所示。

图4-55 密码正确

这样就完成了破解,这种方法比较简单。只要在strcmp()函数处设置断点即可。大家可以试着在其他几个API函数处设置断点,然后试着找到正确的注册码。接下来,尝试使用另外的方法来对CrackMe进行破解。

2.破解方法二

在上一种方法中通过对API函数设置断点找到了正确的密码,现在通过提示字符串来完成破解。在不知道正确密码的情况下输入密码,通常会得到的提示字符串是“密码错误”,只要在程序中寻找该字符串,并且查看是何处使用了该字符串,那么就可以对破解起到提示性的作用。

用OD打开CrackMe程序,然后在反汇编界面处单击鼠标右键,在弹出的菜单中依次选择“超级字串参考”–> “查找ASCⅡ”命令,会出现“超级字串参考”窗口,如图4-56所示。

图4-56 超级字串参考

在“超级字串参考”窗口中可以看到两个非常熟悉的字符串,双击“密码正确”这个字符串,来到004024de地址处,该地址内容如图4-57所示。

图4-57 004024DE地址处反汇编代码

从图4-57中可以看到3处比较关键性的内容,第一个是可以看到strcmp()函数,第二个是可以看到字符串“密码正确”,第三个是可以看到字符串“密码”错误。由这3个内容可以让我们联想到这岂不是和C代码基本上是对应的啊。根据strcmp()的比较结果,if……else……会选择不同的流程进行执行。也就是说,只要改变比较的结果,或者更换比较的条件,都可以改变程序的流程。下面主要讲述一下修改比较条件的方法,拿具体的例子来解释一下,在代码中是这么一个情况,代码如下:

strcmp()是字符串比较函数,如果两个字符串相等也就是说输入的密码与正确的密码匹配,匹配则执行“密码正确”流程,否则反之。那么如果修改一下比较的条件,也就是说两个密码匹配不成功,使其执行“密码成功”的流程。这样,输入错误的密码,也会提示“密码正确”的提示。在C语言中的修改很简单,只要修改为如下代码就可以:

但是对于反汇编应该如何做呢?其实非常简单,我们再回过头来看一下图4-57中的那几条反汇编代码。想要修改其判断条件,那么只要修改004024D8处的指令代码JNZ SHORT EasyCrac.004024ED即可,该指令的意思是如果比较结果不为0,则跳转到004024ED处执行。 JNZ结果不为0则跳转,只要把JNZ修改为JZ即可,JZ的意思刚好与JNZ相反。修改方法很简单,选中004024D8地址所在的行,按下空格键即可进行编辑,如图4-58所示。

单击窗口上的“汇编”按钮,然后按F9键运行,随便输入一个长度大于7位的账号,再输入一个密码,然后单击“确定”按钮,会提示“密码正确”,如图4-59所示。

关掉OD和CrackMe,然后直接运行CrackMe,随便输入账号和密码,单击“确定”按钮后提示密码错误。为什么呢?因为刚才只是在内存中进行了修改,需要对文件进行存盘在以后运行时我们的修改才有效。修改后的存盘方法为:选中修改的反汇编代码(可以多选几行,只要修改的那行被选中即可),然后单击右键,在弹出的菜单中选择“复制到可执行文件” –> “选定内容”命令,会出现“文件”对话框,如图4-60所示。

图4-58 在OD中修改反汇编代码

图4-59 修改指令后的流程

图4-60 “文件”对话框

在出现的这个对话框中单击鼠标右键,在弹出的菜单中选择“保存文件”命令,然后进行保存。这样我们的修改就存盘了,下次在执行该程序时,随便输入大于7位的账号和密码都会提示“密码正确”了。如果你输入了正确的密码,那么会提示“密码错误”。

上面就是两种破解该CrackMe的方法,这两种方法都是极其简单的方法,现在可能已经很不实用了。这里是为了学习,为了提高动手能力,而采用了这两种方法。

4.8 文件补丁及内存补丁

4.8.1 文件补丁

用OD修改CrackMe是比较容易的,如果脱离OD该如何修改呢?其实在OD中修改了反汇编的指令以后,对应的在文件中修改的是机器码。只要在文件中能定位到指令对应的机器码的位置,那么直接修改机器码就可以了。JNZ对应的机器码指令为0×75, JZ对应的机器码指令为0×74。也就是说,只要在文件中找到这个要修改的位置,用十六进制编辑器把0×75修改为0×74即可。如何能把这个内存中的地址定位到文件地址呢?这就是前面介绍的PE文件结构中把VA转换FileOffset的知识了。

具体的手动步骤大家自己尝试,我们直接通过写代码进行修改吧。为了简单起见,这里使用控制台来编写,而且直接对文件进行操作,中间的步骤就省略了,想必有了思路以后对于大家来说已经不是难事了。

看一下关于文件补丁的代码:

代码给出了详细的注释,只需要把CrackMe文件拖放到文件补丁上即可,如图4-61所示。

图4-61 对CrackMe进行文件补丁

通常,在做文件补丁以前一定要对打算进行修改的位置进行比较,以免产生错误的修改。程序使用的方法是将要修改的部分读出来,看是否与用OD调试时的值相同,如果相同则打补丁。由于这里只是介绍编程知识,针对的是一个CrackMe。如果对某个软件进行了破解,自己做了一个文件补丁发布出去给别人使用,不进行相应的判断就直接进行修改,很有可能导致软件不能使用的情况,因为对外发布以后你不能确认别人所使用的软件的版本等因素。因此,在进行文件补丁时最好判断一下,或者是用CopyFileO对文件进行备份。

4.8.2 内存补丁

相对于文件补丁来说,还有一种补丁是内存补丁。这种补丁是把程序加载到内存中以后对其进行修改,也就是说本身是不对文件进行修改的。要将CrackMe载入内存中,载入内存可以调用CreateProcess()这个API函数来完成,这个函数参数众多,功能强大。使用CreateProcess()创建一个子进程,并且在创建的过程中将该子进程暂停,那么就可以安全地使用WriteProcessMemory()函数来对CrackMe进行修改了。整个过程也比较简单,下面直接来阅读源代码。

代码中的注释也比较详细,代码的关键是要进行比较,否则会造成程序的运行崩溃。在进行内存补丁前,需要将线程暂停,这样做的好处是有些情况下可能没有机会进行补丁就已经执行完需要打补丁的地方了。当打完补丁以后,再恢复线程继续运行就可以了。

文件补丁与内存补丁就已经介绍完了。对于这两种补丁来说,都是通过前面学到的知识来完成的,可见前面的基础知识的用处还是非常广泛的。用了这么多的篇幅来介绍使用OD破解CrackMe,也介绍了文件补丁和内存补丁,那么,接下来就要开始学习调试API了。掌握了调试API以后,完全可以打造一个类似于OD这样的应用程序调试器。让我们一步一步学习吧。

4.9 调试API函数的使用

在Windows中有这么一些API函数是专门用来进行调试的,这些函数被称作为Debug API,或者是调试API。利用这些函数可以进行调试器的开发,调试器通过创建有调试关系的父子进程来进行调试,被调试进程的底层信息、即时的寄存器、指令等信息都可以被获取,进而用来分析。

上面介绍的OllyDbg调试器的功能非常得强大,虽然有众多的功能,但是其基础的实现就是依赖于调试API。调试API函数的个数虽然不多,但是合理的使用会产生非常大的作用。调试器依赖于调试事件,调试事件有着非常复杂的结构体,调试器有着固定的流程,由于实时需要等待调试事件的发生,其过程是一个调试循环体,非常类似SDK开发程序中的消息循环。无论是调试事件,还是调试循环,对于调试或者说调试器来说,其最根本、最核心的部分是中断,或者说其最核心的部分是可以捕获中断。

4.9.1 常见的3种断点方法

在前面介绍OD的时候提到过,产生中断的方法是设置断点,常见的产生中断的断点方法有3种,一种是中断断点,一种是内存断点,还有一种是硬件断点。下面分别来介绍这3种断点的不同。

中断断点,这里通常指的是汇编语言中的int 3指令,CPU执行该指令时会产生一个断点,因此也常称之为INT3断点。现在演示一下如何使用int 3来产生一个断点。代码如下:

在代码中使用了__asm,在__asm后面可以使用汇编指令,如果想添加一段汇编指令,方法是__asm{}这样的。通过__asm可以在C语言中进行内嵌汇编语言。在__asm后面直接使用的是int 3指令,这样会产生一个异常,称为断点中断异常。对这段简单的代码进行编译连接,并且运行。运行后出现了错误对话框,如图4-62所示。

图4-62 异常对话框

这个对话框可能常常见到,而且见到以后多半很郁闷,通常情况是直接单击“不发送”按钮,然后关闭这个对话框。在这里,这个异常是通过int 3导致的,不要忙着关掉它。通常在写自己的软件时如果出现这样的错误,应该去寻找一些更多的帮助信息来修正错误。单击“请单击此处”链接,出现如图4-63所示的对话框。

图4-63 “异常基本信息”对话框

弹出“异常基本信息”对话框,因为这个对话框给出的信息实在太少了,继续单击“要查看关于错误报告的技术信息”后面的“单击此处”链接,打开如图4-64所示的对话框。

图4-64 “错误报告内容”对话框

通常情况下,在这个报告中只关心两个内容,一个内容是Code,另一个内容是Address。在图4-64中,Code后面的值为0×80000003,在Address后面的值为0×0000000000401028。 Code的值为产生异常的异常代码,Address是产生异常的地址。在Winnt.h中定义了关于Code的值,在这里0×80000003的定义为STATUS_BREAKPOINT,也就是断点中断。在Winnt. h中的定义为:

这里给的Address可以看出是一个VA(虚拟地址),用OD打开这个程序,直接按F9键运行,如图4-65、图4-66所示。

图4-65 在OD中运行后被断下

图4-66 OD状态栏提示

从图4-65所示的地方看到,程序执行停在了00401029的位置处。从图4-66所示的地方可以看到,INT3命令位于00401028的位置处。再看一下图4-64中Address后面的值,值为00401028。这也就证明了在系统的错误报告中可以给出正确的出错地址的。这样在以后写程序的过程中可以很容易地定位到自己程序中有错误的位置。

回到中断断点的话题上,中断断点是由int 3产生的,那么要如何通过调试器(调试进程)在被调试进程中设置中断断点呢?看图4-65中00401028地址处,在地址值的后面,在反汇编代码的前面,中间那一列的内容是汇编指令对应的机器码。可以看出,INT3对应的机器码是0×CC。如果想通过调试器在被调试进程中设置INT3断点的话,那么只需要把要中断的位置的机器码改为0×CC即可,当调试器捕获到该断点异常时,修改为原来的值即可。

内存断点的方法同样是通过异常来产生的。在Win32平台下,内存是按页进行划分的,每页的大小为4KB。每一页内存都有其各自的内存属性,常见的内存属性有只读、可读写、可执行等。内存断点的原理就是通过对内存属性的修改,而导致本该进行的操作无法进行,这样便会引发异常。

在OD中关于内存断点有两种,一种是内存访问,另外一种是内存写入。用OD随便打开一个应用程序,在其“转存窗口”(或者叫“数据窗口”)中随便选中一些数据点后单击右键,在弹出的菜单中选择“断点”命令,在“断点”子命令下会看到“内存访问”和“内存写入”两种断点,如图4-67所示。

图4-67 内存断点类型

下面通过简单例子来看一下如何产生一个内存访问异常,代码如下:

在这个程序中,使用了VirtualProtect()函数,该函数与第3章中介绍的VirtualProtectEx()函数类似,不过VirtualProtect()是用来修改当前进程的内存属性的。如果不记得,可以参考一下MSDN。

对这个程序编译连接,并运行起来。熟悉的出错界面又出现在眼前,如图4-68所示。

图4-68 “异常基本信息”对话框

按照前面介绍的步骤打开“错误报告内容”对话框,如图4-69所示。

图4-69 “错误报告内容”对话框

按照上面的分析方法来看一下Code和Address这两个值。Code后面的值为0×C0000005,这个值在Winnt.h中的定义如下:

这个值的意义表示访问违例。在Address后面的值为0×0000000000402fa3,这个值是地址,但是这里的地址根据程序来考虑,值是用malloc()函数申请的,用于保存数据的堆地址,而不是用来保存代码的地址。这个地址就不进行测试了,因为是动态申请,很可能每次不同,因此大家了解就可以了。

硬件断点是有硬件进行支持的,关于硬件断点的具体原理就不进行介绍了。在OD中使用硬件断点的方法类似于内存断点,同样是在右键菜单中进行设置。由于是由硬件支持的,因此只能设置4个。请大家自行学习使用。

4.9.2 调试API函数及相关结构体介绍

通过前面的内容已经知道,调试器的根本是依靠中断,其核心也是中断。前面也演示了两个产生中断异常的例子。本小节的内容是介绍调试API函数,及其相关的调试结构体。调试API函数的数量非常少,但是其结构体是非常少有的较为复杂的,虽然说是复杂,其实只是嵌套的层级比较多,只要了解了较为常见的,剩下的可以自己对照MSDN进行学习。在介绍完调试API函数及其结构体后,再来简单演示一下,如何通过调试API捕获INT3断点和内存断点。

创建调试关系

既然是调试,那么必然存在调试和被调试。调试和被调试的这种调试关系是如何建立起来的,是我们首先要了解的内容。要使调试和被调试创建调试关系,那么就会用到两个函数中的一个,这两个函数分别是CreateProcess()和DebugActiveProcess()。其中CreateProcess()函数已经介绍过了,那么如何使用CreateProcess()函数来建立一个需要被调试的进程呢?还是来回顾一下CreateProcess()函数吧。CreateProcess()函数的定义如下:

现在要做的是创建一个被调试进程。在CreateProcess()函数中,有一个dwCreationFlags的参数,该参数的取值中有两个重要的常量,分别为DEBUG_PROCESS和DEBUG_ONLY_THIS_PROCESS。DEBUG_PROCESS的作用就是被创建的进程处于调试状态,如果一同指定了DEBUG_ONLY_THIS_PROCESS的话,那么就只能调试被创建的进程,而不能调试被调试进程创建出来的进程。只要在使用CreateProcess()函数时指定这两个常量即可。

除了CreateProcess()函数以外,还有一种创建调试关系的方法,该方法用的函数如下:

这个函数的功能是附加到一个进程上。该函数的参数就一个,该参数指定了被调试进程的进程ID号。从函数名与函数参数可以看出来,这个函数是和一个已经被创建的进程来建立调试关系的,跟CreateProcess()的方法是不一样的。在OD中也同样有这个功能,打开OD,选择菜单中的“文件”> “挂接”命令,就出现“选择要挂接的进程”窗口,如图4-70所示。

图4-70 “选择要挂接到的进程”的窗口

OD的这个功能就是通过DebugActiveProcessO函数来完成的。

调试器与被调试的目标进程可以通过前两个函数建立调试关系,但是如何使调试器与被调试的目标进程断开调试关系呢?有一个很简单的方法,关闭调试器进程,这样调试器进程与被调试的目标进程会同时结束。也可以关闭被调试的目标进程,这样也可以达到断开调试关系。那如何让调试器与被调试的目标进程断开调试关系,又保持被调试目标进程的运行呢?这里介绍一个函数,函数名为DebugActiveProcessStopO,其定义如下:

该函数只有一个参数,就是被调试进程的进程ID号。使用该函数可以在不影响调试器进程和被调试进程的正常运行而将两者的关系进行解除。

4.9.3 判断是否处于被调试状态

很多程序都要检测自己是否处于被调试状态,比如游戏、病毒,或者加壳后的程序。游戏为了防止被做出外挂而进行反调试,病毒为了给反病毒工程师增加分析难度而反调试,加壳程序是专门用来保护软件的,当然也会有反调试的功能(该功能仅限于加密壳,压缩壳是没有反调试功能的)。

本小节不是要介绍反调试,而是要介绍一个简单的函数,这个函数是判断自身是否处于被调试状态,函数名为IsDebuggerPresent(),函数的定义如下:

该函数没有参数,根据返回值来判断是否处于被调试状态。这个函数也可以用来进行反调试。不过由于这个函数的实现过于简单,很容易就能够被分析者突破,因此现在也没有软件再使用该函数来用作反调试了。

下面通过一个简单的例子来演示一下IsDebuggerPresent()函数的使用。代码如下:

这个例子用来检测是否被调试。在进入主函数后,直接调用IsDebuggerPresent()函数,用来判断是否被调试器创建。在自定义线程函数中,一直循环检测是否被附加。只要发现自身处于被调试状态,那么就在控制台中进行输出提示。

现在用OD对这个程序进行测试。首先用OD直接打开这个程序并按F9键运行,如图4-71所示。

图4-71 主函数检测到调试器

按下F9键启动以后,控制台中输出“mian func checked the debuggee”,也就是发现了调试器。

再测试一下检测OD附加的效果。先运行这个程序,然后用OD去挂接它,看其提示,如图4-72所示。

图4-72 线程函数检测到调试器

控制台中输出“thread func checked the debuggee”。可以看出用OD进行附加也能够检测到自身处于被调试状态。

4.9.4 断点异常函数

有时为了调试方便可能会在自己的代码中插入_asm int 3,这样当程序运行到这里时会产生一个断点,就可以用调试器进行调试了。其实微软提供了一个函数,使用该函数可以直接让程序运行到某处的时候产生INT3断点,该函数的定义如下:

修改一下前面的程序,把_asm int 3替换为DebugBreak(),编译连接并运行看一下。同样会因产生异常而出现“异常基本信息”对话框,查看它的“错误报告内容”,如图4-73所示。

图4-73 “错误报告内容”对话框

看一下Code的后面的值,看到值为0×80000003就应该知道是EXCEPTION_BREAKPOINT。再看Address后面的值,值为0×000000007c92120e,从这个地址可以看出,该值在系统的DLL文件中,因为调用的是系统提供的函数。

4.9.5 调试事件

调试器在调试程序的过程中,是通过不断地下断点来完成的,而断点的产生在前面的内容中提到过一部分。通过前面介绍的INT3断点和内存断点可以得知,调试器是在捕获目标进程产生的异常从而作出响应。当然,对于介绍的断点来说是这样的,不过对于调试器来说,除了对异常作出响应以外,还会对其他的一些事件作出响应,异常只是所有调试能进行响应事件的一部分。

调试器的工作主要是依赖调试事件,调试事件在系统中被定义为一个结构体,也是到目前为止要接触的最为复杂的一个结构体,因为这个结构体的嵌套关系很多。这个结构体的定义如下:

这个结构体非常重要,我们有必要详细地介绍一番。

(1) dwDebugEventCode:该字段指定了调试事件的类型编码。在调试的过程中可能产生的调试事件非常多,因此要根据不同的类型码进行不同的响应处理。常见的调试事件如图4-74所示。

图4-74 dwDebugEventCode取值

(2) dwProcessId:该字段指明了引发调试事件的进程ID号。

(3) dwThreadId:该字段指明了引发调试事件的线程ID号。

(4) u:该字段是一个联合体,该联合体的取值由dwDebugEventCode指定。在该联合体中包含了很多个结构体,包括 EXCEPTION_DEBUG_INFO、CREATE_THREAD_ DEBUGJNFO、CREATE_PROCESS_DEBUG_INFO、EXIT_THREAD_DEBUG_INFO、 EXIT_PROCESS_DEBUG_INFO、LOAD_DLL_DEBUG_INFO、UNLOAD_DLL_DEBUG_ INFO和 OUTPUT_DEBUG_STRING_INFO。

在以上众多的结构体中,特别要介绍一下EXCEPTION_DEBUG_INFO,因为这个结构体中包含了关于异常相关的信息,而对于其他的几个结构体,使用比较简单,大家可以参考 MSDN。EXCEPTION_DEBUG_INFO的定义如下:

在EXCEPTION_DEBUG_INFO中包含的EXCEPTION_RECORD结构体中保存着真正的异常信息,对于dwFirstChance里面保存着ExceptionRecord的个数。看一下关于 EXCEPTION_RECORD结构体的定义:

(1) ExceptionCode:异常码。该值在MSDN中的定义非常多,不过我们需要使用的值只有 3个,分别是 EXCEPTION_ACCESS_VIOLATION (访问违例)、EXCEPTION_ BREAKPOINT (断点异常)和EXCEPTION_SINGLE_STEP (单步异常)。这3个值中的前两个值对于我们来说是非常熟悉的,因为在前面已经介绍过了,关于最后一个单步异常想必也是非常熟悉的了。我们在使用OD快捷键的F7键、F8键时就是在使用单步功能,而单步异常就是有EXCEPTION_SINGLE_STEP来表示的。

(2) ExceptionRecord:指向一个EXCEPTION_RECORD的指针,异常记录是一个链表,其中可能保存着很多的异常信息。

(3) ExceptionAddress:异常产生的地址。

调试事件这个结构体DEBUG_EVENT看似非常复杂,其实也只是嵌套得比较深而已。只要大家去体会每个结构体,体会每层嵌套的含义,自然而然就觉得它没有多么复杂了。

4.9.6 调试循环

调试器在不断地对被调试目标进程进行捕获调试信息,有点类似于Win32应用程序的消息循环,但是又有所不同。调试器在捕获到调试信息后,进行相应地处理,然后恢复线程使之继续运行。

用来等待捕获被调试进程调试事件的函数是WaitForDebugEvent(),该函数的定义如下:

(1) lpDebugEvent:该参数用于接收保存调试事件;

(2) dwMillisenconds:该参数用于指定超时的时间,无限制等待使用INFINITE。

在调试器捕获到调试事件后会对被调试的目标进程中产生调试事件的线程进行挂起,在调试器对被调试目标进程进行相应的处理后,需要使用ContinueDebugEvent()对先前被挂起的线程进行恢复。ContinueDebugEvent()函数的定义如下:

(1) dwProcessId:该参数表示被调试进程的进程标识符。

(2) dwThreadId:该参数表示准备恢复挂起线程的线程标识符。

(3 ) dwContinueStatus:该参数指定了该线程以何种方式继续执行‘该参数的取值为 DBG_EXCEPTION_NOT_HANDLED和DBG_CONTINUE。对于这两个值来说,在通常的情况下并没有什么差别。但是当遇到调试事件中的调试码为EXCEPTION_DEBUG_EVENT时这两个常量就会有不同的动作,如果使用DBG_EXCEPTION_NOT_HANDLED,调试器进程将会忽略该异常,Windows会使用被调试进程的异常处理函数对异常进行处理;如果使用 DBG_CONTINUE的话,那么需要调试器进程对异常进行处理,然后继续运行。

由上面两个函数配合调试事件结构体,就可以构成一个完整的调试循环,以下这段调试循环的代码摘自MSDN,代码如下:

以上就是一个完整的调试循环,不过有些调试事件对于我们来说可能是用不到的,那么就把不需要的调试事件所对应的case语句删除掉就可以了。

4.9.7 内存的操作

调试器进程通常要对被调试的目标进程进行内存的读取或写入。对于跨进程的内存读取和写入的函数其实在前面的章节已介绍过,就是ReadProcessMemory()和WriteProcessMemory().

要对被调试的目标进程设置INT3断点时,就需要使用WriteProcessMemory()函数对指定的位置写入0×CC.当INT3被执行后,要在原来的位置上把原来的机器码写回去,原来的机器码需要使用ReadProcessMemory()函数来进行读取。

关于内存操作除了以上两个函数以外,还有一个就是修改内存的页面属性的函数,即 VirtualProtectEx(),同样这个函数前面也介绍过了。

4.9.8 线程环境相关API及结构体

在前面的章节中介绍过,进程是用来向系统申请各种资源的,而真正被分配到CPU并执行代码的是线程。进程中的每个线程都共享着进程的资源,但是每个线程都有不同的线程上下文,或线程环境。Windows是一个多任务的操作系统,在Windows中为每一个线程分配一个时间片,当某个线程执行完其所属的时间片后,Windows会切换到另外的线程去执行。在进行线程切换以前有一步保存线程环境的工作,那就是保存在切换时线程的所有寄存器值、栈信息及描述符等相关的所有信息。只有把线程的上下文保存起来,在下次该线程被CPU再次调度时才能正确地接着上次的工作继续进行。

在Windows系统下,将线程环境定义为CONTEXT结构体,该结构体需要在Winnt.h头文件中找到,在MSDN中并没有给出定义。CONTEXT结构体的定义如下:

这个结构体看似很大,但是也并不大,只要了解汇编语言的话,结构体中的各个字段应该是非常熟悉的。关于各个寄存器的介绍这里就不进行介绍了,这个需要大家自己查看资料了。这里只介绍一下ContextFlags字段的功能,该字段用于控制GetThreadContext()和SetThreadContext()能够获取或写入的环境信息。ContextFlags的取值也只能在Winnt.h头文件中找到,其取值如下:

从这些宏定义的注释来看,能很清楚地知道这些宏可以控制GetThreadContext()和 SetThreadContext()进行何种操作。大家在真正使用时进行相应的赋值就可以了。

线程环境在Windows中定义了一个CONTEXT的结构体,我们要进行获取或设置线程环境的话,需要使用GetThreadContext()和SetThreadContext()。这两个函数的定义分别如下:

这两个函数的参数都基本一样,hThread表示线程句柄,而lpContext表示指向CONTEXT的指针。所不同的是,GetThreadContext()是用来获取线程环境的,SetThreadContext()是用来进行设置线程环境的。

4.10 打造一个密码显示器

关于系统提供的调试API函数我们已经学习了不少了,而且基本上常用到的函数我们也都学习过了。下面用调试API编写一个能够显示密码的程序。大家别以为我们写的程序什么密码都能显示,这是不可能的。下面针对前面的那个CrackMe来编写一个显示密码的程序。

在编写关于CrackMe的密码显示程序以前需要准备两项工作,第一个工作是知道要在什么地方合理地下断点,第二个工作是从哪里能读取到密码。带着这两个问题重新来进行思考一下。在我们的程序中,要对两个字符串进行比较,而比较的函数是strcmp(),该函数有两个参数,分别是输入的密码和真正的密码。也就是说在调用strcmp()这个函数的位置下断点通过查看它的参数是可以获取到正确的密码的。就在调用strcmp()这个函数的位置设置INT3断点,也就是将0×CC机器码写入这个地址。用OD看一下调用strcmp()函数的地址,如图 4-75所示。

图4-75 调用strcmp()函数的地址

从图4-75中可以看出,调用strcmp()函数的地址为004024CE。有了这个地址只要找到该函数的两个参数,就可以找到输入的错误的密码及正确的密码。从图4-75中看出,正确的密码的起始地址保存在EDX中,错误的密码的起始地址保存在ECX中,只要在004024CE这个地址下断,并通过线程环境读取EDX和ECX寄存器值就可以得到两个密码的起始地址。

要进行准备的工作已经做好了,下面来写一个控制台的程序。先定义两个常量,一个是用来设置断点的地址,另一个是INT3指令的机器码。定义如下:

把CrackMe的文件路径及文件名当参数传递给显示密码的程序。显示的程序首先要以调试的方式创建CrackMe,代码如下:

然后就进入调试循环,调试循环中要处理两个调试事件,一个是 CREATE_PROCESS_DEBUG_EVENT,另一个调试事件是 EXCEPTION_DEBUG_EVENT下的EXCEPTION_BREAKPOINT。这两个事件在调试循环中进行处理,对于处理 CREATE_PROCESS_DEBUG_EVENT的代码如下:

在CREATE_PROCESS_DEBUG_EVENT中对调用strcmp()函数的地址处设置INT3断点,在将0×CC写入这里时要把原来的机器码读取出来。读取原机器码使用 ReadProcessMemory(),写入 INT3的机器码使用 WriteProcessMemory()。再来看一下 EXCEPTION_DEBUG_EVENT下的 EXCEPTION_BREAKPOINT是如何进行处理的。

对于调试事件的处理,应该放到调试循环中,上面的代码给的是对调试事件的处理,再来看一下调试循环的大体代码:

只要把调试事件的处理方法放入到调试循环中,程序就完整了。接下来编译连接一下,然后把CrackMe直接拖放到这个密码显示程序上。程序会启动CrackMe进程,并等待我们的输入,当输入账号及密码后单击“确定”按钮,程序会显示出正确的密码和我们输入的密码,如图4-76所示。

图4-76 显示正确密码

根据图4-76显示的结果进行验证,可见我们的获取是成功的。至此程序到此结束了。大家可以把该程序修改一下,把它改成通过附加调试进程来显示密码,以巩固我们的知识。

4.11 总结

本章学习了 PE结构的基础部分,学习了 OD的使用及调试API函数等。相信大家对PE结构解析、OD调试工具使用及调试原理有了一定的了解。本章学习了一些基础的加解密知识,在以后学习了更多的相关知识后会发现,这些基础知识对我们学习加解密的知识是非常有必要的。希望大家多多掌握原理,多多动手实践。

第5章 HOOK编程

有一种技术被称作是HOOK技术,人们常叫钩子。谈到钩子很容易让人联想到是在钓东西,比如鱼钩就用于钓鱼。在编程中钩子技术在DOS时代就已经存在了,在Windows下,钩子按照实现技术的不同,其种类也越来越多,但是设置钩子的本质却是始终不变的。

钩子到底是做什么用的呢?钩子的应用范围非常广泛,比如输入监控、API拦截、消息捕获等方面都在应用它。杀毒软件会用HOOK技术钩住一些API函数,比如钩住RegSetValueExO函数,防止病毒对注册表进行写入;病毒使用HOOK技术有针对性地捕获键盘的输入从而进行记录。这些都是属于HOOK范畴的知识。本章将介绍与HOOK有关的知识。

5.1 HOOK知识前奏

在DOS时代进行编程,那时操作系统提供的编程接口不称为API函数,而称为中断服务向量。也就是说,当时的操作系统提供的编程接口只有中断,要进行写文件要调用系统中断,要进行读文件也要调用系统中断(当然,也不调用操作系统的中断直接调用更底层的中断)……中断服务向量类似于Windows下的API函数,中断服务向量在操作系统的某个地址保存着,它是以数组形式保存着的,我们也称其为中断向量表。DOS时代的HOOK技术也就是修改中断向量表中的中断地址。比如,要捕获写操作,那么就修改中断向量表中关于写文件的地址,将写文件的中断地址保存好,然后替换为我们的地址,这样当程序调用写文件中断时,我们的函数就被执行了;当程序执行完可以继续调用原来的中断地址,从而完成写文件的操作。

在Windows下HOOK技术的方法比较多,使用比较灵活,常见的应用层的HOOK方法有Inline Hook、IATHOOK、Windows钩子……HOOK技术涉及到了DLL相关的知识。因为HOOK其他进程的时候需要访问其他进程的地址空间,使用DLL是必然的。HOOK技术也涉及到了注入的知识,想要把完成HOOK功能的DLL加载到目标进程空间中就要使用注入的知识了。让我们来学习常用的HOOK技术吧。

5.2 内联钩子——Inline Hook

5.2.1 Inline Hook的原理

API函数都保存在操作系统提供的DLL文件中,当在程序中使用某个API函数时,在运行程序后,程序会隐式地将API所在的DLL加载入进程中。这样,程序就会像调用自己的函数一样调用API,大体过程如图5-1所示。

从图5-1中可以看出,在进程中当EXE模块调用CreateFile()函数的时候,会去调用kernel32. dll模块中的CreateFile()函数,因为真正的CreateFile()函数的实现在kernel32. dll模块中。

CreateFile()是API函数,API函数也是由人编写的代码再编译而成的,也有其对应的二进制代码。既然是代码,那么就可以被修改。通过一种“野蛮”的方法来直接修改API函数在内存中的映像,从而对API函数进行HOOK。使用的方法是,直接使用汇编指令的jmp指令将其代码执行流程改变,进而执行我们的代码,这样就使原来的函数的流程改变了。执行完我们的流程以后,可以选择性地执行原来的函数,也可以不继续执行原来的函数。

假设要对某进程的kernel32. dll的CreateFile()函数进行HOOK,首先需要在指定进程中的内存中找到CreateFile()函数的地址,然后修改CreateFile()函数的首地址的代码为jmpMyProc的指令。这样,当指定的进程调用CreateFile()函数时,就会首先跳转到我们的函数当中去执行流程,这样就完成了我们的HOOK了。看一下它的流程图,如图5-2所示。

图5-1 调用API函数的大体过程

图5-2 Inline Hook的流程

由于这种方法是在程序流程中直接进行嵌入jmp指令来改变流程的,所以就把它叫做Inline Hook.

5.2.2 Inline Hook的实现

了解了大体的HOOK流程后,现在来学习它的具体实现。

我们的C程序被编译连接后为一个二进制文件,在二进制文件中,对于代码部分来说,都是CPU可以用来执行的机器码,机器码和汇编指令又是一一对应的。前面讲过了,Inline Hook是在程序中嵌入jmp汇编指令然后跳转到流程处继续执行的,jmp指令的用法是jmp目的地址。jmp在汇编语言中是一个无条件的跳转指令,jmp后面跟随的参数是跳转的目的地址。用OD随便打开一个程序,并且修改它的某条指令为jmp指令。跳转的目的为一个任意地址,如图5-3和图5-4所示。

图5-3 准备修改00401600地址处的代码为jmp指令

图5-4 修改后的代码内容

从图5-3和图5-4的对比可以看出,jmp指令占用了5个字节。原来从00401600到00401605处的机器码为55 8BEC6AFF,当修改为jmp 12345678后,现在的机器码为E9 73 40 F4 11。可以告诉大家,jmp对应的机器码是E9(针对长转移来说的),后面的73 40 F4 11是一个偏移量,这个偏移量是多少呢?这个偏移量是11F44073,请回忆一下前面提到过的字节顺序的问题。偏移量的计算公式如下:

JMP后的偏移量=目标地址-原地址-5

这是一个非常重要的公式,当然对于我们的使用只要记住就可以了,这里的5是JMP的指令长度,也就是说JMP XXXXXXXX这个指令的机器码长度为5个字节。验证一下这个公式,目标地址是12345678,原地址为00401600,用12345678-00401600-5 = 11F44073,用计算器进行计算,如图5-5所示。

上面地址都是用十六进制进行计算的,大家计算时要注意这一点,以免计算错误。通过上面的例子可以看出来,修改时只需要修改5个字节就可以了。下面来梳理一下Inline Hook的流程吧。流程如下。

(1)构造跳转指令。

(2)在内存中找到欲HOOK函数地址,并保存欲HOOK位置处的前5个字节。

(3)将构造的跳转指令写入需HOOK的位置处。

(4)当被HOOK位置被执行时会转到我们的流程执行。

(5)如果要执行原来的流程,那么取消HOOK,也就是还原被修改的字节。

(6)执行原来的流程。

(7)继续HOOK住原来的位置。

图5-5 偏移量计算

这就是Inline Hook的大概的流程。

由于Inline Hook的实现代码比较简单,关键就是一个HOOK和一个取消HOOK的过程,因此可用C++封装一个Inline Hook的类,在今后Inline Hook编程中,可以始终使用这个封装好的类。

一般情况下封装类都有两个文件,一个是类的头文件;另一个是类的实现文件。在Windows下,类名(Class Name)都是以“C”开头的,我们封装的是Inline Hook类,因此类名是CILHook。为了保持一致性,类的头文件和实现文件分别是ILHook.h文件和 ILHook.cpp文件。先来看一下ILHook.h文件中的类定义部分。

在C++中,类的定义使用关键字“Class”。在类中定义有成员函数和成员变量,通常情况下把成员函数放在上面,把成员变量放在下面。因为对于拿到头文件的人来说,他首先关注的是类实现了哪些功能,因此应该让他第一眼就能看到实现了的成员函数,当然这不是必须的。

回到类定义,在类中除了构造函数和析构函数以外,还定义了3个成员函数,分别是 Hook()、UnHook()和ReHook()函数。它们的功能分别是用来进行HOOK操作、取消HOOK操作和重新进行HOOK操作的。对于3个成员函数来说,这里只是一个定义,实现部分在 ILHook.cpp中。

除了上面的3个成员函数外,还定义了3个成员变量,分别是m_pfnOrig、bO1dBytes[5]和bNewBytes[5]。这3个函数的作用已经在定义中给出了注释,想必大家应该能明白,这里就不具体说了。接着看ILHook.cpp文件中的实现代码吧。

在构造函数中主要是完成对成员变量的初始化工作,在析构函数中主要是取消HOOK。构造函数在C++对象被创建时自动执行,同样析构函数是在C++对象被销毁时自动执行。

该函数是InlineHook类的重要函数,在Hook()成员函数中,我们完成了3项工作,首先是获得了被HOOK函数的函数地址;接下来是保存了函数的前5个字节;最后是用构造好的跳转指令来修改被HOOK函数的前5个字节的内容。

除了上面的函数外,还有两个函数,分别是取消挂钩和重新挂钩两个函数。这两个函数非常简单,就是完成修改内存属性、复制字节的工作。代码如下:

上面两个成员函数就不进行介绍了,只要大家看懂了Hook()函数的实现,这两个函数的功能就肯定理解了。

整个Inline Hook的封装已经完成了,在后面的代码中,可以很容易地实现对函数的HOOK功能了。

5.2.3 HOOK MessageBoxA

本小节将完成一个HOOK本进程MessageBoxA()的程序,这个程序的目的是测试我们的类是否封装成功,以便完成今后的程序。在VC6下创建一个控制台程序,添加好封装过的库,然后键入下面的代码:

在主函数中,调用了两次MessageBox()函数,两次弹出的文本内容是一样的。但是第二次调用MessageBox()函数时,对MessageBox()做了HOOK, HOOK的函数是我们自己写的 MyMessageBoxA()函数。在MyMessageBoxA()函数中首先恢复了对MessageBox()函数的HOOK,然后连续调用了两次MessageBox()函数。那么,这个测试程序应该弹出3次 MessageBox()对话框。大家将其编译连接一下,并运行,结果和我们想的完全一样,弹出了3次MessageBox()对话框。

这里介绍了关于本进程的Inline Hook的例子,接下来要介绍的是其他进程Inline Hook的例子。由于每个进程的地址空间是隔离的,那么对于其他进程的Inline Hook是需要用到 DLL文件的。下面学一些如何使用DLL文件来完成对其他进程的Inline Hook的工作。

5.2.4 HOOK CreateProcessW

在这个例子中,我们先写一个DLL,然后通过DLL来HOOK CreateProcessW()函数。在 Windows下,大部分的应用程序都是由Explorer.exe进程来创建的。我们用“Process Explorer”这个工具来查看一下,如图5-6所示。

从图5-6中可以看出,大部分的应用程序都是由Explorer. exe这个进程创建的,那么只要把Explorer. exe进程的CreateProcessW()函数HOOK住,就可以针对要完成的工作做很多事情了,比如,可以记录哪个应用程序被启动了,也可以对应用程序进程进行拦截了。

图5-6 “Process Explorer”查看应用程序的父进程

我们的例子就是通过HOOK CreateProcessW()函数来显示一下被创建的进程的进程名。还是使用前面给出的ILHook类来进行HOOK工作。代码如下:

代码不是很长,Hook功能是由前面封装过的类来完成的,只要去使用封装好的类进行HOOK,并定义一个HOOK函数就可以了。将这段代码编译连接,然后用第3章中编写的 DLL注入工具将这个DLL文件注入到Explorer.exe中,如图5-7所示。

图5-7 用DLL注入工具注入HOOK DLL

将这个DLL注入到Explorer.exe进程后,运行一下IE浏览器,会弹出一个对话框,如图5-8所示。

图5-8 对话框标题栏上显示了被创建的进程名

单击“确定”按钮后,IE浏览器就被打开了。再打开记事本、画图、计算器等程序,都成功地显示出了其进程名及进程的路径。

把这个程序修改一下,让它可以拦截进程的创建,这样来达到对创建应用程序的管控。修改的方法很简单,在弹出对话框以后,对话框上有两个按钮,分别选择一下相应的按钮就可以了。修改后的代码如下:

编译连接一下这个程序,提示连接错误。原因是刚才编译连接的DLL文件正在被使用,所以无法对其修改。用DLL注入工具将刚才的DLL进行卸载,然后再次编译连接,这次就通过了。把这个DLL文件注入到Explorer.exe进程中,然后启动IE浏览器,如图5-9所示。

单击“是”按钮,那么IE浏览器被创建。如果单击“否”按钮,那么会提示“您启动的程序被拦截”,并且IE浏览器没有被打开。单击“否”按钮看一下效果,如图5-10所示。

图5-9 是否创建进程的提示框图5-10进程创建被拦截的提示

图5-10 进程创建被拦截的提示

提示框出现了,单击“确定”按钮以后,IE浏览器没有被打开。再对记事本、计算器、画图等程序进行测试。测试的结果都和IE浏览器的结果是一样的,那么说明对应用程序创建的拦截功能已经成功了。

5.2.5 7字节Inline Hook

做Inline Hook的时候是通过构造一个jmp指令来修改目标函数入口的。在构造jmp指令时唯一比较不好理解的可能是计算jmp指令后面的偏移量,这是由于CPU机器码要求的。既然是修改目标函数入口指令,可以多修改几条指令,从而达到不计算jmp指令的跳转偏移量。

完成的指令为两条,一条是把目标地址保存入寄存器eax中,然后直接跳转到寄存器eax中保存的地址处。代码如下:

用OD随便打开一个程序,然后修改其入口代码为上述代码,然后提取其机器码,如图 5-11所示。

图5-11 修改入口代码

从图5-11中可以看出mov eax,12345678对应的机器码为B8 78 56 34 12,也就是说B8是mov指令的机器码。再看一下jmp eax,其对应的机器码为FF E0。将其定义为一个字节数组为:

Byte bJmpCode[]={‘\xb8’, ‘\0’, ‘\0’, ‘\’, ‘\0’, ‘\xFF’, ‘\xE0’};

这样定义以后,只要把目标函数的地址保存在从第一个到第四个字节的位置就可以了 (下标是从0开始的)。通过这种方法,就不用再计算jmp要跳转的位置对应的偏移地址了。这也是一种进行Inline Hook的方法,不过同样都是修改目标函数的入口。

5.2.6 Inline Hook的注意事项

在写Hook函数时一定要注意函数的调用约定,函数的调用约定决定了函数调用后负责平衡栈的一个约定,如果在调用函数后栈不恢复到调用前的样子的话,那么程序后续的部分一定会报错。也许程序短可能会不报错,但是千万不要有这样侥幸的心里。

下面用Hook本进程的例子做一个简单的修改,来演示一下调用。

Hook的是MessageBoxA()函数,该函数有4个参数,我们定义的函数也一定要是4个参数。MessageBoxA()函数的调用约定是__stdcall,那么定义函数时也要使用__stdcall。定义时使用的是WINAPI,这是一个宏,该宏的定义如下:

在MSDN中看一下MessageBox()的函数定义,该函数的定义如下:

在MSDN中,并没有看到对MessageBox()函数有关于调用约定方面的修饰。在WinUser.h中看一下关于MessageBoxA()函数的定义,该定义如下:

在WinUser.h这个头文件中可以看到,在定义中使用了WINAPI这个函数调用约定的修饰。现在来修改一下代码,修改的代码如下:

从代码中可以看到,这里把WINAPI函数调用约定的宏注释掉了。将程序进行编译连接,并运行。运行后,看到了MessageBox()的对话框,但是最后却出现了报错,如图5-12和图 5-13所示。

图5-12 错误对话框

图5-13 错误对话框

在出现图5-12后,单击“忽略”按钮,会弹出如图5-13所示的错误提示。从图5-13中可以看到一个提示“File:i386\chkesp.c”。看到这个提示以后首先要知道,这个提示告诉我们是Debug版本在检查栈平衡时报的错误。虽然这个代码是系统的代码,不是自己写的代码,但是在系统检查栈时报错,多半是由于代码破坏了栈的平衡。因此,要检查我们的代码。

出现这个错误时,其实我们是知道错误原因的,是因为我们把WINAPI这个函数调用约定的修饰去掉了。因此,的确是要检查我们的代码。但是应该从哪里开始着手呢?修改了调用约定以后,栈会不平衡。使用—stdcall是在被调用函数内进行平栈,而VC默认的调用约定是——cdcel,而此种调用约定是由调用方进行平栈。那么,我们就要手动进行平栈了。

MessageBoxA()函数有4个参数,每个参数占用4个字节,那么我们自己在函数中进行平栈,只要在返回时调用ret 0×10就可以了。修改的代码如下:

编译连接,并运行,仍然提示有错误。看来,要进行更进一步的调试了。在 MsgHook.UnHook()位置处按F9键设置断点,如图5-14所示。

按F5键执行代码,运行到断点处。单击工具栏上的反汇编按钮,如图5-15所示。

图5-14 在MsgHook.UnHook()位置处设置断点

图5-15 反汇编按钮

单击了反汇编按钮以后,将代码窗口往上移动,到函数的定义处,如图5-16所示。

通过反汇编看到有几条修改栈的操作,分别是push ebp、sub esp,40h、push ebx、push esi、push edi这5条代码。根据这5条代码来修改我们的代码以保证栈的平衡。按F7键停止调试状态的程序,并修改代码,修改后的代码如下:

图5-16 函数定义处的代码

将该代码编译连接并运行,这次运行正常了。

以上演示了一次如何手动平衡栈的过程,是不是很麻烦?其实对汇编熟悉的话就不麻烦了。不过个人觉得即使不麻烦,还是要按照原函数的函数定义来定义HOOK函数,以避免不必要的麻烦。在学习的过程中,为了深入地学习和掌握知识,手动平衡栈是可以的。但是在实际编程的过程中,仍然使用手动进行栈的平衡,那就成了钻牛角尖了。

本节介绍了Inline HOOK的原理,并通过两个例子学习了Inline Hook的用法。一个例子是对本进程的HOOK,另一个例子是对其他进程的HOOK。在对其他进程的HOOK中,演示了如何HOOK CreateProcessW()函数,并且从中学习了如何拦截应用程序进程被创建的过程,又强调了对函数栈平衡的重要性。再接下来的HOOK学习中,我们将要学习的是IATHOOK。

5.3 导入地址表钩子——IATHOOK

导入地址表是PE文件结构中的一个表结构,在学习PE文件结构的时候虽然没有提到导入地址表,但是提到了数据目录。数据目录在IMAGE_OPTIONAL_HEADER中的DataDirectory中,我们回忆一下它的定义:

(1) NumberOfRvaAndSizes:该字段表示数据目录的个数,该个数的定义为16。如下:

(2)DataDirectory:数据目录表,由NumberOfRvaAndSize个IMAGE_DATA_DIRECTORY结构体组成。该数组中包含了输入表、输出表、资源等数据的RVA。 IMAGE_DATA_DIRECTORY的定义如下:

该结构体的第一个变量为该目录的相对虚拟地址的起始值,第二个是该目录的长度。

5.3.1 导入表简介

在可执行文件中使用其他DLL可执行文件的代码或数据时称为导入,或者称为输入。当 PE文件被加载时,Windows加载器会定位所有的导入的函数或数据。这个定位是需要借助于导入表来完成的。导入表中存放了使用的DLL的模块名称,及导入的函数。

在加壳与脱壳的研究中,导入表是非常关键的部分。加壳要尽可能地隐藏导入表,脱壳一定要找到导入表。如果无法还原或修复脱壳后的导入表的话,那么可执行文件仍然是无法运行的。

在免杀中也有与导入表相关的内容,比如移动导入表函数、修改导入表描述信息、隐藏导入表……不过这些操作都是杀毒软件将特征码定位到了导入表上才需要这样做,不过可以看出导入表也同样受到杀毒软件的“关注”。

既然导入表这么重要,我们先来学习一下关于导入表的知识吧。

5.3.2 导入表的数据结构定义

在数据目录中定位第二个目录,即IMAGE_DIRECTORY_ENTRY_IMPORT。该结构体中保存了导入函数的重要信息,每个DLL都对应一个IMAGE_IMPORT_DESCRIPTOR结构,也就是说导入的DLL文件与IMAGE_IMPORT_DESCRIPTOR是一对一的关系。 IMAGE_IMPORT_DESCRIPTOR在文件中是一个数组,但是文件中并没有明确地指出导入表的个数,在导入表中是一个以全“0”的IMAGE_IMPORT_DESCRIPTOR为结束的。导入表对应的结构体定义如下:

(1) OriginalFirstThunk:该字段指向导入名称表的RVA,该表是一个IMAGE_THUNK_DATA的结构体数组。

(2) TimeDataStamp:该字段可以被忽略。

(3) ForwarderChain:该字段一般为0。

(4) Name:该字段为DLL名称的指针,该指针也为一个RVA。

(5) FirstThunk:该字段包含了导入地址表(IAT)的RVA,IAT是一个IMAGE_THUNK_DATA的结构体数组。

IMAGE THUNK_DATA结构体的定义如下:

该结构体的成员是一个联合体,虽然联合体中有若干个变量,但由于该结构体中包含的是一个联合体,那么这个结构体也就相当于只有一个成员变量,只是有时代表的意义不同。看其本质,该结构体实际上是一个DWORD类型。当IMAGE_THUNK_DATA值的最高位为 1时,表示函数以序号方式导入,而这时第31位被看作是一个导入序号。当其最高位为0时,表示函数以函数名字符串的方式导入,这时DWORD的值表示一个RVA,并指向一个 IMAGE_IMPORT_BY_NAME结构。

IMAGE_IMPORT_BY_NAME结构体的定义如下:

(1) Hint:该字段表示该函数在其DLL中的导出表中的序号。

(2) Name:该字段表示导入函数的函数名,导入函数是一个以ASCII编码的字符串,并以NULL来结尾。在IMAGE_IMPORT_BY_NAME中使用Name[l]来定义该字段,表示这是只有1个长度大小的字符串。通过越界访问,来达到访问变长字符串的功能。 IMAGE_THUNK_DATA与IMAGE_IMPORT_DESCRIPTOR类似,同样是以一个全“0”的 IMAGE_THUNK_DATA为结束的。

5.3.3 手动分析导入表

在学习PE文件结构时就借助十六进制编辑器完成了我们的学习,现在仍然通过十六进制编辑器来学习导入表的结构体IMAGE_IMPORT_DESCRIPTOR。在这里随便找个PE(EXE格式)文件来进行分析,大家可以找一个PE文件来进行分析。

用C32ASM打开找来的PE文件,首先定位到数据目录的第二项,如图5-17所示。

在图5-17中看到了数据目录中的第二项的内容,其值分别是0×00024000和0×0000003C。 0×00024000的值表示IMAGE_IMPORT_DESCRIPTOR的RVA,注意这里给出的是RVA。现在使用十六进制编辑器打开,那么就要通过RVA转换为FileOffset,也就是从相对虚拟地址转换为文件偏移地址。使用LordPE来进行转换,如图5-18所示。

图5-17 IMAGE—IMPORT—DESCRIPTOR的RVA及大小

从图5-18中可以看出,0×00024000这个RVA对应的FileOffset为0×00023000。那么在 C32中转移到0×00023000的位置处,按下Ctrl+G组合键,在弹出的对话框中填入“23000”,如图5-19所示。

图5-18 计算IMAGE—IMPORT_DESCRIPTOR的FileOffset

图5-19 C32中的“跳转到”

单击“确定”按钮,来到了文件偏移为00023000的位置处,如图5-20所示。

图5-20 导入表位置

来到文件偏移的00023000处就是IMAGE_IMPORT_DESCRIPTOR的开始位置了。从图 5-20中可以看出,该文件有2个IMAGE_IMPORT_DESCRIPTOR结构体。我们重点分析第一个IMAGE_IMPORT_DESCRIPTOR,同样关于其他几个相关的数据结构也只分析第一个。

在IMAGE_IMPORT_DESCRIPTOR中只看最后两个字段,分别是Name和FirstThunk。看一下第一个IMAGE_IMPORT_DESCRIPTOR的这两个字段的值分别是00024338和 00024190。这两个值都是RVA值。

先来看一下00024338这个值,该值表示DLL名字符串的RVA,将其转换为文件偏移后值为00023338。转到00023338这个文件偏移处查看,如图5-21所示。

图5-210 0023338处的内容

从图5-21中可以看出,这个位置保存的内容是一个字符串,该字符串的内容为 KERNEL32.dll,说明这个Name值的确保存的是DLL名字符串的RVA。

再来看一下00024190这个值,该值表示IMAGE_THUNK_DATA的RVA。将该值转换为文件偏移后的值为00023190。转到00023190这个文件偏移处查看,如图5-22所示。

图5-22 FirstThunk的值

可以看到,000242E4是FristThunk的值。该值的最高位不为1,那么说该值是导入函数 IMAGE_IMPORT_BY_NAME的RVA。将该值转换为文件偏移后的值为000232E4。转到 000232E4这个偏移处查看该处的内容,如图5-23所示。

图5-23 IMAGE—IMPORT—BY—NAME处的内容

从图5-23中可以看出,这里保存的是CloseHandle()这个函数名称的函数字符串。

在IMAGE_IMPORT_DESCRIPTOR中,有两个IMAGE_THUNK_DATA结构体,第一个为导入名字表,第二个为导入地址表。两个结构体在文件当中是没有差别的,但是当PE文件被装载内存后,第二个IMAGE_THUNK_DATA的值会被修正,该值为一个RVA,该RVA加上映像基址后,虚拟地址就保存了真正的导入函数的入口地址。

5.3.4 枚举导入地址表

从上面的分析过程中已经学习了IMAGE_IMPORT_DESCRIPTOR这个结构体。那么,下面就来用代码实现枚举导入地址表的内容。我们知道一个DLL文件会对应一个IMAGE_IMPORT_DESCRIPTOR结构,而一个DLL文件中有多个函数,那么需要使用两个循环来进行枚举。外层循环来枚举所有的DLL,而内层循环来枚举所导入的该DLL的所有的函数名及函数地址。

代码如下:

只要对于手动分析导入表能够理解的话,那么上面这段代码就不难理解了。希望大家可以理解上面的代码。对某个程序进行一个测试,看其输出结果,如图5-24所示。

图5-24 测试程序的导入表信息

用OD进行验证一下,对该测试程序的导入表信息的获取是否正确。用OD载入测试程序,然后在数据窗口中按下Ctrl+G组合键,输入地址“424190”,然后在数据窗口上单击鼠标右键,在弹出的菜单中选择“长型”-> “地址”命令,看数据窗口的内容,如图5-25所示。

图5-25 测试程序在OD中的导入表信息

那么说明程序是正确的。关于导入表的知识就介绍到这里了。接下来,要介绍关于如何对IAT进行HOOK的内容了,请大家务必掌握该小节介绍的内容。

5.3.5 IAT HOOK介绍

在前面的内容中提到这样一个问题,在IMAGE_IMPORT_DESCRIPTOR中,有两个 IMAGE_THUNK_DATA结构体,第一个为导入名字表,第二个为导入地址表(IAT)。两个结构体在文件当中是没有差别的,但是当PE文件被装载内存后,第二个 IMAGE_THUNK_DATA的值会被修正,该值为一个RVA,该RVA加上映像基址后,虚拟地址就保存了真正的导入函数的入口地址。

在这个描述当中我们知道,要对IAT进行HOOK大概分为3个步骤,首先是获得要HOOK函数的地址,第二步是找到该函数所保存的IAT中的地址,最后一步是把IAT中的地址修改为HOOK函数的地址。这样就完成了IATHOOK。也许这样的描述不是很清楚,那么下面就来举例说明一下。

比如要在IAT中HOOK系统模块kernel32.dll中的ReadFile()函数,那么首先是获得 ReadFile()函数的地址,第二步是找到ReadFile()所保存的IAT地址,最后一步是把IAT中的 ReadFile()函数的地址修改为HOOK函数的地址。这样是不是就能够明白了呢?下面通过一个实例来介绍IATHOOK的具体过程和步骤。

5.3.6 IAT HOOK之 CreateFileW ()

上次对Explorer.exe进程的CreateProcessW()函数进行了Inline Hook,这次对记事本进程的CreateFileW()函数进行IAT Hook。对CreateFileW()函数进行HOOK后主要是管控记事本要打开的文件是否允许被打开,我们一步一步地来完成代码。

先建立一个DLL文件,然后定义好DLL文件的主函数,并定义一个 HookNotePadProcessIAT()函数,在DLL被进程加载的时候,让DLL文件去调用 HookNotePadProcessIAT()函数。代码如下:

在遍历某程序的导入表时是通过文件映射来完成的,但是当一个可执行文件已经被 Windows装载器装载入内存后,便可以省去CreateFile()、CreateFileMapping()等诸多繁琐的步骤,取而代之的是通过简单的GetModuleHandle()函数就可以得到EXE文件的模块映像地址,并能够很容易地获取DLL文件导入表的虚拟地址。代码如下:

在获得导入表的位置以后,要在导入表中找寻要HOOK函数的模块名,也就是说,要对CreateFileW()函数进行HOOK,首先要找到该进程中是否有“kernel32.dll”这个模块存在。一般情况下,kernel32.d11模块一定会存在于进程的地址空间内,因为它是Win32子系统的基本模块。当然,我们并不是简单地要找到该模块是否存在,关键是要找到这个模块所对应的IMAGE_IMPORT_DESCRIPTOR结构体,这样才能通过kernel32.d11所对应的IMAGE_IMPORT_DESCRIPTOR结构体去查找保存CreateFileW()函数的地址,并进行修改。看一下代码:

对CreateFileW()函数进行HOOK,目的是为了对其打开的文件进行管控。由于这是演示程序,那么笔者在G盘下建立一个test.txt文件,然后对其进行管控,也就是说,如果用记事本打开这个程序的话,可以选择性的是否允许打开,或者不允许打开。代码如下:

我们HOOK的函数是CreateFileW(),通过函数中的W可以看出,这个函数是一个UNICODE版本的字符串,也就是宽字符串。在CreateFileW()函数的参数中,1pFileName的类型是一个指向宽字符的指针变量。那么,就需要在操作该字符串时使用宽字符集的字符串函数,而不应该再使用操作ANSI字符串的函数。在代码中wcscpy()、wcscmp()、wcslwr()都是针对宽字符集的字符串。WCHAR是定义宽字符集类型的关键字。L“g:\\test.txt”中的“L”表示这个字符串常量是一个宽字符型的。

打开一个记事本程序,然后将编译连接好的DLL文件注入到记事本进程中,当注入并HOOK成功后会用对话框提示“Hook Successfully !”。然后用记事本打开G盘下的test.txt文件,会弹出对话框询问“是否打开文件”,单击“否”按钮,也就是拒绝打开该文件,如图5-26和图5-27所示。

在图5-27中单击“确定”按钮,然后可以看到记事本并没有打开G盘下的test.txt文件,这说明对G盘下的test.txt文件的管控还算是成功的(这个程序并不完善,希望大家可以自行将其完善)。

图5-26 询问是否打开

图5-27 选择“否”后的提示

以上实例演示了如何对IAT进行HOOK。不过上面针对IAT进行HOOK的做法只能针对隐型调用。也就是说,可执行文件是直接调用了DLL的导出函数,用上面的代码可以对 IAT进行HOOK。如果是显式调用的话,以上的例子就无法达到HOOK的作用了。当可执行文件直接通过调用LoadLibrary()函数和GetProcAddress()函数来使用某个函数的话,上面的HOOK代码是无能为力的。如何解决这样的问题,答案是要对LoadLibrary()和 GetProcAddress()函数也进行HOOK,这样就可以避免对DLL的显式加载,和对函数的显式调用了。

5.4 Windows钩子函数

5.4.1 钩子原理

Windows下的应用程序大部分是基于消息模式机制的,一些CUI的程序不是基于消息的。 Windows下的应用程序都有一个消息函数,根据不同的消息来完成不同的功能。Windows操作系统提供的钩子机制的作用是用来截获、监视系统中的消息的。Windows操作系统提供了很多不同种类的钩子,不同的钩子可以处理不同的消息。

钩子分为局部钩子和全局钩子。局部钩子是针对一个线程的,而全局钩子则是针对整个操作系统内基于消息机制的应用程序的。全局钩子需要使用DLL文件,DLL文件里存放了钩子函数的代码。

在操作系统中安装了全局钩子以后,只要进程接收到可以发出钩子的消息后,全局钩子的DLL文件会被操作系统自动或强行地加载到该进程中。由此可见,设置消息钩子也是一种可以进行DLL注入的方法。

5.4.2 钩子函数

上面已经简单地讲述了Windows钩子的基本原理,现在来学习一下Windows下的钩子函数,主要有3个,分别是SetWindowsHookEx()、CallNextHookEx()和UnhookWindowsHookEx()。下面介绍这些函数的使用方法。

SetWindowsHookEx()函数的定义如下:

该函数的返回值为一个钩子句柄。这个函数有4个参数,下面分别进行介绍。

(1) 1pfn:该参数指定为Hook函数的地址。如果dwThreadId参数被赋值为0,或者被设置为一个其他进程中的线程ID,那么1pfn则属于DLL中的函数过程。如果dwThreadId为当前进程中的线程ID,那么1pfn可以是指向当前进程中的函数过程,也可以是属于DLL中的函数过程。

(2) hMod:该参数指定钩子函数所在模块的模块句柄。该模块句柄就是1pfn所在的模块的句柄。如果dwThreadId为当前进程中的线程ID,而且1pfn所指向的函数在当前进程中,那么hMod将被设置为NULL。

(3) dwThreadId:该参数设置为需要被挂钩的线程的线程ID号。如果设置为0,那么表示在所有的线程中挂钩(这里的所有的线程表示基于消息机制的所有的线程)。如果指定为具体的线程的ID号,那么表示要在指定的线程中进行挂钩。该参数影响上面两个参数的取值,该参数的取值决定了该钩子属于全局钩子,还是局部钩子。

(4) idHook:该参数表示钩子的类型。由于钩子的类型非常多,因此放在所有的参数后面进行介绍。钩子的类型非常多,下面介绍几个常用到的,也可能是大家比较关心的几个钩子类型。

1. WH_GETMESSAGE。安装该钩子的作用是监视被投递到消息队列中的消息。也就是当调用GetMessage()或PeekMessage()函数时,函数从程序的消息队列中获取一个消息后则调用该钩子。WH GETMESSAGE钩子函数的定义如下:

2. WH_MOUSE。安装该钩子的作用是监视鼠标消息。该钩子函数的定义如下:

3. WH_KEYBOARD。安装该钩子的作用是监视键盘消息。该钩子函数的定义如下:

4. WH_DEBUG。安装该钩子的作用是调试其他钩子的钩子函数。该钩子函数的定义如下:

其他的钩子类型请大家参考MSDN吧。从上面的这些钩子函数定义可以看出,每个钩子函数的定义都是一样的。是的,的确是这样的,每种类型的钩子监视、截获的消息不同,虽然它们的定义都是相同的,但是函数参数的意义是不同的。由于篇幅所限,每个函数的具体意义请参考MSDN,在MSDN上会给出最为详细的介绍。

接着介绍跟钩子有关的函数,UnhookWindowsHookEx(),该函数的定义如下:

这个函数是用来移除先前用SetWindowsHookEx()安装的钩子。该参数只有一个参数,是钩子句柄。也就是用该函数用通过制定的钩子句柄来移除与其相对应的钩子。

在操作系统中,可以多次反复地使用SetWindowsHookEx()函数来安装钩子,而且可以安装多个同样类型的钩子。这样,钩子就会形成一条钩子链,最后安装的钩子会首先截获到消息,当该钩子对消息处理完毕以后,会选择返回,或者选择把消息继续传递下去。在通常情况下,为了消息可以传达到目标窗口,我们会选择将消息继续传递,使消息能继续传递的函数的定义如下:

该函数有4个参数,第一个参数是钩子句柄,就是SetWindowsHookEx()函数的返回值。后面3个参数是钩子函数的参数,直接抄过来即可。例如:

5.4.3 键盘钩子实例

下面来写一个可以截获键盘消息的钩子程序,这个程序的功能非常简单,就是把按下的键对应的字符显示出来。既然要截获键盘消息,那么肯定是截获系统范围内的键盘消息,因此需要安装全局钩子,这样就需要DLL文件的支持。先来新建一个DLL文件,在该DLL文件中,需要定义两个导出函数和两个全局变量,定义如下:

在DllMain()函数中,需要保存该DLL模块的句柄,以方便安装全局钩子。代码如下:

安装与卸载钩子的函数如下:

对于Windows钩子来说,上面的这些步骤基本上都是必须有的,或者是差别不大的,关键在于钩子函数。这里是为了获取键盘按下的键,钩子函数如下:

关于钩子函数,这里简单地解释一下,首先是进入钩子函数的第一个判断。

如果code的值小于0,则必须调用CallNextHookEx(),将消息继续传递下去,不对该消息进行处理,并返回CallNextHookEx()函数的返回值。这一点是MSDN上要求这么做的。

如果code等于HC_ACTION,表示消息中包含按键消息,如果为WM_KEYDOWN,则显示按键对应的文本。

将该DLL文件编译连接。为了测试该DLL文件,新建一个MFC的Dialog工程,添加两个按钮,如图5-28所示。

图5-28 键盘钩子的测试程序

分别对这两个按钮添加代码,如下:

直接调用DLL文件导出这两个函数,不过在使用之前要先对这两个函数进行声明,否则编译器因无法找到这两个函数的原型而导致连接失败。定义如下:

进行编译连接,提示出错,出错内容如下:

从给出的提示可以看出是连接错误,是找不到外部的符号。将DLL编译连接后生成的DLL文件和LIB文件都复制到测试工程的目录下,并将LIB文件添加到工程中。在代码中添加如下语句:

再次连接,成功!

运行测试程序,并单击“HookOn”按钮,随便按下键盘上的任意一个键,会出现提示对话框,如图5-29所示。

图5-29 截获到的键盘输入

从图5-29中可以看出,当我们按下键盘上的按键时,程序将捕获到我们的按键。到此,键盘钩子的例子程序就完成了。关于钩子更多的使用请大家自行思考研究。

5.4.4 使用钩子进行DLL注入

Windows提供的钩子类型非常多,其中有一种类型的钩子非常实用,那就是WH_GETMESSAGE这个类型的钩子。它可以很方便地将DLL文件注入到所有的基于消息机制的程序中。

在有些情况下,需要DLL文件完成一些功能,但是完成功能时需要DLL在目标进程的空间当中。这时,就需要使用WH_GETMESSAGE这个消息把DLL注入到目标的进程中。代码非常简单,这里直接给出DLL文件的代码,代码如下:

整个代码就是这样,只要大家知道,在需要DLL大范围的注入到基于消息的进程中时可以使用这种方法。

5.5 总结

在Windows操作系统下挂钩的方法非常多,这里只介绍了Inline Hook、IAT Hook和Windows钩子等几种。这些都是较为常见的挂钩方法,希望大家可以掌握。

第6章 黑客编程剖析

通过前面5章学到的知识,本章将完成一个综合性的实例,以帮助我们复习和巩固前面所学知识。本章旨在为大家起到抛砖引玉的作用,希望大家可以在自己感兴趣的方面进行深入地学习和研究。

6.1 恶意程序剖析

恶意程序通常是指带有攻击意图的一段程序,主要包括木马、病毒和蠕虫等。恶意程序的编写是违反道德和法律的,我们这里只是进行学习。在前面的章节中说过,黑客编程和普通编程本质上都是编程,只是侧重点不同。我们为了学习编程,学习防御黑客编程攻击,就必须对恶意程序的编写要有所了解。

6.1.1 恶意程序的自启动

每当黑客们入侵了计算机以后,为了下次的登录都会安装一个后门或者木马。当计算机关机或重启时,所有的进程都将会被关闭。那么后门或者木马是如何在计算机重启以后仍然能继续运行呢?下面先来讨论如何实现恶意程序的自启动。

恶意程序的自启动的实现方法很多,下面只介绍几种常见的方法,至于其他方法大家可自己在网上搜集,然后通过编程实现即可。

一、启动文件夹

在Windows系统下,有一个文件夹是专门用来存放启动文件的。该文件夹的位置如图6-1所示。

在“启动”菜单处单击鼠标右键,然后选择“属性”命令,就可以看到启动文件夹在硬盘上的具体位置了,其位置如:“系统盘:\Documents and Settings\<用户名>\『开始』菜单\程序”。通常情况下<用户名>是当前用户的用户名,Windows为每个用户创建了一个文件夹。如果想要所有的用户在启动时都运行某个程序,需要使用的文件夹为“All Users”。那么,也就是把需要启动的程序放到“系统盘:\Documents and Settings\All Users\『开始』菜单\程序”这个位置下。该程序的实现方法非常的简单,因为基本上在前面已经介绍过该程序的实现方法了。下面给出它的部分代码,代码如下:

图6-1 启动文件夹的位置

打开All Users下的启动目录,也就是上面的“系统盘:\Documents and Settings\All Users\「开始」菜单\程序”这个位置,然后编译连接并运行程序,可以看到在启动目录下多了一个“test.exe”的程序。

二、注册表启动

注册表启动也是一种很常见的启动方法,而且在注册表中可以用来进行启动的位置非常多。这里给大家介绍几个在注册表中可以完成自启动的注册表位置。

1. Run注册表键

HKCU \Software\Microsoft\Windows\CurrentVersion\Run

HKLM \Software\Microsoft\Windows\CurrentVersion\Run

2. Boot Execute

HKLM\System\CurrentControlSet\Control\Session Manager\BootExecute

HKLM\System\CurrentControlSet\Control\Session Manager\SetupExecute

HKLM\System\CurrentControlSet\Control\Session Manager\Execute

HKLM\System\CurrentControlSet\Control\Session Manager\SOInitialCommand

3. Load注册表键

HKCU\Software\Microsoft\Windows NT\CurrentVersion\Windows\Load

当然,在注册表下绝对不只这么几个位置能够使程序跟随系统自动启动,这只是注册表下可以让程序随机启动位置的“冰山一角”。打开一个注册表项看一下,如图6-2所示。

图6-2 Run注册表键中的启动项

同样,下面来完成一个通过写入注册表进行自启动的例子程序,其部分代码如下:

将该代码编译连接并运行,然后打开注册表中写入的位置查看一下,会发现已经把它写入了注册表,当下次开机时,它就会随机启动了。

三、其他启动方法

除了使用上面两种方法外,还有很多启动方法,比如文件关联、创建服务、ActiveX启动、svchost.exe启动等。下面大概地介绍一下。

文件关联启动是通过修改注册表来完成的。比如,默认启动“文本文件”的程序是记事本程序,只要在注册表中把启动“文本文件”的关联程序改掉,也就是把记事本程序改掉,改成我们的木马程序,然后由木马去调用记事本来启动文本文件,这样,就达到了启动木马的效果。来看一下注册表,如图6-3所示。

图6-3 文本文件对应的文件关联

文本文件对应的文件关联的注册表位置为KHEY_CLASS_ROOT\txtfile\shell\ open\command。

创建系统服务的方法是使用CreateService()函数进行创建,然后通过服务来启动恶意程序。CreateService()函数的定义如下:

该函数的参数虽然很多,但是使用起来并不复杂,大部分参数可以使用NULL来表示。该函数的参数不进行说明,请大家自行参考MSDN进行使用。

关于恶意程序的启动就介绍这么多,还有其他更多的方法,大家可以自行查找相关的资料进行学习。最后给大家介绍一款工具,这款工具可以查看电脑上的所有启动项。也许大家会想到360、金山等一些工具,但是这里要介绍的这款工具是微软自己的工具,而且其功能相对来说非常的全面,该工具如图6-4所示。

图6-4 Autoruns工具界面

6.1.2 木马的配置生成与反弹端口

由于黑色产业链的供需关系,木马作为商品在网络上有着大量的交易,因此木马必须具备可配置性。当然了,即使是免费的木马,为了可以让广大网友使用,木马也是一定可以进行配置的。

木马要进行配置后才可以生成真正的服务端,看一下灰鸽子的配置界面,如图6-5所示。

从灰鸽子的配置界面中可以看出,灰鸽子支持的可配置的内容非常多,这里只显示了其中的一部分而已。灰鸽子在进行了服务端配置后会生成服务器端程序。下面就来介绍一下如何对木马进行配置生成,并介绍一下如何进行反弹连接。

一、反弹连接介绍

早期的防火墙只对连入主机的连接进行阻拦,而不对连出的连接进行阻拦。也就是说,当有人试图连接主机时,防火墙会给出提示有人要连接主机。而当主机向外连接其他主机时,防火墙是不会给出提示的。在这样的情况下,反弹木马诞生了。

图6-5 灰鸽子的配置界面

所谓的反弹木马是由攻击者监听一个端口,中木马的被攻击者主动向被攻击者发起连接。由于是被攻击者发起的连接,从而被攻击的防火墙不会给被攻击者任何安全提示。给出一个简单的示意图进行说明,如图6-6所示。

图6-6 反弹木马的示意图

从图6-6中看到了反弹木马的工作原理。通常情况下攻击者的IP地址是变动的,那么“小白”是如何连接到“黑客”的主机的呢?一般情况下黑客要把自己的IP地址动态地保存到某个固定的IP地址下(比如保存到网上FTP空间中),然后木马通过读取该IP地址下保存的黑客的IP地址进行连接,同样用图来说明,如图6-7所示。

从图6-7中可以看出,黑客开启木马客户端后,首先会更新服务器上保存着的自己的IP地址。“小白”会去读取服务器中保存着的黑客的IP地址,然后“小白”去连接“黑客”的主机,主动地让黑客去控制它,这就是木马中的“自动上线”。关于反弹端口的介绍就到这里。有了思路,通过前面学习的Winsock的知识自己可以试着实现一下,这里就不做更多的介绍了。

二、木马的配置生成与配置信息的保护

木马写好以后,通常会发布一个程序,在木马程序中通过配置一些相关的内容和参数后,会生成一个木马的服务器端程序。为什么木马的客户端会生成木马的服务端程序呢?其实木马的客户端和服务端本来就是两个程序,只是通过某种方式使其成为了一个文件而已。让木马的服务端和客户端成为一个文件可以有多种方法,常见的有资源法和文件附加数据法两种。

图6-7 木马动态获取黑客的IP地址

在PE文件结构中有一个数据目录称作资源,资源可以是图片、图标、音频、视频等内容。资源法也就是把服务端以资源的形式连接到客户端的程序中,然后客户端通过一些操作资源的函数将资源读取出来并生成文件。文件附加数据法是将服务端保存到客户端的末尾,然后通过文件操作函数,直接将服务端读取出来并生成新的文件。

反弹端口连接是要访问某个固定的IP地址去读取保存着黑客的动态IP地址的信息,而这个固定的IP地址是保存在木马程序中的。也就是说,我们的客户端在把服务端生成以后,会把一些配置信息写入服务端程序的指定位置中,服务端程序会读取指定位置的信息来进行使用。配置信息的写入与读出必须要一致,否则就没有意义了。

对于配置信息中往往会存在一些比较敏感的信息,比如邮箱账号、密码等内容。比如,我们在分析盗QQ的木马时会发现接收QQ密码的邮箱,由于现在很多邮箱都需要SMTP的验证,因此在配置信息中也会看到邮箱的账号及密码信息。这样配置信息中的这些敏感信息很容易被人获取到,甚至接收QQ密码邮箱的账号和密码也会被别人获取到,真是“偷鸡不成蚀把米”。对于此类情况,正确的做法是对配置信息进行加密。也就是说客户端往服务端中写配置信息前需要加密后再写入,而服务端在使用这些信息前需要先解密再使用。

关于配置生成客户端与配置信息的保护上面已经介绍得差不多了,接下来应该把重点放在代码的实现上了。我们的代码是模拟实现上面的内容,而不是真的去生成木马。

6.1.3 代码实现剖析

我们通过资源来生成木马,首先要写一个简单的被生成的程序,这个程序要去读取被写入的配置信息。客户端把配置信息写入服务端的文件末尾,服务端从文件的末尾将信息读入。下面来写一个简单的程序,来充当我们的服务端程序。需要设置的配置信息有IP地址和端口号,把这两个信息都写入服务器端程序。先来定义一个结构体,结构体如下:

下面写一个模拟的简单的服务端程序,具体代码如下:

上面的代码就是服务端读取配置文件的代码,把文件指针移动到配置信息处,然后直接读取出来,让服务端连接配置信息中的IP地址就可以了。这就是我们模拟的服务端。下面再来写一个模拟的客户端,用来对其进行配置。

创建一个MFC的对话框程序,然后对界面进行布局,界面布局如图6-8所示。

把模拟的服务端编译连接好以后添加入这个模拟客户端的资源里,添加方法是在VC中的资源选项卡中单击鼠标右键,在弹出的菜单中选择“Import…”命令,如图6-9所示。然后在弹出的对话框中选择编译好的模拟服务端程序,如图6-10所示。会出现一个输入自定义资源类型的对话框,输入“IDC_MUMA“,如图6-11所示。

图6-8 模拟客户端窗口布局

图6-9 添加资源

图6-10 选中编译好的程序

图6-11 自定义资源类型对话框

单击“OK”按钮,就将其添加入资源对话框中了,如图6-12所示。

图6-12 资源选项卡

做好这些准备工作以后,就可以开始写代码了。给“生成”按钮添加如下代码:

编译连接并运行这个程序,输入配置程序,单击“生成”按钮,会生成一个muma.exe的程序,然后运行这个程序就会输出配置信息的内容,如图6-13所示。

图6-13 程序运行结果

在这个程序中使用了4个以前没有使用过的函数,下面分别进行介绍。

(1) 查找资源FindResource()函数的定义如下:

①hModule:该参数表示要查找模块的句柄。

②lpName:该参数表示要查找资源的名称。

③lpType:该参数表示了要查找资源的类型。

(2) SizeofResource()函数用来计算被查找资源的大小,该函数的定义如下:

①hModule:该参数同FindResouce()相同。

②hResInfo: FindResouce()的返回值。

(3) LoadResource()函数用来将资源载入全局内存中,该函数的定义如下:

该函数参数的意义与SizeofResouce()的相同。

(4) LockResouce()函数的作用是将资源锁定,并返回其起始位置的指针,该函数定义如下:

上面介绍了关于使用资源来将两个程序合并为一个程序的方法。除此而外还有一种方法是使用附加数据法将两个程序合并为一个程序。何为附加数据法呢?PE文件在被载入内存时是按照节来映射的,没有被映射入内存的部分就是附加数据,虽然该部分占用文件大小,却不占用映像大小。关于配置信息的保护部分,只要使用简单的加密算法将配置信息加密就可以了,比如异或算法,这里就不进行介绍了。

6.2 简单病毒剖析

编写病毒不是一件光彩的事情,而且随时都有让自己惹上官司的可能。我们介绍一个简单的病毒的编写只是为了进行研究,以便编出更好的防范工具。

6.2.1 病毒的感染剖析

大部分病毒都有感染的功能,病毒会把自身当中的或者需要其他程序来完成的指定功能的代码感染给其他的正常的文件。就像人类的流行感冒,办公室中只要有一个人携带感冒病毒,就有可能所有人都会被传染。如果没被传染说明已经预防过了,因此在机器上安装杀毒软件还是非常有必要的。

前面说了,病毒要感染其他文件也就是把病毒本身的攻击代码或者病毒期望其他程序要完成的功能代码写入到其他程序当中,而想要对其他程序写入代码就必须要有写入代码的空间。除了把代码写入到其他程序中以外,还必须让这些代码有机会被执行到。就上面两个问题而言都是比较容易解决的,下面分别来讨论一下。

病毒要对其他程序写入代码,必须确定目标程序有足够的空间让它把代码写入。通常情况下有两种比较容易实现的方法,第一种在前面的章节介绍过,就是添加一个节区,添加一个节区后就有足够的空间让病毒来写入了。第二种方法是缝隙查找,然后写入代码。何为缝隙?在每个节与节之间,必然有没有使用到的空间,这个空间就叫缝隙。只要确定要写入代码的长度,然后根据这个长度来查找是否有满足该长度的缝隙就可以了。由于第一种添加节区的方法在前面的章节中已经给出过了,因此这里主要介绍第二种方法。

6.2.2 缝隙搜索的实现

通常情况下,每个节之间都是有未使用的空间的,搜索这些未使用的空间来把我们的代码写入到这个位置。由于只是一个测试代码,因此不会写具有攻击性的代码,我们写入目标程序的代码的功能是什么都不做,就是汇编中的“NOP”指令,其机器指令是0×90。简单地写入11个0×90就可以了。定义如下:

搜索缝隙的代码如下:

在代码节和紧挨代码节之后的节的中间搜索缝隙,搜索的方向是从代码节的末尾开始,个人认为反方向的搜索速度要快一些。通过该代码可以找到缝隙,但是也可能找不到缝隙,因此在调用完该函数后要做一些判断,以应变各种不同的情况。

6.2.3 感染目标程序文件剖析

我们把代码添加到了目标文件中,但是这些代码如何才能被执行到呢,这就要修改目标可执行文件的入口地址。修改目标入口地址先来执行我们的代码,然后再跳转到原来程序的入口处继续执行,很多病毒都是这样工作的。修改一下机器码,定义如下:

把机器码的后几个字节改为一条mov指令和一条jmp指令,这个过程和前面章节介绍的inline hook有些类似。我们写一个程序,来调用上面的函数,并且将机器码写入到目标程序中,具体代码如下:

编译连接后,找个以前写的VC程序,将其改名为“hello.exe”,并放到同一个目录下,然后运行这个感染程序。用OD打开“hello.exe”程序看一下,如图6-14所示。

图6-14 被感染后的目标程序入口

从图6-14中可以看出,感染是成功的,而且jmp eax指令中的eax保存的是“hello.exe”的入口地址。运行被感染的目标程序,是可以运行的。再次对其进行一次感染,然后用OD打开看一下目标程序“hello.exe”,如图6-15所示。

图6-15 被二次感染的目标程序入口

可以看到目标程序被二次感染了。由于只是添加了一些简单的跳转指令,因此没有太大影响,但是如果是真正的病毒,很有可能导致被感染的目标程序无法正常运行。因此,需要对被感染过的文件写一个标志,这样就可以避免被二次感染了。

6.2.4 添加感染标志

为了避免重复感染目标程序,必须对目标程序写入感染标志以防被二次感染,导致目标程序无法执行。每次在对程序进行感染时都要先判断是否有感染标志,如果有感染标志则不进行感染,如果没有感染标志则进行感染。在PE文件结构中有非常多不实用的字段,可以找一个合适的位置写入感染标志。想必这个非常容易理解,下面直接看代码。写入感染标志的代码如下:

读取的感染标志的代码类似,代码如下:

每次在进行感染前先调用CheckSig()函数,判断是否有感染标志,然后根据是否被感染做出不同的选择,调用以上两个函数的方法如下:

在代码中我们把感染标志写到了IMAGE_DOS_HEADER中的e_cblp这个位置处。 IMAGE_DOS_HEADER中除了e_magic和e_lfanew这两个字段外,其余都是没有用的,大家可以放心写入。代码中的offsetof()是一个宏,该宏的定义如下:

该宏的作用是取得某字段在结构体中的偏移。对于IMAGE_DOS_HEADER结构体中的e_cblp来说,它在结构体中的偏移是2。那么offsetof(IMAGE_DOS_HEADER, e_cblp)返回的值则为2,大家可以调试跟踪一下。

6.2.5 自删除功能的实现

某些程序在首次运行以后就莫名其妙地消失。当人们意识到这是病毒或木马时已经晚了,悔恨自己没装杀毒软件。其实病毒或木马被执行后都把自己复制到系统盘里了,改个看起来很重要的文件名,并且把自己隐藏得很深。

下面介绍一下它是如何自删除的。自删除的方法有很多,最简单的方法就是创建一个“.cmd”的批处理文件。批处理文件中通过DOS命令del来删除可执行文件,再通过del删除自身。我们来看一下它的实现代码:

直接在main()函数中调用CreateBat()函数,编译连接并运行它。可以看到,编译好的程序消失了。其实,是创建的批处理文件将编译好的程序和批处理本身都删除掉了。这样就达到了自删除的功能了。

6.3 隐藏DLL文件

前面的章节介绍了如何隐藏进程,隐藏进程的方法是把要在进程中完成的功能放在DLL文件中完成,然后将DLL文件注入到其他进程当中,从而达到隐藏进程的目的。现在要做的是隐藏进程中的DLL文件,当把DLL文件注入到远程进程后,可以将DLL也隐藏掉。操作系统在进程中维护着一个叫做TEB的结构体,这个结构体是线程环境块。下面就要通过WinDBG这个调试工具来一步一步地学习TEB,并通过TEB来学习如何隐藏DLL文件。

6.3.1 启动WinDBG

启动WinDBG工具,如图6-16所示。

依次单击菜单栏的“File” -> “Symbol File Path…”命令,在里面输入符号文件路径,这里直接填入微软为我们提供的符号服务器,“srv*F:\Program Files\symbolcache*http://msdl.microsoft.com/download/symbols”,如图6-17所示。

设置好符号路径,就可以开始调试了。这里调试的目标就是WinDBG,因为原理是相同的。我们来进行本地调试,依次单击菜单“File” -> “Kernel Debug”命令,出现如图6-18所示的窗口。

图6-16 WinDBG启动界面

图6-17 设置符号文件路径

图6-18 Kernel Debugging窗口

选择“Local”选项卡,也就是进行本地调试,单击“确定”按钮。这样,就可以用WinDBG开始调试了,跟着步骤一步一步做就可以了。

6.3.2 调试步骤

首先获取TEB,也就是线程环境块。在编程的时候,TEB始终保存在寄存器FS中。获取TEB的命令为“!teb”。在WinDBG的命令提示处输入该命令,WinDBG将输出如下内容。

从上面的输出内容可以看出,TEB地址为7ffde000。

获得TEB以后,通过TEB的地址来解析TEB的数据结构,从而获得PEB,也就是进程环境块,命令为“dt_teb 7ffde000”,WinDBG的输出内容如下:

上面的输出只是部分输出,该结构体非常长,这里只查看其中的一部分内容,只要找到PEB在TEB中的偏移就可以了。从该命令的输出可以看出,PEB结构体的地址位于TEB结构体偏移0×30的位置,该位置保存的地址是7ffd5000。也就是说PEB的地址是7ffd5000,通过该地址来解析PEB,并获得LDR。在命令提示符处输入命令“dt nt!_peb 7ffd5000”,输出内容如下:

从输出结果可以看出,LDR在PEB结构体偏移的0×0C处,该地址保存的地址是001ale90。通过该地址来解析LDR结构体。在命令提示符输入命令“dt _peb_ldr_data 001ale90”, WinDBG输出如下内容:

在这个结构体中,可以看到3个相同的数据结构,也就是在偏移0×0c、0×14、0×24处的3个结构体_LIST_ENTRY。该结构体是个链表,定义如下:

上面这个结构体在SDK提供的帮助中是找不到的,需要去WDK的帮助中才可以找到。这3条链表分别保存的是_LDR_DATA_TABLE_ENTRY,也就是LDR_DATA表的入口。

现在来手动遍历一下第一条链表,输入如下命令“dd lalec0”。

在这么多的输出中,在链表偏移0×18的位置是模块的映射地址,即ImageBase,在链表偏移0×28的位置是模块的路径及名称的地址,在0×30的位置是模块名称的地址。查看一下, lalec0偏移0×28的位置中保存的地址是20c64,接下来输入命令“du 20c64”。

可以看到,输出WinDBG的全部路径,来看一下偏移0×18的地址,该进程的映射基址为01000000。再来看一下偏移0×30处的地址保存着20ca6,查看该地址,输入命令“du 20ca6”。

的确是模块的名称。既然是链表,那么看看下一条链表的信息。

按照上面介绍的解析方法自己进行解析。

上面介绍的几个结构体在VC6的头文件中是找不到的,不过在网上还是可以查到的。这里给出MSDN上给出的几个结构体的定义,该MSDN的地址为: http://msdn.microsoft.com/zh-cn/library/aa813708(v=VS. 85) . aspx,方便大家查看。涉及的几个结构体的定义如下:

从这两个结构体中可以看出有非常多的保留字段,这些都是微软不愿意公开的,或不愿意让大家使用的。不过在网上有大量的相关结构体的具体定义,大家可以自行查找进行阅读。

看完上面的各种结构体是不是觉得我们自己都可以实现枚举进程中模块的函数了?是的,我们来写一个吧。

6.3.3 编写枚举进程中模块的函数

枚举进程中的模块的方法就是通过上面介绍的几个结构体来完成,其步骤是。

获得TEB地址->获得PEB地址->得到Ldr->获得第二条链表的地址->遍历该链表并输出其地0×18的值和0×28指向的内容。

只要把上面在WinDBG中找到链表的方法学明白,那么就不是太大的问题了。关键的问题是TEB怎么找到。告诉大家,TEB保存在FS中,有了这个提示就很好解决了吧?看代码吧。

该函数的实现没有太多的技巧,主要在于对C语言中指针的掌握,还有就是对以上介绍的几个结构体之间的关系能够掌握,也就是各结构体之间的数据关系。在main()函数中调用一下这个函数,输出结果如图6-19所示。

6.3.4 指定模块的隐藏

模块的隐藏是把指定模块在链表中的节点断掉,也就是做一个数据结构中链表的删除动作,只不过不进行删除,只是将其节点脱链即可,如图6-20所示。

图6-19 自实现的枚举模块函数

图6-20 链表节点脱链

如果是枚举模块的话,一般情况下,只要枚举第二条链表就可以了,也就是偏移0×14处的那条。如果要做模块隐藏的话,那最好是将3条链表中的指定模块全部都脱链。对于脱链的方法,其实也是对3条链表进行遍历,然后将指定的模块脱链就可以了。和上面枚举的方法差别不大。下面给出代码,如下:

在main()函数中调用这个函数,主函数如下:

接下来隐藏调用“kernel32.dll”这个模块,当然,这里的隐藏只能是这个程序运行时隐藏该进程中的“kernel32.dll”模块,对其余进程中的模块并没有影响。在程序的末尾使用getchar(),其用意是希望该进程可以停留住,否则它如果退出,便没有验证“kernel32.dll”模块是否真的被隐藏的机会了。编译连接并运行,然后用第3章中写的工具来查看一下,如图6-21所示。

图6-21 查看HideModule中Kernel32.dll模块被隐藏

从图6-21中可以看出,在HideModule. exe进程中看不到Kernel32. dll的模块名。当然,就算用我们自己编写的枚举的模块函数也是没用的,因为模块已经不在链表中了。虽然这个程序把进程中“kernel32.dll”隐藏了,但是并没有多大的实际意义。隐藏模块主要是用在被注入的DLL中,也就是一个DLL文件被注入到远程线程中后,为了不被发现而隐藏。

6.4 安全工具开发基础

黑客攻防一直都在共同进步、发展着,一正一邪,而正邪只在一念之差。黑客攻防从技术层面上看完全没有差别,其差别只在于使用者的想法。前面介绍了恶意程序的编写,那么自然也要介绍一些关于安全工具的开发,从而保证了安全。

6.4.1 行为监控工具开发基础

现在有一种流行的防病毒软件被称作HIPS,中文名字叫做主机防御系统,比如EQ。该软件可以在进程创建时、有进程对注册表进行写入时或有驱动被加载时,给用户予以选择,选择是否拦截进程的创建、是否拦截注册表的写入、是否拦截驱动的加载等功能。

HIPS纯粹是以预防为主,比如有陌生的进程在被创建阶段,就可以让用户禁止,这样就避免了特征码查杀的滞后性。对于杀毒软件的特征码查杀而言,如果杀毒软件不更新病毒数据库,那么依赖病毒特征码的杀毒软件就无法查杀新型的病毒了,那么对新型的病毒就成为一个摆设了。

行为监控的原理主要就是对相关的关键API函数进行HOOK,比如前面介绍的进程拦截。当一个木马程序要秘密启动的时候,对CreateProcessW()函数进行了HOOK,在进程被创建前,会询问用户是否启动该进程,那么木马的隐秘启动就被暴露出来了。对于没有安全知识的大众来说,也许仍然会让木马运行,但是木马的运行可以被我们所发现了。对于没有安全知识的大众来说,使用HIPS可能有点困难。因为不是每个使用计算机的人都是对计算机有所了解的,计算机对于他们而言可能只用来打打游戏,或看看电影之类的。这该如何做呢?现在通常使用的方法就是使用白库和黑库,也就是所谓的白名单和黑名单。在进程被创建时,把要创建的进程到黑白库中去匹配,然后做相应的动作,或者放行,或者拦截。

下面就来实现一个应用层下的简单的进程防火墙,注册表防火墙的功能。

一、简单进程防火墙

进程防火墙指的是放行/拦截准备要创建的进程。通过前面章节的介绍我们知道,进程的创建是依靠CreateProcessW()函数完成的。只要HOOK CreateProcessW()函数就可以实现进程防火墙的功能。对于注册表来说,要对非法进程进行删除或写入注册表键值进行管控,因此需要HOOK两个注册表函数,分别是注册表写入函数RegSetValueExW()和注册表删除函数RegDeleteValueW()。由于使用了HOOK,那么就必然要涉及DLL的编写,我们分DLL和EXE两部分来进行详细的介绍。

二、实现HOOK部分的DLL程序的编写

因为要对目标进程进行HOOK,因此要编写DLL程序。我们创建一个DLL程序,并加入前面的已封装的ILHook.h头文件和ILHook.cpp的实现文件。

为了能在所有的基于消息的进程中注入我们的DLL,必须使用Windows钩子,这样就可以将DLL轻易地注入到基于消息的进程中。代码如下:

以上函数用来定义导出函数,用于加载完成HOOK功能的DLL文件。这里利用WH_GETMESSAGE这个钩子类型,在前面的内容中是介绍过的,这里就不做过多的介绍了。

定义3个CILHook类的对象,分别用来对CreateProcessW()函数、RegSetValueExW()函数和RegDeleteValueW()函数进行挂钩。定义如下:

HOOK部分是在DllMain()函数中完成的,代码如下:

对于放行/拦截这部分是给用户选择的,那么就要给出提示让用户进行选择,至少要给出放行/拦截的类型,比如是注册表写入或是进程的创建,还要给出是哪个进程进行的操作。要把这个信息反馈给用户,我们定义一个结构体,将该结构体的信息发送给用于加载DLL的EXE文件,并让EXE给出提示。结构体定义如下:

定义一些常量用来标识放行/拦截的类型,定义如下:

将这些定义好以后,就可以开始完成HOOK函数了,我们主要给出CreateProcessW()函数的HOOK实现,其余两个函数的HOOK实现请大家自行实现。代码如下:

这里使用了一个SendMessage()函数,该函数用来发送一个WM_COPYDATA消息,将结构体传给了加载DLL的EXE程序,使EXE程序把提示显示给用户。

SendMessage()函数的功能非常强大,该函数的定义如下:

该函数的第一个参数是目标窗口的句柄,第二个参数是消息类型,最后两个参数是消息的附加参数,根据消息类型的不同而不同。

以上代码就是DLL程序的全部了,剩下两个对注册表操作的HOOK函数由大家自己完成。

三、行为监控前台程序的编写

先来看一下程序能达到的效果,然后再给大家讲解程序EXE部分的实现代码,如图6-22和图6-23所示。

图6-22 程序主界面

从上面两个图可以看出,程序的确是可以拦截进程的启动的,当单击“允许”按钮后,进程会被正常创建,当单击“取消”按钮后,进程将被阻止创建。这就是我们最终要完成的功能,来看看主要的实现代码。

图6-23 拦截提示框

EXE的部分主要就是如何来启动行为监控功能,还有就是如果接收DLL程序通过SendMessage()函数发出的消息来给用户弹出提示框。进行拦截的部分已经在DLL程序中通过HOOK实现了,所以重点也就在界面上和消息的接收上了。

先看如何启动和停止行为的监控。代码如下:

从代码中不难看出,直接调用了DLL的两个导出函数,就可以开启我们的打开。在关闭时为什么调用了CloseHandle()函数和FreeLibrary()函数呢。大家把FreeLibrary()函数去掉,然后单击“停止”监控行为,但是还是处在被监控的状态下。因为恢复Inline Hook是在DLL被卸载的情况。因此,在卸载时,我们调用GetModuleHandle()获得本进程的DLL句柄后,虽然CloseHandle()了,但是只是减少了对DLL的引用计数,并没有真正的释放,必须再次使用FreeLibrary()函数才可以使DLL被卸载掉,从而恢复Inline Hook。

对于EXE程序接收DLL消息的代码如下:

这部分代码就是对WM_COPYDATA消息的一个响应,整个代码基本是对界面进行了操作。在代码中有一个CTips类的对象,这个类是用来自定义窗口的。该窗口就是用来提示放行和拦截的窗口。带窗口的主要代码如下:

DLL程序中的SendMessage()函数的返回要等待WM_COPYDATA的消息结束,并从中获得返回值来决定下一步是否执行,因此这里只要简单地返回TRUE或FALSE即可。

对于行为监控,就介绍这么多。这个例子演示了如何通过Inline Hook来达到对进程创建、注册表操作的管控。当然,我们的代码并不能管控所有的进程,而且我们的行为监控过于简单,很容易被恶意程序突破。我们主要是通过实例来完成对行为监控原理的学习,希望可以起到抛砖引玉的作用。

6.4.2 专杀工具

现在免费的杀毒软件越来越多了,例如360安全卫士、金山毒霸、瑞星……有很多人不会去安装杀毒软件,如果爆发了传播速度较快,感染规模较大的病毒或蠕虫的话,杀毒厂商会为了阻止这种比较“暴力”的病毒或蠕虫的传播与感染,会推出免费的供网友使用的专杀工具。

专杀工具是针对某一个或某一类的病毒、木马或蠕虫等恶意软件开发的工具。专业的杀毒软件需要专业的反病毒公司来进行开发,而专杀工具可能是由反病毒公司开发,也可能是由个人来进行开发。

一、病毒分析方法简介

对于编写专杀工具,除非是感染型病毒,通常情况下并不需要对病毒做逆向分析,只需要对病毒进行行为分析就可以编写专杀工具。而如果病毒是感染型的,需要修复被病毒感染的文件,那么就不能只是简单地对病毒进行行为分析,必须对病毒进行逆向分析,从而修复被病毒所感染的文件。

(1)行为分析。病毒、木马等恶意程序都有一些比较隐蔽的“小动作”,而这些动作一般情况下是正常程序所没有的。比如,把自己添加进启动项,或把自己的某个DLL文件注入到其他进程中去,或把自己复制到系统目录下……这些行为都不是正常的行为。我们拿到一个病毒样本以后,通常是将病毒复制到虚拟机中,然后打开一系列的监控工具,比如注册表监控、文件监控、进程监控、网络监控等,将各种准备工作做好以后,在虚拟机中把病毒运行起来,看病毒对注册表进行了哪些操作,对文件进行了哪些操作,连接了哪个IP地址、创建了多少进程。通过观察这一系列操作,就可以写一个程序。只要把它创建的进程结束掉,把它写入注册表的内容删除掉,把它新建的文件删除掉,就等于把这个病毒杀掉了。当然整个过程并不会像说起来这么容易。

(2)逆向分析。当病毒感染了可执行文件以后,感染的是什么内容是无法通过行为监控工具发现的。而病毒对可执行文件的感染,有可能是添加一个新节来存放病毒代码,也可能是通过节与节之间的缝隙来存放病毒代码的。无论是哪种方式,都需要通过逆向的手段进行分析。通常的逆向工具有OD、IDA、WinDBG。

二、病毒查杀方法简介绍

病毒的查杀方法有很多种,在网络安全日益普及的今天,在杀毒软件公司大力宣传的今天,想必大部分关心网络安全的人对于病毒查杀的技术有了一些了解。当今常见的主流病毒查杀技术有特征码查杀、启发式查杀、虚拟机查杀和主动防御等。下面简单地说说特征码查杀和启发式查杀两种病毒查杀方法。

(1)特征码查杀。特征码查杀是杀毒软件厂商查杀病毒的较为原始的一种方法。该方法是通过从病毒体内提取病毒特征码,从而能够有效识别出病毒。这种方法只能查杀已知病毒,对于未知病毒则无能为力。

(2)启发式查杀。静态地通过一系列的“带权规则组合”对文件进行判定,如果值高于某个界限则被认定为病毒,否则不为病毒。启发式查杀可以相对有效地识别出病毒,但是往往也会出现误报的情况。

三、编写病毒专杀工具之简单病毒行为分析

我们来编写一个简单的病毒专杀工具,这个病毒专杀工具非常简单,用到的知识都是前面的知识。先准备一个简单的病毒样例,然后在虚拟机中进行一次行为分析,然后写出一个简单的病毒专杀工具。虽然前面的例子都有代码让大家练习,但是这次我们要对病毒进行行为分析,然后编写代码,完成我们的专杀工具。

我们将在虚拟机中进行病毒分析,因此安装虚拟机是一个必须的步骤。虚拟机也是一个软件,它用来模拟计算机的硬件,在虚拟机中可以安装操作系统,安装好操作系统后可以安装各种各样的应用软件,与操作真实的计算机是没有任何区别的。在虚拟机中的操作完全不影响我们真实的系统。除了对病毒进行分析需要安装虚拟机以外,在进行双机调试系统内核时安装虚拟机也是不错的选择。在虚拟机中安装其他种类的操作系统也非常方便,总之使用虚拟机的好处非常多。这里使用VMware这款虚拟机,请大家自行安装。

在安装好虚拟机以后,在虚拟机上放置几个行为分析的工具,包括FileMon、RegMon和Procexp三个工具。分别对几个工具进行设置,对FileMon和RegMon进行字体设置,并设置过滤选项,如图6-24和图6-25所示。

图6-24 对RegMon设置过滤

对于FileMon和RegMon的字体设置在菜单“选项”-> “字体”命令下,通常笔者选择“宋体”、“9号”,大家可以根据自己的喜好进行设置。在设置过滤条件时,“包含”后面输入的是需要监控的文件,这里的“a2.exe”是病毒的名字。也就是说,只监控与该病毒名相关的操作。

图6-25 对FileMon设置过滤

下面对Procexp进行设置,我们需要在进程创建或关闭时持续5秒钟高亮显示进程,以进行观察。设置方法为单击菜单“Options” -> “Difference Highlight Duration”命令,在弹出的对话框中设置“Difference Highlight Duration”为“5”,如图6-26所示。

将上面几个工具都设置完后,运行一下病毒,观察几个工具的反应,如图6-27、图6-28和图6-29所示。

图6-26 对Procexp的设置

图6-27 在Procexp中看到的mirwzntk.exe的病毒进程

图6-28 RegMon对注册表监控的信息

在图6-27中看到的病毒进程名为“mirwzntk.exe”,而不是“a2.exe”。这个进程是病毒“a2.exe”创建的,在病毒做完其相关工作后,将自己删除。图6-28和图6-29不一一进行说明了,下面作一下行为分析的总结。

图6-29 FileMon对文件监控的信息

病毒在注册中写入了一个值,值的内容为 “mirwznt.dll”,写入的位置如下: HKLM\SOFTWARE\MICROSOFT\WINDOWSNT\CURRENTVERSION\WINDOWS\APPINIT _DLLS。病毒在C:\WINDOWS\system32\下创建了两个文件,分别是“mirwznt.dll”和 “mirwzntk.exe,创建病毒进程mirwzntk.exe,并生成了一个.bat的批处理程序用于删除自身,也就是删除“a2.exe”。

下面来写一个专杀工具对该病毒进行查杀,如图6-30所示。

图6-30 mirwzntk病毒的专杀工具

四、编写病毒专杀工具之专杀工具编写

在查杀病毒的技术中有一种方法类似特征码查杀法,这种方法并不从病毒体内提取特征码,而是计算病毒的散列值。也就是对病毒的大小进行散列计算,然后在查杀的过程中计算每个文件的散列,然后进行比较。这种方法简单且易实现,常见的有MD5、CRC32等一些计算散列的算法。

下面选用CRC32算法计算函数的散列值,这里给出一个现成的CRC32函数,只需直接调用就可以了,代码如下:

该函数的参数有两个,一个是指向缓冲区的指针,第二个是缓冲区的长度。将文件全部读入缓冲区内,然后用CRC32函数计算文件的CRC32散列值。看一下查杀的源代码吧。

整个代码的内容都是前面介绍过的知识,这里只是一个综合的应用。对于有些情况,需要对磁盘的文件进行遍历,那么可以使用FindFirstFile0和FindNextFile0这两个函数,或者是用CFileFind类对磁盘文件进行遍历。其具体使用方法请参考MSDN,这里只给出一段简单的参考代码供大家学习使用。

6.4.3 U盘防御软件

在早期互联网还不发达的时候,病毒都是通过软盘、光盘等媒介进行传播的。到后来互联网被普及以后,通过互联网进行传播的病毒大面积地相继出现。虽然软盘已经被淘汰,但是并没有因为软盘的淘汰而使移动磁盘的病毒减少。相反,U盘的普及使得移动磁盘对病毒的传播更加得方便。U盘的数据传输速度和数据存储容量等多方面都比软盘要先进很多,因此,软盘可以传播病毒,U盘当然也可以传播病毒。

通过U盘来传播病毒通常是使用操作系统的自动运行功能,并配合U盘下的Autorun.inf文件来实现的。如果让操作系统不自动运行移动磁盘,或者保证移动磁盘下不存在Autorun.inf文件,那样通过U盘感染病毒的几率就小很多了。

一、通过系统配置禁止自动运行

先介绍一下关于如何通过系统配置来禁止U盘中Autorun.inf的自动运行。通常情况下需要进行两方面的设置,一方面是通过“管理工具”中的“服务”来进行设置,另一方面是通过“组策略”来进行设置。一般这两处都需要进行修改。下面分别介绍一下如何对这两处进行设置。

先来看一下如何在“服务”中进行设置。首先打开控制面板中的“管理工具”,然后找到 “服务”将其双击打开。在服务列表中找到名称为“Shell Hardware Detection”的服务,双击该服务,打开“Shell Hardware Detection的属性”对话框。单击“停止”按钮将该服务停止,再把“启动类型”修改为“已禁用”状态,如图6-31所示。

将服务中的“Shell Hardware Detection”禁用后,再来对“组策略”进行设置。首先在“运行”中输入“gpedit.msc”,然后依次单击左边的树形控件,“计算机配置”-> “管理模板”-> “系统”,然后在右边双击“关闭自动播放”选项,弹出“关闭自动播放属性”对话框,在“设置”选项卡中选择“已启用”单选项,在”关闭自动播放“处选择”所有驱动器“选项,设置完成后单击”确定“按钮。再到左边的树形控件中选择“用户配置”-> “管理模板”-> “系统”,到右边找到“自动关闭播放”选项,设置方法同上,如图6-32所示。

图6-31 禁用“Shell Hardware Detection”服务

图6-32 组策略中的“关闭自动播放”

通过以上设置,的确是可以相对有效地保护计算机不中U盘相关的病毒。不过,我们不能因此而满足,因为我们的目的是打造一个U盘防御的软件。

二、打造一个简易的U盘防御软件

我们将打造一个U盘防火墙,当插入U盘时会有提示,并且自动检查U盘下是否有 Autorun.inf文件,并解析Autorun.inf文件。除此而外,通过U盘防火墙可以打开U盘,从而可以安全地使用U盘。

如何才能知道有U盘被插入电脑呢?可以使用定时器不断地去检查,也可以开启一个线程不断地去检查,也可以通过Windows的消息得到通知。前两种方法笨了些,我们主动不断地去检查是否有U盘的插入,不如被动地等待Windows的消息来通知我们。

三、WM_DEVICECHANGE和OnDeviceChange()

在Windows下有一个消息可以通知应用程序计算机配置发生了变化,这个消息是 WM_DEVICECHANGE。该消息的消息过程定义如下:

该消息通过两个附加参数来进行使用,其中wParam表示设备改变的事件,1Param表示事件对应的数据。我们要得到设备被插入的消息类型,因此wParam的取值为 DBT_DEVICEARRIVAL,而该消息对应的数据的数据类型为DEV_BROADCAST_HDR,该结构体的定义如下:

在该结构体中,主要看的是dbch_devicetype,也就是设备的类型。如果设备类型为 DBT_DEVTYP_VOLUME,则把当前结构体转换为DEV_BROADCAST_VOLUME结构体,该结构体定义如下:

在该结构体中主要是dbcv_unitmask和dbcv_flags。dbcv_unitmask通过位来表示逻辑盘符,第0位表示A盘,第1位表示B盘。dbcv_flags表示受影响的盘符或媒介,该值为0时表示U盘或移动硬盘。

上面介绍了关于WM_DEVICECHANGE这个消息,由于是在MFC下进行开发的,因此可以使用OnDeviceChange()这个消息响应函数来进行代替WM_DEVICECHANGE消息。虽然使用了OnDeviceChange()这个消息响应函数而没有使用WM_DEVICECHANGE,但是响应函数的附加参数与WM_DEVICECHANGE相同。OnDeviceChange()函数定义如下:

四、通过OnDeviceChange()消息来获得被插入U盘的盘符

通过上面对WM_DEVICECHANGE消息的介绍,下面使用MFC下的OnDeviceChange()消息响应函数来编写一个获取被插入U盘的盘符的小程序,为编写U盘防火墙做一个简单的准备工作。首先来添加消息映射,添加代码如下:

在头文件中添加消息响应函数的定义,定义如下:

最后添加消息响应函数的实现,代码如下:

我们将其编译连接并运行,插入一个U盘,得到如图6-33所示的提示。

图6-33 检测到的U盘盘符

上面的这段代码可以将其封装为一个函数,封装后的函数定义如下:

在使用类似DBT_DEVICEARRIVAL这样以DBT_开头的宏时应包含头文件”“dbt.h”文件。

五、U盘防火墙的完善

前面已经获得了被插入U盘的盘符,接下来就可以对U盘上的Autorun.inf文件进行分析,并删除要运行的程序,还可以安全地打开U盘。改写一下OnDeviceChange()函数,以实现要完成的功能,代码如下:

安全打开U盘的实现代码如下:

用DeleteFile()函数删除U盘中要运行的程序可能会失败,因为有时U盘并没有完全准备好。可以通过判断来完成,但这里就不给出代码了,大家可自行修改完成。在代码中,涉及到两个新的API函数,分别是GetPrivateProfileString()和ShellExecute()函数。这两个函数的功能分别是获取配置文件中指定键的键值,和运行指定的文件或文件夹,大家可以通过查询MSDN进行详细地了解。

在上面的程序中,通过提示让用户选择是否要删除掉U盘中要被执行的文件,如果用户对此并没有太多的了解和认识的话很有可能不删除。如果删了本不该删的文件,那么用户对该软件的友好感便会降低,那应该如何做呢?应该建立一个白名单和黑名单,无论是通过散列进行比较,还是通过文件名进行比较,都可以。当然,越精确的匹配方法越好。这样,就可以提早一步为用户进行判断了,然后给出一个安全建议,不但提高了软件的友好感,而且会显得相对较为专业了。

6.4.4 目录监控工具

我们介绍了通过HOOK技术对进程创建的监控,然后介绍了通过CRC32对病毒进行查杀,还介绍了通过使用WM_DEVICECHANGE消息对U盘的防护。接下来,简单讨论一下如何通过ReadDirectoryChangesW()来编写一个监视目录变化的程序。

对目录及目录中的文件实时监控,可以有效地发现文件被改动的情况。就好像,我们在本地安装了IIS服务器,并搭建了一个网站平台,有时候会遭到黑客的篡改,而我们无法及时地恢复被篡改的页面,导致出现了非常不好的影响。如果能及时地发现网页被篡改,并及时地恢复本来的页面就好了,那么该如何做呢?

下面通过一个简单的例子来介绍如何监控某目录及目录下文件的变动情况。首先需要了解的函数为ReadDirectoryChangesW(),该函数的定义如下:

参数说明如下。

(1) hDirectory:该参数指向一个要监视目录的句柄。该目录需要用 FILE_LIST_DIRECTORY的访问权限打开。

(2) lpBuffer:该参数指向一个内存的缓冲区,它用来存放返回的结果。结果为一个 FILE_NOTIFY_INFORMATION的数据结构。

(3) nBufferLength:表示缓冲区的大小。

(4) bWatchSubtree:该参数为TRUE时,表示监视指定目录下的文件及子目录下的文件操作。如果该参数为FALSE,则只监视指定目录下的文件,不包含子目录下的文件。

(5) dwNotifyfilter:该参数指定要返回何种文件变更后的类型,该参数的常量值参见 MSDN。

(6) lpBytesReturned:该参数返回传给lpBuffer结果的字节数。

(7) lpOverlapped:该参数执行一个OVERLAPPED结构体,该结构体用于异步操作,否则该数据为NULL。

目录监控完整源代码

该函数的使用非常简单,下面通过一个例子来学习该函数的使用。该例子是对E盘目录进行监控,我们将程序编写完成后对E盘进行简单的文件操作,以观察程序的输出结构。完整的代码如下:

将程序编译连接并运行,在E盘下进行简单的操作,查看程序对E盘的监视输出记录,如图6-34所示。

图6-34 目录监控输出记录

对于目录监视的这个例子,可以将其改为一个简单的文件防篡改程序。首先将要监视的文件目录进行一次备份,然后对文件目录进行监视,如果有文件发生了修改,那么就使用备份目录下的指定文件恢复被修改的文件。

6.5 引导区解析

很多病毒会感染磁盘的引导区,这导致了病毒在系统启动前就会执行病毒。关于引导型的病毒这里不多作介绍,只简单介绍一下引导区的知识,并写一个程序来简单地解析一下引导区。在整个过程中,编写程序不是难点,难点在于引导区的各个数据结构和各结构之间的数据关系。

为了能写出程序,先进行一次对引导区的手动分析,使用的工具是WinHex。

6.5.1 通过WinHex来手动解析引导区

WinHex是一个强大的十六进制编辑工具,也是一个强大的磁盘编辑工具。打开WinHex,并打开磁盘,如图6-35、图6-36和图6-37所示。

图6-35 WinHex工具栏图6-36选择物理磁盘

图6-36 选择物理磁盘

图6-37 打开后的位置

当打开磁盘后,会看到很多密密麻麻的十六进制数据,这些数据很像学习PE文件结构时的情况。一眼看上去不能理解,当根据其各种不同的结构进行解析后就一目了然了。因此,重要的是学习其各种结构。这里不对硬盘中涉及的全部各个结构都进行介绍(指的是文件格式),主要介绍一下组成引导区的各个结构体。

引导区,也叫主引导记录(Master Boot Record,简称MBR)。MBR位于整个硬盘的0柱面0磁头1扇区的位置处。MBR在计算机引导过程中起着重要的作用。MBR可以分为五部分,分别是引导程序、Windows磁盘签名、保留位、分区表和结束标志。这五部分构成了一个完整的引导区,引导区的大小为512个字节。我们通过WinHex来具体查看一下每一部分的内容,来了解一下这512个字节的作用。

首先来看一下引导记录,如图6-38所示。

图6-38 MBR的引导程序

在图6-38中被选中的地方就是MBR的引导程序。引导程序会判断MBR的有效性,判断磁盘分区的合法性,及把控制权交给操作系统。引导程序占用了MBR的前440字节。

再来看一下Windows的磁盘签名,如图6-39所示。

图6-39 MBR中的Windows磁盘签名

在图6-39中被选中的位置就是Windows的磁盘签名,它的位置在紧接引导程序的第4个字节。Windows磁盘签名对于MBR来说不是必须的,但是对于Windows系统来说是必须的,它是Windows系统在初始化时写入的,Windows依靠磁盘签名来识别硬盘,如果该签名丢失则Windows认为该磁盘没有被初始化。在图6-39中,Windows的磁盘签名为 “0×BF5FBF5F”。

紧接在磁盘签名后的两个字节是保留字节,也就是暂时没有被MBR使用的位置。

在保留的两个字节后的64个字节,则保存了分区表,如图6-40所示。

图6-40 MBR中的分区表

分区表在MBR中占用了64个字节的位置,分区表被称为DPT (Disk Partition Table),它在MBR中是一个非常关键的数据结构。分区表是用来管理硬盘分区的,如果丢失或者破坏的话,硬盘的分区就会丢失。分区表占用了64个字节,用每16个字节来描述一个分区项的数据结构。由于其字节数的限制,一个硬盘最多可以有4个主硬盘分区(注意,是主硬盘分区)。图6-40中框住的部分就是一个分区表项,可以看出,MBR中只有两个分区表项。硬盘中的磁盘可以分为主磁盘分区和扩展分区,使用过DOS命令中的FDISDK的话应该很清楚。通常情况下,主分区是C盘。在系统中除了C盘以外,还可能存在D盘、E盘、F盘等分区,这3个分区都是从扩展分区中分配出来的,而这些分区并不在MBR中保存。右键单击“我的电脑”,在菜单中选择“管理”命令将出现“计算机管理”这个程序,选择“磁盘管理”,显示如图6-41所示的窗口。

图6-41 磁盘管理

从图6-41中可以看出,在磁盘上,D盘、E盘、F盘、G盘的周围有一个绿色的框,这个框就表示为扩展分区。

下面具体地介绍一下。单击WinHex的菜单项“视图”-> “模板管理”命令,出现如图6-42所示的对话框。

图6-42 模板管理器

在“模板管理器”对话框中双击“Master Boot Record”,也就是主引导记录,出现如图 6-43所示的MBR的偏移解析器。

图6-43 MBR偏移解析器

在图6-43中可以清晰地看到两个分区表项的内容,分别是Partition Table Entry #1和 Partition Table Entry #2。对我们有用的字段已经用框选中。下面介绍Partition Table Entry #1中对于我们有用的几个字段。第一个是在MBR中偏移为0×lBE的位置,这个位置的值为 0×80。该值是一个引导标志,表示该分区是一个活动分区。在MBR中偏移0×lC2的位置处保存的值为0×07,这个值表示的是分区的类型,0×07表示该值为NTFS的系统文件格式。在偏移0×lC6的位置处保存的值为63,该值表示在本分区前使用了多少个扇区,这里表示当前分区前使用了63个扇区。最后一个0×lCA处保存的值为16386237,该位置表示本分区的总扇区数。一个扇区有512个字节数,那个16386237个扇区是多大呢?我们计算一下,首先用 16386237×512求出本扇区占多少字节。通过计算获得本分区所占字节数为8389753344字节,那么字节如何转换成GB呢?这是一个简单的公式,1024字节等于1KB,1024KB等于 1MB,1024MB等于1GB,那么做一个简单的除法就可以了。用8389753344/1024/1024/1024就得出了当前分区是多少个GB了,得出的结果如图6-44所示。

图6-44 第一个分区的大小

从图6-44可以看出,当前的分区占用了7.81个GB的大小(C盘大小为7.81GB)。关于 Partition Table Entry #2大家可以自己进行分析,这里就不介绍了。

在MBR中还有最后一个内容,如图6-40所示。在图6-40中紧接着DPT后面的两个字节就是MBR中最后的两个字节。这两个字节是MBR的结束标志,用“55AA”表示。引导程序会判断MBR扇区的最后两个字节是否为”55AA”,如果不是则报错。

到此,关于MBR的部分就介绍完了,相信大家已对MBR有了比较全面的了解。下面来写一个简单的程序将上面的通过WinHex分析的内容解析出来。

6.5.2 通过程序解析MBR

对于我们来说,解析MBR可能不会有太大的问题,因为前面解析过PE文件结构。虽然解析过PE文件,但是还是有一些微小的几点差别,首先造成解析困难的一点是MBR没有给出具体的结构体。当初分析PE文件结构时,各个结构体在WinNt.h头文件中都有给出定义,而MBR的定义是没有给出的。因此上面也没有对照着结构体给大家介绍。再一个问题是,解析PE文件结构时,我们会打开具体的可执行文件去按照PE文件结构的定义进行解析,而硬盘的引导区属于哪个文件?用WinHex打开的是物理硬盘,那么我们如何来打开物理硬盘。这可能是两个比较困惑的地方。不过,这些都不是太大的问题。只要我们解决了这两个问题,我们的解析其实就容易多了。

6.5.3 自定义MBR的各种结构体

下面介绍如何将MBR的信息定义成一个个的结构体。通过前面用WinHex对MBR的手动分析,我们了解到MBR分为五部分,并且知道每部分占用的字节数。因此,可以将MBR定义为如下:

这就是定义的MBR了,引导程序共440个字节,Windows签名共4个字节,保留字节共2个字节,分区表共64个字节,再加上2个结束标志,一共512个字节。不过这样的定义并不好,因为里面的常量比较多,下面修改一下,定义如下:

这样定义后,可以很方便地获得引导程序的大小和分区表的大小。虽然这样定义直观一些,但是还不能算太直观,因为定义的都是unsigned char类型,无法真正反映出每个成员变量的具体含义。下面再次进行修改,定义如下:

这次修改后,可以很容易地从MBR这个结构体中看出主要两个成员变量的含义了。虽然直观了,但还是有问题。Dpt其实是一个有4条记录的表,也就是说它其实是一个数组,这样的定义当解析它的时候并不方便。这样的定义方便我们一次性将DPT读出,只要再定义一个DP的结构体来对DPT进行转换,就可以方便地对DPT进行解析了。下面再次定义一个结构体,定义如下:

有了这个结构体,就可以方便地对DPT进行解析了。最后两个定义就是对MBR各结构体的完整定义(这几个结构体是笔者自己定义的,可以会有很多考虑不周的地方,网上有公开的结构体的定义,大家可以自行参考。之所以如此反复介绍如何进行MBR结构体的定义,是想告诉大家一个在没有相关数据结构定义的情况下如何通过自己的分析来定义数据结构的思路和方法)。

大家思考一下,如果不定义这些结构体是不是就无法对MBR进行解析,定义了这些结构体后对于解析MBR有哪些影响。对于MBR的解析,可以完全不定义这些结构体,定义这些结构体的目的是方便对程序的后期维护,并使程序在整体上有一个良好的格式。定义数据结构可以清晰地表达各个数据结构之间的关系,让我们在写程序的过程中有一个清晰的思路,让看程序的人也可以一目了然。

硬盘设备的符号链接

有了上面的结构体,解析MBR已经不是太大的问题了。不过还有一个问题,那就是如何打开硬盘读取MBR。其实很简单,只要打开硬盘设备提供的设备符号链接就可以了。如何找到硬盘的设备符号链接呢?有一款工具WinObj可以帮助查找到。打开WinObj,再依次打开左边的树形控件,如图6-45所示。

图6-45 WinObj找到的硬盘设备

通过图6-45可以找到硬盘设备的设备名,例如可以通过\Device\Harddisk0\DR0这个设备名再去查找相应的设备符号链接。我们再依次打开WinObj左边的树形控件,如图6-46所示。

图6-46 WinObj找到的硬盘设备符号链接

从图6-46中可以看到硬盘的设备符号链接为PhysicalDrive0,在使用时应该书写为\\.\PhysicalDrive0。

下面简单地介绍一下设备名和设备符号链接。每个设备在Windows的内核中都有对应的驱动模块,在驱动模块中会为设备提供一个名字来对设备进行操作,驱动模块中提供的名字即为“设备名”。设备名只能在内核模块中使用。如果想要在应用程序下对设备进行操作,不能直接使用设备名,应该使用设备符号链接。设备符号链接就是驱动模块为应用程序提供的操作设备的一个符号,通过这个符号可与设备进行对应。

6.5.4 解析MBR的程序实现

到了这里大家可能会觉得通过程序解析MBR已经不是问题了,下面直接提供程序的代码吧。如果代码中有不理解的地方,可以参考一下我们是如何通过WinHex对MBR的解析了。代码如下:

代码非常短,也不复杂,看起来跟读写文件没什么太大的差别,其实就是在读写文件。前面介绍过,Windows将各种设备都当作文件来看待,因此打开硬盘设备的时候直接使用 CreateFile()函数就可以了。关于MBR的部分就介绍到这里了。

6.6 加壳与脱壳

6.6.1 手动加壳

壳是一种较为特殊的软件。壳分为两类,一类是压缩壳,另一类是加密壳。当然,还有介于两者之间的混合壳。下面先来手动为一个可执行文件加一层外壳,需要准备的工具有 C32ASM、LordPE、添加节表工具和OD。

首先用LordPE查看可执行文件,并对需要的几个数据做一个简单的记录,如图6-47所示。

图6-47 PE格式中需要用到的数据

在LordPE中,需要查看几个对于我们需要的数据,包括PE文件的入口RVA、映像地址和代码节的相关数据。有了这些数据以后就可以通过C32ASM对代码进行加密了。用 C32ASM以十六进制的方式打开可执行文件,然后从代码节的文件偏移开始选择,也就是从1000h的位置开始选择,一直选到4fffh的位置。然后单击右键,在弹出的快捷菜单上选择“修改数据”命令,在“修改数据”对话框中选择“异或”算法来对代码节进行加密,如图6-48所示。

使用0×88来对代码节进行异或加密,单击“确定”按钮后代码节被修改。保存以后使用在前面章节编写的添加节表的软件对可执行文件添加一个新的节,如图6-49所示。

图6-48 用“异或”算法对代码节进行加密

图6-49添加新节区

添加新的节区以后使用OD对可执行文件添加一些代码,用OD打开可执行程序,来到00408000地址处,添加如下代码:

以上代码的作用是将上面修改的代码节内容还原,然后进行保存。用LordPE修改该可执行文件的入口和代码节的属性,如图6-50、图6-51和图6-52所示。

图6-50修改入口点

图6-51添加代码节属性

图6-52修改新节属性

运行修改过的可执行文件,可以正常运行。

下面整理一下思路,以方便我们写代码。最开始用LordPE查看了将要用到的一些PE信息,然后用C32ASM对代码节进行了简单的异或加密,接下来新添加了一个节并在新节中写入了还原代码节的解密指令,最后用LordPE修改了文件的入口地址、代码节属性和新添加节的属性。对照一下前后两个文件的不同之处,如图6-53所示。

图6-53加壳前后PE文件的对比

从图中可以看出这两个PE文件的差别,相信大家对此已经没有不理解的地方了。下面开始手动打造一个这样简单的加壳软件。

6.6.2 编写简单的加壳工具

其实不按照上面的步骤进行也可以,只要步骤是合理的就可以了。我们主要有4个函数需要实现,分别是获取PE信息GetPelnfo()、添加新节AddSection()、加密代码节Encode()和写入解密代码WriteDecode()。获取PE信息和添加新节的代码实现前面已经介绍过了,这里就不重复介绍了。我们主要看两个函数的代码,分别是Encode()和WriteDecode()。

Encode()函数的作用是对代码节的内容进行加密,这里选择的加密算法是异或算法。在进行加密以前需要获得代码节在文件中的位置,以及代码节的长度。有了这两个信息就可以进行加密了,代码如下:

WriteDecode()函数的作用是将对代码节的解密代码写入到新添加的节中,这样在运行真正代码之前会先对加密的代码进行还原。同样,解密的代码也使用异或算法进行。解密的代码不能直接写入C代码,而是要写入机器码,机器码可以到OD中取得,代码如下:

在虚拟地址和汇编指令的中间部分就是汇编指令对应的机器码,将其取出并定义为C语言的数组,定义如下:

这个机器码在解密的过程中要根据实际的PE信息进行修改,修改的位置有3处,分别是代码节的起始虚拟地址、代码节的结束虚拟地址和程序的原始入口点,代码如下:

这就是解密代码,大家可以找个用VC写的程序来进行测试。在这里使用Release版的helloworld测试通过。

这个壳属于袖珍版的壳,严格来说,算不上是一个壳,但是这个壳在免杀领域是有用的,也就是把定位到的特征码进行加密,然后再写入解密代码从而隐藏特征码。一个真正的壳会对导入表、导出表、资源、TLS、附加数据等相关的部分进行处理。加密壳会加入很多反调试的功能,而且还会让壳和可执行文件融合在一起,达到“骨肉相连”的程度来增加脱壳的难度。

第7章 最后的旅程——简单

驱动开发及逆向

本章将一起来学习和了解与驱动相关的编程,还有当下倍受欢迎的逆向知识。这两方面的知识无论是从反面的免杀、外挂、破解角度来说,还是从正面的反病毒、反外挂、反调试来说都非常有用。对于学习黑客编程的新手来说,真正要了解这些也许要花一点时间,不过还是要讨论一下,驱动毕竟是比较神秘的知识,而逆向确实是对于开发环境和分析环节的一个精髓。

7.1 驱动版的 “Hello World”

驱动都是要加载入内核的,我们要做的很多事情也需要在内核下完成,要想在内核中实现功能就需要编写驱动模块。提到驱动可能会想到硬件,大家可能会简单地认为驱动程序是控制硬件设备的。在Windows下驱动并不单单是用来控制硬件设备的。Windows操作系统中的驱动程序可以创建虚拟设备,也可以与设备无关。Windows操作系统是一个开放式的操作系统,这个开放式并不是指其开放源代码,而是指通过其提供的接口可以很容易和方便地对其内核进行扩展。

要开发Windows下的驱动程序时,需要下载安装Windows下的驱动开发包,即WDK(Windows Driver Kit),该开发包免费提供下载,里面附带了开发驱动的头文件、帮助文档、工具及大量的文档等内容。笔者当前使用的版本是WDK 7600.16385.0。下面来编写一个简单的、与设备无关的驱动程序,名为HelloWorld。编写驱动不再依赖VC6的开发环境,而是在记事本或任意一款文本编辑器中编写代码,新建一个文件为helloworld.c的C源码文件。代码如下:

在开发驱动时,不再使用main()函数,而是使用DriverEntry(),该函数是驱动程序的入口函数,其定义在WDK自带的帮助文档中查找:

在WDK的帮助文档中查找DriverEntry()时会找到非常多的关于它的定义,这里查看的是DriverEntry[WDK kernel]的定义。该函数有如下两个参数。

(1) DriverObject:该参数是一个指向DRIVER_OBJECT结构体(驱动对象)的指针.

(2) RegistryPath:该参数是一个UNICODE字符串,指向此驱动负责的注册表。在程序中使用到了第一个参数,DRIVER_OBJECT结构体的定义如下:



该定义在WDK目录下的\inc\ddk\中的wdm.h头文件中,虽然在WDK的帮助文档中有该结构体的介绍,但是笔者并没有找到关于该结构体的具体定义。在该程序中用到了该结构体中的几个成员变量,分别是DriverUnload和DriverName。DriverUnload是一个用来卸载驱动的函数,卸载驱动的工作是由Windows来完成的,因此该函数是一个回调函数,这个函数用来完成对驱动资源的释放工作。该函数的定义格式如下:

DriverName是一个UNICODE_STRING结构体的变量,该变量指向了驱动的名称, UNICODE_STRING结构体的定义如下:

该结构体中的Buffer里保存了驱动的名称,其他两个变量里保存了驱动名称字符串的长度和最大长度。我们的程序到这里基本上介绍的差不多了,还剩下一个KdPrint()函数没有介绍,这个函数的用法类似printf()函数的用法,这里就不做过多的介绍了。下面来准备编译连接写好的第一个驱动程序吧。

要编译驱动程序需要使用WDK提供的编译工具,在“开始”菜单的程序下可以找到安装 WDK的菜单,如:“Windows Driver Kits->WDK 7600.16385.0->Build Environments-> Windows XP->×86 Checked Build Environment”,除了 Windows XP的以外还有 Windows 2003、 Windows 7等相关的编译命令行。在驱动编译的工程中也同样提供两个版本,分别是Checked版和Free版,这两个版本对应的就是Debug版和Release版,只是叫法不同而已(注:在不同平台下应使用不同的编译命令行)。

在命令行下编译需要编译脚本,编译脚本有两个,分别是“makefile”和“sources”。这两个脚本不用自己写,只要找一个改改就行。我们到WDK提供的例子程序中找个编译脚本,例如笔者在“\7600.16385.0\src\filesys\miniFilter\cancelSafe\”目录下找了这两个文件,并复制到我们写的驱动的目录下。下面来修改一下编译脚本,只需要修改“sources”文件即可,另一个文件保持原样即可。Sources文件修改如下:

简单解释一下,第一行是编译连接后的驱动名称,第二行是编译后的类型,第三行是需要编译连接的源代码文件。修改好后保存,就可以进行编译了。输入编译的命令build/g,如图7-1所示。

图7-1 驱动的编译连接过程

在编译成功后会在最下面一行看到一个“ 1 executable built”的输出,到我们的目录下去找一下编译好的驱动程序,其所在目录为“ HelloWorldDriver\objchk_wxp_x86\i386”下,那个扩展名为.sys的helloworld就是编译好的驱动文件了。

.sys的文件是无法直接双击执行的,需要通过工具进行加载,这里使用的工具是一款名为 “KmdManager”的EXE文件。除了要加载以外,还要查看驱动的输出,由于驱动没有界面,因此无法直接查看其输出,需要使用Dbgview工具来查看,下面具体操作一遍。打开KmdManager和Dbgview两个工具,将驱动程序拖放至KmdManager中,然后单击“Register”加载驱动,单击“Run”运行程序,然后查看Dbgview中有一串字符串,那正是在 DriverEntry()中输出的字符串。单击“Stop”按钮停止驱动,并单击“Unregistry”卸载驱动,再次观察Dbgview会看到在DriverUnload()中输出的字符串,如图7-2和图7-3所示。

图7-2 KmdManager的操作

我们逐步完成了 Helloworld驱动的编写、编译连接及运行显示输出的过程。这一节的内容也主要是让大家学会如何编译连接、运行和查看驱动输出。