从 Apk 提取代码到单独 dex
Android 中动态加载
是指应用程序在运行时加载和执行 Dex 文件的过程,可以在运行时加载不同的代码或功能,而无需重新编译整个应用程序,动态加载 Dex 文件通常涉及以下步骤:
- 创建 Dex 文件
我们接触到的通常是 Android studio 等 IDE 工具将 Java 或 Kotlin 代码编译成 Dex 格式的字节码文件。 - 将 Dex 文件打包到 APK 中
Apk 就是一个压缩包,通常也是 IDE 将编译好的 Dex 文件打包到应用程序的 APK 文件中。 - 运行时加载 Dex 文件
当应用程序启动时,Android 系统会加载应用程序的代码。如果应用程序中包含 Dex 文件,系统会自动将其加载到内存中,以便应用程序可以执行其中的代码。 - 执行 Dex 文件中的代码:
一旦 Dex 文件被加载到内存中,应用程序就可以执行其中的代码。
通过动态加载 Dex 文件,可以实现更加灵活和可扩展的功能,可以编写插件或模块化的代码,并在运行时根据需要加载它们。DexClassLoader
是 Android 中的一个类加载器,用于动态加载包含 Dex 文件的 jar 或 apk 文件,它的工作原理大概是:加载 Dex 文件,DexClassLoader 会从指定的路径中读取 Dex 文件,并将其加载到内存中… …无序过多描述,我们知道它可以加载 dex 文件即可。
本文讲的如下图所示:
如何从 apk 中获取特定包名下的代码并保存到 dex 文件中,然后再把此 dex 放到 assets 目录下,最后重新打包成 APK。
本次通过 Java 实现,最终产物是一个可执行的 Jar,使用方式如下所示:
java -jar xpluginJs.jar -mapping mapping.txt -apk 1.apk -dexname hello -pkg com.primer.pay.manager,com.primer.login.manager
意思是:
根据 mapping.txt 映射文件从 1.apk 安装包中提取所有 com.primer.pay.manager,com.primer.login.manager 包下的代码,将所提取代码写入 hello.dex 文件,hello.dex 文件将被放到包体内 assets 目录下。
/*** * 1、必须参数* -mapping:安装包的映射文件* -apk:待处理 apk 文件* -dexname:处理后存放在 assets 目录下的 dex 文件名* -pkg:需要提取的包名(混淆前的包名),多个包名用英文逗号分隔* * * 2、当前目录运行环境目录结构* tools:内置工具(apktool、smali)* xpluginJs.jar:主程序* mapping.txt:映射文件* 1.apk:apk 文件* output-dex2jar:中间产物输出目录* output-dex2jar\apk-decode\dist\***.apk:最终产物*/
0、线性执行
一图概述~
public void run() {System.err.println("=================== 1、Initializes the output directory");initOutPutDir();mDexConfig.setspecifyPkgSmailOutputPath(OUTPUT_PATH_SMAIL);System.err.println("=================== 2、Load the local mapping configuration");mObsMapping = loadMappingConfig(mDexConfig.getMappingFilePath());System.err.println("=================== 3、Gets a list of mapping names for the specified package name");List<String> specifyMappingPkgList = ObsMappingUtils.collectionSpecifyPkgMapping(mObsMapping,mDexConfig.getSpecifiyPkgList());System.err.println("=================== 4、Decompile Apk using apktool");String decodeOutPath = decodeApk(mDexConfig.getApkFilePath(), mDexConfig.getApkDecodeOutputDirname());mDexConfig.setApkDecodeOutputDirpath(decodeOutPath);System.err.println("=================== 5、Collects and moves the smail file under the specified package name");collectionAndMoveSpecifyPkgSmail(decodeOutPath, specifyMappingPkgList);System.err.println("=================== 6、Package the smali file as dex");String smailToDexPath = encodeSmailToDex(mDexConfig.getSpecifyPkgSmailOutputPath());if (CommUtil.isEmptyOrNoExists(smailToDexPath)) {throw new IllegalArgumentException("smailToDexPath is null");}System.err.println("\tsmailToDexPath: " + smailToDexPath);System.err.println("=================== 7、dex encryption");String encodeDexPath = encodeDex(mDexConfig.getEncodeDexFilename(), smailToDexPath);System.err.println("\tencodeDexPath: " + encodeDexPath);String decodeDexPath = decodeDex(mDexConfig.getEncodeDexFilename() + "-decode", encodeDexPath);System.err.println("\tencodeDexPath: " + decodeDexPath);System.err.println("=================== 8、copy dex encryption");boolean isCopy = copyDexToAssets(encodeDexPath, decodeOutPath);if (!isCopy) {throw new IllegalArgumentException("copy dex to assets error");}System.err.println("=================== 9、Apk compile back");String unsignApkFilePath = encodeApk(decodeOutPath);System.err.println("\t [Successfully~] unsignApkFilePath: " + unsignApkFilePath);}
1、参数解析
按格式解析参数,收集必要的信息,这里参数解析或许有漏洞,但你可以根据自己的想法写出更好的参数解析,避免因外部使用的多样化导致内部解析异常。
package com.primer;import java.util.Arrays;
import com.primer.bean.CmdArgs;public class Main {private static DexToolManager mDexToolManager;private static CmdArgs mCmdArgs;public static void main(String[] args) {mCmdArgs = parserArgs(args);initDexToolManager();}//解析 java -jar 传入的参数private static CmdArgs parserArgs(String[] args) {if (args == null || args.length == 0) {throw new IllegalArgumentException(" argument not be empty");}CmdArgs cmdArgs = new CmdArgs();String current;for (int i = 0; i < args.length; i++) {current = args[i];if (current.equals("-mapping")) {if (i + 1 >= args.length) {throw new IllegalArgumentException("-mapping args error");}cmdArgs.mappingPath = args[i + 1];} else if (current.equals("-apk")) {if (i + 1 >= args.length) {throw new IllegalArgumentException("-apk args error");}cmdArgs.apkPath = args[i + 1];} else if (current.equals("-dexname")) {if (i + 1 >= args.length) {throw new IllegalArgumentException("-dexname args error");}cmdArgs.encodeDexFilename = args[i + 1];} else if (current.equals("-pkg")) {if (i + 1 >= args.length) {throw new IllegalArgumentException("-pkg args error");}String str = args[i + 1];String[] pkgList = str.split(",");if (pkgList == null || pkgList.length == 0) {throw new IllegalArgumentException("args error");}cmdArgs.specifiyPkgList = Arrays.asList(pkgList);}}cmdArgs.checkArgument();return cmdArgs;}private static void initDexToolManager() {DexConfig dexConfig = new DexConfig.Builder().setApkFilePath(mCmdArgs.apkPath).setMappingFilePath(mCmdArgs.mappingPath).setSpecifyPkgList(mCmdArgs.specifiyPkgList).setEncodeDexFilename(mCmdArgs.encodeDexFilename).setApkDecodeOutputDirname("apk-decode").build();//简单得对参数是否有效做检查dexConfig.checkConfigIllegal();mDexToolManager = new DexToolManager();mDexToolManager.setDexConfig(dexConfig);mDexToolManager.run();}
}
2、准备工作目录
- initOutPutDir:创建输出目录、清空输出目录残留的文件
如果让自己写删除目录及目录下的所有文件,很容易让我们想起了递归遍历,可以递归删除文件。
public void traversalFile(File dirFile, FileTraversal traversal) {if (dirFile == null) {return;}for (File file : dirFile.listFiles()) {if (file.isDirectory()) {traversalFile(file, traversal);traversal.processDir(file);} else {traversal.processFile(file);}}}//使用
traversalFile(outFile, new FileTraversal() {@Overridepublic void processFile(File file) {if (file.exists()) {file.delete();}}@Overridepublic void processDir(File file) {if (file.exists()) {file.delete();}}});
3、加载映射
- loadMappingConfig:加载 mapping.txt 映射文件
我们知道 build/output/**/ release/mapping.txt
就是 Android 开启混淆打包生成的映射文件,就是根据该文件的格式进行解析,可以从中解析获取混淆前类的全限定名(包名+类名),当然也可以拿到混淆前后的方法名等信息。
com.opos.mobad.service.tasks.a -> com.opos.mobad.service.tasks.a:java.lang.String a -> ajava.io.FileFilter b -> bjava.util.HashMap getPayMap(android.content.Context,boolean,int) -> aboolean d(android.content.Context) -> djava.lang.String j(android.content.Context) -> j
根据映射文件我们可以这样简单写出解析存储数据的 bean 类及关系。
//一个映射文件包含很多类的映射
public class ObsMapping {private LinkedList<ClassObsMapping> obsMapping;//略
}//一个类包含类名映射、多个成员变量映射、多个方法的映射(我们没有使用到成员变量,所以可以不要)
public class ClassObsMapping {//类名映射private MappingItem classMapping;//方法映射private LinkedList<MappingItem> methodsMapping;//略
}//映射的基本元素是混淆前后的名称
public class MappingItem {//混淆前名称private String originalName;//混淆后名称private String mappingName;//略
}
4、确认提取目标
- collectionSpecifyPkgMapping:根据 -pkg 参数列表指定的包名,从映射文件中收集混淆后的包名。
如 com.primer.manager.A -> com.android.manager.AA,那么收集的是 com.android.manager,最终返回的是混淆后的类的包名。
5、apk 反编译
- decodeApk:利用 apktool 工具反编译
简单地封装 Runtime.getRuntime(),通过调用 runtime.exec(cmdline)
执行控制台命令,这里是通过控制台执行 bat 脚本,再由脚本执行 java 命令执行 apktool。
APKTOOL_BAT_PATH 指向的一个 bat 脚本路径:
:: 参数1-apktool 参数2-apk路径 参数3-输出路径 -f:强制覆盖
java -jar %1 d %2 -o %3 -f
/*** java -jar apktool_2.7.0.jar d [apk file] -o [out name]** @param inputApkPath* @param outDirName* @return*/private String decodeApk(String inputApkPath, String outDirName) {if (inputApkPath == null || inputApkPath.isEmpty()) {throw new IllegalArgumentException("inputApkPath is null");}String cmdline;String outApkPath = OUTPUT_PATH + File.separator + outDirName;StringBuilder sb = new StringBuilder();sb.append(APKTOOL_BAT_PATH).append(" ").append(APKTOOL_JAR_PATH).append(" ").append(inputApkPath).append(" ").append(outApkPath);if (sb.toString().contains("\\") && !sb.toString().contains("\\\\")) {cmdline = sb.toString().replace("\\", "\\\\");} else {cmdline = sb.toString();}System.out.println("decodeApk cmd: " + cmdline);CommUtil.executeCmdline(cmdline, false);return outApkPath;}
6、smali 收集
- collectionAndMoveSpecifyPkgSmail:根据上述收集到的映射,在反编译目录下查找文件并存放到额外目录下。
通过递归遍历过了 smali 找到映射文件,再把目标文件已到指定目录待下一步处理。
private void collectionAndMoveSpecifyPkgSmail(String path, List<String> specifyPkgList) {if (CommUtil.isEmptyOrNoExists(specifyPkgList)) {return;}traversalFile(new File(path), new FileTraversal() {@Overridepublic void processFile(File file) {if (file.getName().endsWith(".smali")) {SmailFile smailFile = splitClassPkgname(file.getAbsolutePath());if (!CommUtil.isEmptyOrNoExists(smailFile.pakcgeName)) {for (String pkg : specifyPkgList) {if (pkg.equals(smailFile.pakcgeName)) {moveAndDeleteTargetFile(file, smailFile);break;}}}}}@Overridepublic void processDir(File file) {}});}
public class SmailFile {//映射后的文件名(类名)public String filename;//映射后的包名,如 com.primer.managerpublic String pakcgeName;//映射后的包路径,如 com/primer/manager//因为提取到外部目录下也应该创建相同包名的目录,再不 smali 文件存放到该目录下,确保前后一致public String pakagePath;@Overridepublic String toString() {return "SmailFile: " + filename + ", " + pakcgeName + ", " + pakagePath;}
}
7、smali 打包
- encodeSmailToDex:使用 smali.jar 把一组 smali 文件打包成 dex
这里有一点需要注意的是,最新版 smali.jar 打包参数是 assemble
,好像以前的包版本打包参数是 b。
:: java -jar smali.jar b out_directory -o output.dex
:: 参数1:smali.jar
:: 参数2:out_directory smali 文件目录
java -jar %1 assemble %2
8、dex 加密
- encodeDex:对 dex 文件应用自己的加密算法
dex 文件本质上是一个二进制文件,二进制文件读取出来就是一组字节数组 byte[]
,简单的对字节数组进行特殊操作(插入偏移量等)就是对文件的加密。
9、dex 加密文件放入包体
- copyDexToAssets:如果你想把提取部分的代码 dex 后续通过动态加载方式执行,你可以重新把它打入包体的其他地方存储备用,也可以后续通过远程请求获取再加载等。
10、apk 打包
- encodeApk:同样的,也是使用 apktool 工具
这里还是先调用 bat 脚本,再由 bat 执行 java 命令执行 apktool,你也可以以自己的方式处理。
java -jar apktool_2.7.0.jar b [apk decode file path]
:: 参数1-apktool 参数2-apk路径
java -jar %1 b %2
TODO:
当然,有了想法你可以做很多诸如此类的事情!
- 你可以在 apk 打包完成功之后,完成重新签名
- 等等等