资讯专栏INFORMATION COLUMN

浅谈 Android Dex 文件

Cristic / 1033人阅读

摘要:程序一般使用语言开发,但是虚拟机并不支持直接执行字节码,所以会对编译生成的文件进行翻译重构解释压缩等处理,这个处理过程是由进行处理,处理完成后生成的产物会以结尾,称为文件。文件格式是专为设计的一种压缩格式。参考资源官方资料介绍和字节码的对比

概述 为什么要了解 Dex 文件

了解了 Dex 文件以后,对日常开发中遇到一些问题能有更深的理解。如:APK 的瘦身、热修复、插件化、应用加固、Android 逆向工程、64 K 方法数限制。

什么是 Dex 文件

在明白什么是 Dex 文件之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虚拟机,用来运行 JAVA 字节码程序。Dalvik 是 Google 设计的用于 Android平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在Android 4.4推出。ART 比 Dalvik 的性能更好。Android 程序一般使用 Java 语言开发,但是 Dalvik 虚拟机并不支持直接执行 JAVA 字节码,所以会对编译生成的 .class 文件进行翻译、重构、解释、压缩等处理,这个处理过程是由 dx 进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。Dex 文件格式是专为 Dalvik 设计的一种压缩格式。所以可以简单的理解为:Dex 文件是很多 .class 文件处理后的产物,最终可以在 Android 运行时环境执行。

Dex 文件是怎么生成的

java 代码转化为 dex 文件的流程如图所示,当然真的处理流程不会这么简单,这里只是一个形象的显示:

注:图片来源于网络

现在来通过一个简单的例子实现 java 代码到 dex 文件的转化。

从 .java 到 .class

先来创建一个 Hello.java 文件,为了便于分析,这里写一些简单的代码。代码如下:

public class Hello {
    private String helloString = "hello! youzan";

    public static void main(String[] args) {
        Hello hello = new Hello();
        hello.fun(hello.helloString);
    }

    public void fun(String a) {
        System.out.println(a);
    }
}

在该文件的同级目录下面使用 JDK 的 javac 编译这个 java 文件。

javac Hello

javac 命令执行后会在当前目录生成 Hello.class 文件,Hello.class 文件已经可以直接在 JVM 虚拟机上直接执行。这里使用使用命令执行该文件。

java Hello

执行后应该会在控制台打印出“hello! youzan”

这里也可以对 Hello.class 文件执行 javap 命令,进行反汇编。

javap -c Hello

执行结果如下:

public class Hello {
  public Hello();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: aload_0
       5: ldc           #2                  // String hello! youzan
       7: putfield      #3                  // Field helloString:Ljava/lang/String;
      10: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #4                  // class Hello
       3: dup
       4: invokespecial #5                  // Method "":()V
       7: astore_1
       8: aload_1
       9: aload_1
      10: getfield      #3                  // Field helloString:Ljava/lang/String;
      13: invokevirtual #6                  // Method fun:(Ljava/lang/String;)V
      16: return

  public void fun(java.lang.String);
    Code:
       0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: aload_1
       4: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       7: return
}

其中 Code 之后都是具体的指令,供 JVM 虚拟机执行。指令的具体含义可以参考 JAVA 官方文档。

从 .class 到 .dex

上面生成的 .class 文件虽然已经可以在 JVM 环境中运行,但是如果要在 Android 运行时环境中执行还需要特殊的处理,那就是 dx 处理,它会对 .class 文件进行翻译、重构、解释、压缩等操作。

dx 处理会使用到一个工具 dx.jar,这个文件位于 SDK 中,具体的目录大致为 你的SDK根目录/build-tools/任意版本 里面。使用 dx 工具处理上面生成的Hello.class 文件,在 Hello.class 的目录下使用下面的命令:

dx --dex --output=Hello.dex Hello.class

执行完成后,会在当前目录下生成一个 Hello.dex 文件。这个 .dex 文件就可以直接在 Android 运行时环境执行,一般可以通过 PathClassLoader 去加载 dex 文件。现在在当前目录下执行 dexdump 命名来反编译:

dexdump -d Hello.dex

执行结果如下(部分区域的含义已经在下面描述):

Processing "Hello.dex"...
Opened "Hello.dex", DEX version "035"

------ 这里是编写的 Hello.java 的类的信息 ------
Class #0            -
  Class descriptor  : "LHello;"
  Access flags      : 0x0001 (PUBLIC)
  Superclass        : "Ljava/lang/Object;"
  Interfaces        -
  Static fields     -
  Instance fields   -
    #0              : (in LHello;)
      name          : "helloString"
      type          : "Ljava/lang/String;"
      access        : 0x0002 (PRIVATE)

------ 下面区域描述的是构造方法的信息。7010 0400 0100 1a00 0b00 之类的数字就是方法中的代码翻译成的指令。Dalvik 使用的是16位代码单元,所以这里就是4个数字为一组,每个数字是16进制。invoke-direct 这些是前面指令对应的助记符,也代表着这些指令的真正操作。如果对这些指令转化感兴趣可以去https://source.android.com/devices/tech/dalvik/instruction-formats 查看 ------
  Direct methods    -
    #0              : (in LHello;) 
      name          : "" --- 方法名称:这个很明显就是构造方法 ---
      type          : "()V" --- 方法原型,()里面表示入参,()后面表示返回值,V代表void---
      access        : 0x10001 (PUBLIC CONSTRUCTOR) --- 方法访问类型 ---
      code          -
      registers     : 2  --- 方法使用的寄存器数量 ---
      ins           : 1  --- 方法入参,方法除了我们定义的参数以外,系统还会默认带一个特殊参数 ---
      outs          : 1 
      insns size    : 8 16-bit code units  --- 指令大小 ---
000148:                                        |[000148] Hello.:()V
000158: 7010 0400 0100                         |0000: invoke-direct {v1}, Ljava/lang/Object;.:()V // method@0004
00015e: 1a00 0b00                              |0003: const-string v0, "hello! youzan" // string@000b
000162: 5b10 0000                              |0005: iput-object v0, v1, LHello;.helloString:Ljava/lang/String; // field@0000
000166: 0e00                                   |0007: return-void
      catches       : (none)
      positions     :
        0x0000 line=1
        0x0003 line=2
      locals        :
        0x0000 - 0x0008 reg=1 this LHello;

    #1              : (in LHello;)
      name          : "main"
      type          : "([Ljava/lang/String;)V"
      access        : 0x0009 (PUBLIC STATIC)
      code          -
      registers     : 3
      ins           : 1
      outs          : 2
      insns size    : 11 16-bit code units
000168:                                        |[000168] Hello.main:([Ljava/lang/String;)V
000178: 2200 0000                              |0000: new-instance v0, LHello; // type@0000
00017c: 7010 0000 0000                         |0002: invoke-direct {v0}, LHello;.:()V // method@0000
000182: 5401 0000                              |0005: iget-object v1, v0, LHello;.helloString:Ljava/lang/String; // field@0000
000186: 6e20 0100 1000                         |0007: invoke-virtual {v0, v1}, LHello;.fun:(Ljava/lang/String;)V // method@0001
00018c: 0e00                                   |000a: return-void
      catches       : (none)
      positions     :
        0x0000 line=5
        0x0005 line=6
        0x000a line=7
      locals        :

  Virtual methods   -
    #0              : (in LHello;)
      name          : "fun"
      type          : "(Ljava/lang/String;)V"
      access        : 0x0001 (PUBLIC)
      code          -
      registers     : 3
      ins           : 2
      outs          : 2
      insns size    : 6 16-bit code units
000190:                                        |[000190] Hello.fun:(Ljava/lang/String;)V
0001a0: 6200 0100                              |0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0001
0001a4: 6e20 0300 2000                         |0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@0003
0001aa: 0e00                                   |0005: return-void
      catches       : (none)
      positions     :
        0x0000 line=10
        0x0005 line=11
      locals        :
        0x0000 - 0x0006 reg=1 this LHello;

  source_file_idx   : 1 (Hello.java)

到此为止,已经完成了将 Java 代码转变成 Dalvik 可执行的文件,即 dex。

Dex 文件的具体格式

现在来分析一下 Dex 文件的具体格式,就像 MP3,MP4,JPG,PNG 文件一样,Dex 文件也有它自己的格式,只有遵守了这些格式,才能被 Android 运行时环境正确识别。

Dex 文件整体布局如下图所示:

这些区域的数据互相关联,互相引用。由于篇幅原因,这里只是显示部分区域的关联,完整的请去官网自行查看相关数据整理。下图中的各字段都在后面的各区域的详细介绍中有具体介绍。

下面将分别对文件头、索引区、类定义区域进行简单的介绍。其它区域可以去 Android 官网了解。

文件头

文件头区域决定了该怎样来读取这个文件。具体的格式如下表(在文件中排列的顺序就是下面表格中的顺序):

id 区

id 区存储着字符串,type,prototype,field, method 资源的真正数据在文件中的偏移量,我们可以根据 id 区的偏移量去找到该 id 对应的真实数据。

字符串 id 区域

这个区块是一个偏移量列表,每个偏移量对应了一个真正的字符串资源,每个偏移量占32位。我们可以通过偏移量找到对应的实际字符串数据。具体格式如下:

最终这个偏移的位置应该是落在数据区的。找到这个偏移量的位置后,根据下面的格式就可以读取出这个字符串资源的具体数据:

类型 id 区

这个区块是一个索引列表,索引的值对应字符串id区域偏移量列表中的某一项。数据格式如下:

如果我们要找到某个类型的值,需要先根据类型id列表中的索引值去字符串id列表中找到对应的项,这一项存储的偏移量对应的字符串资源就是这个类型的字符串描述。

方法原型 id 区

这个区块是一个方法原型 id 列表,数据格式为:

成员 id 区

这个区块存储着原型 id 列表,数据格式为:

方法 id 区

这个区块存储着方法 id 列表,数据格式为: 这个区块存储着原型 id 列表,数据格式为:

类定义区

这个区域存储的是类定义的列表,具体的数据结构如下:

解析 dex 文件的工具

这里推荐一个可以解析 dex 文件的工具 010 Editor。它可以通过预置的模板让我们更清晰的了解 dex 文件的格式。

Dex 文件在 Android Tinker 热修复中的应用

在目前的主流的 Android 热修复方案中,Tinker有免费、开源、用户量大等优点,因此在有赞也是基于 Tinker 搭建 Android 热修复服务。Tinker 热修复的主要原理就是通过对比旧 APK 的 dex 文件与新 APK 的 dex 文件,生成补丁包,然后在 APP 中通过补丁包与旧 APK 的 dex 文件合成新的 dex 文件。流程如下图所示:

注:图片来源于 Tinker 官网
补丁包的生成

Tinker 官方使用自研一套合成方案,就是 DexDiff。它基于 Dex文件格式的特性,具有补丁包小,消耗内存小等优点。在 DexDiff 算法中,会根据 Dex文件的格式,将 Dex 文件划分为不同的区块类,如下图:

这些区块有一个统一的数据结构,主要的数据有区块对应的实际数据类型及在文件中的偏移量。如下图:

有了区块数据中的实际数据类型与偏移量,再根据实际数据类型对应的数据结构就可以从文件中读出这个区块包含的实际数据。这里以 header 区域为例,读取代码如下(删除了部分无关代码,代码可以参照上面的 Dex 文件格式的文件头的介绍):

private void readHeader(Dex.Section headerIn) throws UnsupportedEncodingException {
 byte[] magic = headerIn.readByteArray(8); 
 int apiTarget = DexFormat.magicToApi(magic);
 checksum = headerIn.readInt(); 
 signature = headerIn.readByteArray(20);
 fileSize = headerIn.readInt();
 int headerSize = headerIn.readInt();
 int endianTag = headerIn.readInt();
 linkSize = headerIn.readInt();
 linkOff = headerIn.readInt();
 mapList.off = headerIn.readInt();
 stringIds.size = headerIn.readInt();
 stringIds.off = headerIn.readInt();
 typeIds.size = headerIn.readInt();
 typeIds.off = headerIn.readInt();
 protoIds.size = headerIn.readInt();
 protoIds.off = headerIn.readInt();
 fieldIds.size = headerIn.readInt();
 fieldIds.off = headerIn.readInt();
 methodIds.size = headerIn.readInt();
 methodIds.off = headerIn.readInt();
 classDefs.size = headerIn.readInt();
 classDefs.off = headerIn.readInt();
 dataSize = headerIn.readInt();
 dataOff = headerIn.readInt();
}

从文件中读取到新旧 Dex 文件各区块的具体的数据后,就可以进行对比生成补丁包了。因为各区块的数据结构不一致,因此各区块有着相应的 diff 算法来处理各区块补丁生成与合成。算法列表如图:

这些算法会对比新旧 Dex 文件转化成数据结构以后数据的差异,然后生成相关的操作指令,存储到补丁文件,下发到客户端。

补丁的合成

客户端收到补丁文件后,会使用相同的读取方式,将旧 Dex 文件转换为相关的数据结构,然后使用补丁包中的操作指令,对旧 Dex 数据进行修改,生成新 Dex 数据,最后数据写入文件,生成新 Dex 文件,这样就完成了补丁的合成。

写在最后

本文并没有写什么特别深入的东西,对 dex 的文件格式也没有完全描述完全。主要是给大家分享一个 dex 文件的大致结构,还有一些在实际中的应用。让大家在以后遇到相关问题的时候,可以有一些方向去了解 dex 文件,然后解决问题。最后,如果大家有任何的建议或意见,欢迎反馈。

参考资源

Android 官方资料

Tinker 介绍

Dalvik 和 Java 字节码的对比

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/14559.html

相关文章

  • 浅谈Android应用保护(一):Android应用逆向的基本方法

    摘要:的工作原理是,反编译文件,遍历代码,搜索配置文件中配置的需要监控的,如果找到一个的调用,则分析其参数,然后调用包空间下对应的类的静态函数。这些静态函数实现都是解析参数,打印输出信息。 对于未进行保护的Android应用,有很多方法和思路对其进行逆向分析和攻击。使用一些基本的方法,就可以打破对应用安全非常重要的机密性和完整性,实现获取其内部代码、数据,修改其代码逻辑和机制等操作。这篇文章...

    oneasp 评论0 收藏0
  • 浅谈Android应用保护(零):出发点和背景

    摘要:无线端应用对抗的出发点和背景根本上来说,手机客户端是完全控制在使用者手上的,是不可信的,不应该依靠客户端来实现任何安全逻辑业务相关的控制。无线客户端分析和保护的出发点就是基于上述背景。 近几年来,无线平台特别是Android平台的安全逐渐成为各厂商关注的重点。各种新的思路和玩法层出不穷。所以,笔者基于前一段时间的学习和整理,写了这系列关于Android应用安全和保护的文章。这5篇文章主...

    K_B_Z 评论0 收藏0
  • 信安 - 收藏集 - 掘金

    摘要:咱妈说别乱点链接之浅谈攻击阅读掘金作者马达编辑迷鹿马达,精通开发开发,擅长接口设计以及平台化建设,独自主导过多个产品。一题目购物应用分环境要求安全学习资料汇总掘金安全学习资料汇总安全学习网站收集 咱妈说别乱点链接之浅谈 CSRF 攻击 - 阅读 - 掘金作者 | 马达编辑 | 迷鹿 马达, 精通PHP开发、Web开发,擅长api接口设计以及平台化建设,独自主导过多个Web产品。目前就职...

    lushan 评论0 收藏0
  • 信安 - 收藏集 - 掘金

    摘要:咱妈说别乱点链接之浅谈攻击阅读掘金作者马达编辑迷鹿马达,精通开发开发,擅长接口设计以及平台化建设,独自主导过多个产品。一题目购物应用分环境要求安全学习资料汇总掘金安全学习资料汇总安全学习网站收集 咱妈说别乱点链接之浅谈 CSRF 攻击 - 阅读 - 掘金作者 | 马达编辑 | 迷鹿 马达, 精通PHP开发、Web开发,擅长api接口设计以及平台化建设,独自主导过多个Web产品。目前就职...

    codecraft 评论0 收藏0
  • Android 安全开发之 ZIP 文件目录遍历

    摘要:阿里聚安全的应用漏洞扫描服务,可以检测出应用的文件目录遍历风险。阿里聚安全对开发者建议对重要的压缩包文件进行数字签名校验,校验通过才进行解压。 1、ZIP文件目录遍历简介 因为ZIP压缩包文件中允许存在../的字符串,攻击者可以利用多个../在解压时改变ZIP包中某个文件的存放位置,覆盖掉应用原有的文件。如果被覆盖掉的文件是动态链接so、dex或者odex文件,轻则产生本地拒绝服务漏洞...

    sorra 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<