二进制分析实战
上QQ阅读APP看书,第一时间看更新

1.1 C编译过程

二进制文件是通过编译生成的,编译是将人类可读的源代码(如C或C++)转换为处理器可以执行的机器码的过程。[1]图1-1显示了典型C代码编译过程(C++代码编译过程类似)。编译C代码包括4个阶段(确实很不方便),分为预处理、编译、汇编及链接。在实践中,现代编译器经常合并部分或者全部阶段,但为了演示,我将会一一介绍它们。

图1-1 C代码编译过程

1.1.1 预处理阶段

编译过程从你要编译的各种源文件开始(可能只有一个源文件,但大型程序通常由许多文件组成,在图1-1中显示为file-1.c到file-n.c)。这不仅使得项目更易于管理,而且加快了编译速度,因为如果一个文件发生了更改,你只需重新编译该文件而不是所有代码。

C的源文件包含宏(用#define表示)和#include指令。你可以使用#include指令包含源文件所依赖的头文件(扩展名为.h)。预处理阶段扩展了源文件中的所有#define#include指令,因此剩下的就是准备编译的纯C代码。

让我们通过一个示例来说明这一点。此示例使用GCC编译器,这是许多Linux发行版(包括Ubuntu,常在虚拟机上安装的操作系统)的默认编译器。其他编译器(如Clang或者Visual Studio)编译的结果与之相似。我会将本书中的所有代码示例(包括当前示例)编译成x86-64代码,除非另有说明。

假设你想要编译一个C源文件,如清单1-1所示,它将“Hello, world!”消息输出到屏幕。

清单1-1 compilation_example.c

#include <stdio.h>

#define FORMAT_STRING "%s"
#define MESSAGE         "Hello, world!\n"

int
main(int argc, char * argv[])  {
   printf(FORMAT_STRING, MESSAGE);
   return 0; 
}

稍后你将看到在后续的编译过程中会发生什么,但是现在,我们只考虑预处理阶段的输出。在默认情况下,GCC会自动执行所有的编译阶段,因此你必须明确告诉它在预处理后停止,并显示中间输出。对GCC来说,这可以使用命令gcc-E-P来完成,其中-E告诉GCC在预处理后停止,-P使GCC忽略调试信息,以便输出更清晰。清单1-2显示了预处理器的输出,为了简洁,我对该输出进行了编辑。启动虚拟机(Virtual Machine,VM),并查看预处理器的完整输出。

清单1-2 “Hello, world!”程序的C预处理器输出

$ gcc -E -P compilation_example.c

typedef long unsigned int size_t;
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;

/ * ...*/
extern int sys_nerr;
extern const char *const sys_errlist[];
extern int fileno (FILE * __stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern int fileno_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern FILE *popen (const char *__command, const char *__modes) ;
extern int pclose (FILE *__stream);
extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__));
extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));

int
main(int argc, char* argv[]) {
   printf(❶"%s",❷"Hello, world!\n");
   return 0;
}

stdio.h头文件全部包含在内,其所有的类型定义、全局变量及函数原型都被“复制”到源文件中。因为每个#include指令都会发生这种情况,所以预处理器输出可能非常冗长。预处理器还完整地扩展了#define定义的任何宏的所有用法。在示例中,这意味着对printf(FORMAT_STRING❶和MESSAGE❷)的两个参数进行计算,并用它们所代表的常量字符串进行替换。

1.1.2 编译阶段

预处理阶段完成后,就可以编译源代码了。编译阶段采用预处理代码并将代码转换为汇编语言。大多数编译器也会在此阶段进行大量的优化,通常可以通过命令行开关配置优化级别,如GCC中的选项-O0-O3。正如你将在第6章中看到的那样,编译过程中的优化级别会对反汇编产生深远的影响。

为什么编译阶段会将代码转换为汇编语言而不是机器代码?这个设计决策似乎在一种语言(在本例中为C)的上下文中没有意义,但是当你考虑其他语言的时候,这样做确实是有意义的。对一些编译语言如C、C++、Objective-C、Common Lisp、Delphi、Go及Haskell等,编写一种能够直接为每种语言翻译(emit)机器代码的编译器是一项极其苛刻且耗时的任务,因此最好先为其翻译出汇编代码(已经是一项足够具有挑战性的任务了),并且需要有一个专用的汇编程序,以处理每种语言的汇编代码到机器代码的转换。

因此,编译阶段的输出,是以人类可读的形式进行汇编的,其中的符号信息完整无缺。如前所述,GCC通常会自动调用所有的编译阶段,因此要从编译阶段查看已翻译的汇编,你要告诉GCC在此阶段后必须停止,并将汇编文件存储到磁盘。你可以使用-S标志执行此操作(.s是汇编文件的常规扩展名)。你还可以将选项-masm-intel传递给GCC,让它以Intel语法而不是默认的AT&T语法翻译汇编语言。清单1-3显示了示例程序中编译阶段生成的汇编代码。[2]

清单1-3 由“Hello, world!”程序在编译阶段生成的汇编代码

  $ gcc -S -masm=intel compilation_example.c
  $ cat compilation_example.s

          .file "compilation_example.c"
          .intel_syntax noprefix
          .section .rodata
❶ .LC0:
          .string "Hello, world!"
          .text
          .globl main
          .type main, @function
❷ main:
   .LFB0:
          .cfi_startproc
          push      rbp
          .cfi_def_cfa_offset 16
          .cfi_offset 6, -16
          mov     rbp, rsp
          .cfi_def_cfa_register 6
          sub     rsp, 16
          mov     DWORD PTR [rbp-4], edi
          mov     QWORD PTR [rbp-16], rsi
          mov     edi, ❸ OFFSET FLAT:.LC0
          call    puts
          mov     eax, 0
          leave
          .cfi_def_cfa 7, 8
          ret
          .cfi_endproc
   .LFE0:
          .size      main, .-main
          .ident     "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
          .section   .note.GNU-stack,"",@progbits

现在,我不会给你详细介绍汇编代码。有趣的是,在清单1-3中汇编代码相对容易阅读,因为符号和函数已经被保留下来了。如常量和变量拥有符号名称而不仅仅是内存地址(即使只是一个自动生成的名称,如“Hello, world!”的匿名字符串是LC0❶),并且main函数有一个显式标签(该情况下的唯一函数)❷。对代码的任何引用都是有符号的,如对“Hello, world!”字符串❸的引用。在本书后文介绍的处理剥离的二进制文件中,你就没有这样奢侈了!(因为没有符号。)

1.1.3 汇编阶段

在汇编阶段,最终你可以得到真正的机器代码!汇编阶段的输入是在编译阶段生成的汇编语言集,输出是一组对象文件,有时简称为模块。对象文件原则上包含可由处理器执行的机器指令。但像我刚才所说的,你需要一个可执行文件。通常,每个源文件对应一个汇编文件,每个汇编文件对应一个对象文件。为了生成对象文件,传递-c标志给GCC,如清单1-4所示。

清单1-4 使用GCC生成对象文件

$ gcc -c compilation_example.c
$ file compilation_example.o
compilation_example.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

你可以使用file工具(将会在第5章介绍的一款工具)来确认生成的compilation_example.o文件确实是对象文件。如清单1-4所示,文件显示这是一个ELF 64位重定位文件。

这到底是什么意思?file输出的第一部分显示了该文件符合二进制可执行文件的ELF规范(我将在第2章中详细讨论)。更具体地说,它是一个64位的ELF二进制文件(因为在这个示例中你编译的是x86-64),并且是最低有效位(Least Significant Bit,LSB),这意味着数字在内存中的排序是以最低有效字节优先的。但最重要的是,你可以看到该文件是可重定位的。

可重定位文件不依赖于放置在内存中的任何特定地址,相反,它们可以随意移动,而不会破坏代码中的任何假设。当你在文件输出中看到术语“可重定位”时,你就知道你正在处理的是对象文件而不是可执行文件。[3]

对象文件相互独立编译,因此汇编程序在组装对象文件时无法知道其他对象文件的内存地址。这就是对象文件需要可重定位的原因,这样你就可以按照任意顺序将对象文件链接在一起,形成完整的二进制可执行文件。如果对象文件不可重定位,则无法实现文件的任意顺序组合。

当你准备第一次反汇编文件的时候,你将会在后文看到对象文件的内容。

1.1.4 链接阶段

链接阶段是编译过程的最后阶段。顾名思义,此阶段将所有对象文件链接到一个二进制可执行文件中。在现代系统中,链接阶段有时会包含额外的优化过程,被称为链接时优化(Link-Time Optimization,LTO)。

执行链接阶段的程序被称为链接器或者链接编辑器。通常链接器与编译器相互独立,编译器通常实现前面所有的步骤。

正如我前面已经提到的,对象文件是可重定位的,因为它们是相互独立编译的,这使得编译器无法假设对象最终会出现在任意特定的基址上。此外,对象文件可以引用其他对象文件或程序外部库中的函数或者变量。在链接阶段之前,引用代码和数据的地址尚不清楚,因此对象文件只包含重定位符号,这些符号指定最终如何解析函数和变量引用。在链接上下文中,依赖于重定位符号的引用称为符号引用。当一个对象文件通过绝对地址引用自己的函数或变量时,该引用也会被符号化。

链接器的工作是获取属于程序的所有对象文件,并将它们合并为一个连贯的可执行文件,然后加载到特定的内存地址。既然现在已经知道可执行文件中所有模块的排列,链接器也就可以解析大多数的符号引用了。根据库文件的类型,对库文件的引用可能会、也可能不会完全解析。

静态库(在Linux操作系统上扩展名通常为.a,见图1-1)被合并到二进制可执行文件中,允许完全解析对静态库的任何引用。还有动态(共享)库,它们在系统上运行的所有程序的内存中共享。换句话说,不是将库文件复制到使用它的每个二进制文件中,而是仅将动态库加载到内存中一次,并且任何想要使用该库的二进制文件都需要使用此共享副本。在链接阶段,动态库将驻留的内存地址尚不清楚,因此无法解析对它们的引用。相反,即使在最终的可执行文件中,链接器也会对这些库文件留下符号引用,并且在将二进制文件实际加载到要执行的内存中之前,不会解析这些引用。

大多数编译器(包括GCC)在编译过程结束时会自动调用链接器。因此,要生成完整的二进制可执行文件,只需在没有任何特殊开关的情况下调用GCC,如清单1-5所示。

清单1-5 使用GCC生成二进制可执行文件

$ gcc compilation_example.c
$ file a.out
a.out: ❶ ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ❷ dynamically
linked, ❸ interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=d0e23ea731bce9de65619cadd58b14ecd8c015c7, ❹ not stripped
$ ./a.out
Hello, world!

默认情况下,可执行文件名为a.out,但你可以通过-o选项给GCC重写此命名,选项后跟输出文件的名称。file实用程序现在告诉你正在处理的是一个ELF 64位LSB可执行文件❶,而不是在汇编阶段结束时看到的可重定位文件。此外还有一个重要信息是文件是动态链接的❷,这意味着它使用的某些库未合并到可执行文件中,而是在同一系统上运行的所有程序之间共享。最后,解释器/lib64/ld-linux-x86-64.so.2❸的文件输出会告诉你,当可执行文件加载到内存中执行时,哪个动态链接器将会被用来解析动态库的最终依赖关系。当你运行二进制文件(使用./a.out)时,你可以看到文件产生了预期的输出,打印“Hello, world!”到标准输出,这证实你已经生成了一个可以正常运行的二进制文件。

但是这个二进制文件没有被“剥离”,这是什么意思呢❹?接下来我会讨论这个问题!