预热
先读开源的检测方案:DetectFrida,源码我肯定是没看完就上手改frida去测了,搞到最后发现这个检测app把socket权限给ban了,没法create socket就暂时放弃了爆改Frida通讯的方案,要自己去写的话,真的十分费劲。
先读Frida的实现:frida-agent、frida-gadget注入目标进程后,自己通过g_thread_new(在gthread.h头文件中)分别去创建几个线程:gum-js-loop、frida-main-loop、frida-gadget,名字硬编码在源代码里面的,以及两个依赖库所创建的线程:gmain、gdbus(来自GLib内部)。
然后源码里面还有一些特征字符串frida:rpc之类的,一个个抹去再重新构建,其实很麻烦。
除此之外,Frida-server是通过注入Zygote.so内部,加载自己的函数库后实现的调度,spawn其实就是 启动应用、再Attach那么简单,Zygote是个常驻的进程,Frida-server注入以后,进程内部就有Frida的so特征,所以在安卓上起Frida-server再注入Frida-agent的话,暴露的检测面非常非常大,而且Frida-server一定要留在/data/local/tmp这种全局共享的目录,基本可以考虑放弃Frida-server官方实现的重构了,有那个功夫不如换条路自己写注射器(AndKittyInjector)。
兜兜转转就剩下一个Frida-gadget能动,再减少更多特征、那还是Frida吗?然后就转头去看了DetectFrida的实现:
磁盘检查:
data/local/tmp目录,底下有东西就标记,为什么是这个目录,因为全局共享可读可写可执行,其他目录例如/data/data/com.xxx自己包的目录是查不到的,权限隔离,同时跨权限(即使是root)去注射的话,也没必要查,因为都动到内核了没必要防。
进程检查:
检查map里面是否有frida的特征,极端一点的先扫几遍Frida这个特征字符串有没有出现在自己的内存段中,然后去扫匿名内存是否存在elf头,扫特征码什么的也是情有可原。
线程检查:
开头说的那几个线程名字基本都在黑名单里,Frida的函数库载入目标内存后,目标应用可以通过轮询查看自身线程池的办法去检测,要规避这个检测,真的很棘手。
socket、端口检查:
frida注入到目标进程之后会自己create一个socket,有连接模式、监听模式,要么被动接受指令调试,要么主动连接远程调试,而保护的话就检查是否有异常socket创建就行,极端一点的,限制socket权限,起都没法起。
内联钩子检查:
inlinehook要动内存,常规程序启动前先记住一遍native层系统函数库的校验和,运行时再轮询检查一遍内存的校验和,这个我是没搞懂:为什么so文件运行时在内存里是不变的?按理来说不应该会因为堆栈参数导致一直变动吗?
剩下就是异常solist检查等这些比较小的检测。
实操环节
Frida-sever挂彩了,那么要自己写注射器,这个简单:检测目标启动、Hook Linker链接器、把自己的so路径提供给注入器加载、顺带摘solist链表、抹map匿名函数,线程就难搞了。
因为frida-gadget进入进程后,起的线程是异步的,此时frida-gadget.so的权限属于目标应用的权限,改名的时机太早找不到线程名、改名的时机太晚会被检测、而要精准地在起线程命名时篡改,要去hook依赖的libc.so,而这个hook还没法由frida来做,因为此时frida正在初始化。而改名也算得上是一种比较差的对抗方案,检测有可能检测有没有多出来的异常线程什么的,最好是从tid里面摘掉线程名,但是又绕回来了:由注射器去摘线程名、不知道异步起的时间,肯定要Hook libc.so,就存在内存脏页,可以检测的,由Frida去摘线程名、就是先落地后执行的问题,同样会被检测Frida特征。
那怎么办呢?只能改Frida-gadget的源码:在初始化之前,先做一遍摘表,然后恢复内存脏页的问题,最后正常运行,这个最稳妥。其他像什么seccomp-BPF、KernelSU .ko,太麻烦太麻烦了。一时半会说得不是很清楚,三言两语看感觉确实挺容易,上手一干一晚上就过去了。
最后成品:zygisk写注射器,100%隐蔽稳定、方便,frida-gadget自助、摘线程、摘elf头、编译时混淆字符串,基本能跑就行。我知道肯定还有什么神人检测去看gum、GLib的特征,做完之后一拍脑袋才想到,这条路说不定走不通。最后放弃了就过来写写日志、做个记录,别再想什么frida了,他只有一个优势、就是能跑额外的脚本、动态调试方便,除此之外全是检测面。
额外
想着想着回去看了下BA外挂的样本,注入之后的函数库是咋处理自己的,有点意外发现:
JNI_OnLoad
├─ pthread_create → TCP C&C server (port 8080) [通信层]
└─ pthread_create → main_worker
│
├─ [1] sleep(3) × N [等待注入完成]
│
├─ [2] sub_4db354 + sub_4dc070 ★ ELF header 内存擦除
│ 扫 /proc/self/maps → 找所有 “\x7fELF” → 全写零
│ ↑ 对抗: IDA/frida 的内存模块扫描、/proc/PID/maps 内存镜像分析
│
├─ [3] sleep(5)
│
└─ [4] DobbyHook(sub_55bb38(encrypted_name), sub_4d3870, …)
sub_55bb38 = 加密字符串解析器 → 解出目标函数名 (libc.so 某函数)
sub_4d3870 = hook handler → 访问 pthread_internal_t 各字段
↑ 对抗: 拦截新线程创建 → 自动从 bionic 链表移除
这个样本用了两层线程隐藏:
- ELF 抹头(
sub_4db354+sub_4dc070):
所有加载的 .so 在内存中 ELF magic 被清零 →/proc/PID/maps里的 anonymous 无名映射 + 内存镜像都无法被识别为 ELF - pthread_create hook(DobbyHook →
sub_4d3870):
hook 某个 libc 函数(待解密),在每次创建新线程时调用 hook handler 操作pthread_internal_t字段,将线程从 bionic 链表自动 unlink →/proc/PID/task/不受影响(kernel TID 还在),但 bionic 层的 pthread 枚举看不到
加分项:用 process_vm_readv 读自身内存(而不是直接 mmap_read),这在某些框架的检测逻辑里会漏过(因为不是常规文件 I/O 或 /proc/pid/mem 读)。
和我想得差不多,但是操作起来太费劲太费劲了。