1. checksec输出的那一行:RELRO三档到底改了什么
打pwn题的人对checksec一定不陌生。我几乎每道题都会先跑一遍,看Arch、RELRO、Stack、NX、PIE这几项。但说句实话,圈子里对RELRO这项的态度一直很微妙——很多人直接跳过不看,还有一部分人看了也只当成“哦,开了/没开”就完了。但你如果多问一句“Partial RELRO和Full RELRO具体差在哪个段上”,不少人会愣一下。
这个现象本身就是很好的注脚:RELRO在所有保护机制里,属于存在感最低、效果最容易被绕过的那一个。网上有人管它叫“最小丑的机制”,我认为这个说法虽然带点调侃,但足够精准——它经常被写在防护列表里,却几乎拦不住真正会打的人。
先说最基础的部分。RELRO全称是RELocation Read-Only,重定位只读,它管的是程序加载后那些与动态链接相关的数据段能不能被改写。在checksec里你会看到三档:
| 级别 | GOT本身 | .got.plt | .init_array / .fini_array | 性能影响 |
|---|---|---|---|---|
| No RELRO | 可写 | 可写 | 可写 | 无额外开销 |
| Partial RELRO | 只读 | 可写 | 只读 | 几乎无感 |
| Full RELRO | 只读 | 只读 | 只读 | 启动时有额外解析开销 |
我解释一下这个表格里几个词。.got和.got.plt都在GOT区域内,前者保存全局变量偏移和部分固定解析结果,后者专门给动态库函数做跳板。Partial RELRO做的事情是把.got和.init_array、.fini_array变成只读,但关键的可写跳板.got.plt保留原样。换句话说,Partial这个名字取得非常诚实——它确实保护了一部分,但恰恰是攻击者最感兴趣的那部分没保护。
Full RELRO则会把.got.plt也彻底变成只读页,同时要求动态链接器在程序启动阶段一次性把所有符号解析完,之后GOT里都是最终地址,不再需要运行期回填。
看到这里你应该已经闻到了一丝“小丑”的味道:Partial RELRO作为最常见的默认配置,留出的.got.plt几乎就是给GOT覆写量身定做的入口。所以真正入门pwn的人会慢慢形成一个默契——看到Partial RELRO,心里默认它约等于没有开这个防护。
2. GOT和PLT的连体婴关系:为什么Partial RELRO等于留了一扇侧门
要理解RELRO为什么拦不住人,必须先搞清楚GOT和PLT是怎么配合工作的。这对结构在很多教程里被反复讲,但大多数人只是背了结论,没细想它俩为什么长成这样。
2.1 一次动态链接调用的完整走位
拿最简单的puts("hello")举例。你的程序编译时不知道puts在libc里的绝对地址,因为libc加载到哪个内存区域是运行期才决定的。那调用puts时CPU怎么知道跳去哪?答案是通过PLT桩和GOT槽位配合:
- main函数里编译出的
call puts@plt,跳到.plt段中puts对应的那一小段桩代码。 - PLT桩的第一条指令是
jmp [GOT+puts的槽位],也就是间接跳转,从GOT里读出puts的真实地址再跳过去。 - 如果是第一次调用,GOT槽位里存的还不是真实地址,而是PLT桩里下一条指令的地址。于是跳转落回PLT桩内部,继续向下执行。
- 接下来代码压入一个
reloc_arg参数,再跳到PLT[0]公共桩,进入动态链接器的_dl_runtime_resolve函数。 - 动态链接器根据
reloc_arg找到对应的重定位项,解析符号名,算出真实函数地址,回填到GOT槽位。 - 跳转到真实函数。第二次再调用
puts时,GOT槽已经是最终地址,直接一步到位。
这个过程叫做lazy binding,懒绑定。它最大的好处是程序启动时不需要把成百上千个符号全部解析一遍,用到哪个函数才解析哪个。
2.2 lazy binding:为什么第一次调用后函数地址才被回填
理解了上面的流程,你就明白了一个关键事实:只要lazy binding存在,.got.plt就必须是可写的,否则_dl_runtime_resolve没有资格把解析出来的地址回填进去。
Partial RELRO的取舍恰恰在这里——它保留了lazy binding,换取了运行速度,代价是.got.plt可写。而攻击者眼里,.got.plt里放的都是puts、printf、system这类动态库函数的函数指针,一旦有任意地址写能力,把某个函数的GOT槽改成system的地址,程序下一次调用这个函数时就直接栽进攻击者的代码里。
2.3 从攻击者的角度看,.got.plt为什么是宝藏
为什么GOT覆写这么流行?因为它不需要泄露栈地址,而且在无PIE的题目里,GOT地址是固定的。栈上的返回地址固然也能劫持,但你要先知道栈在哪;而GOT就在低地址固定位置,IDA里打开ELF看一眼就写在脸上。
我常用一个比喻:.got.plt就像一栋办公楼里每个部门门口的信箱,信箱上写着“puts的信箱”“printf的信箱”。lazy binding的意思就是,邮递员第一次送信时才用笔把真正的门牌号写在信箱上。Partial RELRO这栋楼的管理处,给信箱上了锁,却没锁放笔的地方——你说这锁还有什么意义。
顺带说一句,PIE(位置无关可执行)开启的情况下GOT地址是随机的,但RELRO和PIE是两个独立维度。就算PIE开着,只要Partial RELRO,攻击者只要能先泄露一次基址,GOT覆写照样成立。所以RELRO这个机制本身,在高强度对抗中真的很尴尬。
3. 性能账与历史包袱:Partial RELRO怎么就成默认了
聊到这里,肯定有人会问:既然Full RELRO更安全,为什么不干脆全部默认Full RELRO?答案很现实——性能。
3.1 默认的Partial是真省时间
一个稍微复杂点的程序往往依赖几十个甚至上百个动态库,每个库又有成百上千个导出符号。如果启动时一次性全部解析,动态链接器要做的工作量非常可观,直接表现为程序启动变慢。尤其是一些命令行工具、脚本解释器,启动速度直接影响用户体验,所以大部分发行版和编译默认选项都选择了Partial这种折中方案。
我印象里,RELRO这套设计最早是Red Hat的开发者Ulrich Drepper在2004年前后提出的。它最初的目的也比较朴素——把重定位相关的内存改成只读,防止意外写坏、减少一些低级的段错误。换句话说,它一开始就不是冲着对抗恶意攻击者去的,更像是一个提高健壮性的清理措施。后来安全界逐渐把它当成“防护机制”来宣传,但它骨子里并没有为了抗攻击而改变设计。
3.2 Full RELRO的兼容性代价
Full RELRO不只是启动变慢这么简单。关闭lazy binding意味着所有符号必须在程序启动时全部解析完成,如果某个符号在当前运行环境里找不到,程序直接启动失败,而不是等到真正调用那个函数时才报错。
这在动态库版本复杂的环境里是很头疼的。比如你有个插件系统,插件依赖某个可选符号,主程序启动时如果强制解析,缺了符号的插件会把整个应用拖垮。所以不少软件为了兼容动态加载场景,只能继续使用Partial RELRO。
还有一层细节:即使开了Full RELRO,GOT是不可写了,但.plt段本身、以及动态链接器维护的很多内部结构(比如link_map链表、_rtld_global全局对象)依然可写。所以Full RELRO更像是在“GOT覆写”这条最普通的路上设了卡,而不是把路封死。这也是为什么后文要讲的ret2dlresolve、FSOP之类的高级技巧照样能绕过去。
4. Partial RELRO的三种死法:从常规GOT覆写到Canary失灵
前面铺垫了那么多,现在进入实战。Partial RELRO下,攻击者至少有三种成熟的玩法。我只挑有代表性的讲,更多是让读者建立起“看到Partial RELRO脑子里就该立刻弹出一串选项”的条件反射。
4.1 最经典的GOT覆写:puts变system
这是最基础、最常用的一条路径,适合那种有任意地址写、或者有格式化字符串漏洞的题目。以无PIE程序为例:
- 找漏洞点。常见的是格式化字符串
printf(buf)、read配合栈溢出实现任意写,或者堆漏洞里造出任意地址写。 - 泄露libc基址。通常打印
puts@got里的地址,再用libc-database或者libc.rip之类的工具反查libc版本。 - 算偏移。
libc.symbols['system'] - libc.symbols['puts']得到两者差值,加上泄露到的puts实际地址,就是system的实际地址。 - 把
puts@got写成system地址。 - 触发一次
puts("/bin/sh"),让程序拿着我们已经改过的GOT去调用,实际上就是system("/bin/sh")。
给你一段最简脚本骨架:
from pwn import * elf = ELF('./pwn') libc = ELF('./libc.so.6') puts_got = elf.got['puts'] # 先泄露 puts 实际地址 # ... libc.address = leaked_puts - libc.symbols['puts'] payload = fmtstr_payload(offset, {puts_got: libc.symbols['system']}) # 或者用栈溢出的姿势: payload = b'A' * offset + p64(puts_got) + ...核心要点是:你改写的GOT槽对应的函数,必须是你之后还能触发、并且参数可控的函数。比如puts的参数就是你输入的字符串,改成system之后,输入/bin/sh就变成执行命令。
4.2 借刀杀人:把__stack_chk_fail改掉,Canary直接废掉
第二种玩法有意思得多。有些题目开了栈Canary,理论上栈溢出会被Canary检测拦住。但你别忘了,Canary检查失败的退出逻辑是通过调用__stack_chk_fail实现的,而__stack_chk_fail本身是个动态符号,它的GOT槽在Partial RELRO下同样是可写的。
思路如下:
- 程序有栈溢出,但你不知道Canary值,也没办法泄露。
- 把
__stack_chk_fail@got覆写成main的地址。 - 触发溢出,Canary检查失败,但它不会退出,而是跳回main重新执行。
- 程序相当于变成了一个可以循环利用的溢出入口。在多轮操作里你可以逐字节爆破Canary(从低位开始猜,每猜对一轮程序不会崩溃),也可以把这次“失败”当成一次暂时的控制权转移。
这个技巧在早期CTF里非常常见,很多入门题就是靠它把开了Canary的题硬生生变成无Canary。它之所以成立,还是因为lazy binding留下的.got.plt可写空间——__stack_chk_fail的槽位就在那里,改掉它,栈保护就成了摆设。
4.3 万金油思路:不拿shell,只改控制流
第三种更抽象一点。有时候你没法直接改成system,比如参数不可控,或者one_gadget的条件不满足。这时候可以考虑改一些不是“目标函数”的GOT,比如:
- 把
printf@got改成system@got的地址,然后让程序执行printf("/bin/sh")。 - 把
strlen@got改成puts@plt,反正你想让某个函数跳转到另一处。 - 把
exit@got改成main,让程序在正常退出时回到主逻辑,形成循环利用。 - 把
free@got改成system,配合堆上布置/bin/sh字符串。
这一类玩法的精髓不在于“改哪个函数”,而在于先想清楚程序在下一次调用这个函数时,参数会是什么。GOT覆写本质上是一次“函数指针偷梁换柱”,你替换的目标决定了你下一步能做什么。
这也解释了为什么不少比赛中像ctfshow这类平台上出的pwn入门题,很多都在Partial RELRO + 无PIE的环境下让你练手——因为这条路足够经典,能帮你把所有基础技术串起来。很多新人在里面练的第一套组合拳就是“格式化字符串泄露libc + GOT覆写getshell”。
5. Full RELRO也没救:ret2dlresolve的构造全过程
GOT覆写这么好用,但碰到Full RELRO是不是就没辙了?不是,还有一类更巧妙的攻击叫ret2dlresolve,直接把动态链接器本身当成了傀儡。
5.1 动态链接器认死理:解析流程全链路
要理解ret2dlresolve,你得回顾一下2.1节里_dl_runtime_resolve的完整处理流程。动态链接器拿到一个reloc_arg后,是这样工作的:
- 计算
reloc = .rel.plt + reloc_arg,取出一个Elf32_Rel结构。 - 从
reloc.r_info中拆出符号索引sym_index。 - 计算
sym = .dynsym + sym_index * sizeof(Elf32_Sym),得到一个Elf32_Sym结构。 - 从
sym.st_name拿到字符串偏移量,在.dynstr段里定位函数名字符串。 - 用这个名字去查找真正的函数地址,写入
reloc.r_offset指定的内存位置。
整条链路里,动态链接器只信任你从栈上传给它的reloc_arg,完全不做边界检查,不会去确认这个偏移是否真的指向.rel.plt段内,也不会验证符号索引是否真的落在.dynsym里。这就是漏洞的根源。
所以攻击思路是:既然它能用任意reloc_arg,那我们就在一个攻击者可控的写区域内(通常是bss段)伪造一个Elf32_Rel、伪造一个Elf32_Sym,再伪造一个字符串system,然后把控制流劫持到.plt[0],让它按我们伪造的参数去解析。
5.2 手工构造fake Rel和fake Sym
我按32位来演示,因为32位下结构体小、计算直观。假设:
bss = 0x804a800,攻击者可以用read往bss里写数据。.rel.plt基址、.dynsym基址、.dynstr基址都已知,无PIE时这些地址固定。- 目标是把
read的返回值改成解析system,让动态链接器把system的地址写到一个可写地址上,之后栈迁移过去调用。
构造过程:
- 在
bss+0x200处伪造Elf32_Rel:r_offset = bss+0x300(之后要把栈迁移到这里执行),r_info = (sym_index << 8) | 7,其中type=7对应R_386_JMP_SLOT。 - 在
bss+0x100处伪造Elf32_Sym:关键字段是st_name,它必须是指向字符串system\0的偏移量,且这个字符串要放在伪造Sym的附近。 - 计算
sym_index:找到动态符号表里真实某个符号,让它作为“索引基准”。极简做法是计算fake_sym_addr相对.dynsym的偏移,再除以16(32位Elf32_Sym大小):
sym_index = (fake_sym_addr - elf.dynamic_value_by_tag('DT_SYMTAB')) // 16 fake_sym_addr = symtab_addr + sym_index * 16- 计算
reloc_arg:fake_reloc_addr - rel_plt_addr。动态链接器会拿这个值加上.rel.plt基址,恰好定位到你的fake Rel。 st_name要满足:fake_sym_addr + st_name == 指向"system"的地址,也就是字符串相对.dynstr的偏移。因为动态链接器是拿fake_sym.st_name去.dynstr里取名字的,而.dynstr基址是真实的,所以你要算的是从真实.dynstr开始偏移多少正好到你的system\0。
到这里你看出门道了:我们伪造的Elf32_Sym并不需要真的出现在.dynsym段里,只要让sym_index这个数字经过计算后,把动态链接器带到我们伪造的位置即可。帮助读者理解的一点是:动态链接器只做“基址 + 索引 * 大小”的算术,它不在乎结果落在哪个段。
5.3 pwntools替你打工:Ret2dlresolvePayload的正确用法
手工构造容易出错,尤其是指针偏移算错一位就前功尽弃。pwntools里提供了现成的封装,我平时一般用它来做快速验证:
from pwn import * elf = ELF('./pwn32') rop = ROP(elf) dlresolve = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh']) # 把 fake 结构写到 bss payload = b'A' * offset payload += p32(elf.plt['read']) payload += p32(pop3_ret) # read 的参数寄存器的 pop 地址 payload += p32(0) # fd = stdin payload += p32(dlresolve.data_addr) payload += p32(len(dlresolve.payload)) payload += p32(elf.plt['read']) # 再次 read,这次读入 fake 结构 payload += p32(0) payload += p32(dlresolve.data_addr)这个封装背后做的事情就是5.2节那套手工计算。工具能用,但原理必须懂,否则出问题的时候根本不知道去哪排查。
5.4 64位的麻烦:空间与寄存器的双重考验
64位环境下的ret2dlresolve要比32位曲折一些。原因有几个:
- 64位使用的是
Elf64_Rela结构,比32位大不少,一个结构就24字节,加上Elf64_Sym的24字节,伪造数据更占空间。 - 64位的参数传递走寄存器,系统调用也好、函数调用也好,都要用ROP链来设置
rdi、rsi、rdx,这意味着你需要更多gadget。 - 很多题的栈溢出空间根本塞不下完整ROP链 + fake结构,于是还要先做栈迁移到bss,再在bss上展开整套布局。
所以实际做题时,64位程序我更倾向于先试libc泄露后打hook,或者用FSOP一类的技巧,而不是一上来就ret2dlresolve。除非题目明确是那种“极简环境 + 静态分析 + 无libc泄露”的设定,ret2dlresolve才有用武之地。
5.5 Full RELRO的其他苦恼:hook与FSOP
glibc 2.34之前,__malloc_hook、__free_hook这些hook函数指针一直留在libc的数据段里,可写。所以就算GOT全只读,攻击者只要泄露libc基址,把__free_hook改成一个one_gadget或者system地址,照样能拿shell。这几乎成了Full RELRO的标准答案。
glibc 2.34之后hook被移除,人们开始大规模用FSOP。FSOP的思路是篡改_IO_2_1_stdout_这类FILE结构,利用_IO_overflow虚表调用链把控制流拐到任意地址。换个说法:GOT只读没关系,我打的是libc里的可写全局结构体。再往上走,还有针对ld.so的利用,比如改写_rtld_global里的锁函数指针,这类技巧在house of banana里被玩得很多。
所以我反复强调一个观点:RELRO只是在GOT覆写这条路上立了个路障,路从来就没被堵死。
6. 到了生产环境,RELRO还算一道门锁:它的真实价值区间
写了半天的“小丑”,如果只让人记住RELRO没用,那也不客观。它确实有作用,只是作用范围和CTF里的解题预期不太一样。
6.1 RELRO挡的是哪一路攻击
RELRO真正擅长应对的是非定向的、粗放的写入类漏洞。举个例子,一个程序里有一个“碰巧”能写坏内存的逻辑bug,攻击者并不知道具体能写哪个偏移、能写什么值,只能盲目触发写入。这种情况下,如果GOT是只读的,那他在瞎打的过程中就很难篡改关键函数指针,攻击成功率会大幅下降。
另一个实际意义是缩小攻击面。.init_array和.fini_array被保护后,程序启动和退出时执行的回调函数列表不能再被随意替换,这直接防住了部分把回调函数指针改成恶意代码的骚操作。
换句话说,RELRO是一个“防莽夫”的机制,不是“防高手”的机制。它让攻击者从“恰好有一定写能力”变成“必须完全理解libc内部结构、精确布置攻击链”,这中间的技术门槛差距非常大。
6.2 攻击成本对比:一页表看清局势
| 防护级别 | 攻击者最可能的路线 | 需要的前提条件 | 难度 |
|---|---|---|---|
| No RELRO | 直接改GOT,可能不需要libc地址 | 任意写即可 | 低 |
| Partial RELRO | GOT覆写,配合libc泄露 | 任意写 + 一次泄露 | 中低 |
| Full RELRO | ret2dlresolve / hook / FSOP | 更大溢出空间 + 精确布局 | 高 |
所以你看,Full RELRO真正的意义是把攻击成本抬高了。它在安全业界是一种纵深防御措施,单个机制单独拿出来都可以被绕过,但叠加上ASLR、PIE、Canary一起用,要让所有机制同时失效,攻击者的工作量就会指数级上升。
6.3 给CTF选手和开发者的不同建议
站在CTF选手的角度,我的建议是:
- 看到Partial RELRO,优先把GOT覆写加入备选方案。
- 看到Full RELRO,第一时间考虑泄露libc之后打hook或FSOP,而不是死磕ret2dlresolve。
- 看到No RELRO,基本上等于送分,先试最无脑的GOT改写。
站在开发者的角度,如果你是做生产环境的安全加固:
- 编译时加上
-Wl,-z,relro,-z,now,打开Full RELRO。 - 如果程序启动性能敏感,至少保证Partial RELRO,这不需要额外成本。
- 配合
-Wl,-z,noexecstack关闭栈执行,再配合-fstack-protector-strong开启Canary,这些成本都很低,但能把常规漏洞利用的难度明显拉高。
7. 我自己的翻车记录:一次不该选的ret2dlresolve
最后分享一个真实教训。有一次我拿到一道题,checksec一看Full RELRO + 无PIE + 有Canary,溢出点是个gets,但溢出的字节数很有限。我当时第一反应是“Full RELRO,那就上ret2dlresolve”,花了两个小时构造64位的fake Rel和ROP链,结果反复卡在栈空间不够、gadget凑不齐这几个问题上。
后来我重新看了一遍漏洞点,发现程序里有个free的调用,而glibc版本是2.31,__free_hook依然存在。我只要泄露libc,把__free_hook改成system,堆上放一个/bin/sh字符串,三步下班。当时的感觉就是非常尴尬——我一开始被“Full RELRO”这个词吓住了,把思路锁死在了最复杂的方案上,反而忽略了旁边那条更短的路径。
从那以后我给自己定了条规矩:拿到题目先看RELRO,但看完先别急着选路线,先把漏洞点能提供的空间、控制能力、可调用函数都摸清楚,再对照RELRO状态选最短路径。
还有个小技巧也顺带分享:checksec看完不代表万事大吉,最好再用readelf -d看一眼动态段里的实际地址,比如JMPREL、SYMTAB、STRTAB的地址,确认一下.rel.plt、.dynsym、.dynstr到底在哪。这些地址是ret2dlresolve手工构造时的硬依赖,晚查不如早查。
RELRO这东西,说它最小丑,因为它经常被列在防护清单里,但在有经验的攻击者面前几乎没什么威慑力。可换个角度想,小丑在舞台上的作用本来就是制造滑稽效果——RELRO的滑稽之处,就在于它以为自己挡得住,其实挡不住。而对于我们这些做研究、打比赛的人来说,真正重要的不是嘲讽某个机制弱,而是清楚每条路要什么条件、每一步为什么这么做。把原理吃透了,RELRO对你就永远只是一个看一眼就翻页的字段,不会再是卡住思路的坎。