上一篇文章介绍了 IO 的基础知识,本节我们来举一个 IO 的应用实例——Dex 文件加密。当然,通常情况下我们是使用 Gradle 进行 Dex 加密的,这里使用 IO 权当是练手了。
1、理论知识
1.1 了解反编译
解压 apk 文件可以得到如下内容:
- META_INF 文件夹:签名信息。
- res 文件夹:资源。
- AndroidManifest.xml:清单文件。
- classes.dex:类似于 jar 包,反编译主要就是通过该文件找到源代码。如果没有做过 App 加固或至少做个 progurad 的话,相当于你的 App 毫无秘密可言了。但是 proguard 仅仅是做了成员名称的替换,但是代码逻辑还是能看清的,因此它的意义并不大。
- resources.arsc:资源映射表。
通过 jd-gui 工具可以查看反编译的 class 文件,过程是先解压 apk 文件,然后使用 dex2jar 将 class.dex 编成 jar 包,最后使用 jd-gui 查看 class 文件。
1.2 加固方案
常用的加固方案有以下三种:
- 反模拟器:模拟器运行 apk,可以用模拟器监控到 apk 的各种行为(所有人都可以自己使用 Google 提供的代码编译一个模拟器,从而自己添加监控 app 行为的代码,如打印网络请求信息等,运行 app 时就能监控到该 app 的重要信息了),所以在实际的加固 apk 运行时,一旦发现模拟器在运行该 apk,就停止核心代码的运行。
- 代码虚拟化:代码虚拟化在桌面平台应用保护中已经是非常的常见了,主要的思路是自建一个虚拟执行引擎,然后把原生的可执行代码转换成自定义的指令进行虚拟执行(把原本的应用 -> 虚拟机变成应用 -> 虚拟执行引擎 -> 虚拟机,增加了编码方案,应用运行在虚拟引擎上,而不是原本的系统的虚拟机上)。
- 加密:样本的部分可执行代码是以压缩或者加密的形式存在的。比如,被保护过的代码被切割成多个小段,前面的一段代码先把后面的代码片段在内存中解密,然后再去执行解密之后的代码,如此一块块的迭代执行。
对于加密的方案,dex 文件要先被 ClassLoader 加载进内存后,它包含的类才能运行。每个 dex 文件中的代码可以分为核心代码与非核心代码,只对核心代码进行加固,当非核心代码调用到核心代码时,对核心代码进行解密后运行。
我们把一个 dex 文件分为壳 dex 和源 dex,源 dex 被加密,壳 dex 不加密而且要负责解密源 dex。当它们需要运行时,先被加载进内存,然后用壳 dex 对源 dex 进行解密后才可执行源 dex 的代码。示意图如下:
除了框架图中画出的内容之外,还有几个问题需要先弄清楚:
1. dex 文件可以随便拼凑吗?
2. 壳 dex 怎么来的?
3. 如何签名?
4. 如何运行新 apk(如何脱壳)?
我们先来了解下 dex 文件。
1.3 dex 文件
加固的目的是保护 dex,直接而言就是对 dex 文件进行操作,操作前必须知道 dex 文件是什么、什么是源 dex、什么是壳 dex。以下是 dex 文件的结构图:
类的定义区包含变量和方法的定义信息,但是不包含方法内具体的执行代码。因此在反编译工具中能看到方法是如何定义声明的,但是看不到方法体。
1.4 Apk 打包流程
2、代码实现
由于加固并不属于 app 本身的功能,所以可以在业务模块外单独建一个 Android Library 按照如下流程进行操作:APK -> unzip -> 过滤文件 -> 找出所有 dex 文件 -> 加密得到源 dex -> 获取壳 dex -> 打包壳&源 dex 后签名。
2.1 解压 apk 并加密 dex
先将需要加固的 apk 解压到指定的临时目录,然后把 dex 文件读取成字节的形式,对这些字节进行加密,最后写到临时目录中同名的 dex 文件中,示例代码:
/*
第一步:解压原始apk,找到dex文件并加密
*/
AES.init(AES.DEFAULT_PWD);
File apkFile = new File("source/apk/app-debug.apk");
File newApkFile = new File(apkFile.getParent() + File.separator + "temp");
if (!newApkFile.exists()) {
newApkFile.mkdirs();
}
// 拿到主 dex 加密后的文件
File mainDexFile = AES.encryptAPKFile(apkFile, newApkFile);
// 把加密过的 dex 文件重命名一下,在名字和.之间加一个_
if (newApkFile.isDirectory()) {
File[] files = newApkFile.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.endsWith(".dex");
}
});
if (files != null) {
for (File file : files) {
String name = file.getName();
System.out.println("name:" + name);
int cursor = name.indexOf(".dex");
// 生成的文件名字要加上前面的路径,否则就会在项目根目录下生成重命名后的文件了
String newName = file.getParent() + File.separator + name.substring(0, cursor) + "_" + ".dex";
file.renameTo(new File(newName));
}
}
}
AES 类专门做加密解密工作,上边用到了 AES 的初始化和加密方法:
public static final String DEFAULT_PWD = "abcdefghijklmnop";
private static final String algorithmStr = "AES/ECB/PKCS5Padding";
private static Cipher encryptCipher;
private static Cipher decryptCipher;
/**
* 生成一个实现指定转换的 Cipher 对象。
*/
public static void init(String password) {
try {
encryptCipher = Cipher.getInstance(algorithmStr);
decryptCipher = Cipher.getInstance(algorithmStr);
byte[] passwordBytes = password.getBytes();
SecretKeySpec keySpec = new SecretKeySpec(passwordBytes, "AES");
encryptCipher.init(Cipher.ENCRYPT_MODE, keySpec);
decryptCipher.init(Cipher.DECRYPT_MODE, keySpec);
} catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException e) {
e.printStackTrace();
}
}
/**
* 把 srcAPKFile 表示的 apk 文件先解压到 dstApkFile 路径中,然后将 dstApkFile 目录下的
* dex 文件以字节的形式加密并写回
*
* @param srcAPKFile 源文件
* @param dstApkFile 目标文件
* @return 加密后的主dex文件
*/
public static File encryptAPKFile(File srcAPKFile, File dstApkFile) throws Exception {
if (srcAPKFile == null) {
throw new NullPointerException("srcAPKFile is null!");
}
Zip.unzip(srcAPKFile, dstApkFile);
// 获取所有 dex 文件(有分包情况,因此有不止一个 dex 文件)
File[] dexFiles = dstApkFile.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.endsWith(".dex");
}
});
File mainDexFile = null;
if (dexFiles != null) {
for (File dexFile : dexFiles) {
// 把文件读取成字节数组
byte[] buffer = Utils.getBytes(dexFile);
// 对字节数组进行加密
byte[] encryptBytes = AES.encrypt(buffer);
if (dexFile.getName().endsWith("classes.dex")) {
mainDexFile = dexFile;
}
// 把加密后的字节数据写回到 dex 文件中
FileOutputStream fos = new FileOutputStream(dexFile);
fos.write(encryptBytes);
fos.close();
}
}
return mainDexFile;
}
加密方法中是先将 apk 解压到临时目录了,然后用 FilenameFilter 过滤出 dex 文件,遍历这些 dex 文件时,把它们读取成字节形式并对字节进行加密:
Utils.java:
public static byte[] getBytes(File file) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
// 一次性把文件中的内容读取到 buffer 中,这个过程是在 Windows 系统下运行的,
// 而不是在 JVM 中,因此不用担心占用内存过大。
byte[] buffer = new byte[(int) randomAccessFile.length()];
randomAccessFile.readFully(buffer);
randomAccessFile.close();
return buffer;
}
AES.java:
private static byte[] encrypt(byte[] bytes) {
try {
return encryptCipher.doFinal(bytes);
} catch (BadPaddingException | IllegalBlockSizeException e) {
e.printStackTrace();
}
return null;
}
到这里加密工作结束。
2.2 获取壳 dex
壳 dex 的作用:
- 对已经加密的 dex 文件进行解密。
- 正常加载解密后的 dex 文件。
因此壳 dex 是不需要被加密的。对于本例而言,我们采用的方式是在一个 Android Library 模块中使用 Application 去解密,而 app 模块直接声明使用 Android Library 的 Application(在真正的商业项目开发中,由于 app 模块中一定是有一个自己的 Application 的,如果我们这个 Android Library 模块也要通过 Application 去解密和加载源 dex,那么只能是用插件化方式,让模块中的 Application 去 hook app 中的 Application 使得两个 Application 都得以执行)。
比如说 Android Library 模块 encryptdex 使用的 Application 的名称是 ShellApplication,那么 app 模块就要在 build.gradle 和 AndroidManifest.xml 中做如下配置:
dependencies {
implementation project(':encryptdex')
}
<application
android:name="com.frank.encryptdex.ShellApplication"/>
这样当 app 模块运行 Application 时,才会运行 ShellApplication,进而完成脱壳和加载 dex 的操作。
Android Library 模块编译出来的产物是 aar 文件。它的结构与 apk 文件相比,没有签名文件,并且没有 class.dex 文件,而是 class.jar 文件。制作壳 dex 文件,就是将这个 aar 文件解压,得到 class.jar 文件再转变成壳 dex。具体操作:
/*
第二步:处理 aar 文件,获得壳 dex 文件
*/
File aarFile = new File("source/aar/encryptdex-debug.aar");
File aarDexFile = Dx.jar2Dex(aarFile);
// 把 source/aar/temp/classes.dex 复制到 source/apk/temp 目录下
File tempMainDex = new File(newApkFile.getPath() + File.separator + "classes.dex");
if (!tempMainDex.exists()) {
tempMainDex.createNewFile();
}
FileOutputStream fos = new FileOutputStream(tempMainDex);
byte[] buffer = Utils.getBytes(aarDexFile);
fos.write(buffer);
fos.close();
Dx.jar2Dex() 执行了将 aar 文件解压并将其内部的 classes.jar 转换成 dex 文件的操作:
/**
* aar 文件放在 source/aar/encryptdex-debug.aar 路径下,将它解压到 source/aar/temp 目录下。
*
* @param aarFile
*/
public static File jar2Dex(File aarFile) {
// 构造 source/aar/temp 目录
File fakeDex = new File(aarFile.getParent() + File.separator + "temp");
Zip.unzip(aarFile, fakeDex);
// 找到 classes.jar 文件
File[] files = fakeDex.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.endsWith("classes.jar");
}
});
if (files == null || files.length <= 0) {
throw new RuntimeException("the aar is invalidate");
}
// 使用 android tools 里面的 dx.bat 命令将 classes.jar 转换成 dex 文件
File jarFile = files[0];
File dexFile = new File(jarFile.getParent(), "classes.dex");
dxCommand(jarFile, dexFile);
return dexFile;
}
/**
* 在 Windows 下使用 dx.bat 命令将 jar 文件转换成 dex 文件
*
* @param jarFile
* @param dexFile
*/
private static void dxCommand(File jarFile, File dexFile) {
Runtime runtime = Runtime.getRuntime();
Process process = null;
try {
process = runtime.exec("cmd.exe /C dx --dex --output=" +
dexFile.getAbsolutePath() + " " + jarFile.getAbsolutePath());
process.waitFor();
// 没有正常执行完的情况,log 输出信息并抛出异常
if (process.exitValue() != 0) {
InputStream is = process.getErrorStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[2048];
int length;
while ((length = is.read(buffer)) != -1) {
outputStream.write(buffer, 0, length);
}
System.out.println(new String(outputStream.toByteArray(), "GBK"));
throw new RuntimeException("dx run failed!");
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
if (process != null) {
process.destroy();
}
}
}
dxCommand() 其实就是执行了 dx 命令将 jar 文件转换成 dex 文件。到这就得到了这个壳 dex classes.dex 文件。
2.3 打包签名
接下来就要把壳 dex 移动到第1步得到的存放已经加密过的 dex 文件所在的临时目录中,然后将该目录下所有的文件打包成 apk 文件再签名。步骤如下:
/*
第三步:打包签名
*/
File unsignedApk = new File("source/result/apk-unsigned.apk");
unsignedApk.getParentFile().mkdirs();
// 把 source/apk/temp 目录下的文件全部压缩到 unsignedApk 文件中
Zip.zip(newApkFile, unsignedApk);
// 不用插件就不能自动使用原apk的签名...
File signedApk = new File("source/result/apk-signed.apk");
Signature.signature(unsignedApk, signedApk);
重点是 Signature 执行签名的过程:
public class Signature {
public static void signature(File unsignedApk, File signedApk) throws InterruptedException, IOException {
// 执行的命令是键值对形式
String cmd[] = {"cmd.exe", "/C ", "jarsigner",
"-sigalg", "MD5withRSA",
"-digestalg", "SHA1",
"-keystore", "C:\\Users\\69129\\demo.keystore",
"-storepass", "123456",
"-keypass", "123456",
"-signedjar", signedApk.getAbsolutePath(),
unsignedApk.getAbsolutePath(),
"mykey"};
Process process = Runtime.getRuntime().exec(cmd);
System.out.println("start sign");
// 对执行结果信息进行输出
int waitResult = process.waitFor();
System.out.println("waitResult: " + waitResult);
System.out.println("process.exitValue() " + process.exitValue());
if (process.exitValue() != 0) {
InputStream inputStream = process.getErrorStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[2048];
int length;
while ((length = inputStream.read(buffer)) != -1) {
bos.write(buffer, 0, length);
}
System.out.println(new String(bos.toByteArray(), "GBK"));
}
System.out.println("finish signed");
process.destroy();
}
}
与 dx 类似其实也是在 cmd 中执行了一条指令完成的打包。使用 jarsigner 指令打包前,需要先新建一个 keystore:
D:\>keytool -genkey -alias demo.keystore -keyalg RSA -validity 40000 -keystore demo.keystore
/*说明:-genkey 产生密钥
-alias demo.keystore 别名 demo.keystore
-keyalg RSA 使用RSA算法对签名加密
-validity 40000 有效期限4000天
-keystore demo.keystore */
生成了这个 demo.keystore 之后要记好 keystore 的密码,然后才使用 jarsigner 对 apk 进行签名:
D:\>jarsigner -verbose -keystore demo.keystore -signedjar demo_signed.apk demo.apk demo.keystore
/*说明:-verbose 输出签名的详细信息
-keystore demo.keystore 密钥库位置
-signedjar demor_signed.apk demo.apk demo.keystore 正式签名,三个参数中依次为签名后产生的文件demo_signed,要签名的文件demo.apk和密钥库demo.keystore.*/
如果忘记了 keystore 文件是如何配置的,也可以通过命令查看 keystore 的信息:
keytool -v -list -keystore demo.keystore
2.4 脱壳
加密、签名过后得到的 apk 文件可以安装了,但是不能运行,因为还没解密。
运行时才脱壳,安装时没有执行代码,所以解密不是在安装阶段,而是在运行阶段,并且是在运行阶段的越早越好,因为不解密,被加固的代码就无法执行。所以解密代码要写在 Application 的 attachBaseContent() 中(attachBaseContent() 比 onCreate() 早):
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
AES.init(getPassword());
File apkFile = new File(getApplicationInfo().sourceDir);
//data/data/包名/files/fake_apk/
File unZipFile = getDir("fake_apk", MODE_PRIVATE);
File app = new File(unZipFile, "app");
if (!app.exists()) {
Zip.unZip(apkFile, app);
File[] files = app.listFiles();
for (File file : files) {
String name = file.getName();
if (name.equals("classes.dex")) {
// 本例中 classes.dex 是壳 dex,已经被加载了,这里不用再处理。
} else if (name.endsWith(".dex")) {
try {
byte[] bytes = getBytes(file);
FileOutputStream fos = new FileOutputStream(file);
byte[] decrypt = AES.decrypt(bytes);
fos.write(decrypt);
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
List list = new ArrayList<>();
Log.d("FAKE", Arrays.toString(app.listFiles()));
for (File file : app.listFiles()) {
if (file.getName().endsWith(".dex")) {
list.add(file);
}
}
Log.d("FAKE", list.toString());
try {
V19.install(getClassLoader(), list, unZipFile);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}
还是先将 apk 文件解压,解压出来的 dex 文件中,classes.dex 是壳 dex 已经被系统加载了,需要重新用 ClassLoader 加载的是剩下的加密的 dex。对这些 dex 先解密,然后通过反射让 ClassLoader 重新加载这些 dex 文件进内存。这个过程就跟热修复的最终过程是一样的了,先贴出一段代码,在 API < 27 是可行的:
private static final class V19 {
private V19() {
}
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory) throws IllegalArgumentException,
IllegalAccessException, NoSuchFieldException, InvocationTargetException,
NoSuchMethodException {
Field pathListField = findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
ArrayList suppressedExceptions = new ArrayList();
Log.d(TAG, "Build.VERSION.SDK_INT " + Build.VERSION.SDK_INT);
if (Build.VERSION.SDK_INT >= 23) {
expandFieldArray(dexPathList, "dexElements", makePathElements(dexPathList, new
ArrayList(additionalClassPathEntries), optimizedDirectory,
suppressedExceptions));
} else {
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new
ArrayList(additionalClassPathEntries), optimizedDirectory,
suppressedExceptions));
}
if (suppressedExceptions.size() > 0) {
Iterator suppressedExceptionsField = suppressedExceptions.iterator();
while (suppressedExceptionsField.hasNext()) {
IOException dexElementsSuppressedExceptions = (IOException)
suppressedExceptionsField.next();
Log.w("MultiDex", "Exception in makeDexElement",
dexElementsSuppressedExceptions);
}
Field suppressedExceptionsField1 = findField(loader,
"dexElementsSuppressedExceptions");
IOException[] dexElementsSuppressedExceptions1 = (IOException[]) ((IOException[])
suppressedExceptionsField1.get(loader));
if (dexElementsSuppressedExceptions1 == null) {
dexElementsSuppressedExceptions1 = (IOException[]) suppressedExceptions
.toArray(new IOException[suppressedExceptions.size()]);
} else {
IOException[] combined = new IOException[suppressedExceptions.size() +
dexElementsSuppressedExceptions1.length];
suppressedExceptions.toArray(combined);
System.arraycopy(dexElementsSuppressedExceptions1, 0, combined,
suppressedExceptions.size(), dexElementsSuppressedExceptions1.length);
dexElementsSuppressedExceptions1 = combined;
}
suppressedExceptionsField1.set(loader, dexElementsSuppressedExceptions1);
}
}
private static Object[] makeDexElements(Object dexPathList,
ArrayList<File> files, File
optimizedDirectory,
ArrayList<IOException> suppressedExceptions) throws
IllegalAccessException, InvocationTargetException, NoSuchMethodException {
Method makeDexElements = findMethod(dexPathList, "makeDexElements", new
Class[]{ArrayList.class, File.class, ArrayList.class});
return ((Object[]) makeDexElements.invoke(dexPathList, new Object[]{files,
optimizedDirectory, suppressedExceptions}));
}
}
/**
* A wrapper around
* {@code private static final dalvik.system.DexPathList#makePathElements}.
*/
private static Object[] makePathElements(
Object dexPathList, ArrayList<File> files, File optimizedDirectory,
ArrayList<IOException> suppressedExceptions)
throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
Method makePathElements;
try {
makePathElements = findMethod(dexPathList, "makePathElements", List.class, File.class,
List.class);
} catch (NoSuchMethodException e) {
Log.e(TAG, "NoSuchMethodException: makePathElements(List,File,List) failure");
try {
makePathElements = findMethod(dexPathList, "makePathElements", ArrayList.class, File.class, ArrayList.class);
} catch (NoSuchMethodException e1) {
Log.e(TAG, "NoSuchMethodException: makeDexElements(ArrayList,File,ArrayList) failure");
try {
Log.e(TAG, "NoSuchMethodException: try use v19 instead");
return V19.makeDexElements(dexPathList, files, optimizedDirectory, suppressedExceptions);
} catch (NoSuchMethodException e2) {
Log.e(TAG, "NoSuchMethodException: makeDexElements(List,File,List) failure");
throw e2;
}
}
}
return (Object[]) makePathElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions);
}
代码没整理,写的混乱,因为后续还要重新再写。原因是这些代码在 API >= 27 就不可用了,因为 makePathElements() 的返回值那个语句使用的反射会抛出异常:
Opening an oat file without a class loader.Are you using the deprecated DexFile APIs?
抛出异常的原因是,Google 在我们反射的源码 DexFile 类加上了 @Deprecated,并且在底层的 oat_file_manager.cc 文件中加入了判断:
if (class_loader == nullptr) {
LOG(WARNING) << "Opening an oat file without a class loader. "
<< "Are you using the deprecated DexFile APIs?";
context = nullptr;
} else {
context = ClassLoaderContext::CreateContextForClassLoader(class_loader, dex_elements);
}
它输出的 log 内容正是我们看到的 log 中被抛出的异常内容。
所以在 API >= 27 时不能再使用上面的方法了,可以参考腾讯 Tinker 热修复的做法:
https://github.com/Tencent/tinker/blob/master/tinker-android%2Ftinker-android-loader%2Fsrc%2Fmain%2Fjava%2Fcom%2Ftencent%2Ftinker%2Floader%2FNewClassLoaderInjector.java
https://github.com/Tencent/tinker/blob/master/tinker-android/tinker-android-loader/src/main/java/com/tencent/tinker/loader/SystemClassLoaderAdder.java
参考资料
加固的参考资料:
10-实战开发APK安全加固
Android apk加固实现原理
Android apk加固实现原理(二)
apk加固
Android APK加固完善篇
[Android 分享] 安卓apk简单加固防修改
AES 加密算法相关的:
AES加密算法的详细介绍与实现
使用java实现AES加密
Java实现AES加密
压缩相关:
Java操作zip-压缩和解压文件
java实现对rar文件和zip文件的解压缩
利用java zip进行对文件的压缩和解压
签名相关:
Android学习系列(1)–为App签名(为apk签名)
给Android的APK程序签名和重新签名的方法
Android apk签名的两种方法
cmd里查看jks签名信息,更改别名,删除别名,查看apk签名信息,keytool高级用法