一个由进程内存布局异常引起的问题

一个由进程内存布局异常引起的问题

前段时间业务反映某类服务器上更新了 bash 之后,ssh 连上去偶发登陆失败,客户端吐出错误信息如下所示:

图 - 0

该版本 bash 为部门这边所定制,但实现上并没有改动原有逻辑,只是加入了些监控功能,那么这些错误从哪里来呢?

是 bash 的锅吗

从上面的错误信息可以猜测,异常是 bash 在启动过程中分配内存失败所导致,看起来像是某些情况下该进程错误地进行了大量内存分配,最后导致内存不足,要确认这个事情比较简单,动态内存分配到系统调用这一层上主要就两种方式: brk() 和 mmap(), 所以只要统计一下这两者的调用就可以大概估算出是否有大内存分配了。

bash 是由 sshd 启动的,于是 strace 跟踪了一下 sshd 进程,结果发现异常发生时,bash 分配的内存非常地少,少到有时甚至只有几十字节也会失败,几乎可以断定 bash 在内存使用上没有异常,但在这期间发现一个诡异的现象,Bash 一直只用 brk 在分配小内存,brk() 失败后就直接退出了,一般程序使用的 libc 中的 malloc (或其它类似的 malloc) 会结合 brk 和 mmap 一起使用【0】,不至于 brk 一失败就分配不到内存,顺手查看了下 bash 的源码,发现它确实只基于 brk 做了自己的内存管理,并没有使用 malloc 或 mmap。

但那并不是重点,重点是即使是只使用 brk,也不至于只能分配几十字节的内存。

进程的内存布局

进程的内存布局在结构上是有规律的,具体来说对于 linux 系统上的进程,其内存空间一般可以粗略地分为以下几大段【1】,从高内存到低内存排列:
1、内核态内存空间,其大小一般比较固定(可以编译时调整),但 32 位系统和 64 位系统的值不一样。
2、用户态的堆栈,大小不固定,可以用 ulimit -s 进行调整,默认一般为 8M,从高地址向低地址增长。
3、mmap 区域,进程茫茫内存空间里的主要部分,既可以从高地址到低地址延伸(所谓 flexible layout),也可以从低到高延伸(所谓 legacy layout),看进程具体情况【2】【3】。
4、brk 区域,紧邻数据段(甚至贴着),从低位向高位伸展,但它的大小主要取决于 mmap 如何增长,一般来说,即使是 32 位的进程以传统方式延伸,也有差不多 1 GB 的空间(准确地说是 TASK_SIZE/3 - 代码段数据段,参看 arch/x86/include/asm/processor.h 里的定义)【4】
5、数据段,主要是进程里初始化和未初始化的全局数据总和,当然还有编译器生成一些辅助数据结构等等),大小取决于具体进程,其位置紧贴着代码段。
6、代码段,主要是进程的指令,包括用户代码和编译器生成的辅助代码,其大小取决于具体程序,但起始位置根据 32 位还是 64 位一般固定(-fPIC, -fPIE等除外【5】)。

以上各段(除了代码段数据段)其起始位置根据系统是否起用 randomize_va_space 一般稍有变化,各段之间因此可能有随机大小的间隔,千言万语不如一幅图:


图 - 1

所以现在的问题归结为:为什么目标进程的 brk 的区域突然那么小了,先检查一下 bash 的内存布局:


图 - 2

这个进程的内存布局和一般理解上有很大出入,从上往下是低内存到高内存:
#1 处为进程的代码段和数据段,这两个区域一般处于进程内存空间的最低处,但现在在更低处明显有动态库被映射了进来。
#2 处为 brk 的区域,该区域还算紧临着数据段,但是,brk 与代码段之间也被插入了动态库,而且更要命的是,brk 区域向高处伸展的方向上,动态库映射的区域贴的很近,导致 brk 的区域事实上只有很小一个空间(0x886000 - 0x7ac000)。

这并不是我们想要的内存布局,我们想要的应该是长成下面这样的:


图 - 3

看出来不同了没有,两个 bash 进程都是 64 位的,不同在于前者是 sshd 起的进程后者是我手动在终端上起起来的,手动 cat /proc/self/maps 看了下 64 位的 cat 的进程的内存布局也是正常的:


图 - 4

那 sshd 进程呢?

图 - 5

sshd 进程也不正常,而且意外发现 sshd 是 32 位的,于是写了个测试程序:


图 - 6

该程序编译为 32 位在目标机器上可以重现问题,而如果编译为 64 位则一切正常,另一个发现是只要是 32 位的进程,它们的内存布局都"不正常"。

操作系统的锅吗?

要搞清楚这个问题得先搞明白进程在内核里启动的流程,对用户态的进程来说,任何进程都是从母进程 fork 出来后再执行 execve, execv 则主要调用对应的加载器(主要是 elf loader)来把代码段、数据段以及动态连接器(ld.so,如果需要)加载进内存空间的各个相应位置,完成之后直接跳到动态连接器的入口(这里先忽略静态链接的程序),其它的动态库都由动态库连接器负责加载,需要注意的是,无论是内核加载 ld.so 还是 ld.so 加载其它动态库,都需要 mmap 的协助,这是用来在内存空间里找位置用的。

现在我们来看看内核出了什么问题,目标系统版本如下,经过咨询 tlinux 的人,该系统基于 centos 6.5: http://vault.centos.org/6.5/centosplus/Source/SPackages/kernel-2.6.32-431.el6.centos.plus.src.rpm


图 - 7

首先看看 arch/x86/mm/mmap.c: arch_pick_mmap_layout() 这个函数,它的作用是根据进程和当前系统的设置初化 mmap 相关的入口:


图 - 8

Exec-shield 是一类安全功能的开关,由红帽在很多年前主导搞的对 buffer overflow 攻击的一系列增强,具体可以参看这几个连接 1234,exec shield 在实现和使用上一直有问题,也破坏了有些旧程序的兼容性【6】,因此一直没进主干,只在 redhat 家族 6.x 及其派生系统上使用。

这个功能有一个开关 /proc/sys/kernel/exec-shield,根据链接【6】上的说明,exec-shield 可以设置为 0、1、2、3,分别表示:强制关闭/默认关闭除非可执行程序指定打开/默认打开除非可执行程序指定关闭/强制打开。

mm->get_unmapped_area 是进程需要进行 mmap 时调用的最终函数, arch_get_unmap_area() 用来以传统方式从低位开始搜索合适的位置,arch_get_unmapped_area_topdown() 则以 flexible layout 的方式从高位开始搜索合适的位置,关键点在于 125 ~ 129 行,exec-shield 引进了另一种专门针对 32 位进程的内存分配方式,这种方式指定如果要分配的内存需要可执行权限,那么应该从 mm->shlib_base 这里开始搜索合适的位置,shlib_base 的值为 SHLIB_BASE 加上一个小的随机偏移,而 SHLIB_BASE 的值为【7】:

图 - 9

注意到该地址位于 32 位进程的代码段之前(0x8048000),所以这就解释了为什么 32 位的进程,它的动态库被加载到了低位甚至穿插进了 brk 和数据段之间的空隙,本来这个特殊的搜索内存空间的方式是只针对需要可执行权限的内存,但由于 elf 加载器在加载动态库时是分段(PT_LOAD)进行加载【8】,第一个段的位置由 mm->get_unmap_area() 搜索合适的位置分配,后续的段则使用 MAP_FIXED 强制放在了第一个段的后面,所以导致数据段也映射到了低位.【9】

下图 1641 行展示了 mmap 时怎样从 mm 结构里获取 get_area 函数,可以看到,只要 mm->get_unmmapped_exec_area 不为空,且要分配的内存需要可执行权限,就优先使用 mm->get_unmmapped_exec_area 进行搜索。


图 - 10

上面这种针对 exec 内存的分配方式实际上很容易引起冲突,redhat 在这里也是打了不少补丁,参看123

问题并没有解决

上面的解释说明了为什么 32 位进程的内存布局会异常,但是这里的问题是,为什么用 32 位进程起 64 位进程时,64 位的进程也同样受到了影响。要搞清楚这里的问题,就得看看 fs/binfmt_elf.c: load_elf_binary() 这个函数,它用来在当前进程中加载 elf 格式可执行文件并跳过去执行,此函数被 32 位的 elf 与 64 位 elf 所共用(借助了比较隐蔽的宏),它做的事情总结起来包括如下:
1、读取和解析 elf 文件里包含的各种信息,关键信息如代码段,数据段,动态链接器等。
2、flush_old_exec(): 停止当前进程内的所有线程,清空当前内存空间,重置各种状态等。
3、设置新进程的状态,如分配内存空间,初始化等。
4、加载动态连接器并跳过去执行。


图 - 11

现在回到我们问题,当前进程是 32 位的,在 64 位的系统上执行 32 位的进程需要内核支持,当内核发现 elf 是 32 位的程序时,会在 task 内部置一个标志,这个标志在上图 load_elf_binary() 函数里 740 行调用 SET_PERSONALITY() 才会被清除,所以在 721 行时,当前进程仍认为自己是 32 位的,flush_old_exec() 做了什么事情呢,参看:fs/exec.c: flush_old_exec()


图 - 12

注意其中 1039 行,bprm->mm 表示新的内存空间(旧的还在,但马上就要释放并切换新的),这里需要对新的内存空间进行设置,参看: fs/exec.c: exec_mmap()


图 - 13

我们可以看到在当前进程还是 32 位的时候,内核对新的内存空间进行了初始化,导致 arch_pick_mmap_layout() 错误地将 arch_get_unmaped_exec_area 赋值给了 bprm->mm->get_unmapped_exec_area 这个成员变量,虽然图-11中 load_elf_binary() 函数在 748 行,32 位的标志被清空之后再次调用 set_up_new_exec() -> arch_get_unmapped_exec_area(),但 arch_get_unmaped_exec_area() 并没有清空 mm->get_unmapped_exec_area 这个变量,导致 execv 后虽然进程是 64 位的,但仍然以 mm->shlib_base 这里作为起始地址搜索内存空间给动态库使用, oops.

解决方案

最直接可靠的做法是在进入 arch_pick_mmap_layout() 时,先把 mm->get_unmapped_exec_area 置为 NULL,但这就要修改内核了,用户态要规避的话有以下方式:
1、设置 ulimit -s unlimited,并设置 exec-shield 为 0 或 1,再起进程,这样一来,因为用户态的栈是无限长的,内核只能以传统的方式来对 32 位进程分配内存,不会掉进 exec-shield 的坑里。
2、把 randomize_va_space 禁掉,但这个做法只是把头埋进了沙子里。

总的来说,上面两种用户态的规避方案基本是哪里疼往哪贴膏药,并非解决问题之道(且有安全隐患),退一步来说,不要用 32 位的进程来起动 64 位进程还相对稳妥点.

参考

【0】https://en.wikipedia.org/wiki/C_dynamic_memory_allocation
【1】https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/5/html/Tuning_and_Optimizing_Red_Hat_Enterprise_Linux_for_Oracle_9i_and_10g_Databases/sect-Oracle_9i_and_10g_Tuning_Guide-Growing_the_Oracle_SGA_to_2.7_GB_in_x86_Red_Hat_Enterprise_Linux_2.1_Without_VLM-Linux_Memory_Layout.html
【2】understanding the linux kernel, page 819, flexible memory region layout: https://books.google.com.hk/books?id=h0lltXyJ8aIC&pg=PT925&lpg=PT925&dq=linux+flexible+memory&source=bl&ots=gO7rIYb8HR&sig=pirB5pswdHFHSljy57EksxS3ABw&hl=en&sa=X&ved=0ahUKEwjpkfa-2_rRAhVGFJQKHcETDSUQ6AEITDAH#v=onepage&q=linux%20flexible%20memory&f=false
【3】https://gist.github.com/CMCDragonkai/10ab53654b2aa6ce55c11cfc5b2432a4
【4】http://lxr.free-electrons.com/source/arch/x86/include/asm/processor.h#L770
【5】 https://access.redhat.com/blogs/766093/posts/1975793
【6】https://lwn.net/Articles/31032/
【7】https://lwn.net/Articles/454949/
【8】http://lxr.free-electrons.com/source/fs/binfmt_elf.c#L549
【9】http://lxr.free-electrons.com/source/fs/binfmt_elf.c#L563
【10】类似问题: https://bugzilla.redhat.com/show_bug.cgi?id=870914 https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=522849

时间: 2024-10-21 09:37:41

一个由进程内存布局异常引起的问题的相关文章

Linux C进程内存布局

当程序文件运行为进程时,进程在内存中获得空间.这个空间是进程自己的内存空间.每个进程空间按照如下方式分为不同区域: 进程内存空间布局图 text:代码段.存放的是程序的全部代码(指令),来源于二进制可执行文件中的代码部分 initialized data(简称data段)和uninitialized data(简称bss段)组成了数据段.其中data段存放的是已初始化全局变量和已初始化static局部变量,来源于二进制可执行文件中的数据部分: bss段存放的是未初始化全局变量和未初始化stati

linux下进程内存布局及变量存储位置检查

进程的内存布局如下(虚拟内存): 它们分别从低地址向高地址增长 在linux中,存在三个全局符号:etext, edata, end分别指向文本段,初始化数据段,未初始化数据段结尾处的下一字节的地址. 所以我们可以在c程序中声明这些变量,然后定义一些变量再查看其地址是否在对应的地址范围内,可得出其变量被存储在哪个区中. #include<unistd.h> #include<stdlib.h> #include<stdio.h> extern char etext, e

Unix系统编程()进程内存布局

每个进程所分配的内存由很多部分组成,通常称之为"段(segment)". 文本段包含了进程运行的程序机器语言指令.文本段具有只读属性,以防止进程通过错误指针意外修改自身指令. 因为多个进程可同时运行同一程序,所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中. 初始化数据段包含显式初始化的全局变量和静态变量.当程序加载到内存时,从可执行文件中读取这些变量的值. 未初始化数据段包括了未进行显式初始化的全局变量和静态变量. 程序启动之前,系统将本段内所

linux进程内存布局

一个程序本质上都是由 BSS 段.data段.text段三个组成的.这样的概念在当前的计算机程序设计中是很重要的一个基本概念,而且在嵌入式系统的设计中也非常重要,牵涉到嵌入式系统运行时的内存大小分配,存储单元占用空间大小的问题. BSS段:在采用段式内存管理的架构中,BSS段(bss segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域.BSS是英文Block Started by Symbol的简称.BSS段属于静态内存分配. 数据段:在采用段式内存管理的架构中,数据段(da

c进程内存布局说明

Text:代码段.存放程序的全部代码(指令),来源于二进制可执行文件中的代码部分,在编译的时候就已经放置到二进制文件中. Initialized data(简称data段)和uninitialized data(简称bss段)组成了数据段.其中data段存放的是已初始化全局变量和已初始化static局部变量,来源于二进制可执行文件中的数据部分,这部分在编译的时候就已经放置到二进制文件中:bss段存放的是未初始化全局变量和未初始化static局部变量,其内容不来源于二进制可执行文件中的数据部分(也

UNIX高级环境编程(8)进程环境(Process Environment)- 进程的启动和退出、内存布局、环境变量列表

在学习进程控制相关知识之前,我们需要了解一个单进程的运行环境. 本章我们将了解一下的内容: 程序运行时,main函数是如何被调用的: 命令行参数是如何被传入到程序中的: 一个典型的内存布局是怎样的: 如何分配内存: 程序如何使用环境变量: 程序终止的各种方式: 跳转(longjmp和setjmp)函数的工作方式,以及如何和栈交互: 进程的资源限制 ? 1 main函数 main函数声明: int main (int argc, char *argv[]); 参数说明: argc:命令行参数个数

进程内存和内存损坏

本教程的这一部分的先决条件是对ARM汇编的基本了解(在第一个教程系列" ARM汇编基础 "中有介绍).在本章中,您将了解32位Linux环境中进程的内存布局.之后,您将学习堆栈和堆相关的内存损坏的基本原理,以及它们在调试器中的样子. 缓冲区溢出 堆栈溢出 堆溢出 摇摇欲坠的指针 格式字符串 本教程中使用的示例是在ARMv6 32位处理器上编译的.如果您无法访问ARM设备,则可以按照以下教程创建自己的实验室并在VM中模拟Raspberry Pi发行版:使用QEMU模拟Raspberry

002 Java内存区域与内存溢出异常

1.运行时数据区域 ① 程序计数器(Program Counter Register) 是一块较小的内存区域,可以看作是当前线程所执行的字节码的行号指示器 如果线程正在执行一个java方法,那么这个计数器记录的是正在执行的虚拟机字节码的指令地址 如果正在执行的是Native方法,则这个计数器的值为空(Undefined) 此区域是唯一一个在java虚拟机中没有规定任何OutOfMemoryError情况的区域 ②java虚拟机栈 线程私有的,生命周期与线程相同 描述java方法执行的内存模型:每

面试 -- Java内存布局【图】以及java各种存储区【详解】

一.Java内存布局浅谈 1. 总述 我们知道,线程是操作系统调度的基本单元.所有线程共享父进程的堆空间,而每个线程都有自己的栈空间和程序计数器.所以,Java虚拟机也看以看作是一个独立的进程,里面的内存空间分为线程共享空间和线程独有空间.Java虚拟机内存布局如下: 2. 所有线程共享的内存空间 (1)堆空间:JVM规范中规定,所有对象实例以及数组都要在堆上进行分配.一般来说,堆空间都有一个默认大小,取决于JVM实现,而且可以根据需要动态扩展.当创建对象需要在堆上分配空间,而且堆本身的空间不够