Java反射

在代码已经运行后,将需要动态加载的类,直接传给java封装实现的反射类,从而获取其属性甚至是直接调用

但是反射会大幅度降低性能

Java的ClassLoader

双亲委派机制

就是找类的时候先由父加载器直到Bootstrap ClassLoader寻找并更新,如果找不到,再由自己更新

Java的ClassLoader分为两种:

  • 系统类加载器

BootstrapClassLoader, ExtensionsClassLoader, ApplicationClassLoader

  • 自定义类加载器

Custom ClassLoader, 通过继承java.lang.ClassLoader实现

ClassLoader的继承关系如图所示:

  1. ClassLoader 是一个抽象类,定义了ClassLoader的主要功能
  2. SecureClassLoader 继承自ClassLoader,但并不是ClassLoader的实现类,而是拓展并加入了权限管理方面的功能,增强了安全性
  3. URLClassLoader 继承自SecureClassLoader 可通过URL路径从jar文件和文件夹中加载类和资源
  4. ExtClassLoader和AppClassLoader都继承自URLClassLoader

Android中的ClassLoader

Java中的ClassLoader可以加载jar和class文件(本质都是加载class文件)

而在Android中,无论DVM还是ART加载的文件都是dex文件,所以需要重新设计ClassLoader的相关类.

Android中的ClassLoader分为系统类加载器和自定义加载器:

  • 系统类加载器

包括 BootClassLoader, PathClassLoader, DexClassLoader等

  • 自定义加载器

通过继承BaseDexClassLoader实现,它们的继承关系如图所示:

boot secure url等类继承关系与之前的java差不多

但是多了一个base作为classloader的实现类

而下面的三种具体loader方式则继承了base类

而下面的三种具体load则是实现Android加固的主要方法

  1. DexClassLoader 可以加载未安装apk的dex文件

它是一代加固——整体加固(落地加载)的核心之一

  1. InMemoryDexClassLoader 可以加载内存中的dex文件

它是二代加固——整体加固(不落地加载)的核心之一

实际上DexClassLoader,PathClassLoader以及InMemoryDexClassLoader加载类时,均通过委托BaseDexClassLoader实现

具体流程

1、加载dex文件

Java层

PathClassLoader和DexClassLoader委托BaseDexClassLoader最终执行JNI方法DexFile.openDexFileNative进入Native层.

而InMemoryDexClassLoader委托BaseDexClassLoader后则执行DexFile.openInMemoryDexFiles进入Native层.

Native层

PathClassLoader和DexClassLoader这条委托链会根据不同情况,调用ArtDexFileLoader::Open的不同重载,或者调用OpenOneDexFileFromZip.

InMemoryDexClassLoader调用ArtDexFileLoader::Open的第3种重载.

无论是调用哪个函数,最终都会调用ArtDexFileLoader::OpenCommon. 创建DexFile对象后,Class对应的文件便被加载到ART虚拟机中

2、将dex文件转化为可供虚拟机运行的class

那三种classloader在进行load_class时会根据双亲委派机制寻找到父类classloader进行加载,最终进入DexFile

进入DexFile后,主要执行以下操作:

  1. DexFile

通过JNI函数defineClassNative进入Native层.

  1. DexFile_defineClassNative

通过FindClassDef枚举DexFile的所有DexClassDef结构并使用ClassLinker::DefineClass创建对应的Class对象.

之后调用ClassLinker::InsertDexFileInToClassLoader将对应的DexFile对象添加到ClassLoader的ClassTable中.

  1. ClassLinker::DefineClass

调用LoadField加载类的相关字段,之后调用LoadMethod加载方法,再调用LinkCode执行方法代码的链接.

Android应用程序启动流程

一代加固

简单总结:

加密整个文件并将加密后的apk和壳dex一起合并变成一个新的dex

通过在真实oncreate前添加

// 原来系统启动流程:System → GameApplication.attachBaseContext() → GameApplication.onCreate()

// 加固后启动流程:System → ProxyApplication.attachBaseContext() → [解密提取原APK] → [替换ClassLoader] → ProxyApplication.onCreate() → GameApplication.onCreate()

举点例子

com.example.game.apk(原始)
├── AndroidManifest.xml
│ └── android:name="com.example.game.GameApplication" 真实Application
├── classes.dex
│ ├── com.example.game.GameApplication
│ ├── com.example.game.MainActivity
│ ├── com.example.game.Player
│ └── com.example.game.WeaponSystem
├── lib/
│ ├── armeabi-v7a/libgame.so
│ └── arm64-v8a/libgame.so
└── res/...
com.example.game.apk(加壳后)
├── AndroidManifest.xml
│ └── android:name="com.example.shell.ProxyApplication" 换成壳的入口
│ <meta-data
│ android:name="APPLICATION_CLASS_NAME"
│ android:value="com.example.game.GameApplication"/> 真实类名
├── classes.dex 壳dex
│ ├── [正常dex文件头]
│ ├── com.example.shell.ProxyApplication ← 壳的代码
│ ├── com.example.shell.Reflection ← 壳的工具类
│ ├── ─────────────────────────────────────
│ ├── [加密后的原始APK二进制数据] ← 原始APK加密后附在这里
│ └── [4字节:原始APK大小,小端序] ← 最后4字节
├── lib/ ← 壳自己的so
└── res/...← 壳的资源

二代壳

区别

内部调用readAndCombineDexs读取并合并源apk的多个dex为一个文件

在android8.0以上使用InMemoryDexClassLoader进行内存时的加载

另外需要针对第一代加固不支持Multidex进行优化:

源apk每个dex文件前添加4字节标识其大小,之后全部合并成一个文件再合并到壳dex,最后添加4字节标识文件总大小

举点例子

三代加固-抽取加固

基于整体加固遇到的部分问题,引申出了三代加固: 代码抽取加固

思路: 将关键方法的指令抽空,替换为nop指令,运行时动态回填指令执行, 回填的核心是对Android系统核心函数进行hook

前面解密dex部分与一二代壳并无区别,主要是解密后的dex的方法字节码仍是null,我们该如何回填

  1. 解析.codes文件

解析code文件结构

[4字节: codeOff][4字节: insnSize][insnSize*2 字节: 指令数据]
[4字节: codeOff][4字节: insnSize][insnSize*2 字节: 指令数据]
codeOff:这段字节码在原始dex文件中的偏移位置
insnSize:指令数量(注意每条指令是u2=2字节,所以实际字节数是insnSize*2)
指令数据:真实的字节码

2.hook_LoadMethod —— 拦截方法加载

void hook_LoadMethod() {
// 第一步:在libart.so的ELF文件里找到 LoadMethod 的符号名
// 因为C++有名称修饰,真实符号名类似:
// _ZN3art11ClassLinker10LoadMethodERKNS_7DexFileE...
const char* symbol = getClassLinkerLoadMethodSymbol();

// 第二步:用 Dobby 框架,通过符号名找到函数在内存中的地址
void* loadMethodAddress = DobbySymbolResolver(
    getClassLinkerLoadMethodLibPath(),  // libart.so路径
    symbol
);

// 第三步:用 Dobby 替换这个函数
DobbyHook(
    loadMethodAddress,          // 要hook的原函数地址
    (void*)newLoadMethod,       // 替换成我们的函数
    (void**)&g_originLoadMethod // 保存原函数地址,以便后续调用
);
}

3、这里要求so中需要有

1、禁用dex2oat优化

dex2oat是Android的AOT编译器把dex字节码预编译成机器码,存到oat文件里

如果仍旧执行oat,那么就会不走LoadMethod ,就会导致方法一直都是null,所以

我们得动态(强制让ART用解释执行模式)

即:每个方法执行前必须走LoadMethod → hook生效 → 回填成功

2、让dex内存可写

ART加载dex文件时,用mmap把dex文件映射到内存

映射时权限是 PROT_READ(只读)

所以hook mmap,把所有只读映射偷偷改成可读可写
4、核心回填代码

void innerLoadMethod(void* thiz, const DexFile* dexFile, ClassAccessor::Method* method, void* klass, void* dest){
    // dex文件路径
    std::string location = dexFile->location_;
    //logd("Load Dex File Location: %s",location.c_str())
    // 判断是否为解密释放的dex文件,位于私有目录内
    if(location.find("app_tmp_dex") == std::string::npos){
        return;
    }
    // 如果未解析过dexCodes文件则进行解析,每个dex文件只解析一次,创建对应的map<CodeOff,CodeItem>映射
    if(codeMapList.find(location)==codeMapList.end()){
        logd("Parse dex file %s codes",location.c_str());
        codeMapList[location]=std::map<uint32_t,CodeItem>(); //创建新的codeMap
        parseExtractedCodeFiles(location);
    }
    // 不存在DexCode 直接跳过
    if(method->code_off_==0){
        return;
    }
    // 指令地址
    uint8_t* codeAddr = (uint8_t*)(dexFile->begin_ + method->code_off_ + 16); //insn结构前面有16字节

    //logd("MethodCodeOff: %d",method->code_off_);
    // 回填指令
    std::map<uint32_t,CodeItem> codeMap=codeMapList[location];
    // 似乎没有走到回填指令处 (注意c++浅拷贝问题,不能随意delete)
    if(codeMap.find(method->code_off_) != codeMap.end()){
        CodeItem codeItem = codeMap[method->code_off_];
        memcpy(codeAddr,codeItem.getInsns(),codeItem.getInsnsSize()*2); //注意指令为u2类型,长度需要*2
    }
}

总结

一代流程:
attachBaseContext
├── readDexFromAp读壳dex字节
├── extractSrcApkFromShellDex()
│ ├── 从末尾读4字节 → APK大小
│ ├── 截取加密APK → 解密
│ ├── 写入磁盘 Source.apk ← 落地
│ └── 解压so到 app_tmp_lib
└── replaceClassLoader()
└── DexClassLoader(磁盘路径)
二代流程:
attachBaseContext
├── readDexFromApk() 读壳dex字节(相同)
├── extractDexFilesFromShellDex()
│ ├── 从末尾读4字节 → sourceDexs总大小
│ ├── 截取加密sourceDexs → 解密
│ └── 循环拆分多个dex → ByteBuffer[]内存
└── replaceClassLoader(ByteBuffer[])
└── InMemoryDexClassLoader(内存数据)
三代流程:
Java层(ThirdProxyApplication)
├── 和二代基本相同
│     读壳dex → 解密 → 多个ByteBuffer → 写出空壳dex
│     复制 .codes 文件到私有目录
│     DexClassLoader 加载空壳dex
│     替换 ClassLoader
│     替换 Application
└── 多了一行:System.loadLibrary("androidshell") ← 触发Native层

Native层(libandroidshell.so)
├── hookMmap:让dex内存可写(为回填做准备)
├── hookExecve:禁dex2oat(保证走解释执行,LoadMethod必经)
└── hook_LoadMethod:拦截方法加载,在这里执行回填

数据层(.codes文件)
├── 存储被抽取的真实字节码
├── 格式:[codeOff(4)][insnSize(4)][指令数据(insnSize*2)]
└── 运行时由Native层读取解析

参考:https://bbs.kanxue.com/thread-286929.htm