为什么 Linux 支持 0x7f 映射?
Why does Linux favor 0x7f mappings?
通过 运行 一个简单的 less /proc/self/maps
我看到大多数映射以 55
和 7F
开头。我还注意到每当我调试任何二进制文件时都会使用这些范围。
此外,这条评论 here 表明内核确实有一些范围偏好。
这是为什么?上述范围是否有更深层次的技术原因?如果我手动 mmap
这些前缀之外的页面会不会有问题?
首先,假设你在谈论 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
__________________|____________|__________________|_________|___________________________________________________________
... | ... | ... | ...
用户space 地址在 x86-64 中始终采用规范形式,仅使用 4 级页面的低 48 位 tables 或 5 级页面的 57 位 tables(请注意,最高位是符号扩展的,并且仅针对内核设置为 1
,因此实际上您最多只能看到 userspace 中设置的最多 47 或 56 位,其中最高有效位始终设置为 0
).
参见:
- x86-64 canonical address?
这将 user-space 虚拟内存的末尾置于 0x7fffffffffff
。这是新程序堆栈开始的地方:即 0x7ffffffff000
(减去由于 ASLR 引起的一些随机偏移)并增长到 较低的 地址。
让我先解决一个简单的问题:
Will there be a problem if I manually mmap
pages outside of these prefixes?
一点也不,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 程序。加载器的路径在ELF程序头中指定,程序头类型为PT_INTERP
.
加载器:加载器是一个没有指定PT_INTERP
程序头的ELF,它负责加载和启动正常的程序。在实际启动正在加载的程序之前,它还在幕后做了很多花哨的事情(解决重定位、加载所需的库等)。
当内核通过execve
系统调用执行新的ELF 时,它需要将程序本身和加载程序映射到内存中。然后控制将传递给加载器,加载器将解析和映射所有需要的共享库,最后将控制传递给程序。由于程序及其加载器都需要映射,因此内核需要确保这些映射不重叠(并且加载器将来的映射请求也不会重叠)。
为了做到这一点,加载程序被映射到堆栈附近(在比堆栈低的地址,但有一些容差,因为如果需要,堆栈可以通过添加更多页面来增长),留下将 ASLR 应用于 mmap
本身的责任。然后使用 load_bias
映射程序(如上面的代码片段所示),使其远离加载程序(在低得多的地址)。
如果我们看一下 ELF_ET_DYN_BASE
,我们会发现它依赖于体系结构,并且在 x86-64 上它的计算结果为:
((1ULL << 47) - (1 << 12)) / 3 * 2 == 0x555555554aaa
如果启用了 ASLR,基本上大约是 TASK_SIZE
. That load_bias
is then adjusted adding arch_mmap_rnd()
字节的 2/3,最后是页面对齐。归根结底,这就是我们通常看到程序地址以 0x55
开头的原因。
当控制被传递给加载程序时,进程的虚拟内存区域已经被定义,并且连续的 mmap
不指定地址的系统调用将 return 从靠近装载机。正如我们刚才看到的加载器被映射到栈附近,而栈在用户地址的最末端space:这就是我们通常看到地址以[=34=开头的原因] 图书馆.
上面有一个常见的例外。在直接调用加载程序的情况下,例如:
/lib/x86_64-linux-gnu/ld-2.24.so ./myprog
在这种情况下,内核将不会映射 ./mpyprog
,并将其留给加载程序。因此,./myprog
将被加载程序映射到某个 0x7f...
地址。
您可能想知道:为什么内核 总是 让加载器映射程序,或者为什么程序不正确映射 before/after装载机?我对此没有 100% 明确的答案,但我想到了几个原因:
一致性:让内核自己将ELF加载到内存中,而不依赖于加载器,避免了麻烦。如果不是这种情况,内核将完全依赖于用户space 加载程序,这是不可取的(这也可能部分是安全问题)。
效率:我们确信至少 executable 和它的加载器都需要被映射(不管任何链接库) , 不妨节省宝贵的时间并立即执行,而不是等待另一个具有关联上下文切换的系统调用。
安全性:在默认情况下,将程序映射到与加载程序和其他库不同的随机地址,在程序本身和加载的库之间提供了一种“隔离”。换句话说,“泄露”任何库地址都不会泄露程序在内存中的位置,反之亦然。将程序映射到加载程序和其他库的预定义偏移量反而会部分破坏 ASLR 的目的。
在理想的安全驱动场景中,每个 mmap
(即任何需要的库)也将被放置在独立于先前映射的随机地址,但这会显着损害性能。保持分配分组可以加快页面 table 查找:参见 Understanding The Linux Kernel (3rd edition), page 606: Table 15-3。每个基数树高度的最高索引和最大文件大小。它还会导致更大的虚拟内存碎片,成为需要将大文件映射到内存的程序的真正问题。程序代码和库代码之间的实质性隔离已经完成,再进一步利大于弊。
易于调试:查看 RIP=0x55...
与 RIP=0x7f...
可立即帮助找出要查看的位置(程序本身或库代码)。
通过 运行 一个简单的 less /proc/self/maps
我看到大多数映射以 55
和 7F
开头。我还注意到每当我调试任何二进制文件时都会使用这些范围。
此外,这条评论 here 表明内核确实有一些范围偏好。
这是为什么?上述范围是否有更深层次的技术原因?如果我手动 mmap
这些前缀之外的页面会不会有问题?
首先,假设你在谈论 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
__________________|____________|__________________|_________|___________________________________________________________
... | ... | ... | ...
用户space 地址在 x86-64 中始终采用规范形式,仅使用 4 级页面的低 48 位 tables 或 5 级页面的 57 位 tables(请注意,最高位是符号扩展的,并且仅针对内核设置为 1
,因此实际上您最多只能看到 userspace 中设置的最多 47 或 56 位,其中最高有效位始终设置为 0
).
参见:
- x86-64 canonical address?
这将 user-space 虚拟内存的末尾置于 0x7fffffffffff
。这是新程序堆栈开始的地方:即 0x7ffffffff000
(减去由于 ASLR 引起的一些随机偏移)并增长到 较低的 地址。
让我先解决一个简单的问题:
Will there be a problem if I manually
mmap
pages outside of these prefixes?
一点也不,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 程序。加载器的路径在ELF程序头中指定,程序头类型为
PT_INTERP
.加载器:加载器是一个没有指定
PT_INTERP
程序头的ELF,它负责加载和启动正常的程序。在实际启动正在加载的程序之前,它还在幕后做了很多花哨的事情(解决重定位、加载所需的库等)。
当内核通过execve
系统调用执行新的ELF 时,它需要将程序本身和加载程序映射到内存中。然后控制将传递给加载器,加载器将解析和映射所有需要的共享库,最后将控制传递给程序。由于程序及其加载器都需要映射,因此内核需要确保这些映射不重叠(并且加载器将来的映射请求也不会重叠)。
为了做到这一点,加载程序被映射到堆栈附近(在比堆栈低的地址,但有一些容差,因为如果需要,堆栈可以通过添加更多页面来增长),留下将 ASLR 应用于 mmap
本身的责任。然后使用 load_bias
映射程序(如上面的代码片段所示),使其远离加载程序(在低得多的地址)。
如果我们看一下 ELF_ET_DYN_BASE
,我们会发现它依赖于体系结构,并且在 x86-64 上它的计算结果为:
((1ULL << 47) - (1 << 12)) / 3 * 2 == 0x555555554aaa
如果启用了 ASLR,基本上大约是 TASK_SIZE
. That load_bias
is then adjusted adding arch_mmap_rnd()
字节的 2/3,最后是页面对齐。归根结底,这就是我们通常看到程序地址以 0x55
开头的原因。
当控制被传递给加载程序时,进程的虚拟内存区域已经被定义,并且连续的 mmap
不指定地址的系统调用将 return 从靠近装载机。正如我们刚才看到的加载器被映射到栈附近,而栈在用户地址的最末端space:这就是我们通常看到地址以[=34=开头的原因] 图书馆.
上面有一个常见的例外。在直接调用加载程序的情况下,例如:
/lib/x86_64-linux-gnu/ld-2.24.so ./myprog
在这种情况下,内核将不会映射 ./mpyprog
,并将其留给加载程序。因此,./myprog
将被加载程序映射到某个 0x7f...
地址。
您可能想知道:为什么内核 总是 让加载器映射程序,或者为什么程序不正确映射 before/after装载机?我对此没有 100% 明确的答案,但我想到了几个原因:
一致性:让内核自己将ELF加载到内存中,而不依赖于加载器,避免了麻烦。如果不是这种情况,内核将完全依赖于用户space 加载程序,这是不可取的(这也可能部分是安全问题)。
效率:我们确信至少 executable 和它的加载器都需要被映射(不管任何链接库) , 不妨节省宝贵的时间并立即执行,而不是等待另一个具有关联上下文切换的系统调用。
安全性:在默认情况下,将程序映射到与加载程序和其他库不同的随机地址,在程序本身和加载的库之间提供了一种“隔离”。换句话说,“泄露”任何库地址都不会泄露程序在内存中的位置,反之亦然。将程序映射到加载程序和其他库的预定义偏移量反而会部分破坏 ASLR 的目的。
在理想的安全驱动场景中,每个
mmap
(即任何需要的库)也将被放置在独立于先前映射的随机地址,但这会显着损害性能。保持分配分组可以加快页面 table 查找:参见 Understanding The Linux Kernel (3rd edition), page 606: Table 15-3。每个基数树高度的最高索引和最大文件大小。它还会导致更大的虚拟内存碎片,成为需要将大文件映射到内存的程序的真正问题。程序代码和库代码之间的实质性隔离已经完成,再进一步利大于弊。易于调试:查看
RIP=0x55...
与RIP=0x7f...
可立即帮助找出要查看的位置(程序本身或库代码)。