Android DEX 内存 Dump 全流程实战:从 APK 提取到无特征内存盲扫

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 的 ddskip 值非常大时(如 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 速度慢,需处理跨块拼接

总结

  1. APK 解压 是第一选择,加壳应用则需要内存 dump。
  2. maps 标记定位 适用于标准壳,但加固方案可能抹除标记。
  3. 内存盲扫 是终极手段,通过 DEX 魔数 + header file_size 实现精确提取。
  4. dd bs=4096 是内存读取的可靠参数,大 bs 在高位地址会出问题。
  5. 本文使用的是纯 adb + dd 方案,无需 Frida 或 root 权限以外的任何工具。

本文所有操作均在授权测试环境中进行,仅供学习研究用途。

相关推荐
杉氧4 小时前
兼容与共生:如何在旧项目中优雅地引入 Compose?
android·架构·android jetpack
Flynt5 小时前
Room 3.0 包名重构 + KMP 迁移:我把项目升级踩了个遍
android·数据库·kotlin
杉氧5 小时前
性能优化实战:如何定位冗余重组并榨干 Compose 的每一帧性能?
android·架构·android jetpack
阿pin6 小时前
Android随笔-ATMS与AMS区别与联系
android·ams·atms
alexhilton16 小时前
将应用迁移到Navigation 3:痛点、加班和紧急修复
android·kotlin·android jetpack
杉氧1 天前
Navigation Compose 深度实践:如何优雅地串联起你的全栈 App?
android·架构·android jetpack
雨白1 天前
指针与数组的核心机制
android
黄林晴1 天前
Room 3.0 正式发布!包名彻底重构,KMP 成为核心主线
android·android jetpack
三少爷的鞋1 天前
Kotlin 协程环境下的 DCL 懒加载:别把线程时代的经验直接搬过来
android