我这两年经常跟系统固件和逆向分析打交道,碰到最多的情况之一就是:从设备里拉出来的应用数据目录里没有 dex 文件,只有一堆 vdex。第一次遇到的时候我也愣了一下,后来用 vdexExtractor 把 vdex 还原成原始的 dex 文件,整个分析链路才重新走通。这篇文章就把 vdexExtractor 的完整使用过程、原理和踩坑记录整理出来,给同样被这个环节卡住的朋友做个参考。
1. 先搞清楚 VDEX 是什么,为什么非得还原成 DEX
1.1 从 APK 到 DEX 再到 VDEX 的编译链路
Android 应用打包之后,核心代码都在 classes.dex 里。普通情况下,我们可以直接解压 APK 拿到 dex,拖进 jadx 或 jeb 里看逻辑。但在 Android 8.0 之后,系统引入了 ART 编译链路的优化,安装应用或系统预编译时,会生成 vdex 文件。
vdex 的全称是 Verified DEX,它的设计初衷是把校验(verification)和去重(deduplication)的结果缓存下来,这样应用启动时不用再重新对 dex 做一遍校验。简单理解,vdex 是一个容器,里面包含了原始 dex 文件的内容,但它的文件头、索引结构、验证状态数据都跟纯 dex 不一样,所以直接改后缀或者用普通反编译工具去打开,通常会失败。
这个设计本身是为了提升系统流畅度,但对于做安全研究、恶意样本分析、系统裁剪的人来说,意味着我们不能像以前那样随手拖一个 dex 就开工了。vdex 就像是一个被加了层包装的礼品,礼品还是那个礼品,但你得先把包装拆开。
1.2 什么场景下必须把 VDEX 还原成 DEX
现实工作中,至少有三类场景绕不开这一步。
第一种是做系统级应用分析。厂商把某些系统应用直接预编译进 system 分区,APK 里可能只剩资源文件和老旧的 dex,真正的代码逻辑被 ART 提取到了 /system/framework 或 /system/priv-app 下的 oat/vdex 文件里。分析这类应用时,拿不到原始 dex 等于啥都没看到。
第二种是抓取内存中的类加载数据。有些加固方案会把类加载放到运行时动态进行,但系统为了性能仍然会在 vdex 里保留一部分原始输入的副本。把这类 vdex 还原出来,偶尔能直接看到明文 dex,省掉脱壳的复杂操作,对恶意样本分析来说价值很大。
第三种是搞 ROM 定制或者做系统镜像裁剪的工程师。想移除某个内置应用但仍然保留系统启动能力,或者想了解系统进程到底加载了哪些代码,同样得先把 vdex 还原成 dex,才能用 jadx 全文检索关键字。
1.3 工具链全貌:不只一个 vdexExtractor
vdexExtractor 是目前最常见的开源工具,但它不是唯一的东西。完整的工具链里通常还包括:
- vdexExtractor:负责解析 vdex 容器,把里面的 dex 提取出来;
- dexdump:检查还原后的 dex 是否完整、头部是否有问题;
- baksmali:把 dex 转成 smali 汇编,方便细粒度阅读和修改;
- jadx / jadx-gui:将 dex 还原为 Java 代码,适合业务逻辑理解;
- apktool:处理 APK 资源和 smali 干扰,在部分场景下会配合使用。
只靠 vdexExtractor 拿到 dex 还不算完,后面是否能用 jadx 打开、函数名是否完整、类结构有没有被截断,才是真正影响效率的地方。
2. 环境准备与 vdexExtractor 编译
2.1 源码下载与依赖整理
vdexExtractor 是开源项目,托管在 GitHub 上,仓库地址很直观,搜索 vdexExtractor 就能找到。下载源码时建议直接拉最新 release 版本,不要用 master 分支上那种实验性提交,因为 vdexExtractor 对 Android 不同版本的兼容性差异很大,实验性代码可能只适配了特定 API level。
下载命令很简单:
git clone https://github.com/anestisb/vdexExtractor.git cd vdexExtractor编译之前先确认系统里装了几个基础依赖:
- make、gcc、g++ 等基础编译工具;
- 或者 Android NDK,因为部分组件需要用 NDK 交叉编译到 ARM 平台直接放到设备上跑。
我自己的习惯是:如果只处理本机上的 vdex 文件,就直接在 x86 Linux 环境下编译本地工具;如果需要在 Android 设备上直接解析 /data/data 下的 vdex,再编一份 ARM64 版本推送到设备上。
2.2 编译流程与参数选择
项目里提供了现成的 Makefile,如果只是编译本地工具:
./makefile或者:
make编译完成后会在当前目录下生成 vdexExtractor 可执行文件。如果想要生成 Android 设备上运行的版本,需要先设置 NDK 交叉编译环境:
export NDK_HOME=/path/to/your/ndk ./makefile ndk编译时注意两点。第一,vdexExtractor 的具体版本决定了它支持哪些 Android 版本,有些老版本对 Android 10 之后的 vdex 格式支持并不好。第二,编译选项里默认会包含 oat2dex 相关组件,这个组件会进一步处理 oat 文件里的 dex 缓存,如果你的需求只是提取 vdex 中的 dex,那可以不关注这部分,但如果后面发现提取出来的 dex 不可用,oat2dex 反而是救命的工具。
2.3 按 Android 版本选择对应模块
vdexExtractor 内部有多个模块,不同 Android 版本的 vdex 格式差异很大:
- Android 8.0/8.1:vdex 里有完整的 dex,有 checksum,结构相对简单;
- Android 9:dex 和验证信息同时存在,vdex 格式略有变化;
- Android 10 及以后:vdex 里的 dex 可能不是完整副本,而是 quickened dex,需要更进一步处理。
如果不确定当前环境是哪个版本,可以在设备上先用getprop ro.build.version.sdk查一下 API level,再对照工具文档选择对应参数。这一步看似多余,但能避免后面解析时报一堆莫名其妙的 "invalid header" 错误。
3. 实操:把 VDEX 批量还原为 DEX
3.1 从系统镜像中定位 VDEX 文件
vdex 文件的存放位置在不同场景下不一样。我总结了一下主要的几个路径:
- 系统预编译应用:
/system/framework/、/system/priv-app/<包名>/、/system/app/<包名>/ - 用户安装应用:
/data/app/<包名>/目录下,通常以base.vdex或split_config.<架构>.vdex形式存在 - OTA 升级包内:在
system/framework/或system/app/相关目录下,与 boot.oat 一起出现
如果你想抓取自己设备上的 vdex,adb root 之后直接用:
adb pull /system/framework/arm64/boot-framework.vdex ./ adb pull /data/app/com.example.demo-xxx/base.vdex ./如果没有 root,这部分基本拿不到,但可以退而求其次去 OTA 包或者官方刷机镜像里提取,把system.img解包后照样能拿到。解包 system.img 推荐用 simg2img 加 ext4 分区挂载,或者用现成的工具比如 7-Zip 配合分区处理插件,都能做到。
3.2 使用 vdexExtractor 执行转换
拿到 vdex 文件之后,第一步是确认工具能识别它。直接在命令行执行:
./vdexExtractor -i input.vdex工具会自动打印 vdex 的版本信息、包含的 dex 数量、每个 dex 的 checksum 和是否包含 quickened 信息。正常情况下输出里会看到类似dex [0] checksum: 0xabcdef12的内容,这代表解析成功。
然后通过-o参数指定输出目录,并加上--disassemble这类选项:
./vdexExtractor -i input.vdex -o output_dir --disassemble --ignore-error参数说明如下:
-i:输入文件路径;-o:输出目录;--disassemble:对 dex 做脱优化处理(de-optimize),把 quickened 指令还原为普通 dex 指令;--ignore-error:遇到单个 dex 出错时继续处理其他 dex,批量操作时建议加上;--input-list:可以用一个文本文件指定多个 vdex 路径,适合批量场景。
如果只想提取原始 dex,不还原 quickened 指令,可以不加--disassemble,但这样拿到的 dex 很可能在 jadx 里无法正常阅读,函数体都是空壳或者乱码。我建议默认加上这个参数。
批量处理整个目录时,可以写一个简单脚本:
for f in /path/to/vdex_dir/*.vdex; do ./vdexExtractor -i "$f" -o /path/to/output --disassemble --ignore-error done跑完之后,输出目录里会出现以原始文件命名的 dex 文件,文件名通常会追加 dex 序号。比如input.vdex_dex0.dex、input.vdex_dex1.dex。
3.3 校验还原结果是否可用
提取只是第一步,还原出的 dex 是否能被反编译工具正确解析才是关键。
先用 dexdump 做一个快速健康检查:
dexdump -d output_dir/input.vdex_dex0.dex | head -50如果能看到Class descriptor和Method信息,就说明 dex 头部解析正常。然后直接拖进 jadx-gui:
jadx-gui output_dir/input.vdex_dex0.dex重点检查这几个地方:
- 类列表是否完整,有没有大量空类;
- 方法体是否能看到 Java 伪代码,而不是一堆
throw new RuntimeException("Stub!"); - 字符串和常量池有没有被抽空。
如果 jadx 能直接打开并且能看到完整代码逻辑,说明还原成功。如果只有类名没有方法体,大概率是 --disassemble 没开,或者 vdexExtractor 版本不支持当前 Android 版本的 quickened 指令还原,需要按第 4 部分的思路处理。
3.4 配合反编译工具继续分析
拿到可用的 dex 后,后续分析路线就平滑了。通常我的工作流是:
用 jadx 建立整个项目的索引,先看一眼包的层级结构和主要入口类。如果遇到 jadx 识别不了的混淆代码,再退回到 smali 层面,用 baksmali 把 dex 拆开,逐段读指令,这样即使类名和方法名被混淆,也能从 smali 的 register 使用流程反推逻辑。
如果是从恶意样本里提取的 dex,还要额外注意资源混淆和字符串加密。vdexExtractor 还原出来的是系统编译时的 dex 快照,并不代表应用运行时看到的字符串就是明文,很多样本会把字符串做一次运行时解密。遇到这种情况,建议在 jadx 里全局搜索decrypt、dec、loadClass之类关键词,找到解密函数,然后手动跑一遍对应的算法,把字符串还原出来再读代码。
比如某次分析一个内置恶意功能的系统应用,vdex 还原出来的 dex 里,所有 URL 和 C2 地址都是密文,字符串的长度和特征像 AES 或者 DES 加盐。我直接用 jadx 打开后只看静态代码,找不到明文,后来在onCreate里发现一个initSecretKey方法,动态解密后才挖到真正的地址。这个经验尤其适用于厂商内置应用分析。
4. 常见问题与排查经验
4.1 vdexExtractor 版本与 Android 版本不匹配
这是最常见的坑。用旧版工具处理 Android 12 之后的 vdex,经常出现 "not a valid vdex file" 或者 "unsupported version: 006" 之类的错误。
原因在于 vdex 文件头里有版本号字段,不同 Android 版本会用到不同的 vdex 版本号。比如 Android 8.x 是 004,Android 9 是 006,Android 10 之后会继续升版本。旧工具对新高版本号没有对应解析逻辑。
排查方法很简单,用十六进制编辑器打开 vdex 文件,看文件头的 6 到 10 字节,就能确认版本号。然后对照 vdexExtractor 的 release notes,确认你使用的工具版本是否支持该版本。如果不支持,优先升级工具。
提示:真实设备上的 vdex 版本号不一定和系统版本严格对应,厂商可能修改过 ART 参数,所以不要只看 Android 大版本,而是以实际文件头里的版本号为准。
4.2 提示无法解析 VDEX 文件头
即使版本号匹配,也会遇到 "invalid dex size" 或 "header check failed"。
大部分情况是因为 vdex 文件本身不完整。有些分析场景里,我们通过内存 dump 或者文件碎片恢复拿到了 vdex,但尾部校验区被截断了。vdexExtractor 在解析时会对 dex 大小做校验,一旦大小超过实际文件长度就会报错。
这时候可以用--ignore-error强制继续,或者用工具自带的-f参数尝试从文件尾部反向解析。如果还是不行,就要去原始镜像或者设备上重新拉取一份完整的 vdex。
另一种可能是 vdex 被厂商定制过。国内部分 ROM 会修改 ART 编译参数,导致 vdex 的 dex 存储位置和标准实现不一样。遇到这种情况,可以先试--raw模式直接按 dex magic 关键字去扫整个文件,有些时候能暴力从 vdex 里抠出完整的 dex。
4.3 还原出的 DEX 无法被 jadx 打开
这大概率是 quickened dex 没有正确 de-optimize。vdex 里的 dex 指令被 ART 优化过,比如某些方法调用被替换成快路径指令,寄存器分配也做了调整,直接用普通反编译工具,会解析失败。
处理办法比较暴力但很有效:用 baksmali 把还原后的 dex 先转成 smali,再用 smali 2.x 重新打包成一个 dex。虽然不能完全还原到原始源码级别,但至少能让 jadx 或者 jeb 建立正确的类和方法索引,然后部分函数就能看到逻辑了。
另外,如果只是为了快速确认关键逻辑,也可以跳过 jadx,直接用 grep 或者 strings 搜 dex 里的字符串常量。反正 vdex 里的 dex 包含大量字符串数据,快速定位不需要完整反编译,看一眼字符串特征也能猜出个大概。
4.4 从内存和 OTA 包提取 VDEX 的补充思路
不是所有场景都能直接从文件系统拿到 vdex。如果应用在运行期才把 dex 解密到内存,然后由 ART 编译,最终产物可能在/data/app/之外的临时目录,或者直接存在于进程地址空间。这种场景下需要配合 frida 或内存 dump 工具,在 ART 完成编译之后把内存中的 dex 快照抓出来,再走 vdexExtractor 的流程。
OTA 包是另一个可靠来源。购买或下载到官方 OTA 增量包之后,解包得到system.new.dat或payload.bin,再解出其中的 vdex 文件。整体思路和常规刷机包一样,需要用到 payload-dumper-go 或者 ext4 解包工具,但 vdex 文件的解析方式完全一致。
这个补充路径适合那些没有 root 权限但又能搞到 OTA 包的场景,尤其是分析厂商内置应用时非常实用。毕竟如果厂商 ROM 把系统应用设为不可卸载,vdex 里的代码逻辑就是唯一快速入口。
5. 实操过程中的几个心得
最后一次分享两个小经验。
第一,vdexExtractor 处理大批量文件时,尽量用一个干净的输出目录,并且每个 vdex 用单独的文件夹隔离,不然多个 vdex 还原出的 dex 文件名会重复,互相覆盖。用 shell 循环的时候,我习惯把输入文件名的前缀带上,比如basename "$f" .vdex。
第二,别只依赖 vdexExtractor 的一次还原结果。遇到厂商深度定制的 ROM,同一份 vdex 在工具的不同版本下,还原结果可能有细微差别。特别是当你发现某个类的某个方法在 jadx 里看起来不完整,可以试试用旧版工具重新提取,再对比两份 dex 的差异,有时候旧版反而能绕过新版对某些指令的误判。
另外补一句,vdexExtractor 本身并不是一个维护特别频繁的工具,遇到新 Android 版本的格式变更,社区往往需要一段时间才跟进。所以平时最好在自己的工具库里多留几个历史版本,别一味追新。很多时候,旧版本对老设备的 vdex 反而是最稳的方案。