版权声明:本文为博主原创文章,未经博主允许不得转载。
本文纯个人学习笔记,由于水平有限,难免有所出错,有发现的可以交流一下。
一个安卓 apk 里面包含有 AndroidManifest.xml、classes.dex、resources.arsc 等文件,java 代码主要存在于 classes.dex 中。
把 classes.dex 文件拆分为两个 dex 文件,其中主 dex 文件就只包含应用加载的 Application,另外一个 dex 文件包含其他所有类。然后,对非主 dex 文件进行加密,得到加密后的 dex 文件,在 Application 中进行加密后的 dex 的加载和解密操作。
这样的方案大体上实现了加密操作,但是,我们很经常在 Application 类中进行一些初始化操作,会引入较多的类,Application 类存放于主 dex 中,也会泄漏较多的信息。
在这边进行对加密方案的优化,对 Application 也进行加密。把 classes.dex 文件进行重命名,改为非主 dex,然后对这个 dex 进行加密,这样加密后的 dex 就包含所有的类,包括 Application。我们需要添加一个 dex 作为主 dex,在这里面只包含一个 Application 类,这个类的作用就是对加密后的 dex 进行加载和解密。
类加载器,我们常用的一般是 PathClassLoader 和 DexClassLoader。我们在安卓中,用到的是 PathClassLoader 这个类加载器,可以直接在代码中通过 getClassLoader() 进行打印出来,进行验证。
源码目录 \libcore\dalvik\src\main\java\dalvik\system
PathClassLoader:
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
PathClassLoader 里面没干什么事情,就继承了 BaseDexClassLoader。BaseDexClassLoader 是通过 findClass 方法来查找到一个类的,传递一个全类名进来,然后获取到一个类对象。
BaseDexClassLoader 的 findClass:
protected Class> findClass(String name) throws ClassNotFoundException {
List suppressedExceptions = new ArrayList();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
findClass 方法里面是通过 DexPathList 的 findClass 方法进行查找。
DexPathList 的 findClass:
/**
* List of dex/resource (class path) elements.
* Should be called pathElements, but the Facebook app uses reflection
* to modify 'dexElements' (http://b/7726934).
*/
private Element[] dexElements;
public Class findClass(String name, List suppressed) {
// 遍历成员数组 dexElements
for (Element element : dexElements) {
//加载 dex
DexFile dex = element.dexFile;
if (dex != null) {
// 加载 dex 里面的类
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
dexElements 是一个 dex 的数组节点,通过注释我们可以看到,Facebook 的 app 通过反射对这个进行了修改。dexElements 保存的是应用要加载的 dex 文件数组,默认是保存了 classes.dex 这个文件,当我们要对应用进行 dex 分包的时候,需要把其他的 dex 添加到这里面。
这边需要用到几个工具进行,这几个工具在安卓的 sdk 下都可以找到。
我们使用 sdk\build-tools 下的 dx.bat 把一个 jar 包生成 dex 文件。
生成 dex 指令:dx --dex --output out.dex in.jar
我们使用 sdk\build-tools 下的 zipalign.exe 对未压缩的数据开头均相对于文件开头部分执行特定的字节对齐,减少应用运行内存。
整理对齐指令:zipalign [-v] [-f] 4 in.apk out.apk
我们使用 sdk\build-tools (SDK 24 以上)下的 apksigner.bat 对未签名的 APK 进行签名操作。
apk 签名指令:apksigner sign --ks jks文件地址 --ks-key-alias 别名 --ks-pass pass:jsk密码 --key-pass pass:别名密码 --out out.apk in.apk
校验命令:apksigner verify -v out.apk
我们先来实现 apk 的解密部分。新建一个 Android Library 工程 proxy-guard-core。
我们需要创建一个 ProxyApplication 继承自 Application,作为假的 Application,配置在 AndroidManifest.xml 中。
ActivityThread 创建 Application 之后调用的第一个函数是 attachBaseContext()这个方法(具体就不解释了),我们需要在这个方法里面进行真正 dex 的解密和加载。
attachBaseContext():
// 真实的 Application 全类名,在 AndroidManifest.xml 中配置
private String app_name;
// 一个版本标识,用于 dex 解密后的目录名,在 AndroidManifest.xml 中配置
private String app_version;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// 获取 AndroidMainfest.xml 中的 app_name 和 app_version
getMetaData();
Log.e("", "111 zx " + app_name + app_version);
// 获得当前应用的 apk 文件
File apkFile = new File(getApplicationInfo().sourceDir);
// 把 apk 解压出来,存放在/data/data/包名/app_name_app_version/app 下
File versionDir = getDir(app_name + "_" + app_version, MODE_PRIVATE);
File appDir = new File(versionDir, "app");
// apk 中解密后的所有 dex 放入到这个目录
File dexDir = new File(appDir, "dexDir");
}
在 AndroidMainfest.xml 中保存了 app_name 与 app_version 两个参数,用来对各个版本的 apk 创建临时文件,使用 getMetaData()方法去进行这两个值的读取,也可以使用其他方法。
创建 data/data/包名/app_name_app_version/app 目录用来存放解压后的 apk,创建 data/data/包名/app_name_app_version/app/dexDir 目录用来存放解密后的 dex 文件。
getMetaData():
/**
* 获取 AndroidMainfest.xml 下配置的 meta-data
*/
public void getMetaData(){
try {
ApplicationInfo applicationInfo = getPackageManager()
.getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
Bundle metaData = applicationInfo.metaData;
//是否设置app_name 与 app_version
if (null != metaData) {
//是否存在name为app_name的meta-data数据
if (metaData.containsKey("app_name")) {
app_name = metaData.getString("app_name");
}
if (metaData.containsKey("app_version")) {
app_version = metaData.getString("app_version");
}
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
attachBaseContext():
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
...
// 第一次进入,需要对 dex 进行解密
// 如果需要,还可以对 dex 先进行 md5 校验
if (!dexDir.exists() ){
dexDir.mkdirs();
}
if (dexDir.list().length == 0){
// apk 解压到 appDir 文件夹下
ZipUtil.unZip(apkFile, appDir);
// 获取解压后的 apk 所有文件
File[] files = appDir.listFiles();
for (File file : files){
String name = file.getName();
// 文件名是 .dex 结尾,且不是主 dex 的话,放入 dexDir 目录
if (name.endsWith(".dex") && !"classes.dex".equals(name)) {
byte[] bytes = FileUtil.getBytes(file);
// 进行解密,并且保存到 dexDir 目录下
byte[] plaintext = AESUtil.decrypt(bytes, "asdfghjuyt123456");
FileOutputStream writer = null;
try {
Log.d("test", "111 zx plaintext: " + plaintext.length);
// 写到指定目录
writer = new FileOutputStream(new File(dexDir.getAbsolutePath(), name));
writer.write(plaintext);
writer.flush();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
if (writer != null){
writer.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
这一步比较简单,先把 apk 解压到 data/data/包名/app_name_app_version/app 目录,采用的解压工具类比较常见。遍历解压出来的文件,查找需要进行解密的 dex,调用解密方法,并把解密后的文件保存在 data/data/包名/app_name_app_version/app/dexDir 目录下。
这个解密需要与加密方法对应,这边采用的简单的 AES 加解密作为例子,具体实际可根据要求采用对应的加解密方式。另外,解密方法还可以考虑使用 so 库进行代替,增加安全性。自己编写或者使用 openSSL 等。
attachBaseContext():
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
...
// 解密后的 dex,需要加载
Log.d("test", "111 zx dexDir: " + dexDir.getAbsolutePath());
List dexFiles = new ArrayList<>();
for (File file : dexDir.listFiles()) {
dexFiles.add(file);
}
loadDex(dexFiles, versionDir);
}
上面查看 ClassLoader 可以知道,dexElements 数组保存着需要加载的 dex,要把我们自己的 dex 加载到应用中,只要添加到 dexElements 这个数组中即可。
由于 dexElements 是私有的属性,且没有方法可以直接进行设置,所以采用反射,对 dexElements 数组进行操作。
loadDex():
/**
* 加载 dex
* @param dexFiles 需要加载的 dex 集合
* @param optimizedDirectory dex 加载缓存目录
*/
private void loadDex(List dexFiles, File optimizedDirectory){
try {
/**
* 1.获得系统 classloader 中的 dexElements 数组
*/
// 获得 classloader 中的 pathList(是一个 DexPathList)
Field pathListField = ClassUtil.findField(getClassLoader(), "pathList");
Object pathList = pathListField.get(getClassLoader());
// 获得pathList类中的 dexElements
Field dexElementsField = ClassUtil.findField(pathList, "dexElements");
Object[] dexElements = (Object[]) dexElementsField.get(pathList);
/**
* 2.创建新的 element 数组 -- 解密后加载dex
*/
// 需要适配安卓版本,5.0、6.0、7.0 都不一样
// 具体要看各个版本的 dexElements 的创建方法是哪个,对这个方法进行反射
ArrayList suppressedExceptions = new ArrayList();
Method makeDexElements = null;
// Element 数组
Object[] addElements = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && Build.VERSION.SDK_INT <
Build.VERSION_CODES.M) {
makeDexElements = ClassUtil.findMethod(pathList, "makeDexElements", ArrayList.class,
File.class, ArrayList.class);
addElements = (Object[]) makeDexElements.invoke(pathList, dexFiles,
optimizedDirectory, suppressedExceptions);
} else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.N){
makeDexElements = ClassUtil.findMethod(pathList, "makePathElements", ArrayList.class,
File.class, ArrayList.class);
addElements = (Object[]) makeDexElements.invoke(pathList, dexFiles,
optimizedDirectory, suppressedExceptions);
}else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
makeDexElements = ClassUtil.findMethod(pathList, "makePathElements", ArrayList.class,
File.class, ArrayList.class, ClassLoader.class);
addElements = (Object[]) makeDexElements.invoke(pathList, dexFiles,
optimizedDirectory, suppressedExceptions, getClassLoader());
}
/**
* 3.合并两个数组
*/
//创建一个数组
Object[] newElements = (Object[]) Array.newInstance(dexElements.getClass()
.getComponentType(), dexElements.length +
addElements.length);
System.arraycopy(dexElements, 0, newElements, 0, dexElements.length);
System.arraycopy(addElements, 0, newElements, dexElements.length, addElements.length);
/**
* 4.替换classloader中的 element数组
*/
dexElementsField.set(pathList, newElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
由于在 AndroidMainfest.xml 中配置的 application 为 ProxyApplication,所以需要把原先在 application 中实现的初始化操作过程也移到 ProxyApplication 中。例如创建 ProxyApplication 的单例。
private static ProxyApplication app;
@Override
public void onCreate() {
app = this;
super.onCreate();
}
/** 获取单例 */
public static ProxyApplication getInstance() {
return app;
}
新建一个 Java Library 工程 proxy-guard-tools。
proxy-guard-tools 主要是作为一个 java 工具,通过运行 main 方法实现一些操作:
1.把 proxy-guard-core 打包成主 dex。
2.解压程序的 apk,对原先的主 dex 进行加密,以及修改名称,成为非主 dex。
3.把刚打包生成的主 dex 整合到 apk 的解压目录下,重新打包成 apk。
4.对 apk 进行对齐以及签名操作。
需要把上面编写的 proxy-guard-core 下的代码制作成主 dex,让应用启动的时候就进行加载。
首先需要获取到 java 代码生成的 jar 包,可以自己使用命令进行打包。这边直接使用 proxy-guard-core 的 aar 文件进行解压到 proxy-guard-tools/aarTemp 下获取。(如果没有存在 aar 文件, Rebuild 一下即可。)
// 1.解压 aar,获取 proxy-guard-core 的 java 代码生成的 classes.jar
File aarFile = new File("proxy-guard-core/build/outputs/aar/proxy-guard-core-debug.aar");
File aarTemp = new File("proxy-guard-tools/aarTemp");
ZipUtil.unZip(aarFile, aarTemp);
使用 dex 命令对获取的 jar 包进行打包生成 dex。1.这个 dex 的名字必须是 classes.dex。2,dex 命令没有偶在环境变量中进行配置的话是会出错的。
// 2.执行 dex 命令,将 jar 打包成 dex 文件
File classesJar = new File(aarTemp, "classes.jar");
File classesDex = new File(aarTemp, "classes.dex");
// 执行命令,windows 需要添加 cmd /c 才可以
Process process = Runtime.getRuntime().exec("cmd /c dx --dex --output "
+ classesDex.getAbsolutePath() + " " + classesJar.getAbsolutePath());
process.waitFor();
// 失败
if (process.exitValue() != 0){
throw new RuntimeException("dex error");
}
执行成功后,会在 proxy-guard-tools 工程下生成一个 aarTemp 文件夹,里面保存着 proxy-guard-core 工程的 java 代码打包的 dex。
首先需要先使用证书对应用的进行签名打包,生成带证书的 apk。需要记住这本证书,等会重新签名时候需要用到。
解压生成的 apk 到 proxy-guard-tools 工程下的 apkTemp,正常解压即可。
// 1.解压 apk
File apkFile = new File("app/release/app-release.apk");
File apkTemp = new File("proxy-guard-tools/apkTemp");
ZipUtil.unZip(apkFile, apkTemp);
在解压出来的文件中进行查找,如果是 dex 的话进行加密操作(这个加密算法与上方的解密算法对应,这里不需要使用 so 库进行调用,直接 java 代码即可)。同时需要重命名(主要是 classes.dex 这个主 dex 需要重命名)。
// 2.获得 apk 中所有的 dex,并进行加密
File[] dexFiles = apkTemp.listFiles(new FilenameFilter() {
@Override
public boolean accept(File file, String s) {
return s.endsWith(".dex");
}
});
// 加密
for (File dex : dexFiles) {
encryFile(dex, new File(apkTemp, "secret-" + dex.getName()));
}
encryFile():
private static void encryFile(File dex, File secretDex){
RandomAccessFile read = null;
FileOutputStream writer = null;
try {
// 读取 dex 文件的字节数组
read = new RandomAccessFile(dex, "r");
byte[] buffer = new byte[(int) read.length()];
read.readFully(buffer);
// 加密
byte[] ciphertext = AESUtil.encrypt(buffer, "asdfghjuyt123456");
// 写到指定目录
writer = new FileOutputStream(secretDex);
writer.write(ciphertext);
writer.flush();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
if (read != null){
read.close();
dex.delete();
}
if (writer != null){
writer.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行成功后,会在 proxy-guard-tools 工程下的 apkTemp 中解压出 apk 的所有内容,并且 classes.dex 已经被加密为 secret-classes.dex。
把上面 proxy-guard-core 打包生成的 classes.dex 拷贝到 apkTemp 目录下,使用压缩命令,重新打包为 apk,并保存在 proxy-guard-tools/outputs 下。
/**
* 把 classes.dex 放入 apk 解压的目录,再压缩成 apk
*/
classesDex.renameTo(new File(apkTemp, "classes.dex"));
File file = new File("proxy-guard-tools/outputs");
if (!file.exists()){
file.mkdirs();
}
File unSignedApk = new File("proxy-guard-tools/outputs/app-unsigned.apk");
ZipUtil.zip(apkTemp, unSignedApk);
上述打包生产的 apk 是未签名的,我们需要对这个 apk 进行对齐和签名。
使用 zipalign 进行对齐,可以减少应用的运行内存。
//1.对齐
// 26.0.2 不支持 -p 参数
File alignedApk = new File("proxy-guard-tools/outputs/app-unsigned-aligned.apk");
process = Runtime.getRuntime().exec("cmd /c zipalign -f 4 " + unSignedApk
.getAbsolutePath() + " " +
alignedApk.getAbsolutePath());
process.waitFor();
// 失败
if (process.exitValue() != 0) {
throw new RuntimeException("zipalign error");
}
对对齐后的应用进行签名操作。这个签名证书需要使用前面对 apk 进行签名使用的证书。
// 2.签名
File signedApk = new File("proxy-guard-tools/outputs/app-signed-aligned.apk");
File jks = new File("proxy-guard-tools/xiaoyue.jks");
process = Runtime.getRuntime().exec("cmd /c apksigner sign --ks " + jks.getAbsolutePath()
+ " --ks-key-alias xiaoyue --ks-pass pass:xiaoyue --key-pass pass:xiaoyue --out "
+ signedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());
System.out.print("cmd /c apksigner sign --ks " + jks.getAbsolutePath()
+ " --ks-key-alias xiaoyue --ks-pass pass:xiaoyue --key-pass pass:xiaoyue --out "
+ signedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());
process.waitFor();
//失败
if (process.exitValue() != 0) {
throw new RuntimeException("apksigner error");
}
这时候安装即可正常运行,解压 apk 可以发现,我们编写的 java 代码被进行加密操作,无法进行反编译查看。第一次运行会比较慢,因为要进行解密操作。
在整个过程中碰到几个问题,这边记录一下,可以参考一下。
1.各个命令没有配置在环境变量中。
由于这边是使用代码进行运行命令行,所以需要环境变量支持的,编写成一个 java 方法,后期可以复用。
2.java 版本与各个命令运行环境要求不符。
碰见过签名时候 JDK 版本不对,导致签名失败。可以只在在命令行窗口进行运行命令看能不能成功。修改完 JDK 版本后,需要重启 Android Studio。
3.两次签名使用证书不同。
apk 打包的时候需要自己选择好证书进行打包,后面重新打包的时候也要使用这一个证书。
4.Rebuild 时候,proxy-guard-core 下 build 有存在的话,有时候不会重新进行打包。
应该是我使用的这个版本 Android Studio 的 bug 吧,进行手动删除,然后再 Rebuild。
代码链接