APK加固原理详解

一、前言

之前使用的360加固,挺好用的,从2021年底的时候限制每天每个账号仅上传2次apk(免费的,不知道VIP的是不是这样)。通过这个事情,感觉技术还是掌握在自己手里稳妥点,不用受制于人,想怎么玩就怎么玩。

通过技术调研有两条路子可以走:

  • 方式一:直接对apk进行加密,启动应用时通过壳程序去加载apk运行;

  • 方式二:仅对原apk的dex文件进行加密,启动应用时对dex解密,通过DexClassLoader进行加载;

本文主要是参考了360免费加固的思路,所以主要研究的方式二。

二、原理

先看下流程,然后再来详细讲下具体的步骤

加固流程

根据上流程图可以总结如下七个步骤:
步骤一:将加固壳中的aar中的jar利用dx工具转成dex文件
步骤二:将需要加固的APK解压,并将所有dex文件打包成一个zip包,方便后续进行加密处理
步骤三:对步骤二的zip包进行加密,并与壳dex合成新dex文件
步骤四:修改AndroidManifest(替换Application的android:name属性和新增<meta-data>)
步骤五:将步骤三生成的新dex文件替换apk中的所有dex文件
步骤六:APK对齐处理
步骤七:对生成的APK进行签名

到这其实就把APK加固流程讲完了,下面就来结合项目对各步骤进行详解。

三、项目案例

从上步骤中可以看到,加固会涉及到三个工程为:demojiagu_shelljiaguLib,如下图:

image.png

demo工程
普通的app工程,即我们平时开发的工程。主要用于生成待加固的apk。

jiaguLib工程
apk加固主工程,完成apk的加固工作。
1.将加固壳中的aar中的jar利用dx工具转成dex文件
生成Aar包:选中jiagu_shell工程,Build - Make Module 'apkjiagu.jiagu_shell',会在jiagu_shell - build - outputs - aar目录中生成jiagu_shell-debug.aar
aar包生成后,就可以利用dx工具生成dex了,如下核心代码:

/**
     * 步骤一:将加固壳中的aar中的jar转成dex文件
     * @throws Exception 异常
     */
    private File shellAar2Dex() throws Exception{
        logTitle("步骤一:将加固壳中的aar中的jar转成dex文件");
        //步骤一:将加固壳中的aar中的jar转成dex文件
        File aarFile = new File(ROOT+"aar/jiagu_shell-release.aar");
        File aarTemp = new File(OUT_TMP+"shell");
        ZipUtil.unZip(aarFile, aarTemp);
        File classesJar = new File(aarTemp, "classes.jar");
        File classesDex = new File(aarTemp, "classes.dex");
        boolean ret = ProcessUtil.executeCommand(String.format(Locale.CHINESE,"dx --dex --output %s %s",classesDex.getAbsolutePath(),classesJar.getAbsolutePath()));
        if (ret){
            System.out.println("已生成======"+classesDex.getPath());
        }
        return classesDex;
    }

ZipUtil中unZip方法:

public static void unZip(File apkFile,File destDir) throws Exception{
        // 判断源文件是否存在
        if (!apkFile.exists()) {
            throw new Exception(apkFile.getPath() + "所指文件不存在");
        }
        //开始解压
        //构建解压输入流
        ZipInputStream zIn = new ZipInputStream(new FileInputStream(apkFile));
        ZipEntry entry = null;
        File file = null;
        while ((entry = zIn.getNextEntry()) != null) {
            if (!entry.isDirectory() && !entry.getName().equals("")) {
                file = new File(destDir, entry.getName());
                if (!file.exists()) {
                    file.getParentFile().mkdirs();//创建此文件的上级目录
                }
                FileOutputStream fos = new FileOutputStream(file);
                int len = -1;
                byte[] buf = new byte[1024];
                while ((len = zIn.read(buf)) != -1) {
                    fos.write(buf, 0, len);
                }
                // 关流顺序,先打开的后关闭
                fos.flush();
                fos.close();
            }else {
                file = new File(destDir, entry.getName());
                //是文件夹的时候创建目录
                if (!file.exists()){
                    file.mkdirs();
                }
            }
            zIn.closeEntry();
        }
        zIn.close();
    }

ProcessUtil中executeCommand为执行命令方法,如下:

public static boolean executeCommand(String cmd) throws Exception{
        System.out.println("开始执行命令===>"+cmd);
        Process process = Runtime.getRuntime().exec("cmd /c "+cmd);
        ProcessUtil.consumeInputStream(process.getInputStream());
        ProcessUtil.consumeInputStream(process.getErrorStream());
        process.waitFor();
        if (process.exitValue() != 0) {
            throw new RuntimeException("执行命令错误===>"+cmd);
        }
        return true;
    }

jar转dex的命令

命令:dx --dex --output [输出dex] [输入的jar]

2.对待加固的APK解压,并将所有dex文件打包成一个zip包
直接对待加固的apk进行unzip,然后拿到解压目录中的所有dex文件,并打包成一个新的zip。代码如:

private File apkUnzipAndZipDexFiles(){
        logTitle("步骤二:将需要加固的APK解压,并将所有dex文件打包成一个zip包,方便后续进行加密处理");
        //下面加密码APK中所有的dex文件
        File apkFile = new File(ORIGIN_APK);
        File apkTemp = new File(OUT_TMP+"unzip/");
        try {
            //首先把apk解压出来
            ZipUtil.unZip(apkFile, apkTemp);

            //其次获取解压目录中的dex文件
            File dexFiles[] = apkTemp.listFiles(new FilenameFilter() {
                @Override
                public boolean accept(File file, String s) {
                    return s.endsWith(".dex");
                }
            });

            if (dexFiles == null) return null;

            //三:将所有的dex文件压缩为AppDex.zip文件
            File outTmpFile = new File(OUT_TMP);
            File outputFile = new File(outTmpFile,"AppDex.zip");
            //创建目录
            if (!outTmpFile.exists()){
                outTmpFile.mkdirs();
            }
            if (outputFile.exists()){
                outputFile.delete();
            }
            Zip4jUtil.zipFiles(dexFiles,outputFile);
            System.out.println("已生成======"+outputFile.getAbsolutePath());
            FileUtils.deleteFile(apkTemp.getAbsolutePath());
            return outputFile;
        }catch (Exception e){
             e.printStackTrace();
        }
        return null;
    }

这一步比较简单,仅涉及文件的解压和压缩操作。
值得注意:采用系统自带的ZipOutputSteam对dex压缩会存在Bad size问题,故这里采用zip4j包进行压缩。

3.对上述生成的zip进行加密,然后合并到壳dex中
这一步比较关键,涉及到dex文件格式,需要对dex格式进行一定了解。
可以参考Dex文件结构
我们只需要关注以下三个部分:

  • checksum,文件校验码 ,使用alder32 算法校验文件除去 maigc ,checksum 外余下的所有文件区域 ,用于检查文件错误 。
  • signature,使用 SHA-1 算法 hash 除去 magic ,checksum 和 signature 外余下的所有文件区域 ,用于唯一识别本文件 。
  • file_size,Dex文件的总长度。

为什么说我们只需要关注这三个字段呢?

因为我们需要将一个文件(加密之后的源dex包)写入到Dex中,那么我们肯定需要修改文件校验码(checksum).因为他是检查文件是否有错误。那么signature也是一样,也是唯一识别文件的算法。还有就是需要修改dex文件的大小。

不过这里还需要一个操作,就是标注一下我们加密的Zip的大小,当我们脱壳的时候,需要知道Zip的大小,才能正确的得到Zip。这个值直接放到文件的末尾就可以了。

所以总结一下我们需要做:修改Dex的三个文件头,将源Apk的dex包大小追加到壳dex的末尾就可以了。
我们修改之后得到新的Dex文件样式如下:


image.png

具体实现代码如下:

private File combine2NewDexFile(File shellDexFile,File originalDexZipFile){
        logTitle("步骤三:对步骤二的zip包进行加密,并与壳dex合成新dex文件");
        try {
            AESUtil aesUtil = new AESUtil();
            byte[] data = readFileBytes(originalDexZipFile);
            System.out.println("加密前数据大小为:"+data.length);
            byte[] payloadArray = aesUtil.encrypt(data);//以二进制形式读出zip,并进行加密处理//对源Apk进行加密操作
            byte[] unShellDexArray = readFileBytes(shellDexFile);//以二进制形式读出dex
            int payloadLen = payloadArray.length;
            int unShellDexLen = unShellDexArray.length;
            int totalLen = payloadLen + unShellDexLen +4;//多出4字节是存放长度的。
            byte[] newdex = new byte[totalLen]; // 申请了新的长度
            //添加解壳代码
            System.arraycopy(unShellDexArray, 0, newdex, 0, unShellDexLen);//先拷贝dex内容
            //添加加密后的解壳数据
            System.arraycopy(payloadArray, 0, newdex, unShellDexLen, payloadLen);//再在dex内容后面拷贝apk的内容
            //添加解壳数据长度
            System.arraycopy(intToByte(payloadLen), 0, newdex, totalLen-4, 4);//最后4为长度

            //修改DEX file size文件头
            fixFileSizeHeader(newdex);
            //修改DEX SHA1 文件头
            fixSHA1Header(newdex);
            //修改DEX CheckSum文件头
            fixCheckSumHeader(newdex);

            String str = OUT_TMP + "classes.dex";
            File file = new File(str);
            if (!file.exists()) {
                file.createNewFile();
            }

            //输出成新的dex文件
            FileOutputStream localFileOutputStream = new FileOutputStream(str);
            localFileOutputStream.write(newdex);
            localFileOutputStream.flush();
            localFileOutputStream.close();
            System.out.println("已生成新的Dex文件======"+str);

            //删除dex的zip包
            FileUtils.deleteFile(originalDexZipFile.getAbsolutePath());
            return file;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

注意:为了提高破解难度,本文加解密代码采用C写的,并编译成dll文件被java工程引用。若不想那么麻烦可自行修改加密方式。
将C/C++编译成dll供Java工程使用

readFileBytes方法:

    private byte[] readFileBytes(File file) throws IOException {
        byte[] arrayOfByte = new byte[1024];
        ByteArrayOutputStream localByteArrayOutputStream = new ByteArrayOutputStream();
        FileInputStream fis = new FileInputStream(file);
        while (true) {
            int i = fis.read(arrayOfByte);
            if (i != -1) {
                localByteArrayOutputStream.write(arrayOfByte, 0, i);
            } else {
                return localByteArrayOutputStream.toByteArray();
            }
        }
    }

修改文件大小方法,fixFileSizeHeader方法:

private void fixFileSizeHeader(byte[] dexBytes) {
        //新文件长度
        byte[] newfs = intToByte(dexBytes.length);
        System.out.println("fixFileSizeHeader ===== size : " + dexBytes.length);
        byte[] refs = new byte[4];
        //高位在前,低位在前掉个个
        for (int i = 0; i < 4; i++) {
            refs[i] = newfs[newfs.length - 1 - i];
        }
        System.arraycopy(refs, 0, dexBytes, 32, 4);//修改(32-35)
    }

修改dex头中的sinature方法,fixSHA1Header:

    /**
     * 修改dex头 sha1值
     * @param dexBytes
     * @throws NoSuchAlgorithmException
     */
    private void fixSHA1Header(byte[] dexBytes)
            throws NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("SHA-1");
        md.update(dexBytes, 32, dexBytes.length - 32);//从32为到结束计算sha--1
        byte[] newdt = md.digest();
        System.arraycopy(newdt, 0, dexBytes, 12, 20);//修改sha-1值(12-31)
        //输出sha-1值,可有可无
        String hexstr = "";
        for (int i = 0; i < newdt.length; i++) {
            hexstr += Integer.toString((newdt[i] & 0xff) + 0x100, 16)
                    .substring(1);
        }
    }

修改CheckSum值

    /**
     * 修改dex头,CheckSum 校验码
     * @param dexBytes
     */
    private void fixCheckSumHeader(byte[] dexBytes) {
        Adler32 adler = new Adler32();
        adler.update(dexBytes, 12, dexBytes.length - 12);//从12到文件末尾计算校验码
        long value = adler.getValue();
        int va = (int) value;
        byte[] newcs = intToByte(va);
        //高位在前,低位在前掉个个
        byte[] recs = new byte[4];
        for (int i = 0; i < 4; i++) {
            recs[i] = newcs[newcs.length - 1 - i];
        }
        System.arraycopy(recs, 0, dexBytes, 8, 4);//效验码赋值(8-11)
    }

到这里,我们就生成了加密后的dex文件,这时在Android studio中查看,你会发现仅能看到脱壳的类信息。
4.修改原APK中的AndroidManifest.xml文件
为了保证能正常使用apktool命令对apk正常反编译和回编译,我们要先修改AndroidManifest.xml,再对dex进行替换。若先替换dex,在对apk进行回编译时,加密的数据回丢失,导致包错误。
在这一步,主要采用apktool对apk进行反编译,通过代码修改AndroidManifest.xml,然后在进行回编译重新生成新的Apk。
具体实现代码如下:

    private String modifyOriginApkManifest() throws Exception{
        String apkPath = ORIGIN_APK;
        String outputPath = OUT_TMP + "apk/";
        logTitle("步骤四:修改AndroidManifest(Application的android:name属性和新增<meta-data>)");
        String path = "";
        long start = System.currentTimeMillis();
        //1:执行命令进行反编译原apk
        System.out.println("开始反编译原apk ......");
        boolean ret = ProcessUtil.executeCommand("apktool d -o " + outputPath + " " + apkPath);
        if (ret){
            //2.修改AndroidManifest.xml,使用壳的Application替换原Application,并将原Application名称配置在meta-data中
            modifyAndroidManifest(new File(outputPath,"AndroidManifest.xml"));

            //3:重新编译成apk,仍以原来名称命名
            System.out.println("开始回编译apk ......");
            String apk = OUT_TMP + apkPath.substring(apkPath.lastIndexOf("/")+1);
            ret = ProcessUtil.executeCommand(String.format(Locale.CHINESE,"apktool b -o %s %s",apk,outputPath));
            if (ret){
                path = apk;
            }
            System.out.println("=== modifyOriginApkManifest ==== "+(System.currentTimeMillis()-start)+"ms");
        }
        return path;
    }

修改AndroidManifest.xml主要做的内容为:
1.替换<application>标签中android:name值为com.zhh.jiagu.shell.StubApplication
2.添加<meta-data>记录原application配置的name值,
<meta-data android:name="APPLICATION_CLASS_NAME" android:value="原apk的Application name"/>
具体代码如下:

    private void modifyAndroidManifest(File xmlFile){
        if (xmlFile == null){
            System.out.println("请设置AndroidManifest.xml文件");
            return;
        }
        if (!xmlFile.exists()){
            System.out.println("指定的AndroidManifest.xml文件不存在");
            return;
        }
        System.out.println("开始修改AndroidManifest.xml......");
        String shellApplicationName = "com.zhh.jiagu.shell.StubApplication";
        String metaDataName = "APPLICATION_CLASS_NAME";
        String attrName = "android:name";

        //采用Dom读取AndroidManifest.xml文件
        try {
            //1.实例化Dom工厂
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            //2.构建一个builder
            DocumentBuilder builder = factory.newDocumentBuilder();
            //3.通过builder解析xml文件
            Document document = builder.parse(xmlFile);
            NodeList nl = document.getElementsByTagName("application");
            if (nl != null){
                Node app = nl.item(0);
                //获取原APK中application
                String applicationName = "android.app.Application";
                NamedNodeMap attrMap = app.getAttributes();
                //有属性时
                Node node = app.getAttributes().getNamedItem(attrName);
                //默认为系统的Application
                if (node != null){
                    applicationName = node.getNodeValue();
                    node.setNodeValue(shellApplicationName);
                }else {//不存在该属性时,则创建一个
                    Attr attr = document.createAttribute(attrName);
                    attr.setValue(shellApplicationName);
                    attrMap.setNamedItem(attr);
                }

                //添加<meta-data>数据,记录原APK的application
                Element metaData = document.createElement("meta-data");
                metaData.setAttribute("android:name",metaDataName);
                metaData.setAttribute("android:value",applicationName);
                app.appendChild(metaData);


                //重新写入文件xml文件
                TransformerFactory outFactory = TransformerFactory.newInstance();
                Transformer transformer = outFactory.newTransformer();
                Source xmlSource = new DOMSource(document);
                Result outResult = new StreamResult(xmlFile);
                transformer.transform(xmlSource,outResult);
                System.out.println("已完成修改AndroidManifest文件======");
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

这一步使用的命令为:

apktool d -o [输出目录] [apk]
apktool b -o [输出apk] [回编译目录]

5.将新编译的apk中的所有dex删除,并将上述生成的新dex文件添加进apk中
删除dex文件方法:

    public static void deleteDexFromZip(String zipFilePath) throws ZipException{
        ZipFile zipFile = new ZipFile(zipFilePath);
        List<FileHeader> files = zipFile.getFileHeaders();
        List<String> dexFiles = new ArrayList<>();
        for (FileHeader file : files) {
            if (file.getFileName().endsWith(".dex")) {
                dexFiles.add(file.getFileName());
            }
        }
        zipFile.removeFiles(dexFiles);
    }

添加dex到apk中的方法:

public static void addFile2Zip(String zip,String filepath,String rootFolder) throws ZipException{
        ZipFile zipFile = new ZipFile(zip);
        ZipParameters parameters = new ZipParameters();
        /*
         * 压缩方式
         * COMP_STORE = 0;(仅打包,不压缩)
         * COMP_DEFLATE = 8;(默认)
         * COMP_AES_ENC = 99; 加密压缩
         */
        parameters.setCompressionMethod(CompressionMethod.DEFLATE);
        /*
         * 压缩级别
         * DEFLATE_LEVEL_FASTEST = 1; (速度最快,压缩比最小)
         * DEFLATE_LEVEL_FAST = 3; (速度快,压缩比小)
         * DEFLATE_LEVEL_NORMAL = 5; (一般)
         * DEFLATE_LEVEL_MAXIMUM = 7;
         * DEFLATE_LEVEL_ULTRA = 9;
         */
        parameters.setCompressionLevel(CompressionLevel.NORMAL);
        // 目标路径
        if (rootFolder == null){
            rootFolder = "";
        }
        parameters.setRootFolderNameInZip(rootFolder);
        zipFile.addFile(filepath, parameters);
    }

如果将加密和解密通过JNI调用的,则记得要把so文件复制仅apk中(示例中就采用这种方式,有些可能仅采用Java加密,故复制so代码部分就不贴出来了,若感兴趣可以查看文章末尾的源码)。

6.apk对齐处理
到了这一步APK加固的主要工作其实已经完成了,只剩下对APK进行对齐处理和签名工作了。
apk对齐命令:zipalign -v -p 4 [输入的apk] [对齐后的apk]
具体实现代码如下:

private File zipalignApk(File unAlignedApk) throws Exception{
        logTitle("步骤六:重新对APK进行对齐处理.....");
        //步骤四:重新对APK进行对齐处理
        File alignedApk = new File(unAlignedApk.getParent(),unAlignedApk.getName().replace(".apk","_align.apk"));
        boolean ret = ProcessUtil.executeCommand("zipalign -v -p 4 " + unAlignedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());
        if (ret){
            System.out.println("已完成APK进行对齐处理======");
        }
        //删除未对齐的包
        FileUtils.deleteFile(unAlignedApk.getAbsolutePath());
        return alignedApk;
    }

7.签名
在Android系统中,未签名的Apk是无法正常安装运行的,因此我们要对上述对齐后的apk进行一次签名处理。

命令:apksigner sign --ks [签名文件] --ks-key-alias [alias名字] --min-sdk-version 21 --ks-pass pass:[keystore密码] --key-pass pass:[key密码] --out [输出apk] [输入apk]
具体实现代码为:

private File resignApk(File unSignedApk) throws Exception{
        logTitle("步骤七:对生成的APK进行签名");
        KeyStore store = KeyStoreUtil.readKeyStoreConfig((isRelease ? "":"jiaguLib/")+KEYSTORE_CFG);
        //步骤五:对APK进行签名
        File signedApk = new File(ROOT+"out",unSignedApk.getName().replace(".apk","_signed.apk"));
        //创建保存加固后apk目录
        if (!signedApk.getParentFile().exists()){
            signedApk.getParentFile().mkdirs();
        }

        String signerCmd = String.format("apksigner sign --ks %s --ks-key-alias %s --min-sdk-version 21 --ks-pass pass:%s --key-pass pass:%s --out %s %s",
                store.storeFile,store.alias,store.storePassword,store.keyPassword,signedApk.getAbsolutePath(),unSignedApk.getAbsolutePath());

        boolean ret = ProcessUtil.executeCommand(signerCmd);
        System.out.println("已完成签名======"+signedApk.getPath());
        //删除未对齐的包
        FileUtils.deleteFile(unSignedApk.getAbsolutePath());
        return signedApk;
    }

其实核心代码为:

String signerCmd = String.format("apksigner sign --ks %s --ks-key-alias %s --min-sdk-version 21 --ks-pass pass:%s --key-pass pass:%s --out %s %s",  store.storeFile,store.alias,store.storePassword,store.keyPassword,signedApk.getAbsolutePath(),unSignedApk.getAbsolutePath());
boolean ret = ProcessUtil.executeCommand(signerCmd);

由于笔者为了方便其他apk加固,采用读取签名配置的方式获取签名文件相关数据信息。

public static KeyStore readKeyStoreConfig(String configPath){
        File cf = new File(configPath);
        if (!cf.exists()){
            System.out.println("签名配置文件不存在");
            return null;
        }

        try {
            List<String> lines = Files.readAllLines(cf.toPath());
            if (lines == null || lines.size() <= 0){
                System.out.println("签名配置文件内容为空");
                return null;
            }
            KeyStore store = new KeyStore();
            for (String line : lines){
                if (line.trim().startsWith("storeFile")){
                    store.storeFile = line.split("=")[1].trim();
                }else if (line.trim().startsWith("storePassword")){
                    store.storePassword = line.split("=")[1].trim();
                }else if (line.trim().startsWith("alias")){
                    store.alias = line.split("=")[1].trim();
                }else if (line.trim().startsWith("keyPassword")){
                    store.keyPassword = line.split("=")[1].trim();
                }
            }
            return store;
        }catch (Exception e){
            e.printStackTrace();
        }
        return null;
    }

好了,到这里已经完成了APK的加固工作,可以正常安装apk了。
那么如何让我们加固后的APK进行脱壳呢?接下来就来看下jiagu_shell工程

jiagu_shell工程
该工程主要提供APK脱壳工作。
根据app启动流程不难发现脱壳工作必须要在壳Application中进行,先来看下脱壳的流程:

脱壳流程

attachBaseContext中的主要工作为:

  • 从apk中读取dex文件,获取加密的dex数据,并对其进行解密保存;
  • 通过DexClassLoader动态加载AppDex.zip;
  • 主动调用ActivityThread中的installContentProviders方法(后续问题中会提到这点);

onCreate主要工作:

  • 替换Application对象,并运行新的Application的create方法;

解析apk,读取dex文件数据进行解密,然后采用DexClassLoader动态加载:

public static boolean decodeDexAndReplace(Application context, int appVersionCode){
        try {
            //创建两个文件夹payload_odex,payload_lib 私有的,可写的文件目录
            File odex = context.getDir("payload_odex", Application.MODE_PRIVATE);
//            File libs = context.getDir("payload_lib", Application.MODE_PRIVATE);
            String odexPath = odex.getAbsolutePath();
            //按版本号来标记zip
            String dexFilePath = String.format(Locale.CHINESE,"%s/AppDex.zip",odexPath);

            LogUtil.info("decodeDexAndReplace =============================开始");

            File dexFile = new File(dexFilePath);
            LogUtil.info("apk size ===== "+dexFile.length());
            if (dexFile.exists()){
                dexFile.delete();
            }
            //第一次加载APP
            if (!dexFile.exists()) {
                //先清空odexPath目录中文件,防止数据越来越多
                File[] children = odex.listFiles();
                if (children != null && children.length > 0){
                    for (File child : children){
                        child.delete();
                    }
                }
                LogUtil.info( " ===== App is first loading.");
                long start = System.currentTimeMillis();
                dexFile.createNewFile();  //在payload_odex文件夹内,创建payload.apk

                String apkPath = context.getApplicationInfo().sourceDir;
                // 读取程序classes.dex文件
                byte[] dexdata = Utils.readDexFileFromApk(apkPath);

                //从classes.dex中再取出AppDex.zip解密后存放到/AppDex.zip,及其so文件放到payload_lib下
                Utils.releaseAppDexFile(dexdata,dexFilePath);

                LogUtil.info("解压和解密耗时 ===== "+(System.currentTimeMillis() - start) + "  === " + dexFile.exists());
            }
            // 配置动态加载环境
            //获取主线程对象
            Object currentActivityThread = getCurrentActivityThread();
            String packageName = context.getPackageName();//当前apk的包名
            LogUtil.info("packageName ===== "+packageName);
            //下面两句不是太理解
            ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect(
                    "android.app.ActivityThread", currentActivityThread, "mPackages");
            LogUtil.info("反射得到的mPackages ===== "+mPackages);
            WeakReference wr = (WeakReference) mPackages.get(packageName);
            ClassLoader mClassLoader = (ClassLoader) RefInvoke.getFieldOjbect("android.app.LoadedApk", wr.get(), "mClassLoader");
            //创建被加壳apk的DexClassLoader对象  加载apk内的类和本地代码(c/c++代码)
            DexClassLoader dLoader = new DexClassLoader(dexFilePath, odexPath, context.getApplicationInfo().nativeLibraryDir, mClassLoader);
            LogUtil.info("反射得到的dLoader ===== "+dLoader);
            //base.getClassLoader(); 是不是就等同于 (ClassLoader) RefInvoke.getFieldOjbect()? 有空验证下//?
            //把当前进程的DexClassLoader 设置成了被加壳apk的DexClassLoader  ----有点c++中进程环境的意思~~
            RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), dLoader);

            LogUtil.info("decodeDexAndReplace ============================= 结束");
            return true;
        } catch (Exception e) {
            LogUtil.error( "error ===== "+Log.getStackTraceString(e));
            e.printStackTrace();
        }
        return false;
    }

获取classes.dex数据的方法,其实就是解压的方式,代码如下:

public static byte[] readDexFileFromApk(String apkPath) throws IOException {
        LogUtil.info("从classes.dex解析出加密的原包的dex数据");
        ByteArrayOutputStream dexByteArrayOutputStream = new ByteArrayOutputStream();
        //获取当前zip进行解压
        ZipInputStream zipInputStream = new ZipInputStream(
                new BufferedInputStream(new FileInputStream(apkPath)));
        while (true) {
            ZipEntry entry = zipInputStream.getNextEntry();
            if (entry == null) {
                zipInputStream.close();
                break;
            }
            if (entry.getName().equals("classes.dex")) {
                byte[] arrayOfByte = new byte[1024];
                while (true) {
                    int i = zipInputStream.read(arrayOfByte);
                    if (i == -1)
                        break;
                    dexByteArrayOutputStream.write(arrayOfByte, 0, i);
                }
            }
            zipInputStream.closeEntry();
        }
        zipInputStream.close();
        return dexByteArrayOutputStream.toByteArray();
    }

接着从classes.dex中获取加密的数据并解密和输出到AppDex.zip文件:

public static void releaseAppDexFile(byte[] apkdata,String apkFileName) throws Exception {
        int length = apkdata.length;
        //取被加壳apk的长度   这里的长度取值,对应加壳时长度的赋值都可以做些简化
        byte[] dexlen = new byte[4];
        System.arraycopy(apkdata, length - 4, dexlen, 0, 4);
        ByteArrayInputStream bais = new ByteArrayInputStream(dexlen);
        DataInputStream in = new DataInputStream(bais);
        int readInt = in.readInt();
        LogUtil.info("============ 读取原Dex压缩文件大小 ======"+readInt);
        byte[] newdex = new byte[readInt];
        //把被加壳apk内容拷贝到newdex中
        System.arraycopy(apkdata, length - 4 - readInt, newdex, 0, readInt);
        LogUtil.info("============ 开始对加密dex进行解密======" + newdex.length);
        //对zip包进行解密
        newdex = AESUtil.decrypt(newdex);
        LogUtil.info("============ 解密后的大小为======" + newdex.length);
        //写入AppDex.zip文件
        File file = new File(apkFileName);
        try {
            FileOutputStream localFileOutputStream = new FileOutputStream(file);
            localFileOutputStream.write(newdex);
            localFileOutputStream.close();
        } catch (IOException localIOException) {
            throw new RuntimeException(localIOException);
        }
    }

最后通过反射获取原Application对象,在通过反射调用ActivityThread中的installContentProviders方法。

public static Application makeApplication(String srcApplicationClassName){
        LogUtil.info( "makeApplication ============== " + srcApplicationClassName);
        if (TextUtils.isEmpty(srcApplicationClassName)){
            LogUtil.error("请配置原APK的Application ===== ");
            return null;
        }
        //调用静态方法android.app.ActivityThread.currentActivityThread获取当前activity所在的线程对象
        Object currentActivityThread = getCurrentActivityThread();
        LogUtil.info("currentActivityThread ============ "+currentActivityThread);
        //获取当前currentActivityThread的mBoundApplication属性对象,
        //该对象是一个AppBindData类对象,该类是ActivityThread的一个内部类
        Object mBoundApplication = getBoundApplication(currentActivityThread);
        LogUtil.info("mBoundApplication ============ "+mBoundApplication);
        //读取mBoundApplication中的info信息,info是LoadedApk对象
        Object loadedApkInfo = getLoadApkInfoObj(mBoundApplication);
        LogUtil.info("loadedApkInfo ============ "+loadedApkInfo);
        //先从LoadedApk中反射出mApplicationInfo变量,并设置其className为原Application的className
        //todo:注意:这里一定要设置,否则makeApplication还是壳Application对象,造成一直在attach中死循环
        ApplicationInfo mApplicationInfo = (ApplicationInfo) RefInvoke.getFieldOjbect(
                "android.app.LoadedApk", loadedApkInfo, "mApplicationInfo");
        mApplicationInfo.className = srcApplicationClassName;
        //执行 makeApplication(false,null)
        Application app = (Application) RefInvoke.invokeMethod("android.app.LoadedApk", "makeApplication", loadedApkInfo, new Class[] { boolean.class, Instrumentation.class }, new Object[] { false, null });
        LogUtil.info("makeApplication ============ app : "+app);
        //由于源码ActivityThread中handleBindApplication方法绑定Application后会调用installContentProviders,
        //此时传入的context仍为壳Application,故此处进手动安装ContentProviders,调用完成后,清空原providers
        installContentProviders(app,currentActivityThread,mBoundApplication);
        return app;
    }

反射调用ActivityThread中的installContentProviders方法:

private static void installContentProviders(Application app,Object currentActivityThread,Object boundApplication){
        if (app == null) return;
        LogUtil.info("执行installContentProviders =================");
        List providers = (List) RefInvoke.getFieldOjbect("android.app.ActivityThread$AppBindData",
                boundApplication, "providers");
        LogUtil.info( "反射拿到providers = " + providers);
        if (providers != null) {
            RefInvoke.invokeMethod("android.app.ActivityThread","installContentProviders",currentActivityThread,new Class[]{Context.class,List.class},new Object[]{app,providers});
            providers.clear();
        }
    }

最后新旧Application对象的替换工作。如下代码:

public static void replaceAndRunMainApplication(Application app){
        if (app == null){
            return;
        }
        LogUtil.info( "onCreate ===== 开始替换=====");
        // 如果源应用配置有Appliction对象,则替换为源应用Applicaiton,以便不影响源程序逻辑。
        final String appClassName = app.getClass().getName();

        //调用静态方法android.app.ActivityThread.currentActivityThread获取当前activity所在的线程对象
        Object currentActivityThread = getCurrentActivityThread();
        //获取当前currentActivityThread的mBoundApplication属性对象,
        //该对象是一个AppBindData类对象,该类是ActivityThread的一个内部类
        Object mBoundApplication = getBoundApplication(currentActivityThread);
        //读取mBoundApplication中的info信息,info是LoadedApk对象
        Object loadedApkInfo = getLoadApkInfoObj(mBoundApplication);
        //检测loadApkInfo是否为空
        if (loadedApkInfo == null){
            LogUtil.error( "loadedApkInfo ===== is null !!!!");
        }else {
            LogUtil.info( "loadedApkInfo ===== "+loadedApkInfo);
        }
        //把当前进程的mApplication 设置成了原application,
        RefInvoke.setFieldOjbect("android.app.LoadedApk", "mApplication", loadedApkInfo, app);
        Object oldApplication = RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mInitialApplication");
        LogUtil.info( "oldApplication ===== "+oldApplication);
        ArrayList<Application> mAllApplications = (ArrayList<Application>) RefInvoke.getFieldOjbect(
                "android.app.ActivityThread", currentActivityThread, "mAllApplications");
        //将壳oldApplication从ActivityThread#mAllApplications列表中移除
        mAllApplications.remove(oldApplication);
        //将原Application赋值给mInitialApplication
        RefInvoke.setFieldOjbect("android.app.ActivityThread", "mInitialApplication", currentActivityThread, app);
//        ApplicationInfo appinfo_In_LoadedApk = (ApplicationInfo) RefInvoke.getFieldOjbect(
//                "android.app.LoadedApk", loadedApkInfo, "mApplicationInfo");
        ApplicationInfo appinfo_In_AppBindData = (ApplicationInfo) RefInvoke.getFieldOjbect(
                "android.app.ActivityThread$AppBindData", mBoundApplication, "appInfo");
//        appinfo_In_LoadedApk.className = appClassName;
        appinfo_In_AppBindData.className = appClassName;
        ArrayMap mProviderMap = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mProviderMap");
        Iterator it = mProviderMap.values().iterator();
        while (it.hasNext()) {
            Object providerClientRecord = it.next();
            Object localProvider = RefInvoke.getFieldOjbect("android.app.ActivityThread$ProviderClientRecord", providerClientRecord, "mLocalProvider");
            RefInvoke.setFieldOjbect("android.content.ContentProvider", "mContext", localProvider, app);
        }
        LogUtil.info( "app ===== "+app + "=====开始执行原Application");
        app.onCreate();
    }

至此脱壳工作完成,运行APP了。

中间涉及JNI层代码这里就不多说明,可以看后续的源码。

四、加固工具及命令

1.jar转dex

命令:dx --dex --output [输出dex] [输入的jar]

2.apktool反编译与回编译

反编译:apktool d -o [输出目录] [apk]
回编译:apktool b -o [输出apk] [回编译目录]

3.apk对齐命令

命令:zipalign -v -p 4 [输入的apk] [对齐后的apk]

4.签名命令

命令:apksigner sign --ks [签名文件] --ks-key-alias [alias名字] --min-sdk-version 21 --ks-pass pass:[keystore密码] --key-pass pass:[key密码] --out [输出apk] [输入apk]

5.AndroidManifest二进制文件修改器(备用)
AXMLEditor强大的AndroidManifest.xml二进制修改器,无需对APK进行反编译和回编译,节约时间。

注:由于apktool反编译和回编译apk太过耗时,想采用该工具直接修改AndroidManifest.xml,提升打包效率,不知道为什么打包后,始终无法运行,不执行壳Application,最后不得不放弃,待后面有时间在好好研究吧。如果该方案可行,打包效率会提高几十倍。

五、遇到问题

问题1:解密后加载dex,提示文件大小问题(Bad size ...)?

解决:采用ZipOutputStream进行压缩,导致的问题。所以改用了Zip4j进行压缩,解压时仍可使用系统提供的解压方式。

问题2:提示找不到androidx.core.content.FileProviders类问题?

原因:通过查阅源码(ActivityThread - handleBindApplication())发现,makeApplication后会若providers不为空,则会执行初始化ContentProvider的操作(installContentProviders()),而在makeApplication中会执行Application的attachBaseContext方法,若在此将providers清空,后面就不会初始化ContentProvider的操作了,但是又不能不执行初始化。

解决:获取原Application对象 -> 通过反射手动调用installContentProviders() -> 清空providers列表

问题3:通过反射调用LoadedApk类中的makeApplication方法后,运行出现一直重复执行Application的attachBaseContext方法?

原因:执行makeApplication时反射的类是mApplicationInfo.className,而该值仍为壳Application类,因此反射后获取的Application与壳Application类一样,导致重复执行。

解决:在反射makeApplication前,先获取LoadedApk中的mApplicationInfo对象,并设置其className属性的值为原Application的类名,这样在调用makeApplication实例化Application对象就可以了。

问题4:应用启动后,原Application也替换成功了,初始化操作的时候提示找不到so文件?

解决:在实例化DexClassLoader对象时,传入的librarySearchPath不正确,应当使用app的nativeLibraryDir目录,即:context.getApplicationInfo().nativeLibraryDir

问题5:加固时如何采用JNI对数据进行加密?

采用vs studio将C/C++代码编译成dll文件,在java工程中引用。
System.load()可以加载绝对路径的dll库,
System.loadLibrary()加载jre/bin中的dll文件。

欢迎留言,一起学习,共同进步!

github - 示例源码
gitee - 示例源码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容