ret2dlresolve

关于动态链接与延迟绑定

想起来再写…

时隔多年终于打算写了,之前认为这个方法比较抽象就没有深入研究,最近打比赛遇到没有gadget的题目时我的傲慢终于付出了代价

不过一切都不算太迟,现在来看看吧…

1
printf("hello ret2dlresolve!");

在c语言中写下这个句子,观察其汇编代码会看到有这么一段

1
call printf@plt <printf@plt>

这个过程到底发生了什么?

先来了解一下历史吧…

你应该知道printf是一个库函数,早期,所有库函数在编译期直接拷贝进可执行文件,导致了大量的资源浪费与高额的维护成本

于是,人们引入了共享库的概念,就是我们常看到的libc.so.6,核心思想是多个程序共享同一份代码

但问题随之而来,程序在编译时根本不知道libc会被加载到哪个地址,函数地址只有在程序运行时才能确定

最原始的解决方法是这样的,动态链接器(ld-linux-x86-64.so.2)在程序刚启动时,就把用到的所有库函数地址全算一遍,这就是eager binding(立即绑定),很明显,这样做启动极慢,且大量函数可能一辈子都不会被调用,耗费大量性能

因此,聪明的工程师们提出了一个非常朴素但革命性的想法,既然很多函数根本不会用到,那为什么要一开始就解析?

于是便引入了这样的做法,程序启动时,一个函数都不解析,只有当第一次调用某函数时,才去问动态链接器它在哪,这就是lazy binding(延迟绑定)!

为了延迟绑定,ELF文件引入了GOT,PLT,重定位表,dynsym,dynstr等各种乱七八糟的名词,直接看似乎有点抽象,我们去gdb中看看怎么个事儿???

先从宏观看吧,自顶向下

回到call printf@plt <printf@plt>,我们si步进一下

看到

1
2
0x401040 <printf@plt>      endbr64
0x401044 <printf@plt+4> jmp qword ptr [rip + 0x2fb6] <0x401030>

继续ni步过

看到

1
2
3
0x401030   endbr64
0x401034 push 0
0x401039 jmp 0x401020

你应该知道,PLT相当于一个跳板,jmp跳转到printf在GOT里存的地址,然而目前是第一次调用printf,GOT中还没有加载其在libc中的真实地址,因此此时该GOT表项被初始化为指向PLT的公共解析入口resolver stub(PLT[0]),也就是你看到的0x401020,它是通向动态链接器的唯一入口,比较重要,而后面从PLT[1]开始,每个PLT表项对应一个外部函数,如PLT[1]对应printf,PLT[3]对应read,同一个函数的PLT表项中,硬编码了它在.rela.plt中的索引,但在真正延迟绑定的过程中,真正用于符号解析的还是.rela.plt中的重定位索引,而非PLT表项的编号本身

也就是这个push 0,这便是关键的重定位表索引(relocation index),动态链接器正是通过该索引,在重定位表中定位到对应的重定位条目,并由该条目进一步确定其所需要解析的符号描述信息(.dynsym .dynstr),对于其它通过PLT延迟绑定的函数,比如read,假设是push 2好了,其对应的PLT表项中会压入不同的索引值(此时是PLT[3]),该索引在链接阶段已确定,并与.rela.plt中的条目一一对应,而与函数的实际调用顺序无关…

继续ni,看到

1
2
0x401020   push qword ptr [rip + 0x2fca]   # link_map 指针
0x401026 jmp qword ptr [rip + 0x2fcc] <0x7ffff7fda2f0> # ld-linux-x86-64.so.2: _dl_runtime_resolve

额讲不下去了

我自己都觉得有点抽象…

算了来看看实际的例子吧

首先是GOT表,分为两部分,.got.got.plt.got是全局变量重定位,用于全局变量、静态数据的重定位,程序启动时一次性解析完成,不管

.got.plt才是我们关注的GOT

结构大致是这样的

1
2
3
4
5
6
7
8
GOT[0]: .dynamic节的地址(动态链接信息) #不管
GOT[1]: link_map 结构地址(链接器内部数据结构) #重要
GOT[2]: _dl_runtime_resolve 函数地址(动态解析函数) #重要(你猜ret2dlresolve这个名字怎么来的)

GOT[3]: 第一个动态链接函数(如printf)的地址槽
GOT[4]: 第二个动态链接函数(如read)的地址槽
GOT[5]: 第三个动态链接函数...
...

在实际的内存布局中大致是这样的(地址随便编的)

1
2
3
4
5
6
7
.got.plt 节:
0x600e00: 0x0000000000400390 # GOT[0] - .dynamic地址
0x600e08: 0x00007ffff7ffe170 # GOT[1] - link_map
0x600e10: 0x00007ffff7fe9770 # GOT[2] - _dl_runtime_resolve
0x600e18: 0x0000000000400596 # GOT[3] - printf(初始指向PLT+6)
0x600e20: 0x00007ffff7a7c690 # GOT[4] - read(已解析,现存放真实地址)
...

PLT表结构是这样的

1
2
3
4
5
PLT[0]: 动态链接器入口(公共桩代码)resolver stub
PLT[1]: 第一个动态函数(如printf)的桩代码
PLT[2]: 第二个动态函数(如read)的桩代码
PLT[3]: 第三个动态函数...
...

具体的代码即是

1
2
3
4
5
6
7
8
9
# PLT[0] - 动态链接器入口(所有函数共用)
push [GOT + 8] ; 压入 link_map
jmp [GOT + 16] ; 跳转到 _dl_runtime_resolve

# PLT[n] - 第n个函数的桩代码 (n >= 1)
PLT[n]:
jmp [GOT + (n + 2) * 8] ; 跳转到 GOT[n + 2]
push (n-1) ; 压入重定位索引
jmp PLT[0] ; 跳转到动态链接器

注意我仅讨论了linux x86-64架构,其它架构核心思想应该都相同…

这样看是不是把上面的串起来了,第一次调用printf时,call printf@plt <printf@plt>,即调用PLT[1],开始执行PLT[1]的代码,第一条代码为jmp [GOT + (n + 2) * 8],而此时GOT[3]还未放入printf的真实地址,其初始化地址其实就是PLT[1] + 6,也就是下一条指令push 0,然后跳转到PLT[0]执行push [GOT + 8],压入link_map,最后jmp [GOT + 16],跳转执行_dl_runtime_resolve,此时才是真正解析函数真实地址的地方,我们构造利用的关键

而当第二次调用printf时,依旧call printf@plt <printf@plt>调用PLT[1],依旧jmp [GOT + (n + 2) * 8],而此时printf已经被解析,GOT[3]中已经存入了printf的真实地址,便会直接执行printf,后面的代码便不再会执行了,可以看到,只有在第一次解析时有_dl_runtime_resolve的参与,后面便可以直接在GOT中找到真实地址了,这便是今日讨论的延迟绑定的精髓所在

这种设计哲学,既避免了程序启动阶段不必要的解析性能开销,同时又保证了后续调用的执行效率,真是无比智慧啊~

然而…

为了支持这种按需解析机制,ELF在程序中保留了完整的重定位信息与符号描述结构,并允许动态链接器在运行时根据这些信息修改GOT内容,这一设计在提升灵活性的同时,也为我们的攻击面埋下了伏笔…

通过伪造link_map以及重定位索引,我们可以在程序可控的内存区域中构造出符合ELF规范的Elf64_Rela,符号表(.dynsym)以及字符串表(.dynstr)等关键结构体,使程序在执行_dl_runtime_resolve时误以为这些数据来源于合法的ELF映像,从而按照既定的动态链接流程解析并跳转到我们指定的函数地址…

程序以为它在正常地解析一个printf函数,殊不知,整个解析过程都被我们控制,直接解析成了system(/bin/sh),哈哈哈哈哈

哪个才是真正的我,我自己也不知道…

Full Relro : 你在说什么?

全剧终…

to be continued

补充:

突然想到这一篇的tag是OS,那我就只写OS得了,后面在pwn的tag里专门开一个讲攻击,虽然网上有很多模板,但加我一个又何妨?

最近好多比赛,好难!被打到怀疑人生了,路漫漫其修远兮啊…

最后,祝大家新年快乐!

虽然这是一篇静态博客根本没人看,但我还是写得好像有很多人看一样,roxy,你这家伙…

感谢阅读…

生活愉快…


ret2dlresolve
https://roxy5201314.github.io/2026/01/15/ret2dlresolve/
作者
roxy
发布于
2026年1月15日
更新于
2026年2月10日
许可协议