一文读懂 DEX 文件格式解析
曾有人问我,为什么要去干解析 dex 文件这种麻烦的事?
我想说的是写个解析脚本不是为了模仿着 apktools 造轮子,而是在解析过程中寻找逆向的道路,方法会变,工具会变,但一切都建立在 dex 上的安卓不会变
一、什么是 Dex 文件
dex 文件是 Android 平台上可执行文件的一种文件类型。它的文件格式可以下面这张图概括:
二、文件头解析
1、文件头简介
dex 文件头一般固定为 0x70 个字节大小,包含标志、版本号、校验码、sha-1 签名以及其他一些方法、类的数量和偏移地址等信息。如下图所示:
2、dex 文件头各字段解析
dex 文件头包含以下各个字段:
magic: 包含了 dex 文件标识符以及版本,从 0x00 开始,长度为 8 个字节
checksum: dex 文件校验码,偏移量为: 0x08,长度为 4 个字节。
signature: dex sha-1 签名,偏移量为 0x0c, 长度为 20 个字节
file_szie: dex 文件大小,偏移量为 0x20,长度为 4 个字节
header_size: dex 文件头大小,偏移量为 0x24,长度为 4 个字节,一般为 0x70
endian_tag: dex 文件判断字节序是否交换,偏移量为 0x28,长度为 4 个字节,一般情况下为 0x78563412
link_size: dex 文件链接段大小,为 0 则表示为静态链接,偏移量为 0x2c,长度为 4 个字节
link_off: dex 文件链接段偏移位置,偏移量为 0x30,长度为 4 个字节
map_off: dex 文件中 map 数据段偏移位置,偏移位置为 0x34,长度为 4 个字节
string_ids_size: dex 文件包含的字符串数量,偏移量为 0x38,长度为 4 个字节
string_ids_off: dex 文件字符串开始偏移位置,偏移量为 0x3c,长度为 4 个字节
type_ids_size: dex 文件类数量,偏移量为 0x40,长度为 4 个字节
type_ids_off: dex 文件类偏移位置,偏移量为 0x44,长度为 4 个字节
photo_ids_size: dex 文件中方法原型数量,偏移量为 0x48,长度为 4 个字节
photo_ids_off: dex 文件中方法原型偏移位置,偏移量为 0x4c,长度为 4 个字节
field_ids_size: dex 文件中字段数量,偏移量为 0x50,长度为 4 个字节
field_ids_off: dex 文件中字段偏移位置,偏移量为 0x54,长度为 4 个字节
method_ids_size: dex 文件中方法数量,偏移量为 0x58,长度为 4 个字节
method_ids_off: dex 文件中方法偏移位置,偏移量为 0x5c,长度为 4 个字节
class_defs_size: dex 文件中类定义数量,偏移量为 0x60,长度为 4 个字节
class_defs_off: dex 文件中类定义偏移位置,偏移量为 0x64,长度为 4 个字节
data_size: dex 数据段大小,偏移量为 0x68,长度为 4 个字节
data_off: dex 数据段偏移位置,偏移量为 0x6c,长度为 4 个字节
3、dex 文件头代码解析示例 (python)
dex 使用 open 函数以二进制打开文件,然后使用 seek 函数移动文件指针,例如 magic 就是f.seek(0x00)
,然后读取相应信息的字节数即可,例如读取版本号f.seek(0x04) f.read(4)
,然后做相应打印操作就行,dex 文件头较简单,不涉及编码等,所以解析起来感觉脑子都不用带。。。。。具体代码可以看下面或者 github,下面附上代码运行图:
4、dex 文件头解析实现代码(python 实现)
ps:只截取关键代码,完整代码请参考文末 github 链接或网盘链接
三、checksum (校验和)解析
1、checksum 介绍
checksum(校验和)是 DEX 位于文件头部的一个信息,用来判断 DEX 文件是否损坏或者被篡改,它位于头部的0x08
偏移地址处,占用 4 个字节,采用小端序存储。
在 DEX 文件中,采用Adler-32
校验算法计算出校验和,将 DEX 文件从0x0C
处开始读取到文件结束,将读取到的字节数组使用Adler-32 校验算法
计算出结果即是校验和即 checksum 字段!!!
2、Adler-32 算法
Adler-32
算法如下步骤实现:
a、定义两个变量varA
、varB
,其中varA
初始化为1,varB
初始化为0。
b、 读取字节数组的一个字节(假设该字节变量名为byte
),计算varA = (varA + byte) mod 65521
,然后可以计算出varB = (varA + varB) mod 65521
。
c. 重复步骤,直到字节数组全部读取完毕,得到最终varA
、varB
两个变量的结果。
d. 根据第三步得到的varA
、varB
两个变量,可得到最终校验和checksum =(varB << 16)+ varA
。
下面是官方 WIKI 给的例子:
3、python 实现 Adler-32 算法
先给出 Dex 文件头部信息以及代码跑出的结果
python 代码实现如下(python 3.6 版本):
四、字符串解析
1、DEX 文件中的字符串
a、DEX 文件大致上可以粗略的分为 3 个部分:文件头、索引区以及数据区。而文件头一般来说占了整个 DEX 文件 0x70 个字节(还不了解 DEX 文件头的可以看一下我前面两篇文章),在文件头中,关于字符串的相关信息一共有 8 个字节,分别位于 0x38(4 Bytes) 和 0x3c(4 Bytes) 处,前者说明了该 DEX 文件包含了多少个字符串,后者则是字符串索引区的起始地址,但是需要注意的是,DEX 存储是以小端序存储的(通俗一点的说就是从后往前读),如下所示:
b、前面我们通过文件头知道了字符串数量和字符串索引区起始地址等信息,接下来我们就来具体看一下字符串索引区。字符串索引区存储的是字符串真正存储在数据区的偏移地址,以 4 个字节为一组,表示一个字符串在数据区的偏移地址,所以索引区一个占字符串数量 X 4
个字节那么多,同样的,索引区也采用的是小端序存储,所以我们在读取地址时,需要与小端序的方式来读取真正的地址,如下所示:
c、从上面我们已经知道了如何找到字符串在数据区的偏移地址,接下来我们需要做的就是解析这些数据区的字节。通过偏移地址我们可以在数据区找到代表字符串的这些字节,在 DEX 文件中,字符串是通过MUTF-8
编码而成的(至于 mutf-8 是什么编码,我会将一些相关博客链接贴在文末),在MUTF-8
编码中,第一个字节代表了这个字符串所需要用到的字节数目(不包括最后一个代表终结的字节),最后一个字节为0x00
,表示这个字符串到此结束,跟 c 语言有点类似,中间部分才是一个字符串的具体内容,如下所示:(PS:mutf-8
第一个字节还经过uleb128
编码,所以简单的进行进制换算得到的字节数很多人奇怪对不上,由于比较复杂,就不过多解释了,想进一步了解更深的可以去看一下安卓源码中对 DEX 文件解析出字符串这一部分)
2、解析代码:
PS:我电脑运行环境-- python3.6
代码关键截图如下:
运行截图:
五、类的类型解析
1、DEX 文件中的类的类型
a、Dex 文件中关于类的类型需要知道字符串是怎么解析的,如果不知道的,可以看一下前面部分。好了,切入正题,关于类的类型,就是一个对象的所属的类(大概这么理解吧。。。),例如在 java 中一个字符串,它的类型就是java/lang/String
。在 Dex 文件头中,跟类的类型有关的一共有八个字节,分别是位于0x40
处占四个字节表示类的类型的数量和位于0x44
处占四个字节表示类的类型索引值的起始偏移地址,如下所示:
b、关于类的类型数量,没什么好说的,只需要注意它是以小端序存储的,读取的时候注意即可。对于类的类型偏移地址,找到偏移地址后,它是以四个字节为一组,对应了在解析出来的字符串数组中的索引值,例如下图中的第一组,它的数据是BE 04 00 00
,我们读取出来就是0x04BE
(同样采用的小端序存储),对应的类的类型就是字符串数组 [0x04be]
。
2、解析脚本
PS:我电脑上脚本运行环境 python3.6
运行效果:
代码关键截图如下:
六、方法原型解析
1、DEX 文件中的方法原型
a、关于 dex 文件中方法原型的解析,需要知道怎么解析出字符串和类的类型,不明白的可以看前面解析。DEX 文件中的方法原型定义了一个方法的返回值类型和参数类型,例如一个方法返回值为void
,参数类型为int
,那么在 dex 文件中该方法原型表示为V(I)
(smali
中V
表示void
,I
表示int
)。在 dex 文件头部中,关于方法原型有两处,第一处位于0x48
处,用 4 个字节定义了方法原型的数量,在0x4C
处用 4 个字节定义了方法原型的偏移地址,如下所示:
b、在上面我们知道了方法原型的起始偏移地址,接下来我们根据这个偏移地址找到方法原型,同样的,跟解析类的类型比较类似,一个方法原型所占字节数为 12 个字节,第一个字节到第四个字节表示了定义方法原型的字符串,这四个字节按小端序存储,读取出来为在字符串列表的索引,例如一个方法原型返回值为void
,参数为boolean
,那么定义该方法原型的字符串即为VZ
;第 5 个字节到第八个字节表示该方法原型的返回值类型,读取出来的值为前面解析出来的类的类型列表的索引;第 8 个字节到第十二给字节表示该方法原型的参数,读取出来为一组地址,通过该地址可以找到该方法原型的参数,跳转到该地址去,首先看前 4 个字节,前四个字节按照小端序存储,读取出来的值为该方法原型参数的个数,接着根据参数个数,读取具体的参数类型,每个参数类型占 2 个字节,这两个字节读取出来的值为前面解析出来的类的类型列表的索引,如下所示:
2、解析代码
运行环境:我电脑环境为 python3.6
运行截图:
解析代码关键截图:
七、字段解析
1、dex 文件中的字段
a、在 dex 文件头中,关于字段(ps:字段可以简单理解成定义的变量或者常量)相关的信息有 8 个字节,在0x50~0x53
这四个字节,按小端序存储这 dex 文件中的字段数量,在0x54~0x57
这四个字节,存储这读取字段的起始偏移地址,如下所示:
b、根据上面的字段起始偏移地址,我们可以找到字段,表示一个字段需要用八个字节,其中,前两个字节为我们在前面解析出来类的类型列表的索引,通过该索引找到的类的类型表示该字段在该类中被定义的(ps:我是这么理解的,如有不对,还请纠正);第三个字节和第四个字节,也是类的类型列表的索引,表示该字段的类型,例如我们在 java 某个类中定义了一个变量int a
,那么我们此处解析出来的字段类型就是int
;最后四个字节,则是我们前面解析出来字符串列表的索引,通过该索引找到的字符串表示字段的,例如我们定义了一个变量String test;
,那么我们在这里解析出来的就是test
,如下图所示:
2、解析代码
解析代码运行截图:
解析代码关键截图:
八、方法定义解析
1、Dex 文件中的方法定义
a、在 dex 文件头中,关于方法定义的信息同样是八个字节,分别位于0x58
处和0x5c
处。在0x58
处的四个字节,指明了 dex 文件中方法定义的数量,在0x5c
处的四个字节,表明了 dex 文件中的方法定义的起始地址(ps:都是以小端序存储的),如下图所示:
b、在上面的一步以及找到了方法定义的起始地址,跟字段类似的,一个方法定义也需要八个字节。其中,在前两个字节,以小端序存储着解析出来的类的类型列表的索引,表示该方法属于哪个类;第三个字节和第四个字节,以小端序存储这解析出来的方法原型列表的索引,通过该索引值找到的方法原型声明了该方法的返回值类型和参数类型;最后四个字节则以小端序存储着前面解析出来的字符串列表的索引,声明了该方法的方法名。如下图所示:
2、解析代码
解析代码运行截图:
解析代码关键截图:
九、类解析
PS:Dex 文件解析到现在,终于到了最重要也是结构最复杂的部分了,这里分析的 dex 样本来自一个复杂 apk 的 dex 文件,但是代码运行时使用的样本是一个在网上找的很简单的 dex 样本,原因很简单,分析使用的 dex 涉及的 smali 指令太多了,大概有 200 多个,挨个解析起来工作量太大了,有时间我会写一个通用的 python 解析模块,完成了我会上传到 github 仓库,有兴趣的完成后可以看一下,用简单的 dex 只涉及到 5 个指令,代码写起来就没那么麻烦了!!!(tips:Dex 类数据这里解析起来有种俄罗斯套娃的感觉,多看几篇就很容易理解了。)
1、uleb128 编码
PS:本来关于 uleb128 编码网上一大堆,没必要写这个,但是网上的你抄我的我抄你的,能找的的相关资料基本都一样。。。。或者干脆贴个官方代码,官方代码的位运算写的很巧妙,但是直接去看的化,反正我是没看懂到底是怎么解码出来的。
uleb128 编码,是一种可变长度的编码,长度大小为1-5字节
,uleb128 通过字节的最高位来决定是否用到下一个字节,如果最高位为 1,则用到下一个字节,直到某个字节最高位为 0 或已经读取了 5 个字节为止,接下来通过一个实例来理解 uleb128 编码。
假设有以下经过 uleb128 编码的数据(都为 16 进制)--81 80 04
,首先来看第一个字节81
,他的二进制为10000001
,他的最高位为1
,则说明还要用到下一个字节,它存放的数据则为0000001
;再来看第二个字节80
,它的二进制为10000000
,它的最高位为1
,则说明还需要用到第三个字节,存放的数据为0000000
;再来看第三个字节04
,它的二进制为00000100
,最高位为0
,说明一共使用了三个字节,它存放的数据为0000100
;通过上面的数据我们已经获取了存放的数据,接下来就是把这些 bit 组合起来获取解码后的数据,dex 文件里面的数据都是采用的小端序的方式,uleb128 也不例外,在这三个字节,也不例外,第三个字节04
存放的数据0000100
作为解码后的数据的高 7 位
,第二个字节80
存放的数据0000000
作为解码后的数据的中 7 位
,第一个字节81
存放的数据0000001
作为解码后的数据的低 7 位
;那么解码后的数据二进制则为0000100 0000000 0000001
,转换为 16 进制则为0x10001
。其他使用 5 个字节、4 个字节照此类推即可,下面是 python 读取 uleb128 的代码(ps:该代码是最终类数据解析代码的一共函数,无法单独运行,仅供参考,采用的是官方提供的位运算算法):
2、类解析第一层结构:class_def_item
a、在 dex 文件头0x60-0x63
这四个字节,指明了class
的数量,在0x64-0x67
这四个字节,指明的class_def_item
的偏移地址。如下所示:
b、通过上面的偏移地址,我们可以找到 class_def_item 的起始地址,class_def_item 包含了一个类的类名、接口、父类、所属 java 文件名等信息。一个 class_def_item 结构大小为 32 字节,分别包含 8 个信息,每个信息大小为 4 字节(小端序存储):
第 1-4 字节 -- class_idx
(该值为前面解析出来的类的类型列表的索引,也就是这个类的类名);
第 5-8 字节 -- access_flags
(类的访问标志,也就是这个类是 public 还是 private 等,这个通过官方的文档查表得知,具体算法在最后面说明);
第 9-12 字节 -- superclass_idx
(该值也为前面解析出来的类的类型列表的索引,指明了父类的类名)
第 13-16 字节 -- interfaces_off
(该值指明了接口信息的偏移地址,所指向的地址结构为 typelist,前面的文章有说过,这里不再多说,如果该类没有接口,该值则为 0)
第 17-20 字节 -- source_file_idx
(该值为 dex 字符串列表的的索引,指明了该类所在的 java 文件名)
第 21-24 字节 -- annotations_off
(该值为注释信息的偏移地址,由于注释信息不是我要解析的重点,要查看注释信息具体结构的可以参考官方文档,官方文档地址粘贴在文末)
第 25-28 字节 -- class_data_off
(该值是这个类数据第二层结构的偏移地址,在该结构中指明了该类的字段和方法)
第 29-32 字节 -- static_value_off
(该值也是一个偏移地址,指向了一个结构,不是重点,感兴趣的参考官方文档,如果没相关信息,则该值为 0)
具体分析过程,如下图所示:
3、类解析第二层结构:class_data_item
a、通过上面 class_def_item 的分析,我们知道了类的基本信息,例如类名、父类等啊,接下来就是要找到类里面的字段和方法这些信息,而这些信息,在 class_def_item 里面的 class_data_off 字段给我们指明class_data_item
就包含这些信息并给出了偏移地址,即现在需要解析class_data_iem
结构获取字段和方法信息。(ps:以下的数据结构不做特别说明都为 uleb128 编码格式)
b、class_data_item
结构包含以下信息:
第一个uleb128编码--static_field_size
,指明了该类的静态字段的数量
第二个uleb128编码--instance_field_size
,指明了该类的实例字段的数量(实例字段不知道是啥的建议百度)
第三个uleb128编码--direct_method_size
,指明了该类的直接方法的个数
第四个uleb128编码--virtual_method_size
,指明了该类的虚方法的个数(虚方法理解不清楚的建议百度一下)
encoded_field--static_fields
,该结构指明了具体的静态字段信息,该结构的存在前提是static_field_size >0
,该结构包含两个 uleb128 编码,第一个 uleb128 编码为前面解析出来的字段列表的索引,第二个 uleb128 编码指明了该字段的访问标志
encoded_field--instance_fields
,跟上面类似,不再多说,值得注意的是,该结构存在的前提是instance_field_size > 0
encoded_method--direct_methods
,该结构指明了直接方法具体信息,该结构存在的前提同样是direct_method_size > 0
,该结构包含 3 个 uleb128 编码,第一个 uleb128 为前面文章解析出来的方法原型列表的索引值,第二个 uleb128 编码为该方法的访问标志,第三个 uleb128 为 code_off,也就是该方法具体代码的字节码的偏移地址,对应的结构为 code_item,code_item 结构里面包含了该方法内部的代码,这里是字节码,也就是 smali(ps: 如果该方法为抽象方法,例如 native 方法,这时 code_off 对应的值为 0,即该方法不存在具体代码)
encoded_method--virtual_methods
,该结构指明了该类的虚方法的具体信息,存在前提为virtual_method_size > 0
,具体结构和上面一样,不再多说
具体分析过程,如下图所示:
4、类解析的第三层结构:code_item
a、在上面的 class_data_item 结构中的encoded_method
结构的第三个 uleb128 编码中,指出了一个类中的方法具体代码的偏移地址,也就是 dv 虚拟机在执行该方法的具体指令的偏移地址,该值指向的地址结构为code_item
,里面包含了寄存器数量、具体指令等信息,下面来分析一下该结构。
b、code_item
结构包含以下信息:
第 1-2 字节 -- registers_size
,该值指明了该方法使用的寄存器数量,对应的 smali 语法中的.register
的值
第 3-4 字节 -- ins_size
,该值指明了传入参数的个数
第 5-6 字节 -- outs_size
,该值指明了该方法内部调用其他函数用到的寄存器个数
第 7-8 字节 -- tries_size
,该值指明了该方法用到的try-catch
语句的个数
第 9-12 字节 -- debug_info_off
,该值指明了调试信息结构的偏移地址,如果不存在调试信息,则该值为 0
第 13-16 字节 -- insns_size
,该值指明了指令列表的大小,可以这么理解:规定了指令所用的字节数大小--2 x insns_size
ushort[insns_size] -- insns
,这个是指令列表,包含了该方法所用到的指令的字节,每个指令占用的字节数可以参考官方文档,这个没什么算法,就是一个查表的过程,例如invoke-direct
指令占用 6 个字节,return-void
指令占用 2 个字节
2 个字节 -- padding
,该值存在的前提是tries-size > 0
,作用用来对齐代码
try_item--tries
,该值存在的前提是tries-size > 0
,作用是指明异常具体位置和处理方式,该结构不是解析重点,重点是解析指令,感兴趣的查看官方文档
encoded_catch_handler_list--handlers
, 该结构存在前提为tries-size > 0
,同样不是解析重点,感兴趣的查看官方文档
具体分析过程,如下图所示:
5、access_flags算法
access_flags 访问标志具体值可以去查看官方文档,下图只截了一部分。如果 access_flags 的算法为access_flags = flag1 | flag2 | ...
,如果访问标志只有一共,直接查表即可,如果是两个,按照算法对比值即可,下面举个例子来理解该算法。
例如我有一个类的访问标志为public static
,经过查表得知public
对应的值为0x01
,static
对应的值为0x8
,那么public static
对应的访问标志为0x01 | 0x08 = 0x9
,如果读取出来的 access_flags 为 0x09,那么对应的访问标志则为public static
,其余的照此算法计算即可!!!
6、解析代码
PS:代码运行环境推荐 3.6 及其以上,需要模块binascii
,运行样本为Hello.dex
,样本附在文末网盘链接中!!!
运行截图
通过脚本解析出来的和通过 apktools 反编译出来的 smali 文件对比图
(ps:左侧为 apktools 反编译出来的,右侧为脚本解析出来的,可以发现基本差不多)
解析代码关键截图:
十、一些总结
有人可能会问没事做这么多复杂的工作干啥,不是有 apktools 可以直接反编译就完了嘛,但我想说的是,做这么做解析工作不是想替换 apktools,而是想在这个解析过程中弄明白 dex 文件格式,只有先理解了这些文件的格式,才能写一下加固方案和分析一些可能市面上的一些小众的壳。例如 java 层指令抽取壳,就是在最后的方法指令上做保护。逆向方法会变,工具会变,但这些最根本的东西,不会变,理解这些,是一名搞安卓安全的必备知识吧!!!
十一、参考资料和样本代码地址
参考资料:
1、Android 逆向之旅—解析编译之后的 Dex 文件格式:
http://www.520monkey.com/archives/579
2、一篇文章带你搞懂 DEX 文件的结构:
https://blog.csdn.net/sinat_18268881/article/details/55832757
3、官方文档:
https://source.android.google.cn/devices/tech/dalvik/dex-format#embedded-in-class_def_item,-encoded_field,-encoded_method,-and-innerclass
样本及代码下载:
github 链接:
https://github.com/windy-purple/parserDex
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- python在linux环境下安装skimage
- python3异步爬虫 ——aiohttp模板使用
- [PHP] 抽象工厂设计模式-创建型设计模式
- [javascript] 支付宝小程序网络GET请求
- [PHP] 生成器模式-创建型设计模式
- [PHP] 工厂方法设计模式-创建型设计模式
- [PHP] 对象池模式-创建型设计模式
- [PHP] 原型模式-创建型设计模式
- [PHP] 简单工厂模式-创建型设计模式
- [PHP] 单例模式-创建型设计模式
- [PHP] 静态工厂模式-创建型设计模式
- [PHP] 适配器模式-结构型设计模式
- [PHP] 使用curl扩展POST或者PUT时数据不全和连接中断的排查
- [PHP] 桥接模式-结构型设计模式
- [PHP] 组合模式-结构型设计模式