PLT 与 GOT 表详解

PLT 与 GOT 表详解

引言

在 Linux 系统中,可执行程序对动态库接口的调用机制设计十分巧妙,其核心依赖于过程链接表(PLT)和全局偏移表(GOT)。本文将从 ELF 文件层面,深入解析这两个表的工作原理及动态库调用流程。

动态库调用流程解析

非首次调用(常规流程)

当程序并非首次调用动态库接口时,调用流程如下:

  1. 代码中对printf的调用会被编译为printf@plt形式,即先跳转到 PLT 表中对应的条目
  2. PLT 表是一个由汇编代码组成的数组,除索引 0 外,每个条目对应一个外部符号的调用(假设printf对应 PLT 表索引为 n)
  3. PLT [n] 的第一条指令是跳转到 GOT 表中对应的条目(假设对应 GOT 表索引为 m)
  4. 此时 GOT [m] 已存储实际函数地址,直接跳转到动态库中的printf函数执行
  5. 函数执行完成后返回原调用处,完成一次调用

首次调用(延迟绑定机制)

Linux 采用延迟绑定(Lazy Binding)机制,首次调用时 GOT 表中尚未填充实际函数地址,流程如下:

PLT 条目的结构

printf对应的 PLT [n] 为例,其汇编结构如下(伪代码):

printf@plt:
    jmp *GOT[m]       ; 跳转到GOT表第m项(首次调用时指向本条目的下一条指令)
    push n            ; 将PLT索引n压栈作为参数
    jmp PLT[0]        ; 跳转到PLT[0]的桩代码

PLT [0] 桩代码结构

PLT 表索引 0 的特殊桩代码结构如下(伪代码):

PLT0:
    pushq [GOT+8]     ; 将GOT[1]中的link_map结构地址压栈
    jmp [GOT+16]      ; 跳转到GOT[2]指向的_dl_runtime_resolve函数
    nop               ; 内存对齐填充
    nop

地址解析过程

  1. 首次调用时,GOT [m] 指向 PLT [n] 的第二条指令(push n
  2. 执行push n将 PLT 索引压栈,再跳转到 PLT [0]
  3. PLT [0] 将 GOT [1](link_map 结构地址)压栈,然后跳转到 GOT [2] 指向的_dl_runtime_resolve函数
  4. _dl_runtime_resolve 函数通过两个参数(link_map 指针和 PLT 索引 n)执行符号解析:
  • 根据 PLT 索引 n 找到.rela.plt 重定位节中的对应条目(Elf64_Rela 结构)
  • 通过条目中的 r_info 字段定位.dynsym 动态符号表中的符号信息(Elf64_sym 结构)
  • 利用符号信息中的 st_name 字段从.dynstr 动态字符串表获取符号名(如 “printf”)
  • 遍历 link_map 记录的已加载库,查找符号对应的实际地址(如 libc.so 中的 printf 地址)
  1. 将解析得到的实际地址写入 GOT [m],完成地址绑定
  2. 执行实际函数并返回,后续调用将直接使用 GOT [m] 中的地址(即常规流程)

安全风险与防护

ELF 文件的 PLT/GOT 机制存在潜在安全风险:攻击者可通过修改 GOT 表中的地址,将函数调用重定向到恶意代码,执行完成后再跳转回原流程,从而篡改程序行为。

针对此类风险,常用的防护手段是 “加壳”:通过加壳工具对程序进行保护,增加逆向分析和篡改的难度。例如 Virbox Protector 等工具支持多种文件类型,能有效提升程序的安全性。

滚动至顶部
售前客服
周末值班
电话

电话

13910187371