本文记录了基础的Frida和Root的环境检测方案和绕过方案,本人学习时间实在不长,若文章有纰漏,望各位高手们找出后及时提出,我会及时编辑,学习,修改,谢谢各位
在多数教程中,都指导把Frida-server推送到/data/local/tmp中,并使用27042或者27043端口的TCP连接,应用层采取类D-BUS协议,传输RPC调用,再与frida-server建立通信,最终由frida-server完成对目标进程的注入与脚本执行。Spawn 模式下,Frida 通常先请求系统创建目标进程并使其保持暂停状态,然后完成附加和脚本加载,最后恢复进程执行,因此,它通常早于多数 Application.onCreate() 及业务检测,但无法覆盖 Zygote 阶段、链接器初始化以及部分极早期 native 初始化
我们从启动点的源代码开始分析
分析进程注入的源代码可以得知,不论是spawn还是attach都最终调用了attach,不过spawn是先挂起进程,再使用attach附加
他们最终都是通过perform_attach_to()函数实现frida-agent注入
下一步我们看看Linjector的具体实现
根据是否支持匿名内存的方式来确认agent是否要落地到磁盘上,他们最终都都调用了inject_library_fd
InjectSession.open()会通过 ptrace attach 到目标进程并保存寄存器状态。然后 session.inject() 才是真正做注入工作的
用之前 ptrace 停住时的上下文,把 PC/IP 指到 loader_base(loader 入口),把第一个参数设成 HelperLoaderContext*,栈换成刚分配的
之后的代码逻辑就是在src\linux\helpers\loader.c中,入口是frida_load(),里面通过线程启动frida_main()方法,在后者中,通过dlopen() 加载 agent,通过 dlsym() 找到 frida_agent_main,最后调用 agent 入口函数
大概看完上述注入原理,我们可以发现frida的几个特征还是比较明显的,我们以下细细说明
上述提到frida-server 通常被推送到 /data/local/tmp
java层有:
Native层有:
对frida-server进行改名
放到其他目录,比如 /data/local/tmp/.hidden/,不过这只能骗路径写死的弱检测
用 ZygiskFrida,不依赖 frida-server
frida-server 默认监听 27042(和 27043)App 直接尝试连接 127.0.0.1:27042,能连上就说明有 Frida嫌疑
java层实现:
Native层实现:
当然更严谨的要发送D-BUS认证消息,frida-server 使用 D-Bus 协议进行通信,因此会对 D-Bus 的 AUTH 握手请求做出响应,更彻底的检测会遍历 0~65535 所有端口,对每个开放端口都发 D-Bus AUTH 探测
下面是用于说明思路的简化伪代码,不代表完整的协议实现:
或者更绝一点,直接给比较点Hook了,这是因为我们知道认证消息可以包括REJECT和OK
Frida 注入后,其 frida-agent 和运行时引擎会在目标进程的内存中留下痕迹
上文也提到了关于系统是否支持memfd时的Frida注入的差别,Frida常制造匿名rwx权限段来放置frida-agent.so,目的是逃过磁盘上上的文件扫描
Android系统强制执行W^X,但Frida在安装inline hook时,需要修改目标函数指令并写入跳转代码(trampoline),这要求Frida将相关内存页权限修改为rwx,说明我们可以检查内存权限段,当然rwx段并不一定就是,许多合法库也有的
在实际的App加固方案中,检测代码会更为复杂,还有命中后自毁abort()等手段
可以Hook fopen、open、read等文件操作函数,在检测代码读取maps时,动态修改返回的内容,擦除Frida的特征
当然,最简单的方法就是直接上魔改的得了,社区一堆
Linux/Android 内核规定:同一个进程同时只能被一个调试器(Tracer)附加
Frida 在注入阶段通过 ptrace 附加并暂停目标线程,读写其寄存器和内存,让目标线程临时执行 loader,在其创建新线程后立即返回,Frida 恢复寄存器并 detach
App 在启动的极早期(通常在 .init_array 或 JNI_OnLoad 中),主动调用 ptrace(PTRACE_TRACEME, 0, 0, 0),如果 Frida 已经附加,App 再调用相关 ptrace 操作可能失败,由此检测出问题
再有一种变体就是开Watcher进程先 ptrace 附加 Worker,占住 ptrace 字段
Hook ptrace,不再调用原始实现,直接返回伪造结果
Frida通过 memfd 加载 frida-agent.so 时,会在目标进程里留下一个 fd,指向 /memfd:frida-agent-64.so
Native实现如下:
魔改 Frida 改 memfd 名称,或 Hook readlink 返回假路径
Frida 运行时依赖 GLib,会创建名称固定的辅助线程,可以通过遍历 /proc/self/task/ 目录,读取各线程的comm文件,匹配特征线程名
伪代码如下:
Hook 文件打开与读取:核心思路是拦截对 /proc 目录下 comm 或 status 文件的读取操作。当发现 App 试图读取特定线程的信息时,检查读取到的内容,如果是 gmain 等敏感名称,将其替换为合法的系统线程名(如 epoll、config_store 等)
或者干脆使用去特征的魔改
对于 fd (文件描述符)、status (线程状态)、maps (内存映射) 这三类基于 /proc 文件系统的检测,上述 IO 重定向 方案是通解,只要将敏感文件指向无害文件(如 /dev/null 或提前备份的正常文件),即可实现完美绕过
以上我们大多通过hook libc导出函数来绕过检查,那么假如不走libc,直接 svc进内核,自己实现 字符串比较、读文件,不调你 Hook 的符号,那我们就难以实现反调试
AArch64 下
以下给出实际例子:
我们发现,自实现系统函数一个重要的前提就是它们都有标准的系统调用号,标准的机器码,直接搜索对应函数的特征码,定位到之后再使用Interceptor进行Hook,但是需要处理架构差异
Inline Hook 是 Frida Interceptor 模块的核心机制。原理是直接修改内存中目标函数的前几条指令(Prologue),替换为一条跳转指令(Trampoline/跳板),把执行流引导到 Frida 的处理函数,还有软件断点,调试器插入断点会写入 0xCC (INT 3) 指令,同样会改变 CRC
一般的检测是 CRC/MD5 比对:计算内存中函数前 N 字节的哈希,和磁盘 .so 里原始机器码比对,不一致说明被 Hook
方法一:Hook memcmp
但是Hook strcmp带来的开销太大,我们常Hook哈希函数作为替代
Root的权限判断并没有想象中仅仅修改一个uid这么简单,权限检查是多重配合的,我目前已知是:UID / GID capabilities SELinux seccomp 挂载命名空间
每个进程都有一个task_struct,其内部有一个cred结构体记载了权限信息
每个 App 安装时,PackageManager 分配一个独立 UID,当访问文件的时候,第一层是检查DAC(Discretionary Access Control),根据 UID/GID 和文件权限位(rwx)决定能不能访问,文件属主可以自己 chmod 改权限
举例:
一个 App,UID 是 10123,去读另一个 App 的文件
如果是Root权限,会直接无视这一步的检查,这是因为DAC检查了一个CAP_DAC_OVERRIDE,有这个的默认放行,而Root的能力集cap_effective 里有这一项
SELinux 是强制访问控制(MAC,Mandatory Access Control)
过了DAC权限检查后,并不能直接地获得所有文件的读写权限,否则太易受攻击,而Security-Enhanced Linux采取的是最小权限原则
SELinux的核心是它的安全上下文,格式为:user:role:type:level
他是从域和策略的角度来决定的是否进程能访问某个文件
举例如下:
seccomp (Secure Computing Mode) 是 Linux 内核的一种安全机制,用于限制进程可以调用的系统调用集,本质上是linux系统调用的防火墙,可查看文档df9K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2S2L8U0N6Q4x3X3g2G2M7X3N6Q4x3V1k6D9K9h3&6#2P5q4)9J5c8X3#2S2L8W2)9J5k6s2m8S2k6$3g2K6i4K6u0r3L8h3q4F1x3#2)9J5c8Y4y4W2j5$3y4G2L8i4m8Q4y4h3k6J5N6h3I4W2i4K6g2X3j5h3c8V1i4K6u0W2x3#2)9J5k6h3S2@1L8h3H3`.
seccomp 的核心工作原理是基于 cBPF(classic Berkeley Packet Filter),一种内核中的 "虚拟机" 机制,来定义和执行系统调用的过滤规则
BPF就理解成内核里的if-else规则表就好
假设内核要决定:某个系统调用(syscall)不让执行,没有 BPF 的话,内核只能写死
可以把 Seccomp 想象成一个海关检查站:
应用程序 (Application): 发起一个请求
Seccomp 过滤器 (The Checkpoint): 拦截这个请求,并检查手中的“规则手册”(BPF Profiles)
规则:允许删除文件吗?No
内核 (Kernel): 根据 Seccomp 的指令,直接拒绝请求,并可能惩罚该进程(终止运行)
结果:恶意操作未遂,系统安全得到保障
前四层管的是“能不能做这个操作”:
UID/GID:你的身份是什么
capabilities:你有没有这个特权
SELinux:你的域允许不允许
seccomp:这个 syscall 能不能发出去
挂载命名空间管的是你这个进程能看到什么
老版本 Android 的 /system 分区还没有挂载 nosuid,那么这就给了普通 App执行/system/bin/su的机会,大概原理是su文件含有setuid位,以“文件属主”的身份运行,而su的身份显然是Root,现在我们就有了一个Root进程,su已经以较高权限跑起来了,但是它自己还要完成策略和切换,修改 cred uid=0, capabilities...等等参数,然后通常再 exec 成一个 shell
,但是问题在于,Andriod根本不想给你提权的机会,它的/system/bin/下根本不会给你这些su文件,这就带来了检测方案
我们直接对着扫目录就行
Java层:
Native层:
但是很显然地,这个直接mount namespace就解决了,所以我们可以尝试执行提权命令,通过回返判断
Native层:
尽管如此,但仍可能被策略拒绝、隐藏、Hook 执行结果
https://bbs.kanxue.com/thread-292966.htm内有一张不错的流程图

了解过 Android 启动过程的应该知道,Bootloader 负责加载并启动内核,内核随后启动 PID 1 的 init,再由 init 按启动配置拉起其他系统服务和进程(可以看这位师傅的https://bbs.kanxue.com/thread-285949.htm),Magisk 正是通过修改 boot 镜像,在早期启动阶段插入自己的逻辑(核心是 magiskinit),从更靠前的位置完成 Root 环境的部署
下面我们对着源代码分析一下关键节点:
用 magiskboot 工具解包 boot 镜像,并找出真正的 ramdisk
把 ramdisk 里的 init 换成 magiskinit,备份原始 ramdisk,记录配置,重新打包
那么在magiskinit里到底做了什么呢,我们追踪到e9bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6@1L8%4m8B7L8$3S2F1N6%4g2Q4x3V1k6y4j5h3N6A6M7$3E0Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4K6N6r3g2J5i4K6u0r3L8X3q4@1K9i4k6W2i4K6u0r3M7%4u0U0i4K6u0r3K9h3&6A6N6q4)9J5c8X3W2F1K9i4c8Q4x3X3g2J5M7H3`.`.
接下来看start
分析first_stage:
其实就是
接下来分析second_stage:
两个patch_root,由于篇幅过长,简单总结
这一阶段主要是处理第一阶段的痕迹,再者就是修改selinux策略,原始 SELinux 策略里没有关于magisk的域,所以 Magisk 必须修改 SELinux 策略,修改策略原理如下
Magisk 用 FIFO 把 init 卡在"加载 SELinux 策略"这一步,趁机把策略换掉,再放 init 走,init 以为自己加载了原始策略,实际上加载的是 Magisk 改过的版本
接下来我们本来应该追exec_init,位于df4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6@1L8%4m8B7L8$3S2F1N6%4g2Q4x3V1k6y4j5h3N6A6M7$3E0Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4K6N6r3g2J5i4K6u0r3L8X3q4@1K9i4k6W2i4K6u0r3M7%4u0U0i4K6u0r3K9h3&6A6N6q4)9J5c8X3#2G2N6h3&6@1i4K6u0W2M7Y4x3`.
但是该文件内部其他段落也比较重要,所以我一并分析了
启动早期,真正的 /data 分区还没挂载,但 Magisk 需要在 /data 下放东西,所以在 /data 上挂一个 tmpfs(内存文件系统),临时充当 /data
magiskinit 干完所有活后,执行真正的 init,把自己替换掉,回到了init
magiskinit 在启动时,通过 patch_rc_scripts() 和 inject_magisk_rc() 函数,将一段启动 magiskd 的配置写入系统的 init.rc 文件,大致如下
此时,magisk --post-fs-data 作为一个客户端被启动。它的首要任务是尝试连接 magiskd 的 Unix Domain Socket由于 magiskd 尚未运行,连接会失败。
随后,客户端代码会调用 connect_daemon() 函数,根据源码逻辑,该函数会检测到 Socket 不存在,然后 fork() 一个子进程。这个子进程会调用 daemon_entry(),从而正式启动 magiskd 守护进程
Magisk维护的守护进程
b80K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6@1L8%4m8B7L8$3S2F1N6%4g2Q4x3V1k6y4j5h3N6A6M7$3E0Q4x3V1k6@1M7X3g2W2i4K6u0r3L8h3q4K6N6r3g2J5i4K6u0r3L8X3q4@1K9i4k6W2i4K6u0r3M7%4u0U0i4K6u0r3j5$3!0J5k6b7`.`.
随后调用handle_requests处理权限校验:
接着是su_daemon_handler
Magisk采取了Magic Mount配合DenyList来做到隐藏机制,是 Magisk 实现 systemless 修改的核心机制,所谓“systemless”,指的是不修改真实的系统分区,而是在当前进程的挂载命名空间里,用 bind mount 把模块文件“覆盖”到系统分区的对应路径上,对上层应用来说,/system/bin/xxx 看起来就是系统原本的文件,实际可能来自模块目录
举例如下:
工作原理:利用挂载命名空间(Mount Namespace)实现“视图隔离”
Linux系统允许每个进程拥有独立的文件系统视图,这就是挂载命名空间,DenyList正是利用了这一机制
隔离视图:当被列入DenyList的应用启动时,Magisk会为它创建一个全新的、干净的挂载命名空间。在这个“房间”里,Magisk的su二进制、模块目录等所有挂载痕迹都被移除了
效果:对于这个应用而言,它看到的系统是“原厂、未修改”的。当它执行检查/system/bin/su、/data/adb/modules等操作时,得到的会是“文件不存在”的结果
Zygisk会伪装成系统原生的“Native Bridge”库,在系统启动时被Zygote进程自动加载
加载后,Zygisk会在Zygote进程中注册一个“fork前”的回调,此时Zygisk会检查目标App是否在DenyList中,或者是否有Zygisk模块需要注入
由于注入在应用代码执行之前就已完成,应用没有机会在启动早期检测到这些模块的存在,而且这个时候进程还没触发UID drop
简单了解了magisk的注入原理后,应继续了解怎么进行检测
Zygisk 会向目标进程注入一个动态库,该库被映射到进程的地址空间,并在内存中留下持久记录,Shamiko 无法将其移除
Shamiko 的功能在于清理文件系统挂载,使得 /data/adb/modules 等目录在目标进程的挂载命名空间中不可见
然而,Zygisk 的注入发生在文件系统隐藏之前,其动态库已经通过 mmap 映射到进程内存中,因此文件系统层面的隐藏无法消除内存中的映射痕迹
读 /proc/self/maps,一行一行看
如果看到
这就是 Zygisk 注入痕迹
可以查询这些关键词
搜到就是有 Zygisk,但关键词只能抓没改名的