上一篇讲的是怎么把代码塞进目标进程。
这一篇讲的是更底层的问题:环境本身是脏的。
(系列第二篇 · 内核态伪装)
第一篇:frida 进场 2.5 秒就被杀,我写了套零线程注入
第三篇:加固不让你抓包?那就到内核层动手
企业级加固真正开始干活的时候发现另一件事:这个环境本身,一眼就能看出来被动过。
加固根本不需要检测 frida。它只要读一眼这些,就知道你不是善茬。
先看看市面上主流方案留下了什么:
Magisk
KernelSU
两者共同
加固查什么?查文件、查进程表、查挂载、读 /proc/cmdline。
它的检测本质是对比「应然」和「实然」:
常规方案是改用户态。改完之后,跟你"应该有的样子"对不上,就露馅。
加固查的每一样东西 —— /proc/cmdline、进程表、maps —— 数据全部由内核产出。
而内核嘛……我可以自己编一个。
那么:
没有任何一个用户态文件需要藏,因为谎言是在数据出厂前就编好的。
代价是每次系统更新、换机型都要重新编译。
纪律也很重要:
Linux 里"是不是 root"不是天赋,是一张证件。
每个进程在内核里都挂着一份 struct cred,记着 uid、gid 和 capability 集。
所谓提权,就是把这张证件换成全 0 的。
常规方案(su)是在用户态放一个 setuid 0 的文件,谁执行谁换证。
我的做法:在内核里开一个后门命令,进程一发这个命令,内核亲手给它换证。
不能新增 syscall —— 那会在 syscall 表上留一行新记录,一扫就看见。
所以借用一个已有的 prctl,在里面加一个私有 option:
__sc_swap() 里做的事很朴素:
uid 2000 就是 adb shell。
只有从电脑插线进来的 shell 能提权,App 哪怕反编译出来知道了 0x4d52 这个魔数,也调不动(直接 EPERM)。
而提权工具本身就是 /data/local/tmp/ 下一个普通二进制文件。
谁都能删,删了随时再推。
它本身不含任何权限,权限完全在内核里。
最后这条值得说一下:__always_inline 让 __sc_swap 不产生 kallsyms 符号。
/proc/kallsyms 里查不到,逆向也对不上号。
换了 uid 0 的证件,只是过了 Unix 层。
Android 还有第二套保险:SELinux。
就算你是 root,策略说你这个 domain 不能读那个文件,一样拒绝。
常规做法是 setenforce 0 —— 全局关掉。
这一下就露馅了。
加固读一下 getenforce,返回不是 Enforcing,直接判定环境异常。
SELinux 的所有权限检查最终都汇聚到一个入口函数 avc_has_perm_noaudit。
补丁只在这里加三行:
securebits 是 cred 里一个平时没人用的字段,征用一个 bit 当暗号。
配合上面换证时打的标记。
这大概是整个框架里我最喜欢的一处:不破坏系统的任何可观测状态,只给自己开小灶。
对比 setenforce 0 那种"把保安室炸了"的做法,高下立判。
剩下的就是逐个补检测面。
每个补丁的格式都一样:谁在问 → 问哪个接口 → 答真话还是假话。
加固读 /proc/cmdline 看 androidboot.verifiedbootstate。刷过机的是 orange。
补丁在内核启动早期把这个字符串等长替换成 green。
等长很重要 —— 长度变了会破坏后面参数的解析。
加固遍历 /proc/*/comm 找名字。
补丁在 proc_pid_readdir() 里跳过命中的进程 —— 那个 pid 直接不出现在列表里。
加固读 /proc/self/maps 找 frida-agent-*.so、/memfd:。
这个补丁我翻过车,而且是两次,值得单独讲 —— 见下一节。
加固轮询 /proc/self/status 的 TracerPid,非 0 说明正被调试。
补丁在 task_state() 里:
意思是:uid ≥ 10000(App 世界)读时永远返回 0;uid < 10000(自己人)读时返回真值。
这个双视图设计很关键 —— 藏东西的人必须自己看得见藏哪了,不然没法调试。
这是最狠的一招:检测到异常直接 kill 自己,让你连排查机会都没有。
补丁拦在 sys_kill / sys_tgkill / sys_tkill 三个入口:
效果:加固照常检测、照常判定异常、照常发起自杀 —— 但进程永远死不掉。
我们还能继续观察它。
加固遍历 /data/local/tmp 找提权工具、boot 镜像、frida 相关文件名。
补丁在 filldir() —— 所有目录列表输出的必经之路。
一个补丁管住 ls、opendir、find 所有姿势。
uname -r 会显示 4.14.268-Marisa-KSU 这种自编后缀,一眼看出刷过机。
补丁按调用者 comm 匹配,命中的进程看到的版本变成 3.10.0-lowmem(一个古老低调的版本号)。
这个补丁也翻过车:v1 只匹配了主进程名,结果注入实际发生在 helper 子进程里,helper 的 uname 没被伪装,照样露馅。
教训:伪装面要覆盖"所有会报信的进程",漏一个就白干。
回到 maps 那个补丁。
思路很直接:命中就整行不输出 —— 假装这段映射不存在。
结果:agent 挂死,CPU 100%。
想了一下才明白:agent 自己也要读自己的 maps 来定位注入段。
它读不到自己的映射,就陷入忙等。
这告诉我们一件事:
视图伪造不能"一刀切地删",因为观察者不止敌人一个。
地址、权限、偏移全部照常输出,只把路径字符串换成无辜的系统库:
加固看到的是"一段普通的媒体库映射"。
agent 看到自己的映射范围完整无损。
所有观察者都满意。
这套框架里有五条思维模型,我觉得比代码本身更有价值:
① 改报告,不改事实
进程确实存在、文件确实存在、信号确实发了 —— 但 /proc 说不存在、readdir 说没有、kill 返回 0。