新人第一次写博客,勿喷..
本文也发布在知乎上
Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。
注入 zygote 进程
我们想实现Xposed那样在目标进程加载自己的模块,就必须把我们自己的代码注入到目标进程,而且我们的代码执行的时机还需要足够早,一般来说都是选择直接注入到zygote进程。
先来看看其他框架的实现:
- Xposed :Xposed for art 重新实现了app_process,libart.so等重要系统库,安装时会替换这些文件,而各大厂商几乎没有不修改它们的,一旦被替换很可能变砖,导致Xposed在非原生系统上的稳定性很差。
- EdXposed : EdXp依赖 Riru 而Riru是通过替换libmemtrack.so来实现,这个so库会在zygote进程启动时被加载,并且比libart轻得多(只有10个导出函数),然后就可以在zygote进程里执行任意代码。
- 太极阳 : 太极阳通过一种我看不懂的魔法(看了一下只发现libjit.so,但weishu表示Android系统里并没有一个这样一个库,所以并不是简单替换so)注入进zygote(以前是替换libprocessgroup.so)
可以看出,其他框架几乎都通过直接替换系统已有的so库实现,而替换已有so库则需要尽量选择较轻的库,以避免厂商的修改导致的问题。然而,我们没法避免厂商在so里加料,如果厂商修改了这个so库,我们直接把我们自己以AOSP为蓝本写的so替换上去,则会导致严重的问题。
有没有别的什么办法?下面介绍梦境的实现方式。
(注:如无特别说明,本文中的AOSP源码都是 7.0.0_r36 对应的代码)
我们知道,在android中,所有的应用进程都是由zygote进程fork出来的,而zygote对应的可执行文件就是app_process(具体可以看init.rc)
app_process的main方法如下:
1 | int main(int argc, char* const argv[]) |
可以发现,是通过AppRuntime启动的,而AppRuntime继承自AndroidRuntime,start方法的实现在AndroidRuntime里
1 | /* |
注意startVm这个方法,我们点进去看看。
1 | /* |
接下来看JNI_CreateJavaVM方法:
1 | // JNI Invocation interface. |
这个函数不长,我就直接全部贴出来了。注意看android::InitializeNativeLoader(),这个函数直接调用了g_namespaces->Initialize(),而g_namespaces是一个LibraryNamespaces指针,继续看下去,我们发现了宝藏:
1 | void Initialize() { |
public_native_libraries_system_config=/system/etc/public.libraries.txt,而ReadConfig方法很简单,读取传进来的文件路径,按行分割,忽略空行和以#开头的行,然后把这行push_back到传进来的vector里。
所以这个函数做了这几件事:
- 读取/system/etc/public.libraries.txt和/vendor/etc/public.libraries.txt
- 挨个dlopen这两个txt文件里提到的所有so库
注:这里分析的源码是7.0.0的,在7.0往后的所有版本(截至本文发布)你都能找到类似的逻辑。
知道了这件事注入zygote就好办多了嘛!只要把我们自己写的so库扔到/system/lib下面(64位是/syste/lib64),然后在/system/etc/public.libraries.txt里把我们自己的文件加上去,这样zygote启动的时候就会去加载我们的so库,然后我们写一个函数,加上__attribute__((constructor)),这样这个函数就会在so库被加载的时候被调用,我们就完成了注入逻辑;而且这个文件是一个txt文件,只需要追加一行文件名就行,即使厂商做了修改也不用担心,稳定性棒棒哒!
(注1:此方法是我看一篇博客时看见的,那篇博客吐槽“在public.libraries.txt里加上的so库竟然会在zygote启动时被加载,每次修改都要重启手机才能生效,多不方便调试”,但是他抱怨的特性却成为了我的曙光,可惜找不到那篇博客了,没法贴出来…)
(注2:在我大致完成了核心逻辑之后,我在EdXp的源码里发现了这个文件 ;这个部分看起来是使用whale进行java hook的方案,但是我从来没有听说过有使用纯whale进行java hook的EdXp版本,并且我在install.sh中没有看见操作public.libraries.txt,所以不太懂他想用这个文件干什么 :( )
ok,现在我们完成了注入zygote进程的逻辑,刚完成的时候我想,完成了注入部分,ART Hook部分也有很多开源库,那么实现一个xposed不是很简单的事吗?果然我还是太年轻…
监控应用进程启动
前面我们注入了zygote进程,然而这样还不够,我们还需要监控应用进程启动并在应用进程执行代码才行。
刚开始我的想法很简单:直接在zygote里随便用art hook技术hook掉几个java方法;不过在我们的so库被加载的时候Runtime还没启动完成,没法拿到JNIEnv(就算拿到也用不了),这个也好办,native inline hook掉几个会在Runtime初始化完成时调用的函数就行,然并卵,提示无法分配可执行内存。
wtf??为什么分配内存会失败?内存满了?没对齐?最后终于发现这样一条log:
type=1400 audit(0.0:5): avc: denied { execmem } for scontext=u:r:zygote:s0 tcontext=u:r:zygote:s0 tclass=process permissive=0
上网查了一下,这条log代表zygote进程的context(u:r:zygote:s0)不允许分配可执行的匿名内存。这就麻烦了呀,很多事都做不了了(包括java方法的inline hook),想过很多办法(比如替换sepolicy),最后都被我否决了。那怎么办?最后打算去看EdXp的处理方式,没看见任何有关SELinux的部分,似乎是让magisk处理,不过我的是模拟器,没法装magisk。
这个问题困扰了我很久,最后,在Riru的源码里发现了另一种实现方案:通过GOT Hook拦截jniRegisterNativeMethods,然后就可以替换部分关键JNI函数。
简单来说,当发生跨ELF的函数调用时,会去.got表里查这个函数的绝对地址,然后再跳转过去,所以我们直接改这个表就能达到hook的目的,更多实现细节可以看xhook的说明文档。
这种方式的好处是不需要直接操作内存中的指令,不需要去手动分配可执行内存,所以不会受到SELinux的限制;缺点也很明显,如果人家不查.got表,就无法hook了。所以这种方式一般用于hook系统函数,比如来自libc的malloc, open等函数。
好了,GOT Hook并不是重点,接下来Riru使用xhook hook了libandroid_runtime.so对jniRegisterNativeMethods方法(来自libnativehelper.so)的调用,这样就能拦截一部分的JNI方法调用了。为什么说是一部分?因为另一部分JNI函数的实现在libart里,这一部分函数直接通过env->RegisterNativeMethods完成注册,所以无法hook。
之后riru在被替换的jniRegisterNativeMethods中动了一点小手脚:如果正在注册来自Zygote类的JNI方法,那么会把nativeForkSystemServer和nativeForkAndSpecialize替换成自己的实现,这样就能拦截system_server与应用进程的启动了!
Riru的这个方案非常好,但是还有优化空间:nativeForkAndSpecialize这个函数基本上每个版本都会变签名,而且各个厂商也会做修改,Riru的办法很简单:比对签名,如果签名不对那么就不会替换。不过,实际上我们并不需要那么精密的监控进程启动,让我们来找一下有没有其他的hook点。
大家都知道的,zygote进程最终会进入ZygoteInit.main,在main方法里fork出system_server,然后进入死循环接收来自AMS的请求fork出新进程。
1 | public static void main(String argv[]) { |
在startSystemServer里会fork出system_server,runSelectLoop中会进入死循环等待创建进程请求,然后fork出应用进程。
1 | /** |
点进去handleSystemServerProcess里看看:
1 | /** |
最终会进入RuntimeInit.zygoteInit(7.x,对于8.x与以上在ZygoteInit.zygoteInit),记住这一点
然后,应用进程的创建是在runSelectLoop()里,最后会通过ZygoteConnection.runOnce进行处理
1 | /** |
1 | /** |
最后也是通过RuntimeInit.zygoteInit(7.x,对于8.x与以上在ZygoteInit.zygoteInit)完成。点进去看看有没有hook点:
1 | /** |
注意到那个nativeZygoteInit没有!!很明显是个native方法,而且是我们可以hook到的native方法!!这样子我们就可以直接在jniRegisterNativeMethods里替换掉这个方法了!而且这个方法从7.0到10.0也只有过一次改变:从RuntimeInit搬到ZygoteInit,比nativeForkAndSpecialize稳得多。
(然而现在看来某些操作还是需要比较精细的监控的,以后再改吧)
加载Xposed模块
这一部分其实是最简单的,目前已经有很多开源的ART Hook库,拿来就能用,需要自己写的地方也不需要跟太久的系统函数调用。
目前是选择了SandHook作为核心ART Hook库,主要是已经提供好了Xposed API,很方便。
然后是模块管理,因为没有那么多时间去弄,所以只是简单的把对应的配置文件设置成谁都能读,当然以后会优化。
未来
注:本段内容没有什么营养,可以直接跳过。
编译打包自动化
对,目前连自动化打包都没实现,还是手动拿dex等等然后手动压缩进去…
支持magisk安装
现在只支持通过安装脚本直接修改/system,如果能够支持magisk模块式安装会少很多麻烦,比如如果变砖了只需要用mm管理器把模块给删了就好了
支持重要系统进程加载模块
由于SELinux限制,目前不支持关键系统进程(如Zygote和system_server)加载模块,我这边没有什么很好的解决办法,求各位大佬赐教 :)
顺便列出一些其他的限制:
android 9,隐藏API访问限制,这个好办,绕过方式有很多,就不细讲了。
Android 9及以上,zygote&system_server还有其他系统应用会SetOnlyUseSystemOatFiles(),然后就不能加载不在/system下面的oat文件了,如果违反这个策略就会直接abort掉。
android zygote fork新进程时,如果有不在白名单中的文件描述符,会进到ZygoteFailure里,然后整个进程abort掉。
配置文件加载部分
因为在目标进程里加载模块肯定需要获取对应的配置,目前的做法是,把对应的配置文件设置成谁都能读,然后直接读这个文件就行,这样做当然不妥,所以计划以后去优化,比如优化成单独跑一个配置守护进程,只有这个进程能去读写配置,其他应用只能通过跨进程交互的方式拿到配置。
重新实现Xposed API
目前梦境的Xposed API是SandHook自带的xposedcompat,通过DexMaker动态创建新的dex实现适配,这么做没有什么兼容性问题,但是有很大的效率问题,如果是第一次创建这个方法,需要生成dex,一套流程走下来可能就要用上100+ms。
在xposedcompat_new中,有另一种实现方案:通过动态代理动态生成方法,然后把这个方法设置成native,对应的native函数也是通过libffi动态生成的,在这个native方法里跳到分发函数执行。这个方案对我来说很不错,至少不会太慢。(当然稳定性存疑)
结语
目前梦境框架还有非常多的不足,目前只能当个PoC用,如果你有兴趣,不妨一起来玩 ^_^
核心:Dreamland
配套的管理器 Dreamland Manager
QQ群:949888394