第四章 ARM下的堆调试技巧:GDB、heapinfo与堆块追踪

做ARM堆利用,调试工具就是你的眼睛。x86上有成熟的pwndbg、peda,但到了ARM平台,很多插件直接罢工。我刚开始转ARM pwn时,就吃过这个亏——明明感觉漏洞触发了,但堆块状态完全看不清。

这一章,我把自己踩过的坑和总结的方法都摊开来讲。你会学到:怎么在ARM上用GDB看堆,怎么让pwndbg/peda在ARM上干活,以及怎么手动追踪堆块的状态变化。

4.1 ARM GDB查看堆的基本操作

先别急着上插件。裸GDB也能干不少活,关键是知道看哪里。

4.1.1 查看堆基址与arena

ARM Linux的堆管理基于glibc的ptmalloc。每个线程有自己的arena,主线程的arena就是main_arena。我一般这样定位:

(gdb) info proc mappings
...
0xb6f00000 0xb6f01000 rw-p   [heap]
...
(gdb) p &main_arena
$1 = (struct malloc_state *) 0xb6f00010

拿到main_arena地址后,就能看它的内部结构了:

(gdb) p main_arena
$2 = {mutex = 0, flags = 1, have_fastchunks = 0, 
      fastbinsY = {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
      top = 0xb6f00a80, last_remainder = 0x0, 
      bins = {0xb6f00010, 0xb6f00010, ...}, ...}
小技巧: 如果p &main_arena报错,试试p (struct malloc_state)*0xb6f00010直接强转。ARM上符号表有时不完整,得手动来。

4.1.2 查看堆块元数据

每个堆块前面有8字节的chunk header(32位ARM下)。结构很简单:

chunk -> +--------+
         | prev_size|  (4 bytes, 前一块大小)
         +--------+
         | size     |  (4 bytes, 本块大小 & 标志位)
         +--------+
         | user data|
         +--------+

举个例子,假设分配了一个0x40大小的块:

(gdb) x/4wx 0xb6f00800
0xb6f00800:	0x00000000	0x00000041	0x41414141	0x41414141

这里0x41就是size,最低3位是标志位:

  • PREV_INUSE (0x1):前一块正在使用
  • IS_MMAPPED (0x2):mmap分配
  • NON_MAIN_ARENA (0x4):非主线程arena

所以0x41 = 0x40 | 0x01,表示块大小0x40,前一块在用。

注意: ARM上堆块对齐是8字节,但size字段始终是16的倍数(因为最低4位被标志位占用)。我见过有人把size=0x40当成0x40字节用户区,其实用户区只有0x38字节——因为header占了8字节。

4.2 heapinfo命令与手动计算

pwndbg的heapinfo在ARM上经常失灵。为什么?因为插件硬编码了x86的寄存器名和结构体偏移。我建议你学会手动计算,再配合半自动脚本。

4.2.1 手动遍历fastbin

fastbin是单链表,头指针在main_arena.fastbinsY[i]里。每个bin对应一个size范围:

fastbin索引 size范围 实际块大小
0 0x10 - 0x18 0x10, 0x18
1 0x20 - 0x28 0x20, 0x28
2 0x30 - 0x38 0x30, 0x38
3 0x40 - 0x48 0x40, 0x48
4 0x50 - 0x58 0x50, 0x58
5 0x60 - 0x68 0x60, 0x68
6 0x70 - 0x78 0x70, 0x78
7 0x80 - 0x88 0x80, 0x88

查看fastbin[2](0x30大小)的链表:

(gdb) p main_arena.fastbinsY[2]
$3 = (mfastbinptr) 0xb6f00920
(gdb) x/gx 0xb6f00920
0xb6f00920:	0x00000000 0x00000031  // 第一个块,fd=0(链表尾)
(gdb) x/gx 0xb6f00920+8
0xb6f00928:	0x00000000              // fd指针为NULL

如果fd不为0,就继续往下跟。这就是fastbin的遍历方式。

4.2.2 查看unsorted bin

unsorted bin是双向链表,通过bins[0]和bins[1]访问。我习惯这样:

(gdb) p main_arena.bins[0]
$4 = 0xb6f00010
(gdb) p main_arena.bins[1]
$5 = 0xb6f00010

如果bins[0] == bins[1] == &main_arena.bins[0] - 0x10,说明链表为空。否则,bins[0]是第一个空闲块的fd,bins[1]是最后一个块的bk。

核心经验: 我写了个ARM专用的GDB脚本,把heapinfo的核心逻辑用纯Python重写了。核心就是:读main_arena偏移,然后按链表遍历。你可以在.gdbinit里加个armheap命令,省得每次手动算。

4.3 pwndbg/peda在ARM上的适配

说实话,pwndbg和peda对ARM的支持都是半成品。我试过好几个版本,总结出一套兼容方案。

4.3.1 常见问题与修复

问题 原因 修复方法
heapinfo报错 硬编码了x86寄存器名 修改pwndbg/commands/heap.py,把sp换成r13
chunk命令显示错误 结构体偏移不对 手动指定main_arena地址:set $arena=0xb6f00010
find_fake_fast失效 地址计算用了x86的页大小 ARM页大小是4KB,改page_size常量
peda的heap命令 不支持ARM的堆结构 建议直接用pwndbg,peda放弃ARM

4.3.2 我的推荐方案

我个人习惯用pwndbg + 手动补丁。具体步骤:

  1. 安装pwndbg(git clone最新版)
  2. 修改pwndbg/glibc.py,把main_arena的查找逻辑改成从info proc mappings的heap段偏移计算
  3. 写一个.gdbinit,启动时自动设置ARM架构:set architecture arm
  4. 加一个自定义命令armheap,封装手动遍历逻辑
避坑指南: 我曾经在ARM64上直接用了x86的pwndbg,结果heapinfo把堆块大小全读反了。后来发现是大小端问题——ARM默认小端,但pwndbg的某些代码假设了大端。加一行set endian little就解决了。

4.4 追踪堆块状态变化

调试堆利用,核心就是看堆块怎么从allocated变成freed,又怎么被重新分配。我一般用断点+条件打印来追踪。

4.4.1 设置malloc/free断点

(gdb) b *malloc
(gdb) commands
> printf "malloc(%zu) called\n", $r0
> continue
> end
(gdb) b *free
(gdb) commands
> printf "free(%p) called\n", $r0
> continue
> end

ARM上,参数通过r0-r3传递。所以$r0就是malloc的size或free的指针。

4.4.2 记录堆块生命周期

我写了个小脚本,每次malloc/free时记录堆块地址和状态:

define logheap
    set $logfile = fopen("/tmp/heap.log", "a")
    if $argc == 2
        fprintf($logfile, "%s %p size=%zu\n", $arg0, $arg1, $arg2)
    else
        fprintf($logfile, "%s %p\n", $arg0, $arg1)
    end
    fclose($logfile)
end

然后在断点里调用:

(gdb) b *malloc+4
(gdb) commands
> logheap "malloc" $r0
> continue
> end

4.4.3 实战:追踪double free

假设我们怀疑一个double free漏洞。流程是这样的:

  1. 第一次free(ptr)后,堆块进入fastbin,fd指向NULL
  2. 第二次free(ptr)后,堆块再次进入fastbin,fd指向自己(形成循环)
  3. 两次malloc相同大小,会拿到同一个堆块

用GDB验证:

# 第一次free后
(gdb) x/2wx ptr-8
0xb6f00920:	0x00000000	0x00000031  # prev_size=0, size=0x31
(gdb) x/gx ptr
0xb6f00928:	0x00000000              # fd=NULL

# 第二次free后(double free)
(gdb) x/2wx ptr-8
0xb6f00920:	0x00000000	0x00000031
(gdb) x/gx ptr
0xb6f00928:	0xb6f00928              # fd指向自己!

看到fd == ptr,基本就实锤double free了。

重要: ARM上double free的检测机制和x86一样,但glibc版本不同行为有差异。比如glibc 2.26之后加了tcache,double free在tcache里可能不会立即崩溃,但fastbin里会。我建议你调试前先确认目标设备的glibc版本:ldd --version。

4.5 本章知识体系

下面这张图总结了ARM堆调试的核心流程和工具链:

ARM堆调试知识体系 ARM堆调试知识体系 调试目标 查看堆块元数据 遍历bin链表 追踪状态变化 方法 x/wx查看chunk header 解析size标志位 方法 手动遍历fastbin/unsorted 自定义armheap命令 方法 malloc/free断点+log 对比fd指针变化 裸GDB pwndbg(适配后) 自定义GDB脚本 常见场景 double free检测 | UAF验证 | 堆溢出定位 | tcache/fastbin混淆 输出:堆块状态报告 核心原则:先裸GDB确认基础信息,再上插件提升效率,最后用脚本固化流程

4.6 本章小结

ARM堆调试没有x86那么舒服,但掌握了手动方法后,反而更踏实。你想想看,插件帮你做的事,你自己也能做,那还怕什么?

几个关键点再强调一下:

  • 裸GDB是底线——x/wx、p main_arena、info proc mappings这三板斧必须熟练
  • 插件要适配——pwndbg能用,但得改几行代码。别指望开箱即用
  • 追踪靠断点——malloc/free断点+条件打印,比任何可视化工具都可靠
  • 日志是王道——把堆块状态变化写到文件里,回头分析时一目了然

嗯,这一章就到这。调试工具是基础,但真正的高手,是用工具的人,不是被工具用的人。