APK加固原理详解
一、前言
之前使用的360加固,挺好用的,从2021年底的时候限制每天每个账号仅上传2次apk(免费的,不知道VIP的是不是这样)。通过这个事情,感觉技术还是掌握在自己手里稳妥点,不用受制于人,想怎么玩就怎么玩。
通过技术调研有两条路子可以走:
-
方式一:直接对apk进行加密,启动应用时通过壳程序去加载apk运行;
-
方式二:仅对原apk的dex文件进行加密,启动应用时对dex解密,通过DexClassLoader进行加载;
本文主要是参考了360免费加固的思路,所以主要研究的方式二。
二、原理
先看下流程,然后再来详细讲下具体的步骤
加固流程根据上流程图可以总结如下七个步骤:
步骤一:将加固壳中的aar中的jar利用dx工具转成dex文件
步骤二:将需要加固的APK解压,并将所有dex文件打包成一个zip包,方便后续进行加密处理
步骤三:对步骤二的zip包进行加密,并与壳dex合成新dex文件
步骤四:修改AndroidManifest(替换Application的android:name属性和新增<meta-data>)
步骤五:将步骤三生成的新dex文件替换apk中的所有dex文件
步骤六:APK对齐处理
步骤七:对生成的APK进行签名
到这其实就把APK加固流程讲完了,下面就来结合项目对各步骤进行详解。
三、项目案例
从上步骤中可以看到,加固会涉及到三个工程为:demo
、jiagu_shell
、jiaguLib
,如下图:
demo工程
普通的app工程,即我们平时开发的工程。主要用于生成待加固的apk。
jiaguLib工程
apk加固主工程,完成apk的加固工作。
1.将加固壳中的aar中的jar利用dx工具转成dex文件
生成Aar包:选中jiagu_shell工程,Build - Make Module 'apkjiagu.jiagu_shell',会在jiagu_shell - build - outputs - aar
目录中生成jiagu_shell-debug.aar
。
aar包生成后,就可以利用dx
工具生成dex了,如下核心代码:
/**
* 步骤一:将加固壳中的aar中的jar转成dex文件
* @throws Exception 异常
*/
private File shellAar2Dex() throws Exception{
logTitle("步骤一:将加固壳中的aar中的jar转成dex文件");
//步骤一:将加固壳中的aar中的jar转成dex文件
File aarFile = new File(ROOT+"aar/jiagu_shell-release.aar");
File aarTemp = new File(OUT_TMP+"shell");
ZipUtil.unZip(aarFile, aarTemp);
File classesJar = new File(aarTemp, "classes.jar");
File classesDex = new File(aarTemp, "classes.dex");
boolean ret = ProcessUtil.executeCommand(String.format(Locale.CHINESE,"dx --dex --output %s %s",classesDex.getAbsolutePath(),classesJar.getAbsolutePath()));
if (ret){
System.out.println("已生成======"+classesDex.getPath());
}
return classesDex;
}
ZipUtil中unZip方法:
public static void unZip(File apkFile,File destDir) throws Exception{
// 判断源文件是否存在
if (!apkFile.exists()) {
throw new Exception(apkFile.getPath() + "所指文件不存在");
}
//开始解压
//构建解压输入流
ZipInputStream zIn = new ZipInputStream(new FileInputStream(apkFile));
ZipEntry entry = null;
File file = null;
while ((entry = zIn.getNextEntry()) != null) {
if (!entry.isDirectory() && !entry.getName().equals("")) {
file = new File(destDir, entry.getName());
if (!file.exists()) {
file.getParentFile().mkdirs();//创建此文件的上级目录
}
FileOutputStream fos = new FileOutputStream(file);
int len = -1;
byte[] buf = new byte[1024];
while ((len = zIn.read(buf)) != -1) {
fos.write(buf, 0, len);
}
// 关流顺序,先打开的后关闭
fos.flush();
fos.close();
}else {
file = new File(destDir, entry.getName());
//是文件夹的时候创建目录
if (!file.exists()){
file.mkdirs();
}
}
zIn.closeEntry();
}
zIn.close();
}
ProcessUtil中executeCommand为执行命令方法,如下:
public static boolean executeCommand(String cmd) throws Exception{
System.out.println("开始执行命令===>"+cmd);
Process process = Runtime.getRuntime().exec("cmd /c "+cmd);
ProcessUtil.consumeInputStream(process.getInputStream());
ProcessUtil.consumeInputStream(process.getErrorStream());
process.waitFor();
if (process.exitValue() != 0) {
throw new RuntimeException("执行命令错误===>"+cmd);
}
return true;
}
jar转dex的命令
命令:dx --dex --output [输出dex] [输入的jar]
2.对待加固的APK解压,并将所有dex文件打包成一个zip包
直接对待加固的apk进行unzip,然后拿到解压目录中的所有dex文件,并打包成一个新的zip。代码如:
private File apkUnzipAndZipDexFiles(){
logTitle("步骤二:将需要加固的APK解压,并将所有dex文件打包成一个zip包,方便后续进行加密处理");
//下面加密码APK中所有的dex文件
File apkFile = new File(ORIGIN_APK);
File apkTemp = new File(OUT_TMP+"unzip/");
try {
//首先把apk解压出来
ZipUtil.unZip(apkFile, apkTemp);
//其次获取解压目录中的dex文件
File dexFiles[] = apkTemp.listFiles(new FilenameFilter() {
@Override
public boolean accept(File file, String s) {
return s.endsWith(".dex");
}
});
if (dexFiles == null) return null;
//三:将所有的dex文件压缩为AppDex.zip文件
File outTmpFile = new File(OUT_TMP);
File outputFile = new File(outTmpFile,"AppDex.zip");
//创建目录
if (!outTmpFile.exists()){
outTmpFile.mkdirs();
}
if (outputFile.exists()){
outputFile.delete();
}
Zip4jUtil.zipFiles(dexFiles,outputFile);
System.out.println("已生成======"+outputFile.getAbsolutePath());
FileUtils.deleteFile(apkTemp.getAbsolutePath());
return outputFile;
}catch (Exception e){
e.printStackTrace();
}
return null;
}
这一步比较简单,仅涉及文件的解压和压缩操作。
值得注意:采用系统自带的ZipOutputSteam对dex压缩会存在Bad size问题,故这里采用zip4j包进行压缩。
3.对上述生成的zip进行加密,然后合并到壳dex中
这一步比较关键,涉及到dex文件格式,需要对dex格式进行一定了解。
可以参考Dex文件结构
我们只需要关注以下三个部分:
-
checksum
,文件校验码 ,使用alder32 算法校验文件除去 maigc ,checksum 外余下的所有文件区域 ,用于检查文件错误 。 -
signature
,使用 SHA-1 算法 hash 除去 magic ,checksum 和 signature 外余下的所有文件区域 ,用于唯一识别本文件 。 -
file_size
,Dex文件的总长度。
为什么说我们只需要关注这三个字段呢?
因为我们需要将一个文件(加密之后的源dex包)写入到Dex中,那么我们肯定需要修改文件校验码(checksum).因为他是检查文件是否有错误。那么signature也是一样,也是唯一识别文件的算法。还有就是需要修改dex文件的大小。
不过这里还需要一个操作,就是标注一下我们加密的Zip的大小,当我们脱壳的时候,需要知道Zip的大小,才能正确的得到Zip。这个值直接放到文件的末尾就可以了。
所以总结一下我们需要做:修改Dex的三个文件头,将源Apk的dex包大小追加到壳dex的末尾就可以了。
我们修改之后得到新的Dex文件样式如下:
image.png
具体实现代码如下:
private File combine2NewDexFile(File shellDexFile,File originalDexZipFile){
logTitle("步骤三:对步骤二的zip包进行加密,并与壳dex合成新dex文件");
try {
AESUtil aesUtil = new AESUtil();
byte[] data = readFileBytes(originalDexZipFile);
System.out.println("加密前数据大小为:"+data.length);
byte[] payloadArray = aesUtil.encrypt(data);//以二进制形式读出zip,并进行加密处理//对源Apk进行加密操作
byte[] unShellDexArray = readFileBytes(shellDexFile);//以二进制形式读出dex
int payloadLen = payloadArray.length;
int unShellDexLen = unShellDexArray.length;
int totalLen = payloadLen + unShellDexLen +4;//多出4字节是存放长度的。
byte[] newdex = new byte[totalLen]; // 申请了新的长度
//添加解壳代码
System.arraycopy(unShellDexArray, 0, newdex, 0, unShellDexLen);//先拷贝dex内容
//添加加密后的解壳数据
System.arraycopy(payloadArray, 0, newdex, unShellDexLen, payloadLen);//再在dex内容后面拷贝apk的内容
//添加解壳数据长度
System.arraycopy(intToByte(payloadLen), 0, newdex, totalLen-4, 4);//最后4为长度
//修改DEX file size文件头
fixFileSizeHeader(newdex);
//修改DEX SHA1 文件头
fixSHA1Header(newdex);
//修改DEX CheckSum文件头
fixCheckSumHeader(newdex);
String str = OUT_TMP + "classes.dex";
File file = new File(str);
if (!file.exists()) {
file.createNewFile();
}
//输出成新的dex文件
FileOutputStream localFileOutputStream = new FileOutputStream(str);
localFileOutputStream.write(newdex);
localFileOutputStream.flush();
localFileOutputStream.close();
System.out.println("已生成新的Dex文件======"+str);
//删除dex的zip包
FileUtils.deleteFile(originalDexZipFile.getAbsolutePath());
return file;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
注意:为了提高破解难度,本文加解密代码采用C写的,并编译成dll文件被java工程引用。若不想那么麻烦可自行修改加密方式。
将C/C++编译成dll供Java工程使用
readFileBytes方法:
private byte[] readFileBytes(File file) throws IOException {
byte[] arrayOfByte = new byte[1024];
ByteArrayOutputStream localByteArrayOutputStream = new ByteArrayOutputStream();
FileInputStream fis = new FileInputStream(file);
while (true) {
int i = fis.read(arrayOfByte);
if (i != -1) {
localByteArrayOutputStream.write(arrayOfByte, 0, i);
} else {
return localByteArrayOutputStream.toByteArray();
}
}
}
修改文件大小方法,fixFileSizeHeader方法:
private void fixFileSizeHeader(byte[] dexBytes) {
//新文件长度
byte[] newfs = intToByte(dexBytes.length);
System.out.println("fixFileSizeHeader ===== size : " + dexBytes.length);
byte[] refs = new byte[4];
//高位在前,低位在前掉个个
for (int i = 0; i < 4; i++) {
refs[i] = newfs[newfs.length - 1 - i];
}
System.arraycopy(refs, 0, dexBytes, 32, 4);//修改(32-35)
}
修改dex头中的sinature方法,fixSHA1Header:
/**
* 修改dex头 sha1值
* @param dexBytes
* @throws NoSuchAlgorithmException
*/
private void fixSHA1Header(byte[] dexBytes)
throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(dexBytes, 32, dexBytes.length - 32);//从32为到结束计算sha--1
byte[] newdt = md.digest();
System.arraycopy(newdt, 0, dexBytes, 12, 20);//修改sha-1值(12-31)
//输出sha-1值,可有可无
String hexstr = "";
for (int i = 0; i < newdt.length; i++) {
hexstr += Integer.toString((newdt[i] & 0xff) + 0x100, 16)
.substring(1);
}
}
修改CheckSum值
/**
* 修改dex头,CheckSum 校验码
* @param dexBytes
*/
private void fixCheckSumHeader(byte[] dexBytes) {
Adler32 adler = new Adler32();
adler.update(dexBytes, 12, dexBytes.length - 12);//从12到文件末尾计算校验码
long value = adler.getValue();
int va = (int) value;
byte[] newcs = intToByte(va);
//高位在前,低位在前掉个个
byte[] recs = new byte[4];
for (int i = 0; i < 4; i++) {
recs[i] = newcs[newcs.length - 1 - i];
}
System.arraycopy(recs, 0, dexBytes, 8, 4);//效验码赋值(8-11)
}
到这里,我们就生成了加密后的dex文件,这时在Android studio中查看,你会发现仅能看到脱壳的类信息。
4.修改原APK中的AndroidManifest.xml文件
为了保证能正常使用apktool
命令对apk正常反编译和回编译,我们要先修改AndroidManifest.xml,再对dex进行替换。若先替换dex,在对apk进行回编译时,加密的数据回丢失,导致包错误。
在这一步,主要采用apktool对apk进行反编译,通过代码修改AndroidManifest.xml,然后在进行回编译重新生成新的Apk。
具体实现代码如下:
private String modifyOriginApkManifest() throws Exception{
String apkPath = ORIGIN_APK;
String outputPath = OUT_TMP + "apk/";
logTitle("步骤四:修改AndroidManifest(Application的android:name属性和新增<meta-data>)");
String path = "";
long start = System.currentTimeMillis();
//1:执行命令进行反编译原apk
System.out.println("开始反编译原apk ......");
boolean ret = ProcessUtil.executeCommand("apktool d -o " + outputPath + " " + apkPath);
if (ret){
//2.修改AndroidManifest.xml,使用壳的Application替换原Application,并将原Application名称配置在meta-data中
modifyAndroidManifest(new File(outputPath,"AndroidManifest.xml"));
//3:重新编译成apk,仍以原来名称命名
System.out.println("开始回编译apk ......");
String apk = OUT_TMP + apkPath.substring(apkPath.lastIndexOf("/")+1);
ret = ProcessUtil.executeCommand(String.format(Locale.CHINESE,"apktool b -o %s %s",apk,outputPath));
if (ret){
path = apk;
}
System.out.println("=== modifyOriginApkManifest ==== "+(System.currentTimeMillis()-start)+"ms");
}
return path;
}
修改AndroidManifest.xml主要做的内容为:
1.替换<application>标签中android:name
值为com.zhh.jiagu.shell.StubApplication
;
2.添加<meta-data>记录原application配置的name值,
<meta-data android:name="APPLICATION_CLASS_NAME" android:value="原apk的Application name"/>
;
具体代码如下:
private void modifyAndroidManifest(File xmlFile){
if (xmlFile == null){
System.out.println("请设置AndroidManifest.xml文件");
return;
}
if (!xmlFile.exists()){
System.out.println("指定的AndroidManifest.xml文件不存在");
return;
}
System.out.println("开始修改AndroidManifest.xml......");
String shellApplicationName = "com.zhh.jiagu.shell.StubApplication";
String metaDataName = "APPLICATION_CLASS_NAME";
String attrName = "android:name";
//采用Dom读取AndroidManifest.xml文件
try {
//1.实例化Dom工厂
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
//2.构建一个builder
DocumentBuilder builder = factory.newDocumentBuilder();
//3.通过builder解析xml文件
Document document = builder.parse(xmlFile);
NodeList nl = document.getElementsByTagName("application");
if (nl != null){
Node app = nl.item(0);
//获取原APK中application
String applicationName = "android.app.Application";
NamedNodeMap attrMap = app.getAttributes();
//有属性时
Node node = app.getAttributes().getNamedItem(attrName);
//默认为系统的Application
if (node != null){
applicationName = node.getNodeValue();
node.setNodeValue(shellApplicationName);
}else {//不存在该属性时,则创建一个
Attr attr = document.createAttribute(attrName);
attr.setValue(shellApplicationName);
attrMap.setNamedItem(attr);
}
//添加<meta-data>数据,记录原APK的application
Element metaData = document.createElement("meta-data");
metaData.setAttribute("android:name",metaDataName);
metaData.setAttribute("android:value",applicationName);
app.appendChild(metaData);
//重新写入文件xml文件
TransformerFactory outFactory = TransformerFactory.newInstance();
Transformer transformer = outFactory.newTransformer();
Source xmlSource = new DOMSource(document);
Result outResult = new StreamResult(xmlFile);
transformer.transform(xmlSource,outResult);
System.out.println("已完成修改AndroidManifest文件======");
}
}catch (Exception e){
e.printStackTrace();
}
}
这一步使用的命令为:
apktool d -o [输出目录] [apk]
apktool b -o [输出apk] [回编译目录]
5.将新编译的apk中的所有dex删除,并将上述生成的新dex文件添加进apk中
删除dex文件方法:
public static void deleteDexFromZip(String zipFilePath) throws ZipException{
ZipFile zipFile = new ZipFile(zipFilePath);
List<FileHeader> files = zipFile.getFileHeaders();
List<String> dexFiles = new ArrayList<>();
for (FileHeader file : files) {
if (file.getFileName().endsWith(".dex")) {
dexFiles.add(file.getFileName());
}
}
zipFile.removeFiles(dexFiles);
}
添加dex到apk中的方法:
public static void addFile2Zip(String zip,String filepath,String rootFolder) throws ZipException{
ZipFile zipFile = new ZipFile(zip);
ZipParameters parameters = new ZipParameters();
/*
* 压缩方式
* COMP_STORE = 0;(仅打包,不压缩)
* COMP_DEFLATE = 8;(默认)
* COMP_AES_ENC = 99; 加密压缩
*/
parameters.setCompressionMethod(CompressionMethod.DEFLATE);
/*
* 压缩级别
* DEFLATE_LEVEL_FASTEST = 1; (速度最快,压缩比最小)
* DEFLATE_LEVEL_FAST = 3; (速度快,压缩比小)
* DEFLATE_LEVEL_NORMAL = 5; (一般)
* DEFLATE_LEVEL_MAXIMUM = 7;
* DEFLATE_LEVEL_ULTRA = 9;
*/
parameters.setCompressionLevel(CompressionLevel.NORMAL);
// 目标路径
if (rootFolder == null){
rootFolder = "";
}
parameters.setRootFolderNameInZip(rootFolder);
zipFile.addFile(filepath, parameters);
}
如果将加密和解密通过JNI调用的,则记得要把so文件复制仅apk中(示例中就采用这种方式,有些可能仅采用Java加密,故复制so代码部分就不贴出来了,若感兴趣可以查看文章末尾的源码)。
6.apk对齐处理
到了这一步APK加固的主要工作其实已经完成了,只剩下对APK进行对齐处理和签名工作了。
apk对齐命令:zipalign -v -p 4 [输入的apk] [对齐后的apk]
。
具体实现代码如下:
private File zipalignApk(File unAlignedApk) throws Exception{
logTitle("步骤六:重新对APK进行对齐处理.....");
//步骤四:重新对APK进行对齐处理
File alignedApk = new File(unAlignedApk.getParent(),unAlignedApk.getName().replace(".apk","_align.apk"));
boolean ret = ProcessUtil.executeCommand("zipalign -v -p 4 " + unAlignedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());
if (ret){
System.out.println("已完成APK进行对齐处理======");
}
//删除未对齐的包
FileUtils.deleteFile(unAlignedApk.getAbsolutePath());
return alignedApk;
}
7.签名
在Android系统中,未签名的Apk是无法正常安装运行的,因此我们要对上述对齐后的apk进行一次签名处理。
命令:apksigner sign --ks [签名文件] --ks-key-alias [alias名字] --min-sdk-version 21 --ks-pass pass:[keystore密码] --key-pass pass:[key密码] --out [输出apk] [输入apk]
具体实现代码为:
private File resignApk(File unSignedApk) throws Exception{
logTitle("步骤七:对生成的APK进行签名");
KeyStore store = KeyStoreUtil.readKeyStoreConfig((isRelease ? "":"jiaguLib/")+KEYSTORE_CFG);
//步骤五:对APK进行签名
File signedApk = new File(ROOT+"out",unSignedApk.getName().replace(".apk","_signed.apk"));
//创建保存加固后apk目录
if (!signedApk.getParentFile().exists()){
signedApk.getParentFile().mkdirs();
}
String signerCmd = String.format("apksigner sign --ks %s --ks-key-alias %s --min-sdk-version 21 --ks-pass pass:%s --key-pass pass:%s --out %s %s",
store.storeFile,store.alias,store.storePassword,store.keyPassword,signedApk.getAbsolutePath(),unSignedApk.getAbsolutePath());
boolean ret = ProcessUtil.executeCommand(signerCmd);
System.out.println("已完成签名======"+signedApk.getPath());
//删除未对齐的包
FileUtils.deleteFile(unSignedApk.getAbsolutePath());
return signedApk;
}
其实核心代码为:
String signerCmd = String.format("apksigner sign --ks %s --ks-key-alias %s --min-sdk-version 21 --ks-pass pass:%s --key-pass pass:%s --out %s %s", store.storeFile,store.alias,store.storePassword,store.keyPassword,signedApk.getAbsolutePath(),unSignedApk.getAbsolutePath());
boolean ret = ProcessUtil.executeCommand(signerCmd);
由于笔者为了方便其他apk加固,采用读取签名配置的方式获取签名文件相关数据信息。
public static KeyStore readKeyStoreConfig(String configPath){
File cf = new File(configPath);
if (!cf.exists()){
System.out.println("签名配置文件不存在");
return null;
}
try {
List<String> lines = Files.readAllLines(cf.toPath());
if (lines == null || lines.size() <= 0){
System.out.println("签名配置文件内容为空");
return null;
}
KeyStore store = new KeyStore();
for (String line : lines){
if (line.trim().startsWith("storeFile")){
store.storeFile = line.split("=")[1].trim();
}else if (line.trim().startsWith("storePassword")){
store.storePassword = line.split("=")[1].trim();
}else if (line.trim().startsWith("alias")){
store.alias = line.split("=")[1].trim();
}else if (line.trim().startsWith("keyPassword")){
store.keyPassword = line.split("=")[1].trim();
}
}
return store;
}catch (Exception e){
e.printStackTrace();
}
return null;
}
好了,到这里已经完成了APK的加固工作,可以正常安装apk了。
那么如何让我们加固后的APK进行脱壳呢?接下来就来看下jiagu_shell工程
jiagu_shell工程
该工程主要提供APK脱壳工作。
根据app启动流程不难发现脱壳工作必须要在壳Application中进行,先来看下脱壳的流程:
attachBaseContext中的主要工作为:
- 从apk中读取dex文件,获取加密的dex数据,并对其进行解密保存;
- 通过DexClassLoader动态加载AppDex.zip;
- 主动调用ActivityThread中的
installContentProviders
方法(后续问题中会提到这点);
onCreate主要工作:
- 替换Application对象,并运行新的Application的create方法;
解析apk,读取dex文件数据进行解密,然后采用DexClassLoader动态加载:
public static boolean decodeDexAndReplace(Application context, int appVersionCode){
try {
//创建两个文件夹payload_odex,payload_lib 私有的,可写的文件目录
File odex = context.getDir("payload_odex", Application.MODE_PRIVATE);
// File libs = context.getDir("payload_lib", Application.MODE_PRIVATE);
String odexPath = odex.getAbsolutePath();
//按版本号来标记zip
String dexFilePath = String.format(Locale.CHINESE,"%s/AppDex.zip",odexPath);
LogUtil.info("decodeDexAndReplace =============================开始");
File dexFile = new File(dexFilePath);
LogUtil.info("apk size ===== "+dexFile.length());
if (dexFile.exists()){
dexFile.delete();
}
//第一次加载APP
if (!dexFile.exists()) {
//先清空odexPath目录中文件,防止数据越来越多
File[] children = odex.listFiles();
if (children != null && children.length > 0){
for (File child : children){
child.delete();
}
}
LogUtil.info( " ===== App is first loading.");
long start = System.currentTimeMillis();
dexFile.createNewFile(); //在payload_odex文件夹内,创建payload.apk
String apkPath = context.getApplicationInfo().sourceDir;
// 读取程序classes.dex文件
byte[] dexdata = Utils.readDexFileFromApk(apkPath);
//从classes.dex中再取出AppDex.zip解密后存放到/AppDex.zip,及其so文件放到payload_lib下
Utils.releaseAppDexFile(dexdata,dexFilePath);
LogUtil.info("解压和解密耗时 ===== "+(System.currentTimeMillis() - start) + " === " + dexFile.exists());
}
// 配置动态加载环境
//获取主线程对象
Object currentActivityThread = getCurrentActivityThread();
String packageName = context.getPackageName();//当前apk的包名
LogUtil.info("packageName ===== "+packageName);
//下面两句不是太理解
ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread, "mPackages");
LogUtil.info("反射得到的mPackages ===== "+mPackages);
WeakReference wr = (WeakReference) mPackages.get(packageName);
ClassLoader mClassLoader = (ClassLoader) RefInvoke.getFieldOjbect("android.app.LoadedApk", wr.get(), "mClassLoader");
//创建被加壳apk的DexClassLoader对象 加载apk内的类和本地代码(c/c++代码)
DexClassLoader dLoader = new DexClassLoader(dexFilePath, odexPath, context.getApplicationInfo().nativeLibraryDir, mClassLoader);
LogUtil.info("反射得到的dLoader ===== "+dLoader);
//base.getClassLoader(); 是不是就等同于 (ClassLoader) RefInvoke.getFieldOjbect()? 有空验证下//?
//把当前进程的DexClassLoader 设置成了被加壳apk的DexClassLoader ----有点c++中进程环境的意思~~
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), dLoader);
LogUtil.info("decodeDexAndReplace ============================= 结束");
return true;
} catch (Exception e) {
LogUtil.error( "error ===== "+Log.getStackTraceString(e));
e.printStackTrace();
}
return false;
}
获取classes.dex数据的方法,其实就是解压的方式,代码如下:
public static byte[] readDexFileFromApk(String apkPath) throws IOException {
LogUtil.info("从classes.dex解析出加密的原包的dex数据");
ByteArrayOutputStream dexByteArrayOutputStream = new ByteArrayOutputStream();
//获取当前zip进行解压
ZipInputStream zipInputStream = new ZipInputStream(
new BufferedInputStream(new FileInputStream(apkPath)));
while (true) {
ZipEntry entry = zipInputStream.getNextEntry();
if (entry == null) {
zipInputStream.close();
break;
}
if (entry.getName().equals("classes.dex")) {
byte[] arrayOfByte = new byte[1024];
while (true) {
int i = zipInputStream.read(arrayOfByte);
if (i == -1)
break;
dexByteArrayOutputStream.write(arrayOfByte, 0, i);
}
}
zipInputStream.closeEntry();
}
zipInputStream.close();
return dexByteArrayOutputStream.toByteArray();
}
接着从classes.dex中获取加密的数据并解密和输出到AppDex.zip文件:
public static void releaseAppDexFile(byte[] apkdata,String apkFileName) throws Exception {
int length = apkdata.length;
//取被加壳apk的长度 这里的长度取值,对应加壳时长度的赋值都可以做些简化
byte[] dexlen = new byte[4];
System.arraycopy(apkdata, length - 4, dexlen, 0, 4);
ByteArrayInputStream bais = new ByteArrayInputStream(dexlen);
DataInputStream in = new DataInputStream(bais);
int readInt = in.readInt();
LogUtil.info("============ 读取原Dex压缩文件大小 ======"+readInt);
byte[] newdex = new byte[readInt];
//把被加壳apk内容拷贝到newdex中
System.arraycopy(apkdata, length - 4 - readInt, newdex, 0, readInt);
LogUtil.info("============ 开始对加密dex进行解密======" + newdex.length);
//对zip包进行解密
newdex = AESUtil.decrypt(newdex);
LogUtil.info("============ 解密后的大小为======" + newdex.length);
//写入AppDex.zip文件
File file = new File(apkFileName);
try {
FileOutputStream localFileOutputStream = new FileOutputStream(file);
localFileOutputStream.write(newdex);
localFileOutputStream.close();
} catch (IOException localIOException) {
throw new RuntimeException(localIOException);
}
}
最后通过反射获取原Application对象,在通过反射调用ActivityThread
中的installContentProviders
方法。
public static Application makeApplication(String srcApplicationClassName){
LogUtil.info( "makeApplication ============== " + srcApplicationClassName);
if (TextUtils.isEmpty(srcApplicationClassName)){
LogUtil.error("请配置原APK的Application ===== ");
return null;
}
//调用静态方法android.app.ActivityThread.currentActivityThread获取当前activity所在的线程对象
Object currentActivityThread = getCurrentActivityThread();
LogUtil.info("currentActivityThread ============ "+currentActivityThread);
//获取当前currentActivityThread的mBoundApplication属性对象,
//该对象是一个AppBindData类对象,该类是ActivityThread的一个内部类
Object mBoundApplication = getBoundApplication(currentActivityThread);
LogUtil.info("mBoundApplication ============ "+mBoundApplication);
//读取mBoundApplication中的info信息,info是LoadedApk对象
Object loadedApkInfo = getLoadApkInfoObj(mBoundApplication);
LogUtil.info("loadedApkInfo ============ "+loadedApkInfo);
//先从LoadedApk中反射出mApplicationInfo变量,并设置其className为原Application的className
//todo:注意:这里一定要设置,否则makeApplication还是壳Application对象,造成一直在attach中死循环
ApplicationInfo mApplicationInfo = (ApplicationInfo) RefInvoke.getFieldOjbect(
"android.app.LoadedApk", loadedApkInfo, "mApplicationInfo");
mApplicationInfo.className = srcApplicationClassName;
//执行 makeApplication(false,null)
Application app = (Application) RefInvoke.invokeMethod("android.app.LoadedApk", "makeApplication", loadedApkInfo, new Class[] { boolean.class, Instrumentation.class }, new Object[] { false, null });
LogUtil.info("makeApplication ============ app : "+app);
//由于源码ActivityThread中handleBindApplication方法绑定Application后会调用installContentProviders,
//此时传入的context仍为壳Application,故此处进手动安装ContentProviders,调用完成后,清空原providers
installContentProviders(app,currentActivityThread,mBoundApplication);
return app;
}
反射调用ActivityThread
中的installContentProviders
方法:
private static void installContentProviders(Application app,Object currentActivityThread,Object boundApplication){
if (app == null) return;
LogUtil.info("执行installContentProviders =================");
List providers = (List) RefInvoke.getFieldOjbect("android.app.ActivityThread$AppBindData",
boundApplication, "providers");
LogUtil.info( "反射拿到providers = " + providers);
if (providers != null) {
RefInvoke.invokeMethod("android.app.ActivityThread","installContentProviders",currentActivityThread,new Class[]{Context.class,List.class},new Object[]{app,providers});
providers.clear();
}
}
最后新旧Application对象的替换工作。如下代码:
public static void replaceAndRunMainApplication(Application app){
if (app == null){
return;
}
LogUtil.info( "onCreate ===== 开始替换=====");
// 如果源应用配置有Appliction对象,则替换为源应用Applicaiton,以便不影响源程序逻辑。
final String appClassName = app.getClass().getName();
//调用静态方法android.app.ActivityThread.currentActivityThread获取当前activity所在的线程对象
Object currentActivityThread = getCurrentActivityThread();
//获取当前currentActivityThread的mBoundApplication属性对象,
//该对象是一个AppBindData类对象,该类是ActivityThread的一个内部类
Object mBoundApplication = getBoundApplication(currentActivityThread);
//读取mBoundApplication中的info信息,info是LoadedApk对象
Object loadedApkInfo = getLoadApkInfoObj(mBoundApplication);
//检测loadApkInfo是否为空
if (loadedApkInfo == null){
LogUtil.error( "loadedApkInfo ===== is null !!!!");
}else {
LogUtil.info( "loadedApkInfo ===== "+loadedApkInfo);
}
//把当前进程的mApplication 设置成了原application,
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mApplication", loadedApkInfo, app);
Object oldApplication = RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mInitialApplication");
LogUtil.info( "oldApplication ===== "+oldApplication);
ArrayList<Application> mAllApplications = (ArrayList<Application>) RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread, "mAllApplications");
//将壳oldApplication从ActivityThread#mAllApplications列表中移除
mAllApplications.remove(oldApplication);
//将原Application赋值给mInitialApplication
RefInvoke.setFieldOjbect("android.app.ActivityThread", "mInitialApplication", currentActivityThread, app);
// ApplicationInfo appinfo_In_LoadedApk = (ApplicationInfo) RefInvoke.getFieldOjbect(
// "android.app.LoadedApk", loadedApkInfo, "mApplicationInfo");
ApplicationInfo appinfo_In_AppBindData = (ApplicationInfo) RefInvoke.getFieldOjbect(
"android.app.ActivityThread$AppBindData", mBoundApplication, "appInfo");
// appinfo_In_LoadedApk.className = appClassName;
appinfo_In_AppBindData.className = appClassName;
ArrayMap mProviderMap = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mProviderMap");
Iterator it = mProviderMap.values().iterator();
while (it.hasNext()) {
Object providerClientRecord = it.next();
Object localProvider = RefInvoke.getFieldOjbect("android.app.ActivityThread$ProviderClientRecord", providerClientRecord, "mLocalProvider");
RefInvoke.setFieldOjbect("android.content.ContentProvider", "mContext", localProvider, app);
}
LogUtil.info( "app ===== "+app + "=====开始执行原Application");
app.onCreate();
}
至此脱壳工作完成,运行APP了。
中间涉及JNI层代码这里就不多说明,可以看后续的源码。
四、加固工具及命令
1.jar转dex
命令:dx --dex --output [输出dex] [输入的jar]
2.apktool反编译与回编译
反编译:apktool d -o [输出目录] [apk]
回编译:apktool b -o [输出apk] [回编译目录]
3.apk对齐命令
命令:zipalign -v -p 4 [输入的apk] [对齐后的apk]
4.签名命令
命令:apksigner sign --ks [签名文件] --ks-key-alias [alias名字] --min-sdk-version 21 --ks-pass pass:[keystore密码] --key-pass pass:[key密码] --out [输出apk] [输入apk]
5.AndroidManifest二进制文件修改器(备用)
AXMLEditor强大的AndroidManifest.xml二进制修改器,无需对APK进行反编译和回编译,节约时间。
注:由于apktool反编译和回编译apk太过耗时,想采用该工具直接修改AndroidManifest.xml,提升打包效率,不知道为什么打包后,始终无法运行,不执行壳Application,最后不得不放弃,待后面有时间在好好研究吧。如果该方案可行,打包效率会提高几十倍。
五、遇到问题
问题1:解密后加载dex,提示文件大小问题(Bad size ...)?
解决:采用ZipOutputStream进行压缩,导致的问题。所以改用了Zip4j进行压缩,解压时仍可使用系统提供的解压方式。
问题2:提示找不到androidx.core.content.FileProviders类问题?
原因:通过查阅源码(ActivityThread - handleBindApplication())发现,makeApplication后会若providers不为空,则会执行初始化ContentProvider的操作(installContentProviders()),而在makeApplication中会执行Application的attachBaseContext方法,若在此将providers清空,后面就不会初始化ContentProvider的操作了,但是又不能不执行初始化。
解决:
获取原Application对象 -> 通过反射手动调用installContentProviders() -> 清空providers列表
问题3:通过反射调用LoadedApk
类中的makeApplication
方法后,运行出现一直重复执行Application的attachBaseContext
方法?
原因:执行
makeApplication
时反射的类是mApplicationInfo.className
,而该值仍为壳Application类,因此反射后获取的Application与壳Application类一样,导致重复执行。解决:在反射
makeApplication
前,先获取LoadedApk
中的mApplicationInfo
对象,并设置其className
属性的值为原Application的类名
,这样在调用makeApplication
实例化Application对象就可以了。
问题4:应用启动后,原Application也替换成功了,初始化操作的时候提示找不到so文件?
解决:在实例化DexClassLoader对象时,传入的librarySearchPath不正确,应当使用app的nativeLibraryDir目录,即:
context.getApplicationInfo().nativeLibraryDir
问题5:加固时如何采用JNI对数据进行加密?
采用vs studio将C/C++代码编译成dll文件,在java工程中引用。
System.load()可以加载绝对路径的dll库,
System.loadLibrary()加载jre/bin中的dll文件。
欢迎留言,一起学习,共同进步!