4058 字
20 分钟
mmap

进程的内存地址空间:隔离的虚拟世界#

首先,核心概念是进程地址空间 (Process Address Space)。操作系统为了实现进程间的隔离,会为每个进程分配一个独立的、从0开始的虚拟地址范围(VAS, Virtual Address Space)。 这意味着,进程A的地址0x66ccff和进程B的地址0x66ccff在物理内存中是完全不同的位置。这种虚拟地址到物理地址的转换由CPU的内存管理单元(MMU)在操作系统的页表(Page Table)辅助下完成。

一个典型的Linux进程地址空间从低地址到高地址通常包含以下部分:

  • 代码段 (.text): (r-x)存放可执行文件的二进制代码。
  • 数据段 (.data, .rodata, .bss): (r—)存放已初始化、只读数据、未初始化的全局变量和静态变量。
  • 堆 (Heap): 动态内存分配区域,通过mallocnew等函数申请的内存位于此处,libc 通过 brkmmap 系统调用在此区域分配内存,堆地址从低向高增长。
  • 内存映射区 (Memory Mapping Segment): 通过 mmap 系统调用映射文件或分配匿名内存的区域。动态链接库(共享库)也加载在此处。
  • 栈 (Stack): 用于维护函数调用帧,存放函数参数、局部变量和返回地址,栈地址从高向低增长。
  • [vDSO] 与 [vvar]: 特殊的内核映射区域,用于加速特定系统调用,后文详述。
  • 内核空间: 所有进程共享同一个内核空间。地址空间的最高部分保留给内核使用。用户态代码无法直接访问此区域,只有在发生系统调用、中断或异常时,CPU才会从用户态切换到内核态并在此空间执行代码。

如何窥探进程地址空间?#

  • /proc/[pid]/maps: 这是内核提供的一个虚拟文件,实时展示了指定进程ID(pid)的内存布局。 每一行都代表一个内存段(VMA, Virtual Memory Area),详细列出了该段的地址范围、权限(读/写/执行)、映射到文件的偏移量、设备号、inode以及映射的文件名。
  • pmap (Process Memory Map): pmap 命令是一个用户态工具,它通过解析/proc/[pid]/maps文件,以更友好的方式展示进程的内存映射。 使用-x选项可以显示更详细的信息。 pidof game命令可以获取名为”game”的进程ID,然后通过pmap -x $(pidof game)就能清晰地看到该游戏进程的完整内存布局,如RSS(Resident Set Size,实际占用的物理内存大小)。
  • GDB 的 info inferiors: 在GDB调试器中,“inferior”指的是被调试的进程。 info inferiors命令会列出GDB当前正在管理的所有inferior(进程)的信息,包括它们的进程ID。 这在调试多进程程序(如使用fork创建子进程)时尤其有用。
Terminal window
pmap -x 102204 # pmap 的本质是 访问 `/proc/` 实现的
102204: /home/ubun/a.out (static)
Address Kbytes RSS Dirty Mode Mapping
0000000000400000 4 4 0 r---- a.out # ELF 文件头及只读数据
0000000000401000 1492 1148 8 r-x-- a.out # 代码段
0000000000576000 344 168 0 r---- a.out # .rodata只读数据段
00000000005cc000 44 44 8 r---- a.out
00000000005d7000 12 12 12 rw--- a.out # .data可读写数据段
00000000005da000 4128 24 24 rw--- [ anon ] # .bss
00000000009e2000 136 12 12 rw--- [ anon ] # [heap]
00007ffff7ff9000 16 0 0 r---- [ anon ] # [vvar]
00007ffff7ffd000 8 8 0 r-x-- [ anon ] # [vdso]
00007ffffffde000 132 12 12 rw--- [ stack ]
---------------- ------- ------- -------
total kB 6316 1432 76

vDSO: 内核与用户空间的“高速公路”#

  • [ vdso ] (Virtual Dynamic Shared Object): 它是一种内核优化机制,目的是为了减少某些高频、只读系统调用的开销(如gettimeofday, time, getcpu)。
  • [ vvar ] (Virtual Variables): 这是与vDSO相辅相成的机制。它是一块由内核映射的只读内存页,包含了vDSO 中函数可能需要的数据,例如内核维护的系统时间。这样,vDSO中的代码可以直接从 [ vvar ] 区域读取数据,而无需再与内核进行交互。

通常,进程调用系统调用(syscall)需要经历一个“用户态 -> 内核态 -> 用户态”的完整上下文切换,涉及CPU特权级改变、寄存器保存恢复等操作开销。 但对于像 gettimeofdaytimegetcpu 这类仅仅是读取内核数据的系统调用,频繁地陷入内核就显得很浪费。

vDSO的解决方案是:内核在每个进程启动时,主动将一小块包含这些系统调用实现代码的内存页映射到该进程的只读地址空间中。 当进程调用这些函数时,libc会检查是否存在vDSO,如果存在,就直接在用户态调用vDSO中的函数,像调用一个普通的共享库函数一样,完全避免了陷入内核的上下文切换,从而大大提升了性能。

在pmap的输出中,名为[vdso]的内存段就是这个由内核提供的、用于加速系统调用的“虚拟”共享库。

#include <stdio.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
double gettime() {
struct timeval t;
gettimeofday(&t, NULL); // trapless system call
return t.tv_sec + t.tv_usec / 1000000.0;
}
int main() {
printf("Time stamp: %ld\n", time(NULL)); // trapless system call
double st = gettime();
sleep(1);
double ed = gettime();
printf("Time: %.6lfs\n", ed - st);
}
Terminal window
cat /proc/self/maps | grep -E 'vdso|vsyscall'
7ffeb954e000-7ffeb9550000 r-xp 00000000 00:00 0 [vdso]
strace -e trace=clock_gettime,gettimeofday,time ./a.out
Time stamp: 1762651250
Time: 1.000233s
+++ exited with 0 +++
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
hyperv_clocksource_tsc_page # 我这里是 WSL
# 物理机或VMware虚拟机上,通常能看到tsc(Time Stamp Counter)

mmap: 内存映射的瑞士军刀#

mmap (memory map) 是一个非常强大且核心的Linux系统调用。其本质作用是在进程的虚拟地址空间中创建一个新的内存映射VMA。

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
int mprotect(void *addr, size_t length, int prot);
  • “一切皆文件”哲学的体现: mmap可以将一个文件(由文件描述符fdoffset指定)的一部分直接映射到进程的虚拟内存中。 之后,映射建立后,程序可以像访问普通内存数组一样,通过指针来读写文件内容,而无需调用 read()write() 系统调用。这避免了数据在内核缓冲区和用户缓冲区之间的多次拷贝,极大地提高了大文件或频繁访问文件的I/O效率。
  • 匿名映射: 当 flags 参数包含 MAP_ANONYMOUS 并且 fd 参数设为-1时,mmap 会创建一块不与任何文件关联的匿名内存区域。这块内存会被初始化为0。malloc 在申请大块内存时,内部通常就会使用 mmap 的匿名映射来实现。
  • 写时复制 (Copy-on-Write): 当 flags 包含 MAP_PRIVATE 时,对映射区域的写操作会触发写时复制机制。这意味着写操作不会修改原始文件(或父进程的内存),而是会为该进程创建一个该内存页的私有副本,后续的写操作都在这个副本上进行。fork() 系统调用后父子进程共享内存就是利用了这个机制。

mmap 与缺页异常 (Page Fault) 的协同工作: 按需分页(Demand Paging#

  • 虚拟内存空间预留: 调用mmap时,内核首先只是在进程的虚拟地址空间中预留出一块区域,并为其创建一个VMA结构体来描述虚拟地址与文件(或匿名内存)的映射关系。此时并不会立即分配物理内存
  • 按需分配物理内存 (Page Fault): 当进程第一次访问这块预留区域中的某个内存页时,MMU在翻译该虚拟地址时,会发现页表中没有对应的物理页映射。于是MMU会中断当前指令的执行并触发一个硬件异常缺页异常 (Page Fault)
  • 异常处理与页面加载: 内核会捕获这个缺页异常,检查该地址是否属于一个合法的VMA。如果不是,说明这是一次非法的内存访问,内核会向进程发送 SIGSEGV 信号,进程通常会因此终止(段错误)。如果合法,内核会分配一页物理内存,然后将文件中对应的数据从磁盘加载到这页物理内存中,最后更新进程的页表,建立虚拟地址到物理页的映射。之后,CPU重新执行访问指令,这次就能成功访问了。

由于 mmap 的懒加载特性,程序可以映射远大于物理内存的文件,并能高效地利用物理内存。并且程序启动时只有少量必要的页面被载入内存,大大加快了启动速度。

有了mmap,我们通过readelf的信息就可以知道ELF要把哪个文件加载到哪里,从而映射到内存#

对于每一个类型为 LOAD 的段(这是加载器眼中内存映射的最小单元。),内核会使用 mmap 逻辑来处理,从而高效地在进程的虚拟地址空间中构建起了代码段、数据段等内存布局。

Terminal window
# readelf -S # Section
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
# readelf -l # LOAD
# 动态链接的可执行文件 PIE:地址空间布局随机化 (ASLR)
Elf file type is DYN (Position-Independent Executable file):
# 下面看到的所有 VirtAddr (虚拟地址) 都是相对地址
Entry point 0x1100:
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] # 即“不要直接运行我,请先加载并运行指定的动态链接器/加载器(ld.so)”
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000908 0x0000000000000908 R 0x1000 # ELF 头部、程序头表、动态链接所需的元数据
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x00000000000003a5 0x00000000000003a5 R E 0x1000 # 代码段
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000001c8 0x00000000000001c8 R 0x1000 # 常量字符串、全局常量
LOAD 0x0000000000002d68 0x0000000000003d68 0x0000000000003d68
0x00000000000002b4 0x00000000000003f8 RW 0x1000 # 可读写数据段 .bss段:只读取前 0x2b4 字节剩余的 0x144 字节内存区域清零
DYNAMIC 0x0000000000002d80 0x0000000000003d80 0x0000000000003d80
0x0000000000000200 0x0000000000000200 RW 0x8 # .dynamic 节包含一个数组,里面是动态链接器需要的各种信息
...

总结:从文件到开始运行的完整流程

  1. 执行命令: 用户在 shell 中输入 ./a.out。
  2. 内核介入: 内核读取 ELF 文件的程序头。
  3. 加载解释器: 内核发现 INTERP 段,于是加载并运行 /lib64/ld-linux-x86-64.so.2 (用户空间的共享库,通过mmap映射到进程的虚拟地址空间)
  4. 动态链接器接管: ld.so 开始工作。它会:
    1. 读取 ./a.out 的 LOAD 段,并使用 mmap 系统调用(遵循 VirtAddr, FileSiz, MemSiz, Flags 的指令)将程序的代码和数据段映射到内存中一个随机的基地址之上。
    2. 处理 .bss 段,将其清零。
    3. 读取 DYNAMIC 段,找出所有依赖的共享库(如 libc.so)。
    4. 递归地加载所有依赖的共享库到内存中。
    5. 执行符号重定位,将代码中对外部函数和变量的引用修正为它们在内存中的实际地址。
    6. 应用 GNU_RELRO 安全策略,将部分数据段设为只读。
  5. 交还控制权: 所有准备工作完成后,ld.so 跳转到程序的入口点 (基地址 + 0x1100)。
  6. 程序运行: ./a.out 的代码正式开始执行。

高级应用与“黑科技”:pmap的威力#

现在我们来谈谈几个非常有趣的应用场景,这些场景完美诠释了 pmap 不仅仅是一个诊断工具,更是进行运行时分析、逆向工程和系统维护的强大起点。

场景一:按键精灵#

一个游戏外挂或按键精灵需要与游戏进程进行交互,比如修改内存中的金币数量,或者调用游戏内部的某个函数来执行特定动作。要做到这一点,首先必须知道:

  1. 目标数据(如金币)在内存的哪个地址?
  2. 目标函数(如“使用技能”)的入口地址在哪里?

pmap能清晰地展示出游戏进程加载了哪些动态库(.so文件)以及它们在内存中的基地址。攻击者可以结合readelfobjdump等工具分析这些库文件,找出特定函数的偏移地址,然后用pmap提供的基地址加上偏移,就能计算出该函数在运行时的绝对虚拟地址

一旦获得了地址,就可以通过ptrace等工具向目标进程注入代码或者直接修改内存数据,从而实现“按键”效果。

场景二:变速齿轮 (欺骗进程时钟)#

这个技术的核心是API Hooking,即拦截并篡改函数的行为。要欺骗进程的时间,就是要让它调用的时间相关函数(如sleep, alarm, gettimeofday)返回一个被“加速”或“减速”过的值。

实现步骤如下:

  1. 侦察: 使用pmap找到目标进程中libc.so.6(或其他包含时间函数的库)的内存基地址。
  2. 定位: 结合readelf等工具,计算出sleep等函数在内存中的确切地址。
  3. 注入与劫持: 使用ptrace这样的调试工具附加(attach)到目标进程。
  4. 修改内存:
    • 在目标进程的内存空间中写入一小段我们自己的代码(shellcode),这段代码的作用是:调用原始的sleep,但传入一个修改过的时间参数(例如,原时间的1/2,实现2倍速)。
    • 修改目标进程代码段中sleep函数的开头几个字节,将其替换成一个跳转指令(JMP),跳转到我们注入的代码处。

这样一来,当游戏调用sleep(10)时,实际上会跳转到我们的“假”sleep,我们的代码可能会执行original_sleep(1),从而实现了游戏时间的加速。这个过程非常凶险,因为它直接修改了正在运行的代码。

场景三:软件热补丁 (mprotect)#

软件热补丁指的是在不重启服务的情况下,动态地修复正在运行程序中的BUG。mprotect系统调用是实现这一技术的关键。

通常,进程的代码段是只读和可执行的(r-x)。如果你试图写入代码段,会立即引发段错误(Segmentation Fault)。而热补丁需要在运行时修改代码。

mprotect的作用就是改变指定内存区域的访问权限

实现热补丁的流程:

  1. 定位: 和变速齿轮一样,首先通过pmap等工具确定需要修复的函数地址。
  2. 权限修改: 调用mprotect,将包含该函数的内存页权限临时修改为可读、可写、可执行 (PROT_READ | PROT_WRITE | PROT_EXEC)
  3. 代码替换: 权限修改成功后,就可以像修改普通数据一样,用新的、修复了BUG的函数二进制代码覆盖掉旧函数的代码。
  4. 恢复权限: 为了安全,修复完成后,再次调用mprotect将内存页的权限恢复为只读、可执行 (PROT_READ | PROT_EXEC)。

通过这种方式,可以在服务不中断的情况下,完成对线上BUG的紧急修复。 示例代码:DSU(Dynamic Software Updating) 动态软件更新(热补丁)

#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
void foo() { printf("In old function %s\n", __func__); }
void foo_new() { printf("In new function %s\n", __func__); }
// 48 b8 ff ff ff ff ff ff ff ff movabs $0xffffffffffffffff,%rax # 将一个立即数mov到%rax
// ff e0 jmpq *%rax
void DSU(void* old, void* new) {
#define ROUNDDOWN(ptr) ((void*)(((uintptr_t)ptr) & ~0xfff))
size_t pg_size = sysconf(_SC_PAGESIZE);
char* pg_boundary = ROUNDDOWN(old);
int flags = PROT_WRITE | PROT_READ | PROT_EXEC;
printf("Dynamically updating...\n");
fflush(stdout);
mprotect(pg_boundary, 2 * pg_size, flags);
memcpy(old + 0, "\x48\xb8", 2);
memcpy(old + 2, &new, 8);
memcpy(old + 10, "\xff\xe0", 2);
mprotect(pg_boundary, 2 * pg_size, flags & ~PROT_WRITE);
printf("Done\n");
fflush(stdout);
}
int main() {
foo();
DSU(foo, foo_new);
foo();
}
// 因为一个函数体有可能跨越两个内存页的边界。为了确保整个12字节的补丁都能被完整写入,需要修改2*pg_size的内存页
// 在更复杂的场景下,可能需要额外的指令(如 mfence)或缓存刷新操作来确保CPU能看到修改后的代码。
mmap
https://blog.alinche.dpdns.org/posts/os/memory/mmap/
作者
Oeasy1412
发布于
2025-05-10
许可协议
CC BY-NC-SA 4.0