ELF格式复习整理

本篇将主要结合CSAPP介绍ELF文件的格式,常用的分析工具,以及Linux程序装载的流程。

一个Demo

main.c

1
2
3
4
5
6
7
void swap();
int buf[2] = {1, 2};

int main(){
swap();
return 0;
}

swap.c

1
2
3
4
5
6
7
8
9
10
11
extern int buf[];
int * bufp0 = &buf[0];
int * bufp1;

void swap(){
int t;
bufp1 = &buf[1];
t = *bufp0;
*bufp0 = *bufp1;
*bufp1 = t;
}

构建项目,注意为了吻合CSAPP的Demo,需要指定32位模式

1
2
3
gcc -m32 -g -o p main.c swap.c
gcc -m32 -g -c -o main.o main.c
gcc -m32 -g -c -o swap.o swap.c

ELF结构

段头部表,program header table,在内存中;节头部表,section header table,在二进制文件中

查看段头部表,发现有两个LOAD段,分别对应了下面的02和03两个段(从0开始数),一般来说只有这两个段会被加载到内存。第一个段包含了.text.rodata等,这些段是只读的,所以Flags有R,并且代码段.text是可执行的,所以Flags还有E。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ readelf -l p
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x005b4 0x005b4 R E 0x1000
LOAD 0x000f08 0x08049f08 0x08049f08 0x0011c 0x00124 RW 0x1000
...

Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
...

symtab是一个符号表,符号表并不一定需要-g选项才会生成。包含引用的函数和全局变量的信息。
line是C中的行号和机器地址的对照表。

当程序真正被加载到Linux内存中时,呈现下面的布局。可以看到,栈是从高往低增长的,ESP是栈顶;堆是由低往高增长的,由brk限制。

符号表

查看main.o的符号表,发现其中的swap符号对应是UND的,表示是在本模块中引用了,但是未定义(其实在swap.o中定义了)的符号。

1
2
3
4
5
6
7
$ readelf -s main.o

Num: Value Size Type Bind Vis Ndx Name
...
13: 00000000 8 OBJECT GLOBAL DEFAULT 3 buf
14: 00000000 36 FUNC GLOBAL DEFAULT 1 main
15: 00000000 0 NOTYPE GLOBAL DEFAULT UND swap

而buf对应了Ndx为3,我们查看Section表,3表示.data段。

1
2
3
4
5
6
7
8
9
$ readelf -S main.o

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 1
[ 2] .rel.text REL 00000000 0003a0 000008 08 I 18 1 4
[ 3] .data PROGBITS 00000000 000058 000008 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000060 000000 00 WA 0 0 1

下面我们再看看swap.o的符号表,这里出现了一个COM,表示是未初始化的符号。我觉得这个应该可以直接对应到bss,也就是4号段,但是这边给出的是COMMON。

1
2
3
4
5
Num:    Value  Size Type    Bind   Vis      Ndx Name
13: 00000000 4 OBJECT GLOBAL DEFAULT 3 bufp0
14: 00000000 0 NOTYPE GLOBAL DEFAULT UND buf
15: 00000004 4 OBJECT GLOBAL DEFAULT COM bufp1
16: 00000000 54 FUNC GLOBAL DEFAULT 1 swap

重定向

目标

观察最终结果./p的汇编
下面介绍一下call指令,它通过e8来识别,具有一个操作数0a 01 00 00。它的行为是将下一条地址0x80482f6压栈,然后跳转到被调用的函数swap的第一条命令0x8048400处。这个地址是怎么算出来的呢?我们注意到0x80482f6+0x010a=0x8048400,所以call命令的参数是相对于EIP寄存器的偏移。我们现在有问题1:为什么是以下一条指令的EIP0x80482f6而不是当前的0x80482f1为基准计算呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Disassembly of section .text:

080482e0 <main>:
80482e0: 8d 4c 24 04 lea 0x4(%esp),%ecx
80482e4: 83 e4 f0 and $0xfffffff0,%esp
80482e7: ff 71 fc pushl -0x4(%ecx)
80482ea: 55 push %ebp
80482eb: 89 e5 mov %esp,%ebp
80482ed: 51 push %ecx
80482ee: 83 ec 04 sub $0x4,%esp
80482f1: e8 0a 01 00 00 call 8048400 <swap>
80482f6: 83 c4 04 add $0x4,%esp
80482f9: 31 c0 xor %eax,%eax
80482fb: 59 pop %ecx
80482fc: 5d pop %ebp
80482fd: 8d 61 fc lea -0x4(%ecx),%esp
8048300: c3 ret

观察main.o的汇编。这里call的偏移是0xfffffffc(-4),经过计算就是16-4=12,对应的是fc ff ff ff的开头。我们现在有问题2:这个跳转非常奇怪,为什么相对位移是-4呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ objdump -d main.o

main.o: file format elf32-i386
Disassembly of section .text:

00000000 <main>:
0: 8d 4c 24 04 lea 0x4(%esp),%ecx
4: 83 e4 f0 and $0xfffffff0,%esp
7: ff 71 fc pushl -0x4(%ecx)
a: 55 push %ebp
b: 89 e5 mov %esp,%ebp
d: 51 push %ecx
e: 83 ec 04 sub $0x4,%esp
11: e8 fc ff ff ff call 12 <main+0x12>
16: b8 00 00 00 00 mov $0x0,%eax
1b: 83 c4 04 add $0x4,%esp
1e: 59 pop %ecx
1f: 5d pop %ebp
20: 8d 61 fc lea -0x4(%ecx),%esp
23: c3 ret

可以看到,从不可执行的main.o到可执行的./p,发生了两个显著的变化。

  1. 左边的地址对应了main.o的代码实际被加载的地址,我们知道32位程序默认会被加载到0x8048000处。
    观察main.o里面的Section表,发现代码段的地址仍然为空

    1
    2
    3
    4
    5
    6
    $ readelf -S main.o
    Section Headers:
    [Nr] Name Type Addr Off Size ES Flg Lk Inf Al
    [ 0] NULL 00000000 000000 000000 00 0 0 0
    [ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 1
    [ 2] .rel.text REL 00000000 0003a0 000008 08 I 18 1 4

    而p里面的Section表,地址以及确定了

    1
    2
    3
    $ readelf -S p
    Section Headers:
    [14] .text PROGBITS 080482e0 0002e0 0001a2 00 AX 0 0 16
  2. 右边call的地址,从0xfffffffc(-4)变为了0x8048400,这个对应了swap的实际装载地址
    从objdump中可以看到,0x8048400处确实是会swap的代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    $ objdump -d p
    08048400 <swap>:
    8048400: a1 20 a0 04 08 mov 0x804a020,%eax
    8048405: 8b 0d 1c a0 04 08 mov 0x804a01c,%ecx
    804840b: c7 05 28 a0 04 08 1c movl $0x804a01c,0x804a028
    8048412: a0 04 08
    8048415: 8b 10 mov (%eax),%edx
    8048417: 89 08 mov %ecx,(%eax)
    8048419: 89 15 1c a0 04 08 mov %edx,0x804a01c
    804841f: c3 ret

计算过程

下面我们来看看,目标的1和2是如何得到的。

代码

主要的一个过程是*refaddr=*refptr,为此我们需要计算下面两个指标

  1. refaddr
    refaddr表示需要修改的call的参数的地址。即我们应该算得0x80482f2
  2. *refptr
    *refptr表示call参数的最新值。

符号表

我们查看重定位条目表,它提供了三个信息

  1. 需要修改的重定位地址refaddr相对于main的偏移量是12
  2. R_386_PC32表示重定位一个32位的相对地址的引用。特别地,还有一种R_386_32表示绝对地址
  3. 把重定位地址refaddr处的值改成程序swap处的地址
1
2
3
4
5
$ readelf -r main.o

Relocation section '.rel.text' at offset 0x3a0 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
00000012 00000f02 R_386_PC32 00000000 swap

具体计算

首先计算refaddr,它等于ADDR(s) + r.offset,从前面的objdump的结果可以看到0x080482e0 + 0x120x80482f2,与实际结果吻合。
下面计算*refptr,我们用绝对地址减去基址的值得到。绝对地址可以从objdump看到,是0x8048400,而call命令的地址是0x80482f1,所以*refptr的值是0x10f。不对啊,之前看到实际的命令的参数是0x10a啊?没错,我们还需要减去原来*refptr的值也就是0xfffffffc=-4。这可以理解为我们的基址是0x80482f2+4=0x80482f6,正好是下一条指令的开头。这里的原因很简单,因为EIP(即PC)始终是指向下一条待执行的指令,所以计算偏移的时候,我们需要以此时实际EIP指向的位置来加上call指定的偏移。而0xfffffffc=-4这个值正好帮助EIP跳过自己占用的四个字节。

动态链接和PIC

我们考虑动态链接的场景,程序依赖的外部程序并没有在静态链接期链接到可执行文件中,而需要在运行期动态加载。负责这个过程的动态加载器由INTERP段指定,Linux上的默认值为/lib/ld-linux.so.2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ readelf -l p

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
...
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]

$ objdump -s -j ".interp" p

p: file format elf32-i386

Contents of section .interp:
8048154 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so
8048164 2e3200 .2.

这个时候,动态链接器有两个选择:

  1. 直接模仿静态链接器一样修改代码,不过这个是不现实的,我们从前文知道,.text段是只读的。
  2. 借助于GOT表

我们添加下面两个选项,以得到一个位置无关的swap.so。

  1. -fPIC创建位置无关代码
  2. -shared创建共享库
    1
    2
    3
    4
    5
    6
    7
    8
    9
    gcc -fPIC -m32 -g -c -o swap_no_shared.so swap.c
    gcc -shared -m32 -g -c -o swap_no_pic.so swap.c
    gcc -shared -fPIC -m32 -g -c -o swap.so swap.c
    gcc -fPIC -m32 -g -o p_pic main.c swap.c
    gcc -m32 -g -o no_pic main.c swap.c

    gcc -fPIC -m32 -g -S -o main_pic.s main.c
    gcc -fPIC -m32 -g -c -o main_pic.o main.c
    gcc -fPIC -m32 -g -o p_pic main.c swap.c

PIC

PIC的意思是位置无关代码,在之前看到的call命令肯定是位置无关的,因为它的参数是相对EIP的偏移,那么有什么代码是位置相关的呢?这包含两部分:

  1. 调用外部过程
  2. 引用全局变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cat main_pic.s
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ebx
pushl %ecx
call __x86.get_pc_thunk.ax ; call 10 <main+0x10> ; call 8048407 <__x86.get_pc_thunk.ax>
addl $_GLOBAL_OFFSET_TABLE_, %eax ; add $0x1,%eax ; add $0x1c11,%eax
movl %eax, %ebx
call swap@PLT ; call 1c <main+0x1c> ; call 804840b <swap>
movl $0, %eax
popl %ecx
popl %ebx
popl %ebp
leal -4(%ecx), %esp
ret
__x86.get_pc_thunk.ax:
movl (%esp), %eax
ret

下面编译一个最简单的程序

1
2
3
4
5
#include <stdio.h>

int main(){
puts("Hello, world!");
}

编译,这里C库默认是动态链接的,如果要静态链接需要指定-static。此外,某些版本会自动开启PIE,这样readelf会显示是DYN而不是EXEC,objdump无法得到绝对地址,这样就无法调试了,所以要指定-no-pie

1
2
3
apt install gcc-multilib
gcc -no-pie -fPIC -m32 -g -o pic pic.c
gcc -m32 -static -g -o nopic pic.c

反编译动态链接的结果,发现实际调用的是puts@plt这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
(gdb) disass main
Dump of assembler code for function main:
0x0804840b <+0>: lea 0x4(%esp),%ecx
0x0804840f <+4>: and $0xfffffff0,%esp
0x08048412 <+7>: pushl -0x4(%ecx)
0x08048415 <+10>: push %ebp
0x08048416 <+11>: mov %esp,%ebp
0x08048418 <+13>: push %ebx
0x08048419 <+14>: push %ecx
0x0804841a <+15>: call 0x8048447 <__x86.get_pc_thunk.ax>
0x0804841f <+20>: add $0x1be1,%eax ; $_GLOBAL_OFFSET_TABLE_, %eax
0x08048424 <+25>: sub $0xc,%esp
0x08048427 <+28>: lea -0x1b30(%eax),%edx ; leal .LC0@GOTOFF(%eax), %edx
0x0804842d <+34>: push %edx
0x0804842e <+35>: mov %eax,%ebx
0x08048430 <+37>: call 0x80482e0 <puts@plt>
0x08048435 <+42>: add $0x10,%esp
0x08048438 <+45>: mov $0x0,%eax
0x0804843d <+50>: lea -0x8(%ebp),%esp
0x08048440 <+53>: pop %ecx
0x08048441 <+54>: pop %ebx
0x08048442 <+55>: pop %ebp
0x08048443 <+56>: lea -0x4(%ecx),%esp
0x08048446 <+59>: ret
End of assembler dump.

disass 0x8048447
Dump of assembler code for function __x86.get_pc_thunk.ax:
0x08048447 <+0>: mov (%esp),%eax
0x0804844a <+3>: ret

看看puts@plt的实现,看起来是两个jmp和一个push,这个很奇怪。

1
2
3
4
5
6
(gdb) disass puts
Dump of assembler code for function puts@plt:
0x080482e0 <+0>: jmp *0x804a00c
0x080482e6 <+6>: push $0x0
0x080482eb <+11>: jmp 0x80482d0
End of assembler dump.

这些地址都是啥呢?检查Section表,发现第一个jmp落在.got.plt段上,而第二个jmp落在.plt段上。

1
2
3
4
(gdb) info symbol 0x804a00c
_GLOBAL_OFFSET_TABLE_ + 12 in section .got.plt
(gdb) info symbo 0x80482d0
No symbol matches 0x80482d0.

.plt所在的段是可读可执行不可写的,可以认为是一段代码。而.got.plt所在的段是可写的,也就是在运行时会被更新。

1
2
3
4
5
6
7
8
9
10
11
12
$ readelf -S pic
There are 36 section headers, starting at offset 0x1a94:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 9] .rel.dyn REL 08048290 000290 000008 08 A 5 0 4
[10] .rel.plt REL 08048298 000298 000010 08 AI 5 24 4
[12] .plt PROGBITS 080482d0 0002d0 000030 04 AX 0 0 16
[13] .plt.got PROGBITS 08048300 000300 000008 00 AX 0 0 8
[14] .text PROGBITS 08048310 000310 0001a2 00 AX 0 0 16
[23] .got PROGBITS 08049ffc 000ffc 000004 04 WA 0 0 4
[24] .got.plt PROGBITS 0804a000 001000 000014 04 WA 0 0 4

第一个jmp

查看第一个jmp的代码,发现没有啥意义,其实这个只是一个地址查询表,我们打印出来是一个要跳转的地址0x8049f14

1
2
3
4
5
6
7
(gdb) disass 0x804a00c
Dump of assembler code for function _GLOBAL_OFFSET_TABLE_:
0x0804a000: adc $0x9f,%al
End of assembler dump.

(gdb) print /x *0x804a00c
$1 = 0x80482e6

而这个地址恰好对应了puts@plt的下一行代码

1
2
(gdb) info symbol 0x80482e6
puts@plt + 6 in section .plt

第二个jmp

接着,我们入栈了一个0x0,接着jmp到0x80482d0,这个似乎不好直接disass,于是我们采用下面的办法。

1
2
(gdb) disass x80482d0
No function contains specified address.

发现这边没有函数信息,于是强行disass一波,现在有了。看起来,我们入栈了一个0x804a004之后,又jmp到了*0x804a008这个位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) disass 0x80482d0,0x080482f0
Dump of assembler code from 0x80482d0 to 0x80482f0:
0x080482d0: pushl 0x804a004
0x080482d6: jmp *0x804a008
0x080482dc: add %al,(%eax)
0x080482de: add %al,(%eax)
0x080482e0 <puts@plt+0>: jmp *0x804a00c
0x080482e6 <puts@plt+6>: push $0x0
0x080482eb <puts@plt+11>: jmp 0x80482d0
End of assembler dump.

(gdb) print /x *0x804a008
$3 = 0x0

我们发现*0x804a008居然是0。很奇怪,难道是没有运行的缘故?于是我们打断点,并执行

1
2
3
4
5
6
7
8
9
10
(gdb) 
b *0x080482e0
b *0x080482e6
b *0x080482eb
run
...
s
Cannot find bounds of current function
print /x *0x804a008
$1 = 0xf7fee000

发现执行完之后,这里竟然有值了!但依然没有函数名,对这种情况,我们可以安装一个debug版本的glibc来解决

1
2
ldd ./pie # 确定版本
apt install libc6-dbg:i386

这样在gdb里面就可以add symbol上面

1
add-symbol-file /usr/lib/debug/lib/x86_64-linux-gnu/ld-2.27.so 0xf7fd6ab0

这个值实际上是_dl_runtime_resolve的函数。这个函数主要内容就是入栈5个参数,然后调用_dl_fixup,最后再弹出栈。

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) disass 0xf7fee000
Dump of assembler code for function _dl_runtime_resolve:
0xf7fee000 <+0>: push %eax
0xf7fee001 <+1>: push %ecx
0xf7fee002 <+2>: push %edx
0xf7fee003 <+3>: mov 0x10(%esp),%edx
0xf7fee007 <+7>: mov 0xc(%esp),%eax
0xf7fee00b <+11>: call 0xf7fe77e0 <_dl_fixup>
0xf7fee010 <+16>: pop %edx
0xf7fee011 <+17>: mov (%esp),%ecx
0xf7fee014 <+20>: mov %eax,(%esp)
0xf7fee017 <+23>: mov 0x4(%esp),%eax

ret的操作数指定了在弹出返回地址后,需要释放的字节/字数量。通常,这些字节/字被是作为调用的输入

1
2
   0xf7fee01b <+27>:	ret    $0xc
End of assembler dump.

我们还可以从重定向表中,可能找到0x0804a00c这偏移对应的表项。

1
2
3
4
5
6
7
8
9
10
$ readelf -r pic

Relocation section '.rel.dyn' at offset 0x290 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
08049ffc 00000206 R_386_GLOB_DAT 00000000 __gmon_start__

Relocation section '.rel.plt' at offset 0x298 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
0804a00c 00000107 R_386_JUMP_SLOT 00000000 puts@GLIBC_2.0
0804a010 00000307 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0

Reference

  1. http://flint.cs.yale.edu/cs422/doc/ELF_Format.pdf