对编译和链接的一点Hack

Linux中有一个小工具叫objcopy,它用于目标文件之间的拷贝和转换。我们可以利用它将一个普通文件转换为可以用于链接的目标文件。

先创建一个内容为Hello,world的文本文件:

$ echo ‘Hello,world’ > file

然后用objcopy将其转为一个ELF格式的32位目标文件:

$ objcopy -I binary -O elf32-i386 -B i386 file file.o

可以使用objdump工具来查看file.o的内容,例如查看符号表(另一种方法是readelf -s):

$ objdump –t file.ofile.o:     file format elf32-i386 

SYMBOL TABLE:

00000000 l    d  .data   00000000 .data

00000000 g       .data   00000000 _binary_file_start

0000000c g       .data   00000000 _binary_file_end

0000000c g       *ABS*   00000000 _binary_file_size

能够看到,其中存在三个全局可见的符号。

事实上,_binary_file_start(以下简称_start)、_binary_file_end(以下简称_end)分别指向这个文件的起始和结束位置;_binary_file_size(以下简称_size)是一个ABS型的符号,即一个绝对的值(ABS是absolutely的缩写,是指在重定位过程中不会发生改变)。

既然这是一个合法的目标文件,并且导出了一些符号,我们是不是可以利用它?

例如下面的代码:

#include <stdio.h>extern char _binary_file_start; 

extern int _binary_file_size;

int main()

{

printf(“%s”, &_binary_file_start);

printf(“%#x\n”, (unsigned int)&_binary_file_size);

return 0;

}

将其保存为main.c,编译之:

$ gcc –c main.c

得到目标文件main.o,然后将它和file.o链接起来:

$ gcc main.o file.o

就得到了可执行文件a.out,运行一下看看:

$ ./a.outHello,world 

0xc

成功了。

但是源码中存在两处问题:

  1. _start既然是起始地址,难道不是指针吗?为什么可以定义为char型?
  2. _size既然声明为int型,为什么打印出来实际长度需要先用&取它的地址?

我们先直接作简洁的回答,再一步步地探索为什么会这样:

  1. 在这种情况下,声明成什么都行,只要使用适当,并且在编译时代码能通过类型检查就行。
  2. 在这种情况下,无论声明成什么类型,_start都需要经过&操作,来获得file内容的起始地址;_size都需要经过&操作,来获得file内容的长度。

这两条结论似乎有违常理,其实是与特定情景有关的。更确切地说,“这种情况”是指用objcopy直接将数据转成目标文件然后与代码链接的方法,这当然不是一种常见的做法,而且似乎没什么意义,除了好玩以外。

为了证实这两点,我们将_start和_size的类型修改为指针:

#include <stdio.h>extern char *_binary_file_start; 

extern int *_binary_file_size;

int main()

{

printf(“%s”, (char *)&_binary_file_start);

printf(“%#x\n”, (unsigned int)&_binary_file_size);

return 0;

}

同时,我们还给第一个printf加上强制类型转换,以便编译时不报类型错误。再次编译、链接,结果依然正确。

这两个特点似乎让人有些匪夷所思。我们不妨按照常规思路来写一个程序,事实上这也是笔者自己写出来的第一个程序:

#include <stdio.h>extern char *_binary_file_start; 

int main()

{

printf(“%s”, _binary_file_start);

return 0;

}

这个程序看似非常完美:声明了一个外部字符指针,然后将其打印。但是运行起来的结果将是“段错误”(Segment Fault),然后直接退出。

这就很奇怪了。我们知道,段检查机制是在保护模式下引入的,当对一个地址的访问操作超出了该地址所在段的段描述符规定的权限范围,就会发生段错误。只从应用程序的角度来看,段错误往往是访问的地址有误。

我们不妨来看看此时a.out的汇编代码:

$ objdump –d a.out…… 

080483e4 <main>:

80483e4:  55                     push   %ebp

80483e5:  89 e5                  mov    %esp,%ebp

80483e7:  83 e4 f0               and    $0xfffffff0,%esp

80483ea:  83 ec 10               sub    $0×10,%esp

80483ed:  8b 15 14 a0 04 08    mov    0x804a014,%edx

80483f3:  b8 d0 84 04 08       mov    $0x80484d0,%eax

80483f8:  89 54 24 04          mov    %edx,0×4(%esp)

80483fc:  89 04 24               mov    %eax,(%esp)

80483ff:  e8 18 ff ff ff       call   804831c <printf@plt>

8048404:  b8 00 00 00 00       mov    $0×0,%eax

8048409:  c9                     leave

804840a:  c3                     ret

……

其中尤其值得我们注意的是这两句:

80483ed:   8b 15 14 a0 04 08    mov    0x804a014,%edx…… 

80483f8:   89 54 24 04          mov    %edx,0×4(%esp)

在GNU格式的汇编语言中,符号Imm并非立即数,而是指绝对寻址M[Imm];符号$Imm才是立即数Imm。也就是说,这里的mov 0x804a014, %edx并非将0x804a014这个值复制给寄存器edx,而是将0x804a014处的内容复制给edx。使用如下命令:

$ objdump –s a.outContents of section .data: 

804a00c 00000000 00000000 48656c6c 6f2c776f  ……..Hello,wo

804a01c 726c640a                             rld.

可以看到,0x804a014处的内容是0x48656c6c,即”Hello,world”的前四个字节。(谨慎起见,我们还可以查询Intel的Reference Manual 253666,在Vol 2A,3-654页指出8b这个opcode是从寄存器或内存拷贝值到寄存器,而不是从立即数拷贝。)

因此,edx携带着0x48656c6c这个值,传到了%esp + 4的地方,它将作为后面printf的第二个参数,而这个参数的实际含义应该是%s所指字符串的起始地址。因此,printf来到0x48656c6c读取字符串,这个地址在Linux中是动态共享库的加载地址,自然访问出错了。

现在我们能理解段错误从何而起了——根源在于程序根据0x804a014寻址了一次,然后将内容作为程序中的_binary_file_start的内容。但我们不是声明了_binary_file_start就是指针吗?

来看一下链接以后,a.out中的符号表信息:

$ readelf -s a.outSymbol table ‘.symtab’ contains 68 entries: 

Num:    Value  Size Type    Bind   Vis      Ndx Name

55: 0804a014     0 NOTYPE  GLOBAL DEFAULT   24 _binary_file_start

56: 0000000c     0 NOTYPE  GLOBAL DEFAULT  ABS _binary_file_size

63: 0804a020     0 NOTYPE  GLOBAL DEFAULT   24 _binary_file_end

可以看到,_start、_size、_end都是NOTYPE类型,它们的值分别为0804a014、0000000c、0804a020。这些值在链接阶段被用于重定位过程中的代码修正。

再来看一下main.o的汇编代码:

$ objdump –d main.o00000000 <main>: 

0:  55                     push   %ebp

1:  89 e5                  mov    %esp,%ebp

3:  83 e4 f0               and    $0xfffffff0,%esp

6:  83 ec 10               sub    $0×10,%esp

9:  8b 15 00 00 00 00    mov    0×0,%edx

f:  b8 00 00 00 00       mov    $0×0,%eax

14:  89 54 24 04          mov    %edx,0×4(%esp)

18:  89 04 24               mov    %eax,(%esp)

1b:  e8 fc ff ff ff       call   1c <main+0x1c>

20:  b8 00 00 00 00       mov    $0×0,%eax

25:  c9                     leave

26:  c3                     ret

其中,偏移b、10两处00000000等待指令修正。

于是我们可以知道如下事实:

只要是使用外部变量,编译器就会生成基于绝对寻址的汇编代码,然后等待链接时,链接器修正这些代码,将符号表中的值作为变量的绝对地址填充到其中,于是完成了代码对外部变量的使用。

因为这一过程有编译器和链接器合作来完成,程序员对它们是不可控的,在正常情况下也是透明的,因此不用专门记住。

现在我们再来理解前面的两个问题。

第一,为什么_start可以被声明为char?其实这是无所谓的,后面我们也将它声明为指针了,但无论如何申明,它始终被编译器和链接器合作,解释为符号表中地址值所指向的内容,而一旦需要取得这个值本身,就需要用&操作(取地址)。

第二点,为什么使用_size也需要取地址?这是objcopy的一个特点,它将_size导出为ABS型的符号,因此它在符号表中的值就是长度本身,而我们已经分析了,想要取出符号表中的值,就需要用&操作。

现在hack已经基本完成。如果还要探索,就是确定在正常使用编译器生成目标程序时,是否和objcopy表现一样。即上述事实的适用范围到底有多广。

为此我们再做一个小实验。

考虑如下代码(main.c):

#include <stdio.h>extern int *data; 

int main()

{

printf(“%d\n”, (int)data );

return 0;

}

以及如下代码(data.c):

int data = 2010;

将它们编译、链接:

$ gcc –c main.c data.c$ gcc main.o data.o

运行之,可以得到输出:

$ ./a.out2010

在这段代码中,我们的变量data实际定义是整型,但声明为指针,使用的时候却是data本身,而不是* data(大家也可以试试,如果使用* data,将会发生段错误)。这就是因为无论data如何声明,在编译和连接后,data这个符号本身始终通过一次绝对寻址变为2010。而如果要得到data的地址,同样是使用(int *)& data。

因此,我们的结论实际上也适用于正常的目标文件编译和链接。

致谢

如果没有王明磊的帮助,我是不可能完成这篇文章的。事实上,我在有了这个思路但是遇到段错误之后,一直没有进展,直到他给出第一个运行正常的程序。他还进一步提出了_size的疑问,这让我能够换一个角度考虑问题。他的方法也是很好的,正是在他的提醒下,我对比了许多不同的代码,才能发现其中一些细微的区别。

参考文献

俞甲子等. 程序员的自我修养:链接、装载与库. 北京: 电子工业出版社, 2009.4

R. E. Bryant, D. O. Hallaron. 深入理解计算机系统. 北京: 中国电力出版社, 2004.5

于渊. Orange’s:一个操作系统的实现. 北京: 电子工业出版社, 2009.6

Intel Corporation. Intel 64 and IA-32 Architectures Software Developer’s Manual. Volume 2A: Instruction Set Reference, A-M. Order Number: 253666. 2009.9

Leave a Reply

Your email address will not be published. Required fields are marked *