ODEX

发布于 2019-05-13  42 次阅读


ODEX简介

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

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

  1. 虚拟机JIT输出会跑到一个特殊的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仅被执行一次。

优化

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

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

生成ODEX文件

使用dexopt-wrapper可以将dex转换为odex。dexopt-wrapper在安卓2.3以前的源码中可以找到。将dex-wrapper编译后放到手机中。

adb push dexopt-wrapper /data/local
adb shell chmod 777 /data/local/dexopt-wrapper

从apk文件中提取一个dex文件,将其改名为classex.dex,zip将其压缩后改名为HelloDex.zip

adb push HelloDex.zip /data/local
adb shell
cd /data/local
./dexopt-wrapper HelloDex.zip HelloDex.odex

将ODEX文件转换为DEX文件

经过优化的ODEX文件中包含与设备相关的依赖库列表Dependences结构信息,不同的Android设备的底层bootClassPath环境变量中存放的系统加载库列表也不尽相同。因此,将ODEX文件转换成DEX文件的过程是设备相关的。
为了将ODEX文件还原成DEX文件,需要先将ODEX文件反编译成smali文件,再将smali文件编译成DEX文件。我们将这个过程称为deodex。反编译与编译smali文件使用的工具都是smali。在使用baksmali命令反编译ODEX文件时,需要加入参数-d,以指定与ODEX相关的设备的framework目录。
因为依赖库都来源于Android设备的/system/framework目录,所以,
第一步操作是将设备上的framework目录pull到本地。

adb pull /system/framework ./

(注意,执行以上命令需要拥有设备的root权限)
接下来,执行如下命令完成反编译

baksmali -a 19 -x crack.odex -d ./framework -o ./outdex

其中,-a参数时Android设备的版本号,19表示当前设备是4.4版本的。-x参数用于指定要操作的ODEX文件,-d参数用于指定framework目录,-o参数指定输出smali文件的目录。完成该命令后,会生成一系列smali文件,接下来,只要执行smali命令将smali文件生成DEX文件即可。

smali ./outdex -o outdex.dex

(当然,网上也有odex2dex的自动脚本)