From 2a21b6f38c6234dbd8257858a0f34aabbbf4e7da Mon Sep 17 00:00:00 2001 From: canyie Date: Mon, 27 May 2024 20:57:36 +0800 Subject: [PATCH] =?UTF-8?q?friends:=20add=20=E9=BB=84=E5=A4=A7=E5=AE=98AOS?= =?UTF-8?q?P=20&=20=E4=B8=80=E8=A8=80,=20close=20#7;=20general:=20fix=20fa?= =?UTF-8?q?vicon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 2019/10/28/hello-world/index.html | 2 +- .../03/a-new-xposed-style-framework/index.html | 2 +- .../15/fast-load-dex-on-art-runtime/index.html | 2 +- .../dynamic-hooking-framework-on-art/index.html | 2 +- .../index.html | 2 +- 2020/08/18/nbinjection/index.html | 2 +- 2020/08/20/af447-in-aci/index.html | 2 +- 2021/05/01/anti-magisk-xposed/index.html | 2 +- 2022/01/09/end-of-2021/index.html | 2 +- .../index.html | 2 +- .../index.html | 2 +- 2023/11/06/first-19-years-of-my-life/index.html | 2 +- .../index.html | 2 +- .../android-security-bulletin-index/index.html | 2 +- 404.html | 2 +- about/index.html | 2 +- archives/2019/10/index.html | 2 +- archives/2019/index.html | 2 +- archives/2020/02/index.html | 2 +- archives/2020/04/index.html | 2 +- archives/2020/06/index.html | 2 +- archives/2020/08/index.html | 2 +- archives/2020/index.html | 2 +- archives/2021/05/index.html | 2 +- archives/2021/index.html | 2 +- archives/2022/01/index.html | 2 +- archives/2022/04/index.html | 2 +- archives/2022/index.html | 2 +- archives/2023/03/index.html | 2 +- archives/2023/11/index.html | 2 +- archives/2023/index.html | 2 +- archives/2024/04/index.html | 2 +- archives/2024/index.html | 2 +- archives/index.html | 2 +- archives/page/2/index.html | 2 +- categories/index.html | 2 +- content.json | 2 +- data/image/dopamine.jpg | Bin 0 -> 39180 bytes data/image/favicon.ico | Bin 0 -> 15406 bytes friends/index.html | 2 +- index.html | 2 +- page/2/index.html | 2 +- service-worker.js | 2 +- sitemap.xml | 14 +++++++------- tags/AOP/index.html | 2 +- tags/android/index.html | 2 +- tags/art/index.html | 2 +- tags/dex/index.html | 2 +- tags/hidden-api/index.html | 2 +- tags/hook/index.html | 2 +- tags/index.html | 2 +- tags/kernel/index.html | 2 +- tags/magisk/index.html | 2 +- tags/property/index.html | 2 +- tags/system/index.html | 2 +- tags/xposed/index.html | 2 +- "tags/\345\256\211\345\205\250/index.html" | 2 +- "tags/\345\271\264\351\211\264/index.html" | 2 +- .../index.html" | 2 +- "tags/\346\263\250\345\205\245/index.html" | 2 +- "tags/\350\210\252\347\251\272/index.html" | 2 +- "tags/\351\227\262\350\201\212/index.html" | 2 +- 62 files changed, 66 insertions(+), 66 deletions(-) create mode 100644 data/image/dopamine.jpg create mode 100644 data/image/favicon.ico diff --git a/2019/10/28/hello-world/index.html b/2019/10/28/hello-world/index.html index c003321..564b9a8 100644 --- a/2019/10/28/hello-world/index.html +++ b/2019/10/28/hello-world/index.html @@ -1,4 +1,4 @@ -我也有自己的个人博客啦! - 残页的小博客

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

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


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

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


在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

源码分析

注:为了说明此方法能在较低版本的ART上运行,本文分析的源码是Android 5.0的源码,之后的Android版本里OpenDexFileFromOat方法搬到了OatFileManager里,而调用dex2oat的部分则重构到了OatFileAssistant中,大致逻辑相同,感兴趣的可以自己去看看;至于Android 4.4,简单扫了一下源码似乎是生成oat失败就会直接抛一个IOException拒绝加载,emmm……

我们在Java代码里用new DexClassLoader()的方式加载dex,最后会调用到DexFile.openDexFileNative中,这个函数的实现是这样的:

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
static jlong DexFile_openDexFileNative(JNIEnv* env, jclass, jstring javaSourceName, jstring javaOutputName, jint) {
ScopedUtfChars sourceName(env, javaSourceName);
if (sourceName.c_str() == NULL) {
return 0;
}
NullableScopedUtfChars outputName(env, javaOutputName);
if (env->ExceptionCheck()) {
return 0;
}

ClassLinker* linker = Runtime::Current()->GetClassLinker();
std::unique_ptr<std::vector<const DexFile*>> dex_files(new std::vector<const DexFile*>());
std::vector<std::string> error_msgs;

bool success = linker->OpenDexFilesFromOat(sourceName.c_str(), outputName.c_str(), &error_msgs,
dex_files.get());

if (success || !dex_files->empty()) {
// In the case of non-success, we have not found or could not generate the oat file.
// But we may still have found a dex file that we can use.
return static_cast<jlong>(reinterpret_cast<uintptr_t>(dex_files.release()));
} else {
// 加载失败的情况,省略
}
}

这里的注释很有意思,如果返回false(生成oat失败),但是有被成功加载的dex,那么还是应该当做成功。
可以看出具体实现在ClassLinker中的OpenDexFilesFromOat里,我们点进去看看:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// Multidex files make it possible that some, but not all, dex files can be broken/outdated. This
// complicates the loading process, as we should not use an iterative loading process, because that
// would register the oat file and dex files that come before the broken one. Instead, check all
// multidex ahead of time.
bool ClassLinker::OpenDexFilesFromOat(const char* dex_location, const char* oat_location,
std::vector<std::string>* error_msgs,
std::vector<const DexFile*>* dex_files) {
// 1) Check whether we have an open oat file.
// This requires a dex checksum, use the "primary" one.
bool needs_registering = false;

const OatFile::OatDexFile* oat_dex_file = FindOpenedOatDexFile(oat_location, dex_location,
dex_location_checksum_pointer);
std::unique_ptr<const OatFile> open_oat_file(
oat_dex_file != nullptr ? oat_dex_file->GetOatFile() : nullptr);

// 2) If we do not have an open one, maybe there's one on disk already.

// In case the oat file is not open, we play a locking game here so
// that if two different processes race to load and register or generate
// (or worse, one tries to open a partial generated file) we will be okay.
// This is actually common with apps that use DexClassLoader to work
// around the dex method reference limit and that have a background
// service running in a separate process.
ScopedFlock scoped_flock;

if (open_oat_file.get() == nullptr) {
if (oat_location != nullptr) {
std::string error_msg;

// We are loading or creating one in the future. Time to set up the file lock.
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
return false;
}

// TODO Caller specifically asks for this oat_location. We should honor it. Probably?
open_oat_file.reset(FindOatFileInOatLocationForDexFile(dex_location, dex_location_checksum,
oat_location, &error_msg));

if (open_oat_file.get() == nullptr) {
std::string compound_msg = StringPrintf("Failed to find dex file '%s' in oat location '%s': %s",
dex_location, oat_location, error_msg.c_str());
VLOG(class_linker) << compound_msg;
error_msgs->push_back(compound_msg);
}
} else {
// TODO: What to lock here?
bool obsolete_file_cleanup_failed;
open_oat_file.reset(FindOatFileContainingDexFileFromDexLocation(dex_location,
dex_location_checksum_pointer,
kRuntimeISA, error_msgs,
&obsolete_file_cleanup_failed));
// There's no point in going forward and eventually try to regenerate the
// file if we couldn't remove the obsolete one. Mostly likely we will fail
// with the same error when trying to write the new file.
// TODO: should we maybe do this only when we get permission issues? (i.e. EACCESS).
if (obsolete_file_cleanup_failed) {
return false;
}
}
needs_registering = true;
}

// 3) If we have an oat file, check all contained multidex files for our dex_location.
// Note: LoadMultiDexFilesFromOatFile will check for nullptr in the first argument.
bool success = LoadMultiDexFilesFromOatFile(open_oat_file.get(), dex_location,
dex_location_checksum_pointer,
false, error_msgs, dex_files);
if (success) {
// 我们没有有效的oat文件,所以不会走到这里
} else {
if (needs_registering) {
// We opened it, delete it.
open_oat_file.reset();
} else {
open_oat_file.release(); // Do not delete open oat files.
}
}

// 4) If it's not the case (either no oat file or mismatches), regenerate and load.

// Look in cache location if no oat_location is given.
std::string cache_location;
if (oat_location == nullptr) {
// Use the dalvik cache.
const std::string dalvik_cache(GetDalvikCacheOrDie(GetInstructionSetString(kRuntimeISA)));
cache_location = GetDalvikCacheFilenameOrDie(dex_location, dalvik_cache.c_str());
oat_location = cache_location.c_str();
}

bool has_flock = true;
// Definitely need to lock now.
if (!scoped_flock.HasFile()) {
std::string error_msg;
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
has_flock = false;
}
}

if (Runtime::Current()->IsDex2OatEnabled() && has_flock && scoped_flock.HasFile()) {
// Create the oat file.
open_oat_file.reset(CreateOatFileForDexLocation(dex_location, scoped_flock.GetFile()->Fd(),
oat_location, error_msgs));
}

// Failed, bail.
if (open_oat_file.get() == nullptr) { // 如果无法生成oat,那么直接加载dex
std::string error_msg;
// dex2oat was disabled or crashed. Add the dex file in the list of dex_files to make progress.
DexFile::Open(dex_location, dex_location, &error_msg, dex_files);
error_msgs->push_back(error_msg);
return false;
}
// 再次尝试加载oat,无关,省略
}

这个函数比较长,所以做了一点精简。
我们在这里看到了一点端倪,这个函数做了这些事情:

  1. 检查我们是否已经有一个打开了的oat
  2. 如果没有,那么检查oat缓存目录(创建DexClassLoader时传入的第二个参数)是否已经有了一个oat,并且检查这个oat的有效性
  3. 如果没有或者这个oat是无效的,那么生成一个oat文件

我们首次加载dex时,肯定没有有效的oat,最后会生成一个新的oat:

1
2
3
4
5
if (Runtime::Current()->IsDex2OatEnabled() && has_flock && scoped_flock.HasFile()) {
// Create the oat file.
open_oat_file.reset(CreateOatFileForDexLocation(dex_location, scoped_flock.GetFile()->Fd(),
oat_location, error_msgs));
}

这里有一个if判断,直接决定是否进行dex2oat,我们看看能不能通过各种手段让这个判断不成立。

禁用dex2oat

第一招:修改Runtime中的变量

这个if判断里,第一个条件就是Runtime::Current()->IsDex2OatEnabled(),如果返回false,那么就不会生成oat。这个函数的实现如下:

1
2
3
4
5
6
7
bool IsDex2OatEnabled() const {
return dex2oat_enabled_ && IsImageDex2OatEnabled();
}

bool IsImageDex2OatEnabled() const {
return image_dex2oat_enabled_;
}

dex2oat_enabled_image_dex2oat_enabled_都是Runtime对象中的成员变量,而Runtime可以通过JavaVM获取,所以我们只需要修改这个值就能禁用dex2oat。已经有其他人实现了这一步,具体可以看看这篇博客
然而事情真的会这么简单吗?
查看源码发现Runtime是一个炒鸡大的结构体,Android里有什么东西都往这扔,你几乎可以从Runtime对象上直接或间接获取到任何东西,然而也正是因为Runtime太大了,使得没有什么好的办法获取里面的值。

让我们看看还有没有其他方法:

第二招:使用PathClassLoader

我们可以看见,在if判断里,还有两个条件:has_flockscoped_flock.HasFile(),让我们看看是否可以让这两个条件不成立。
has_flock的赋值:

1
2
3
4
5
6
7
8
9
bool has_flock = true;
// Definitely need to lock now.
if (!scoped_flock.HasFile()) {
std::string error_msg;
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
has_flock = false;
}
}

又是scoped_flock,看看在上面scoped_flock可能在哪里被初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
ScopedFlock scoped_flock;
if (open_oat_file.get() == nullptr) {
if (oat_location != nullptr) {
std::string error_msg;

// We are loading or creating one in the future. Time to set up the file lock.
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
return false;
}
// 省略代码
}
}

看看ScopedFlock的Init方法:

1
2
3
4
5
6
7
8
9
10
bool ScopedFlock::Init(const char* filename, std::string* error_msg) {
while (true) {
file_.reset(OS::OpenFileWithFlags(filename, O_CREAT | O_RDWR));
if (file_.get() == NULL) {
*error_msg = StringPrintf("Failed to open file '%s': %s", filename, strerror(errno));
return false;
}
// 省略一大堆代码……
}
}

可以看见,会打开这个文件,flags为O_CREAT | O_RDWR,那我们只需要设置oat_location为不可写的路径,就能让ScopedFlock::Init返回false。不过我们要注意的是,如果oat_location不为null并且无法使用,那在上面的一个判断里就会直接返回false。怎么办?

是时候请出我们的主角PathClassLoader了!

PathClassLoader作为DexClassLoader的兄弟(也可能是姐妹?),受到的待遇与DexClassLoader截然不同:网上讲解动态加载dex的文章几乎都只讲DexClassLoader,而对于PathClassLoader则是一笔带过:“PathClassLoader只能加载系统中已经安装过的apk”。
然而事实真的是这样吗?或许Android 5.0以前是,但Android 5.0时就已经可以加载外部dex了,今天我要为PathClassLoader正名!
让我们来对比一下DexClassLoader和PathClassLoader的源码。
DexClassLoader:

1
2
3
4
5
6
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}

对,你没看错,有效代码就这么点。
让我们再看看PathClassLoader的源码:

1
2
3
4
5
6
7
8
9
10
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}

public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent) {
super(dexPath, null, libraryPath, parent);
}
}

实际上所以实现代码都在BaseDexClassLoader中,DexClassLoader和PathClassLoader都调用了同一个构造函数:

1
2
3
4
5
6
7
8
9
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
}

注意第二个参数,optimizedDirectory,DexClassLoader传入的是new File(optimizedDirectory),而PathClassLoader传入的是null。记住这一点。
这两种情况最后都会调用到DexFile.openDexFileNative中

1
private static native long openDexFileNative(String sourceName, String outputName, int flags);

如果是PathClassLoader,outputName为null,会进入这个if分支中:

1
2
3
4
5
6
7
8
// Look in cache location if no oat_location is given.
std::string cache_location;
if (oat_location == nullptr) {
// Use the dalvik cache.
const std::string dalvik_cache(GetDalvikCacheOrDie(GetInstructionSetString(kRuntimeISA)));
cache_location = GetDalvikCacheFilenameOrDie(dex_location, dalvik_cache.c_str());
oat_location = cache_location.c_str();
}

这里会把oat_location设置成/data/dalvik-cache/下的路径,接下来因为我们根本没有对dalvik-cache的写入权限,所以无法打开fd,然后就会走到这里直接加载原始dex

1
2
3
4
5
6
7
if (open_oat_file.get() == nullptr) {
std::string error_msg;
// dex2oat was disabled or crashed. Add the dex file in the list of dex_files to make progress.
DexFile::Open(dex_location, dex_location, &error_msg, dex_files);
error_msgs->push_back(error_msg);
return false;
}

至此整个逻辑已经明朗,通过PathClassLoader加载会把oat输出路径设置成/data/dalvik-cache/下,然后因为我们没有对dalvik-cache的写入权限,所以无法打开fd,之后会直接加载原始dex,不会进行dex2oat。
(注:本文分析源码是Android 5.0Android 8.1时源码有改动,就算是DexClassLoader也会把optimizedDirectory设置成null,输出的oat在dex的父目录/oat/下,所以无法通过PathClassLoader快速加载dex,但在8.1时已经有InMemoryDexClassLoader了,直接通过InMemoryDexClassLoader加载就好了。

简单做了个小测试,在我的AVD(Android 7.1.1)上,用DexClassLoader加载75M的qq apk用了近80秒,并生成了一个313M的oat,而PathClassLoader用时稳定在2秒左右,emmm……

看起来我们已经有一个比较好的办法禁用dex2oat了,不过需要修改源码没法直接全局禁用,修改Runtime风险又太大,让我们看看还有没有其他方法。

第三招:hook execv

到了这里,上面那个判断肯定会成立了,似乎进行dex2oat已成定局?我们继续看CreateOatFileForDexLocation。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const OatFile* ClassLinker::CreateOatFileForDexLocation(const char* dex_location,
int fd, const char* oat_location,
std::vector<std::string>* error_msgs) {
// Generate the output oat file for the dex file
VLOG(class_linker) << "Generating oat file " << oat_location << " for " << dex_location;
std::string error_msg;
if (!GenerateOatFile(dex_location, fd, oat_location, &error_msg)) {
CHECK(!error_msg.empty());
error_msgs->push_back(error_msg);
return nullptr;
}
std::unique_ptr<OatFile> oat_file(OatFile::Open(oat_location, oat_location, nullptr,
!Runtime::Current()->IsCompiler(),
&error_msg));
if (oat_file.get() == nullptr) {
std::string compound_msg = StringPrintf("\nFailed to open generated oat file '%s': %s",
oat_location, error_msg.c_str());
error_msgs->push_back(compound_msg);
return nullptr;
}

return oat_file.release();
}

GenerateOatFile是核心逻辑,这个函数大部分都是我们不关心的配置dex2oat参数就不贴出来了,最后会fork出一个新进程,然后在子进程里执行execv()调用dex2oat。
看起来我们必然要执行dex2oat了?别慌,还有办法。虽然没有直接的开关去阻止dex2oat,但我们还有hook大法!生成oat最后是通过execv调用dex2oat进行的,所以我们可以hook掉execv函数,如果是执行dex2oat那么直接让这个进程退出即可!Lody大神的早期作品TurboDex就是这样实现的。不过这个项目其实还可以优化一下:TurboDex是使用的Substrate进行hook,这是一个inline hook库,而execv是来自libc.so的导出符号,其实直接通过GOT Hook就能hook到,没有必要去用inline hook,反而增加crash风险。

总结

本来我只是为了研究DexClassLoader与PathClassLoader的区别的,网上的文章和实验的结果完全不一样,结果意外发现一个快速加载dex的方法,就写出来了 :)
这个故事告诉我们,没事多看源码(手动滑稽)
另外个人建议,快速加载dex之后后台可以开一个线程单独进行dex2oat,具体可以参考ArtDexOptimizer,下次启动的时候如果完成了可以直接用生成好的oat文件,毕竟用oat比直接加载dex快得多,而且更稳定~


在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

源码分析

注:为了说明此方法能在较低版本的ART上运行,本文分析的源码是Android 5.0的源码,之后的Android版本里OpenDexFileFromOat方法搬到了OatFileManager里,而调用dex2oat的部分则重构到了OatFileAssistant中,大致逻辑相同,感兴趣的可以自己去看看;至于Android 4.4,简单扫了一下源码似乎是生成oat失败就会直接抛一个IOException拒绝加载,emmm……

我们在Java代码里用new DexClassLoader()的方式加载dex,最后会调用到DexFile.openDexFileNative中,这个函数的实现是这样的:

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
static jlong DexFile_openDexFileNative(JNIEnv* env, jclass, jstring javaSourceName, jstring javaOutputName, jint) {
ScopedUtfChars sourceName(env, javaSourceName);
if (sourceName.c_str() == NULL) {
return 0;
}
NullableScopedUtfChars outputName(env, javaOutputName);
if (env->ExceptionCheck()) {
return 0;
}

ClassLinker* linker = Runtime::Current()->GetClassLinker();
std::unique_ptr<std::vector<const DexFile*>> dex_files(new std::vector<const DexFile*>());
std::vector<std::string> error_msgs;

bool success = linker->OpenDexFilesFromOat(sourceName.c_str(), outputName.c_str(), &error_msgs,
dex_files.get());

if (success || !dex_files->empty()) {
// In the case of non-success, we have not found or could not generate the oat file.
// But we may still have found a dex file that we can use.
return static_cast<jlong>(reinterpret_cast<uintptr_t>(dex_files.release()));
} else {
// 加载失败的情况,省略
}
}

这里的注释很有意思,如果返回false(生成oat失败),但是有被成功加载的dex,那么还是应该当做成功。
可以看出具体实现在ClassLinker中的OpenDexFilesFromOat里,我们点进去看看:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// Multidex files make it possible that some, but not all, dex files can be broken/outdated. This
// complicates the loading process, as we should not use an iterative loading process, because that
// would register the oat file and dex files that come before the broken one. Instead, check all
// multidex ahead of time.
bool ClassLinker::OpenDexFilesFromOat(const char* dex_location, const char* oat_location,
std::vector<std::string>* error_msgs,
std::vector<const DexFile*>* dex_files) {
// 1) Check whether we have an open oat file.
// This requires a dex checksum, use the "primary" one.
bool needs_registering = false;

const OatFile::OatDexFile* oat_dex_file = FindOpenedOatDexFile(oat_location, dex_location,
dex_location_checksum_pointer);
std::unique_ptr<const OatFile> open_oat_file(
oat_dex_file != nullptr ? oat_dex_file->GetOatFile() : nullptr);

// 2) If we do not have an open one, maybe there's one on disk already.

// In case the oat file is not open, we play a locking game here so
// that if two different processes race to load and register or generate
// (or worse, one tries to open a partial generated file) we will be okay.
// This is actually common with apps that use DexClassLoader to work
// around the dex method reference limit and that have a background
// service running in a separate process.
ScopedFlock scoped_flock;

if (open_oat_file.get() == nullptr) {
if (oat_location != nullptr) {
std::string error_msg;

// We are loading or creating one in the future. Time to set up the file lock.
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
return false;
}

// TODO Caller specifically asks for this oat_location. We should honor it. Probably?
open_oat_file.reset(FindOatFileInOatLocationForDexFile(dex_location, dex_location_checksum,
oat_location, &error_msg));

if (open_oat_file.get() == nullptr) {
std::string compound_msg = StringPrintf("Failed to find dex file '%s' in oat location '%s': %s",
dex_location, oat_location, error_msg.c_str());
VLOG(class_linker) << compound_msg;
error_msgs->push_back(compound_msg);
}
} else {
// TODO: What to lock here?
bool obsolete_file_cleanup_failed;
open_oat_file.reset(FindOatFileContainingDexFileFromDexLocation(dex_location,
dex_location_checksum_pointer,
kRuntimeISA, error_msgs,
&obsolete_file_cleanup_failed));
// There's no point in going forward and eventually try to regenerate the
// file if we couldn't remove the obsolete one. Mostly likely we will fail
// with the same error when trying to write the new file.
// TODO: should we maybe do this only when we get permission issues? (i.e. EACCESS).
if (obsolete_file_cleanup_failed) {
return false;
}
}
needs_registering = true;
}

// 3) If we have an oat file, check all contained multidex files for our dex_location.
// Note: LoadMultiDexFilesFromOatFile will check for nullptr in the first argument.
bool success = LoadMultiDexFilesFromOatFile(open_oat_file.get(), dex_location,
dex_location_checksum_pointer,
false, error_msgs, dex_files);
if (success) {
// 我们没有有效的oat文件,所以不会走到这里
} else {
if (needs_registering) {
// We opened it, delete it.
open_oat_file.reset();
} else {
open_oat_file.release(); // Do not delete open oat files.
}
}

// 4) If it's not the case (either no oat file or mismatches), regenerate and load.

// Look in cache location if no oat_location is given.
std::string cache_location;
if (oat_location == nullptr) {
// Use the dalvik cache.
const std::string dalvik_cache(GetDalvikCacheOrDie(GetInstructionSetString(kRuntimeISA)));
cache_location = GetDalvikCacheFilenameOrDie(dex_location, dalvik_cache.c_str());
oat_location = cache_location.c_str();
}

bool has_flock = true;
// Definitely need to lock now.
if (!scoped_flock.HasFile()) {
std::string error_msg;
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
has_flock = false;
}
}

if (Runtime::Current()->IsDex2OatEnabled() && has_flock && scoped_flock.HasFile()) {
// Create the oat file.
open_oat_file.reset(CreateOatFileForDexLocation(dex_location, scoped_flock.GetFile()->Fd(),
oat_location, error_msgs));
}

// Failed, bail.
if (open_oat_file.get() == nullptr) { // 如果无法生成oat,那么直接加载dex
std::string error_msg;
// dex2oat was disabled or crashed. Add the dex file in the list of dex_files to make progress.
DexFile::Open(dex_location, dex_location, &error_msg, dex_files);
error_msgs->push_back(error_msg);
return false;
}
// 再次尝试加载oat,无关,省略
}

这个函数比较长,所以做了一点精简。
我们在这里看到了一点端倪,这个函数做了这些事情:

  1. 检查我们是否已经有一个打开了的oat
  2. 如果没有,那么检查oat缓存目录(创建DexClassLoader时传入的第二个参数)是否已经有了一个oat,并且检查这个oat的有效性
  3. 如果没有或者这个oat是无效的,那么生成一个oat文件

我们首次加载dex时,肯定没有有效的oat,最后会生成一个新的oat:

1
2
3
4
5
if (Runtime::Current()->IsDex2OatEnabled() && has_flock && scoped_flock.HasFile()) {
// Create the oat file.
open_oat_file.reset(CreateOatFileForDexLocation(dex_location, scoped_flock.GetFile()->Fd(),
oat_location, error_msgs));
}

这里有一个if判断,直接决定是否进行dex2oat,我们看看能不能通过各种手段让这个判断不成立。

禁用dex2oat

第一招:修改Runtime中的变量

这个if判断里,第一个条件就是Runtime::Current()->IsDex2OatEnabled(),如果返回false,那么就不会生成oat。这个函数的实现如下:

1
2
3
4
5
6
7
bool IsDex2OatEnabled() const {
return dex2oat_enabled_ && IsImageDex2OatEnabled();
}

bool IsImageDex2OatEnabled() const {
return image_dex2oat_enabled_;
}

dex2oat_enabled_image_dex2oat_enabled_都是Runtime对象中的成员变量,而Runtime可以通过JavaVM获取,所以我们只需要修改这个值就能禁用dex2oat。已经有其他人实现了这一步,具体可以看看这篇博客
然而事情真的会这么简单吗?
查看源码发现Runtime是一个炒鸡大的结构体,Android里有什么东西都往这扔,你几乎可以从Runtime对象上直接或间接获取到任何东西,然而也正是因为Runtime太大了,使得没有什么好的办法获取里面的值。

让我们看看还有没有其他方法:

第二招:使用PathClassLoader

我们可以看见,在if判断里,还有两个条件:has_flockscoped_flock.HasFile(),让我们看看是否可以让这两个条件不成立。
has_flock的赋值:

1
2
3
4
5
6
7
8
9
bool has_flock = true;
// Definitely need to lock now.
if (!scoped_flock.HasFile()) {
std::string error_msg;
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
has_flock = false;
}
}

又是scoped_flock,看看在上面scoped_flock可能在哪里被初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
ScopedFlock scoped_flock;
if (open_oat_file.get() == nullptr) {
if (oat_location != nullptr) {
std::string error_msg;

// We are loading or creating one in the future. Time to set up the file lock.
if (!scoped_flock.Init(oat_location, &error_msg)) {
error_msgs->push_back(error_msg);
return false;
}
// 省略代码
}
}

看看ScopedFlock的Init方法:

1
2
3
4
5
6
7
8
9
10
bool ScopedFlock::Init(const char* filename, std::string* error_msg) {
while (true) {
file_.reset(OS::OpenFileWithFlags(filename, O_CREAT | O_RDWR));
if (file_.get() == NULL) {
*error_msg = StringPrintf("Failed to open file '%s': %s", filename, strerror(errno));
return false;
}
// 省略一大堆代码……
}
}

可以看见,会打开这个文件,flags为O_CREAT | O_RDWR,那我们只需要设置oat_location为不可写的路径,就能让ScopedFlock::Init返回false。不过我们要注意的是,如果oat_location不为null并且无法使用,那在上面的一个判断里就会直接返回false。怎么办?

是时候请出我们的主角PathClassLoader了!

PathClassLoader作为DexClassLoader的兄弟(也可能是姐妹?),受到的待遇与DexClassLoader截然不同:网上讲解动态加载dex的文章几乎都只讲DexClassLoader,而对于PathClassLoader则是一笔带过:“PathClassLoader只能加载系统中已经安装过的apk”。
然而事实真的是这样吗?或许Android 5.0以前是,但Android 5.0时就已经可以加载外部dex了,今天我要为PathClassLoader正名!
让我们来对比一下DexClassLoader和PathClassLoader的源码。
DexClassLoader:

1
2
3
4
5
6
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}

对,你没看错,有效代码就这么点。
让我们再看看PathClassLoader的源码:

1
2
3
4
5
6
7
8
9
10
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}

public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent) {
super(dexPath, null, libraryPath, parent);
}
}

实际上所以实现代码都在BaseDexClassLoader中,DexClassLoader和PathClassLoader都调用了同一个构造函数:

1
2
3
4
5
6
7
8
9
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
}

注意第二个参数,optimizedDirectory,DexClassLoader传入的是new File(optimizedDirectory),而PathClassLoader传入的是null。记住这一点。
这两种情况最后都会调用到DexFile.openDexFileNative中

1
private static native long openDexFileNative(String sourceName, String outputName, int flags);

如果是PathClassLoader,outputName为null,会进入这个if分支中:

1
2
3
4
5
6
7
8
// Look in cache location if no oat_location is given.
std::string cache_location;
if (oat_location == nullptr) {
// Use the dalvik cache.
const std::string dalvik_cache(GetDalvikCacheOrDie(GetInstructionSetString(kRuntimeISA)));
cache_location = GetDalvikCacheFilenameOrDie(dex_location, dalvik_cache.c_str());
oat_location = cache_location.c_str();
}

这里会把oat_location设置成/data/dalvik-cache/下的路径,接下来因为我们根本没有对dalvik-cache的写入权限,所以无法打开fd,然后就会走到这里直接加载原始dex

1
2
3
4
5
6
7
if (open_oat_file.get() == nullptr) {
std::string error_msg;
// dex2oat was disabled or crashed. Add the dex file in the list of dex_files to make progress.
DexFile::Open(dex_location, dex_location, &error_msg, dex_files);
error_msgs->push_back(error_msg);
return false;
}

至此整个逻辑已经明朗,通过PathClassLoader加载会把oat输出路径设置成/data/dalvik-cache/下,然后因为我们没有对dalvik-cache的写入权限,所以无法打开fd,之后会直接加载原始dex,不会进行dex2oat。
(注:本文分析源码是Android 5.0Android 8.1时源码有改动,就算是DexClassLoader也会把optimizedDirectory设置成null,输出的oat在dex的父目录/oat/下,所以无法通过PathClassLoader快速加载dex,但在8.1时已经有InMemoryDexClassLoader了,直接通过InMemoryDexClassLoader加载就好了。

简单做了个小测试,在我的AVD(Android 7.1.1)上,用DexClassLoader加载75M的qq apk用了近80秒,并生成了一个313M的oat,而PathClassLoader用时稳定在2秒左右,emmm……

看起来我们已经有一个比较好的办法禁用dex2oat了,不过需要修改源码没法直接全局禁用,修改Runtime风险又太大,让我们看看还有没有其他方法。

第三招:hook execv

到了这里,上面那个判断肯定会成立了,似乎进行dex2oat已成定局?我们继续看CreateOatFileForDexLocation。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const OatFile* ClassLinker::CreateOatFileForDexLocation(const char* dex_location,
int fd, const char* oat_location,
std::vector<std::string>* error_msgs) {
// Generate the output oat file for the dex file
VLOG(class_linker) << "Generating oat file " << oat_location << " for " << dex_location;
std::string error_msg;
if (!GenerateOatFile(dex_location, fd, oat_location, &error_msg)) {
CHECK(!error_msg.empty());
error_msgs->push_back(error_msg);
return nullptr;
}
std::unique_ptr<OatFile> oat_file(OatFile::Open(oat_location, oat_location, nullptr,
!Runtime::Current()->IsCompiler(),
&error_msg));
if (oat_file.get() == nullptr) {
std::string compound_msg = StringPrintf("\nFailed to open generated oat file '%s': %s",
oat_location, error_msg.c_str());
error_msgs->push_back(compound_msg);
return nullptr;
}

return oat_file.release();
}

GenerateOatFile是核心逻辑,这个函数大部分都是我们不关心的配置dex2oat参数就不贴出来了,最后会fork出一个新进程,然后在子进程里执行execv()调用dex2oat。
看起来我们必然要执行dex2oat了?别慌,还有办法。虽然没有直接的开关去阻止dex2oat,但我们还有hook大法!生成oat最后是通过execv调用dex2oat进行的,所以我们可以hook掉execv函数,如果是执行dex2oat那么直接让这个进程退出即可!Lody大神的早期作品TurboDex就是这样实现的。不过这个项目其实还可以优化一下:TurboDex是使用的Substrate进行hook,这是一个inline hook库,而execv是来自libc.so的导出符号,其实直接通过GOT Hook就能hook到,没有必要去用inline hook,反而增加crash风险。

总结

本来我只是为了研究DexClassLoader与PathClassLoader的区别的,网上的文章和实验的结果完全不一样,结果意外发现一个快速加载dex的方法,就写出来了 :)
这个故事告诉我们,没事多看源码(手动滑稽)
另外个人建议,快速加载dex之后后台可以开一个线程单独进行dex2oat,具体可以参考ArtDexOptimizer,下次启动的时候如果完成了可以直接用生成好的oat文件,毕竟用oat比直接加载dex快得多,而且更稳定~


大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

其他框架的一些问题

注:这里并没有贬低其他框架的意思,只是单纯的比较
现在是2020年,ART Hook框架已经非常多,但是肯定不是随便拿一个就能用的;我们需要一个能提供Xposed-style hook接口的框架,Xposed的hook接口只要求提供一个callback,是完全动态的,而像YAHFA这样的框架则要求提供一个与目标方法参数与返回值都相同的方法,如果需要调用原方法还需要提供一个backup方法,无法直接做到像Xposed那样的风格的hook。
这样一过滤,剩下的框架就不多了,挑出了几个框架:

  • Whale,原理是设置目标方法为native,然后用libffi动态生成一个处理函数,设置entry_point_from_jni为这个处理函数。这个框架可以直接像Xposed那样hook,不过实测不太稳定,比如在bridge里随便抛个异常(即使被try-catch住)就会导致Runtime直接abort。Frida/AndHook似乎也是一样的套路,应该也会有这个问题。
  • SandHook,这个框架对Xposed兼容自带有两种方案:一是用DexMaker动态生成bridge函数,没有什么兼容性问题,但是第一次加载的时候会很慢;二是用动态代理生成bridge方法,这个bridge方法用像whale一样的方案:设置native,libffi动态生成native处理函数,设置entry_point_from_jni。这个方案用起来很快,但是存在挺多坑,稳定性存疑。
  • FastHook,根据调用约定在栈里捞参数,作者宣称它“高效稳定、简洁易用”,然而试了下,并不稳定,而且提供的hook接口很难用(一个通用接口有7个参数),而且作者现在似乎不维护了,emmm
  • Epic,根据调用约定从寄存器和栈里解析参数,VirtualXposed太极都在用,经过大量验证非常稳定,不过现在闭源了,开源版有一些bug

经过对比,发现大多数hook框架都不太符合要求,Epic现在闭源,最终决定自己动手写一个。
注:这里只是根据我的需求评估的,如果你可以提供与原方法参数和返回值都相同的hook方法与backup方法,或者对稳定性有较高要求而速度是其次的话,那么更建议使用成熟的SandHook框架

基础知识

在介绍Pine之前,先介绍一下基础知识。
一个方法/构造器在art中表示为一个ArtMethod对象,ArtMethod保存着该方法的信息等。
Android 9.0的源码为例,一个ArtMethod是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ArtMethod FINAL {
/** 该方法的所属类 */
GcRoot<mirror::Class> declaring_class_;

/** 方法的访问标志,比如public,private就存储在这里 */
std::atomic<std::uint32_t> access_flags_;

// 省略一些成员

struct PtrSizedFields {
/** 公共存储区域,Native方法为对应的native函数,非native方法则是其他东西(比如jit要用的ProfilingInfo) */
void* data_;

/** 方法入口,如果已被编译则为编译后的代码入口,未编译则为解释器入口 */
void* entry_point_from_quick_compiled_code_;
}
}

略去一些细节,ART的函数调用过程其实很简单:

  1. caller想办法拿到callee的ArtMethod对象
  2. 将参数按照约定存到寄存器和栈里
  3. 跳转到callee的方法入口

基本实现

Pine支持两种方案,一种是替换入口,即修改ArtMethod的entrypoint;另一种类似于native的inline hook,即覆盖掉目标方法的代码开始处的一段代码,用于弥补Android 8.0以下版本入口替换很有可能不生效的问题。

入口替换

我们看看上面的ArtMethod,发现了一个很重要的成员:entry_point_from_quick_compiled_code_,这个变量保存至该方法的代码入口,如果我们直接修改这个变量,不就可以达到hook的目的了吗?
然而事情并没有这么简单。用入口替换方案去hook自己的方法,大部分情况下是没有问题的;但如果你需要hook系统的方法,并且这个方法不是virtual方法(比如TextView.setText(CharSequence)),那么很有可能不生效。这是因为,Android 8.0以下,art有一个Sharpening优化,如果art能够确定callee的代码入口,那么有可能直接把入口硬编码在机器码内,根本不会去ArtMethod里取入口。

还有没有其他方法呢?当然有,那就是inline hook。

inline hook

从上面的分析可以看出,目标方法的代码是一定会用到的;那么我们可以直接修改目标方法的代码,把前几条代码修改为一段跳转指令,这样当这个方法执行时,就会直接跳到我们指定的另一段代码处执行,我们就达到了hook目的。
以下情况不能被inline hook:

  • jni方法和代理方法。jni方法和代理方法都没有对应的已编译代码,其entry_point_from_quick_compiled_code_固定指向一段trampoline,这个trampoline会跳转到真正的代码处执行。
  • 方法未被编译且尝试编译失败。
  • 方法已被编译,但代码太短以至于一个简单的跳转指令都放不下。跳转指令arm32需要8字节,arm64需要16字节;实际上我们可以考虑其他方式跳转,比如如果我们可以偷到目标代码附近的内存,就能直接使用b指令跳转(此方法来自于Dobby框架);或者我们可以放一个非法指令,程序执行到这条指令时会产生一个SIGILL信号,我们捕获到这个信号,对寄存器和栈进行操作就能直接控制执行流程(注:此方法来自于卓桐大佬的Android elf hook的方式)。之所以不做这个处理,是因为这样的方法很少,而且很可能被直接内联到caller里,个人认为没必要。
  • 方法的前几条代码里有pc寄存器相关指令,hook时没问题,但执行原方法时会有问题(具体见后面的 一些问题-执行原方法 部分)

当出现以上情况时,自动转用入口替换模式。

四个跳板

我们需要写四段模板代码,注意要用纯汇编写以避免破坏栈和寄存器,暂时命名为trampoline:

  1. DirectJumpTrampoline:inline hook使用,功能是跳转至一个绝对地址,需要插入目标方法的代码开始处。
  2. BridgeJumpTrampoline:两个方案都需要使用,处理一些东西并跳转至Bridge方法,这个bridge是我们预先写好的一个java方法,跳转到这个方法之后我们就回到了java世界,然后就可以开始处理真实的AOP逻辑。
  3. CallOriginTrampoline:入口替换使用,功能是设置r0寄存器为原方法并跳转至原方法入口。将成为backup方法的入口。(暂未使用,目前发现设置r0寄存器为原方法后有几率造成卡死)
  4. BackupTrampoline:inline hook使用,设置r0寄存器为原方法、存放被覆盖的代码并跳转至(原方法入口+备份代码大小)处继续执行。将成为backup方法的入口。(其实这段代码叫trampoline是不太合适的)

一些问题

基本原理比较简单,但是实现的过程中会遇到很多的问题,这里简单说一下。

参数解析

大概想了一下,参数解析有以下方案:

  • 动态生成出一个有和原方法相同的参数列表的方法,这个方法只是一个bridge,作用就是传递参数和返回值;需要注意的是,这个方法必须要有对应的代码,和一些必要的成员,为此可以有两种方法:
    • 通过如DexMaker等动态字节码生成技术动态生成出这个方法(EdXposed用的方案);这种方案的主要问题是比较慢,动态生成dex并加载是耗时操作。
    • 利用java的动态代理动态生成,难点在于如何控制这个新生成的代理方法做你想要做的事;SandHook的xposedcompat_new里实现了一个:把这个代理方法设置为native方法,然后通过libffi动态生成对应的native函数,然后修改其entry_point_from_jni(其实就是Whale那个方案,不过whale是对目标方法,SandHook的xposedcompat_new是对处理方法),这个方案有点问题,凭空把一个非native方法变为native有很多未知的坑等着你去踩。
  • 统一bridge方法,在这个bridge方法里自己解析参数。
  • art为了实现java的动态代理,自己就有一个art_quick_proxy_invoke_handler,如果能利用好这个内置函数,那么就可以达到目的。不过看了下代码,这个函数利用起来很难,暂时先放弃。

最终我选择自己解析参数,hook时较快;为此我们需要了解ART的函数调用约定,根据这个约定去解析。
以arm32/thumb2为例,在ART中,r0寄存器固定保存callee的ArtMethod*,r1~r3寄存器保存前三个参数(注:非静态方法实际上第一个参数就是this);同时,sp~sp+12上也传递着r0~r3上的值;多余的参数通过栈传递,比如第四个参数就存在sp+16上;如果一个参数一个寄存器放不下(long/double),那么会占用两个寄存器。不过这只是基本情况,其他情况还需特别处理(如在6.0或以上,如果第一个参数是long/double类型的,那么会跳过r1寄存器等等)。
ok,我们发现了一个简单的办法:我们可以修改r1~r3传递其他东西,必须需要的只有sp,因为sp~sp+12上也存放着r0~r3上的值,剩下的参数也通过sp传递,那么我们直接通过sp就能获取到所有参数?
兴冲冲的写好代码测试发现,此路不通,通过sp拿到的前几个参数是乱的。为什么?weishu的文章论ART上运行时 Method AOP实现里揭露了答案:

虚拟机本身也是知道 sp + 12 这段空间相当于是浪费的,因此他直接把这段空间当做类似寄存器使用了。

那怎么办呢?在栈上分配内存来放?同样行不通,这样一旦发生栈回溯,sp被修改的那一帧会因为回溯不到对应的函数引发致命错误,导致runtime abort。
现在我采用和epic类似的方法实现:在hook时分配一段内存,这段内存用来放r0~r3,先保存了再跳转到bridge方法,bridge方法就可以取出对应的值。
另外对象类型要特别处理,art传递对象时其实传的是这个对象的地址,我们接收到的只是一串数字,转换的方法有两个:

  • 直接通过java层的Unsafe,直接put进地址,拿出来的就是对象
  • 通过jni,用一个art内部函数把地址转换成jobject,返回到java的时候会自动进行转换

这里我选择第二种方法,因为第一种需要用反射调用Unsafe,而反射效率不是很高,有较多参数时效率会比较低。

多线程并发

上面提到由于没有其他地方放前几个参数,所以在hook时就会提前分配一块地址专门来放,大概这样:

1
2
3
4
ldr ip, extra_addr
str r1, [ip, #0]
str r2, [ip, #4]
str r3, [ip, #8]

然后会在bridge里拿到r1~r3。
这段代码在单线程下执行并没有问题,但在多线程环境下,如果存值之后还没来得及取值就被其他线程修改,就会读到错误的值,导致错误。
修复这个问题的一种方法是禁止多线程并发执行,比如给目标方法加上synchronized的flag,但是这样显然太重了,我们只需要在存值——取值这段时间里禁止并发即可。为此,我通过CAS机制写了一个自旋锁:

1
2
3
4
5
6
7
8
9
acquire_lock:
ldrex r0, [ip]
cmp r0, #0
wfene // other thread holding the lock, wait it release lock
mov r0, #1
strexeq r0, r0, [ip]
cmpeq r0, #0 // store succeeded?
bne acquire_lock // acquire lock failed, try again
dmb

extras的第一个变量即为锁标志,为0代表无锁,为1代表已有线程持有该锁,获取时通过CAS去抢锁,bridge里将锁标志置0释放锁。注意需要添加内存屏障以阻止部分关键指令被乱序执行引发错误。
这里还有一个问题:arm32/thumb2下,ldrex/strex需要一个寄存器来接收结果,而ip寄存器已存放着extras的地址;arm64下stlxr要求source和status不能相同等等,都需要占用一个额外寄存器,这里我选择r0寄存器,原因很简单:r0寄存器固定保存callee的ArtMethod指针,这个值在hook的时候就已确定;跳转到bridge方法时也会更改为bridge的ArtMethod等。
(注:发现arm64有一个xzr/wzr寄存器固定为0,如果把lock_flag改成0为有锁1为无锁就可以直接用wzr寄存器了,可以少用一个寄存器,列入TODO列表里了)
(注2:仔细思考了一下,发现如果两个线程抢锁,持有锁的线程获取到锁以后还没来得及释放就因为gc等进入checkpoint被挂起,而另一个线程在等锁无法进入checkpoint,导致类似死锁的情况,最终导致挂起所有线程超时runtime abort,没想到怎么解决,暂时先挂着吧)

执行原方法

要执行原方法,我们需要一个原方法代码入口。入口替换模式下,直接使用原来那个入口就行;但在inline hook模式下,由于我们修改了方法入口处的代码,需要对原方法代码进行备份,调用原方法的时候直接执行这段备份的代码,然后继续跳转到剩余代码部分执行即可。
我们特别写了一段叫做BackupTrampoline的代码实现,以arm32为例:

1
2
3
4
5
6
7
8
9
10
FUNCTION(pine_backup_trampoline)
ldr r0, pine_backup_trampoline_origin_method // 将r0寄存器设置为原方法
VAR(pine_backup_trampoline_override_space)
.long 0 // 会被替换为真实代码
.long 0 // 会被替换为真实代码
ldr pc, pine_backup_trampoline_remaining_code_entry // 跳转到剩余部分继续执行
VAR(pine_backup_trampoline_origin_method)
.long 0
VAR(pine_backup_trampoline_remaining_code_entry)
.long 0

而thumb2需要特别注意,由于在thumb2下一条指令可能是4字节也可能是2字节,如果我们固定为只备份8字节则有可能导致指令被截断(比如4-2-4),所以备份的时候一定要注意指令完整性。

1
2
3
static inline bool IsThumb32(uint16_t inst) {
return ((inst & 0xF000) == 0xF000) || ((inst & 0xF800) == 0xE800);
}
1
2
3
4
5
6
7
8
9
10
11
FUNCTION(pine_thumb_backup_trampoline)
ldr r0, pine_thumb_backup_trampoline_origin_method
VAR(pine_thumb_backup_trampoline_override_space)
.long 0 // 会被替换为真实代码
.long 0 // 会被替换为真实代码
nop // 可能会被替换为真实代码,否则只是一条nop
ldr pc, pine_thumb_backup_trampoline_remaining_code_entry // 跳转到剩余部分继续执行
VAR(pine_thumb_backup_trampoline_origin_method)
.long 0
VAR(pine_thumb_backup_trampoline_remaining_code_entry)
.long 0

另一个问题是,在inline hook模式下,我们需要把部分原始指令备份到另一个地方,如果这部分指令里有pc相关指令,由于此时指令地址不同,会发生错误。传统native inline hook框架的做法是做指令修复,而因为art上这种情况很少,所以Pine目前并没有指令修复,只是简单的做判断,如果发现这种情况直接转用入口替换模式。
(注:其实还可以有另一种方案,就是不用原来的指令,设置r0寄存器为原方法后直接跳转到art_quick_to_interpreter_bridge走解释执行就行,注意需要清掉对应ProfilingInfosaved_entry_point_,否则可能会直接跳转到原方法入口执行,就死循环了)

好的,现在我们有了原代码入口,我们可以动态创建出一个backup方法,设置backup方法入口为原代码入口,然后直接反射调用这个方法就行了!不过有一点要注意,由于这个方法是动态创建出来的,而ArtMethod的declaring_class是GcRoot,可能被gc移动,art会自动更新原版ArtMethod里的地址,但是不会更新我们自己创建的ArtMethod里的地址,所以需要我们自己主动更新。

1
2
3
4
5
6
7
8
9
void Pine_updateDeclaringClass(JNIEnv *env, jclass, jobject javaOrigin, jobject javaBackup) {
auto origin = art::ArtMethod::FromReflectedMethod(env, javaOrigin);
auto backup = art::ArtMethod::FromReflectedMethod(env, javaBackup);
uint32_t declaring_class = origin->GetDeclaringClass();
if (declaring_class != backup->GetDeclaringClass()) {
LOGI("The declaring_class of method has moved by gc, update its reference in backup method now!");
backup->SetDeclaringClass(declaring_class);
}
}

不过还有一个问题,假如在我们检查完declaring_class之后调用backup之前发生gc,这个class对象被移动了,怎么办呢?难道要在这段时间里直接关闭Moving GC?太重了,我们只希望declaring_class不会被移动就行。实际上,确实有让一个对象暂时不会被移动的方法:对于在栈上有引用的对象,不会被gc移动。那就简单了,保证对应的Class对象在栈上有引用即可,需要注意必须显式使用一下,否则会被优化:
(此方法来源于SandHook,未验证,在Android 10上测试并不能阻止对象被移动,哎)
(注:这里之前是考虑过用FastHook的那种方案的,即动态代理创建出forward方法,只修改forward的entry而非全部备份,不过试下来发现有点问题)
ok,调用原方法完成。

jit

这是官方文档上的JIT工作流程图。
Pine对jit的处理和其他框架差不多:

  • 如果目标方法没被编译,先尝试调用jit_compile_method进行编译,编译的结果直接影响到走inline hook还是入口替换;
  • jit编译会改变线程状态,有可能会造成crash,所以编译完后需要恢复线程状态;
  • 给原方法和backup方法添加kAccCompileDontBother防止其被jit编译从而引发错误;
  • 另外还照着SandHook写了一个禁用jit inline。

不过这似乎还远远不够。FastHook作者在这篇文章中提到了这两点:

  • 如果该方法正在jit编译,那么我们手动编译是不安全的。
  • jit gc会修改方法入口为解释器入口,当方法进入解释器时会重新设置为原来的入口并跳转到原来的入口执行。

另外我简单看了下jit源码,发现包括ProfilingInfo被编译的代码在内的大部分内容都有可能被回收
暂时还没想好这个怎么处理emmm

ELF符号解析

受限于实现原理,我们需要获得来自系统私有库内的大量私有符号,最简单的办法就是用dlsym,不过在Android N上,Google禁止了这种行为,而且我们还需要获取一些在.symtab表里的符号(比如art_quick_to_interpreter_bridge),这些用dlsym是搜索不到的。因为我对elf格式不熟,所以直接用的SandHook作者的AndroidELF,在这特别表示感谢~

各设备兼容

处理安卓各版本的变化这些都是老生常谈了,这条要讲的问题是指当厂商修改了一些成员偏移的情况。
比如ArtMethod,我们做hook至少需要获得art_entry_point_from_quick_compiled_code_access_flags_,我们可以根据AOSP写死偏移,但是这样的话一旦厂商做了什么手脚改了偏移,那就完蛋了。而我知道的框架只有SandHook和Whale是动态查找偏移,其他都是根据AOSP写死偏移。
实际上我们可以在运行时动态获得这些offset,拿access_flags_来说吧,我们可以定义一个方法,然后根据该方法的属性预测access_flag,然后可以用这个预测的flag在ArtMethod里动态搜索到值(这个方法最好是native的,否则很有可能会被加上一个kAccSkipAccessChecks导致搜索不到);而对于entry_point_from_quick_compiled_code_,并没有办法预测值,但是我们可以预测在art_entry_point_from_quick_compiled_code_旁边的data_成员的值:对于native方法,这个值是对应的jni函数地址,我们可以搜索到,然后直接加上成员大小就行(需要注意内存对齐)
而对于无法动态获得偏移的情况,比如CompilerOptions,它在内存中是这样的:

1
2
3
4
5
6
7
8
9
10
class CompilerOptions final {
CompilerFilter::Filter compiler_filter_;
size_t huge_method_threshold_;
size_t large_method_threshold_;
size_t small_method_threshold_;
size_t tiny_method_threshold_;
size_t num_dex_methods_threshold_;
size_t inline_max_code_units_;
// 省略一大堆成员
}

假如我们要修改它的inline_max_code_units_,没什么能很好获取偏移的办法,那么我们只能根据版本写死偏移,运行时就只能判断对应的值是否在范围内,超过范围不修改(比如获取出一个114514,那肯定不是正常的值,就可以判断出偏移不对)。

使用

上面说了这么久实现原理,下面让我们来看看这东西怎么用吧~

基础使用

bridge.gradle里加入如下依赖:

1
2
3
dependencies {
implementation 'top.canyie.pine:core:0.0.1'
}

配置一些基本信息:

1
2
PineConfig.debug = true; // 是否debug,true会输出较详细log
PineConfig.debuggable = BuildConfig.DEBUG; // 该应用是否可调试,建议和配置文件中的值保持一致,否则会出现问题

然后就可以开始使用了。

例子1:监控Activity onCreate(注:仅做测试使用,如果你真的有这个需求更建议使用registerActivityLifecycleCallbacks()等接口)

1
2
3
4
5
6
7
8
9
Pine.hook(Activity.class.getDeclaredMethod("onCreate", Bundle.class), new MethodHook() {
@Override public void beforeHookedMethod(Pine.CallFrame callFrame) {
Log.i(TAG, "Before " + callFrame.thisObject + " onCreate()");
}

@Override public void afterHookedMethod(Pine.CallFrame callFrame) {
Log.i(TAG, "After " + callFrame.thisObject + " onCreate()");
}
});

Pine.CallFrame就相当于xposed的MethodHookParams。

例子2:拦截所有java线程的创建与销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final MethodHook runHook = new MethodHook() {
@Override public void beforeHookedMethod(Pine.CallFrame callFrame) throws Throwable {
Log.i(TAG, "Thread " + callFrame.thisObject + " started...");
}

@Override public void afterHookedMethod(Pine.CallFrame callFrame) throws Throwable {
Log.i(TAG, "Thread " + callFrame.thisObject + " exit...");
}
};

Pine.hook(Thread.class.getDeclaredMethod("start"), new MethodHook() {
@Override public void beforeHookedMethod(Pine.CallFrame callFrame) {
Pine.hook(ReflectionHelper.getMethod(callFrame.thisObject.getClass(), "run"), runHook);
}
});

注意如果我们只hook Thread.run(),Thread子类可能会重写Thread.run()方法不调用super.run()那么就无法hook到,所以我们可以hook一定会被调用的Thread.start()方法感知到新线程建立,此时可以获得具体的类,然后直接hook这些运行时才被发现的类就行。

我们还可以玩点丧心病狂的,比如:

1
2
Method checkThread = Class.forName("android.view.ViewRootImpl").getDeclaredMethod("checkThread");
Pine.hook(checkThread, MethodReplacement.DO_NOTHING);

这段代码会干什么呢?没错,现在你可以在任何线程随意操作ui了,不用怕ViewRootImpl.CalledFromWrongThreadException了 ^_^
当然,Pine的用途远不止这些,这一切都取决于您的想象力~

其他一些API

这里介绍一些其他的API:

  • Pine.ensureInitialized():默认情况下Pine是懒初始化的,即第一次调用需要初始化的API时才会进行初始化,你可以调用此方法来主动进行初始化
  • Pine.invokeOriginalMethod(Member method, Object thisObject, Object... args):调用原方法,不过不建议使用这个接口,更建议使用效率更高的CallFrame.invokeOriginalMethod()
  • Pine.setHookMode(int hookMode):设置Pine的hook方案,取值:Pine.HookMode.AUTO:由Pine自行决定;Pine.HookMode.INLINE:inline hook优先;Pine.HookMode.REPLACEMENT:入口替换优先。注:设置hook方案并不代表Pine一定会以该方案进行hook,如hook jni函数就只能进行入口替换。
  • Pine.disableJitInline():尝试关闭JIT的内联优化。
  • Pine.compile(Member method):主动调用JIT尝试编译一个方法。
  • Pine.decompile(Member method, boolean disableJit):使某个方法转换为解释执行。参数disableJit表示是否需要阻止该方法再次被JIT编译。

还有其他一些不太常用的就不再介绍了,感兴趣的可以去看看源码。

使用须知

  1. Pine的开源协议是反996协议
  2. Pine支持Android 4.4(只支持ART)~10.0,aarch32(未测试,几乎见不到,以后可能会移除)/thumb2/arm64架构;6.0 32bit下参数解析可能会有问题,没有对应测试机无法测试(其实是看epic源码有对M进行特殊处理,不过这段没看懂emm);另外,Pine没有自带绕过隐藏API限制策略的方法,如果你需要在9.0及以上使用,那么请自行处理(比如使用FreeReflection);R上简单看了下,jmethodID有可能不是真实的ArtMethod*了,不过java层Executable的artMethod变量似乎还是,处理了这点应该就行
  3. Pine只在少数的几台设备上做过测试,稳定性暂无法保证,不建议在生产环境中使用。
  4. 大部分java方法都可被Pine hook,但是这些方法除外:
  • 类的静态初始化块
  • 部分关键系统方法
  • 被Pine内部使用了的方法(hook会导致死循环)
  • 有无法被inline hook的情况
  • 被完全内联的方法(如果能知道caller,那么可以decompile caller)

总结

嗯,大概就是这样啦~
再放一下开源地址:Pine
几个对我帮助比较大的项目:

在这再次表示感谢~

如果你对本项目感兴趣的话,可以拿出你的手机帮我测试一下,欢迎提issue和PR,也可以加一下QQ群:949888394一起讨论,^_^


大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

其他框架的一些问题

注:这里并没有贬低其他框架的意思,只是单纯的比较
现在是2020年,ART Hook框架已经非常多,但是肯定不是随便拿一个就能用的;我们需要一个能提供Xposed-style hook接口的框架,Xposed的hook接口只要求提供一个callback,是完全动态的,而像YAHFA这样的框架则要求提供一个与目标方法参数与返回值都相同的方法,如果需要调用原方法还需要提供一个backup方法,无法直接做到像Xposed那样的风格的hook。
这样一过滤,剩下的框架就不多了,挑出了几个框架:

  • Whale,原理是设置目标方法为native,然后用libffi动态生成一个处理函数,设置entry_point_from_jni为这个处理函数。这个框架可以直接像Xposed那样hook,不过实测不太稳定,比如在bridge里随便抛个异常(即使被try-catch住)就会导致Runtime直接abort。Frida/AndHook似乎也是一样的套路,应该也会有这个问题。
  • SandHook,这个框架对Xposed兼容自带有两种方案:一是用DexMaker动态生成bridge函数,没有什么兼容性问题,但是第一次加载的时候会很慢;二是用动态代理生成bridge方法,这个bridge方法用像whale一样的方案:设置native,libffi动态生成native处理函数,设置entry_point_from_jni。这个方案用起来很快,但是存在挺多坑,稳定性存疑。
  • FastHook,根据调用约定在栈里捞参数,作者宣称它“高效稳定、简洁易用”,然而试了下,并不稳定,而且提供的hook接口很难用(一个通用接口有7个参数),而且作者现在似乎不维护了,emmm
  • Epic,根据调用约定从寄存器和栈里解析参数,VirtualXposed太极都在用,经过大量验证非常稳定,不过现在闭源了,开源版有一些bug

经过对比,发现大多数hook框架都不太符合要求,Epic现在闭源,最终决定自己动手写一个。
注:这里只是根据我的需求评估的,如果你可以提供与原方法参数和返回值都相同的hook方法与backup方法,或者对稳定性有较高要求而速度是其次的话,那么更建议使用成熟的SandHook框架

基础知识

在介绍Pine之前,先介绍一下基础知识。
一个方法/构造器在art中表示为一个ArtMethod对象,ArtMethod保存着该方法的信息等。
Android 9.0的源码为例,一个ArtMethod是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ArtMethod FINAL {
/** 该方法的所属类 */
GcRoot<mirror::Class> declaring_class_;

/** 方法的访问标志,比如public,private就存储在这里 */
std::atomic<std::uint32_t> access_flags_;

// 省略一些成员

struct PtrSizedFields {
/** 公共存储区域,Native方法为对应的native函数,非native方法则是其他东西(比如jit要用的ProfilingInfo) */
void* data_;

/** 方法入口,如果已被编译则为编译后的代码入口,未编译则为解释器入口 */
void* entry_point_from_quick_compiled_code_;
}
}

略去一些细节,ART的函数调用过程其实很简单:

  1. caller想办法拿到callee的ArtMethod对象
  2. 将参数按照约定存到寄存器和栈里
  3. 跳转到callee的方法入口

基本实现

Pine支持两种方案,一种是替换入口,即修改ArtMethod的entrypoint;另一种类似于native的inline hook,即覆盖掉目标方法的代码开始处的一段代码,用于弥补Android 8.0以下版本入口替换很有可能不生效的问题。

入口替换

我们看看上面的ArtMethod,发现了一个很重要的成员:entry_point_from_quick_compiled_code_,这个变量保存至该方法的代码入口,如果我们直接修改这个变量,不就可以达到hook的目的了吗?
然而事情并没有这么简单。用入口替换方案去hook自己的方法,大部分情况下是没有问题的;但如果你需要hook系统的方法,并且这个方法不是virtual方法(比如TextView.setText(CharSequence)),那么很有可能不生效。这是因为,Android 8.0以下,art有一个Sharpening优化,如果art能够确定callee的代码入口,那么有可能直接把入口硬编码在机器码内,根本不会去ArtMethod里取入口。

还有没有其他方法呢?当然有,那就是inline hook。

inline hook

从上面的分析可以看出,目标方法的代码是一定会用到的;那么我们可以直接修改目标方法的代码,把前几条代码修改为一段跳转指令,这样当这个方法执行时,就会直接跳到我们指定的另一段代码处执行,我们就达到了hook目的。
以下情况不能被inline hook:

  • jni方法和代理方法。jni方法和代理方法都没有对应的已编译代码,其entry_point_from_quick_compiled_code_固定指向一段trampoline,这个trampoline会跳转到真正的代码处执行。
  • 方法未被编译且尝试编译失败。
  • 方法已被编译,但代码太短以至于一个简单的跳转指令都放不下。跳转指令arm32需要8字节,arm64需要16字节;实际上我们可以考虑其他方式跳转,比如如果我们可以偷到目标代码附近的内存,就能直接使用b指令跳转(此方法来自于Dobby框架);或者我们可以放一个非法指令,程序执行到这条指令时会产生一个SIGILL信号,我们捕获到这个信号,对寄存器和栈进行操作就能直接控制执行流程(注:此方法来自于卓桐大佬的Android elf hook的方式)。之所以不做这个处理,是因为这样的方法很少,而且很可能被直接内联到caller里,个人认为没必要。
  • 方法的前几条代码里有pc寄存器相关指令,hook时没问题,但执行原方法时会有问题(具体见后面的 一些问题-执行原方法 部分)

当出现以上情况时,自动转用入口替换模式。

四个跳板

我们需要写四段模板代码,注意要用纯汇编写以避免破坏栈和寄存器,暂时命名为trampoline:

  1. DirectJumpTrampoline:inline hook使用,功能是跳转至一个绝对地址,需要插入目标方法的代码开始处。
  2. BridgeJumpTrampoline:两个方案都需要使用,处理一些东西并跳转至Bridge方法,这个bridge是我们预先写好的一个java方法,跳转到这个方法之后我们就回到了java世界,然后就可以开始处理真实的AOP逻辑。
  3. CallOriginTrampoline:入口替换使用,功能是设置r0寄存器为原方法并跳转至原方法入口。将成为backup方法的入口。(暂未使用,目前发现设置r0寄存器为原方法后有几率造成卡死)
  4. BackupTrampoline:inline hook使用,设置r0寄存器为原方法、存放被覆盖的代码并跳转至(原方法入口+备份代码大小)处继续执行。将成为backup方法的入口。(其实这段代码叫trampoline是不太合适的)

一些问题

基本原理比较简单,但是实现的过程中会遇到很多的问题,这里简单说一下。

参数解析

大概想了一下,参数解析有以下方案:

  • 动态生成出一个有和原方法相同的参数列表的方法,这个方法只是一个bridge,作用就是传递参数和返回值;需要注意的是,这个方法必须要有对应的代码,和一些必要的成员,为此可以有两种方法:
    • 通过如DexMaker等动态字节码生成技术动态生成出这个方法(EdXposed用的方案);这种方案的主要问题是比较慢,动态生成dex并加载是耗时操作。
    • 利用java的动态代理动态生成,难点在于如何控制这个新生成的代理方法做你想要做的事;SandHook的xposedcompat_new里实现了一个:把这个代理方法设置为native方法,然后通过libffi动态生成对应的native函数,然后修改其entry_point_from_jni(其实就是Whale那个方案,不过whale是对目标方法,SandHook的xposedcompat_new是对处理方法),这个方案有点问题,凭空把一个非native方法变为native有很多未知的坑等着你去踩。
  • 统一bridge方法,在这个bridge方法里自己解析参数。
  • art为了实现java的动态代理,自己就有一个art_quick_proxy_invoke_handler,如果能利用好这个内置函数,那么就可以达到目的。不过看了下代码,这个函数利用起来很难,暂时先放弃。

最终我选择自己解析参数,hook时较快;为此我们需要了解ART的函数调用约定,根据这个约定去解析。
以arm32/thumb2为例,在ART中,r0寄存器固定保存callee的ArtMethod*,r1~r3寄存器保存前三个参数(注:非静态方法实际上第一个参数就是this);同时,sp~sp+12上也传递着r0~r3上的值;多余的参数通过栈传递,比如第四个参数就存在sp+16上;如果一个参数一个寄存器放不下(long/double),那么会占用两个寄存器。不过这只是基本情况,其他情况还需特别处理(如在6.0或以上,如果第一个参数是long/double类型的,那么会跳过r1寄存器等等)。
ok,我们发现了一个简单的办法:我们可以修改r1~r3传递其他东西,必须需要的只有sp,因为sp~sp+12上也存放着r0~r3上的值,剩下的参数也通过sp传递,那么我们直接通过sp就能获取到所有参数?
兴冲冲的写好代码测试发现,此路不通,通过sp拿到的前几个参数是乱的。为什么?weishu的文章论ART上运行时 Method AOP实现里揭露了答案:

虚拟机本身也是知道 sp + 12 这段空间相当于是浪费的,因此他直接把这段空间当做类似寄存器使用了。

那怎么办呢?在栈上分配内存来放?同样行不通,这样一旦发生栈回溯,sp被修改的那一帧会因为回溯不到对应的函数引发致命错误,导致runtime abort。
现在我采用和epic类似的方法实现:在hook时分配一段内存,这段内存用来放r0~r3,先保存了再跳转到bridge方法,bridge方法就可以取出对应的值。
另外对象类型要特别处理,art传递对象时其实传的是这个对象的地址,我们接收到的只是一串数字,转换的方法有两个:

  • 直接通过java层的Unsafe,直接put进地址,拿出来的就是对象
  • 通过jni,用一个art内部函数把地址转换成jobject,返回到java的时候会自动进行转换

这里我选择第二种方法,因为第一种需要用反射调用Unsafe,而反射效率不是很高,有较多参数时效率会比较低。

多线程并发

上面提到由于没有其他地方放前几个参数,所以在hook时就会提前分配一块地址专门来放,大概这样:

1
2
3
4
ldr ip, extra_addr
str r1, [ip, #0]
str r2, [ip, #4]
str r3, [ip, #8]

然后会在bridge里拿到r1~r3。
这段代码在单线程下执行并没有问题,但在多线程环境下,如果存值之后还没来得及取值就被其他线程修改,就会读到错误的值,导致错误。
修复这个问题的一种方法是禁止多线程并发执行,比如给目标方法加上synchronized的flag,但是这样显然太重了,我们只需要在存值——取值这段时间里禁止并发即可。为此,我通过CAS机制写了一个自旋锁:

1
2
3
4
5
6
7
8
9
acquire_lock:
ldrex r0, [ip]
cmp r0, #0
wfene // other thread holding the lock, wait it release lock
mov r0, #1
strexeq r0, r0, [ip]
cmpeq r0, #0 // store succeeded?
bne acquire_lock // acquire lock failed, try again
dmb

extras的第一个变量即为锁标志,为0代表无锁,为1代表已有线程持有该锁,获取时通过CAS去抢锁,bridge里将锁标志置0释放锁。注意需要添加内存屏障以阻止部分关键指令被乱序执行引发错误。
这里还有一个问题:arm32/thumb2下,ldrex/strex需要一个寄存器来接收结果,而ip寄存器已存放着extras的地址;arm64下stlxr要求source和status不能相同等等,都需要占用一个额外寄存器,这里我选择r0寄存器,原因很简单:r0寄存器固定保存callee的ArtMethod指针,这个值在hook的时候就已确定;跳转到bridge方法时也会更改为bridge的ArtMethod等。
(注:发现arm64有一个xzr/wzr寄存器固定为0,如果把lock_flag改成0为有锁1为无锁就可以直接用wzr寄存器了,可以少用一个寄存器,列入TODO列表里了)
(注2:仔细思考了一下,发现如果两个线程抢锁,持有锁的线程获取到锁以后还没来得及释放就因为gc等进入checkpoint被挂起,而另一个线程在等锁无法进入checkpoint,导致类似死锁的情况,最终导致挂起所有线程超时runtime abort,没想到怎么解决,暂时先挂着吧)

执行原方法

要执行原方法,我们需要一个原方法代码入口。入口替换模式下,直接使用原来那个入口就行;但在inline hook模式下,由于我们修改了方法入口处的代码,需要对原方法代码进行备份,调用原方法的时候直接执行这段备份的代码,然后继续跳转到剩余代码部分执行即可。
我们特别写了一段叫做BackupTrampoline的代码实现,以arm32为例:

1
2
3
4
5
6
7
8
9
10
FUNCTION(pine_backup_trampoline)
ldr r0, pine_backup_trampoline_origin_method // 将r0寄存器设置为原方法
VAR(pine_backup_trampoline_override_space)
.long 0 // 会被替换为真实代码
.long 0 // 会被替换为真实代码
ldr pc, pine_backup_trampoline_remaining_code_entry // 跳转到剩余部分继续执行
VAR(pine_backup_trampoline_origin_method)
.long 0
VAR(pine_backup_trampoline_remaining_code_entry)
.long 0

而thumb2需要特别注意,由于在thumb2下一条指令可能是4字节也可能是2字节,如果我们固定为只备份8字节则有可能导致指令被截断(比如4-2-4),所以备份的时候一定要注意指令完整性。

1
2
3
static inline bool IsThumb32(uint16_t inst) {
return ((inst & 0xF000) == 0xF000) || ((inst & 0xF800) == 0xE800);
}
1
2
3
4
5
6
7
8
9
10
11
FUNCTION(pine_thumb_backup_trampoline)
ldr r0, pine_thumb_backup_trampoline_origin_method
VAR(pine_thumb_backup_trampoline_override_space)
.long 0 // 会被替换为真实代码
.long 0 // 会被替换为真实代码
nop // 可能会被替换为真实代码,否则只是一条nop
ldr pc, pine_thumb_backup_trampoline_remaining_code_entry // 跳转到剩余部分继续执行
VAR(pine_thumb_backup_trampoline_origin_method)
.long 0
VAR(pine_thumb_backup_trampoline_remaining_code_entry)
.long 0

另一个问题是,在inline hook模式下,我们需要把部分原始指令备份到另一个地方,如果这部分指令里有pc相关指令,由于此时指令地址不同,会发生错误。传统native inline hook框架的做法是做指令修复,而因为art上这种情况很少,所以Pine目前并没有指令修复,只是简单的做判断,如果发现这种情况直接转用入口替换模式。
(注:其实还可以有另一种方案,就是不用原来的指令,设置r0寄存器为原方法后直接跳转到art_quick_to_interpreter_bridge走解释执行就行,注意需要清掉对应ProfilingInfosaved_entry_point_,否则可能会直接跳转到原方法入口执行,就死循环了)

好的,现在我们有了原代码入口,我们可以动态创建出一个backup方法,设置backup方法入口为原代码入口,然后直接反射调用这个方法就行了!不过有一点要注意,由于这个方法是动态创建出来的,而ArtMethod的declaring_class是GcRoot,可能被gc移动,art会自动更新原版ArtMethod里的地址,但是不会更新我们自己创建的ArtMethod里的地址,所以需要我们自己主动更新。

1
2
3
4
5
6
7
8
9
void Pine_updateDeclaringClass(JNIEnv *env, jclass, jobject javaOrigin, jobject javaBackup) {
auto origin = art::ArtMethod::FromReflectedMethod(env, javaOrigin);
auto backup = art::ArtMethod::FromReflectedMethod(env, javaBackup);
uint32_t declaring_class = origin->GetDeclaringClass();
if (declaring_class != backup->GetDeclaringClass()) {
LOGI("The declaring_class of method has moved by gc, update its reference in backup method now!");
backup->SetDeclaringClass(declaring_class);
}
}

不过还有一个问题,假如在我们检查完declaring_class之后调用backup之前发生gc,这个class对象被移动了,怎么办呢?难道要在这段时间里直接关闭Moving GC?太重了,我们只希望declaring_class不会被移动就行。实际上,确实有让一个对象暂时不会被移动的方法:对于在栈上有引用的对象,不会被gc移动。那就简单了,保证对应的Class对象在栈上有引用即可,需要注意必须显式使用一下,否则会被优化:
(此方法来源于SandHook,未验证,在Android 10上测试并不能阻止对象被移动,哎)
(注:这里之前是考虑过用FastHook的那种方案的,即动态代理创建出forward方法,只修改forward的entry而非全部备份,不过试下来发现有点问题)
ok,调用原方法完成。

jit

这是官方文档上的JIT工作流程图。
Pine对jit的处理和其他框架差不多:

  • 如果目标方法没被编译,先尝试调用jit_compile_method进行编译,编译的结果直接影响到走inline hook还是入口替换;
  • jit编译会改变线程状态,有可能会造成crash,所以编译完后需要恢复线程状态;
  • 给原方法和backup方法添加kAccCompileDontBother防止其被jit编译从而引发错误;
  • 另外还照着SandHook写了一个禁用jit inline。

不过这似乎还远远不够。FastHook作者在这篇文章中提到了这两点:

  • 如果该方法正在jit编译,那么我们手动编译是不安全的。
  • jit gc会修改方法入口为解释器入口,当方法进入解释器时会重新设置为原来的入口并跳转到原来的入口执行。

另外我简单看了下jit源码,发现包括ProfilingInfo被编译的代码在内的大部分内容都有可能被回收
暂时还没想好这个怎么处理emmm

ELF符号解析

受限于实现原理,我们需要获得来自系统私有库内的大量私有符号,最简单的办法就是用dlsym,不过在Android N上,Google禁止了这种行为,而且我们还需要获取一些在.symtab表里的符号(比如art_quick_to_interpreter_bridge),这些用dlsym是搜索不到的。因为我对elf格式不熟,所以直接用的SandHook作者的AndroidELF,在这特别表示感谢~

各设备兼容

处理安卓各版本的变化这些都是老生常谈了,这条要讲的问题是指当厂商修改了一些成员偏移的情况。
比如ArtMethod,我们做hook至少需要获得art_entry_point_from_quick_compiled_code_access_flags_,我们可以根据AOSP写死偏移,但是这样的话一旦厂商做了什么手脚改了偏移,那就完蛋了。而我知道的框架只有SandHook和Whale是动态查找偏移,其他都是根据AOSP写死偏移。
实际上我们可以在运行时动态获得这些offset,拿access_flags_来说吧,我们可以定义一个方法,然后根据该方法的属性预测access_flag,然后可以用这个预测的flag在ArtMethod里动态搜索到值(这个方法最好是native的,否则很有可能会被加上一个kAccSkipAccessChecks导致搜索不到);而对于entry_point_from_quick_compiled_code_,并没有办法预测值,但是我们可以预测在art_entry_point_from_quick_compiled_code_旁边的data_成员的值:对于native方法,这个值是对应的jni函数地址,我们可以搜索到,然后直接加上成员大小就行(需要注意内存对齐)
而对于无法动态获得偏移的情况,比如CompilerOptions,它在内存中是这样的:

1
2
3
4
5
6
7
8
9
10
class CompilerOptions final {
CompilerFilter::Filter compiler_filter_;
size_t huge_method_threshold_;
size_t large_method_threshold_;
size_t small_method_threshold_;
size_t tiny_method_threshold_;
size_t num_dex_methods_threshold_;
size_t inline_max_code_units_;
// 省略一大堆成员
}

假如我们要修改它的inline_max_code_units_,没什么能很好获取偏移的办法,那么我们只能根据版本写死偏移,运行时就只能判断对应的值是否在范围内,超过范围不修改(比如获取出一个114514,那肯定不是正常的值,就可以判断出偏移不对)。

使用

上面说了这么久实现原理,下面让我们来看看这东西怎么用吧~

基础使用

bridge.gradle里加入如下依赖:

1
2
3
dependencies {
implementation 'top.canyie.pine:core:0.0.1'
}

配置一些基本信息:

1
2
PineConfig.debug = true; // 是否debug,true会输出较详细log
PineConfig.debuggable = BuildConfig.DEBUG; // 该应用是否可调试,建议和配置文件中的值保持一致,否则会出现问题

然后就可以开始使用了。

例子1:监控Activity onCreate(注:仅做测试使用,如果你真的有这个需求更建议使用registerActivityLifecycleCallbacks()等接口)

1
2
3
4
5
6
7
8
9
Pine.hook(Activity.class.getDeclaredMethod("onCreate", Bundle.class), new MethodHook() {
@Override public void beforeHookedMethod(Pine.CallFrame callFrame) {
Log.i(TAG, "Before " + callFrame.thisObject + " onCreate()");
}

@Override public void afterHookedMethod(Pine.CallFrame callFrame) {
Log.i(TAG, "After " + callFrame.thisObject + " onCreate()");
}
});

Pine.CallFrame就相当于xposed的MethodHookParams。

例子2:拦截所有java线程的创建与销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final MethodHook runHook = new MethodHook() {
@Override public void beforeHookedMethod(Pine.CallFrame callFrame) throws Throwable {
Log.i(TAG, "Thread " + callFrame.thisObject + " started...");
}

@Override public void afterHookedMethod(Pine.CallFrame callFrame) throws Throwable {
Log.i(TAG, "Thread " + callFrame.thisObject + " exit...");
}
};

Pine.hook(Thread.class.getDeclaredMethod("start"), new MethodHook() {
@Override public void beforeHookedMethod(Pine.CallFrame callFrame) {
Pine.hook(ReflectionHelper.getMethod(callFrame.thisObject.getClass(), "run"), runHook);
}
});

注意如果我们只hook Thread.run(),Thread子类可能会重写Thread.run()方法不调用super.run()那么就无法hook到,所以我们可以hook一定会被调用的Thread.start()方法感知到新线程建立,此时可以获得具体的类,然后直接hook这些运行时才被发现的类就行。

我们还可以玩点丧心病狂的,比如:

1
2
Method checkThread = Class.forName("android.view.ViewRootImpl").getDeclaredMethod("checkThread");
Pine.hook(checkThread, MethodReplacement.DO_NOTHING);

这段代码会干什么呢?没错,现在你可以在任何线程随意操作ui了,不用怕ViewRootImpl.CalledFromWrongThreadException了 ^_^
当然,Pine的用途远不止这些,这一切都取决于您的想象力~

其他一些API

这里介绍一些其他的API:

  • Pine.ensureInitialized():默认情况下Pine是懒初始化的,即第一次调用需要初始化的API时才会进行初始化,你可以调用此方法来主动进行初始化
  • Pine.invokeOriginalMethod(Member method, Object thisObject, Object... args):调用原方法,不过不建议使用这个接口,更建议使用效率更高的CallFrame.invokeOriginalMethod()
  • Pine.setHookMode(int hookMode):设置Pine的hook方案,取值:Pine.HookMode.AUTO:由Pine自行决定;Pine.HookMode.INLINE:inline hook优先;Pine.HookMode.REPLACEMENT:入口替换优先。注:设置hook方案并不代表Pine一定会以该方案进行hook,如hook jni函数就只能进行入口替换。
  • Pine.disableJitInline():尝试关闭JIT的内联优化。
  • Pine.compile(Member method):主动调用JIT尝试编译一个方法。
  • Pine.decompile(Member method, boolean disableJit):使某个方法转换为解释执行。参数disableJit表示是否需要阻止该方法再次被JIT编译。

还有其他一些不太常用的就不再介绍了,感兴趣的可以去看看源码。

使用须知

  1. Pine的开源协议是反996协议
  2. Pine支持Android 4.4(只支持ART)~10.0,aarch32(未测试,几乎见不到,以后可能会移除)/thumb2/arm64架构;6.0 32bit下参数解析可能会有问题,没有对应测试机无法测试(其实是看epic源码有对M进行特殊处理,不过这段没看懂emm);另外,Pine没有自带绕过隐藏API限制策略的方法,如果你需要在9.0及以上使用,那么请自行处理(比如使用FreeReflection);R上简单看了下,jmethodID有可能不是真实的ArtMethod*了,不过java层Executable的artMethod变量似乎还是,处理了这点应该就行
  3. Pine只在少数的几台设备上做过测试,稳定性暂无法保证,不建议在生产环境中使用。
  4. 大部分java方法都可被Pine hook,但是这些方法除外:
  • 类的静态初始化块
  • 部分关键系统方法
  • 被Pine内部使用了的方法(hook会导致死循环)
  • 有无法被inline hook的情况
  • 被完全内联的方法(如果能知道caller,那么可以decompile caller)

总结

嗯,大概就是这样啦~
再放一下开源地址:Pine
几个对我帮助比较大的项目:

在这再次表示感谢~

如果你对本项目感兴趣的话,可以拿出你的手机帮我测试一下,欢迎提issue和PR,也可以加一下QQ群:949888394一起讨论,^_^


2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

2021/4/10 更新:
本文存在纰漏,请看一个通用的纯 Java 安卓隐藏 API 限制绕过方案

上有政策

常言道,知己知彼,百战百胜。要想破解这个限制,就必须去搞懂系统是怎么施加的限制;ok,废话不多说,let’s go!
以我们在Java层通过反射获取一个Method为例,Class.getMethod/getDeclaredMethod最终都会进入一个native方法getDeclaredMethodInternal,这个方法的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static jobject Class_getDeclaredMethodInternal(JNIEnv* env, jobject javaThis, jstring name, jobjectArray args) {
// 省略无关代码……
Handle<mirror::Method> result = hs.NewHandle(
mirror::Class::GetDeclaredMethodInternal<kRuntimePointerSize>(
soa.Self(),
klass,
soa.Decode<mirror::String>(name),
soa.Decode<mirror::ObjectArray<mirror::Class>>(args),
GetHiddenapiAccessContextFunction(soa.Self())));
if (result == nullptr || ShouldDenyAccessToMember(result->GetArtMethod(), soa.Self())) {
return nullptr;
}
return soa.AddLocalReference<jobject>(result.Get());
}

我们可以发现,如果ShouldDenyAccessToMember返回true,那么就会返回null,上层就会抛出方法找不到的异常。这里和Android P没什么不同,只是把ShouldBlockAccessToMember改了个名而已。
ShouldDenyAccessToMember会调用到hiddenapi::ShouldDenyAccessToMember,该函数是这样实现的:

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
37
38
39
template<typename T>
inline bool ShouldDenyAccessToMember(T* member,
const std::function<AccessContext()>& fn_get_access_context,
AccessMethod access_method)
REQUIRES_SHARED(Locks::mutator_lock_) {

const uint32_t runtime_flags = GetRuntimeFlags(member);

// 1:如果该成员是公开API,直接通过
if ((runtime_flags & kAccPublicApi) != 0) {
return false;
}

// 2:不是公开API(即为隐藏API),获取调用者和被访问成员的Domain
// 因为获取调用者需要回溯调用栈,性能非常差,所以尽量避免这个消耗
const AccessContext caller_context = fn_get_access_context();
const AccessContext callee_context(member->GetDeclaringClass());

// 3:如果调用者是可信的,直接返回
if (caller_context.CanAlwaysAccess(callee_context)) {
return false;
}

// 4:非可信调用者尝试访问隐藏API,根据调用者的Domain决定行为
switch (caller_context.GetDomain()) {
case Domain::kApplication: {
DCHECK(!callee_context.IsApplicationDomain());

// 如果访问检查被完全禁用,那么直接返回
EnforcementPolicy policy = Runtime::Current()->GetHiddenApiEnforcementPolicy();
if (policy == EnforcementPolicy::kDisabled) {
return false;
}

// 5:调用detail::ShouldDenyAccessToMemberImpl,决定是否需要拒绝访问
return detail::ShouldDenyAccessToMemberImpl(member, api_list, access_method);
}
// 省略
}

这个函数还是比较长的,我们一步步分析。

  1. 判断目标成员是否是公开API,如果是那么直接通过,具体可以看见是通过GetRuntimeFlags获取,这个flags其实是储存在该成员的access_flags_中(其实这里不太完全,暂且这么认为吧)
  2. 获取调用者的Domain,判断是否可信,如果可信直接通过
  3. 以上条件都不满足,根据Domain走不同的实现,我们应用的代码对应的Domain是kApplication,主要看第一个case就行。

第二步中获取调用者的函数中核心部分如下:

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
37
38
bool VisitFrame() override REQUIRES_SHARED(Locks::mutator_lock_) {
ArtMethod *m = GetMethod();
if (m == nullptr) {
// Attached native thread. Assume this is *not* boot class path.
caller = nullptr;
return false;
} else if (m->IsRuntimeMethod()) {
// 跳过 ART Runtime 内部方法
return true;
}

ObjPtr<mirror::Class> declaring_class = m->GetDeclaringClass();
if (declaring_class->IsBootStrapClassLoaded()) {
// 跳过 java.lang.Class 中的内部方法
if (declaring_class->IsClassClass()) {
return true;
}

// 跳过 java.lang.invoke.*
ObjPtr<mirror::Class> lookup_class = GetClassRoot<mirror::MethodHandlesLookup>();
if ((declaring_class == lookup_class || declaring_class->IsInSamePackage(lookup_class))
&& !m->IsClassInitializer()) {
return true;
}

// 如果PREVENT_META_REFLECTION_BLACKLIST_ACCESS为Enabled,跳过来自 java.lang.reflect.* 的访问
// 系统对“套娃反射”的限制的关键就在此
ObjPtr<mirror::Class> proxy_class = GetClassRoot<mirror::Proxy>();
if (declaring_class->IsInSamePackage(proxy_class) && declaring_class != proxy_class) {
if (Runtime::Current()->isChangeEnabled(kPreventMetaReflectionBlacklistAccess)) {
return true;
}
}
}

caller = m;
return false;
}

根据caller选择Domain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static Domain ComputeDomain(ObjPtr<mirror::ClassLoader> class_loader, const DexFile* dex_file) {
if (dex_file == nullptr) {
// dex_file == nullptr && class_loader == nullptr(即被BootClassLoader加载的类),那么可信
return ComputeDomain(/* is_trusted= */ class_loader.IsNull());
}
// 获取dex_file的Domain
return dex_file->GetHiddenapiDomain();
}

static Domain ComputeDomain(ObjPtr<mirror::Class> klass, const DexFile* dex_file)
REQUIRES_SHARED(Locks::mutator_lock_) {

Domain domain = ComputeDomain(klass->GetClassLoader(), dex_file);

if (domain == Domain::kApplication && klass->ShouldSkipHiddenApiChecks() && Runtime::Current()->IsJavaDebuggable()) {
// debug mode下开发者可以主动指定某类可信,用于调试
domain = ComputeDomain(/* is_trusted= */ true);
}

return domain;
}

dex_file的Domain在第一次加载Class时被初始化(注:Android 8时就已经不允许一个DexFile同时加载多个ClassLoader了):

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
static Domain DetermineDomainFromLocation(const std::string& dex_location,
ObjPtr<mirror::ClassLoader> class_loader) {

if (ArtModuleRootDistinctFromAndroidRoot()) {
// 在几个核心apex module内
if (LocationIsOnArtModule(dex_location.c_str())
|| LocationIsOnConscryptModule(dex_location.c_str())
|| LocationIsOnI18nModule(dex_location.c_str())) {
return Domain::kCorePlatform;
}

// 在apex下但是不是核心module
if (LocationIsOnApex(dex_location.c_str())) {
return Domain::kPlatform;
}
}

// 在/system/framework/下
if (LocationIsOnSystemFramework(dex_location.c_str())) {
return Domain::kPlatform;
}

// 在/system/ext/framework/下
if (LocationIsOnSystemExtFramework(dex_location.c_str())) {
return Domain::kPlatform;
}

// ClassLoader是null(即BootClassLoader)
if (class_loader.IsNull()) {
LOG(WARNING) << "DexFile " << dex_location << " is in boot class path but is not in a known location";
return Domain::kPlatform;
}

return Domain::kApplication;
}

值得注意的是Android Q中细分出了三个Domain:kCorePlatform、kPlatform、kApplication,同时对kPlatform访问kCorePlatform的情况也做出了一定限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case Domain::kPlatform: {
DCHECK(callee_context.GetDomain() == Domain::kCorePlatform);

// 如果是需要暴露出来的Core Platform API,通过
if ((runtime_flags & kAccCorePlatformApi) != 0) {
return false;
}

// 完全关闭访问限制,通过
// Android Q中默认是关闭,R中不知道
EnforcementPolicy policy = Runtime::Current()->GetCorePlatformApiEnforcementPolicy();
if (policy == EnforcementPolicy::kDisabled) {
return false;
}

return detail::HandleCorePlatformApiViolation(member, caller_context, access_method, policy);
}

Q中默认是关闭这个功能,R中不知道;这部分简单了解一下就好,主要还是关注kApplication即应用代码访问系统API的情况。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
template<typename T>
bool ShouldDenyAccessToMemberImpl(T* member, ApiList api_list, AccessMethod access_method) {
Runtime* runtime = Runtime::Current();

EnforcementPolicy hiddenApiPolicy = runtime->GetHiddenApiEnforcementPolicy();

MemberSignature member_signature(member);

// ART内部存在一个豁免名单,如果匹配,那么通过
if (member_signature.DoesPrefixMatchAny(runtime->GetHiddenApiExemptions())) {
MaybeUpdateAccessFlags(runtime, member, kAccPublicApi);
return false;
}

bool deny_access = false;

EnforcementPolicy testApiPolicy = runtime->GetTestApiEnforcementPolicy();
// 如果hiddenApiPolicy == EnforcementPolicy::kJustWarn,那么不作处理(EnforcementPolicy只有三种状态)
if (hiddenApiPolicy == EnforcementPolicy::kEnabled) {
if (testApiPolicy == EnforcementPolicy::kDisabled && api_list.IsTestApi()) {
// ART内部对测试API(即有@TestApi注解的API)的特别处理
deny_access = false;
} else {
// 比较SdkVersion,决定是否需要拒绝访问
// 所谓的 灰名单/黑名单 便在此实现
switch (api_list.GetMaxAllowedSdkVersion()) {
case SdkVersion::kP:
deny_access = runtime->isChangeEnabled(kHideMaxtargetsdkPHiddenApis);
break;
case SdkVersion::kQ:
deny_access = runtime->isChangeEnabled(kHideMaxtargetsdkQHiddenApis);
break;
default:
deny_access = IsSdkVersionSetAndMoreThan(runtime->GetTargetSdkVersion(), api_list.GetMaxAllowedSdkVersion());
}
}
}

if (access_method != AccessMethod::kNone) {
// 省略代码:发出关于访问隐藏API的警告

// If this access was not denied, move the member into whitelist and skip
// the warning the next time the member is accessed.
if (!deny_access) {
MaybeUpdateAccessFlags(runtime, member, kAccPublicApi);
}
}

return deny_access;
}

OK,大致流程都已经清晰,梳理一下:

  1. 如果目标成员是公开API,直接通过
  2. 获取调用者的AccessContext,如果可信那么通过
  3. 如果访问检查被完全关闭,那么通过
  4. 判断目标成员是否在豁免名单里,如果在那么通过
  5. hiddenApiPolicy == EnforcementPolicy::kJustWarn,不会对deny_access赋值,警告后通过
  6. 以上条件都不满足,根据targetSdkVersion决定是否需要拒绝访问

下有对策

把系统的策略搞清楚了,接下来绕过就容易了。
我们一步一步来:
首先如果这个member的access flags里有kAccPublicApi,那么系统就认为这是一个公开API,就不会进行任何限制了,然而我们如果要对access_flags动手脚,必须先拿到这个member,然而系统就是限制了我们去拿这个member的过程,死循环了,放弃;

然后,如果调用者是可信的,那么也会通过,有这些情况:

  1. 调用者所在的类在系统路径下
  2. 调用者所在的类对应的类加载器(ClassLoader)为null(即被BootClassLoader加载的类)
  3. debug版并且主动设置了跳过限制,对应接口为VMDebug.allowHiddenApiReflectionFrom(Class<?> klass)

我们有两种方法,一种是直接把自己的类变成系统类,另一种是通过系统API发起调用;第二种对应的实现方案就是套娃反射,然而现在已经被谷歌封掉了,我也找不到其他API,就只剩下把自己的类变成系统类了。
首先排除只能在debug mode工作的3;而1也没法满足,主动修改dex_file->hiddenapi_domain_需要先拿到这个dex_file指针,而不使用ART内部接口的情况下是不方便拿到的,而且修改hiddenapi_domain_需要提前知道这个成员变量对应的偏移,先放弃;2我觉得是这三种方法里最好的,Class对象直接在java层就能拿到,改也可以直接在java层改,类似这样:

1
2
3
Field classLoaderField = Class.class.getDeclaredField("classLoader");
classLoaderField.setAccessible(true);
classLoaderField.set(MyClass.class, null);

然后就可以用这个MyClass进行反射。
问题在于这个classLoader变量也是隐藏API,当然你也可以用Unsafe等方案,但终究不保险;我们最好使用公开API,那有这样的公开API吗?
有!
dalvik.system.DexFile中有这样一个方法

1
public Class loadClass (String name, ClassLoader loader)

第二个参数就是指定该Class的ClassLoader,设置成null即可。
但使用这个方法,你需要自己额外准备一个dex文件,加载获得DexFile对象后再调用loadClass,略显繁琐,实际使用的话可以弄个gradle脚本,拦截mergeDexDebug/Release拿到dex;另一个问题是DexFile是Deprecated,虽然现在用没问题,但保不准哪天就被谷歌给删了。
提到这个方法,有的小伙伴可能会想起来,我们的目标是获得一个行为受我们控制且class_loader==null的类,java.lang.reflect.Proxy中也有类似的接口,那么我们可以用动态代理吗?
事实证明是不行的,你确实可以获得一个class_loader==null的代理类,然而动态代理只是一个桥梁,最后执行动作是用的InvocationHandler对象,最终栈回溯的结果取决于这个InvocationHandler对应的Class。
(注:这就是我当时提出的另一个绕过方法,当时在我自己的贴吧里发了个贴,之后被百度删了,申诉四次没过,呵呵)

似乎从这里入手不太好的样子,我们继续。
第三步和第五步中,都需要通过runtime->GetHiddenApiEnforcementPolicy()的返回值做判断,查看实现可以看见其实就是返回了一个成员变量hidden_api_policy_,那我们改这个成员变量不就行了?然而打开runtime.h你就会发现这个对象太大了,什么东西都往里面扔,改里面的值存在一定风险;搜索一下你会发现art通过RAII机制封装了一个ScopedHiddenApiEnforcementPolicySetting出来,然而这个类的构造函数并没有导出,我们无法直接调用,先放着。

第四步中提到art内部有一个豁免名单,而这个名单同样保存在runtime中,和上面的情况一样;不过这个API暴露到了java层,java层中有对应的接口(VMRuntime.setHiddenApiExemptions(String[] exemptions)),不过这个接口在黑名单内,也无法直接调用。

OK,研究完了,得出结论:没有较为方便通用稳定的方法……
才怪。
我们的最终目标是成功拿到member,反映到代码里就是ShouldDenyAccessToMember返回false,为此我们可以通过各种方式干扰这个函数的执行,但最终都还是为了让它返回false。既然我们只是为了这个,那么其实可以用更直观的方式:native hook。
查看art代码可以发现无论是P上的ShouldBlockAccessToMember还是Q上的ShouldDenyAccessToMember,执行流程中都会调用某个关键的且符号已被导出的函数,P上是GetMemberActionImpl,Q上是ShouldDenyAccessToMemberImpl,直接hook住,修改它的返回值就ok了,目前Pine采用了这种方式,具体实现可见这个commit

总结

嗯,大概就是这样啦~
又放一下咱的QQ群:949888394~
感谢你能看到最后~


2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

2021/4/10 更新:
本文存在纰漏,请看一个通用的纯 Java 安卓隐藏 API 限制绕过方案

上有政策

常言道,知己知彼,百战百胜。要想破解这个限制,就必须去搞懂系统是怎么施加的限制;ok,废话不多说,let’s go!
以我们在Java层通过反射获取一个Method为例,Class.getMethod/getDeclaredMethod最终都会进入一个native方法getDeclaredMethodInternal,这个方法的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static jobject Class_getDeclaredMethodInternal(JNIEnv* env, jobject javaThis, jstring name, jobjectArray args) {
// 省略无关代码……
Handle<mirror::Method> result = hs.NewHandle(
mirror::Class::GetDeclaredMethodInternal<kRuntimePointerSize>(
soa.Self(),
klass,
soa.Decode<mirror::String>(name),
soa.Decode<mirror::ObjectArray<mirror::Class>>(args),
GetHiddenapiAccessContextFunction(soa.Self())));
if (result == nullptr || ShouldDenyAccessToMember(result->GetArtMethod(), soa.Self())) {
return nullptr;
}
return soa.AddLocalReference<jobject>(result.Get());
}

我们可以发现,如果ShouldDenyAccessToMember返回true,那么就会返回null,上层就会抛出方法找不到的异常。这里和Android P没什么不同,只是把ShouldBlockAccessToMember改了个名而已。
ShouldDenyAccessToMember会调用到hiddenapi::ShouldDenyAccessToMember,该函数是这样实现的:

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
37
38
39
template<typename T>
inline bool ShouldDenyAccessToMember(T* member,
const std::function<AccessContext()>& fn_get_access_context,
AccessMethod access_method)
REQUIRES_SHARED(Locks::mutator_lock_) {

const uint32_t runtime_flags = GetRuntimeFlags(member);

// 1:如果该成员是公开API,直接通过
if ((runtime_flags & kAccPublicApi) != 0) {
return false;
}

// 2:不是公开API(即为隐藏API),获取调用者和被访问成员的Domain
// 因为获取调用者需要回溯调用栈,性能非常差,所以尽量避免这个消耗
const AccessContext caller_context = fn_get_access_context();
const AccessContext callee_context(member->GetDeclaringClass());

// 3:如果调用者是可信的,直接返回
if (caller_context.CanAlwaysAccess(callee_context)) {
return false;
}

// 4:非可信调用者尝试访问隐藏API,根据调用者的Domain决定行为
switch (caller_context.GetDomain()) {
case Domain::kApplication: {
DCHECK(!callee_context.IsApplicationDomain());

// 如果访问检查被完全禁用,那么直接返回
EnforcementPolicy policy = Runtime::Current()->GetHiddenApiEnforcementPolicy();
if (policy == EnforcementPolicy::kDisabled) {
return false;
}

// 5:调用detail::ShouldDenyAccessToMemberImpl,决定是否需要拒绝访问
return detail::ShouldDenyAccessToMemberImpl(member, api_list, access_method);
}
// 省略
}

这个函数还是比较长的,我们一步步分析。

  1. 判断目标成员是否是公开API,如果是那么直接通过,具体可以看见是通过GetRuntimeFlags获取,这个flags其实是储存在该成员的access_flags_中(其实这里不太完全,暂且这么认为吧)
  2. 获取调用者的Domain,判断是否可信,如果可信直接通过
  3. 以上条件都不满足,根据Domain走不同的实现,我们应用的代码对应的Domain是kApplication,主要看第一个case就行。

第二步中获取调用者的函数中核心部分如下:

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
37
38
bool VisitFrame() override REQUIRES_SHARED(Locks::mutator_lock_) {
ArtMethod *m = GetMethod();
if (m == nullptr) {
// Attached native thread. Assume this is *not* boot class path.
caller = nullptr;
return false;
} else if (m->IsRuntimeMethod()) {
// 跳过 ART Runtime 内部方法
return true;
}

ObjPtr<mirror::Class> declaring_class = m->GetDeclaringClass();
if (declaring_class->IsBootStrapClassLoaded()) {
// 跳过 java.lang.Class 中的内部方法
if (declaring_class->IsClassClass()) {
return true;
}

// 跳过 java.lang.invoke.*
ObjPtr<mirror::Class> lookup_class = GetClassRoot<mirror::MethodHandlesLookup>();
if ((declaring_class == lookup_class || declaring_class->IsInSamePackage(lookup_class))
&& !m->IsClassInitializer()) {
return true;
}

// 如果PREVENT_META_REFLECTION_BLACKLIST_ACCESS为Enabled,跳过来自 java.lang.reflect.* 的访问
// 系统对“套娃反射”的限制的关键就在此
ObjPtr<mirror::Class> proxy_class = GetClassRoot<mirror::Proxy>();
if (declaring_class->IsInSamePackage(proxy_class) && declaring_class != proxy_class) {
if (Runtime::Current()->isChangeEnabled(kPreventMetaReflectionBlacklistAccess)) {
return true;
}
}
}

caller = m;
return false;
}

根据caller选择Domain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static Domain ComputeDomain(ObjPtr<mirror::ClassLoader> class_loader, const DexFile* dex_file) {
if (dex_file == nullptr) {
// dex_file == nullptr && class_loader == nullptr(即被BootClassLoader加载的类),那么可信
return ComputeDomain(/* is_trusted= */ class_loader.IsNull());
}
// 获取dex_file的Domain
return dex_file->GetHiddenapiDomain();
}

static Domain ComputeDomain(ObjPtr<mirror::Class> klass, const DexFile* dex_file)
REQUIRES_SHARED(Locks::mutator_lock_) {

Domain domain = ComputeDomain(klass->GetClassLoader(), dex_file);

if (domain == Domain::kApplication && klass->ShouldSkipHiddenApiChecks() && Runtime::Current()->IsJavaDebuggable()) {
// debug mode下开发者可以主动指定某类可信,用于调试
domain = ComputeDomain(/* is_trusted= */ true);
}

return domain;
}

dex_file的Domain在第一次加载Class时被初始化(注:Android 8时就已经不允许一个DexFile同时加载多个ClassLoader了):

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
static Domain DetermineDomainFromLocation(const std::string& dex_location,
ObjPtr<mirror::ClassLoader> class_loader) {

if (ArtModuleRootDistinctFromAndroidRoot()) {
// 在几个核心apex module内
if (LocationIsOnArtModule(dex_location.c_str())
|| LocationIsOnConscryptModule(dex_location.c_str())
|| LocationIsOnI18nModule(dex_location.c_str())) {
return Domain::kCorePlatform;
}

// 在apex下但是不是核心module
if (LocationIsOnApex(dex_location.c_str())) {
return Domain::kPlatform;
}
}

// 在/system/framework/下
if (LocationIsOnSystemFramework(dex_location.c_str())) {
return Domain::kPlatform;
}

// 在/system/ext/framework/下
if (LocationIsOnSystemExtFramework(dex_location.c_str())) {
return Domain::kPlatform;
}

// ClassLoader是null(即BootClassLoader)
if (class_loader.IsNull()) {
LOG(WARNING) << "DexFile " << dex_location << " is in boot class path but is not in a known location";
return Domain::kPlatform;
}

return Domain::kApplication;
}

值得注意的是Android Q中细分出了三个Domain:kCorePlatform、kPlatform、kApplication,同时对kPlatform访问kCorePlatform的情况也做出了一定限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case Domain::kPlatform: {
DCHECK(callee_context.GetDomain() == Domain::kCorePlatform);

// 如果是需要暴露出来的Core Platform API,通过
if ((runtime_flags & kAccCorePlatformApi) != 0) {
return false;
}

// 完全关闭访问限制,通过
// Android Q中默认是关闭,R中不知道
EnforcementPolicy policy = Runtime::Current()->GetCorePlatformApiEnforcementPolicy();
if (policy == EnforcementPolicy::kDisabled) {
return false;
}

return detail::HandleCorePlatformApiViolation(member, caller_context, access_method, policy);
}

Q中默认是关闭这个功能,R中不知道;这部分简单了解一下就好,主要还是关注kApplication即应用代码访问系统API的情况。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
template<typename T>
bool ShouldDenyAccessToMemberImpl(T* member, ApiList api_list, AccessMethod access_method) {
Runtime* runtime = Runtime::Current();

EnforcementPolicy hiddenApiPolicy = runtime->GetHiddenApiEnforcementPolicy();

MemberSignature member_signature(member);

// ART内部存在一个豁免名单,如果匹配,那么通过
if (member_signature.DoesPrefixMatchAny(runtime->GetHiddenApiExemptions())) {
MaybeUpdateAccessFlags(runtime, member, kAccPublicApi);
return false;
}

bool deny_access = false;

EnforcementPolicy testApiPolicy = runtime->GetTestApiEnforcementPolicy();
// 如果hiddenApiPolicy == EnforcementPolicy::kJustWarn,那么不作处理(EnforcementPolicy只有三种状态)
if (hiddenApiPolicy == EnforcementPolicy::kEnabled) {
if (testApiPolicy == EnforcementPolicy::kDisabled && api_list.IsTestApi()) {
// ART内部对测试API(即有@TestApi注解的API)的特别处理
deny_access = false;
} else {
// 比较SdkVersion,决定是否需要拒绝访问
// 所谓的 灰名单/黑名单 便在此实现
switch (api_list.GetMaxAllowedSdkVersion()) {
case SdkVersion::kP:
deny_access = runtime->isChangeEnabled(kHideMaxtargetsdkPHiddenApis);
break;
case SdkVersion::kQ:
deny_access = runtime->isChangeEnabled(kHideMaxtargetsdkQHiddenApis);
break;
default:
deny_access = IsSdkVersionSetAndMoreThan(runtime->GetTargetSdkVersion(), api_list.GetMaxAllowedSdkVersion());
}
}
}

if (access_method != AccessMethod::kNone) {
// 省略代码:发出关于访问隐藏API的警告

// If this access was not denied, move the member into whitelist and skip
// the warning the next time the member is accessed.
if (!deny_access) {
MaybeUpdateAccessFlags(runtime, member, kAccPublicApi);
}
}

return deny_access;
}

OK,大致流程都已经清晰,梳理一下:

  1. 如果目标成员是公开API,直接通过
  2. 获取调用者的AccessContext,如果可信那么通过
  3. 如果访问检查被完全关闭,那么通过
  4. 判断目标成员是否在豁免名单里,如果在那么通过
  5. hiddenApiPolicy == EnforcementPolicy::kJustWarn,不会对deny_access赋值,警告后通过
  6. 以上条件都不满足,根据targetSdkVersion决定是否需要拒绝访问

下有对策

把系统的策略搞清楚了,接下来绕过就容易了。
我们一步一步来:
首先如果这个member的access flags里有kAccPublicApi,那么系统就认为这是一个公开API,就不会进行任何限制了,然而我们如果要对access_flags动手脚,必须先拿到这个member,然而系统就是限制了我们去拿这个member的过程,死循环了,放弃;

然后,如果调用者是可信的,那么也会通过,有这些情况:

  1. 调用者所在的类在系统路径下
  2. 调用者所在的类对应的类加载器(ClassLoader)为null(即被BootClassLoader加载的类)
  3. debug版并且主动设置了跳过限制,对应接口为VMDebug.allowHiddenApiReflectionFrom(Class<?> klass)

我们有两种方法,一种是直接把自己的类变成系统类,另一种是通过系统API发起调用;第二种对应的实现方案就是套娃反射,然而现在已经被谷歌封掉了,我也找不到其他API,就只剩下把自己的类变成系统类了。
首先排除只能在debug mode工作的3;而1也没法满足,主动修改dex_file->hiddenapi_domain_需要先拿到这个dex_file指针,而不使用ART内部接口的情况下是不方便拿到的,而且修改hiddenapi_domain_需要提前知道这个成员变量对应的偏移,先放弃;2我觉得是这三种方法里最好的,Class对象直接在java层就能拿到,改也可以直接在java层改,类似这样:

1
2
3
Field classLoaderField = Class.class.getDeclaredField("classLoader");
classLoaderField.setAccessible(true);
classLoaderField.set(MyClass.class, null);

然后就可以用这个MyClass进行反射。
问题在于这个classLoader变量也是隐藏API,当然你也可以用Unsafe等方案,但终究不保险;我们最好使用公开API,那有这样的公开API吗?
有!
dalvik.system.DexFile中有这样一个方法

1
public Class loadClass (String name, ClassLoader loader)

第二个参数就是指定该Class的ClassLoader,设置成null即可。
但使用这个方法,你需要自己额外准备一个dex文件,加载获得DexFile对象后再调用loadClass,略显繁琐,实际使用的话可以弄个gradle脚本,拦截mergeDexDebug/Release拿到dex;另一个问题是DexFile是Deprecated,虽然现在用没问题,但保不准哪天就被谷歌给删了。
提到这个方法,有的小伙伴可能会想起来,我们的目标是获得一个行为受我们控制且class_loader==null的类,java.lang.reflect.Proxy中也有类似的接口,那么我们可以用动态代理吗?
事实证明是不行的,你确实可以获得一个class_loader==null的代理类,然而动态代理只是一个桥梁,最后执行动作是用的InvocationHandler对象,最终栈回溯的结果取决于这个InvocationHandler对应的Class。
(注:这就是我当时提出的另一个绕过方法,当时在我自己的贴吧里发了个贴,之后被百度删了,申诉四次没过,呵呵)

似乎从这里入手不太好的样子,我们继续。
第三步和第五步中,都需要通过runtime->GetHiddenApiEnforcementPolicy()的返回值做判断,查看实现可以看见其实就是返回了一个成员变量hidden_api_policy_,那我们改这个成员变量不就行了?然而打开runtime.h你就会发现这个对象太大了,什么东西都往里面扔,改里面的值存在一定风险;搜索一下你会发现art通过RAII机制封装了一个ScopedHiddenApiEnforcementPolicySetting出来,然而这个类的构造函数并没有导出,我们无法直接调用,先放着。

第四步中提到art内部有一个豁免名单,而这个名单同样保存在runtime中,和上面的情况一样;不过这个API暴露到了java层,java层中有对应的接口(VMRuntime.setHiddenApiExemptions(String[] exemptions)),不过这个接口在黑名单内,也无法直接调用。

OK,研究完了,得出结论:没有较为方便通用稳定的方法……
才怪。
我们的最终目标是成功拿到member,反映到代码里就是ShouldDenyAccessToMember返回false,为此我们可以通过各种方式干扰这个函数的执行,但最终都还是为了让它返回false。既然我们只是为了这个,那么其实可以用更直观的方式:native hook。
查看art代码可以发现无论是P上的ShouldBlockAccessToMember还是Q上的ShouldDenyAccessToMember,执行流程中都会调用某个关键的且符号已被导出的函数,P上是GetMemberActionImpl,Q上是ShouldDenyAccessToMemberImpl,直接hook住,修改它的返回值就ok了,目前Pine采用了这种方式,具体实现可见这个commit

总结

嗯,大概就是这样啦~
又放一下咱的QQ群:949888394~
感谢你能看到最后~


之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

源码分析

大家都知道的,zygote对应的可执行文件就是app_process,它的main函数代码如下(已精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char* const argv[])
{
AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv));
// Process command line arguments
// ignore argv[0]
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.");
}
}

AppRuntime继承自AndroidRuntime,而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
30
31
32
/*
* 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)
{
ALOGD(">>>>>> START %s uid %d <<<<<<\n",
className != NULL ? className : "(unknown)", getuid());

/* start the virtual machine */
JniInvocation jni_invocation;
jni_invocation.Init(NULL);
JNIEnv* env;
if (startVm(&mJavaVM, &env, zygote, primary_zygote) != 0) {
return;
}
onVmCreated(env);

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

// ...
}

这个函数做的最重要一件事就是把虚拟机启动起来(startVm),然后调用传入类的main方法。
追踪这个startVm方法你会发现调用到了Runtime::Init初始化runtime,这个函数很长,截取了一段对我们来说最重要的:

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
bool Runtime::Init(RuntimeArgumentMap&& runtime_options_in) {
// ...
// Look for a native bridge.
//
// The intended flow here is, in the case of a running system:
//
// Runtime::Init() (zygote):
// LoadNativeBridge -> dlopen from cmd line parameter.
// |
// V
// Runtime::Start() (zygote):
// No-op wrt native bridge.
// |
// | start app
// V
// DidForkFromZygote(action)
// action = kUnload -> dlclose native bridge.
// action = kInitialize -> initialize library
//
//
// The intended flow here is, in the case of a simple dalvikvm call:
//
// Runtime::Init():
// LoadNativeBridge -> dlopen from cmd line parameter.
// |
// V
// Runtime::Start():
// DidForkFromZygote(kInitialize) -> try to initialize any native bridge given.
// No-op wrt native bridge.
{
std::string native_bridge_file_name = runtime_options.ReleaseOrDefault(Opt::NativeBridge);
is_native_bridge_loaded_ = LoadNativeBridge(native_bridge_file_name);
}
// ...
}

在Runtime::Init里会加载native bridge,LoadNativeBridge()函数是这样实现的:

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
37
38
39
40
41
42
43
bool LoadNativeBridge(const char* nb_library_filename,
const NativeBridgeRuntimeCallbacks* runtime_cbs) {
// We expect only one place that calls LoadNativeBridge: Runtime::Init. At that point we are not
// multi-threaded, so we do not need locking here.

if (nb_library_filename == nullptr || *nb_library_filename == 0) {
CloseNativeBridge(false);
return false;
} else {
if (!NativeBridgeNameAcceptable(nb_library_filename)) {
CloseNativeBridge(true);
} else {
// Try to open the library.
void* handle = dlopen(nb_library_filename, RTLD_LAZY);
if (handle != nullptr) {
callbacks = reinterpret_cast<NativeBridgeCallbacks*>(dlsym(handle,
kNativeBridgeInterfaceSymbol));
if (callbacks != nullptr) {
if (isCompatibleWith(NAMESPACE_VERSION)) {
// Store the handle for later.
native_bridge_handle = handle;
} else {
callbacks = nullptr;
dlclose(handle);
ALOGW("Unsupported native bridge interface.");
}
} else {
dlclose(handle);
}
}

// Two failure conditions: could not find library (dlopen failed), or could not find native
// bridge interface (dlsym failed). Both are an error and close the native bridge.
if (callbacks == nullptr) {
CloseNativeBridge(true);
} else {
runtime_callbacks = runtime_cbs;
state = NativeBridgeState::kOpened;
}
}
return state == NativeBridgeState::kOpened;
}
}

发现了什么没有!!是我们熟悉的dlopen!!dlopen会执行目标库的.init_array中的所有函数,而让自己的函数进入.init_array实际上只需要声明__attribute__((constructor))就好了,完全没有难度啊!
hey,先冷静一下,我们还有一个问题不知道答案:这个native bridge是从哪传进来的?答案很简单,回过头看一下AndroidRuntime::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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/*
* 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, bool primary_zygote)
{
JavaVMInitArgs initArgs;
// ...

// Native bridge library. "0" means that native bridge is disabled.
//
// Note: bridging is only enabled for the zygote. Other runs of
// app_process may not have the permissions to mount etc.
property_get("ro.dalvik.vm.native.bridge", propBuf, "");
if (propBuf[0] == '\0') {
ALOGW("ro.dalvik.vm.native.bridge is not expected to be empty");
} else if (zygote && strcmp(propBuf, "0") != 0) {
snprintf(nativeBridgeLibrary, sizeof("-XX:NativeBridge=") + PROPERTY_VALUE_MAX,
"-XX:NativeBridge=%s", propBuf);
addOption(nativeBridgeLibrary);
}
// ...
initArgs.version = JNI_VERSION_1_4;
initArgs.options = mOptions.editArray();
initArgs.nOptions = mOptions.size();
initArgs.ignoreUnrecognized = JNI_FALSE;

/*
* 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;
}

原来是读取的ro.dalvik.vm.native.bridge这个系统属性啊,等等,这个属性名字是以.ro开头的,也就代表着这个属性是只读的,一旦设置不能修改…… 另一个问题是,这个属性定义在default.prop中,而非常规的build.prop,这个文件改不了,每次开机都会重新读取,那还玩啥啊,拜拜……
等等!谁说这条属性就只能由厂商修改了?

利用

我拿来测试的设备是一台Google Pixel 3(Android 10,Magisk 20.4),因为有magisk所以直接写成了magisk模块;没有magisk的话可以考虑修改ramdisk.img(此方法同样适用于模拟器),将default.prop中的ro.dalvik.vm.native.bridge修改为我们的so文件名就好了(注意文件必须在系统的lib下面)
这里就当你把环境配置好了吧,让我们继续:
写一个函数,往里面写入代码,加上__attribute__((constructor)),编译,放/system/lib64和/system/lib下面,修改ro.dalvik.vm.native.bridge为我们的文件名,重启,成功,完结撒花……

当然不可能这么容易,此时虽然你已经把代码成功注入到了zygote进程,但是还有一些问题要处理,让我们来细数一下。

系统原有的native bridge被覆盖

native bridge这东西对arm设备上来说基本没啥用,然而对x86设备来说,没有这玩意你就没法用只支持arm的app,也就是说你连微信都用不了……
要解决这个问题,还是得看源码,看看系统是怎么调用的native bridge里的函数:

1
2
3
4
5
6
7
void* NativeBridgeGetTrampoline(void* handle, const char* name, const char* shorty,
uint32_t len) {
if (NativeBridgeInitialized()) {
return callbacks->getTrampoline(handle, name, shorty, len);
}
return nullptr;
}

是用的一个叫callbacks的全局变量啊,看下这个callbacks是啥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Native bridge interfaces to runtime.
struct NativeBridgeCallbacks {
// Version number of the interface.
uint32_t version;

bool (*initialize)(const struct NativeBridgeRuntimeCallbacks* runtime_cbs,
const char* private_dir, const char* instruction_set);

void* (*loadLibrary)(const char* libpath, int flag);

void* (*getTrampoline)(void* handle, const char* name, const char* shorty, uint32_t len);
// ...
}

// Pointer to the callbacks. Available as soon as LoadNativeBridge succeeds, but only initialized
// later.
static const NativeBridgeCallbacks* callbacks = nullptr;

原来是一个指向NativeBridgeCallbacks的指针,这个叫做NativeBridgeCallbacks的结构体里包含函数指针,运行时会找到对应的函数指针然后调用。
这个变量是在哪初始化的呢:

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
// The symbol name exposed by native-bridge with the type of NativeBridgeCallbacks.
static constexpr const char* kNativeBridgeInterfaceSymbol = "NativeBridgeItf";

bool LoadNativeBridge(const char* nb_library_filename,
const NativeBridgeRuntimeCallbacks* runtime_cbs) {
// Try to open the library.
void* handle = dlopen(nb_library_filename, RTLD_LAZY);
if (handle != nullptr) {
callbacks = reinterpret_cast<NativeBridgeCallbacks*>(dlsym(handle,
kNativeBridgeInterfaceSymbol));
if (callbacks != nullptr) {
if (isCompatibleWith(NAMESPACE_VERSION)) {
// Store the handle for later.
native_bridge_handle = handle;
} else {
callbacks = nullptr;
dlclose(handle);
ALOGW("Unsupported native bridge interface.");
}
} else {
dlclose(handle);
}
}
return state == NativeBridgeState::kOpened;
}
}

是从native bridge的so库中找到的,对应符号是NativeBridgeItf
既然系统是这样做的,那我们就顺着系统来,在合适的时候偷梁换柱一下。
首先声明一个对应类型的变量NativeBridgeItf:

1
__attribute__ ((visibility ("default"))) NativeBridgeCallbacks NativeBridgeItf;

注:如果你使用c++,记得加上extern "C"
然后,在系统dlopen我们的库时,会执行.init_array里的函数,我们可以在这里动手脚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (real_nb_filename[0] == '\0') {
LOGW("ro.dalvik.vm.native.bridge is not expected to be empty");
} else if (strcmp(real_nb_filename, "0") != 0) {
LOGI("The system has real native bridge support, libname %s", real_nb_filename);
const char* error_msg;
void* handle = dlopen(real_nb_filename, RTLD_LAZY);
if (handle) {
void* real_nb_itf = dlsym(handle, "NativeBridgeItf");
if (real_nb_itf) {
// sizeof(NativeBridgeCallbacks) maybe changed in other android version
memcpy(&NativeBridgeItf, real_nb_itf, sizeof(NativeBridgeCallbacks));
return;
}
errro_msg = dlerror();
dlclose(handle);
} else {
errro_msg = dlerror();
}
LOGE("Could not setup NativeBridgeItf for real lib %s: %s", real_nb_filename, error_msg);
}

简单解释一下:系统是通过读取我们的NativeBridgeItf这个变量来获取要执行的对应函数的,那我们就可以仿照系统,从真正的native bridge中读取这个变量,覆盖掉我们暴露出去的那个NativeBridgeItf,这样就会走真实的native bridge callbacks。
注:这里还有个坑,NativeBridgeCallbacks这个结构体的大小在其他系统版本是不同的,如果只复制固定大小,要么复制不全要么越界;所以这里需要按照版本判断一下。

无法驻留在内存中

当你兴致勃勃地写好了代码,运行时你会发现各种奇怪的bug,排查N遍后你才发现,你写好的这个so在内存中不知道什么时候消失了??
让我们看看系统的那个LoadNativeBridge:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void* handle = dlopen(nb_library_filename, RTLD_LAZY);
if (handle != nullptr) {
callbacks = reinterpret_cast<NativeBridgeCallbacks*>(dlsym(handle, kNativeBridgeInterfaceSymbol));
if (callbacks != nullptr) {
if (isCompatibleWith(NAMESPACE_VERSION)) {
// Store the handle for later.
native_bridge_handle = handle;
} else {
callbacks = nullptr;
dlclose(handle);
ALOGW("Unsupported native bridge interface.");
}
} else {
dlclose(handle);
}
}

如果isCompatibleWith这个函数返回false,那么就会close掉我们的so库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// The policy of invoking Nativebridge changed in v3 with/without namespace.
// Suggest Nativebridge implementation not maintain backward-compatible.
static bool isCompatibleWith(const uint32_t version) {
// Libnativebridge is now designed to be forward-compatible. So only "0" is an unsupported
// version.
if (callbacks == nullptr || callbacks->version == 0 || version == 0) {
return false;
}

// If this is a v2+ bridge, it may not be forwards- or backwards-compatible. Check.
if (callbacks->version >= SIGNAL_VERSION) {
return callbacks->isCompatibleWith(version);
}

return true;
}

是通过callbacks->versioncallbacks->isCompatibleWith这个函数指针判断的。
那我们需要在系统没有native bridge时设置一下这些东西。(如果系统有native bridge那么在上面NativeBridgeItf就已经被覆盖了)
你需要把callbacks里面的东西都设置一下,以免发生其他问题;还好还好,那些函数只需要写个空实现就行,需要注意的是版本,比如5.0就只接受v1版本的native bridge,而7.0时只接受v3及以上版本。

把这些设置好了以后,你的so库能成功驻留在zygote进程的内存中了;然而,你在应用进程中找不到这个so库,这是因为新进程fork出来以后,如果不需要native bridge,系统会卸载它:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static void ZygoteHooks_nativePostForkChild(JNIEnv* env,
jclass,
jlong token,
jint runtime_flags,
jboolean is_system_server,
jboolean is_zygote,
jstring instruction_set) {
// ...
if (instruction_set != nullptr && !is_system_server) {
ScopedUtfChars isa_string(env, instruction_set);
InstructionSet isa = GetInstructionSetFromString(isa_string.c_str());
Runtime::NativeBridgeAction action = Runtime::NativeBridgeAction::kUnload;
if (isa != InstructionSet::kNone && isa != kRuntimeISA) {
action = Runtime::NativeBridgeAction::kInitialize;
}
runtime->InitNonZygoteOrPostFork(env, is_system_server, is_zygote, action, isa_string.c_str());
} else {
runtime->InitNonZygoteOrPostFork(
env,
is_system_server,
is_zygote,
Runtime::NativeBridgeAction::kUnload,
/*isa=*/ nullptr,
profile_system_server);
}
}

void Runtime::InitNonZygoteOrPostFork(
JNIEnv* env,
bool is_system_server,
// This is true when we are initializing a child-zygote. It requires
// native bridge initialization to be able to run guest native code in
// doPreload().
bool is_child_zygote,
NativeBridgeAction action,
const char* isa,
bool profile_system_server) {
if (is_native_bridge_loaded_) {
switch (action) {
case NativeBridgeAction::kUnload:
UnloadNativeBridge();
is_native_bridge_loaded_ = false;
break;
case NativeBridgeAction::kInitialize:
InitializeNativeBridge(env, isa);
break;
}
}
// ...
}

这个过程我们很难干预,然而其实我们可以换个思路:既然系统要卸载这个so库,那我们就让它卸载;我们已经可以在zygote里执行任意代码了,那么写个新so库把主要逻辑放里面,在这个假的native bridge里dlopen()这个新库,假的native bridge直接当个loader不就好了嘛!而且这样的话实际上我们不用实现那堆函数,只需要把version设置成一个无效的值(比如0),这样系统检测到版本无效就会自动关闭我们的假native bridge库,也不用担心那些回调函数会被调用~

总结

利用native bridge可以实现比较简单的zygote注入,实际用起来需要费点功夫,不过都是体力活,比如每个版本中NativeBridgeCallbacks这个结构体的大小之类的;以后可能会把这东西应用在我的Dreamland上。
文末再放一下示例代码链接:NbInjection
QQ群:949888394,欢迎一起来玩~
文章可能有疏漏,也可能有更好的办法;欢迎交流讨论~


之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

源码分析

大家都知道的,zygote对应的可执行文件就是app_process,它的main函数代码如下(已精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char* const argv[])
{
AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv));
// Process command line arguments
// ignore argv[0]
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.");
}
}

AppRuntime继承自AndroidRuntime,而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
30
31
32
/*
* 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)
{
ALOGD(">>>>>> START %s uid %d <<<<<<\n",
className != NULL ? className : "(unknown)", getuid());

/* start the virtual machine */
JniInvocation jni_invocation;
jni_invocation.Init(NULL);
JNIEnv* env;
if (startVm(&mJavaVM, &env, zygote, primary_zygote) != 0) {
return;
}
onVmCreated(env);

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

// ...
}

这个函数做的最重要一件事就是把虚拟机启动起来(startVm),然后调用传入类的main方法。
追踪这个startVm方法你会发现调用到了Runtime::Init初始化runtime,这个函数很长,截取了一段对我们来说最重要的:

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
bool Runtime::Init(RuntimeArgumentMap&& runtime_options_in) {
// ...
// Look for a native bridge.
//
// The intended flow here is, in the case of a running system:
//
// Runtime::Init() (zygote):
// LoadNativeBridge -> dlopen from cmd line parameter.
// |
// V
// Runtime::Start() (zygote):
// No-op wrt native bridge.
// |
// | start app
// V
// DidForkFromZygote(action)
// action = kUnload -> dlclose native bridge.
// action = kInitialize -> initialize library
//
//
// The intended flow here is, in the case of a simple dalvikvm call:
//
// Runtime::Init():
// LoadNativeBridge -> dlopen from cmd line parameter.
// |
// V
// Runtime::Start():
// DidForkFromZygote(kInitialize) -> try to initialize any native bridge given.
// No-op wrt native bridge.
{
std::string native_bridge_file_name = runtime_options.ReleaseOrDefault(Opt::NativeBridge);
is_native_bridge_loaded_ = LoadNativeBridge(native_bridge_file_name);
}
// ...
}

在Runtime::Init里会加载native bridge,LoadNativeBridge()函数是这样实现的:

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
37
38
39
40
41
42
43
bool LoadNativeBridge(const char* nb_library_filename,
const NativeBridgeRuntimeCallbacks* runtime_cbs) {
// We expect only one place that calls LoadNativeBridge: Runtime::Init. At that point we are not
// multi-threaded, so we do not need locking here.

if (nb_library_filename == nullptr || *nb_library_filename == 0) {
CloseNativeBridge(false);
return false;
} else {
if (!NativeBridgeNameAcceptable(nb_library_filename)) {
CloseNativeBridge(true);
} else {
// Try to open the library.
void* handle = dlopen(nb_library_filename, RTLD_LAZY);
if (handle != nullptr) {
callbacks = reinterpret_cast<NativeBridgeCallbacks*>(dlsym(handle,
kNativeBridgeInterfaceSymbol));
if (callbacks != nullptr) {
if (isCompatibleWith(NAMESPACE_VERSION)) {
// Store the handle for later.
native_bridge_handle = handle;
} else {
callbacks = nullptr;
dlclose(handle);
ALOGW("Unsupported native bridge interface.");
}
} else {
dlclose(handle);
}
}

// Two failure conditions: could not find library (dlopen failed), or could not find native
// bridge interface (dlsym failed). Both are an error and close the native bridge.
if (callbacks == nullptr) {
CloseNativeBridge(true);
} else {
runtime_callbacks = runtime_cbs;
state = NativeBridgeState::kOpened;
}
}
return state == NativeBridgeState::kOpened;
}
}

发现了什么没有!!是我们熟悉的dlopen!!dlopen会执行目标库的.init_array中的所有函数,而让自己的函数进入.init_array实际上只需要声明__attribute__((constructor))就好了,完全没有难度啊!
hey,先冷静一下,我们还有一个问题不知道答案:这个native bridge是从哪传进来的?答案很简单,回过头看一下AndroidRuntime::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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/*
* 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, bool primary_zygote)
{
JavaVMInitArgs initArgs;
// ...

// Native bridge library. "0" means that native bridge is disabled.
//
// Note: bridging is only enabled for the zygote. Other runs of
// app_process may not have the permissions to mount etc.
property_get("ro.dalvik.vm.native.bridge", propBuf, "");
if (propBuf[0] == '\0') {
ALOGW("ro.dalvik.vm.native.bridge is not expected to be empty");
} else if (zygote && strcmp(propBuf, "0") != 0) {
snprintf(nativeBridgeLibrary, sizeof("-XX:NativeBridge=") + PROPERTY_VALUE_MAX,
"-XX:NativeBridge=%s", propBuf);
addOption(nativeBridgeLibrary);
}
// ...
initArgs.version = JNI_VERSION_1_4;
initArgs.options = mOptions.editArray();
initArgs.nOptions = mOptions.size();
initArgs.ignoreUnrecognized = JNI_FALSE;

/*
* 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;
}

原来是读取的ro.dalvik.vm.native.bridge这个系统属性啊,等等,这个属性名字是以.ro开头的,也就代表着这个属性是只读的,一旦设置不能修改…… 另一个问题是,这个属性定义在default.prop中,而非常规的build.prop,这个文件改不了,每次开机都会重新读取,那还玩啥啊,拜拜……
等等!谁说这条属性就只能由厂商修改了?

利用

我拿来测试的设备是一台Google Pixel 3(Android 10,Magisk 20.4),因为有magisk所以直接写成了magisk模块;没有magisk的话可以考虑修改ramdisk.img(此方法同样适用于模拟器),将default.prop中的ro.dalvik.vm.native.bridge修改为我们的so文件名就好了(注意文件必须在系统的lib下面)
这里就当你把环境配置好了吧,让我们继续:
写一个函数,往里面写入代码,加上__attribute__((constructor)),编译,放/system/lib64和/system/lib下面,修改ro.dalvik.vm.native.bridge为我们的文件名,重启,成功,完结撒花……

当然不可能这么容易,此时虽然你已经把代码成功注入到了zygote进程,但是还有一些问题要处理,让我们来细数一下。

系统原有的native bridge被覆盖

native bridge这东西对arm设备上来说基本没啥用,然而对x86设备来说,没有这玩意你就没法用只支持arm的app,也就是说你连微信都用不了……
要解决这个问题,还是得看源码,看看系统是怎么调用的native bridge里的函数:

1
2
3
4
5
6
7
void* NativeBridgeGetTrampoline(void* handle, const char* name, const char* shorty,
uint32_t len) {
if (NativeBridgeInitialized()) {
return callbacks->getTrampoline(handle, name, shorty, len);
}
return nullptr;
}

是用的一个叫callbacks的全局变量啊,看下这个callbacks是啥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Native bridge interfaces to runtime.
struct NativeBridgeCallbacks {
// Version number of the interface.
uint32_t version;

bool (*initialize)(const struct NativeBridgeRuntimeCallbacks* runtime_cbs,
const char* private_dir, const char* instruction_set);

void* (*loadLibrary)(const char* libpath, int flag);

void* (*getTrampoline)(void* handle, const char* name, const char* shorty, uint32_t len);
// ...
}

// Pointer to the callbacks. Available as soon as LoadNativeBridge succeeds, but only initialized
// later.
static const NativeBridgeCallbacks* callbacks = nullptr;

原来是一个指向NativeBridgeCallbacks的指针,这个叫做NativeBridgeCallbacks的结构体里包含函数指针,运行时会找到对应的函数指针然后调用。
这个变量是在哪初始化的呢:

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
// The symbol name exposed by native-bridge with the type of NativeBridgeCallbacks.
static constexpr const char* kNativeBridgeInterfaceSymbol = "NativeBridgeItf";

bool LoadNativeBridge(const char* nb_library_filename,
const NativeBridgeRuntimeCallbacks* runtime_cbs) {
// Try to open the library.
void* handle = dlopen(nb_library_filename, RTLD_LAZY);
if (handle != nullptr) {
callbacks = reinterpret_cast<NativeBridgeCallbacks*>(dlsym(handle,
kNativeBridgeInterfaceSymbol));
if (callbacks != nullptr) {
if (isCompatibleWith(NAMESPACE_VERSION)) {
// Store the handle for later.
native_bridge_handle = handle;
} else {
callbacks = nullptr;
dlclose(handle);
ALOGW("Unsupported native bridge interface.");
}
} else {
dlclose(handle);
}
}
return state == NativeBridgeState::kOpened;
}
}

是从native bridge的so库中找到的,对应符号是NativeBridgeItf
既然系统是这样做的,那我们就顺着系统来,在合适的时候偷梁换柱一下。
首先声明一个对应类型的变量NativeBridgeItf:

1
__attribute__ ((visibility ("default"))) NativeBridgeCallbacks NativeBridgeItf;

注:如果你使用c++,记得加上extern "C"
然后,在系统dlopen我们的库时,会执行.init_array里的函数,我们可以在这里动手脚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (real_nb_filename[0] == '\0') {
LOGW("ro.dalvik.vm.native.bridge is not expected to be empty");
} else if (strcmp(real_nb_filename, "0") != 0) {
LOGI("The system has real native bridge support, libname %s", real_nb_filename);
const char* error_msg;
void* handle = dlopen(real_nb_filename, RTLD_LAZY);
if (handle) {
void* real_nb_itf = dlsym(handle, "NativeBridgeItf");
if (real_nb_itf) {
// sizeof(NativeBridgeCallbacks) maybe changed in other android version
memcpy(&NativeBridgeItf, real_nb_itf, sizeof(NativeBridgeCallbacks));
return;
}
errro_msg = dlerror();
dlclose(handle);
} else {
errro_msg = dlerror();
}
LOGE("Could not setup NativeBridgeItf for real lib %s: %s", real_nb_filename, error_msg);
}

简单解释一下:系统是通过读取我们的NativeBridgeItf这个变量来获取要执行的对应函数的,那我们就可以仿照系统,从真正的native bridge中读取这个变量,覆盖掉我们暴露出去的那个NativeBridgeItf,这样就会走真实的native bridge callbacks。
注:这里还有个坑,NativeBridgeCallbacks这个结构体的大小在其他系统版本是不同的,如果只复制固定大小,要么复制不全要么越界;所以这里需要按照版本判断一下。

无法驻留在内存中

当你兴致勃勃地写好了代码,运行时你会发现各种奇怪的bug,排查N遍后你才发现,你写好的这个so在内存中不知道什么时候消失了??
让我们看看系统的那个LoadNativeBridge:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void* handle = dlopen(nb_library_filename, RTLD_LAZY);
if (handle != nullptr) {
callbacks = reinterpret_cast<NativeBridgeCallbacks*>(dlsym(handle, kNativeBridgeInterfaceSymbol));
if (callbacks != nullptr) {
if (isCompatibleWith(NAMESPACE_VERSION)) {
// Store the handle for later.
native_bridge_handle = handle;
} else {
callbacks = nullptr;
dlclose(handle);
ALOGW("Unsupported native bridge interface.");
}
} else {
dlclose(handle);
}
}

如果isCompatibleWith这个函数返回false,那么就会close掉我们的so库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// The policy of invoking Nativebridge changed in v3 with/without namespace.
// Suggest Nativebridge implementation not maintain backward-compatible.
static bool isCompatibleWith(const uint32_t version) {
// Libnativebridge is now designed to be forward-compatible. So only "0" is an unsupported
// version.
if (callbacks == nullptr || callbacks->version == 0 || version == 0) {
return false;
}

// If this is a v2+ bridge, it may not be forwards- or backwards-compatible. Check.
if (callbacks->version >= SIGNAL_VERSION) {
return callbacks->isCompatibleWith(version);
}

return true;
}

是通过callbacks->versioncallbacks->isCompatibleWith这个函数指针判断的。
那我们需要在系统没有native bridge时设置一下这些东西。(如果系统有native bridge那么在上面NativeBridgeItf就已经被覆盖了)
你需要把callbacks里面的东西都设置一下,以免发生其他问题;还好还好,那些函数只需要写个空实现就行,需要注意的是版本,比如5.0就只接受v1版本的native bridge,而7.0时只接受v3及以上版本。

把这些设置好了以后,你的so库能成功驻留在zygote进程的内存中了;然而,你在应用进程中找不到这个so库,这是因为新进程fork出来以后,如果不需要native bridge,系统会卸载它:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static void ZygoteHooks_nativePostForkChild(JNIEnv* env,
jclass,
jlong token,
jint runtime_flags,
jboolean is_system_server,
jboolean is_zygote,
jstring instruction_set) {
// ...
if (instruction_set != nullptr && !is_system_server) {
ScopedUtfChars isa_string(env, instruction_set);
InstructionSet isa = GetInstructionSetFromString(isa_string.c_str());
Runtime::NativeBridgeAction action = Runtime::NativeBridgeAction::kUnload;
if (isa != InstructionSet::kNone && isa != kRuntimeISA) {
action = Runtime::NativeBridgeAction::kInitialize;
}
runtime->InitNonZygoteOrPostFork(env, is_system_server, is_zygote, action, isa_string.c_str());
} else {
runtime->InitNonZygoteOrPostFork(
env,
is_system_server,
is_zygote,
Runtime::NativeBridgeAction::kUnload,
/*isa=*/ nullptr,
profile_system_server);
}
}

void Runtime::InitNonZygoteOrPostFork(
JNIEnv* env,
bool is_system_server,
// This is true when we are initializing a child-zygote. It requires
// native bridge initialization to be able to run guest native code in
// doPreload().
bool is_child_zygote,
NativeBridgeAction action,
const char* isa,
bool profile_system_server) {
if (is_native_bridge_loaded_) {
switch (action) {
case NativeBridgeAction::kUnload:
UnloadNativeBridge();
is_native_bridge_loaded_ = false;
break;
case NativeBridgeAction::kInitialize:
InitializeNativeBridge(env, isa);
break;
}
}
// ...
}

这个过程我们很难干预,然而其实我们可以换个思路:既然系统要卸载这个so库,那我们就让它卸载;我们已经可以在zygote里执行任意代码了,那么写个新so库把主要逻辑放里面,在这个假的native bridge里dlopen()这个新库,假的native bridge直接当个loader不就好了嘛!而且这样的话实际上我们不用实现那堆函数,只需要把version设置成一个无效的值(比如0),这样系统检测到版本无效就会自动关闭我们的假native bridge库,也不用担心那些回调函数会被调用~

总结

利用native bridge可以实现比较简单的zygote注入,实际用起来需要费点功夫,不过都是体力活,比如每个版本中NativeBridgeCallbacks这个结构体的大小之类的;以后可能会把这东西应用在我的Dreamland上。
文末再放一下示例代码链接:NbInjection
QQ群:949888394,欢迎一起来玩~
文章可能有疏漏,也可能有更好的办法;欢迎交流讨论~


2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

(注:本文主要内容将对比《空中浩劫》中所述与事实进行对比,不继续深入探讨飞机失事的深层原因;本文内容基于官方调查报告及官方黑匣子数据;如无特别说明,本文所有时间都采用UTC时间)。

在空难发生前几分钟,冰晶堵塞了皮托管,导致空速读数不一致,飞机的自动驾驶仪立刻断开,同时保护法则切换至备用。此时博南接手控制并开始拉杆。




接下来,该片中表示“即使另一名飞行员罗伯特告诉博南需要推杆,博南仍然一直在拉杆”;然而,FDR数据显示博南听从了罗伯特的建议,开始推杆(模拟动画中可以很明显看见右座侧杆在“nose down”位)。同时,ECAM上显示了最大速度,调查报告中表示这可能使机组更担心飞机超速而非失速,然而该细节没有展示在该片中。
接下来博南因为对当前情况的困惑再次开始拉杆。



此时,飞机陷入失速开始坠落,该片中表示“罗伯特接手控制,他开始推杆以改出失速,但博南仍在拉杆,他们的输入完全相反导致互相抵消了”;而现实是,罗伯特的确尝试接手,但是他只是向左扳了两下侧杆,然后控制权就又被博南抢走了。
调查报告原文如下:

At 2 h 11 min 37, the PNF said “controls to the left”, took over priority without any callout and continued to handle the aeroplane. The PF almost immediately took back priority without any callout and continued piloting.







此时,由于空速过低(三根皮托管测出的空速数据都小于60 kt),飞机认为这是电脑出了故障而抑制了失速警告,随后飞行员曾多次尝试推杆降低攻角,这使得空速回升,虽然还是失速但电脑认为这个值是有效的,再次触发了失速警告,这使得飞行员更加困惑;而如此重要的信息ACI里竟然一句都没提。
调查报告原文:

At around 2 h 11 min 42, the Captain re-entered the cockpit. During the following seconds, all of the recorded speeds became invalid and the stall warning stopped, after having sounded continuously for 54 seconds.

At 2 h 12 min 02, the PF said, “I have no more displays”, and the PNF “we have no valid indications”. At that moment, the thrust levers were in the IDLE detent and the engines’ N1’s were at 55%. Around fifteen seconds later, the PF made pitch-down inputs. In the following moments, the angle of attack decreased, the speeds became valid again and the stall warning triggered again.

If the CAS measurements for the three ADR are lower than 60 kt, the angle of attack values of the three ADR are invalid and the stall warning is then inoperative. This results from a logic stating that the airflow must be sufficient to ensure a valid measurement by the angle of attack sensors, especially to prevent spurious warnings.

在这种情况下,飞行员不会相信失速警告因为它表现得和以前他们所学到的内容完全相反;飞行员会本能地拉着杆让失速警告停止,殊不知这样会让情况更加恶化。



飞机就这样因为失速不断下坠,到了8000 ft,这个时候才发生明确的俯仰指令冲突,且FDR数据中有非常明确的“DUAL INPUT”警告,随后博南立刻放开了手,并非该片中所说的“两位飞行员输入相反,导致在没有警告的情况下相互抵消”;随后两位飞行员都开始拉杆;此时高度过低已无力回天,飞机坠入了冰冷的大西洋,机上228人全部罹难,R.I.P.






另附一些有趣的细节:

  1. 对其他机组进行类似状况模拟,有多个机组未能正确认识当前状态(见调查报告 1.16.2 及 1.16.8.4)。
  2. 残骸表明左座位在空难发生时在收回位(见调查报告 1.16.9)。
  3. 2006年5月3日,亚美尼亚航空967号班机在索契国际机场附近坠海,最终调查报告中提到在飞行的最后时刻,两位飞行员对侧杆做出了相反的输入,且“DUAL INPUT”警告因为优先级低于近地警告而被抑制。
  4. 法航A330“空速不可靠”操作手册:


至此,相信大家都了解了某文献剧是什么样的。

另附营销号笑话一则(纯娱乐,无恶意):
飞机是怎么飞起来的呢?飞机相信大家都很熟悉,但是飞机是怎么飞起来的呢,下面就让小编带大家一起了解吧。
飞机能飞起来,其实靠的就是引擎,而引擎是怎么工作的呢,其实和我们小时候玩的摇摇车是一样原理,投币就能工作,大家可能会很惊讶飞机怎么会靠引擎飞起来呢?但事实就是这样,小编也感到非常惊讶。
这就是关于飞机能飞起来的事情了,大家有什么想法呢,欢迎在评论区告诉小编一起讨论哦!



外部引用:


2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

(注:本文主要内容将对比《空中浩劫》中所述与事实进行对比,不继续深入探讨飞机失事的深层原因;本文内容基于官方调查报告及官方黑匣子数据;如无特别说明,本文所有时间都采用UTC时间)。

在空难发生前几分钟,冰晶堵塞了皮托管,导致空速读数不一致,飞机的自动驾驶仪立刻断开,同时保护法则切换至备用。此时博南接手控制并开始拉杆。




接下来,该片中表示“即使另一名飞行员罗伯特告诉博南需要推杆,博南仍然一直在拉杆”;然而,FDR数据显示博南听从了罗伯特的建议,开始推杆(模拟动画中可以很明显看见右座侧杆在“nose down”位)。同时,ECAM上显示了最大速度,调查报告中表示这可能使机组更担心飞机超速而非失速,然而该细节没有展示在该片中。
接下来博南因为对当前情况的困惑再次开始拉杆。



此时,飞机陷入失速开始坠落,该片中表示“罗伯特接手控制,他开始推杆以改出失速,但博南仍在拉杆,他们的输入完全相反导致互相抵消了”;而现实是,罗伯特的确尝试接手,但是他只是向左扳了两下侧杆,然后控制权就又被博南抢走了。
调查报告原文如下:

At 2 h 11 min 37, the PNF said “controls to the left”, took over priority without any callout and continued to handle the aeroplane. The PF almost immediately took back priority without any callout and continued piloting.







此时,由于空速过低(三根皮托管测出的空速数据都小于60 kt),飞机认为这是电脑出了故障而抑制了失速警告,随后飞行员曾多次尝试推杆降低攻角,这使得空速回升,虽然还是失速但电脑认为这个值是有效的,再次触发了失速警告,这使得飞行员更加困惑;而如此重要的信息ACI里竟然一句都没提。
调查报告原文:

At around 2 h 11 min 42, the Captain re-entered the cockpit. During the following seconds, all of the recorded speeds became invalid and the stall warning stopped, after having sounded continuously for 54 seconds.

At 2 h 12 min 02, the PF said, “I have no more displays”, and the PNF “we have no valid indications”. At that moment, the thrust levers were in the IDLE detent and the engines’ N1’s were at 55%. Around fifteen seconds later, the PF made pitch-down inputs. In the following moments, the angle of attack decreased, the speeds became valid again and the stall warning triggered again.

If the CAS measurements for the three ADR are lower than 60 kt, the angle of attack values of the three ADR are invalid and the stall warning is then inoperative. This results from a logic stating that the airflow must be sufficient to ensure a valid measurement by the angle of attack sensors, especially to prevent spurious warnings.

在这种情况下,飞行员不会相信失速警告因为它表现得和以前他们所学到的内容完全相反;飞行员会本能地拉着杆让失速警告停止,殊不知这样会让情况更加恶化。



飞机就这样因为失速不断下坠,到了8000 ft,这个时候才发生明确的俯仰指令冲突,且FDR数据中有非常明确的“DUAL INPUT”警告,随后博南立刻放开了手,并非该片中所说的“两位飞行员输入相反,导致在没有警告的情况下相互抵消”;随后两位飞行员都开始拉杆;此时高度过低已无力回天,飞机坠入了冰冷的大西洋,机上228人全部罹难,R.I.P.






另附一些有趣的细节:

  1. 对其他机组进行类似状况模拟,有多个机组未能正确认识当前状态(见调查报告 1.16.2 及 1.16.8.4)。
  2. 残骸表明左座位在空难发生时在收回位(见调查报告 1.16.9)。
  3. 2006年5月3日,亚美尼亚航空967号班机在索契国际机场附近坠海,最终调查报告中提到在飞行的最后时刻,两位飞行员对侧杆做出了相反的输入,且“DUAL INPUT”警告因为优先级低于近地警告而被抑制。
  4. 法航A330“空速不可靠”操作手册:


至此,相信大家都了解了某文献剧是什么样的。

另附营销号笑话一则(纯娱乐,无恶意):
飞机是怎么飞起来的呢?飞机相信大家都很熟悉,但是飞机是怎么飞起来的呢,下面就让小编带大家一起了解吧。
飞机能飞起来,其实靠的就是引擎,而引擎是怎么工作的呢,其实和我们小时候玩的摇摇车是一样原理,投币就能工作,大家可能会很惊讶飞机怎么会靠引擎飞起来呢?但事实就是这样,小编也感到非常惊讶。
这就是关于飞机能飞起来的事情了,大家有什么想法呢,欢迎在评论区告诉小编一起讨论哦!



外部引用:


不久前,开发者Rikka & vvb2060上架了一款环境检测应用Momo,把大家一直以来信任的各种反检测手段击得粉碎。下面我会通过部分已公开的源码,分析这个可能是史上最强的环境检测应用。

检测 Magisk

这一部分只分析有趣的东西,关于MagiskDetector的其他一些具体实现细节请直接查看 https://github.com/vvb2060/MagiskDetector/blob/master/README_ZH.md

反Magisk Hide

首先分析Magisk Hide的原理:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
static void new_zygote(int pid) {
struct stat st;
if (read_ns(pid, &st))
return;

auto it = zygote_map.find(pid);
if (it != zygote_map.end()) {
// Update namespace info
it->second = st;
return;
}

LOGD("proc_monitor: ptrace zygote PID=[%d]\n", pid);
zygote_map[pid] = st;

xptrace(PTRACE_ATTACH, pid);

waitpid(pid, nullptr, __WALL | __WNOTHREAD);
xptrace(PTRACE_SETOPTIONS, pid, nullptr,
PTRACE_O_TRACEFORK | PTRACE_O_TRACEVFORK | PTRACE_O_TRACEEXIT);
xptrace(PTRACE_CONT, pid);
}

void proc_monitor() {
// 省略...

// First try find existing zygotes
check_zygote();

for (int status;;) {
const int pid = waitpid(-1, &status, __WALL | __WNOTHREAD);
if (pid < 0) {
// 省略...
}

if (!WIFSTOPPED(status) /* Ignore if not ptrace-stop */)
DETACH_AND_CONT;

int event = WEVENT(status);
int signal = WSTOPSIG(status);

if (signal == SIGTRAP && event) {
unsigned long msg;
xptrace(PTRACE_GETEVENTMSG, pid, nullptr, &msg);
if (zygote_map.count(pid)) {
// Zygote event
switch (event) {
case PTRACE_EVENT_FORK:
case PTRACE_EVENT_VFORK:
PTRACE_LOG("zygote forked: [%lu]\n", msg);
attaches[msg] = true;
break;
// ...
}
} else {
switch (event) {
case PTRACE_EVENT_CLONE:
PTRACE_LOG("create new threads: [%lu]\n", msg);
if (attaches[pid] && check_pid(pid)) // 这里就会实际hide magisk
continue;
break;
// ...
}
}
xptrace(PTRACE_CONT, pid);
} else if (signal == SIGSTOP) {
if (!attaches[pid]) {
// Double check if this is actually a process
attaches[pid] = is_process(pid);
}
if (attaches[pid]) {
// This is a process, continue monitoring
PTRACE_LOG("SIGSTOP from child\n");
xptrace(PTRACE_SETOPTIONS, pid, nullptr,
PTRACE_O_TRACECLONE | PTRACE_O_TRACEEXEC | PTRACE_O_TRACEEXIT);
xptrace(PTRACE_CONT, pid);
} // ...
} // ...
}
}

可以看见,magisk hide通过ptrace机制跟踪所有zygote,通过cat /proc/<pid>/status看见TracerPid也可以证实我们的发现。子进程的第一个线程创建时,对其实际进行hide。

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
37
38
39
40
41
42
43
44
45
static bool check_pid(int pid) {
char path[128];
char cmdline[1024];
struct stat st;

sprintf(path, "/proc/%d/cmdline", pid);
if (auto f = open_file(path, "re")) {
fgets(cmdline, sizeof(cmdline), f.get());
} else {
// Process died unexpectedly, ignore
detach_pid(pid);
return true;
}

if (cmdline == "zygote"sv || cmdline == "zygote32"sv || cmdline == "zygote64"sv ||
cmdline == "usap32"sv || cmdline == "usap64"sv)
return false;

// 通过uid和进程名判断是否需要hide
if (!is_hide_target(uid, cmdline))
goto not_target;

// 如果命名空间未分离,进行unmount会影响到zygote从而影响到而后启动的所有进程,跳过
read_ns(pid, &st);
for (auto &zit : zygote_map) {
if (zit.second.st_ino == st.st_ino &&
zit.second.st_dev == st.st_dev) {
// ns not separated, abort
LOGW("proc_monitor: skip [%s] PID=[%d] UID=[%d]\n", cmdline, pid, uid);
goto not_target;
}
}

// Detach but the process should still remain stopped
// The hide daemon will resume the process after hiding it
LOGI("proc_monitor: [%s] PID=[%d] UID=[%d]\n", cmdline, pid, uid);
detach_pid(pid, SIGSTOP);
hide_daemon(pid);
return true;

not_target:
PTRACE_LOG("[%s] is not our target\n", cmdline);
detach_pid(pid);
return true;
}

hide_daemon里会fork一个新的进程,setns到目标进程的命名空间,然后卸载所有被magisk修改过的东西。注意,里面有个if判断,如果命名空间未分离,进行unmount会影响到zygote从而影响到而后启动的所有进程,那么直接跳过。这就是Magisk Hide的第一个问题。

在MagiskDetector的实现细节介绍里说明了有两种情况符合:

一个是应用appops的读取存储空间op为忽略,一个是该进程为隔离进程。

此处的隔离进程指的就是配置了 android:isolatedProcess="true"的service。而且,Android 10上还有一种有(私)趣(货)的东西,叫做App Zygote,这玩意几乎找不到说明,唯一的文档就是ZygotePreload,感觉更像谷歌给Chrome开的后门。咳咳,偏题了,这玩意运行在一个单独的进程,也不会分离命名空间。

目前已知解决此问题的方案有两种,第一种就是Magisk Lite,直接对zygote卸载而非应用,但这种方式会破坏很多现有模块;另一种就是利用进程注入,强行分离命名空间,典型的解决方案是Riru-Unshare

好的,这个问题说完了,下一个~~

在上面的判断代码里,读取进程名部分,是通过读取/proc/<pid>/cmdline进行判断的;而实际上,这个文件内容的长度是有限制的!这表示,当配置的进程名过长时,Magisk读取到的进程名会不匹配,从而跳过这个进程!这也就是Issue #3997的原理。Magisk对此做了临时修复:如果前缀匹配就直接认为是目标进程进行hide。

完了吗?没有。下一个问题在把进程添加到数据库的时候:

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
static int add_list(const char *pkg, const char *proc) {
if (proc[0] == '\0')
proc = pkg;

if (!validate(pkg) || !validate(proc))
return HIDE_INVALID_PKG;

// ...
}

static bool validate(const char *s) {
if (strcmp(s, ISOLATED_MAGIC) == 0)
return true;
bool dot = false;
for (char c; (c = *s); ++s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == ':') {
continue;
}
if (c == '.') {
dot = true;
continue;
}
return false;
}
return dot;
}

这里会对包名和进程名进行检查,如果含有非法字符或者没有点,那么认为是无效进程。Android对包名有严格规定,通过android:process配置的进程名也有规定,似乎无法作妖?然而问题确实发生了:Issue #4176

经过检查,该应用程序使用了隔离进程来检查Magisk,但不同的是,其服务类名含有非法字符(Java并没有限制类名),且Android 10+,系统会给隔离进程的名字追加类名(https://t.me/vvb2060Channel/441),导致检查不通过。解决方法也很简单,修改一下这个validate就好。

检测init.rc的修改:

随机只有在无法遍历的情况下才有效。如果可以遍历,使用统计方法即可准确找出每次都不一样的东西。

这句话看着有点迷惑,看看Magisk源码就知道了。https://github.com/topjohnwu/Magisk/blob/master/native/jni/init/rootdir.cpp#L45:

1
2
3
4
5
6
7
// Inject Magisk rc scripts
char pfd_svc[16], ls_svc[16], bc_svc[16];
gen_rand_str(pfd_svc, sizeof(pfd_svc));
gen_rand_str(ls_svc, sizeof(ls_svc));
gen_rand_str(bc_svc, sizeof(bc_svc));
LOGD("Inject magisk services: [%s] [%s] [%s]\n", pfd_svc, ls_svc, bc_svc);
fprintf(rc, MAGISK_RC, tmp_dir, pfd_svc, ls_svc, bc_svc);

Magisk在启动时会往init.rc中注入三个自己的服务,用来接收post-fs-data等事件;这三个服务的名称是做了随机化处理,而init实际上会往系统属性里添加像init.svc.<service name>这样子的属性,值是running或者stopped,以告诉其他进程该服务的状态。MagiskDetector就利用了这个机制,遍历系统属性记录所有服务名,然后在用户重启之后就能知道是否有服务的名称发生了变化。

检测SELinux规则

https://github.com/topjohnwu/Magisk/blob/master/native/jni/magiskpolicy/rules.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Allow these processes to access MagiskSU
const char *clients[] { "init", "shell", "appdomain", "zygote" };
for (auto type : clients) {
if (!exists(type))
continue;
allow(type, SEPOL_PROC_DOMAIN, "unix_stream_socket", "connectto");
allow(type, SEPOL_PROC_DOMAIN, "unix_stream_socket", "getopt");

// Allow termios ioctl
const char *pts[] { "devpts", "untrusted_app_devpts" };
for (auto pts_type : pts) {
allow(type, pts_type, "chr_file", "ioctl");
if (db->policyvers >= POLICYDB_VERSION_XPERMS_IOCTL)
allowxperm(type, pts_type, "chr_file", "0x5400-0x54FF");
}
}

由于Magisk允许了一些ioctl,所以会被检测到。解决方法是,更新到Android 8+ & Magisk 21+,Magisk会自动使用新的规则。

同时,不仅仅是Magisk自身的锅,错误使用SELinux也可能会造成Magisk被轻易检测到。举例:

1
2
type(SEPOL_PROC_DOMAIN, "domain");
type(SEPOL_FILE_TYPE, "file_type");

加了两个magisk自己的domain,看起来没问题,但是,如果用户把selinux设置为宽容模式(permissive),那么app可以进行selinux_check_access()(java层对应的接口为SELinux.checkSELinuxAccess()),如果获得允许,那么代表这个domain存在 => 安装了Magisk。

不只是宽容模式,如果添加了allow appdomain xxx relabelfrom之类的规则,又没有deny appdomain magisk_file relabelto,则app可能把某个文件的context给chcon成magisk_file,然后通过尝试操作这个文件判断有没有被拒绝就可以测试出系统中有没有这个domain。

SELinux是Android安全机制中的重要组成部分,强烈反对将其设置为宽容模式或忽略neverallow随意添加规则。

题外话:检测 magiskd

虽然 MagiskDetector 没有使用这个方法,但觉得有点意思,可以拿出来讲一讲。
Android 7之前,/proc没有限制,任何人都能遍历获得进程列表;在7的时候,加了hidepid=2,但并不是所有厂商都跟上了;对于这些设备,扫一下看看有没有个叫magiskd的进程就能确定有没有magisk。

Xposed

检测 Xposed

原版 Xposed 框架将自己的类加入到bootclasspath中,这导致任何人都能轻易找到。之后,大家都选择把 classloader 隔离开来,让检测没有那么容易;但是,只要它存在于内存中,那就可以被找到。XposedDetector的原理很简单,通过art的一个内部接口(VisitRoots),找到堆里的所有ClassLoader,然后一个一个尝试。目前lsp、edxp、梦境等都选择只在目标应用加载,以阻止误伤。把这个函数hook了当然可以,但我们并不想玩这种猫鼠游戏,只能保证非目标应用的环境不被修改。

反 Xposed Hook

XposedDetector的做法是,通过上面的方法,可以找到当前进程里的所有类,依据此法找到 XposedBridge 把 disableHookssHookedMethodCallbacks 改掉就可以。

实际上,还有很多其他方法:
除了原版xposed和0.5之前的edxposed,其他框架基本都直接忽略隔离进程,可以把重要的东西放在隔离进程。

通过 Xposed hook 一个方法,最终都会走到这个方法:

1
2
3
4
5
6
public static XC_MethodHook.Unhook hookMethod(Member hookMethod, XC_MethodHook callback) {
// ...
else if (hookMethod.getDeclaringClass().isInterface()) {
throw new IllegalArgumentException("Cannot hook interfaces: " + hookMethod.toString());
}
}

这段检查在Android 7之后是有问题的,因为Android 7支持了一个Java 8特性,叫做interface default method。interface不再只能“说空话”,而也能有自己的方法体,而实现类只要不重写,该方法的declaring class就是interface,Xposed进行hook时就会抛出异常。

Xposed和各路实现基本原理都是对entry_point_from_quick_compiled_code_这个成员动手脚,可以直接修改这个成员也可以inline hook;而在art中有一个“万能入口”:解释执行入口,通过设置方法入口为解释执行可以使得 Xposed hook 失效,但对 Frida 这种修改了解释器的无效。


不久前,开发者Rikka & vvb2060上架了一款环境检测应用Momo,把大家一直以来信任的各种反检测手段击得粉碎。下面我会通过部分已公开的源码,分析这个可能是史上最强的环境检测应用。

检测 Magisk

这一部分只分析有趣的东西,关于MagiskDetector的其他一些具体实现细节请直接查看 https://github.com/vvb2060/MagiskDetector/blob/master/README_ZH.md

反Magisk Hide

首先分析Magisk Hide的原理:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
static void new_zygote(int pid) {
struct stat st;
if (read_ns(pid, &st))
return;

auto it = zygote_map.find(pid);
if (it != zygote_map.end()) {
// Update namespace info
it->second = st;
return;
}

LOGD("proc_monitor: ptrace zygote PID=[%d]\n", pid);
zygote_map[pid] = st;

xptrace(PTRACE_ATTACH, pid);

waitpid(pid, nullptr, __WALL | __WNOTHREAD);
xptrace(PTRACE_SETOPTIONS, pid, nullptr,
PTRACE_O_TRACEFORK | PTRACE_O_TRACEVFORK | PTRACE_O_TRACEEXIT);
xptrace(PTRACE_CONT, pid);
}

void proc_monitor() {
// 省略...

// First try find existing zygotes
check_zygote();

for (int status;;) {
const int pid = waitpid(-1, &status, __WALL | __WNOTHREAD);
if (pid < 0) {
// 省略...
}

if (!WIFSTOPPED(status) /* Ignore if not ptrace-stop */)
DETACH_AND_CONT;

int event = WEVENT(status);
int signal = WSTOPSIG(status);

if (signal == SIGTRAP && event) {
unsigned long msg;
xptrace(PTRACE_GETEVENTMSG, pid, nullptr, &msg);
if (zygote_map.count(pid)) {
// Zygote event
switch (event) {
case PTRACE_EVENT_FORK:
case PTRACE_EVENT_VFORK:
PTRACE_LOG("zygote forked: [%lu]\n", msg);
attaches[msg] = true;
break;
// ...
}
} else {
switch (event) {
case PTRACE_EVENT_CLONE:
PTRACE_LOG("create new threads: [%lu]\n", msg);
if (attaches[pid] && check_pid(pid)) // 这里就会实际hide magisk
continue;
break;
// ...
}
}
xptrace(PTRACE_CONT, pid);
} else if (signal == SIGSTOP) {
if (!attaches[pid]) {
// Double check if this is actually a process
attaches[pid] = is_process(pid);
}
if (attaches[pid]) {
// This is a process, continue monitoring
PTRACE_LOG("SIGSTOP from child\n");
xptrace(PTRACE_SETOPTIONS, pid, nullptr,
PTRACE_O_TRACECLONE | PTRACE_O_TRACEEXEC | PTRACE_O_TRACEEXIT);
xptrace(PTRACE_CONT, pid);
} // ...
} // ...
}
}

可以看见,magisk hide通过ptrace机制跟踪所有zygote,通过cat /proc/<pid>/status看见TracerPid也可以证实我们的发现。子进程的第一个线程创建时,对其实际进行hide。

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
37
38
39
40
41
42
43
44
45
static bool check_pid(int pid) {
char path[128];
char cmdline[1024];
struct stat st;

sprintf(path, "/proc/%d/cmdline", pid);
if (auto f = open_file(path, "re")) {
fgets(cmdline, sizeof(cmdline), f.get());
} else {
// Process died unexpectedly, ignore
detach_pid(pid);
return true;
}

if (cmdline == "zygote"sv || cmdline == "zygote32"sv || cmdline == "zygote64"sv ||
cmdline == "usap32"sv || cmdline == "usap64"sv)
return false;

// 通过uid和进程名判断是否需要hide
if (!is_hide_target(uid, cmdline))
goto not_target;

// 如果命名空间未分离,进行unmount会影响到zygote从而影响到而后启动的所有进程,跳过
read_ns(pid, &st);
for (auto &zit : zygote_map) {
if (zit.second.st_ino == st.st_ino &&
zit.second.st_dev == st.st_dev) {
// ns not separated, abort
LOGW("proc_monitor: skip [%s] PID=[%d] UID=[%d]\n", cmdline, pid, uid);
goto not_target;
}
}

// Detach but the process should still remain stopped
// The hide daemon will resume the process after hiding it
LOGI("proc_monitor: [%s] PID=[%d] UID=[%d]\n", cmdline, pid, uid);
detach_pid(pid, SIGSTOP);
hide_daemon(pid);
return true;

not_target:
PTRACE_LOG("[%s] is not our target\n", cmdline);
detach_pid(pid);
return true;
}

hide_daemon里会fork一个新的进程,setns到目标进程的命名空间,然后卸载所有被magisk修改过的东西。注意,里面有个if判断,如果命名空间未分离,进行unmount会影响到zygote从而影响到而后启动的所有进程,那么直接跳过。这就是Magisk Hide的第一个问题。

在MagiskDetector的实现细节介绍里说明了有两种情况符合:

一个是应用appops的读取存储空间op为忽略,一个是该进程为隔离进程。

此处的隔离进程指的就是配置了 android:isolatedProcess="true"的service。而且,Android 10上还有一种有(私)趣(货)的东西,叫做App Zygote,这玩意几乎找不到说明,唯一的文档就是ZygotePreload,感觉更像谷歌给Chrome开的后门。咳咳,偏题了,这玩意运行在一个单独的进程,也不会分离命名空间。

目前已知解决此问题的方案有两种,第一种就是Magisk Lite,直接对zygote卸载而非应用,但这种方式会破坏很多现有模块;另一种就是利用进程注入,强行分离命名空间,典型的解决方案是Riru-Unshare

好的,这个问题说完了,下一个~~

在上面的判断代码里,读取进程名部分,是通过读取/proc/<pid>/cmdline进行判断的;而实际上,这个文件内容的长度是有限制的!这表示,当配置的进程名过长时,Magisk读取到的进程名会不匹配,从而跳过这个进程!这也就是Issue #3997的原理。Magisk对此做了临时修复:如果前缀匹配就直接认为是目标进程进行hide。

完了吗?没有。下一个问题在把进程添加到数据库的时候:

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
static int add_list(const char *pkg, const char *proc) {
if (proc[0] == '\0')
proc = pkg;

if (!validate(pkg) || !validate(proc))
return HIDE_INVALID_PKG;

// ...
}

static bool validate(const char *s) {
if (strcmp(s, ISOLATED_MAGIC) == 0)
return true;
bool dot = false;
for (char c; (c = *s); ++s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == ':') {
continue;
}
if (c == '.') {
dot = true;
continue;
}
return false;
}
return dot;
}

这里会对包名和进程名进行检查,如果含有非法字符或者没有点,那么认为是无效进程。Android对包名有严格规定,通过android:process配置的进程名也有规定,似乎无法作妖?然而问题确实发生了:Issue #4176

经过检查,该应用程序使用了隔离进程来检查Magisk,但不同的是,其服务类名含有非法字符(Java并没有限制类名),且Android 10+,系统会给隔离进程的名字追加类名(https://t.me/vvb2060Channel/441),导致检查不通过。解决方法也很简单,修改一下这个validate就好。

检测init.rc的修改:

随机只有在无法遍历的情况下才有效。如果可以遍历,使用统计方法即可准确找出每次都不一样的东西。

这句话看着有点迷惑,看看Magisk源码就知道了。https://github.com/topjohnwu/Magisk/blob/master/native/jni/init/rootdir.cpp#L45:

1
2
3
4
5
6
7
// Inject Magisk rc scripts
char pfd_svc[16], ls_svc[16], bc_svc[16];
gen_rand_str(pfd_svc, sizeof(pfd_svc));
gen_rand_str(ls_svc, sizeof(ls_svc));
gen_rand_str(bc_svc, sizeof(bc_svc));
LOGD("Inject magisk services: [%s] [%s] [%s]\n", pfd_svc, ls_svc, bc_svc);
fprintf(rc, MAGISK_RC, tmp_dir, pfd_svc, ls_svc, bc_svc);

Magisk在启动时会往init.rc中注入三个自己的服务,用来接收post-fs-data等事件;这三个服务的名称是做了随机化处理,而init实际上会往系统属性里添加像init.svc.<service name>这样子的属性,值是running或者stopped,以告诉其他进程该服务的状态。MagiskDetector就利用了这个机制,遍历系统属性记录所有服务名,然后在用户重启之后就能知道是否有服务的名称发生了变化。

检测SELinux规则

https://github.com/topjohnwu/Magisk/blob/master/native/jni/magiskpolicy/rules.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Allow these processes to access MagiskSU
const char *clients[] { "init", "shell", "appdomain", "zygote" };
for (auto type : clients) {
if (!exists(type))
continue;
allow(type, SEPOL_PROC_DOMAIN, "unix_stream_socket", "connectto");
allow(type, SEPOL_PROC_DOMAIN, "unix_stream_socket", "getopt");

// Allow termios ioctl
const char *pts[] { "devpts", "untrusted_app_devpts" };
for (auto pts_type : pts) {
allow(type, pts_type, "chr_file", "ioctl");
if (db->policyvers >= POLICYDB_VERSION_XPERMS_IOCTL)
allowxperm(type, pts_type, "chr_file", "0x5400-0x54FF");
}
}

由于Magisk允许了一些ioctl,所以会被检测到。解决方法是,更新到Android 8+ & Magisk 21+,Magisk会自动使用新的规则。

同时,不仅仅是Magisk自身的锅,错误使用SELinux也可能会造成Magisk被轻易检测到。举例:

1
2
type(SEPOL_PROC_DOMAIN, "domain");
type(SEPOL_FILE_TYPE, "file_type");

加了两个magisk自己的domain,看起来没问题,但是,如果用户把selinux设置为宽容模式(permissive),那么app可以进行selinux_check_access()(java层对应的接口为SELinux.checkSELinuxAccess()),如果获得允许,那么代表这个domain存在 => 安装了Magisk。

不只是宽容模式,如果添加了allow appdomain xxx relabelfrom之类的规则,又没有deny appdomain magisk_file relabelto,则app可能把某个文件的context给chcon成magisk_file,然后通过尝试操作这个文件判断有没有被拒绝就可以测试出系统中有没有这个domain。

SELinux是Android安全机制中的重要组成部分,强烈反对将其设置为宽容模式或忽略neverallow随意添加规则。

题外话:检测 magiskd

虽然 MagiskDetector 没有使用这个方法,但觉得有点意思,可以拿出来讲一讲。
Android 7之前,/proc没有限制,任何人都能遍历获得进程列表;在7的时候,加了hidepid=2,但并不是所有厂商都跟上了;对于这些设备,扫一下看看有没有个叫magiskd的进程就能确定有没有magisk。

Xposed

检测 Xposed

原版 Xposed 框架将自己的类加入到bootclasspath中,这导致任何人都能轻易找到。之后,大家都选择把 classloader 隔离开来,让检测没有那么容易;但是,只要它存在于内存中,那就可以被找到。XposedDetector的原理很简单,通过art的一个内部接口(VisitRoots),找到堆里的所有ClassLoader,然后一个一个尝试。目前lsp、edxp、梦境等都选择只在目标应用加载,以阻止误伤。把这个函数hook了当然可以,但我们并不想玩这种猫鼠游戏,只能保证非目标应用的环境不被修改。

反 Xposed Hook

XposedDetector的做法是,通过上面的方法,可以找到当前进程里的所有类,依据此法找到 XposedBridge 把 disableHookssHookedMethodCallbacks 改掉就可以。

实际上,还有很多其他方法:
除了原版xposed和0.5之前的edxposed,其他框架基本都直接忽略隔离进程,可以把重要的东西放在隔离进程。

通过 Xposed hook 一个方法,最终都会走到这个方法:

1
2
3
4
5
6
public static XC_MethodHook.Unhook hookMethod(Member hookMethod, XC_MethodHook callback) {
// ...
else if (hookMethod.getDeclaringClass().isInterface()) {
throw new IllegalArgumentException("Cannot hook interfaces: " + hookMethod.toString());
}
}

这段检查在Android 7之后是有问题的,因为Android 7支持了一个Java 8特性,叫做interface default method。interface不再只能“说空话”,而也能有自己的方法体,而实现类只要不重写,该方法的declaring class就是interface,Xposed进行hook时就会抛出异常。

Xposed和各路实现基本原理都是对entry_point_from_quick_compiled_code_这个成员动手脚,可以直接修改这个成员也可以inline hook;而在art中有一个“万能入口”:解释执行入口,通过设置方法入口为解释执行可以使得 Xposed hook 失效,但对 Frida 这种修改了解释器的无效。


当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

计算机:LSPosed 与向 Magisk 提交 PR

2021 年开头,发生了一件大家可能都知道的事情:shana 姐姐希望给 EdXposed 做出较大修改,如强制白名单作用域等,但 MlgmXyysd 姐姐不同意,认为应该保持与原版 Xposed 的一致性,吵过几次之后,shana 姐姐宣布分道扬镳,创建 LSPosed 还带走了 ksm。最初版的 LSPosed 就是一个强制启用了作用域版的 EdXposed,但仅仅如此就有大量用户表示获得了巨大的性能提升。以后的日子里,他们重构了整个项目,带来了基于 binder 的后端服务,修复了一大堆 bug 还添加了新的 API。当然这一切都与我没什么关系,我自己几乎一行代码没给 LSPosed 写过,也就是几个 art 有关的 bug 和一起他们讨论出修复方法,说白了就是打个酱油——惊喜的是,我竟然获得了 owner !而且和一群志同道合的哥哥姐姐们互相交流技术一起写代码真的很开心!!学会了很多我自己可能一辈子也学不到的东西!!如今,LSPosed 已经是国内外最受欢迎的 Xposed 实现,也算是完成了自己以前的一个小心愿吧~

在 LSPosed 刚开始的时候,我们就一直收到用户反馈安装后手机无法开机,获取到 log 后发现是 Magisk 挂载系统文件时到一半突然卡住,从 log 里完全看不出原因。更奇怪的是,我们添加了一些 log 构建出自己的 debug 版本发给用户测试后,竟然可以开机了!!百思不得其解之际,内群里另一位开发者的 magisk 卡住了,而且她的 sui 可以在 magisk su 不可用时获取到 root 权限帮助调试。用 lldb 折腾了两天之后,我们终于搞清楚她的问题出在哪里:log 里调用的 localtime_r 里面有一把不可重入的锁,加锁之后刚好收到一个 signal,程序直接转跳到 signal handler 执行,signal handler 打印 log 又要再次调用 localtime_r,里面又尝试获取自己已经加过的锁,然后直接死锁。理论上讲,它确实可以造成之前的问题,等到 magisk 把这个 bug 修复之后,我们以为万事大吉了。然而事实是,我们仍然继续收到相关反馈。几个月后,我们终于找到了一位可以稳定复现该问题的用户,最后发现了这个 bug 的本质:magisk 使用了一个没有初始化的变量,它可能是随机值。提交修复之后,整个内群都在发这么一句话:截图

这是我们第一次和 magisk bug 打交道。之后,我偶然发现我的备用手机红米 5 Plus 上 magisk hide 完全不工作,一开始我以为是我刷的 lineageos 的原因,但后来我在我的 Pixel 3 上也开始随机遇到这个问题,而且不断有人向我反馈 MomoHider 工作不正常,调查后发现是 magisk hide 坏了。某一天我静下心来,把那台备用机开机,连上电脑,一遍遍回看相关逻辑一点点改代码,最后被我一行代码修好了。然后我向 magisk 提交了我自己的第一个 PR,很可惜的是因为 magisk 后来删除了 magisk hide,我的 PR 没有被合并。往后的日子里,我们给 Magisk 提交了多次代码,修复了多个 bug。在之前我绝对想不到在全世界范围里获得数亿使用的 Magisk 会有这么多 bug,也很开心自己能参与一个这么大的项目,希望自己在新的一年里学到更多,最好拿到 collaborator (

学习:中职日常

寒假结束回到学校之后,我被发下来的课程表震惊了:我们的专业名叫计算机网络技术,但是第二学期把这门专业课给取消了,然后加了什么 常用工具软件(教你怎么用 QQ 怎么用美图秀秀怎么用酷狗音乐腾讯视频)还有 Photoshop。去问学校这是怎么回事,得到的答复是,学生们反映这门课太难听不懂就取消了。无语至极,也只能一边上着荒唐至极的课,一边自学准备高职高考。幸运的是,能在这里碰到一起编程一起学习一起高考的伙伴。三个月电子厂实习也结束了,希望自己新的一年能全力备战高考~

实习:被推向电子厂的三个月

六月份听说计算机等级考试九月份开始报名,十一点一到直接在课堂上拿出电脑报名。现在想来庆幸当时的自己的果断,韶大考点名额很少被自己抢到了一个,过了一天再去看只剩下松山学院可以报了~ 但是,直到期末我们才被通知,下个学期一开始就要去实习三个月。度过了一个暑假,开学了,学校果然没骗我,待了三天就直接坐着大巴去了几百公里外的城市。说是实习,其实也就是让学生去电子厂干些正常人都会干的活。在厂里上班三个月,我的感受就是这不是人干的活:程序员天天抱怨的996在那里根本就不是问题,我最晚一次早上八点上班上到晚上十点半,连晚饭都没吃;几乎没有假放,中秋的一天还是调休调出来的,国庆只有三天回趟家几乎在睡觉中度过;第一个月还好,第二个月开始上夜班两班倒,到后面基本上就是麻木的在那里干活,脑子放空手上还在机械的做那些动作,感觉时间流速都不一样了,活还在手上干着外面天已经亮了;回来基本上手机拿在手里没充电坐在床上就直接睡着了。三个月下来,拿到手工资不到一万,还把之前的基础几乎忘完了,到实习结束写实习报告的时候连字都不会写。但好在,这三个月我已经熬过来了。现在只希望自己能好好自学,参加高职高考能考上好的大学,否则按国内这个环境,我会再难的东西也是去电子厂的命。在知乎上看见一个立志要考上深职院的学姐,真的很励志呀,希望自己也能怎么努力~附上链接:中职生高职高考自学,感觉找不到好的方法学习方法,该怎么办? - 话梅的回答 - 知乎

在故事结束之后的故事

回来学校之后,很迷茫,似乎又回到了以前在学校那种状态,上完六节课就在课室或者宿舍躺着,送的试卷也没动力去翻一下。昨天晚上看 weishu 直播修 bug,猛然又想起当时的梦想,当时的梦多么天真多么美好啊,那就让我们去实现它吧~ 我不是一个有恒心的人,所以如果你愿意的话,请监督我吧,希望我们能在美好的未来相见~~


当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

计算机:LSPosed 与向 Magisk 提交 PR

2021 年开头,发生了一件大家可能都知道的事情:shana 姐姐希望给 EdXposed 做出较大修改,如强制白名单作用域等,但 MlgmXyysd 姐姐不同意,认为应该保持与原版 Xposed 的一致性,吵过几次之后,shana 姐姐宣布分道扬镳,创建 LSPosed 还带走了 ksm。最初版的 LSPosed 就是一个强制启用了作用域版的 EdXposed,但仅仅如此就有大量用户表示获得了巨大的性能提升。以后的日子里,他们重构了整个项目,带来了基于 binder 的后端服务,修复了一大堆 bug 还添加了新的 API。当然这一切都与我没什么关系,我自己几乎一行代码没给 LSPosed 写过,也就是几个 art 有关的 bug 和一起他们讨论出修复方法,说白了就是打个酱油——惊喜的是,我竟然获得了 owner !而且和一群志同道合的哥哥姐姐们互相交流技术一起写代码真的很开心!!学会了很多我自己可能一辈子也学不到的东西!!如今,LSPosed 已经是国内外最受欢迎的 Xposed 实现,也算是完成了自己以前的一个小心愿吧~

在 LSPosed 刚开始的时候,我们就一直收到用户反馈安装后手机无法开机,获取到 log 后发现是 Magisk 挂载系统文件时到一半突然卡住,从 log 里完全看不出原因。更奇怪的是,我们添加了一些 log 构建出自己的 debug 版本发给用户测试后,竟然可以开机了!!百思不得其解之际,内群里另一位开发者的 magisk 卡住了,而且她的 sui 可以在 magisk su 不可用时获取到 root 权限帮助调试。用 lldb 折腾了两天之后,我们终于搞清楚她的问题出在哪里:log 里调用的 localtime_r 里面有一把不可重入的锁,加锁之后刚好收到一个 signal,程序直接转跳到 signal handler 执行,signal handler 打印 log 又要再次调用 localtime_r,里面又尝试获取自己已经加过的锁,然后直接死锁。理论上讲,它确实可以造成之前的问题,等到 magisk 把这个 bug 修复之后,我们以为万事大吉了。然而事实是,我们仍然继续收到相关反馈。几个月后,我们终于找到了一位可以稳定复现该问题的用户,最后发现了这个 bug 的本质:magisk 使用了一个没有初始化的变量,它可能是随机值。提交修复之后,整个内群都在发这么一句话:截图

这是我们第一次和 magisk bug 打交道。之后,我偶然发现我的备用手机红米 5 Plus 上 magisk hide 完全不工作,一开始我以为是我刷的 lineageos 的原因,但后来我在我的 Pixel 3 上也开始随机遇到这个问题,而且不断有人向我反馈 MomoHider 工作不正常,调查后发现是 magisk hide 坏了。某一天我静下心来,把那台备用机开机,连上电脑,一遍遍回看相关逻辑一点点改代码,最后被我一行代码修好了。然后我向 magisk 提交了我自己的第一个 PR,很可惜的是因为 magisk 后来删除了 magisk hide,我的 PR 没有被合并。往后的日子里,我们给 Magisk 提交了多次代码,修复了多个 bug。在之前我绝对想不到在全世界范围里获得数亿使用的 Magisk 会有这么多 bug,也很开心自己能参与一个这么大的项目,希望自己在新的一年里学到更多,最好拿到 collaborator (

学习:中职日常

寒假结束回到学校之后,我被发下来的课程表震惊了:我们的专业名叫计算机网络技术,但是第二学期把这门专业课给取消了,然后加了什么 常用工具软件(教你怎么用 QQ 怎么用美图秀秀怎么用酷狗音乐腾讯视频)还有 Photoshop。去问学校这是怎么回事,得到的答复是,学生们反映这门课太难听不懂就取消了。无语至极,也只能一边上着荒唐至极的课,一边自学准备高职高考。幸运的是,能在这里碰到一起编程一起学习一起高考的伙伴。三个月电子厂实习也结束了,希望自己新的一年能全力备战高考~

实习:被推向电子厂的三个月

六月份听说计算机等级考试九月份开始报名,十一点一到直接在课堂上拿出电脑报名。现在想来庆幸当时的自己的果断,韶大考点名额很少被自己抢到了一个,过了一天再去看只剩下松山学院可以报了~ 但是,直到期末我们才被通知,下个学期一开始就要去实习三个月。度过了一个暑假,开学了,学校果然没骗我,待了三天就直接坐着大巴去了几百公里外的城市。说是实习,其实也就是让学生去电子厂干些正常人都会干的活。在厂里上班三个月,我的感受就是这不是人干的活:程序员天天抱怨的996在那里根本就不是问题,我最晚一次早上八点上班上到晚上十点半,连晚饭都没吃;几乎没有假放,中秋的一天还是调休调出来的,国庆只有三天回趟家几乎在睡觉中度过;第一个月还好,第二个月开始上夜班两班倒,到后面基本上就是麻木的在那里干活,脑子放空手上还在机械的做那些动作,感觉时间流速都不一样了,活还在手上干着外面天已经亮了;回来基本上手机拿在手里没充电坐在床上就直接睡着了。三个月下来,拿到手工资不到一万,还把之前的基础几乎忘完了,到实习结束写实习报告的时候连字都不会写。但好在,这三个月我已经熬过来了。现在只希望自己能好好自学,参加高职高考能考上好的大学,否则按国内这个环境,我会再难的东西也是去电子厂的命。在知乎上看见一个立志要考上深职院的学姐,真的很励志呀,希望自己也能怎么努力~附上链接:中职生高职高考自学,感觉找不到好的方法学习方法,该怎么办? - 话梅的回答 - 知乎

在故事结束之后的故事

回来学校之后,很迷茫,似乎又回到了以前在学校那种状态,上完六节课就在课室或者宿舍躺着,送的试卷也没动力去翻一下。昨天晚上看 weishu 直播修 bug,猛然又想起当时的梦想,当时的梦多么天真多么美好啊,那就让我们去实现它吧~ 我不是一个有恒心的人,所以如果你愿意的话,请监督我吧,希望我们能在美好的未来相见~~


Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

系统实现分析

首先,我们有这几个问题需要解答:

  1. ro. 开头的属性是只读的,只能被设置一次,系统是怎么实现的?
  2. 系统里的属性那么多,难免会有一些 app 读取不了的敏感属性,系统是怎么限制我们读取的?

Linus 大神有句话很出名:“Read the F*cking Source Code。”想要解答这些问题,阅读源码是必须的。

Property Context

我们通常使用 __system_properties_get 这个 API 去获取系统属性,点开这个函数看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static SystemProperties system_properties;

__BIONIC_WEAK_FOR_NATIVE_BRIDGE
int __system_property_get(const char* name, char* value) {
return system_properties.Get(name, value);
}

int SystemProperties::Get(const char* name, char* value) {
const prop_info* pi = Find(name);
if (pi != nullptr) {
return Read(pi, nullptr, value);
} else {
value[0] = 0;
return 0;
}
}

先去调用 Find() 函数找到一个叫做 prop_info* 的东西,然后从里面读出值来。

1
2
3
4
5
6
7
8
9
10
11
const prop_info* SystemProperties::Find(const char* name) {
if (!initialized_) {
return nullptr;
}
prop_area* pa = contexts_->GetPropAreaForName(name);
if (!pa) {
async_safe_format_log(ANDROID_LOG_WARN, "libc", "Access denied finding property \"%s\"", name);
return nullptr;
}
return pa->find(name);
}

看到这里你是不是一头雾水,这函数有个 initialized_ 一看就是要初始化的,谁去初始化的?contexts_ prop_area 又是什么?这里就不卖关子了,在 libc.so 被加载的时候,它的 .init.array 段里面的函数会被自动执行,而有 __attribute__((constructor(1)) 的函数就会被放到 .init.array 段从而被自动执行。里面兜兜转转,会调用一个 __system_properties_init() 函数,而这个函数正是初始化上面这些东西的关键所在。

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
#define PROP_FILENAME "/dev/__properties__"
int __system_properties_init() {
return system_properties.Init(PROP_FILENAME) ? 0 : -1;
}

bool SystemProperties::Init(const char* filename) {
// This is called from __libc_init_common, and should leave errno at 0 (http://b/37248982).
// ...
strcpy(property_filename_, filename);
if (is_dir(property_filename_)) {
if (access("/dev/__properties__/property_info", R_OK) == 0) {
contexts_ = new (contexts_data_) ContextsSerialized();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
} else {
contexts_ = new (contexts_data_) ContextsSplit();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
}
} else {
contexts_ = new (contexts_data_) ContextsPreSplit();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
}
initialized_ = true;
return true;
}

名词越来越多了……先别急着头晕,聪明的你肯定发现,这里出现了两个文件路径:/dev/__properties__/dev/__properties__/property_info。连上手机 ls 一下看看:

1
2
3
4
5
6
7
8
vince:/ # ls -lZ /dev/__properties__
total 1376
-r--r--r-- 1 root root u:object_r:properties_serial:s0 131072 1970-07-08 15:56 properties_serial
-r--r--r-- 1 root root u:object_r:property_info:s0 62540 1970-07-08 15:56 property_info
-r--r--r-- 1 root root u:object_r:aac_drc_prop:s0 131072 1970-07-08 15:56 u:object_r:aac_drc_prop:s0
-r--r--r-- 1 root root u:object_r:aaudio_config_prop:s0 131072 1970-07-08 15:56 u:object_r:aaudio_config_prop:s0
-r--r--r-- 1 root root u:object_r:ab_update_gki_prop:s0 131072 1970-07-08 15:56 u:object_r:ab_update_gki_prop:s0
-r--r--r-- 1 root root u:object_r:adbd_config_prop:s0 131072 1970-07-08 15:56 u:object_r:adbd_config_prop:s0

而查看 /dev/__properties__/property_info 这个文件是一堆二进制数据。细心的你可能已经发现了我们上面的 u:object_r:adbd_config_prop:s0 这些奇怪的文件名,同时还有 release_or_codenamedex2oat-flags 这些我们用过的属性的名字的一段(以点分割)。点开 ContextsSerialized 里面是一些字典树之类的无趣的数据结构内容;而我们要明白它们的作用,其实可以看看没有这个文件时(如低版本系统)用的 ContextsSplit 的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool ContextsSplit::InitializeProperties() {
// Use property_contexts from /system & /vendor, fall back to those from /
if (access("/system/etc/selinux/plat_property_contexts", R_OK) != -1) {
if (!InitializePropertiesFromFile("/system/etc/selinux/plat_property_contexts")) {
return false;
}
if (access("/vendor/etc/selinux/vendor_property_contexts", R_OK) != -1) {
InitializePropertiesFromFile("/vendor/etc/selinux/vendor_property_contexts");
} else {
// Fallback to nonplat_* if vendor_* doesn't exist
InitializePropertiesFromFile("/vendor/etc/selinux/nonplat_property_contexts");
}
} else {
// ... 省略 其他文件路径
}
return true;
}

找一部手机,看看 /system/etc/selinux/plat_property_contexts 是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#line 1 "system/sepolicy/private/property_contexts"
##########################
# property service keys
#
#
net.rmnet u:object_r:net_radio_prop:s0
# ...
net. u:object_r:system_prop:s0
dev. u:object_r:system_prop:s0
ro.runtime. u:object_r:system_prop:s0
ro.runtime.firstboot u:object_r:firstboot_prop:s0
hw. u:object_r:system_prop:s0
ro.hw. u:object_r:system_prop:s0
sys. u:object_r:system_prop:s0
# ...

左边的 ro. net. 很明显是属性的前缀,右边的 u:object_r:system_prop:s0 不是刚好对应我们上面看到的文件名吗?
找一个文件 cat 一下,没错,属性的名字和值都在里面。而实际上,上面的 prop_area 也是根据属性名搜索到对应的文件位置然后将它 mmap 到内存中获得的。
而我们再回头看看这个文件名,是不是很熟悉?如果你有了解过 SELinux 这一 Android 安全模型的重要组成部分,你很容易就会发现这个文件名就是 SELinux 中所谓的 Context 的格式。而再回头看上面 ls -Z 的输出,你会发现里面所有名字是这个格式的文件,它们的名字和“SELinux 上下文”都一样。

自此,我们得到了第一个结论:
Android 系统按照 Context 将属性们分为一个个“组”,获取属性值时,首先通过名字查询到这个属性所在的组,然后再在对应的文件里面查询。

Property Area

回到我们刚刚的 SystemProperties::Find() 这个函数里,它拿到对应的 prop_area 也就是所谓的“属性组”之后,又调用了它的 find() 函数在里面搜索,点开看看:

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
37
38
39
40
41
42
43
const char* remaining_name = name;
prop_bt* current = trie;
while (true) {
const char* sep = strchr(remaining_name, '.');
const bool want_subtree = (sep != nullptr);
const uint32_t substr_size = (want_subtree) ? sep - remaining_name : strlen(remaining_name);
if (!substr_size) {
return nullptr;
}
prop_bt* root = nullptr;
uint_least32_t children_offset = atomic_load_explicit(&current->children, memory_order_relaxed);
if (children_offset != 0) {
root = to_prop_bt(&current->children);
} else if (alloc_if_needed) {
uint_least32_t new_offset;
root = new_prop_bt(remaining_name, substr_size, &new_offset);
if (root) {
atomic_store_explicit(&current->children, new_offset, memory_order_release);
}
}
if (!root) {
return nullptr;
}
current = find_prop_bt(root, remaining_name, substr_size, alloc_if_needed);
if (!current) {
return nullptr;
}
if (!want_subtree) break;
remaining_name = sep + 1;
}
uint_least32_t prop_offset = atomic_load_explicit(&current->prop, memory_order_relaxed);
if (prop_offset != 0) {
return to_prop_info(&current->prop);
} else if (alloc_if_needed) {
uint_least32_t new_offset;
prop_info* new_info = new_prop_info(name, namelen, value, valuelen, &new_offset);
if (new_info) {
atomic_store_explicit(&current->prop, new_offset, memory_order_release);
}
return new_info;
} else {
return nullptr;
}

prop_bt prop_info 又是什么?这一堆什么 subtree left right 看着就像一棵树,它的结构是什么样的呢,其实注释里已经写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Properties are stored in a hybrid trie/binary tree structure.
// Each property's name is delimited at '.' characters, and the tokens are put
// into a trie structure. Siblings at each level of the trie are stored in a
// binary tree. For instance, "ro.secure"="1" could be stored as follows:
//
// +-----+ children +----+ children +--------+
// | |-------------->| ro |-------------->| secure |
// +-----+ +----+ +--------+
// / \ / |
// left / \ right left / | prop +===========+
// v v v +-------->| ro.secure |
// +-----+ +-----+ +-----+ +-----------+
// | net | | sys | | com | | 1 |
// +-----+ +-----+ +-----+ +===========+

这是一颗二叉树和字典树的混血儿。在传统的二叉树结构中,假如一个节点 node 拥有 left right 两个成员:node { var left, right },其中的 left right 都是 node 这个节点的孩子;在传统二叉树的搜索中,如果给定一个 key,那么流程大概会是这样的伪代码:

1
2
3
4
5
6
7
8
9
while (currentNode != null) {
if (key < currentNode.key)
currentNode = currentNode.left
else if (key > currentNode.key)
currentNode = currentNode.right
else // key == currentNode.key
return currentNode
}
return null

而在这个混合结构中,节点 nodeleft rightnode 本身其实是平级关系,它们的父节点是同一个,child 才是节点的子节点。
而理解了这一点之后,要看懂上面这幅图也就不难了。
还是以 ro.secure=1 来举例。首先对属性名 ro.secure 以点分割,得 rosecure,先从树上查找到 ro,而 ro 的左右节点 sysnetro 是平级关系。接着,从 ro 的 child 指向的节点里查找 secure,很明显第一个就是。找到了之后,根据节点的 prop 值指向的一个 prop_info 结构就能读取到值。
因此 prop_bt 其实只是用来树上的一个个节点,而它指向的 prop_info 才是真正存放属性值的地方。

属性区域的初始化

我们还有一个问题:我们上面的操作都是在 /dev/__properties__ 这个文件夹下面玩的,那这个文件夹和里面的文件是哪来的呀?
其实早在 init 进程启动时,它就调用了 PropertyInit() 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void PropertyInit() {
mkdir("/dev/__properties__", S_IRWXU | S_IXGRP | S_IXOTH);
CreateSerializedPropertyInfo();
if (__system_property_area_init()) {
LOG(FATAL) << "Failed to initialize property area";
}
if (!property_info_area.LoadDefaultPath()) {
LOG(FATAL) << "Failed to load serialized property info file";
}
// If arguments are passed both on the command line and in DT,
// properties set in DT always have priority over the command-line ones.
ProcessKernelDt();
ProcessKernelCmdline();
ProcessBootconfig();
// Propagate the kernel variables to internal variables
// used by init as well as the current required properties.
ExportKernelBootProps();
PropertyLoadBootDefaults();
}

这个函数先是创建了 /dev/__properties__ 随后将 split context 的一堆文件“编译”成二进制树的形式并序列化到 property_info 中以加速其他进程的启动速度,而后将内核命令行、default.prop、build.prop 等的内容加载进来,然后我们的属性区域就有值了!

再往后看,init 进程还会调用一个 StartPropertyService() 的东西,这个 property service 又是什么呢,这里我就不放源码了,它建立了一个 Socket Server,__system_properties_set() 时就会去通过 socket 连上它,然后这个 property service 再进行实际的写入。

我们上面提到的鉴权的问题,也已经有了答案:对于获取属性,因为每个属性都有对应的 context,进程如果权限不够,尝试打开这个 context 对应文件时就会被 SELinux 直接挡住;而对于写入属性,其他进程尝试写入属性实际都是通过 socket 连上运行在 init 的一个 service,由这个 service 来做鉴权、阻止只读属性被覆盖的检查以及最终的写入操作。

其实到这里,属性系统还有很多内容没有介绍,比如 long value、persist props 这些东西,但由于篇幅限制,只能到这里了,有兴趣的同学可以自己查阅源代码哦 :)

魔法时刻

绕过 ro 属性的只读限制

上面提到,ro 属性的检查是在 init 进程手动做的,那如果我们直接操作对应内存,是不是就能绕开这个限制呢?事实上,由于这些文件的权限以及 SELinux 相关限制,在 init 进程以外你在正常的 Android 系统里几乎没办法写入这些文件;但如果你的进程有足够高的权限,这是可行的!事实上,Magisk 的 resetprop 就是这么实现的,你甚至还可以做到更神奇的事,比如删除属性!

属性的“隔离”操作

什么是“隔离”?通常来说,就是 14+7

我们想要的“隔离”效果大概就是,两个进程在同一时刻对同一个属性获取到不同的值。

Android 系统原生是不支持这种操作的,但是我们可以使用黑魔法来完成这一点。

在 Linux 中,有一个概念叫做 namespace,它就是一种资源隔离方案。在它可以隔离的资源中,有一种东西,叫 mount。

什么概念:简单点说,如果两个进程拥有不一样的 mount namespace,进程 A 搞破坏把 /data 给 mount 到了 /system,但是这一切进程 B 是看不见的,它所看见的 /system 还是原来那个。

结合我们上面提到的,属性其实是存在一堆“一次性”文件里的这一点,我们有了一个大胆的想法:首先 unshare 分离 namespace,把某个 context 对应的文件给复制一份,然后把这个复制品 bind mount 到原来的文件,直接修改这个复制品里的属性值,就能实现只让一个进程看见对应属性的修改!

我们还可以改变一下,实现一个可以回滚的 resetprop:在 zygote 启动之前和 init 同命名空间的某个高权限进程里把复制品 bind mount 回原文件,然后修改里面的内容,之后启动的任何进程(包括 zygote)都会看见被修改后的属性;然后在需要回滚修改的 app 进程里 unmount 这些文件,随后因为有些文件可能已经在 zygote 里被提前 mmap 过了,我们需要把它们 unmap 再重新 mmap 以确保 app 读到的永远是未被修改的真实值,然后我们就实现了属性修改的回滚!

我已经在自己的 Magisk fork 上进行了简单测试,确认可行,源码在这里:https://github.com/canyie/Magisk/commit/d50a060a23727236b36d79afc696632519247fd8

当然这个方法有一些小小缺陷,比如我们 hijack 一个 context 后所有归属于这个 context 的其他属性都会受到影响,之后的 setprop 只会对进行了回滚的进程生效,因为 init 已经打开了所有文件,只会对原文件进行写入操作。这个问题可以通过 inotify 监听写入然后手动更新解决,但是是体力活。但是,我们的确发明了我们自己的魔法!至于这个好玩的新东西能做什么,可以发挥你的想象力 :)


Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

系统实现分析

首先,我们有这几个问题需要解答:

  1. ro. 开头的属性是只读的,只能被设置一次,系统是怎么实现的?
  2. 系统里的属性那么多,难免会有一些 app 读取不了的敏感属性,系统是怎么限制我们读取的?

Linus 大神有句话很出名:“Read the F*cking Source Code。”想要解答这些问题,阅读源码是必须的。

Property Context

我们通常使用 __system_properties_get 这个 API 去获取系统属性,点开这个函数看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static SystemProperties system_properties;

__BIONIC_WEAK_FOR_NATIVE_BRIDGE
int __system_property_get(const char* name, char* value) {
return system_properties.Get(name, value);
}

int SystemProperties::Get(const char* name, char* value) {
const prop_info* pi = Find(name);
if (pi != nullptr) {
return Read(pi, nullptr, value);
} else {
value[0] = 0;
return 0;
}
}

先去调用 Find() 函数找到一个叫做 prop_info* 的东西,然后从里面读出值来。

1
2
3
4
5
6
7
8
9
10
11
const prop_info* SystemProperties::Find(const char* name) {
if (!initialized_) {
return nullptr;
}
prop_area* pa = contexts_->GetPropAreaForName(name);
if (!pa) {
async_safe_format_log(ANDROID_LOG_WARN, "libc", "Access denied finding property \"%s\"", name);
return nullptr;
}
return pa->find(name);
}

看到这里你是不是一头雾水,这函数有个 initialized_ 一看就是要初始化的,谁去初始化的?contexts_ prop_area 又是什么?这里就不卖关子了,在 libc.so 被加载的时候,它的 .init.array 段里面的函数会被自动执行,而有 __attribute__((constructor(1)) 的函数就会被放到 .init.array 段从而被自动执行。里面兜兜转转,会调用一个 __system_properties_init() 函数,而这个函数正是初始化上面这些东西的关键所在。

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
#define PROP_FILENAME "/dev/__properties__"
int __system_properties_init() {
return system_properties.Init(PROP_FILENAME) ? 0 : -1;
}

bool SystemProperties::Init(const char* filename) {
// This is called from __libc_init_common, and should leave errno at 0 (http://b/37248982).
// ...
strcpy(property_filename_, filename);
if (is_dir(property_filename_)) {
if (access("/dev/__properties__/property_info", R_OK) == 0) {
contexts_ = new (contexts_data_) ContextsSerialized();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
} else {
contexts_ = new (contexts_data_) ContextsSplit();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
}
} else {
contexts_ = new (contexts_data_) ContextsPreSplit();
if (!contexts_->Initialize(false, property_filename_, nullptr)) {
return false;
}
}
initialized_ = true;
return true;
}

名词越来越多了……先别急着头晕,聪明的你肯定发现,这里出现了两个文件路径:/dev/__properties__/dev/__properties__/property_info。连上手机 ls 一下看看:

1
2
3
4
5
6
7
8
vince:/ # ls -lZ /dev/__properties__
total 1376
-r--r--r-- 1 root root u:object_r:properties_serial:s0 131072 1970-07-08 15:56 properties_serial
-r--r--r-- 1 root root u:object_r:property_info:s0 62540 1970-07-08 15:56 property_info
-r--r--r-- 1 root root u:object_r:aac_drc_prop:s0 131072 1970-07-08 15:56 u:object_r:aac_drc_prop:s0
-r--r--r-- 1 root root u:object_r:aaudio_config_prop:s0 131072 1970-07-08 15:56 u:object_r:aaudio_config_prop:s0
-r--r--r-- 1 root root u:object_r:ab_update_gki_prop:s0 131072 1970-07-08 15:56 u:object_r:ab_update_gki_prop:s0
-r--r--r-- 1 root root u:object_r:adbd_config_prop:s0 131072 1970-07-08 15:56 u:object_r:adbd_config_prop:s0

而查看 /dev/__properties__/property_info 这个文件是一堆二进制数据。细心的你可能已经发现了我们上面的 u:object_r:adbd_config_prop:s0 这些奇怪的文件名,同时还有 release_or_codenamedex2oat-flags 这些我们用过的属性的名字的一段(以点分割)。点开 ContextsSerialized 里面是一些字典树之类的无趣的数据结构内容;而我们要明白它们的作用,其实可以看看没有这个文件时(如低版本系统)用的 ContextsSplit 的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool ContextsSplit::InitializeProperties() {
// Use property_contexts from /system & /vendor, fall back to those from /
if (access("/system/etc/selinux/plat_property_contexts", R_OK) != -1) {
if (!InitializePropertiesFromFile("/system/etc/selinux/plat_property_contexts")) {
return false;
}
if (access("/vendor/etc/selinux/vendor_property_contexts", R_OK) != -1) {
InitializePropertiesFromFile("/vendor/etc/selinux/vendor_property_contexts");
} else {
// Fallback to nonplat_* if vendor_* doesn't exist
InitializePropertiesFromFile("/vendor/etc/selinux/nonplat_property_contexts");
}
} else {
// ... 省略 其他文件路径
}
return true;
}

找一部手机,看看 /system/etc/selinux/plat_property_contexts 是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#line 1 "system/sepolicy/private/property_contexts"
##########################
# property service keys
#
#
net.rmnet u:object_r:net_radio_prop:s0
# ...
net. u:object_r:system_prop:s0
dev. u:object_r:system_prop:s0
ro.runtime. u:object_r:system_prop:s0
ro.runtime.firstboot u:object_r:firstboot_prop:s0
hw. u:object_r:system_prop:s0
ro.hw. u:object_r:system_prop:s0
sys. u:object_r:system_prop:s0
# ...

左边的 ro. net. 很明显是属性的前缀,右边的 u:object_r:system_prop:s0 不是刚好对应我们上面看到的文件名吗?
找一个文件 cat 一下,没错,属性的名字和值都在里面。而实际上,上面的 prop_area 也是根据属性名搜索到对应的文件位置然后将它 mmap 到内存中获得的。
而我们再回头看看这个文件名,是不是很熟悉?如果你有了解过 SELinux 这一 Android 安全模型的重要组成部分,你很容易就会发现这个文件名就是 SELinux 中所谓的 Context 的格式。而再回头看上面 ls -Z 的输出,你会发现里面所有名字是这个格式的文件,它们的名字和“SELinux 上下文”都一样。

自此,我们得到了第一个结论:
Android 系统按照 Context 将属性们分为一个个“组”,获取属性值时,首先通过名字查询到这个属性所在的组,然后再在对应的文件里面查询。

Property Area

回到我们刚刚的 SystemProperties::Find() 这个函数里,它拿到对应的 prop_area 也就是所谓的“属性组”之后,又调用了它的 find() 函数在里面搜索,点开看看:

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
37
38
39
40
41
42
43
const char* remaining_name = name;
prop_bt* current = trie;
while (true) {
const char* sep = strchr(remaining_name, '.');
const bool want_subtree = (sep != nullptr);
const uint32_t substr_size = (want_subtree) ? sep - remaining_name : strlen(remaining_name);
if (!substr_size) {
return nullptr;
}
prop_bt* root = nullptr;
uint_least32_t children_offset = atomic_load_explicit(&current->children, memory_order_relaxed);
if (children_offset != 0) {
root = to_prop_bt(&current->children);
} else if (alloc_if_needed) {
uint_least32_t new_offset;
root = new_prop_bt(remaining_name, substr_size, &new_offset);
if (root) {
atomic_store_explicit(&current->children, new_offset, memory_order_release);
}
}
if (!root) {
return nullptr;
}
current = find_prop_bt(root, remaining_name, substr_size, alloc_if_needed);
if (!current) {
return nullptr;
}
if (!want_subtree) break;
remaining_name = sep + 1;
}
uint_least32_t prop_offset = atomic_load_explicit(&current->prop, memory_order_relaxed);
if (prop_offset != 0) {
return to_prop_info(&current->prop);
} else if (alloc_if_needed) {
uint_least32_t new_offset;
prop_info* new_info = new_prop_info(name, namelen, value, valuelen, &new_offset);
if (new_info) {
atomic_store_explicit(&current->prop, new_offset, memory_order_release);
}
return new_info;
} else {
return nullptr;
}

prop_bt prop_info 又是什么?这一堆什么 subtree left right 看着就像一棵树,它的结构是什么样的呢,其实注释里已经写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Properties are stored in a hybrid trie/binary tree structure.
// Each property's name is delimited at '.' characters, and the tokens are put
// into a trie structure. Siblings at each level of the trie are stored in a
// binary tree. For instance, "ro.secure"="1" could be stored as follows:
//
// +-----+ children +----+ children +--------+
// | |-------------->| ro |-------------->| secure |
// +-----+ +----+ +--------+
// / \ / |
// left / \ right left / | prop +===========+
// v v v +-------->| ro.secure |
// +-----+ +-----+ +-----+ +-----------+
// | net | | sys | | com | | 1 |
// +-----+ +-----+ +-----+ +===========+

这是一颗二叉树和字典树的混血儿。在传统的二叉树结构中,假如一个节点 node 拥有 left right 两个成员:node { var left, right },其中的 left right 都是 node 这个节点的孩子;在传统二叉树的搜索中,如果给定一个 key,那么流程大概会是这样的伪代码:

1
2
3
4
5
6
7
8
9
while (currentNode != null) {
if (key < currentNode.key)
currentNode = currentNode.left
else if (key > currentNode.key)
currentNode = currentNode.right
else // key == currentNode.key
return currentNode
}
return null

而在这个混合结构中,节点 nodeleft rightnode 本身其实是平级关系,它们的父节点是同一个,child 才是节点的子节点。
而理解了这一点之后,要看懂上面这幅图也就不难了。
还是以 ro.secure=1 来举例。首先对属性名 ro.secure 以点分割,得 rosecure,先从树上查找到 ro,而 ro 的左右节点 sysnetro 是平级关系。接着,从 ro 的 child 指向的节点里查找 secure,很明显第一个就是。找到了之后,根据节点的 prop 值指向的一个 prop_info 结构就能读取到值。
因此 prop_bt 其实只是用来树上的一个个节点,而它指向的 prop_info 才是真正存放属性值的地方。

属性区域的初始化

我们还有一个问题:我们上面的操作都是在 /dev/__properties__ 这个文件夹下面玩的,那这个文件夹和里面的文件是哪来的呀?
其实早在 init 进程启动时,它就调用了 PropertyInit() 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void PropertyInit() {
mkdir("/dev/__properties__", S_IRWXU | S_IXGRP | S_IXOTH);
CreateSerializedPropertyInfo();
if (__system_property_area_init()) {
LOG(FATAL) << "Failed to initialize property area";
}
if (!property_info_area.LoadDefaultPath()) {
LOG(FATAL) << "Failed to load serialized property info file";
}
// If arguments are passed both on the command line and in DT,
// properties set in DT always have priority over the command-line ones.
ProcessKernelDt();
ProcessKernelCmdline();
ProcessBootconfig();
// Propagate the kernel variables to internal variables
// used by init as well as the current required properties.
ExportKernelBootProps();
PropertyLoadBootDefaults();
}

这个函数先是创建了 /dev/__properties__ 随后将 split context 的一堆文件“编译”成二进制树的形式并序列化到 property_info 中以加速其他进程的启动速度,而后将内核命令行、default.prop、build.prop 等的内容加载进来,然后我们的属性区域就有值了!

再往后看,init 进程还会调用一个 StartPropertyService() 的东西,这个 property service 又是什么呢,这里我就不放源码了,它建立了一个 Socket Server,__system_properties_set() 时就会去通过 socket 连上它,然后这个 property service 再进行实际的写入。

我们上面提到的鉴权的问题,也已经有了答案:对于获取属性,因为每个属性都有对应的 context,进程如果权限不够,尝试打开这个 context 对应文件时就会被 SELinux 直接挡住;而对于写入属性,其他进程尝试写入属性实际都是通过 socket 连上运行在 init 的一个 service,由这个 service 来做鉴权、阻止只读属性被覆盖的检查以及最终的写入操作。

其实到这里,属性系统还有很多内容没有介绍,比如 long value、persist props 这些东西,但由于篇幅限制,只能到这里了,有兴趣的同学可以自己查阅源代码哦 :)

魔法时刻

绕过 ro 属性的只读限制

上面提到,ro 属性的检查是在 init 进程手动做的,那如果我们直接操作对应内存,是不是就能绕开这个限制呢?事实上,由于这些文件的权限以及 SELinux 相关限制,在 init 进程以外你在正常的 Android 系统里几乎没办法写入这些文件;但如果你的进程有足够高的权限,这是可行的!事实上,Magisk 的 resetprop 就是这么实现的,你甚至还可以做到更神奇的事,比如删除属性!

属性的“隔离”操作

什么是“隔离”?通常来说,就是 14+7

我们想要的“隔离”效果大概就是,两个进程在同一时刻对同一个属性获取到不同的值。

Android 系统原生是不支持这种操作的,但是我们可以使用黑魔法来完成这一点。

在 Linux 中,有一个概念叫做 namespace,它就是一种资源隔离方案。在它可以隔离的资源中,有一种东西,叫 mount。

什么概念:简单点说,如果两个进程拥有不一样的 mount namespace,进程 A 搞破坏把 /data 给 mount 到了 /system,但是这一切进程 B 是看不见的,它所看见的 /system 还是原来那个。

结合我们上面提到的,属性其实是存在一堆“一次性”文件里的这一点,我们有了一个大胆的想法:首先 unshare 分离 namespace,把某个 context 对应的文件给复制一份,然后把这个复制品 bind mount 到原来的文件,直接修改这个复制品里的属性值,就能实现只让一个进程看见对应属性的修改!

我们还可以改变一下,实现一个可以回滚的 resetprop:在 zygote 启动之前和 init 同命名空间的某个高权限进程里把复制品 bind mount 回原文件,然后修改里面的内容,之后启动的任何进程(包括 zygote)都会看见被修改后的属性;然后在需要回滚修改的 app 进程里 unmount 这些文件,随后因为有些文件可能已经在 zygote 里被提前 mmap 过了,我们需要把它们 unmap 再重新 mmap 以确保 app 读到的永远是未被修改的真实值,然后我们就实现了属性修改的回滚!

我已经在自己的 Magisk fork 上进行了简单测试,确认可行,源码在这里:https://github.com/canyie/Magisk/commit/d50a060a23727236b36d79afc696632519247fd8

当然这个方法有一些小小缺陷,比如我们 hijack 一个 context 后所有归属于这个 context 的其他属性都会受到影响,之后的 setprop 只会对进行了回滚的进程生效,因为 init 已经打开了所有文件,只会对原文件进行写入操作。这个问题可以通过 inotify 监听写入然后手动更新解决,但是是体力活。但是,我们的确发明了我们自己的魔法!至于这个好玩的新东西能做什么,可以发挥你的想象力 :)


与我以往的风格不同,本文为科普类文章,因此不会涉及到太过高深难懂的知识。但这些内容可能 Android 应用层开发者甚至部分 framework 层开发者都不了解,因此仍旧高能预警。

另外广东这两天好冷啊,大家注意保暖~

虚拟机与运行时

对象的概念

假设 getObjectAddress(Object) 是一个获取对象内存地址的方法。

第一题:

考虑如下代码:

1
2
3
4
5
6
public static void main(String[] args) {
Object o = new Object();
long address1 = getObjectAddress(o);
// .......
long address2 = getObjectAddress(o);
}

main 方法中,创建了一个 Object 对象,随后两次调用 getObjectAddress 获取该对象的地址。两次获取到的对象地址是否有可能不同?换句话说,对象的地址是否有可能变更?





答:有可能。JVM 中存在 GC 即“垃圾回收”机制,会回收不再使用的对象以腾出内存空间。GC 可能会移动对象。

第二题:

考虑如下代码:

1
2
3
4
5
6
7
8
9
10
private static long allocate() {
Object o = new Object();
return getObjectAddress(o);
}

public static void main(String[] args) {
long address1 = allocate();
// ......
long address2 = allocate();
}

allocate() 创建了一个 Object 对象,然后获取它的对象地址。
main 方法中调用两次 allocate(),这两个对象的内存地址是否有可能相同?





答:有可能。在 allocate() 方法中创建的对象在该方法返回后便失去所有引用成为“不再需要的对象”,如果两次方法调用之间,第一次方法调用中产生的临时对象被上文中提到的 GC 机制回收,对应的内存空间就变得“空闲”,可以被其他对象占用。

第三题:

哎呀,既然上面说同一个对象的内存地址可能不相同,两个不同对象也有可能有相同的内存地址,而java 里的 == 又是判断对象的内存地址,那么

1
2
Object o = new Object();
if (o != o)

还有

1
2
3
Object o1 = new Object();
Object o2 = new Object();
if (o1 == o2)

这里的两个 if 不是都有可能成立?





答:不可能。== 操作符比较的确实是对象地址没错,但是这里其实还隐含了两个条件:

  1. 这个操作符比较的是 “那一刻” 两个对象的地址。
  2. 比较的两个对象都位于同一个进程内。

上述提到的两种情况都不满足“同一时间”这一条件,因此这两条 if 永远不会成立。

类与方法

第四题:

假设 Framework 是 Android Framework 里的一个类,App 是某个 Android App 的一个类:

1
2
3
4
5
6
7
8
9
10
11
public class Framework {
public static int api() {
return 0;
}
}

public class App {
public static void main(String[] args) {
Framework.api();
}
}

编译 App,然后将 Frameworkapi 方法的返回值类型从 int 改为 long,编译 Framework 但不重新编译 App,App 是否可以正常调用 Framework 的 api 方法?





答:不能。Java 类内存储的被调用方法的信息里包含返回值类型,如果返回值类型不对在运行时就找不到对应方法。将方法改为成员变量然后修改该变量的类型也同理。

第五题:

考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Parent {
public void call() {
privateMethod();
}
private void privateMethod() {
System.out.println("Parent method called");
}
}

class Child extends Parent {
private void privateMethod() {
System.out.println("Child method called");
}
}

new Child().call();

Child 里的 privateMethod 是否重写了 Parent 里的?call 中调用的 privateMethod() 会调用到 Parent 里的还是 Child 里的?





答:不构成方法重写,还是会调用到 Parent 里的 privateMethod。private 方法是 direct 方法,direct 方法无法被重写。

操作系统基础

多进程与虚拟内存

假设有进程 A 和进程 B。

第六题:

进程 A 里的对象 a 和进程 B 里的对象 b 拥有相同的内存地址,它们是同一个对象吗?





答:当然不是,上面才说过“对象相等”这个概念在同一个进程里才有意义,不认真听课思考是会被打屁屁的~

第七题:

进程 A 内有一个对象 a 并将这个对象的内存地址传递给了 B,B 是否可以直接访问(读取、写入等操作)这个对象?





答:不能,大概率会触发段错误,小概率会修改到自己内存空间里某个冤种对象的数据,无论如何都不会影响到进程 A。作为在用户空间运行的进程,它们拿到的所谓内存地址全部都是虚拟地址,进程访问这些地址的时候会先经过一个转换过程转化为物理地址再操作。如果转换出错(人家根本不认识你给的这个地址,或者对应内存的权限不让你执行对应操作),就会触发段错误。

第八题:

还是我们可爱的进程 A 和 B,但是这次 B 是 A 的子进程,即 A 调用 fork 产生了 B 这个新的进程:

1
2
3
4
5
6
7
8
9
10
11
void a() {
int* p = malloc(sizeof(int));
*p = 1;
if (fork() > 0) {
// 进程 A 也即父进程
// 巴拉巴拉巴拉一堆操作
} else {
// 进程 B 也即子进程
*p = 2;
}
}

(fork 是 Posix 内创建进程的 API,调用完成后如果仍然在父进程则返回子进程的 pid 永远大于 0,在子进程则返回 0)

(还是理解不了就把 A 想象为 Zygote 进程,B 想象为任意 App 进程)

这一段代码分配了一段内存,调用 fork 产生了一个子进程,然后在子进程里将预先分配好的那段内存里的值更改为 2。
问:进程 B 做出的更改是否对进程 A 可见?





答:不可见,进程 A 看见的那一段内存的值依然是 1。Linux 内核有一个叫做“写时复制”(Copy On Write)的技术,在进程 B 尝试写入这一段内存的时候会偷偷把真实的内存给复制一份,最后写入的是这份拷贝里的值,而进程 A 看见的还是原来的值。

跨进程大数据传递

已知进程 A 和进程 B,进程 A 暴露出一个 AIDL 接口,现在进程 B 要从 A 获取 10M 的数据(远远超出 binder 数据大小限制),且禁止传递文件路径,只允许调用这个 AIDL 接口一次,请问如何实现?





答:可以传递文件描述符(File Descriptor)。别以为这个玩意只能表示文件!举个例子,作为应用层开发者我们可以使用共享内存的方法,这样编写 AIDL 实现类把数据传递出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override public SharedMemory getData() throws RemoteException {
int size = 10 * 1024 * 1024;
try {
SharedMemory sharedMemory = SharedMemory.create("shared memory", size);
ByteBuffer buffer = sharedMemory.mapReadWrite();
for (int i = 0;i < 10;i++) {
// 模拟产生一堆数据
buffer.put(i * 1024 * 1024, (byte) 114);
buffer.put(i * 1024 * 1024 + 1, (byte) 51);
buffer.put(i * 1024 * 1024 + 2, (byte) 4);
buffer.put(i * 1024 * 1024 + 3, (byte) 191);
buffer.put(i * 1024 * 1024 + 4, (byte) 98);
buffer.put(i * 1024 * 1024 + 5, (byte) 108);
buffer.put(i * 1024 * 1024 + 6, (byte) 93);
}
SharedMemory.unmap(buffer);
sharedMemory.setProtect(OsConstants.PROT_READ);
return sharedMemory;
} catch (ErrnoException e) {
throw new RemoteException("remote create shared memory failed: " + e.getMessage());
}
}

然后在进程 B 里这样拿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
IRemoteService service = IRemoteService.Stub.asInterface(binder);
try {
SharedMemory sharedMemory = service.getData();
ByteBuffer buffer = sharedMemory.mapReadOnly();

// 模拟处理数据
int[] temp = new int[10];
for (int i = 0;i < 10;i++) {
for (int j = 0;j < 10;j++) {
temp[j] = buffer.get(i * 1024 * 1024 + j);
}
Log.e(TAG, "Large buffer[" + i + "]=" + Arrays.toString(temp));
}
SharedMemory.unmap(buffer);
sharedMemory.close();
} catch (Exception e) {
throw new RuntimeException(e);
}

这里使用的 SharedMemory 从 Android 8.1 开始可用,在 8.1 之前的系统里也有一个叫做 MemoryFile 的 API 可以用。
打开 SharedMemory 里的源码,你会发现其实它内部就是创建了一块 ashmem (匿名共享内存),然后将对应的文件描述符传递给 binder。内核会负责将一个可用的文件描述符传递给目标进程。
你可以将它理解为可以跨进程传递的 File Stream(只要能通过权限检查),合理利用这个小玩意有奇效哦 :)


与我以往的风格不同,本文为科普类文章,因此不会涉及到太过高深难懂的知识。但这些内容可能 Android 应用层开发者甚至部分 framework 层开发者都不了解,因此仍旧高能预警。

另外广东这两天好冷啊,大家注意保暖~

虚拟机与运行时

对象的概念

假设 getObjectAddress(Object) 是一个获取对象内存地址的方法。

第一题:

考虑如下代码:

1
2
3
4
5
6
public static void main(String[] args) {
Object o = new Object();
long address1 = getObjectAddress(o);
// .......
long address2 = getObjectAddress(o);
}

main 方法中,创建了一个 Object 对象,随后两次调用 getObjectAddress 获取该对象的地址。两次获取到的对象地址是否有可能不同?换句话说,对象的地址是否有可能变更?





答:有可能。JVM 中存在 GC 即“垃圾回收”机制,会回收不再使用的对象以腾出内存空间。GC 可能会移动对象。

第二题:

考虑如下代码:

1
2
3
4
5
6
7
8
9
10
private static long allocate() {
Object o = new Object();
return getObjectAddress(o);
}

public static void main(String[] args) {
long address1 = allocate();
// ......
long address2 = allocate();
}

allocate() 创建了一个 Object 对象,然后获取它的对象地址。
main 方法中调用两次 allocate(),这两个对象的内存地址是否有可能相同?





答:有可能。在 allocate() 方法中创建的对象在该方法返回后便失去所有引用成为“不再需要的对象”,如果两次方法调用之间,第一次方法调用中产生的临时对象被上文中提到的 GC 机制回收,对应的内存空间就变得“空闲”,可以被其他对象占用。

第三题:

哎呀,既然上面说同一个对象的内存地址可能不相同,两个不同对象也有可能有相同的内存地址,而java 里的 == 又是判断对象的内存地址,那么

1
2
Object o = new Object();
if (o != o)

还有

1
2
3
Object o1 = new Object();
Object o2 = new Object();
if (o1 == o2)

这里的两个 if 不是都有可能成立?





答:不可能。== 操作符比较的确实是对象地址没错,但是这里其实还隐含了两个条件:

  1. 这个操作符比较的是 “那一刻” 两个对象的地址。
  2. 比较的两个对象都位于同一个进程内。

上述提到的两种情况都不满足“同一时间”这一条件,因此这两条 if 永远不会成立。

类与方法

第四题:

假设 Framework 是 Android Framework 里的一个类,App 是某个 Android App 的一个类:

1
2
3
4
5
6
7
8
9
10
11
public class Framework {
public static int api() {
return 0;
}
}

public class App {
public static void main(String[] args) {
Framework.api();
}
}

编译 App,然后将 Frameworkapi 方法的返回值类型从 int 改为 long,编译 Framework 但不重新编译 App,App 是否可以正常调用 Framework 的 api 方法?





答:不能。Java 类内存储的被调用方法的信息里包含返回值类型,如果返回值类型不对在运行时就找不到对应方法。将方法改为成员变量然后修改该变量的类型也同理。

第五题:

考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Parent {
public void call() {
privateMethod();
}
private void privateMethod() {
System.out.println("Parent method called");
}
}

class Child extends Parent {
private void privateMethod() {
System.out.println("Child method called");
}
}

new Child().call();

Child 里的 privateMethod 是否重写了 Parent 里的?call 中调用的 privateMethod() 会调用到 Parent 里的还是 Child 里的?





答:不构成方法重写,还是会调用到 Parent 里的 privateMethod。private 方法是 direct 方法,direct 方法无法被重写。

操作系统基础

多进程与虚拟内存

假设有进程 A 和进程 B。

第六题:

进程 A 里的对象 a 和进程 B 里的对象 b 拥有相同的内存地址,它们是同一个对象吗?





答:当然不是,上面才说过“对象相等”这个概念在同一个进程里才有意义,不认真听课思考是会被打屁屁的~

第七题:

进程 A 内有一个对象 a 并将这个对象的内存地址传递给了 B,B 是否可以直接访问(读取、写入等操作)这个对象?





答:不能,大概率会触发段错误,小概率会修改到自己内存空间里某个冤种对象的数据,无论如何都不会影响到进程 A。作为在用户空间运行的进程,它们拿到的所谓内存地址全部都是虚拟地址,进程访问这些地址的时候会先经过一个转换过程转化为物理地址再操作。如果转换出错(人家根本不认识你给的这个地址,或者对应内存的权限不让你执行对应操作),就会触发段错误。

第八题:

还是我们可爱的进程 A 和 B,但是这次 B 是 A 的子进程,即 A 调用 fork 产生了 B 这个新的进程:

1
2
3
4
5
6
7
8
9
10
11
void a() {
int* p = malloc(sizeof(int));
*p = 1;
if (fork() > 0) {
// 进程 A 也即父进程
// 巴拉巴拉巴拉一堆操作
} else {
// 进程 B 也即子进程
*p = 2;
}
}

(fork 是 Posix 内创建进程的 API,调用完成后如果仍然在父进程则返回子进程的 pid 永远大于 0,在子进程则返回 0)

(还是理解不了就把 A 想象为 Zygote 进程,B 想象为任意 App 进程)

这一段代码分配了一段内存,调用 fork 产生了一个子进程,然后在子进程里将预先分配好的那段内存里的值更改为 2。
问:进程 B 做出的更改是否对进程 A 可见?





答:不可见,进程 A 看见的那一段内存的值依然是 1。Linux 内核有一个叫做“写时复制”(Copy On Write)的技术,在进程 B 尝试写入这一段内存的时候会偷偷把真实的内存给复制一份,最后写入的是这份拷贝里的值,而进程 A 看见的还是原来的值。

跨进程大数据传递

已知进程 A 和进程 B,进程 A 暴露出一个 AIDL 接口,现在进程 B 要从 A 获取 10M 的数据(远远超出 binder 数据大小限制),且禁止传递文件路径,只允许调用这个 AIDL 接口一次,请问如何实现?





答:可以传递文件描述符(File Descriptor)。别以为这个玩意只能表示文件!举个例子,作为应用层开发者我们可以使用共享内存的方法,这样编写 AIDL 实现类把数据传递出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override public SharedMemory getData() throws RemoteException {
int size = 10 * 1024 * 1024;
try {
SharedMemory sharedMemory = SharedMemory.create("shared memory", size);
ByteBuffer buffer = sharedMemory.mapReadWrite();
for (int i = 0;i < 10;i++) {
// 模拟产生一堆数据
buffer.put(i * 1024 * 1024, (byte) 114);
buffer.put(i * 1024 * 1024 + 1, (byte) 51);
buffer.put(i * 1024 * 1024 + 2, (byte) 4);
buffer.put(i * 1024 * 1024 + 3, (byte) 191);
buffer.put(i * 1024 * 1024 + 4, (byte) 98);
buffer.put(i * 1024 * 1024 + 5, (byte) 108);
buffer.put(i * 1024 * 1024 + 6, (byte) 93);
}
SharedMemory.unmap(buffer);
sharedMemory.setProtect(OsConstants.PROT_READ);
return sharedMemory;
} catch (ErrnoException e) {
throw new RemoteException("remote create shared memory failed: " + e.getMessage());
}
}

然后在进程 B 里这样拿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
IRemoteService service = IRemoteService.Stub.asInterface(binder);
try {
SharedMemory sharedMemory = service.getData();
ByteBuffer buffer = sharedMemory.mapReadOnly();

// 模拟处理数据
int[] temp = new int[10];
for (int i = 0;i < 10;i++) {
for (int j = 0;j < 10;j++) {
temp[j] = buffer.get(i * 1024 * 1024 + j);
}
Log.e(TAG, "Large buffer[" + i + "]=" + Arrays.toString(temp));
}
SharedMemory.unmap(buffer);
sharedMemory.close();
} catch (Exception e) {
throw new RuntimeException(e);
}

这里使用的 SharedMemory 从 Android 8.1 开始可用,在 8.1 之前的系统里也有一个叫做 MemoryFile 的 API 可以用。
打开 SharedMemory 里的源码,你会发现其实它内部就是创建了一块 ashmem (匿名共享内存),然后将对应的文件描述符传递给 binder。内核会负责将一个可用的文件描述符传递给目标进程。
你可以将它理解为可以跨进程传递的 File Stream(只要能通过权限检查),合理利用这个小玩意有奇效哦 :)


上次发年终总结还是 2022 年发 2021 年的,2022 年的年终总结缺失了。今年发生了好多好多的事情,一直想写今年的年终总结但是却总没到年终,干脆写成前 19 年纪念吧。

从中职,到大学

2021 年年底,从电子厂回来了,简单过了一个年之后,2022 年初,我的中职学校终于开设了高考班,但令我没想到的是,学校要求高考班学生先进电子厂再实习三个月,九月份再正式开始学。为了逃避实习,只能和学校说要参加竞赛,然后跑去 CSP-J 2022 玩了一圈。九月份后,进入高三(我不管我不管中职三年级也能叫做高三),实习的同学都回来了,课程再次从零开始重置,挑战了一出三个月学完全部内容。2022 年底,COVID-19 疫情防控政策放开,COVID-19 Omicron 大爆发,临近考试的时刻被学校全部遣送回家,然后体验了新冠一周速通(不得不说是真他妈难受啊)。幸好,高职高考宣布延期(不延期我就要当场死掉了!!!),在家玩了最长的一个寒假(除了2020年寒假),2月份就这样上了考场。结果是,367分,一开始看见分数有点失望,但是不知道是今年试题比往年偏难还是其他考生经过新冠后都没发挥好,竟然考到了全省四百多名,完全想不到的结果。然后就是填志愿选学校了,面对只有两间还都是民办的本科院校和全省甚至全国最好的专科院校,还是选择了填报本科。到了专业技能测试,结果竟然考了50道选择题,轻松拿下。总之怎么说呢,终于摆脱了中等职业教育,终于和以前的同学们又站在了同一条起跑线。上学好累,真的好累,继续努力吧。

我想,做我喜欢的事情

我喜欢计算机,我喜欢编程,我喜欢研究奇怪的事情,我喜欢帮助别人。我一直都知道。这两年给 Magisk 提交了好多好多代码,就在 2022 年的最后一天晚上,我还在给 magisk 修 bug。commit, push, create pull request, reply to the initial issue,时钟就这样转到了 23:59 (以 UTC+8 时间)。和另一位维护者说了句新年快乐,就这样,2022 年结束了,新的 2023 年来了。2023 年一月,新的一年的第一个月,拿到了 Magisk 项目的 collaborator 身份,后续又把 canyie 这个名字放进了 magisk app 主页【关注我们】里的开发者列表,算是安慰一下自己吧。

高职高考结束之后,选择硬刚学校,自行跑路后联系了以前认识的一个长沙老板,成功加入到了他们公司干了四个月活,是小时候梦寐以求的 Android Framework 开发工程师哦。

工作的时候,研究 Android Framework 的时候还意外发现了一个安全漏洞,和 google 反馈之后,google 定级为中危漏洞,本来以为能领到人生中第一个 CVE 的,结果五月份 google 改政策,不给普通的 moderate issue 分配 CVE 了,到手的 CVE 飞了,太坏了。继续努力吧。

终究,和别人天生不同

童年的记忆已经模糊,依稀记得小时候表现出的种种不同。二年级尝试把针插进插座里电自己,生命早期开始的反复自伤自残自杀,似乎不是一个“抑郁发作”“孤独症谱系障碍”能完全覆盖的。面对抑郁再次复发,从精神病院走出几年的我,这次主动踏进了精神科。从长沙到广州,从“复发性抑郁障碍”到疑似双相情感障碍Ⅱ型,还得到了易性症的确诊诊断,往前如梦一般的人生似乎终于得到了一个答案。虽然双相被认为无法治愈,但是已经习惯了。今天是和精神疾病相伴的第3208天,一个月前(10月5日)再次发疯吞药,现在似有好转的迹象,又担心只是躁狂发作的引线。

我尽力抓住一丝丝微光。我用尽全力做自己。

可【自己】也在迷雾中,若隐若现。



继续加油做自己吧,在昏沉的天空下追逐若隐若现的梦。即使用生命也要换一个幻影。

“若人间是场大梦啊
梦里啷当巨铁锁人家
夜归的人叩响了门环
我要不要叫醒他
若人间是场大梦啊
梦里风雨万丈碎石瓦
谁要春秋大业寿与天齐
我要不要依许他”

就这样吧。期待下次相见。


上次发年终总结还是 2022 年发 2021 年的,2022 年的年终总结缺失了。今年发生了好多好多的事情,一直想写今年的年终总结但是却总没到年终,干脆写成前 19 年纪念吧。

从中职,到大学

2021 年年底,从电子厂回来了,简单过了一个年之后,2022 年初,我的中职学校终于开设了高考班,但令我没想到的是,学校要求高考班学生先进电子厂再实习三个月,九月份再正式开始学。为了逃避实习,只能和学校说要参加竞赛,然后跑去 CSP-J 2022 玩了一圈。九月份后,进入高三(我不管我不管中职三年级也能叫做高三),实习的同学都回来了,课程再次从零开始重置,挑战了一出三个月学完全部内容。2022 年底,COVID-19 疫情防控政策放开,COVID-19 Omicron 大爆发,临近考试的时刻被学校全部遣送回家,然后体验了新冠一周速通(不得不说是真他妈难受啊)。幸好,高职高考宣布延期(不延期我就要当场死掉了!!!),在家玩了最长的一个寒假(除了2020年寒假),2月份就这样上了考场。结果是,367分,一开始看见分数有点失望,但是不知道是今年试题比往年偏难还是其他考生经过新冠后都没发挥好,竟然考到了全省四百多名,完全想不到的结果。然后就是填志愿选学校了,面对只有两间还都是民办的本科院校和全省甚至全国最好的专科院校,还是选择了填报本科。到了专业技能测试,结果竟然考了50道选择题,轻松拿下。总之怎么说呢,终于摆脱了中等职业教育,终于和以前的同学们又站在了同一条起跑线。上学好累,真的好累,继续努力吧。

我想,做我喜欢的事情

我喜欢计算机,我喜欢编程,我喜欢研究奇怪的事情,我喜欢帮助别人。我一直都知道。这两年给 Magisk 提交了好多好多代码,就在 2022 年的最后一天晚上,我还在给 magisk 修 bug。commit, push, create pull request, reply to the initial issue,时钟就这样转到了 23:59 (以 UTC+8 时间)。和另一位维护者说了句新年快乐,就这样,2022 年结束了,新的 2023 年来了。2023 年一月,新的一年的第一个月,拿到了 Magisk 项目的 collaborator 身份,后续又把 canyie 这个名字放进了 magisk app 主页【关注我们】里的开发者列表,算是安慰一下自己吧。

高职高考结束之后,选择硬刚学校,自行跑路后联系了以前认识的一个长沙老板,成功加入到了他们公司干了四个月活,是小时候梦寐以求的 Android Framework 开发工程师哦。

工作的时候,研究 Android Framework 的时候还意外发现了一个安全漏洞,和 google 反馈之后,google 定级为中危漏洞,本来以为能领到人生中第一个 CVE 的,结果五月份 google 改政策,不给普通的 moderate issue 分配 CVE 了,到手的 CVE 飞了,太坏了。继续努力吧。

终究,和别人天生不同

童年的记忆已经模糊,依稀记得小时候表现出的种种不同。二年级尝试把针插进插座里电自己,生命早期开始的反复自伤自残自杀,似乎不是一个“抑郁发作”“孤独症谱系障碍”能完全覆盖的。面对抑郁再次复发,从精神病院走出几年的我,这次主动踏进了精神科。从长沙到广州,从“复发性抑郁障碍”到疑似双相情感障碍Ⅱ型,还得到了易性症的确诊诊断,往前如梦一般的人生似乎终于得到了一个答案。虽然双相被认为无法治愈,但是已经习惯了。今天是和精神疾病相伴的第3208天,一个月前(10月5日)再次发疯吞药,现在似有好转的迹象,又担心只是躁狂发作的引线。

我尽力抓住一丝丝微光。我用尽全力做自己。

可【自己】也在迷雾中,若隐若现。



继续加油做自己吧,在昏沉的天空下追逐若隐若现的梦。即使用生命也要换一个幻影。

“若人间是场大梦啊
梦里啷当巨铁锁人家
夜归的人叩响了门环
我要不要叫醒他
若人间是场大梦啊
梦里风雨万丈碎石瓦
谁要春秋大业寿与天齐
我要不要依许他”

就这样吧。期待下次相见。


终于开了一直想写的这篇文章,再不写点东西就真的是年更博客了……

本文可以认为是 Android Booting Shenanigans 的中文补充说明,同时添加了 magiskinit 的一些处理细节。即使你对 magiskinit 没兴趣也可以看看,说不定就有一些你平时从来没注意到的细节呢 :)

Android Init

在介绍 init 执行过程前,我们先来介绍一下 init 是什么。

init 由 Linux 内核直接启动,是 Android 启动时用户空间的第一个进程,它的 pid 为 1。它承担了挂载关键系统分区、加载 SELinux policy、启动 property service、加载并执行启动脚本(init.rc)等重要工作。我们所熟知的 zygote、service manager 等进程就是被写在了 init.rc 内由 init 负责启动的。由于 init 进程的重要性,在安全层面它被视为内核的等效组件之一

抛开细节不谈,init 所做的重要的事情大概有这些:

  1. 挂载 /dev /proc /sys 等重要文件系统,创建 /dev/urandom 等设备
  2. 加载 SELinux policy 进 kernel
  3. 启动 property service 处理 setprop 等事件
  4. 执行 init.rc,完成系统剩余的启动流程,如解密 userdata、启动 zygote 等

旧 rootfs 时代纯粹的 init

Android 5.0.2 init.c,点进去搜索 main 查看源码,整个逻辑非常清晰,大概就是上面的列表做的事情,没什么好讲的。

在这个时候,启动流程大概如下所示:

  1. Bootloader 加载 boot.img
  2. 解压运行里面的 kernel
  3. kernel 初始化后,调用 populate_rootfs() 将 boot.img 内的 ramdisk 释放到 /
  4. 运行 /init,此时因为 / 这个根文件系统的文件是从 boot.img 的 ramdisk 中来的,所以会运行 boot ramdisk 里的 init
  5. init 完成用户空间的初始化工作,继续开机

到了 Android 6.0 init.cpp,事情发生了一些变化:init 分了阶段。kernel 直接启动时,init 处在“一阶段”,此时 init 需要做一些用户空间的初始化工作,加载 SELinux policy,而此时因为 init 由 kernel 直接启动,它的 SELinux context 会是 u:r:kernel:s0,所以它会 exec 它本身来 transition 到它专属的 SELinux domain(u:r:init:s0)。也就是说,上面的第 5 步被拆分为下面几小步:

  • 进行挂载 /dev 等必要的初始化
  • 加载 SELinux policy
  • 重新执行 init 本身,让进程的 SELinux domain 由 kernel 转换到 init
  • 完成剩下的初始化,继续开机

这就是以前 Android 设备启动的方式。如果你在这些设备开机完成后查看 mounts 你会看见这样的东西:

1
rootfs / rootfs ro,relatime 0 0

这代表 / 的文件系统类型是 rootfs,而 rootfs 是由 kernel 挂载 boot.img 内的 ramdisk 上去的。

Project Treble 带来的旧式 system-as-root 分区布局

到了 Android 8.0 时代(其实从 7.1 就开始准备了),Google 推出了 Project Treble。为了实现 Project Treble 及相关的 Generic System Image,/ 需要和系统相绑定。Google 选择了推出名为 system-as-root (以下简称为 SAR)的新分区布局。简单来说,/ 这个根文件系统中的内容不再来自 boot.img 内的 ramdisk,而是来自 system.img。为了实现这一行为,设备的 bootloader 在启动内核时会传递启动参数 skip_initramfs,设备的内核会看见这一参数,从而跳过 boot.img 内的 ramdisk 而是直接挂载 system.img 到 /。如果你在这些设备上查看 mounts,你会发现 / 的文件系统类型变成了 ext4(或 erofs?)而不是 rootfs。

同时,还出现了一个新的东西,A/B (Seamless) System Update (即所谓的 A/B 分区)。设备的 boot/system/vendor 等关键分区现在其实有两个,一个会被使用,而另一个用作后备。当系统更新的时候,更新包其实会被写入后备分区。这样的好处时,当更新导致无法开机的时候,因为有另一个分区存储着可以开机的系统,设备可以自动回退到上一个可以开机的版本。副作用也很明显:这些分区本来只需要一个的,现在每个都需要两个,直接 double 了,需要更大的存储空间(之后 Google 还引入了 Virtual A/B,不过这都是后话了,对我们不重要)。

怎么减少要用的存储空间呢?在之前,Android 设备需要一个 recovery 分区,里面存储着一个小型的 Linux 系统,用于在设备无法开机时可以进入 recovery 模式,从而恢复系统。虽然引入了 A/B 分区,但是 recovery 模式仍然需要保留,而 recovery 分区则不一定。对于 A/B 设备来说,由于 boot 现在有两个,即使其中一个分区因为不完整的系统更新被破坏,另一个也存储着完整的、未受破坏的 boot.img,所以 boot 至少有一个是可用的;而正常开机的时候,内核会直接挂载 system.img/,boot 内的 ramdisk 未被使用,所以可以用来放 recovery 的 ramdisk。此时,设备的开机流程如下所示:

  • bootloader 判断设备是正常启动,还是要启动到 recovery
  • 如果是正常启动,传递参数 skip_initramfs
  • 内核如果看见了 bootloader 传递过来的 skip_initramfs,就代表设备要正常启动,直接挂载 system.img 到 /;否则就是要启动到 recovery,因为 recovery 的 ramdisk 现在同样放在 boot,释放 boot.img 里的 ramdisk 到 /
  • 执行 init,继续启动流程。对于正常开机,这里 init 直接来自 system.img。

而对于非 A/B 的设备,因为 boot 只有一个,可能会在 OTA 中被破坏,为了保证设备始终有可用的 recovery,它们仍然保留了 recovery 分区。boot 内的 ramdisk 根本不会被使用到,所以 boot.img 内根本没有 ramdisk,同时设备的 bootloader 可能会直接把 boot ramdisk 给排除掉(看 OEM 怎么实现的,像三星等大部分厂商就会排除,但是小米等小部分厂商就不会)。

虽然 Google 在推出它时将其直接称为 system-as-root,但为了与下文的 2-Stage-Init System-as-root 区分开来,我们把它称为 Legacy SAR。Legacy SAR 对于出厂版本在 Android 9.0 以下的设备是可选的,但对于出厂 Android 9.0 的设备是强制的。

Android 10+:有三步的两步启动!

随着时间继续推移,到 Android 10 时,Google 推出了动态分区。对于上文提到的 Legacy SAR 来说,这是不可能的,因为 Linux 内核无法直接理解这种新的分区格式,无法将 system 挂载到 /

Google 的解决办法是:继续重写,发明新的启动方式!新的启动方式分为以下几步:

  • 像旧式 rootfs 一样,boot.img 内的 ramdisk 会被释放到 /
  • 执行 /init,所以 boot.img 内的 /init 会被执行
  • init 进入“第一阶段”,初始化用户空间,挂载 /system
  • 执行一个 switch root 操作,将 rootdir 切换到 /system。现在的 / 其实是 switch root 之前的 /system
  • 此时,设备的分区布局由旧式的 rootfs 变为 system-as-root。接下来是加载 SELinux policy,而这一步应该和 system 绑定,所以 init 选择执行 system 内的 init 来完成这一步。
  • system 内的 init 收到前辈传来的 selinux_setup 参数,进入“第二阶段”,加载 SELinux policy。然后,为了把 SELinux domain 从 kernel 切到 init,init 再次执行自己。
  • init 再次被 exec,进入“第三阶段”,完成剩下的初始化工作,继续开机。

(可以查看 Android 10.0 init main.cpp,里面的代码很详细)。

我们把这种新的启动方式称为 2-Stage-Init (简称为 2SI)。由于开机完成后,设备的 rootdir 和 Legacy SAR 一样是 system,所以我们仍然把这种分区布局看作是 system-as-root(虽然以 Google 的标准,只有 Legacy SAR 才被看作 SAR)。

……慢着!明明 init 会被执行三次了,为什么把它称为“两步启动”?

这是因为,这种启动方式会在用户空间改变 rootdir,从 rootfs 改变为 system-as-root,而上面提到的其他两种都不会改变 rootdir。我们把 switch root 之前称为“第一步”,把 switch root 之后称为“第二步”,就得到和其他两种启动方式所区分开的名字:2SI。

对于出厂 Android 10+ 的设备来说,这种启动方式是强制的;从使用 rootfs 的旧系统更新到 Android 10+ 时,也需要使用这种新的启动方式;但对于使用 Legacy SAR 的设备,它们可以继续使用 Legacy SAR。典型的例子是 Google Pixel 3 & 3a 系列,出厂时它们使用 Legacy SAR,但 Google 对其进行了改进,使得升级到 Android 10 后它们转为使用 2SI 方式启动。

由于 Legacy SAR 和 2SI 最后都会使用 system-as-root 分区布局,所以搭载 Android 10+ 的设备其实都会使用 SAR。文档 中也提到 All devices running Android 10 must use a system-as-root partition layout to enable system-only OTAs.

注:在 Android 10+ 上使用 Legacy SAR 时,由于 Android 10 的 init 写死了会执行三次,所以也会有 first stage、setup selinux、second stage 这三步;虽然 rootdir 没有发生改变,为了与 Android 9.0 Legacy SAR 区别,我们把这种情况称为 2SI legacy SARLegacy SAR with 2SI,而本节中提到的会改变 rootdir 的启动方式称为 modern 2SI2SI ramdisk SAR2SI from initramfs。当我们使用简称 2SI 时,我们默认指的是 Modern 2SI

关于更多细节,可以查阅官方的 Android Init - Early Init Boot Sequence

魅族:2SI,但是从 rootfs 到 rootfs

(注:此种特殊行为在原版 Magisk - Android Booting Shenanigans 文档中并未列出)

上文提到,“搭载 Android 10+ 的设备其实都会使用 SAR”,但对于魅族 16 系列来说并非如此。搭载 Android 10 的魅族 16 系列设备启动时会经过上面 2SI 的所有步骤,除了 switch root 这一步。由于并没有 switch root,所以设备会一直使用 rootfs。

MagiskInit

先来介绍一下 magiskinit 是什么:magiskinit 是 magisk 的重要组件之一,magisk 正是通过替换 init 为自己的 magiskinit 实现劫持设备启动过程的。

可以通过查看文档来大概了解 magiskinit 做了什么。接下来我们结合上面的知识以及 magiskinit 源码来具体分析。

注:截至发稿,本文分析的源码为 Magisk master 分支上的最新代码。最新 commit 为 cfb20b0,点进去可以浏览对应的文件。

!!!硬核警告,非战斗人员请迅速逃离现场!!!

由于 magiskinit 替换了 init,所以当内核执行 init 时,magiskinit 的 main() 会被执行。main() 的代码位于 init.cpp:

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
int main(int argc, char *argv[]) {
umask(0);

auto name = basename(argv[0]);
if (name == "magisk"sv)
return magisk_proxy_main(argc, argv);

if (getpid() != 1)
return 1;

BaseInit *init;
BootConfig config{};

if (argc > 1 && argv[1] == "selinux_setup"sv) {
rust::setup_klog();
init = new SecondStageInit(argv);
} else {
// This will also mount /sys and /proc
load_kernel_info(&config);

if (config.skip_initramfs)
init = new LegacySARInit(argv, &config);
else if (config.force_normal_boot)
init = new FirstStageInit(argv, &config);
else if (access("/sbin/recovery", F_OK) == 0 || access("/system/bin/recovery", F_OK) == 0)
init = new RecoveryInit(argv, &config);
else if (check_two_stage())
init = new FirstStageInit(argv, &config);
else
init = new RootFSInit(argv, &config);
}

// Run the main routine
init->start();
exit(1);
}

我们重点关注几个 if 分支。

RootFSInit

看名字就可以看出来,这就是用来处理上面提到的第一种分区布局 RootFS 的分支。类的定义在 init.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/************
* Initramfs
************/

class RootFSInit : public MagiskInit {
private:
void prepare();
public:
RootFSInit(char *argv[], BootConfig *config) : MagiskInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
}
void start() override {
prepare();
patch_rw_root();
exec_init();
}
};
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
void RootFSInit::prepare() {
prepare_data();
LOGD("Restoring /init\n");
/** 把备份的原来的 init 重命名为 /init ,这样接下来我们执行 /init 的时候就会执行原本的 init **/
rename(backup_init(), "/init");
}

#define PRE_TMPSRC "/magisk"
#define PRE_TMPDIR PRE_TMPSRC "/tmp"

void MagiskInit::patch_rw_root() {
mount_list.emplace_back("/data");
parse_config_file();

// Create hardlink mirror of /sbin to /root
mkdir("/root", 0777);
clone_attr("/sbin", "/root");
link_path("/sbin", "/root");

// Handle overlays
load_overlay_rc("/overlay.d");
mv_path("/overlay.d", "/");
rm_rf("/data/overlay.d");
rm_rf("/.backup");

// Patch init.rc
/** 修补 init.rc,植入我们的代码,让 init 启动我们的服务 **/
patch_rc_scripts("/", "/sbin", true);

bool treble;
{
auto init = mmap_data("/init");
treble = init.contains(SPLIT_PLAT_CIL);
}

/** 因为 rootfs 是可写的,创建 /magisk,然后把 /magisk/tmp 当成 magisk 内部的 tmpfs,初始化 magisk 内部文件**/
xmkdir(PRE_TMPSRC, 0);
xmount("tmpfs", PRE_TMPSRC, "tmpfs", 0, "mode=755");
xmkdir(PRE_TMPDIR, 0);
setup_tmp(PRE_TMPDIR);
chdir(PRE_TMPDIR);

// Extract magisk
/** 释放 magisk32 magisk64 等文件 **/
extract_files(true);

/** 修补 SELinux policy,注入我们的规则 **/
if ((!treble && access("/sepolicy", F_OK) == 0) || !hijack_sepolicy()) {
patch_sepolicy("/sepolicy", "/sepolicy");
}

chdir("/");

// Dump magiskinit as magisk
/** 把 magiskinit 当成 magisk 复制到 /sbin/magisk。init 第一次启动我们的服务的时候,会进入 magisk_proxy_main **/
cp_afc(REDIR_PATH, "/sbin/magisk");
}

int magisk_proxy_main(int argc, char *argv[]) {
rust::setup_klog();
LOGD("%s\n", __FUNCTION__);

// Mount rootfs as rw to do post-init rootfs patches
xmount(nullptr, "/", nullptr, MS_REMOUNT, nullptr);

unlink("/sbin/magisk");

// Move tmpfs to /sbin
// make parent private before MS_MOVE
/** 选择 /sbin 作为新的 magisk internal tmpfs(因为 /sbin 在 PATH 里,把 su 等可执行文件放在里面,运行 su 的时候直接就能找到这个文件并执行) **/
xmount(nullptr, PRE_TMPSRC, nullptr, MS_PRIVATE, nullptr);
xmount(PRE_TMPDIR, "/sbin", nullptr, MS_MOVE, nullptr);
xumount2(PRE_TMPSRC, MNT_DETACH);
rmdir(PRE_TMPDIR);
rmdir(PRE_TMPSRC);

// Create symlinks pointing back to /root
/** 恢复 sbin 里原本就有的文件 **/
recreate_sbin("/root", false);

// Tell magiskd to remount rootfs
setenv("REMOUNT_ROOT", "1", 1);
/** 带上参数运行真正的 magisk32/magisk64 **/
execv("/sbin/magisk", argv);
return 1;
}

patch_rc_scripts() 里最重要的就是把这段 rc 脚本注入进了 init.rc,使得 init 会在系统开机过程中自动执行 magisk:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
on post-fs-data
start logd
exec %2$s 0 0 -- %1$s/magisk --post-fs-data

on property:vold.decrypt=trigger_restart_framework
exec %2$s 0 0 -- %1$s/magisk --service

on nonencrypted
exec %2$s 0 0 -- %1$s/magisk --service

on property:sys.boot_completed=1
exec %2$s 0 0 -- %1$s/magisk --boot-complete

on property:init.svc.zygote=stopped
exec %2$s 0 0 -- %1$s/magisk --zygote-restart

对 init.rc 语法规则不熟的同学,可以参考 Android Init Language

整个逻辑还是比较清晰的,忽略一些过于内部的细节,大概如下所示:

  1. kernel 执行 init,magiskinit 被运行,检测系统环境,进入 RootFSInit
  2. 修补 init.rc,注入我们的代码
  3. 初始化 magisk 内部要用到的 tmpfs
  4. 修补 sepolicy,注入我们的规则(细节后面再谈)
  5. 把 magiskinit 复制到 /sbin/magisk,这样 init 第一次执行 magisk 时会进入 magisk_proxy_main() 做剩余工作
  6. 执行系统原来的 init,继续开机流程
  7. init 会解析并执行 init.rc,运行 magisk,这个时候运行的其实是 magiskinit,进入 magisk_proxy_main()
  8. 把 /sbin 用作 magisk internal tmpfs,然后因为我们 mount 了 tmpfs 在 sbin 上,要把里面本来就有的文件恢复出来要不然就没了
  9. 运行真正的 magisk32/magisk64

Modern 2SI

由于劫持 Legacy SAR 的启动过程过于复杂且存在太多种情况,我们先讲 Modern 2SI。

劫持 Modern 2SI 启动过程的难点在于:由于一阶段 init 来自 boot.img 内 ramdisk,所以 magiskinit 会被执行;但是,一旦运行原来的 init,她就会挂载 /system、switch root 到 /system,我们对原来 rootfs 所做的修改 全 部 木 大。而 init 一阶段运行时,/system 还未被挂载,直接对 /system 做修改也行不通。所以我们必须找到一种方法,在 init 挂载 /system 之后做修改,且 /system 往往是 read-only 的,由于 system.img 可能是 de-duplicate 过的 ext4 或者干脆就是 EROFS,也不可能将其 remount 为 read-write,任何方式直接修改都行不通,只能用 bind-mount 这种方法变相修改。

我们注意到,init 在 switch root 之前,会把现在的 mounts (除了 / 和 /system 本身)给 move 到新的 root 下面,然后再 switch root(具体代码可见这里)。通过利用这个特性,我们似乎可以让我们的某些东西在 switch root 之后仍然保留下来,但是怎么利用呢?

我们还是来看看 magiskinit 的代码吧。magiskinit 的基本思想是,first stage 做一些 hack,执行原本的 init 让它为我们挂载 /system 并 switch root,然后通过前面做的 hack 想办法让 init 再次触发自己。也就是说,类似 2SI,它也是分两步走的。

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
/***************
* 2 Stage Init
***************/

class FirstStageInit : public BaseInit {
private:
void prepare();
public:
FirstStageInit(char *argv[], BootConfig *config) : BaseInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
};
void start() override {
prepare();
exec_init();
}
};

class SecondStageInit : public MagiskInit {
private:
bool prepare();
public:
SecondStageInit(char *argv[]) : MagiskInit(argv) {
LOGD("%s\n", __FUNCTION__);
};

void start() override {
bool is_rootfs = prepare();
if (is_rootfs)
patch_rw_root();
else
patch_ro_root();
exec_init();
}
};

FirstStageInit 的 prepare 非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
#define INIT_PATH  "/system/bin/init"
#define REDIR_PATH "/data/magiskinit"

void FirstStageInit::prepare() {
prepare_data();
restore_ramdisk_init();
auto init = mmap_data("/init", true);
// Redirect original init to magiskinit
for (size_t off : init.patch(INIT_PATH, REDIR_PATH)) {
LOGD("Patch @ %08zX [" INIT_PATH "] -> [" REDIR_PATH "]\n", off);
}
}

这个函数做了什么呢?首先,prepare_data() 给 /data 挂上了 tmpfs,释放文件到 /data,把 magiskinit 复制到 /data/magiskinit;接着将原本 init 里的 /system/bin/init patch 成了 /data/magiskinit

为什么可以这样做呢?还是得看回上面的特性。我们知道,switch root 前的 mount 会保留到 switch root 之后,所以可以用这种方法保留某些东西;但是,mount 要求目标文件(夹)必须存在,而 /system 并不可写,所以自己 mkdir 一个文件夹,mount tmpfs 然后指望 init 帮我们 move mount 的做法行不通。为了保留某些东西,我们必须选一个 init first stage 到 selinux_setup 期间都不会被使用到的文件夹来帮我们放东西,而 magisk 选择的是 /data。这让 patch init 使得 init 执行 /system/bin/init 进行 selinux_setup 时转为执行 magiskinit。

第二阶段的代码:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
bool SecondStageInit::prepare() {
/** 这一行 unmount 是给 Legacy SAR with 2SI 使用的 **/
umount2("/init", MNT_DETACH);
unlink("/data/init");

// Make sure init dmesg logs won't get messed up
argv[0] = (char *) INIT_PATH;

// Some weird devices like meizu, uses 2SI but still have legacy rootfs
struct statfs sfs{};
statfs("/", &sfs);
/** 魅族设备的 2SI 不会 switch root,所以依然在 rootfs,需要判断这种特殊情况 **/
if (sfs.f_type == RAMFS_MAGIC || sfs.f_type == TMPFS_MAGIC) {
// We are still on rootfs, so make sure we will execute the init of the 2nd stage
unlink("/init");
xsymlink(INIT_PATH, "/init");
return true;
}
return false;
}

#define ROOTMIR MIRRDIR "/system_root"
#define NEW_INITRC_DIR "/system/etc/init/hw"

void MagiskInit::patch_ro_root() {
mount_list.emplace_back("/data");
parse_config_file();

/** Android 11+ sbin 可能不存在,使用 /debug_ramdisk 代替 **/
string tmp_dir;
if (access("/sbin", F_OK) == 0) {
tmp_dir = "/sbin";
} else {
tmp_dir = "/debug_ramdisk";
xmkdir("/data/debug_ramdisk", 0);
xmount("/debug_ramdisk", "/data/debug_ramdisk", nullptr, MS_MOVE, nullptr);
}

/** 初始化 magisk internal tmpfs **/
setup_tmp(tmp_dir.data());
chdir(tmp_dir.data());

if (tmp_dir == "/sbin") {
// Recreate original sbin structure
xmkdir(ROOTMIR, 0755);
xmount("/", ROOTMIR, nullptr, MS_BIND, nullptr);
recreate_sbin(ROOTMIR "/sbin", true);
xumount2(ROOTMIR, MNT_DETACH);
} else {
// Restore debug_ramdisk
xmount("/data/debug_ramdisk", "/debug_ramdisk", nullptr, MS_MOVE, nullptr);
rmdir("/data/debug_ramdisk");
}

xrename("overlay.d", ROOTOVL);

extern bool avd_hack;
// Handle avd hack
if (avd_hack) {
// Android API 28 AVD 模拟器相关,跳过
}

load_overlay_rc(ROOTOVL);
if (access(ROOTOVL "/sbin", F_OK) == 0) {
// Move files in overlay.d/sbin into tmp_dir
mv_path(ROOTOVL "/sbin", ".");
}

/** 修补 init.rc,注入 magisk 自己的服务 **/
// Patch init.rc
if (access(NEW_INITRC_DIR, F_OK) == 0) {
// Android 11's new init.rc
patch_rc_scripts(NEW_INITRC_DIR, tmp_dir.data(), false);
} else {
patch_rc_scripts("/", tmp_dir.data(), false);
}

// Extract magisk
extract_files(false);

/** 修补 sepolicy,注入我们的规则 **/
// Oculus Go will use a special sepolicy if unlocked
if (access("/sepolicy.unlocked", F_OK) == 0) {
patch_sepolicy("/sepolicy.unlocked", ROOTOVL "/sepolicy.unlocked");
} else if ((access(SPLIT_PLAT_CIL, F_OK) != 0 && access("/sepolicy", F_OK) == 0) ||
!hijack_sepolicy()) {
patch_sepolicy("/sepolicy", ROOTOVL "/sepolicy");
}

/** 用 bind mount 将需要修改的文件给一个个 mount 上去,这样就实现了不修改真实分区而修改文件 **/
// Mount rootdir
magic_mount(ROOTOVL);
int dest = xopen(ROOTMNT, O_WRONLY | O_CREAT, 0);
write(dest, magic_mount_list.data(), magic_mount_list.length());
close(dest);

chdir("/");
}

大致逻辑还是差不多的,只不过多了一些不同系统的处理(比如魅族设备需要特殊处理,这里因为 rootfs 仍然可写所以直接走 rootfs 逻辑就好了),还有由于 switch root 后 / 并不可写,所以所有对文件的修改都是先把修改过的文件放在 magisk internal tmpfs 内,然后 bind mount 到它原始的路径。
大致流程为:

  1. kernel 执行 init,magiskinit 被运行,检测系统环境,判断为 2SI 设备第一阶段,进入 FirstStageInit
  2. 挂载 /data 为 tmpfs,释放 /data/magiskinit
  3. 修补原始 init,重定向 /system/bin/init 到 /data/magiskinit,然后执行原始 init
  4. 原始 init 会挂载 /system 然后 switch root 进去,接下来它执行 /system/bin/init(被我们 patch 了所以会执行 /data/magiskinit)进行 selinux_setup
  5. magiskinit 被再次执行,看见 selinux_setup 知道这是二阶段,switch root 已经完成,开始 patch_ro_root()
  6. 修补 init.rc,注入我们的代码
  7. 修补 sepolicy,注入我们的规则(细节后面再谈)
  8. 利用 bind mount 替换 所有需要修改的文件
  9. 执行系统原来的 init,继续开机流程
  10. init 会解析并执行 init.rc,运行 magisk

Legacy SAR

终于讲到 Legacy SAR 这个坑人的玩意了。。。
这玩意坑人的地方在于:

  1. boot.img 内 ramdisk 正常情况不会用到,magiskinit 根本没机会执行。对于 A/B 设备,boot 里放着的是 recovery ramdisk,bootloader 通过向内核传递 skip_initramfs 来让内核知道该不该挂载 initramfs。Magisk 的解决办法是,安装的时候 patch 一下内核,把 skip_initramfs patch 成别的东西这样内核就认不出来 bootloader 传的是什么了,然后就会乖乖的挂载 initramfs。但对于 A-only 设备,boot.img 里根本没有 ramdisk,就算手动加一个 ramdisk,bootloader 也很有可能并不识别它,根本没办法插一脚。Magisk 表示臣妾无能为力,patch recovery.img 然后每次都重启到 recovery 用吧,要不然我也插不进去啊~
  2. 即使我们成功把 init 偷梁换柱成了 magiskinit,/ 也是 system.img,写不了,很难干活。

来看看 magiskinit 的代码吧:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/*************
* Legacy SAR
*************/

class LegacySARInit : public MagiskInit {
private:
bool mount_system_root();
void first_stage_prep();
public:
LegacySARInit(char *argv[], BootConfig *config) : MagiskInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
};
void start() override {
prepare_data();
bool is_two_stage = mount_system_root();
if (is_two_stage)
first_stage_prep();
else
patch_ro_root();
exec_init();
}
};

bool LegacySARInit::mount_system_root() {
LOGD("Mounting system_root\n");

// there's no /dev in stub cpio
xmkdir("/dev", 0777);

strcpy(blk_info.block_dev, "/dev/root");

do {
// Try legacy SAR dm-verity
strcpy(blk_info.partname, "vroot");
auto dev = setup_block();
if (dev > 0)
goto mount_root;

// Try NVIDIA naming scheme
strcpy(blk_info.partname, "APP");
dev = setup_block();
if (dev > 0)
goto mount_root;

sprintf(blk_info.partname, "system%s", config->slot);
dev = setup_block();
if (dev > 0)
goto mount_root;

// Poll forever if rootwait was given in cmdline
} while (config->rootwait);

// We don't really know what to do at this point...
LOGE("Cannot find root partition, abort\n");
exit(1);

mount_root:
xmkdir("/system_root", 0755);

if (xmount("/dev/root", "/system_root", "ext4", MS_RDONLY, nullptr)) {
if (xmount("/dev/root", "/system_root", "erofs", MS_RDONLY, nullptr)) {
// We don't really know what to do at this point...
LOGE("Cannot mount root partition, abort\n");
exit(1);
}
}

switch_root("/system_root");

// Make dev writable
xmount("tmpfs", "/dev", "tmpfs", 0, "mode=755");
mount_list.emplace_back("/dev");

// Use the apex folder to determine whether 2SI (Android 10+)
bool is_two_stage = access("/apex", F_OK) == 0;
LOGD("is_two_stage: [%d]\n", is_two_stage);

// For API 28 AVD, it uses legacy SAR setup that requires
// special hacks in magiskinit to work properly.
if (!is_two_stage && config->emulator) {
// AVD,不重要
}

return is_two_stage;
}

void LegacySARInit::first_stage_prep() {
// Patch init binary
int src = xopen("/init", O_RDONLY);
int dest = xopen("/data/init", O_CREAT | O_WRONLY, 0);
{
mmap_data init("/init");
for (size_t off : init.patch(INIT_PATH, REDIR_PATH)) {
LOGD("Patch @ %08zX [" INIT_PATH "] -> [" REDIR_PATH "]\n", off);
}
write(dest, init.buf(), init.sz());
fclone_attr(src, dest);
close(dest);
close(src);
}
xmount("/data/init", "/init", nullptr, MS_BIND, nullptr);
}

总结如下:

  1. magiskinit 被运行,检测系统环境,判断为 Legacy SAR,进入 LegacySARInit
  2. 因为 kernel 被我们 patch 了,原本 switch root 应该由 kernel 来做的,现在没有了,需要手动 mount /system 然后 switch root 进去
  3. 检测有没有 /apex,有的话代表 Android 10+,init 有 selinux_setup 阶段,可以劫持,走 first_stage_prep();没有的话直接 patch_ro_boot() 然后执行原来的 init
  4. first_stage_prep() 里会像 Modern 2SI 那样修补 init,将 /system/bin/init 重定向到 /data/magiskinit,然后把它挂载到 /init 以供后面执行;执行原来的 init。
  5. 原来的 init 执行 selinux_setup(若有),转到 magiskinit,magiskinit 识别到二阶段,自动 patch_ro_boot()
  6. patch_ro_boot() 完成之后,执行 init 交还控制权,继续开机

修补 SEPolicy

由于这一块过于复杂,所以单独放一节讲。

如果你仔细看了上面的代码,你会发现修补 SEPolicy 有 patch_sepolicy() 以及 hijack_sepolicy() 两个函数,那么它们有什么不同呢?

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
void MagiskInit::patch_sepolicy(const char *in, const char *out) {
LOGD("Patching monolithic policy\n");
auto sepol = unique_ptr<sepolicy>(sepolicy::from_file(in));

/** 修补 sepolicy **/
sepol->magisk_rules();

LOGD("Dumping sepolicy to: [%s]\n", out);
sepol->to_file(out);

// Remove OnePlus stupid debug sepolicy and use our own
if (access("/sepolicy_debug", F_OK) == 0) {
unlink("/sepolicy_debug");
link("/sepolicy", "/sepolicy_debug");
}
}

#define MOCK_COMPAT SELINUXMOCK "/compatible"
#define MOCK_LOAD SELINUXMOCK "/load"
#define MOCK_ENFORCE SELINUXMOCK "/enforce"

bool MagiskInit::hijack_sepolicy() {
xmkdir(SELINUXMOCK, 0);

if (access("/system/bin/init", F_OK) == 0) {
// On 2SI devices, the 2nd stage init file is always a dynamic executable.
// This meant that instead of going through convoluted methods trying to alter
// and block init's control flow, we can just LD_PRELOAD and replace the
// security_load_policy function with our own implementation.
dump_preload();
setenv("LD_PRELOAD", "/dev/preload.so", 1);
}

// Hijack the "load" and "enforce" node in selinuxfs to manipulate
// the actual sepolicy being loaded into the kernel
auto hijack = [&] {
LOGD("Hijack [" SELINUX_LOAD "]\n");
close(xopen(MOCK_LOAD, O_CREAT | O_RDONLY, 0600));
xmount(MOCK_LOAD, SELINUX_LOAD, nullptr, MS_BIND, nullptr);
LOGD("Hijack [" SELINUX_ENFORCE "]\n");
mkfifo(MOCK_ENFORCE, 0644);
xmount(MOCK_ENFORCE, SELINUX_ENFORCE, nullptr, MS_BIND, nullptr);
};

string dt_compat;
if (access(SELINUX_ENFORCE, F_OK) != 0) {
// selinuxfs not mounted yet. Hijack the dt fstab nodes first
// and let the original init mount selinuxfs for us.
// This only happens on Android 8.0 - 9.0

char buf[4096];
ssprintf(buf, sizeof(buf), "%s/fstab/compatible", config->dt_dir);
dt_compat = full_read(buf);
if (dt_compat.empty()) {
// Device does not do early mount and uses monolithic policy
return false;
}

// Remount procfs with proper options
xmount(nullptr, "/proc", nullptr, MS_REMOUNT, "hidepid=2,gid=3009");

LOGD("Hijack [%s]\n", buf);

// Preserve sysfs and procfs for hijacking
mount_list.erase(std::remove_if(
mount_list.begin(), mount_list.end(),
[](const string &s) { return s == "/proc" || s == "/sys"; }), mount_list.end());

mkfifo(MOCK_COMPAT, 0444);
xmount(MOCK_COMPAT, buf, nullptr, MS_BIND, nullptr);
} else {
hijack();
}

// Create a new process waiting for init operations
if (xfork()) {
// In parent, return and continue boot process
return true;
}

if (!dt_compat.empty()) {
// This open will block until init calls DoFirstStageMount
// The only purpose here is actually to wait for init to mount selinuxfs for us
int fd = xopen(MOCK_COMPAT, O_WRONLY);

char buf[4096];
ssprintf(buf, sizeof(buf), "%s/fstab/compatible", config->dt_dir);
xumount2(buf, MNT_DETACH);

hijack();

xwrite(fd, dt_compat.data(), dt_compat.size());
close(fd);
}

// This open will block until init calls security_getenforce
int fd = xopen(MOCK_ENFORCE, O_WRONLY);

// Cleanup the hijacks
umount2("/init", MNT_DETACH);
xumount2(SELINUX_LOAD, MNT_DETACH);
xumount2(SELINUX_ENFORCE, MNT_DETACH);

// Load and patch policy
auto sepol = unique_ptr<sepolicy>(sepolicy::from_file(MOCK_LOAD));
sepol->magisk_rules();
sepol->load_rules(rules);

// Load patched policy into kernel
sepol->to_file(SELINUX_LOAD);

// Write to the enforce node ONLY after sepolicy is loaded. We need to make sure
// the actual init process is blocked until sepolicy is loaded, or else
// restorecon will fail and re-exec won't change context, causing boot failure.
// We (ab)use the fact that init reads the enforce node, and because
// it has been replaced with our FIFO file, init will block until we
// write something into the pipe, effectively hijacking its control flow.

string enforce = full_read(SELINUX_ENFORCE);
xwrite(fd, enforce.data(), enforce.length());
close(fd);

// At this point, the init process will be unblocked
// and continue on with restorecon + re-exec.

// Terminate process
exit(0);
}

最直观的感觉就是,两个函数代码量不一样……

Android 上,sepolicy 可以以单个的 sepolicy 文件的形式存储,也可以存储多个拆分的 cil 文件;前者被称为 monolithic policy,后者被称为 split sepolicy。Linux 内核只接受 monolithic policy,所以加载 split sepolicy 时,由 init 进程负责将 cil 文件编译为 monolithic policy 并加载进内核。

对于 monolithic policy,magiskinit patch 起来非常轻松惬意:直接加载 /sepolicy,修改,放回去,然后 init 加载的时候就会加载我们修改过的 sepolicy。

而对于 split policy 可就麻烦了,init 运行之前可能根本没有 /sepolicy 给你 patch,怎么办呢?因为 init 会把 cil 全部编译成 monolithic policy 然后再传进内核里(具体是往 /sys/fs/selinux/load 写入),要是有方法拦截这个写入过程就好了!

有吗?还真有。

先快速介绍一下 FIFO 的概念:FIFO 看起来就像一个普通的文件,但是它用起来类似 pipe,就是一根管子,当一个进程往里写内容的时候,另一个进程能读取到。反过来,一个进程读它的时候,默认会阻塞,直到有另外的进程打开了这个 FIFO 并且写入内容。说白了就是一个专门用来进程间通信的管子,只不过长得是个文件而已。

Magisk 的 hijack_policy() 正是利用了 FIFO 的特性,才能拦截到 init 加载 sepolicy 的过程。
具体来说,大致分为以下几步:

  1. 判断 selinuxfs 有没有被挂载,如果没有需要先等 init 挂载 selinuxfs。这里我们同样利用 FIFO,选择了一个 init 挂载完 selinuxfs 之后会读取的文件,来卡住时机。里面的注释 This only happens on Android 8.0 - 9.0 其实是因为更旧的版本没有 split policy,直接 patch monolithic policy 就好了,不用走 hijack;而 Android 10+ init 始终分为三步,我们永远在 selinux_setup 时进行 hijack,此时 first stage 已经走完了,selinuxfs 肯定已经被 init mount 上了;只有 Android 8.0-9.0,有可能有 split policy 又没有单独的 selinux_setup 这一步可以拦截,所以只能手动进行特殊处理。
  2. 用 bind mount 把 /sys/fs/selinux/load 覆盖成一个普通的文件,这样 init 往里写入 sepolicy 时其实是写进了我们的文件,而不是内核里
  3. 调用 mkfifo() 创建一个 FIFO,然后 bind mount 到 /sys/fs/selinux/enforce 上面
  4. fork 一个子进程,父进程继续执行剩余步骤,然后执行原始 init;子进程会以 write-only 的方式打开这个 FIFO,此时因为 FIFO 的特性,这个进程会阻塞,直到有另一个进程以 read 方式打开同样的 FIFO。在我们的例子当中,子进程会阻塞,直到 init 调用 security_getenforce() 读取了 /sys/fs/selinux/enforce,此时 init 阻塞,等待我们的子进程往 FIFO 内写入内容,而此时 init 已经把要加载的 sepolicy 写入到了 /sys/fs/selinux/load
  5. 读取 init 原本要加载的 sepolicy,进行 patch 注入自己的规则,然后手动加载进内核里(因为之前 init 尝试加载的时候被我们给拦了)
  6. 往 FIFO 写入内容,唤醒对端的 init 进程,做一些 cleanup 然后可以退出子进程。

虽然整个 hijack 还使用了 LD_PRELOAD 等技术来保证 sepolicy hijack 能够正常工作,但大致流程就是如上所示。整个机制非常精妙,非常依赖 init 的实际行为,需要结合 android init 及 libselinux 的源码反复琢磨才能搞清其中逻辑。


终于开了一直想写的这篇文章,再不写点东西就真的是年更博客了……

本文可以认为是 Android Booting Shenanigans 的中文补充说明,同时添加了 magiskinit 的一些处理细节。即使你对 magiskinit 没兴趣也可以看看,说不定就有一些你平时从来没注意到的细节呢 :)

Android Init

在介绍 init 执行过程前,我们先来介绍一下 init 是什么。

init 由 Linux 内核直接启动,是 Android 启动时用户空间的第一个进程,它的 pid 为 1。它承担了挂载关键系统分区、加载 SELinux policy、启动 property service、加载并执行启动脚本(init.rc)等重要工作。我们所熟知的 zygote、service manager 等进程就是被写在了 init.rc 内由 init 负责启动的。由于 init 进程的重要性,在安全层面它被视为内核的等效组件之一

抛开细节不谈,init 所做的重要的事情大概有这些:

  1. 挂载 /dev /proc /sys 等重要文件系统,创建 /dev/urandom 等设备
  2. 加载 SELinux policy 进 kernel
  3. 启动 property service 处理 setprop 等事件
  4. 执行 init.rc,完成系统剩余的启动流程,如解密 userdata、启动 zygote 等

旧 rootfs 时代纯粹的 init

Android 5.0.2 init.c,点进去搜索 main 查看源码,整个逻辑非常清晰,大概就是上面的列表做的事情,没什么好讲的。

在这个时候,启动流程大概如下所示:

  1. Bootloader 加载 boot.img
  2. 解压运行里面的 kernel
  3. kernel 初始化后,调用 populate_rootfs() 将 boot.img 内的 ramdisk 释放到 /
  4. 运行 /init,此时因为 / 这个根文件系统的文件是从 boot.img 的 ramdisk 中来的,所以会运行 boot ramdisk 里的 init
  5. init 完成用户空间的初始化工作,继续开机

到了 Android 6.0 init.cpp,事情发生了一些变化:init 分了阶段。kernel 直接启动时,init 处在“一阶段”,此时 init 需要做一些用户空间的初始化工作,加载 SELinux policy,而此时因为 init 由 kernel 直接启动,它的 SELinux context 会是 u:r:kernel:s0,所以它会 exec 它本身来 transition 到它专属的 SELinux domain(u:r:init:s0)。也就是说,上面的第 5 步被拆分为下面几小步:

  • 进行挂载 /dev 等必要的初始化
  • 加载 SELinux policy
  • 重新执行 init 本身,让进程的 SELinux domain 由 kernel 转换到 init
  • 完成剩下的初始化,继续开机

这就是以前 Android 设备启动的方式。如果你在这些设备开机完成后查看 mounts 你会看见这样的东西:

1
rootfs / rootfs ro,relatime 0 0

这代表 / 的文件系统类型是 rootfs,而 rootfs 是由 kernel 挂载 boot.img 内的 ramdisk 上去的。

Project Treble 带来的旧式 system-as-root 分区布局

到了 Android 8.0 时代(其实从 7.1 就开始准备了),Google 推出了 Project Treble。为了实现 Project Treble 及相关的 Generic System Image,/ 需要和系统相绑定。Google 选择了推出名为 system-as-root (以下简称为 SAR)的新分区布局。简单来说,/ 这个根文件系统中的内容不再来自 boot.img 内的 ramdisk,而是来自 system.img。为了实现这一行为,设备的 bootloader 在启动内核时会传递启动参数 skip_initramfs,设备的内核会看见这一参数,从而跳过 boot.img 内的 ramdisk 而是直接挂载 system.img 到 /。如果你在这些设备上查看 mounts,你会发现 / 的文件系统类型变成了 ext4(或 erofs?)而不是 rootfs。

同时,还出现了一个新的东西,A/B (Seamless) System Update (即所谓的 A/B 分区)。设备的 boot/system/vendor 等关键分区现在其实有两个,一个会被使用,而另一个用作后备。当系统更新的时候,更新包其实会被写入后备分区。这样的好处时,当更新导致无法开机的时候,因为有另一个分区存储着可以开机的系统,设备可以自动回退到上一个可以开机的版本。副作用也很明显:这些分区本来只需要一个的,现在每个都需要两个,直接 double 了,需要更大的存储空间(之后 Google 还引入了 Virtual A/B,不过这都是后话了,对我们不重要)。

怎么减少要用的存储空间呢?在之前,Android 设备需要一个 recovery 分区,里面存储着一个小型的 Linux 系统,用于在设备无法开机时可以进入 recovery 模式,从而恢复系统。虽然引入了 A/B 分区,但是 recovery 模式仍然需要保留,而 recovery 分区则不一定。对于 A/B 设备来说,由于 boot 现在有两个,即使其中一个分区因为不完整的系统更新被破坏,另一个也存储着完整的、未受破坏的 boot.img,所以 boot 至少有一个是可用的;而正常开机的时候,内核会直接挂载 system.img/,boot 内的 ramdisk 未被使用,所以可以用来放 recovery 的 ramdisk。此时,设备的开机流程如下所示:

  • bootloader 判断设备是正常启动,还是要启动到 recovery
  • 如果是正常启动,传递参数 skip_initramfs
  • 内核如果看见了 bootloader 传递过来的 skip_initramfs,就代表设备要正常启动,直接挂载 system.img 到 /;否则就是要启动到 recovery,因为 recovery 的 ramdisk 现在同样放在 boot,释放 boot.img 里的 ramdisk 到 /
  • 执行 init,继续启动流程。对于正常开机,这里 init 直接来自 system.img。

而对于非 A/B 的设备,因为 boot 只有一个,可能会在 OTA 中被破坏,为了保证设备始终有可用的 recovery,它们仍然保留了 recovery 分区。boot 内的 ramdisk 根本不会被使用到,所以 boot.img 内根本没有 ramdisk,同时设备的 bootloader 可能会直接把 boot ramdisk 给排除掉(看 OEM 怎么实现的,像三星等大部分厂商就会排除,但是小米等小部分厂商就不会)。

虽然 Google 在推出它时将其直接称为 system-as-root,但为了与下文的 2-Stage-Init System-as-root 区分开来,我们把它称为 Legacy SAR。Legacy SAR 对于出厂版本在 Android 9.0 以下的设备是可选的,但对于出厂 Android 9.0 的设备是强制的。

Android 10+:有三步的两步启动!

随着时间继续推移,到 Android 10 时,Google 推出了动态分区。对于上文提到的 Legacy SAR 来说,这是不可能的,因为 Linux 内核无法直接理解这种新的分区格式,无法将 system 挂载到 /

Google 的解决办法是:继续重写,发明新的启动方式!新的启动方式分为以下几步:

  • 像旧式 rootfs 一样,boot.img 内的 ramdisk 会被释放到 /
  • 执行 /init,所以 boot.img 内的 /init 会被执行
  • init 进入“第一阶段”,初始化用户空间,挂载 /system
  • 执行一个 switch root 操作,将 rootdir 切换到 /system。现在的 / 其实是 switch root 之前的 /system
  • 此时,设备的分区布局由旧式的 rootfs 变为 system-as-root。接下来是加载 SELinux policy,而这一步应该和 system 绑定,所以 init 选择执行 system 内的 init 来完成这一步。
  • system 内的 init 收到前辈传来的 selinux_setup 参数,进入“第二阶段”,加载 SELinux policy。然后,为了把 SELinux domain 从 kernel 切到 init,init 再次执行自己。
  • init 再次被 exec,进入“第三阶段”,完成剩下的初始化工作,继续开机。

(可以查看 Android 10.0 init main.cpp,里面的代码很详细)。

我们把这种新的启动方式称为 2-Stage-Init (简称为 2SI)。由于开机完成后,设备的 rootdir 和 Legacy SAR 一样是 system,所以我们仍然把这种分区布局看作是 system-as-root(虽然以 Google 的标准,只有 Legacy SAR 才被看作 SAR)。

……慢着!明明 init 会被执行三次了,为什么把它称为“两步启动”?

这是因为,这种启动方式会在用户空间改变 rootdir,从 rootfs 改变为 system-as-root,而上面提到的其他两种都不会改变 rootdir。我们把 switch root 之前称为“第一步”,把 switch root 之后称为“第二步”,就得到和其他两种启动方式所区分开的名字:2SI。

对于出厂 Android 10+ 的设备来说,这种启动方式是强制的;从使用 rootfs 的旧系统更新到 Android 10+ 时,也需要使用这种新的启动方式;但对于使用 Legacy SAR 的设备,它们可以继续使用 Legacy SAR。典型的例子是 Google Pixel 3 & 3a 系列,出厂时它们使用 Legacy SAR,但 Google 对其进行了改进,使得升级到 Android 10 后它们转为使用 2SI 方式启动。

由于 Legacy SAR 和 2SI 最后都会使用 system-as-root 分区布局,所以搭载 Android 10+ 的设备其实都会使用 SAR。文档 中也提到 All devices running Android 10 must use a system-as-root partition layout to enable system-only OTAs.

注:在 Android 10+ 上使用 Legacy SAR 时,由于 Android 10 的 init 写死了会执行三次,所以也会有 first stage、setup selinux、second stage 这三步;虽然 rootdir 没有发生改变,为了与 Android 9.0 Legacy SAR 区别,我们把这种情况称为 2SI legacy SARLegacy SAR with 2SI,而本节中提到的会改变 rootdir 的启动方式称为 modern 2SI2SI ramdisk SAR2SI from initramfs。当我们使用简称 2SI 时,我们默认指的是 Modern 2SI

关于更多细节,可以查阅官方的 Android Init - Early Init Boot Sequence

魅族:2SI,但是从 rootfs 到 rootfs

(注:此种特殊行为在原版 Magisk - Android Booting Shenanigans 文档中并未列出)

上文提到,“搭载 Android 10+ 的设备其实都会使用 SAR”,但对于魅族 16 系列来说并非如此。搭载 Android 10 的魅族 16 系列设备启动时会经过上面 2SI 的所有步骤,除了 switch root 这一步。由于并没有 switch root,所以设备会一直使用 rootfs。

MagiskInit

先来介绍一下 magiskinit 是什么:magiskinit 是 magisk 的重要组件之一,magisk 正是通过替换 init 为自己的 magiskinit 实现劫持设备启动过程的。

可以通过查看文档来大概了解 magiskinit 做了什么。接下来我们结合上面的知识以及 magiskinit 源码来具体分析。

注:截至发稿,本文分析的源码为 Magisk master 分支上的最新代码。最新 commit 为 cfb20b0,点进去可以浏览对应的文件。

!!!硬核警告,非战斗人员请迅速逃离现场!!!

由于 magiskinit 替换了 init,所以当内核执行 init 时,magiskinit 的 main() 会被执行。main() 的代码位于 init.cpp:

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
int main(int argc, char *argv[]) {
umask(0);

auto name = basename(argv[0]);
if (name == "magisk"sv)
return magisk_proxy_main(argc, argv);

if (getpid() != 1)
return 1;

BaseInit *init;
BootConfig config{};

if (argc > 1 && argv[1] == "selinux_setup"sv) {
rust::setup_klog();
init = new SecondStageInit(argv);
} else {
// This will also mount /sys and /proc
load_kernel_info(&config);

if (config.skip_initramfs)
init = new LegacySARInit(argv, &config);
else if (config.force_normal_boot)
init = new FirstStageInit(argv, &config);
else if (access("/sbin/recovery", F_OK) == 0 || access("/system/bin/recovery", F_OK) == 0)
init = new RecoveryInit(argv, &config);
else if (check_two_stage())
init = new FirstStageInit(argv, &config);
else
init = new RootFSInit(argv, &config);
}

// Run the main routine
init->start();
exit(1);
}

我们重点关注几个 if 分支。

RootFSInit

看名字就可以看出来,这就是用来处理上面提到的第一种分区布局 RootFS 的分支。类的定义在 init.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/************
* Initramfs
************/

class RootFSInit : public MagiskInit {
private:
void prepare();
public:
RootFSInit(char *argv[], BootConfig *config) : MagiskInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
}
void start() override {
prepare();
patch_rw_root();
exec_init();
}
};
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
void RootFSInit::prepare() {
prepare_data();
LOGD("Restoring /init\n");
/** 把备份的原来的 init 重命名为 /init ,这样接下来我们执行 /init 的时候就会执行原本的 init **/
rename(backup_init(), "/init");
}

#define PRE_TMPSRC "/magisk"
#define PRE_TMPDIR PRE_TMPSRC "/tmp"

void MagiskInit::patch_rw_root() {
mount_list.emplace_back("/data");
parse_config_file();

// Create hardlink mirror of /sbin to /root
mkdir("/root", 0777);
clone_attr("/sbin", "/root");
link_path("/sbin", "/root");

// Handle overlays
load_overlay_rc("/overlay.d");
mv_path("/overlay.d", "/");
rm_rf("/data/overlay.d");
rm_rf("/.backup");

// Patch init.rc
/** 修补 init.rc,植入我们的代码,让 init 启动我们的服务 **/
patch_rc_scripts("/", "/sbin", true);

bool treble;
{
auto init = mmap_data("/init");
treble = init.contains(SPLIT_PLAT_CIL);
}

/** 因为 rootfs 是可写的,创建 /magisk,然后把 /magisk/tmp 当成 magisk 内部的 tmpfs,初始化 magisk 内部文件**/
xmkdir(PRE_TMPSRC, 0);
xmount("tmpfs", PRE_TMPSRC, "tmpfs", 0, "mode=755");
xmkdir(PRE_TMPDIR, 0);
setup_tmp(PRE_TMPDIR);
chdir(PRE_TMPDIR);

// Extract magisk
/** 释放 magisk32 magisk64 等文件 **/
extract_files(true);

/** 修补 SELinux policy,注入我们的规则 **/
if ((!treble && access("/sepolicy", F_OK) == 0) || !hijack_sepolicy()) {
patch_sepolicy("/sepolicy", "/sepolicy");
}

chdir("/");

// Dump magiskinit as magisk
/** 把 magiskinit 当成 magisk 复制到 /sbin/magisk。init 第一次启动我们的服务的时候,会进入 magisk_proxy_main **/
cp_afc(REDIR_PATH, "/sbin/magisk");
}

int magisk_proxy_main(int argc, char *argv[]) {
rust::setup_klog();
LOGD("%s\n", __FUNCTION__);

// Mount rootfs as rw to do post-init rootfs patches
xmount(nullptr, "/", nullptr, MS_REMOUNT, nullptr);

unlink("/sbin/magisk");

// Move tmpfs to /sbin
// make parent private before MS_MOVE
/** 选择 /sbin 作为新的 magisk internal tmpfs(因为 /sbin 在 PATH 里,把 su 等可执行文件放在里面,运行 su 的时候直接就能找到这个文件并执行) **/
xmount(nullptr, PRE_TMPSRC, nullptr, MS_PRIVATE, nullptr);
xmount(PRE_TMPDIR, "/sbin", nullptr, MS_MOVE, nullptr);
xumount2(PRE_TMPSRC, MNT_DETACH);
rmdir(PRE_TMPDIR);
rmdir(PRE_TMPSRC);

// Create symlinks pointing back to /root
/** 恢复 sbin 里原本就有的文件 **/
recreate_sbin("/root", false);

// Tell magiskd to remount rootfs
setenv("REMOUNT_ROOT", "1", 1);
/** 带上参数运行真正的 magisk32/magisk64 **/
execv("/sbin/magisk", argv);
return 1;
}

patch_rc_scripts() 里最重要的就是把这段 rc 脚本注入进了 init.rc,使得 init 会在系统开机过程中自动执行 magisk:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
on post-fs-data
start logd
exec %2$s 0 0 -- %1$s/magisk --post-fs-data

on property:vold.decrypt=trigger_restart_framework
exec %2$s 0 0 -- %1$s/magisk --service

on nonencrypted
exec %2$s 0 0 -- %1$s/magisk --service

on property:sys.boot_completed=1
exec %2$s 0 0 -- %1$s/magisk --boot-complete

on property:init.svc.zygote=stopped
exec %2$s 0 0 -- %1$s/magisk --zygote-restart

对 init.rc 语法规则不熟的同学,可以参考 Android Init Language

整个逻辑还是比较清晰的,忽略一些过于内部的细节,大概如下所示:

  1. kernel 执行 init,magiskinit 被运行,检测系统环境,进入 RootFSInit
  2. 修补 init.rc,注入我们的代码
  3. 初始化 magisk 内部要用到的 tmpfs
  4. 修补 sepolicy,注入我们的规则(细节后面再谈)
  5. 把 magiskinit 复制到 /sbin/magisk,这样 init 第一次执行 magisk 时会进入 magisk_proxy_main() 做剩余工作
  6. 执行系统原来的 init,继续开机流程
  7. init 会解析并执行 init.rc,运行 magisk,这个时候运行的其实是 magiskinit,进入 magisk_proxy_main()
  8. 把 /sbin 用作 magisk internal tmpfs,然后因为我们 mount 了 tmpfs 在 sbin 上,要把里面本来就有的文件恢复出来要不然就没了
  9. 运行真正的 magisk32/magisk64

Modern 2SI

由于劫持 Legacy SAR 的启动过程过于复杂且存在太多种情况,我们先讲 Modern 2SI。

劫持 Modern 2SI 启动过程的难点在于:由于一阶段 init 来自 boot.img 内 ramdisk,所以 magiskinit 会被执行;但是,一旦运行原来的 init,她就会挂载 /system、switch root 到 /system,我们对原来 rootfs 所做的修改 全 部 木 大。而 init 一阶段运行时,/system 还未被挂载,直接对 /system 做修改也行不通。所以我们必须找到一种方法,在 init 挂载 /system 之后做修改,且 /system 往往是 read-only 的,由于 system.img 可能是 de-duplicate 过的 ext4 或者干脆就是 EROFS,也不可能将其 remount 为 read-write,任何方式直接修改都行不通,只能用 bind-mount 这种方法变相修改。

我们注意到,init 在 switch root 之前,会把现在的 mounts (除了 / 和 /system 本身)给 move 到新的 root 下面,然后再 switch root(具体代码可见这里)。通过利用这个特性,我们似乎可以让我们的某些东西在 switch root 之后仍然保留下来,但是怎么利用呢?

我们还是来看看 magiskinit 的代码吧。magiskinit 的基本思想是,first stage 做一些 hack,执行原本的 init 让它为我们挂载 /system 并 switch root,然后通过前面做的 hack 想办法让 init 再次触发自己。也就是说,类似 2SI,它也是分两步走的。

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
/***************
* 2 Stage Init
***************/

class FirstStageInit : public BaseInit {
private:
void prepare();
public:
FirstStageInit(char *argv[], BootConfig *config) : BaseInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
};
void start() override {
prepare();
exec_init();
}
};

class SecondStageInit : public MagiskInit {
private:
bool prepare();
public:
SecondStageInit(char *argv[]) : MagiskInit(argv) {
LOGD("%s\n", __FUNCTION__);
};

void start() override {
bool is_rootfs = prepare();
if (is_rootfs)
patch_rw_root();
else
patch_ro_root();
exec_init();
}
};

FirstStageInit 的 prepare 非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
#define INIT_PATH  "/system/bin/init"
#define REDIR_PATH "/data/magiskinit"

void FirstStageInit::prepare() {
prepare_data();
restore_ramdisk_init();
auto init = mmap_data("/init", true);
// Redirect original init to magiskinit
for (size_t off : init.patch(INIT_PATH, REDIR_PATH)) {
LOGD("Patch @ %08zX [" INIT_PATH "] -> [" REDIR_PATH "]\n", off);
}
}

这个函数做了什么呢?首先,prepare_data() 给 /data 挂上了 tmpfs,释放文件到 /data,把 magiskinit 复制到 /data/magiskinit;接着将原本 init 里的 /system/bin/init patch 成了 /data/magiskinit

为什么可以这样做呢?还是得看回上面的特性。我们知道,switch root 前的 mount 会保留到 switch root 之后,所以可以用这种方法保留某些东西;但是,mount 要求目标文件(夹)必须存在,而 /system 并不可写,所以自己 mkdir 一个文件夹,mount tmpfs 然后指望 init 帮我们 move mount 的做法行不通。为了保留某些东西,我们必须选一个 init first stage 到 selinux_setup 期间都不会被使用到的文件夹来帮我们放东西,而 magisk 选择的是 /data。这让 patch init 使得 init 执行 /system/bin/init 进行 selinux_setup 时转为执行 magiskinit。

第二阶段的代码:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
bool SecondStageInit::prepare() {
/** 这一行 unmount 是给 Legacy SAR with 2SI 使用的 **/
umount2("/init", MNT_DETACH);
unlink("/data/init");

// Make sure init dmesg logs won't get messed up
argv[0] = (char *) INIT_PATH;

// Some weird devices like meizu, uses 2SI but still have legacy rootfs
struct statfs sfs{};
statfs("/", &sfs);
/** 魅族设备的 2SI 不会 switch root,所以依然在 rootfs,需要判断这种特殊情况 **/
if (sfs.f_type == RAMFS_MAGIC || sfs.f_type == TMPFS_MAGIC) {
// We are still on rootfs, so make sure we will execute the init of the 2nd stage
unlink("/init");
xsymlink(INIT_PATH, "/init");
return true;
}
return false;
}

#define ROOTMIR MIRRDIR "/system_root"
#define NEW_INITRC_DIR "/system/etc/init/hw"

void MagiskInit::patch_ro_root() {
mount_list.emplace_back("/data");
parse_config_file();

/** Android 11+ sbin 可能不存在,使用 /debug_ramdisk 代替 **/
string tmp_dir;
if (access("/sbin", F_OK) == 0) {
tmp_dir = "/sbin";
} else {
tmp_dir = "/debug_ramdisk";
xmkdir("/data/debug_ramdisk", 0);
xmount("/debug_ramdisk", "/data/debug_ramdisk", nullptr, MS_MOVE, nullptr);
}

/** 初始化 magisk internal tmpfs **/
setup_tmp(tmp_dir.data());
chdir(tmp_dir.data());

if (tmp_dir == "/sbin") {
// Recreate original sbin structure
xmkdir(ROOTMIR, 0755);
xmount("/", ROOTMIR, nullptr, MS_BIND, nullptr);
recreate_sbin(ROOTMIR "/sbin", true);
xumount2(ROOTMIR, MNT_DETACH);
} else {
// Restore debug_ramdisk
xmount("/data/debug_ramdisk", "/debug_ramdisk", nullptr, MS_MOVE, nullptr);
rmdir("/data/debug_ramdisk");
}

xrename("overlay.d", ROOTOVL);

extern bool avd_hack;
// Handle avd hack
if (avd_hack) {
// Android API 28 AVD 模拟器相关,跳过
}

load_overlay_rc(ROOTOVL);
if (access(ROOTOVL "/sbin", F_OK) == 0) {
// Move files in overlay.d/sbin into tmp_dir
mv_path(ROOTOVL "/sbin", ".");
}

/** 修补 init.rc,注入 magisk 自己的服务 **/
// Patch init.rc
if (access(NEW_INITRC_DIR, F_OK) == 0) {
// Android 11's new init.rc
patch_rc_scripts(NEW_INITRC_DIR, tmp_dir.data(), false);
} else {
patch_rc_scripts("/", tmp_dir.data(), false);
}

// Extract magisk
extract_files(false);

/** 修补 sepolicy,注入我们的规则 **/
// Oculus Go will use a special sepolicy if unlocked
if (access("/sepolicy.unlocked", F_OK) == 0) {
patch_sepolicy("/sepolicy.unlocked", ROOTOVL "/sepolicy.unlocked");
} else if ((access(SPLIT_PLAT_CIL, F_OK) != 0 && access("/sepolicy", F_OK) == 0) ||
!hijack_sepolicy()) {
patch_sepolicy("/sepolicy", ROOTOVL "/sepolicy");
}

/** 用 bind mount 将需要修改的文件给一个个 mount 上去,这样就实现了不修改真实分区而修改文件 **/
// Mount rootdir
magic_mount(ROOTOVL);
int dest = xopen(ROOTMNT, O_WRONLY | O_CREAT, 0);
write(dest, magic_mount_list.data(), magic_mount_list.length());
close(dest);

chdir("/");
}

大致逻辑还是差不多的,只不过多了一些不同系统的处理(比如魅族设备需要特殊处理,这里因为 rootfs 仍然可写所以直接走 rootfs 逻辑就好了),还有由于 switch root 后 / 并不可写,所以所有对文件的修改都是先把修改过的文件放在 magisk internal tmpfs 内,然后 bind mount 到它原始的路径。
大致流程为:

  1. kernel 执行 init,magiskinit 被运行,检测系统环境,判断为 2SI 设备第一阶段,进入 FirstStageInit
  2. 挂载 /data 为 tmpfs,释放 /data/magiskinit
  3. 修补原始 init,重定向 /system/bin/init 到 /data/magiskinit,然后执行原始 init
  4. 原始 init 会挂载 /system 然后 switch root 进去,接下来它执行 /system/bin/init(被我们 patch 了所以会执行 /data/magiskinit)进行 selinux_setup
  5. magiskinit 被再次执行,看见 selinux_setup 知道这是二阶段,switch root 已经完成,开始 patch_ro_root()
  6. 修补 init.rc,注入我们的代码
  7. 修补 sepolicy,注入我们的规则(细节后面再谈)
  8. 利用 bind mount 替换 所有需要修改的文件
  9. 执行系统原来的 init,继续开机流程
  10. init 会解析并执行 init.rc,运行 magisk

Legacy SAR

终于讲到 Legacy SAR 这个坑人的玩意了。。。
这玩意坑人的地方在于:

  1. boot.img 内 ramdisk 正常情况不会用到,magiskinit 根本没机会执行。对于 A/B 设备,boot 里放着的是 recovery ramdisk,bootloader 通过向内核传递 skip_initramfs 来让内核知道该不该挂载 initramfs。Magisk 的解决办法是,安装的时候 patch 一下内核,把 skip_initramfs patch 成别的东西这样内核就认不出来 bootloader 传的是什么了,然后就会乖乖的挂载 initramfs。但对于 A-only 设备,boot.img 里根本没有 ramdisk,就算手动加一个 ramdisk,bootloader 也很有可能并不识别它,根本没办法插一脚。Magisk 表示臣妾无能为力,patch recovery.img 然后每次都重启到 recovery 用吧,要不然我也插不进去啊~
  2. 即使我们成功把 init 偷梁换柱成了 magiskinit,/ 也是 system.img,写不了,很难干活。

来看看 magiskinit 的代码吧:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/*************
* Legacy SAR
*************/

class LegacySARInit : public MagiskInit {
private:
bool mount_system_root();
void first_stage_prep();
public:
LegacySARInit(char *argv[], BootConfig *config) : MagiskInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
};
void start() override {
prepare_data();
bool is_two_stage = mount_system_root();
if (is_two_stage)
first_stage_prep();
else
patch_ro_root();
exec_init();
}
};

bool LegacySARInit::mount_system_root() {
LOGD("Mounting system_root\n");

// there's no /dev in stub cpio
xmkdir("/dev", 0777);

strcpy(blk_info.block_dev, "/dev/root");

do {
// Try legacy SAR dm-verity
strcpy(blk_info.partname, "vroot");
auto dev = setup_block();
if (dev > 0)
goto mount_root;

// Try NVIDIA naming scheme
strcpy(blk_info.partname, "APP");
dev = setup_block();
if (dev > 0)
goto mount_root;

sprintf(blk_info.partname, "system%s", config->slot);
dev = setup_block();
if (dev > 0)
goto mount_root;

// Poll forever if rootwait was given in cmdline
} while (config->rootwait);

// We don't really know what to do at this point...
LOGE("Cannot find root partition, abort\n");
exit(1);

mount_root:
xmkdir("/system_root", 0755);

if (xmount("/dev/root", "/system_root", "ext4", MS_RDONLY, nullptr)) {
if (xmount("/dev/root", "/system_root", "erofs", MS_RDONLY, nullptr)) {
// We don't really know what to do at this point...
LOGE("Cannot mount root partition, abort\n");
exit(1);
}
}

switch_root("/system_root");

// Make dev writable
xmount("tmpfs", "/dev", "tmpfs", 0, "mode=755");
mount_list.emplace_back("/dev");

// Use the apex folder to determine whether 2SI (Android 10+)
bool is_two_stage = access("/apex", F_OK) == 0;
LOGD("is_two_stage: [%d]\n", is_two_stage);

// For API 28 AVD, it uses legacy SAR setup that requires
// special hacks in magiskinit to work properly.
if (!is_two_stage && config->emulator) {
// AVD,不重要
}

return is_two_stage;
}

void LegacySARInit::first_stage_prep() {
// Patch init binary
int src = xopen("/init", O_RDONLY);
int dest = xopen("/data/init", O_CREAT | O_WRONLY, 0);
{
mmap_data init("/init");
for (size_t off : init.patch(INIT_PATH, REDIR_PATH)) {
LOGD("Patch @ %08zX [" INIT_PATH "] -> [" REDIR_PATH "]\n", off);
}
write(dest, init.buf(), init.sz());
fclone_attr(src, dest);
close(dest);
close(src);
}
xmount("/data/init", "/init", nullptr, MS_BIND, nullptr);
}

总结如下:

  1. magiskinit 被运行,检测系统环境,判断为 Legacy SAR,进入 LegacySARInit
  2. 因为 kernel 被我们 patch 了,原本 switch root 应该由 kernel 来做的,现在没有了,需要手动 mount /system 然后 switch root 进去
  3. 检测有没有 /apex,有的话代表 Android 10+,init 有 selinux_setup 阶段,可以劫持,走 first_stage_prep();没有的话直接 patch_ro_boot() 然后执行原来的 init
  4. first_stage_prep() 里会像 Modern 2SI 那样修补 init,将 /system/bin/init 重定向到 /data/magiskinit,然后把它挂载到 /init 以供后面执行;执行原来的 init。
  5. 原来的 init 执行 selinux_setup(若有),转到 magiskinit,magiskinit 识别到二阶段,自动 patch_ro_boot()
  6. patch_ro_boot() 完成之后,执行 init 交还控制权,继续开机

修补 SEPolicy

由于这一块过于复杂,所以单独放一节讲。

如果你仔细看了上面的代码,你会发现修补 SEPolicy 有 patch_sepolicy() 以及 hijack_sepolicy() 两个函数,那么它们有什么不同呢?

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
void MagiskInit::patch_sepolicy(const char *in, const char *out) {
LOGD("Patching monolithic policy\n");
auto sepol = unique_ptr<sepolicy>(sepolicy::from_file(in));

/** 修补 sepolicy **/
sepol->magisk_rules();

LOGD("Dumping sepolicy to: [%s]\n", out);
sepol->to_file(out);

// Remove OnePlus stupid debug sepolicy and use our own
if (access("/sepolicy_debug", F_OK) == 0) {
unlink("/sepolicy_debug");
link("/sepolicy", "/sepolicy_debug");
}
}

#define MOCK_COMPAT SELINUXMOCK "/compatible"
#define MOCK_LOAD SELINUXMOCK "/load"
#define MOCK_ENFORCE SELINUXMOCK "/enforce"

bool MagiskInit::hijack_sepolicy() {
xmkdir(SELINUXMOCK, 0);

if (access("/system/bin/init", F_OK) == 0) {
// On 2SI devices, the 2nd stage init file is always a dynamic executable.
// This meant that instead of going through convoluted methods trying to alter
// and block init's control flow, we can just LD_PRELOAD and replace the
// security_load_policy function with our own implementation.
dump_preload();
setenv("LD_PRELOAD", "/dev/preload.so", 1);
}

// Hijack the "load" and "enforce" node in selinuxfs to manipulate
// the actual sepolicy being loaded into the kernel
auto hijack = [&] {
LOGD("Hijack [" SELINUX_LOAD "]\n");
close(xopen(MOCK_LOAD, O_CREAT | O_RDONLY, 0600));
xmount(MOCK_LOAD, SELINUX_LOAD, nullptr, MS_BIND, nullptr);
LOGD("Hijack [" SELINUX_ENFORCE "]\n");
mkfifo(MOCK_ENFORCE, 0644);
xmount(MOCK_ENFORCE, SELINUX_ENFORCE, nullptr, MS_BIND, nullptr);
};

string dt_compat;
if (access(SELINUX_ENFORCE, F_OK) != 0) {
// selinuxfs not mounted yet. Hijack the dt fstab nodes first
// and let the original init mount selinuxfs for us.
// This only happens on Android 8.0 - 9.0

char buf[4096];
ssprintf(buf, sizeof(buf), "%s/fstab/compatible", config->dt_dir);
dt_compat = full_read(buf);
if (dt_compat.empty()) {
// Device does not do early mount and uses monolithic policy
return false;
}

// Remount procfs with proper options
xmount(nullptr, "/proc", nullptr, MS_REMOUNT, "hidepid=2,gid=3009");

LOGD("Hijack [%s]\n", buf);

// Preserve sysfs and procfs for hijacking
mount_list.erase(std::remove_if(
mount_list.begin(), mount_list.end(),
[](const string &s) { return s == "/proc" || s == "/sys"; }), mount_list.end());

mkfifo(MOCK_COMPAT, 0444);
xmount(MOCK_COMPAT, buf, nullptr, MS_BIND, nullptr);
} else {
hijack();
}

// Create a new process waiting for init operations
if (xfork()) {
// In parent, return and continue boot process
return true;
}

if (!dt_compat.empty()) {
// This open will block until init calls DoFirstStageMount
// The only purpose here is actually to wait for init to mount selinuxfs for us
int fd = xopen(MOCK_COMPAT, O_WRONLY);

char buf[4096];
ssprintf(buf, sizeof(buf), "%s/fstab/compatible", config->dt_dir);
xumount2(buf, MNT_DETACH);

hijack();

xwrite(fd, dt_compat.data(), dt_compat.size());
close(fd);
}

// This open will block until init calls security_getenforce
int fd = xopen(MOCK_ENFORCE, O_WRONLY);

// Cleanup the hijacks
umount2("/init", MNT_DETACH);
xumount2(SELINUX_LOAD, MNT_DETACH);
xumount2(SELINUX_ENFORCE, MNT_DETACH);

// Load and patch policy
auto sepol = unique_ptr<sepolicy>(sepolicy::from_file(MOCK_LOAD));
sepol->magisk_rules();
sepol->load_rules(rules);

// Load patched policy into kernel
sepol->to_file(SELINUX_LOAD);

// Write to the enforce node ONLY after sepolicy is loaded. We need to make sure
// the actual init process is blocked until sepolicy is loaded, or else
// restorecon will fail and re-exec won't change context, causing boot failure.
// We (ab)use the fact that init reads the enforce node, and because
// it has been replaced with our FIFO file, init will block until we
// write something into the pipe, effectively hijacking its control flow.

string enforce = full_read(SELINUX_ENFORCE);
xwrite(fd, enforce.data(), enforce.length());
close(fd);

// At this point, the init process will be unblocked
// and continue on with restorecon + re-exec.

// Terminate process
exit(0);
}

最直观的感觉就是,两个函数代码量不一样……

Android 上,sepolicy 可以以单个的 sepolicy 文件的形式存储,也可以存储多个拆分的 cil 文件;前者被称为 monolithic policy,后者被称为 split sepolicy。Linux 内核只接受 monolithic policy,所以加载 split sepolicy 时,由 init 进程负责将 cil 文件编译为 monolithic policy 并加载进内核。

对于 monolithic policy,magiskinit patch 起来非常轻松惬意:直接加载 /sepolicy,修改,放回去,然后 init 加载的时候就会加载我们修改过的 sepolicy。

而对于 split policy 可就麻烦了,init 运行之前可能根本没有 /sepolicy 给你 patch,怎么办呢?因为 init 会把 cil 全部编译成 monolithic policy 然后再传进内核里(具体是往 /sys/fs/selinux/load 写入),要是有方法拦截这个写入过程就好了!

有吗?还真有。

先快速介绍一下 FIFO 的概念:FIFO 看起来就像一个普通的文件,但是它用起来类似 pipe,就是一根管子,当一个进程往里写内容的时候,另一个进程能读取到。反过来,一个进程读它的时候,默认会阻塞,直到有另外的进程打开了这个 FIFO 并且写入内容。说白了就是一个专门用来进程间通信的管子,只不过长得是个文件而已。

Magisk 的 hijack_policy() 正是利用了 FIFO 的特性,才能拦截到 init 加载 sepolicy 的过程。
具体来说,大致分为以下几步:

  1. 判断 selinuxfs 有没有被挂载,如果没有需要先等 init 挂载 selinuxfs。这里我们同样利用 FIFO,选择了一个 init 挂载完 selinuxfs 之后会读取的文件,来卡住时机。里面的注释 This only happens on Android 8.0 - 9.0 其实是因为更旧的版本没有 split policy,直接 patch monolithic policy 就好了,不用走 hijack;而 Android 10+ init 始终分为三步,我们永远在 selinux_setup 时进行 hijack,此时 first stage 已经走完了,selinuxfs 肯定已经被 init mount 上了;只有 Android 8.0-9.0,有可能有 split policy 又没有单独的 selinux_setup 这一步可以拦截,所以只能手动进行特殊处理。
  2. 用 bind mount 把 /sys/fs/selinux/load 覆盖成一个普通的文件,这样 init 往里写入 sepolicy 时其实是写进了我们的文件,而不是内核里
  3. 调用 mkfifo() 创建一个 FIFO,然后 bind mount 到 /sys/fs/selinux/enforce 上面
  4. fork 一个子进程,父进程继续执行剩余步骤,然后执行原始 init;子进程会以 write-only 的方式打开这个 FIFO,此时因为 FIFO 的特性,这个进程会阻塞,直到有另一个进程以 read 方式打开同样的 FIFO。在我们的例子当中,子进程会阻塞,直到 init 调用 security_getenforce() 读取了 /sys/fs/selinux/enforce,此时 init 阻塞,等待我们的子进程往 FIFO 内写入内容,而此时 init 已经把要加载的 sepolicy 写入到了 /sys/fs/selinux/load
  5. 读取 init 原本要加载的 sepolicy,进行 patch 注入自己的规则,然后手动加载进内核里(因为之前 init 尝试加载的时候被我们给拦了)
  6. 往 FIFO 写入内容,唤醒对端的 init 进程,做一些 cleanup 然后可以退出子进程。

虽然整个 hijack 还使用了 LD_PRELOAD 等技术来保证 sepolicy hijack 能够正常工作,但大致流程就是如上所示。整个机制非常精妙,非常依赖 init 的实际行为,需要结合 android init 及 libselinux 的源码反复琢磨才能搞清其中逻辑。


之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

2024-05-01

Framework

CVE-2024-0024 EoP High
创建用户时限制将用户名、账号名、账号类型这三个字符串的长度,否则长度可能会超出 BinaryXmlSerializer 的写入限制,导致后续 user restrictions 写入失败,创建出没有限制的用户。
疑问:我在今年1月4号就已经在 aosp 上看见了这个提交,为什么现在才放在安全公告里?

CVE-2024-0025 EoP High
发送非 PendingIntent 类型的 IntentSender 时避免携带 allowlist token。因为这个时候传入的 IIntentSender 实际上可能是 app 的一个 binder,再在上面调用 send 实际上相当于把 allowlist token 直接发送给了 app,然后这个 token 可以用来绕过很多的后台限制。
大概猜测一下利用方法:
创建一个自定义 IIntentSender
把它塞进一个 PendingIntent 里(PendingIntent 存放着一个 IIntentSender,这是它的真正实现)
把 PendingIntent 放进 notification deleteIntent 里
发送 notification 然后立刻取消,触发 deleteIntent 执行
PendingIntent.send() 把内部的 IIntentSender 传递到 sendIntentSender,因为这个 IIntentSender 不是 PendingIntentRecord,所以 IIntentSender.send() 被调用,此时会直接把 allowlist token 发给 app
妙啊,太妙了
疑问:1月4号我也看见了这个补丁,为什么现在才发出来?

CVE-2024-23705 EoP High
在 CVE-2024-0024 补丁的基础上限制 accountOptions 的大小,防止类似的问题。

CVE-2024-23708 EoP High
让系统发出的 toast 优先显示,保证其他应用不能通过发送大量 toast 的方式延后系统 toast 显示。同时把粘贴剪贴板文字的 toast 的显示时长加到 long。

System

(跳过 CVE-2024-23709)

CVE-2024-23706 EoP Critical
android health 相关的功能,禁止插入 record types 为空的 changelog。对这模块不是很熟,没看懂能干啥,也没看懂为啥定级这么高,待进一步分析。CVE 描述:In multiple locations, there is a possible bypass of health data permissions due to an improper input validation. This could lead to local escalation of privilege with no additional execution privileges needed. User interaction is not needed for exploitation.

CVE-2024-0043 EoP High
在 PermissionController 后端禁止给 work profile 中的应用授权监听通知。

CVE-2024-23707 EoP High
系统设置里 SearchResultTrampoline 把原来的用 getCallingActivity 判断调用者改成用 getLaunchedFromPackage,前者返回的是哪个 activity 会收到 onActivityResult 回调,后者返回的是实际启动 Activity 的人(Pending intent 按创建者算)

2024-04-01

这次补丁给我的感觉就是,像愚人节特辑

Framework

CVE-2024-23710 EoP High
如果一个 app 和特权 app 共享 UID,并且设置了 sharedUserMaxSdkVersion 且处于生效状态,那么 sharedUserId 的设置是无效的,这个时候不应该把它标记为特权 app

CVE-2024-23713 EoP High
NotificationManagerService migrateNotificationFilter 内如果传入的 disallowApps 并没有被安装就跳过它。猜测问题是可以传入超长字符串阻止 mRequestedNotificationListeners 被序列化,从而阻止数据变更被保存?

CVE-2024-0022 ID High
CompanionDeviceManagerService requestNotificationAccess 没有检查传入的 user id,而是留了个 TODO 在那里(写到一半想着先这样完事然后就忘了?),然后直接用这个 user id 取得一个 PendingIntent 并返回,然后发送这个 pending intent 实际上可以跳到对应用户的通知访问管理页面。这个问题我很早就注意到了,但是反馈被标 duplicate 了。

CVE-2024-23712 DoS High
AppOpsService 里过滤未被定义的 proxyAttributionTag (这啥?),同时把一个包里最大能定义的标签的数量从 10000 下调到 1000,防止大量数据造成 DoS。有待进一步分析。

System

CVE-2024-23704 EoP High
如果当前用户有 DISALLOW_ADD_WIFI_CONFIG 这个限制,不允许打开 WifiDialogActivity。

CVE-2023-21267 ID High
去年12月的补丁,原问题是有 app pining 的时候尝试按电源键 lockdown 会因为逻辑错误跳过锁屏。原来的补丁有问题,回滚了之后修复了问题重新提交。

CVE-2024-0026/CVE-2024-0027 DoS High
大量 snoozed notifications 可能导致的拒绝服务问题(这个问题不是修过几次吗怎么又来了?)。判断 snooze notification 数量是否超限的时候要把已经存进文件里的(mPersistedSnoozedNotifications 和 mPersistedSnoozedNotificationsWithContext)一并纳入计算。还修复了 repostGroupSummary 方法里没有把通知从 mPersistedSnoozedNotifications 和 mPersistedSnoozedNotificationsWithContext 里移除的问题,会导致不需要的通知数据始终被序列化,可能被滥用导致资源耗尽拒绝服务。

2024-03-01

Framework

(跳过了 CVE-2024-0049/CVE-2024-0050/CVE-2024-0051,三个都是 libstagefright 中的内存错误)

CVE-2024-0044 EoP High
过滤应用的“安装者包名”(可以通过 pm install -i 指定)里的非法字符。此漏洞细节:攻击者可以在“安装者包名”内插入换行符,之后系统会把该字段原样写进 /data/system/packages.list 里,伪造出新的条目,之后可以通过伪造的条目欺骗 run-as 从而获得受害应用的权限,可以操作内部目录等。
发现者的博客写的很清楚了:https://rtx.meta.security/exploitation/2024/03/04/Android-run-as-forgery.html
数据取证专用漏洞了属于是

CVE-2024-0046 EoP High
阻止系统应用被升级为 instant app(免安装应用 谷歌小程序)。免安装应用只能获得很小的一部分权限,攻击者可以利用该漏洞使特定系统应用的权限失效,从而绕过运营商限制。

CVE-2024-0048 EoP High
AccountManagerService 又上新补丁了。AccountManagerService 内部有一个超时机制,bind 到 Authenticator 后如果它长时间没响应就 unbind。搞笑的是,代码里根本没有任何地方启动这个机制,整个超时机制根本不会被触发。AccountManager API 最初在 Android 2.1 里加入,十几年过去了还能有这种问题,提前预定一个 2024 年最搞笑漏洞。

CVE-2024-0053 ID High
如果打印服务返回的自定义的打印机图标里的 URI 指向其他用户就把它过滤掉,防止看见其他用户的图像。最近这种漏洞真的好多好多。

CVE-2024-0047 DoS High
Android 14 中对设备管理(Android for Work 的功能)的重构中引入的 bug,设备管理员设置的 global restriction(对整个设备的限制)可能会被错误识别为 local restriction(对单个用户的限制),导致管理员无法撤销限制,设备功能受限。就算 bug 修好了,已经损坏的配置文件仍然是损坏的,为了让这些设备不清除数据就能正常工作还要加个 workaround。

System

(跳过了 CVE-2024-0039、CVE-2024-23717、CVE-2024-0045)

CVE-2023-40081 ID High
SystemUI 加载多媒体文件的图标时检查 URI 权限,防止跨用户读取

CVE-2024-0052 ID High
对 Android Health 这个功能不是很熟,看提交信息是如果调用者没有权限,部分 API 返回的数据要去除掉用户运动的路线信息。

2024-02-01

Framework

CVE-2024-0029 EoP High
DevicePolicyManagerService 里的逻辑 bug,保存禁止截屏的状态的时候只用了一个 int 保存被禁用的用户 id,在未受管用户启动的时候会覆盖掉之前设置的。

CVE-2024-0032 EoP High
DownloadStorageProvider 正确重写带有 includeHidden 参数的 queryChildDocuments()。
更多可参考 https://t.me/qianqianzhuang/4

CVE-2024-0034 EoP High
阻止壁纸服务从后台启动 activity。因为前台客户端绑定壁纸服务之后,会满足这一条豁免条件 “该应用的一项服务被另一个可见的应用绑定。绑定到该服务的应用必须保持可见,以便后台应用成功启动 activity。”
安卓 14 的 BAL 强化中限制了 target>=U 时必须指定 BIND_ALLOW_ACTIVITY_STARTS 才允许服务进程借用客户进程的状态启动,故该漏洞不影响 14

CVE-2024-0036 EoP High
startNextMatchingActivity 中设置 setAvoidMoveToFront 阻止后台启动 activity,具体可见 https://mp.weixin.qq.com/s/dbupJ3D-i0Da9ml1Pamu-w
这个接口漏了这么久都没人注意到确实挺难得的,当然我也没注意到,但是你看别人都没注意到,所以也不能怪我,你说是吧

CVE-2024-0038 EoP High
injectInputEventToInputFilter 中缺失调用者权限检查,只影响 14

CVE-2024-0041 EoP High
removePersistentDot 里的竞态条件,有可能导致 persistent dot 不会移除(然后会覆盖新显示的状态?)

CVE-2023-40122 & CVE-2024-0037 EoP High
检查自动填充相关传入的 URI,确保不会读取到其他用户的数据

CVE-2024-0040 ID High
MTP 协议处理中没有正确增大缓冲区大小,可能发生越界读

System

CVE-2024-0031 RCE Critical
蓝牙 GATT 协议栈里的越界写,理论可实现 RCE。这玩意真的几乎每个月都能搞一个 RCE 出来。

CVE-2024-0014 EoP High
该漏洞未公开补丁链接,似乎是 GMS 私有组件。漏洞描述:In startInstall of UpdateFetcher.java, there is a possible way to trigger a malicious config update due to a logic error. This could lead to local escalation of privilege with no additional execution privileges needed. User interaction is not needed for exploitation.

CVE-2024-0033 EoP High
libbinder 打开 memfd 时指定 F_SEAL_GROW | F_SEAL_SHRINK 阻止后续其他进程更改大小造成错误

CVE-2024-0035 EoP High
TileService 返回 null 时正确释放资源,阻止绕过后台启动前台 activity 的限制。更多可以参考 https://wrlus.com/android-security/bindservice-error-handle/

CVE-2023-40093 ID High
升级 pdfium 到 Chrome 114.0.5735.130 的

CVE-2024-0030 ID High
蓝牙组件里不充分的检查可能造成越界读

2024-01-01

Framework

CVE-2023-21245 EoP High
阻止在设备设置向导完成之前进入锁屏,可以插入一张需要 PIN/PUK 码解锁的 SIM 卡复现。应该是可以绕过恢复出厂保护(FRP,Factory Reset Protection),也就是所谓的激活锁。

CVE-2024-0015 EoP High
保证 DreamService 配置的 settingsActivity 属于应用自身,防止调用到非公开的 activity 造成 launch anywhere。向上搜索可以发现 SettingsLib 的 DreamBackend launchSettings() 这个方法会直接 startActivity,这里在 Settings 里运行所以是 system 权限。
题外话:DreamService 是 Android 4.2 加入的,十几年过去了,相关代码在2024年1月还能出现这么简单直接的漏洞确实有点不可思议。这个补丁早在2022年就创建了,而且更新的版本列表里并没有最新的14,于是我去看了下14的代码发现确实已经合进了这个补丁,然后我搜索这个补丁,发现这个漏洞早在2022年就被确认并被授予 CVE-2022-20550 这个编号,但是谷歌当时不知道怎么想的只给了 moderate 评级然后只给 pixel 修了。。。

CVE-2024-0018 EoP High
libstagefright 里无符号数运算不当导致意外结果的漏洞。

CVE-2024-0019 ID High
System UI 进程重启后,如果在重启之前有应用在进行敏感操作,并且现在还没结束,那么仍然应该显示隐私指示器。

System

CVE-2024-0021 EoP High
禁止工作空间(work profile)内的应用监听通知,只给 13 14 更新了,应该是以前正常的功能,大版本迭代不小心引入。

CVE-2024-0017 ID High
Camera2 如果是被其他 app 启动,而且启动它的这个 app 没有位置权限,返回的照片 exif 信息里不应该包含位置信息。之前的代码里有做判断,但是有问题,getCallingPackage 返回 null 的时候一律判定为不是 app 启动不需要过滤,但是如果 app 用 startActivity 而非 startActivityForResult ,getCallingPackage 本来就会返回 null,算是逻辑 bug。

CVE-2024-0020 ID High
设置铃声的时候检查传入的 URI,防止指向别的用户的数据。最近这种问题似乎很多很多。

2023-12-01

可能在野被积极利用的漏洞:CVE-2023-33063、CVE-2023-33107、CVE-2023-33106

Framework

CVE-2023-40077 EOP Critical
libstagefright 里的 UAF 问题

CVE-2023-40076 ID Critical
CredentialManagerUi 跑在 system_server,且创建 PendingIntent 的时候没有指定用户,默认会去用 context 对应的用户,然后指向 USER 0,其他用户拿到这个 PendingIntent 然后 send 就能跳转到主用户的凭据管理页面。不知道为什么定级这么高

CVE-2023-40079 EoP High
ShortcutService 启动 app 给出的 IntentSender 时指定 MODE_BACKGROUND_ACTIVITY_START_DENIED 防止 BAL

CVE-2023-40089 EoP High
DevicePolicyService.getCredentialManagerPolicy 需要传入 userId。感觉这个漏洞的实际影响情况可能是有跨用户权限的进程需要拿到其他用户的东西但是因为之前的设计问题所以拿不到?

CVE-2023-40094 EoP High
keyguardGoingAway 这个方法没检查调用者权限,然后所有 app 都能调用这个方法解除锁屏。

CVE-2023-40095 EoP High
又是一个忘了指定禁止 BAL 造成的 BAL bypass,这次是 requestGeofence 。

CVE-2023-40096 EoP High
更改的文件比较多,看起来是设置录音权限为仅前台时应用退到后台还会继续录音的问题。

CVE-2023-40103 EoP High
看提交信息是用 ApkAssets 的时候改成用基于引用计数的指针,防止 UAF/double free

CVE-2023-45774 EoP High
ShortcutService 校验传入的 uri。看 cve 描述是能指定其他用户的图像。

CVE-2023-45777 EoP High
最看不懂的一集
给了两个提交,第一个提交和九月份的 CVE-2023-35669 补丁是一样的,当时这个漏洞描述是 “there is a possible way to control other running activities”,第二个提交只是把弃用不带类型的 getParcelable 改成类型安全的,然而底下明明就有手动的类型检查,我觉得缺这个提交也没法利用,不知道为什么又分配了一个 CVE。看这个 CVE 的描述是能 Launch Anywhere ,不知道怎么能做到。

AccountManagerService 里使用不带参数类型的 getParcelable() 方法可能会导致反序列化出非预期的对象,调用到其他类的构造方法,导致 parcel 数据被修改,绕过 checkKeyIntentParceledCorrectly 的缓解措施。PoC: https://github.com/michalbednarski/TheLastBundleMismatch

CVE-2023-21267 ID High
在 lockdown 模式始终展示锁屏。看起来是有 app pinning 的时候尝试 lockdown 会因为逻辑错误跳过显示锁屏。

CVE-2023-40073 ID High
Notification style 相关的 uri 校验

CVE-2023-40081 ID High
MediaDataManager URI 校验。截止目前已经是第三个了。

CVE-2023-40092 ID High
ShortcutService 多个函数检查 user id,防止跨用户操作

CVE-2023-40074 DoS High
PersistableBundle 忽略无效数据,防止拒绝服务。

CVE-2023-40075 DoS High
限制能通过 addDynamicShortcuts 添加的快捷方式的数量。

System

这里面大部分洞都是比较传统的内存损坏,所以跳过了很多漏洞。

CVE-2023-40088 RCE Critical
蓝牙相关,com_android_bluetooth_btservice_AdapterService free callbackEnv 这个指针后没有置空,可能发生 UAF。不知道是不是我的错觉,总感觉近几个月能 RCE 的漏洞好像变多了。

CVE-2023-40097 EoP High
禁止创建目标为 Chooser 的快捷方式。没看懂有什么意义,看 CVE 描述是 URI grant。

CVE-2023-21394 ID High
添加 phone account 时检查给定的 URI,防止跨用户读取图片。嗯,第四个了。

CVE-2023-35668 ID High
又是 URI 相关的跨用户读取,这次还是 Notification 里……

CVE-2023-40098 ID High
看起来是有个叫 priority conversation widget 的东西,其他用户添加这个 widget 能看见主用户的通知。解决方法就是不让其他用户添加。


之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

2024-05-01

Framework

CVE-2024-0024 EoP High
创建用户时限制将用户名、账号名、账号类型这三个字符串的长度,否则长度可能会超出 BinaryXmlSerializer 的写入限制,导致后续 user restrictions 写入失败,创建出没有限制的用户。
疑问:我在今年1月4号就已经在 aosp 上看见了这个提交,为什么现在才放在安全公告里?

CVE-2024-0025 EoP High
发送非 PendingIntent 类型的 IntentSender 时避免携带 allowlist token。因为这个时候传入的 IIntentSender 实际上可能是 app 的一个 binder,再在上面调用 send 实际上相当于把 allowlist token 直接发送给了 app,然后这个 token 可以用来绕过很多的后台限制。
大概猜测一下利用方法:
创建一个自定义 IIntentSender
把它塞进一个 PendingIntent 里(PendingIntent 存放着一个 IIntentSender,这是它的真正实现)
把 PendingIntent 放进 notification deleteIntent 里
发送 notification 然后立刻取消,触发 deleteIntent 执行
PendingIntent.send() 把内部的 IIntentSender 传递到 sendIntentSender,因为这个 IIntentSender 不是 PendingIntentRecord,所以 IIntentSender.send() 被调用,此时会直接把 allowlist token 发给 app
妙啊,太妙了
疑问:1月4号我也看见了这个补丁,为什么现在才发出来?

CVE-2024-23705 EoP High
在 CVE-2024-0024 补丁的基础上限制 accountOptions 的大小,防止类似的问题。

CVE-2024-23708 EoP High
让系统发出的 toast 优先显示,保证其他应用不能通过发送大量 toast 的方式延后系统 toast 显示。同时把粘贴剪贴板文字的 toast 的显示时长加到 long。

System

(跳过 CVE-2024-23709)

CVE-2024-23706 EoP Critical
android health 相关的功能,禁止插入 record types 为空的 changelog。对这模块不是很熟,没看懂能干啥,也没看懂为啥定级这么高,待进一步分析。CVE 描述:In multiple locations, there is a possible bypass of health data permissions due to an improper input validation. This could lead to local escalation of privilege with no additional execution privileges needed. User interaction is not needed for exploitation.

CVE-2024-0043 EoP High
在 PermissionController 后端禁止给 work profile 中的应用授权监听通知。

CVE-2024-23707 EoP High
系统设置里 SearchResultTrampoline 把原来的用 getCallingActivity 判断调用者改成用 getLaunchedFromPackage,前者返回的是哪个 activity 会收到 onActivityResult 回调,后者返回的是实际启动 Activity 的人(Pending intent 按创建者算)

2024-04-01

这次补丁给我的感觉就是,像愚人节特辑

Framework

CVE-2024-23710 EoP High
如果一个 app 和特权 app 共享 UID,并且设置了 sharedUserMaxSdkVersion 且处于生效状态,那么 sharedUserId 的设置是无效的,这个时候不应该把它标记为特权 app

CVE-2024-23713 EoP High
NotificationManagerService migrateNotificationFilter 内如果传入的 disallowApps 并没有被安装就跳过它。猜测问题是可以传入超长字符串阻止 mRequestedNotificationListeners 被序列化,从而阻止数据变更被保存?

CVE-2024-0022 ID High
CompanionDeviceManagerService requestNotificationAccess 没有检查传入的 user id,而是留了个 TODO 在那里(写到一半想着先这样完事然后就忘了?),然后直接用这个 user id 取得一个 PendingIntent 并返回,然后发送这个 pending intent 实际上可以跳到对应用户的通知访问管理页面。这个问题我很早就注意到了,但是反馈被标 duplicate 了。

CVE-2024-23712 DoS High
AppOpsService 里过滤未被定义的 proxyAttributionTag (这啥?),同时把一个包里最大能定义的标签的数量从 10000 下调到 1000,防止大量数据造成 DoS。有待进一步分析。

System

CVE-2024-23704 EoP High
如果当前用户有 DISALLOW_ADD_WIFI_CONFIG 这个限制,不允许打开 WifiDialogActivity。

CVE-2023-21267 ID High
去年12月的补丁,原问题是有 app pining 的时候尝试按电源键 lockdown 会因为逻辑错误跳过锁屏。原来的补丁有问题,回滚了之后修复了问题重新提交。

CVE-2024-0026/CVE-2024-0027 DoS High
大量 snoozed notifications 可能导致的拒绝服务问题(这个问题不是修过几次吗怎么又来了?)。判断 snooze notification 数量是否超限的时候要把已经存进文件里的(mPersistedSnoozedNotifications 和 mPersistedSnoozedNotificationsWithContext)一并纳入计算。还修复了 repostGroupSummary 方法里没有把通知从 mPersistedSnoozedNotifications 和 mPersistedSnoozedNotificationsWithContext 里移除的问题,会导致不需要的通知数据始终被序列化,可能被滥用导致资源耗尽拒绝服务。

2024-03-01

Framework

(跳过了 CVE-2024-0049/CVE-2024-0050/CVE-2024-0051,三个都是 libstagefright 中的内存错误)

CVE-2024-0044 EoP High
过滤应用的“安装者包名”(可以通过 pm install -i 指定)里的非法字符。此漏洞细节:攻击者可以在“安装者包名”内插入换行符,之后系统会把该字段原样写进 /data/system/packages.list 里,伪造出新的条目,之后可以通过伪造的条目欺骗 run-as 从而获得受害应用的权限,可以操作内部目录等。
发现者的博客写的很清楚了:https://rtx.meta.security/exploitation/2024/03/04/Android-run-as-forgery.html
数据取证专用漏洞了属于是

CVE-2024-0046 EoP High
阻止系统应用被升级为 instant app(免安装应用 谷歌小程序)。免安装应用只能获得很小的一部分权限,攻击者可以利用该漏洞使特定系统应用的权限失效,从而绕过运营商限制。

CVE-2024-0048 EoP High
AccountManagerService 又上新补丁了。AccountManagerService 内部有一个超时机制,bind 到 Authenticator 后如果它长时间没响应就 unbind。搞笑的是,代码里根本没有任何地方启动这个机制,整个超时机制根本不会被触发。AccountManager API 最初在 Android 2.1 里加入,十几年过去了还能有这种问题,提前预定一个 2024 年最搞笑漏洞。

CVE-2024-0053 ID High
如果打印服务返回的自定义的打印机图标里的 URI 指向其他用户就把它过滤掉,防止看见其他用户的图像。最近这种漏洞真的好多好多。

CVE-2024-0047 DoS High
Android 14 中对设备管理(Android for Work 的功能)的重构中引入的 bug,设备管理员设置的 global restriction(对整个设备的限制)可能会被错误识别为 local restriction(对单个用户的限制),导致管理员无法撤销限制,设备功能受限。就算 bug 修好了,已经损坏的配置文件仍然是损坏的,为了让这些设备不清除数据就能正常工作还要加个 workaround。

System

(跳过了 CVE-2024-0039、CVE-2024-23717、CVE-2024-0045)

CVE-2023-40081 ID High
SystemUI 加载多媒体文件的图标时检查 URI 权限,防止跨用户读取

CVE-2024-0052 ID High
对 Android Health 这个功能不是很熟,看提交信息是如果调用者没有权限,部分 API 返回的数据要去除掉用户运动的路线信息。

2024-02-01

Framework

CVE-2024-0029 EoP High
DevicePolicyManagerService 里的逻辑 bug,保存禁止截屏的状态的时候只用了一个 int 保存被禁用的用户 id,在未受管用户启动的时候会覆盖掉之前设置的。

CVE-2024-0032 EoP High
DownloadStorageProvider 正确重写带有 includeHidden 参数的 queryChildDocuments()。
更多可参考 https://t.me/qianqianzhuang/4

CVE-2024-0034 EoP High
阻止壁纸服务从后台启动 activity。因为前台客户端绑定壁纸服务之后,会满足这一条豁免条件 “该应用的一项服务被另一个可见的应用绑定。绑定到该服务的应用必须保持可见,以便后台应用成功启动 activity。”
安卓 14 的 BAL 强化中限制了 target>=U 时必须指定 BIND_ALLOW_ACTIVITY_STARTS 才允许服务进程借用客户进程的状态启动,故该漏洞不影响 14

CVE-2024-0036 EoP High
startNextMatchingActivity 中设置 setAvoidMoveToFront 阻止后台启动 activity,具体可见 https://mp.weixin.qq.com/s/dbupJ3D-i0Da9ml1Pamu-w
这个接口漏了这么久都没人注意到确实挺难得的,当然我也没注意到,但是你看别人都没注意到,所以也不能怪我,你说是吧

CVE-2024-0038 EoP High
injectInputEventToInputFilter 中缺失调用者权限检查,只影响 14

CVE-2024-0041 EoP High
removePersistentDot 里的竞态条件,有可能导致 persistent dot 不会移除(然后会覆盖新显示的状态?)

CVE-2023-40122 & CVE-2024-0037 EoP High
检查自动填充相关传入的 URI,确保不会读取到其他用户的数据

CVE-2024-0040 ID High
MTP 协议处理中没有正确增大缓冲区大小,可能发生越界读

System

CVE-2024-0031 RCE Critical
蓝牙 GATT 协议栈里的越界写,理论可实现 RCE。这玩意真的几乎每个月都能搞一个 RCE 出来。

CVE-2024-0014 EoP High
该漏洞未公开补丁链接,似乎是 GMS 私有组件。漏洞描述:In startInstall of UpdateFetcher.java, there is a possible way to trigger a malicious config update due to a logic error. This could lead to local escalation of privilege with no additional execution privileges needed. User interaction is not needed for exploitation.

CVE-2024-0033 EoP High
libbinder 打开 memfd 时指定 F_SEAL_GROW | F_SEAL_SHRINK 阻止后续其他进程更改大小造成错误

CVE-2024-0035 EoP High
TileService 返回 null 时正确释放资源,阻止绕过后台启动前台 activity 的限制。更多可以参考 https://wrlus.com/android-security/bindservice-error-handle/

CVE-2023-40093 ID High
升级 pdfium 到 Chrome 114.0.5735.130 的

CVE-2024-0030 ID High
蓝牙组件里不充分的检查可能造成越界读

2024-01-01

Framework

CVE-2023-21245 EoP High
阻止在设备设置向导完成之前进入锁屏,可以插入一张需要 PIN/PUK 码解锁的 SIM 卡复现。应该是可以绕过恢复出厂保护(FRP,Factory Reset Protection),也就是所谓的激活锁。

CVE-2024-0015 EoP High
保证 DreamService 配置的 settingsActivity 属于应用自身,防止调用到非公开的 activity 造成 launch anywhere。向上搜索可以发现 SettingsLib 的 DreamBackend launchSettings() 这个方法会直接 startActivity,这里在 Settings 里运行所以是 system 权限。
题外话:DreamService 是 Android 4.2 加入的,十几年过去了,相关代码在2024年1月还能出现这么简单直接的漏洞确实有点不可思议。这个补丁早在2022年就创建了,而且更新的版本列表里并没有最新的14,于是我去看了下14的代码发现确实已经合进了这个补丁,然后我搜索这个补丁,发现这个漏洞早在2022年就被确认并被授予 CVE-2022-20550 这个编号,但是谷歌当时不知道怎么想的只给了 moderate 评级然后只给 pixel 修了。。。

CVE-2024-0018 EoP High
libstagefright 里无符号数运算不当导致意外结果的漏洞。

CVE-2024-0019 ID High
System UI 进程重启后,如果在重启之前有应用在进行敏感操作,并且现在还没结束,那么仍然应该显示隐私指示器。

System

CVE-2024-0021 EoP High
禁止工作空间(work profile)内的应用监听通知,只给 13 14 更新了,应该是以前正常的功能,大版本迭代不小心引入。

CVE-2024-0017 ID High
Camera2 如果是被其他 app 启动,而且启动它的这个 app 没有位置权限,返回的照片 exif 信息里不应该包含位置信息。之前的代码里有做判断,但是有问题,getCallingPackage 返回 null 的时候一律判定为不是 app 启动不需要过滤,但是如果 app 用 startActivity 而非 startActivityForResult ,getCallingPackage 本来就会返回 null,算是逻辑 bug。

CVE-2024-0020 ID High
设置铃声的时候检查传入的 URI,防止指向别的用户的数据。最近这种问题似乎很多很多。

2023-12-01

可能在野被积极利用的漏洞:CVE-2023-33063、CVE-2023-33107、CVE-2023-33106

Framework

CVE-2023-40077 EOP Critical
libstagefright 里的 UAF 问题

CVE-2023-40076 ID Critical
CredentialManagerUi 跑在 system_server,且创建 PendingIntent 的时候没有指定用户,默认会去用 context 对应的用户,然后指向 USER 0,其他用户拿到这个 PendingIntent 然后 send 就能跳转到主用户的凭据管理页面。不知道为什么定级这么高

CVE-2023-40079 EoP High
ShortcutService 启动 app 给出的 IntentSender 时指定 MODE_BACKGROUND_ACTIVITY_START_DENIED 防止 BAL

CVE-2023-40089 EoP High
DevicePolicyService.getCredentialManagerPolicy 需要传入 userId。感觉这个漏洞的实际影响情况可能是有跨用户权限的进程需要拿到其他用户的东西但是因为之前的设计问题所以拿不到?

CVE-2023-40094 EoP High
keyguardGoingAway 这个方法没检查调用者权限,然后所有 app 都能调用这个方法解除锁屏。

CVE-2023-40095 EoP High
又是一个忘了指定禁止 BAL 造成的 BAL bypass,这次是 requestGeofence 。

CVE-2023-40096 EoP High
更改的文件比较多,看起来是设置录音权限为仅前台时应用退到后台还会继续录音的问题。

CVE-2023-40103 EoP High
看提交信息是用 ApkAssets 的时候改成用基于引用计数的指针,防止 UAF/double free

CVE-2023-45774 EoP High
ShortcutService 校验传入的 uri。看 cve 描述是能指定其他用户的图像。

CVE-2023-45777 EoP High
最看不懂的一集
给了两个提交,第一个提交和九月份的 CVE-2023-35669 补丁是一样的,当时这个漏洞描述是 “there is a possible way to control other running activities”,第二个提交只是把弃用不带类型的 getParcelable 改成类型安全的,然而底下明明就有手动的类型检查,我觉得缺这个提交也没法利用,不知道为什么又分配了一个 CVE。看这个 CVE 的描述是能 Launch Anywhere ,不知道怎么能做到。

AccountManagerService 里使用不带参数类型的 getParcelable() 方法可能会导致反序列化出非预期的对象,调用到其他类的构造方法,导致 parcel 数据被修改,绕过 checkKeyIntentParceledCorrectly 的缓解措施。PoC: https://github.com/michalbednarski/TheLastBundleMismatch

CVE-2023-21267 ID High
在 lockdown 模式始终展示锁屏。看起来是有 app pinning 的时候尝试 lockdown 会因为逻辑错误跳过显示锁屏。

CVE-2023-40073 ID High
Notification style 相关的 uri 校验

CVE-2023-40081 ID High
MediaDataManager URI 校验。截止目前已经是第三个了。

CVE-2023-40092 ID High
ShortcutService 多个函数检查 user id,防止跨用户操作

CVE-2023-40074 DoS High
PersistableBundle 忽略无效数据,防止拒绝服务。

CVE-2023-40075 DoS High
限制能通过 addDynamicShortcuts 添加的快捷方式的数量。

System

这里面大部分洞都是比较传统的内存损坏,所以跳过了很多漏洞。

CVE-2023-40088 RCE Critical
蓝牙相关,com_android_bluetooth_btservice_AdapterService free callbackEnv 这个指针后没有置空,可能发生 UAF。不知道是不是我的错觉,总感觉近几个月能 RCE 的漏洞好像变多了。

CVE-2023-40097 EoP High
禁止创建目标为 Chooser 的快捷方式。没看懂有什么意义,看 CVE 描述是 URI grant。

CVE-2023-21394 ID High
添加 phone account 时检查给定的 URI,防止跨用户读取图片。嗯,第四个了。

CVE-2023-35668 ID High
又是 URI 相关的跨用户读取,这次还是 Notification 里……

CVE-2023-40098 ID High
看起来是有个叫 priority conversation widget 的东西,其他用户添加这个 widget 能看见主用户的通知。解决方法就是不让其他用户添加。


404 Not Found

啊哦?页面不见了呢......

404 Not Found

啊哦?页面不见了呢......

本站是残页无聊搭的一个小博客,基于 GitHub Pages + Hexo,主题为 Volantis

闲聊频道,发布一些矫揉造作的伤春悲秋或者一些技术内容但我觉得信息密度不值得专门写一篇博客的内容: https://t.me/CanyieChannel

关于我:

网名叫“残页”“canyie” 是五六年级就开始用的网名了。出生于 2004/08/06,一个 19 岁的中职毕业生,目前大一,喜欢编程,喜欢航空。
玻璃心,情绪化严重,活在深井,梦想天空。

Android Developer & Security Reseracher,研究方向主要偏向 framework & runtime 等系统层面的

患有孤独症谱系障碍,所以和别人交流可能比较困难,先说一声抱歉。思维方式和正常人不太一样,可能令人无法理解 www(

我在这里:QQ聊天 GitHub 知乎 看雪论坛 哔哩哔哩 Twitter Google BugHunters

本站是残页无聊搭的一个小博客,基于 GitHub Pages + Hexo,主题为 Volantis

闲聊频道,发布一些矫揉造作的伤春悲秋或者一些技术内容但我觉得信息密度不值得专门写一篇博客的内容: https://t.me/CanyieChannel

关于我:

网名叫“残页”“canyie” 是五六年级就开始用的网名了。出生于 2004/08/06,一个 19 岁的中职毕业生,目前大一,喜欢编程,喜欢航空。
玻璃心,情绪化严重,活在深井,梦想天空。

Android Developer & Security Reseracher,研究方向主要偏向 framework & runtime 等系统层面的

患有孤独症谱系障碍,所以和别人交流可能比较困难,先说一声抱歉。思维方式和正常人不太一样,可能令人无法理解 www(

我在这里:QQ聊天 GitHub 知乎 看雪论坛 哔哩哔哩 Twitter Google BugHunters

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

所有分类


所有分类


之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。


之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。


大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。


大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。


大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

在国内的大环境下,Android上插件化/热修复等技术百花齐放,而这一切都基于代码的动态加载。Android提供了一个DexClassLoader。用这个API能成功加载dex,但有一个比较严重的问题:Android Q以下,当这个dex被加载时,如果没有已经生成的oat,则会执行一次dex2oat把这个dex编译为oat,导致第一次加载dex会非常非常慢。个人认为这样的设计是非常不合理的,虽然转换成oat之后执行会很快,但完全可以让用户以解释器模式先愉快的用着,dex2oat放另一个线程执行多好。Android 8.0上谷歌还提供了一个InMemoryDexClassLoader,而以前的Android版本,就要开发者自己想办法了……

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

2018年发布的Android 9中引入了对隐藏API的限制,这对整个Android生态来说当然是一件好事,但也严重限制了以往我们通过反射等手段实现的“黑科技”(如插件化等),所以开发者们纷纷寻找手段绕过这个限制,比如我曾经提出了两个绕过方法,其中一个便是几乎完美的双重反射(即“元反射”,现在来看叫“套娃反射”比较好);而在即将发布的Android R中把这个方法封杀了(谷歌:禁止套娃!),因此我重新研究了Android R中的限制策略。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

大家应该还记得我上次介绍的Dreamland吧,忘记了也没事,简单介绍一下:这是一个类似Xposed的框架,可以注入应用进程并进行方法hook。进程注入上次已经说过了,另一个重点hook当时是使用了SandHook框架,这是一款非常优秀的hook框架,但是有点问题,不太适合Dreamland;在比较了其他hook框架之后,发现似乎都存在一些问题,最终决定自己动手写一个。已经开源,代码在这:Pine,接下来我会介绍它的具体实现。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

之前一直在看的每月补丁分析的博客 https://wrlus.com/ 看起来是不再更新了,想了想反正自己每个月也要去追着看,干脆写一下分析得了,方便自己后面找。

本人很菜,分析的大部分都是 Java 层漏洞,大佬别骂我 QAQ

最初发表在我的 telegram 频道。每月补丁都会在此文中更新。

最后更新时间:2024/05/10 更新内容:添加 2024-05

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

Android Property (属性系统)可谓是 Android 中使用最广泛的进程间信息共享机制了,如 app 获取系统版本号,就是通过属性系统传递的信息;对于如此常用的底层机制,你可能知道 getprop SystemProperties __system_property_get 这些 API,但是,你真的了解它吗?这次,我们不但要会遵守规则用这些 API,我们还要成为规则的缔造者,让系统为我们服务!Let’s go!

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

之前研究art的时候发现了native bridge,简单来说这东西是主要作用就是为了能运行不同指令集的so(比如x86的设备运行arm的app),而arm设备上这个东西一般都是关闭的,研究了一下后发现这东西挺适合动手脚的,刚好自己在用的Riru被针对了,所以有了这篇博客。把对应的示例代码传到了github:NbInjection,接下来我们聊一下这个小玩具。

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

Xposed框架在Android上是神器般的存在,它给了普通用户随意定制系统的能力,各种骚操作层出不穷。随着咱对Android的了解越来越深(其实一点都不深..),逐渐冒出了自己写一个类Xposed框架的想法,最终搞出了这个勉强能用的半成品。
代码在这:Dreamland & Dreamland Manager ,代码写的很辣鸡,求轻喷QAQ
接下来会介绍一下实现细节与遇到的问题。

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

2009年6月1日(UTC时间),法国航空447号班机(机型空中客车A330-203、注册号F-GZCP)在大西洋中部雷达盲区神秘失踪,后被证实坠毁,机上228人(乘客216人、机组成员12人)全数罹难。著名空难文献剧《Air Crash Investigation》(中文一般译为《空中浩劫》,以下简称ACI)在S12E13中收录了此事故,揭露了所谓的“空难真相”,将矛头直指副驾驶皮埃尔-塞德里克·博南(Pierre Cédric Bonin);同时,由于该片的知名度,让许多航空爱好者乃至真正的航空从业者认为该片所述即为事实。然而,真的是这样吗?

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。

当我写下这段文字时,时间是 2022 年 1 月 9 日,这个时候发上一年年终总结似乎晚了点;老实说,在这个博客刚创建的时候,我没有打算写这种记录性的内容,我的目标一直都是像 Weishu’s Notes 的高质量内容;但是昨晚看 weishu 直播写代码,他说了一句让我很触动的话:“人生不需要有意义,有意思就够了。”我的 2021 可能没什么意义,但如果我不做点什么纪念一下,它很有可能就消逝在时间的长河里,连我自己都回忆不起来,更别提有意思了。