binder_free_transaction() 进程生命周期释放后使用
本仓库包含针对 Linux 内核 Binder 释放后使用漏洞的内容,该漏洞已由上游提交
f223d27a546c1e1f48d38fd67760e78f068fe8c4 修复:
binder_chain_64468.c — 一个无特权的概念验证程序,可触发该漏洞并让内核自行证明,附带 KASAN 实验室和易受攻击/已修复的差分测试(lab/、run.sh、verify.sh)。exploit.c — 一个针对 x86_64 的自包含本地权限提升程序。使用 gcc -O2 -pthread -o exploit exploit.c 编译,以普通用户身份运行,最终获得 root shell。demo/ — 一个实验室,在未打补丁的内核上启动真实的 Debian 13 用户空间,因此该漏洞利用程序可由目标自身的 gcc 编译,并在其随后接管的那台机器上运行。所有内容均以普通用户身份(uid/gid 1000,无能力,无命名空间)针对未打任何补丁的纯上游内核运行。
警告
此代码有意竞态内核对象生命周期,然后劫持内核控制流。竞态失败会破坏内核堆状态,并可能导致机器崩溃或挂起。请仅在您拥有的隔离、一次性虚拟机中运行。切勿在宿主机上运行。
| 状态 | 声明 |
|---|---|
| 已确认 | 该漏洞真实存在,可从无特权进程触达,且上游修复已将其消除。 |
| 已演示 | 对濒临消亡的 binder_proc 的易受攻击解引用在未打补丁的内核上自然且反复发生,而在已修复内核上从未发生。 |
| 已演示 | 完整的释放后使用,由 KASAN 在未打补丁的内核上报告。 |
| 已演示 | 使用攻击者控制的字节回收已释放的 binder_proc、内核控制流劫持,以及在 x86_64 上从无特权用户提权至 uid 0。 |
| 未声明 | 针对特定厂商或 Android 设备的任何结果。仅测试了下列上游内核,均在 x86_64 上。 |
| 未声明 | 随附的漏洞利用程序无需修改即可针对发行版内核运行。请参阅受影响系统:它需要针对每个内核的偏移量,而且在所调查的每个通用发行版上,binder 设备首先就无法被无特权用户触达。 |
binder_free_transaction() 在 t->lock 下从事务中读取目标进程,释放该锁,然后获取目标进程的内部锁:```c
spin_lock(&t->lock);
target_proc = t->to_proc;
spin_unlock(&t->lock);
if (target_proc) {
binder_inner_proc_lock(target_proc); /* use after free */
没有任何东西能让 `target_proc` 在该间隙中保持存活。一个正在被并行销毁的进程可能在此期间到达 `binder_proc_dec_tmpref() -> kfree()`,因此锁会在已释放的内存上被获取。上游修复方案在仍持有 `t->lock` 的同时固定 `t->to_thread`,这会使所属进程保持存活,直到内部锁被使用并释放。
该漏洞由 **Alice Ryhl** 报告,并由 **Carlos Llamas** 修复,两人均来自 Google。[原始报告](https://lore.kernel.org/all/[email protected]/) 附带了相关的 KASAN 跟踪信息。
### 到达易受攻击的访问点
唯一能够到达*外部* `to_proc` 的调用者是 `binder_send_failed_reply()`,并且它仅在 `t->from` 为 `NULL` 时才遍历到 `t->from_parent`:```c
target_thread = binder_get_txn_from_and_acq_inner(t);
if (target_thread) { ...; binder_free_transaction(t); return; }
next = t->from_parent;
binder_free_transaction(t);
t = next;
from_parent 恰好在一处被赋值,而该赋值在几行之前受到 binder 的坏事务栈检查的保护:一个线程只有在栈顶是它正在接收的事务时,才能发送同步事务。因此,对于该链的每一环,子进程的发送方和父进程的接收方都是同一个线程。
这带来一个尖锐的后果。binder_thread_release() 在持有 proc->inner_lock 的情况下遍历正在消亡线程的栈,并写入以下两者:```
iteration j : [holds child->lock ] child->from = NULL ; unlock child->lock
spin_lock(parent->lock)
iteration j+1 : [holds parent->lock] parent->to_proc = NULL
in **同一遍历的相邻迭代中**,每次迭代都持有各自的
`t->lock`。遍历者只有在释放了 `child->lock` 之后,才能得知 `child->from == NULL`,并且需要 `parent->lock` 来获取自身的快照。因此,整个机会窗口就位于该次遍历的 `spin_unlock(&child->lock)` 与
`spin_lock(&parent->lock)` 之间——仅相隔几条指令。释放遍历持有自旋锁,在该处无法被抢占;只有中断才能延迟它。
这正是该竞态条件极为狭窄的原因,也是概念验证和利用程序都依赖概率的原因。
### 概念验证构建了什么
`binder_chain_64468.c` 构建了能够触达易受攻击访问的最短链,从而使遍历者在该窗口内的工作量尽可能小——仅两次 `t->lock` 获取和一次 `kfree()`,不涉及外部 `inner_proc_lock`、无 `wake_up`,也无回复投递:```
B thread i --e2 (sync, code 0x4442414b)--> P thread Y_i
P thread Y_i --e1 (sync, code 0x54414c4c)--> B thread i (nested target)
B thread i stack: [ e2 outgoing , e1 incoming (top) ]
P thread Y_i stack: [ e2 incoming , e1 outgoing (top) ]
P 随后关闭其 binder fd,因此 binder_deferred_release() 会释放
Y_1..Y_K 并最终释放 binder_proc,而每个 B 线程
同时发出 BINDER_THREAD_EXIT:```
binder_thread_release(B_i) nulls e1->to_proc (own proc) and e2->from
binder_send_failed_reply(e1) e1->from == NULL once Y_i was released
-> binder_free_transaction(e1) target_proc already NULL, kfree(e1)
-> binder_free_transaction(e2) target_proc == P <-- vulnerable access
第三个进程是活页夹上下文管理器,仅用于分发另外两个进程所需的句柄。`exploit.c` 完全复用了这一构造。
### 两个独立条件
一份 KASAN 报告需要同时满足以下两点:
* **易受攻击的访问** —— 遍历者必须在上方所述的狭窄窗口内获取 `parent->lock`,从而快照到一个仍然存活的 `to_proc`;以及
* **窗口内的释放落地** —— 遍历者随后必须在释放 `t->lock` 与获取受害者内部锁之间失去其 CPU,并保持离线状态,直到延迟释放完成并释放 `binder_proc`。
第一个条件有其自身的判定依据,无需依赖 KASAN:活页夹会打印```
binder: binder_free_proc: Unexpected outstanding_txns -1
每当这种情况发生时,因为遍历器和 binder_thread_release() 随后都会对同一事务的同一个计数器进行递减。请注意,这本身并非漏洞/修复之间的差异——修复阻止的是释放,而不是第二次递减——因此它在两个内核上都会出现。这里使用它只是为了展示易受攻击的代码路径被执行。
概念验证止步于对已释放内存的 4 字节递减。要将其转化为 uid 0 需要四样东西,而它们都不来自漏洞本身:该漏洞不会泄露任何信息。
struct binder_proc 为 648 字节,通过普通的 GFP_KERNEL kzalloc 分配——而非 __GFP_ACCOUNT。因此它会落入 kmalloc-1k,与该大小的所有其他未记账分配放在一起,并且不会被隔离在 kmalloc-cg-* 之后。仅此一点就使得该对象可以被回收。
kfree() 发生的时刻无法从用户空间观察到,遍历器再次触碰该对象的时刻同样无法观察到,因此没有任何可供计时对齐的时机。因此喷射以泵的形式运行:它持续分配并释放 kmalloc-1k 对象,在运行延迟释放的那个 CPU 上执行,只要遍历器在运行就持续进行。
为此使用 System V 消息。alloc_msg() 是一个普通的未记账 kmalloc,包含 48 字节头部加负载,因此一条 976 字节的消息就是一次 1024 字节的分配;配额是按队列而非按 uid 计算的;并且 msgrcv() 会同步释放。实测吞吐量:每秒约 198,000 次分配,零失败。
首先尝试了 add_key/user_key_payload,但它是个陷阱。其负载受按 uid 的字节配额(kernel.keys.maxbytes,默认 20000)限制,该配额只有在密钥垃圾收集器销毁密钥时才会释放,因此紧凑的分配/释放循环会在毫秒内耗尽它:实测 27,151 次成功分配,对应 2,121,009 次失败——98.7% 的喷射在静默地做无用功,看起来就像一场永远赢不到槽位的喷射。KEYCTL_INVALIDATE 使情况更糟(4,775 次成功),因为它会排队等待 GC 工作。
遍历器会触碰已释放 binder_proc 的四个字段(偏移量通过 pahole 在目标构建上测得):
| 偏移量 | 字段 | 遍历器执行的操作 |
|---|---|---|
| 108 | int outstanding_txns | 对其递减 |
| 113 | bool is_frozen | 读取它 |
| 120 | wait_queue_head_t freeze_wait | 若 outstanding_txns == 0 && is_frozen 则遍历它 |
| 624 | spinlock_t inner_lock | 获取并释放它 |
当 outstanding_txns == 1 且 is_frozen == 1 时,递减达到零,遍历器调用 wake_up_interruptible_all(&proc->freeze_wait)。随后 __wake_up_common 计算 curr = head.next - 24 并调用 *(head.next - 8):一个从 head.next 所指向位置读取的函数指针,而该值由被回收的对象提供。
该指针必须到达攻击者控制的内存,且位于内核地址空间,而该漏洞不泄露任何信息。这两个地址都来自预取计时——与 KASLD(Brendan Coles,MIT)相同的信道,本实现即源自该项目:
内核文本。 对已映射内核地址的预取会在页表遍历中解析并退出,其速度可测量地快于对未映射地址的预取,即使该访问在架构上永远不会可见。扫描文本范围的 2 MiB 槽位会显示镜像为一段快速槽位;其第一个槽位即 _text。
直接映射。 启用 CONFIG_RANDOMIZE_MEMORY 后,直接映射以 1 GiB 为单位随机化,因此也必须定位它。与文本不同,它覆盖全部 RAM,因此它是映射槽位中最长的连续段。需要两项改进才能使其可用:
运行可靠起始的位置并非 page_offset_base 本身,而是内核可能以 1 GiB 页映射的第一个槽位——即覆盖物理 4 GiB 的那个。在其之下,PCI 空洞和固件保留区强制使用 2 MiB 页,其更长的遍历在此处无法与未映射区分。该槽位正是喷射所需的,因此使用它。
随后约 60% 的物理内存被填充为同一精心构造的 4 KiB 页的副本,因此无论布局最终如何,从该锚点起的固定偏移都由该构造页支撑。
freeze_wait.head.next -> entry1 (in the sprayed page)
entry1.func = mov 0x28(%rdi),%rdi ; mov 0x18(%rdi),%rax ; jmp *0x58(%rax) RDI <- entry1+0x28 = &cred RAX <- cred+0x18 jmp *(RAX+0x58) = commit_creds -> commit_creds(cred)
entry2.func = mov $-1,%rax ; ret __wake_up_common breaks out on ret < 0
没有栈迁移,也没有 `iretq`:被劫持的线程正常地从它的 `ioctl()` 返回,然后只是以 root 身份回到用户空间。
### 伪造的 cred,以及那个本会浪费它的 bug
分发器 gadget 从 `cred+0x18` 获取它的 `RAX`,而 `cred+0x18` 是
`euid`/`egid`,所以链执行后 `euid` 立即变成内核指针的低半部分。
这只是外观问题。真正的问题在于 `prepare_creds()` ——
**每个后续的 `fork()` 和 `execve()` 都会调用它** —— 会解引用三个字段,且没有 NULL 检查:```c
get_group_info(new->group_info); /* refcount_inc(&gi->usage) */
get_uid(new->user); /* refcount_inc(&u->__count) */
new->ucounts = get_ucounts(new->ucounts);
伪造的凭据若将其置为 NULL,会得到 uid 0,然后在第一次 execve 时使机器崩溃——也就是说,该漏洞利用会报告成功,却立刻摧毁目标系统。因此,利用链将 user、ucounts 和 group_info 指向真实的内核全局变量 root_user、init_ucounts 和 init_groups,这些变量的地址来自同一个 _text 基址。设置好这些之后,提权线程在 init_user_ns 中持有 CAP_SETUID,于是它调用 setresuid(0,0,0),内核便会用一份干净、由内核分配的 root 凭据覆盖伪造的凭据。只有在这之后,才会执行其他任何操作。
权限通过一份 setuid-root 的漏洞利用二进制副本传递给父进程,该副本在 exec 启动 shell 之前会自行删除,因此 root shell 在主进程中于干净的终端上运行,且不会留下任何 setuid 残留。
仅凭 uname -r 并不足够。厂商内核通常会移植 binder 修复,而不会将版本号改为对应的主线版本;请检查源码或软件包变更日志。