Android加固方案 之 类方法抽取指令
前言
以前我们介绍了加密dex文件的加固方案Android最初的加固,其实现在市场上用的比较多的是类方法抽取指令的加固方案,或者说是综合应用。由于商业问题此类的资料还是比较少的。所幸姜维写了几遍关于类方法抽取指令的文章,下方有他的链接。本文就是参照他的资料去实现的。
请读者务必阅读上一章Android免Root 修改程序运行时内存指令逻辑(Hook系统函数)
这是指令还原的前提
开发环境
Android4.4.4
Nexus5手机(ARM)
Android Studio3.5.1
eclipse
思路
主要分两步一是指令抽取,二是指令还原。
我们先开发一个dex文件使用ClassLoader去加载并执行其中的方法。
这个dex很可能被他人进行逆向,分析所以我们对其关键的方法进行抽取置空。
这样这个方法就是空的。那么什么时候还原呢?
就是上一章在dex文件加载进内存的时候,这样就要去hook dexFindClass函数。上一章我们是改变代码逻辑,这里我为了方便进行硬编码还原代码。
加载Dex项目开发
先开发那个需要加载的dex,这里我使用Eclipse开发。这样开发出来的dex文件不会有太多无关的东西,有利于我们分析。
image.png
这里非常简单就是返回一个字符串密码回去。我们就要在真正的项目中调用这个方法。将其编译后取出器dex文件改名为CoreDex.dex。
使用010 Editor看它的指令如下图
修改前.png
可以看到此方法指令为{26, 33, 17}
指令抽取
这里我们要将指令置为0,就要去解析dex文件。我用c写过一个解析工具https://github.com/bigGreenPeople/DexAnalysis
姜维也有一个用java写的解析器https://github.com/fourbrother/parse_androiddex
由于他的项目功能更全,我这里就使用了他的项目
他的是一个Eclipse项目导入后直接使用
我们需要修改ParseDexUtils.java,在解析的过程中将CodeItem结构体到Map中,方便我们后面去取得我们需要的方法指令
public class ParseDexUtils {
...
//类方法抽取Map
public static Map<String,CodeItem> directMethodCodeItemMap = new HashMap<String,CodeItem>();
public static Map<String,CodeItem> virtualMethodCodeItemMap = new HashMap<String,CodeItem>();
...
/*************************** 解析代码内容 ***************************/
public static void parseCode(byte[] srcByte) {
for (ClassDataItem item : dataItemList) {
int premid = 0;
//解析静态方法
for (EncodedMethod item1 : item.direct_methods) {
int offset = Utils.decodeUleb128(item1.code_off);
CodeItem items = parseCodeItem(srcByte, offset);
int index = Integer.valueOf(
Utils.bytesToHexString(item1.method_idx_diff).trim(),
16) + premid;
premid = index;
MethodIdsItem methodItem = methodIdsList.get(index);
//获得方法名称
String methodName = stringList.get(methodItem.name_idx);
int classIndex = typeIdsList.get(methodItem.class_idx).descriptor_idx;
//获得类名
String className = stringList.get(classIndex);
//使用方法的签名作为key
directMethodCodeItemMap.put(getMethodSignStr(methodItem), items);
//directMethodCodeItemList.add(items);
System.out.println("class name:"+className+":"+methodName+"-----direct method item:" + items);
}
premid = 0;
//解析对象方法
for (EncodedMethod item1 : item.virtual_methods) {
int offset = Utils.decodeUleb128(item1.code_off);
CodeItem items = parseCodeItem(srcByte, offset);
int index = Integer.valueOf(
Utils.bytesToHexString(item1.method_idx_diff).trim(),
16) + premid;
premid = index;
MethodIdsItem methodItem = methodIdsList.get(index);
//获得方法名称
String methodName = stringList.get(methodItem.name_idx);
int classIndex = typeIdsList.get(methodItem.class_idx).descriptor_idx;
//获得类名
String className = stringList.get(classIndex);
virtualMethodCodeItemMap.put(getMethodSignStr(methodItem), items);
//virtualMethodCodeItemList.add(items);
System.out.println("class name:"+className+":"+methodName+"-----virtual method item:" + items);
}
}
}
...
}
在解析方法得到代码结构CodeItem 的时候,将每个方法保存到上面定义的静态Map中。
这里定义的两个Map,一个保存所有的静态方法,一个保存所有的对象方法
那么用什么作为这个方法的key呢?当然是这个方法的签名了。getMethodSignStr就是用来获得方法的签名。其代码如下
//得到方法的唯一签名
public static String getMethodSignStr(MethodIdsItem methodItem){
int classIndex = typeIdsList.get(methodItem.class_idx).descriptor_idx;
//获得类名
String className = stringList.get(classIndex);
//获得方法名称
String methodName = stringList.get(methodItem.name_idx);
//获得方法签名
ProtoIdsItem protoIdsItem = protoIdsList.get(methodItem.proto_idx);
String protoName = stringList.get(protoIdsItem.shorty_idx);
//返回值
int returnIndex = typeIdsList.get(protoIdsItem.return_type_idx).descriptor_idx;
String returnName = stringList.get(returnIndex);
String sinName = className+methodName+"#"+returnName+"()"+protoName;
System.out.println("Shark:"+sinName);
return sinName;
}
这里还要注意一点,在修改指令的时候我们需要,指令的Offset(偏移),而CodeItem结构体中没有这个成员,我们需要添加上去。
CodeItem.java
package com.wjdiankong.parsedex.struct;
import com.wjdiankong.parsedex.Utils;
public class CodeItem {
public short registers_size;
public short ins_size;
public short outs_size;
public short tries_size;
public int debug_info_off;
public int insns_size;
public short[] insns;
//指令偏移
public int insnsOffset;
}
这个再什么时候赋值呢?
image.png
这里的offset就是这个CodeItem的偏移
修改parseCodeItem方法
private static CodeItem parseCodeItem(byte[] srcByte, int offset) {
CodeItem item = new CodeItem();
/**
* public short registers_size; public short ins_size; public short
* outs_size; public short tries_size; public int debug_info_off; public
* int insns_size; public short[] insns;
*/
byte[] regSizeByte = Utils.copyByte(srcByte, offset, 2);
item.registers_size = Utils.byte2Short(regSizeByte);
byte[] insSizeByte = Utils.copyByte(srcByte, offset + 2, 2);
item.ins_size = Utils.byte2Short(insSizeByte);
byte[] outsSizeByte = Utils.copyByte(srcByte, offset + 4, 2);
item.outs_size = Utils.byte2Short(outsSizeByte);
byte[] triesSizeByte = Utils.copyByte(srcByte, offset + 6, 2);
item.tries_size = Utils.byte2Short(triesSizeByte);
byte[] debugInfoByte = Utils.copyByte(srcByte, offset + 8, 4);
item.debug_info_off = Utils.byte2int(debugInfoByte);
byte[] insnsSizeByte = Utils.copyByte(srcByte, offset + 12, 4);
item.insns_size = Utils.byte2int(insnsSizeByte);
//赋值指令的偏移
item.insnsOffset = offset + 16;
short[] insnsAry = new short[item.insns_size];
int aryOffset = offset + 16;
for (int i = 0; i < item.insns_size; i++) {
byte[] insnsByte = Utils.copyByte(srcByte, aryOffset + i * 2, 2);
insnsAry[i] = Utils.byte2Short(insnsByte);
}
item.insns = insnsAry;
return item;
}
这里的insnsOffset就是offset + 16;为什么是加16呢?
image.png
高亮的部分刚好就是16个字节,所以+16就指向了指令部分了。
这样Map中的CodeItem就有指令的偏移了。
现在回到main方法中
因为上面保存工作是在解析的时候做的,我们不要修改原来的代码逻辑,直接在后面加我们的代码就行了
ParseDexMain.java
package com.wjdiankong.parsedex;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import com.wjdiankong.parsedex.struct.CodeItem;
public class ParseDexMain {
private static Map<String, CodeItem> codeItemMap = new HashMap<String, CodeItem>();
public static void main(String[] args) {
//---------------------------原先的解析逻辑 ----------------------------------
...
// ----------------------------方法抽取逻辑----------------------------------------
String className = "Lcom/shark/calculate/CoreUtils;";
String methodName = "getPwd#Ljava/lang/String;()L";
//构造出要抽取方法的方法签名
String signName = className + methodName;
//将两个map合并到codeItemMap
codeItemMap.putAll(ParseDexUtils.directMethodCodeItemMap);
// 遍历所有方法的信息
for (String key : codeItemMap.keySet()) {
System.out.println("key:" + key);
// 找到想抽取的方法
if (key.equals(signName)) {
CodeItem codeItem = codeItemMap.get(key);
// 获取方法对应的指令个数和偏移
int insns_size = codeItem.insns_size;
int insns_Offset = codeItem.insnsOffset;
// 构造空指令 每条指令占两个字节
byte[] nopBytes = new byte[insns_size * 2];
for (int i = 0; i < nopBytes.length; i++) {
nopBytes[i] = 0;
}
try {
// 替换原有指令
srcByte = Utils.replaceBytes(srcByte, nopBytes,
insns_Offset);
// 修改DEX file size文件头
Utils.updateFileSizeHeader(srcByte);// dex中32到35的位置为文件长度
// 修改DEX SHA1 文件头
Utils.updateSHA1Header(srcByte);
// dex中12到31位置,32到结束参与SHA1计算
// 修改DEX CheckSum文件头
Utils.updateCheckSumHeader(srcByte);// dex中8到11位置,12到文件结束计算checksum
String str = "dex/new_CoreDex.dex";
File file = new File(str);
if (!file.exists()) {
file.createNewFile();
}
FileOutputStream fileOutputStream = new FileOutputStream(
file);
fileOutputStream.write(srcByte);
fileOutputStream.flush();
fileOutputStream.close();
System.out.println("done!");
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
这里的逻辑很简单就是从保存的Map中找到我们要修改的方法,然后构造了一个空指令byte[]调用replaceBytes覆盖它。
因为修改了它的指令所以SHA1 和 CheckSum都有变化需要重新计算
最后调用updateFileSizeHeader、updateSHA1Header、updateCheckSumHeader修改DEX file size 、SHA1 、CheckSum
最后保存到dex/CoreDex.dex
先看下replaceBytes的实现
//用来覆盖字节数组
public static byte[] replaceBytes(byte[] source_byte, byte[] replace_byte,
int offset) {
for (int i = 0; i < replace_byte.length; i++) {
source_byte[offset++] = replace_byte[i];
}
return source_byte;
}
updateFileSizeHeader、updateSHA1Header、updateCheckSumHeader
/**
* 修改dex头 sha1值
*
* @param dexBytes
* @throws NoSuchAlgorithmException
*/
public static void updateSHA1Header(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)
}
/**
* 修改dex头 file_size值
*
* @param dexBytes
*/
public static void updateFileSizeHeader(byte[] dexBytes) {
// 新文件长度
byte[] newfs = intToByte(dexBytes.length);
// 高位低位交换
for (int i = 0; i < 2; i++) {
byte tmp = newfs[i];
newfs[i] = newfs[newfs.length - 1 - i];
newfs[newfs.length - 1 - i] = tmp;
}
System.arraycopy(newfs, 0, dexBytes, 32, 4);// 修改(32-35)
}
/**
* 修改dex头,CheckSum 校验码
*
* @param dexBytes
*/
public static void updateCheckSumHeader(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);
for (int i = 0; i < 2; i++) {
byte tmp = newcs[i];
newcs[i] = newcs[newcs.length - 1 - i];
newcs[newcs.length - 1 - i] = tmp;
}
System.arraycopy(newcs, 0, dexBytes, 8, 4);// 效验码赋值(8-11)
}
这些其实在以前的文章中都使用过了,只不过上面的源码中没有这种方法,所以直接拿过来再次使用了。
到这里抽取指令项目就完成了,完整代码我放到了github上:
https://github.com/bigGreenPeople/parse_androiddex-master
运行
将前面开发的CoreDex.dex放入到项目的dex文件夹下
image.png
运行项目后得到new_CoreDex.dex
image.png
再次使用010 Editor查看new_CoreDex.dex
修改后.png
可以看到指令为0了!
使用JEB打开dex
image.png
显示的也为nop
指令还原
指令还原就很简单了,就是上一章的东西改几个地方就行
首先是DexUtils.java,改为调用getPwd
package com.shark.androidinlinehook;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.lang.reflect.Method;
import dalvik.system.DexClassLoader;
public class DexUtils {
public static final String SHARK = "shark";
public static void exeCoreMethod(Context context) {
try {
//创建文件夹
File optfile = context.getDir("opt_dex", 0);
File libfile = context.getDir("lib_path", 0);
//得到当前Activity 的ClassLoader 以下的方法得到的都是同一个ClassLoader
ClassLoader parentClassloader = MainActivity.class.getClassLoader();
ClassLoader tmpClassLoader = context.getClassLoader();
//创建我们自己的DexClassLoader 指定其父节点为当前Activity 的ClassLoader
/*dexPath:目标所在的apk或者jar文件的路径,装载器将从路径中寻找指定的目标类。
dexOutputDir:由于dex 文件在APK或者 jar文件中,所以在装载前面前先要从里面解压出dex文件,这个路径就是dex文件存放的路径,
在 android系统中,一个应用程序对应一个linux用户id ,应用程序只对自己的数据目录有写的权限,所以我们存放在这个路径中。
libPath :目标类中使用的C/C++库。
最后一个参数是该装载器的父装载器,一般为当前执行类的装载器。*/
DexClassLoader dexClassLoader = new DexClassLoader("/sdcard/CoreDex.dex",
optfile.getAbsolutePath(), libfile.getAbsolutePath(), MainActivity.class.getClassLoader());
Class<?> clazz=dexClassLoader.loadClass("com.shark.calculate.CoreUtils");
// Method calculateMoney=clazz.getDeclaredMethod("calculateMoney",int.class,int.class);
// Object obj=clazz.newInstance();
// int result = (int)calculateMoney.invoke(obj,2,3);
// Log.i(SHARK, "calculateMoney result:" + result);
//------------------------------------------------------------------------
//getPwd方法执行
Method getPwd=clazz.getDeclaredMethod("getPwd");
Log.i(SHARK, "getPwd result:" + getPwd.invoke(null));
} catch (Exception e) {
Log.i(SHARK, "exec exeCoreMethod err:" + Log.getStackTraceString(e));
}
}
}
hooktest.cpp修改被Hook的函数逻辑。将正确的代码写回到dex中
const DexClassDef *newDexFindClass(const DexFile *pFile, const char *descriptor) {
//只关注需要修改的类Lcom/shark/calculate/CoreUtils;
int cmp = strcmp("Lcom/shark/calculate/CoreUtils;", descriptor);
if (cmp == 0) {
//执行原来的逻辑得到类结构信息
const DexClassDef *pClassDef = oldDexFindClass(pFile, descriptor);
if (pClassDef == NULL) {
return pClassDef;
}
//打印信息
LOGI("class def:%d", (int)pClassDef);
LOGI("class dex find class name:%s", descriptor);
//我们需要调用DexReadAndVerifyClassData得到DexClassData代码结构,所以需要得到其地址
//依然需要用IDA打开libdvm.so文件查看DexReadAndVerifyClassData函数的导出名称:
DexReadAndVerifyClassData getClassData = (DexReadAndVerifyClassData) dlsym(
dvmLib, "_Z25dexReadAndVerifyClassDataPPKhS0_");
const u1 *pEncodedData = dexGetClassData(pFile, pClassDef);
DexClassData *pClassData = getClassData(&pEncodedData, NULL);
DexClassDataHeader header = pClassData->header;
//打印对象方法数量
LOGI("method size:%d", header.directMethodsSize);
//得到首个对象方法的指针
DexMethod *pDexDirectMethod = pClassData->directMethods;
u1 *ptr = (u1 *) pDexDirectMethod;
//循环遍历每个方法
for (int i = 0; i < header.directMethodsSize; i++) {
//这里每个方法都是相邻的,每个大小都是DexMethod结构体的大小
pDexDirectMethod = (DexMethod *) (ptr + sizeof(DexMethod) * i);
//得到方法名称
const DexMethodId *methodId = dexGetMethodId(pFile, pDexDirectMethod->methodIdx);
const char *methodName = dexStringById(pFile, methodId->nameIdx);
//如果是getPwd方法就进行替换逻辑
if (strcmp("getPwd", methodName) == 0) {
LOGI("pDexDirectMethod methodName:%s", methodName);
//打印指令
printMethodInsns(pFile, pDexDirectMethod);
//修改内存页属性
int start_add = (int) (pFile->baseAddr + pDexDirectMethod->codeOff);
int result = changeMemWrite(start_add);
LOGI("mp result:%d", result);
//获取方法对应DexCode结构
DexCode *dexCode = (DexCode *) dexGetCode(pFile, pDexDirectMethod);
//下面就是覆盖指令了
u2 new_ins[3] = {26, 33, 17};
memcpy(dexCode->insns, &new_ins, 3 * sizeof(u2));
printMethodInsns(pFile,pDexDirectMethod);
}
}
return pClassDef;
} else{
//执行原来的逻辑
return oldDexFindClass(pFile,descriptor);
}
}
因为getPwd是静态方法,所以这里要去静态方法中找。方法名也要改为getPwd。最后指令这里我们硬编码了{26, 33, 17},就是原来的指令
项目完整代码:https://github.com/bigGreenPeople/AndroidInlineHook
运行
测试效果.png可以看到我们得到了正确的结果~