news 2026/10/1 12:34:05

RELRO机制详解:从GOT表劫持到Full RELRO绕过实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RELRO机制详解:从GOT表劫持到Full RELRO绕过实战

先聊点直接的。我在CTF里打了这么多道pwn题,最常被新手忽略、又最能在关键时刻给你“上一课”的机制,就是RELRO。很多人一上来习惯性checksec,看到RELRO: Partial RELRO就只知道“GOT表好像能改”,看到Full RELRO就只知道“GOT表不能改了”。但真要问一句:RELRO到底保护了谁、为什么分Partical和Full、它在你整个利用链里到底扮演什么角色?能讲清楚的人不多。

这篇文章我想从“最小丑的机制”这个角度,把RELRO彻底拆开讲透。为什么叫“小丑”?因为它看起来是个安全机制,但在pwn利用里,它从来不是真正的铜墙铁壁,甚至很多时候,它只是给你多添了一道需要绕过的工序。但反过来,它又是理解GOT表劫持、延迟绑定、动态链接这些核心知识的最佳入口。把它吃透了,你做pwn题的思路会清楚很多。

1. RELRO到底是什么,又是怎么来的

1.1 延迟绑定与GOT表的关系

要搞懂RELRO,必须先搞懂一个前置知识:GOT表是怎么被填充的。

现代操作系统都有ASLR(地址空间布局随机化),libc在每次加载时的基址都不一样。编译器在编译时,没法知道system、printf这些函数最终会落在哪个地址。所以它引入了一个间接跳转层,也就是PLT+GOT机制。

大致流程是这样的:

  • 你的代码里写call printf@plt,这里的printf@plt是一小段跳板指令。
  • printf@plt会跳转到GOT表里记录的printf的真实地址。
  • GOT表一开始存的是printf@plt的下一条指令地址,也就是动态链接器的解析函数_dl_runtime_resolve的入口。
  • 第一次调用printf时,PLT桩子会触发动态链接器,让它查找到printf在libc中的真实地址,然后写入GOT表。
  • 第二次调用printf时,直接跳转GOT表里的真实地址。

这个“第一次调用时解析、后续直接使用”的机制,叫延迟绑定(Lazy Binding)。它在真实程序里的作用非常明显,尤其是一个启动时要加载几百个动态库的大型软件,如果全部提前解析,启动时间会暴涨。延迟绑定只在函数真的被调用时才去解析,能省下大量启动开销。

但这里有个致命问题:为了支持延迟绑定,GOT表在运行时必须是可写的。因为动态链接器需要往GOT表里写入解析出来的函数地址。

这就给了攻击者机会。如果攻击者能往GOT表里写任意数据,把某个函数的GOT表项改成system的地址,那么下次程序调用这个函数时,实际执行的就是system。这就是经典的GOT表劫持。

RELRO就是为了限制GOT表的可写性而出现的。

1.2 Partial和Full到底差在哪

编译器的解决方案分了两档。

Partial RELRO,对应编译选项gcc -Wl,-z,relro。它的做法是:

  • 把GOT表和.data等数据段合并到同一个内存段。
  • 把其中.got部分(存放非延迟绑定全局符号的地址)设为只读。
  • 但.got.plt部分(存放延迟绑定函数地址的表)保持可写。

也就是说,Partial RELRO下,大部分参与延迟绑定的函数,它们的GOT表项依然可写。攻击者的GOT表劫持基本不受影响。

Full RELRO,对应编译选项gcc -Wl,-z,relro,-z,now。它的做法是:

  • 程序启动时,动态链接器一次性解析所有动态符号,把GOT表全部填满。
  • 然后将整个GOT表所在的内存页设为只读。
  • 代价是放弃延迟绑定,程序启动时间会变长。

Full RELRO下,GOT表从用户态角度是写不进去的。

说句题外话,我在做pwn题时,判断一个程序是Partial还是Full,除了看checksec,还有一个土办法:看程序启动速度。Full RELRO的程序启动时会有一瞬间的“卡顿”,虽然实际差异很小,但在高频率启动调试时能感觉到。

2. RELRO的底层实现:它到底是怎么被“锁”起来的

2.1 ELF程序头里的PT_GNU_RELRO

在ELF文件格式里,动态链接器并不是凭空知道哪些内存区域要设置成只读的。它靠的是一个特殊的程序头:PT_GNU_RELRO。

你可以用readelf -l查看一个二进制的程序头,会看到类似这样的输出:

程序头: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0004b0 0x0004b0 R E 0x200000 LOAD 0x000e00 0x0000000000600e00 0x0000000000600e00 0x000230 0x000248 RW 0x200000 DYNAMIC 0x000e18 0x0000000000600e18 0x0000000000600e18 0x0001c0 0x0001c0 RW 0x8 GNU_RELRO 0x000e00 0x0000000000600e00 0x0000000000600e00 0x000230 0x000230 R 0x1

注意最后一项GNU_RELRO。动态链接器在加载程序时,会读取这个程序头,得到需要保护的内存范围(在这个例子里就是地址0x600e00到0x601030这段)。然后调用mprotect,把这段内存从RW改成R。

这正是RELRO的底层实质:它不是一个强制的硬件防护,而是用户在程序启动时,主动调用mprotect把一段内存改成了只读。

这个点非常关键,因为它引出一个直接影响:RELRO保护的是整段内存页,而不是精确定位到每一个GOT表项。如果GOT表所在的内存页里还混着其他数据,那么这些数据也一并被设为只读。

2.2 Full RELRO编译前后的段变化

当你用gcc -Wl,-z,relro,-z,now编译时,和Partial RELRO相比,生成的ELF有几处明显不同:

  • 不再存在独立的.got.plt段。.got和.got.plt被合并到同一个段里,并且启动时就被全部解析填充。
  • GNU_RELRO覆盖的范围变大,把整个GOT表都包进去。
  • 动态段(PT_DYNAMIC)里的DT_BIND_NOW标志被设置,告诉动态链接器要立刻绑定所有符号。

用readelf -S查看段表也能看出端倪:

[24] .got PROGBITS 0000000000600ff0 00000ff0 0000000000000020 0000000000000008 WA 0 0 8 [25] .got.plt PROGBITS 0000000000601010 00001010 0000000000000030 0000000000000008 WA 0 0 8

Partial RELRO下能看到.got和.got.plt两个段,Full RELRO下通常只有一个.got段,且它的地址在GNU_RELRO范围内。

实操时我经常用readelf -S配合readelf -l一起来看,这样能准确判断某个GOT表项到底在不在RELRO保护范围内。不要只依赖checksec输出,checksec偶尔会因为解析问题给出不准确的结果。

2.3 glibc里是谁在调用mprotect

glibc的代码在elf/dl-reloc.c里有个_dl_protect_relro函数。它的逻辑不算复杂:

  1. 遍历程序头表,找到PT_GNU_RELRO。
  2. 拿到起始地址l_addr + l_relro_addr。
  3. 拿到结束地址,向上取整到页边界。
  4. 调用__mprotect,把这段内存改成只读。

如果你在gdb里跟踪一个Full RELRO的程序的启动过程,在_dl_protect_relro下断点,能看到它执行完后,GOT表的VMMap状态从rw-p变成r--p。

但是,有时候你会发现一个有意思的现象:GOT表所在的内存页被设成只读后,同一页里的其他数据也被连带只读了。这正是“页粒度保护”的副作用。比如一个程序里有些全局变量恰好和GOT表在同一个页上,那么这些变量也变成了只读。这在真实CVE利用中偶尔会被用作一种“白名单”排查方式。

3. RELRO为什么挡不住攻击者

这个章节是这篇文章的核心,我想跟你聊透一个问题:RELRO到底能不能真正防住GOT表劫持?

3.1 Partial RELRO基本等于没防

在Partial RELRO下,GOT表劫持依然是最经典、最顺手的利用手法。因为你只需要一个任意地址写,把某个函数的GOT表项改成你想要的函数地址,然后等着程序调用它就可以。

我举个很常见的例子。有些题目给了格式化字符串漏洞,而且程序是Partial RELRO。这种题几乎就是送分题:

  • 格式化字符串天然支持任意地址读和写。
  • 你可以用%n把printf@got改成system@plt。
  • 下次程序调用printf时,实际执行的是system。
  • 如果程序里有个sprintf(buf, "%s", user_input)或类似调用,你还能顺势控制参数,直接传/bin/sh。

这种打法在CTF中太常见了,可以说每个做过几道pwn题的人都打过GOT表劫持。而Partial RELRO对这道利用链一点办法都没有。

3.2 Full RELRO下打GOT表的替代思路

再看Full RELRO。很多新手一看到Full RELRO就认为“GOT表不能动了”。这话在字面意义上是对的,因为GOT表所在内存页已经被设置成只读。但攻击者的最终目标不是“改GOT表”,而是劫持控制流。

GOT表只是劫持控制流的一种载体。没有这个载体,还有别的:

  • libc hook:老版本glibc里,__free_hook、__malloc_hook、__realloc_hook等是实实在在的全局函数指针。free在调用时会先判断__free_hook是否为空,不为空就调用它。改掉__free_hook等于改掉free的行为。这些hook变量在libc的数据段里,跟GOT表没有任何关系,RELRO保护不到。
  • FSOP(File Stream Oriented Programming):通过伪造_IO_FILE结构体,在程序调用exit或_IO_flush_all_lockp时,劫持vtable里的函数指针。这种利用方式完全不碰GOT表,RELRO对它无效。
  • stderr/stdout结构体攻击:直接改写_IO_2_1_stderr_里的_chain字段和vtable指针,实现控制流劫持。
  • 堆上的函数指针:很多真实程序会在堆上保存函数指针(比如C++的虚表指针、回调函数指针等),这些存储在堆里的指针,RELRO同样管不到。
  • 直接ROP:不依赖任何函数指针,直接在栈上构造返回地址链。RELRO对ROP没有任何防御能力。

所以,Full RELRO下的利用,本质上就是把这些替代手法拿出来用。这也是为什么很多Full RELRO的题目,题解都在打__free_hook、打FSOP,而不是打GOT表。

3.3 极端情况:间接写GOT表

还有一种比较“绕”的思路:虽然GOT表被设为只读,但如果攻击者能控制动态链接器本身,让它去修改GOT表呢?

这有点像是在问:锁是用户自己上的,如果用户自己又把锁打开了怎么办?

在理论上,如果你能控制_rtld_global结构体里的某些字段,或者覆盖DT_JMPREL、DT_SYMTAB等动态表项的地址,那么当程序触发一次延迟绑定时(但Full RELRO下没有延迟绑定,所以这个思路在Full RELRO里受限),动态链接器会按照你伪造的重定位表去解析符号,然后把解析出来的地址写入GOT表。

这种攻击在真实世界里出现过,但CTF题目里很少见。原因很简单:题目不会给你这么宽泛的控制能力。如果攻击者已经能控制_rtld_global,那早就拿到代码执行了,不需要再绕这么一个大圈去写GOT表。

3.4 对小丑机制的精确定位

总结一下:RELRO防护的是“GOT表内容不被篡改”,但攻击者需要劫持的是“控制流”,而控制流可以通过函数指针、返回地址、vtable等多种载体劫持。RELRO只管住了其中一种载体,而且管得还不彻底。

所谓“最小丑”,说的就是这个:它看起来在防最经典的GOT表劫持,但实际上只防住了入门程度的那一种,稍微进阶一点的手法它都管不了。

但反过来想,如果题目设计者就是想让你打GOT表劫持,他会开Partial RELRO;如果他想让你提升难度,他会开Full RELRO,逼你去用hook劫持、FSOP等进阶手法。所以RELRO又是pwn题难度设计里最精准的调节旋钮之一。

4. 实战视角:不同RELRO状态下的利用路线

前面讲了很多理论,这一章我们把它落到实际做题中,看不同RELRO状态下,一个pwn题的利用链是怎么设计的。

4.1 Partial RELRO+GOT表劫持的完整思路

假设这样一个典型题目:菜单题,带格式化字符串漏洞,Partial RELRO,无PIE。

我的利用流程通常是这样:

  1. 用checksec确认防护状态,看RELRO是否为Partial,PIE是否开启。
  2. 用objdump -d ./pwn | grep '<printf@plt>'或readelf -r查看GOT表布局,找到printf@got的地址。
  3. 用gdb或readelf确认system@plt的地址。
  4. 用格式化字符串先泄露几个地址(比如栈上的libc地址),计算出libc基址,从而得到system的真实地址。如果你连system@plt都不想用,直接写system的libc地址也行。
  5. 利用格式化字符串的任意地址写,把printf@got改成system的地址。写的时候注意用%hn分段写,避免一次性输出大量字节。
  6. 触发一次printf调用,程序实际执行了system。如果调用时的第一个参数是/bin/sh,直接拿shell。

这个流程几乎是模板化的,我打了无数道这种题。唯一要小心的就是格式化字符串的偏移计算和写入顺序。

4.2 Full RELRO+libc hook劫持的完整思路

再看典型场景:Full RELRO,PIE开启,堆漏洞(比如UAF),libc版本2.23。

这时候GOT表劫持行不通了,我通常会走hook劫持路线:

  1. 利用UAF泄露堆地址(通过fastbin的fd指针),再泄露libc地址(通过unsorted bin的fd/bk指针指向main_arena)。
  2. 计算出__free_hook的地址。不同libc版本偏移不同,可以用libc-database或者one_gadget工具查。
  3. 利用fastbin attack或者其他堆布局技巧,往__free_hook写入system的地址。
  4. 分配一个堆块,内容写上/bin/sh\x00,然后free它。调用free时,glibc发现__free_hook不为空,于是调用system("/bin/sh"),拿到shell。

这个思路里,RELRO根本没有参与感。你从头到尾都没碰GOT表。

4.3 Full RELRO+FSOP的思路

如果libc版本是2.34以上,__free_hook已经被删了,hook劫持这条路也断了。这时候FSOP就成了主流打法。

FSOP的核心思路是:

  • 伪造一个_IO_FILE结构体(可以放在堆上)。
  • 通过某种写原语,把_IO_list_all指向这个伪造的结构体。
  • 利用_IO_flush_all_lockp或_IO_wfile_jumps等vtable中的函数指针,在程序退出时触发控制流劫持。

FSOP的实现细节很复杂,涉及glibc IO源码的很多内部逻辑。但它的核心点跟RELRO没有直接关系,RELRO完全拦不住这种攻击。

4.4 做题时怎么根据RELRO快速定思路

我个人的习惯是,拿到一个pwn题,先看RELRO,再快速定一条利用主线:

RELRO状态常见可利用点首选利用思路
Partial RELROGOT表可写直接GOT表劫持,配合格式化字符串或任意地址写
Full RELRO + 老glibcGOT表只读__free_hook/__malloc_hook劫持
Full RELRO + 新glibc无hookFSOP / ROP / vtable劫持
Full RELRO + 沙箱无法execveORW(open/read/write)ROP链

这个表不是绝对的,但它能帮你在第一时间排除错误方向。比如你辛辛苦苦构造了半天,发现目标是Full RELRO,而你一直在想怎么改GOT表,那基本就是白费功夫。

5. 实际操作中常见的坑与调试技巧

这章说点实操经验,都是我自己调试时踩过的坑。很多坑你网上搜不到,只能在报错里慢慢体会。

5.1 坑1:GOT表地址不等于GOT表项地址

很多新手在写GOT表时,直接拿objdump里看到的.got.plt起始地址当作某个函数的地址来写。这是一个非常经典的错误。

.got.plt段里有多个表项,每个函数对应一个。你要写的是这个函数的GOT表项地址,而不是整个段的起始地址。

正确做法是:

  • 用readelf -r查看重定位表,里面会列出每个动态符号对应的地址。
  • 或者在gdb里用got命令(pwndbg/gdb插件都有)直接查看每个函数的GOT表项地址。

比如:

pwndbg> got GOT protection: Partial RELRO 0x601018: printf 0x601020: read 0x601028: write 0x601030: strcmp

这里的0x601018才是printf的GOT表项地址,不要写成0x601010或段起始地址。

5.2 坑2:写GOT表时没有考虑高地址和低地址的写入顺序

利用%n写GOT表时,通常用%hn写两个字节。如果一个GOT表项需要写入类似0x7ffff7a52390这样的地址,你要写4次:

  • 第一次写低2字节:0x2390
  • 第二次写0x23a0(地址+2)
  • 第三次写0x23a2(地址+4)
  • 第四次写高2字节:0x7fff

但格式化字符串的%hn是按照前面输出的字符数来写入的,所以你要精确控制每次写入前的输出长度。如果前面的一次输出多了或少了,后面全部都会错位。

我常用的技巧是:把目标值拆成四次单独写,每次都用%hn,并且在每次写入前调整输出长度。如果目标值小于当前已输出长度,就用%c指定一个较小的输出宽度来微调。

调试时,每写一次就用x/gx查看一次GOT表项内容,确认前面写对了再继续。不要一口气写完整条链,否则出错后很难定位。

5.3 坑3:误以为Full RELRO下GOT表完全不可读

这是很多新手的一个误区。RELRO把GOT表设为只读,但它并没有让GOT表变成不可读。

GOT表里的内容仍然是可以正常读取的。这意味着什么?意味着在Full RELRO下,GOT表仍然是一个非常稳定的信息泄露源。

  • 你可以通过格式化字符串漏洞读printf@got,得到printf在libc里的真实地址。
  • 用这个地址减去printf在libc里的偏移,就能算出libc基址。
  • 有了libc基址,hook劫持、ROP链、one_gadget全都能用了。

所以,Full RELRO下GOT表的角色,从“可写的利用目标”变成了“只读的信息泄露源”。这个转变很微妙,理解它之后,你就能明白为什么很多Full RELRO题目的第一步都是泄露某个GOT表项。

5.4 技巧1:用pwndbg的got命令快速查看GOT表防护状态

pwndbg的got命令不仅能列出GOT表项,还能为你标记当前GOT表的保护状态。如果显示GOT protection: Partial RELRO,说明GOT表可写;显示Full RELRO,说明GOT表只读。

这个命令在调试时很实用,因为有时候checksec会因为分离libc或strip等原因给出模糊信息,而got命令直接读取进程内存,信息更准确。

5.5 技巧2:做pwn题前先确认libc版本

这一点看似和RELRO无关,但实际上关系很大。

  • 如果你面对的是glibc 2.23到2.31,__free_hook是可用的,Full RELRO下可以直接打hook。
  • 如果libc版本是2.34以上,hook被删了,你需要考虑FSOP或其他手段。
  • 如果题目给了libc.so.6文件,直接用libc-database查询__free_hook偏移、system偏移等,会非常方便。

我曾经在一道题上卡了很久,原因是libc版本是2.34,我一直尝试打__free_hook,结果怎么都不对。后来查了libc版本才发现这个符号已经不存在了。从那以后,我拿到题的第一时间就会确认libc版本,再看RELRO状态定策略。

6. 关于RELRO,我的最终体会

写到这里,关于RELRO的核心内容基本都说完了。最后聊一点个人心得。

RELRO这个机制,在pwn领域里确实容易被低估。新手觉得它只是一个“GOT表能不能改”的开关,老手则清楚它背后牵扯到延迟绑定、动态链接、内存页权限、glibc内部结构等一系列知识。但一旦你把这条链路吃透,你会发现,它其实非常“薄”——它就是一次mprotect调用,把一段内存设为只读。

它的价值不在于它能挡住多少攻击,而在于它强制你去理解“为什么GOT表要可写”。理解了这一点,你才真正理解动态链接的一整套运作逻辑,也才能在面对Full RELRO时,自然而然地想到打hook、打FSOP、打ROP,而不是束手无策。

所以我的建议一直是:不要急着学各种花哨的利用技巧,先把RELRO、GOT表、延迟绑定这三者的关系彻底搞明白。这一步走扎实了,后面学什么都快。这就像打游戏,你先把基础装备升满,再打高级副本才不会一直在同一个地方翻车。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 12:33:50

Maven打包报错Unable to find main class:从根源到修复的全面指南

先说明一个很常见的尴尬场景&#xff1a;你在 IDEA 里写好了一个 Spring Boot 项目&#xff0c;Application类里的main方法写得明明白白&#xff0c;点击 Maven 面板里的package&#xff0c;控制台却甩出这么一行&#xff1a;[ERROR] Failed to execute goal org.springframewo…

作者头像 李华
网站建设 2026/10/1 12:33:43

Java实现琴房预约管理系统:需求拆解与并发控制实战

做毕业设计这些年&#xff0c;最常被问到的一道题就是“老师&#xff0c;琴房预约这种题能做吗&#xff1f;感觉业务太简单&#xff0c;怕写不出东西”。其实恰恰相反&#xff0c;琴房预约管理系统是高校里特别典型的管理类题目&#xff0c;需求边界清晰、角色分明、有并发冲突…

作者头像 李华
网站建设 2026/10/1 12:33:39

Stream流全拆解:概念、排序实战与disconnected报错排查

最近排查一个线上问题时&#xff0c;日志里连续出现几行这样的报错&#xff1a;stream disconnected before completion: stream closed before response.completed、transport error: network error: error。说实话&#xff0c;这类报错看起来简短&#xff0c;但背后牵涉的东西…

作者头像 李华
网站建设 2026/10/1 12:33:36

中小企业GPU算力租赁预算指南:从选型到成本优化

中小企业的AI算力预算&#xff0c;十有八九是笔糊涂账。我见过太多团队&#xff0c;一上来就问"租一张4090一个月多少钱"&#xff0c;然后按最便宜的单价下单&#xff0c;结果跑了两周发现显存不够、卡型不匹配、数据传输比训练还慢&#xff0c;钱花了活没干成。算力…

作者头像 李华
网站建设 2026/10/1 12:31:42

大数据与LLM融合的农产品价格预测及推荐系统实战

前几年做毕业设计&#xff0c;大家还在纠结SSH框架还是Spring Boot&#xff0c;今年风向已经完全变了——别再只做一个CRUD管理系统了。如果你选了大数据方向&#xff0c;又想蹭上大模型的热度&#xff0c;这个选题值得认真看一下&#xff1a; Spark Hadoop Hive LLM大模型…

作者头像 李华
网站建设 2026/10/1 12:30:44

XGBoost原理与实战:从梯度提升树到调参优化全攻略

第一次用XGBoost的时候&#xff0c;我其实挺烦它的——默认参数跑下来&#xff0c;准确率确实还行&#xff0c;但换个数据集效果就飘忽不定&#xff1b;调max_depth和learning_rate全凭感觉&#xff0c;加正则化更是一头雾水。后来把算法原理啃了一遍&#xff0c;再回头做项目&…

作者头像 李华