Dumpling:基于 eBPF 的 Android DEX 内存 Dump 工具

android 逆向 rust ebpf

Rust + eBPF uprobe hook ART ClassLinker::DefineClass,从目标进程内存中提取运行时 DEX。无注入、无 ptrace、单文件静态分发。

整体架构

┌─────────────────────────────────────────────────────────────┐
│                        Kernel Space                          │
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  eBPF uprobe @ ClassLinker::DefineClass             │   │
│   │                                                     │   │
│   │  1. bpf_get_current_uid_gid() → check target UID   │   │
│   │  2. read arg6 (DexFile* pointer)                    │   │
│   │  3. PerfEventArray.output(DexEvent)                 │   │
│   └───────────────────────┬─────────────────────────────┘   │
│                           │                                  │
└───────────────────────────┼──────────────────────────────────┘
                            │ perf event
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                        User Space (tokio async)              │
│                                                             │
│   ┌──────────┐    mpsc     ┌──────────┐    /proc/pid/mem   │
│   │ ipc.rs   │ ──────────→ │ dex.rs   │ ──────────────→    │
│   │ (per-CPU │   channel   │ (worker) │   read memory      │
│   │  reader) │             │          │                     │
│   └──────────┘             └────┬─────┘                     │
│                                 │                            │
│                                 ▼                            │
│                          ┌─────────────┐                     │
│                          │dex_parser.rs│                     │
│                          │ calc real   │                     │
│                          │ file size   │                     │
│                          └──────┬──────┘                     │
│                                 │                            │
│                                 ▼                            │
│                          dump to disk                        │
└─────────────────────────────────────────────────────────────┘

eBPF 内核程序

 1#![no_std]
 2#![no_main]
 3
 4#[map]
 5static UID: Array<u32> = Array::with_max_entries(1, 0);
 6#[map]
 7static DEX_FILE_PTR_EVENT: PerfEventArray<DexEvent> = PerfEventArray::new(0);
 8
 9fn try_dumpling(ctx: ProbeContext) -> Result<u32, u32> {
10    // UID 不匹配直接返回,全设备只多几条指令的开销
11    if let Some(target_uid) = UID.get(0) {
12        if target_uid != &ctx.uid() {
13            return Ok(0);
14        }
15    }
16    // arg5 = const DexFile&
17    let dex_base_ptr = ctx.arg::<u64>(5).unwrap_or(0);
18    DEX_FILE_PTR_EVENT.output(&ctx, &DexEvent {
19        dex_file_base: dex_base_ptr,
20        pid: ctx.pid(),
21        _padding: 0,
22    }, 0);
23    Ok(0)
24}

挂载到 /apex/com.android.art/lib64/libart.soClassLinker::DefineClass,第 6 个参数是 const DexFile&。无论什么加壳方案,类加载都绕不过这个入口。


进程内存读取

封装了一个 Ptr 类型,通过 /proc/{pid}/mem seek + read 实现跨进程内存读取,不需要 ptrace attach:

 1pub struct Ptr {
 2    pub addr: u64,
 3    reader: Rc<RefCell<ProcessMemoryReader>>,
 4}
 5
 6impl Ptr {
 7    pub fn add(&self, offset: u64) -> Self { ... }
 8    pub fn read_pointer(&self) -> Result<Self> { ... }
 9    pub fn read_u32(&self) -> Result<u32> { ... }
10    pub fn read_cpp_string(&self, max_len: usize) -> Result<String> { ... }
11}

其中 read_cpp_string 处理了 libc++ std::string 的 SSO 布局——最低 bit 为 1 是长字符串(堆指针在 +0x10),为 0 是短字符串(数据 inline 在对象里)。用来读 DexFile::location_ 判断是否系统 DEX。


DEX 解析核心:真实大小计算

壳经常篡改 DEX header 的 file_size,不能信。思路是遍历 class_defs → 解析每个类的方法 → 找到 code_off/annotation_off/static_values_off/class_data_off 四个维度的最远偏移 → 加上该数据项自身大小。

 1pub fn parse(&self) -> Result<DexParseResult> {
 2    let dex_ptr = base.add(POINTER_SIZE).read_pointer()?;     // begin_
 3    let dex_size = base.add(POINTER_SIZE * 2).read_usize()?;  // size_
 4
 5    // 先读 1.5 倍
 6    let dex_bytes = dex_ptr.read_byte_array(dex_size * 3 / 2)?;
 7
 8    // 解析结构算真实大小
 9    let last_item_offset = DexParser::from_bytes(&dex_bytes)?.get_farthest_offset()?;
10    let actual_size = last_item_offset.farthest_offset as usize;
11
12    // 不够再读一次
13    let dex_bytes = if actual_size > dex_size {
14        dex_ptr.read_byte_array(actual_size)?
15    } else {
16        dex_bytes
17    };
18    Ok(DexParseResult { dex_bytes, is_system: false })
19}

这里读 1.5 倍并不准确,权宜之策,想要精准的确定大小需要额外的工作量,当时只是想临时用下就没有继续开发了。


去重

同一个 DEX 每个类加载都会触发 uprobe,用 checksum 做 key,Arc<Mutex<HashMap<u32, &str>>> 跟踪状态。每个事件 spawn 一个 tokio task 处理,已见过的 checksum 直接跳过。

实际上发现,Jadx 自己就会去重,这一步除了省内存之外没什么太大的用处。

对抗

  1. Uprobe 并不是无感,虽然是Kernel接管的断点,实际上依然会 patch 成 BKR指令,和软断点没区别,CRC32 一致性过不了。
  2. Uprobe 会覆盖 maps,增加一个 [uprobe] 断点
  3. Uprobe 会修改目标的页属性
  4. 从内存直接解析 Dex 存在一个 Timing ,这个时间点足够壳销毁、Patch Dex 文件所在的内存,可能残缺、格式错误。

局限