风控的本质是剔除坏人,保留好人。
在客户端风控对抗中,作为防守方需要在端上采集各种风险因子,环境因素上报服务端进行上报分析用于黑灰产对抗。但是在通过系统 API 进行采集信息时,可能被攻击方进行 Hook 拦截,修改了真实返回值。我们当然可以做 Hook 检测来进行判断,但是攻击方同样也可以反反 Hook,这样子无休止的循环对抗。那么,我们是否可以在关键节点来进行布局对抗呢?下文中,我们将对 Android 系统函数调用流程进行介绍,并阐述在边界处可能的对抗手段。
前置知识
进程隔离与通信
在操作系统中,两个不同进程间内存是不共享的,无法互相访问对方的数据,这就是进程隔离。但是有时候需要进行不同进程间的数据交换,那么就需要特殊的通信机制:进程间通信(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)等跨进程通信方式…
用户态和内核态
在操作系统中,有很多高敏感的操作,例如读写文件等,肯定不能直接让用户程序直接调用,所以就需要将核心内核层与应用层隔离,当需要在内核态进行操作时就需要「系统调用」,对于数据拷贝用到的就是以下系统调用:
1 | copy_from_user() //将数据从用户空间拷贝到内核空间 |
Binder 架构设计
mmap 原理
传统的 read 方式:从硬盘拷贝到内核层,再从内核层拷贝到应用层
mmap:在进程的虚拟内存开辟一块空间,映射到内核的物理内存,并建立和文件的关联,内存映射的实现过程,总的来说可以分为三个阶段:
(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
- 进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
- 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
- 为此虚拟区分配一个 vm_area_struct 结构,接着对这个结构的各个域进行了初始化
- 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中
(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
- 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
- 通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
- 内核 mmap 函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
- 通过 remap_pfn_range 函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。
(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
- 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
- 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
- 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用 nopage 函数把所缺的页从磁盘装入到主存中。
- 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用 msync() 来强制同步, 这样所写的内容就能立即保存到文件里了。
内存映射
Binder 的 IPC 底层机制是通过 mmap 实现:
内存映射简单的讲就是将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间;反之内核空间对这段区域的修改也能直接反应到用户空间。
mmap() 通常是用在有物理介质的文件系统上的。比如进程中的用户区域是不能直接和物理设备打交道的,如果想要把磁盘上的数据读取到进程的用户区域,需要两次拷贝(磁盘–>内核空间–>用户空间);通常在这种场景下 mmap() 就能发挥作用,通过在物理介质和用户空间之间建立映射,减少数据的拷贝次数,用内存读写取代I/O读写,提高文件读取效率。
Binder是用来在内核空间创建接受数据的缓存空间:
- 首先 Binder 驱动在内核空间创建一个数据接收缓存区;
- 接着在内核空间开辟一块内核缓存区,建立内核缓存区和内核中数据接收缓存区之间的映射关系,以及内核中数据接收缓存区和接收进程用户空间地址的映射关系;
- 发送方进程通过系统调用 copyfromuser() 将数据 copy 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信。
CS 架构
类似微内核的架构,Binder 也是这样的思想:
类似网络访问的形式
- Clinet:客户端
- Server:服务端
- ServiceManager:DNS
- Binder Driver:路由器
Client 视角中的调用对象其实是 BinderProxy 代理对象,调用方法时就会将参数传递,一路透传到 Binder 驱动进入到内核层,内核层查询 SM 并调用真正的 Sever 进程的 Binder 本地对象,将参数透传给 Server 进程,Server 运行后返回结果又到 Binder 驱动中,再返回给 Client 进程中。
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 的相关实现需要开发者自己实现。
我们可以手写一个 AIDL 跨进程调用,例子参考:https://github.com/birdmanwings/HelloBinder
Java 层最后一个函数 BinderProxy.transact 函数,可以在这个网站上查看 Android 源代码的交叉引用:http://aospxref.com/
客户端
进入调用到 Native 层
ioctl 进入到内核层中的 binder 驱动
服务端
把 binder 线程注册进入 binder 驱动的线程池中
死循环等待指令,来了之后执行命令并向 binder 驱动写回数据
最后 jni 调用回 java 层 execTransact
最后调用回Service 中的 transact
实际系统调用
以及 WiFiManager.getWiFiInfo() 就是通过 Binder 的来调用在系统进程中的 WiFiService,实现了跨 IPC 调用。
我们可以看如何在 Java 层直接调用 IPC 通信,获取 Android ID 代码例子如下
1 | public String getAndroidIdByBinder(Context context) { |
攻击
直接Hook BinderProxy.translate,亦或者更底层的方法,直接在 SystemServer 拦截 execTranslate方法,这个应用测基本无解,但是黑灰产很少修改服务端的逻辑。
防守
应用进程内最后的 Java 层调用:BinderProxy.transact 代码看 getAndroidIdByBinder 可以来绕过对 Framework 层的修改
自己实现 Native 层的调用 binder 驱动
具体实现需要适配不同的 binder 版本,以及各种可能存在的 Corner Case。
总结思考
对于风控对抗其实就是原理上的理解,系统代码甚至 BUG 的理解,以及对抗成本与精力对抗,毕竟安全没有银弹!