防止栈溢出的保护措施
根据之前写过的文章[1],了解到了栈溢出漏洞的原理,但是目前存在很多种保护栈溢出的措施,下面我们来介绍一下。
The NX bits
、ASLR
、PIE
、Canary
、RELRO
The NX bits
NX位是“No-Execute bit”的缩写,中文意为“禁止执行位”。它是现代CPU硬件层面的一种安全功能,用来将内存区域标记为“可执行”或“不可执行”。
简单来说,操作系统会将内存的不同区域进行标识:
- 代码区 (.text):存放程序的指令代码,这部分内存需要被执行,所以会被标记为“可执行”。
- 数据区(.data, .bss)和栈(Stack):这些区域用来存放变量、数据和函数调用信息。正常情况下,这些区域的数据只应该被读取和写入,而不应该被当作指令来执行。
NX位的作用就是由操作系统和CPU硬件共同强制执行这个规则。
当NX位功能开启时,如果程序试图跳转到并执行位于栈上或堆上等非执行区域的代码,CPU会直接抛出一个硬件异常,从而阻止这段代码的执行,并通常会使程序崩溃。
ASLR
ASLR的全称是 Address Space Layout Randomization,中文译为“地址空间布局随机化”。它是一种非常重要的计算机安全技术,用于防范内存攻击,如缓冲区溢出、返回导向编程(ROP)等。
简单来说,ASLR就是让程序在每次运行时,其关键部分的内存地址都是随机的,而不是固定的。
具体到操作系统层面,当一个程序启动时,ASLR会随机化以下关键内存区域的基地址:
- 栈 (Stack):存放局部变量和函数调用信息。
- 堆 (Heap):动态分配内存的地方。
- 共享库 (Libraries):程序所依赖的系统函数库(如
libc.so
)。 - 可执行文件本身 (.text)
这样一来,每次程序加载到内存中时,这些区域的地址都会改变。
1 | 查看ASLR状态 |
PIE
定义:
PIE,全称为“位置无关可执行文件”(Position-Independent Executable),是一种特殊编译模式下生成的可执行二进制文件格式。其核心特性是,该文件的程序代码能够在进程虚拟地址空间的任意非固定基地址上加载并正确执行,而无需运行时进行代码重定位。
实现原理:
PIE的实现依赖于编译器和链接器在生成代码时采用特定的寻址模式:
- 相对寻址 (Relative Addressing): PIE程序内部的函数调用、分支跳转和数据访问不使用硬编码的绝对虚拟地址。取而代之的是,它使用基于**指令指针(Instruction Pointer, 在x86-64架构下为RIP)**的相对地址。所有内部目标的地址都被计算为“当前指令地址 + 一个固定的偏移量”。由于代码块内部的相对距离在链接时就已确定,因此无论整个代码块被加载到内存的哪个基地址,这种相对关系都保持不变。
- 间接寻址 (Indirect Addressing): 对于外部符号(如共享库中的函数或全局变量),PIE通过全局偏移表(GOT, Global Offset Table)和过程链接表(PLT, Procedure Linkage Table)等数据结构进行间接寻址。代码首先通过相对地址访问GOT/PLT中的条目,然后动态链接器(dynamic linker)在程序加载时负责将这些条目填充为外部符号的真实绝对地址。
功能与目的:
PIE的主要功能是**使能地址空间布局随机化(ASLR)**对可执行文件本身的应用。标准的ASLR可以随机化栈、堆和共享库的基地址,但对于一个非PIE编译的主程序,其自身的代码段(.text
)和数据段(.data
)通常会被加载到一个固定的、在链接时就已决定的基地址。PIE则消除了这个限制,允许操作系统加载器将整个可执行文件的内存映像放置在每次运行时都不同的随机基地址上。
1 | 编译为PIE程序 |
实际效果演示:
非PIE + ASLR关闭:
1 | 程序总是加载到固定地址:0x400000 |
非PIE + ASLR开启:
1 | 程序加载地址固定:0x400000 |
PIE + ASLR开启:
1 | 程序加载地址随机:0x555555554000 / 0x555555756000 |
Canary
定义:
Canary,也称为“栈保护器”(Stack Protector),是一种由编译器实现的、用于在运行时检测栈缓冲区溢出(Stack Buffer Overflow)的漏洞缓解技术。其核心机制是在函数的返回地址等关键控制数据之前,放置一个攻击者不可预测的秘密值(即Canary值)。
原理机制:
该机制在函数的生命周期内通过以下两个关键阶段实现:
- 函数前序 (Function Prologue): 当一个受此机制保护的函数被调用时,在其栈帧建立的初始阶段,一个特殊的Canary值会从一个安全的、通常为只读的内存区域(例如通过
fs
/gs
段寄存器访问的线程本地存储 Thread-Local Storage)被复制到当前函数的栈帧上。该值被精确地放置在所有局部变量(特别是缓冲区)与保存的旧帧指针(EBP/RBP)及函数返回地址之间。 - 函数尾声 (Function Epilogue): 在函数执行完毕、即将执行返回指令(
ret
)之前,程序会执行一个强制的校验操作。它会从栈帧的相应位置读回Canary值,并将其与存储在安全内存区域中的原始值进行比较。
运行逻辑:
校验成功: 如果栈上的Canary值与原始值完全一致,表明栈帧的控制数据区域未被篡改,函数将继续执行,并正常返回到其调用者。
校验失败: 如果两者不一致,则表明发生了一次缓冲区溢出,攻击者写入的数据已经覆盖了Canary。程序会判定这是一次潜在的攻击行为,将立即停止正常的执行流程,并调用一个预设的异常处理函数(例如,在glibc中为 __stack_chk_fail
)。该函数通常会输出错误信息并立即终止整个进程。
安全目标:
Canary机制的主要安全目标是为栈帧的完整性提供运行时保护。它通过在攻击者必须覆盖的路径上设置一个检查点,确保在被恶意修改的返回地址被CPU使用之前就能检测到内存损坏。通过在检测到篡改后立即终止程序,Canary有效地阻止了基于栈溢出的控制流劫持(Control-Flow Hijacking)攻击。
此机制通过在编译时传递特定标志(例如,GCC/Clang中的 -fstack-protector
或 -fstack-protector-all
)来启用。
RELRO
定义:
RELRO,全称为“Relocation Read-Only”(重定位只读),是一种由链接器(linker)和加载器(loader)共同实现的漏洞缓解技术。其核心功能是在程序启动后,将部分包含重定位信息的内存区域的权限设置为只读,以防止攻击者在运行时篡改这些区域。
针对的安全威胁:
在没有RELRO保护的情况下,一些存储着函数指针或数据指针的内存段在整个程序运行期间都是可写的。攻击者可利用内存损坏漏洞覆写这些指针,特别是**全局偏移表(GOT, Global Offset Table)**中的条目,以劫持程序控制流。这种攻击通常被称为“GOT Overwrite”攻击。RELRO旨在消除或减小此类攻击的可行性。
实现原理和级别:
RELRO机制分为两种级别:Partial RELRO
和Full RELRO
。
- Partial RELRO (部分RELRO)
- 启用方式: 通过链接器标志
-z relro
启用。 - 工作机制: 链接器会重排ELF文件的内部段,并将
.ctors
、.dtors
、.got
(非PLT部分) 等数据段标记为在动态链接器处理完其初始重定位后,应由加载器(loader)将其权限设置为只读。 - 局限性: 在Partial RELRO模式下,用于动态函数解析的
.got.plt
段仍然保持可写状态,以支持延迟绑定(Lazy Binding)。延迟绑定是一种性能优化,即函数地址只在其第一次被调用时才由动态链接器进行解析和填充。由于.got.plt
保持可写,攻击者仍然有机会篡改这部分GOT条目。
- 启用方式: 通过链接器标志
- Full RELRO (完全RELRO)
- 启用方式: 通过链接器标志
-z relro -z now
启用。 - 工作机制:
-z now
标志会强制禁用延迟绑定。它要求动态链接器在程序启动时,就必须解析所有动态链接符号,而不是在函数第一次被调用时才解析。 - 安全优势: 由于所有重定位工作都在加载时一次性完成,整个全局偏移表(GOT)在程序开始执行其主逻辑之前,就可以被内存管理器(例如通过
mprotect
系统调用)设置为完全只读。 - 结果: 这从根本上杜绝了运行时对GOT表的任何修改尝试,能完全有效地防御GOT Overwrite攻击。其代价是可能会稍微增加程序的启动时间。
- 启用方式: 通过链接器标志
安全目标:
RELRO的主要安全目标是缩减程序的攻击面(Attack Surface)。通过将动态链接过程完成后不再需要写入的内存区域(特别是GOT)的权限从“可读可写”收紧为“只读”,来防止针对这些关键数据结构的内存覆写攻击。Full RELRO提供了当前最高级别的GOT保护,是现代安全编译选项中的重要组成部分。
ROP(Return Oriented Programming)
传统的漏洞利用(比如栈溢出)可以直接在栈上写入 shellcode 并跳过去执行。
但现代系统有 DEP(Data Execution Prevention)/NX(No eXecute) 保护 —— 栈、堆不能执行代码。
这样,攻击者就不能随便在栈上放一段 shellcode 来运行。那么就要使用到ROP去进行攻击。
ROP的核心思想
既然不能执行栈上的新代码,那攻击者就 利用二进制程序或库里已经存在的代码片段。
- 这些片段通常以
ret
指令结尾 - 攻击者把这些片段的地址按顺序放到栈上
- 当函数返回 (
ret
) 时,程序跳到攻击者指定的片段 - 一个片段执行一点操作,再
ret
,跳到下一个片段
这些片段叫 gadgets。
组合起来,就像用乐高积木拼出任意功能 —— 这就是 ROP。
系统调用
什么是系统调用?
用户程序要访问内核资源(文件、网络、进程等)时,不能直接操作,只能通过 系统调用 (syscall)。
二进制里系统调用的方式
- 库函数封装
调用write()
、open()
这样的函数时,glibc 内部最终会触发syscall
。 - 直接调用指令
汇编里直接写syscall
(x86-64)、int 0x80
(x86)、svc #0
(ARM)、ecall
(RISC-V)。
参数传递规则 (Linux x86-64)
rax
= 系统调用号rdi, rsi, rdx, r10, r8, r9
= 参数- 返回值放在
rax
实例
C代码:
1 | write(1, "Hi\n", 3); |
反汇编后核心就是:
1 | mov rax, 1 ; sys_write |
ROP整个过程的分析
在程序(32位)中没有已存在的一段代码是:
1 | mov eax, 0xb |
我们仍要执行execve("/bin/sh",NULL,NULL)
该怎么做呢?使用ROP!!!
ROP原理图[2]
1、栈溢出覆盖返回地址
前提:这里到了stack overflow;ret;
,栈溢出然后覆盖返回地址。
下面开始执行汇编代码,执行到了ret;
这里,ret
,其实其本质就是pop eip
,即将栈里面的返回地址,存放到EIP
寄存器中,EIP
中负责存放下一个执行的指令的地址,然后esp
向上移动。
所以,这里就是把返回地址0x08052318
地址存放在寄存器EIP
中,然后下一个就是执行这个EIP
寄存器中的地址0x08052318
的位置。
这个0x08052318
地址存放的指令为pop %edx; ret;
。
同样的执行,先执行pop %edx
,esp
向上移动,将0x0c0c0c
存放到寄存器edx
中。然后再执行ret
命令,同样esp
上移,然后,将0x0809951f
地址存放到寄存器EIP
中,作为下一个执行的指令的地址。
2、返回地址指向第一个gadget
接着前面说的,这里的0x0809951f
地址已经存放在了EIP
中,然后执行EIP
寄存器中地址的指令,即这里的xor %eax,%eax; ret
这条指令。
先执行xor %eax,%eax
,这个是表示%eax = %eax xor %eax
,这里的计算的结果其实就是0
,将自身的异或的结果存在eax
寄存器中。然后再执行ret
,esp
再次上移,将0x080788c1
存放在EIP
寄存器中等待下一个执行。
3、但会地址指向第二个gadget
紧接着上面,EIP
寄存器中现在存的地址为0x080788c1
,然后调到地址0x080788c1
去执行该处的指令mov %eax, (%edx); ret
,将edx
寄存器中的值给eax
寄存器。然后执行ret
指令,esp
上移,将0x41414141
地址存放到EIP
寄存器中等待下一步的执行。
4、结束ROP
最后就是全部一整个流程执行完成了。
ROP流程图
ret2syscall案例
查看文件的安全防护
首先是ret2syscall
查看可执行文件的安全防护情况。
canary
未启用,可以进行栈溢出,NX
开启了,栈不可执行,不能直接栈上写入shellcode
。我的服务器,ASLR
是开启了的,栈的基地址是随机的,所以这里写shellcode
在栈上也是不行的,找不到写的shellcode
的地址,PIE
未启用。
对返回地址进行覆盖
使用IDA Pro进行反编译查看,使用了gets
函数,int v4
变量,存在栈溢出。
使用gdb查看覆盖需要填充的字符。这里发现在get
输入一个正常的字符串以后是上移了0x1c
即上移了(16+12) * 4 =112
字节。此时,如果填充字符超过这个字节数量,就会覆盖函数调用的返回地址。
构造gadgets
根据安全防护,这里只能使用到rop去进行攻击。
rop需要去执行的一段代码为execve("/bin/sh",NULL,NULL)
,对应的汇编代码为:
1 | mov eax, 0xb |
使用ROPgadget
去寻找gadget
的地址。
1 | ROPgadget --binary ret2syscall --only "pop|ret" |
第1个gadget
首先找到第1个gadget
,给eax寄存器赋值。
1 | 0x080bb196 : pop eax ; ret |
第2个gadget
找到了第2个gadget
,分别给edx、ecx、ebx
寄存器赋值。
1 | 0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret |
找对应的寄存器中的值,即0、0、/bin/sh
。
使用IDA Pro进行反编译,找打/bin/sh
字符串对应的地址为:
1 | .rodata:080BE408 aBinSh db '/bin/sh',0 ; DATA XREF: .data:shell↓o |
或者使用:
1 | ROPgadget --binary ret2syscall --string "/bin/sh" |
第3个gadget
最后一个要int 0x80
,进行系统的调用。
1 | 0x08049421 : int 0x80 |
总结利用
根据上面的3个gadget,可以构造下面的溢出的内容。
我们再来正向解释一下:
首先,是栈溢出覆盖了函数的返回地址,返回地址pop_eax_ret addr
的内容为0x080bb196
,该地址的指令为pop eax ; ret
,这是第1个gadget,将0xb
的存储到寄存器eax
中,再执行ret
,将pop_edx_ecx_ebx_ret addr
即0x0806eb90
给到EIP
寄存器,即下一条要执行的命令。然后执行 0x0806eb90
地址处的指令pop edx ; pop ecx ; pop ebx ; ret
,这是第2个gadget,将0、0、["/bin/sh"]
分别存放在寄存器edx、ecx、ebx
中,再执行ret
,将int_0x80_addr
即0x08049421
给到EIP
寄存器,即下一条要执行的命令。执行int 0x80
,这是第3个gadget。
编写脚本exp.py
1 | from pwn import * |
成功pwn!