mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2222 字
6 分钟
pidread-ebpf:用 eBPF 在 Android 上按 PID 只读内存

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.ceBPF 运行时:加载 mem.ebpf.o、触发内核读
ebpf/mem.ebpf.ceBPF 程序本体: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 mapread_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;
}
#endif
read_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()

  1. mm->pgd(通过内核读拿到 pgd 的内核虚拟地址)出发;
  2. 按当前 va_bits(48/52/47/42/39/36 候选自动探测)逐级索引 PGD→PUD→PMD→PTE;
  3. 每级表项也是通过 read_kernel_memory 读出来的;
  4. 落到叶子表项后,把块内偏移加上去,得到目标虚拟地址对应的物理地址

拿到物理地址后,再用 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_addrvabits_actualkimage_vaddrkimage_voffset,ARM64 的 VA bits 优先取 CONFIG_ARM64_VA_BITS(CORE 构建),否则在用户态用 arm64_vabits_actual() 通过探测 mmap 可接受的最高地址来反推。

eBPF 的触发:三条路径,自动降级#

eBPF 程序真正去「读内核内存」的那一下,需要一个触发点。项目实现了三种并自动 fallback:

  1. UPROBE:把 read_kernel_memory_uprobe 挂到 helper 自身二进制里的 _read_kernel_memory 桩函数(/proc/self/exe:_read_kernel_memory)。用户态一调用这个 stub,uprobe 就被触发,在参数里拿到地址和长度直接读。(非 helper 构建默认优先。)
  2. XDP:一个挂在 lo 回环口上的 XDP 程序,解析发往 127.0.0.1:9999 的 UDP 包,从 payload 里取出 addr/size/op 执行读取。用户态只要 sendto 一个 UDP 包即可。(helper 构建 / -x 默认走这条。)
  3. 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:16libc.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(),以及类型化 helper read_u8/u16/u32/u64/i32/i64/ptr/cstring()。请求支持字符串表达式、(address, size)(module, offset, size) 三种写法。
  • pidread_session.py--session-stdin 的 Python 启动器,临时起会话、复用设备侧缓存做交互式读取。

构建#

Windows 下构建 Android helper#

仓库保留的入口是:

Terminal window
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。其中 nocoreuninocore_universal.h 手写内核类型头,不需要 vmlinux.h(BTF),可移植性最好。

一个最小工作流#

Terminal window
# 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 中。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

pidread-ebpf:用 eBPF 在 Android 上按 PID 只读内存
https://zhizhu-blog.pages.dev/posts/pidread-ebpf/
作者
蜘蛛
发布于
2026-07-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录