类加载器与动态加载是学习Android加壳与脱壳的需要。
Java程序的编译执行过程:
1. 类加载器
类加载器遵循双亲委派模式。
JVM的类加载器包括3种:
- Bootstrap ClassLoader(引导类加载器):C/C++代码实现的加载器,用于加载指定的JDK核心类库,比如java.lang、java.uti等这些系统类。Java虚拟机的启动就是通过Bootstrap,该ClassLoader在Java里无法获取,负责加载/lib下的类。
- Extensions ClassLoader(拓展类加载器):Java中的实现类为ExtClassLoader,提供了除了系统类之外的额外功能,可以在Java里获取,负责加载/lib/ext下的类。
- Application ClassLoader(应用程序类加载器):Java中的实现类为AppClassLoader,是与我们接触最多的类加载器,开发人员写的代码默认就是由它来加载,ClassLoader.getSystemClassLoader返回的就是它。
也可以自定义类加载器,只需要通过继承java.lang.ClassLoader类的方式来实现自己的类加载器即可。
在Android中与ClassLoader相关的类加载器共有8个:
- ClassLoader:抽象类。
- BootClassLoader:预加载常用类,单例模式。与Java中的Bootstrap ClassLoader不同,它并不是由C/C++实现,而是由Java实现的。
- BaseDexClassLoader:是PathClassLoader、DexClassLoader、InMemoryDexClassLoader的父类,类加载的主要逻辑都是在BaseDexClassLoader完成的。
- SecurityClassLoader:继承了抽象类ClassLoader,拓展了ClassLoader类加入了权限方面的功能,加强了安全性。
- URLClassLoader:SecurityClassLoader子类,用URL路径从JAR文件中加载类和资源。
- PathClassLoader:Android默认使用的类加载器,一个APK中的Activity等类便是在其中加载。
- DexClassLoader:可以加载任意目录下的DEX/JAR/APK/ZIP文件,比PathClassLoader更灵活,是实现插件化、热修复以及DEX加壳的重点。
- InMemoryDexClassLoader:Android 8.0新引入,用于直接从内存中加载DEX。
1.1 双亲委派模式
1.1.1 双亲委派模式定义
加载.class
文件时,以递归的形式逐级向上委托给父加载器ParentClassLoader加载,如果加载过了,就不用再加载一遍;如果父加载器没有加载过,继续委托给父加载器去加载,一直到这条链路的顶级,顶级ClassLoader如果没有加载过,则尝试加载,加载失败,则逐级向下交还调用者加载。
1 | protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{ |
(1)先检查自己是否已经加载过.class
文件,用findLoadedClass()
方法,如果已经加载了直接返回
(2)如果自己没有加载过,存在父类,则委派父类去加载,用parent.loadClass(name,false)
方法,此时会向上传递,然后去父加载器中循环第1步,一直到顶级ClassLoader
(3)如果父类没有加载,则尝试本级ClassLoader加载,如果加载失败了就会向下传递,交给调用方式实现.class
文件的加载
1.1.2 双亲委派加载流程
我们要加载一个.class
文件,定义一个CustomerClassLoader类加载器:
(1)首先会判断自己的CustomerClassLoader是否加载过,如果加载过直接返回;
(2)如果没有加载过则会调用父类PathClassLoader去加载,该父类同样会判断自己是否加载过,如果没有加载过则委托给父类BootClassLoader去加载;
(3)这个BootClassLoader是顶级ClassLoader,同样会去判断自己有没有加载过,如果也没有加载过则会调用自己的findClass(name)
去加载;
(4)如果顶级BootClassLoader加载失败了,则会把加载这个动作向下交还给PathClassLoader;
(5)这个PathClassLoader也会尝试去调用findClass(name)
去加载,如果加载失败了,则会继续向下交还给CustomerClassLoader来完成加载。这整个过程感觉是一个递归的过程,逐渐往上然后又逐渐往下,直到加载成功。
1.1.3 双亲委派的作用
(1) 防止同一个.class
文件重复加载;
(2) 对于任意一个类确保在虚拟机中的唯一性,由加载它的类加载器和这个类的全类名一同确立其在Java虚拟机中的唯一性;
(3) 保证.class
文件不被篡改,通过委派方式可以保证系统类的加载逻辑不被篡改。
1.2 Android中的类加载机制
1.2.1 Dalvik虚拟机启动过程
Dalvik虚拟机的启动过程可以分为8个步骤:
1.2.1.1 AndroidRuntime.start()
1 | void AndroidRuntime::start(const char* className, const bool startSystemServer){ |
这个函数主要做了4件事情:
- 调用成员函数
startVm()
来创建一个Dalvik虚拟机实例,并且保存在成员变量mJavaVm中。 - 调用成员函数
startReg()
来注册一些Android核心类的JNI方法。 - 调用参数className所描述的一个Java类的静态成员函数
main()
,来作为Zygote进程的Java层入口。这个入口类就为com.android.internal.os.ZygoteInit。执行这一步的时候,Zygote进程中的Dalvik虚拟机实例就开始正式运作了。注意,在这一步中,也就是在com.android.internal.os.ZygoteInit类的静态成员函数main()
,会进行大量的Android核心类和系统资源文件预加载。 - 从com.android.internal.os.ZygoteInit类的静态成员函数
main()
返回来的时候,就说明Zygote进程准备要退出来了。在退出之前,会调用前面创建的Dalvik虚拟机实例的成员函数DetachCurrentThread()
和DestroyJavaVM()
。其中,前者用来将Zygote进程的主线程脱离前面创建的Dalvik虚拟机实例,后者是用来销毁前面创建的Dalvik虚拟机实例。
1.2.1.2 AndroidRuntime.startVm()
1 | int AndroidRuntime::startVm(JavaVM** pJavaVM, JNIEnv** pEnv){ |
在启动Dalvik虚拟机的时候,可以指定一系列的选项,这些选项可以通过特定的系统属性来指定。下面我们就简单了解几个可能有用的选项。
- -Xcheck:jni:用来启动JNI方法检查。我们在C/C++代码中,可以修改Java对象的成员变量或者调用Java对象的成员函数。加了-Xcheck:jni选项之后,就可以对要访问的Java对象的成员变量或者成员函数进行合法性检查,例如,检查类型是否匹配。我们可以通过dalvik.vm.checkjni或者ro.kernel.android.checkjni这两个系统属性来指定是否要启用-Xcheck:jni选项。注意,加了-Xcheck:jni选项之后,会使用得JNI方法执行变慢。
- -Xint:portable,-Xint:fast,-Xint:jit:用来指定Dalvik虚拟机的执行模式。Dalvik虚拟机支持三种运行模式,分别是Portable、Fast和Jit。Portable是指Dalvik虚拟机以可移植的方式来进行编译,也就是说,编译出来的虚拟机可以在任意平台上运行。Fast是针对当前平台对Dalvik虚拟机进行编译,这样编译出来的Dalvik虚拟机可以进行特殊的优化,从而使得它能更快地运行程序。Jit不是解释执行代码,而是将代码动态编译成本地语言后再执行。我们可以通过dalvik.vm.execution-mode系统属性来指定Dalvik虚拟机的解释模式。
- -Xstacktracefile:用来指定调用堆栈输出文件。Dalvik虚拟机接收到SIGQUIT(Ctrl-\或者kill -3)信号之后,会将所有线程的调用堆栈输出来,默认是输出到日志里面。指定了-Xstacktracefile选项之后,就可以将线程的调用堆栈输出到指定的文件中去。我们可以通过dalvik.vm.stack-trace-file系统属性来指定调用堆栈输出文件。
- -Xmx:用来指定Java对象堆的最大值。Dalvik虚拟机的Java对象堆的默认最大值是16M,不过我们可以通过dalvik.vm.heapsize系统属性来指定为其它值。
设置好Dalvik虚拟机的启动选项之后,AndroidRuntime的成员函数startVm()
就会调用另外一个函数JNI_CreateJavaVM()
来创建以及初始化一个Dalvik虚拟机实例。
1.2.1.3 JNI_CreateJavaVM()
1 | /* * Create a new VM instance. * |
JNI_CreateJavaVM主要完成以下4件事情:
- 为当前进程创建一个Dalvik虚拟机实例,即一个JavaVMExt对象。
- 为当前线程创建和初始化一个JNI环境,即一个JNIEnvExt对象,这是通过调用函数
dvmCreateJNIEnv()
来完成的。 - 将参数vm_args所描述的Dalvik虚拟机启动选项拷贝到变量argv所描述的一个字符串数组中去,并且调用函数
dvmStartup()
来初始化前面所创建的Dalvik虚拟机实例。 - 调用函数
dvmChangeStatus()
将当前线程的状态设置为正在执行Native代码,并且将前面所创建和初始化好的JavaVMExt对象和JNIEnvExt对象通过输出参数p_vm和p_env返回给调用者。
gDvm是一个类型为DvmGlobals的全局变量,用来收集当前进程所有虚拟机相关的信息,其中,它的成员变量vmList指向的就是当前进程中的Dalvik虚拟机实例,即一个JavaVMExt对象。以后每当需要访问当前进程中的Dalvik虚拟机实例时,就可以通过全局变量gDvm的成员变量vmList来获得,避免了在函数之间传递该Dalvik虚拟机实例。
每一个Dalvik虚拟机实例都有一个函数表,保存在对应的JavaVMExt对象的成员变量funcTable中,而这个函数表又被指定为gInvokeInterface。gInvokeInterface是一个类型为JNIInvokeInterface的结构体,如下所示:
1 | static const struct JNIInvokeInterface gInvokeInterface = { |
有了这个Dalvik虚拟机函数表之后,我们就可以将当前线程Attach或者Detach到Dalvik虚拟机中去,或者销毁当前进程的Dalvik虚拟机等。
每一个Dalvik虚拟机实例还有一个JNI环境列表,保存在对应的JavaVMExt对象的成员变量envList中。注意,JavaVMExt对象的成员变量envList描述的是一个JNIEnvExt列表,其中,每一个Attach到Dalvik虚拟机中去的线程都有一个对应的JNIEnvExt,用来描述它的JNI环境。有了这个JNI环境之后,我们才可以在Java函数和C/C++函数之间互相调用。
每一个JNIEnvExt对象都有两个成员变量prev和next,它们均是一个JNIEnvExt指针,分别指向前一个JNIEnvExt对象和后一个JNIEnvExt对象,也就是说,每一个Dalvik虚拟机实例的成员变量envList描述的是一个双向JNIEnvExt列表,其中,列表中的第一个JNIEnvExt对象描述的是主线程的JNI环境。
1.2.1.4 dvmCreateJNIEnv()
1 | /* * Create a new JNIEnv struct and add it to the VM's list. * |
函数dvmCreateJNIEnv()
主要是执行了以下3个操作:
- 创建一个JNIEnvExt对象,用来描述一个JNI环境,并且设置这个JNIEnvExt对象的宿主Dalvik虚拟机,以及所使用的本地接口表,即设置这个JNIEnvExt对象的成员变量funcTable和vm。这里的宿主Dalvik虚拟机即为当前进程的Dalvik虚拟机,它保存在全局变量gDvm的成员变量vmList中。本地接口表由全局变量gNativeInterface来描述。
- 参数self描述的是前面创建的JNIEnvExt对象要关联的线程,可以通过调用函数
dvmSetJniEnvThreadId()
来将它们关联起来。注意,当参数self的值等于NULL的时候,就表示前面的JNIEnvExt对象是要与主线程关联的,但是要等到后面再关联,因为现在用来描述主线程的Thread对象还没有准备好。通过将一个JNIEnvExt对象的成员变量envThreadId和self的值分别设置为0x77777775和0x77777779来表示它还没有与线程关联。 在一个Dalvik虚拟机里面,可以运行多个线程。所有关联有JNI环境的线程都有一个对应的JNIEnvExt对象,这些JNIEnvExt对象相互连接在一起保存在用来描述其宿主Dalvik虚拟机的一个JavaVMExt对象的成员变量envList中。因此,前面创建的JNIEnvExt对象需要连接到其宿主Dalvik虚拟机的JavaVMExt链表中去。
gNativeInterface是一个类型为JNINativeInterface的结构体,如下所示:
1 | static const struct JNINativeInterface gNativeInterface = { |
这一步执行完成之后,返回到前面的第3步中,即函数JNI_CreateJavaVM()
中,接下来就会继续调用函数dvmStartup()
来初始化前面所创建的Dalvik虚拟机实例。
1.2.1.5 dvmStartup()
这个函数用来初始化Dalvik虚拟机,我们分段来阅读:
1 | /* * VM initialization. Pass in any options provided on the command line. |
这段代码用来处理Dalvik虚拟机的启动选项,这些启动选项保存在参数argv中,并且个数等于argc。在处理这些启动选项之前,还会执行以下两个操作:
- 调用函数
setCommandLineDefaults()
来给Dalvik虚拟机设置默认参数,因为启动选项不一定会指定Dalvik虚拟机的所有属性。 - 调用函数
dvmPropertiesStartup()
来分配足够的内存空间来容纳由参数argv和argc所描述的启动选项。
完成以上两个操作之后,就可以调用函数dvmProcessOptions()
来处理参数argv和argc所描述的启动选项了,也就是根据这些选项值来设置Dalvik虚拟机的属性,例如,设置Dalvik虚拟机的Java对象堆的最大值。
1 | /* configure signal handling */ |
如果我们没有在Dalvik虚拟机的启动选项中指定-Xrs,那么gDvm.reduceSignals的值就会被设置为false,表示要在当前线程中屏蔽掉SIGQUIT信号。在这种情况下,会有一个线程专门用来处理SIGQUIT信号。这个线程在接收到SIGQUIT信号的时候,就会将各个线程的调用堆栈打印出来,因此,这个线程又称为dump-stack-trace线程。
1 | /* * Initialize components. */ |
这段代码用来初始化Dalvik虚拟机的各个子模块。
1 | /* make sure we got these [can this go away?] */ |
这段代码检查java.lang.Class、java.lang.Object、java.lang.Thread、java.lang.VMThread和java.lang.ThreadGroup这五个核心类经过前面的初始化操作后已经得到加载,并且确保系统中存在java.lang.InternalError、java.lang.StackOverflowError、java.lang.UnsatisfiedLinkError和java.lang.NoClassDefFoundError这四个核心类。
1 | /* * Miscellaneous class library validation. */ |
这段代码继续执行其它函数来执行其它的初始化和检查工作。
上述初始化和检查操作执行完成之后,我们再来看最后一段代码:
1 | /* * Init for either zygote mode or non-zygote mode. The key difference |
这段代码完成Dalvik虚拟机的最后一步初始化工作。它检查Dalvik虚拟机是否指定了-Xzygote启动选项。如果指定了的话,那么就说明当前是在Zygote进程中启动Dalvik虚拟机,因此,接下来就会调用函数dvmInitZygote()
来执行最后一步初始化工作。否则的话,就会调用另外一个函数dvmInitAfterZygote()
来执行最后一步初始化工作。
1.2.1.6 dvmInitZygote()
1 | /* * Do zygote-mode-only initialization. */ |
函数dvmInitZygote()
的实现很简单,它只是调用了系统调用setpgid()
来设置当前进程,即Zygote进程的进程组ID。注意,在调用setpgid()
的时候,传递进去的两个参数均为0,这意味着Zygote进程的进程组ID与进程ID是相同的,也就是说,Zygote进程运行在一个单独的进程组里面。
这一步执行完成之后,Dalvik虚拟机的创建和初始化工作就完成了,回到前面的第1步中,即AndroidRuntime类的成员函数start()
中,接下来就会调用AndroidRuntime类的另外一个成员函数startReg()
来注册Android核心类的JNI方法。
1.2.1.7 AndroidRuntime.startReg()
1 | /* * Register android native functions with the VM. */ |
AndroidRuntime类的成员函数startReg()
首先调用函数androidSetCreateThreadFunc()
来设置一个线程创建钩子javaCreateThreadEtc。这个线程创建钩子是用来初始化一个Native线程的JNI环境的,也就是说,当我们在C++代码中创建一个Native线程的时候,函数javaCreateThreadEtc()
会被调用来初始化该Native线程的JNI环境。后面在分析Dalvik虚拟机线程的创建过程时,我们再详细分析函数javaCreateThreadEtc()
的实现。
AndroidRuntime类的成员函数startReg()
接着调用函数register_jni_procs()
来注册Android核心类的JNI方法。在注册JNI方法的过程中,需要在Native代码中引用到一些Java对象,这些Java对象引用需要记录在当前线程的一个Native堆栈中。但是此时Dalvik虚拟机还没有真正运行起来,也就是当前线程的Native堆栈还没有准备就绪。在这种情况下,就需要在注册JNI方法之前,手动地将在当前线程的Native堆栈中压入一个帧(Frame),并且在注册JNI方法之后,手动地将该帧弹出来。
当前线程的JNI环境是由参数env所指向的一个JNIEnv对象来描述的,通过调用它的成员函数PushLocalFrame()
和PopLocalFrame()
就可以手动地往当前线程的Native堆栈压入和弹出一个帧。注意,这个帧是一个本地帧,只可以用来保存Java对象在Native代码中的本地引用。
函数register_jni_procs()
的实现如下所示:
1 | static int register_jni_procs(const RegJNIRec array[], size_t count, JNIEnv* env){ |
从前面的调用过程可以知道,参数array指向的是全局变量gRegJNI所描述的一个JNI方法注册函数表,其中,每一个表项都用一个RegJNIRec对象来描述,而每一个RegJNIRec对象都有一个成员变量mProc,指向一个JNI方法注册函数。通过依次调用这些注册函数,就可以将Android核心类的JNI方法注册到前面的所创建的Dalvik虚拟机中去。
回到AndroidRuntime类的成员函数startReg()
中,接下来我们就继续分析函数androidSetCreateThreadFunc()
的实现,以便可以了解线程创建钩子javaCreateThreadEtc的注册过程。
1.2.1.8 androidSetCreateThreadFunc()
1 | static android_create_thread_fn gCreateThreadFn = androidCreateRawThreadEtc; |
从这里就可以看到,线程创建钩子javaCreateThreadEtc被保存在一个函数指针gCreateThreadFn中。注意,函数指针gCreateThreadFn默认是指向函数androidCreateRawThreadEtc()
的,也就是说,如果我们不设置线程创建钩子的话,函数androidCreateRawThreadEtc()
就是默认使用的线程创建函数。后面在分析Dalvik虚拟机线程的创建过程时,我们再详细分析函数指针gCreateThreadFn是如何使用的。
1.2.1.9 总结
至此,我们就分析完成Dalvik虚拟机在Zygote进程中的启动过程,这个启动过程主要就是完成了以下四件事情:
- 创建了一个Dalvik虚拟机实例;
- 加载了Java核心类及其JNI方法;
- 为主线程设置了一个JNI环境;
- 注册了Android核心类的JNI方法。
换句话说,就是Zygote进程为Android系统准备好了一个Dalvik虚拟机实例,以后Zygote进程在创建Android应用程序进程的时候,就可以将它自身的Dalvik虚拟机实例复制到新创建Android应用程序进程中去,从而加快了Android应用程序进程的启动过程。此外,Java核心类和Android核心类(位于DEX文件中),以及它们的JNI方法(位于so文件中),都是以内存映射的方式来读取的,因此,Zygote进程在创建Android应用程序进程的时候,除了可以将自身的Dalvik虚拟机实例复制到新创建的Android应用程序进程之外,还可以与新创建的Android应用程序进程共享Java核心类和Android核心类,以及它们的JNI方法,这样就可以节省内存消耗。
同时,Zygote进程为了加快Android应用程序进程的启动过程,牺牲了自己的启动速度,因为它需要加载大量的Java核心类,以及注册大量的Android核心类JNI方法。Dalvik虚拟机在加载Java核心类的时候,还需要对它们进行验证以及优化,这些通常都是比较耗时的。又由于Zygote进程是由init进程启动的,也就是说Zygote进程在是开机的时候进行启动的,因此,Zygote进程的牺牲是比较大的。不过毕竟我们在玩手机的时候,很少会关机,也就是很少开机,因此,牺牲Zygote进程的启动速度是值得的,换来的是Android应用程序的快速启动。而且,Android系统为了加快Java类的加载速度,还会想方设法地提前对DEX文件进行验证和优化。
1.2.2 Android基本类预加载
Android中的ClassLoader类型分为系统ClassLoader和自定义ClassLoader。其中系统ClassLoader包括3种,分别是BootClassLoader、DexClassLoader、PathClassLoader。
(1)BootClassLoader:Android平台上所有Android系统启动时会使用BootClassLoader来预加载常用的类。
(2)BaseDexClassLoader:实际应用层类文件的加载,而真正的加载委托给pathList来完成。
(3)DexClassLoader:可以加载DEX文件以及包含DEX的压缩文件(APK, DEX, JAR, ZIP),可以加载一个未安装的APK文件的DEX文件,一般为自定义类加载器。
(4)PathClassLoader:可以加载系统类和应用程序的类,通常用来加载已安装的APK的DEX文件。
2. 壳的动态加载及修复流程
2.1 App启动流程
- BootClassLoader加载系统核心库
- PathClassLoader加载App自身DEX
- 进入App自身组件开始执行
- 调用声明Application的attachBaseContext
- 调用声明Application的onCreate
2.2 加壳应用的启动流程
壳要做的工作有:
- 对原先App的DEX进行解密
- 初始化自定义类加载器
- 替换LoadApk中的加载器为自定义加载器
在这个启动流程有两个问题需要解决:
- 何时进行DEX的解密?在自定义Application的attachBaseContext方法中进行解密。
- 如何解决动态加载的DEX中的类的生命周期问题?在自定义Application的onCreate方法中实现。
2.3 生命周期类处理
DexClassLoader加载的类是没有组件生命周期的,也就是说即使DexClassLoader通过对APK的动态加载完成了对组件类的加载,当系统启动该组件时,依然会出现加载类失败的异常。为什么呢?
因为Activity不像函数方法,Activity具有生命周期和相关组件信息,只有当ClassLoader被修正后,才能正确加载被解密后的DEX类和方法。
ClassLoader修正的两种解决方案:
- 通过层层反射,拿到mPackage内容,然后根据包名通过LoadApk获取App内的类加载器,最终使用自定义类加载器进行替换。
- 利用双亲委派机制,在BootClassloader和PathClassloader中插入我们自定义的类加载器,完成修复。
方法一是加壳厂商经常会用的方式,也就是通过自定义DexClassLoader对原先的类加载器进行替换修复。
这里还分为两种情况:
- 一种是原始的App没有自定义Application子类,那么这种情况比较简单,直接替换壳的Application即可。
- 另一种情况,是原始App定义了Application子类,那么壳Application的工作就不仅仅是进行解密、类加载器修复等工作了,还有对解密后的DEX原始的Application的方法和类重新进行处理,保证整个过程运行顺畅。
我们可以看AndroidManifest.xml
内的主Application是否被替换为壳Application入口,同时观察壳Application的汇编代码,会发现进行了大量的修复。