百种弊病,皆从懒生

Linux 进程虚拟内存分布

2020.08.26

巩固下基础知识.

现代系统都运行在保护模式(protected mode)下, x86_64 下用户态程序启动时候都会被分配一片虚拟内存, 大小是 2^47 (128TiB), 但目前 cpu 只能映射 2^46 (64TiB), 通过 page table 将虚拟内存中的地址(vma)和物理地址映射起来. 以前的 BIOS 跑在 实模式下(real mode), 直接访问物理内存, 不过只能访问前 2^20(1MiB) 的空间.

Linux 下 /proc/$pid/maps 文件内容就是进程的虚拟内存分布. 每一列含义通过 man 5 proc 看, 不重复了.

网上的例子都有点过时, 在比较新的 linux 发行版里, 默认开启了ASLR(地址空间随机化), 自带的二进制文件都是 PIE(Position Independent Executable) build 的. 这两结合起来让进程在虚拟内存中的基地址随机化, heap 和 stack 的开始位置也随机化, 目的是为了防止远程注入漏洞猜到一些软件的vma.

准备工作

判断一个可执行文件是否是 pie build 的.

readelf -h /usr/bin/ls:

    ELF Header:
      Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
      Class:                             ELF64
      Data:                              2's complement, little endian
      Version:                           1 (current)
      OS/ABI:                            UNIX - System V
      ABI Version:                       0
      Type:                              DYN (Shared object file)
      Machine:                           Advanced Micro Devices X86-64
      Version:                           0x1
      Entry point address:               0x5b20
      Start of program headers:          64 (bytes into file)
      Start of section headers:          140208 (bytes into file)
      Flags:                             0x0
      Size of this header:               64 (bytes)
      Size of program headers:           56 (bytes)
      Number of program headers:         11
      Size of section headers:           64 (bytes)
      Number of section headers:         27
      Section header string table index: 26

看 Type 字段, 如果是EXEC, 就不是 pie build, 是 DYN 就是 pie buid 的.

或者: file /usr/bin/ls, 看 ...ELF 64-bit LSB, LSB 后面的, 取决于 file 命令的版本, 如果是 pie build, 有的会显示 shared object, 有的会显示 pie executable, 只有 executable 表示不是 pie build.

gcc 7.0 以上编译出来的可执行文件默认会打开 pie, 通过 -no-pie 来关闭, 为了方便看虚拟内存的布局, 测试程序会关闭 pie.

编写一个简单的 c 程序:

#include <stdio.h>

int main() {
    getchar();
    return 0;
}

编译: gcc -no-pie test.c, getchar 只是为了让进程停在那, 方便看虚拟内存.

/proc/$pid/maps

这是我在 manjaro 上的实际结果, gcc 版本10.1.0.

    00400000-00401000 r--p 00000000 103:02 1051799                           /home/will/a.out
    00401000-00402000 r-xp 00001000 103:02 1051799                           /home/will/a.out
    00402000-00403000 r--p 00002000 103:02 1051799                           /home/will/a.out
    00403000-00404000 r--p 00002000 103:02 1051799                           /home/will/a.out
    00404000-00405000 rw-p 00003000 103:02 1051799                           /home/will/a.out
    00e16000-00e37000 rw-p 00000000 00:00 0                                  [heap]
    7fdda3df3000-7fdda3e18000 r--p 00000000 103:02 11018917                  /usr/lib/libc-2.31.so
    7fdda3e18000-7fdda3f65000 r-xp 00025000 103:02 11018917                  /usr/lib/libc-2.31.so
    7fdda3f65000-7fdda3faf000 r--p 00172000 103:02 11018917                  /usr/lib/libc-2.31.so
    7fdda3faf000-7fdda3fb0000 ---p 001bc000 103:02 11018917                  /usr/lib/libc-2.31.so
    7fdda3fb0000-7fdda3fb3000 r--p 001bc000 103:02 11018917                  /usr/lib/libc-2.31.so
    7fdda3fb3000-7fdda3fb6000 rw-p 001bf000 103:02 11018917                  /usr/lib/libc-2.31.so
    7fdda3fb6000-7fdda3fbc000 rw-p 00000000 00:00 0 
    7fdda3ffc000-7fdda3ffe000 r--p 00000000 103:02 11018892                  /usr/lib/ld-2.31.so
    7fdda3ffe000-7fdda401e000 r-xp 00002000 103:02 11018892                  /usr/lib/ld-2.31.so
    7fdda401e000-7fdda4026000 r--p 00022000 103:02 11018892                  /usr/lib/ld-2.31.so
    7fdda4027000-7fdda4028000 r--p 0002a000 103:02 11018892                  /usr/lib/ld-2.31.so
    7fdda4028000-7fdda4029000 rw-p 0002b000 103:02 11018892                  /usr/lib/ld-2.31.so
    7fdda4029000-7fdda402a000 rw-p 00000000 00:00 0 
    7ffe8fd00000-7ffe8fd22000 rw-p 00000000 00:00 0                          [stack]
    7ffe8fd6a000-7ffe8fd6e000 r--p 00000000 00:00 0                          [vvar]
    7ffe8fd6e000-7ffe8fd70000 r-xp 00000000 00:00 0                          [vdso]
    ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

vma 上面是低位, 下面是高位.

先看最下面的 [vsyscall], 只是一个已经被废弃的功能, 用于把一些 syscall 从内核映射到用户空间(eg: gettimeofday), 目的是加快这些函数的访问速度, 无需切换进内核, 速度和访问一个普通 c 函数一样快, 但 vsyscall 的缺点是大小限制是 4KiB, 并且开始地址是固定的 0xffffffffff600000, 有安全隐患.

[vdso] (virtual Dynamically linked Shared Object) 就是替代 vsyscall 的, 空间可扩展, 地址可以随 ASLR 变化, [vvar] 用于保存内核映射出来的数据.

[stack] 是进程的栈空间, 由高位向低位增长.

[heap] 是进程的堆空间, 由低位向高位增长, 这个简单的程序会有 heap, 是因为 getchar 内部调用了 brk, 可以用 strace 追踪确认.

如果开启了 ASLR, stack 和 heap 的开始地址每次进程重启都会变化, 比较坑的是 golang, go 没有使用系统的进程堆, 在 64 位 linux 上总是把 heap 从固定地址(0x00c000000000) 开始分配, 所以 go 的 heap 会忽略 ASLR, 相关 issue: https://github.com/golang/go/issues/27583

开始几行映射到了 elf binary. 开始地址是 0x00400000 (4MiB) 是 elf 64 规定的 offset, 前面的空间没分配物理内存, elf 32 offset 是 0x08049000. 如果编译时候开启了 pie, 开始地址就会变成类似 0x55631aea7000 这样的随机地址, pie 模式下没有额外的 offset.

很多例子里对 elf binary 的映射都只有三行, 表示 .text(指令段, 带 x, 可执行权限), .data(已初始化静态变量段), .bss(未初始化静态变量段, 带w, 可写权限), 我有五行, 可能是gcc 版本比较高的原因, 目的是一样的, 把 elf binary 里包括这三个段的内容映射到内存.

再找下 binary 真正的执行入口: readelf -e a.out, 找到 Entry point address: 0x401040, 0x401040 这个地址指向了 .text 段, 是程序的真正入口, 在 maps 文件里位于 00401000-00402000 r-xp 00001000 103:02 1051799 /home/will/a.out 范围, 有可执行权限, 这就对上了.

comments powered by Disqus