深入Android风控边界-binder

风控的本质是剔除坏人,保留好人。

在客户端风控对抗中,作为防守方需要在端上采集各种风险因子,环境因素上报服务端进行上报分析用于黑灰产对抗。但是在通过系统 API 进行采集信息时,可能被攻击方进行 Hook 拦截,修改了真实返回值。我们当然可以做 Hook 检测来进行判断,但是攻击方同样也可以反反 Hook,这样子无休止的循环对抗。那么,我们是否可以在关键节点来进行布局对抗呢?下文中,我们将对 Android 系统函数调用流程进行介绍,并阐述在边界处可能的对抗手段。

前置知识

进程隔离与通信

img

在操作系统中,两个不同进程间内存是不共享的,无法互相访问对方的数据,这就是进程隔离。但是有时候需要进行不同进程间的数据交换,那么就需要特殊的通信机制:进程间通信(IPC)

常见的 IPC 方式有:

  • 管道(pipe):管道描述符是半双工,单向的,数据只能往一个方向流,想要读写需要两个管道描述符。Linux提供了 pipe(fds) 来获取一对描述符,一个读一个写。匿名管道只能用在具有亲缘关系的父子进程间的通信,有名管道无此限制。
  • Socket:全双工,可读可写。如 Zygote 进程等待AMS系统服务发起 socket 请求来创建应用进程。
  • 共享内存(shm,Shared Memory):会映射一段能被多个进程访问的内存,是最高效的 IPC 方式,他通常需要结合其他跨进程方式如信号量来同步信息。Android 基于 shm 改进得到匿名共享内存 Ashmem(Anonymous Shared Memory),因高效而适合处理较大的数据,如应用进程通过共享内存来读取 SurfaceFlinger 进程合成的视图数据,进行展示。
  • 内存映射(mmap):Linux 通过将一个虚拟内存区域与一个磁盘上的文件关联起来,以初始化这个虚拟内存区域的内容。通过指针的方式读写内存,系统会同步进对应的磁盘文件。Binder用到了 mmap
  • 信号(signal):单向的,发个信号就完事,无返回结果。只能发信号,带不了参数。如子进程被杀掉后系统会发出 SIGCHLD 信号,父进程会清理子进程在进程表的描述信息防止僵尸进程的发生。
  • 外还有文件共享、消息队列(Message)等跨进程通信方式…

用户态和内核态

img

在操作系统中,有很多高敏感的操作,例如读写文件等,肯定不能直接让用户程序直接调用,所以就需要将核心内核层与应用层隔离,当需要在内核态进行操作时就需要「系统调用」,对于数据拷贝用到的就是以下系统调用:

1
2
copy_from_user() //将数据从用户空间拷贝到内核空间
copy_to_user() //将数据从内核空间拷贝到用户空间

Binder 架构设计

mmap 原理

传统的 read 方式:从硬盘拷贝到内核层,再从内核层拷贝到应用层

img

mmap:在进程的虚拟内存开辟一块空间,映射到内核的物理内存,并建立和文件的关联,内存映射的实现过程,总的来说可以分为三个阶段:

img

(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

  1. 进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
  2. 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
  3. 为此虚拟区分配一个 vm_area_struct 结构,接着对这个结构的各个域进行了初始化
  4. 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

  1. 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
  2. 通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
  3. 内核 mmap 函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
  4. 通过 remap_pfn_range 函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

  1. 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
  2. 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
  3. 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用 nopage 函数把所缺的页从磁盘装入到主存中。
  4. 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用 msync() 来强制同步, 这样所写的内容就能立即保存到文件里了。

内存映射

Binder 的 IPC 底层机制是通过 mmap 实现:

内存映射简单的讲就是将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间;反之内核空间对这段区域的修改也能直接反应到用户空间。

mmap() 通常是用在有物理介质的文件系统上的。比如进程中的用户区域是不能直接和物理设备打交道的,如果想要把磁盘上的数据读取到进程的用户区域,需要两次拷贝(磁盘–>内核空间–>用户空间);通常在这种场景下 mmap() 就能发挥作用,通过在物理介质和用户空间之间建立映射,减少数据的拷贝次数,用内存读写取代I/O读写,提高文件读取效率。

Binder是用来在内核空间创建接受数据的缓存空间:

  1. 首先 Binder 驱动在内核空间创建一个数据接收缓存区;
  2. 接着在内核空间开辟一块内核缓存区,建立内核缓存区内核中数据接收缓存区之间的映射关系,以及内核中数据接收缓存区接收进程用户空间地址的映射关系;
  3. 发送方进程通过系统调用 copyfromuser() 将数据 copy 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信。

img

CS 架构

类似微内核的架构,Binder 也是这样的思想:

img

类似网络访问的形式

  • Clinet:客户端
  • Server:服务端
  • ServiceManager:DNS
  • Binder Driver:路由器

img

Client 视角中的调用对象其实是 BinderProxy 代理对象,调用方法时就会将参数传递,一路透传到 Binder 驱动进入到内核层,内核层查询 SM 并调用真正的 Sever 进程的 Binder 本地对象,将参数透传给 Server 进程,Server 运行后返回结果又到 Binder 驱动中,再返回给 Client 进程中。

img

Binder 代码浅析

  • IBinder : IBinder 是一个接口,代表了一种跨进程通信的能力。只要实现了这个借口,这个对象就能跨进程传输。
  • IInterface : IInterface 代表的就是 Server 进程对象具备什么样的能力(能提供哪些方法,其实对应的就是 AIDL 文件中定义的接口)
  • Binder : Java 层的 Binder 类,代表的其实就是 Binder 本地对象。BinderProxy 类是 Binder 类的一个内部类,它代表远程进程的 Binder 对象的本地代理;这两个类都继承自 IBinder, 因而都具有跨进程传输的能力;实际上,在跨越进程的时候,Binder 驱动会自动完成这两个对象的转换。
  • Stub : AIDL 的时候,编译工具会给我们生成一个名为 Stub 的静态内部类;这个类继承了 Binder, 说明它是一个 Binder 本地对象,它实现了 IInterface 接口,表明它具有 Server 承诺给 Client 的能力;Stub 是一个抽象类,具体的 IInterface 的相关实现需要开发者自己实现。

img

我们可以手写一个 AIDL 跨进程调用,例子参考:https://github.com/birdmanwings/HelloBinder

Java 层最后一个函数 BinderProxy.transact 函数,可以在这个网站上查看 Android 源代码的交叉引用:http://aospxref.com/

客户端

image-20230907132054969

image-20230907132117336

image-20230907132145130

进入调用到 Native 层

image-20230907132446172

image-20230907132625972

image-20230907132854924

ioctl 进入到内核层中的 binder 驱动

image-20230907132945227

服务端

把 binder 线程注册进入 binder 驱动的线程池中

image-20230907133335674

死循环等待指令,来了之后执行命令并向 binder 驱动写回数据

image-20230907133539723

image-20230907134002638

最后 jni 调用回 java 层 execTransact

image-20230907134727041

最后调用回Service 中的 transact

image-20230907134845595

实际系统调用

以及 WiFiManager.getWiFiInfo() 就是通过 Binder 的来调用在系统进程中的 WiFiService,实现了跨 IPC 调用。

Wi-Fi 架构

我们可以看如何在 Java 层直接调用 IPC 通信,获取 Android ID 代码例子如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public String getAndroidIdByBinder(Context context) {
try {
// Acquire the ContentProvider
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getMethod("currentActivityThread");
Object currentActivityThread = currentActivityThreadMethod.invoke(null);
Method acquireProviderMethod = activityThreadClass.getMethod("acquireProvider", Context.class, String.class, int.class, boolean.class);
Object provider = acquireProviderMethod.invoke(currentActivityThread, context, "settings", 0, true);

// Get the Binder
Class<?> iContentProviderClass = Class.forName("android.content.IContentProvider");
Field mRemoteField = provider.getClass().getDeclaredField("mRemote");
mRemoteField.setAccessible(true);
IBinder binder = (IBinder) mRemoteField.get(provider);

// Create the Parcel for the arguments
Parcel data = Parcel.obtain();
data.writeInterfaceToken("android.content.IContentProvider");
if (android.os.Build.VERSION.SDK_INT
>= android.os.Build.VERSION_CODES.S) {
context.getAttributionSource().writeToParcel(data, 0);
data.writeString("settings"); //authority
data.writeString("GET_secure"); //method
data.writeString("android_id"); //stringArg
data.writeBundle(Bundle.EMPTY);
} else if (android.os.Build.VERSION.SDK_INT
== android.os.Build.VERSION_CODES.R) {
//android 11
data.writeString(context.getPackageName());
data.writeString(null); //featureId

data.writeString("settings"); //authority
data.writeString("GET_secure"); //method
data.writeString("android_id"); //stringArg
data.writeBundle(Bundle.EMPTY);
} else if (android.os.Build.VERSION.SDK_INT
== android.os.Build.VERSION_CODES.Q) {
//android 10
data.writeString(context.getPackageName());

data.writeString("settings"); //authority
data.writeString("GET_secure"); //method
data.writeString("android_id"); //stringArg
data.writeBundle(Bundle.EMPTY);
} else {
data.writeString(context.getPackageName());

data.writeString("GET_secure"); //method
data.writeString("android_id"); //stringArg
data.writeBundle(Bundle.EMPTY);
}

Parcel reply = Parcel.obtain();
binder.transact((int) iContentProviderClass.getDeclaredField("CALL_TRANSACTION").get(null), data, reply, 0);
reply.readException();
Bundle bundle = reply.readBundle();
reply.recycle();
data.recycle();

return bundle.getString("value");
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

攻击

直接Hook BinderProxy.translate,亦或者更底层的方法,直接在 SystemServer 拦截 execTranslate方法,这个应用测基本无解,但是黑灰产很少修改服务端的逻辑。

防守

应用进程内最后的 Java 层调用:BinderProxy.transact 代码看 getAndroidIdByBinder 可以来绕过对 Framework 层的修改

自己实现 Native 层的调用 binder 驱动

具体实现需要适配不同的 binder 版本,以及各种可能存在的 Corner Case。

总结思考

对于风控对抗其实就是原理上的理解,系统代码甚至 BUG 的理解,以及对抗成本与精力对抗,毕竟安全没有银弹!

参考资料