前言:
在现代操作系统中,单纯的栈溢出已经很难直接奏效。编译器和系统引入了多种保护机制,其中最与栈溢出相关的就是 Canary 和 PIE/ASLR
对于一个elf文件我们可以 checksec 来查看它用了什么保护
Canary (金丝雀) 保护绕过
Canary 是一种专门针对连续覆盖类型栈溢出的保护机制。它会在函数的 rbp/ebp 之前插入一个随机生成的加密数值(金丝雀)。在函数执行完毕准备 ret 返回前,程序会检查这个值是否被篡改。如果被破坏,通常会输出 *** stack smashing detected *** 并强制终止程序。
canary位置:
1 | 高地址 (栈底) |
Canary 的设计有一个非常关键的特征:它的最低位(第一个字节)永远是 \x00。这是为了防止 puts、printf 等字符串输出函数不小心把它打印出来(因为这些函数遇到 \x00 就会当作字符串结尾停止输出)。
绕过手法一:覆盖最低位泄露 (Leak)
既然输出函数(如puts,printf,fprintf)遇到 \x00 就停,那我们就利用溢出,精准填满缓冲区,并把 Canary 最低位的 \x00 覆盖掉(比如用字符 ‘a’)。这样一来,字符串就没有了结束符,输出函数就会顺藤摸瓜,连带着把剩下的 7 个字节的 Canary 全部打印出来!
核心 Payload 逻辑:
1 | from pwn import * |
绕过手法二:逐字节爆破 (Brute-force)
这种手法通常用于使用了 fork() 处理并发的网络服务进程。 核心原理: Canary 在主进程启动时确定,只生成一次。子进程通过 fork() 拷贝父进程时,Canary 的值是完全一样的。如果子进程崩溃(检测到 smashing),父进程依然存活并能派生新的子进程,这为我们提供了无限次尝试的机会。
爆破逻辑:
已知最低位是
\x00。从次低位开始,每次只覆盖一个字节,从
0x00尝试到0xff(共 256 次)。如果程序返回了
smashing报错,说明猜错了;如果没有报错,说明猜对了当前字节。固定该字节,继续爆破下一个字节。
1 | #canary 检测到一般会输出*** stack smashing detected *** 是固定的提示。所以可以通过爆破来获取canary |
PIE / ASLR (地址随机化)
PIE(Position-Independent Executable)使得程序每次加载的基地址都不同,配合系统的 ASLR(地址空间布局随机化),让硬编码地址的 ROP 链全部失效。
核心破局点:页对齐原理 (Page Alignment)
无论是主程序还是 libc.so,加载到内存时都必须严格遵循页对齐。在 Linux 中,页的大小通常是 4KB (即 0x1000 字节)。
绝对铁律: 加载基址的低 12 位(二进制最低 12 bit,即十六进制的最后三位数)始终为 0。
这意味着,一个地址的低 12 位(即页内偏移)是永远固定的,无论 ASLR 怎么变,它都不会参与随机化
对于64位Linux:
1 | [ 47 .................. 12 ][ 11 .......... 0 ] |
bit[0..11] (低 12 位):页内偏移,永远是固定的(页大小 4KB)。
bit[12..47]:页号,会随着 ASLR 随机化。
bit[48..63]:在 Linux 下一般是符号扩展(因为有效地址只有 48 位)。
对于32位Linux:
1 | [ 31 .................. 12 ][ 11 .......... 0 ] |
bit[0..11] → 页内偏移,固定。
bit[12..31] → 页号,会被 ASLR 随机化。
绕过手法一:部分覆盖 (Partial Write)
当我们没有任何漏洞可以泄露地址时,可以利用“页内偏移固定”的特性。我们不覆盖整个 8 字节的返回地址(64位linux),而是只覆盖返回地址的最末尾 1 到 2 个字节。
如果只覆盖最低 1 字节(8 bits),必定 100% 成功,因为低 12 位是绝对不变的。
如果覆盖最低 2 字节(16 bits),会有 4 个 bit (16 - 12 = 4) 受到 ASLR 的影响,成功率通常是 $1/2^4 = 1/16$,可以结合脚本循环爆破。
只要你的目标函数(比如后门 get_shell)和栈上原本要返回的函数在同一个代码页内(相对距离非常近,只有末尾 1~2 个字节不同),你就可以用这种手法,把 ASLR 变成摆设
实战示例 (单字节稳定篡改):
1 | # 原本的返回地址可能是 0x55xxxxxx1234,目标指令地址在 0x55xxxxxx1289 |
绕过手法二:泄露并计算基址
最常规、最稳妥的打法:利用输出函数(如 puts、write)打印出内存中某个已知的绝对地址,然后减去其固定偏移,逆向算出内存加载基址。
细节避坑: 在发送 padding 准备泄露地址时,务必使用 send() 而不是 sendline()。因为 sendline() 会在末尾追加一个换行符 \n (0x0a),这会污染目标地址的最末位,导致泄露失败。
例题:basectf 2024 pie
1 | from pwn import * |
RELRO (重定位只读) 的应对策略
在没有 RELRO 或者只有 Partial RELRO 的时代,由于延迟绑定机制的存在,GOT 表(.got.plt 段)必须是可读可写 (rw-) 的。黑客最喜欢的打法就是GOT 表劫持:利用任意地址写漏洞,把 elf.got['puts'] 里的地址改成 system 的地址。下次程序再调用 puts("/bin/sh") 时,实际上执行的就是 system("/bin/sh")。
为了封杀这种极其好用的打法,RELRO (Relocation Read-Only) 保护应运而生。
保护级别的差异:Partial vs Full
使用 checksec 命令时,你会看到 RELRO 有两种状态:
1. Partial RELRO (部分保护)
现状: 仅仅把
.dynamic等一些内部段设为只读。但最重要的.got.plt(也就是我们常说的 GOT 表)依然是可写的!应对策略: 毫无影响。该怎么劫持 GOT 表就怎么劫持,直接把 GOT 表项的地址当作你的写入目标即可。
2. Full RELRO (完全保护)
现状: 程序在启动的瞬间,动态链接器会强制把所有外部函数的真实地址全部查出来并填好(放弃了延迟绑定带来的启动速度优势)。填完之后,立刻把整个 GOT 表的权限改成“只读 (
r--)”!影响: 一旦开启,GOT 表劫持彻底宣告死亡。如果你用 Payload 强行往 GOT 表地址里写数据,程序会当场抛出段错误 (Segmentation Fault) 并崩溃。
突破 Full RELRO:寻找“替身”指针
既然 GOT 表被锁进了保险箱,我们就不能死磕了。我们的核心思路是:在内存中寻找其他依然“可读可写”,并且“必定会被程序调用”的函数指针!
其中最经典、最好用的替身,就藏在 libc.so 里:
替身一:劫持 Hook 函数 (__malloc_hook / __free_hook)
在 Linux 的标准 C 库 (glibc) 中,为了方便程序员调试内存泄漏,开发者预留了几个钩子(Hook)变量。它们存放在 libc 的可读写数据段(.data 或 .bss)中。
工作原理: 当程序调用 malloc() 时,底层会先去检查 __malloc_hook 这个变量里有没有存放地址。如果有,它就会先去执行这个地址里的代码;free() 和 __free_hook 同理。
利用手法: 我们通过漏洞(比如格式化字符串或堆溢出),把 system 的地址或者 one_gadget 的地址,写进 __free_hook 中。接下来,只要程序代码里执行了 free(chunk),并且 chunk 里面刚好装着字符串 /bin/sh,就完美等价于执行了 system("/bin/sh")!
Pwntools 实战寻址:
1 | # 前提:你已经通过漏洞泄露并计算出了 libc_base |
替身二:劫持退出函数 (__exit_funcs / tls_dtor_list)
如果程序没有任何漏洞可以触发 malloc 或 free 怎么办?只要程序最终会调用 exit() 正常退出,或者从 main 函数 return,它就会去执行注册在 __exit_funcs 列表里的收尾函数。
利用手法: 找到这个列表的指针,将其覆盖为我们要执行的后门地址。当程序“自以为”在正常退出时,实际上却启动了我们的 Shell。
替身三:既然不让改指针,那就改栈!(栈迁移)
如果题目既开启了 Full RELRO,又是极其严苛的栈溢出(比如溢出长度只有区区 8 个字节,刚够覆盖 rbp 和 ret,根本写不下一条完整的 ROP 链),而且也没有任意地址写漏洞去改 Hook 怎么办?
这时候,我们就彻底放弃“修改已有的函数指针”,转而劫持 RSP(栈顶指针),把整个栈强行搬到我们提前布置好数据的 BSS 段或堆上去执行
详见栈迁移
- 本文链接: http://example.com/2026/03/13/PWN/pwn_attack/stack_overflow/安全保护机制与绕过策略/
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
欢迎关注我的其它发布渠道