Android DEX 内存 Dump 全流程实战:从 APK 提取到无特征内存盲扫
本文记录了一次完整的 Android 应用 DEX 文件逆向提取过程,涵盖 APK 内 DEX 提取、基于
/proc/pid/maps的内存定位提取、以及不依赖任何 maps 标记的内存盲扫三种方案,最终对反编译后的源码进行安全审计。
环境信息
| 项目 | 说明 |
|---|---|
| 目标应用 | com.google.samples.apps.nowinandroid.demo.debug (Google Now in Android) |
| 模拟器 | Android Emulator (ARM64), root 权限 |
| ADB | 通过 USB/网络连接 |
| JADX | 1.5.5 (Homebrew 安装) |
| 工具链 | Python3 + adb shell + dd + xxd |
| 宿主机 | macOS (Apple Silicon) |
第一步:从 APK 中直接提取 DEX
这是最直接的方式,适用于未加壳或壳已脱的情况。
bash
# 1. 获取应用 APK 路径
adb shell pm path com.google.samples.apps.nowinandroid.demo.debug
# 输出: package:/data/app/~~.../base.apk
# 2. Pull APK 到本地
adb pull /data/app/~~.../base.apk ./nowinandroid_base.apk
# 3. 解压出所有 DEX 文件
unzip -o nowinandroid_base.apk "*.dex" -d ./nowinandroid_dex/
结果:成功提取出 14 个 DEX 文件 (classes.dex ~ classes14.dex)。
第二步:从进程内存中提取 DEX
当应用使用加壳/加固技术时,APK 内的 DEX 只是壳代码,真实的 DEX 在运行时才被解密加载到内存中。此时需要从进程内存中 dump。
2.1 定位 DEX 内存映射
通过 /proc/<pid>/maps 可以找到 ART 运行时加载的 DEX 文件内存映射:
bash
PID=$(adb shell pidof com.google.samples.apps.nowinandroid.demo.debug)
adb shell cat /proc/$PID/maps | grep "classes.*dex"
输出示例:
css
7125ee2000-7126303000 r--p ... [anon:dalvik-classes14.dex extracted in memory from ...!classes14.dex]
712a334000-712bf56000 r--p ... [anon:dalvik-classes.dex extracted in memory from ...!classes.dex]
...
ART 会在 maps 中标记 [anon:dalvik-classes*.dex extracted in memory],直接给出了 DEX 在内存中的精确地址范围。
2.2 使用 dd 读取内存
通过 adb exec-out dd if=/proc/<pid>/mem 从设备端读取内存:
python
import subprocess, math
PID = 4347
start = 0x712a334000 # DEX 在内存中的起始地址
size = 0x1C22000 # 映射大小 (end - start)
bs = 1048576 # dd 的 block size
skip = start // bs
offset = start % bs
count = math.ceil((size + offset) / bs)
cmd = ["adb", "exec-out",
f"dd if=/proc/{PID}/mem bs={bs} skip={skip} count={count} 2>/dev/null"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
data, _ = proc.communicate(timeout=60)
dex_data = data[offset:offset + size]
关键点: dd 从 /proc/<pid>/mem 读取时,由于 block size 对齐,需要计算 skip(跳过多少个块)和 offset(块内偏移),读取后再裁剪到精确长度。
第三步:内存盲扫(不依赖 maps 标记)
这是本文的核心。在加壳场景 下,壳程序运行时将真实 DEX 解密到内存中,maps 中不会有 dex extracted 标记 ,DEX 可能藏在任意匿名内存区域中(如堆空间 dalvik-main space)。
3.1 扫描策略
bash
1. 解析 /proc/<pid>/maps → 收集所有可读匿名内存区域 [anon:*]
2. 对每个区域分块读取(避免一次性分配过多内存)
3. 在每个块中逐 4 字节扫描 DEX 魔数: "dex\n035" ~ "dex\n039"
4. 读取 DEX header 偏移 0x20 处的 file_size 字段 → 精确截取
5. 如果 DEX 跨块,额外读取缺失部分拼接
6. 去重保存
3.2 DEX 文件格式
DEX header 结构(前 0x70 字节):
java
偏移 大小 字段名
0x00 8B magic ("dex\n035\0")
0x08 4B checksum (Adler32)
0x0C 20B SHA-1 signature
0x20 4B file_size ← 关键!精确截取用
0x24 4B header_size
...
通过扫描 dex\n + 版本号的 4 字节对齐位置,读取偏移 0x20 处的 file_size,即可精确知道 DEX 的完整长度。
3.3 完整脚本
python
#!/usr/bin/env python3
"""盲扫进程内存 dump 所有 DEX(不依赖 maps 中的 dex 关键字标记)"""
import subprocess, struct, re, os, math
PID = 4347
DEX_MAGIC = b"dex\n"
DEX_VERSIONS = [b"035", b"036", b"037", b"038", b"039"]
DD_BS = 4096 # 必须用 4KB!1MB 在大 skip 值时精度丢失
SCAN_CHUNK_SIZE = 16 * 1024 * 1024 # 16MB
OUTPUT_DIR = "./mem_dex_dump"
def parse_maps(pid):
"""解析 maps,收集所有可读匿名内存区域"""
output = subprocess.check_output(
["adb", "shell", f"cat /proc/{pid}/maps"]
).decode("utf-8", errors="ignore")
regions = []
pattern = re.compile(
r'^([0-9a-fA-F]+)-([0-9a-fA-F]+)\s+([\w-]+)\s+\S+\s+\S+\s+\S+\s*(.*)?$'
)
for line in output.splitlines():
m = pattern.match(line.strip())
if not m:
continue
start = int(m.group(1), 16)
end = int(m.group(2), 16)
perms = m.group(3)
path = m.group(4).strip() if m.group(4) else ""
if "r" not in perms:
continue
if path and not path.startswith("[anon:") and path != "":
continue # 跳过有文件路径的映射(.so/.oat/.vdex 等)
regions.append((start, end, end - start, path))
return regions
def read_memory(start, size):
"""从 /proc/<pid>/mem 读取内存"""
skip = start // DD_BS
offset = start % DD_BS
count = math.ceil((size + offset) / DD_BS)
cmd = ["adb", "exec-out",
f"dd if=/proc/{PID}/mem bs={DD_BS} skip={skip} count={count} 2>/dev/null"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
data, _ = proc.communicate(timeout=120)
return data[offset:offset + size]
def scan_dex(data, base_addr):
"""在内存块中扫描 DEX 魔数"""
results = []
for i in range(0, len(data) - 40, 4):
if data[i:i+4] != DEX_MAGIC:
continue
if data[i+4:i+7] not in DEX_VERSIONS:
continue
file_size = struct.unpack_from("<I", data, i + 0x20)[0]
if file_size < 112 or file_size > 512 * 1024 * 1024:
continue
results.append((i, base_addr + i, file_size))
return results
def main():
regions = parse_maps(PID)
dex_found = {}
for start, end, size, path in regions:
chunks = math.ceil(size / SCAN_CHUNK_SIZE)
for ci in range(chunks):
cs = start + ci * SCAN_CHUNK_SIZE
cz = min(SCAN_CHUNK_SIZE, start + size - cs)
data = read_memory(cs, cz)
if not data:
continue
for off, addr, fs in scan_dex(data, cs):
key = (addr, fs)
if key not in dex_found:
dex_found[key] = (data, off, fs)
# 提取保存(含跨块补读逻辑)
os.makedirs(OUTPUT_DIR, exist_ok=True)
for (addr, fs), (data, off, _) in sorted(dex_found.items()):
available = len(data) - off
if available >= fs:
dex = data[off:off + fs]
else:
# 跨块:补读缺失部分
first = data[off:]
second = read_memory(addr + len(first), fs - len(first))
dex = first + (second[:fs - len(first)] if second else b"")
if len(dex) >= 112 and dex[:4] == DEX_MAGIC:
with open(os.path.join(OUTPUT_DIR, f"mem_0x{addr:x}_{fs}.dex"), "wb") as f:
f.write(dex)
if __name__ == "__main__":
main()
踩坑记录
坑 1:dd 的 bs 不能用 1MB
现象: 使用 dd bs=1048576 读取高位地址的小内存区域时返回空数据。
原因: Android toybox 的 dd 在 skip 值非常大时(如 463,000+)存在精度丢失。
解决: 改用 dd bs=4096(与内存页大小一致),所有地址均可正确读取。
bash
# 错误:高位地址返回 0 字节
dd if=/proc/4347/mem bs=1048576 skip=463551 count=1 → got 0 bytes
# 正确:
dd if=/proc/4347/mem bs=4096 skip=118669056 count=86 → got 352256 bytes ✓
坑 2:DEX 跨扫描块截断
现象: 大 DEX 文件(>16MB)只 dump 了 16MB,JADX 报错 Dex file truncated。
原因: 扫描时分块大小 16MB,DEX header 声明的 file_size 超出当前块。
解决: 发现截断后,从 DEX起始地址 + 已有长度 开始额外 dd 补读缺失部分,拼接为完整文件。
坑 3:盲扫漏掉 6 个小 DEX
现象: 14 个 DEX 只扫到 8 个。
根因: 同坑 1,dd bs=1MB 导致高位地址(0x71836xxxx)的 classes3.dex 等小区域读取为空。
解决: 统一将 DD_BS 改为 4096 后全部扫出。
JADX 反编译与安全审计
bash
jadx -d ./jadx_output --no-res ./mem_dex_blind_scan/*.dex
审计结果
| 检查项 | 结果 |
|---|---|
| API Key / Secret 硬编码 | 未发现 |
| OAuth / Bearer Token | 未发现 |
| 密码 / 私钥硬编码 | 未发现 |
| 加密算法密钥暴露 | 未发现 |
http:// 明文通信 |
发现 (http://example.com,demo 占位符) |
| Firebase Access Token | 发现(Storage 公开 token,风险较低) |
| Debug 构建标志 | 发现 (DEBUG = true) |
Retrofit API 端点
bash
GET /newsresources # 全量新闻资源
GET /changelists/newsresources # 增量变更
GET /changelists/topics # Topic 增量
GET /topics # Topic 全量
Base URL: http://example.com(BuildConfig 中硬编码)
方案对比
| 方案 | 适用场景 | 优点 | 局限 |
|---|---|---|---|
| APK 解压 | 未加壳应用 | 最简单直接 | 加壳时只能拿到壳 DEX |
| maps 定位 | ART 加载的标准 DEX | 地址精确,读取简单 | 依赖 dex extracted 标记 |
| 内存盲扫 | 加壳/自定义 ClassLoader | 不依赖任何标记,可发现隐藏 DEX | 速度慢,需处理跨块拼接 |
总结
- APK 解压 是第一选择,加壳应用则需要内存 dump。
- maps 标记定位 适用于标准壳,但加固方案可能抹除标记。
- 内存盲扫 是终极手段,通过 DEX 魔数 + header file_size 实现精确提取。
dd bs=4096是内存读取的可靠参数,大 bs 在高位地址会出问题。- 本文使用的是纯
adb + dd方案,无需 Frida 或 root 权限以外的任何工具。
本文所有操作均在授权测试环境中进行,仅供学习研究用途。