本文介绍了proc/pid/maps文件在Android/Linux系统中的作用,并且实现对其的读取和解析。
proc/pid/maps以文件的形式记录了进程当前映射的虚拟内存区域信息,是我们了解应用进程内存状态的重要途径。
例如常见的adb shell dumpsys meminfo 命令,实际就是通过解析proc/pid/maps来收集、分类和统计内存信息的。具体代码见android_os_Debug.cpp。
另外,通过读取和解析proc/pid/maps,可以在内存中找到已经加载到内存的so库(动态库),进而对动态库(本质是ELF格式文件)进行分析甚至修改。这是proc/pid/maps在一些Android Hook过程中起的主要作用。
如果你从没有了解过ELF文件格式,那么可以把Android Hook - 动态加载so库当做一个很好的开始,先了解ELF文件头、Section表和Program表的概念。
一、背景
1、proc/pid/maps介绍
1、proc/[pid]/maps介绍
在 Linux 系统(包括 Android)中,/proc/[pid]/maps 是一个非常有用的文件,提供了进程虚拟内存映射的详细信息。
其中的pid是变量,即你要关注的进程的id。如果你的目标进程是当前进程,那么也可以使用**/proc/self/maps** 来读取,而不需要拼接当前进程的id。
具体描述见官方文档proc_pid_maps(5) — Linux manual page。
上图粗略展示了进程的虚拟内存布局,而/proc/[pid]/maps 正是具体记录了每段内存的范围和权限等信息。
当动态库通过mmap的方式加载到内存后,也会占用一段连续的内存,进而在/proc/[pid]/maps 有对应的记录。
2、查看/proc/[pid]/maps
由于/proc/[pid]/maps 是以文件的形式提供的,因此要查看/proc/[pid]/maps 就是查看该文件的内容。
首先我们需要查找的到目标进程id,这里以应用进程为例:
adb shell ps | grep com.muye.mapsvisitor(进程名)
输出:
u0_a172 5864 341 15167200 140668 do_epoll_wait 0 S com.muye.mapsvisitor
其中的5864就是进程pid。如果你不了解ps指令,可以阅读官方文档。
之后,我们只需要使用cat指令查看该进程对应的/proc/[pid]/maps 文件即可:
adb shell cat /proc/5864/maps
输出:
12c00000-2ac00000 rw-p 00000000 00:00 0 [anon:dalvik-main space (region space)]
704ee000-707a7000 rw-p 00000000 00:00 0 [anon:dalvik-/system/framework/boot.art]
707a7000-707f0000 rw-p 00000000 00:00 0 [anon:dalvik-/system/framework/boot-core-libart.art]
707f0000-7081a000 rw-p 00000000 00:00 0 [anon:dalvik-/system/framework/boot-okhttp.art]
7081a000-70858000 rw-p 00000000 00:00 0 [anon:dalvik-/system/framework/boot-bouncycastle.art]
...
6fef1c4000-6fef1c5000 r-xp 00000000 00:00 0 [vdso]
6fef1c5000-6fef1c7000 rw-p 00158000 07:48 14 /apex/com.android.runtime/bin/linker64
6fef1c7000-6fef1d1000 rw-p 00000000 00:00 0 [anon:.bss]
6fef1d1000-6fef1d2000 r--p 00000000 00:00 0 [anon:.bss]
6fef1d2000-6fef1d4000 rw-p 00000000 00:00 0 [anon:.bss]
7fecbde000-7fecbdf000 ---p 00000000 00:00 0
7fecbdf000-7fed3de000 rw-p 00000000 00:00 0 [stack]
可以看出,/proc/[pid]/maps 文件内容是**以行为单位(即\n)**来进行分隔的,每一行就代表一块内存区域的信息。
3、/proc/[pid]/maps 内容格式
以proc_pid_maps(5) — Linux manual page中的例子为例,介绍/proc/[pid]/maps 内容格式。
address perms offset dev inode pathname
00400000-00452000 r-xp 00000000 08:02 173521 /usr/bin/dbus-daemon
图中从左至右各个数据段的解释如下:
| 字段 | address | perms | offset | dev | inode | pathname |
|---|---|---|---|---|---|---|
| 数据 | 00400000-00452000 | r-xp | 00000000 | 08:02 | 173521 | /usr/bin/dbus-daemon |
| 含义 | 本段内存映射的虚拟地址范围 | 读写权限 | 本段映射地址在ELF文件中的偏移 | 主设备号(major):从设备号(minor) | 文件的inode | 通常是文件内存映射的文件路径。非文件映射则可能伪路径(以[开始)。 |
更具体的,每个字段的作用为:
- address。表示内存区域的范围,即起始地址和结束地址。
- perms。进程对该内存区域的权限,由四个标志位表示,按照顺序为可读(r)、可写(w)、可执行(x)、进程私有还是共享(p或s),如果没有对应的权限,则记录为**
-**。 - offset。该区域在文件中的偏移。如果该内存区域是由文件映射而来的,那么offset表示其在文件中的偏移。
- dev。主设备号(major):从设备号(minor)。主设备号(major device number)和从设备号(minor device number)是 Linux 和类 Unix 操作系统中用来标识设备的编号。
- inode。如果该内存区域是由文件映射而来的,那么
inode表示该文件对应的inode节点。 - pathname。
- 除了换行符会被替换成8进制的转义序列,其他字符都是未转义的。因此,无法确认原生路径是包含一个换行符,还是一个
\012的字符。 - 如果该内存区域是由文件映射而来的,
pathname表示该文件的路径。如果内存区域对应的文件被删除,那么字符串(deleted)会被拼接在pathname后面。 - 如果
pathname是空的,那么这是匿名内存映射获得的内存。 pathname可能表示内存区域的别名(即特殊含义),通常用[]包裹。
- 除了换行符会被替换成8进制的转义序列,其他字符都是未转义的。因此,无法确认原生路径是包含一个换行符,还是一个
2、常见的特殊内存区域
proc/pid/maps文件中,除了加载动态库而产生的内存区域,还有一些系统分配或匿名内存映射产生的特殊内存区域。
下面来介绍一下这些区域,它们都有特殊的名称,用于标记用途。
# 1、虚拟机相关内存
12c00000-2ac00000 rw-p 00000000 00:00 0 [anon:dalvik-main space (region space)]
...
# 2、(deleted)表明内存对应的文件被删除
9ae13000-9ce13000 r--s 00000000 00:01 2053 /memfd:jit-zygote-cache (deleted)
...
# 3、应用动态库依赖的libc++.so
6c9b84b000-6c9b93a000 r-xp 00000000 fe:25 49258 /data/app/~~XE-nAtAKH6cywqWbw7mnAw==/com.muye.mapsvisitor-kFJRcK0BFsP7rwqt8KVxSQ==/lib/arm64/libc++_shared.so
...
# 4、线程栈内存,线程id为5889,这里是不可读写执行的,说明这是监听栈溢出的保护内存
6ca2041000-6ca2042000 ---p 00000000 00:00 0 [anon:stack_and_tls:5889]
# 5、线程栈内存,线程id为5889,这是真正可用的栈内存,包括存储thread local变量内存
6ca2042000-6ca2149000 rw-p 00000000 00:00 0 [anon:stack_and_tls:5889]
...
# 6、[anon:表明这是一块匿名内存映射
6cb3d03000-6cf3d03000 ---p 00000000 00:00 0 [anon:libwebview reservation]
...
# 7、apex依赖的libc++.so
6cf3eae000-6cf3efd000 r--p 00000000 07:30 71 /apex/com.android.vndk.v34/lib64/libc++.so
...
# 8、属于 Scudo 内存分配器的主分配区域。Scudo 是一种现代的内存分配器,用于提高内存安全性和减少碎片化。
6d27314000-6d27394000 rw-p 00000000 00:00 0 [anon:scudo:primary]
...
# 9、system级别动态库依赖的libc++.so
6fcea71000-6fceac0000 r--p 00000000 fe:00 1817 /system/lib64/libc++.so
..
# 10、动态链接器(linker)分配的内存区域,用于加载和链接共享库
6feddec000-6fedeb4000 r--p 00000000 00:00 0 [anon:linker_alloc]
# 11、Bionic(Android 中的 C 标准库)分配的内存区域,用于存储小对象
6fedeb4000-6fedeb7000 rw-p 00000000 00:00 0 [anon:bionic_alloc_small_objects]
...
# 12、主线程的栈和 TLS 区域
6fedecf000-6feded3000 rw-p 00000000 00:00 0 [anon:stack_and_tls:main]
...
# 13、处理线程信号(例如,处理 SIGSEGV 信号等)分配的信号栈
6fef1a5000-6fef1ad000 rw-p 00000000 00:00 0 [anon:thread signal stack]
...
# 14、vvar 区域通常用于存放与系统调用相关的某些共享数据。特别地,它用于加速与时间戳相关的操作,如 gettimeofday、time 和 clock_gettime 等系统调用。这些系统调用会在 vvar 中存放一些内核状态信息,从而避免了频繁的系统调用切换。
6fef1c2000-6fef1c4000 r--p 00000000 00:00 0 [vvar]
# 15、虚拟动态共享对象(VDSO)映射,它是操作系统提供的一种内存区域,用于加速系统调用
6fef1c4000-6fef1c5000 r-xp 00000000 00:00 0 [vdso]
...
# 16、程序的堆栈区域,用于存储线程的局部变量和函数调用信息
7fecbdf000-7fed3de000 rw-p 00000000 00:00 0 [stack]
注释中已经初步介绍了应用进程常见的内存区域,下面进一步解释以加深大家对/proc/[pid]/maps 文件的理解。
- Android虚拟机相关的内存。通常以
[anon:dalvik开头,是虚拟机分配对象和进行GC的场所,不同版本虚拟机根据回收机制不同,可能有不同的内存区域。 - pathname以
(deleted)结尾,表明内存对应的文件被删除。 - pathname以
[anon:开头,表明这是一块通过匿名内存映射申请的内容。 [anon:stack_and_tls:pid]线程栈内存。每个原生线程都有对应的线程栈内存,这块内存分为监听栈溢出的保护内存和真正可用的**栈内存(**包括存储thread local变量内存)。[anon:thread signal stack]信号栈内存。信号处理函数执行时可以使用的栈内存。- 系统内存分配机制所预申请的内存。包括
[anon:scudo:primary]、[anon:bionic_alloc_small_objects]、[anon:linker_alloc]这些都是系统自身使用到的内存。 - 系统特殊用途的内存。例如
[vdso]、[vvar]。 - 动态链接器Namespace机制。从上述例子可以发现有多个
libc++.so,这是由于动态链接器Namespace机制,使得不同层次的动态库可以依赖不同版本的libc++.so。
3、ELF文件Program表(段表)
在了解完成常见特殊内存区域后,我们还是回到关注的重点,即动态库在内存中的分布。
在此之前,需要进一步(假设你已经读过Android Hook - 动态加载so库)介绍ELF中**Program表(段表)**的内容。
以/system/lib64/libc++.so这个动态库(从模拟器或真机中导出,笔者使用的是API 34的模拟器)为例,通过readelf指令查看其Program表(段表)。
/Users/XXX/Library/Android/sdk/ndk/26.1.10909125/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -l libc++.so
输出:
# 1、DYN表示这个ELF文件是一个动态库
Elf file type is DYN (Shared object file)
# 2、动态库的入口地址都是0x0,因为需要被动态链接器加载到内存才能确认真实的内存地址
Entry point 0x0
# 3、这个ELF文件的段表包含10个项,段表从文件中的 偏移量 64(即 0x40)开始
There are 10 program headers, starting at offset 64
# 4、接下来是逐个段表的信息
Program Headers:
# 5、信息含义依次分别为:段的类型(Type)、段起始在文件中的偏移(Offset)、段的虚拟内存地址(VirtAddr)、段的物理内存地址(PhysAddr)、段在文件中的大小(FileSiz)、段在内存中的大小(MemSiz)、段的权限(Flg)、段的内存对齐大小(Align)
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
# 6、段类型为PHDR,即段表本身也是一个段,偏移量是0x000040,正好和前面starting at offset 64一致
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x000230 0x000230 R 0x8
# 7、段类型为LOAD,表明该段需要被加载到内存中
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x04eb70 0x04eb70 R 0x10000
LOAD 0x050000 0x0000000000050000 0x0000000000050000 0x05a010 0x05a010 R E 0x10000
LOAD 0x0b0000 0x00000000000b0000 0x00000000000b0000 0x006550 0x006550 RW 0x10000
LOAD 0x0b6550 0x00000000000c6550 0x00000000000c6550 0x000029 0x003120 RW 0x10000
# 8、段类型为DYNAMIC,表示DYNAMIC段,记录该库的动态链接信息
DYNAMIC 0x0b4e78 0x00000000000b4e78 0x00000000000b4e78 0x0001d0 0x0001d0 RW 0x8
GNU_RELRO 0x0b0000 0x00000000000b0000 0x00000000000b0000 0x006550 0x007000 R 0x1
GNU_EH_FRAME 0x039b30 0x0000000000039b30 0x0000000000039b30 0x003324 0x003324 R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x0
NOTE 0x000270 0x0000000000000270 0x0000000000000270 0x000038 0x000038 R 0x4
# 9、Segment(段)和Section(节)的关系。其中00、01、..等表示前面段表的序号
Section to Segment mapping:
Segment Sections...
# 10、00表示第一个段,即类型为PHDR的段,即段表本身,不对应任何节
00
# 11、01表示第二个段,对应下来的节。其他依次类推。
01 .note.android.ident .note.gnu.build-id .dynsym .gnu.version .gnu.version_r .gnu.hash .dynstr .rela.dyn .relr.dyn .rela.plt .rodata .gcc_except_table .eh_frame_hdr .eh_frame
02 .text .plt
03 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt
04 .data .bss
05 .dynamic
06 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt
07 .eh_frame_hdr
08
09 .note.android.ident .note.gnu.build-id
# 12、剩余的节没有对应的段,说明这些节不会被运行时使用到,即不会被加载到内存当中
None .shstrtab .gnu_debugdata
注释中对Program表(段表)信息进行了描述,请务必按顺序读过一遍。由于这部分信息细节很多,因此不得不进一步解释:
- 段类型为
LOAD说明该段才会被加载到内存中。- Android Hook - 动态加载so库中提到,段表是动态链接器以运行视图的角度来解释ELF文件的方式,而Section表(节表)则是编译器以链接视图的角度解释ELF文件。
- 通常来说,ELF运行时,并不是所有的节都需要被加载到内存中。例子
.shstrtab、.gnu_debugdata这两个节就没有被加载到内存。
- 段和节有对应关系。
readelf输出的最后,记录了段和节的对应关系。以序号为02的段(即代码段)为例,对应的节是.text和.plt。
- 类型为
LOAD的段主要以读写权限进行划分。- 例子中有按顺序(序号01-04)有4个类型为
LOAD的段,其权限分别为R、R E、RW、RW,这和我们常说的文本段(.text)、代码段(.code)、数据段(.data)、**BSS段(.bss)**有点类似,但并不完全对应,例如序号为04的段实际包含.data和.bss两个节,可以说它一部分是数据段,一部分是BSS段(这里由于历史翻译的关系,段和节的称呼有些混淆,实际称为数据节、BSS节更合适)。
- 例子中有按顺序(序号01-04)有4个类型为
- 其他类型的段,实际是被段类型为
LOAD包含。- 以DYNAMIC段为例,可以看到它的Offset为
0x0b4e78,FileSiz为0x0001d0,而序号为03,类型为LOAD的段Offset为0x0b0000,FileSiz为0x006550,可见该段实际包含DYNAMIC段。 - 通过段和节的对应关系,也可以看出段之间的包含关系。例如序号为
03的段包含名为.dynamic的节,DYNAMIC段也包含这个节。
- 以DYNAMIC段为例,可以看到它的Offset为
FileSiz和MemSiz可能不相等。FileSiz表示段在文件中的大小、MemSiz表示段加载到内存后的大小。- 通常情况下,FileSiz和MemSiz是相等的,但是序号为04的段中两者分别为
0x000029和0x003120,即MemSiz大于FileSiz。 - 为什么FileSiz和MemSiz会不相等的呢?原因是序号为04的段实际对应两个节,即
.data和.bss,我们知道.data记录的是已经有初始值的数据,而.bss则记录未决初始化的数据,即值为0的数据。对于ELF来说,为了减少文件的大小,没有必要把值全为0的.bss节真实记录到文件中(即占用文件空间来存储连续的0),只需要记录在段表中即可。当该段被加载到内存时,才实际为.bss节申请内存空间。因此,这里FileSiz实际只表示.data节的大小,而MemSiz表示该段被加载到内存后,.data节和.bss节的总大小。
4、动态库内存分布
在了解ELF文件段表结构以后,我们知道类型为LOAD的段会被加载到内存。
接下来看看,这些段被加载到内存后是怎么分布的。仍然以/system/lib64/libc++.so为例。
address perms offset dev inode pathname
...
6fcea71000-6fceac0000 r--p 00000000 fe:00 1817 /system/lib64/libc++.so
6fceac0000-6fceac1000 ---p 00000000 00:00 0
6fceac1000-6fceb1c000 r-xp 00050000 fe:00 1817 /system/lib64/libc++.so
6fceb1c000-6fceb21000 ---p 00000000 00:00 0
6fceb21000-6fceb28000 r--p 000b0000 fe:00 1817 /system/lib64/libc++.so
6fceb28000-6fceb37000 ---p 00000000 00:00 0
6fceb37000-6fceb38000 rw-p 000b6000 fe:00 1817 /system/lib64/libc++.so
6fceb38000-6fceb3b000 rw-p 00000000 00:00 0 [anon:.bss]
...
由于**offset表示的是该内存区域在文件中的偏移**,根据这个对应关系,我们发现:
- 前三个
pathname为/system/lib64/libc++.so的内存区域,即6fcea71000-6fceac0000、6fceac1000-6fceb1c000、6fceb21000-6fceb28000,实际对应的就是ELF文件中序号为01、02、03的段。通过perms(内存权限),我们也可以再次确认这一点。 - 按理来说,接下来应该就是序号为
04的段,但是通过观察,加载到内存时,这个段被分成两块内存区域,即6fceb37000-6fceb38000和6fceb38000-6fceb3b000。原因和前面提到的是一致的,该段实际由.data和.bss两个节组成,.data有实际的文件空间和它对应,因而被加载到内存后,其pathname应该有值,为/system/lib64/libc++.so。而.bss实际没有存储在ELF文件中,pathname不能对应/system/lib64/libc++.so,因此系统给了它一个别名**[anon:.bss]**。 - 最后,还观察到前三个段后面,都有一段权限为**
---p的内存区域,意味着这块内存是不能读写执行的。例如6fcea71000-6fceac0000后面紧跟着6fceac0000-6fceac1000。这些区域的存在,是由于内存对齐和文件偏移**的需要。- 以
6fcea71000-6fceac0000内存区域为例,它占用的内存大小为0x6fceac0000-0x6fcea71000 = 0x4F000,而对应的序号为01的段,该段的MemSiz为0x04eb70,之所以占用的内存大小不是0x04eb70而是0x4F000,是因为内存页大小是系统内存分配的基本单位,由于这里系统内存页大小为4k(即0x1000),因此需要对齐到0x4F000,即0x1000的整数倍。 - 还有就是需要考虑段的Offset(文件偏移)。序号为
02的段,在文件中的偏移为0x00050000,根据它和序号为01的段在文件中的偏移关系,可以计算出序号为02的段的在内存中的起始地址应该是0x6fcea71000 + 0x00050000 = 0x6fceac1000,正好和proc/pid/maps看到的一致。但是0x00050000大于0x4F000,即有0x00050000 - 0x4F000 = 0x1000大小的内存是多余的,这部分内存就是6fceac0000-6fceac1000。
- 以
二、实现
1、解析proc/pid/maps
前文介绍虽然额外介绍了动态库中的段在内存中的分布情况,但即使不完全理解,其实也不影响对
proc/pid/map的解析。
proc/pid/maps内容是以换行符分隔的文本,并且具有固定的格式,即每行由address 、perms 、offset、dev 、 inode 、pathname组成。
因此使用代码解析proc/pid/maps文件实际很简单,但是这里仍有一些注意点:
- 使用c语言而不是c++实现。原因是解析
proc/pid/maps作为基础能力,后续会用于一些hook机制的实现,因此避免对c++标准库的依赖。 - 使用迭代器模式。这个则见仁见智,作者使用迭代器的原因是使用上更灵活,尤其是可以复用迭代器而避免重复打开
proc/pid/maps文件。读者也可以选择使用回调等方式提供对proc/pid/maps文件的遍历访问接口。
下面是使用sscanf解析的例子,供大家参考:
bool maps_visitor_has_next(MapsVisitor_t *visitor) {
if (!maps_visitor_valid(visitor)) {
return false;
}
//读取下一行
return fgets(visitor->buffer, sizeof(visitor->buffer), visitor->fd) != NULL;
}
MapItem* maps_visitor_next(MapsVisitor_t *visitor, MapItem *mapItem) {
if (!maps_visitor_valid(visitor)) {
return NULL;
}
int pathPosition;
//1、按照固定格式,从一行字符串中解析出address` 、`perms `、`offset`、`dev` 、 `inode` 、`pathname`
sscanf(visitor->buffer,
"%" PRIxPTR"-%" PRIxPTR" %4s %" PRIxPTR" %" PRIxPTR":%" PRIxPTR" %" PRIuMAX" %n",
&mapItem->start_address, &mapItem->end_address, mapItem->permission, &mapItem->offset,
&mapItem->major_dev, &mapItem->minor_dev, &mapItem->inode, &pathPosition);
//2、截取pathname
char *path = visitor->buffer + pathPosition;
size_t len = strlen(path);
if (len && path[len - 1] == '\n') {
path[len - 1] = '\0';
}
if (len == 0) {
mapItem->path[0] = '\0';
} else {
strncpy(mapItem->path, path, len);
}
return mapItem;
}
更完整的代码从MapsVisitor获取。
2、proc/pid/maps的缺陷
由于proc/pid/maps实时反映进程内存的分布情况,因此对proc/pid/maps的读取本质上是并发不安全的,即proc/pid/maps可能在读取时就被修改。
但是操作系统提供了一些保证(参考自linker_jni.cc):
- 每个内存区域记录(即每行)的格式是规范的,不会出现解析错误或格式问题。
- 如果在整个
/proc/pid/maps读取过程中,某个虚拟地址vaddr是有效的且存在的,那么在输出中一定会显示与这个地址相关的记录。
三、总结
本文介绍了proc/[pid]/maps文件的作用和内容格式,并结合实际例子,让读者了解应用进程的内存分布情况,熟悉一些常见的内存区域。
在此基础上,进一步详细介绍了ELF文件的Program表(段表),和段表被加载到内存后的分布情况,以说明两者之间的映射关系。
最后,说明使用代码读取并解析proc/pid/maps文件的思路和注意点。
解析proc/pid/maps文件的一个目的,就是在内存中找到目标动态库,进而对动态库(本质是ELF格式文件)进行分析甚至修改。
这将在后续文章,将结合实际的Hook技术进行运用,欢迎收藏关注。
四、写在最后
1、源码下载
2、免责声明
本文涉及的代码,旨在展示和描述方案的可行性,可能存bug或者性能问题。
不建议未经修改验证,直接使用于生产环境。
3、转载声明
本文欢迎转载,转载请注明出处。
4、留言讨论
你是否也在现实开发中遇到类似的场景或者应用,是否有更多的意见和想法,欢迎留言一起学习讨论。
5、欢迎关注
如果你对更多的Android Hook开发技巧、思路感兴趣的,欢迎关注我的栏目。
后续将提供更多优质内容,硬核干货。