作者 | 百度APP技术平台
导读
在移动互联网快速发展的背景下,保护Android应用程序的安全性和知识产权变得尤为重要。为了防止恶意攻击和未授权访问,通常采用对dex文件进行代码加固来保护应用程序。随着Android加固技术经过动态加载、不落地加载、指令抽取、java2cpp、VMP等技术不断演进和改进,VMP加固技术成为一种高安全性解决方案。因此,本文将着重介绍一种实现和落地VMP技术的思路,以帮助大家了解其工作原理和应用场景。
全文8359字,预计阅读时间21分钟。
在移动互联网快速发展的背景下,Android 作为全球最受欢迎的移动操作系统,吸引了大量开发者和用户。随着应用市场的竞争加剧,保护应用程序的安全性和知识产权变得越来越重要。
同时,随着公司业务的发展,百度与外部友商深度合作,需要对外输出了百度业务能力SDK。在这种背景下,对Android代码进行加固成为了一种必要的安全措施。加固可以提高应用程序的安全性,保护知识产权,防止逆向工程和破解。
Android 应用程序是由 Java/Kotlin 语言编写而成,然后打包成 APK 文件。Java 代码被编译成 APK/AAR 中的 dex 文件,dalvik/art 虚拟机解释执行 dex 中的字节码。攻击者可以使用反编译工具很容易的逆向分析 dex 文件,理解代码关键逻辑,增加恶意代码,再打包回 APK 文件。
可以看到,dex 文件就是代码加固的保护核心!
为了解决对 dex文件的代码加固,我们进行了相关技术调研,其实在Android代码安全领域,相关技术一直属于不断攻防演进的过程。如下是业界常用的加固技术方案:比如最初的360加固给APK加壳,通过不落地动态加载实现加固;市场上常用的类方法抽取指令加固;以及将java方法转native方法jni调用等。
利用 Android 系统的 DexClassLoader 动态加载机制,通过将保护的 dex 文件解压解密后,动态加载到内存中执行。
这种方式有效地抵御了 APK 文件的静态分析,使得逆向分析者无法在 APK 文件中找到真实的 dex 文件。但是由于动态加载技术主要依赖于java的动态加载机制,所以要求关键逻辑部分必须进行解压,并且释放到文件系统。
这种动态加载技术不足之处在于:1.这一解压释放机制就给攻击者留下直接获取对应文件的机会; 2.可以通过hook虚拟机关键函数,进行dump出原始的dex文件数据。
针对 DexClassLoader 动态加载机制的保护缺陷,采用 Hook 技术来解决问题。
在动态加载过程中,通过替换 DexClassLoader 执行过程中的 dex 内存,将其替换为真实 dex 文件的内存,从而实现了无需将 dex 落地的加载方式。
然而,dex 文件虽然不会解密并保存到文件系统,但它在内存中是完整存在的。因此,在应用程序运行后,逆向分析者可以通过内存搜索的方式将 dex 文件转储出来。
为了对抗逆向开发通过内存搜索的方式将 dex 文件转储出来,加固技术采用了函数抽取的方法,使得 dex 文件在内存中一直处于不完整的状态。
其实现思路大致如下:
1、对要保护的 dex 文件进行预处理,将需要保护的函数指令抽取出来并进行加密存储,同时在原位置填充 nop 指令。
2、当 dalvik/art 执行到抽取的函数时,利用 hook 技术拦截 libdalvik.so/libart.so 中的指令读取部分,将函数对应的真实指令解密并填充,使得 dalvik/art 能够继续解释执行。
随着逆向技术的不断发展,改造 dalvik 并遍历所有 dex 方法,以及内存重组 dex,成为了对抗此种加固保护的有效方法。其中,dexhunter 是该领域的主要代表之一。
随着内存脱壳机的出现,指令抽取的保护方式逐渐失去有效性。为了应对这一问题,java2cpp 技术开始被引入到加固保护中。
核心是对 dex 中的函数进行处理,将函数中的 dalvik 指令转换成等效的 cpp 代码(基于 JNI),然后编译成本地的动态链接库(native so 库),并将保护的方法标记为 native 属性。这样,在执行到受保护的方法时,执行流会转移到本地层执行对应的 cpp 代码。
比如原函数:
public class HelloVMP2 {
public int compute(int a, int b) {
int c = a + a;
int d = a * b;
int e = a - b;
int f = a / b;
int result = c + d + e + f;
return result;
}
}
转换后:
public class HelloVMP2 {
static {
System.loadLibrary("hello_vmp2");
}
public native int compute(int a, int b);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_vmp_mylibrary_HelloVMP2_compute(JNIEnv* env, jobject obj, jint a, jint b) {
jint c = a + a;
jint d = a * b;
jint e = a - b;
jint f = a / b;
jint result = c + d + e + f;
return result;
}
这种方式下,仅将 java 转 cpp 编译成动态链接库,但是so代码依然可以被破解,在此基础上其实还是可以继续提高代码保护的安全性,那就是 DEX-VMP 技术。
DEX-VMP 原理理解起来比较容易,其针对的保护单位也是函数。将方法的 dalvik 指令转换成等价的自定义指令,函数原指令替换成自定义 VM 的调用入口指令,再将函数参数通过 VMP 入口传入到自定义 VM 中执行,自定义 VM 解释执行自定义指令。
如图,当 Dalvik VM 执行到 DEX-VMP 保护的函数时,执行的是 VMP native 入口函数,开始进入 VMP 的执行流程,VMP 首先会初始化 dex 文件信息,接着获取该保护方法的一些信息,比如寄存器数量,待执行指令的内存位置等,然后初始化寄存器存储结构,最后进入到解释器中解释执行每一条指令。在解释执行的过程,如果执行到外部函数,就会使用 JNI CallMethod 的形式调用,让其切换回 Dalvik VM,让 Dalvik 去执行真正的函数。
加固过程原函数的代码逻辑替换为 native 方法,同时对 Custom VM 进行初始化,原函数 native 方法负责将参数传入到 Custom VM 中,Custom VM 解释执行原代码的等价指令。
实现 DEX-VMP 总体来说需要两步:
1、对原 dex 处理,找到要保护的方法,将原指令翻译成等价指令,加密存储,并将原指令替换为 VMP 入口指令
2、实现 VM,解释执行存储的等价指令
可以看到,加固技术是不断攻防升级的过程,下面我们将以上加固技术分为五代进行对比:
由以上对比我们可以看出,在加固技术演进过程中,VMP方案是发展到目前,加固安全度最高的方式,本着安全性角度出发,我们选择VMP方案重点介绍与分析,以下是对于项目中VMP加固的分析过程。
以下是我们要保护的一段示例代码:
package com.vmp.mylibrary;
public class HelleVMP3 {
public int compute(int a, int b) {
int c = a + a;
int d = a * b;
int e = a - b;
int f = a / b;
int result = c + d + e + f;
return result;
}
}
dex 预处理主要做两方面工作:
1、保护方法的原指令拷贝出来并存储
2、保护方法的原指令替换成 VMP 入口方法
将要保护的 java 代码编译成 dex 文件,放入 010editor 中可以查看 compute 方法对应的指令数据:
可以看到蓝色区域包含的方法所需要的寄存器数,内部参数,外部参数及指令长度。这些都是 VM 需要的关键信息,需要存储起来。然后将指令替换为 DEX-VMP 的 native 入口指令。
有一些工具可以帮我们实现以上操作,比如 dexlib2,使用该工具可以对指定方法构造 dalvik 指令,或获取方法的指令数据。该工具的具体使用方法大家可以自定搜索。
通过dexdump 命令查看,原方法二进制结构内容如下:
Virtual methods -
#0 : (in Lcom/vmp/mylibrary/HelloVMP3;)
name : 'compute'
registers : 6
ins : 3
outs : 0
insns size : 11 16-bit code units
28e588: |[28e588] com.vmp.mylibrary.HelloVMP3.compute:(II)I
28e598: 9000 0404 |0000: add-int v0, v4, v4
28e59c: 9201 0405 |0002: mul-int v1, v4, v5
28e5a0: 9102 0405 |0004: sub-int v2, v4, v5
28e5a4: b354 |0006: div-int/2addr v4, v5
28e5a6: b010 |0007: add-int/2addr v0, v1
28e5a8: b020 |0008: add-int/2addr v0, v2
28e5aa: b040 |0009: add-int/2addr v0, v4
28e5ac: 0f00 |000a: return v0
从示例 compute 方法的一些 hex 数据中,可以得到一些关键信息:
compute 方法在执行过程中需要使用到 6 个寄存器,传入参数 3 个, 没有使用 try 结构,指令数据为 16 个字。
Dalvik 寄存器最大长度为 32bit,我们可以直接申请一段内存来表示寄存器:
regptr_t regs[6];
regs[0] = 0;
regs[1] = 0;
regs[2] = 0;
regs[3] = 0;
regs[4] = 0;
regs[5] = 0;
regs[3] = (regptr_t) thiz;
regs[4] = p1;
regs[5] = p2;
u1 reg_flags[6];
reg_flags[0] = 0;
reg_flags[1] = 0;
reg_flags[2] = 0;
reg_flags[3] = 0;
reg_flags[4] = 0;
reg_flags[5] = 0;
reg_flags[3] = 1;
regs 表示寄存器,4 个寄存器分别为 regs [0], regs [1], regs [2], regs [3]。regs_bits_obj 表示对应寄存器是否是 Object,比如 regs [3] 是 Object,则 regs_bits_obj [3] = 1,非 object 的情况均为 0;
每一个保护方法在进入 VM 后,我们就像示例这样创建好这样的寄存器单元,供 VM 在解释执行阶段使用,执行完毕销毁即可。
注意这个过程的专业的加固工具会在 dex 预处理过程中识别二进制结构内容进行执行,无需每保护一个方法单独开发。
4.3 虚拟机实现
我们就以示例 compute 方法中的 add-int, mul-int, sub-int, div-int 这几条指令来实现一个简易的解释器
介绍一下这几条指令的作用:add-int、mul-int、sub-int、div-int 对两个源寄存器执行已确定的二元运算,并将结果存储到目标寄存器中。
首先定义自定义虚拟机需要执行的vmCode结构:
typedef struct {
const u2 *insns; // 指令
const u4 insnsSize; // 指令大小
regptr_t *regs; // 寄存器
u1 *reg_flags; // 寄存器数据类型标记,主要标记是否为对象
const u1 *triesHandlers; // 异常表
} vmCode;
自定义Opcode:
enum Opcode {
OP_ADD_INT = 0x3a,
OP_MUL_INT = 0xe4,
OP_SUB_INT = 0x77,
OP_DIV_INT_2ADDR = 0x6c,
OP_ADD_INT_2ADDR = 0xcf,
OP_RETURN = 0xde,
};
目标方法转化的 native 方法:
static jint Java_com_vmp_mylibrary_HelloVMP3_compute__II_I(JNIEnv *env, jobject thiz , jint p1, jint p2) {
regptr_t regs[6];
regs[0] = 0;
regs[1] = 0;
regs[2] = 0;
regs[3] = 0;
regs[4] = 0;
regs[5] = 0;
regs[3] = (regptr_t) thiz;
regs[4] = p1;
regs[5] = p2;
u1 reg_flags[6];
reg_flags[0] = 0;
reg_flags[1] = 0;
reg_flags[2] = 0;
reg_flags[3] = 0;
reg_flags[4] = 0;
reg_flags[5] = 0;
reg_flags[3] = 1;
static const u2 insns[] = {
0x00b3, 0x0404, 0x0120, 0x0504, 0x02ee, 0x0504, 0x546c, 0x10a9, 0x20a9, 0x40a9,
0x00ad,
};
const u1 *tries = NULL;
const vmCode code = {
.insns=insns,
.insnsSize=11,
.regs=regs,
.reg_flags=reg_flags,
.triesHandlers=tries
};
jvalue value = vmInterpret(env,
&code,
&dvmResolver);
return value.i;
}
执行指令处理逻辑:
#define OP_END
#define INST_AA(_inst) ((_inst) >> 8)
#define FETCH(_offset) (pc[(_offset)])
#define SET_REGISTER(_idx, _val) \
DELETE_LOCAL_REF(_idx); \
(fp[(_idx)] =(u4) (_val)); \
SET_REGISTER_FLAGS(_idx, 0)
#define HANDLE_OP_X_INT(_opcode, _opname, _op, _chkdiv)
HANDLE_OPCODE(_opcode /*vAA, vBB, vCC*/)
{
u2 srcRegs;
vdst = INST_AA(inst);
srcRegs = FETCH(1);
vsrc1 = srcRegs & 0xff;
vsrc2 = srcRegs >> 8;
ILOGV("|%s-int v%d,v%d", (_opname), vdst, vsrc1);
......
}
FINISH(2);
#define HANDLE_OP_X_INT(_opcode, _opname, _op, _chkdiv) \
HANDLE_OPCODE(_opcode /*vAA, vBB, vCC*/) \
{ \
u2 srcRegs; \
vdst = INST_AA(inst); \
srcRegs = FETCH(1); \
vsrc1 = srcRegs & 0xff; \
vsrc2 = srcRegs >> 8; \
ILOGV("|%s-int v%d,v%d", (_opname), vdst, vsrc1); \
if (_chkdiv != 0) { \
s4 firstVal, secondVal, result; \
firstVal = GET_REGISTER(vsrc1); \
secondVal = GET_REGISTER(vsrc2); \
if (secondVal == 0) { \
dvmThrowArithmeticException(env,"divide by zero"); \
GOTO_exceptionThrown(); \
} \
if ((u4)firstVal == 0x80000000 && secondVal == -1) { \
if (_chkdiv == 1) \
result = firstVal; /* division */ \
else \
result = 0; /* remainder */ \
} else { \
result = firstVal _op secondVal; \
} \
SET_REGISTER(vdst, result); \
} else { \
/* non-div/rem case */ \
SET_REGISTER(vdst, (s4) GET_REGISTER(vsrc1) _op (s4) GET_REGISTER(vsrc2)); \
} \
} \
FINISH(2);
__attribute__((visibility("default")))
jvalue vmInterpret(JNIEnv *env, const vmCode *code, const vmResolver *dvmResolver) {
jvalue args_tmp[5]; // 方法调用时参数传递(参数数量小于等于5)
jvalue retval;
regptr_t *fp = code->regs; // 寄存器
u1 *fp_flags = code->reg_flags; // 寄存器类型标识
const u2 *pc = code->insns;
......
/* File: c/OP_ADD_INT.cpp */
HANDLE_OP_X_INT(OP_ADD_INT, "add", +, 0)
OP_END
/* File: c/OP_SUB_INT.cpp */
HANDLE_OP_X_INT(OP_SUB_INT, "sub", -, 0)
OP_END
/* File: c/OP_MUL_INT.cpp */
HANDLE_OP_X_INT(OP_MUL_INT, "mul", *, 0)
OP_END
/* File: c/OP_DIV_INT.cpp */
HANDLE_OP_X_INT(OP_DIV_INT, "div", /, 1)
OP_END
/* File: c/OP_REM_INT.cpp */
HANDLE_OP_X_INT(OP_REM_INT, "rem", %, 2)
OP_END
end:
return 0;
}
上面是一个解析自定义 opcode 的解释器,大家可以从其中看到解释器就是 while switch 的程序结构,执行到 return 指令时退出循环。
通过以上实现,可以发现虚拟机加固核心自定义一套opcode用于对保护方法的指令替换,同时还需要对替换后的指令识别后,如果对Java函数的调用交给DVM进行处理,如果是原函数指令则创建寄存器交给机器处理。整个加固过程中分为编译器+解释器两部分。
其中编译器负责对打包的AAR或者APK进行加固,加固过程则是将要保护的方法转换为JNI调用,同时C++部分根据原方法指令生成需要的寄存器与opcode;而解释器则是在运行过程,当执行到JNI调用时,能够对创建的opcode进行识别,转化原指令与寄存器交由真正的DVM进行执行。
兼容风险:
规避建议:
产生性能消耗的主要有两点:
JNI 调用
DEX-VMP 与 系统 VM 的切换
优化建议:
JNI 调用是性能消耗主要因素。对于一些常用的 java class,可以在初始化时统一获取 jclass 缓存起来,这可以一定程度上提高性能,类似的还有避免重复查找 class。
尽量避免全量代码保护(dex 中所有的方法都 DEX-VMP 保护,包含 Android SDK 的基础类库),排除Android基础类库和开源类库,仅将业务自己的核心逻辑代码方法进行保护。
总结来说,虚拟机加固是一种可以提高应用程序安全性的技术,但它也带来了性能、兼容性和维护成本等方面的挑战。
我们在使用代码虚拟化时,需要根据应用程序的特点和安全需求,合理选择和优化虚拟化方案。
——END——
推荐阅读:
文章浏览阅读1.6k次。安装配置gi、安装数据库软件、dbca建库见下:http://blog.csdn.net/kadwf123/article/details/784299611、检查集群节点及状态:[root@rac2 ~]# olsnodes -srac1 Activerac2 Activerac3 Activerac4 Active[root@rac2 ~]_12c查看crs状态
文章浏览阅读1.3w次,点赞45次,收藏99次。我个人用的是anaconda3的一个python集成环境,自带jupyter notebook,但在我打开jupyter notebook界面后,却找不到对应的虚拟环境,原来是jupyter notebook只是通用于下载anaconda时自带的环境,其他环境要想使用必须手动下载一些库:1.首先进入到自己创建的虚拟环境(pytorch是虚拟环境的名字)activate pytorch2.在该环境下下载这个库conda install ipykernelconda install nb__jupyter没有pytorch环境
文章浏览阅读5.2k次,点赞19次,收藏28次。选择scoop纯属意外,也是无奈,因为电脑用户被锁了管理员权限,所有exe安装程序都无法安装,只可以用绿色软件,最后被我发现scoop,省去了到处下载XXX绿色版的烦恼,当然scoop里需要管理员权限的软件也跟我无缘了(譬如everything)。推荐添加dorado这个bucket镜像,里面很多中文软件,但是部分国外的软件下载地址在github,可能无法下载。以上两个是官方bucket的国内镜像,所有软件建议优先从这里下载。上面可以看到很多bucket以及软件数。如果官网登陆不了可以试一下以下方式。_scoop-cn
文章浏览阅读4.5k次,点赞2次,收藏3次。首先要有一个color-picker组件 <el-color-picker v-model="headcolor"></el-color-picker>在data里面data() { return {headcolor: ’ #278add ’ //这里可以选择一个默认的颜色} }然后在你想要改变颜色的地方用v-bind绑定就好了,例如:这里的:sty..._vue el-color-picker
文章浏览阅读640次。基于芯片日益增长的问题,所以内核开发者们引入了新的方法,就是在内核中只保留函数,而数据则不包含,由用户(应用程序员)自己把数据按照规定的格式编写,并放在约定的地方,为了不占用过多的内存,还要求数据以根精简的方式编写。boot启动时,传参给内核,告诉内核设备树文件和kernel的位置,内核启动时根据地址去找到设备树文件,再利用专用的编译器去反编译dtb文件,将dtb还原成数据结构,以供驱动的函数去调用。firmware是三星的一个固件的设备信息,因为找不到固件,所以内核启动不成功。_exynos 4412 刷机
文章浏览阅读2w次,点赞24次,收藏42次。Linux系统配置jdkLinux学习教程,Linux入门教程(超详细)_linux配置jdk
文章浏览阅读3.3k次,点赞5次,收藏19次。xlabel('\delta');ylabel('AUC');具体符号的对照表参照下图:_matlab微米怎么输入
文章浏览阅读119次。顺序读写指的是按照文件中数据的顺序进行读取或写入。对于文本文件,可以使用fgets、fputs、fscanf、fprintf等函数进行顺序读写。在C语言中,对文件的操作通常涉及文件的打开、读写以及关闭。文件的打开使用fopen函数,而关闭则使用fclose函数。在C语言中,可以使用fread和fwrite函数进行二进制读写。 Biaoge 于2024-03-09 23:51发布 阅读量:7 ️文章类型:【 C语言程序设计 】在C语言中,用于打开文件的函数是____,用于关闭文件的函数是____。
文章浏览阅读3.4k次,点赞2次,收藏13次。跟随鼠标移动的粒子以grid(SOP)为partical(SOP)的资源模板,调整后连接【Geo组合+point spirit(MAT)】,在连接【feedback组合】适当调整。影响粒子动态的节点【metaball(SOP)+force(SOP)】添加mouse in(CHOP)鼠标位置到metaball的坐标,实现鼠标影响。..._touchdesigner怎么让一个模型跟着鼠标移动
文章浏览阅读178次。项目运行环境配置:Jdk1.8 + Tomcat7.0 + Mysql + HBuilderX(Webstorm也行)+ Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。项目技术:Springboot + mybatis + Maven +mysql5.7或8.0+html+css+js等等组成,B/S模式 + Maven管理等等。环境需要1.运行环境:最好是java jdk 1.8,我们在这个平台上运行的。其他版本理论上也可以。_基于java技术的停车场管理系统实现与设计
文章浏览阅读3.5k次。前言对于MediaPlayer播放器的源码分析内容相对来说比较多,会从Java-&amp;gt;Jni-&amp;gt;C/C++慢慢分析,后面会慢慢更新。另外,博客只作为自己学习记录的一种方式,对于其他的不过多的评论。MediaPlayerDemopublic class MainActivity extends AppCompatActivity implements SurfaceHolder.Cal..._android多媒体播放源码分析 时序图
文章浏览阅读2.4k次,点赞41次,收藏13次。java 数据结构与算法 ——快速排序法_快速排序法