ROP 进阶:利用 BSS 段与 ret2libc 构建万能调用链

在基于面向返回编程(ROP)的代码重用攻击体系中,最理想的场景莫过于目标程序在动态链接库或自身代码段中早已硬编码了 "/bin/sh" 这样的敏感字符串,同时又恰好提供了现成的 system() 函数符号。在这种“教科书式”的完美环境下,分析人员只需一条简单的 pop rdi; ret 即可完成参数装载。
然而在真实的工业级漏洞复盘与渗透测试中,现实往往要骨感得多:
- 目标程序出于轻量化或安全合规考虑,代码段(
.text)与只读数据段(.rodata)中根本不存在任何可用的 Shell 字符串常量; - 目标程序是一个严格编译的生产服务,没有直接链接
system()函数,只导出了基础的底层系统 I/O 函数(如read()、write()); - 甚至由于 x86-64 体系下
glibc内部对 16 字节栈对齐的严格苛求,许多看似逻辑正确的 ROP 链在执行到system()入口处会毫无征兆地触发movaps崩溃。
面对这种无现成参数、无高阶利用函数的“双无”残局,如何突破困境?本文立足底层二进制攻防机理,深度剖析如何利用非 PIE 程序中地址固定的 .bss 段作为中转数据跳板,结合两段式 read -> execve/system 与栈对齐修剪,构建高可靠的通用 ROP 调用链。
一、 破局支点:未初始化全局数据区(.bss 段)的战略价值
1. 为什么是 .bss 段?
在 Linux ELF 规范中,.bss(Block Started by Symbol)段专门用于存放程序中未初始化的全局变量和静态局部变量。
在系统安全防御的坐标系中,如果目标程序在编译时遗漏了 -fPIE -pie 选项(即仅开启了 NX 与 Canary,未开启位置无关执行):
- 基址恒定:无论操作系统内核的 ASLR 级别是 1 还是 2,主程序的所有段在虚拟内存中的加载基址都是绝对固定的(x86-64 默认加载在
0x400000附近); - 内存具备读写权限(RW):在 ELF 的程序头表(Program Headers)中,包含
.bss的数据段 LOAD 属性为RW(可读、可写、不可执行); - 空间充裕且无破坏副作用:
.bss段通常紧随数据段之后,在其末尾通常存在未被完全占用的物理页空间,向该区域写入几十个字节的数据,绝不会破坏任何正在运行的关键业务逻辑。
因此,.bss 段成为了攻击者在没有动态内存泄露条件下的天然**“受控数据落盘停机坪”**。
二、 两段式 ROP 链的核心执行拓扑
既然内存中没有我们需要的命令字符串,那我们就通过程序原本拥有的合法输入函数,由我们自己“亲手写入”到 .bss 段,随后再触发执行。
1. 整体攻击时序推演
整个 ROP 链必须被拆解为两个严格衔接的阶段:
+-------------------------------------------------------------+
| 阶段一: 字符串写入阶段 (Staging Phase) |
| -> 利用 Gadgets 编排调用: read(0, bss_addr, 16) |
| -> 将攻击载荷字符串 (如 "/bin/sh\x00") 从标准输入读入 .bss 段 |
+-------------------------------------------------------------+
|
v (执行完毕后平滑流转)
+-------------------------------------------------------------+
| 阶段二: 命令执行阶段 (Execution Phase) |
| -> 利用 Gadgets 将 RDI 寄存器指向刚才写入的 bss_addr |
| -> 执行栈 16 字节对齐修剪 (补充 1 条空 ret) |
| -> 跳转进入 system() 函数完成执行 |
+-------------------------------------------------------------+
2. x86-64 调用栈帧微观编排(Stack Layout)
为了驱动上述逻辑,我们必须在被溢出的执行栈上,按照 x86-64 传参约定(RDI=参数1, RSI=参数2, RDX=参数3)编排如下的多米诺骨牌:
高地址 0x7fffffffe068 +--------------------------------------------+
| <system() 函数的虚拟内存地址> |
0x7fffffffe060 +--------------------------------------------+
| <bss_addr: 存放 "/bin/sh" 的固定内存首地址> |
0x7fffffffe058 +--------------------------------------------+
| <Gadget 1: pop rdi; ret> |
0x7fffffffe050 +--------------------------------------------+
| <Gadget 4: ret (纯空返回,用于 16 字节对齐)> |
0x7fffffffe048 +--------------------------------------------+
| <read() 函数的虚拟内存地址> |
0x7fffffffe040 +--------------------------------------------+
| 0x0000000000000010 (读取长度: 16 字节) |
0x7fffffffe038 +--------------------------------------------+
| <Gadget 3: pop rdx; ret> |
0x7fffffffe030 +--------------------------------------------+
| <bss_addr: 目标写入缓冲区首地址> |
0x7fffffffe028 +--------------------------------------------+
| <Gadget 2: pop rsi; ret> |
0x7fffffffe020 +--------------------------------------------+
| 0x0000000000000000 (文件描述符 0: stdin) |
0x7fffffffe018 +--------------------------------------------+
| <Gadget 1: pop rdi; ret> |
低地址 0x7fffffffe010 +--------------------------------------------+ <-- 溢出后的当前 RSP
三、 致命暗坑与实战调试:movaps 16 字节对齐崩溃
在 64 位 Linux 环境下编写 ROP 链时,绝大多数安全研究员都会踩进同一个诡异的“深坑”:
所有的参数(RDI/RSI/RDX)明明配置得完全正确,GDB 单步跟踪也确确实实跳进了 system 函数,然而在 system 函数内部执行到某条指令时,程序突然莫名其妙地抛出 SIGSEGV 崩溃!
1. 根因探秘:SSE 指令集的硬件硬约束
在 64 位的 glibc 实现中,为了追求极致的内存处理性能,许多内部函数(包括 system、printf 以及 do_system)会使用 x86-64 的 SSE 向量指令(如 movaps)在栈上搬运 128 位的 XMM 寄存器数据。
Intel 指令集规范对 movaps 有一条极其严苛的硬件约束:
操作数所指向的内存栈地址,必须严格按 16 字节物理对齐(即地址最低 4 位必须为 0x0,以 0 结尾)!
当程序在正常运行流程中通过正常的 call 指令进入函数时,硬件会自动压入 8 字节的返回地址,函数序言再压入 RBP(8 字节),刚好完成 16 字节对齐。但在攻击者人工手工拼接的 ROP 链中,连续的 pop 和 ret 操作使得 RSP 的增减完全脱离了编译器控制,导致进入 system 时 RSP % 16 != 0。硬件一旦检测到未对齐访问,立即抛出通用保护故障(General Protection Fault)。
2. 优雅的化解之法:插入“NOP Gadget”
解决这一硬件约束的方案极其优雅——在跳转进入 system 之前,在 ROP 链中强行插入一个单独的 ret 指令地址(称为 Aligning Ret):
- 这个单独的
ret什么运算都不做,仅仅是从栈顶再弹出一个 8 字节的值赋值给RIP; - 它的唯一物理作用,是将执行栈指针
RSP平移 8 字节,瞬间将奇数倍对齐转化为偶数倍对齐,使RSP完美满足 16 字节对齐约束。
四、 防御视角的终极思考:如何从源头斩断此类 ROP 链?
利用 .bss 段的两段式 ROP 链之所以能奏效,完全是建立在**“程序的主代码段与数据段基地址固定不可变”**这一致命脆弱点之上。
如果运维与安全团队在软件上线前做好了编译基线治理,此类攻击在第一毫秒就会被彻底化解:
# 生产构建强制加固基线
SEC_FLAGS := -O2 \
-fstack-protector-strong \
-fPIE \
-Wl,-z,relro,-z,now \
-Wl,-z,noexecstack
- 强制开启 PIE(
-fPIE -pie):
使得主可执行文件的代码段和.bss段在物理内存中的基址完全随每次进程启动被内核随机化打散。攻击者在不知道.bss动态基址的前提下,根本无法预先填入read()的目标参数,两段式链条在起点即告断裂。 - 完全重定位只读(Full RELRO,
-Wl,-z,relro,-z,now):
将所有的 GOT 表在程序启动时一次性解析完毕并置为严格只读,彻底封堵任何试图篡改函数指针的可能。
五、 结语
二进制攻防的精妙,恰恰体现在对计算机体系结构细节的穷尽挖掘。两段式 ROP 链展现了在严苛资源受限环境下,如何将合法的文件 I/O 与静态段内存组合成图灵完备利用链的精巧思维;而对 16 字节对齐与硬件异常的探究,更揭示了底层系统指令集不可违背的物理铁律。知其白,守其黑。深谙攻击链条的每一步拼装,方能站在系统架构的高维视角,用最小的编译与部署成本构筑起坚不可摧的内存防御长城。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)