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 的 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 速度慢,需处理跨块拼接

总结

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

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

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui