新人第一次写博客,勿喷..
本文也发布在知乎

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, char* const argv[])
{
// 省略无关代码...
AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv));
// 省略无关代码...
if (zygote) {
runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
} else if (className) {
runtime.start("com.android.internal.os.RuntimeInit", args, zygote);
} else {
fprintf(stderr, "Error: no class name or --zygote supplied.\n");
app_usage();
LOG_ALWAYS_FATAL("app_process: no class name or --zygote supplied.");
return 10;
}
}

可以发现,是通过AppRuntime启动的,而AppRuntime继承自AndroidRuntime,start方法的实现在AndroidRuntime里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* Start the Android runtime. This involves starting the virtual machine
* and calling the "static void main(String[] args)" method in the class
* named by "className".
*
* Passes the main function two arguments, the class name and the specified
* options string.
*/
void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
// 省略无关代码...
/* start the virtual machine */
JniInvocation jni_invocation;
jni_invocation.Init(NULL);
JNIEnv* env;
if (startVm(&mJavaVM, &env, zygote) != 0) {
return;
}
onVmCreated(env);

/*
* Register android functions.
*/
if (startReg(env) < 0) {
ALOGE("Unable to register all android natives\n");
return;
}
// 省略无关代码...
}

注意startVm这个方法,我们点进去看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*
* Start the Dalvik Virtual Machine.
*
* Various arguments, most determined by system properties, are passed in.
* The "mOptions" vector is updated.
*
* CAUTION: when adding options in here, be careful not to put the
* char buffer inside a nested scope. Adding the buffer to the
* options using mOptions.add() does not copy the buffer, so if the
* buffer goes out of scope the option may be overwritten. It's best
* to put the buffer at the top of the function so that it is more
* unlikely that someone will surround it in a scope at a later time
* and thus introduce a bug.
*
* Returns 0 on success.
*/
int AndroidRuntime::startVm(JavaVM** pJavaVM, JNIEnv** pEnv, bool zygote)
{
// 省略无关代码...
/*
* Initialize the VM.
*
* The JavaVM* is essentially per-process, and the JNIEnv* is per-thread.
* If this call succeeds, the VM is ready, and we can start issuing
* JNI calls.
*/
if (JNI_CreateJavaVM(pJavaVM, pEnv, &initArgs) < 0) {
ALOGE("JNI_CreateJavaVM failed\n");
return -1;
}
return 0;
}

接下来看JNI_CreateJavaVM方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// JNI Invocation interface.

extern "C" jint JNI_CreateJavaVM(JavaVM** p_vm, JNIEnv** p_env, void* vm_args) {
ScopedTrace trace(__FUNCTION__);
const JavaVMInitArgs* args = static_cast<JavaVMInitArgs*>(vm_args);
if (IsBadJniVersion(args->version)) {
LOG(ERROR) << "Bad JNI version passed to CreateJavaVM: " << args->version;
return JNI_EVERSION;
}
RuntimeOptions options;
for (int i = 0; i < args->nOptions; ++i) {
JavaVMOption* option = &args->options[i];
options.push_back(std::make_pair(std::string(option->optionString), option->extraInfo));
}
bool ignore_unrecognized = args->ignoreUnrecognized;
if (!Runtime::Create(options, ignore_unrecognized)) {
return JNI_ERR;
}

// Initialize native loader. This step makes sure we have
// everything set up before we start using JNI.
android::InitializeNativeLoader();

Runtime* runtime = Runtime::Current();
bool started = runtime->Start();
if (!started) {
delete Thread::Current()->GetJniEnv();
delete runtime->GetJavaVM();
LOG(WARNING) << "CreateJavaVM failed";
return JNI_ERR;
}

*p_env = Thread::Current()->GetJniEnv();
*p_vm = runtime->GetJavaVM();
return JNI_OK;
}

这个函数不长,我就直接全部贴出来了。注意看android::InitializeNativeLoader(),这个函数直接调用了g_namespaces->Initialize(),而g_namespaces是一个LibraryNamespaces指针,继续看下去,我们发现了宝藏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void Initialize() {
std::vector<std::string> sonames;
const char* android_root_env = getenv("ANDROID_ROOT");
std::string root_dir = android_root_env != nullptr ? android_root_env : "/system";
std::string public_native_libraries_system_config =
root_dir + kPublicNativeLibrariesSystemConfigPathFromRoot;

LOG_ALWAYS_FATAL_IF(!ReadConfig(public_native_libraries_system_config, &sonames),
"Error reading public native library list from \"%s\": %s",
public_native_libraries_system_config.c_str(), strerror(errno));

// 省略无关代码

// This file is optional, quietly ignore if the file does not exist.
ReadConfig(kPublicNativeLibrariesVendorConfig, &sonames);

// android_init_namespaces() expects all the public libraries
// to be loaded so that they can be found by soname alone.
//
// TODO(dimitry): this is a bit misleading since we do not know
// if the vendor public library is going to be opened from /vendor/lib
// we might as well end up loading them from /system/lib
// For now we rely on CTS test to catch things like this but
// it should probably be addressed in the future.
for (const auto& soname : sonames) {
dlopen(soname.c_str(), RTLD_NOW | RTLD_NODELETE);
}

public_libraries_ = base::Join(sonames, ':');
}

public_native_libraries_system_config=/system/etc/public.libraries.txt,而ReadConfig方法很简单,读取传进来的文件路径,按行分割,忽略空行和以#开头的行,然后把这行push_back到传进来的vector里。
所以这个函数做了这几件事:

  1. 读取/system/etc/public.libraries.txt和/vendor/etc/public.libraries.txt
  2. 挨个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
2
3
4
5
6
7
8
9
10
public static void main(String argv[]) {
// 省略无关代码...
if (startSystemServer) {
startSystemServer(abiList, socketName);
}
// 省略无关代码...
Log.i(TAG, "Accepting command socket connections");
runSelectLoop(abiList);
// 省略无关代码...
}

在startSystemServer里会fork出system_server,runSelectLoop中会进入死循环等待创建进程请求,然后fork出应用进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Prepare the arguments and fork for the system server process.
*/
private static boolean startSystemServer(String abiList, String socketName)
throws MethodAndArgsCaller, RuntimeException {
// 省略无关代码...
/* Request to fork the system server process */
pid = Zygote.forkSystemServer(
parsedArgs.uid, parsedArgs.gid,
parsedArgs.gids,
parsedArgs.debugFlags,
null,
parsedArgs.permittedCapabilities,
parsedArgs.effectiveCapabilities);
/* For child process */
if (pid == 0) { // 注:返回0代表子进程
if (hasSecondZygote(abiList)) {
waitForSecondaryZygote(socketName);
}
handleSystemServerProcess(parsedArgs);
}
}

点进去handleSystemServerProcess里看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Finish remaining work for the newly forked system server process.
*/
private static void handleSystemServerProcess(
ZygoteConnection.Arguments parsedArgs)
throws ZygoteInit.MethodAndArgsCaller {
// 省略无关代码...
if (parsedArgs.invokeWith != null) {
// 走不进去,省略
} else {
ClassLoader cl = null;
if (systemServerClasspath != null) {
cl = createSystemServerClassLoader(systemServerClasspath,
parsedArgs.targetSdkVersion);
Thread.currentThread().setContextClassLoader(cl);
}
/*
* Pass the remaining arguments to SystemServer.
*/
RuntimeInit.zygoteInit(parsedArgs.targetSdkVersion, parsedArgs.remainingArgs, cl);
}
}

最终会进入RuntimeInit.zygoteInit(7.x,对于8.x与以上在ZygoteInit.zygoteInit),记住这一点

然后,应用进程的创建是在runSelectLoop()里,最后会通过ZygoteConnection.runOnce进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Reads one start command from the command socket. If successful,
* a child is forked and a {@link ZygoteInit.MethodAndArgsCaller}
* exception is thrown in that child while in the parent process,
* the method returns normally. On failure, the child is not
* spawned and messages are printed to the log and stderr. Returns
* a boolean status value indicating whether an end-of-file on the command
* socket has been encountered.
*
* @return false if command socket should continue to be read from, or
* true if an end-of-file has been encountered.
* @throws ZygoteInit.MethodAndArgsCaller trampoline to invoke main()
* method in child process
*/
boolean runOnce() throws ZygoteInit.MethodAndArgsCaller {
//忽略无关代码
pid = Zygote.forkAndSpecialize(parsedArgs.uid, parsedArgs.gid, parsedArgs.gids,
parsedArgs.debugFlags, rlimits, parsedArgs.mountExternal, parsedArgs.seInfo,
parsedArgs.niceName, fdsToClose, parsedArgs.instructionSet,
parsedArgs.appDataDir);
// 忽略无关代码
if (pid == 0) { // 子进程
// in child
IoUtils.closeQuietly(serverPipeFd);
serverPipeFd = null;
handleChildProc(parsedArgs, descriptors, childPipeFd, newStderr);

// should never get here, the child is expected to either
// throw ZygoteInit.MethodAndArgsCaller or exec().
return true;
} else {
// 忽略
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Handles post-fork setup of child proc, closing sockets as appropriate,
* reopen stdio as appropriate, and ultimately throwing MethodAndArgsCaller
* if successful or returning if failed.
*
* @param parsedArgs non-null; zygote args
* @param descriptors null-ok; new file descriptors for stdio if available.
* @param pipeFd null-ok; pipe for communication back to Zygote.
* @param newStderr null-ok; stream to use for stderr until stdio
* is reopened.
*
* @throws ZygoteInit.MethodAndArgsCaller on success to
* trampoline to code that invokes static main.
*/
private void handleChildProc(Arguments parsedArgs,
FileDescriptor[] descriptors, FileDescriptor pipeFd, PrintStream newStderr)
throws ZygoteInit.MethodAndArgsCaller {
// 忽略无关代码
if (parsedArgs.invokeWith != null) {
// 走不进去,省略
} else {
RuntimeInit.zygoteInit(parsedArgs.targetSdkVersion,
parsedArgs.remainingArgs, null /* classLoader */);
}
}

最后也是通过RuntimeInit.zygoteInit(7.x,对于8.x与以上在ZygoteInit.zygoteInit)完成。点进去看看有没有hook点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* The main function called when started through the zygote process. This
* could be unified with main(), if the native code in nativeFinishInit()
* were rationalized with Zygote startup.<p>
*
* Current recognized args:
* <ul>
* <li> <code> [--] &lt;start class name&gt; &lt;args&gt;
* </ul>
*
* @param targetSdkVersion target SDK version
* @param argv arg strings
*/
public static final void zygoteInit(int targetSdkVersion, String[] argv, ClassLoader classLoader)
throws ZygoteInit.MethodAndArgsCaller {
if (DEBUG) Slog.d(TAG, "RuntimeInit: Starting application from zygote");

Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "RuntimeInit");
redirectLogStreams();

commonInit();
nativeZygoteInit();
applicationInit(targetSdkVersion, argv, classLoader);
}

注意到那个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