各大热补丁方案分析和比较

jopen 9年前

最近开源界涌现了很多热补丁项目,但从方案上来说,主要包括DexposedAndFixClassLoader(来源是原QZone,现淘宝的工程师陈钟,在15年年初就已经开始实现)三种。前两个都是阿里巴巴内部的不同团队做的(淘宝和支付宝),后者则来自腾讯的QQ空间团队。

开源界往往一个方案会有好几种实现(比如ClassLoader方案已经有不下三种实现了),但这三种方案的原理却徊然不同,那么让我们来看看它们三者的原理和各自的优缺点吧。

Dexposed

基于Xposed的AOP框架,方法级粒度,可以进行AOP编程、插桩、热补丁、SDK hook等功能。

Xposed需要Root权限,是因为它要修改其他应用、系统的行为,而对单个应用来说,其实不需要root。 Xposed通过修改Android Dalvik运行时的Zygote进程,并使用Xposed Bridge来hook方法并注入自己的代码,实现非侵入式的runtime修改。比如蜻蜓fm和喜马拉雅做的事情,其实就很适合这种场景,别人反编译市场下载的代码是看不到patch的行为的。小米(onVmCreated里面还未小米做了资源的处理)也重用了dexposed,去做了很多自定义主题的功能,还有沉浸式状态栏等。

我们知道,应用启动的时候,都会fork zygote进程,装载class和invoke各种初始化方法,Xposed就是在这个过程中,替换了app_process,hook了各种入口级方法(比如handleBindApplication、ServerThread、ActivityThread、ApplicationPackageManager的getResourcesForApplication等),加载XposedBridge.jar提供动态hook基础。

具体到方法,可参见XposedBridge:

/**   * Intercept every call to the specified method and call a handler function instead.   * @param method The method to intercept   */  private native synchronized static void hookMethodNative(Member method, Class<?> declaringClass, int slot, Object additionalInfo);

其具体native实现则在Xposed的libxposed_common.cpp里面有注册,根据系统版本分发到libxposed_dalvik和libxposed_art里面,以dalvik为例大致来说就是记录下原来的方法信息,并把方法指针指向我们的hookedMethodCallback,从而实现拦截的目的。

方法级的替换是指,可以在方法前、方法后插入代码,或者直接替换方法。只能针对java方法做拦截,不支持C的方法。

来说说硬伤吧,不支持art,不支持art,不支持art。
重要的事情要说三遍。尽管在6月,项目网站的roadmap就写了7、8月会支持art,但事实是现在还无法解决art的兼容。

另外,如果线上release版本进行了混淆,那写patch也是一件很痛苦的事情,反射+内部类,可能还有包名和内部类的名字冲突,总而言之就是写得很痛苦。

AndFix

同样是方法的hook,AndFix不像Dexposed从Method入手,而是以Field为切入点。

先看Java入口,AndFixManager.fix:

/**   * fix   *   * @param file        patch file   * @param classLoader classloader of class that will be fixed   * @param classes     classes will be fixed   */  public synchronized void fix(File file, ClassLoader classLoader, List<String> classes) {    // 省略...判断是否支持,安全检查,读取补丁的dex文件      ClassLoader patchClassLoader = new ClassLoader(classLoader) {     @Override     protected Class<?> findClass(String className) throws ClassNotFoundException {      Class<?> clazz = dexFile.loadClass(className, this);      if (clazz == null && className.startsWith("com.alipay.euler.andfix")) {       return Class.forName(className);// annotation’s class not found      }      if (clazz == null) {       throw new ClassNotFoundException(className);      }      return clazz;     }    };    Enumeration<String> entrys = dexFile.entries();    Class<?> clazz = null;    while (entrys.hasMoreElements()) {     String entry = entrys.nextElement();     if (classes != null && !classes.contains(entry)) {      continue;// skip, not need fix     }        // 找到了,加载补丁class     clazz = dexFile.loadClass(entry, patchClassLoader);     if (clazz != null) {      fixClass(clazz, classLoader);     }    }   } catch (IOException e) {    Log.e(TAG, "pacth", e);   }  }

看来最终fix是在fixClass方法:

private void fixClass(Class<?> clazz, ClassLoader classLoader) {    Method[] methods = clazz.getDeclaredMethods();    MethodReplace methodReplace;    String clz;    String meth;    // 遍历补丁class里的方法,进行一一替换,annotation则是补丁包工具自动加上的    for (Method method : methods) {      methodReplace = method.getAnnotation(MethodReplace.class);      if (methodReplace == null)        continue;      clz = methodReplace.clazz();      meth = methodReplace.method();      if (!isEmpty(clz) && !isEmpty(meth)) {        replaceMethod(classLoader, clz, meth, method);      }    }  }    private void replaceMethod(ClassLoader classLoader, String clz, String meth, Method method) {    try {      String key = clz + "@" + classLoader.toString();      Class<?> clazz = mFixedClass.get(key);      if (clazz == null) {// class not load        // 要被替换的class        Class<?> clzz = classLoader.loadClass(clz);        // 这里也很黑科技,通过C层,改写accessFlags,把需要替换的类的所有方法(Field)改成了public,具体可以看Method结构体        clazz = AndFix.initTargetClass(clzz);      }      if (clazz != null) {// initialize class OK        mFixedClass.put(key, clazz);        // 需要被替换的函数        Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes());        // 这里是调用了jni,art和dalvik分别执行不同的替换逻辑,在cpp进行实现        AndFix.addReplaceMethod(src, method);      }    } catch (Exception e) {      Log.e(TAG, "replaceMethod", e);    }  }

在dalvik和art上,系统的调用不同,但是原理类似,这里我们尝个鲜,以6.0为例art_method_replace_6_0:

// 进行方法的替换  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的,所以说了解一下jni还是很有必要的,java世界在c世界是有映射关系的  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上的实现略有不同,是通过jni bridge来指向补丁的方法。

使用上,直接写一个新的类,会由补丁工具会生成注解,描述其与要打补丁的类和方法的对应关系。

ClassLoader

原腾讯空间Android工程师,也是我的启蒙老师的陈钟发明的热补丁方案,是他在看源码的时候偶然发现的切入点。

我们知道,multidex方案的实现,其实就是把多个dex放进app的classloader之中,从而使得所有dex的类都能被找到。而实际上findClass的过程中,如果出现了重复的类,参照下面的类加载的实现,是会使用第一个找到的类的。

public Class findClass(String name, List<Throwable> suppressed) {          for (Element element : dexElements) {  //每个Element就是一个dex文件          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;  }

该热补丁方案就是从这一点出发,只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,不就可以让虚拟机加载到打完补丁的class了吗。

说到此处,似乎已经是一个完整的方案了,但在实践中,会发现运行加载类的时候报preverified错误,原来在DexPrepare.cpp,将dex转化成odex的过程中,会在DexVerify.cpp进行校验,验证如果直接引用到的类和clazz是否在同一个dex,如果是,则会打上CLASS_ISPREVERIFIED标志。通过在所有类(Application除外,当时还没加载自定义类的代码)的构造函数插入一个对在单独的dex的类的引用,就可以解决这个问题。空间使用了javaassist进行编译时字节码插入。

开源实现有NuwaHotFixDroidFix

比较

Dexposed不支持Art模式(5.0+),且写补丁有点困难,需要反射写混淆后的代码,粒度太细,要替换的方法多的话,工作量会比较大。

AndFix支持2.3-6.0,但是不清楚是否有一些机型的坑在里面,毕竟jni层不像java曾一样标准,从实现来说,方法类似Dexposed,都是通过jni来替换方法,但是实现上更简洁直接,应用patch不需要重启。但由于从实现上直接跳过了类初始化,设置为初始化完毕,所以像是静态函数、静态成员、构造函数都会出现问题,复杂点的类Class.forname很可能直接就会挂掉。

ClassLoader方案支持2.3-6.0,会对启动速度略微有影响,只能在下一次应用启动时生效,在空间中已经有了较长时间的线上应用,如果可以接受在下次启动才应用补丁,是很好的选择。

总的来说,在兼容性稳定性上,ClassLoader方案很可靠,如果需要应用不重启就能修复,而且方法足够简单,可以使用AndFix,而Dexposed由于还不能支持art,所以只能暂时放弃,希望开发者们可以改进使它能支持art模式,毕竟xposed的种种能力还是很吸引人的(比如hook别人app的方法拿到解密后的数据,嘿嘿),还有比如无痕埋点啊线上追踪问题之类的,随时可以下掉。


原文出处:http://blog.zhaiyifan.cn/2015/11/20/HotPatchCompare/