(十三)Dex 加解密与多 Dex 加载

版权声明:本文为博主原创文章,未经博主允许不得转载。
本文纯个人学习笔记,由于水平有限,难免有所出错,有发现的可以交流一下。

一、加密原理

一个安卓 apk 里面包含有 AndroidManifest.xml、classes.dex、resources.arsc 等文件,java 代码主要存在于 classes.dex 中。

把 classes.dex 文件拆分为两个 dex 文件,其中主 dex 文件就只包含应用加载的 Application,另外一个 dex 文件包含其他所有类。然后,对非主 dex 文件进行加密,得到加密后的 dex 文件,在 Application 中进行加密后的 dex 的加载和解密操作。

(十三)Dex 加解密与多 Dex 加载_第1张图片

这样的方案大体上实现了加密操作,但是,我们很经常在 Application 类中进行一些初始化操作,会引入较多的类,Application 类存放于主 dex 中,也会泄漏较多的信息。

在这边进行对加密方案的优化,对 Application 也进行加密。把 classes.dex 文件进行重命名,改为非主 dex,然后对这个 dex 进行加密,这样加密后的 dex 就包含所有的类,包括 Application。我们需要添加一个 dex 作为主 dex,在这里面只包含一个 Application 类,这个类的作用就是对加密后的 dex 进行加载和解密。

(十三)Dex 加解密与多 Dex 加载_第2张图片

二、ClassLoader

(十三)Dex 加解密与多 Dex 加载_第3张图片

类加载器,我们常用的一般是 PathClassLoader 和 DexClassLoader。我们在安卓中,用到的是 PathClassLoader 这个类加载器,可以直接在代码中通过 getClassLoader() 进行打印出来,进行验证。

 源码目录  \libcore\dalvik\src\main\java\dalvik\system 

1.PathClassLoader

PathClassLoader:

public class PathClassLoader extends BaseDexClassLoader {

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

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

PathClassLoader 里面没干什么事情,就继承了 BaseDexClassLoader。BaseDexClassLoader 是通过 findClass 方法来查找到一个类的,传递一个全类名进来,然后获取到一个类对象。

2.BaseDexClassLoader

BaseDexClassLoader 的 findClass:

    protected Class findClass(String name) throws ClassNotFoundException {
        List suppressedExceptions = new ArrayList();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

findClass 方法里面是通过 DexPathList 的 findClass 方法进行查找。

3.DexPathList

DexPathList 的 findClass:

    /**
     * List of dex/resource (class path) elements.
     * Should be called pathElements, but the Facebook app uses reflection
     * to modify 'dexElements' (http://b/7726934).
     */
    private Element[] dexElements;
    
    public Class findClass(String name, List suppressed) {
	    // 遍历成员数组 dexElements
        for (Element element : dexElements) {
	        //加载 dex
            DexFile dex = element.dexFile;

            if (dex != null) {
	            // 加载 dex 里面的类
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

dexElements 是一个 dex 的数组节点,通过注释我们可以看到,Facebook 的 app 通过反射对这个进行了修改。dexElements 保存的是应用要加载的 dex 文件数组,默认是保存了 classes.dex 这个文件,当我们要对应用进行 dex 分包的时候,需要把其他的 dex 添加到这里面。

三、工具

这边需要用到几个工具进行,这几个工具在安卓的 sdk 下都可以找到。

1. dx 工具

我们使用 sdk\build-tools 下的 dx.bat 把一个 jar 包生成 dex 文件。

生成 dex 指令:dx --dex --output out.dex in.jar

2. zipalign 工具

我们使用 sdk\build-tools 下的 zipalign.exe 对未压缩的数据开头均相对于文件开头部分执行特定的字节对齐,减少应用运行内存。

整理对齐指令:zipalign [-v] [-f]  4 in.apk out.apk

3. apksigner 工具

我们使用 sdk\build-tools (SDK 24 以上)下的 apksigner.bat 对未签名的 APK 进行签名操作。

apk 签名指令:apksigner sign  --ks jks文件地址 --ks-key-alias 别名 --ks-pass pass:jsk密码 --key-pass pass:别名密码 --out  out.apk in.apk

校验命令:apksigner verify -v out.apk

四、解密和加载多个 dex

我们先来实现 apk 的解密部分。新建一个 Android Library 工程 proxy-guard-core。
(十三)Dex 加解密与多 Dex 加载_第4张图片

我们需要创建一个 ProxyApplication 继承自 Application,作为假的 Application,配置在 AndroidManifest.xml 中。

(十三)Dex 加解密与多 Dex 加载_第5张图片

ActivityThread 创建 Application 之后调用的第一个函数是 attachBaseContext()这个方法(具体就不解释了),我们需要在这个方法里面进行真正 dex 的解密和加载。

1.创建文件夹

attachBaseContext():

    // 真实的 Application 全类名,在 AndroidManifest.xml 中配置
    private String app_name;
    // 一个版本标识,用于 dex 解密后的目录名,在 AndroidManifest.xml 中配置
    private String app_version;

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);

        // 获取 AndroidMainfest.xml 中的 app_name 和 app_version
        getMetaData();
        Log.e("", "111 zx " + app_name + app_version);
        // 获得当前应用的 apk 文件
        File apkFile = new File(getApplicationInfo().sourceDir);
        // 把 apk 解压出来,存放在/data/data/包名/app_name_app_version/app 下
        File versionDir = getDir(app_name + "_" + app_version, MODE_PRIVATE);
        File appDir = new File(versionDir, "app");

        // apk 中解密后的所有 dex 放入到这个目录
        File dexDir = new File(appDir, "dexDir");
   }

在 AndroidMainfest.xml 中保存了 app_name 与 app_version 两个参数,用来对各个版本的 apk 创建临时文件,使用 getMetaData()方法去进行这两个值的读取,也可以使用其他方法。
在这里插入图片描述

创建 data/data/包名/app_name_app_version/app 目录用来存放解压后的 apk,创建 data/data/包名/app_name_app_version/app/dexDir 目录用来存放解密后的 dex 文件。
(十三)Dex 加解密与多 Dex 加载_第6张图片

getMetaData():

   /**
     * 获取 AndroidMainfest.xml 下配置的 meta-data
     */
    public void getMetaData(){
        try {
            ApplicationInfo applicationInfo = getPackageManager()
                    .getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);

            Bundle metaData = applicationInfo.metaData;
            //是否设置app_name 与 app_version
            if (null != metaData) {
                //是否存在name为app_name的meta-data数据
                if (metaData.containsKey("app_name")) {
                    app_name = metaData.getString("app_name");
                }
                if (metaData.containsKey("app_version")) {
                    app_version = metaData.getString("app_version");
                }
            }

        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
    }

2.解密 dex

attachBaseContext():

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
		...
		
        // 第一次进入,需要对 dex 进行解密
        // 如果需要,还可以对 dex 先进行 md5 校验
        if (!dexDir.exists() ){
            dexDir.mkdirs();
        }
        if (dexDir.list().length == 0){

            // apk 解压到 appDir 文件夹下
            ZipUtil.unZip(apkFile, appDir);
            // 获取解压后的 apk 所有文件
            File[] files = appDir.listFiles();

            for (File file : files){

                String name = file.getName();
                // 文件名是 .dex 结尾,且不是主 dex 的话,放入 dexDir 目录
                if (name.endsWith(".dex") && !"classes.dex".equals(name)) {
                    byte[] bytes = FileUtil.getBytes(file);
                    // 进行解密,并且保存到 dexDir 目录下
                    byte[] plaintext = AESUtil.decrypt(bytes, "asdfghjuyt123456");

                    FileOutputStream writer = null;
                    try {
                        Log.d("test", "111 zx plaintext: " + plaintext.length);
                        // 写到指定目录
                        writer = new FileOutputStream(new File(dexDir.getAbsolutePath(), name));
                        writer.write(plaintext);
                        writer.flush();

                    } catch (FileNotFoundException e) {
                        e.printStackTrace();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }finally {
                        try {
                            if (writer != null){
                                writer.close();
                            }
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }

这一步比较简单,先把 apk 解压到 data/data/包名/app_name_app_version/app 目录,采用的解压工具类比较常见。遍历解压出来的文件,查找需要进行解密的 dex,调用解密方法,并把解密后的文件保存在 data/data/包名/app_name_app_version/app/dexDir 目录下。

这个解密需要与加密方法对应,这边采用的简单的 AES 加解密作为例子,具体实际可根据要求采用对应的加解密方式。另外,解密方法还可以考虑使用 so 库进行代替,增加安全性。自己编写或者使用 openSSL 等。

3.加载解密后的 dex

attachBaseContext():

    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        ...
        
        // 解密后的 dex,需要加载
        Log.d("test", "111 zx dexDir: " + dexDir.getAbsolutePath());
        List dexFiles = new ArrayList<>();
        for (File file : dexDir.listFiles()) {
            dexFiles.add(file);
        }
        loadDex(dexFiles, versionDir);
    }

上面查看 ClassLoader 可以知道,dexElements 数组保存着需要加载的 dex,要把我们自己的 dex 加载到应用中,只要添加到 dexElements 这个数组中即可。

由于 dexElements 是私有的属性,且没有方法可以直接进行设置,所以采用反射,对 dexElements 数组进行操作。

loadDex():

    /**
     * 加载 dex
     * @param dexFiles 需要加载的 dex 集合
     * @param optimizedDirectory dex 加载缓存目录
     */
    private void loadDex(List dexFiles, File optimizedDirectory){

        try {
            /**
             * 1.获得系统 classloader 中的 dexElements 数组
             */
            // 获得 classloader 中的 pathList(是一个 DexPathList)
            Field pathListField = ClassUtil.findField(getClassLoader(), "pathList");
            Object pathList = pathListField.get(getClassLoader());

            // 获得pathList类中的 dexElements
            Field dexElementsField = ClassUtil.findField(pathList, "dexElements");
            Object[] dexElements = (Object[]) dexElementsField.get(pathList);

            /**
             * 2.创建新的 element 数组 -- 解密后加载dex
             */
            // 需要适配安卓版本,5.0、6.0、7.0 都不一样
            // 具体要看各个版本的 dexElements 的创建方法是哪个,对这个方法进行反射
            ArrayList suppressedExceptions = new ArrayList();
            Method makeDexElements = null;
            // Element 数组
            Object[] addElements = null;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && Build.VERSION.SDK_INT <
                    Build.VERSION_CODES.M) {
                makeDexElements = ClassUtil.findMethod(pathList, "makeDexElements", ArrayList.class,
                        File.class, ArrayList.class);
                addElements = (Object[]) makeDexElements.invoke(pathList, dexFiles,
                        optimizedDirectory, suppressedExceptions);
            } else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.N){
                makeDexElements = ClassUtil.findMethod(pathList, "makePathElements", ArrayList.class,
                        File.class, ArrayList.class);
                addElements = (Object[]) makeDexElements.invoke(pathList, dexFiles,
                        optimizedDirectory, suppressedExceptions);
            }else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
                makeDexElements = ClassUtil.findMethod(pathList, "makePathElements", ArrayList.class,
                        File.class, ArrayList.class, ClassLoader.class);
                addElements = (Object[]) makeDexElements.invoke(pathList, dexFiles,
                        optimizedDirectory, suppressedExceptions, getClassLoader());
            }

            /**
             * 3.合并两个数组
             */
            //创建一个数组
            Object[] newElements = (Object[]) Array.newInstance(dexElements.getClass()
                    .getComponentType(), dexElements.length +
                    addElements.length);
            System.arraycopy(dexElements, 0, newElements, 0, dexElements.length);
            System.arraycopy(addElements, 0, newElements, dexElements.length, addElements.length);

            /**
             * 4.替换classloader中的 element数组
             */
            dexElementsField.set(pathList, newElements);

        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

4.Application 的替换

由于在 AndroidMainfest.xml 中配置的 application 为 ProxyApplication,所以需要把原先在 application 中实现的初始化操作过程也移到 ProxyApplication 中。例如创建 ProxyApplication 的单例。

    private static ProxyApplication app;

    @Override
    public void onCreate() {
        app = this;
        super.onCreate();

    }
    /** 获取单例 */
    public static ProxyApplication getInstance() {
        return app;
    }

五、加密和生成多个 dex

新建一个 Java Library 工程 proxy-guard-tools。
在这里插入图片描述

proxy-guard-tools 主要是作为一个 java 工具,通过运行 main 方法实现一些操作:

1.把 proxy-guard-core 打包成主 dex。
2.解压程序的 apk,对原先的主 dex 进行加密,以及修改名称,成为非主 dex。
3.把刚打包生成的主 dex 整合到 apk 的解压目录下,重新打包成 apk。
4.对 apk 进行对齐以及签名操作。

1.制作主 dex

需要把上面编写的 proxy-guard-core 下的代码制作成主 dex,让应用启动的时候就进行加载。

首先需要获取到 java 代码生成的 jar 包,可以自己使用命令进行打包。这边直接使用 proxy-guard-core 的 aar 文件进行解压到 proxy-guard-tools/aarTemp 下获取。(如果没有存在 aar 文件, Rebuild 一下即可。)
(十三)Dex 加解密与多 Dex 加载_第7张图片

        // 1.解压 aar,获取 proxy-guard-core 的 java 代码生成的 classes.jar
        File aarFile = new File("proxy-guard-core/build/outputs/aar/proxy-guard-core-debug.aar");
        File aarTemp = new File("proxy-guard-tools/aarTemp");
        ZipUtil.unZip(aarFile, aarTemp);

使用 dex 命令对获取的 jar 包进行打包生成 dex。1.这个 dex 的名字必须是 classes.dex。2,dex 命令没有偶在环境变量中进行配置的话是会出错的。

        // 2.执行 dex 命令,将 jar 打包成 dex 文件
        File classesJar = new File(aarTemp, "classes.jar");
        File classesDex = new File(aarTemp, "classes.dex");
        // 执行命令,windows 需要添加 cmd /c 才可以
        Process process = Runtime.getRuntime().exec("cmd /c dx --dex --output "
                +   classesDex.getAbsolutePath() + " " + classesJar.getAbsolutePath());
        process.waitFor();
        // 失败
        if (process.exitValue() != 0){
            throw new RuntimeException("dex error");
        }

执行成功后,会在 proxy-guard-tools 工程下生成一个 aarTemp 文件夹,里面保存着 proxy-guard-core 工程的 java 代码打包的 dex。
(十三)Dex 加解密与多 Dex 加载_第8张图片

2.加密 apk 中的 dex

首先需要先使用证书对应用的进行签名打包,生成带证书的 apk。需要记住这本证书,等会重新签名时候需要用到。
(十三)Dex 加解密与多 Dex 加载_第9张图片

解压生成的 apk 到 proxy-guard-tools 工程下的 apkTemp,正常解压即可。

        // 1.解压 apk
        File apkFile = new File("app/release/app-release.apk");
        File apkTemp = new File("proxy-guard-tools/apkTemp");
        ZipUtil.unZip(apkFile, apkTemp);

在解压出来的文件中进行查找,如果是 dex 的话进行加密操作(这个加密算法与上方的解密算法对应,这里不需要使用 so 库进行调用,直接 java 代码即可)。同时需要重命名(主要是 classes.dex 这个主 dex 需要重命名)。

        // 2.获得 apk 中所有的 dex,并进行加密
        File[] dexFiles = apkTemp.listFiles(new FilenameFilter() {
            @Override
            public boolean accept(File file, String s) {
                return s.endsWith(".dex");
            }
        });

        // 加密
        for (File dex : dexFiles) {
            encryFile(dex, new File(apkTemp, "secret-" + dex.getName()));
        }

encryFile():

    private static void encryFile(File dex, File secretDex){

        RandomAccessFile read = null;
        FileOutputStream writer = null;

        try {
            // 读取 dex 文件的字节数组
            read = new RandomAccessFile(dex, "r");
            byte[] buffer = new byte[(int) read.length()];
            read.readFully(buffer);

            // 加密
            byte[] ciphertext = AESUtil.encrypt(buffer, "asdfghjuyt123456");

            // 写到指定目录
            writer = new FileOutputStream(secretDex);
            writer.write(ciphertext);
            writer.flush();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                if (read != null){
                    read.close();
                    dex.delete();
                }
                if (writer != null){
                    writer.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

运行成功后,会在 proxy-guard-tools 工程下的 apkTemp 中解压出 apk 的所有内容,并且 classes.dex 已经被加密为 secret-classes.dex。
(十三)Dex 加解密与多 Dex 加载_第10张图片

3.重新打包 apk

把上面 proxy-guard-core 打包生成的 classes.dex 拷贝到 apkTemp 目录下,使用压缩命令,重新打包为 apk,并保存在 proxy-guard-tools/outputs 下。

        /**
         * 把 classes.dex 放入 apk 解压的目录,再压缩成 apk
         */
        classesDex.renameTo(new File(apkTemp, "classes.dex"));

        File file = new File("proxy-guard-tools/outputs");
        if (!file.exists()){
            file.mkdirs();
        }
        File unSignedApk = new File("proxy-guard-tools/outputs/app-unsigned.apk");
        ZipUtil.zip(apkTemp, unSignedApk);

(十三)Dex 加解密与多 Dex 加载_第11张图片

4.apk 的对齐与签名

上述打包生产的 apk 是未签名的,我们需要对这个 apk 进行对齐和签名。

使用 zipalign 进行对齐,可以减少应用的运行内存。

        //1.对齐
        // 26.0.2 不支持 -p 参数
        File alignedApk = new File("proxy-guard-tools/outputs/app-unsigned-aligned.apk");
        process = Runtime.getRuntime().exec("cmd /c zipalign -f 4 " + unSignedApk
                .getAbsolutePath() + " " +
                alignedApk.getAbsolutePath());
        process.waitFor();
        // 失败
        if (process.exitValue() != 0) {
            throw new RuntimeException("zipalign error");
        }

对对齐后的应用进行签名操作。这个签名证书需要使用前面对 apk 进行签名使用的证书。

        // 2.签名
        File signedApk = new File("proxy-guard-tools/outputs/app-signed-aligned.apk");
        File jks = new File("proxy-guard-tools/xiaoyue.jks");
        process = Runtime.getRuntime().exec("cmd /c apksigner sign  --ks " + jks.getAbsolutePath()
                + " --ks-key-alias xiaoyue --ks-pass pass:xiaoyue --key-pass  pass:xiaoyue --out "
                + signedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());

        System.out.print("cmd /c apksigner sign  --ks " + jks.getAbsolutePath()
                + " --ks-key-alias xiaoyue --ks-pass pass:xiaoyue --key-pass  pass:xiaoyue --out "
                + signedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());
        process.waitFor();
        //失败
        if (process.exitValue() != 0) {
            throw new RuntimeException("apksigner error");
        }

(十三)Dex 加解密与多 Dex 加载_第12张图片

这时候安装即可正常运行,解压 apk 可以发现,我们编写的 java 代码被进行加密操作,无法进行反编译查看。第一次运行会比较慢,因为要进行解密操作。

六、问题

在整个过程中碰到几个问题,这边记录一下,可以参考一下。

1.各个命令没有配置在环境变量中。
由于这边是使用代码进行运行命令行,所以需要环境变量支持的,编写成一个 java 方法,后期可以复用。

2.java 版本与各个命令运行环境要求不符。
碰见过签名时候 JDK 版本不对,导致签名失败。可以只在在命令行窗口进行运行命令看能不能成功。修改完 JDK 版本后,需要重启 Android Studio。

3.两次签名使用证书不同。
apk 打包的时候需要自己选择好证书进行打包,后面重新打包的时候也要使用这一个证书。

4.Rebuild 时候,proxy-guard-core 下 build 有存在的话,有时候不会重新进行打包。
应该是我使用的这个版本 Android Studio 的 bug 吧,进行手动删除,然后再 Rebuild。

七、附

代码链接

你可能感兴趣的:(性能优化)