系列第一篇文章就分析过 Class 文件格式,我们都知道 .java
源文件经过编译器编译会生成 JVM 可识别的 .class
文件。在 Android 中,不管是 Dalvik 还是 Art,和 JVM 的区别还是很大的。Android 系统并不直接使用 Class 文件,而是将所有的 Class 文件聚合打包成 DEX 文件,DEX 文件相比单个单个的 Class 文件更加紧凑,可以直接在 Android Runtime 下执行。
对于学习热修复框架,加固和逆向相关知识,了解 DEX 文件结构是很有必要的。再之前解析过 Class 文件和 AndroidManifest.xml 文件结构之后,发现看二进制文件看上瘾了。。后面会继续对 Apk 文件中的其他文件结构进行分析,例如 so 文件,resources.arsc 文件等。
DEX 文件的生成
在解析 DEX 文件结构之前,先来看看如何生成 DEX 文件。为了方便解析,本篇文章中就不从市场上的 App 里拿 DEX 文件过来解析了,而是手动生成一个最简单的 DEX 文件。还是以 Class 文件解析时候用的例子:
public class Hello { private static String HELLO_WORLD = "Hello World!"; public static void main(String[] args) { System.out.println(HELLO_WORLD); } } 复制代码
首先 javac
编译成 Hello.class
文件,然后利用 Sdk 自带的 dx
工具生成 DEX 文件:
dx --dex --output=Hello.dex Hello.class 复制代码
dx 工具位于 Sdk 的 build-tools 目录下,可添加至环境变量方便调用。dx 也支持多 Class 文件生成 dex。
DEX 文件结构
概览
关于 DEX 文件结构的学习,给大家推荐两个资料。
第一个是看雪神图,出自非虫,
第二个是 Android 源码中对 DEX 文件格式的定义,dalvik/libdex/DexFile.h,其中详细定义了 DEX 文件中的各个部分。
第三个是 010 Editor
,在之前解析 AndroidManifest.xml 文件格式解析 也介绍过,它提供了丰富的文件模板,支持常见文件格式的解析,可以很方便的查看文件结构中的各个部分及其对应的十六进制。一般我在代码解析文件结构的时候都是对照着 010 Editor 来进行分析。下面贴一张 010 Editor 打开之前生成的 Hello.dex 文件的截图:
我们可以一目了然的看到 DEX 的文件结构,着实是一个利器。在详细解析之前,我们先来大概给 DEX 文件分个层,如下图所示:
文末我放了一张详细的思维导图,也可以对着思维导图来阅读文章。
依次解释一下:
header :
DEX 文件头,记录了一些当前文件的信息以及其他数据结构在文件中的偏移量string_ids :
字符串的偏移量type_ids :
类型信息的偏移量proto_ids :
方法声明的偏移量field_ids :
字段信息的偏移量method_ids :
方法信息(所在类,方法声明以及方法名)的偏移量class_def :
类信息的偏移量data :
: 数据区link_data :
静态链接数据区
从 header
到 data
之间都是偏移量数组,并不存储真实数据,所有数据都存在 data
数据区,根据其偏移量区查找。对 DEX 文件有了一个大概的认识之后,我们就来详细分析一下各个部分。
header
DEX 文件头部分的具体格式可以参考 DexFile.h 中的定义:
struct DexHeader { u1 magic[8]; // 魔数 u4 checksum; // adler 校验值 u1 signature[kSHA1DigestLen]; // sha1 校验值 u4 fileSize; // DEX 文件大小 u4 headerSize; // DEX 文件头大小 u4 endianTag; // 字节序 u4 linkSize; // 链接段大小 u4 linkOff; // 链接段的偏移量 u4 mapOff; // DexMapList 偏移量 u4 stringIdsSize; // DexStringId 个数 u4 stringIdsOff; // DexStringId 偏移量 u4 typeIdsSize; // DexTypeId 个数 u4 typeIdsOff; // DexTypeId 偏移量 u4 protoIdsSize; // DexProtoId 个数 u4 protoIdsOff; // DexProtoId 偏移量 u4 fieldIdsSize; // DexFieldId 个数 u4 fieldIdsOff; // DexFieldId 偏移量 u4 methodIdsSize; // DexMethodId 个数 u4 methodIdsOff; // DexMethodId 偏移量 u4 classDefsSize; // DexCLassDef 个数 u4 classDefsOff; // DexClassDef 偏移量 u4 dataSize; // 数据段大小 u4 dataOff; // 数据段偏移量 }; 复制代码
其中的 u
表示无符号数,u1
就是 8 位无符号数,u4
就是 32 位无符号数。
magic
一般是常量,用来标记 DEX 文件,它可以分解为:
文件标识 dex + 换行符 + DEX 版本 + 0 复制代码
字符串格式为 dex\n035\0
,十六进制为 0x6465780A30333500
。
checksum
是对去除 magic
、 checksum
以外的文件部分作 alder32 算法得到的校验值,用于判断 DEX 文件是否被篡改。
signature
是对除去 magic
、 checksum
、 signature
以外的文件部分作 sha1 得到的文件哈希值。
endianTag
用于标记 DEX 文件是大端表示还是小端表示。由于 DEX 文件是运行在 Android 系统中的,所以一般都是小端表示,这个值也是恒定值 0x12345678
。
其余部分分别标记了 DEX 文件中其他各个数据结构的个数和其在数据区的偏移量。根据偏移量我们就可以轻松的获得各个数据结构的内容。下面顺着上面的 DEX 文件结构来认识第一个数据结构 string_ids
。
string_ids
struct DexStringId { u4 stringDataOff; }; 复制代码
string_ids
是一个偏移量数组,stringDataOff
表示每个字符串在 data 区的偏移量。根据偏移量在 data 区拿到的数据中,第一个字节表示的是字符串长度,后面跟着的才是字符串数据。这块逻辑比较简单,直接看一下代码:
private void parseDexString() { log("\nparse DexString"); try { int stringIdsSize = dex.getDexHeader().string_ids__size; for (int i = 0; i < stringIdsSize; i++) { int string_data_off = reader.readInt(); byte size = dexData[string_data_off]; // 第一个字节表示该字符串的长度,之后是字符串内容 String string_data = new String(Utils.copy(dexData, string_data_off + 1, size)); DexString string = new DexString(string_data_off, string_data); dexStrings.add(string); log("string[%d] data: %s", i, string.string_data); } } catch (IOException e) { e.printStackTrace(); } } 复制代码
打印结果如下:
parse DexString string[0] data: <clinit> string[1] data: <init> string[2] data: HELLO_WORLD string[3] data: Hello World! string[4] data: Hello.java string[5] data: LHello; string[6] data: Ljava/io/PrintStream; string[7] data: Ljava/lang/Object; string[8] data: Ljava/lang/String; string[9] data: Ljava/lang/System; string[10] data: V string[11] data: VL string[12] data: [Ljava/lang/String; string[13] data: main string[14] data: out string[15] data: println 复制代码
其中包含了变量名,方法名,文件名等等,这个字符串池在后面其他结构的解析中也会经常遇到。
type_ids
struct DexTypeId { u4 descriptorIdx; }; 复制代码
type_ids
表示的是类型信息,descriptorIdx
指向 string_ids
中元素。根据索引直接在上一步读取到的字符串池即可解析对应的类型信息,代码如下:
private void parseDexType() { log("\nparse DexTypeId"); try { int typeIdsSize = dex.getDexHeader().type_ids__size; for (int i = 0; i < typeIdsSize; i++) { int descriptor_idx = reader.readInt(); DexTypeId dexTypeId = new DexTypeId(descriptor_idx, dexStringIds.get(descriptor_idx).string_data); dexTypeIds.add(dexTypeId); log("type[%d] data: %s", i, dexTypeId.string_data); } } catch (IOException e) { e.printStackTrace(); } } 复制代码
解析结果:
parse DexType type[0] data: LHello; type[1] data: Ljava/io/PrintStream; type[2] data: Ljava/lang/Object; type[3] data: Ljava/lang/String; type[4] data: Ljava/lang/System; type[5] data: V type[6] data: [Ljava/lang/String; 复制代码
proto_ids
struct DexProtoId { u4 shortyIdx; /* index into stringIds for shorty descriptor */ u4 returnTypeIdx; /* index into typeIds list for return type */ u4 parametersOff; /* file offset to type_list for parameter types */ }; 复制代码
proto_ids
表示方法声明信息,它包含以下三个变量:
- shortyIdx : 指向 string_ids ,表示方法声明的字符串
- returnTypeIdx : 指向 type_ids ,表示方法的返回类型
- parametersOff : 方法参数列表的偏移量
方法参数列表的数据结构在 DexFile.h 中用 DexTypeList
来表示:
struct DexTypeList { u4 size; /* #of entries in list */ DexTypeItem list[1]; /* entries */ }; struct DexTypeItem { u2 typeIdx; /* index into typeIds */ }; 复制代码
size
表示方法参数的个数,参数用 DexTypeItem
表示,它只有一个属性 typeIdx
,指向 type_ids
中对应项。具体的解析代码如下:
private void parseDexProto() { log("\nparse DexProto"); try { int protoIdsSize = dex.getDexHeader().proto_ids__size; for (int i = 0; i < protoIdsSize; i++) { int shorty_idx = reader.readInt(); int return_type_idx = reader.readInt(); int parameters_off = reader.readInt(); DexProtoId dexProtoId = new DexProtoId(shorty_idx, return_type_idx, parameters_off); log("proto[%d]: %s %s %d", i, dexStringIds.get(shorty_idx).string_data, dexTypeIds.get(return_type_idx).string_data, parameters_off); if (parameters_off > 0) { parseDexProtoParameters(parameters_off); } dexProtos.add(dexProtoId); } } catch (IOException e) { e.printStackTrace(); } } 复制代码
解析结果:
parse DexProto proto[0]: V V 0 proto[1]: VL V 412 parameters[0]: Ljava/lang/String; proto[2]: VL V 420 parameters[0]: [Ljava/lang/String; 复制代码
field_ids
struct DexFieldId { u2 classIdx; /* index into typeIds list for defining class */ u2 typeIdx; /* index into typeIds for field type */ u4 nameIdx; /* index into stringIds for field name */ }; 复制代码
field_ids
表示的是字段信息,指明了字段所在的类,字段的类型以及字段名称,在 DexFile.h
中定义为 DexFieldId
, 其各个字段含义如下:
- classIdx : 指向 type_ids ,表示字段所在类的信息
- typeIdx : 指向 ype_ids ,表示字段的类型信息
- nameIdx : 指向 string_ids ,表示字段名称
代码解析很简单,就不贴出来了,直接看一下解析结果:
parse DexField field[0]: LHello;->HELLO_WORLD;Ljava/lang/String; field[1]: Ljava/lang/System;->out;Ljava/io/PrintStream; 复制代码
method_ids
struct DexMethodId { u2 classIdx; /* index into typeIds list for defining class */ u2 protoIdx; /* index into protoIds for method prototype */ u4 nameIdx; /* index into stringIds for method name */ }; 复制代码
method_ids
指明了方法所在的类、方法声明以及方法名。在 DexFile.h 中用 DexMethodId
表示该项,其属性含义如下:
- classIdx : 指向 type_ids ,表示类的类型
- protoIdx : 指向 type_ids ,表示方法声明
- nameIdx : 指向 string_ids ,表示方法名
解析结果:
parse DexMethod method[0]: LHello; proto[0] <clinit> method[1]: LHello; proto[0] <init> method[2]: LHello; proto[2] main method[3]: Ljava/io/PrintStream; proto[1] println method[4]: Ljava/lang/Object; proto[0] <init> 复制代码
class_def
struct DexClassDef { u4 classIdx; /* index into typeIds for this class */ u4 accessFlags; u4 superclassIdx; /* index into typeIds for superclass */ u4 interfacesOff; /* file offset to DexTypeList */ u4 sourceFileIdx; /* index into stringIds for source file name */ u4 annotationsOff; /* file offset to annotations_directory_item */ u4 classDataOff; /* file offset to class_data_item */ u4 staticValuesOff; /* file offset to DexEncodedArray */ }; 复制代码
class_def
是 DEX 文件结构中最复杂也是最核心的部分,它表示了类的所有信息,对应 DexFile.h
中的 DexClassDef
:
- classIdx : 指向 type_ids ,表示类信息
- accessFlags : 访问标识符
- superclassIdx : 指向 type_ids ,表示父类信息
- interfacesOff : 指向 DexTypeList 的偏移量,表示接口信息
- sourceFileIdx : 指向 string_ids ,表示源文件名称
- annotationOff : 注解信息
- classDataOff : 指向 DexClassData 的偏移量,表示类的数据部分
- staticValueOff :指向 DexEncodedArray 的偏移量,表示类的静态数据
DefCLassData
重点是 classDataOff
这个字段,它包含了一个类的核心数据,在 Android 源码中定义为 DexClassData
,它不在 DexFile.h 中了,而是在 DexClass.h 中:
struct DexClassData { DexClassDataHeader header; DexField* staticFields; DexField* instanceFields; DexMethod* directMethods; DexMethod* virtualMethods; }; 复制代码
DexClassDataHeader
定义了类中字段和方法的数目,它也定义在 DexClass.h 中:
struct DexClassDataHeader { u4 staticFieldsSize; u4 instanceFieldsSize; u4 directMethodsSize; u4 virtualMethodsSize; }; 复制代码
- staticFieldsSize : 静态字段个数
- instanceFieldsSize : 实例字段个数
- directMethodsSize : 直接方法个数
- virtualMethodsSize : 虚方法个数
在读取的时候要注意这里的数据是 LEB128 类型。它是一种可变长度类型,每个 LEB128 由 1~5 个字节组成,每个字节只有 7 个有效位。如果第一个字节的最高位为 1,表示需要继续使用第 2 个字节,如果第二个字节最高位为 1,表示需要继续使用第三个字节,依此类推,直到最后一个字节的最高位为 0,至多 5 个字节。除了 LEB128 以外,还有无符号类型 ULEB128。
那么为什么要使用这种数据结构呢?我们都知道 Java 中 int 类型都是 4 字节,32 位的,但是很多时候根本用不到 4 个字节,用这种可变长度的结构,可以节省空间。对于运行在 Android 系统上来说,能多省一点空间肯定是好的。下面给出了 Java 读取 ULEB128 的代码:
public static int readUnsignedLeb128(byte[] src, int offset) { int result = 0; int count = 0; int cur; do { cur = copy(src, offset, 1)[0]; cur &= 0xff; result |= (cur & 0x7f) << count * 7; count++; offset++; DexParser.POSITION++; } while ((cur & 0x80) == 128 && count < 5); return result; } 复制代码
继续回到 DexClassData 中来。header
部分定义了各种字段和方法的个数,后面跟着的分别就是 静态字段 、实例字段 、直接方法 、虚方法 的具体数据了。字段用 DexField
表示,方法用 DexMethod
表示。
DexField
struct DexField { u4 fieldIdx; /* index to a field_id_item */ u4 accessFlags; }; 复制代码
- fieldIdx : 指向 field_ids ,表示字段信息
- accessFlags :访问标识符
DexMethod
struct DexMethod { u4 methodIdx; /* index to a method_id_item */ u4 accessFlags; u4 codeOff; /* file offset to a code_item */ 46}; 复制代码
method_idx
是指向 method_ids 的索引,表示方法信息。accessFlags
是该方法的访问标识符。codeOff
是结构体 DexCode
的偏移量。如果你坚持看到了这里,是不是发现说到现在还没说到最重要的东西,DEX 包含的代码,或者说指令,对应的就是 Hello.java 中的 main 方法。没错,DexCode
就是用来存储方法的详细信息以及其中的指令的。
struct DexCode { u2 registersSize; // 寄存器个数 u2 insSize; // 参数的个数 u2 outsSize; // 调用其他方法时使用的寄存器个数 u2 triesSize; // try/catch 语句个数 u4 debugInfoOff; // debug 信息的偏移量 u4 insnsSize; // 指令集的个数 u2 insns[1]; // 指令集 /* followed by optional u2 padding */ // 2 字节,用于对齐 /* followed by try_item[triesSize] */ /* followed by uleb128 handlersSize */ /* followed by catch_handler_item[handlersSize] */ }; 复制代码
我们打开 010 Editor,定位到 main() 方法对应的 DexCode
,对照进行分析:
public class Hello { private static String HELLO_WORLD = "Hello World!"; public static void main(String[] args) { System.out.println(HELLO_WORLD); } } 复制代码
main()
方法对应的 DexCode 十六进制表示为 :
03 00 01 00 02 00 00 00 79 02 00 00 08 00 00 00 62 00 01 00 62 01 00 00 6E 20 03 00 10 00 0E 00 复制代码
使用的寄存器个数是 3 个。参数个数是 1 个,就是 main()
方法中的 String[] args
。调用外部方法时使用的寄存器个数为 2 个。指令个数是 8 。
终于说到指令了,main() 函数中有 8 条指令,就是上面十六进制中的第二行。尝试来解析一下这段指令。Android 官网就有 Dalvik 指令的相关介绍,链接。
第一个指令 62 00 01 00
,查询文档 62
对应指令为 sget-object vAA, field@BBBB
,AA
对应 00
, 表示 v0
寄存器。BBBB
对应 01 00
,表示 field_ids
中索引为 1 的字段,根据前面的解析结果该字段为 Ljava/lang/System;->out;Ljava/io/PrintStream
,整理一下,62 00 01 00
表示的就是:
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream; 复制代码
接着是 62 01 00 00
。还是 sget-object vAA, field@BBBB
, AA
对应 01
,BBBB
对应 0000
, 使用的是 v1
寄存器,field 位 field_ids 中索引为 0 的字段,即 LHello;->HELLO_WORLD;Ljava/lang/String
,该句完整指令为:
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String; 复制代码
接着是 6E 20 03 00
, 查看文档 6E
指令为 invoke-virtual {vC, vD, vE, vF, vG}, meth@BBBB
。6E
后面一个十六位 2
表示调用方法是两个参数,那么 BBBB
就是 03 00
,指向 method_ids 中索引为 3 方法。根据前面的解析结果,该方法就是 Ljava/io/PrintStream;->println(Ljava/lang/String;)V
。完整指令为:
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V 复制代码
最后的 0E
,查看文档该指令为 return-void
,到这 main() 方法就结束了。
将上面几句指令放在一起:
62 00 01 00 : sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream; 62 01 00 00 : sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String; 6E 20 03 00 : invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V OE OO : return-void 复制代码
这就是 main() 方法的完整指令了。还记得我之前的一篇文章 Smali 语法解析——Hello World,其实这个解析结果和 Hello.java 对应的 smali 代码是一致的:
.method public static main([Ljava/lang/String;)V .registers 3 .prologue .line 6 sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream; sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String; invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V .line 7 return-void .end method 复制代码
总结
这种文章真的是又臭又长,但是耐下心去看,还是会有很大收货的。最后来一张思维导图总结一下:
Java 版本 DEX 文件格式解析源码,点我 DexParser