内容纲要

前言

在学习 Hijack GOT 这种 PWN 攻击方式时 , 需要理解程序在链接库内定位函数的两张表 ------ GOT( 全局偏移量表 )PLT( 程序链接表 ) , 这是非常关键的

因为之前没有接触过这方面内容 , 所以本章先不去探究 GOT 和 PLT 这两张表 , 而是看一看基础内容


一些基础内容

在研究 GOT 和 PLT 这两张表前 , 需要学习一些基础内容 , 这里参考了 PLT and GOT - the key to code sharing and dynamic librariesPhyzX Linux中的GOT和PLT到底是个啥 ?

Linux下的库有两种 静态库动态库( 共享库 ) , 两者的不同点在于代码被载入的时刻不同 .

  • 静态库 : 代码在编译过程中已经被加载入可执行程序 , 代码体积比较大

  • 共享库 : 代码在可执行程序运行时才会被载入内存 , 在编译过程中仅简单引用 , 代码体积比较小

库是别人 写好的 , 成熟的 , 可以复用 的代码 . 现实中每个程序都要依赖很多基础的底层库 , 不可能每个人的代码都是从零开始 , 因此库的存在意义非同寻常

共享库(Shared Library) 是现代操作系统中不可缺少的部分 , 它的优点是 : 不同的应用程序如果调用相同的库 , 那么在内存里仅需要有一份该共享库的实例即可

而要分析共享库机制 , 还得从二进制文件开始分析


二进制可执行文件的生成

首先要知道 , 在C语言中 , 可以执行文件的生成过程通常需要三个步骤

  1. 编译器把每个( .c )文件编译成汇编( .s )文件

  2. 汇编器把每个( .s )文件转换为对象( .o )文件

  3. 链接器把多个( .o )文件链接为一个可执行( .out )文件

需要注意对象文件( .o ) , 有时它也被称为目标文件 , 目标文件是源代码编译但未链接的中间文件( Windows 的 .obj 文件 和 Linux 的 .o 文件 ) , 它们与可执行文件的内容和结构十分类似 , 从广义上两者的格式是完全相同的 .

这种文件格式在 Windows 下被称为 PE-COFF 文件格式 , Linux 下被称为 ELF 文件格式 , 事实上它们都是 COFF( Common Object File Format , 通用对象文件格式 ) 的变种格式

上文提到了 目标文件 和 可执行文件 在结构和内容上十分类似 , 但还是有区别的 , 这里简单说下两者区别 , 以防混淆

  1. 结构上 : 目标文件是编译后的可执行文件格式 , 但是未经链接过程 , 因此缺少启动代码和库实例 , 而可执行文件涵盖了编译 , 链接 , 装载 , 执行 的各个方面

  2. 内容上 : 目标文件的内容包含了编译后的机器指令代码 , 数据 以及链接时所需要的信息( 符号表 , 调试信息 , 字符串 ... ) . 通常这些信息会根据属性 , 以( Section , 或者叫 " 段 " , 可看作存储数据的容器 )的形式存储 . 通常情况下 , 机器指令被放在代码段( .code / .text ) , 全局变量和局部静态变量被放在数据段( .data )

扯远了 , 你只需要知道对象文件( 或者叫目标文件 ) 有三个种类

  • 可重定位的对象文件( Relocatable Object File )
    这是由汇编器汇编而成的( .o )文件 , 链接器在链接时将一个或者多个 Relocatable Object File 作为输入 , 经过链接处理后可以得到一个可执行的对象文件( Executable Object File ) 或者 一个可被共享的对象文件( Shared Object File )

    该类文件包含二进制代码和数据 , 由多个节( Section )构成 .

  • 可执行的对象文件( Executable Object File )
    这是日常生活中使用最多的文件( 各种工具和应用 ) .

    在 Linux 系统中 , 只存在两种可被执行的文件 , 除了可执行的对象文件 , 另一种就是可执行的脚本( 比如 Shell 脚本 ) , 虽然这些脚本仅是文本文件 , 但执行这些脚本所用的解释器是可执行的对象文件( 比如 Bash )

  • 可被共享的对象文件( Shared Object File )
    这些文件也被称为动态库文件或者共享库文件( 也就是 .so 文件 ) . 在程序运行是被动态加载 , Shared Object File 是没有 main() 函数的 , 这是它与 Executable Object File 最大的区别

    本文开始时已经比较过静态库动态库( 共享库 )之间的优劣区别

    共享库在发挥作用的过程中需要经历两个阶段

     1. 链接编辑器( link editor )拿动态库和其他的 Relocatable Object File 以及其它的 Shared Object File 作为输入 , 经过链接处理后 , 生成另外的 Shared Object File 或者 Executable Object File
    
     2. 在程序开始运行时, 动态链接器( dynamic linker)拿动态库和一个 Executable Object File 以及另外一些 Shared Object File 一起处理 , 在Linux系统中创建一个进程镜像
    
     注意 : 运行时的动态加载并不是运行到特定的库函数才加载 , 而是在程序一运行时就加载好了 , 进程镜像中的代码段都是只读的 , 一旦运行起来不能随意更改
    

ELF可重定位的对象文件结构

Linux生成的目标文件是标准的 ELF 格式文件 . ELF( Executable and Linkable Format )文件格式是一种用于二进制文件 , 可执行文件 , 目标文件 , 共享库和核心转储的标准文件格式( 参考 ) .

ELF文件格式提供了两种视图 , 一种叫链接视图 , 在链接时使用 , 以节( Section )为单位 . 一种叫执行视图 , 在执行时使用 , 以段( Segment )为单位 . , 它们本质是一样的 .

png

 如上图所示 : 左侧为链接视图 , 右侧为执行视图

 一个ELF文件可以被划分为四个部分
 1. ELF Header : ELF头部 , 描述整个文件的组织
 2. Program Header Table : 程序头部表 , 描述文件中各种 Segments , 告诉系统如何创建进程镜像 . 在链接阶段可以忽略该项 . 
 3. Sections / Segments : Sections从链接的角度来描述ELF文件 , Segments从运行的角度来描述ELF文件 . 从图中可以看出 : Segments 和 Sections 是包含关系 , 一个 Segment 包含多个 Section 
 4. Section Header Table : 节区头部表 , 包含了文件各个Section的属性信息 , 比如大小 , 偏移 ... , 在执行阶段可以忽略该项 . 

 其中每个部分的具体内容读者可以自行搜索~

上面说过 , Segment 是多个 Section 的集合 , Section 按照一定规则映射到 Segment , 那为什么还要区分两种视图呢?

 当 ELF 文件被加载到内存中( 也就是运行时 ) , 系统中会将多个拥有相同权限( flg值 )的 Section 合并为一个 Segment . 

 现在主流操作系统都是以 " 页 " 为基本单位来管理内存分配的 , 一般页的大小为 4096B  . 同时 , 内存的权限管理粒度也是以页为单位的 , 页内的内存具有相同的权限

 操作系统对于内存的管理是追求高效和高利用率的 . 在ELF文件被映射时是以系统的页长度为单位的 . 如果 Section 的长度不是页长的整数倍 , 则多余的部分也将占用一个页 . 一个 ELF 文件中含有多个 Section . 因此可能占用较多的页 .

 将多个 Section 根据权限合并 , 减少页面内部的碎片 , 从而减少内存资源浪费 . 提高了内存利用率 .

重定位

什么是重定位 , 为什么需要重定位 ?

二进制中的重定位( Relocations )是在编译代码时留下的坑 , 预留给外部变量或者函数

这里的变量和函数都被称为符号( symbols ) , 在编译时编译器只知道外部符号的类型( 变量类型和函数原型 ) , 而不知道具体的值( 变量值和函数实现 )

举个例子

  • C语言代码
    png

    通过 extern 定义了一个外部变量

  • GCC编译汇编源代码 , 但不进行链接 , 生成可重定位的对象文件 , 然后通过 readelf 工具查看重定位部分的内容

    png

    符号 foo 对应的 Sym.Value = 0 , 说明在把 test.c 编译成 test.o 时 , foo 这个符号的值还不确定 , 所以编译时先写上默认值 0 . 然后在 Type 这个位置写上 R_X86_64_PC32 , 从而告诉链接器 : 在最终生成的可执行文件的 .text 的 0x6 这个偏移位置补上符号 foo 的值

这些预留的坑会在之后( 链接期间或者运行期间 )填上 . 如果在链接期间填充 , 主要通过工具链中的链接器( 比如 GNU 链接器 ld ) ; 如果在运行期间填充 , 主要通过动态链接器( 比如解释器 )

本文中会用到两个C语言文件

  • 首先是 symbol.c , 定义了一个函数和一个变量

    png

  • 其次是一个主函数文件 , 调用该动态链接库

    png


符号表

函数和变量被当成符号存储在可执行文件中 , 不同类型的符号又聚集在一起 , 被称为符号表

有两种类型的符号表

  • 常规符号表( .symtab / .strtab )
    常规的符号表通常只在调试时被调用 , 可以通过 strip 命令删除该符号表

  • 动态符号表( .dynsym 和 .dynstr )
    程序执行时真正查找的目标是动态符号表


位置无关代码( 地址无关代码 )

位置无关代码( PIC , Position Independent Code ) 是指运行和放置与地址无关的代码 , 又被称为 地址无关可执行文件( PIE , Position Independent Executable ) , 下面将会通过实例来看一看 PIC 到底是怎么一回事

  1. 首先将之前创建的 symbol.c 编译为动态链接库( .so ) , 这里以32位程序为例 , 方便学习

    如果当前主机为64位机器 , 需要安装适配库 , 否则会报错

    png

    安装后就可以通过 -m 32 选项编译32位程序了

    png

     -m32 : 编译32位程序
    
     -shared : 生成动态链接库( .so )
    
     -fPIC : 生成与位置无关的代码( 什么是与位置无关后面会提到 )
    
     一般来说 , -shared 和 -fPIC 是配套使用的 , 生成与位置无关的 .so 文件
    
    1. 下面要使编写的动态库发挥作用 , 就像上文所讲的一样 , 经过链接编辑器( Link Editor )的链接处理 , 生成另外的 Shared Object File 或者 Executable Object File , 这里分别链接生成两个文件 , 位置相关的 main位置无关的 main_pi

    png

     -L : 指定库的路径
    
     -l : 指定需要连接的库名( 注意 , 你写的库被命名为 " libxxx.so " , 那么 " -l xxx " 就代表在 -L 指定的路径中寻找 " libxxx.so " 这个动态库文件 )
    
     -no-pie : GCC默认开启 --enable-default-pie 这个选项 . 当开启此选项时 , 编译后的可执行文件类型为 " DYN (Shared object file) " ; 关闭此选项后 , 才会生成 " EXEC (Executable file) " 这个文件类型 . 具体内容可以参考下面的链接
    
     -fno-pic : PIC( Position independent code ) , 即位置无关代码 , 通过 -fno-pic 即可生成位置相关代码
    

    -no-pie

在编译libsymbol.so时指定了 -fPIC , 在链接main_pi时指定了 -pie( 默认 ) , 这些参数都是为了生成位置无关代码 , 那么所谓的 " 位置无关代码 " 和 " 位置相关代码 " 到底是什么呢?

操作系统在执行一个可执行文件时 , 首先会将磁盘上的该文件读取到内存中 , 然后再执行

每个进程都会有自己的虚拟内存空间 , 以 32 位程序为例 , 就有 2^32 = 4G 的寻址空间 , 从 0x00000000 到 0xffffffff , 虚拟内存最终会通过页表映射到物理内存中

链接器在加载程序时是有规定的 , 比如 32 位程序会加载到 0x08048000 这个位置 , 而 64 位程序会加载到 0x00400000 这个位置 , 因此在写程序时 , 可以以这个地址为基础 , 对变量进行绝对地址寻址

还需要注意一点 , 每个32位程序的加载地址都为 0x08048000 , 也就是说代码段的地址起点是 0x08048000 , 代码段后紧跟数据段 , 所以数据段的地址起点一般为 : 0x08049000

拿刚才链接的 main 文件举个例子

  • 先验证代码段和数据段的地址起点是否符合上面的结论
    1. " .text Section " 存放编译程序的机器代码 , 属于代码段内容

      png

      Offset 为 0x001050 , 0x001050 + 0x08048000 = 0x08049050 , 符合给出的 Address

    2. " .bss Section " 存放未初始化的全局变量 , 属于数据段内容

      png

      Offset 为 0x003020 , 0x003020 + 0x08049000 = 0x0804c020 , 符合给出的 Address

    可见 , 0x08048000 是32位程序代码段的初始地址 , 0x08049000 是32位程序数据段的初始地址

  • 再看 main 文件中 main 函数的汇编代码

    png

    注意绿色的四行 , 如果你熟悉汇编代码就知道这四行是获取函数局部变量并将它们压入栈中保存 , 这里就是通过绝对地址寻址来获取变量的值 , 你可以通过 gdb 来查看这两个地址的值

    png

    x 是内存查看命令 , 用于查看指定内存地址的值

    可以看到这两个地址分别对应在 main.c 中定义的两个变量 var 和 my_var , 其中 var 被赋值为 10 , 而my_var 被赋予初始值0( 可以参考前面重定位的内容 )

按照绝对地址寻址对于执行文件没有什么大问题 , 因为一个进程只有一个主函数

但是对于动态链接库而言 , 如果每个 .so 文件都要求加载到某个绝对地址 , 这是很危险的 , 因为无法确保当前 .so 文件加载地址是否和其它的 .so 文件加载地址冲突 , 因此就有了 与位置无关 概念

  • 之前以 与位置无关 的方式创建了一个 .so 文件( main_pi ) , 可以查看其代码段和数据段的对应信息

    png

    Offset是固定的 , 但是 Address 却不再保存绝对地址 , 也就是说程序可以加载到虚拟内存中的任意位置 !

  • 再来看汇编代码中 main 函数的内容

    png

    这里没有像前面一样通过 绝对地址 进行寻址 , 而是通过 %eax 寄存器来寻址 , 并且调用了 <__x86.get_pc_thunk.ax> 这个函数 , 这个函数再后面的代码中可以看到 .

    png

    该函数的作用就是把ESP中的内容当作内存地址 , 向该内存地址所指的内存单元中取值 , 将该值( 其实就是返回地址的值 )传递给 %eax .( 注意这里是 AT&T 规范 , 前面是源操作数 , 后面是目的操作数 )

    32位架构下是不支持直接访问EIP寄存器的 , 所以要通过间接的函数调用来保存返回地址 ,64位架构下就可以直接访问EIP寄存器了 .

    你会注意到之后进行了一步add操作( add $0x2e50,%eax ) , 现在 %eax 的值就发生了变化 , 那么变化后的值到底是什么呢 ?

现在要去确定 %eax 的值 , %eax的值有两部分组成 , 一部分是当前EIP的值 , 一部分是add指令增加的立即数 0x2e50

  1. 当前 EIP 的值

    这个要深究比较复杂 , 王爽老师那本《汇编语言》的第二章的2.10节有关CPU如何执行一条指令的一系列图示讲的很详细 . 总结而言就是一句话 , 当CPU还没有执行下一条指令的时候 , IP寄存器的值已经指向下一条指令了

    在执行 add 指令前 , EIP的值已经指向这条 add 指令了 , 即为 11b0

  2. 加法运算后 %eax 的内容

    0x11b0 + 0x2e50 = 0x4000

    这个地址对应的是 ...

    png

    .got.plt 的起始地址!!! 终于接近了本文的重点 , 但是别急 , 让我先把 位置无关代码的内容写完~

    那么如何通过 %eax 得到变量的值呢? , 其实根据 %eax 的偏移量 , 就可以成功找到变量的值 , 如下

    png

    此时 %eax 寄存器为 .got.plt 的起始地址 , 然后按照偏移量来获取变量 , 通过 %eax + 0x1c.data Section 获取了参数var的值 , 通过 %eax - 0xc , 从 .got Section 获取了参数 my_var 的值 , 但后者是在 symbol.c 中定义的 , 其内容在编译期是未知的 , 所以被赋予初值 0 .

因此 , 位置无关代码( PIC )实际上就是通过运行的PC指针的值来找到代码所引用的其它符号的位置 , 不管二进制文件被加载到主存储器的哪个位置 , 都可以正确运行 , 不受其绝对地址影响 .

位置无关代码( PIC )的基本思想是 : 把指令中需要修改的部分分离出来 , 跟数据部分放在一起 , 这样指令部分就可以保持不变 , 而数据部分则在每一个进程中都具有一个副本 . 因此位置无关代码能够在不做修改的情况下被复制到内存中的任意位置 .


后续

关于 GOT 和 PLT 的基础内容就到这里了 , 在下一章中将会对这两张表进行具体的分析

最后修改日期:2019年9月17日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。