RiskEngine 是我开源在 GitHub 上的一个 Android 端设备指纹采集 + 风险检测 SDK。Java + C++17 双层结构,纯离线,进 App 之后调一次 RiskEngine.collect() 拿一份 RiskReport。
整篇按"招式"排,一招一招拆。Frida 检测占一半多的篇幅,是整个 SDK 最重的部分,按"对抗演化"的层次从入门级一路讲到内存级。
代码组织上分两层:
入口长这样:
接的人不用关心内部细节,等回调就行。但要看安全设计,得看回调背后的逻辑。
代码盘点:12 个 Detector(root、hook、模拟器、调试、mount、ADB、进程扫描、沙箱、云手机、自定义 ROM、方法完整性等),十多个 Collector(android_id、build props、telephony、wifi、bluetooth、签名、屏幕、容器信号等)。Native 那边还有 5 个原生检测器和若干原生采集器。
采集层定下的第一条原则:单源采集顶多算"原始数据",做不了"指纹"。
Android ID 这种东西,绝大多数人一行就完事:
放在风控里这就是个一行就能 hook 掉的"假指纹"——一段 Frida 脚本:
设备指纹工作直接归零。
collector/java_layer/AndroidIdCollector.java 里同一个 Android ID 从 4 个独立路径各读一遍:
四条路:
四路读到的值丢同一个 CollectorResult,由 core/DataAggregator.java 比对一致性。DataAggregator 第 27 行起:
任意两路不一致直接合成一个 multi_source_validation 的 HIGH 级检测项。
这个设计的关键不在每条单路读到了什么,而在让攻击方同时维护四条路径的一致性。hook 一个静态 Java 方法,一行 Frida 就够。要让四条路全部返回"一致的伪造值",要做的事是:
第四条命令行通道,要拦只能 root 之后 hook 整个 system_server 改 settings provider,或者拦 shell 调用本身,工作量级跳一档。加这一路就是冲着"hook 不到的同进程外路径"来的。
讲完 Java 层多源,再看 native 层。
Frida 在 Android 上的入侵姿势,一大半都是 hook libc 的几个常用函数:open openat read fopen fgets pread。原因很简单——绝大部分检测代码(不管是 Java 的 FileReader 还是 C 的 fopen)底层都会落到 libc,hook 一个就能拦一片。
cpp/util/syscall_wrapper.cpp 里直接走 raw syscall:
syscall(__NR_openat, ...) 不走 libc 的 openat 包装函数,直接通过 syscall 这个汇编入口(ARM64 上是 svc #0 指令)陷入内核。Frida 默认 hook 的是 libc 的 openat 符号,syscall 路径完全绕开它。
如果攻击方只是 Interceptor.attach(Module.findExportByName("libc.so", "openat"), ...) 这种常规姿势,对 native 检测路径完全失效。要绕开这条得搞内核态 hook(kprobe / sys_call_table 改写),需要 root + 内核级访问;或者扫指令找到所有 svc #0 全部插桩,技术上能做,Frida 默认不干。工作量级再跳一档。
syscall_wrapper.cpp 底下还封装了一个 read_file_content,把 openat + read + close 包成一个函数,几乎所有 native 检测器读 proc 文件都走它。
这部分是 RiskEngine 最重的一块,单独放出来讲。
这一块设计的时候有个明确的层次:从最入门的字符串扫描到最高级的内存检测,每一层都是独立的检测维度,单独看都可能被绕掉,但堆在一起就强迫攻击方在所有维度同时绕过。每层按"常规做法 + 容易被绕的姿势 + RiskEngine 怎么做"展开。
讲检测前先讲对手怎么动手。Frida 在 Android 上有两种主要落地方式:
frida-server 模式:电脑 PC 通过 USB / TCP 连一个跑在手机上的 frida-server,server 默认 27042 listen,跟客户端用 D-Bus over TCP 通信。需要 root,server 要 ptrace 目标进程。
frida-gadget 模式:把 libfrida-gadget.so 重打包进目标 APK,进程一启动 System.loadLibrary 把 gadget 装载进自己进程,gadget 自己跑一个 listener 等远程脚本连过来。不需要 root。
不管哪种,落到目标进程内一定会留下三类痕迹:
下面六层检测顺着这三个维度排,越往上越靠近"物理痕迹"那一边。
入门三招:扫字符串、扫线程名、扫端口。这三招实现简单、成本极低、单独看都能被绕掉。先把这三招过一遍,再讲为什么留着。
扫字符串。开 /proc/self/maps,看里面有没有 frida 相关字串。HookFrameworkDetector.java 第 96 行起:
maps 一行行读,全转小写,看里面有没有 frida 或 gadget。绕这层有两种姿势:把 libfrida-gadget.so 改名成 libutils_v2.so 重打包进 APK,maps 里再没 frida 字样了;或者 hook open/openat,让读 /proc/self/maps 时返回伪造内容。第二条 Java 路径走 FileReader 最终落到 libc 不抗 hook,但 native 路径走 raw syscall 不在此列。
扫线程名。Frida runtime 起来后会在进程里创建带特征名的线程:gum-js-loop、gmain、pool-frida、gdbus。这些是 GLib 和 GUM 库内部线程,启动时就 setname 了。HookFrameworkDetector.java 第 116 行起:
遍历 /proc/self/task/,每个子目录是一个线程的目录,里面 comm 文件存线程名。
/proc/[pid]/comm 和 /proc/[pid]/task/[tid]/comm 不是同一个东西。前者是进程主线程的名字,后者是某个具体线程的名字。Frida 是开新线程,主线程名字它没改,所以必须遍历 task 子目录。
绕这一层的姿势:在 Frida 启动时 hook prctl(PR_SET_NAME) 把线程名替换;或者拿 Frida 源码改名重新编译,把 gum-js-loop 改成 worker-x;又或者拦截 /proc/self/task 的目录读取(hook getdents64),过滤掉 frida 的 tid——native 走 raw getdents64 把这条打回去了。
扫端口。frida-server 默认 27042 listen,最直接的检测就是 connect 这个端口:
HookFrameworkDetector.java 第 108 行那段。短小精悍,攻击方一行 frida-server -l 0.0.0.0:9999 换端口就破。
升级版在 util/ProcfsUtils.java 第 91 行:
不再固定端口,直接读 /proc/net/tcp 和 /proc/net/tcp6,把所有 LISTEN 状态、绑在回环地址(127.0.0.1、::1、0.0.0.0、::)的端口列出来。
/proc/net/tcp 的格式可以照抄写 parser,不用查文档:
每行一个连接。第二列 local_address 是 16 进制的 IP:PORT,前 8 位是 IP(小端),后 4 位是端口。第四列 st 是状态,0A 就是 LISTEN。
ProcfsUtils.readTcpTable 干的就是把这玩意儿解析出来,isListening() 比对状态、isLoopback() 判断是不是回环,组合后拿一份"本机所有 LISTEN 端口"。绕这层还能让 server 不 listen,切到 gadget 模式——gadget 默认是进程内通信,可以不开端口。这就把战场推到内存检测那一档去。
那这三招既然都能被绕,为什么还要留?
留着抓蠢的。现实里相当一部分外挂作者、爬虫开发者、刚学 Frida 的萌新,就是装上 frida-server 直接连过来跑脚本,不做任何隐藏。这三条规则一秒能把这一拨人全部拦掉。底层用便宜的规则筛掉量级最大的那批低质攻击,把昂贵的检测预算留给真正有威胁的少数对手——这是任何风控系统都该有的一层。
下一档开始进入"扫到了之后还要确认它真是 frida"这一阶段。
第 1 层有个隐患:扫到一个 LISTEN 端口,但怎么确认它就是 frida-server?万一是别的合法服务呢?
这里换协议指纹。Frida 内部通信走 D-Bus over TCP。D-Bus 协议有个特点:客户端连上来要先发一个 NUL 字节加 AUTH 命令开始握手,服务端拒绝(认证失败、协议不对)会回一个以 REJECTED 开头的响应。
util/ProcfsUtils.java 第 212 行:
发出去的 payload 就一个 NUL + AUTH\r\n,故意不带任何认证内容。frida-server 这种走 D-Bus 的会回 REJECTED EXTERNAL 或类似字串。普通 HTTP 服务器、其他 RPC 服务都不会有这种回包。
误报率几乎为零。这一招的价值在于把"扫端口"升级成"协议握手",准确率拉满。
回到 HookFrameworkDetector.java 第 137 行,把第 1 层和第 2 层串起来:
把第 1 层拿到的所有 LISTEN 端口逐个发 D-Bus 探针。
举一反三:很多敏感工具都可以用类似思路做协议指纹。adbd 在 5555 上跑,连过去发 host:version 回包带版本号;gdbserver 连过去发 +,回包是 $qSupported#73 这种 GDB Remote Serial Protocol 报文;debugserver(lldb 那边)也有自己的 banner。只要愿意花时间读协议规范,"高准确率指纹"全都能写出来。
绕这一层只能把 frida-server 的通信协议从 D-Bus 换成自定义二进制协议。技术上能做,等于自己 fork 一个 frida-tools 维护,几乎没人愿意。
到第 2 层,已经能很精准地判断"本机有 D-Bus 服务在监听"。但还有一个细节:怎么证明这个服务就是 Frida而不是别的什么 D-Bus 应用?
HookFrameworkDetector.java 第 151 行又加了一道门:
逻辑分两步:
第一步,扫遍 /proc/[pid]/,从 comm 和 cmdline 里找名字带 frida-server 或 frida_helper 的进程,捞出所有候选 PID。findPidsByNameFragments 干这事。
第二步,针对每个候选 PID,读 /proc/[pid]/net/tcp 和 /proc/[pid]/net/tcp6——这个文件存的是这个进程能看到的 socket 表(在 net namespace 下),一样能找出它在 listen 哪些回环端口。
进程身份和端口监听绑死:哪怕攻击者改了端口、又装作其他服务,只要"某个进程同时具备 frida 进程特征 + 在 listen 一个回环端口",就 strong 信号直接打。
测过的对手里有把 frida-server 改名叫 media.codec_v2、端口换成 31337、还专门起了个伪装 ContentProvider 抢答其他检测的。这套规则(进程名特征 + 进程独立持有的端口表)是当时唯一稳稳钉死它的检测项。
多源关联是反作弊一切方法的灵魂。单维度检测一打就穿,两个维度对上了可信度翻倍,三个维度对上了攻击者几乎赖不掉。
但到这里所有检测都还在"看名字、看协议、看端口"——只要攻击者把 Frida 改造得彻底匿名(gadget 模式、不开端口、不用 D-Bus),上面这三层都会失效。
下面进入项目最硬的一层。
前面讲过 hook 的本质:要让原方法跳到 hook 实现,就得改原方法的入口。这是绕不过去的事实。代码可以重命名,端口可以换,协议可以改,要 hook 一个函数那个函数的内存就一定会变。最高级的检测都在内存层。
cpp/detector/native_hook_detector.cpp 里干了三件事,盯三种"内存痕迹"。
痕迹一:anon_exec 匿名可执行内存段
正常 APK 里的 .so 文件加载进来,maps 里那一行一定有 pathname 字段对应文件路径。frida-gadget 通过 mmap(MAP_ANONYMOUS | PROT_READ | PROT_EXEC, ...) 注入的代码段,路径列是空的。
native_hook_detector.cpp 第 128 行:
判定:可读 + 可执行(r-xp)、路径空、不在白名单里。后面这个白名单是关键——Android 自己的 ART 就有一堆合法的匿名可执行段(JIT 编译出来的代码、dalvik-jit、memfd:jit),不排掉它们误报满天飞。误报治理的代码占整个检测器近一半篇幅,但生产环境必须有。
maps 的格式:
每行一个内存段,pathname 缺失就是匿名映射。
绕这一层的姿势:在 mmap 之后改 /proc/self/maps 的内容——不行,maps 是内核生成的虚拟文件,应用层改不了。只能 hook 读路径。但 native 走的是 raw syscall + 自己 parse,hook libc 没用。
更高级的姿势:把代码段提前 mmap 到一个有合法路径的文件后面,伪造成系统库延伸。这要么对应文件不存在(fstat 一查就露),要么得真的预先放一个伪造文件落盘——工作量级再跳一档。
痕迹二:ARM64 inline hook 的 trampoline 指令模式
inline hook 的本质:把目标函数开头几条指令替换成跳转指令,让程序跳到 hook 实现,hook 实现执行完再跳回原指令的下一条。
ARM64 上一种最常见的跳板写法:
这两条指令在机器码里有非常稳定的模式。native_hook_detector.cpp 第 94 行起:
ARM64 指令编码:每条 ARM64 指令固定 4 字节。LDR (literal) 的高位 opcode 模式是 0x18000000 加各种修饰位。BR 指令是 0xD61F0000 加寄存器编号(占低 5 位)。两个 mask 把变化位过滤掉,比较固定位就能识别指令类型。
扫每个可执行段开头:
只扫前 4096 字节是个性能权衡。inline hook 的跳板永远在函数开头,深扫整个段就是浪费 CPU。
绕这层换其他跳板模式。比如 MOVZ + MOVK + MOVK + MOVK + BR 用四条 MOVZ/MOVK 拼出 64 位地址再跳。能用,但加一条规则的成本比攻击方换实现的成本低一个数量级——主动权在防守方。
痕迹三:SIGTRAP handler 探针
这条最有意思。native_hook_detector.cpp 第 196 行:
代码自己注册一个 SIGTRAP handler,然后用 tgkill 给当前线程发一个 SIGTRAP,看 handler 收没收到(g_sigtrap_seen 标志位)。如果 handler 没收到,说明信号路径被劫持了——通常是某个调试器或 hook 框架在接管 SIGTRAP。
原理:ptrace + 调试断点是用 SIGTRAP 实现的。调试器给目标进程下断点 = 把目标指令换成 BRK,被 ptrace 跟踪的进程触发 BRK 时内核会把 SIGTRAP 投递给 tracer 而不是 tracee,tracee 自己注册的 handler 就吃不到这个信号。
这是个"反推存在":没法直接判断有没有被 ptrace,那就发个 SIGTRAP 给自己看自己接不接得到。接不到就有人在动信号路径。
到第 4 层,前面所有招数都在主动找 Frida 在进程里的痕迹。还有一个完全不同的视角没用:别去找 frida 在哪,去看自己的关键方法有没有被 frida 动过。
detector/MethodIntegrityDetector.java:
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 4小时前
被江左梅郎编辑
,原因: