360插件化框架 RePlugin 之 ClassLoader Hook

/ 移动技术 / 没有评论 / 648浏览

360插件化框架 RePlugin 之 ClassLoader Hook

前言

工作原因,最近在研究 RePlugin 。RePlugin 是360手机卫士团队开发的占坑类插件化框架。 近几年 Android 插件化非常火,各公司都有不同的插件化方案。这些插件化框架都难免修改系统的 API 来实现一些特性,多处修改系统的 API 带来了一定的风险。如果 Android 系统升级之后有改动,那么插件化框架就有可能面临不可用的状态。尤其是在 Google 明确表示从 Android9.0 之后将会逐步禁用非官方 API。总之插件化带来了很多的好处,同时也存在一定的风险。RePlugin 的强大之处在于,它只 Hook 了一处系统 API,那就是 ClassLoader。关于 RePlugin 可能会写一系列的文章,但是不会以系统的整个流程这种宏观的方式去写,会从某一个角度出发在源码的基础上进行分析,同时给出合适的 Demo。下面就开始本篇的正题,分享一下 RePlugin 是如何 Hook ClassLoader 的。

什么是ClassLoader

ClassLoader 按字面意思就是类加载器。Android 应用程序的运行是基于虚拟机的,我们在写代码的时候所有的类文件(.java),在编译阶段都会编译成二进制文件(.class)。在 Android 打包成 apk 的过程中 class 文件会打包到 dex 里。Android 的虚拟机执行的就是 dex 文件。在程序运行过程中,当我们需要 new 一个对象的时候,虚拟机首先需要加载这个对象所对应的 class,加载 class 就是由 ClassLoader 来做的。在 Android 系统中我们常用的 ClassLoader 有两种,一种是 PathClassLoader 另一种是 DexClassLoader,它们都是继承自 BaseDexClassLoader。我们都知道 PathClassLoader 只能加载我们安装过的 apk,DexClassLoader 可以加载 sd 卡上的 apk。那么为什么 DexClassLoader 可以加载 sd 卡上的 apk 呢?

我们分别来看一下 PathClassLoader 和 DexClassLoader 的源码。

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);
    }
}

public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

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);
    }
}

以上就是 PathClassLoader 和 DexClassLoader 的构造方法。它们的主要不同之处就在于 optimizedDirectory 这个参数,PathClassLoader 的 optimizedDirectory 是 null,DexClassLoader 可以接受一个我们自己定义的路径,接下来再看这个参数是在哪使用的。DexPathList 的构造方法接收了这个参数,然后又把它传到了 makeDexElements 中,在 makeDexElements 中 loadDexFile 又接收了这个参数

DexPathList.java

public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory);
    }

    private static Element[] makeDexElements(ArrayList<File> files,
            File optimizedDirectory) {
        ArrayList<Element> elements = new ArrayList<Element>();
        for (File file : files) {
            ZipFile zip = null;
            DexFile dex = null;
            String name = file.getName();
            if (name.endsWith(DEX_SUFFIX)) {
                dex = loadDexFile(file, optimizedDirectory);
            } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
                    || name.endsWith(ZIP_SUFFIX)) {
                zip = new ZipFile(file);
            }

            if ((zip != null) || (dex != null)) {
                elements.add(new Element(file, zip, dex));
            }
        }
        return elements.toArray(new Element[elements.size()]);
    }

    private static DexFile loadDexFile(File file, File optimizedDirectory)
            throws IOException {
            //主要的区别就在这里,PathClassLoader 的 optimizedDirectory 一定是 null
        if (optimizedDirectory == null) {
            return new DexFile(file);
        } else {
            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
            return DexFile.loadDex(file.getPath(), optimizedPath, 0);
        }
    }

    private static String optimizedPathFor(File path,
            File optimizedDirectory) {
        String fileName = path.getName();
        if (!fileName.endsWith(DEX_SUFFIX)) {
            int lastDot = fileName.lastIndexOf(".");
            if (lastDot < 0) {
                fileName += DEX_SUFFIX;
            } else {
                StringBuilder sb = new StringBuilder(lastDot + 4);
                sb.append(fileName, 0, lastDot);
                sb.append(DEX_SUFFIX);
                fileName = sb.toString();
            }
        }
        File result = new File(optimizedDirectory, fileName);
        return result.getPath();
    }

optimizedDirectory 是用来缓存我们需要加载的 dex 文件的,并创建一个 DexFile 对象,如果它为null,那么会直接使用dex文件原有的路径来创建 DexFile 对象。

optimizedDirectory 必须是一个内部存储路径,无论哪种动态加载,加载的可执行文件一定要存放在内部存储。DexClassLoader 可以指定自己的 optimizedDirectory,所以它可以加载外部的 dex,因为这个 dex 会被复制到内部路径的 optimizedDirectory;而 PathClassLoader 没有 optimizedDirectory,所以它只能加载内部的 dex,就是在系统中已经安装过的 apk 里面的。我们再来看一下为什么可执行文件一定要放在内部存储,直接上 DexFile 的代码。

DexFile.java
 private DexFile(String sourceName, String outputName, int flags) throws IOException {
        if (outputName != null) {
            try {
                String parent = new File(outputName).getParent();
                if (Libcore.os.getuid() != Libcore.os.stat(parent).st_uid) {
                    throw new IllegalArgumentException("Optimized data directory " + parent
                            + " is not owned by the current user. Shared storage cannot protect"
                            + " your application from code injection attacks.");
                }
            } catch (ErrnoException ignored) {
                // assume we'll fail with a more contextual error later
            }
        }

        mCookie = openDexFile(sourceName, outputName, flags);
        mFileName = sourceName;
        guard.open("close");
    }

从上面的代码我们可以看到,当 outputName!=null 的时候,如果传入的是一个 sd 卡的路径会抛出一个 IllegalArgumentException 异常,表示传入的目录不属于当前用户,sd 卡是共享的目录,有可能引起注入攻击。

以上就是 DexClassLoader 和 PathClassLoader 的区别。

双亲委托模型

通过 ClassLoader 的构造方法我们可以看到,当我们要实例化一个 ClassLoader 时必须传入一个parent classloader 参数,这样 ClassLoader 之间就有了依赖关系。当类加载器要加载某个类时,首先将加载任务委托给父类加载器。如果父类加载器可以加载这个类,就成功返回。当父类加载器无法加载这个类时,才自己去加载。RePlugin 之所以能通过 Hook ClassLoader 来实现插件的加载就是它打破了原有的双亲委托模型。在加载类时先从插件中去加载,当插件无法加载时再从宿主加载。

RePlugin是如何Hook ClassLoader的

在集成 RePlugin 时,Application 需要继承 RePluginApplication,我们看一下 RePluginApplication 的 attachBaseContext 方法

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
		 ...
        RePlugin.App.attachBaseContext(this, c);
    }
    

关键的代码就是 RePlugin.App.attachBaseContext(this, c) 我们进去看一下它具体做了什么。

public static void attachBaseContext(Application app, RePluginConfig config) {

			...省略了大部分代码,其中主要是关于UI进程和常驻进程的一些初始化工作
			
            PMF.init(app);
          ...
        }

只看我们关心的 Hook ClassLoader 的地方,其他的关于进程初始化的代码以后再做分析。在 attachBaseContext 方法中有调用了 PMF.init(app) 在 init 方法里又调用了 PatchClassLoaderUtils.patch(application) 这就是 Hook ClassLoader 的地方,再进去看一下

    public static final void init(Application application) {
        setApplicationContext(application);
			...
        PatchClassLoaderUtils.patch(application);
    }

    public static boolean patch(Application application) {
        try {
            ...
            
            // 获取原始的classloader
            ClassLoader oClassLoader = (ClassLoader) ReflectUtils.readField(oPackageInfo, "mClassLoader");
            
            // 创建RePluginClassLoader类,就是我们要替换的classloader
            ClassLoader cl = RePlugin.getConfig().getCallbacks().createClassLoader(oClassLoader.getParent(), oClassLoader);

            // 将新的RePluginClassLoader写入mPackageInfo.mClassLoader,这里就是最重要的Hook点
            ReflectUtils.writeField(oPackageInfo, "mClassLoader", cl);
            
            ...
            
        } catch (Throwable e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

我们看到通过反射替换了系统的 ClassLoader 变成了 RePluginClassLoader,接下来我们看一下 RePluginClassLoader 的 loadClass方法。

RePluginClassLoader

 @Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        //先用插件的classloader去加载类
        Class<?> c = null;
        c = PMF.loadClass(className, resolve);
        if (c != null) {
            return c;
        }
        //插件找不到用宿主的classloader去加载
        try {
            c = mOrig.loadClass(className);
            return c;
        } catch (Throwable e) {
        }
        return super.loadClass(className, resolve);
    }

在 RePluginClassLoader 的 loadClass 方法中先调用了 PMF.loadClass(className, resolve)。每个插件都会对应一个ClassLoader,这个方法会调用插件的 ClassLoader 也就是 PluginDexClassLoader,去加载插件的类。我们看一下 PluginDexClassLoader。

@Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        // 插件自己的Class。从自己开始一直到BootClassLoader,采用正常的双亲委派模型流程,读到了就直接返回
        Class<?> pc = null;
        ClassNotFoundException cnfException = null;
        try {
            pc = super.loadClass(className, resolve);
        } catch (ClassNotFoundException e) {
            cnfException = e;
        }
	
		...
        return null;
    }

在 Android 中没有在 AndroidManifest.xml 文件中注册的 Activity 是无法启动的。那么在 RePlugin 是如何如何做到骗过系统的呢?那就是通过提前在宿主的 Manifest 注册好一些 Activity 也就是坑位的概念,当我们要启动一个插件的 Activity 的时候,会在宿主的 Manifest 中启动一个对应的坑位 Activity 。坑位的生成是在编译期通过 gradle 插件实现的。

接下来我们就以此为基础把 RePlugin 精简为一个小的插件化框架,我们目前只实现 Activity 的加载。 我们实现插件化的套路就是

首先定义 HostClassLoader 和 PluginClassLoder


public class HostClassLoader extends PathClassLoader {

    public static Resources mPkgResources;
    public static PluginContext mPkgContext;
    private final ClassLoader mOrig;
    static PluginClassLoader dexClassLoader;

    public HostClassLoader(ClassLoader origin, ClassLoader parent) {
        super("", "", parent);
        mOrig = origin;

        String path = Environment.getExternalStorageDirectory() + File.separator;
        String filename = "plugin-debug.apk";

        File optimizedDirectoryFile = App.context.getCacheDir();
        //初始化插件的ClassLoader,替换插件的资源路径。让插件能找到自己的资源文件。
        dexClassLoader = new PluginClassLoader(path + filename, optimizedDirectoryFile.getAbsolutePath(),
                null, this.getClass().getClassLoader());

        PackageManager pm = App.context.getPackageManager();
        PackageInfo mPackageInfo = pm.getPackageArchiveInfo(path + filename,
                PackageManager.GET_ACTIVITIES | PackageManager.GET_SERVICES | PackageManager.GET_PROVIDERS | PackageManager.GET_RECEIVERS | PackageManager.GET_META_DATA);
        mPackageInfo.applicationInfo.sourceDir = path + filename;
        mPackageInfo.applicationInfo.publicSourceDir = path + filename;
        try {
            mPkgResources = pm.getResourcesForApplication(mPackageInfo.applicationInfo);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
    }

    public static Object getContext(Activity activity, Context context) {
        mPkgContext = new PluginContext(context, android.R.style.Theme, activity.getClass().getClassLoader(), mPkgResources);
        return mPkgContext;
    }

    @Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {

        Class<?> c;
        c = dexClassLoader.loadClass(className);

        if (c != null) {
            return c;
        }
        try {
            c = mOrig.loadClass(className);
            return c;
        } catch (Throwable e) {
        }
        return super.loadClass(className, resolve);
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        return super.findClass(className);
    }
}


public class PluginClassLoader extends DexClassLoader {
    public PluginClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        super(dexPath, optimizedDirectory, librarySearchPath, parent);
    }

    @Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
    
    //欺骗系统,当我们要启动com.host.sample.fake时,用插件的主页面替换掉,
    //让系统去加载MainActivity这个类并返回,
    //这样就相当于com.host.sample.fake这个坑位由MainActivity占领了。
    
        if (className.contains("com.host.sample.fake")) {
            className = "com.plugin.sample.MainActivity";
        }
        Class<?> pc;
        try {
            pc = super.loadClass(className, resolve);
            if (pc != null) {
                return pc;
            }
        } catch (ClassNotFoundException e) {
        }

        return null;
    }
}

Hook 系统的 ClassLoader

public class App extends Application {

    public static Context context;

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        context = base;
        //在这里把系统的ClassLoader替换成HostClassLoader
        patch(this);
    }

    public static boolean patch(Application application) {
        try {
            Context oBase = application.getBaseContext();
            Object oPackageInfo = readField(oBase, "mPackageInfo");
            ClassLoader oClassLoader = (ClassLoader) readField(oPackageInfo, "mClassLoader");
            ClassLoader cl = new HostClassLoader(oClassLoader, oClassLoader.getParent());
            writeField(oPackageInfo, "mClassLoader", cl);
        } catch (Throwable e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

}

在 Manifest 中注册坑位

<activity android:name="com.host.sample.fake" />

public class MainActivity extends Activity {
    Class<?> threadClazz = null;

    @Override
    protected void attachBaseContext(Context newBase) {
        try {
            threadClazz = Class.forName("com.host.sample.HostClassLoader");
            Method method = threadClazz.getMethod("getContext", Activity.class, Context.class);
            newBase = (Context) method.invoke(null, this, newBase);

        } catch (Exception e) {
            e.printStackTrace();
        }

        super.attachBaseContext(newBase);
    }
}


最后启动插件的 Activity

 findViewById(R.id.open).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent();
                //这里真实启动的就是插件的Activity
                intent.setComponent(new ComponentName("com.host.sample", "com.host.sample.fake"));
                try {
                    MainActivity.this.startActivity(intent);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });

希望通过这个小例子能让大家加深对 RePlugin 的理解,以上 demo 是在 Android 6.0 上跑的,不保证兼容性问题。

源码地址:https://github.com/77Y/HostSample