一台小米 9,一款加固过的 App,六个星期。
这篇讲的是:当所有常规工具都失效之后,我是怎么把代码塞进目标进程的。
(系列第一篇 · 用户态注入)
第二篇:为了不让加固发现,我给自己编了个内核
第三篇:加固不让你抓包?那就到内核层动手
先看抓包效果
事情的开始很普通:我想看看一个加固 App 的网络请求。
按惯例上 frida。结果进程秒死。
一开始以为是姿势问题,于是把能想到的全试了一遍:
六种,全部死。存活时间从 0.4 秒到 1.2 秒,没有一个活过 2.5 秒。
对照组很重要:不注入的时候,这个 App 稳定存活 63 秒以上。
所以这不是"自然退出",是被杀了。
死法也一样:SIGKILL,干净利落,连个 signal handler 都不给你留。
真正让我开窍的,是把六次实验的数据摆在一起看。
六种手法干的事情完全不同,但它们有一个唯一的共同产物:
每次 attach,目标进程的线程数都精确 +5。
这不是巧合。
frida-agent 是一个完整的运行时,进场必然带五条线程:JS 引擎、消息循环、心跳、GC、反调试。
而加固方在做什么?
它在轮询 /proc/self/task/ 数线程数,周期不到一秒。
真相一下子清楚了:
最后这点也被验证过:我找了两个"魔改版"的 frida-server,改包名改符号,结果死在同一个 2.5 秒。
这个结论不是猜出来的,是对照实验逼出来的。
每次只改一个变量,其余全部固定。
遇到"不知道对方怎么检测的",第一件事就是设计对照组。
知道它在数线程之后,有两条路:
A. 用 frida,想办法把线程藏起来。
B. 不用 frida,写一个零线程的东西。
A 走不通 —— 你不能既要求一个运行时常驻,又要求它没有线程。
只能走 B。
CPU 执行代码需要一条执行流:取指令 → 执行 → 下一条。
线程是执行流的载体,但载体不是非得自己造。
只要让"别人的执行流"路过你的代码,你的代码就跑起来了。
整个方案一共两次借流:
不是它不懂这个技巧,是它的产品形态要求常驻:
"电脑上敲一行 JS、手机立刻生效" = 进程里必须随时有东西在监听消息 = 需要一条永远醒着的执行流 = 必须有自己的线程。
我的取舍很明确:放弃常驻监听,换零线程。
constructor 是一次性的,跑完就把线程还回去。想再来一次?从外面再 ptrace 敲一下。
ptrace 是 Linux 的调试接口,gdb 就是靠它做的。对我们最有用的三个能力:
关键:attach、改内存、改寄存器,全是"外部动作",目标进程里不增加任何东西。
具体怎么"调用 dlopen":
整个过程目标进程只知道"卡了一下",没有任何新线程、新进程、新 fd 留下。
这一步有个反直觉的事实。
同一个 so,换路径加载,结果天差地别:
一开始我以为是加固做了路径黑名单。
后来做了个跨进程对照:把同一个 so 注入一个完全没有加固的系统进程,一样失败。
那就不是加固干的。
真相是 Android 10 之后的 linker namespace:每个进程有个"允许从哪里加载 so"的白名单,App 进程的白名单里只有系统 so 目录 + 它自己的 /data/app/.../lib/arm64/。
这是 AOSP 的系统级设计,跟加固无关。
判定方法很简单:跨进程对照。找个没加固的进程试同样的事,如果它也失败,就不是加固干的。
所以操作流程是:把 so cp 到 App 自己的 lib 目录(root 下可写),再 dlopen。
能加载了,但还有个坑。
第一版 so 是用 glibc 交叉编译器编的,依赖清单长这样:
Android 的 libc 叫 libc.so,链接器叫 linker64。
这两个名字在 Android 上根本不存在。
所以它在任何路径、任何进程下都必然加载失败。
ELF 的 .dynamic 段里有张依赖清单,linker 加载时会逐项去找。
清单为空 = 没有任何依赖要解析 = 绕开整个依赖查找。
编译命令(本机 LLVM clang,不用 NDK):
其中 -ffreestanding -fno-builtin 这两个不能省,原因见下面。
没有这两个参数时,clang 会做一件很聪明的事:
把"长得像 memcpy 的循环"优化成对 memcpy 的调用。
问题是我用了 -nostdlib,没人提供 memcpy,依赖清单又是空的。
结果:linker 解析失败 → dlopen 返回 NULL → 日志里一个字都看不到。
排查了很久,最后靠反汇编才看到那个 bl memcpy。
从此养成一个习惯:每次编译完必须复查动态符号表。
不能调 libc,就自己发系统调用。ARM64 上是 svc #0:
常用的号记一下:openat=56、close=57、read=63、write=64、mmap=222、getpid=172。
这一条差点让我误判"constructor 没执行"。
App 进程的 stdout/stderr 被重定向到 /dev/null。
你往 fd 1/2 写东西,logcat 里什么都看不到。
验证代码有没有跑,唯一可靠的办法:
用 openat 在 App 自己的 data 目录建文件,写进去,从外部 cat 出来。
constructor 会在这个 so 被 dlopen 时,在发起调用的那个线程里同步执行。
也就是说:只要让 App 的线程调了 dlopen,我们的代码就自动在 App 的线程里跑起来了 —— 不需要、也没有机会开新线程。
第一次跑通的时候,日志是这样的:
对照 frida 的"+5 线程 → 1 秒被杀",这一步就是能活和必死的分水岭。
linker 对已加载的 so 有缓存。
同一个路径第二次 dlopen,直接返回旧 handle,constructor 不再执行。
而我一路用 cp 覆盖同名文件 —— inode 不变 —— 从第二次起就静默不执行了。
于是伪造出两种假象:
铁律:每次测试换新文件名。 libv001、libv002、libv003……
到这一步,我们已经在目标进程里跑起了一段 C 代码。
但它还只是个"哑巴"—— 能读写文件、发系统调用,摸不到 Java 世界。
想摸 Java,需要 JNIEnv。
dlsym 是 libdl 的导出函数,我们的 freestanding so 没法直接调。
所以自己实现一遍符号查找。分三步:
① 从 /proc/self/maps 找模块基址