为什么机器码反编译器的能力不如 CLR 和 JVM 的反编译器?

逆向工程 反编译 x64 x86 手臂
2021-06-09 01:21:18

Java 和 .NET 反编译器(通常)可以生成几乎完美的源代码,通常非常接近原始代码。

为什么不能对本机代码做同样的事情?我尝试了一些,但它们要么不起作用,要么产生一堆带有指针的 goto 和强制转换。

2个回答

TL;DR:机器码反编译器非常有用,但不要期望它们为托管语言提供同样的奇迹。列举几个限制:结果一般无法重新编译,缺少来自原始源代码的名称、类型和其他关键信息,可能比原始源代码减去注释更难阅读,并且可能会留下奇怪的反编译列表中特定于处理器的工件。

  1. 为什么反编译器如此受欢迎?

    反编译器是非常有吸引力的逆向工程工具,因为它们有可能节省大量工作。事实上,它们对于诸如 Java 和 .NET 之类的托管语言非常有效,以至于“Java 和 .NET 逆向工程”作为一个主题几乎不存在。这种情况让很多初学者怀疑机器码是否也是如此。不幸的是,这种情况并非如此。机器代码反编译器确实存在,并且有助于节省分析人员的时间。但是,它们只是对非常手动的过程的辅助。之所以如此,是因为字节码语言和机器码反编译器面临着不同的挑战。

  2. 我会在反编译的源代码中看到原始变量名称吗?

    一些挑战来自整个编译过程中语义信息的丢失。托管语言通常保留变量的名称,例如对象中字段的名称。因此,很容易向人类分析师展示程序员创建的希望有意义的名称。这提高了反编译机器码的理解速度。

    另一方面,机器代码程序的编译器在编译程序时通常会破坏所有这些信息的大部分(也许以调试信息的形式留下一些信息)。因此,即使机器码反编译器在其他方面都很完美,它仍然会呈现非信息变量名称(例如“v11”、“a0”、“esi0”等),这会减慢人类的理解速度.

  3. 我可以重新编译反编译的程序吗?

    一些挑战与反汇编程序有关。在 Java 和 .NET 等字节码语言中,与编译对象关联的元数据通常会描述对象内所有代码字节的位置。即,所有函数都将在对象标题中的某个表中具有一个条目。

    另一方面,在机器语言中,以 x86 Windows 反汇编为例,如果没有大量调试信息(如 PDB)的帮助,反汇编程序不知道二进制文件中的代码位于何处。它给出了一些提示,例如程序的入口点。结果,机器代码反汇编器被迫实现自己的算法来发现二进制文件中的代码位置。他们通常使用两种算法:线性扫描(扫描文本部分以查找通常表示函数开头的已知字节序列)和递归遍历(当遇到对固定位置的调用指令时,将该位置视为包含代码)。

    然而,这些算法通常不会发现二进制文件中的所有代码,这是由于编译器优化,例如修改函数序言导致线性扫描组件失败的过程间寄存器分配,以及由于自然发生的间接控制流(即通过调用函数指针)导致递归遍历失败。因此,即使机器码反编译器没有遇到其他问题,一般也不能产生整个程序的反编译结果,因此无法重新编译结果。

    上面描述的代码/数据分离问题属于一类特殊的理论问题,称为“不可判定”问题,它与其他不可能的问题(例如停机问题)共享。因此,放弃寻找自动机器代码反编译器的希望,该反编译器将产生可以重新编译的输出,以获得原始二进制文件的克隆。

  4. 我是否会获得有关反编译程序使用的对象的信息?

    还存在与 C 和 C++ 等语言与托管语言的编译方式有关的挑战;我将在这里讨论类型信息。在 Java 字节码中,有一个称为“new”的专用指令来分配对象。它接受一个整数参数,该参数被解释为对 .class 文件元数据的引用,该元数据描述了要分配的对象。此元数据依次描述类的布局、成员的名称和类型等。这使得以人类检查员满意的方式反编译对类的引用变得非常容易。

    另一方面,在编译C++程序时,在没有RTTI等调试信息的情况下,对象的创建就不是很整洁了。它调用用户指定的内存分配器,然后将结果指针作为参数传递给构造函数(也可能是内联的,因此不是函数)。访问类成员的指令在语法上与局部变量引用、数组引用等没有区别。此外,类的布局不存储在二进制文件中的任何位置。实际上,发现剥离二进制文件中数据结构的唯一方法是通过数据流分析。因此,反编译器必须实现自己的类型重构以应对这种情况。实际上,

  5. 反编译的控制流结构是否与原始源代码基本相同?

    一些挑战源于编译器优化已应用于已编译的二进制文件。与攻击性较低的编译器相比,被称为“尾部合并”的流行优化导致程序的控制流被破坏,这通常表现为反编译中的大量 goto 语句。稀疏 switch 语句的编译会导致类似的问题。另一方面,托管语言通常具有 switch 语句指令。

  6. 当涉及处理器的模糊方面时,反编译器会给出有意义的输出吗?

    一些挑战源于相关处理器的架构特性。例如,x86 上的内置浮点单元就是一场磨难的噩梦。没有浮点“寄存器”,有一个浮点“堆栈”,必须精确跟踪才能正确反编译程序。相比之下,托管语言通常有专门的指令来处理浮点值,浮点值本身就是变量。(Hex-Rays 可以很好地处理浮点运算。)或者考虑这样一个事实,即 x86 上有数百种合法指令类型,其中大多数从未由常规编译器生成,除非用户明确指定它应该通过固有的。反编译器必须包括对它本机支持的那些指令的特殊处理,

这些只是困扰机器代码反编译器的一些可访问的挑战示例。我们可以预期,在可预见的未来,限制仍将存在。因此,不要寻求与托管语言反编译器一样有效的灵丹妙药。

反编译很困难,因为反编译器必须恢复二进制/字节码目标中缺少的源代码抽象。

有几种类型的抽象:

  • 函数:与高级函数对应的代码的标识,包括其入口、参数、返回值和出口。
  • 变量:每个函数中的局部变量,以及任何全局或静态变量。
  • 类型:每个变量的类型,以及每个函数的参数和返回值。
  • 高级控制流:程序的控制流模式,例如, while (...) { if (...) {...} else {...} }

反编译本机代码很困难,因为这些抽象都没有在本机代码中明确表示。因此,要生成漂亮的反编译代码(即,不要goto到处使用s),反编译器必须根据本机代码的行为重新推断这些抽象。这是一个艰难的过程,已经写了许多关于如何推断这些抽象的论文。请参阅BalakrishnanLee了解初学者。

相比之下,字节码更容易反编译,因为它通常包含足够的信息来允许类型检查因此,字节码通常包含对函数(或方法)、变量和每个变量的类型的显式抽象。字节码中缺少的主要抽象是高级控制流。