Author: geneblue
Blog: https://geneblue.github.io/
过去的一年,一直在忙于 Android 代码保护方面的工作。从这一年多的经验来看,Android平台的代码保护技术已经发展到相对较为稳定的阶段了。目前,市场上的加固产品比较成熟,但各家的 DEX-VMP 技术并没有发展到同一高度,其中兼容性,稳定性参差不一。
该文试图解释清楚 DEX-VMP 技术的基本原理,也算是对之前工作的总结。
背景
众所周知,Android 应用程序是由 java/Kotlin 语言编写而成,然后打包成 APK 文件。java 代码被编译成 APK 中的 dex 文件,dalvik/art 虚拟机解释执行 dex 中的字节码。攻击者可以使用反编译工具很容易的逆向分析 dex 文件,理解代码关键逻辑,增加恶意代码,再打包回 APK 文件。对于应用开发者来说,肯定不愿意看到自己辛苦开发的应用被恶意打包,或者代码中的关键逻辑(支付)被恶意篡改。
为了对抗上述风险,Android 平台的代码保护技术(加固技术)一直在蓬勃发展。可以看到,dex 文件是代码加固的保护核心。
最初的加固技术利用 Android 系统 DexClassLoader 动态加载机制,将保护的 dex 文件解压解密后动态加载到内存中执行。该方式有效的抵抗了 APK 文件的静态分析,使逆向分析者无法在 APK 文件中找到真实的 dex 文件。然而此种方式存在很大的弊端,应用安装运行后,会将真实的 dex 文件解密落地到文件系统。分析者观察 dex 文件路径,将其 copy 出来即可。
为了针对上述保护缺陷,第二代保护利用 hook 技术,在动态加载的时候将 DexClassLoader 执行过中的 dex 内存替换成真实 dex 文件的内存,从而实现 dex 的不落地加载。当然逆向分析技术也在不断发展,dex 文件虽然不会解密落地,但在内存中是完整存在的。在应用运行起来后,内存搜索 dex 将其 dump 下来即可。
为了对抗上述逆向方式,第三代保护技术使用函数抽取的方式,让 dex 在内存中一直是不完整状态。实现的大致思路是,对要保护的 dex 文件预处理,将要保护的函数指令抽取出来加密存储,原位置填充 nop 指令,在 dalvik/art 执行到抽取的函数时,使用 hook 技术拦截 libdalvik.so/libart.so 中的指令读取部分,将函数对应的真实指令解密填充让 dalvik/art 解释执行下去。随着逆向技术的发展,改造 dalvik ,遍历所有 dex 方法,再内存重组 dex 是对抗此种加固保护的有效方式,dexhunter 是其中的主要代表。
随着内存脱壳机的出现,指令抽取的保护方式不再有效。java2cpp 技术开始引入到加固保护中。java2cpp 也是对 dex 中的函数做处理,将函数中的 dalvik 指令转换成等价的 cpp 代码(基于 JNI),再编译成 native so 库,保护方法增加 native 属性。这样执行到该保护方法时就会转入到 native 层执行对应的 cpp 代码。当保护较多方法时,cpp 代码编译的 so 体积也在变大,这会存在包体积过大的问题。
针对 java2cpp 保护大量方法时安装包体积过大的问题,以及更高地提升代码保护的安全性,DEX-VMP 技术在 Android 平台得以发展。这也是本文主要介绍的内容。
DEX-VMP 概览
DEX-VMP 原理理解起来比较容易,其针对的保护单位也是函数。将方法的 dalvik 指令转换成等价的自定义指令,函数原指令替换成自定义 VM 的调用入口指令,再将函数参数通过 VMP 入口传入到自定义 VM 中执行,自定义 VM(XVM) 解释执行自定义指令,总体效果如下:
原函数:可以看到,原函数的代码逻辑不复存在,进而替换为 x.z(), x.v() 等 XVM 入口,这些入口均是 native 方法,负责将参数传入到 XVM 中,XVM 解释执行原代码的等价指令。
实现 DEX-VMP 总体来说需要两步: - 对原 dex 处理,找到要保护的方法,将原指令翻译成等价指令,加密存储,并将原指令替换为 VMP入口指令 - 实现 VM,解释执行存储的等价指令
实现 DEX-VMP,需要熟知 dex 文件格式,smali 指令 和 JNI 调用方面的知识。
DEX-VMP 设计与实现
为了简化过程,这里直接以 dalvik 的原生指令作为等价指令。
dex 文件结构
对 dex 文件结构的介绍可以参考 Dalvik 可执行文件格式,也可以自行搜索其他资料,这里不做详细介绍。
DEX-VMP 中 string,field,method,class 等信息均要从 dex 文件结构中去获取,因为 dalvik 字节码中包含的均是 id 信息,并不直接含有 string,field 等。
dalvik 字节码
Dalvik 字节码 这里包含每条 dalvik 指令的详细解释。 Dalvik 可执行指令格式 这里包含指令格式的解释。
dalvik 共有 256 条指令,在 035 版本的 dex 文件中,0x3E ~ 0x43, 0x79 ~ 0x7A, 0xEE ~ 0xFF 指令未使用,这些指令预留给了高版本的 dex 格式。
一个常见的指令如下所示:
该条指令表示将一个 16 位的源寄存器的值赋值给一个 8 位的目的寄存器中。
红色方框中的 02 表示该指令序号,蓝色横线 2 表示该条指令需要占用 2 个字的空间(1个字16bit),绿色横线 2 表示该指令使用到 2 个寄存器,x 符号表示指令使用到的数据类型,其他类型参考下表
该指令对应的一个示例如下:
1 | 02 03 13 00 |
当 dalvik 读取到 02 时,查找到 02 对应的是 move/from16 这条指令,这条指令占据两个字大小,需要使用到两个寄存器,分别为 v03,v0013,指令操作是将 v0013 的值拷贝给 v03。
将上述描述代码化表示就是解释器的执行过程。
JNI 调用
JNI 是 java 本地调用接口(Java Native Interface),native(本地)代码就是指 c/c++ 代码。写过Android 代码的同学都对这个比较熟悉,我们一般将一些性能或者安全要求较高的逻辑放到 native 中,毕竟 c/c++ 代码的执行效率和逆向难度都要比纯 java 好很多。
java 代码都可以等价为 JNI 的实现形式,如下:
1 | String value = String.valueOf(0); |
简单调用一个 String 类的 valueof() 方法,其对应的 JNI 代码如下:
1 | jint init = 0; |
以上代码功能完全一致,最终都是调用 java 层的 String.valueof() 方法。第一份调用代码本身就在 java 层,第二份运行在 c/c++ 的 native 层。可以看到 JNI 就是沟通 native 与 java 的桥梁。
自动完成上述转换其实就是 java2c 的实现过程。当然 DEX-VMP 的实现也与 JNI 调用息息相关。
DEX-VMP 设计与实现
以下是一段示例代码:
1 | package com; |
VM 结构选择
DEX-VMP 的实现和其他 VM,比如 JVM,DVM 的实现是很相似的。VM 的实现都会面对结构选择问题,常见两类结构,栈结构和寄存器结构。JVM 是典型的使用栈结构的 VM,所有参数在调用之前都会有入栈操作,示例代码反编译如下:
1 | Compiled from "Hello.java" |
可以看到在调用 invokevirtual 指令之前,会调用两个 ldc 指令,ldc 指令负责将两个常量字符串从常量池送到栈顶,在 invokevirtual 时再从栈顶弹出这两个值作为调用参数,这和 x86 指令的调用约定是类似的,只不过 x86 的栈和指令执行是 cpu 硬件约定好的,cpu 就是在解释执行每一条 x86 指令,我们可以把 VM 的解释执行过程当作是一个虚拟的 CPU。
Dalvik VM 是基于寄存器结构的,对应的一段 smali 如下:
1 | .class public Lcom/Hello; |
可以看到在调用 invoke-virtual 之前,是两个 const-string 指令,这两个指令负责将字符串常量直接赋值给 v1,v2 寄存器,在调用 invoke-virtual 时直接将寄存器传入即可,这和 arm 指令的调用约定有一定的相似。
在设计指令时,指令的调用约定就已经决定了后续的 VM 实现是使用寄存器结构,还是栈结构,VM 是没有选择的。
我们要实现的 XVM 因为是直接以 dalvik 的原生指令作为执行指令,所以最简易的做法也是像 dalvik 那样使用寄存器结构。
VMP 执行流程
如图,当 dalvik VM 执行到 DEX-VMP 保护的函数时,执行的是 VMP native 入口函数,开始进入 VMP 的执行流程,VMP 首先会初始化 dex 文件信息,接着获取该保护方法的一些信息,比如寄存器数量,待执行指令的内存位置等,然后初始化寄存器存储结构,最后进入到解释器中解释执行每一条指令。在解释执行的过程,如果执行到外部函数,比如示例代码中的 replaceAll 就是一个外部函数,就会使用 JNI CallMethod 的形式调用,让其切换回 dalvik VM,让 dalvik 去执行真正的 replaceAll 函数。
dex 文件预处理
如 DEX-VMP 概览章节所示的加固前后的对比图,很明显我们需要对 dex 文件做预处理。dex 预处理主要做两方面工作,保护方法的原指令拷贝出来并存储,保护方法的原指令替换成 VMP 入口方法。将示例 java 代码编译成 dex 文件,放入 010editor 中可以查看 main 方法对应的指令数据:
可以看到蓝色区域包含的 main 方法所需要的寄存器数,内部参数,外部参数及指令长度。这些都是 VM 需要的关键信息,需要存储起来。然后将指令替换为 DEX-VMP 的 native 入口指令。
有一些工具可以帮我们实现以上操作,比如 dexlib2,使用该工具可以对指定方法构造 dalvik 指令,或获取方法的指令数据。该工具的具体使用方法大家可以自定搜索。
在构造 native 入口函数时,我们需要将保护方法的所有参数都传入到 native 层,注意非静态方法会有一个隐含的 this 参数。
VMP 寄存器结构设计
我们的 VM 是基于寄存器结构的,那么该如何管理这些寄存器呢?这里所指的寄存器和 dalvik 中的一样,是虚拟概念,并不是指 cpu 真实的寄存器。
从示例 main 函数的一些 hex 数据中,可以得到一些关键信息:
1 | 0400 0100 ....p........... |
如 010editor 中解析的那样,main 方法在执行过程中需要使用到 4 个寄存器,传入参数 1 个,调用其他方法需要用的参数为 3 个, 没有使用 try 结构,指令数据为 16 个字。
dalvik 寄存器最大长度为 32bit,我们可以直接申请一段内存来表示寄存器:
1 | unsigned int *regs = (unsigned int *) calloc(sizeof(unsigned int), 4); |
regs 表示寄存器,4 个寄存器分别为 regs[0], regs[1], regs[2], regs[3]。regs_bits_obj 表示对应寄存器是否是 object,比如 regs[0] 是 object,则 regs_bits_obj[0] = 1,非 object 的情况均为 0;
每一个保护函数在进入 VM 后,我们就先创建好这样的寄存器单元,供 VM 在解释执行阶段使用,执行完毕销毁即可。
VMP dex 文件解析
对 dex 文件格式比较熟悉的同学都知道,为了节省 dex 文件的体积,dex 文件中大量采用索引,字符串会去重统一存放。string, type, proto, field, method 结构均使用索引来获取相应的字符信息。比如 method:
1 | struct MethodId { |
通过具体索引,我们可以进一步查找该 method 所属 class 是哪一个,proto 是哪一个,name 是哪个字符串。
字节码指令中包含的也是索引,所以我们需要在 VM 中实现对 dex 文件的解析,当解释到指令时,如果需要字符索引或其他索引,需要从对应 dex 文件中获取。
可以设计如下几个示例接口:
1 | struct MethodId { |
对 dex 文件的详细解析可以查看源码 libdex/DexFile.h
总之,要对 dex 文件格式熟悉。
VMP 实现示例
我们就以示例 main 方法中的 const-string, invoke-virtual, move-result-object, sget-object, return-void 这几条指令来实现一个简易的解释器
先熟悉一下这几条指令的作用:
const-string vAA, string@BBBB 通过给定的索引获取字符串引用,然后移到指定的寄存器中 invoke-virtual {vC, vD, vE, vF, vG}, meth@BBBB 调用指定方法,如果有结果的话可以与紧跟其后的 move-result-* 指令一起存储 move-result-object vAA 将最新的 invoke-* 指令的执行结果移到指定的寄存器中。该指令必须紧跟在结果不会被忽略的 invoke-* 或者 filled-new-array 指令之后执行,否则无效。 sget-object vAA field@BBBB 获取已标识的字段,并将结果存储在寄存器中 return-void 直接返回j
示例 main 函数指令对应 dex 文件的 hex 数据:
1 | 1a00 0a00 ................ |
一一对应的关系如下:
1 | .method public static main([Ljava/lang/String;)V |
1 |
|
上面是一个示例 demo,大家可以从其中看到解释器就是 while switch 的程序结构,执行到 return 指令时退出循环。
多种保护支持
dex 文件是 DEX-VMP 的主要保护目标。
在 Android 项目中,有的时候我们并不需要开发完整的 app 程序,而是提供 sdk 给其他部门或者其他开发者使用,但又不希望其他开发人员很容易就逆向分析出 sdk 的逻辑,那 DEX-VMP 也可以满足这样的需求吗。可以的。Android 上的 sdk 一般有两种提供形式,jar 包和 aar 包。aar 包是个压缩文件,代码逻辑依然是放在 jar 包中,所以解决 jar 的加固即可。jar 加固的流程和 dex 加固比较相似,首先需要对 jar 文件预处理,将 jar 包使用 android sdk 中的 dx 工具转换成对应的 dex 文件,使用 dexlib2 将 dex 文件中待保护方法的 dalvik 字节码 move 出来,原位置不保留任何字节码,该 dex 文件就是用来寻找 string,method,field 等信息的 cache 文件, 再将 jar 中目标方法的字节码 patch 成 vmp 的入口,原字节码丢弃掉。vmp 部分在初始化时,需要去寻找 cache 文件完成初始化。这样我们提供给第三方的 sdk,会在原基础上增加 vmp so 文件,cache 文件,如果时提供 aar 包,第三方使用者是看不出来区别的。
支持了经 sdk 中的 jar 包,pc 上的 jar 文件加固支持也是同样的原理,把 vmp 代码跨平台编译成 pc 上的即可。
现在的一些大型 App 开始采用插件化开发。国内开源的 bundle 框架有 Atlas,Replugin,VirtualApp 等,而且 Google 在 Android Q 上也开始官方支持 bundle 开发,那么 bundle app 的加固需求也随之而来。bundle app 的加固支持只要设置好 bundle app 对应的 classloader ,设置好 bundle app cache 初始化即可。
可以看到完备的 DEX-VMP 支持多种加固模式。
兼容性问题
JNI reference 的限制。DEX-VMP 是基于 JNI 实现的,熟悉 JNI 的同学都知道 JNI 中有 localreference 和 globalreference 的概念,称为局部引用和全局应用。局部引用可以使用 env>NewLocalRef() 和 env->FindClass(),env->NewObject(),env->GetObjectClass() 等接口获取,这些局部引用时存放在一个局部引用表中,Android VM 中规定局部引用表的大小是 512。局部引用可以选择主动调用 env->DeleteLocalRef() 释放,也可以在 native 函数返回时,由 dalvik/art VM 自动释放。这个表的容量只有 512,非常容易 overflow 造成 crash,所以我们需要仔细对待每一个 localreference,能主动 free 掉的,一定要主动 free 掉。全局引用的限制就没那么大了,全局引用表的容量有 51200,我们可以把一些全局量设置为 global 类型,可以在整个 DEX-VMP 的生命周期内使用,比较方便,而且正常情况下不会 overflow。
JNI 实现不统一且可能存在 bug。每个版本的系统,VM 中 jni 实现细节不尽相同,这点大家可以去 aosp 的代码中翻一翻。oracle 只是提供了 JNI 的数据结构和函数接口定义,不提供具体实现。 dalvik/art 是自行实现 JNI 的。在我们的大量测试中,发现 Android5.0 某个小版本中存在 JNI 实现 bug,该 bug 的后果就是某个 JNI 函数调用完后,正常情况会返回一个 local reference,也就是返回给我们的结果是 jobject,我们使用完后主动 free 掉即可,但该存在 bug 的 JNI 函数在其内部实现中会存在一个隐含的 jobject(local reference)忘记 delete 掉。当多次调用该 JNI 函数时,local reference overflow 不可避免。这个 bug 在之后的 Android 版本中更正过来。也就是说每个 Android 版本出来之后,我们都要看看 VMP 会不会存在 JNI 兼容性方面的 BUG。
特例系统 YunOS。YunOS 是阿里于 2011 年发布的手机系统,兼容 Android APK 格式的安装包。YunOS 没有采用 dalvik/art VM,而是自己实现了一套 TVM。我们遇到过几款使用该系统的手机,主要集中在魅族的一些早期机型和一些不常见的手机品牌。TVM 和 dakvik/art VM 在一些数据结构上有细微差异,比如 Struct Field。某些我们需要的数据可能会有偏移量上的差异。通过简单的逆向,适配好偏移即可。
垮 VM 可能存在的兼容性问题。DEX-VMP 保护后的 App 在其运行过程中,不停的在 dalvik/art VM 和 VMP 之间切换。对于有的待保护代码,这样的切换可能会存在问题。比如如下代码:
1 | public static String str; |
上述代码暂不论 "==" 比较字符串是否合理。这段代码在 dalvik/art VM 和 JVM 上执行结果是 "equal"。如果只对方法 a() DEX-VMP 保护,结果还是 "equal" 吗?对于这样的畸形代码,可以在预处理阶段统一 fix 掉。
这些兼容性问题,有的可以转成 java 层的等价实现,然后翻译成对应的 JNI 代码,这样就可以避免掉一些问题,比如存在 BUG 的某些 JNI 函数。总之这些兼容性问题总是能够解决掉的。
性能问题
性能问题一直是困扰 DEX-VMP 大规模使用的主要因素。产生性能消耗的主要有两点,JNI 调用和 DEX-VMP 与 系统 VM 的切换,其中,JNI 调用是主要因素。对于一些常用的 java class,可以在初始化时统一获取 jclass 缓存起来,这可以一定程度上提高性能,类似的还有避免重复查找 class。
在 4.4 或者 5.0 一些低配置手机上,全量保护(dex 中所有的方法都 DEX-VMP 保护,包含 Android SDK 的基础类库)时性能问题还是比较突出的,卡顿比较明显。所以不得不采用挑选方法的形式。
随着硬件的发展,性能问题在一些高配手机上已经不是那么突出,全量保护也察觉不到卡顿。当然,为了兼顾低配置机器,我们采取排除基础类库和开源类库,将客户自己的逻辑代码都保护的方式,这样安全性会更好。
对于一些只保护 onCreate() 方法或者保护方法数屈指可数的厂商,我的推断是他们的兼容性和性能并没有很好解决。
结语
安全从来不是单点的,对 Android 平台 APP 保护来说,仅仅代码保护显然无法覆盖所有安全问题。