选手拿到题目checksec一跑看到Full RELRO再配上Canary和PIE心里先凉了半截——这题是不是没法打了。我当年也是这样看见RELRO就腿软觉得GOT全锁死了覆写没戏了。直到后来在某道题里折腾半天发现利用链压根走的是堆和__free_hook跟GOT一点关系没有才反应过来这个被当成KPI一样写进checksec里的机制其实是个“小丑”。这篇文章就把RELRO彻底拆开聊一聊。从它到底改了什么到Partial和Full的差别在哪里再到绕过手法和实战经验一次性说透。适合刚入pwn门、正在为“编译选项全家桶”发愁的选手也适合那些打了很久CTF、但从来没认真想过RELRO为什么存在的玩家。1. RELRO到底做了什么为什么叫“最小丑”1.1 从GOT和PLT说起先搞清楚它保护的对象要理解RELRO先得知道它管的是哪块地盘。Linux下ELF程序调用外部函数基本都走GOT和PLT这两张表。简单说PLT是一段段跳板代码GOT是函数真实地址的存放处。程序第一次调用某个libc函数时真实的地址还没解析GOT里放的是PLT下一跳的地址跳回去触发动态链接器的解析流程拿到真实地址再填回GOT。这就是延迟绑定Lazy Binding。RELRO全称RELocation Read-Only本质是把“重定位相关的只读化”这件事从编译/链接阶段落实下来。它的保护对象主要是GOT这种在运行早期需要被写入、但之后不该再被改动的段。这里有个特别重要的细节GOT被分成了.got和.got.plt两块。.got放全局变量偏移、_dl_runtime_resolve相关的指针.got.plt放的是外部函数地址也就是我们常说的GOT表。默认编译情况下.got.plt是可写的因为需要让第一次调用的解析流程往里填地址。RELRO要改的就是这块区域的权限。1.2 Partial RELRO看起来开了其实把门留了一半Partial RELRO是很多编译器的默认选项比如Ubuntu的gcc默认就会带上。它的做法是把.got这一块注意不是.got.plt移动到只读段同时重新排布.data、.bss等段的顺序让只读区域和可写区域分得更开。问题在于.got.plt仍然是可写的。也就是说攻击者最想改的那些外部函数指针比如systemgot、freegot依然裸露在可写区域里。GOT覆写这种最经典的利用手法在Partial RELRO面前几乎是畅通无阻的。所以Partial RELRO的实际防御效果差不多等于没开。它只是重新排了下座次让某些攻击稍微麻烦一点但真正的主菜GOT覆写一点没防住。这也是RELRO被叫“小丑”的第一个原因名字听着像加固实则是纸糊的。1.3 Full RELRO真正的完全只读代价也在这里Full RELRO是我们常说的“全开”状态。编译时加上-Wl,-z,relro,-z,now或者直接-Wl,-z,now就能开启。它做的事情有两大步。第一步是让动态链接器在程序启动时就完成所有符号解析放弃延迟绑定。这个选项叫BIND_NOW动态链接器看到这个标志后启动时就把所有GOT项填好真实地址之后不再需要写了。第二步就是把整个GOT区域现在是一个合并后的.got加上.data.rel.ro等重定位只读段全部归入GNU_RELRO段最后通过mprotect把页面权限变成只读。我用readelf -l看过很多次开启Full RELRO后GNU_RELRO段标识会出现在程序头里readelf -d也能看到BIND_NOW标志。mprotect是按页粒度设置权限的所以即使你只想让几个字节只读内核也会把整页都锁成只读。这就是为什么Full RELRO会额外消耗内存页也是为什么它会影响程序启动性能——所有符号启动时全部解析加载时间会变长。代价是真实的收益呢在CTF选手眼里Full RELRO让GOT覆写直接gg。但对真正的漏洞利用来说这个“收益”往往是心理大于实际因为攻击者早就绕开了GOT。1.4 全景对比Partial RELRO、Full RELRO与无RELRO的差异对比维度无RELROPartial RELROFull RELRO.got权限可写只读只读.got.plt权限可写可写已合并只读延迟绑定启用启用禁用BIND_NOWGOT覆写风险直接裸奔依旧存在被阻断ret2dlresolve可用可用基本失效启动开销低低略高防御强度总结零心理安慰能挡一条路挡不住其他路表格里最扎眼的一行是Partial RELRO下的GOT覆写依然存在所以很多时候我在题解里看到“开了Partial RELRO先想到GOT覆写”就是这个原因——它其实是给攻击者指明了一条又直又宽的进攻路径。2. 它“小丑”在哪里无论开或不开攻击者都能找到自己的路2.1 Partial RELRO不只是没防住还帮攻击者标好了覆写目标开Partial RELRO的程序GOT表项固定排列在.got.plt里每个函数地址的位置是确定的而且是可写的。这相当于程序自己把“可以改的地方”用地图标了出来攻击者只需要找到一个任意写原语就能把freegot改成system或者把strlengot改成printf然后静待程序触发。更讽刺的是一些程序的漏洞利用第一步就是泄露libc基址拿到基址后就能算出system的真实地址。接着用格式化字符串或者堆溢出改GOT表项。这个过程里RELRO唯一的作用就是让.got这部分只读但攻击者根本不碰.got直接改.got.plt。所以我常说一句话Partial RELRO的防住率大概就是“防住了不想攻击你的人”。2.2 Full RELRO挡死GOT但攻击面转移到了更花哨的地方Full RELRO确实把GOT覆写这条路封死了于是攻击者把目光投向了别的地方。常见的选择有这么几个第一是hook函数指针。glibc 2.34之前__free_hook、__malloc_hook、__realloc_hook这些符号在libc里是存在的而且会被malloc/free调用。攻击者只要把__free_hook改成system然后让程序free一个内容为/bin/sh的堆块就能拿到shell。这类利用完全不依赖GOTRELRO管不到。第二是FSOPFile Stream Oriented Programming。通过覆写_IO_list_all或伪造_IO_FILE_plus结构体控制vtable指针让程序在处理标准IO时执行任意函数。这一招在glibc 2.23到2.35之间非常流行RELRO同样管不到。第三是直接攻击栈或堆上的函数指针。有些程序会在堆或栈上保存回调函数地址攻击者通过溢出改掉这些指针一样能达到控制流劫持的效果。所以说RELRO保护的是GOT这一个点但程序里可以被攻击者当作跳板的点远不止这一个。2.3 防御者的视野盲区RELRO只管重定位不管数据段里的指针追根溯源RELRO的设计目标是防止“重定位表项被篡改”它从来没承诺过要保护所有可写的数据指针。程序自己的全局函数指针、堆上的回调结构体、栈上的返回地址这些通通不在RELRO的管辖范围内。攻击者只要找到一个可写的指针并控制它就等于找到了一条与GOT无关的利用链。打个不太恰当的比方RELRO就像一个保安专门站在GOT这扇门前把门锁得死死的。但攻击者绕到后门从堆上的hook、从IO结构体、从栈返回地址溜进去保安浑然不觉。“小丑”说的就是这种尴尬处境——它防的方向恰好是攻击者最容易放弃的方向。2.4 我的一点牢骚RELRO常常被神化也被误读打CTF的时候经常看见新手玩家把checksec的结果当成“题目难度评级工具”。看到Full RELRO就以为这题无敌了看到Partial RELRO就觉得可以放心打GOT。这两种判断其实都不准确。Full RELRO只说明GOT覆写这条路不通但题目如果还有堆溢出照样可以打hook照样可以FSOP。反过来Partial RELRO也不代表只能用GOT堆利用照样成立。checksec只是给了你“哪些路被堵了”的提示真正的攻击面要结合代码逻辑和漏洞点来判断。RELRO被神化的另一个表现是很多教程把它和Canary、NX、PIE并列称为“保护全家桶”好像开了就固若金汤。实际上Canary防的是栈溢出直接改写返回地址NX防的是栈上执行shellcodePIE防的是固定地址泄露各管一摊。RELRO只管GOT相关的一小摊在这四兄弟里它的存在感太低了。3. 实战向不同RELRO状态下的利用路径与模板3.1 Partial RELRO场景下GOT覆写到底怎么打在Partial RELRO的题目里GOT覆写是最直接、最省脑子的打法。我举一个格式化字符串的典型例子。假设程序有格式化字符串漏洞而且存在循环读写的功能那么步骤是第一步用格式化字符串泄露地址。比如用%7$p这类格式符读栈上的libc地址或者直接读GOT表项泄露函数真实地址然后根据libc偏移算出基址。第二步计算目标函数与system的相对偏移或者用one_gadget。第三步通过格式化字符串的%n族写入把system地址写进某个函数的GOT表项。写入的方式有很多最简单的就是用fmtstr_payload。我常用pwntools里的工具可以直接传入offset、写入地址和值自动生成payload。举个例子假设要把freegot改成systemfrom pwn import * elf ELF(./pwn) free_got elf.got[free] system_addr libc_base libc.sym[system] payload fmtstr_payload(offset, {free_got: system_addr}) io.sendline(payload)之后只要让程序执行free(/bin/sh)就能触发system(/bin/sh)。这里有个小诀窍触发时最好保证传入free的参数是/bin/sh字符串的地址可以把/bin/sh写进某个可写的全局变量或者bss段也可以直接用libc里的/bin/sh字符串地址。3.2 ret2dlresolvePartial RELRO特供的经典组合拳当libc版本没有给泄露机会或者题目不给交互式输入时GOT覆写可能用不起来。这时候可以考虑ret2dlresolve。这里简单说下原理。延迟绑定机制下第一次调用某个外部函数时PLT会跳转到_dl_runtime_resolve这个函数接收两个参数一个是重定位表项的索引一个是当前调用帧的link_map指针。攻击者可以通过伪造栈上的数据结构让_dl_runtime_resolve认为“程序正在解析system这个符号”从而让动态链接器把system的真实地址填到指定位置然后跳转执行。这个技巧在Partial RELRO下特别好用因为.got.plt可写而且延迟绑定仍启用。完整的ret2dlresolve利用需要伪造这几个东西Elf64_Rela重定位表项、符号名system字符串、符号表项甚至可以放在bss段。手写这套结构很繁琐但pwntools里已经封装好了from pwn import * elf ELF(./pwn) rop ROP(elf) dlresolve Ret2dlresolvePayload(elf, symbolsystem, args[/bin/sh]) rop.read(0, dlresolve.data_addr) rop.ret2dlresolve(dlresolve) raw_rop rop.chain()这里核心点是Ret2dlresolvePayload会自动把伪造的字符串和重定位结构放在bss段的某个位置rop.read先读到那个地址再通过ret2dlresolve触发。注意这个技巧要求调用read本身不经过同样的延迟绑定流程细节比较绕建议第一次用的时候配合gdb断点看着栈和结构走一遍。3.3 Full RELRO场景GOT不能改就用hook或FSOP题目一旦开了Full RELRO很多选手会卡住。实际上如果程序是glibc 2.34之前的版本最常见的路是打__free_hook。用堆溢出或者UAF把__free_hook改成system然后构造一个内容是/bin/sh的堆块去free它。下面这个片段是常规操作free_hook libc_base libc.sym[__free_hook] system_addr libc_base libc.sym[system] # 假设有unsorted bin的UAF先控制tcache_perthread_struct # 或者直接large bin attack写到free_hook附近 edit(p64(system_addr)) free(binsh_chunk)关键点在于__free_hook本身在libc的数据段里而且默认是可写的在2.34之前。它的地址离libc基址有一段固定偏移泄露任意一个libc地址就能计算出来。如果是glibc 2.34之后hook被彻底移除那就要换思路。目前比较主流的做法是FSOP。通过对_IO_list_all的覆写伪造_IO_FILE_plus结构体把vtable指向_IO_str_jumps等特殊表触发system(/bin/sh)。这条路线的难点在于要精确构造文件结构体的各个字段不同glibc版本的偏移都不一样。还有一个思路是打__exit_funcs通过覆写退出时要调用的函数指针数组在程序退出/exit时执行恶意代码。这招在2.35之后被pointer_guard加密保护难度直接拉满。总之Full RELRO面前的路没有消失只是需要更多的构造功夫。3.4 栈迁移与ROP不管RELRO什么状态栈上的路永远存在还有一条完全无视RELRO的路是栈ROP。只要存在栈溢出且NX开启但栈可读就可以通过ROP链调用execve或system。RELRO管的是GOT栈上返回地址被Canary管着但Canary是可以绕过的——泄露、爆破、或者利用__stack_chk_fail的覆写。在开启了Full RELRO但PIE没开的场景下ROP特别方便因为程序基址固定gadget地址可以直接用。在PIE也开启的情况下则需要先泄露程序基址。一个很典型的组合是栈溢出 ret2csu mprotect 读shellcode 栈执行。RELRO对这个流程没有任何影响。rop.call(mprotect, [stack_page, 0x1000, 7]) rop.call(read, [0, stack_page, 0x100]) rop.call(stack_page) io.sendline(rop.chain()) io.sendline(shellcode)所以在评估一道题能不能打的时候与其先看RELRO不如先看漏洞类型和可用的代码执行面。RELRO大概率只是决定你“走哪条路”而不是“有没有路”。4. 工具与实操记录验证RELRO状态和实战坑点4.1 用readelf、checksec、gdb三种方式看出RELRO状态做pwn题的第一步永远是看防护但看防护不能只靠checksec最好交叉验证。checksec是最直接的一行命令就能看到RELRO是Partial还是Fullchecksec --file./pwnreadelf可以从两个角度确认。一个是看动态标记readelf -d ./pwn | grep BIND_NOW有输出就是启用了BIND_NOW说明是Full RELRO没输出那就是Partial或者无。另一个是看段布局readelf -l ./pwn | grep GNU_RELROFull RELRO下GNU_RELRO段的地址范围会覆盖整个GOT区域。gdb里看更直观启动程序后执行vmmap可以清楚看到GOT所在页面的权限是r--还是rw-。如果还想更细一点可以在_dl_runtime_resolve函数上下断点动态观察延迟绑定有没有触发。Full RELRO下断点是断不上的Partial RELRO下第一次调用外部函数就会断进来。这个调试方法比看代码更直观。4.2 现场实验一个Full RELRO程序强行写GOT会怎样我实际写过一个测试程序编译时开了Full RELRO然后用pwntools尝试格式化字符串覆写GOT。结果很有意思。第一下写入后程序直接段错误用gdb跟进错误是SIGSEGV发生在free函数内部。再细看是因为写入freegot时命中的页面权限是只读内核直接拒绝了这次写操作导致了后续流程的崩溃。其实在Full RELRO下连尝试改GOT都不应该继续走内存页是只读的任何写指令都会引发页错误。所以那些在Partial RELRO下能轻松打通的GOT覆写exp在Full RELRO下不是“被拦住了”而是“当场死掉”。这个实验最大的收获是让我建立了“权限决定攻击面”的直觉看到只读页就不要想着写它看到可写页先想清楚它指向哪里、写进去有什么用。4.3 实战里的几个坑踩过才知道打GOT覆写的时候我最常踩的坑之一是搞错GOT项是哪个。freegot、printfgot、strlengot一定要用pwntools的elf.got[free]取或者readelf自己查不要凭记忆猜。有些题目用到了strcmp或者atoi这些函数的GOT项在不同libc版本里偏移不一样猜错一步整个exp就废了。打ret2dlresolve的时候坑更多。最大的坑是bss段地址的页权限。如果bss所在的页被RELRO覆盖成了只读那伪造的数据结构就写不进去整个ret2dlresolve就废了。所以我每次都会先确认read写目标的地址在哪个段、什么权限常见的做法是把伪造数据写在bss段靠后、.data或者.bss还没被RELRO覆盖的位置。这一步用readelf -S看段的偏移就能确定。打hook的时候也有个坑就是__free_hook在部分版本里不能直接用one_gadget。因为one_gadget对栈环境有约束触发时栈布局不对就会crash。稳妥一点的做法是优先把__free_hook改成system并控制free的参数为/bin/sh字符串地址如果实在找不到可控的free参数才退而求其次试one_gadget。4.4 如何快速判断一道题在RELRO维度上该走哪条路拿到题目我一般按这个顺序快速判断利用路线先看漏洞类型。如果是格式化字符串先看RELRO状态。Partial就直接打GOT覆写Full就找可写的全局指针或者程序自己保存的函数指针格式化字符串可以配合任意地址读写去改这些。如果是堆漏洞先不管RELRO直接看libc版本。2.34之前看hook2.34之后看FSOP或_IO相关。如果是栈溢出先看Canary确定能不能绕过再看有没有PIE决定ROP的地址怎么构造RELRO基本可以不看。这一套流程用顺了之后会发现RELRO在决策树里的权重确实不高。它只是第一层判断题GOT这条路通不通。通了皆大欢喜不通换路走就是了。5. 常见问题速查与避坑清单5.1 RELRO相关高频问题整理常见问题原因分析解决思路为什么我改了freegot后程序直接崩溃开了Full RELROGOT所在页只读写入触发段错误换hook或FSOP路线别跟GOT死磕checksec显示Partial RELRO但我找不到GOT漏洞的利用点Partial不意味着必须打GOT堆/栈该打照打回归漏洞本身别让RELRO决定思路ret2dlresolve为什么总是segfault伪造数据放在了被RELRO覆盖的只读段或者偏移算错用readelf确认bss段权限写在未被RELRO覆盖的位置glibc 2.34后__free_hook打不了怎么办hook机制被移除malloc/free不再检查hook转FSOP或__exit_funcs具体看libc版本和可写字段Full RELRO是不是就绝对安全了不是GOT只是攻击面之一检查程序自定义函数指针、IO结构体、栈数据5.2 从“小丑”身上学到的经验回头看RELRO它确实是小丑但不是“没用的小丑”而是“岗位错位的小丑”。它被安排去守GOT这扇门但这扇门在现代利用链里恰恰是最容易被绕开的一扇。它存在最大的价值反而是提醒我们程序的可写数据永远是最值得关注的地方。GOT只是可写数据里最标准化的一块RELRO把这一块锁上后攻击者会去看其他可写数据——这本质上是一场猫鼠游戏的换防。我在每次构造exp前都会养一个习惯先列出这个程序里所有“可以被写入、且之后会被间接调用”的位置。GOT是第一个但绝不能是最后一个。全局函数指针、_IO_FILE结构体、堆上的回调、环境变量指针这些都可能成为新的跳板。RELRO的存在不是为了让你放弃而是逼你多走一步。最后再分享一个小技巧写exp之前先在gdb里用vmmap看一眼每一段的权限尤其标出GNU_RELRO覆盖了哪些地址。这个动作做多了你对“哪些路被堵死、哪些路还开着”的判断会准得可怕。RELRO再小丑也得先看清它站在哪里才知道怎么绕过它往前走。