为什么像diff
,kdiff3
或者甚至更复杂的文本差异工具通常无法以文本形式突出显示两个反汇编之间的差异 - 特别是两个相关的二进制可执行文件,例如同一程序的不同版本?
为什么 diff/meld/kdiff/... 在反汇编上不令人满意?
我认为这个问题值得回答,所以我给出了问答风格的答案,因为出于某种奇怪的原因,它不适合 600 个字符的评论;)
不过,请不要错过罗尔夫的回答!
为什么像diff
,kdiff3
或者甚至更复杂的文本差异工具通常无法以文本形式突出显示两个反汇编之间的差异 - 特别是两个相关的二进制可执行文件,例如同一程序的不同版本?
为什么 diff/meld/kdiff/... 在反汇编上不令人满意?
我认为这个问题值得回答,所以我给出了问答风格的答案,因为出于某种奇怪的原因,它不适合 600 个字符的评论;)
不过,请不要错过罗尔夫的回答!
TL;DWTR(太长,不想读):跳到部分为什么比较二进制可执行代码是一项艰巨的任务?如果您对组装和拆卸的基本知识感到满意。或者跳到这个答案的底部(TL;DR)。
二进制可执行代码供计算机阅读,这就是为什么它通常被称为机器代码。这意味着它是二进制“数据”,通常用肉眼表示为十六进制数。一类工具 此任务存在名为“十六进制编辑器”。
这是通常的外观,使用HTE作为屏幕截图:
之所以如此方便,是因为每个十六进制数字恰好代表一个半字节,即 4 位。因此可以使用两个十六进制数字来表示单个 8 位字节(最常见的字节类型)。然后以每行 16 个字节的倍数显示它们还有一个额外的优势,即更容易读取十六进制偏移量(在上面的屏幕截图中的十六进制字节前面以 8 位十六进制数的形式给出),因为十进制16
是0x10
. 十六进制数最常见的表示法是:
0x
:例如0x10
(C 和相关语言)$
:例如$10
(Pascal,Delphi)h
:eg 10h
(汇编语言)旁注:代码和数据之间的界限很窄,反汇编程序有时很难将二进制文件中的字节识别为一个或另一个,尽管可以应用启发式方法来帮助识别过程。
除了十六进制数的“原始”形式外,还有一种人类可读的表示形式,称为汇编语言。这是二进制指令(或操作码)的助记符形式,通常以非常小的抽象1:1 表示它们。值得注意的例外是宏汇编程序,例如Microsoft 的 MASM,它们提供更高级别的抽象以方便使用。
旁注:消化汇编语言代码(也称为“汇编代码”或“汇编代码”)的程序称为assembler。
根据处理器的类型和确切的体系结构,存在各种风格的汇编语言。对于这个问题的范围,我们将坚持使用IA-32 架构- 也称为 x86(32 位,x86),因为它起源于 8088 和 8086 处理器,随后的处理器 (CPU) 型号被编号80x86
,其中x
是一位数字。从 80586 开始,Intel 在推出 Pentium 时就偏离了该命名方案。
尽管如此,很高兴知道存在两种主要的处理器架构:CISC(68k、x86、x86-64)和RISC(SPARC、MIPS、PPC),而其中一个的爱好者声称他们的首选架构在有一次,直到今天,两者仍然存在,尽管在微码级别,人们甚至可以认为 CISC 架构是“内部”的 RISC。也就是说,x86是一种 CISC 架构。
只是为了让您一瞥 RISC 和 CISC 的对比情况,让我们看一些基本的 x86 和MIPS 指令:
x86 | MIPS | Meaning
-----------------------------------------------
mov | lb, lw, sb, move | copy/"move" value from/to register/memory location
jmp, jz, jnz | j/b, beq, beqz, bgez | jump unconditionally or on condition
call | jal | call other routine / jump and link
什么有望成为乍(带线清晰mov
)是在MIPS你摇头数目大得多比86非常基本的说明,这是CISC与RISC模式的要旨的事实。另一件事是我们看到 MIPS 如何使用j
和b
作为跳转和分支的助记前缀(区别通常是这些“跳转”可以覆盖的距离),而 x86 也使用j
as in jmp
(无条件跳转),jnz
(如果不是零则跳转[ -flag set]) 但也有一个专用的call
操作码,据我所知 MIPS 没有 - 最接近的近似值可能是jal
(跳转和链接),它也将程序计数器存储在寄存器中,而不是 x86 的call
,不过,它存储它在堆栈上。
在 CISC 中,您可以使用一条指令完成相对复杂的操作,而在 RISC 中,您通常需要多条指令来表达相同的内容。事实上,RISC 架构的汇编器往往具有所谓的伪指令,这些伪指令结合了经常使用的指令组合,但在翻译阶段被翻译成单个 RISC 指令。
一个例子是ror
(位旋转右)指令。在 x86 (CISC) 上,这本身就是一个带有ror
助记符的操作码。在 MIPS (RISC) 上,这是一个伪指令,当汇编器提供它时,它被翻译如下(将寄存器中的值位旋转$t2
一位并$t2
再次存储结果):
ror $t2, $t2, 1 --> sll $1 , $10, 31 (bit-shift $10 left by 31, store in $1)
srl $10, $10, 1 (bit-shift $10 right by 1, store in $10)
or $10, $10, $1 (bit-wise or $1 and $10)
此示例取自此处 ( MIPS 资源),第 370 页后面引用的书。
但是,我们不会深入研究汇编语言基础知识,而是专注于回答问题。我认为,理解为什么简单甚至复杂的 diff 工具无法显示二进制可执行文件的差异的原因没有必要了解最基本的事实。
编译器(编译器) 将通常由人类或机器编写的代码结构转换为机器代码 [*]。通常的翻译是将人类可读的源代码翻译成中间形式,然后进行优化,优化后翻译成汇编语言,然后再翻译成机器代码。这在上面的维基百科文章中得到了很好的展示,下面是我在此处复制的图表:
要点是,编译器通常在后端的某个地方有一个汇编程序,即使您自己可能永远不会看到实际的汇编语言指令。如果你想得到它,你可以使用:
gcc -S ...
(AT&T 语法)或gcc -masm=intel -S ...
(Intel 语法)cl.exe /Fa ...
[*] 这不是全部事实,因为有些编译器会创建一个中间字节码,然后将其进一步翻译成它正在运行的 CPU 本地的机器代码或即时解释。但是对于这个答案的范围,我们将编译器视为通过多个处理阶段将人类可读的高级语言源代码转换为机器代码的实体,其中一个阶段涉及汇编代码。
注意:某些编译器,例如 Embarcadero Delphi,会向您隐藏不同的阶段,并将从编译到链接的过程呈现为一个不透明的步骤。这可能会在尝试学习 C/C++ 的 Delphians 中引起一些混乱,这些 C/C++暴露了不同的步骤。
对于 x86,存在两种相互竞争的语法变体。AT&T 语法在 *nix 世界中受到青睐,在 Windows 世界中受到GASM、Intel 以及大多数反汇编器和汇编器的青睐。考虑这个简单的 C 程序:
#include <stdio.h>
int main()
{
printf("Hello world!\n");
return 0;
}
...以及分别使用 AT&T(gcc -S hello.c
或显式gcc -masm=att -S hello.c
)和 Intel ( gcc -masm=intel -S hello.c
) 语法的翻译:
AT&T | Intel
------------------------------------------------------------------------
.LC0: | .LC0:
.string "Hello world!" | .string "Hello world!"
.text | .text
.globl main | .globl main
.type main, @function | .type main, @function
main: | main:
pushl %ebp | push ebp
movl %esp, %ebp | mov ebp, esp
andl $-16, %esp | and esp, -16
subl $16, %esp | sub esp, 16
movl $.LC0, (%esp) | mov DWORD PTR [esp], OFFSET FLAT:.LC0
call puts | call puts
movl $0, %eax | mov eax, 0
leave | leave
ret | ret
您会注意到语法的不同之处。AT&T 语法中的寄存器用%
, 文字值表示$
,源和目标寄存器的位置与 Intel 语法相反。此外,一些助记符不同(movl
而不是mov
)。在 Intel 语法中,操作数的大小有助于推断预期的操作 - 在寄存器的情况下,这通过分别使用 EAX、AX 和 AL/AH 来表示DWORD
(32 位)、WORD
(16 位)和BYTE
(8 位)大小来明确。但是,该行:
mov DWORD PTR [esp], OFFSET FLAT:.LC0
巧妙地展示了您必须如何明确内存位置的大小才能使其正确。AT&T的movl
助记符意味着这是因为(这里32位),“长动”的应有之义,所以没必要一提的是,我们正在访问一个DWORD
比其他l
的movl
。
请注意:为简洁起见,我在生成的汇编代码的顶部和底部修剪了一些不相关的部分。
对于有兴趣的读者,我建议您获得一份优秀但价格昂贵的副本:
如果您想试验 MIPS,您可以获取SPIM,参考其文档或简单地使用搜索引擎来查找有用的信息,例如此快速教程。
同样,使用搜索引擎查找更多信息或查阅您喜欢的汇编程序的文档,例如NASM。
将二进制机器语言翻译回助记符表示(通常为 1:1)的过程称为反汇编或反汇编。该过程的结果通常也称为反汇编。
用于该过程的工具称为反汇编程序。
由于这主要是一个 1:1 的过程,就像反向(汇编到机器代码)一样,因此无需详细介绍。手写或编译器生成的汇编与生成的二进制代码的反汇编之间存在很大差异,我们将通过比较反汇编器和编译器的输出来更好地了解这一点。
因此,事不宜迟,让我们来看一个实际示例,该示例说明为什么差异很难。
注意:对于本答案的其余部分,我们将使用 Intel 语法作为汇编代码。为简洁起见,我们还将删除 GCC 输出的一些冗余部分。
在我们的第一次迭代中,我们有以下 C 代码(我将其命名为ptest1.c
):
#include <stdio.h>
int syntax_help(int argc)
{
return 20 + argc;
}
int main(int argc, char **argv)
{
if (argc < 3)
return syntax_help(argc);
else if (argc == 3)
return 42;
// else ...
return 0;
}
... 将其编译为程序集gcc -O0 -masm=intel -S -o ptest1.asm ptest1.c
给我们:
.globl syntax_help
.type syntax_help, @function
syntax_help:
push ebp
mov ebp, esp
mov eax, DWORD PTR [ebp+8]
add eax, 20
pop ebp
ret
.size syntax_help, .-syntax_help
.globl main
.type main, @function
main:
push ebp
mov ebp, esp
sub esp, 4
cmp DWORD PTR [ebp+8], 2
jg .L4
mov eax, DWORD PTR [ebp+8]
mov DWORD PTR [esp], eax
call syntax_help
jmp .L5
.L4:
cmp DWORD PTR [ebp+8], 3
jne .L6
mov eax, 42
jmp .L5
.L6:
mov eax, 0
.L5:
leave
ret
现在让我们稍微修改一下程序,然后再次组装它,看看它的外观。
#include <stdio.h>
int syntax_help(int argc)
{
switch (argc)
{
case 0:
return -1;
case 1:
return 23;
default:
return 20 + argc;
}
}
int main(int argc, char **argv)
{
if (argc < 5)
return syntax_help(argc);
else if (argc == 5)
return 42;
// else ...
return 0;
}
如您所见,3
in的两个实例main
更改为5
,我们对syntax_help
. 显然,这是一个人为的例子,但这正是重点。
.globl syntax_help
.type syntax_help, @function
syntax_help:
push ebp
mov ebp, esp
mov eax, DWORD PTR [ebp+8]
test eax, eax
je .L3
cmp eax, 1
je .L4
jmp .L7
.L3:
mov eax, -1
jmp .L5
.L4:
mov eax, 23
jmp .L5
.L7:
mov eax, DWORD PTR [ebp+8]
add eax, 20
.L5:
pop ebp
ret
.size syntax_help, .-syntax_help
.globl main
.type main, @function
main:
push ebp
mov ebp, esp
sub esp, 4
cmp DWORD PTR [ebp+8], 4
jg .L9
mov eax, DWORD PTR [ebp+8]
mov DWORD PTR [esp], eax
call syntax_help
jmp .L10
.L9:
cmp DWORD PTR [ebp+8], 5
jne .L11
mov eax, 42
jmp .L10
.L11:
mov eax, 0
.L10:
leave
ret
那是一口。现在让我们深入研究一个差异——除了“优化”方面——这与一个潜在的人工编写的程序集之间的区别。以下是人工编写的版本可能的样子:
.globl syntax_help
.type syntax_help, @function
syntax_help:
push ebp
mov ebp, esp
mov eax, DWORD PTR [ebp+8]
test eax, eax
je .zero_args
cmp eax, 1
je .one_arg
jmp .return_20plus
.zero_args:
mov eax, -1
jmp .exit_help
.one_arg:
mov eax, 23
jmp .exit_help
.return_20plus:
mov eax, DWORD PTR [ebp+8]
add eax, 20
.exit_help:
pop ebp
ret
.size syntax_help, .-syntax_help
.globl main
.type main, @function
main:
push ebp
mov ebp, esp
sub esp, 4
cmp DWORD PTR [ebp+8], 4
jg .return_42
mov eax, DWORD PTR [ebp+8]
mov DWORD PTR [esp], eax
call syntax_help
jmp .exit
.return_42:
cmp DWORD PTR [ebp+8], 5
jne .return_0
mov eax, 42
jmp .exit
.return_0:
mov eax, 0
.exit:
leave
ret
任何曾经编写过汇编代码的人都会不可避免地注意到我在这里没有声明变量 ( db
, dw
, dd
)。这将是正常的行动过程,但当然,我在这里只是表明我们人类倾向于为代码位置(和变量)赋予符号名称。如果您手动编写程序集,它看起来仍然不同,我只是将代码调整为更像人类可能编写的代码(即它并不完美,当然也不是“手动优化”)。编译器会顽固而有效地在某种字母前缀上添加一个数字并完成它。让我们还使用相同的名称创建第一次迭代的可能的人工编写版本:
.globl syntax_help
.type syntax_help, @function
syntax_help:
push ebp
mov ebp, esp
mov eax, DWORD PTR [ebp+8]
add eax, 20
pop ebp
ret
.size syntax_help, .-syntax_help
.globl main
.type main, @function
main:
push ebp
mov ebp, esp
sub esp, 4
cmp DWORD PTR [ebp+8], 2
jg .return_42
mov eax, DWORD PTR [ebp+8]
mov DWORD PTR [esp], eax
call syntax_help
jmp .exit
.return_42:
cmp DWORD PTR [ebp+8], 3
jne .return_0
mov eax, 42
jmp .exit
.return_0:
mov eax, 0
.exit:
leave
ret
这是diff ptest1.asm ptest2.asm
(编译器生成的表单)的输出:
1c1
< .file "ptest1.c"
---
> .file "ptest2.c"
9a10,22
> test eax, eax
> je .L3
> cmp eax, 1
> je .L4
> jmp .L7
> .L3:
> mov eax, -1
> jmp .L5
> .L4:
> mov eax, 23
> jmp .L5
> .L7:
> mov eax, DWORD PTR [ebp+8]
10a24
> .L5:
20,21c34,35
< cmp DWORD PTR [ebp+8], 2
< jg .L4
---
> cmp DWORD PTR [ebp+8], 4
> jg .L9
25,28c39,42
< jmp .L5
< .L4:
< cmp DWORD PTR [ebp+8], 3
< jne .L6
---
> jmp .L10
> .L9:
> cmp DWORD PTR [ebp+8], 5
> jne .L11
30,31c44,45
< jmp .L5
< .L6:
---
> jmp .L10
> .L11:
33c47
< .L5:
---
> .L10:
对理解差异没有帮助,是吗?
WinMerge提供了更直观的结果。混乱接踵而至……
注意:我决定不修改全高屏幕截图,而是注意左侧窗格,该窗格突出显示了差异(黄色)和丢失的块(灰色)和移动的块(棕色...ish)。
这是diff ptest1.asm-human ptest2.asm-human
(“人工编写”形式)的输出:
6a7,19
> test eax, eax
> je .zero_args
> cmp eax, 1
> je .one_arg
> jmp .return_20plus
> .zero_args:
> mov eax, -1
> jmp .exit_help
> .one_arg:
> mov eax, 23
> jmp .exit_help
> .return_20plus:
> mov eax, DWORD PTR [ebp+8]
7a21
> .exit_help:
17c31
< cmp DWORD PTR [ebp+8], 2
---
> cmp DWORD PTR [ebp+8], 4
24c38
< cmp DWORD PTR [ebp+8], 3
---
> cmp DWORD PTR [ebp+8], 5
哇,这实际上几乎是可读的。使用colordiff
它是有用的。
WinMerge 中相应的视觉比较看起来非常可读:
反汇编器只能在一定程度上聪明,因为它是一个程序。即使是编写本文时最先进的反汇编程序的 IDA Pro,也无法正确猜测所有内容 - 例如,在区分代码或数据时。但是更复杂的工具在这方面做得很好。IDA 将I添加为交互式.
反汇编人员遇到的一件事是汇编程序员所谓的标签和(子)例程。
标签,虽然它们存在于 C 中并且(正确地)与 一起不受欢迎goto
,但也存在于更高级别的语言中,但往往涵盖一些不同的概念。也许最接近汇编语言概念的是BASIC的美好时光中的标签。但是,当您将 C 编译为汇编代码时,每个条件都会被转换为条件或无条件跳转(上述编译器生成的代码中的jmp
, je
, jg
, jne
)。跳转目标称为标签。跳转是代码有条件或无条件分支的地方。
与例程最接近的对应概念是 C 中的函数或Pascal 中的procedure
/function
或sub
BASIC 中的 。
除了 之外call
,两条分支指令之间的每个代码块或多或少都称为基本块。在 IDA Pro 中,这在图形视图中清晰地可视化(可以通过默认的平面视图切换Space):
主 IDA 视图中由箭头链接的每个块都是一个基本块。
到现在为止,您应该对什么使比较变得困难有了一个模糊的概念,但让我们更进一步。让我们从比较编译器生成的和“人工编写的”汇编代码切换到实际的反汇编。
和以前一样,我们将坚持它的要点。
但只是提一下,在反汇编中,链接器 破坏了它后的结果。之前编译器生成的程序集仅包含我们在示例程序中编写的代码。
只是为了给你一个想法,我.asm
使用 IDA生成了一个文件,将其剥离为没有注释和空行的所有内容,最终仍然是 362 行,而不是原始编译器生成的程序集中的 52 行,其中包括使用的元数据通过链接器。这种巨大的差异当然可以归因于链接器添加了初始化可执行文件所需的各种代码。简而言之:它是编译器(或更准确地说是其链接器)添加的样板代码。
对于这个比较,我将完全省略这个样板代码,尽管这显然只会增加diff
工具在比较二进制可执行代码时遇到的复杂性。
与上面显示ptest2.c
反汇编的 IDA 屏幕截图不同,实际上您大部分时间都必须在没有调试符号的情况下工作。这意味着诸如main
和之类的名称syntax_help
将不再存在。相反,像 IDA Pro 这样的反汇编程序大多采用以偏移量命名例程(例如sub_80483DB
)。它同样适用于标签(即命名那些loc_80483F4
或locret_something
)。当然,逆向工程师可以自由地将这些名称更改为自己更易读/更容易识别的名称。但是默认名称仍然取决于偏移量。
事实上,反汇编器很难识别main
函数,因为从可执行文件的入口点开始查看它时,前面提到的样板代码往往在它之前。如果ptest2.c
之前编译的(即 run strip -s ...
)没有可用的符号,IDA 会显示以下内容:
现在让我们看看编译(和剥离)的入口点ptest1.c
:
你注意到区别了吗?这很微妙,但让我为您将它们并排放置:
是的,突出显示的线......哦,偏移量不同。这意味着什么?
嗯,这意味着 IDA Pro 分配给例程和标签(即基本块)的符号名称将根据这些实体在文件中的偏移量而有所不同。
这确实与我们之前遇到的编译器生成的汇编代码和编号标签名称非常相似。
让我们在不同的文本中比较由更简单的反汇编器创建的相关代码段。
使用objdump -M intel -d ...
然后去掉前导偏移量和空格,我们为 WinMerge 中的相关部分得到了这个:
完整的命令是:
objdump -M intel -d ptest1.stripped|grep '^ 80'|cut -f 2- -d ':'|sed 's/^\s*//g'
objdump -M intel -d ptest2.stripped|grep '^ 80'|cut -f 2- -d ':'|sed 's/^\s*//g'
这意味着文本版本比较的工具,如diff
,kdiff3
,WinMerge
和许多其他人将有一个很难比较反汇编,除非反向工程了重新命名所有的程序和标签不是基于偏移东西的时间。
事实上,当面临文本形式的反汇编时,这几乎成为一项不可逾越的任务。IDA Pro 保留的拆解内部形式更加合适。
在文本形式中,每个更改的偏移量 - 并且会有很多 - 会引起您的注意,因为它与文本不同。
不是说我们知道问题所在,我们能做些什么呢?
基本块是解决手头问题的答案。之类的工具DarunGrim(FLOSS),patchdiff2(FLOSS)和Bindiff(商业)使用IDA的有关基本模块构建图形知识。然后可以使用这些图来识别相似和不同的块。通过图形形式的抽象,可视化可以叠加在 IDA 内部的相应视图上,也可以提供专门的视图。
如您所见,当您将反汇编导出到文本文件时,您正在从中剥离 IDA 为您保留在其数据库中的大量上下文信息。而是从 IDA 已有的信息中提取并使用它。插件和脚本允许您进入 IDA 数据库的内部并提取其中的宝藏,以一种文本永远无法做到的方式来理解基本块。
有关能够处理该任务的工具列表,请参阅引发此问题的问题的答案:
文本差异不足以处理文本反汇编的原因是因为文本表示丢弃了反汇编程序在反汇编过程中收集的有价值的信息。此外,反汇编程序以偏移量命名代码位置和变量 - 对程序的更改以及随后的重新编译几乎会更改所有偏移量,因此会在文本表示中产生大量干扰。文本不同会指出每一个,使得从逆向工程师的角度无法找到相关的变化。
大多数问题都是由于对源代码的微小更改可能导致对已编译二进制文件进行较大更改这一事实而产生的。事实上,对源代码不做任何更改仍然会导致不同的二进制文件。
如果你想比较二进制文件,编译器优化会毁了你的一天。最坏的情况是,如果您使用不同的编译器编译了两个二进制文件,或者编译器的不同修订版,或者在不同优化设置下编译器的相同修订版。
想到的几个例子:
内联。这种优化实际上可以完全去除函数,并且可以改变优化函数的控制流图。
指令调度对给定基本块内的指令重新排序以最小化流水线停顿。这对 UNIX 差异式工具造成了严重破坏。
循环不变代码运动。这种优化实际上可以改变一个函数内基本块的数量!在不同优化级别编译的相同函数可以具有不同的控制流签名。
程序内寄存器分配。假设通过在某处添加引用某个已在函数中定义的变量的 if 语句来更改函数。再次使用变量的行为修改了函数变量的定义-使用链。现在,当编译器为给定函数生成低级代码时,它使用 use-def 信息在每个点决定哪些变量应该在堆栈上,哪些应该放在寄存器中。这是过程内寄存器分配。因此,结果可能是,简单地添加一行代码会导致变量保存在不同的寄存器中,和/或保存在堆栈中而不是寄存器中(反之亦然),这显然会影响编译代码的外观。
诸如“过程间寄存器分配”(IRA)、过程间公共子表达式消除(ICSE)等过程间优化会极大地影响编译后的二进制文件的布局,并且它们对源代码中的微小变化也很敏感。例如,IRA 将为不需要符合标准调用约定的函数制定新的调用约定,例如,因为它们不是从包含它们的模块或库中导出的,并且从不通过函数指针引用。ICSE 可以从给定函数中删除部分代码。
配置文件引导优化 (PGO)。在这种优化下,编译器首先生成一个带有额外代码的二进制文件,用于计算有关程序运行时行为的统计信息。然后,程序员将已检测的代码置于“典型工作负载”之下并计算统计信息。然后,程序员重新编译程序,将这些统计信息提供给编译器并告诉它通过 PGO 生成代码。然后编译器通过按每个函数执行的频率对代码进行排序,通过函数的哪些路径最常见等来显着改变二进制文件的布局。不同的训练集将产生不同的统计配置文件,从而产生截然不同的可执行文件。
这不是一个详尽的清单。许多其他优化将困扰您。主要是由于编译器优化,UNIX 差异样式工具在二进制比较空间中几乎没有用处。
我做的一件事是读取机器代码并将其转换回没有任何寻址地址值的 IR 伪操作码,然后在对每个伪 IR 二进制文件使用这种减少方法后执行这两个伪 IR 二进制文件之间的差异。