宕机问题有一种比较少见的pattern,就是看起来完全不相关的机器同时出现宕机。处理这个pattern的问题,我们需要找到,在这些机器上能同时触发问题的条件。

通常,这些机器要么几乎在同一时间点出现问题,要么从某一个时间点开始,相继出现问题。对于前一种情况,比较常见的情形是,物理机故障导致运行在其上的所以虚拟机宕机,或者一个远程管理软件同时杀死了所有被管理的系统里的关键进程;对于后一种情况,可能的一个原因是,用户在所有实例上部署了同一个有问题的模块(软件、驱动)。

而实例被大范围的hack,则是另一个常见的原因。在WannaCry病毒肆虐的时候,经常出现一些公司,或者一些部门的机器全部蓝屏的情形。今天跟大家分享一例这样的问题。

坏掉的内核栈



使用crash工具sys命令,我们可以看到系统的一些基本信息,和宕机的直接原因。对于这个问题来说,宕机的直接原因是"Kernel panic - not syncing: stack-protector: Kernel stack is corrupted in: ffffffffa02987eb"。关于这条信息,我们必须逐字解读。"Kernel panic - not syncing:"这部分内容在内核函数panic里输出,凡是调用到panic函数,必然会有这一部分输出,所以这一部分内容和问题没有直接关系。而"stack-protector: Kernel stack is corrupted in:"这部分内容,在内核函数__stack_chk_fail,这个函数是一个堆栈检查函数,它会检查堆栈,同时在发现问题的时候调用panic函数产生core dump报告问题。而它报告的问题是堆栈损坏。关于这个函数,后续我们会跟进一步分析。




虚拟机主机容灾_运维



而ffffffffa02987eb这个地址,是函数__builtin_return_address(0)的返回值。当这个函数的参数是0的时候,这个函数的输出值是调用它的函数的返回地址。这句话现在有点绕,但是后续分析完调用栈,会很清楚。



函数调用栈



分析宕机问题的核心,就是分析panic的调用栈。下边的调用栈,乍看起来是system_call_fastpath调用了__stack_chk_fail,然后__stack_chk_fail调用了panic,报告了堆栈损坏的问题。但是稍微和类似的堆栈做一点比较的话,就会发现,事实并非这么简单。



虚拟机主机容灾_数据结构与算法_02



下边是一个类似的,以system_call_fastpath函数开始的调用栈,不知道大家有没有看出来这个调用栈和上边调用栈的不同。实际上,以system_call_fastpath函数开始的调用栈,表示这是一次系统调用(system call)的内核调用栈。下图中的调用栈,表示用户模式的进程,有一次epoll的系统调用,然后这个调用进入了内核模式。而上图中的调用栈显然是有问题的,因为我们就算查遍所有的文档,也不会找到一个系统调用,会对应于内核__stack_chk_fail函数。



虚拟机主机容灾_调用栈_03



提示:这边引出另外一个,在分析core dump的时候需要注意的问题,就是用bt打印出来的调用栈有的时候是错误的。



Raw stack



所谓的调用栈,其实不是一种数据结构。用bt打印出来的调用栈,其实是从真正的数据结构,线程(内核)堆栈中,根据一定的算法重构出来的。而这个重构过程,其实是函数调用过程的一个逆向工程。相信大家都知道堆栈的特性,先进后出。关于函数调用,以及堆栈的使用,可以参考一下下边这张图。大家可以看到,每个函数调用,都会在堆栈上分配到一定的空间。而CPU执行每个函数调用指令(call),都会顺便把这条call指令的下一条指令压栈。这些“下一条指令”,就是所谓的函数返回地址。



虚拟机主机容灾_调用栈_04



这个时候,我们再回头看Panic的直接原因那一部分,函数__builtin_return_address(0)的返回值。这个返回值,其实就是调用__stack_chk_fail的call指令的下一条指令,这条指令属于调用者函数。这条指令地址被记录为ffffffffa02987eb。我们用sym命令查看这个地址临近的函数名,显然这个地址不属于函数system_call_fastpath,也不属于内核任何函数。这也再次验证了,panic调用栈是错误的这个结论。



虚拟机主机容灾_运维_05



关于raw stack,我们可以用bt -r命令来查看。因为raw stack往往有几个页面,这里只截图和__stack_chk_fail相关的这一部分内容。



虚拟机主机容灾_调用栈_06



这部分内容,有三个重点数据需要注意,panic调用__crash_kexec函数的返回值,这个值是panic函数的一条指令的地址;__stack_chk_fail调用panic函数的返回值,同样的,它是__stack_chk_fail函数的一条指令的地址;ffffffffa02987eb这个指令地址,属于另外一个未知函数,这个函数调用了__stack_chk_fail。



Syscall number & Syscall table



因为带有system_call_fastpath函数的调用栈,对应着一次系统调用,而panic的调用栈是坏的,所以这个时候我们自然而然会疑问,到底这个调用栈对应的是什么系统调用。在linux操作系统实现中,系统调用被实现为异常。而操作系统通过这次异常,把系统调用相关的参数,通过寄存器传递到内核。在我们使用bt命令打印出调用栈的时候,我们同时会输出,发生在这个调用栈上的异常上下文,也就是保存下来的,异常发生的时候,寄存器的值。对于系统调用(异常),关键的寄存器是RAX,它保存的是系统调用号。我们先找一个正常的调用栈验证一下这个结论。0xe8是十进制的232。



虚拟机主机容灾_虚拟机主机容灾_07



使用crash工具,sys -c命令可以查看内核系统调用表。我们可以看到,232对应的系统调用号,就是epoll。



虚拟机主机容灾_虚拟机主机容灾_08



这个时候我们再回头看“函数调用栈”这节的插图,我们会发现异常上下文中RAX是0。正常情况下这个系统调用号对应read函数,如下图。



虚拟机主机容灾_调用栈_09



如下图,有问题的系统调用表显然是被修改过的。修改系统调用表(system call table)这种事情,常见的有两种代码会做(这个相当辩证),一种是杀毒软件,而另外一种是hack程序。当然还有另外一种情况,就是某个蹩脚的内核驱动,无意识的改写了系统调用表。

虚拟机主机容灾_数据结构与算法_10

另外我们可以看到,被改写过的函数的地址,显然和最初被__stack_chk_fail函数报出来的地址,是非常邻近的。这也可以证明,系统调用确实是走进了错误的read函数,最终踩到了__stack_chk_fail函数。

Raw data



基于上边的数据,来下结论,总归还是有点经验主义。更何况,我们甚至不能区分,问题是由杀毒软件导致的,还是木马导致的。这个时候我们花费了比较多的时间,尝试从core dump里挖掘出ffffffffa02987eb这个地址更多的信息。有一些最基本的尝试,比如尝试找出这个地址对应的内核模块等,但是都无功而返。这个地址既不属于任何内核模块,也不被已知的内核函数所引用。这个时候,我们做了一件事情,就是把这个地址前后连续的,所有已经落实(到物理页面)的页面,用rd命令打印出来,然后看看有没有什么奇怪的字符串可以用来作为特征串定位问题。

虚拟机主机容灾_调用栈_11

就这样,我们在邻近地址发现了下边这些字符串。很明显这些字符串应该是函数名。我们可以看到hack_open和hack_read这两个函数,对应被hacked的0和2号系统调用。还有函数像disable_write_protection等等。这些函数名,显然说明这是一段“不平凡”的代码。公网搜索这些函数会发现很多rootkit的示例。

后记

宕机问题的core dump分析,需要我们非常的耐心。我个人的一条经验是:every bit matters,就是不要放过任何一个bit的信息。core dump因为机制本身的原因,和生成过程中一些随机的因素,必然会有数据不一致的情况,所以很多时候,一个小的结论,需要从不同的角度去验证。