Windows 95 System Programming SECRETs
(Windows 95 系统程序设计 大奥秘)
原著:Matt Pietrek
笔记:Simon wan
我要在这一章讨论下面三种 Windows 程序员用来探索奥秘的方法:
l 档案倾印工具(file dumping utilities)
l API 函式及 Windows 讯息的窥视工具(spy programs)
l 反组译(disassembly)
通常对一个程序的探险动作,第一步就是倾印出其档案内容。这个步骤使你能够迅速知
道你的探险对象的档案型态,以及它可能的用途。表9-1 列出几个常见的档案解剖工具
及其能力。
你从档案倾印工具的输出中所能获得的最有用信息通常是这个可执行档所用到的 DLL
和 API 函式。
虽然档案倾印很有趣,也富含信息意义,但常常无法告诉你你需要知道的每一件事情。能够刺探程序活动程序的工具,才能够胜任上述要求。最有名的 Windows 刺探工具是讯息刺探软件:Microsoft 的 SPY 和 Borland 的 WINSIGHT。讯息刺探软件可以显示窗口收到的任何讯息,以及对此讯息的反应。
虽然反组译很复杂也很困难,它却常常是解决神秘算法或技术的唯一办法。
没有什么单一准则适用于所有的反组译动作。这里我所描述的,是对我有效的准则。如果其它准则适用于你,别犹豫。我的反组译基本哲学,一言以蔽之,就是「分散征服,各个击破」。拿到一份反组译码之后,我并不处理整个函式或整段码,而是经由一系列程序把范围打破、缩小,再进攻,才有较大的成功率。我的终极目标就是把反组译码转换为日后方便易读的 C 形式码。依照你所处理的码的不同,以下数点的重要性以及次序可能会有变化。首先我会说明反组译过程中的一般术语。然后我就跳到核心问题去:参数鉴定啦、区域变量啦、条件判断啦、函式呼叫啦...等等。最后我会示范一个实作例子,利用一些反组译输出结果,转化为有用的信息。反组译一个函式时,你所需要的步骤列于下面。
步骤1:将档案反组译
步骤2:为已知物体做上卷标
步骤3:将指令序列(instruction sequences)区隔开来
步骤4:加上字符串(string literals)
步骤5:将指令凝结为单一的 C 语言叙述
步骤6:鉴定条件句
步骤7:重复(如果必要的话)
讨论过如何反组译一个函式后,现在我要检验某些常见的码,以及汇编语言的一些习惯。这可以帮助你把汇编语言码翻译为对等的高级语言码。
l 辨识函式和程序
看反组译输出码,第一件事情就是辨识出哪里是一个函式的开始和结束。最容易找到函式起始位置的方法就是寻找编译器产生的标准 prologue 码。
如果在 32 位程序,标准的 prologue 码看起来像这样:
PUSH EBP ;; Save caller’s EBP frame.
MOV EBP, ESP ;; Set up new EBP frame.
SUB ESP, XX ;; Make space for local variables on stack.
PUSH ESI ;; ESI, EDI, and EBX are commonly used as
PUSH EDI ;; register variables.
PUSH EBX
决定一个函式的起始和结束之后,记住,紧跟在后面的,就是另一个函式的开始。如果
你看到某些码像是 epilogue,请寻找另一个看起来像 prologue 的段落,与它凑成对。如
果你没找到,那么一种可能是编译器的最佳化动作使 prologue 消失了,另一种可能是前
一个函式有多重出口。
l 函式回返值
当函式传回一个数值,它是把数值放在一个缓存器(或一组缓存器)中传回。为了确定函式传回值有没有被使用,请你检查函式呼叫者,看它们有没有使用那些缓存器。如果你看到某处呼叫一个函式,然后使用那些专门用来传递回返值的缓存器,而却没有事先设定其值,你就知道,它正在使用函式回返值了。举个例子,如果你看到程序代码中呼叫一个函式,然后使用 AX 而未先设定其值,你可以确定,被呼叫函式利用 AX 传回其回返值。
在 32 位码中,函式习惯以 EAX 做为传回值的媒介。16 位码则使用 AX 传递 16位数值,以 DX:AX 传递 32 位数值。如果程序是以汇编语言完成,程序员可以使用任何他们喜欢的缓存器完成传递工作。组译器通常遵循的一个习惯是,如果函式只是传回一个真假值(代表成功或失败),它通常会设定或清除 carry flag(CF)。搜寻紧跟在CALL 指令之后的 JC 和 JNC 指令,便很容易嗅出这类函式。
l 参数的辨识
如果你已经知道你正在拆卸的函式所需要的参数,在汇编语言码中为它们贴上标签将十分容易。除了一个例外(稍后我会带到),编译器总是利用堆栈来传递函式参数。只要知道每一个参数的大小,你就可以轻易定位出堆栈中的每一个参数。然而在我展示范例之前, 我首先必须解释 Windows 和 Win32 所使用的「函式呼叫习惯( callingconvention)」。
在 16 位 Windows 码中,大部份的被输出函式(exported function)都使用pascal 呼叫习惯 -- 呼叫端负责把参数推入堆栈,推入的次序是最左边的参数至最右边的参数。
除了参数推入堆栈的次序从左到右,Pascal 呼叫习惯也要求「被呼叫端」必须在回返之前负责清除堆栈。以我所引用的这个 foo 函式而言,它必须在回返之前将堆栈的 6 个字节推出堆栈。或许它会使用一个 "RETF 6" 指令。
标准的 C/C++ runtime library 使用C 呼叫习惯-- 参数由最右至最左推入堆栈(这样做的好处是它可以处理像printf 那种参数不定的函式)。至于清除堆栈的工作则由「呼叫端」负责。
Win32,微软接受了 stdcall 呼叫习惯,应用于几乎所有的 system DLLs 输出函式(exported function)。Stdcall 是 C 和 Pascal 的混合,它的参数传递次序是像 C 那样由右至左,清除堆栈的责任则落在「被呼叫端」身上,这一点又像 Pascal。此外,当你在 Microsoft C++ 中使用 stdcall 呼叫习惯,编译器会自动(内部地)在函式名称之后加上 "@xx" 字符串,xx 用以表示参数的总字节个数。例如 _GetWindowLong@8 或_PeekMessage@20。
了解函式呼叫习惯之后,你就可以决定参数在堆栈中的位置了。知道参数在堆栈的偏移位置后,你就可以寻找是否有哪个指令参考到该内存地址,然后把该汇编语言地址以符号名称取代之。符号名称对于反组译有莫大帮助。
l 辨识区域变量
和函式参数一样,区域变量通常也栖身于堆栈中。函式参数和区域变量之间最关键性的差别在于,区域变量在堆栈系以负偏移值来定位。例如 16 位码中的[BP-4] 或 32 位元码中的 [EBP-4]。和函式参数不同的是,区域变量没有什么制式作法可以决定变量的型态、使用、位置。你必须斟酌函式码如何使用一个特殊的地址。有时候决定一个区域变量的意义很容易,
例如下面这段 Win 32 码 :
PUSH DWORD PTR [EBP+08]
CALL GetParent
MOV [EBP -0C ],EAX
GetParent 是一个 Win32 API, 需要一个 HWND 参数, 以 EAX 传回父窗口的HWND。由于程序片断中把 EAX 拷贝到 [EBP -0C ],很显然 [EBP -0C ] 是一个 HWND。由此你可以做一次狂野的猜测:参数或许名为 "hWndParent"。到了这一步,你可以利用你的文字编辑器的「搜寻并取代」功能,把所有的 [EBP -0C ] 改变为[hWndParent]。完成之后再看看你的反组译码,是不是清爽多了?
你们之中可能有人要问:『好得很,Matt 先生,但不是每一个区域变量都这么容易被摘取出来唷!』是的,我们还有其它方法。有时候我们很容易因为某个区域变量用做其它函式的参数而把它们辨识出来。下面就是个 Win32 实例:
LEA EAX,[EBP-30] ; Get address of EBP-30h into EAX.
PUSH EAX ; Push it as an LPRECT.
PUSH [EBP+08] ; Push an HWND (a parameter).
CALL GetWindowRect ; Call into USER32 to get the RECT coordinates.
看看 SDK 手册中的 GetWindowRect 数据,我们知道它需要一个 HWND 参数,一个指向 RECT 结构的指针参数。由于 GetWindowRect 是一个 stdcall 函式,所以 RECT 指标应该先被推入堆栈,然后才是 HWND。在上述列表中我们看到,针对 LPRECT 参数,程序代码将一个 EBP-30h 地址推到堆栈中,因此,在 [EBP-30h] 地址处一定是个 RECT型态的区域变量。这是一份意想不到的信息,因为 WINDEF.H 内含 RECT 结构格式(4个 DWORDs),所以我们可以获知堆栈中所有的 RECT 字段:
RECT.left = [EBP-30]
RECT.top = [EBP -2C ]
RECT.right = [EBP-28]
RECT.bottom = [EBP-24]
再一次,我们可以较有意义的符号,搜寻并取代那些 [EBP-xx] 地址。编译器可以暂时把区域变量(和函式参数)拷贝到缓存器中。这可以节省一些程序代码空间和执行时间。对着反组译码工作时,你必须对那些开始使用缓存器变量的地方保持警惕。只要随后你又看到那个缓存器被使用,就以有意义的变量名称取而代之。注意,编译器(或汇编程序员)可能在不同地点以同一个缓存器存放不同的变量。
在 16 位程序中,SI 和 DI 最常被用来当做缓存器变量。由于这两个缓存器只有 16位长,它们通常不用在指标身上,因为 16 位程序中的指针大部份是32 位远程指标。SI 和 DI 通常用于 16 位值如 HWNDs 或 DCs。在 Win32 程序中,ESI、EDI和 EBX 最常被用来当做缓存器变量。Win32 的指针是 32 位近程指针,所以这三个缓存器也可以用来放置指标。啊,没有任何一条路是坚固或快速的,处理缓存器变量时,请运用你的直觉和判断。
l 辨识全域变数
决定程序所使用的全域变量就容易得多。几乎任何一个被写死的内存地址处,就是一个全域变数。全域变量不需要像 EBP 这样的东西辅助才能够定出位置来。在 32 位码中,全域变量可能像这个样子:
MOV EAX, [00464398]
如果你够幸运,而且反组译器有符号表格可以参考,或许 [00464398] 会以原始码的变数名称取代之。如果不是这样,你必须搜寻整个反组译列表,自行以符号名称取代[00464398]。如果你手中没有符号表格,可尝试自行决定有意义的名称。在 16 位码中,辨识全域变数的工作和 32 位码差不多,差只差在 16-/32- 位。但如果该程序有多个数据节区,你必须特别小心。问题出在于相同的偏移地址可能出现在好几个资料节区中。当你处理 DGROUP 以外的数据节区的全域变量时,程序代码会设定一个节区缓存器(通常是 ES)指向该节区,然后程序代码才以该缓存器配合原先写死的偏移值,例如
MOV AX, ES:[ 001C ]。
如果你手上有符号表格,但是你所遭遇的内存位置并不在全域变量列表之中,可能有两种情况。第一种情况是该内存地址被用于一个 static 变量。如果你的符号表格只含public 符号,该变量就不会显示出来。第二种情况是,你可能看到了一个结构或数组中的一个字段。例如 16 位程序有全域变量 MSG MyMsg; 位于 DGROUP 节区的0364h 位置,其中的 wParam 字段占 4 个字节,那么 MyMsg.wParam 就应该在0368h 处。此程序的符号表格将列出 MyMsg 位于 0364h 处,但不会列出 0368h 处有什么东西。不会什么收获都没有!寻找最接近 0368h 地址的符号,我看到有个名叫MyMsg 的家伙在 0364h 处。根据这个名称我假设那是一个 MSG 结构。当然我必须测试我的假设。如果 0364h 处真的是一个MSG 结构,0368h 处就应该是这结构的一个字段,对吗?对!但是在我尚未确定我的假设正确之前,我应该寻找其它的担保。0368h 处看起来像不像WPARAM?其下一个字段(036Ah)看起来像不像一个LPARAM?不幸的是没有坚固而且快速的技术能够派上用场。我必须继续维持我的假设,直到我有了足够的自信。好消息是,编译器很少把全域变量放到缓存器中。
l 辨识字符串
许多 API 函式都需要字符串参数。把函式的字符串参数对应为 ASCII,你就比较能够对函式在做什么有些念头。例如在 16 位码中你可能遭遇以下的指令:
PUSH DS
PUSH 0437
CALL GETMODULEHANDLE
在 32 位码中你可能遭遇以下的指令:
PUSH 00471784
CALL GETMODULEHANDLE
打开你的 API 手册,你会发现 GetModuleHandle 有一个参数,是个字符串指针。那些PUSH 指令就是把字符串指针推入堆栈,当做 GetModuleHandle 的参数。因此在地址00471784 处(或 16 位码中的 DS:0437 处)应该有一个以 null 做为结束字符的字符串(例如 "USER32")。如果你的反组译器把数据节区倾印出来,请你前进到该地址并取出其字符串内容,然后回到程序代码中,加上一段批注,例如:
PUSH 00471784 ;; “USER 32 ”
CALL GETMODULEHANDLE
如果你所反组译的程序使用一大堆字符串,你可能会对经过这样处理之后的结果大感惊讶,因为实在是清爽太多了。这个过程在反组译程序中是比较棘手也比较耗时的。有些可执行文件把字符串放在 code 节区中。通常该字符串会放在使用它的程序代码的下方。好的反组译器可以注意到这种情况并且暂时切换到 hex 倾印模式。然而一般的反组译器常常会出错。有时候你必须看看周遭的码,才能决定函式从哪里开始、数据在哪里结束。通常,内嵌数据(如 switch 指令所产生的 JMP 表格)会在你的反组译码中产生一些暂时性的垃圾。看看周遭的码,常可以获得一些线索,告诉你什么是真正的码,什么是内嵌资料。你可以把这些结果回馈给反组译器,做出第二次列表,区分程序代码和数据。没有人说过反组译是件容易的差事,对不对!
l 辨识if 指令
最简单的条件判断是 if 动作:
if ( some test ) {
do some sequence of code
}
在讨论其各种变化之前,我要显示其汇编语言码。从反组译码看来,你会遭遇三种主要的测试形式:
1. 相等测试:if(a==b)、if(a!=b) ...等等。
2. 布尔值测试:if(a)、if(b) ...等等。
3. 位测试:if(a & 0x0040) ...等等。
虽然编译器对不同型态的测试产生不同的码,,其目标都在设立或清除 CPU 的Zero Flag(ZF)。在设立或清除 Zero Flag 之后,程序代码使用 JZ(Jump if Zero)或 JNZ(Jump ifNot Zero)做条件判断,看是要执行或跳过后续的码。你也可以把 JZ 想成是 JE(Jump ifEqual),把 JNZ 想成是JNE(Jump if Not Equal)。「测试,然后有条件地跳离」这种模式的基本算法是:如果测试结果为否定,CPU 就进行条件式跳离,后续的 { } 或 BEGIN/END 中的码就不会被执行。如果测试结果为肯定,执行顺序就不会跳脱,于是 { } 或 BEGIN/END 中的码就会被执行。
警告:我在这里所描述的只是最最简单的一种情况。真实世界中的码可能复杂得多。例如在 16 位程序中,可能有一个 JZ 或 JNZ 用以跳离正规的JMP 指令。这种情况发生在「if 区块中的码比 127 字节长」时 -- 那是16 位码对一个条件式跳跃的限制。当然啦,前面我所描述的基础前提并没有改变。对于「相等测试」,编译器使用 CMP 指令。以下是 "DUMPBIN/DISASM" 的输出片断:
0000101E: cmp dword ptr [ebp-04],04
00001022: jne 0000102E
00001028: inc byte ptr [ebp-04]
0000102B: inc byte ptr [ebp-08]
0000102E: ...
第一个指令把 [EBP-04] 处的 DWORD 拿来和 4 比较。如果相同,CMP 指令就设立Zero flag,否则它就清除 Zero flag。下一个指令(JNE)跳过后面的码 -- 但只有在 Zeroflag 被清除时才如此。因此,两个 INC 指令只在Zero flag 设立时才得以执行。以 C 语言重写,我们获得:
if ( SomeVariable1 == 4 )
{
SomeVariable1++; // INC [EBP-04]
SomeVariable2++; // INC [EBP-08]
}
如果 if 指令只要测试 TRUE 或 FALSE,编译器对于机器码的产生有两种选择。一种选择是做出像前述 if 指令那样的码, 例如 if(MyVariable) 也可以被视为if(MyVariable!=0)。另一种情况是当词句中的数值位在缓存器中。这种情况下编译器可以使用较少的指令来决定是否其值为 TRUE 或 FALSE。所谓较少的指令是一个 "OR register, register" 指令,像这样:
0000102E: call 00001000
00001033: or eax,eax
00001035: je 0000103E
0000103B: inc byte ptr [ebp-04]
0000103E: ...
其中第一个指令呼叫一个函式,传回值放在 EAX 中。然后编译器不再使用CMP EAX, 0这样的指令,改用 OR 指令。OR 指令对 EAX 中的每一个位都做 logical OR 运算。如果没有任何一个位处于设立状态(也就是说 EAX==0),Zero flag 便会设立。第三种情况是位测试。Windows 程序设计中有许多 WORD 和 DWORD 是以一个位元一个位的旗标值组成,例如 CreateWindow 所需要的 WS_xxx。程序常常利用 AND 运算来检查是否哪一个旗标设立起来。
看看下面这一段 C 程序代码:
DWORD winFlags = GetWinFlags();
if ( winFlags & WF_CPU386 )
is386 = TRUE;
产生出来的汇编语言码像这样:
0000102E: test byte ptr [ebp-08],04 ;; WF_CPU386 == 0004h
00001032: je 0000103F
00001038: mov dword ptr [ebp -0C ],00000001
0000103F : sub eax,eax
第一个指令使用 CPU 的 TEST 指令看看是否某个位有设立起来。TEST 指令会对两个操作数执行 logical AND 运算,但不会改变任何一个操作数的值。如果运算结果并没有任何位设立,Zero flag 会设立起来,否则就会清除为 0。当 Zero flag 设立,JE 指令就不会跳离,于是 [EBP -0C ] 中的 DWORD 值会变成 1。如果你小心地观察前面那个片断中的 TEST 指令,你会发现某些奇怪的东西。在 C 程式码中,winFlags 是一个 DWORD,但是汇编语言码却只在意其最底部的 BYTE。这是因为编译器做了最佳化,尽可能使用最少量指令。如果没有最佳化,上述的第一行应该是:
TEST DWORD PTR [EBP-08], 00000004
我提出这一点并不是要强调最佳化与否,而是告诉你观察 TEST 指令时必须机伶些:被TEST 的地址和位屏蔽可能不是你预期的样子。前一例中如果我们改测试WF_80x87,其值为 00000400h,TEST 指令应该变成 "test [ebp-08], 00000400h",是吗?错!它会变成 "test [ebp-07], 04"。看起来内存地址比winFlags DWORD(在 ebp-08 处)高了一个字节。为了补偿这一点,编译器把将被测试的位旗标向右移 8 个位。这简直是有点卑鄙!如果被测试的位放在变量中,编译器还会把它的地址调出来,依样画葫芦。现在我们看过了所有三种基本的 if 判断型态。让我们再来点大的。比单独的 if 句子稍稍复杂些的是 if-else 句子。考虑下面这段码:
if ( i == 4 )
{
i++;
j++;
}
else
j—;
编译器为它产生这样的码:
0000101E: cmp dword ptr [ebp-04],04
00001022: jne 00001033
00001028: inc byte ptr [ebp-04]
0000102B: inc byte ptr [ebp-08]
0000102E: jmp 00001036
00001033: dec byte ptr [ebp-08]
00001036: ...
前两个指令看起来和单独一个 if 叙述句的情况一样。较远的地方有个 JMP 指令,那是关键所在。JMP 使得执行完「判断句为真」的响应动作之后,就跳过else 子句。JMP 所跳的地址是「else 子句何处结束」的重要线索。当你尝试辨识一个 if-else 句型时,注意两件事情:一开始的JE/JNE 指令所跳过去的位址,是否是紧接在一个 JMP 指令之后?JMP 指令是否跳到一个更高的地址(也就是说向后跳而非向前跳)?
另一种比较复杂的 if 判断句型是多重条件。例如:
if ( (i == 4) && (j == 2) && (k == 6) )
{
i++;
j++;
}
编译器产生如下的码:
0000101E: cmp dword ptr [ebp-08],04
00001022: jne 00001042 ;; Jump past code inside {}’s.
00001028: cmp dword ptr [ebp -0C ],02
0000102C : jne 00001042 ;; Jump past code inside {}’s.
00001032: cmp dword ptr [ebp-04],06
00001036: jne 00001042 ;; Jump past code inside {}’s.
0000103C : inc byte ptr [ebp-08]
0000103F : inc byte ptr [ebp -0C ]
00001042: ...
这些码十分直接了当,有三个测试接续发生。其中任何一个失败,程序代码就会跳离 { } 区域。如果你看到许多「测试并跳离」的组合,可能你面对的就是一个有多重条件的 if 句型。多重条件的 if 句型中,以 OR 串连子句和以 AND 串连子句的情况相差无几。你同样会看到一系列连续的「测试并跳离」动作。所有的测试,除了最后一个之外,都跳到 { }之外 -- 如果其测试结果都是 TRUE 的话。如果测试结果为 FALSE,就会掉到 { } 之内的下一个指令。如果最后一个测试失败,也是跳离 { }。
这一节涵盖的是基础句型。我并没有讨论 for 回路或 while 回路。你或许会遭遇更复杂的东西,然而几乎你所遭遇的每一种句型都可以被分解成我所描述过的这几种的组合和
变形。
l 辨识switch 指令
辨识一个 switch 句型非常非常地简单 -- 虽说它也有三种常见的变形。最简单的一种就是我所谓的白痴型编码。它浪费许多空间,非常容易推断,汇编语言码看起来像这样:
MOV EAX,[EBP+ 0C ]
CMP EAX,00000045
JE someAddress
CMP EAX,00000169
JE someAddress2
CMP EAX,00000265
JE someAddress3
第一个指令把 switch 的参数加载缓存器中 -- 本例为 EAX,但也可以是其它缓存器如EDI。16 位码则似乎总是使用 AX。将欲测试之数值加载缓存器后,程序代码进入一系列的 CMP/JE 组合。对于 switch 句型中的每一个 case,都有一个对应的 CMP/JE 组合。于是我们就很容易找到某个 switch 测试值的处理例程了。如果程序使用 switch 来分派窗口函式中的讯息,你只要在汇编语言码中寻找你感兴趣的 WM_xxx 即可。那是一项简单的工作 -- 只要搜寻 CMP 指令并比对其测试值即可。紧跟其后的 JE 指令内含有讯息处理例程的地址。如果你要把整个句型拆卸开来,看看它如何处理每一个讯息,那么以讯息名称做为处理例程的标记是很有帮助的。
switch 句型的第二种变形十分接近第一种变形,差别在于测试指令使用较少的字节,
并且要求你对中间过程的数值保持注意。看看下面这段码:
MOV EAX,[EBP+ 0C ]
SUB EAX,2
JE someAddress
DEC EAX
JE someAddress2
DEC EAX
JE someAddress3
SUB EAX,5
JE someAddress4
一瞥之下,这段码看起来令人迷惑。它并不像第一种类型那样去比较任何数值,唯一真正的动作是 EAX 的值不断往下掉。为了让这段码看起来比较合理,你必须知道,DEC 和SUB 指令的运算结果若是 0,Zero flag 会设立起来。每一个 DEC 和 SUB 指令都会把输入值吃掉一些。当该值为 0,时候便到了,JE 指令便把它分派到适当的处理例程去。输入值较低,分派出去的时间较快;输入值较高,分派出去的时间较慢。
为了看看 JE 指令中到底是哪一个值被测试,你必须将先前所有被减掉的值做个总和。面对这种 switch 句型,我发现在每个 JE 指令旁做上「目前数值」的标记很有帮助。下面就是我对上一段码的批注:
MOV EAX,[EBP+ 0C ] ; Load EAX with the switch() argument.
SUB EAX,2
JE someAddress ; 2 (Jumps only if EAX was initially 2.)
DEC EAX
JE someAddress2 ; 3 (Jumps only if EAX was initially 3.)
DEC EAX
JE someAddress3 ; 4 (Jumps only if EAX was initially 4.)
SUB EAX,5
JE someAddress4 ; 9 (Jumps only if EAX was initially 9.)
switch 句型的第三种变形称为跳跃表格(jump table)。如果输入值十分接近,编译器也许会建立一个地址数组,每一个数组元素都对应一个 case 值。这种作法的好处是执行速度很快,因为不需要对每一个可能的输入值做检验。请看下面这段码:
switch ( i )
{
case 0x0: i = 2; break;
case 0x1: j = 2; break;
case 0x2: k = 3; break;
// Cases 3 through 8 not shown.
case 0x9: j = j + k + i; break;
}
编译后的结果如下:
00001008: mov eax,dword ptr [ebp -0C ]
0000100B: cmp eax,09
0000100E: ja 00001068
00001010: jmp dword ptr [eax*4+ 0040108F ]
第一个指令把 switch 的输入值放到 EAX 中。后面两个指令判断输入值是否在合法范围内。如果不是,JA 指令会跳离 switch 句子。最后一行码利用 EAX 做为数组索引,找出处理例程的地址,然后跳到该处去。前面程序代码中,编译器把处理例程的地址数组放在可执行文件的数据节区中。然而如果阵列紧跟在 JMP 指令之后,你也别太惊讶。这在 16 位程序中特别普遍。这时候 JMP 指令使用 CS 做为内存地址的一部份。这种情况下你可能会看到一些没用的暂时性指令,那是因为反组译器不知道那些字节到底是程序代码还是数据。一个好的反组译器应该能够辨识出这种形势,或者至少让你告诉它「code 节区中的某一部份其实是资料」。
我已经涵盖反组译器的基本观念了,让我们看一个实际例子,示范如何运用这些观念。我将以 Windows NT 的 CLOCK.EXE 为例,它可以切换程序状态为有(或没有)窗口标题。我选择这个程序有两点理由。第一,我已经从 spy 的角度检验过这个程序了,我们可以更进一步测试,然后比较两个方法所得的结果。第二,微软提供 CLOCK.EXE 原始码给 Win32 程序员,所以你可以判断反组译的精准度。
对这个例子,我使用自己的反组译器。微软的 DUMPBIN 当然也可以,但我的反组译器会自动做某些事情(特别是把 API 呼叫与其符号名称相配起来),而那却是使用DUMPBIN 做为工具时你必须手动完成的。下面是反组译器的最初输出结果:
12F 3B00: PUSH ESI
12F 3B01: PUSH EDI
12F 3B02: MOV ESI,DWORD PTR [ESP+ 0C ]
12F 3B06: PUSH F0
12F 3B08: PUSH ESI
12F 3B09: CALL GetWindowLongA
12F 3B0E: MOV EDI,EAX
12F 3B10: CMP DWORD PTR [ 012F 612C ],00
12F 3B17: JE 012F 3B30
12F 3B19: AND EDI,FFB4FFFF
12F 3B 1F : PUSH 00
12F 3B21: PUSH F4
12F 3B23: PUSH ESI
12F 3B24: CALL SetWindowLongA
12F 3B29: MOV [ 012F 6000],EAX
12F 3B2E: JMP 012F 3B44
12F 3B30: OR EDI,00CF0000
12F 3B36: MOV EAX,[ 012F 6000]
12F 3B3B: PUSH EAX
12F 3B 3C : PUSH F4
12F 3B3E: PUSH ESI
12F 3B 3F : CALL SetWindowLongA
12F 3B44: PUSH EDI
12F 3B45: PUSH F0
12F 3B47: PUSH ESI
12F 3B48: CALL SetWindowLongA
12F 3B4D: PUSH 27
12F 3B 4F : PUSH 00
12F 3B51: PUSH 00
12F 3B53: PUSH 00
12F 3B55: PUSH 00
12F 3B57: PUSH 00
12F 3B59: PUSH ESI
12F 3B 5A : CALL SetWindowPos
12F 3B 5F : PUSH 05
12F 3B61: PUSH ESI
12F 3B62: CALL ShowWindow
12F 3B67: POP EDI
12F 3B68: POP ESI
12F 3B69: RET 0004
最前面两行和最后面三行就是 prologue 码和 epilogue 码。其中只有两件事有点趣味:"RET 0004" 告诉我们这个函式需要一个参数(Win32 中的所有参数都是 4 个字节)。第二,程序代码没有设立 EBP 堆栈框架,所以我们必须追踪堆栈上的东西,以便决定参数在哪里。够幸运的了,这段码之中只有一个指令用到堆栈中的参数。那是 prologue 码之下的:
MOV ESI, DWORD PTR [ESP+ 0C ]
这个指令把参数拷贝到 ESI 中,ESI 会在其它许多地方被用到。看起来 ESI 似乎是某种缓存器变量。唔...ESI 可能是什么呢? 扫描整段码, 我们发现 ESI 被用做GetWindowLong、SetWindowLong、SetWindowPos、ShowWindow 的参数。会不会 ESI 是个 HWND?看起来的确是。让我们以我们所发现的东西,重写上述程序代码,并消除 prologue 和 epilogue 码:
12F 3B02: MOV hWnd(ESI),DWORD PTR [ESP+ 0C ]
12F 3B06: PUSH F0
12F 3B08: PUSH hWnd(ESI)
12F 3B09: CALL GetWindowLongA
12F 3B0E: MOV EDI,EAX
12F 3B10: CMP DWORD PTR [ 012F 612C ],00
12F 3B17: JE 012F 3B30
12F 3B19: AND EDI,FFB4FFFF
12F 3B 1F : PUSH 00
12F 3B21: PUSH F4
12F 3B23: PUSH hWnd(ESI)
12F 3B24: CALL SetWindowLongA
12F 3B29: MOV [ 012F 6000],EAX
12F 3B2E: JMP 012F 3B44
12F 3B30: OR EDI,00CF0000
12F 3B36: MOV EAX,[ 012F 6000]
12F 3B3B: PUSH EAX
12F 3B 3C : PUSH F4
12F 3B3E: PUSH hWnd(ESI)
12F 3B 3F : CALL SetWindowLongA
12F 3B44: PUSH EDI
12F 3B45: PUSH F0
12F 3B47: PUSH hWnd(ESI)
12F 3B48: CALL SetWindowLongA
12F 3B4D: PUSH 27
12F 3B 4F : PUSH 00
12F 3B51: PUSH 00
12F 3B53: PUSH 00
12F 3B55: PUSH 00
12F 3B57: PUSH 00
12F 3B59: PUSH hWnd(ESI)
12F 3B 5A : CALL SetWindowPos
12F 3B 5F : PUSH 05
12F 3B61: PUSH hWnd(ESI)
12F 3B62: CALL ShowWindow
现在,我们要把数个函式呼叫(GetWindowLong、SetWindowLong、SetWindowPos 和ShowWindow)转换为 C 型式。这些函式的参数要不是我们所发现的hWnd,就是一个可以从 WINDOWS.H 中查到的数值。让我们重写程序代码如下:
12F 3B02: MOV hWnd(ESI),DWORD PTR [ESP+ 0C ]
GetWindowLong( hWnd, GWL_STYLE ); // GWL_STYLE == -16 == 0F 0h
12F 3B0E: MOV EDI,EAX
12F 3B10: CMP DWORD PTR [ 012F 612C ],00
12F 3B17: JE 012F 3B30
12F 3B19: AND EDI,FFB4FFFF
SetWindowLong( hWnd, GWL_ID, 0 ); // GWL_ID == -12 == 0F 4h
12F 3B29: MOV [ 012F 6000],EAX
12F 3B2E: JMP 012F 3B44
12F 3B30: OR EDI,00CF0000
12F 3B36: MOV EAX,[ 012F 6000]
12F 3B3B: PUSH EAX
12F 3B 3C : PUSH F4
12F 3B3E: PUSH hWnd(ESI)
12F 3B 3F : CALL SetWindowLongA
12F 3B44: PUSH EDI
12F 3B45: PUSH F0
12F 3B47: PUSH hWnd(ESI)
12F 3B48: CALL SetWindowLongA
SetWindowPos( hWnd,0,0,0,0,0, // 0x27 == the flags on the next line
SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_FRAMECHANGED);
ShowWindow( hWnd, SW_SHOW );
虽然我们可以把一些函式改写为 C 型式, 却还没有足够的信息了解最后两个SetWindowLong 的参数是什么。对于其中之一,我们必须知道 EDI 内含什么,对于另一个我们则必须知道 [ 012F 6000] 地址处是哪一个全域变量。等等!我们已经看到,GetWindowLong 取出代表窗口风格的旗标值,拷贝到 EDI 去。EDI 或许是另一个缓存器变量,用来储存窗口风格旗标位。而对于 [ 012F 6000] 地址,请注意程序代码将 SetWindowLong(GWL_ID) 的回返值储存到其中。稍早前我曾说过,对top-level 窗口而言,窗口识别码字段(GWL_ID)系用来储存HMENU。把这些事实串在一起,你可以猜得出来,[ 012F 6000] 是一个全域变数,内含一个 menu handle(HMENU)。让我们根据这两项新发现重写整段程序代码:
winStyle = GetWindowLong( hWnd, GWL_STYLE );
12F 3B10: CMP DWORD PTR [ 012F 612C ],00
12F 3B17: JE 012F 3B30
winStyle &= ~(WS_DLGFRAME | WS_SYSMENU | WS_MINIMIZEBOX |
WS_MAXIMIZEBOX);
HMenu = SetWindowLong( hWnd, GWL_ID, 0 );
12F 3B2E: JMP 012F 3B44
12F 3B30:
winStyle |= (WS_BORDER | WS_DLGFRAME | WS_SYSMENU |
WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX);
SetWindowLong( hWnd, GWL_ID, HMenu );
12F 3B44:
SetWindowLong(hWnd, GWL_STYLE, winStyle);
SetWindowPos( hWnd,0,0,0,0,0,
SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_FRAMECHANGED);
ShowWindow( hWnd, SW_SHOW );
现在只剩下 [ 012F 3B10] 处的条件句了。CMP 指令比较 [ 012F 612C ] 中的全域变量是否为 0,然后立刻以一个 JMP 指令进行条件式跳跃。看起来这似乎是个标准的 if-else 动作。[ 012F 612C ] 看起来似乎是某种布尔值(boolean)。暂时给它一个名称吧,就叫"MyBool" 好了。安插一些 { } 并缩排,程序代码现在变成这样:
winStyle = GetWindowLong( hWnd, GWL_STYLE );
if ( MyBool != 0 )
{
// Turn off the style bits need for the titlebar, boxes, and menu.
winStyle &= ~(WS_DLGFRAME | WS_SYSMENU | WS_MINIMIZEBOX |
WS_MAXIMIZEBOX);
// Set the window’s HMENU field to 0.
HMenu = SetWindowLong( hWnd, GWL_ID, 0 );
}
else
{
// Turn on the style bits needed for a titlebar, boxes, and menu.
winStyle |= (WS_BORDER | WS_DLGFRAME | WS_SYSMENU |
WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX);
// Set the window’s HMENU field back to whatever it was before.
SetWindowLong( hWnd, GWL_ID, HMenu );
}
// Blast the style bits into the window.
SetWindowLong(hWnd, GWL_STYLE, winStyle);
// Force Windows to recalculate and repaint what the window should look like.
SetWindowPos( hWnd,0,0,0,0,0,
SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_FRAMECHANGED);
ShowWindow( hWnd, SW_SHOW );
真令人惊讶。我们把一段汇编语言码转换为易读的 C 语言码。如果你把这段码拿来和spy 的观察结果比较,你会发现一切吻合。然而反组译所获得的数据比你从 spy 工具处所得到的丰富得多。例如,spy 软件就绝对不会告诉你有两个全域变数牵扯在其中(一个 HMENU 和一个布尔值)。
对某些人而言,这段动作(汇编语言码到 C 语言码)或许是太快了些。并不是每一次反组译都能够这么顺畅这么快速。我希望我已经给你留下这个印象了:反组译过程需要一次一次地修正。做一些假设、找出程序代码显示之外的信息、然后把它回馈到程序代码中、再做一些修正...。最后我要提醒你一点,对于「把有问题的码放到除错器去深入观察」这一点,千万不要犹豫不决。看到程序代码以活生生的数值运转着,常常可以突破心理上的障碍。许多时候我没办法理解某个函式传回什么东西,但是以除错器进去看实际的传回值之后,我常常就能够演绎出某种模型,例如「某个函式总是传回一个 global memory handle」等等。重点是,每一小片线索都可能有帮助。你或许会惊讶那么小的东西可以帮助你打开那么大的视野。