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_buf和binder_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就有机会插进来。这种“时间窗口”漏洞,最难防。
14.2 Slub分配器:UAF的“帮凶”
聊完并发,咱们得说说Slub分配器。Slub是Linux内核里管理小块内存的分配器。Binder的buffer,就是通过Slub分配的。
为什么说Slub是“帮凶”?因为Slub有一个特性:内存复用。一块内存被释放后,Slub不会把它清空,而是把它放回空闲链表。下次再有人申请同样大小的内存,Slub就直接把这块内存给它了。
这就给了攻击者一个机会:堆喷(Heap Spray)。
攻击者可以大量申请和释放特定大小的内存,让Slub把目标内存块“喷”成自己想要的数据。然后,再触发那个UAF漏洞,让受害者线程去读取已经被篡改的内存。
我画了一张图,帮你理解这个过程:
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指针,实现任意地址读写。
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_lock和mutex_lock混用,导致了一个隐蔽的死锁。虽然那不是UAF,但同样说明了并发编程的复杂性。
嗯,最后说一句。Binder UAF这个课题,水很深。今天咱们聊的只是冰山一角。真正在实战中,你还会遇到各种奇奇怪怪的变种。比如,利用epoll和ioctl的组合来制造竞争窗口。但万变不离其宗,核心就是:并发 + 内存复用。抓住这两点,你就能看穿大部分Binder UAF漏洞的本质。