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

卸载ssh报错(客户端吐出错误信息)(1)

该版本 bash 为部门这边所定制,但是实现上与原生版并没有不同,那么这些错误从哪里来?

更多Linux内核视频教程文本资料免费获取可以后台私信【内核】获取。

卸载ssh报错(客户端吐出错误信息)(2)

卸载ssh报错(客户端吐出错误信息)(3)

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

卸载ssh报错(客户端吐出错误信息)(4)

图 - 1

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

卸载ssh报错(客户端吐出错误信息)(5)

图 - 2

这个进程的内存布局和一般理解上有很大出入,从上往下是低内存到高内存:#1 处为进程的代码段和数据段,这两个区域一般处于进程内存空间的最低处,但现在在更低处明显有动态库被映射了进来。

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

卸载ssh报错(客户端吐出错误信息)(6)

图 - 3

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

卸载ssh报错(客户端吐出错误信息)(7)

图 - 4

那 sshd 进程呢?

卸载ssh报错(客户端吐出错误信息)(8)

图 - 5

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

卸载ssh报错(客户端吐出错误信息)(9)

图 - 6

下图中数据段的例子更加复杂,因为它用了一个指针。在此情况下,指针gonzo(4字节内存地址)本身的值保存在数据段中。而它所指向的实际字符串则不在这里。这个字符串保存在代码段中,代码段是只读的,保存了你全部的代码外加零零碎碎的东西,比如字符串字面值。代码段将你的二进制文件也映射到了内存中,但对此区域的写操作都会使你的程序收到段错误。这有助于防范指针错误,虽然不像在C语言编程时就注意防范来得那么有效。下图展示了这些段以及我们例子中的变量:

卸载ssh报错(客户端吐出错误信息)(10)

你可以通过nm和objdump命令来察看二进制镜像,打印其中的符号,它们的地址,段等信息。最后需要指出的是,前文描述的虚拟地址布局在Linux 中是一种“灵活布局”(flexible layout),而且以此作为默认方式已经有些年头了。它假设我们有值 RLIMIT_STACK。当情况不是这样时, Linux 退回使用“经典布局”(classic layout),如下图所示:

卸载ssh报错(客户端吐出错误信息)(11)

对虚拟地址空间的布局就讲这些吧。下一篇文章将讨论内核是如何跟踪这些内存区域的。我们会分析内存映射,看看文件的读写操作是如何与之关联的,以及内存使用概况的含义。

操作系统的锅吗?

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

卸载ssh报错(客户端吐出错误信息)(12)

图 - 7

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

卸载ssh报错(客户端吐出错误信息)(13)

图 - 8

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】:

卸载ssh报错(客户端吐出错误信息)(14)

图 - 9

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

卸载ssh报错(客户端吐出错误信息)(15)

图 - 10

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

问题并没有解决

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

卸载ssh报错(客户端吐出错误信息)(16)

图 - 11

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

卸载ssh报错(客户端吐出错误信息)(17)

图 - 12

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

卸载ssh报错(客户端吐出错误信息)(18)

图 - 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 位进程还相对稳妥点.

卸载ssh报错(客户端吐出错误信息)(19)

,