从ELF到maps

当我们在bash里输入一个程序的路径,轻按回车之后,一个静态的ELF可执行程序就会变成一个动态运行的进程,这个过程涉及到可执行程序与动态链接库的文件格式、内核装载与动态链接,而且在很多书籍与文章里都已经被讨论得不少了。

比如,下面是进程载入运行整体过程的初略描述:

  1. 进程调用execve系统调用,传入的参数包括待执行文件的路径,参数与环境变量,进入内核
  2. 内核首先检查文件头部的魔数(Magic),发现是ELF文件后会调用load_elf_binary内核函数运行此ELF文件
  3. load_elf_binary对ELF文件进行初步检查与处理,继而查找并检查动态链接器(解释器),将动态链接器与ELF文件中标记为载入(LOAD)的段(segment)映射到内存中
  4. 准备进程的参数与环境变量
  5. 内核将进程的指令地址设置为动态链接器的入口地址,以执行动态链接器的代码
  6. 动态链接器完成ELF文件的动态链接之后,将入口设置为ELF的入口,执行ELF可执行程序的指令

当然,在整个过程中还有几个问题。我们通过一个简单的程序来看看:

#include <stdio.h>

int main()
{
    printf("hello, world\n");
    getchar();
}

下面是它的段(readelf -l):

程序头:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000001f8 0x00000000000001f8  R      0x8
  INTERP         0x0000000000000238 0x0000000000000238 0x0000000000000238
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000888 0x0000000000000888  R E    0x200000
  LOAD           0x0000000000000de8 0x0000000000200de8 0x0000000000200de8
                 0x0000000000000250 0x0000000000000258  RW     0x200000
  DYNAMIC        0x0000000000000df8 0x0000000000200df8 0x0000000000200df8
                 0x00000000000001e0 0x00000000000001e0  RW     0x8
  NOTE           0x0000000000000254 0x0000000000000254 0x0000000000000254
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_EH_FRAME   0x0000000000000744 0x0000000000000744 0x0000000000000744
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000000de8 0x0000000000200de8 0x0000000000200de8
                 0x0000000000000218 0x0000000000000218  R      0x1

 Section to Segment mapping:
  段节...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag .note.gnu.build-id 
   06     .eh_frame_hdr 
   07     
   08     .init_array .fini_array .dynamic .got 

从上面的段中可以看到程序有两个类型为LOAD的段,权限分别是可读可执行(RE)与可读可写(RW),但是在程序的maps文件内容却是这样的(cat /proc/#pid/maps):

56183af26000-56183af27000 r-xp 00000000 08:01 665066                     /home/raphael/hello
56183b126000-56183b127000 r--p 00000000 08:01 665066                     /home/raphael/hello
56183b127000-56183b128000 rw-p 00001000 08:01 665066                     /home/raphael/hello
56183b5c7000-56183b5e8000 rw-p 00000000 00:00 0                          [heap]
7f3ea86cd000-7f3ea887e000 r-xp 00000000 08:01 131951                     /lib/x86_64-linux-gnu/libc-2.27.so
... ...

可以看到进程一共有三个段,权限分别是可读可执行(r-x)、只读(r)与可读可写(rw)。

其次,再看大小,第一个LOAD段的大小为0x888,第二个是0x250。而在maps中,第一个段的大小是0x1000,第二个是0x1000,第三个也是0x1000,其中第二段和第三段在地址上是连在一起的。

下面,我们来看看上面两个问题究竟是怎么回事。

先从内核看起。

在内核fs/binfmt_elf.c文件中,比较容易定位到elf_map函数就是用来把ELF中的段映射到进程空间中的段的函数。它开头是这样的(内核4.4):

#define ELF_MIN_ALIGN   PAGE_SIZE
...
#define ELF_PAGEOFFSET(_v) ((_v) & (ELF_MIN_ALIGN-1))
...    
static unsigned long elf_map(struct file *filep, unsigned long addr,
        struct elf_phdr *eppnt, int prot, int type,
        unsigned long total_size)
{
    unsigned long map_addr;
    unsigned long size = eppnt->p_filesz + ELF_PAGEOFFSET(eppnt->p_vaddr);
    unsigned long off = eppnt->p_offset - ELF_PAGEOFFSET(eppnt->p_vaddr);
...

容易看出,eppnt-&gt;p_vaddr是段的虚拟地址,eppnt-&gt;p_offset是段的文件中偏移量,eppnt-&gt;p_filesz为段的文件大小。对上例来说,ELF中第二个段的虚拟地址、文件偏移量与文件中大小分别为0x200de8、0x250与0xde8。

ELF_PAGEOFFSET宏则将地址除以页大小后得到的余数,而在amd64平台上页大小为4096(十六进制形式为0x1000)。则在上例中,则对应ELF_PAGEOFFSET(eppnt-&gt;p_vaddr)即为0xde8。对应读取的文件位置off即为0xde8-0xde8=0,对应读取文件的大小为0x250+0xde8=0x1038。由于内存分配需要以页为单位,因此0x1038大小的内存需要占据两页,这也就是maps中第二段与第三段一页大小内存的由来。

但是内核仅根据ELF中的段进行了内存区域划分,因此execve从内核返回时,进程仍然只有两个段,maps中的第三个段是实际上是从第二个段分裂出来的。

注意maps中的第三个段与第二个段主要的差异是权限,而系统调用mprotect是用来修改页权限的,因此,可以通过gdb来调试进程。在启动进程之前通过b mprotect命令设置断点,然后使用r运行进程,当发生断点时,首先通过p/x $rdi查看mprotect的第一个参数看是否是第二段的起始地址,若是则再通过p/x $rsip/x $rdx查看第二个参数是否是0x1000与PROT_READ,若是则可以使用bt查看调用栈,可以看到动态链接器在_dl_protect_relro函数中调用mprotect将第二段的一页单独修改了权限,因此形成了maps中的三段。

回过头来,我们可以看到,readelf -l的输出中最后有一个段叫GNU_RELRO,它的权限正好是只读(R),而其包含的节(section)刚好是第二个LOAD段开始的节,包括.init_array、.fini_array、.dynamic与.got,而在第二个LOAD段但是不在GNU_RELRO段中的节包括.got.plt、.data与.bss。

我们来看看这些节的信息:

节头:
  [号] 名称              类型             地址              偏移量
       大小              全体大小          旗标   链接   信息   对齐
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000000238  00000238
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.ABI-tag     NOTE             0000000000000254  00000254
       0000000000000020  0000000000000000   A       0     0     4
  [ 3] .note.gnu.build-i NOTE             0000000000000274  00000274
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .gnu.hash         GNU_HASH         0000000000000298  00000298
       000000000000001c  0000000000000000   A       5     0     8
  [ 5] .dynsym           DYNSYM           00000000000002b8  000002b8
       00000000000000c0  0000000000000018   A       6     1     8
  [ 6] .dynstr           STRTAB           0000000000000378  00000378
       000000000000008a  0000000000000000   A       0     0     1
  [ 7] .gnu.version      VERSYM           0000000000000402  00000402
       0000000000000010  0000000000000002   A       5     0     2
  [ 8] .gnu.version_r    VERNEED          0000000000000418  00000418
       0000000000000020  0000000000000000   A       6     1     8
  [ 9] .rela.dyn         RELA             0000000000000438  00000438
       00000000000000c0  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             00000000000004f8  000004f8
       0000000000000030  0000000000000018  AI       5    23     8
  [11] .init             PROGBITS         0000000000000528  00000528
       0000000000000017  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000000540  00000540
       0000000000000030  0000000000000010  AX       0     0     16
  [13] .plt.got          PROGBITS         0000000000000570  00000570
       0000000000000008  0000000000000008  AX       0     0     8
  [14] .text             PROGBITS         0000000000000580  00000580
       00000000000001a2  0000000000000000  AX       0     0     16
  [15] .fini             PROGBITS         0000000000000724  00000724
       0000000000000009  0000000000000000  AX       0     0     4
  [16] .rodata           PROGBITS         0000000000000730  00000730
       0000000000000011  0000000000000000   A       0     0     4
  [17] .eh_frame_hdr     PROGBITS         0000000000000744  00000744
       000000000000003c  0000000000000000   A       0     0     4
  [18] .eh_frame         PROGBITS         0000000000000780  00000780
       0000000000000108  0000000000000000   A       0     0     8
  [19] .init_array       INIT_ARRAY       0000000000200de8  00000de8
       0000000000000008  0000000000000008  WA       0     0     8
  [20] .fini_array       FINI_ARRAY       0000000000200df0  00000df0
       0000000000000008  0000000000000008  WA       0     0     8
  [21] .dynamic          DYNAMIC          0000000000200df8  00000df8
       00000000000001e0  0000000000000010  WA       6     0     8
  [22] .got              PROGBITS         0000000000200fd8  00000fd8
       0000000000000028  0000000000000008  WA       0     0     8
  [23] .got.plt          PROGBITS         0000000000201000  00001000
       0000000000000028  0000000000000008  WA       0     0     8
  [24] .data             PROGBITS         0000000000201028  00001028
       0000000000000010  0000000000000000  WA       0     0     8
  [25] .bss              NOBITS           0000000000201038  00001038
       0000000000000008  0000000000000000  WA       0     0     1
...

可以看到从.got.plt节开始,其大小分别是0x28、0x10与0x8,加起来刚好是0x38,也就是0x1038-0x1000,也就是maps中第一页结束之后剩下的字节,所以刚好就凑齐了maps中的第三段。

当然,我们可以再问深一点,为什么会有这个GNU_RELRO节呢?

这是出于安全性的考虑,在.got节中保存的是ELF文件依赖的外部的符号的地址。在运行时,这些地址会随着动态链接与名称解析的过程被修改为真正的地址。因此,有一些攻击方法通过修改.got节的地址,可以修改程序的控制流,以运行恶意的代码。为了防御这种攻击,链接器添加了relro的链接选项,可以将.got表全部保护起来或者部分保护起来。

gcc提供了三种选项:

  • Debian里缺省的-Wl,-z,relro。这又被称为是partial relro,即部分外链只读保护,外部数据地址在进程启动时全部解析出来,存放在.got节中,对应权限为只读。而外部函数地址则仍然随着进程的运行动态解析绑定,存放在.got.plt节中,相应的权限为读写。
  • 之前比较松散的是-Wl,-z,norelro。即外部变量与函数都放在运行期动态解析,外部数据地址存放在.got节中,外部函数地址存放在.got.plt节中,权限均为读写
  • 最严格的的是-Wl,-z,relro,-z,now。即外部变量与函数都在程序启动时解析,外部数据与外部函数地址均存放在.got节中,权限为只读。当然,这种方式虽然安全,但是会在程序启动阶段带来较大的性能损耗,因为需要解析所有的外部链接地址

发表评论

电子邮件地址不会被公开。 必填项已用*标注