Android 逆向笔记 —— 说说 Dalvik 及其指令集

简介: Android 逆向笔记 —— 说说 Dalvik 及其指令集

Dalvik 虚拟机


Dalvik 是早期 Android 版本中用于运行安卓应用的虚拟机,由 Dan Bornstein 编写的,名字来源于他的祖先曾经居住过名叫 Dalvík 的小渔村,村子位于冰岛。当年也有一部分业内人士认为 Dalvik 是 Google 为了避免与 Oracle 的诉讼而诞生的产物。Dalvik 是基于 Apache License 2.0 发布的。Google 说 Dalvik 是一个清洁室(clean room)的实现,而不是一个在标准 Java 运行环境的改进,这意味着它不继承标准版本的或开源的 Java 运行环境的版权许可限制。关于这一点,Oracle 和一些专家还在讨论中。


Dalvik 是解释执行加上 JIT,每次app运行的时候,它动态的将一部分 Dalvik 字节码 解释为机器码。随着 App 的运行,更多的字节码被编译和缓存。因为 JIT 只编译了一部分代码,它具有更小的内存占用和更少的设备物理空间占用。但是,边解释边执行,效率低下,这也是后来 Dalvik 遭到抛弃的原因。


从 Android 4.4 开始,Google 开始引入了全新的虚拟机 ART(Android Runtime)。ART 是基于 AOT 编译的,由于安装应用耗时过程,后期高版本的 Android 系统加入了加强版的 JIT 编译。Dalvik 在 Android 5.0 中正式被删除,ART 完成上位。那么现在来学习 Dalvik 还有必要吗?其实 ART 是向下兼容的,ART 和 Dalvik 是运行 Dex 字节码的兼容运行时,因此针对 Dalvik 开发的应用也能在 ART 环境中运作。不过,Dalvik 采用的一些技术并不适用于 ART。因此,Dalvik 虚拟机的部分特性以及 Dalvik 字节码指令其实和 ART 都是相通的。


Dalvik 和 JVM

Dalvik 和 JVM 并不兼容,甚至可以说完全是两套机制。下面来说几点它们之间的区别。


  1. 运行的字节码不同


我们都知道 JVM(Java 虚拟机)识别的是 Class 文件,我之前写过一篇 Class 文件格式详解,详细介绍了 Class 文件的二进制结构。JVM 运行的是 Java 字节码,而 Dalvik 运行的是 Dalvik 字节码。Dalvik 不识别单个的 Class 文件,而是将所有 Class 文件打包成 DEX 文件格式,通过解释 DEX 文件来执行字节码。


这样带来的直接好处就是 Dalvik 的可执行文件的体积更小。如果你了解 Class 文件格式的话,你会知道每个 Class 文件都有单独的字符串常量池。如果不同的 Class 文件中有相同的字符串,那么就存在重复存储的情况。同样的,如果一个类引用了其他类中的方法,相应的方法签名也会被复制到该类文件中。这样就会有很多不必要的冗余信息,既浪费内存也影响执行效率。


那么 DEX 文件是如何解决这个问题的呢?对 DEX 文件结构不了解的话,可以阅读我的另一篇文章 Android逆向笔记 —— DEX 文件格式解析。DEX 文件提供了一个统一的共享的常量池,供所有类文件使用,这样就避免了冗余信心,减小了文件体积,提高了解析效率。


  1. 虚拟机架构不同


JVM 是基于栈架构的。当程序运行时,Java 虚拟机会频繁的对栈进行读写数据的操作。在这个过程中,不仅会多次进行指令分派和内存访问,而且会耗费大量的 CPU 时间,因此,对于资源有限的手机设备来说,是一笔很大的开销。每调用一个方法,就会分配一个新的栈帧并压入栈。每从一个方法返回,就弹出相应的栈帧。


Dalvik 是基于寄存器架构的,数据的访问直接在寄存器之间传递。

基于堆栈的机器与基于寄存器的机器谁更有优势一直是个争论不休的话题。 一般来说,基于堆栈的机器必须使用指令才能从堆栈上的加载和操作数据,因此,相对基于寄存器的机器,它们需要更多的指令才能实现相同的性能。但是基于寄存器机器上的指令必须经过编码,因此,它们的指令往往更大。


上面这段来自百度。的确,Java 虚拟机的操作码都是单字节的,其指令字总操作码个数不超过 256 条。而 Dalvik 指令则长的多的多,数量也多的多。要执行相同的操作,JVM 需要短但是更多的指令,Dalvik 需要长但是更少的指令。Dalvik 的思路是用更长但是更少的指令来减少指令分派和内存访问,以此提高运行效率。


Dalvik 指令


指令格式

关于 Dalvik 指令格式,官网 中也有相关介绍。只是官网的介绍实在过于晦涩,看了很多遍才理解。我这里还是从实际的 Dalvik 指令来进行分析。把之前分析过的 main() 方法直接拿过来:

public static void main(String[] args) {
    System.out.println(HELLO_WORLD);
}
复制代码


其 DexCode 如下:

62 00 01 00 62 01 00 00 6E 20 03 00 10 00 0E 00
复制代码


这里要注意的是 DEX 文件是小端表示法,低位在前,高位在后。通常低 8 位就是 op 码,也就是我们说的操作码。在上面的例子中,第一个操作码就是 62,我们在 Dalvik 指令集中可以找到其代表的指令。关于 Dalvik 指令集,Android 开发者网站也做了总结,点我查看。另外,在 Android 4.4 之前的 AOSP 源码中的 dalvik/libdex/DexOpcodes.h 中也有定义。我这里直接在官网资源中查找 62,结果如下图所示:

image.png

操作码 62 表示的指令是 sget-object,表示获取一个静态对象。仅仅知道了操作码的含义还是不够的,我们还不知道该条指令的完整格式。注意列表最左侧的 21c,它表示的就是指令的格式。关于指令的格式,Android 官网也做了相关总结,点我查看。同样的,AOSP 中也有相关定义,位于 Android 4.0 版本中的 dalvik/docs/instruction-formats.html 文件。查一下 21c 的指令格式,如下图所示:

image.png

可得 21c 对应的指令格式为 AA|op BBBB。对应的上面的十六进制,做一下对比:

AA|op BBBB -> op vAA kind@BBBB
00|62 0001 -> 62 v00 kind@0001
复制代码


这样一看,就很清晰了。该指令一共是两个 16 位的字,第一个 16 位的低 8 位是操作码 62,表示 sget-object,高 8 位表示使用的是 v0 寄存器。第二个 16 位是索引值 1,指向 Dex 中 field_id 部分的第一项,根据之前的解析结果,第一项表示的字段是 Ljava/lang/System;->out;Ljava/io/PrintStream,整合一下,这个指令的完整格式如下:

sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
复制代码


表示获取静态字段 PrintStream.out,并保存在寄存器 v0 中。回头再看一下 21c,它的每一个字符其实都是有含义的。

  • 2 表示该指令有多少个 16 位的字组成
  • 1 表示该指令最多使用多少个寄存器
  • c 为类型码,c 代表常量池索引


关于类型码,还有很多种,如下表所示:


助记符 位数 含义
b 8 有符号立即数(字节)
c 16、32 常量池索引
f 16 接口常量(仅对静态链接格式有效)
h 16 有符号立即数(32 位或 64 位值的高阶位,低阶位全为 0)
i 32 有符号立即数(整型)或 32 位浮点数
l 64 有符号立即数(长整型)或 64 位双精度浮点数
m 16 方法常量(仅对静态链接格式有效)
n 4 有符号立即数(半字节)
s 16 有符号立即数(短整型)
t 8、16、32 分支目标
x 0 无额外数据


还有一种特殊情况指令的末尾会多出一个字母。如果是 s,表示指令采用静态链接。如果是字母 i,表示指令应该被内联处理。


寄存器命名



我们都知道 Dalvik 虚拟机是基于寄存器架构的,其使用的寄存器都是 32 位的。对于 64 位类型,使用相邻两个寄存器来表示。Dalvik 基本都是基于 ARM 架构的,ARM 架构的 CPU 本身就含有一定数量的寄存器,那么 Dalvik 虚拟机支持多少个寄存器呢?我们来看一个 move 指令:

move/16 vAAAA, vBBBB
复制代码


vAAAA vBBBB,每个大写字母表示 4 位,一共就是 2^16 -1,也就是 65535 个。当然,不可能会有 65535 个真实寄存器。Dalvik 使用的是虚拟寄存器,它会将部分寄存器映射到 ARM 的寄存器上,另外一部分通过调用栈进行模拟。


Dalvik 虚拟机为每一个进程维护一个调用栈,这个栈的作用之一就是虚拟寄存器。虚拟机通过处理字节码对寄存器进行读写操作,实际上就是对栈空间进行读写。但是在实践中,一个方法需要 16 个以上的寄存器不太常见,而需要 8 个以上的寄存器却相当普遍,因此很多指令仅限于寻址前 16 个寄存器。


那么寄存器是如何命名的呢?上面的分析中提到过 v0 寄存器,是不是 65535 个寄存器就是 v0 - v65535 呢?实际上,寄存器有两种命名方式,v 命名法p 命名法。在介绍它们之前,先来说一些基本概念。以下面这个 add 函数为例:

public int add(int a,int b){
    int c = a+b;
    return c;
}
复制代码


它使用了几个寄存器?如果不是很确定,可以查看其 smali 代码中的 .registers 字段。答案是 4 个。根据 Dalvik 虚拟机规定,方法参数使用最后面的寄存器。这 4 个寄存器中的最后两个就是存储参数 ab。由于 add() 是非静态函数,所以该方法总是会传入当前对象的引用 this,所以实际上是 3 个参数,占用最后 3 个寄存器。而剩余的开头的寄存器就是局部变量寄存器,在 add() 方法中只有一个局部变量寄存器,用于存储 a+b 的值,就是第一个寄存器。下面就来看看 v 命名法p 命名法 分别是如何给这 4 个寄存器命名的。


v 命名法

v 命名法其实很简单,就是上面说的 v0 - v65535。不管是参数寄存器,还是局部变量寄存器,一律以 v 开头。在 add() 函数中,4 个寄存器命名如下:

  • v0 : 局部变量寄存器,存储 a+b 的值
  • v1 : 当前引用 this
  • v2 : 参数寄存器,存储 a 的值
  • v3 : 参数寄存器,存储 b 的值


p 命名法

p 命名法针对参数寄存器进行了优化,参数寄存器的命名从 p0 开始,使得局部变量寄存器和参数寄存器得以很容易的进行区分。smali 语法中就是用了 p 命名法。我们来看下 add() 方法的 smali 代码:

.method public add(II)I
    .registers 4
    .param p1, "a"    # I
    .param p2, "b"    # I
    .prologue
    .line 6
    add-int v0, p1, p2
    .line 7
    .local v0, "c":I
    return v0
.end method
复制代码


这样就很清晰了,4 个寄存器命名如下所示:

  • v0 : 局部变量寄存器,存储 a+b 的值
  • p0 : 当前引用 this
  • p1 : 参数寄存器,存储 a 的值
  • p2 : 参数寄存器,存储 b 的值

p 命名法 更加已读,一般都是使用 p 命名法。


Dalvik 描述符


在更深入的了解 Dalvik 字节码前,先来看一下 Dalvik 是如何描述字段和方法的,这也有助于我们阅读 smali 代码。


类型描述符

Dalvik 字节码中只有两种类型,基本类型和引用类型。除了对象和数组以外,其他的所有 Java 类型都是基本类型。这和 JVM 的类型描述符是基本一致的。基本类型都是使用单个字母来表示。数组类型使用 [ 表示。除数组以外的引用类型使用 L 加上全限定名表示。如下表所示:


类型描述符 类型
v void,只用于返回值类型
Z boolean
B byte
S short
C char
I int
J long
F float
D double
L 对象类型
[ 数组


基本类型都很简单,就不多说了,下面举一个引用类型的例子。例如 String 对象,其全限定名是 java/lang/String;,在 Dalvik 中就表示为 Ljava/lang/String;。对于数组,又可以分为基本类型数组和引用类型数组,其格式都是 [ 加上类型描述符。int[] 就是 [IString[] 就是 [java/lang/String;。多维数组就是多个 [,例如 int[][] 就是 [[I


字段

字段的表示统一用如下格式:

类型;->字段名称:类型描述符
复制代码


比如一个 com.test.Test 类中的一个 String 类型的 name 字段,在 Dalvik 中就可表示为:

Lcom/test/Test;->name:Ljava/lang/String
复制代码


方法

方法的描述和字段的描述有一些类似,区别在于方法多了一个返回值的描述,其基本格式如下:

类型;->方法名(参数类型描述符)返回值类型描述符
复制代码


com.test.Test 类中的 add() 方法为例,就是上面用到的两数相加的函数,其在 Dalvik 中描述为:

Lcom/test/Test;->add(II)I
复制代码


add(II) 中的两个 I 表示两个 int 类型参数,后面跟的一个 I 表示返回值类型是 int。


Dalvik 指令集


有了上面的知识储备之后,就可以具体的学习 Dalvik 指令集了。除了之前介绍过的官方文档和 AOSP 中关于 Dalvik 指令集的整理,我个人经常阅读的,还有一份国外开发者整理的 Dalvik Opcodes,都是很好的学习资料。我也会基于此版本整理一份完整的中文版 Dalvik 操作码,可能还需要一段时间才能整理出来,到时候会开源出来。


下面简单整理一下 Dalvik 指令集。


空指令

语法 说明
nop 空指令,通常用于对齐


数据操作指令

语法 说明
move vA, vB 将 vB(4 位) 寄存器的值赋给 vA(4 位) 寄存器
move/from vAA, vBBBB 将 vBBBB(16 位) 寄存器的值赋给 vAA(8 位) 寄存器
move-object vA, vB 将 vB(4 位) 寄存器存储的对象赋给 vA(4 位) 寄存器
move-object/from16 vAA, vBBBB 将 vBBBB(16 位) 寄存器存储的对象赋给 vAA(8 位) 寄存器
move-result vAA 将最新的 invoke-kind 的单字非对象结果移到指定的寄存器 vAA 中
move-result-wide vAA 将最新的 invoke-kind 的双字非对象结果移到指定的寄存器 vAA 中
move-result-object vAA 将最新的 invoke-kind 的对象结果移到指定的寄存器 vAA 中
move-exception 将刚刚捕获的异常保存到给定寄存器中


返回指令

语法 说明
return-void 返回 void
return-vAA 返回一个 32 位非对象类型
return-wide vAA 返回一个 64 位非对象类型
return-object vAA 返回一个对象类型


数据定义指令

语法 说明
const/4 vA, #+b 将给定的字面值(符号扩展为 32 位)移到指定的寄存器 vA 中
const vAA, #+BBBBBBBB 将给定的字面值移到指定的寄存器 vAA 中
const/high16 vAA, #+BBBB0000 将给定的字面值(右零扩展为 32 位)移到指定的寄存器 vAA 中
const-wide/16 vAA, #+BBBB 将给定的字面值(符号扩展为 64 位)移到指定的寄存器对 vAA 中
const-wide vAA, #+BBBBBBBBBBBBBBBB 将给定的字面值移到指定的寄存器对 vAA 中
const-string vAA, string@BBBB 将通过给定的索引获取的字符串引用移到指定的寄存器 vAA 中
const-class vAA, type@BBBB 将通过给定的索引获取的类引用移到指定的寄存器 vAA 中


锁指令

语法 说明
monitor-enter vAA 获取指定对象的互斥锁
monitor-exit vAA 释放指定对象的互斥锁


类型判断指令

语法 说明
check-cast vAA, type@BBBB 如果给定寄存器 vAA 中的引用不能转型为指定的类型,则抛出 ClassCastException
instance-of vA, vB, type@CCCC 如果指定的引用是给定类型的实例,则为给定目标寄存器赋值 1,否则赋值
new-instance vAA, type@BBBB 根据指定的类型构造新实例,并将对该新实例的引用存储到目标寄存器 vAA 中


数组操作指令

语法 说明
array-length vA, vB 获取寄存器 vB 中数组的长度,并存入寄存器 vA
new-array vA, vB, type@CCCC 构造指令类型(type@CCCC) 和指定大小(vB) 的数组,并赋给 寄存器 vA
filled-new-array {vC, vD, vE, vF, vG}, type@BBBB 构造指令类型(type@BBBB) 和指定大小的数组,并填充内容


异常指令

语法 说明
throw vAA 抛出 vAA 寄存器指定的异常


跳转指令

语法 说明
goto +AA 无条件跳转至指定偏移处,偏移量为 AA
if-test vA, vB, +CCCC 如果两个给定寄存器的值比较结果符合预期,则跳转到偏移量 CCCC 处


if-test一样,还有 if-eqif-neif-ltif-geif-gtif-leif-testzif-eqzif-nezif-ltzif-gezif-ltzif-gezif-gtzif-lez,这些指令格式都是一致的。


字段操作指令

字段操作指令分为两类,分别是对于普通字段和静态字段的操作。


普通字段
语法 说明
iinstanceop vA, vB, field@CCCC 对已标识的字段执行已确定的对象实例字段运算,并将结果加载或存储到值寄存器中


针对不同类型的普通字段,有如下命令:

iget、iget-wide、iget-object、iget-boolean、iget-byte、iget-char、iget-short
iput、iput-wide、iput-object、iput-boolean、iput-byte、iput-char、iput-short
复制代码


静态字段
语法 说明
sstaticop vAA, field@BBBB 对已标识的静态字段执行已确定的对象静态字段运算,并将结果加载或存储到值寄存器中


针对不同类型的静态字段,有如下命令:

sget、sget-wide、sget-object、sget-boolean、sget-byte、sget-char、sget-short
sput、sput-wide、sput-object、sput-boolean、sput-byte、sput-char、sput-short
复制代码


方法调用指令

方法调用指令的格式为 invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB, 具体的有如下指令:


语法 说明
invoke-virtual 调用正常的虚方法(该方法不是 private、static 或 final,也不是构造函数)
invoke-super 调用父类方法
invoke-direct 调用非 static 直接方法(也就是说,本质上不可覆盖的实例方法,即 private 实例方法或构造函数)
invoke-static 调用 static 方法
invoke-interface 调用实例的接口方法


数据运算和转换指令

数据运算指令和数据转换指令都比较简单,且数量很多,这里就不浪费篇幅来写出来了,感兴趣的同学可以查阅资料看一下。后续我也会开源一个完整版的 Dalvik 指令集的表格。


总结


本文介绍了 Dalvik 虚拟机的相关知识,比较了 Dalvik 虚拟机和 JVM,后续着重介绍了 Dalvik 指令集。看懂看会 Dalvik 指令对我们做逆向是很有帮助的,毕竟想要修改程序逻辑,大部分时间就是在和 smali 代码打交道。而 smali 代码就是基于 Dalvik 指令集的。如果你阅读过 smali 代码,应该会对上面提到的 Dalvik 指令很熟悉。



相关文章
|
6月前
|
Linux 编译器 Android开发
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
在Linux环境下,本文指导如何交叉编译x265的so库以适应Android。首先,需安装cmake和下载android-ndk-r21e。接着,下载x265源码,修改crosscompile.cmake的编译器设置。配置x265源码,使用指定的NDK路径,并在配置界面修改相关选项。随后,修改编译规则,编译并安装x265,调整pc描述文件并更新PKG_CONFIG_PATH。最后,修改FFmpeg配置脚本启用x265支持,编译安装FFmpeg,将生成的so文件导入Android工程,调整gradle配置以确保顺利运行。
232 1
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
|
6月前
|
Unix Linux Shell
FFmpeg开发笔记(八)Linux交叉编译Android的FFmpeg库
在Linux环境下交叉编译Android所需的FFmpeg so库,首先下载`android-ndk-r21e`,然后解压。接着,上传FFmpeg及相关库(如x264、freetype、lame)源码,修改相关sh文件,将`SYSTEM=windows-x86_64`改为`SYSTEM=linux-x86_64`并删除回车符。对x264的configure文件进行修改,然后编译x264。同样编译其他第三方库。设置环境变量`PKG_CONFIG_PATH`,最后在FFmpeg源码目录执行配置、编译和安装命令,生成的so文件复制到App工程指定目录。
341 9
FFmpeg开发笔记(八)Linux交叉编译Android的FFmpeg库
|
1月前
|
Web App开发 安全 程序员
FFmpeg开发笔记(五十五)寒冬里的安卓程序员可进阶修炼的几种姿势
多年的互联网寒冬在今年尤为凛冽,坚守安卓开发愈发不易。面对是否转行或学习新技术的迷茫,安卓程序员可从三个方向进阶:1)钻研谷歌新技术,如Kotlin、Flutter、Jetpack等;2)拓展新功能应用,掌握Socket、OpenGL、WebRTC等专业领域技能;3)结合其他行业,如汽车、游戏、安全等,拓宽职业道路。这三个方向各有学习难度和保饭碗指数,助你在安卓开发领域持续成长。
74 1
FFmpeg开发笔记(五十五)寒冬里的安卓程序员可进阶修炼的几种姿势
|
1月前
|
Linux API 开发工具
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
ijkplayer是由B站研发的移动端播放器,基于FFmpeg 3.4,支持Android和iOS。其源码托管于GitHub,截至2024年9月15日,获得了3.24万星标和0.81万分支,尽管已停止更新6年。本文档介绍了如何在Linux环境下编译ijkplayer的so库,以便在较新的开发环境中使用。首先需安装编译工具并调整/tmp分区大小,接着下载并安装Android SDK和NDK,最后下载ijkplayer源码并编译。详细步骤包括环境准备、工具安装及库编译等。更多FFmpeg开发知识可参考相关书籍。
85 0
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
|
3月前
|
JavaScript 前端开发 Java
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
IT寒冬使APP开发门槛提升,安卓程序员需转型。选项包括:深化Android开发,跟进Google新技术如Kotlin、Jetpack、Flutter及Compose;研究Android底层框架,掌握AOSP;转型Java后端开发,学习Spring Boot等框架;拓展大前端技能,掌握JavaScript、Node.js、Vue.js及特定框架如微信小程序、HarmonyOS;或转向C/C++底层开发,通过音视频项目如FFmpeg积累经验。每条路径都有相应的书籍和技术栈推荐,助你顺利过渡。
72 3
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
|
3月前
|
编解码 安全 Ubuntu
Android Selinux 问题处理笔记
这篇文章是关于处理Android系统中SELinux权限问题的笔记,介绍了如何通过分析SELinux拒绝的日志、修改SELinux策略文件,并重新编译部署来解决权限问题,同时提供了一些SELinux的背景知识和实用工具。
79 0
|
6月前
|
安全 Linux Android开发
FFmpeg开发笔记(十六)Linux交叉编译Android的OpenSSL库
该文介绍了如何在Linux服务器上交叉编译Android的FFmpeg库以支持HTTPS视频播放。首先,从GitHub下载openssl源码,解压后通过编译脚本`build_openssl.sh`生成64位静态库。接着,更新环境变量加载openssl,并编辑FFmpeg配置脚本`config_ffmpeg_openssl.sh`启用openssl支持。然后,编译安装FFmpeg。最后,将编译好的库文件导入App工程的相应目录,修改视频链接为HTTPS,App即可播放HTTPS在线视频。
114 3
FFmpeg开发笔记(十六)Linux交叉编译Android的OpenSSL库
|
5月前
|
Java API Android开发
技术经验分享:Android源码笔记——Camera系统架构
技术经验分享:Android源码笔记——Camera系统架构
59 0
|
6月前
|
Java 测试技术 开发工具
Android 笔记:AndroidTrain , Lint , build(1),只需一篇文章吃透Android多线程技术
Android 笔记:AndroidTrain , Lint , build(1),只需一篇文章吃透Android多线程技术
|
6月前
|
设计模式 缓存 前端开发
真的强!借助阿里技术博主分享的Android面试笔记,我拿到了字节跳动的offer
真的强!借助阿里技术博主分享的Android面试笔记,我拿到了字节跳动的offer