2.1.4 基本操作
对于习惯Borland开发环境的朋友来说,用OllyDbg比较容易上手,例如单步功能是用F7键和F8键等,这与Borland的产品习惯完全一样。
在这里,以一个用Visual C++ 6.0编译的程序TraceMe来讲解OllyDbg的操作,编译时优化选项按默认设置为“Maximize speed”。读者也可以按“Minimize Size”优化选项编译一下,并与本文比较。因为优化选项不同,生成的汇编代码也会有所不同。
1.准备工作
拆解一个Windows程序要比拆解一个DOS程序容易得多,因为在Windows中,只要API函数被使用,想对寻找蛛丝马迹的人隐藏一些东西是比较困难的。因此分析一个程序,用什么API函数作为切入点就显得比较关键了,如果有些编程经验,这方面就更得心应手了。
为了便于理解,先简单地看一下TraceMe的序列号验证流程,如图2.9所示。将姓名与序列号输入到文字框中,程序调用GetDlgItemTextA函数把字符读出来,然后进行计算,最后用函数lstrcmp进行比较。因此,这些调用的函数就是解密跟踪的目标,用这些函数作为断点,跟踪程序的序列号验证过程就能找出正确的序列号。
|
| 图2.9 TraceMe程序序列号验证过程 |
2.加载目标文件调试
为了能让OllyDbg中断在程序的入口点,加载程序前必须要设置一下。运行OllyDbg后,单击菜单“Options/Debugging options”,打开调试选项配置对话框,再单击“Event”标签,如图2.10所示。这里设置OllyDbg对中断入口点、模块加载/卸载、线程创建结束等事件的处理,一般调试只需要将暂停点设置在“Entry point of main module”或“WinMain”即可。
|
| 图2.10 设置OllyDbg第一次暂停 |
System breakpoint:系统断点,OllyDbg用CreateProcessA加载DEBUG_ONLY_THIS_PROCESS参数执行,程序运行之后会触发一个INT 3,在系统空间里。
Entry point of main module:主模块的入口点,即文件的入口点。
WinMain:程序的WinMain()函数入口点,即使设置这个选项,OllyDbg一般也只会中断在文件入口点处。
设置好后,单击菜单“File/Open”打开TraceMe.exe,此时OllyDbg会中断在TraceMe的入口点,如图2.11所示。光条停在4013A0这行,这个4013A0就是程序开始执行的入口地址(EntryPoint)。
|
| 图2.11 OllyDbg加载目标程序停在入口点 |
在图2.11中,代码中各部分含义如下:
① 虚拟地址:一般情况下,同一程序的同一条指令在不同系统环境下此值相同;
② 机器码:这就是CPU执行的机器代码;
③ 汇编指令:和机器码对应的程序代码。
3.单步跟踪
调试器的一个最基本功能就是动态跟踪,OllyDbg在菜单“Debug”里控制运行的命令,各个菜单项都有相应的快捷键。OllyDbg的单步跟踪功能键如表2-1所示。
表2-1 OllyDbg的单步跟踪功能键
|
OllyDbg功能键 |
功 能 |
|
F7 |
单步步进,遇到CALL跟进 |
|
F8 |
单步步过,遇到CALL跳过,不跟进 |
|
Ctrl+F9 |
直到出现RET指定时中断 |
|
Alt+F9 |
若进入系统领空,此命令可瞬间回到应用程序领空 |
|
F9 |
运行程序 |
F8键在调试中用得很频繁,可以一句句地单步执行汇编指令,遇到CALL指令不会跟进,而路过。例如:
004013F7 xor esi, esi 004013F9 push esi 004013FA call 00401DA0 ;按F8键不会进去,而直接路过这个CALL 004013FF pop ecx 00401400 test eax, eax |
F7和F8功能键的主要差别就在于若遇到CALL、LOOP等指令,F8键是路过,而F7键是跟进去。
004013F7 xor esi, esi 004013F9 push esi 004013FA call 00401DA0 ;按F7键会进入这个CALL { 00401DA0 xor eax, eax ;上面那句4013FA,按F7键就会来到这里 00401DA2 push 0 00401DA4 cmp [esp+8], eax 00401DA8 push 1000 00401DAD sete al …… }
|
当要重复按多次F7键或F8键时,OllyDbg提供了“Ctrl+F7”和“Ctrl+F8”快捷键,直到用户按Esc键、F12键或遇到其他断点时停止。
当位于某个CALL中,这时想返回到调用这个CALL的地方时,可以按“Ctrl+F9”快捷键执行“执行到返回(Execute till return)”功能。OllyDbg就会停在遇到的第一个返回命令(RET、RETF或者IRET),这样可以很方便地略过一些没用的代码。例如上面的代码,在401DA0这行,如果按“Ctrl+F9”快捷键就会回到4013FA这句。遇到RET指令是暂停还是步过可以在选项里设置,方法是:打开调试设置选项对话框,在“Trace”页面,设置“After Execting till RET,step over RET(执行到RET后,单步步过RET)”。
如果跟进系统DLL提供的API函数中,此时想返回到应用程序领空里,可以按快捷键“Alt+F9”执行“Execute till user code(执行到用户代码)”命令。例如:
004013C0 push ebx 004013C1 push esi 004013C2 push edi 004013C3 mov [ebp-18], esp 004013C6 call [<&KERNEL32.GetVersion>] ;按F7键跟进 KERNEL32.dll里 004013CC xor edx, edx
|
在上面的4013C6一行,按F7键就可跟进系统KERNEL32.DLL里的领空:
7C8114AB kernel32.GetVersion mov eax, fs:[18] 7C8114B1 mov ecx, [eax+30] ;假设当前光标在这行 7C8114B4 mov eax, [ecx+B0] 7C8114BA movzx edx, word ptr [ecx+AC] 7C8114C1 xor eax, FFFFFFFE
|
像地址7C8114AB等都是系统DLL所在的地址空间,这时只要按一下快捷键“Alt+F9”就可回到应用程序领空里。代码如下:
004013C0 push ebx 004013C1 push esi 004013C2 push edi 004013C3 mov [ebp-18], esp 004013C6 call [<&KERNEL32.GetVersion>] 004013CC xor edx, edx ;会返回到此行 |
注意:所谓领空,实际上是指在某一时刻,CPU的CS:EIP所指向的某段代码的所有者。
如果不想单步跟踪,让程序直接运行起来,可以按F9键或单击工具栏中的 按钮。如果想重新调试目标程序,可以按“Ctrl+F2”快捷键或单击工具栏中的 按钮,Ollydbg结束被调试进程并重新加载它。有时程序进入死循环,可以按F12键暂停程序。
4.设置断点
断点是调试器的一个重要功能,可以让程序中断在需要的地方,从而方便对其分析。最常用的断点是INT 3断点,其原理是Ollydbg将断点地址处的代码修改为INT 3指令。在图2.12中,将光标移动到4013A5一行,按F2键即可设置一个断点,再按一次F2键取消断点。也可以用鼠标双击“Hex dump”列中相应的行设置断点,再次双击取消断点。当关闭程序时,OllyDbg会自动将当前应用程序的断点位置保存在其安装目录*.udd文件中,以便下次运行时,这些断点继续有效。如果将断点设置到当前应用程序代码外,OllyDbg将会警告。可以在菜单“Options/Debugging options/Security”选项里将“Warn when breakpoint is outside the code section”取消选中,以关闭这个警告。
|
| 图2.12 设置断点 |
现在开始一个完整的调试分析过程,取消开始设置的所有断点,在OllyDbg里按F9键将实例TraceMe.exe运行起来,如图2.13所示。
|
| 图2.13 实例运行起来的界面 |
字符通常利用Windows文本框输入。为了检查输入的字符,程序常采用下面这些函数把文本框中的内容读出来,如表2-2所示。
表2-2 程序采用的将文本框中内容读出来的函数
|
16位 |
32位(ANSI版) |
32位(Unicode版) |
|
GetDlgItemText |
GetDlgItemTextA |
GetDlgItemTextW |
|
GetWindowText |
GetWindowTextA |
GetWindowTextW |
一般事先不会知道程序具体是调用了什么函数来处理字符的,只好多试几遍,找出相关的函数。
首先,需要在OllyDbg中设定一个“陷阱”(或称断点)。因为这个TraceMe是32位ANSI版的程序,所以在GetDlgItemTextA处设一个断点。按“Ctrl+G”键打开跟随表达式的窗口,输入GetDlgItemTextA字符,如图2.14所示。
|
| 图2.14 打开跟随表达式窗口 |
注意:OllyDbg里对API的大小写敏感,输入的函数名大小写必须正确。
单击OK按钮后,会来到系统USER32.DLL中的GetDlgItemTextA函数入口处,如图2.15所示。
|
| (点击查看大图)图2.15 跳到函数入口处 |
在77D6AC1E这一行,按F2键设个断点,即在GetDlgItemTextA函数入口处设了断点(操作系统版本不同,这个函数入口地址是不一样的),如果这个函数被调用,OllyDbg就会中断。
注意:在Windows 9x系统中,OllyDbg是无法对API函数入口点下断的,因此不能用此方法设断。
下一步可以列出所有断点来检查一下,按“Alt+B”快捷键或单击 按钮打开断点窗口,如图2.16所示。
|
| (点击查看大图)图2.16 断点窗口 |
这里可以显示除硬件断点外的其他断点,其中“Always”表示断点处于激活状态,“Disable”表示断点停用,按空格键可切换其状态,也可以用鼠标右键菜单管理这些断点。
现已经设定了断点,可以捕捉任何对GetDlgItemTextA函数的调用。然后输入姓名和序列号,如姓名为“pediy”,序列号为“1212”。单击“Check”按钮,程序中断在OllyDbg中,就在函数GetDlgItemTextA开始的地方。
也可通过输入表设置断点。在OllyDbg里,按“Ctrl+N”键打开应用程序的输入表,会发现USER32.GetDlgItemTextA函数,在这个函数上按Enter键或右键菜单执行“Find references to import”命令打开调用此函数的参考代码窗口,找到相应的代码,按Enter键即可切换到相应的代码,接下来按F2键设置断点。
5.调试分析
按“Alt+F9”键,回到调用函数的地方,当然也可以按F8键单步走出GetDlgItemTextA这个函数。OllyDbg非常强大,已将各函数的调用参数及当前值都注释出来了。相关代码如下:
004011AE push 51 ; /Count = 51 (81.) 004011B0 push eax ; |Buffer 004011B1 push 6E ; |ControlID = 6E (110.) 004011B3 push esi ; |hWnd 004011B4 call edi ; \GetDlgItemTextA 004011B6 lea ecx, [esp+9C] ; 从GetDlgItemTextA函数里出来会回到这行 004011BD push 65 ; /Count = 65 (101.) 004011BF push ecx ; |Buffer 004011C0 push 3E8 ; |ControlID = 3E8 (1000.) 004011C5 push esi ; |hWnd 004011C6 mov ebx, eax ; | 004011C8 call edi ; \GetDlgItemTextA |
来到TraceMe领空后,可以按“Alt+B”键打开断点窗口,将GetDlgItemTextA处的断点禁止。
很多时候必须重复跟踪同一段代码,因此可以先设置一个断点。将光标移到4011AE一行,按F2键设置新的断点,以方便反复跟踪调试。
中断后的代码如下(可结合源码阅读):
;len=GetDlgItemText(hDlg,IDC_TXT0,cName,sizeof(cName)/sizeof(TCHAR)+1) 004011AA lea eax, [esp+4C] 004011AE push 00000051 ; 参数:最大字符数 004011B0 push eax ; 参数:文本缓冲区指针 004011B1 push 0000006E ; 参数:控件标识(ID号),见resource.h 004011B3 push esi ; 参数:对话框句柄 004011B4 call edi ; 调用GetDlgItemTextA函数取姓名 004011B6 lea ecx, [esp+9C] ; 上句执行后,姓名长度返回到eax中 ;---------------------------------------- ;GetDlgItemText(hDlg,IDC_TXT1,cCode,sizeof(cCode)/sizeof(TCHAR)+1) 004011BD push 00000065 ; 最大字符数 004011BF push ecx ; 文本缓冲区指针 004011C0 push 000003E8 ; 控件标识(ID号) 004011C5 push esi ; 对话框句柄 004011C6 mov ebx, eax ; 将用户名的长度转到ebx中 004011C8 call edi ; 调用GetDlgItemTextA函数取序列号 ;---------------------------------------- ; if (cName[0] == 0 || len<5) 004011CA mov al, byte ptr [esp+4C]; 将用户名第一个字节给al 004011CE test al, al ; 检查有没有输入用户名 004011D0 je 00401248 ; 如果没有输入用户名跳走,告知输入字符太少 004011D2 cmp ebx, 00000005 ; 用户名长度是<5 004011D5 jl 00401248 ;---------------------------------------- ;GenRegCode(cCode, cName ,len) (GenRegCode子程序采用C调用约定) 004011D7 lea edx, [esp+4C] ; 用户名地址放到edx中 004011DB push ebx ; 用户名长度入栈(len参数) 004011DC lea eax, [esp+000000A0] ; 序列号地址放到eax中 004011E3 push edx ; 用户名入栈(cName参数) 004011E4 push eax ; 序列号入栈(cCode参数) 004011E5 call 00401340 ; 这个CALL就是GenRegCode函数 004011EA mov edi, [004040BC] 004011F0 add esp, 0000000C ; 平衡堆栈(C调用约定) 004011F3 test eax, eax ; eax=0注册失败,eax=1注册成功 004011F5 je 0040122E
|
在阅读这些代码时:
要搞清各API函数的定义(查看相关API手册)。
API函数基本采用的是__stdcall调用约定,即函数入口参数按从右到左的顺序入栈,并由被调用者清理栈中参数,返回值放在eax寄存器中。因此,对相关的API函数要分析其前的push指令,这些指令将参数放进堆栈以传送给API调用。整个跟踪过程中要关注堆栈数据变化。
C代码中的子程序采用的是C调用约定,函数入口参数按从右到左的顺序入栈,由调用者清理栈中的参数。
有关调用约定、参数传递等知识,可以从本书第4章获得。阅读上面代码时,需理解的GetDlgItemTextA函数原型如下:
UINT GetDlgItemText( HWND hDlg, // 对话框句柄 int nIDDlgItem, // 控件标识(ID号) LPTSTR lpString, // 文本缓冲区指针 int nMaxCount // 最大字符数 ); |
GetDlgItemText采用标准调用约定,参数按从右到左的顺序入栈。例如本例中的汇编代码:
004011AE push 51 ; int nMaxCount 004011B0 push eax ; LPTSTR lpString, 004011B1 push 6E ; int nIDDlgItem 004011B3 push esi ; HWND hDlg 004011B4 call GetDlgItemTextA
|
当GetWindowText函数执行后,将把取出的文本放到由lpString(LPTSTR是一个长的指针,指向由空字符终止的字符串)指定的位置。如想看到输入的字符串,跟踪的时候,在4011B0一行停住,在eax寄存器单击右键,执行菜单“Follow in Dump”命令查看数据窗口中的内容,当然此时数据窗口中没什么有价值的东西。继续按F8键单步执行完下面一句:
004011B4 call edi ; GetDlgItemTextA函数取姓名
此时GetDlgItemTextA函数已将字符串取出,放到eax所指的地址里。数据窗口右边字符段显示出刚输入的字符“pediy”,如图2.17所示。
|
| (点击查看大图)图2.17 数据窗口查看字符 |
6.保存修改后的文件
在上一节中,已找到序列号的判断核心,这里的一段代码是关键:
004011E5 call 00401340 ; 序列号计算的CALL 004011EA mov edi, [<&USER32.GetDlgItem>] 004011F0 add esp, 0C 004011F3 test eax, eax ; eax=0注册失败,eax=1注册成功 004011F5 je short 0040122E ; 不跳转则成功 |