发布于

PLT 和 GOT

作者

PLT 和 GOT 是 ELF 文件中的两个段,处理了大部分 动态链接 的工作。在 CTF 中,动态链接的二进制文件比静态链接的二进制文件更常见。动态链接的目的是:二进制文件不必携带运行所需的完整代码,这大大减小了它们的大小。它们依赖系统库(尤其是 libc ,C 标准库)提供主要功能。

例如,大多数 ELF 文件都不会自带 puts 函数的实现,而是动态链接到系统提供的 puts 上。这样就减小了 ELF 文件的体积,同时用户可以升级系统库,ELF 文件会自动使用新版本系统库而不必在每次新版本出现时重新下载所有的二进制文件。

Q:那么动态链接机制是否会用硬编码地址来替换函数调用?
A:不会。

静态链接要求 libc 有一个固定的基地址,即每次运行时都加载到同一内存区域。但 ASLR 机制破坏了这一假设,因此需要动态链接来应对地址随机化。由于 ASLR 的工作方式,动态链接需要在运行时解析实际调用地址。PLT 和 GOT 段用于实现动态链接中的地址解析。

0x01 PLT 和 GOT

PLT (Procedure Linkage Table) 过程链接表配合 GOT (Global Offset Table) 全局偏移表实现动态链接查找和跳转。

当你在 C 语言中调用 puts() 并将其编译为 ELF 可执行文件时,它实际上不是 puts()。而是被编译为 puts@plt。我们可以在 pwndbg 中看到这一点:

origin

为什么要这样做?

因为 ELF 文件不知道 puts 实际在哪里。所以首先它会跳转到 puts 的 PLT 条目 puts@plt。从这里开始,puts@plt 会首先查找 GOT 表有无 puts 的条目:

  • 如果有,直接跳转到 GOT 表记录的 puts 的实际地址
  • 如果没有,会触发解析 puts 的实际地址,并跳转至该地址

GOT 是一个十分庞大的地址表,它存储了动态链接库函数的实际内存地址。例如,puts@got 保存 puts 在内存中的实际地址。当 PLT 被调用时,它读取 GOT 表获取调用地址。如果 GOT 为空,则会触发 ld.so动态链接器/加载器)解析实际地址并将解析后的结果写入 GOT 中。

PLT 和 GOT 配合 ld.so 完成动态链接地址解析和调用,GOT 表缓存结果避免重复解析。

0x02 这对于二进制利用有何用处?

上面的说明中有两个关键点:

  1. 调用函数的 PLT 地址相当于调用函数本身
  2. GOT 表存储 libc 中函数的地址,并且 GOT 表位于二进制文件中

第一点的用途很清楚——如果 PLT 表里有我们想利用的函数,例如 system,我们只需要重定向执行流,直接跳转到该 PLT 条目 (system@plt)。system@plt 会解析 system 函数的真实地址并跳转,这相当于直接调用 system 而无需跳入 libc

这样也可以更直观的控制程序的执行流程。

第二点不太明显,但可以说更重要。由于 GOT 表是二进制文件的一部分,因此它始终与基地址有固定的偏移量。如果程序未开启 PIE,你就可以直接得到确切的 GOT 表地址。否则,你可以通过某些手段泄漏基地址,这样也可以算出 GOT 表的地址。GOT 表中保存了 libc 函数的实际地址。通过任意读取 GOT 表可以绕过 ASLR,泄漏 libc 函数的真实地址。

0x03 利用任意读取

我个人主要通过两种方式利用任意读取。请注意,这些方法不仅会导致 GOT 条目返回,还会导致其它所有内容返回,直到到达空字节为止。因为 C 中的字符串是以空字节终止的,所以确保只获取所需的字节数。

1x01 ret2plt

ret2plt 是一种常见技术。它涉及调用 puts@plt 并将 puts 的 GOT 条目 puts@got 作为参数。这会导致 puts 输出它在 libc 中的地址。然后,你可以将返回地址设置为你想要利用的函数并调用它。

这样可以重复利用漏洞,获取多个地址。从而获取足够的信息来确定 libc 的版本和偏移。

# 32-bit
payload = flat(
    b'A' * padding,
    elf.plt['puts'],
    elf.symbols['main'],
    elf.got['puts']
)

# 64-bit
payload = flat(
    b'A' * padding,
    POP_RDI,
    elf.got['puts']
    elf.plt['puts'],
    elf.symbols['main']
)

Tip

flat() 可以根据上下文设置自动将参数打包为 32-bit 或 64-bit,并将打包后的参数连接 起来。这样就不必每次都显式调用 p32() 或者 p64() 进行打包了。

1x02 %s 格式化字符串

这个方法仍基于相同的基本原理。但在某些情况下更为有用,例如:

  1. 当栈空间有限,难以构造足够的 ROP 链时
  2. 执行 ROP 链会修改栈,影响后续的 payload 时

比如在进行 Stack Pivoting 时,该技术就可以简化 Exploit。

payload = p32(elf.got['puts'])
payload += b'|'
payload += b'%3$s'                   # 第三个参数指向缓冲区的开头


# 下面这部分仅在你需要再次调用该函数时才使用

payload = payload.ljust(40, b'A')    # 40 是覆盖指令指针之前的偏移量
payload += p32(elf.symbols['main'])

# 发送 payload

p.recvuntil(b'|')                    # 这不是必需的
puts_leak = u32(p.recv(4))           # 4 字节,因为它是 32-bit

0x04 总结

  • PLT 和 GOT 处理了大量动态链接的工作
  • PLT 会解析需要链接的 libc 函数的实际地址,并将它们缓存到 GOT 表
    • 下次调用直接通过 GOT 跳转,无需重复解析
  • 调用 function@plt 相当于调用函数本身
  • 任意读取可以读取 GOT 表并泄漏 libc 函数地址,由此计算 libc 基地址,绕过 ASLR