Android HotFix方案

背景

随着开发需求的不断迫切,目前开源已经涌现了很多Hot Fix项目,但是从方案上来说,主要是基于rovo89/Xposedalibaba/dexposed;以方法hook,从Field切入的AndFixDex分包Nuwa。而相同原理的不同实现有很多,这里就不再累赘。这三个实现原理截然不同,各有各自优缺点,让我们走近这几个方案。

方案揭秘

Dexposed

alibaba/dexposed是基于rovo89/Xposed的AOP框架,方法级别粒度,而且不仅可以Hot Fix,还可进行AOP编程、插桩、Hook等功能。

Xposed是需要ROOT权限,因为其要修改其他应用及系统行为,而对于单个应用而言,不需要ROOT。Xposed基本原理是:通过修改Dalvik运行时的Zygote进程,使用Xposed Bridge来hook方法并注入自身代码,实现非侵入式的Runtime修改。包括小米(onVmCreated方法),也利用此特性,做了自定义主题、沉浸式状态栏等功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** Called very early during VM startup. */
void onVmCreated(JNIEnv* env) {
if (!initMemberOffsets(env))
return;

jclass classMiuiResources = env->FindClass(CLASS_MIUI_RESOURCES);
if (classMiuiResources != NULL) {
ClassObject* clazz = (ClassObject*)dvmDecodeIndirectRef(dvmThreadSelf(), classMiuiResources);
if (dvmIsFinalClass(clazz)) {
ALOGD("Removing final flag for class '%s'", CLASS_MIUI_RESOURCES);
clazz->accessFlags &= ~ACC_FINAL;
}
}
env->ExceptionClear();
...
}

应用启动时,会fork zygote进程,装载class和invoke各种初始化方法,Xposed就是在这个过程替换了app_process,hook各个入口方法,加载XposedBridge.jar提供动态hook基础,具体查看XposedBridge

具体的Native实现则是在libxposed_common.cpp里面,根据系统版本进行分发到libxposed_dalvik或libxposed_art,记录下原来方法信息,将方法指针指向hookedMethodCallback,从而达到劫持目的。

此方式只能对JAVA方法做拦截,不支持C方法。如果线上RELEASE版本进行混淆,patch也是一个痛苦事情,反射和内部类、包名和内部类冲突等处理很繁琐。

针对Xposed不支持ART这个事情,现在应该已经解决了,具体移步rovo89/android_art

AndFix

简单来说,主要通过补丁工具,利用注解等,描述其与要打补丁的类和方法的对应关系,之后在应用中,加载并替换方法的信息和指针,从而达到热修复目的。里面的实现可能还涉及到其他的部分,比如Security检查、PATCH覆盖等。

化繁为简,直接上核心代码,先以ART(不同版本差异不大,以6.0为例)为例:

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
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src);

art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

dmeth->declaring_class_->class_loader_ =
smeth->declaring_class_->class_loader_; //for plugin classloader
dmeth->declaring_class_->clinit_thread_id_ =
smeth->declaring_class_->clinit_thread_id_;
dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;

// 修改原方法属性为补丁方法
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->method_index_ = dmeth->method_index_;
smeth->dex_method_index_ = dmeth->dex_method_index_;

// 修改实现指针
smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;

smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);

}

// 将方法修改为PUBLIC
void setFieldFlag_6_0(JNIEnv* env, jobject field) {
art::mirror::ArtField* artField =
(art::mirror::ArtField*) env->FromReflectedField(field);
artField->access_flags_ = artField->access_flags_ & (~0x0002) | 0x0001;
LOGD("setFieldFlag_6_0: %d ", artField->access_flags_);
}

在Dalvik上的实现略有不同,先指向到dalvik_dispatcher,之后在里面进行补丁方法的调用和返回,也就是通过JNI 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
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
extern void __attribute__ ((visibility ("hidden"))) dalvik_replaceMethod(
JNIEnv* env, jobject src, jobject dest) {
jobject clazz = env->CallObjectMethod(dest, jClassMethod);
ClassObject* clz = (ClassObject*) dvmDecodeIndirectRef_fnPtr(
dvmThreadSelf_fnPtr(), clazz);
clz->status = CLASS_INITIALIZED;

Method* meth = (Method*) env->FromReflectedMethod(src);
Method* target = (Method*) env->FromReflectedMethod(dest);
LOGD("dalvikMethod: %s", meth->name);

meth->jniArgInfo = 0x80000000;
meth->accessFlags |= ACC_NATIVE;

// 将目标方法赋值到附带值上
int argsSize = dvmComputeMethodArgsSize_fnPtr(meth);
if (!dvmIsStaticMethod(meth))
argsSize++;
meth->registersSize = meth->insSize = argsSize;
meth->insns = (void*) target;

// 指向本地的dispatcher方法
meth->nativeFunc = dalvik_dispatcher;
}

...

static void dalvik_dispatcher(const u4* args, jvalue* pResult,
const Method* method, void* self) {
ClassObject* returnType;
jvalue result;
ArrayObject* argArray;

LOGD("dalvik_dispatcher source method: %s %s", method->name,
method->shorty);
Method* meth = (Method*) method->insns;
meth->accessFlags = meth->accessFlags | ACC_PUBLIC;
LOGD("dalvik_dispatcher target method: %s %s", method->name,
method->shorty);

returnType = dvmGetBoxedReturnType_fnPtr(method);
if (returnType == NULL) {
assert(dvmCheckException_fnPtr(self));
goto bail;
}
LOGD("dalvik_dispatcher start call->");

// 是否静态方法
if (!dvmIsStaticMethod(meth)) {
Object* thisObj = (Object*) args[0];
ClassObject* tmp = thisObj->clazz;
thisObj->clazz = meth->clazz;
argArray = boxMethodArgs(meth, args + 1);
if (dvmCheckException_fnPtr(self))
goto bail;

// 调用
dvmCallMethod_fnPtr(self, (Method*) jInvokeMethod,
dvmCreateReflectMethodObject_fnPtr(meth), &result, thisObj,
argArray);

thisObj->clazz = tmp;
} else {
argArray = boxMethodArgs(meth, args);
if (dvmCheckException_fnPtr(self))
goto bail;
// 调用
dvmCallMethod_fnPtr(self, (Method*) jInvokeMethod,
dvmCreateReflectMethodObject_fnPtr(meth), &result, NULL,
argArray);
}
if (dvmCheckException_fnPtr(self)) {
Object* excep = dvmGetException_fnPtr(self);
jni_env->Throw((jthrowable) excep);
goto bail;
}

if (returnType->primitiveType == PRIM_VOID) {
LOGD("+++ ignoring return to void");
} else if (result.l == NULL) {
if (dvmIsPrimitiveClass(returnType)) {
jni_env->ThrowNew(NPEClazz, "null result when primitive expected");
goto bail;
}
pResult->l = NULL;
} else {
if (!dvmUnboxPrimitive_fnPtr(result.l, returnType, pResult)) {
char msg[1024] = { 0 };
snprintf(msg, sizeof(msg) - 1, "%s!=%s\0",
((Object*) result.l)->clazz->descriptor,
returnType->descriptor);
jni_env->ThrowNew(CastEClazz, msg);
goto bail;
}
}

bail: dvmReleaseTrackedAlloc_fnPtr((Object*) argArray, self);
}

Multiple Dex

Multiple Dex的方案原来是为了突破65535的限制,其就是将多个Dex放到应用的ClassLoader之中,从而使所有的Dex的类都能被找到。实际上,在findClass过程中,如果重复的类,参照下面的类加载的实现,是会使用第一个找到的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Class findClass(String name, List<Throwable> suppressed) {  
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}

而方法过多的原因其实也很明显:因为在Dalvik指令集里,调用方法的invoke-kind指令中,method reference index只给了16bits,最多能调用65535个方法,所以在生成Dex文件的过程中,当方法数超过65535就会报错。

This number is significant in that it represents the total number of references that can be invoked by the code within a single Dalvik Executable (dex) bytecode file.

该方法就是从这入手,将问题修复后,放到一个单独的Dex中,通过反射插入到dexElements数组的最前面,完成虚拟机打补丁的操作。

在此方案中,有一个问题就是,在运行加载类的时候会报preverified错误,在DexPrepare.cpp中,将Dex转化为ODex过程中,会在DexVerify.cpp中进行校验,验证如果直接引用到的类和clazz是否在同一个Dex,如果是则会打上CLASS_ISPREVERIFIED标志。通过在所有类(Application除外,此时还没加载自定义代码)的构造函数中插入一个对在单独Dex类的引用,来解决此问题。在空间中,是使用javaassist进行编译时字节码插入。

当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。
虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志。
怎么样算是校验类成功?如果static方法、private方法、构造函数等,其中的直接引用(第一层关系)到的类都在同一个Dex文件中,那么该类就会被打上CLASS_ISPREVERIFIED标志。
我们要做的就是,阻止该类打上CLASS_ISPREVERIFIED的标志。否则加载其他Dex的时候会报错。
注意下,是阻止引用者的类,也就是说,假设你的app里面有个类叫做LoadBugClass,再其内部引用了BugClass。发布过程中发现BugClass有编写错误,那么想要发布一个新的BugClass类,那么你就要阻止LoadBugClass这个类打上CLASS_ISPREVERIFIED的标志。
你在生成apk之前,就需要阻止相关类打上CLASS_ISPREVERIFIED的标志了。对于如何阻止,可让LoadBugClass在构造方法中,去引用别的dex文件,比如:hack.dex中的某个类即可。
空间使用的是在字节码插入代码,而不是源代码插入,使用的是javaassist库来进行字节码插入的。

需要做的两件事:1、动态改变BaseDexClassLoader对象间接引用的dexElements;2、在app打包的时候,阻止相关类去打上CLASS_ISPREVERIFIED标志。

另外混淆问题,需要保存每个版本的mapping混淆文件,后续编译用相同的mapping文件,保证混淆过后类名始终一致。

此方式无法在已经加载好的类中实现动态替换,所以需要在类加载之前替换。也就是补丁下载下来之后,需要等待用户重启应用才可完成补丁效果。

目前开源实现有:NuwaHotFixDroidFix,其各个版本也有做相应的兼容。

方案对比

  1. Dexposed方法生成补丁难度较大,需要反射写混淆后的代码,粒度太细,如果替换方法很多的话,工作量巨大。
  2. AndFix支持2.3 - 6.0系统,但JNI不像JAVA那样标准,所以偶尔会有未知的机型异常。从实现来说,类似Dexposed,通过JNI来替代方法,更加简洁的完成补丁修复,应用PATCH不需要重启。但由于实现上直接跳过了类初始化,设置为初始化完毕,所以静态函数、静态成员、构造函数都会出现问题,复杂的类Class.forName很可能直接崩溃。
  3. Multiple Dex方案支持2.3 - 6.0系统,对启动速度会有略微影响,而且只能下次启动生效。

相比而言,Multiple Dex方案较为可靠,但是如果Dex文件较为庞大,启动速度会变慢;而AndFix则适用于应用不重启即可修复,且方法够简单;Dexposed方案较为复杂,暂不考虑。

REF

当dex分包遇上NoClassDefFoundError&ClassNotFoundException
让App像Web一样发布新版本
各大热补丁方案分析和比较
Android dex分包方案
安卓App热补丁动态修复技术介绍
Android 热补丁动态修复框架小结
dodola/HotFix: 安卓App热补丁动态修复框架
美团Android DEX自动拆包及动态加载简介
Android dex分包方案
Android下的挂钩(hook)和代码注入(inject)