IDA实例教程
1 软件环境
静态分析有很多好处,例如加壳的程序(尽管对于高手来说这并不会耗费太多时间),我们不需要寻找OEP,也不需要解除自校验,只要修复IAT,DUMP下来就可以动手分析了。假如你需要修改程序,可以使用内存补丁技术。动态与静态,调试器与反汇编器结合可以简化分析任务,帮助我们理解代码。因此掌握一种反汇编器是非常必要的。IDA可以说是这方面的首选工具,它为我们提供了丰富的功能,以帮助我们进行逆向分析。这从IDA复杂的工作界面便可以知道。
种类繁多的工具栏
在分辨率不高的情况,这些工具栏与反汇编窗口挤在小屏幕里,看起来不爽。我一般把它关闭(查看=>工具栏=>主工具栏)以获得更好的视觉效果。当我们需要这些功能的时候,直接使用快捷键就可以了。下面是常用快捷键的清单:
快捷键 |
功能 |
注释 |
C |
转换为代码 |
一般在IDA无法识别代码时使用这两个功能整理代码 |
D |
转换为数据 |
|
A |
转换为字符 |
|
N |
为标签重命名 |
方便记忆,避免重复分析。 |
; |
添加注释 |
|
R |
把立即值转换为字符 |
便于分析立即值 |
H |
把立即值转换为10进制 |
|
Q |
把立即值转换为16进制 |
|
B |
把立即值转换为2进制 |
|
G |
跳转到指定地址 |
|
X |
交叉参考 |
便于查找API或变量的引用 |
SHIFT+/ |
计算器 |
|
ALT+ENTER |
新建窗口并跳转到选中地址 |
这四个功能都是方便在不同函数之间分析(尤其是多层次的调用)。具体使用看个人喜好 |
ALT+F3 |
关闭当前分析窗口 |
|
ESC |
返回前一个保存位置 |
|
CTRL+ENTER |
返回后一个保存位置 |
在工具栏下面的便是工作窗口。主要的窗口分页有“IDA View-A”、“Name”、“Strings”、“Exports”和“Imports”。对于后面3项相信大家都不会陌生了,它们分别是字符参考,输出函数参考和输入函数参考。Name是命名窗口,在那里可以看到我们命名的函数或者变量。这四个窗口都支持索引功能,可以通过双击来快速切换到分析窗口中的相关内容,使用起来十分方便。
简单输入几个字符即可定位目标
IDA View-A是分析窗口,支持两种显示模式,除了常见的反汇编模式之后,还提供图形视图以及其他有趣的功能。
IDA的反汇编窗口
一般我们在分析的时候,并不关心程序的机械码,所以IDA为我们自动隐藏了这些信息。如果你有需要,可以通过以下步骤来设置:
选项=>常规=>反汇编=>显示反汇编行部分=>机械码字节数=>修改为你允许显示的大小
现在让我们以论坛脱壳版块置顶帖的那个经典为例,看看图形视图的表现。首先我们到以下连接下载:http://bbs.pediy.com/upload/bbs/unpackfaq/notepad.upx.rar
你能通过图形视图及其缩略图快速找到壳的出口吗?
如图所示,标签40EA0E便是壳的出口代码的地址。在OD中直接跳到该地址,下断点,然后运行到该处,再单步便能看到OEP了。假如希望通过跳转法找OEP,相信图形视图比你在OD一个一个跳转跟随,要快得多。
再来看看这个壳的另类脱法。直接运行该程序, DUMP下来,再使用IMPORTREC的IAT AutoSearch功能修复输入表。用IDA打开修复了输入表的DUMP文件。在IMPORT窗口随便选一个API,随便通过交叉参考跳转到一个函数的代码。
此处为文件输入表的位置
我选了RegQueryValueExA,通过交叉参考,来到Sub_402488处的函数代码。
用鼠标拖动缩略图中的虚线框到上方,便能看到该CALL的头部了。然后按下图指示操作:
在函数标记上点击鼠标右键
处于最上层的函数,便是OEP了,使用PE工具修改文件入口为10CC。现在函数可以正常工作了。这个方法的原理是通常我们写程序都有如下流程:
Main proc
//代码
CALL FUN1
//代码
CALL FUN2
//代码
END proc
所以处于函数调用最上层的便是MAIN函数了。当然这个方法局限性很大,这里只是对该功能的一种介绍。我们留意到图表功能有两个选项,在上面的例子中,我们使用的是“交叉参考到”。我想细心的朋友大概能通过“交叉参考来自”左边的小图标猜出它的用途了。该功能可以显示目标函数调用了什么函数,当然也包括API。这样除了观察函数的输入参数来判断是否关键CALL之外,又多了一个参考途径。
2 强大的IDC
有时我们需要分析一些非文件格式的代码,例如ShellCode,远线程注入和病毒。这些代码的特点便是动态获取API,这给静态分析带来困难。尽管IDA支持分析2进制文件,但是缺少IAT的情况下,分析起来跟不方便。频繁的切换调试器查看并不是一个好方法。IDC是IDA的脚本语言,它功能强大,为我们提供了另一条与调试器交互的途径。
如何使调试器获得IDA分析得出的符号? IDA提供多种文件格式输出,调试器可以通过解释这些文件获得一些符号。你可以通过文件菜单中的“创建文件”获得更多的信息。 以OD为例,它的GODUP插件支持解释MAP文件(还能加载IDA的SIG)。在IDA中使用如下步骤: 菜单: 文件=>创建文件=>创建MAP文件 即可创建MAP文件,然后切换到OD,使用如下步骤便能获得符号了: 菜单: 插件=>GODUP Plugin=>Map Loader=>Load labels |
仍然以那个经典的UPX加壳的NOTEPAD为例子,这次我们用OD打开,在到达OEP之后DUMP下来,不修复输入表,直接用IDA载入后看到下图:
丰富的文件载入选项
需要注意的是Make imports segment是PE文件特有的选项,该选项会隐藏输入表区域的所有数据,同时你获得的好处便是能在图表功能中看到API的调用。假如你希望查看在输入表的范围内的代码或者数据,你需要使用从菜单中选择“编辑”=>“区段”以删除遮挡数据的部分区段。
为了更真实的模拟从内存中截取代码的情况,在这里选择Binary file,载入偏移量选400000(根据实际代码在内存中的基址来选择),然后IDA就开始尝试分析可能存在于该文件中的代码了。对照OD中的OEP地址,在IDA中可以看到以下代码:
seg000:004010CC push ebp seg000:004010CD mov ebp, esp seg000:004010CF sub esp, 44h seg000:004010D2 push esi seg000:004010D3 call ds:dword_4063E4 seg000:004010D9 mov esi, eax seg000:004010DB mov al, [eax] seg000:004010DD cmp al, 22h seg000:004010DF jnz short loc_4010FC |
OEP处的部分代码
OD中对应的显示:
004010D3 FF15 E4634000 call dword ptr [4063E4] ; kernel32.GetCommandLineA
使用以下ollyscript(附件中的ollyGetSym.txt)提取IAT的符号:
var ea var Ecount //0分隔号的记数器 var oFile
ask "请输入IAT起始地址" cmp $RESULT, 0 je ECancel mov ea, $RESULT ask "输出文件?" cmp $RESULT, 0 je ECancel mov oFile, $RESULT
TryGetSym: GN [ea] //获取该地址的符号 cmp $RESULT,00000000 //OLLYSCRIPT是区分00000000和0的 je ETest WRTA oFile,$RESULT_2 |
mov Ecount,0 add ea,4 jmp TryGetSym
ECancel: msg "无效输入" ret
ETest: cmp Ecount,1 //不同模块的地址以0分隔 je Send //若存在两个DWORD的0则认为是末尾 add Ecount,1 add ea,4 jmp TryGetSym
SEnd: Ret |
使用下面IDC脚本获取符号并对相应地址重命名:
#include
static main() { auto Sbuffer,ea,zcount,filehandle,fileName,CustEa; fileName = AskFile (0,"*.*","打开IAT符号文件"); CustEa = AskAddr(0,"目标IAT地址"); filehandle = fopen(fileName,"r"); for (ea = CustEa; zcount < 2; ea = ea + 4){ if (Dword(ea) !=0){ Sbuffer = readstr(filehandle); if(strlen(Sbuffer) < 2){ //ollyscript的输出文件存在无效字符 Sbuffer = readstr(filehandle); //如果字符无效则再取一次字符 } MakeNameEx (ea,Sbuffer,SN_AUTO ); //为对应DWORD改名 zcount = 0; } else{ zcount = zcount + 1; } } fclose(filehandle) ; } |
GetSym.idc
正如ollyscript接近于ASM,IDC的函数及其语法也近似于C语言(详见IDA的帮助),在编写几个脚本之后,便能轻松掌握它的用法。
seg000:004010CC push ebp seg000:004010CD mov ebp, esp seg000:004010CF sub esp, 44h seg000:004010D2 push esi seg000:004010D3 call ds:GetCommandLineA_ seg000:004010D9 mov esi, eax seg000:004010DB mov al, [eax] seg000:004010DD cmp al, 22h seg000:004010DF jnz short loc_4010FC |
现在可以正常显示函数调用的API了
下面来看看另外一个例子中IDC的表现。附件中的Exvirus.v是一个木马程序。当然这里并不是要分析这个木马,更不会运行它,在静态分析的环境下,很安全。
几乎都是乱码的窗口
加密了的字符,总要在使用之前解密。也就是说可以通过加密字符的交叉引用定位解密代码。
lea edx, [ebp+var_4] mov eax, offset s_XsXQqSxUsSq ; "抿遽翦燥镬桢祓巢宇狃箬雉" call sub_404BEE |
通过交叉引用定位的函数
由字符参考中的“SOFTWARE\Borland\Delphi\RTL”可以判断该木马是用Delphi编写的(也可从函数的参数传递约定判断)。在详细分析之前,先在菜单中进行如下步骤的操作:
文件=>加载文件=>加载FLIRT签名文件=>Delphi7 RTL/VCL/CLX
现在IDA将会根据Delphi的函数特征识别出一些库函数,这样可以减少很多工作量。
函数较长,这里只列出关键代码。判断这部分为关键代码主要是因为整个函数就只有该处是循环。解密是对一定长度的数据进行运算,因此会有一个循环对字符中的数据逐一解密。然后从输入参数与寄存器或者堆栈的关联便可以理解函数的关键部分是如何工作的。由于IDA已经为我们识别出Delphi的库函数,所以这里很容易便知道解密的方便是对目标字符的每个字节都加上80h。下面来看看我如何使用IDC来完成解密字符的工作。
CODE:00404C2C mov [ebp+var_8],1 //已处理字符记数器 CODE:00404C2C CODE:00404C33 CODE:00404C33 loc_404C33: ; CODE XREF: sub_404BEE+6Aj CODE:00404C33 mov eax, [ebp+var_4] CODE:00404C36 mov edx, [ebp+var_8] CODE:00404C39 mov bl, [eax+edx-1] //单字节取字符解密 CODE:00404C3D add bl, 80h CODE:00404C40 lea eax, [ebp+var_C] CODE:00404C43 mov edx, ebx CODE:00404C45 call @System@@LStrFromChar$qqrr17System@AnsiStringc CODE:00404C45 CODE:00404C4A mov edx, [ebp+var_C] CODE:00404C4D mov eax, edi CODE:00404C4F call @System@@LStrCat$qqrv CODE:00404C4F CODE:00404C54 inc [ebp+var_8] CODE:00404C57 dec esi //字符长度=0跳出循环,解密完毕 CODE:00404C58 jnz short loc_404C33 |
Decode.idc
既然可以通过加密字符定位目标函数,那么也可以通过加密函数定位加密字符。通过使用解密函数的交叉引用,往上搜索,解密第一条mov eax,mem32中的字符。当然这里个脚本写得有点简陋,并不能完全解决程序中的加密字符。这个就任务就留给读者来挑战吧。这里要注意的是我在编写IDC的过程中遇到很多BUG,这是因为IDA区分大小写(调试了很久才知道)。此外要转换数据类型得先把原来的分析结果取消才可以。最后要看到下图的窗口,在运行脚本后,你需要重新打开字符参考窗口(不会自动刷新)。
解密后的字符参考窗口
#include "idc.idc"
static main() { auto ea,x,y,z,zbyte,SRange,TStrLen,DeCodeBuffer,DeCodeCounter,NotTarget;
x = 0x404bee;
for ( y=RfirstB(x); y != BADADDR; y=RnextB(x,y) ){ //通过交叉参考取得函数调用地址 for (SRange = 4; SRange < 0x50; SRange++){ z = y - SRange; zbyte = Byte(z); if (zbyte == 0xb8){ //mov eax,mem32的机械码是b8 zbyte = Dword(z + 1); ea = Dword(zbyte); if (ea != 0xFFFFFFFF){ //判断mem32是否有效,防止识别错指令 if (Byte(zbyte - 1) == 1){ //在字符指针前一个字节写入处理标记 break; //避免重复处理 } PatchByte (zbyte - 1,1); TStrLen = 0; while (TStrLen < 0x30){ //解密的循环 DeCodeCounter = zbyte + TStrLen; DeCodeBuffer = Byte(DeCodeCounter) + 0x80; if (DeCodeBuffer == 0x80) break; PatchByte (DeCodeCounter,DeCodeBuffer); TStrLen++; } MakeUnknown (zbyte,TStrLen,0); //取消IDA原来的分析结果 MakeStr (zbyte, DeCodeCounter); //把该位置标记为字符 break; } } } } } |
3 静态脱壳
上一节我们用IDC完成了字符解密的工作,既然脱壳的过程实际就是对源程序的解密,现在让我们来尝试在不运行壳的情况下把壳解决掉。首先到下面连接下载一个壳:
http://www.pediy.com/tools/PACK/Protectors/MSLRH/MSLRHv0.31a.rar
主页对这个壳的介绍是可以作为Unpackme练练手,现在就以该壳的主程序作为例子讲解如何静态脱壳。首先用IDA加载该壳的主程序。
seg005:004560FA loc_4560FA: ; CODE XREF: start:loc_4560F4j seg005:004560FA call sub_456109 seg005:004560FA seg005:004560FA start endp //入口函数的结尾 seg005:004560FA seg005:004560FF seg005:004560FF seg005:004560FF seg005:004560FF sub_4560FF proc near ; CODE XREF: seg005:00456104p seg005:004560FF ; sub_456109p //红色 seg005:004560FF call sub_456DEF seg005:004560FF seg005:004560FF sub_4560FF endp seg005:004560FF seg005:00456104 call sub_4560FF seg005:00456104 seg005:00456109 seg005:00456109 seg005:00456109 seg005:00456109 sub_456109 proc near ; CODE XREF: start:loc_4560FAp seg005:00456109 call near ptr sub_4560FF+1 //+1表示反汇编出现混乱 |
正常的交叉参考标记是绿色,当显示为红色时则证明与其他部分的反汇编代码产生冲突。另外在jcc,jmp和call后面出现“+X”的符号(X为任意数字),一般也为反汇编出现混乱。在正式分析之前,我们必须找到花指令的规律,编写脚本,除去它的影响。现在我们从最初产生影响的地方开始。点击地址4560FF,按D
seg005:004560FF byte_4560FF db 0E8h; CODE XREF: seg005:00456p seg005:00456100 unk_456100 db 0EBh ; ? ; CODE XREF: sub_456109p seg005:00456101 db 0Ch seg005:00456102 db 0 seg005:00456103 db 0 seg005:00456104 call near ptr byte_4560FF |
注意00456104处也是花指令之一,它的作用就是让IDA误以为004560FF处为有效指令。因此也在该位置上按D,将其转换为数据。而在00456100处按C转换为代码。
seg005:004560FA call sub_456109 seg005:004560FA seg005:004560FA start endp seg005:004560FA seg005:004560FA ; --------------------------------------------------------------------------- seg005:004560FF db 0E8h seg005:00456100 ; --------------------------------------------------------------------------- seg005:00456100 seg005:00456100 loc_456100: ; CODE XREF: sub_456109p seg005:00456100 jmp short loc_45610E seg005:00456100 seg005:00456100 ; --------------------------------------------------------------------------- seg005:00456102 db 0 seg005:00456103 db 0 seg005:00456104 db 0E8h seg005:00456105 db 0F6h ; ? seg005:00456106 db 0FFh seg005:00456107 db 0FFh seg005:00456108 db 0FFh seg005:00456109 seg005:00456109 seg005:00456109 sub_456109 proc near ; CODE XREF: start:loc_4560FAp seg005:00456109 call loc_456100 seg005:00456109 seg005:0045610E seg005:0045610E loc_45610E: ; CODE XREF: seg005:loc_456100j seg005:0045610E add esp, 8 |
现在我们手动修正了一处被花掉的代码。我们知道OPCODE的E8和EB后面的实际是一个相对地址偏移,而不是地址编码(反汇编翻译成地址是便于分析)。因此可能你已经想到通过搜索内存中的相应指令序列,然后告诉IDA什么是代码,什么则不是。读者可以先试试自己找出壳中花指令的规律,然后对比一下结果。
经过手动整理之后,发现壳使用了下面4种花指令代码:
call label1 db 0E8h label2: jmp label3 db 0 db 0 db 0E8h db 0F6h ; db 0FFh db 0FFh db 0FFh label1: call label2 label3: add esp, 8 |
花指令1
Jz label1 Jnz label1 db 0EBh db 2 label1: jmp label2 db 81h label2: |
花指令2
push eax call label1 db 29h db 5Ah label1: pop eax imul eax, 3 Call label2 db 29h db 5Ah label2: add esp, 4 pop eax |
花指令3
Jmp label1 db 68h Label1: Jmp label2 db 0CDh, 20h Label2: Jmp label3 db 0E8h Label3: |
花指令4
在知道花指令结构之后,容易写出下面脚本用NOP(0x90h)来代替干扰的反汇编器的数据:
static PatchJunkCode() { auto x,FBin,ProcRange;
FBin = "E8 0A 00 00 00 E8 EB 0C 00 00 E8 F6 FF FF FF"; // 花指令1的特征码 for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){
x = x +5; PatchByte (x,0x90); x = x + 3 ; PatchByte (x,0x90); x++; PatchWord (x,0x9090); x =x +2 ; PatchDword (x,0x90909090);
}
FBin = "74 04 75 02 EB 02 EB 01 81"; // 花指令2的特征码 for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){
x = x + 4; PatchWord (x,0x9090); x = x + 4; PatchByte (x,0x90);
} FBin = "50 E8 02 00 00 00 29 5A 58 6B C0 03 E8 02 00 00 00 29 5A 83 C4 04"; // 花指令3的特征码 for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){
x = x + 6; PatchWord (x,0x9090); x = x + 11; PatchWord (x,0x9090); }
FBin = "EB 01 68 EB 02 CD 20 EB 01 E8"; //花指令4的特征码 for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){
x = x+2; PatchByte (x,0x90); x = x+3; PatchWord (x,0x9090); x = x+4; PatchByte (x,0x90); } } |
通过观察可知花指令中并不包含任何有意义的数据,在花指令的前后,堆栈是平衡的,各寄存器的数值也是不变的。IDC提供了隐藏区域的命令,现在来看看以下脚本:
static HideJunkCode() { auto x,y,FBin;
FBin = "E8 0A 00 00 00 E8 EB 0C 00 00 E8 F6 FF FF FF";
for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){ MakeUnknown (x,0x17,1); y = x + 0x17; HideArea (x,y,atoa(x),atoa(x),atoa(y),-1);
}
FBin = "74 04 75 02 EB 02 EB 01 81";
for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){ MakeUnknown (x,0x09,1); y = x + 0x09; HideArea (x,y,atoa(x),atoa(x),atoa(y),-1);
}
FBin = "50 E8 02 00 00 00 29 5A 58 6B C0 03 E8 02 00 00 00 29 5A 83 C4 04";
for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){ MakeUnknown (x,0x17,1); y = x + 0x17; HideArea (x,y,atoa(x),atoa(x),atoa(y),-1);
}
FBin = "EB 01 68 EB 02 CD 20 EB 01 E8";
for (x = FindBinary(MinEA(),0x03,FBin);x != BADADDR;x = FindBinary(x,0x03,FBin)){ MakeUnknown (x,0x0a,1); y = x + 0x0a; HideArea (x,y,atoa(x),atoa(x),atoa(y),-1); } } |
由于花指令的关系,会使IDA错误识别指令,可能隐藏区域的边界刚好在一条指令的机械码中间,这样隐藏的操作便会失败。因此在隐藏指令执行之前,先使用MakeUnknown将目标代码设置为未识别的状态。在完成隐藏和替换之后,再使用分析引擎分析代码。
static main() { auto x,FBin,ProcRange;
HideJunkCode();
PatchJunkCode();
AnalyzeArea (MinEA(),MaxEA()); } |
CleanJunkCode.idc
在运行脚本之后,现在让我们看看修复的成果。
seg005:0045639F rdtsc seg005:004563A1 push eax seg005:004563A2 rdtsc seg005:004563A4 ; seg005:004563A4 //被隐藏的区域 seg005:004563BB sub eax, [esp-8+arg_4] seg005:004563BE ; seg005:004563BE seg005:004563C7 ; --------------------------------------------------------------------------- seg005:004563C7 seg005:004563C7 loc_4563C7: ; CODE XREF: sub_4563B3:loc_4563C4j seg005:004563C7 add esp, 4 seg005:004563CA ; seg005:004563CA seg005:004563E1 cmp eax, 0FFFh seg005:004563E6 ; seg005:004563E6 seg005:004563F0 seg005:004563F0 loc_4563F0: ; CODE XREF: sub_4563D9:loc_4563EDj seg005:004563F0 jbe short loc_45640D seg005:004563F0 seg005:004563F2 ; seg005:004563F2 seg005:004563FC seg005:004563FC loc_4563FC: ; CODE XREF: sub_4563D9:loc_4563F9j seg005:004563FC int 3 ; Trap to Debugger seg005:004563FD mov ax, 0FEh seg005:00456401 ; seg005:00456401 seg005:0045640A seg005:0045640A loc_45640A: ; CODE XREF: sub_4563D9:loc_456407j seg005:0045640A out 64h, ax ; AT Keyboard controller 8042. seg005:0045640A ; Resend the last transmission seg005:0045640A seg005:0045640D ; seg005:0045640D |
修复之后的代码
除了“sub eax, [esp-8+arg_4]”(实际上是sub eax,[esp])看起来有点怪之后,一切正常。作为一个壳,在解决了花指令之后,剩下的问题便只有反调试代码和解密(解压缩)代码了。例如上面列出的代码是通过时间校验检查调试器,一旦检查到,便使用特权级指令,让程序发生异常,无法继续运行下去。当然,我们在静态的环境下,反调试技巧对于我们来说,毫无意义。尽管如此,我们仍然需要知道程序会在什么时候运行到什么地方,最常见的利用系统的机制莫过于SEH了,现在来看看下面代码:
seg005:00456A9B call $+5 seg005:00456AA0 add dword ptr [esp+0], 136Fh seg005:00456AA7 push large dword ptr fs:0 seg005:00456AAE mov large fs:0, esp |
设置SEH的代码
“call $+5”指令后堆栈里的内容便是它的下一条指令在内存中的地址。这是病毒常用的重定位技巧。shift+/输入0x00456AA0+0x136F便能计算出异常处理函数的地址(457E0F)了。
seg005:0045745C xor eax, eax seg005:0045745E movzx eax, byte ptr [eax] |
产生异常的代码
现在我们应该跳到457E0F继续分析。我想你已经了解如何在静态环境下跟踪程序的流程,现在就让我们跟着程序的流程把解密相关的代码找出来。
seg005:00459191 push ecx seg005:00459192 xor ecx, ecx seg005:00459194 call $+5 seg005:00459199 pop edi seg005:0045919A add edi, 9C4h seg005:004591A0 pop edx seg005:004591A1 add edx, 15h seg005:004591A4 loc_4591A4: ; CODE XREF: sub_459149+6Bj seg005:004591A4 movzx eax, byte ptr [ecx+edi] seg005:004591A8 xor eax, edx seg005:004591AA mov [ecx+edi], al seg005:004591AD inc ecx seg005:004591AE cmp ecx, 93h seg005:004591B4 jb short loc_4591A4 |
解密代码
容易看出这就是解密代码,在循环之中,且有修改内存的指令。至于解密的KEY,其实就是00459191处ECX的值+15h。我希望你还记得到达这里之前曾经看过下面代码:
seg005:004587B6 mov eax, [esp+0Ch] seg005:004587BA xor ecx, ecx seg005:004587BC xor ecx, [eax+4] seg005:004587BF xor ecx, [eax+8] seg005:004587C2 xor ecx, [eax+0Ch] seg005:004587C5 xor ecx, [eax+10h] |
这一段是检查硬件断点的代码,假如没有设置硬件断点,那么ECX的结果应该是0。假如你不能理解为什么,我建议你看看SEH以及关于反硬件断点的一些文章。
在知道解密代码的所有关键要素之后,就可以开始动手写脚本了。
#include "idc.idc"
static main() { auto StartAddr,cKey,Cbuffer,Counter;
StartAddr = 0x00459199 + 0x9c4; cKey = 0x15;
for (Counter = 0 ; Counter < 0x93; Counter ++){ Cbuffer = Byte(StartAddr) ^cKey; // movzx eax, byte ptr [ecx+edi] // xor eax, edx PatchByte(StartAddr,Cbuffer); // mov [ecx+edi], al StartAddr++; } } |
Patch1.idc
在00459BF7和0045B1FC处可以看到类似的加密代码,就不把脚本给出来了,我把它放在附件中,分别为PATCH2.idc和PATCH3.idc。在第三次解密之后,终于看到不同的解密代码了,代码比较多,我把隐藏区域的部分删掉:
seg005:00461F8D call $+5 seg005:00461F92 pop ecx seg005:00461F9D sub ecx, 5 seg005:00461FAA xor ebx, ebx seg005:00461FB6 mov eax, 0BE9Ch seg005:00461FC5 mov edi, ecx seg005:00461FD1 sub edi, eax seg005:00461FDD movzx eax, byte ptr [edi] seg005:00461FEA add ebx, eax seg005:00461FF6 inc edij seg005:00462001 cmp edi, ecx seg005:0046200D jb short loc_461FDD |
自校验代码
自校验代码的两个特征,一是读取代码,二是循环,对于那种单纯与校验结果比较控制流程的程序,我们是不需要理会自校验的。但是在这个例子里,紧跟后面的代码便是解密代码,并且自校验值作为解密KEY,我们就得计算出它的校验值。
seg005:0046200F mov edi, offset unk_447000 seg005:00462014 mov ecx, 0BC00h seg005:00462019 ; seg005:00462019 seg005:00462023 movzx eax, byte ptr [edi] seg005:00462030 add bl, bh seg005:00462032 xor bl, bh seg005:00462034 xor al, bl seg005:00462040 mov [edi], al seg005:0046204C inc edi seg005:00462057 dec ecx seg005:00462062 jnz short loc_462019 |
自校验后的解密代码
相信有了前面的经验,要编写出以下脚本并不难。要注意的是由于之前修复花指令曾经修改过文件,因此在编写好脚本之后,必须重新加载程序,然后按顺序把解密脚本运行一次,确保解出正确的代码。此外还需注意下面代码:
seg005:00462064 call $+5 seg005:00462069 pop ecx seg005:0046206A sub [ecx+16h], ebx seg005:0046206D popa seg005:0046206E pusha seg005:0046206F mov esi, offset unk_447000 seg005:00462074 lea edi, [esi-46000h] seg005:0046207A push edi seg005:0046207B or ebp, 0FFFFFFFFh seg005:0046207E push offset sub_4528D0 seg005:00462083 retn |
自修改代码
这里0046206A的代码实际就是以前面的校验值对0046207E处的指令修改,校验不正确便无法得出正确的返回地址。在写脚本的时候遇到一个问题是,解密代码使用了BL和BH,即BX的低八位和高八位的寄存器。我们可以先将校验值写进一个DWORD,然后获取其中第一个BYTE和第二个BYTE,便可以得到它的值了。由此便可得出下面的脚本:
#include "idc.idc"
static main() { auto StartAddr,EndAddr,cKey,lKey,hKey,Cbuffer,Kbuffer,Counter;
EndAddr = 0x00461F92 - 0x5; cKey = 0;
for (StartAddr = EndAddr - 0x0BE9C; StartAddr < EndAddr; StartAddr ++){
cKey = cKey + Byte(StartAddr); // movzx eax, byte ptr [edi] // add ebx, eax }
Kbuffer = Dword(MinEA()); //从镜象基址借用1个Dword PatchDword(MinEA(),cKey); lKey=Byte(MinEA()); //转换成bl hKey=Byte(MinEA()+1); //转换成bh StartAddr = 0x447000;
for (Counter = 0x0BC00 ; Counter !=0 ; Counter --){
lKey=lKey + hKey; // add bl, bh lKey=lKey ^ hKey; // xor bl, bh Cbuffer = Byte(StartAddr) ^lKey; // movzx eax, byte ptr [edi] // xor al, bl PatchByte(StartAddr,Cbuffer); // mov [edi], al StartAddr++; }
StartAddr = 0x462069+0x16; PatchByte(MinEA(),lKey); cKey = Dword (MinEA()); Cbuffer = Dword(StartAddr) - cKey; PatchDword(StartAddr,Cbuffer); PatchDword(MinEA(),Kbuffer); //恢复原来的数据 } |
Patch4.idc
在还原代码之后,容易看出0046207E处,PUSH+ RET相当于一个绝对跳转,现在让我们看看4528D0处的代码。在4528D0处按P,IDA将认为该处为函数的起点,并为函数建立图形视图。
流程的缩略图
看起来很复杂。或者的确复杂,但是我们只需要将它还原成IDC代码就可以了,甚至不需要我们理解算法的思想。可能你觉得在去除花指令影响之后,用OD改EIP直接运行相关代码也可以,内联汇编,写插件也可以。实际工作的时候,当然效率优先,选择最高效率的方法,但是将低级语言代码还原成高级语言代码,还是有一定意义的,例如你觉得C代码更容易理解一点,那么你可以先把汇编转成C代码,再理解。现在让我们切换到反汇编窗口再看代码:
seg001:004528D0 jmp short loc_4528E2 //跳到开始位置 seg001:004528D0 seg001:004528D2 ; --------------------------------------------------------------------------- seg001:004528D2 nop seg001:004528D3 nop seg001:004528D4 nop seg001:004528D5 nop seg001:004528D6 nop seg001:004528D7 nop seg001:004528D7 seg001:004528D8 seg001:004528D8 loc_4528D8: ; CODE XREF: sub_4528D0:loc_4528E9j seg001:004528D8 mov al, [esi] ; 1 seg001:004528DA inc esi seg001:004528DB mov [edi], al seg001:004528DD inc edi seg001:004528DD seg001:004528DE seg001:004528DE loc_4528DE: ; CODE XREF: sub_4528D0+BAj seg001:004528DE ; sub_4528D0+D1j seg001:004528DE add ebx, ebx seg001:004528E0 jnz short loc_4528E9 seg001:004528E0 seg001:004528E2 seg001:004528E2 loc_4528E2: ; CODE XREF: sub_4528D0j seg001:004528E2 mov ebx, [esi] //从这里开始 seg001:004528E4 seg001:004528E4 loc_4528E4: seg001:004528E4 sub esi, -4 seg001:004528E7 adc ebx, ebx seg001:004528E7 seg001:004528E9 seg001:004528E9 loc_4528E9: ; CODE XREF: sub_4528D0+10j seg001:004528E9 jb short loc_4528D8 |
我们发现开始的地方需要访问ESI指向的内存,往回看发现解密代码需要的参数,在前面说的自修改代码部分(0046206F)已经处理过了。该处代码很容易转成高级语言,现在来看看如何重整代码的流程。跳转向上的时候,代表一个循环。这与高级语言是相通的,值得注意的是向下的跳转。达到某一条件,就绕过一部分代码,向后执行,这跟高级语言中的IF控制语句,即遇到某一条件就执行随后的代码。也就是说,我们得反转比较条件。
以给出的代码为例子,与自身相加,相当于乘2,实际就是以个向左位移操作。想想十进制中,把1向左移动一位,实际就是将1乘以10。在二进制中也是一样,将一个二进制数向左移动一位,则是乘以2。汇编指令jb仅在进位标记CF=1时跳转,也就是说004528E7处的adc ebx, ebx及后面的jb short loc_4528D8的意义为,将EBX中的数向左移一位,并检查的最高位是否为1,1则向上跳转,也就是循环,0则继续执行,即终止循环的条件。现在我们可以构造下面循环的框架:
auto EBX,HigtBitfla;
while (HigtBitflat != 0){
HigtBitflat = EBX & 0x80000000; //与0x80000000进行and运算 //最高位不为0则HigtBitflat为0 //0x80000000最高位为1,其他位0 //不明白的读者看将其展开计算 EBX = EBX + EBX; //向左位移
} |
现在再来看看004528DE处的代码,jnz在ZF=0时产生跳转,即当最高位之外任意一位不为0时产生跳转。正如上面说的,将跳转条件反转,我们便能使用IF语句了。
Auto EBX,IsNotZero;
IsNotZero = EBX & 0x7FFFFFFF; //0x7FFFFFFF最高位为0 //屏蔽最高位,以检查后面的位 //仅当最高位外全为0,IsNotZero为0 If (IsNotZero == 0){ // 此处可以填上004528E2到004528E7的代码 } EBX = EBX + EBX; //注意这里与汇编的区别 //先判断,然后才移位 |
注意这里与汇编代码的区别,由于我们无法在IDC上访问标记寄存器,也无法使用跳转。这里只能先判断最高位,然后才进行位移。下面让我们来直接看最后得出的IDC脚本:
#include "idc.idc"
static main() { auto MyAddr,DeCodeAddr,HigtBitflat,EBX; auto EAX,ECX,EBP,ESI,EDX,CF,IsNotZero,Counter;
MyAddr = 0x447000; DeCodeAddr =0x447000 - 0x46000; ESI=DeCodeAddr; Counter = 0; //初始化循环条件 CF = 0; //代表标志寄存器的CF位 EBX = Dword(MyAddr); MyAddr = MyAddr + 4; HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX++; // 为了统一循环入口,将部分代码移出循环执行。 while (Counter != 1){ while (HigtBitflat != 0){ PatchByte (DeCodeAddr,Byte(MyAddr)); MyAddr++; DeCodeAddr++; IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; //sub esi, -4与add esi,4的区别就是前者CF=1 EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; //加上CF,模拟ADC指令 CF = 0; }
EAX = 1; while (Counter != 1){ //4528F0到45291A,以JMP构成一个循环。因此使用while语句,构造 //一个无限循环。在符合终止循环条件处使用break指令结束循环。 IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; CF = 0; EAX = EAX + EAX; if (HigtBitflat != 0) EAX++; HigtBitflat = EBX & 0x80000000; if (HigtBitflat != 0){ IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero != 0) { //00452901 jnz short loc_45291C EBX = EBX + EBX; break; } EBX = Dword(MyAddr); MyAddr = MyAddr + 4; HigtBitflat = EBX & 0x80000000; if (HigtBitflat != 0) { //0045290A jb short loc_45291C EBX = EBX + EBX; EBX++; break; } CF = 1; } EBX = EBX + EBX; EBX = EBX + CF; CF = 0; EAX--; IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; CF = 0; EAX = EAX + EAX; if (HigtBitflat != 0) EAX++; }
ECX = 0; //xor ecx,ecx常见的为寄存器赋值为0的语句。 //注意00452921 jb short loc_452934处,程序分开两条路线 //在loc_45293F处汇合。因此这里使用if 。。else语句重整程序流程。 if (EAX < 3){ //此处直接使用减法指令作比较,而不是使用CMP EAX = EAX - 3; //因此只能在比较之后再减 IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; CF = 0; } else{ EAX = EAX - 3; EAX = EAX << 8; EAX = EAX + Byte(MyAddr); MyAddr++; EAX = EAX ^ 0xffffffff; if (EAX == 0) break; HigtBitflat = EAX & 1; //检查sar eax,1是否影响CF位 EAX = EAX >> 1; //检查结束再执行位移 EBP = EAX; } ECX = ECX + ECX; if (HigtBitflat != 0) ECX++; IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; CF = 0; ECX = ECX + ECX; if (HigtBitflat != 0) ECX++; if (ECX == 0 ){ ECX++; HigtBitflat = 0; //00452960 jnb short loc_452951 //0045296B jnb short loc_452951 //此处有两个跳转指向循环入口,将00452960处的条件反转,翻译成if //语句。便可得到下面循环: while (HigtBitflat == 0){ IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; CF = 0; ECX = ECX + ECX; if (HigtBitflat != 0) ECX++; HigtBitflat = EBX & 0x80000000; if (HigtBitflat != 0){ IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero != 0) { EBX = EBX + EBX; break; } EBX = Dword(MyAddr); MyAddr = MyAddr + 4; CF = 1; HigtBitflat = EBX & 0x80000000; } EBX = EBX + EBX; EBX = EBX + CF; CF = 0; } ECX = ECX + 2; } //高级语言的比较为有符号数的比较,而0045297F jbe short loc_452990 //是无符数的比较。因此要先比较其最高位,模拟无符号数的比较 HigtBitflat = EBP & 0x80000000; if (HigtBitflat !=0){ if (EBP < 0xfffffb00) CF =1; } else{ CF =1; } ECX ++; ECX = ECX + CF; CF=0; EDX = DeCodeAddr + EBP; if (HigtBitflat !=0){ if (EBP > -4) CF=1; } //0045297F jbe short loc_452990将此处分开两条路线, //以jmp loc_4528DE重新汇合。这里同样使用if….else语句。 if (CF==1){ CF=0; while (ECX !=0){ PatchByte(DeCodeAddr,Byte(EDX)); EDX ++; DeCodeAddr ++; ECX --; } } else{ while(Counter != 1){ PatchDword(DeCodeAddr,Dword(EDX)); EDX = EDX + 4; DeCodeAddr = DeCodeAddr + 4; if (ECX <= 4){ ECX= ECX -4; break; } ECX = ECX - 4; } DeCodeAddr = DeCodeAddr + ECX; } //反汇编代码的循环入口(4528DE)与我们转换的循环入口不同(4528E9) //跟开始的时候一样,入口之前的代码放到循环外面。 IsNotZero = EBX & 0x7FFFFFFF; if (IsNotZero == 0){ CF=1; EBX = Dword(MyAddr); MyAddr = MyAddr + 4; } HigtBitflat = EBX & 0x80000000; EBX = EBX + EBX; EBX = EBX + CF; CF = 0; } } |
Patch5.idc
至此,我们成功将004528D0到004529A1处的代码转换成C代码。在完成如此复杂的代码还原之后,004529A6到004529D8处的反汇编代码只是小菜一碟。里面的代码也很好理解,将符合E8 01和E9 01的机械码解密。位移指令可以通过借用程序中的一个闲置的Dword,使用IDC提供的Pactch系列指令来模拟,详见Patch6.idc。在完成最后的解密代码后,便是IAT的修复了。现在看看下面代码:
004529DA lea edi, [esi+50000h] 004529E0 loc_4529E0: 004529E0 mov eax, [edi] 004529E2 or eax, eax 004529E4 jz short loc_452A22 004529E4 004529E6 mov ebx, [edi+4] 004529E9 lea eax, [eax+esi+549B0h] 004529F0 add ebx, esi 004529F2 push eax 004529F3 add edi, 8 004529F6 call dword ptr [esi+54A3Ch] 004529FC xchg eax, ebp 004529FD loc_4529FD: 004529FD mov al, [edi] 004529FF inc edi 00452A00 or al, al 00452A02 jz short loc_4529E0 00452A02 00452A04 mov ecx, edi 00452A06 push edi 00452A07 dec eax 00452A08 repne scasb 00452A0A push ebp 00452A0B call dword ptr [esi+54A40h] 00452A11 or eax, eax 00452A13 jz short loc_452A1C 00452A13 00452A15 mov [ebx], eax 00452A17 add ebx, 4 00452A1A jmp short loc_4529FD |
在分析该处代码之前,显然应该先把ESI的值计算出来。鼠标点击ESI,以高亮显示该寄存器,向上滚动反汇编窗口,发现从004529A6 pop esi处开始,ESI便没有被修改过,而该处对应于:
seg005:0046206F mov esi, offset unk_447000
seg005:00462074 lea edi, [esi-46000h]
seg005:0046207A push edi
可见ESI=0x401000,容易计算出004529F6和00452A0B处CALL的地址分别为455A3Ch和455A40h。跳转到该地址:
显然,这里便是壳填充IAT的地方了。那么004529DA lea edi,[esi+50000h]中,EDI便是保存API名字的数据表。做脱壳机的任务就留给读者作课后练习,正如前面介绍的那样,只需要API的名字为相关IAT地址重命名,便能分析了。也就是说00452A0B处,调用GetProcAddress,跟踪它的参数lpProcName (00452A06 push edi),以及它的返回值(00452A15 mov [ebx], eax),当然这里的跟踪,可以象刚才那样手动确认,也可以通过与调试器配合快速得出结果。不难得出下面脚本:
#include "idc.idc"
static main() { auto ESI,EDI,EAX,EBX,Counter,cBuffer,BufLen,straa;
ESI = 0x447000 - 0x46000; EDI = ESI + 0x50000; Counter = MaxEA() - MinEA(); MakeUnknown(MinEA(),Counter,1); //将整个程序标记未分析 AnalyzeArea (MinEA(),MaxEA()); //分析整个程序 Counter = 0; while (Counter != 1){ EAX = Dword(EDI); if (EAX == 0) break; EBX = Dword(EDI+4); EBX = EBX + ESI; EDI = EDI + 8; while (Counter != 1){ EAX = Byte(EDI); EDI++; if (EAX == 0) break; cBuffer = GetString(EDI,-1,ASCSTR_C); straa = cBuffer + "_"; //IDA不允许重复命名,加上“_”避免重复 MakeNameEx(EBX,straa,SN_AUTO); EBX = EBX + 4; EDI = EDI + strlen(cBuffer); EDI++; } } } |
IATPATCH.idc
注意解密后,必须将整个程序标记为未分析,并重新分析,然后才能进行重命名。
程序的OEP
到此,静态脱壳完毕。从这个例子也可以知道,对于掌握反汇编器的人来说,除非反调试机制与解密KEY关联,否则根本就没有强度可言。然而,IDA博大精深,还有更多强大的功能,本文也只是抛砖引玉而已。下面给出几个链接,方便大家更进一步学习:
IDA的官方网站:
www.datarescue.com
看雪论坛9月翻译专题:
http://bbs.pediy.com/showthread.php?s=&threadid=31023
IDA Pro的插件开发SDK:
http://bbs.pediy.com/showthread.php?s=&threadid=31441
IDA逆向工程入门:
http://bbs.pediy.com/showthread.php?s=&threadid=40765
IDA简易教程:
www.pediy.com/practise/IDA.htm