第二章:ARM下的内存布局与堆管理基础

做ARM逆向Pwn,说白了第一件事就是搞清楚目标进程在内存里是怎么躺着的。我见过不少新手,一上来就盯着漏洞代码猛看,结果连chunk在哪分配的都没搞明白——嗯,这其实是在给自己挖坑。

这一章我们就把地基打牢。ARM Linux进程的内存布局、brk/sbrk怎么玩、glibc的ptmalloc2在ARM上有什么“小脾气”,还有最核心的chunk结构体。这些东西你搞透了,后面堆利用的招数才能耍得开。

2.1 ARM Linux进程内存布局

一个ARM Linux进程跑起来,虚拟地址空间长什么样?我直接画了张图,你一看就懂。

核心要点:ARM Linux进程的虚拟地址空间从低到高依次为:代码段(.text) → 数据段(.data/.bss) → 堆(heap) → 内存映射段(mmap) → 栈(stack) → 内核空间。堆和栈相向生长,中间是巨大的空洞区域。

ARM Linux 进程虚拟地址空间布局 内核空间 (0xC0000000 - 0xFFFFFFFF) 栈 (Stack) — 向下生长 局部变量、函数调用帧、返回地址 ▼ 空洞区域(可被mmap映射占用)▼ 内存映射段 (mmap) 共享库、大块内存映射、匿名映射 ▲ 空洞区域 ▲ 堆 (Heap) — 向上生长 brk/sbrk管理,ptmalloc2分配器 数据段 (.data / .bss) 代码段 (.text) — 只读 0x00008000 0x00010000 0xC0000000 堆↑ 栈↓

这张图你得刻在脑子里。堆从数据段上方开始,向上生长;栈从内核空间下方开始,向下生长。两者之间的空洞区域,就是mmap系统调用的地盘——大块内存分配、共享库加载都走这里。

我的经验:在ARM32上,堆的起始地址通常在0x00020000附近,而栈顶大约在0xBFFFFFFF。ARM64的地址空间更大,但布局逻辑完全一样。我调试过一个IoT设备上的堆溢出漏洞,就是因为没注意堆和mmap区域之间的空洞被占用了,导致利用偏移算错了——白忙活一整天。

2.2 brk/sbrk系统调用

堆是怎么长大的?靠的是brk和sbrk这两个系统调用。说白了,它们就是调整进程数据段末尾(program break)位置的工具。

系统调用 原型 行为
brk int brk(void *addr) 直接将program break设置为addr指定的地址。成功返回0,失败返回-1。
sbrk void *sbrk(intptr_t increment) 在原有program break基础上增加increment字节。返回调整前的program break地址。

你想想看,malloc(4)这种小分配,底层其实就是在堆上划一小块内存给你。如果堆空间不够了,就调sbrk往上涨一截。但这里有个坑——brk/sbrk只能线性扩展,不能回收中间的空洞。所以glibc的ptmalloc2才搞出了bins和chunk复用机制。

我曾经踩过的坑:在ARM32上,brk的地址必须按页对齐(4KB)。如果你试图设置一个非对齐地址,内核会直接给你对齐到下一页。这会导致你算好的堆布局偏移全部失效。所以,永远不要假设brk返回的地址是连续的——中间可能有页对齐的间隙。

2.3 glibc ptmalloc2在ARM上的实现差异

ptmalloc2是glibc默认的堆分配器。x86和ARM上它的核心逻辑一样,但ARM有一些“小脾气”。我列个表,你对比着看:

特性 x86 (32位) ARM (32位) ARM64
指针大小 4字节 4字节 8字节
chunk最小大小 16字节 16字节 32字节
chunk对齐 8字节 8字节 16字节
PREV_INUSE位 第0位 第0位 第0位
fastbin最大大小 64字节 64字节 128字节
tcache (glibc ≥2.26) 支持 支持 支持

注意看对齐这一行。ARM64要求chunk按16字节对齐,这意味着你伪造chunk size时,低4位必须为0。很多CTF题目就是利用这个对齐检查来卡你的payload——我见过有人因为没注意ARM64的对齐,在house of force里栽了跟头。

ARM上的特殊之处:

  • 原子操作差异:ARM使用LDREX/STREX指令对实现原子操作,而x86用LOCK前缀。这在多线程堆操作中会影响性能,但对我们Pwn来说,只要知道ptmalloc2在ARM上也是线程安全的就行。
  • 系统调用开销:ARM的brk/sbrk系统调用比x86慢一些(上下文切换代价更高),所以ptmalloc2在ARM上更倾向于复用chunk而不是频繁扩展堆。
  • 没有SSE对齐要求:x86上有些SIMD指令要求16字节对齐,但ARM没有这个限制。不过ptmalloc2在ARM64上仍然强制16字节对齐——这是为了兼容性。

2.4 chunk结构体

chunk是堆管理的核心单元。你每次malloc返回的指针,其实指向的是chunk的数据区,而不是chunk头部。这个区别太重要了——我再说一遍:malloc返回的是chunk + 16(ARM32)或chunk + 32(ARM64)的位置。

一个chunk的结构长这样:

// ARM32 上的chunk结构(8字节对齐)
// 假设当前chunk地址为 p
//
// 地址偏移    字段          说明
// p + 0       prev_size     前一个chunk的大小(如果前一个chunk空闲)
// p + 4       size          当前chunk的大小(含头部,低3位为标志位)
// p + 8       fd            (仅空闲chunk)下一个空闲chunk
// p + 12      bk            (仅空闲chunk)上一个空闲chunk
// p + 16      data          用户数据区起始(malloc返回的地址)
//
// 标志位(在size的低3位):
// bit 0: PREV_INUSE — 前一个chunk是否在使用中
// bit 1: IS_MMAPPED — 是否由mmap分配
// bit 2: NON_MAIN_ARENA — 是否属于非主分配区

我个人习惯:在调试堆漏洞时,我总会在GDB里用p/x *(struct malloc_chunk*)addr来查看chunk头部。但更快的办法是直接看size字段的低3位——一眼就能判断chunk的状态。比如size=0x91,低3位是001,说明PREV_INUSE=1,前一个chunk正在使用。

chunk有两种状态:已分配和空闲。它们的结构略有不同:

字段 已分配chunk 空闲chunk
prev_size 有效(如果前一个chunk空闲) 有效
size 有效,PREV_INUSE位通常为1 有效,PREV_INUSE位为0
fd 用户数据(无意义) 指向下一个空闲chunk
bk 用户数据(无意义) 指向上一个空闲chunk
data 用户数据 无意义(但可被覆盖)

这里有个关键点:chunk复用。当一个chunk被释放后,它的数据区(原本用户数据的位置)被用来存放fd和bk指针。这就是很多堆利用技术的根基——比如unlink攻击,就是通过伪造空闲chunk的fd和bk,让glibc在合并chunk时把目标地址写进去。

避坑指南:我曾经在分析一个ARM64的堆溢出题目时,忽略了chunk对齐要求。我伪造的size是0x81,但ARM64要求16字节对齐,所以实际有效size是0x80。结果我的payload偏移全算错了,调试了三个小时才发现——嗯,从那以后我每次都会用(size & 0xF) == 0来检查对齐。

最后,给你一个快速记忆表:

架构 chunk头部大小 最小chunk大小 对齐要求 malloc返回偏移
ARM32 8字节 16字节 8字节 chunk + 8
ARM64 16字节 32字节 16字节 chunk + 16

记住这些基础,后面讲fastbin attack、unsorted bin attack、house of系列的时候,你才不会晕头转向。堆利用说白了就是跟chunk结构体玩游戏——你越熟悉它的规则,就越能写出漂亮的exploit。