巩固下基础知识.
现代系统都运行在保护模式(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
范围, 有可执行权限, 这就对上了.