3、Binder协议与数据流:Binder命令字、BC_TRANSACTION与BR_TRANSACTION、数据包结构(binder_transaction_data)

好,我们继续深入Binder机制的核心地带。前面几章我们把Binder的整体架构和通信模型聊清楚了,现在该看看数据到底是怎么在Binder驱动里流转的。说白了,就是Binder协议本身——驱动和用户空间之间,到底在交换些什么东西。

我个人习惯把Binder协议拆成两个层面来看:命令字层数据包层。命令字决定了“要干什么”,数据包决定了“具体怎么干”。这两个层面配合起来,才构成一次完整的Binder事务。

3.1 Binder命令字:驱动与用户空间的“暗号”

Binder驱动和用户空间(比如ServiceManager、各种Service、Client)之间,是通过一组预定义的命令字来沟通的。这些命令字定义在binder.h头文件里,分为两类:

  • BC_XXX:从用户空间发往Binder驱动(Binder Command)
  • BR_XXX:从Binder驱动发往用户空间(Binder Return)

你想想看,这其实就是一个典型的请求-响应模型。用户空间说“我要做XX事”,驱动说“好的,结果给你”或者“出错了”。

核心命令字速查表

命令字 方向 含义
BC_TRANSACTION 用户空间 → 驱动 发起一次Binder事务(发送请求)
BC_REPLY 用户空间 → 驱动 回复一次Binder事务(发送响应)
BC_FREE_BUFFER 用户空间 → 驱动 通知驱动释放一块缓冲区
BC_ACQUIRE 用户空间 → 驱动 增加Binder实体的引用计数
BC_RELEASE 用户空间 → 驱动 减少Binder实体的引用计数
BR_TRANSACTION 驱动 → 用户空间 通知收到一次Binder事务(收到请求)
BR_REPLY 驱动 → 用户空间 通知收到一次Binder事务的回复
BR_DEAD_BINDER 驱动 → 用户空间 通知某个Binder服务已死亡
BR_SPAWN_LOOPER 驱动 → 用户空间 通知需要创建新的Looper线程

嗯,这里要注意:BC_TRANSACTION和BR_TRANSACTION是一对,BC_REPLY和BR_REPLY是另一对。前者用于发起调用,后者用于返回结果。我在项目中遇到过有人把BC_TRANSACTION和BR_REPLY混在一起理解,结果调试了半天才发现是方向搞反了。

3.2 BC_TRANSACTION与BR_TRANSACTION:一次调用的完整生命周期

我们拿一个最简单的场景举例:Client调用Service的某个方法。整个过程是这样的:

  1. Client端:构造binder_transaction_data数据包,通过ioctl(BC_TRANSACTION)发送给驱动。
  2. 驱动层:解析数据包,找到目标Service的Binder实体,把数据拷贝到目标进程。
  3. 驱动层:在目标进程(Service端)的等待队列里插入一个BR_TRANSACTION通知。
  4. Service端:从ioctl返回,读到BR_TRANSACTION,拿到数据包,开始处理。
  5. Service端:处理完后,构造回复数据包,通过ioctl(BC_REPLY)发回驱动。
  6. 驱动层:把回复数据拷贝回Client进程,插入BR_REPLY通知。
  7. Client端:读到BR_REPLY,拿到结果。

你看,一次调用,驱动层做了两次数据拷贝。这就是Binder比传统Socket高效的地方——它只需要两次拷贝,而Socket需要四次。

避坑指南:我曾经在调试一个性能问题时,发现Binder事务总是超时。后来定位到是Service端处理完BC_REPLY后,驱动没有及时唤醒Client端的等待线程。原因?Service端在发送BC_REPLY之前,忘记检查binder_transaction_data中的handle是否有效。驱动发现handle无效,直接丢弃了回复包,Client端就永远等不到BR_REPLY了。

3.3 数据包结构:binder_transaction_data深度解析

命令字只是告诉驱动“我要做什么”,真正的数据内容,全部封装在binder_transaction_data这个结构体里。这个结构体是Binder协议的核心,也是安全漏洞的高发区。

它的定义如下(简化版,去掉了内核版本差异):

struct binder_transaction_data {
    /* 通用字段 */
    union {
        __u32    handle;      // 目标Binder的句柄(Client端使用)
        binder_uintptr_t ptr; // 目标Binder实体的内核地址(驱动内部使用)
    } target;
    binder_uintptr_t    cookie;    // Binder实体的用户空间cookie
    __u32              code;      // 事务代码(方法ID)

    /* 权限与标志 */
    __u32              flags;     // 标志位(如TF_ONE_WAY、TF_ACCEPT_FDS等)
    pid_t              sender_pid;   // 发送方进程ID
    uid_t              sender_euid;  // 发送方有效用户ID

    /* 数据大小 */
    __u32              data_size;    // 数据缓冲区大小(bytes)
    __u32              offsets_size; // 偏移数组大小(bytes)

    /* 数据指针 */
    union {
        struct {
            binder_uintptr_t buffer; // 数据缓冲区地址
            binder_uintptr_t offsets; // 偏移数组地址
        } ptr;
        __u8 buf[8]; // 内联数据(小数据时使用)
    } data;
};

这个结构体里,有几个字段是安全分析的重中之重:

  • target.handle / target.ptr:决定了这次事务发给谁。如果handle被篡改,就可能把数据发给恶意Service。
  • sender_pid / sender_euid:驱动会自动填充这两个字段,用户空间无法伪造。这是Binder安全模型的基础。
  • data_size / offsets_size:决定了数据缓冲区的大小。如果这两个值不匹配,就可能造成缓冲区溢出。
  • flags:TF_ONE_WAY标志位决定了这次调用是同步还是异步。异步调用不需要回复,但驱动仍然会做权限检查。

安全警告:我在分析CVE-2019-2215漏洞时,发现攻击者正是利用了binder_transaction_data中data_size和offsets_size的校验缺失,构造了一个畸形数据包,导致内核堆溢出。具体来说,攻击者把data_size设为一个很小的值,但offsets_size设为一个很大的值,驱动在校验时只检查了data_size,没有检查offsets_size是否超过实际数据长度,结果在解析偏移数组时越界访问了内核内存。

3.4 数据包中的“偏移数组”:Binder对象传递的秘密

binder_transaction_data里有一个offsets字段,指向一个偏移数组。这个数组的作用是什么?

说白了,就是告诉驱动:数据缓冲区里哪些位置存放的是Binder对象。因为Binder对象(比如IBinder接口的引用)不能像普通数据一样直接拷贝,驱动需要特殊处理——把Binder引用转换成目标进程能理解的句柄。

偏移数组的每个元素是一个flat_binder_object结构体的偏移量:

struct flat_binder_object {
    __u32        type;   // 对象类型(BINDER_TYPE_BINDER、BINDER_TYPE_HANDLE等)
    __u32        flags;  // 标志位
    binder_uintptr_t cookie; // Binder实体的cookie
    union {
        binder_uintptr_t binder; // Binder实体内核地址
        __u32            handle; // Binder句柄
    } binder;
};

驱动在处理BC_TRANSACTION时,会遍历这个偏移数组,对每个flat_binder_object做如下操作:

  • 如果type是BINDER_TYPE_BINDER:表示这是一个Binder实体(服务端),驱动会在目标进程创建对应的Binder节点。
  • 如果type是BINDER_TYPE_HANDLE:表示这是一个Binder引用(客户端),驱动会把handle转换成目标进程能用的句柄。
  • 如果type是BINDER_TYPE_FD:表示这是一个文件描述符,驱动会做文件描述符的跨进程传递。

嗯,这里有个坑:偏移数组中的每个偏移量,必须指向数据缓冲区内的一个有效位置。如果偏移量指向了缓冲区之外,驱动就会崩溃——或者被攻击者利用。

3.5 数据流全景图

为了让你更直观地理解整个数据流,我画了一张图。这张图展示了从Client发起BC_TRANSACTION到Service收到BR_TRANSACTION的完整路径:

Binder事务数据流全景图 Client进程(用户空间) Binder驱动(内核空间) Service进程(用户空间) ① 构造binder_transaction_data 填充handle、code、data ② ioctl(BC_TRANSACTION) 写入binder_write_read ⑧ 收到BR_REPLY 读取回复数据 ③ 解析命令字 binder_ioctl → binder_thread_write ④ 解析binder_transaction_data 校验handle、权限、数据大小 ⑤ 拷贝数据到目标进程 binder_transaction → 内存拷贝 ⑥ 插入BR_TRANSACTION 唤醒Service等待队列 ⑦ ioctl返回BR_TRANSACTION 读取binder_transaction_data ⑨ 处理请求 调用目标方法 ⑩ ioctl(BC_REPLY) 构造回复数据包 BC_TRANSACTION BR_TRANSACTION BC_REPLY BR_REPLY

这张图里,我特意把数据拷贝命令字传递分开了。你注意看,BC_TRANSACTION和BR_TRANSACTION是两条不同的路径——前者是Client主动发起的,后者是驱动主动推送的。驱动在这里扮演了一个“中间人”的角色,负责数据格式转换和权限校验。

3.6 安全视角:数据包校验的薄弱环节

从安全角度看,binder_transaction_data的解析过程有几个关键校验点:

校验点 校验内容 绕过后果
handle有效性 检查handle是否指向一个已注册的Binder实体 可能将数据发送到恶意Service
data_size与offsets_size 检查偏移数组是否完全位于数据缓冲区内部 内核堆溢出(CVE-2019-2215)
flat_binder_object.type 检查对象类型是否合法 类型混淆,可能导致任意内存读写
sender_pid/sender_euid 驱动自动填充,用户空间不可伪造 无法绕过(这是Binder安全模型的基石)
flags中的TF_ACCEPT_FDS 检查目标进程是否允许接收文件描述符 文件描述符泄露,可能导致提权

我个人觉得,最容易被忽视的是offsets_size的校验。很多开发者只关注data_size,觉得数据大小对了就行。但偏移数组才是真正决定“哪些数据需要特殊处理”的关键。如果偏移数组指向了错误的位置,驱动就会把普通数据当成Binder对象来处理——这往往就是漏洞的入口。

实战建议:在编写Binder相关代码时,我建议你始终遵循一个原则——信任驱动,但验证数据。即使驱动做了校验,用户空间也应该对收到的binder_transaction_data做二次检查。特别是data_size和offsets_size的比值,如果offsets_size远大于data_size,那肯定有问题。

好了,这一章我们把Binder协议的数据流讲透了。从命令字到数据包结构,再到偏移数组的作用,你应该对Binder驱动内部的数据处理有了清晰的认识。下一章我们会深入Binder的线程模型和Looper机制——嗯,那是另一个有趣的话题。


公众号:蓝海资料掘金营,微信deep3321