Android 加固方案实践系列1-《第一代加固》

项目地址:github.com/CuteWiseCod…

一、前言

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

基础知识

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.dn.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…

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYKWsDHQ' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片