PIC (Position Independent Code),位置无关的代码。在《深入理解计算机系统(第1版)》(Computer Systems: A Programming’s Perspective by Randal E. Bryant, David O’Hallaron)一书中的 Chapter 7.12 中涉及,然而仅两页内容,细节不够。于是便找出 glibc (v2.21) 源代码,配合 objdump, readelf, gdb 等工具,对其实现做深入理解。

理解 PIC 的另一个用处就是缓冲区溢出的漏洞攻击。

前置知识:需要对Linux内核、动态链接、ELF (Executable and Linkable Format) 有一定了解。

实验环境:Ubuntu 15.04, 64 bit, Linux kernel 3.19.0, gcc 4.9.2

PIC 用于动态链接中,假设我们有一个 libc.so 的动态链接库,由于会有许多程序都链接该库,所以操作系统在内存上只会保留一份拷贝,当程序需要链接该库时,链接器将 libc.so 映射到程序的地址空间中,由于映射的地址是随机的^1^(可以防止 return-to-libc 攻击),因此,程序中调用该库的函数就无法使用固定地址。

随机主要是因为:不能映射到固定地址,因为动态库太多了,无法分配。防止 return-to-libc 攻击可能是因此带来的额外效果?

在32位系统中,程序的运行时存储器映像如下图:

stack

 

代码段总是从0x08048000(128M)开始,接下来是只读段、读写段,然后是堆,上面的是栈,中间一部分预留给共享库映射。

在64位系统中,情况有些不同。首先是代码段改从0x400000(4M)开始,而共享库的位置则不定。

由于 gdb 默认会关闭ASLR(Address Space Layout Randomizations),所以要先开启它。

(gdb) set disable-randomization off
(gdb) i proc map
process 11332
Mapped address spaces:

Start Addr           End Addr       Size     Offset objfile
0x400000           0x401000     0x1000        0x0 /home/huang/Documents/a.out
0x600000           0x601000     0x1000        0x0 /home/huang/Documents/a.out
0x601000           0x602000     0x1000     0x1000 /home/huang/Documents/a.out
0x7ffff7a0f000     0x7ffff7bcf000   0x1c0000        0x0 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7bcf000     0x7ffff7dcf000   0x200000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7dcf000     0x7ffff7dd3000     0x4000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7dd3000     0x7ffff7dd5000     0x2000   0x1c4000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7dd5000     0x7ffff7dd9000     0x4000        0x0 
0x7ffff7dd9000     0x7ffff7dfd000    0x24000        0x0 /lib/x86_64-linux-gnu/ld-2.21.so
0x7ffff7fde000     0x7ffff7fe1000     0x3000        0x0 
0x7ffff7ff6000     0x7ffff7ff8000     0x2000        0x0 
0x7ffff7ff8000     0x7ffff7ffa000     0x2000        0x0 [vvar]
0x7ffff7ffa000     0x7ffff7ffc000     0x2000        0x0 [vdso]
0x7ffff7ffc000     0x7ffff7ffd000     0x1000    0x23000 /lib/x86_64-linux-gnu/ld-2.21.so
0x7ffff7ffd000     0x7ffff7ffe000     0x1000    0x24000 /lib/x86_64-linux-gnu/ld-2.21.so
0x7ffff7ffe000     0x7ffff7fff000     0x1000        0x0 
0x7ffffffde000     0x7ffffffff000    0x21000        0x0 [stack]
0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]
(gdb) run
(gdb) i proc map
process 11377
Mapped address spaces:

Start Addr           End Addr       Size     Offset objfile
0x400000           0x401000     0x1000        0x0 /home/huang/Documents/a.out
0x600000           0x601000     0x1000        0x0 /home/huang/Documents/a.out
0x601000           0x602000     0x1000     0x1000 /home/huang/Documents/a.out
0x7f073ac56000     0x7f073ae16000   0x1c0000        0x0 /lib/x86_64-linux-gnu/libc-2.21.so
0x7f073ae16000     0x7f073b016000   0x200000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7f073b016000     0x7f073b01a000     0x4000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7f073b01a000     0x7f073b01c000     0x2000   0x1c4000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7f073b01c000     0x7f073b020000     0x4000        0x0 
0x7f073b020000     0x7f073b044000    0x24000        0x0 /lib/x86_64-linux-gnu/ld-2.21.so
0x7f073b229000     0x7f073b22c000     0x3000        0x0 
0x7f073b241000     0x7f073b243000     0x2000        0x0 
0x7f073b243000     0x7f073b244000     0x1000    0x23000 /lib/x86_64-linux-gnu/ld-2.21.so
0x7f073b244000     0x7f073b245000     0x1000    0x24000 /lib/x86_64-linux-gnu/ld-2.21.so
0x7f073b245000     0x7f073b246000     0x1000        0x0 
0x7ffc5b3db000     0x7ffc5b3fc000    0x21000        0x0 [stack]
0x7ffc5b3fc000     0x7ffc5b3fe000     0x2000        0x0 [vvar]
0x7ffc5b3fe000     0x7ffc5b400000     0x2000        0x0 [vdso]
0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

可以看到,开启了ASLR后,每次程序运行时,动态库都被加载到不同的地址。

注:这给缓冲区溢出攻击带来很大麻烦。然而仔细观察上图发现,有些东西仍然不变,例如程序的.text, .rodata, .data 段,两次的加载地址不变,这正是突破口。

看一下程序中涉及到的库函数调用的反汇编代码:

400817:    e8 94 fd ff ff          callq  4005b0 <fread@plt>
40081c:   48 8b 45 f8             mov    -0x8(%rbp),%rax
400820:   48 89 c7                mov    %rax,%rdi
400823:   e8 98 fd ff ff          callq  4005c0 <fclose@plt>
400828:   c9                      leaveq 
400829:   c3                      retq  

Disassembly of section .plt:

0000000000400590 <puts@plt-0x10>:
400590:   ff 35 72 0a 20 00       pushq  0x200a72(%rip)        # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
400596:   ff 25 74 0a 20 00       jmpq   *0x200a74(%rip)        # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
40059c:   0f 1f 40 00             nopl   0x0(%rax)

00000000004005a0 <puts@plt>:
4005a0:   ff 25 72 0a 20 00       jmpq   *0x200a72(%rip)        # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
4005a6:   68 00 00 00 00          pushq  $0x0
4005ab:   e9 e0 ff ff ff          jmpq   400590 <_init+0x20>

00000000004005b0 <fread@plt>:
4005b0:   ff 25 6a 0a 20 00       jmpq   *0x200a6a(%rip)        # 601020 <_GLOBAL_OFFSET_TABLE_+0x20>
4005b6:   68 01 00 00 00          pushq  $0x1
4005bb:   e9 d0 ff ff ff          jmpq   400590 <_init+0x20>

00000000004005c0 <fclose@plt>:
4005c0:   ff 25 62 0a 20 00       jmpq   *0x200a62(%rip)        # 601028 <_GLOBAL_OFFSET_TABLE_+0x28>
4005c6:   68 02 00 00 00          pushq  $0x2
4005cb:   e9 c0 ff ff ff          jmpq   400590 <_init+0x20>

对库函数的调用,经过了 .got 和 .plt 的中转,正如书上所说。对库函数的调用会跳转到 .got 表:

(gdb) x/12gx 0x601000
0x601000:   0x0000000000600e28   0x00007ffff7ffe188
0x601010:   0x00007ffff7defed0   0x00000000004005a6
0x601020 <fread@got.plt>: 0x00000000004005b6   0x00000000004005c6
0x601030 <__stack_chk_fail@got.plt>:  0x00000000004005d6   0x00007ffff7a2f950
0x601040 <__gmon_start__@got.plt>:    0x00000000004005f6   0x0000000000400606
0x601050 <fopen@got.plt>: 0x0000000000400616   0x0000000000400626

以 fread 为例,跳转到 0x601020 (内容是 0x4005b6),刚好是 .plt 中的下一句代码,该代码 push 一个调用ID(0x1),然后跳转到 0x400590,继续 push got+0x8 表目,然后跳转到 <got+0x10> 处。

(gdb) x/2gx 0x601010
0x601010:   0x00007ffff7defed0  0x00000000004005a6
(gdb) i proc map
process 11995
Mapped address spaces:

Start Addr           End Addr       Size     Offset objfile
0x400000           0x401000     0x1000        0x0 /home/huang/Documents/a.out
0x600000           0x601000     0x1000        0x0 /home/huang/Documents/a.out
0x601000           0x602000     0x1000     0x1000 /home/huang/Documents/a.out
0x7ffff7a0f000     0x7ffff7bcf000   0x1c0000        0x0 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7bcf000     0x7ffff7dcf000   0x200000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7dcf000     0x7ffff7dd3000     0x4000   0x1c0000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7dd3000     0x7ffff7dd5000     0x2000   0x1c4000 /lib/x86_64-linux-gnu/libc-2.21.so
0x7ffff7dd5000     0x7ffff7dd9000     0x4000        0x0 
0x7ffff7dd9000     0x7ffff7dfd000    0x24000        0x0 /lib/x86_64-linux-gnu/ld-2.21.so
0x7ffff7fde000     0x7ffff7fe1000     0x3000        0x0 
0x7ffff7ff6000     0x7ffff7ff8000     0x2000        0x0 
0x7ffff7ff8000     0x7ffff7ffa000     0x2000        0x0 [vvar]
0x7ffff7ffa000     0x7ffff7ffc000     0x2000        0x0 [vdso]
0x7ffff7ffc000     0x7ffff7ffd000     0x1000    0x23000 /lib/x86_64-linux-gnu/ld-2.21.so
0x7ffff7ffd000     0x7ffff7ffe000     0x1000    0x24000 /lib/x86_64-linux-gnu/ld-2.21.so
0x7ffff7ffe000     0x7ffff7fff000     0x1000        0x0 
0x7ffffffde000     0x7ffffffff000    0x21000        0x0 [stack]
0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

可见是在 libc.so 中,设置一下断点跟踪发现:

(gdb) disas
Dump of assembler code for function _dl_runtime_resolve:
=> 0x00007ffff7defed0 <+0>:    sub    $0x78,%rsp
0x00007ffff7defed4 <+4>:   mov    %rax,0x40(%rsp)
0x00007ffff7defed9 <+9>:   mov    %rcx,0x48(%rsp)
0x00007ffff7defede <+14>:  mov    %rdx,0x50(%rsp)
0x00007ffff7defee3 <+19>:  mov    %rsi,0x58(%rsp)
0x00007ffff7defee8 <+24>:  mov    %rdi,0x60(%rsp)
0x00007ffff7defeed <+29>:  mov    %r8,0x68(%rsp)
0x00007ffff7defef2 <+34>:  mov    %r9,0x70(%rsp)
0x00007ffff7defef7 <+39>:  bndmov %bnd0,(%rsp)
0x00007ffff7defefc <+44>:  bndmov %bnd1,0x10(%rsp)
0x00007ffff7deff02 <+50>:  bndmov %bnd2,0x20(%rsp)
0x00007ffff7deff08 <+56>:  bndmov %bnd3,0x30(%rsp)
0x00007ffff7deff0e <+62>:  mov    0x80(%rsp),%rsi
0x00007ffff7deff16 <+70>:  mov    0x78(%rsp),%rdi
0x00007ffff7deff1b <+75>:  callq  0x7ffff7de88b0 

调用了 libc.so 的 _dl_runtime_resolve,这个方法可以在 glibc 源代码下的sysdeps/x86_64/dl-trampoline.S文件中找到(32位系统在 sysdeps/i386/dl-trampoline.S)。这个方法就是用来解析库函数调用的,调用完毕后会把库函数地址写到上述的 got 表目中,下次调用库函数就不用解析了,直接 jmp。

然而这里我所关心的并不是这个,而是,借由调用ID(参数1)和一个 got 表目(参数2),_dl_runtime_resolve 是如何解析出的呢?

我们知道,程序中所有对库函数的调用,都会在 got 有一个表目,索引位置即其 ID。由于它只是个索引位置,所以并不是唯一标识符,也就是说,并不是事先给所有库函数一个ID,根据ID来查找。因此,肯定是根据函数名称来查找了。那么,函数名称是存在程序的.dynsym段中的:

huang@ubuntu:~/Documents$ readelf --dyn-syms a.out 

Symbol table '.dynsym' contains 10 entries:
Num:    Value          Size Type    Bind   Vis      Ndx Name
0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)
2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fread@GLIBC_2.2.5 (2)
3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fclose@GLIBC_2.2.5 (2)
4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __stack_chk_fail@GLIBC_2.4 (3)
5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
6: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.14 (4)
8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fopen@GLIBC_2.2.5 (2)
9: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND execl@GLIBC_2.2.5 (2)

Program Headers:
Type           Offset             VirtAddr           PhysAddr
FileSiz            MemSiz             Flags  Align
DYNAMIC        0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
0x00000000000001d0 0x00000000000001d0 RW     8

可以看到,参数1与.dynsym中的索引是一样的。因此,libc.so 肯定存有.dynsym的地址,然后才根据参数1来取出函数名称的。有证据吗?当然有:

(gdb) x/2gx 0x601008
0x601008:   0x00007ffff7ffe188  0x00007ffff7defed0

原来参数2又是在 libc.so 中,到底何物?

(gdb) x/10gx 0x00007ffff7ffe188
0x7ffff7ffe188: 0x0000000000000000  0x00007ffff7ffe718
0x7ffff7ffe198: 0x0000000000600e28  0x00007ffff7ffe720
0x7ffff7ffe1a8: 0x0000000000000000  0x00007ffff7ffe188
0x7ffff7ffe1b8: 0x0000000000000000  0x00007ffff7ffe700
0x7ffff7ffe1c8: 0x0000000000000000  0x0000000000600e28

这是什么东西呢?我的第一感觉是这是一个结构体(后来看源代码后证明是对的)。仔细看第三项也就是 0x7ffff7ffe198处,是0x0000000000600e28!这正是上面的DYNAMIC段的地址!

于是,_dl_runtime_resolve可以拿到函数名称的秘密已经解开了。首先是函数在.dynsym的索引值,然后是 DYNAMIC的地址。这不就是查表么?

如果对于反汇编、调试、dump的结果还不满意,可以通过查看源代码证实。下表附赠:

glibc-2.21
_dl_runtime_resolve: sysdeps/x86_64/dl-trampoline.S
_dl_fixup: elf/dl-runtime.c
参数2的结构体:struct link_map, include/link.h
struct link_map
{
/* These first few members are part of the protocol with the debugger.
This is the same format used in SVR4.  */

ElfW(Addr) l_addr;      /* Difference between the address in the ELF
file and the addresses in memory.  */
char *l_name;       /* Absolute file name object was found in.  */
ElfW(Dyn) *l_ld;        /* Dynamic section of the shared object.  */
struct link_map *l_next, *l_prev; /* Chain of loaded objects.  */

// 此处省略
};

小结:这里的小结主要是涉及栈溢出攻击的,似乎跟 PIC 无关,其实不然。

由于现在系统中,无论是 CPU 还是操作系统、编译器,都对栈溢出(stack overflow)做了很多保护措施,包括:

  1. NX (No-eXecute):禁止执行,或称作 DEP(Data Execution Protection, 数据执行保护)。这一项机制是为了防止攻击者在栈中写入 shellcode。也就是说可写入的内存页(比如栈)是不可执行的,而可执行的内存页(比如.text)是不可写入的。

  2. Stack Protector:或称作 canary(金丝雀)。有了 NX 并不够,依然可以利用 ROP(Return-Oriented-Programming) 实施攻击,SP 通过在函数的局部变量顶部放置一个随机值(该随机值并未映射到虚拟地址空间,所以程序无法访问,用 gcc 反汇编会看到是在 %fs:0x28 处的一个值,该值在每次程序运行时都随机取),在函数返回前,会检查该值是否改变,如改变则表明缓冲区溢出,立刻退出程序。

  3. ASLR(Address Space Layout Randomizations):此机制可以防止 return-into-libc 攻击,也就是说,每次程序运行时,动态库都会被加载到随机的地址空间,因此,以前惯用的、通过反汇编精心准备的那些地址,都失效了。

然而,即便有了这些保护机制,黑客仍然是有办法绕过而实施攻击的 :-)

但是,这些保护机制并不是全然没用,相反,非常有用,特别是 SP,保护指数五颗星。在没有这些保护机制的时代,只要是缓冲区溢出,就100%可利用;有了之后,就不是所有的溢出都可以利用了,得看溢出的具体情况,有的可以,有的不可以。