还记得上一篇《OllyDBG 入门系列(三)-函数参考》中的内容吗?在那篇文章中我们分析后发现一个 ESI 寄存器值不知是从什么地方产生的,要弄清这个问题必须要找到生成这个 ESI 值的计算部分。今天我们的任务就是使用 OllyDBG 的内存断点功能找到这个地方,搞清楚这个值是如何算出来的。这次分析的目标程序还是上一篇的那个 crackme,附件我就不再上传了,用上篇中的附件就可以了。下面我们开始:
还记得我们上篇中所说的关键代码的地方吗?温习一下:
00401323 |. E8 4C010000 CALL; GetWindowTextA 00401328 |. E8 A5000000 CALL CrackHea.004013D2 ; 关键,要按F7键跟进去 0040132D |. 3BC6 CMP EAX,ESI ; 比较 0040132F |. 75 42 JNZ SHORT CrackHea.00401373 ; 不等则完蛋
我们重新用 OllyDBG 载入目标程序,F9运行来到上面代码所在的地方(你上次设的断点应该没删吧?),我们向上看看能不能找到那个 ESI 寄存器中最近是在哪里赋的值。哈哈,原来就在附近啊:
我们现在知道 ESI 寄存器的值是从内存地址 40339C 中送过来的,那内存地址 40339C 中的数据是什么时候产生的呢?大家注意,我这里信息窗口中显示的是 DS:[0040339C]=9FCF87AA,你那可能是 DS:[0040339C]=XXXXXXXX,这里的 XXXXXXXX 表示的是其它的值,就是说与我这里显示的 9FCF87AA 不一样。我们按上图的操作在数据窗口中看一下:
从上图我们可以看出内存地址 40339C 处的值已经有了,说明早就算过了。现在怎么办呢?我们考虑一下,看情况程序是把这个值算出来以后写在这个内存地址,那我们要是能让 OllyDBG 在程序开始往这个内存地址写东西的时候中断下来,不就有可能知道目标程序是怎么算出这个值的吗?说干就干,我们在 OllyDBG 的菜单上点 调试->重新开始,或者按 CTR+F2 组合键(还可以点击工具栏上的那个有两个实心左箭头的图标)来重新载入程序。这时会跳出一个“进程仍处于激活状态”的对话框(我们可以在在调试选项的安全标签下把“终止活动进程时警告”这条前面的勾去掉,这样下次就不会出现这个对话框了),问我们是否要终止进程。这里我们选“是”,程序被重新载入,我们停在下面这一句上:
00401000 >/$ 6A 00 PUSH 0 ; pModule = NULL
现在我们就要来设内存断点了。在 OllyDBG 中一般我们用到的内存断点有内存访问和内存写入断点。内存访问断点就是指程序访问内存中我们指定的内存地址时中断,内存写入断点就是指程序往我们指定的内存地址中写东西时中断。更多关于断点的知识大家可以参考 论坛精华7->基础知识->断点技巧->断点原理 这篇 Lenus 兄弟写的《如何对抗硬件断点之一 --- 调试寄存器》文章,也可以看这个帖:http://bbs.pediy.com/showthread.php?threadid=10829。根据当前我们调试的具体程序的情况,我们选用内存写入断点。还记得前面我叫大家记住的那个 40339C 内存地址吗?现在我们要用上了。我们先在 OllyDBG 的数据窗口中左键点击一下,再右击,会弹出一个如下图所示的菜单。我们选择其中的转到->表达式(也可以左键点击数据窗口后按 CTR+G 组合键)。如下图:
现在将会出现这样一个对话框:
我们在上面那个编辑框中输入我们想查看内容的内存地址 40339C,然后点确定按钮,数据窗口中显示如下:
我们可以看到,40339C 地址开始处的这段内存里面还没有内容。我们现在在 40339C 地址处后面的 HEX 数据或 ASCII 栏中按住左键往后拖放,选择一段。内存断点的特性就是不管你选几个字节,OllyDBG 都会分配 4096 字节的内存区。这里我就选从 40339C 地址处开始的四个字节,主要是为了让大家提前了解一下硬件断点的设法,因为硬件断点最多只能选 4 个字节。选中部分会显示为灰色。选好以后松开鼠标左键,在我们选中的灰色部分上右击:
经过上面的操作,我们的内存断点就设好了(这里还有个要注意的地方:内存断点只在当前调试的进程中有效,就是说你如果重新载入程序的话内存断点就自动删除了。且内存断点每一时刻只能有一个。就是说你不能像按 F2 键那样同时设置多个断点)。现在按 F9 键让程序运行,呵,OllyDBG 中断了!
7C932F39 8808 MOV BYTE PTR DS:[EAX],CL ; 这就是我们第一次断下来的地方 7C932F3B 40 INC EAX 7C932F3C 4F DEC EDI 7C932F3D 4E DEC ESI 7C932F3E ^ 75 CB JNZ SHORT ntdll.7C932F0B 7C932F40 8B4D 10 MOV ECX,DWORD PTR SS:[EBP+10]
上面就是我们中断后反汇编窗口中的代码。如果你是其它系统,如 Win98 的话,可能会有所不同。没关系,这里不是关键。我们看一下领空,原来是在 ntdll.dll 内。系统领空,我们现在要考虑返回到程序领空。返回前我们看一下数据窗口:
现在我们转到反汇编窗口,右击鼠标,在弹出菜单上选择断点->删除内存断点,这样内存断点就被删除了。
现在我们来按一下 ALT+F9 组合键,我们来到下面的代码:
00401431 |. 8D35 9C334000 LEA ESI,DWORD PTR DS:[40339C] ; ALT+F9返回后来到的位置 00401437 |. 0FB60D EC334000 MOVZX ECX,BYTE PTR DS:[4033EC] 0040143E |. 33FF XOR EDI,EDI
我们把反汇编窗口往上翻翻,呵,原来就在我们上一篇分析的代码下面啊?
现在我们在 0040140C 地址处那条指令上按 F2 设置一个断点,现在我们按 CTR+F2 组合键重新载入程序,载入后按 F9 键运行,我们将会中断在我们刚才在 0040140C 地址下的那个断点处:
0040140C /$ 60 PUSHAD 0040140D |. 6A 00 PUSH 0 ; /RootPathName = NULL 0040140F |. E8 B4000000 CALL; \GetDriveTypeA 00401414 |. A2 EC334000 MOV BYTE PTR DS:[4033EC],AL ; 磁盘类型参数送内存地址4033EC 00401419 |. 6A 00 PUSH 0 ; /pFileSystemNameSize = NULL 0040141B |. 6A 00 PUSH 0 ; |pFileSystemNameBuffer = NULL 0040141D |. 6A 00 PUSH 0 ; |pFileSystemFlags = NULL 0040141F |. 6A 00 PUSH 0 ; |pMaxFilenameLength = NULL 00401421 |. 6A 00 PUSH 0 ; |pVolumeSerialNumber = NULL 00401423 |. 6A 0B PUSH 0B ; |MaxVolumeNameSize = B (11.) 00401425 |. 68 9C334000 PUSH CrackHea.0040339C ; |VolumeNameBuffer = CrackHea.0040339C 0040142A |. 6A 00 PUSH 0 ; |RootPathName = NULL 0040142C |. E8 A3000000 CALL ; \GetVolumeInformationA 00401431 |. 8D35 9C334000 LEA ESI,DWORD PTR DS:[40339C] ; 把crackme程序所在分区的卷标名称送到ESI 00401437 |. 0FB60D EC334000 MOVZX ECX,BYTE PTR DS:[4033EC] ; 磁盘类型参数送ECX 0040143E |. 33FF XOR EDI,EDI ; 把EDI清零 00401440 |> 8BC1 MOV EAX,ECX ; 磁盘类型参数送EAX 00401442 |. 8B1E MOV EBX,DWORD PTR DS:[ESI] ; 把卷标名作为数值送到 EBX 00401444 |. F7E3 MUL EBX ; 循环递减取磁盘类型参数值与卷标名值相乘 00401446 |. 03F8 ADD EDI,EAX ; 每次计算结果再加上上次计算结果保存在EDI中 00401448 |. 49 DEC ECX ; 把磁盘类型参数作为循环次数,依次递减 00401449 |. 83F9 00 CMP ECX,0 ; 判断是否计算完 0040144C |.^ 75 F2 JNZ SHORT CrackHea.00401440 ; 没完继续 0040144E |. 893D 9C334000 MOV DWORD PTR DS:[40339C],EDI ; 把计算后值送到内存地址40339C,这就是我们后来在ESI中看到的值 00401454 |. 61 POPAD 00401455 \. C3 RETN
通过上面的分析,我们知道基本算法是这样的:先用 GetDriveTypeA 函数获取磁盘类型参数,再用 GetVolumeInformationA 函数获取这个 crackme 程序所在分区的卷标。如我把这个 Crackme 程序放在 F:\OD教程\crackhead\ 目录下,而我 F 盘设置的卷标是 GAME,则这里获取的就是 GAME,ASCII 码为“47414D45”。但我们发现一个问题:假如原来我们在数据窗口中看到的地址 40339C 处的 16 进制代码是“47414D45”,即“GAME”,但经过地址 00401442 处的那条 MOV EBX,DWORD PTR DS:[ESI] 指令后,我们却发现 EBX 中的值是“454D4147”,正好把我们上面那个“47414D45”反过来了。为什么会这样呢?如果大家对 x86系列 CPU 的存储方式了解的话,这里就容易理解了。我们知道“GAME”有四个字节,即 ASCII 码为“47414D45”。我们看一下数据窗口中的情况:
0040339C 47 41 4D 45 00 00 00 00 00 00 00 00 00 00 00 00 GAME............
大家可以看出来内存地址 40339CH 到 40339FH 分别按顺序存放的是 47 41 4D 45。
如下图:
系统存储的原则为“高高低低”,即低字节存放在地址较低的字节单元中,高字节存放在地址较高的字节单元中。比如一个字由两个字节组成,像这样:12 34 ,这里的高字节就是 12 ,低字节就是 34。上面的那条指令 MOV EBX,DWORD PTR DS:[ESI] 等同于 MOV EBX,DWORD PTR DS:[40339C]。注意这里是 DWORD,即“双字”,由 4 个连续的字节构成。而取地址为 40339C 的双字单元中的内容时,我们应该得到的是“454D4147”,即由高字节到低字节顺序的值。因此经过 MOV EBX,DWORD PTR DS:[ESI] 这条指令,就是把从地址 40339C 开始处的值送到 EBX,所以我们得到了“454D4147”。好了,这里弄清楚了,我们再接着谈这个程序的算法。前面我们已经说了取磁盘类型参数做循环次数,再取卷标值 ASCII 码的逆序作为数值,有了这两个值就开始计算了。现在我们把磁盘类型值作为 n,卷标值 ASCII 码的逆序数值作为 a,最后得出的结果作为 b,有这样的计算过程:
第一次:b = a * n
第二次:b = a * (n - 1) + b
第三次:b = a * (n - 2) + b
…
第 n 次:b = a * 1 + b
可得出公式为 b = a * [n + (n - 1) + (n - 2) + … + 1] = a * [n * (n + 1) / 2]
还记得上一篇我们的分析吗?看这一句:
00401405 |. 81F6 53757A79 XOR ESI,797A7553 ; 把ESI中的值与797A7553H异或
这里算出来的 b 最后还要和 797A7553H 异或一下才是真正的注册码。只要你对编程有所了解,这个注册机就很好写了。如果用汇编来写这个注册机的话就更简单了,很多内容可以直接照抄。
到此已经差不多了,最后还有几个东西也说一下吧:
1、上面用到了两个 API 函数,一个是 GetDriveTypeA,还有一个是 GetVolumeInformationA,关于这两个函数的具体用法我就不多说了,大家可以查一下 MSDN。这里只要大家注意函数参数传递的次序,即调用约定。先看一下这里:
00401419 |. 6A 00 PUSH 0 ; /pFileSystemNameSize = NULL 0040141B |. 6A 00 PUSH 0 ; |pFileSystemNameBuffer = NULL 0040141D |. 6A 00 PUSH 0 ; |pFileSystemFlags = NULL 0040141F |. 6A 00 PUSH 0 ; |pMaxFilenameLength = NULL 00401421 |. 6A 00 PUSH 0 ; |pVolumeSerialNumber = NULL 00401423 |. 6A 0B PUSH 0B ; |MaxVolumeNameSize = B (11.) 00401425 |. 68 9C334000 PUSH CrackHea.0040339C ; |VolumeNameBuffer = CrackHea.0040339C 0040142A |. 6A 00 PUSH 0 ; |RootPathName = NULL 0040142C |. E8 A3000000 CALL; \GetVolumeInformationA
把上面代码后的 OllyDBG 自动添加的注释与 MSDN 中的函数原型比较一下:
BOOL GetVolumeInformation( LPCTSTR lpRootPathName, // address of root directory of the file system LPTSTR lpVolumeNameBuffer, // address of name of the volume DWORD nVolumeNameSize, // length of lpVolumeNameBuffer LPDWORD lpVolumeSerialNumber, // address of volume serial number LPDWORD lpMaximumComponentLength, // address of system's maximum filename length LPDWORD lpFileSystemFlags, // address of file system flags LPTSTR lpFileSystemNameBuffer, // address of name of file system DWORD nFileSystemNameSize // length of lpFileSystemNameBuffer );
大家应该看出来点什么了吧?函数调用是先把最后一个参数压栈,参数压栈顺序是从后往前。这就是一般比较常见的 stdcall 调用约定。
2、我在前面的 00401414 地址处的那条 MOV BYTE PTR DS:[4033EC],AL 指令后加的注释是“磁盘类型参数送内存地址4033EC”。为什么这样写?大家把前一句和这一句合起来看一下:
0040140F |. E8 B4000000 CALL; \GetDriveTypeA 00401414 |. A2 EC334000 MOV BYTE PTR DS:[4033EC],AL ; 磁盘类型参数送内存地址4033EC
地址 0040140F 处的那条指令是调用 GetDriveTypeA 函数,一般函数调用后的返回值都保存在 EAX 中,所以地址 00401414 处的那一句 MOV BYTE PTR DS:[4033EC],AL 就是传递返回值。查一下 MSDN 可以知道 GetDriveTypeA 函数的返回值有这几个:
Value Meaning 返回在EAX中的值 DRIVE_UNKNOWN The drive type cannot be determined. 0 DRIVE_NO_ROOT_DIR The root directory does not exist. 1 DRIVE_REMOVABLE The disk can be removed from the drive. 2 DRIVE_FIXED The disk cannot be removed from the drive. 3 DRIVE_REMOTE The drive is a remote (network) drive. 4 DRIVE_CDROM The drive is a CD-ROM drive. 5 DRIVE_RAMDISK The drive is a RAM disk. 6
上面那个“返回在EAX中的值”是我加的,我这里返回的是 3,即磁盘不可从驱动器上删除。
3、通过分析这个程序的算法,我们发现这个注册算法是有漏洞的。如果我的分区没有卷标的话,则卷标值为 0,最后的注册码就是 797A7553H,即十进制 2038068563。而如果你的卷标和我一样,且磁盘类型一样的话,注册码也会一样,并不能真正做到一机一码。