14、Binder Use-After-Free深入:binder_free_buf与binder_transaction并发、slub分配器与堆喷

好,咱们今天聊点硬核的。Binder的Use-After-Free,简称UAF。这玩意儿在Android漏洞历史上,可以说是“老演员”了。但你别看它老,每次出场都能搞出大新闻。我当年在分析某个OEM的内核提权漏洞时,就亲手逮到过一个类似的并发UAF,那感觉,就像在乱糟糟的代码堆里找到了一根金线——兴奋,但也后怕。

说白了,UAF就是“用后释放”。你申请了一块内存,用完了,释放了。但你的代码里,还有个指针傻乎乎地指着那块已经还给系统的内存。这时候,如果攻击者能控制那块内存里写什么,那你的程序就危险了。在Binder里,这个场景尤其致命。

14.1 并发场景:binder_free_buf vs binder_transaction

为什么会发生UAF?核心原因就一个:并发。Binder驱动里,binder_free_bufbinder_transaction这两个函数,如果跑在不同的CPU核心上,就可能打架。

你想想看,一个线程正在执行binder_transaction,它从一块buffer里读取数据。与此同时,另一个线程调用了binder_free_buf,把同一块buffer给释放了。第一个线程手里的指针,就成了悬空指针。它再往下读,读到的就是不可控的数据。

我举个例子,简化后的流程:

// 线程A:binder_transaction
struct binder_buffer *buffer = ...; // 获取一个buffer
void *data = buffer->data;          // 拿到数据指针
// ... 做一些处理 ...
// 此时,线程B可能已经释放了这个buffer!
process_data(data); // UAF!data指向的内存可能已被篡改

// 线程B:binder_free_buf
struct binder_buffer *buffer = ...; // 同一个buffer
binder_free_buffer(buffer);         // 释放buffer
// 释放后,buffer->data指向的内存被归还给slub分配器

嗯,这里要注意。Binder驱动里其实有锁,但问题出在锁的粒度太粗,或者某些路径上锁没覆盖全。我记得有一次,我在看一个补丁,修复的就是binder_transaction里一个提前解锁的问题。解锁后,binder_free_buf就有机会插进来。这种“时间窗口”漏洞,最难防。

⚠️ 避坑指南: 我曾经在调试一个手机重启问题时,发现log里全是Binder的crash。最后定位到,是某个服务在频繁创建和销毁binder buffer,而驱动层没有做好引用计数。所以,不要迷信锁,锁只能降低概率,不能根除并发问题。真正的解法是:在释放后,把指针置NULL,并且所有访问前都做判空。

14.2 Slub分配器:UAF的“帮凶”

聊完并发,咱们得说说Slub分配器。Slub是Linux内核里管理小块内存的分配器。Binder的buffer,就是通过Slub分配的。

为什么说Slub是“帮凶”?因为Slub有一个特性:内存复用。一块内存被释放后,Slub不会把它清空,而是把它放回空闲链表。下次再有人申请同样大小的内存,Slub就直接把这块内存给它了。

这就给了攻击者一个机会:堆喷(Heap Spray)。

攻击者可以大量申请和释放特定大小的内存,让Slub把目标内存块“喷”成自己想要的数据。然后,再触发那个UAF漏洞,让受害者线程去读取已经被篡改的内存。

我画了一张图,帮你理解这个过程:

Slub分配器与Binder UAF堆喷流程 1. binder_alloc_buf 从Slub申请一块内存 2. binder_free_buf 释放内存,归还Slub 3. 悬空指针 原指针仍指向该内存 4. 攻击者堆喷 申请大量同大小内存 5. 填充恶意数据 覆盖原buffer内容 6. 触发UAF 读取已被篡改的内存 关键点:Slub复用内存,攻击者通过堆喷控制释放后的内存内容 成功利用后,可实现任意地址读写,最终提权到root

14.3 堆喷(Heap Spray)的实战技巧

堆喷不是瞎喷。你得算准Slub的分配规律。我个人的习惯是,先通过一些信息泄露漏洞,搞清楚目标系统上Slub的freelist布局。然后,再精心构造喷的数据。

常见的堆喷手法有几种:

  • 同大小喷:申请和释放的buffer大小完全一致。这样Slub会优先复用刚释放的内存。
  • 颜色标记:在喷的数据里,加入特定的“魔术字”。UAF触发后,检查读到的数据里有没有这个魔术字,来判断喷是否成功。
  • 占位符:喷的时候,把目标结构体的关键字段(比如函数指针、对象指针)填成攻击者控制的地址。

我举个例子,假设我们要喷一个binder_buffer结构体:

// 目标结构体(简化)
struct binder_buffer {
    struct list_head entry; // 链表节点
    void *data;             // 数据指针
    size_t size;            // 大小
    uint8_t priority;       // 优先级
};

// 攻击者构造的恶意数据
struct fake_buffer {
    struct list_head entry; // 可以填任意值
    void *data;             // 填成攻击者控制的地址,比如0xdeadbeef
    size_t size;            // 填一个很大的值,用于越界读写
    uint8_t priority;       // 填0
};

// 堆喷代码(伪代码)
for (i = 0; i < 1000; i++) {
    // 申请一块和binder_buffer一样大的内存
    buf = kmalloc(sizeof(struct binder_buffer), GFP_KERNEL);
    // 把恶意数据写进去
    memcpy(buf, &fake_buf, sizeof(struct fake_buffer));
    // 释放掉,让Slub把它放回freelist
    kfree(buf);
}

你看,喷完之后,Slub的freelist里全是我们的fake_buffer。这时候,只要触发UAF,让受害者线程以为它读的是正常的binder_buffer,实际上读到的却是我们精心构造的假数据。接下来,就能通过控制data指针,实现任意地址读写。

💡 个人经验: 堆喷的成功率,很大程度上取决于你对Slub分配器的理解。我建议你多看看内核源码里mm/slub.c这个文件。特别是__slab_alloc__slab_free这两个函数。理解了它们,你就能预测Slub的行为,从而设计出更精准的喷。

14.4 防御与检测

说了这么多攻击手法,咱们也得聊聊怎么防。毕竟,作为安全工程师,攻防一体才是正道。

针对Binder UAF,内核社区已经做了不少加固:

防御手段 原理 效果
引用计数 给每个buffer加一个refcount,只有refcount为0时才释放 能有效防止并发释放,但需要开发者正确使用
KASAN 内核地址消毒剂,在内存释放后标记为不可访问 能检测出UAF,但性能开销大,生产环境一般不开启
SLAB_FREELIST_HARDENED 对Slub的freelist指针进行加密 增加堆喷难度,但无法完全阻止
CONFIG_DEBUG_SLAB 开启Slub的调试模式,释放后填充0xbb 调试时有用,生产环境不开启

我个人觉得,最有效的防御还是代码审计。在开发阶段,就严格检查Binder驱动里所有buffer的生命周期。特别是那些跨线程访问的路径,一定要确保锁的正确性。我曾经在review代码时,发现一个开发者把spin_lockmutex_lock混用,导致了一个隐蔽的死锁。虽然那不是UAF,但同样说明了并发编程的复杂性。

嗯,最后说一句。Binder UAF这个课题,水很深。今天咱们聊的只是冰山一角。真正在实战中,你还会遇到各种奇奇怪怪的变种。比如,利用epollioctl的组合来制造竞争窗口。但万变不离其宗,核心就是:并发 + 内存复用。抓住这两点,你就能看穿大部分Binder UAF漏洞的本质。