Note: 本文主要面对 x86_64 架构。

ELF

首先还是要对 ELF 有一点简单的了解,以下内容主要来源 man 5 elf,也比较建议对着这个手册去查阅相关的内容。

一般而言,ELF 有三个头部,ELF Header、Program Headers、Section Headers:

  • ELF Header 存放着关于 ELF 的信息,通常比较重要的有:

    • e_ident 包含 我们熟知的 ELF magic,所有 ELF 二进制开头四个字节都需要是 \x7fELF;另外还有其他的一些杂项
    • e_type 标识这个二进制是哪种类型,二进制或者共享库(注意并非所有二进制都是 ET_EXEC,也可以是 ET_DYN,后面讲述 PIE 会提到)
    • e_machine 标识二进制的目标平台,x86_64 一般就是 EM_X86_64
    • e_entry 标识二进制的入口,即需要运行的第一行代码的位置,everything starts from here
    • 其他各种偏移、大小等(包括 Program Headers 和 Section Headers 等)
  • Program Headers 则是个数组,存放着每个段(segment)的信息,包括段类型、偏移、大小、权限等等;通常用的比较多的段类型有:

    • PT_LOAD 这部分指示哪些段需要被载入到内存中,通常都是包含代码以及数据的部分
    • PT_INTERP 这部分指示了 dynamic linker 的路径,x86_64 Linux 下通常都是 /lib64/ld-linux-x86-64.so.2,也可以通过在 linker 中加入 --dynamic-linker= 来指定自己的链接库;并且在程序载入完后会首先执行 ld.so 的入口函数,再由 ld.so 跳转到 a.out(目标程序)。
      当然程序也可以没有这个字段,例如我们可以直接执行 /lib64/ld-linux-x86-64.so.2,linker 肯定不能再要别的 linker 去解析符号了(禁止套娃递归)。
  • Section Headers 则是每个段(例如 .text 等)的信息,其中也包括调试信息和符号表等;主要是留给 linker 使用的,内核没有用到

PIE

谈论 PIE 之前,我们需要先了解下 PIC(position-independent code):顾名思义吧,就是产生与位置无关的代码,但是自然这样就感觉有一点点的“抽象”,所以还是来个例子吧:

const char* answer = "!42!"; // extern

void print_me(const char* me) {
        puts(me ?: answer);
}

我们来简单地“人脑编译”将 print_me 函数转换成汇编,多数人(起码是我)应该会是这么想的:

; puts 只接受一个 rdi 作为参数,假设变量 answer 的地址是 0x402000

mov rdi, 0x402000
call puts

好了,这样一个 position-dependent code(no-PIC)二进制就生成了。是不是开始有点感受到了,什么是“position-independent”。没错,上面我们的 rdi 地址是硬编码的,但是对于动态库来说,不可能每个动态库我们都能提前预知(或者写死)它的加载地址,所以我们就必须要使用一些不用硬编码方式就能得到地址的方式,PIC 应运而生。

现在把 PIC 开起来,gcc -fPIC -shared 编译一下,看一下反汇编的结果:

mov    rax,QWORD PTR [rip+0x2eaf]        # 3fe8 <answer@@Base-0x40>
mov    rax,QWORD PTR [rax]
mov    rdi,rax
call   1050 <puts@plt>

可以看到我们多了一次寻址,这是因为 PLT 的缘故,将 answer 变量视作了一个符号(链接过程因懒癌暂时留作 TODO 了),不过重要的还是我们用 rip+0x2eaf 来定位变量了,不再是之前写死的地址。这个解决方案还是有点“巧妙”的,以 RIP 作为偏移的计算的基础,能保证编译器编译时和运行时地址绝对一致。

言归正传,PIE 就是把 PIC 这套机制放到了二进制上。可能接触过 PWN 的朋友会觉得上面 0x402000 这个地址很熟悉,因为在没有开启 PIE 的时候,二进制的起始地址就是 0x400000(这个地址 Stack Overflow 上有人提到,有点像是约定俗成,其中一个原因我猜可能是满足 4MiB 大页表?),并且对于二进制内的变量都是采用硬编码的方式去访问的。

但同样,打 PWN 的时候可能我们都会有点“苦恼”一种东西,ASLR(Address Space Layout Randomization),也就是地址随机化的二进制:

$ checksec --file=a.out
... PIE             ...
... PIE enabled     ...

在开启了 PIE 的情况下,如果内核同时也启用了 ASLR,就会导致真实的程序偏移被加上了一个随机的偏移,有时候就不得不进行暴力找出来。所以 PIE 其实也就是为地址随机化而诞生的,只是因为有前车 PIC 之鉴,称之为 PIE。

Bootstrap

通常我们启动一个二进制都是通过 shell 来进行的,根据 UNIX 哲学API,shell 会帮我们执行两个系统调用:fork() + exec()。所以实际上内核真正操作二进制的地方是 exec(),就直接给出一个调用链:

search_binary_handler (exec.c)
    exec_binprm (exec.c)
        bprm_execve (exec.c)
            do_execveat_common (exec.c)
                do_execve (exec.c)          <-- `exec` syscall
                do_execveat (exec.c)
            kernel_execve (exec.c)

因此当执行 exec() 系统调用时,Linux 就会一路往下走到 search_binary_handler() 函数上来寻找处理二进制的回调函数(应该叫 probe?):

list_for_each_entry(fmt, &formats, lh) {
    if (!try_module_get(fmt->module))
        continue;
    read_unlock(&binfmt_lock);

    // here!
    retval = fmt->load_binary(bprm);

    read_lock(&binfmt_lock);
    put_binfmt(fmt);
    if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
        read_unlock(&binfmt_lock);
        return retval;
    }
}

对于 ELF 格式,会调用 load_elf_binary() 这个函数,这也是下面重点阐述的。

Load ELF in Kernel

稍微抽象了一下 load_elf_binary() 这个函数,大概执行的流程如下(假设要执行的文件是 a.out,linker 名称为 ld.so):

  1. 校验是否为 ELF 文件、校验 ELF 文件类型、校验目标平台是否匹配等等等

  2. 读取 a.out 的 ELF Header,从 ELF Header 找到 Program Headers 的位置和大小(变量为 elf_phdata)先处理下面两个 Header(phdr):

    1. 找到 PT_INTERP,读取 ld.so 文件名,并以此从文件中读取它的 ELF Header
    2. 对于 PT_GNU_STACK 这个 phdr,从中得到栈是否能够执行的标记(似乎这个 phdr 只用来标记栈是否可执行)
  3. 先处理 ld.so,从 ld.so 的 ELF Header 中读取 Program Headers(变量为 interp_elf_phdata

  4. 处理 elf_phdataPT_GNU_PROPERTY 字段(用处暂时不是很确定 TODO)

  5. 清理旧线程留下的资源(例如页表、文件等资源),因为执行 exec() 之前也是会有一个环境的,Linux 似乎没有 CreateProcess() 这种方便的东西;并且设置新的环境(例如分配栈空间等)

  6. 总算开始处理 elf_phdataPT_LOAD 段了

    1. 从 phdr 中获取映射权限(读、写、执行)
    2. 对于 PIE 程序,需要计算并设置 load_bias 偏移(GDB 禁用 ASLR 的话能看到一个比较熟悉的地址 0x555555554000,就是在这里设置的);非 PIE 程序就简单地设置偏移为 0
    3. 根据 phdr 的信息,用 mmap() 映射二进制文件与虚拟地址(直接在文件上进行映射,也算是 lazy?),虚拟地址需要加上 load_bias 的偏移;一个要注意的小点是因为要保证 PT_LOAD 的连续,所以内核的策略是先 mmap() 整个需要的大小(包括 BSS 段),然后再 munmap() 掉,后续的块再重新映射回来。
      P.S. 感叹内核的代码,是有点过于“精致”了,表述能力似乎没有这么的好。。
  7. 对于 ld.sointerp_elf_phdata 也开始映射 PT_LOAD 段,位于子函数 load_elf_interp()

    1. 同样也是将 PT_LOAD 映射到内存区域;不过有个有意思的点是因为 load_addr + vaddr 不知道是“巧合”还是说构造成这样,一开始是都是 0,所以导致 mmap() 的地址 hint 为 0,mmap() 就会从用户地址的最高处 0x7fffffffffff 找到空闲的 VMA 然后映射,这也就是为什么 /proc/self/maps 中的 ld.so 都位于这么高位的原因;另外因为上面第 5 步的原因,栈是最先被映射的,所以是始终是处于用户地址的最高处(不考虑 ASLR);另外的另外 ASLR 是在 mmap() 处理中计算的,这里就暂时也不展开讲了
    2. 设置入口为 ld.so ELF 中的 e_entry;如果没有 dynamic linker,入口则是 a.oute_entry,自然
  8. 映射 vdso,即把一些 syscall 代码映射到用户态中(一般都是比较简单且常用的函数,例如 clock_gettime()),man 7 vdso 能看到所有映射的函数

  9. 往栈里(注意 ld.soa.out 都是共用一个内存空间的,栈也是一样共用)塞入 arg 函数参数、env 环境变量、auxv

    1. 一个比较 tricky 的点就是,argv[0] 存放的是 a.out 的名称,猜测 ld.so 是根据这个来找到的?还是 auxv?留作 TODO,后续 linker 篇应该会继续(
  10. 最后岁月静好,开始 start_thread()current 中寄存器的值更新,尤其是 ipsp,设置完之后下一次调度时就会直接开始执行了!

P.S. 可以看一下 How programs get run 这篇文章,当作扩展阅读了(。

P.P.S. 更详尽的注释说明后续应该会放在 notelinux 仓库下,主要以注释的方式详尽地去说明;主要还是当笔记用,所以可能有点杂乱(而且不正确)WONT FIX

What’s More?

这里主要是做一个简单的 summary,有很多细节的地方可能没有被覆盖到(例如 compat 兼容等等),不过对于粗略地了解 ELF 格式以及它的运行可能还是有一点点用(希望如此?)。

后续会继续往 linker 方向稍微再探究一下,如果我能看得到 RTLD 的话(。