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.so 的 ClassLinker::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 自己就会去重,这一步除了省内存之外没什么太大的用处。
对抗
- Uprobe 并不是无感,虽然是Kernel接管的断点,实际上依然会 patch 成 BKR指令,和软断点没区别,CRC32 一致性过不了。
- Uprobe 会覆盖 maps,增加一个 [uprobe] 断点
- Uprobe 会修改目标的页属性
- 从内存直接解析 Dex 存在一个 Timing ,这个时间点足够壳销毁、Patch Dex 文件所在的内存,可能残缺、格式错误。
局限
- 需要 root(CAP_BPF + CAP_PERFMON)
DexFile对象布局偏移写死,不同 Android 版本可能不同- compact DEX (cdex) 直接跳过
- 内核需要支持 eBPF uprobe、 PerfEventArray,大概 5.2 以上的内核版本就能用。