ELF文件分析(七) 动态链接之PLT/GOT

0x01 PLT/GOT

在上节ELF文件分析(六) 动态链接之基础设计中,我们知道为了解决位置无关代码(PIC)访问外部符号的问题,需要使用PLT(过程链接表)和GOT(全局编译表)来动态解析共享的变量和函数。那么这两个表到底在哪里呢?

我们可以通过readelf输出段节信息然后grep来找找

1
readelf -WSl ELF-file | grep -E ".got|.plt"

下面是实例的情况

elf-7-1

显然其中03是代码段,有.plt和.plt.sec个关于PLT表的节;05是数据段,有.got和.got.plt两个关于GOT表的节。对于这几个节,其作用不尽相同。下面罗列了我们可能见到的PLT/GOT的节及其属性。

节名 所属段属性 作用
.plt 可读可执行 过程链接表。这是动态链接函数的入口点。当程序第一次调用一个外部函数时,实际上是跳转到该函数在 .plt 中对应的条目。这个条目中的代码负责解析函数的真实地址(通过 .got.plt 和动态链接器),然后跳转过去。
.plt.sec 可读可执行 PLT 的安全增强部分。这是 .plt 的一个子集,用于实现一些安全特性,比如 Intel 的间接分支跟踪(Indirect Branch Tracking, IBT),作为控制流完整性(Control-Flow Integrity, CFI)的一部分,以防止某些类型的代码重用攻击。
.plt.got 可读可执行 (待补充)
.got 可读可写->只读 全局偏移表。主要用于存放在程序中引用的全局变量的地址。这些变量的地址在链接时是未知的,需要在程序启动时由动态链接器填充。
.got.plt 可读可写->只读 用于 PLT 的全局偏移表。这个表专门存放外部函数的地址。它的条目初始时指向 .plt 中的下一条指令(即地址解析代码),在函数第一次被调用并解析成功后,动态链接器会将该条目更新为函数的真实内存地址。

note

.got一般是用来保存全局变量的,但是在严格绑定的情况下可能没有.got.plt节,而是由.got来保存全局变量和外部函数。

0x02 IDA

接下来的内容需要调试,通过设置断点来搞清楚整个延迟绑定的过程。

我们需要一款叫做IDA的工具,网上有非常多关于它的泄漏版本。IDA PRO相较于IDA FREE而言会多一些架构的支持以及Hexrays的反编译功能。不过这里我们只使用其来看x64的反汇编代码和进行调试,因此IDA的版本不是问题。Free版本可以在这里找到https://hex-rays.com/ida-free。

IDA在提供静态分析外还提供了本地和远程的调试器组件,相较于gdb而言上手更快,学习成本更低。

0x03 延迟绑定

1. 源码简要浏览

源码依然可以在我的仓库找到,下面是延迟绑定构建的源码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Makefile
CC = gcc
CFLAGS = -Wall -g -no-pie

all: libtest.so main_lazy main_now

# 编译动态库
libtest.so: libtest.c libtest.h
$(CC) -shared -fPIC -o libtest.so libtest.c


# 延迟绑定版本
main_lazy: main.c libtest.so
$(CC) $(CFLAGS) -o main_lazy main.c -L. -ltest -Wl,-rpath=. -Wl,-z,norelro

# 立即绑定版本
main_now: main.c libtest.so
$(CC) $(CFLAGS) -o main_now main.c -L
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main.c
#include <stdio.h>
#include "libtest.h"

int main() {
printf("程序开始执行\n\n");

printf("调用 func_a:\n");
func_a();

printf("\n调用 func_b:\n");
func_b();

printf("\n调用 calculate:\n");
int result = calculate(10, 5);
printf(" 结果: %d\n", result);

printf("\n再次调用 func_a:\n");
func_a();

printf("\n程序结束\n");
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// libtest.c
#include <stdio.h>
#include "libtest.h"

void func_a(void) {
printf(" [动态库] func_a 被调用\n");
}

void func_b(void) {
printf(" [动态库] func_b 被调用\n");
}

void func_c(void) {
printf(" [动态库] func_c 被调用\n");
}

int calculate(int x, int y) {
printf(" [动态库] calculate(%d, %d) 被调用\n", x, y);
return x * y + x - y;
}
1
2
3
4
5
6
7
8
9
10
11
// libtest.h
#ifndef LIBTEST_H
#define LIBTEST_H

// 测试函数
void func_a(void);
void func_b(void);
void func_c(void);
int calculate(int x, int y);

#endif

2. 延迟绑定下第一次触发

整个程序的延迟绑定发生在外部函数调用的过程中。外部函数第一次的调用时,GOT表中还没有对应的地址,因此会先到PLT表中进行解析,然后填入字段。在上面的主函数代码中func_a被先后调用了两次,func_b被调用一次,而func_c没有被调用。我们主要分析func_a的两次调用过程。

首先将程序用ida打开,然后在主函数中在两个call func_a处设置断点,断点保证了代码在执行这条指令前会进行中断处理。

设置好断点后,选择上方绿色三角箭头右侧复选框的调试器为local Linux debugger,点击绿三角运行。

程序会自动在断点处停止,这时候我们可以通过F7单步运行,看看到底发生了什么。

elf-7-2

程序call指令执行后的第一跳到了.plt.sec节,如下图所示:

elf-7-3

info

endbr64属于英特尔控制流强制技术(CET)的一部分,CET 技术主要由影子堆栈 (Shadow Stack, SS)间接分支跟踪 (Indirect Branch Tracking, IBT) 组成。后者保证了跳转的地址必须要有endbr指令否则就会产生异常。这样能有效避免攻击者复用函数内部代码的部分进行攻击。

然后接着是一次jmp,这时候终于到了plt表中的一个神秘函数sub_401070

elf-7-4

注意,这个函数中的跳转到的位置我进行了重命名,正常情况下你那边应该是JUMPOUT_w,实际上它也是plt表的第一个字段,所以叫做PLT[0]是没有任何问题的。对于push 4中的4是一个非常关键的数字,它指示了func_a在重定位表中的具体情况。

接着跟进,我们跳到了PLT[0]中,然后其中的代码是一次push和一次jmp,然后这两个地址相差正好一个字(64位下的8字节)。实际上这两个地址如果你点击进入可以看到GOT表中的第二和第三项。继续F7单步执行,这次我们到了非常重要的一个函数_dl_runtime_resolve (_dl_runtime_resolve_xsavec),这个函数会保存当前的所有寄存器到栈保存上,然后调用_dl_fixup这个关键函数完成重定位的操作

elf-7-5

接着我们按F8离开然后一步步离开ld_linux_x86_64.so.2这个动态链接库,最后一个jmp r11指令完美的把我们送到了func_a的位置

elf-7-6

我们继续F8步过回到主程序,至此,第一次触发正式结束。

3. 延迟绑定下第二次触发

我们直接按F9到第二处的断点,然后按照上面的流程继续进行,然后你会在.plt.sec节跳转前发现不一样的东西。将鼠标指针轻轻放在off_4033A0的地方,看对应的位置对应的值是多少。

elf-7-7

芜湖!同样是jmp cs:off_4033A0,这一次跳转到的位置不再是当时那个带有push 4的神秘函数sub_401070了,而是变成了func_a的值。由于是动态加载的,这个值运行时不能完全确定,但是无论怎样,都是我们刚才执行过的那个函数。

4. 延迟绑定总结

第一次触发的路径

1
2
3
4
5
call func_a@plt              # func_a@plt位于.plt.sec
-> jmp func_a@got # func_a@got位于.got.plt
-> push 4 && jmp PLT[0] # PLT[0]位于.plt
-> push GOT[1] && jmp GOT[2] # GOT[2]的值是_dl_runtime_resolve,位于ld_linux_x86_64.so.2
-> jmp r11 # r11的值是func_a真实地址,位于libtest.so

第二次触发的路径

1
2
call func_a@plt              # 位于.plt.sec
-> jmp func_a@got # 此时该func_a@got已经是func_a真实地址

这说明一开始的时候,got表(.got.plt节)还没有func_a的地址,所以需要调用_dl_runtime_resolve进行解析,然后进行修改。

这样做的好处是不需要获取所有函数的地址进行绑定,一定程度上减少了启动的时间开销,而是在函数实际被调用的第一次进行绑定,后续无需绑定。这在动态库函数和变量非常多,但是实际上使用的数量少的时候是非常有效的一个优化。

note

这里没有详细解析为什么是push 4,有兴趣的读者可以自行研究_dl_runtime_resolve的代码,搞清楚链接器是如何通过这个4来找到对应的函数的。同时有个比较有意思的问题,如果这个数字不是4而是被换成了如func_b所对应的2,那么下一次func_a的过程中会使用的函数是哪一个。

0x04 立即绑定

不同与延迟绑定,立即绑定在动态链接器进行处理的过程中会一次性处理所有got表的地址

有人可能会问,现在既然有效率更高的延迟绑定,为什么还要再讲立即绑定呢?事实上这与安全机制有关系,如果got表的权限为只读,那么在动态链接器绑定的阶段就会出现问题。那好,我们只能先设置为可读可写。但是,当用户的程序被恶意代码利用的时候,很可能就会修改got表来实现函数的劫持,因为我们当时是直接jmp到got表中的一个地址了。

现代的解决方式是先作为可读可写装载,然后动态链接器解析完成后重新设置为只读,这样就保护了got表。具体而言,这个机制叫做RELRO。我们后续会有一个专题来谈谈ELF上的安全机制。

0x05 小问题

关于PLT/GOT机制

  • 尝试在延迟绑定的程序中对func_b进行研究,并回答对func_a的绑定会造成影响吗?
  • 尝试通过修改延迟绑定中got表中的字段实现控制流劫持。
  • 分析push 4中4所代表的具体含义。
  • 思考一下,有没有比RELRO更好的、能解决got表劫持问题的安全机制?