【发布时间】:2020-08-17 01:19:56
【问题描述】:
通过运行一个简单的less /proc/self/maps,我发现大多数映射都以55 和7F 开头。我还注意到每当我调试任何二进制文件时都会使用这些范围。
此外,这条评论here 表明内核确实有一些范围偏好。
这是为什么呢?上述范围是否有更深层次的技术原因?如果我在这些前缀之外手动 mmap 页面会有问题吗?
【问题讨论】:
标签: linux-kernel x86 x86-64 virtual-memory
通过运行一个简单的less /proc/self/maps,我发现大多数映射都以55 和7F 开头。我还注意到每当我调试任何二进制文件时都会使用这些范围。
此外,这条评论here 表明内核确实有一些范围偏好。
这是为什么呢?上述范围是否有更深层次的技术原因?如果我在这些前缀之外手动 mmap 页面会有问题吗?
【问题讨论】:
标签: linux-kernel x86 x86-64 virtual-memory
首先,假设您在谈论 x86-64,我们可以看到 the virtual memory map for x86-64 是:
========================================================================================================================
Start addr | Offset | End addr | Size | VM area description
========================================================================================================================
| | | |
0000000000000000 | 0 | 00007fffffffffff | 128 TB | user-space virtual memory, different per mm
__________________|____________|__________________|_________|___________________________________________________________
... | ... | ... | ...
用户空间地址在 x86-64 中始终采用规范形式,仅使用 4 级页表的低 48 位或 5 级页表的 57 位(请注意,最高位是符号扩展的,仅设置为1 用于内核,因此实际上您在用户空间中最多只能看到 47 或 56 位设置,最高有效位始终设置为 0)。
见:
这会将用户空间虚拟内存的末尾置于0x7fffffffffff。这是新程序堆栈开始的地方:即0x7ffffffff000(减去由于ASLR 而导致的一些随机偏移)并增长到较低地址。
让我先解决一个简单的问题:
如果我在这些前缀之外手动
mmap页面会有问题吗?
一点也不,mmap 系统调用总是检查被请求的地址,它会拒绝映射与已经映射的内存区域重叠的页面或完全无效地址的页面(例如addr < mmap_min_addr 或addr > 0x7ffffffff000 )。
现在...直接进入 Linux 内核代码,确切地说是在内核 ELF 加载程序 (fs/binfmt_elf.c:960) 中,我们可以看到一个相当长且具有暗示性的评论:
/*
* This logic is run once for the first LOAD Program
* Header for ET_DYN binaries to calculate the
* randomization (load_bias) for all the LOAD
* Program Headers, and to calculate the entire
* size of the ELF mapping (total_size). (Note that
* load_addr_set is set to true later once the
* initial mapping is performed.)
*
* There are effectively two types of ET_DYN
* binaries: programs (i.e. PIE: ET_DYN with INTERP)
* and loaders (ET_DYN without INTERP, since they
* _are_ the ELF interpreter). The loaders must
* be loaded away from programs since the program
* may otherwise collide with the loader (especially
* for ET_EXEC which does not have a randomized
* position). For example to handle invocations of
* "./ld.so someprog" to test out a new version of
* the loader, the subsequent program that the
* loader loads must avoid the loader itself, so
* they cannot share the same load range. Sufficient
* room for the brk must be allocated with the
* loader as well, since brk must be available with
* the loader.
*
* Therefore, programs are loaded offset from
* ELF_ET_DYN_BASE and loaders are loaded into the
* independently randomized mmap region (0 load_bias
* without MAP_FIXED).
*/
if (interpreter) {
load_bias = ELF_ET_DYN_BASE;
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd();
elf_flags |= MAP_FIXED;
} else
load_bias = 0;
简而言之,ELF有两种Position Independent Executables:
普通程序:它们需要加载程序才能运行。这基本上代表了普通 Linux 系统上 99.9% 的 ELF 程序。 loader的路径在ELF程序头中指定,程序头类型为PT_INTERP。
Loaders:loader 是一个没有指定PT_INTERP 程序头的ELF,它负责加载和启动普通程序。在实际启动正在加载的程序之前,它还在幕后做了一堆花哨的事情(解决重定位、加载所需的库等)。
当内核通过execve 系统调用执行新的ELF 时,它需要将程序本身和加载程序映射到内存中。然后将控制权传递给加载程序,加载程序将解析和映射所有需要的共享库,最后将控制权传递给程序。由于程序和它的加载器都需要被映射,内核需要确保这些映射不重叠(并且加载器未来的映射请求也不会重叠)。
为了做到这一点,加载器被映射到堆栈附近,(在比堆栈低的地址,但有一些容差,因为如果需要,堆栈可以通过添加更多页面来增长),留下的职责是将 ASLR 应用于 mmap 本身。然后使用load_bias(如上面的 sn-p 所示)映射程序,以将其放置在离加载程序足够远的地方(在低得多的地址处)。
如果我们查看ELF_ET_DYN_BASE,我们会发现它依赖于架构,并且在 x86-64 上它的计算结果为:
((1ULL << 47) - (1 << 12)) / 3 * 2 == 0x555555554aaa
基本上大约是TASK_SIZE 的 2/3。如果启用了 ASLR,然后调整 load_bias 添加 arch_mmap_rnd() 字节,最后进行页面对齐。归根结底,这就是为什么我们通常会看到程序地址以0x55 开头的原因。
当控制权被传递给加载器时,进程的虚拟内存区域已经被定义,并且没有指定地址的连续mmap系统调用将返回从加载器附近开始的递减地址。由于我们刚刚看到加载器映射在堆栈附近,并且堆栈位于用户地址空间的最末端,这就是为什么我们通常会看到库地址以0x7f 开头的原因 .
上述有一个常见的例外。在直接调用加载器的情况下,例如:
/lib/x86_64-linux-gnu/ld-2.24.so ./myprog
在这种情况下,内核不会映射./mpyprog,并将其留给加载程序。因此,./myprog 将被加载程序映射到某个0x7f... 地址。
您可能想知道:为什么内核总是不让加载器映射程序,或者为什么程序不直接在加载器之前/之后映射?我对此没有 100% 明确的答案,但我想到了几个原因:
一致性:让内核自己将 ELF 加载到内存中,而不依赖于加载器,这样可以避免麻烦。如果不是这种情况,内核将完全依赖于用户空间加载器,这根本是不可取的(这也可能部分是安全问题)。
效率:我们确信至少可执行文件和它的加载器都需要映射(不管任何链接库),不妨节省宝贵的时间并立即进行而不是等待用于具有关联上下文切换的另一个系统调用。
安全性:在默认情况下,将程序映射到与加载程序和其他库不同的随机地址会在程序本身和加载的库之间提供一种“隔离”。换句话说,“泄漏”任何库地址都不会显示程序在内存中的位置,反之亦然。将程序映射到加载程序和其他库的预定义偏移量会部分破坏 ASLR 的目的。
在理想的安全驱动方案中,每个mmap(即任何需要的库)也将被放置在独立于先前映射的随机地址中,但这会严重损害性能。保持分配分组可以加快页表查找速度:参见Understanding The Linux Kernel (3rd edition),第 606 页:Table 15-3。每个基数树高度的最高索引和最大文件大小。它还会导致更大的虚拟内存碎片,成为需要将大文件映射到内存的程序的真正问题。程序代码和库代码之间的大部分隔离已经完成,进一步的弊大于利。
易于调试:查看 RIP=0x55... 与 RIP=0x7f... 可立即帮助找出查看位置(程序本身或库代码)。
【讨论】:
00007fffffffffff 是可用48 位虚拟地址规范范围的下半部分的最顶端。 x86-64 48 位虚拟地址是 Linux 选择(1ULL<<47) - 1 的原因。 x86-64 canonical address? 和 Address canonical form and pointer arithmetic 有一个图表。
ld 默认为 401000 作为文本部分的开头,即仅高于零的 4MiB)。这使得几乎整个虚拟地址空间都处于开放状态,以便在底部的静态代码/数据和靠近顶部的堆栈 + mmap 默认值之间进行潜在的巨大连续分配,以防有人愿意这样做。
execve 的加载程序无论如何都没有多大意义。我没有考虑性能,这也是一个好点,但我敢打赌,主要原因不取决于用户空间加载器。