Android逆向分析(3) Android可执行文件之谜 - DEX与ODEX, OAT与ELF

WORKING - 未完

前言

米娜桑,是时候揭开DEX的面纱了!我们都知道multidex,都知道65535方法数超标,那DEX到底是个什么东西呢?或许又有些同学知道DEX会优化为ODEX,那ODEX又是什么鬼,优化了什么呢?为什么ClassLoader热补丁方案插入构造函数导致CLASS_ISPREVERIFIED为false后,会对性能造成影响,和ODEX又有什么关系呢?

我们又知道5.0以上Android虚拟机变成了Art,那DEX在art上变成了什么呢?为什么安装特别耗时间?有时候我看着我的Nexus6安装一个应用在那进度条读啊读的好像卡住了,有一种想砸了它的想法,所以当我拿到Nexus 5测试机的时候,第一件事就是刷到4.4,不然每次安装的效率实在不能忍(捂脸)。

DEX是什么

直接把apk当成zip打开后,第一级目录你就会看见有classes.dex,这就是我们要揭开面纱的东西了。

Why DEX

为什么需要DEX,jar不行吗?相应地,为什么需要Dalvik虚拟机,JVM不行吗?

Dalvik虚拟机是专门为了Android移动平台设计的。目标系统的RAM有限,数据存储在缓慢的内部闪存上,而且性能和上个世纪的周免系统相当。它们运行Linux,来提供虚拟内存,进程和线程,以及基于UID的安全机制。

这些特征和限制使我们聚焦在这些目标上:

  • 类数据,尤其是字节码,必须被多个进程共享,以最小化系统内存使用。
  • 启动一个新app的开销必须最小化,来保证设备的可响应。
  • 在独立的文件存储类数据可能导致很多冗余,尤其是字符串。为了保证磁盘空间,我们需要把这些因子提出来。
  • 解析类数据的fields在类加载的时候增加了很多不必要的开销。把数据值直接当成C类型(比如整数或字符串)使用会更好。
  • 字节码验证是必要的,却也是缓慢的。所以我们想在app执行外尽量验证更多,以便不要影响app本身体验。
  • 字节码优化(加速指令,精简方法)对速度和电池生命很重要。
  • 为了安全原因,进程不能编辑共享代码。

典型的虚拟机执行从压缩文件解压独立的类,然后把它们存到heap上。这就导致了每个类可能在每个进程有独立的拷贝,从而使得应用启动变慢,因为代码必须被解压(或者至少需要从磁盘的很多小片段去读取)。另一方面,在本地heap放置字节码简化了首次使用时的指令重写,从而可能导致一些不同的优化。

这些目标指引了一些基本决定:

  • 多个类被聚集到一个单个的DEX文件。
  • DEX文件被映射为只读,并且在进程间共享。
  • 针对本地系统调整字节码顺序和词对齐。
  • 字节码验证对所有类都是强制的,但我们想要对一切可能的进行”预验证(pre-verify)”。
  • 需要重写字节码的优化必须提前执行。

而Dalvik虚拟机和DEX也就应运而生。

Hello DEX

让我们手动来生成一个java,编译成javac,然后转换为dex看看:

1
2
3
4
5
6
7
echo 'class Foo {'\
'public static void main(String[] args) {'\
'System.out.println("Hello, world"); }}' > Foo.java
javac Foo.java
dx --dex --output=foo.jar Foo.class
adb push foo.jar /sdcard/
adb shell dalvikvm -cp /sdcard/foo.jar Foo

当我们在dx命令的output中指定输出文件后缀为.jar,.zip,或者.apk,名为classes.dex的文件就会被创建并保存在压缩包内。解开Foo.jar你就会看到classes.dex和META-INF文件夹(里面只有一个MANIFEST.MF文件)。

我们创建完该jar后直接push到设备上,并通过shell直接让dalvik虚拟机去运行它,如果操作无误,会看到命令行的反馈 - Hello, world。

DEX in file system

这次我打算多画点图,所以看图说话吧:
DEX in file system

DEX in memory

为什么DEX不能被内存映射,或者说,不能直接从zip去执行呢?因为数据是压缩的,文件头也不保证是词对齐的。这些问题可以通过不压缩直接保存为classes.dex和填充zip文件来解决,但会导致数据网络间传输的包体积变大。

我们需要在使用前把zip包里的classes.dex解压。当我们拿到文件的时候,我们可能还会做些之前提到的其他操作(对齐、优化、验证)。这又引出了另一个问题:谁去负责做这些,我们又该把输出放在哪儿?

ODEX是什么

ODEX,全名Optimized DEX,即优化过的DEX。

有至少3种方法去创建一个“准备好的”DEX文件,即ODEX:

  1. 虚拟机“即时(just in time)”执行。输出会跑到一个特殊的dalvik-cache目录。这只在一些特殊的桌面和工程机的设备上使用(这些机器的build中,dalvik-cache目录的权限不是严格的)。在生产机器上这是不被允许的。
  2. 系统的安装器在程序首次安装时候执行,它有写dalvik-cache的权限。
  3. 构建(build)系统预先执行。相关的 jar / apk 文件还在,但classes.dex被剥离出来了。ODEX和原来的zip包保存在一起,不在dalvik-cache,而是系统镜像的一部分。

dalvik-cache目录更准确地说是$ANDROID_DATA/data/dalvik-cache。里面的文件的名字来源于源DEX的完整路径。在设备上该目录被system所拥有,而system拥有0771权限,保存在那里的ODEX被系统和应用的组所拥有,权限为0644。数字权限保护的应用会使用640权限来防止其他应用去检测它们。底线是你可以读取自己的与其他大部分应用的DEX文件,但你不能创建、修改,或删除它们。

前两种方法的执行分为以下三个步骤:

首先,dalvik-cache文件被创建。这必须在一个有恰当权限的进程进行,所以在“系统安装器”的场景,是在运行为root的installd进程执行的。

接着,classes.dex从zip包中解压出来。文件头部留出一小块空间给ODEX header。

最后,文件被内存映射以便访问,并被为当前系统使用进行调整。这包括了字节交换(byte-swapping),结构重新排列(structure realigning),但并没有对DEX文件做有意义的改变。还做了一些其他的基本结构检查,比如确保文件偏移量和数据索引落在有效范围内。

构建系统不在桌面上运行工具,而宁愿去启动模拟器,强制所有相关DEX文件的即时优化,然后从dalvik-cache把结果提取出来。这样做的原因,在解释完优化后会变得更显而易见。

一旦代码被字节替换和对齐,我们就可以继续了。我们添加了一些预计算的数据,在文件头填写ODEX header,然后开始执行。然而,如果我们对验证和优化有兴趣,就需要在初始准备后再插入一个步骤。

dexopt的魔法

在Android 2.3版本以前,系统源码中提供了生成odex的工具dexopt-wrapper,位于Android 2.2系统源码的 build/tools/dexpreopt/dexopt-wrapper/ 目录下,查看DexOptWrapper.cpp文件会发现实际调用的是 /system/bin/dexopt 程序。在5.0及以上版本的设备上,你可能已经再也找不到dexopt了,取而代之的是dex2oat。

我们想要验证和优化DEX文件里的所有类。最简单和安全的方法就是把所有类加载到虚拟机,然后跑一遍。任何加载失败的就是验证/优化失败的。不幸的是,这可能导致一些资源的分配难以释放(比如native共享库的加载),所以我们不想执行在应用运行的虚拟机里。

解决方案就是起一个叫做dexopt的程序(事实上就是虚拟机的后门)。它会执行一个简短的虚拟机初始化,从引导的类路径加载0个或多个DEX文件,然后开始做一切从目标DEX可以做的验证和优化。结束后,进程退出,释放所有资源。

因为多个虚拟机可能同时需求同一个DEX文件,文件锁被用来确保dexopt仅被执行一次。

验证

字节码验证过程包含了扫描DEX文件中每一个类每个方法的指令。目的是为了识别非法指令序列以便不会在运行时才发现它们。涉及到的很多运算对“准确的”GC也是必要的。更多信息见Dalvik字节码验证器笔记

为了性能原因,(下节描述的)优化器假设验证器已经运行成功,还会做一些其他可能不安全的假设。默认地,Dalvik会坚持验证所有类,并只优化那些被验证过的类。可以使用命令行flags去禁用验证器。怎么在Android应用框架中控制这些功能的指令见控制嵌入式虚拟机

验证失败的报告是一个复杂的问题。例如,在不同的package中,调用一个package内可见的方法是非法的,会被验证器捕捉到。但我们未必想要在验证期报告它 —— 事实上我们想要在试图调用方法的时候抛出异常。在每个方法调用上检查这些访问flags也是很昂贵的,Dalvik字节码验证器笔记提到了这个问题。

成功被验证的类在ODEX有一个flag被设置了,在加载的时候就不会被重新验证。ODEX文件有一个32位的checksum,但那是主要是用来快速检查数据损坏的。

优化

虚拟机解释器通常会在一段代码被首次使用的时候执行某些优化。常量池引用被指向内部数据结构的指针所替代,总是成功的操作或是那些总会以某种方式工作的,会被更简单的形式所替代。这些的一部分需要仅在运行时可用的信息,另一部分在某些特定假设下可以被静态推论出。

Dalvik优化器做了这些:

  • 对于虚方法调用,把方法索引替换为vtable索引。
  • 对于实例变量(field)的get/put,把变量索引替换为字节偏移。另外,把 boolean / byte / char / short 基本变量(variants)合并到单个的32位形式(解释器里更少的代码意味着CPU I-cache里更少的空间)。
  • 替换一些高频次调用,比如把 String.length() 替换成”内联“的。这可以跳过一些常见的方法调用消耗,直接从解释器切换到native实现。
  • 删除空方法。最简单的例子就是Object.,啥都没干,但却必须在任何对象被分配的时候执行。指令会被替换为一个新版本的空指令(no-op)形式,除非调试器被attach上去了。
  • 附加预计算数据。例如,虚拟机想要一个类名的哈希表以便查找。不同于在加载DEX文件时候去计算这个,我们可以先计算,以节省堆(heap)空间和所有加载该DEX文件的虚拟机的计算时间。

大部分的优化显然都会更好。

Hello ODEX

我们继续玩耍之前生成的dex,来做一个odex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
adb push dexopt-wrapper /sdcard/

adb shell
# 不然没权限去/data/local
su
chmod 777 dexopt-wrapper
# 直接在sdcard执行会提示权限错误
cp dexopt-wrapper /data/local/
cp foo.jar /data/local/
cd /data/local
/dexopt-wrapper foo.jar foo.odex
--- BEGIN 'foo.jar' (bootstrap=0) ---
--- waiting for verify+opt, pid=5220
--- would reduce privs here
--- END 'foo.jar' (success) ---
cp foo.odex /sdcard
exit
exit

adb pull /sdcard/foo.odex .

这样子就拿到了优化后的odex,赶紧把手机还给同事。

oat与elf

下期预告

下一次让我们利用本次讲到的这些知识,来改一改apktool,让它能重返19岁,反编译腾讯的apk。最后代码会丢到GitHub上。


参考资料

坚持原创技术分享,您的支持将鼓励我继续创作!