pidread-ebpf:用 eBPF 在 Android 上按 PID 只读内存
做 Android 逆向或者运行时分析时,经常会卡在一个很基础的问题上:想只读一下某个进程某一块内存的内容,但拿不到。 ptrace 太重、容易被反调试检测、/proc/<pid>/mem 在很多内核上被限制。有没有一种更轻、更「内核原生」的读法?
pidread-ebpf 就是我对这个问题的回答。一句话:它是一个面向 Android 的单 PID 只读内存读取项目,底层用 eBPF 来做安全的内核内存读取,配合用户态的页表 / VMA 解析,把目标进程的虚拟地址翻译成物理地址读出来。本文讲清楚它是什么、为什么这么设计、以及核心读取链路是怎么跑起来的。
它的定位
设计上非常克制,先把边界划清楚:
- 只处理单个目标 PID;
- 只做读取,不做写入;
- 支持模块基址解析(
libc.so+0x1234这种); - 支持单地址 / 小范围读取;
- 支持一次性批量读取多个地址范围;
- 提供设备侧常驻 helper、TCP 服务和 Python 长连接客户端。
换句话说,它更像是一条「把单进程读取路径工程化」的基线,而不是一个什么都能干的万能内存工具。后续优化重点也明确放在读取延迟、批量效率、连接复用和 helper 稳定性上。
整体架构
代码分成设备侧 C helper 和上层 Python 工具两半,中间用 TCP 通信。
| 文件 | 职责 |
|---|---|
pidread_android_main.c | 设备侧 helper 主入口:CLI、session、TCP server |
pid_reader.c | 公共读取入口与调试计数 |
pidread_pagetable.c | 虚拟地址 → 物理地址翻译 + 单范围读取 |
pidread_batch.c | 批量读取与 grouped-read 合并 |
pid_modules.c | 模块枚举与基址解析 |
ebpf_runtime.c | eBPF 运行时:加载 mem.ebpf.o、触发内核读 |
ebpf/mem.ebpf.c | eBPF 程序本体:uprobe / XDP 触发器 + mmap 结果 map |
pidread_proxy.py | 启动设备侧 TCP helper、管理转发/直连 |
pidread_client.py | 长连接命令客户端 |
pidread_reader.py | 上层嵌入式读取 API |
pidread_session.py | --session-stdin 模式的 Python 启动器 |
核心读取链路:怎么读「另一个进程」的内存
这是整个项目最值得讲的部分。要读目标进程的用户态虚拟地址,难点在于——页表本身在内核里。所以读取分两步走。
第一步:用 eBPF 安全地读内核内存
要读内核内存(页表项、内核结构体),最干净的方式是走 eBPF 的 bpf_probe_read_kernel。在 ebpf/mem.ebpf.c 里有一个 read_memory():它把内核虚拟地址传进去,验证「这确实像个内核指针」后,用 bpf_probe_read_kernel 把字节拷进一个 mmap 可读的 BPF array map(read_mem_array_map,带 BPF_F_MMAPABLE 标志)。用户态这边 mmap 同一块 map,就能零拷贝拿到结果。
/* mem.ebpf.c 节选:拒绝非内核地址,再 probe read */#ifdef __TARGET_ARCH_arm64 if (address < 0xfff0000000000000ULL) { /* 不像内核指针,拒绝 */ read_mem_result->ret_code = -EINVAL; return 0; }#endifread_mem_result->ret_code = bpf_probe_read_kernel((void *)(&read_mem_result->buf), dump_size, (void *)address);注:eBPF 程序里对地址做了硬下限校验(arm64 上必须
>= 0xfff0000000000000),并且 size 被 clamp 到HUGE_PAGE_SIZE,从内核侧把风险关死。
第二步:走目标进程的页表,把用户 VA 翻译成物理地址
有了「读内核内存」的能力,就可以去读目标进程的页表了。pidread_pagetable.c 里的 walk_arm64_page_tables():
- 从
mm->pgd(通过内核读拿到 pgd 的内核虚拟地址)出发; - 按当前
va_bits(48/52/47/42/39/36 候选自动探测)逐级索引 PGD→PUD→PMD→PTE; - 每级表项也是通过
read_kernel_memory读出来的; - 落到叶子表项后,把块内偏移加上去,得到目标虚拟地址对应的物理地址。
拿到物理地址后,再用 phys_to_virt 换算回内核虚拟地址,调用 read_kernel_memory 把那一页读出来。整个 read_user_page_chunk() 就是在做「VA→phys→kernel read→memcpy 到用户 buffer」这一串。
这里有个关键依赖:物理地址怎么转回内核虚拟地址? 答案是内核的 memstart_addr / kimage_voffset 这类符号。mem.ebpf.c 里的 resolve_kernel_metadata 通过 bpf_kallsyms_lookup_name 在内核里解析 memstart_addr、vabits_actual、kimage_vaddr、kimage_voffset,ARM64 的 VA bits 优先取 CONFIG_ARM64_VA_BITS(CORE 构建),否则在用户态用 arm64_vabits_actual() 通过探测 mmap 可接受的最高地址来反推。
eBPF 的触发:三条路径,自动降级
eBPF 程序真正去「读内核内存」的那一下,需要一个触发点。项目实现了三种并自动 fallback:
- UPROBE:把
read_kernel_memory_uprobe挂到 helper 自身二进制里的_read_kernel_memory桩函数(/proc/self/exe:_read_kernel_memory)。用户态一调用这个 stub,uprobe 就被触发,在参数里拿到地址和长度直接读。(非 helper 构建默认优先。) - XDP:一个挂在
lo回环口上的 XDP 程序,解析发往127.0.0.1:9999的 UDP 包,从 payload 里取出addr/size/op执行读取。用户态只要sendto一个 UDP 包即可。(helper 构建 /-x默认走这条。) - BPF_PROG_TEST_RUN:如果 uprobe 和 XDP 都挂不上,就退化成手工构造一个 Ethernet/IPv4/UDP 包,用
bpf_prog_test_run直接喂给 XDP 程序跑一遍。(-t强制走这条。)
加载时(load_pidread_ebpf)会依次尝试 uprobe → XDP → PROG_TEST_RUN,哪条成功用哪条。结果统一写到那块 mmap 的 array map 里,用户态轮询读取,避免了每次读都走一遍 syscall 把数据搬回来。
模块基址解析
pid_modules.c 负责枚举目标进程的映射(/proc/<pid>/maps 风格的 VMA),给出模块列表和基址。CLI 上对应:
--list-modules:列出目标 PID 当前映射到的模块;--module-base <name-or-path>:只返回模块基址(不顺便读数据);- 读取表达式支持
libc.so+0x0:16、libc.so!0x0:16这种「模块 + 偏移」,批量用逗号 / 分号分隔。
如果模块 basename 有歧义,就传完整映射路径。
设备侧 helper 与协议
构建出来的 pidread.ebpf.android 支持这些模式:
--read-virt <expr>:读一个地址范围;--read-batch <file|inline>:一次读多个范围(一请求一结果,不会偷偷折成一个大块);--bench-read <expr>:在 helper 进程内重复读同一目标,测读取路径本身;--session-stdin:常驻会话,从 stdin 连续收命令;--tcp-server <port>:启动 TCP helper 给上层 Python 复用;--tcp-server-public绑定0.0.0.0用于局域网直连。
session 和 TCP 共用一套紧凑命令语义。短命令:r <expr> / rb <expr1,expr2> / m <module> / lm / stats / group-reads on|off / shutdown / q;长命令:read-virt / read-batch / module-base / list-modules / out-format。响应是 OK READ ... / OK BATCH ... / OK MODULE ... / ERR <code> <message> 这种。TCP helper 同时支持文本行协议和二进制协议,Python 侧优先走二进制。
Python 工具链
上层工具已经统一成 Python,方便嵌到自己的分析脚本里:
pidread_proxy.py:用 adb 启动设备侧 TCP helper,管理adb-forward或局域网直连(--transport adb-forward|direct,推荐direct,数据直走手机局域网地址)。pidread_client.py:长连接 TCP 客户端,适合反复下发文本命令、做重复读测试、手动验状态,默认复用同一连接。pidread_reader.py:面向嵌入调用的 API,提供get_module_base()/get_module_info()/read()/read_bytes()/read_many()/read_many_batch()/read_many_individual()/iter_read_many()/set_group_reads()/get_stats_line(),以及类型化 helperread_u8/u16/u32/u64/i32/i64/ptr/cstring()。请求支持字符串表达式、(address, size)、(module, offset, size)三种写法。pidread_session.py:--session-stdin的 Python 启动器,临时起会话、复用设备侧缓存做交互式读取。
构建
Windows 下构建 Android helper
仓库保留的入口是:
powershell -ExecutionPolicy Bypass -File .\build-android.ps1 -Strip默认行为:自动从本机 Android SDK 找 NDK、用 aarch64-linux-android clang 构建、从手机拉取 /system/lib64/libbpf.so,产物是 pidread.ebpf.android + mem.ebpf.o + libbpf.so。
Linux 下本地构建
最小 Makefile 适合在 Linux 直接构建:
make MODE=nocoreuni可选 core / nocore / nocoreuni。其中 nocoreuni 走 nocore_universal.h 手写内核类型头,不需要 vmlinux.h(BTF),可移植性最好。
一个最小工作流
# 1. 设备侧直接读模块基址.\build-android\pidread.ebpf.android -t --pid 26389 --module-base /lib64/bionic/libc.so --out-format json
# 2. 设备侧读一个地址范围.\build-android\pidread.ebpf.android -t --pid 26389 --read-virt libc.so+0x0:16 --out-format json
# 3. 批量读 + 邻近合并抓取.\build-android\pidread.ebpf.android -t --pid 26389 --read-batch "libc.so+0x0:16,libc.so+0x10:16" --group-reads --out-format json
# 4. 局域网直连代理(推荐)python .\pidread_proxy.py --target-pid 26389 --trigger test_run --transport direct --push用 Python API 读取则更简洁:
from pidread_reader import PidReadReader
with PidReadReader(host="192.168.1.50", port=31337) as reader: libc_base = reader.get_module_base("/lib64/bionic/libc.so") header = reader.read(("libc.so", 0, 16)) values = reader.read_many([ ("libc.so", 0x0, 16), ("libc.so", 0x10, 16), ]) print(hex(libc_base), header.hex(), len(values))明确不做什么
项目边界划得很清楚,避免被误用或膨胀:
- 不做写内存;
- 不做全内存 dump;
- 不把
--module-base混入读数据接口; - 不把
--read-batch的多个结果偷偷折成单块输出。
⚠️ 它是 root / 内核级的只读调试实验代码,只建议在自己拥有且可控的设备上做研究。读取的是进程内存,请务必只针对你有权分析的目标,遵守相关安全与法律边界。
pidread-ebpf 是我把「eBPF 安全读内核 + 页表翻译 + 用户态读取」这套组合在 Android 上跑通的一次实践。最让我满意的点是那个三级触发自动降级(uprobe → XDP → PROG_TEST_RUN)——在不同内核 / 不同权限环境下都能尽量把读取链路建立起来。如果有人感兴趣,后面可以单独写一篇讲讲 ARM64 页表遍历里 va_bits 探测和 phys_to_virt 换算的那些坑。
项目源码与构建脚本在本地工程 30_Android_EBPF_PID_Read 中。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





