项目地址:github.com/CuteWiseCod…
假如觉得有协助,欢迎star

一、前言

本系列文章将从第一代加固讲到第三代加固,包括其原理和代码实践以及它们的优缺点

基础知识

odex和oat格局

  • 在android4.4之前,android为了优化程序履行功率运用的是JIT(just-in-time)即时编译,也便是程序运转时编译。因为apk文件是一个zip压缩包的格局,每次体系发动程序时都需求从apk中读取dex文件并加载履行,为了削减程序发动时从apk读取dex文件所需求的,android在apk第一次装置的时分dexopt将程序的dex文件进行优化生成odex文件,并将其放在了/data/dalvik-cache目录下。等下次apk发动时直接加载这个目录中经过优化的odex文件削减发动所需求的时刻(优化根据当时体系的dalvik虚拟机版别,不同版别上的odex文件无法进行兼容)。在程序运转时android虚拟机会对一些履行频率较高的热门函数进行jit编译生成对应的本地代码,下次再履行此函数的时分直接履行对应的本地代码提高了履行的功率,注意jit编译的代码只会存在于内存中并不会持久化保存再磁盘中,下次发动apk后履行此函数还需求解说履行。

  • 在android4.4之后,android运用的是AOT(Ahead-of-time)事前编译,也便是程序在运转前先编译。oat是ART虚拟机运转的文件,是ELF格局二进制文件,包括DEX和编译的本地机器指令,oat文件包括DEX文件,因而比ODEX文件占用空间更大。
    程序在首次装置的时分,dex2oat默认会把classes.dex编译成本地机器指令,生成ELF格局的OAT文件,并将其放在了/data/dalvik-cache或者是/data/app/packagename/目录下。ART加载OAT文件后不需求经过处理就能够直接运转,它在编译时就从字节码装换成机器码了,因而运转速度更快。不过android4.4之后oat文件的后缀还是odex,但是现已不是android4.4之前的文件格局,而是ELF格局封装的本地机器码.能够以为oat在dex上加了一层壳,能够从oat里提取出dex.

vdex文件格局

在android8.0(Android O)之前dex文件嵌入到oat文件本身中,在Android 8.0之后dex2oat将classes.dex优化生成两个文件oat文件(.odex)和vdex文件(.vdex)

  • odex文件中包括了本机代码的OAT
  • vdex文件包括了原始的DEX文件副本

art文件格局

ART虚拟机在履行dex文件时,需求将dex文件中运用的类,字符串等信息转换为自定义的结构。art文件便是保存了apk中运用的一些类,字符串等信息的ART内部表示,能够加速程序发动的速度。

二、第一代加固

2.1 原理

第一代加固原理相对简略,首要对apk进行解压获取到原dex, 接着对原dex 进行加密,制作并生成壳dex(加载时用来解密原dex), 并从头打包成apk, 运转时运用壳dex对加密的dex进行解密并加载到内存中。
是不是很简略? 当然,这只是大概的原理,下面咱们将具体叙说。

2.1.1 加密

加密的办法有很多种,如RSA,AES等,加固中常用的加密算法是AES,因为加密算法不是本文的要点,读者可自行去了解相关算法的区别。 这儿我运用gradle插件的办法在编译的时分自动解压加密并从头打包,避免了手动加密的繁琐。 解压加密的核心代码处理如下:

public static void encryptAPKFile(File srcAPKfile, File dstApkFile) throws Exception {
    if (srcAPKfile == null) {
       System.out.println("encryptAPKFile :srcAPKfile null");
        return null;
    }
    //解压
    Zip.unZip(srcAPKfile, dstApkFile);
    File[] dexFiles = dstApkFile.listFiles(new FilenameFilter() {
        @Override
        public boolean accept(File file, String s) {
            return s.endsWith(".dex");
        }
    });
    for (File dexFile: dexFiles) {
        byte[] buffer = Utils.getBytes(dexFile);
        //加密
        byte[] encryptBytes = AES.encrypt(buffer);
       //写数据  替换本来的数据
        FileOutputStream fos = new FileOutputStream(dexFile);
        fos.write(encryptBytes);
        fos.flush();
        fos.close();
    }
}

加密后的dex 能够单独放在assets 文件夹或跟本来apk 文件夹下, 假如寻求解密速度的话,主张放在assets下,因为放在apk文件夹下的话,会比放在assets下多了加压进程,所以也就相对耗时。

2.1.2 制作壳dex

壳dex也是加固中终究要的一环,它不仅在运转时用来解密dex, 而且还要加载dex到内存中,终究还需求调用原application的生命周期等。下面咱们逐一剖析。

替换 Manifest中的Application

为了在点击桌面icon首要履行咱们的壳Application, 首要要在打包进程中,将原Application 替换成 壳程序的Application, 一起运用Meta-Data 记录原Application的全途径名,终究的完成如下:

<application
    android:theme="@ref/0x7f0f01d2"
    android:label="@ref/0x7f0e001b"
    android:icon="@ref/0x7f0c0000"
    android:name="com.stub.StubApp"
    android:debuggable="true"
    android:allowBackup="true"
    android:supportsRtl="true"
    android:fullBackupContent="@ref/0x7f110000"
    android:roundIcon="@ref/0x7f0c0001"
    android:appComponentFactory="androidx.core.app.CoreComponentFactory"
    android:dataExtractionRules="@ref/0x7f110001">
...
...
    <meta-data
        android:name="ApplicationName"
        android:value="com.ck.test.MyApp" />
</application>

这样在发动的时分就能够走咱们的壳Applicaiton了。关于壳Application 如何调用原Application,下文再叙说。

解密

2.1.1 上文已提到加密的dex最好放到assets下,故本文以此种办法进行剖析。
首要当然是读取assets下的加密文件,接着对加密的文件进行解密,要害代码如下:

try {
    byte[] bytes = getBytes(file);
    FileOutputStream fos = new FileOutputStream(file);
    byte[] decrypt = a.decrypt(bytes);
    fos.write(decrypt);
    fos.flush();
    fos.close();
} catch (Exception e) {
    e.printStackTrace();
}

加载

获取到解密后的dex文件,此时咱们需求把这些dex 文件加载到内存中,那么应该怎样加载呢?

Android体系供给了几种不同的classloader, 咱们开发用到的一般是PathClassLoader 和DexClassLoader, 看过的这两个加载器源码的朋友应该知道,这两个加载器尽管传参的时分有点差异,但终究都是调用相同参数父classloader,也便是说PathClassLoader和DexClassLoader 几乎是一样的,感兴趣的朋友能够自行阅览相关的源码。所以咱们运用两个加载器的恣意一个都能够。

熟悉类加载和热修正原理的朋友会知道,运用类加载顺序的热修正其实是将修正的dex放在存在bug的dex前面,这儿就用到的父加载器的成员变量 DexPathList, 其间有一个用于寄存dex数组的dexElements。简略来说,只需咱们获取到加载器的dexpathlist中的dexElements数组,将咱们解密后的dex放入到数组中,即可完好dex的加载。要害代码如下:

private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                            File optimizedDirectory)
        throws IllegalArgumentException, IllegalAccessException,
        NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
    /* The patched class loader is expected to be a descendant of
     * dalvik.system.BaseDexClassLoader. We modify its
     * dalvik.system.DexPathList pathList field to append additional DEX
     * file entries.
     */
    long l1 = System.currentTimeMillis();
    Field pathListField = ShareReflectUtil.findField(loader, "pathList");
    Object dexPathList = pathListField.get(loader);
    ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
    ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makePathElements(dexPathList,
            new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
            suppressedExceptions));
    if (suppressedExceptions.size() > 0) {
        for (IOException e : suppressedExceptions) {
            Log.w(TAG, "Exception in makePathElement", e);
            throw e;
        }
    }
}

不同体系版别,部分api或许有所不同,如何兼容不同的体系版别能够参阅 MultiDex.install 办法,读者可自行阅览相关源码。

仔细的朋友或许会想到,加载是加载了,可是怎样调用原app的Application呢?

调用原Application

1. 获取原Application

在AndroidManifest.xml 替换进口Application的时分,咱们运用Meta-Data 记录下原Application的全类名,代码如下:

String applicationName = "";
ApplicationInfo ai;
try {
    ai = this.getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
    applicationName = ai.metaData.getString("ApplicationName");
} catch (Exception e) {
    e.printStackTrace();
}

android.app.LoadedApk供给了一个 makeApplication 办法,咱们能够反射调用这个办法创立咱们原Application。

//反射调用  currentActivityThread() 办法,获取当时ActivityThread
Object ActivityThreadObj = RefinvokeMethod.invokeStaticMethod("android.app.ActivityThread",
        "currentActivityThread", new Class[]{}, new Object[]{});
 //获取currentActivityThread 的成员变量 mBoundApplication
Object mBoundApplication = RefinvokeMethod.getField("android.app.ActivityThread",
        ActivityThreadObj, "mBoundApplication");
 //获取 mBoundApplication 中的 info 成员变量(也便是LoadApk)目标
Object info = RefinvokeMethod.getField("android.app.ActivityThread$AppBindData",
        mBoundApplication, "info");
//info (LoadApk) 中的mApplication 变量置为null
RefinvokeMethod.setField("android.app.LoadedApk", "mApplication", info, null);
//获取当时ActivityThread ActivityThreadObj 中的 mInitialApplication 变量
Object minitApplication = RefinvokeMethod.getField("android.app.ActivityThread",
        ActivityThreadObj, "mInitialApplication");
 //获取当时 ActivityThread ActivityThreadObj 目标的所有 Applications 目标
ArrayList<Application> mAllApplications = (ArrayList<Application>) RefinvokeMethod.getField("android.app.ActivityThread",
        ActivityThreadObj, "mAllApplications");
//并从mAllApplications 中移除 mInitialApplication
mAllApplications.remove(minitApplication);
//从头设置Application 的className, 并创立Application 
ApplicationInfo mApplicationInfo = (ApplicationInfo) RefinvokeMethod.getField("android.app.LoadedApk",
        info, "mApplicationInfo");
ApplicationInfo appInfo = (ApplicationInfo) RefinvokeMethod.getField("android.app.ActivityThread$AppBindData",
        mBoundApplication, "appInfo");
mApplicationInfo.className = applicationName;
appInfo.className = applicationName;
Application appplication = (Application) RefinvokeMethod.invokeMethod("android.app.LoadedApk",
        "makeApplication", info, new Class[]{boolean.class, Instrumentation.class}, new Object[]{false, null});

到此,咱们现已创立了原Application

2. 替换Application

替换的究竟是哪里的Application呢? 答案是替换 provider 中mContext 成员。App发动进程中,四大组件中只要ContentProvider在不被运用的时分就会被初始化

//获取当时ActivityThread mProviderMap
ArrayMap mProviderMap = (ArrayMap) RefinvokeMethod.getField("android.app.ActivityThread", ActivityThreadObj,
        "mProviderMap");
//遍历替换所有provider 中的mContext 变量,即provider默认初始化的并持有的Apllication目标
for (Object mProviderClientRecord : mProviderMap.values()) {
    Object mLocalProvider = RefinvokeMethod.getField("android.app.ActivityThread$ProviderClientRecord",
            mProviderClientRecord, "mLocalProvider");
    RefinvokeMethod.setField("android.content.ContentProvider", "mContext", mLocalProvider, appplication);
}
//调用原application 的生命周期
appplication.onCreate();

仔细的朋友或许会注意到这儿为什么只调用onCreate办法,onCreate之前的办法呢? 比如attachBaseContext办法,答案是藏在了makeApplication进程中,读者可自行探索。

2.1.3 问题

经过前面几个进程,基本上完成了从加密到运转解密的一个简略加固程序的进程,那么这样完成还存在什么问题呢?
在测验进程中,因为首要是针对5.1 以上的机型进行测验,所以本文也首要剖析5.1以上的体系

1.发动速度慢

其间一个比较大的问题是,发动会比较慢,一般发动只需几秒的程序,经过以上进程加固后得程序发动需求数十秒,这能忍?

问题追踪剖析

经过打点调试发现,首次发动首要耗时发生在加载dex的进程中的makePathElements,那么在makePathElements进程中究竟做了什么操作呢?
以版别为23以上的体系为例:

  private static Object[] makePathElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
            throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
        Method makePathElements;
        try {
            makePathElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class, File.class,
                    List.class);
        } catch (NoSuchMethodException e) {
           ....
        }
        long l1 = System.currentTimeMillis();
        Object[] invoke = (Object[]) makePathElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions);
        return invoke;
    }
}

makeDexElements

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
                                         List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
    Element[] elements = new Element[files.size()];
    for (File file : files) {
        if (file.isFile()) {
            String name = file.getName();
            DexFile dex = null;
            if (name.endsWith(DEX_SUFFIX)) {
                // Raw dex file (not inside a zip/jar).
                try {
                    //调用loadDexFile 来加载 dex 文件
                    dex = loadDexFile(file, optimizedDirectory, loader, elements);
                    if (dex != null) {
                        elements[elementsPos++] = new Element(dex, null);
                    }
                }
            } 
        }
    }
    return elements;
}

能够看到,makeDexElements首要作业是加载dex文件到内存中,并添加到dexelements[]数组中。
再看看loadDexFile函数做了什么操作

private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader, Element[] elements)
        throws IOException {
        //optimizedDirectory 为咱们前面解压的途径, 所以走else 分支
    if (optimizedDirectory == null) {
        return new DexFile(file, loader, elements);
    } else {
        String optimizedPath = optimizedPathFor(file, optimizedDirectory);
        return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
    }
}

接下来剖析DexFile.loadDex 干了什么事情

static DexFile loadDex(String sourcePathName, String outputPathName,
                       int flags, ClassLoader loader, DexPathList.Element[] elements) throws IOException {
    return new DexFile(sourcePathName, outputPathName, flags, loader, elements);
}

直接new 了一个DexFile, 而DexFile 结构函数终究会调用native层的

private static native Object openDexFileNative(String sourceName, String outputName, int flags,
                                               ClassLoader loader, DexPathList.Element[] elements);

去native层瞅瞅
art/runtime/native/dalvik_system_DexFile.cc

 static jobject DexFile_openDexFileNative(JNIEnv* env,
                                           jclass,
                                           jstring javaSourceName,
                                       [[maybe_unused]] jstring javaOutputName,
                                       [[maybe_unused]] jint flags,
                                           jobject class_loader,
                                           jobjectArray dex_elements) {
      ScopedUtfChars sourceName(env, javaSourceName);
    //......一些校验
      std::vector<std::string> error_msgs;
      const OatFile* oat_file = nullptr;
      std::vector<std::unique_ptr<const DexFile>> dex_files =
              Runtime::Current()->GetOatFileManager().OpenDexFilesFromOat(sourceName.c_str(),
              class_loader,
              dex_elements,
              /*out*/ &oat_file,
      /*out*/ &error_msgs);
      return CreateCookieFromOatFileManagerResult(env, dex_files, oat_file, error_msgs);
  }

art/runtime/oat_file_manager.cc
这儿只贴要害代码,

switch (oat_file_assistant.MakeUpToDate(filter_, /*out*/ &error_msg))

终究发现这一段代码

if (!runtime->IsDex2OatEnabled()) {
*error_msg = "Generation of oat file for dex location " + dex_location_
+ " not attempted because dex2oat is disabled.";
return kUpdateNotAttempted;
}

经过以上剖析,咱们发现Android 5.0-Android 9.0终究都会走到Runtime::Current()->IsDex2OatEnabled()函数,假如dex2oat没有开启,则不会进行后续oat文件生成的操作,而是直接return返回。

而oat的生成想必会比较耗时,因而有没办法禁用这个呢?
咱们看下IsDex2OatEnabled()办法,它有两个变量,只需修正其间一个即可,那究竟需求修正哪一个呢?理论上都能够,

bool IsDex2OatEnabled() const {
    return dex2oat_enabled_ && IsImageDex2OatEnabled();
}
bool IsImageDex2OatEnabled() const {
    return image_dex2oat_enabled_;
}
原理完成

修正image_dex2oat_enabled_首要需求获取runtime的地址

//获取javaVM 指针
JavaVM *javaVM;
env->GetJavaVM(&javaVM);

紧接着转换成自定义的结构,

struct JavaVMExt {
    void *functions;
    void *runtime;
};

将咱们之前拿到的JavaVM 指针,强制转换为JavaVMExt指针,经过JavaVMExt指针拿到Runtime指针

//将javaVM 强转为自定义的  JavaVMExt
auto *javaVMExt = (JavaVMExt *) javaVM;
void *runtime = javaVMExt->runtime;

因为各个Android体系版别有所区别,这儿咱们需求将runtime转成所对应的版别结构,如下:

//android 6.0 //android 7.1 android 7.0
auto partialRuntime = (PartialRuntime60 *) ((char *) runtime);

拿到对应版别Runtime结构后,修正对应字段即可

partialRuntime->image_dex2oat_enabled_ = enable;

到此修正完毕。当然后续加载完之后需求从头修正回去,以及在子线程去触发dex2oat, 这儿因为高版别禁止了用户去触发dex2oat, 需求运用黑科技来完成,具体能够参阅这篇文章jiuaidu.com/jianzhan/77…