当前位置: 首页 > 科技观察

黑客与宕机

时间:2023-03-21 16:35:00 科技观察

系统异常宕机(无响应、异常重启)的原因有很多,最常见的是操作系统内部缺陷和设备驱动程序缺陷。本文作者将与大家分享内存转储分析的底层逻辑和方法论,并通过一个在线真实案例展示从分析到结论的全过程。希望对同学们处理此类问题,理解系统有所帮助。帮助。相信经常和电脑近距离接触过的人都遇到过系统无响应或者突然重启的情况。如果这种情况出现在客户端设备上,比如手机或者笔记本电脑上,而且不是经常出现,基本上我们的解决方案就是鸵鸟算法,也就是静默重启设备,然后若无其事的继续使用发生了。但是如果这样的问题出现在服务器端,比如虚拟机或者物理机运行微信、微博后台程序,往往会造成非常严重的影响。少则导致业务中断,大则导致业务长时间无法正常工作。众所周知,这些计算机是由其上运行的操作系统驱动的,例如Windows或Linux。系统异常宕机(无响应、异常重启)的原因有很多,但一般来说,操作系统内部缺陷或设备驱动程序缺陷是最常见的两种原因。从根本上解决这类问题的“唯一正确”方法是操作系统内存转储分析(MemoryDumpAnalysis)。内存转储分析是一种高水平的软件调试能力,需要工程师具备丰富全面的系统级理论知识和大量的破案等动手实践经验。内存转储分析方法论内存转储分析是一项对专业能力要求极高的工作,也是一项非常艰巨的工作。分享过往的案例后,我得到了更多有趣的反馈,比如“耳边想起了柯南的配音”,或者“真的是黑猫警长!我是IT工程师,却整天做刑侦工作”.内存转储分析所需的基本能力,包括但不限于反汇编、汇编分析、各种语言的代码分析,了解系统层面的各种结构,如堆、栈、虚表等,甚至深入到位等级。试想一个长时间运行的系统。在这段时间里,系统积累了大量正常甚至异常的状态。这时候,如果系统突然出现问题,那么这个问题很可能与长期积累的状态有关。分析内存转储就是分析系统出现问题时产生的“快照”。事实上,工程师需要以这张快照为起点,追溯历史,找出问题的根源。有点像从案发现场推演事发经过。死锁分析方法内存转储分析方法从要解决的问题的角度可以简单分为两类,即死锁分析方法和异常分析方法。这两种方法的区别在于,死锁分析方法是从系统整体入手,而异常分析方法是从具体的异常点入手。死锁问题表现出来就是系统不响应问题。死锁分析方法着眼于全局。这里的全局指的是整个操作系统,包括所有的进程。我们从课本上学到的知识,一个运行中的程序包括代码段、数据段和堆栈段。用这种方法来看一个系统也很合适。系统的全貌其实包括正在执行的代码(线程)和保存状态的数据(数据、栈)。死锁的本质是系统中部分或全部线程进入了相互等待、相互依赖的状态,使得进程所承载的任务无法继续执行。因此,我们分析此类问题的中心思想是分析系统中所有线程的状态以及它们之间的依赖关系,如图1所示。图1中线程的状态是比较确定的信息。我们可以通过读取内存转储中线程的状态标志来获取此类信息。依赖分析需要大量的技巧和实践经验。最常用的分析方法有对象持有等待关系分析、时序分析等。异常分析方法与死锁分析相比,异常分析方法的核心是异常。我们经常遇到的异常包括被零除、非法指令执行、错误的地址访问,甚至是软件层面的自定义非法操作。这些异常反映在操作系统层面,就是非正常重启的宕机问题。归根结底,异常问题是由处理器执行特定指令引发的。也就是说,我们看到的现象一定是处理器踩到了异常点。因此,分析异常问题,需要从异常点出发,逐步推导出代码执行到该点的完整逻辑。从经验来看,知道如何分析内存转储异常的工程师并不多,理解以上几点的人就更少了。很多工程师分析异常重启问题,基本上只停留在异常本身,根本不去推导问题背后的整个逻辑。与死锁分析方法相比,异常分析方法没有那么多固定规则,甚至在很多情况下,因为问题的逻辑复杂,我们无从找出根源。总的来说,异常分析的底层逻辑是不断比较预期和意外的情况,进而找出背后的原因。比如处理执行错误指令触发的异常,我们需要从回答正常执行的指令应该是什么和处理器为什么得到错误指令这两个问题开始,继续深入得到到它的底部。使用死锁分析方法处理异常问题,使用异常分析方法处理死锁问题。以上两种内存转储分析方法是根据问题分析的出发点和一般的分析方法进行分类的。在处理实际问题的过程中,我们往往需要从系统的全局状态中寻找进一步处理异常问题的思路,也会用具体细致的分析方法对全局问题进行最后一击。黑客攻击和停机问题背景停机问题有一种相对罕见的问题模式,其中看似完全无关的机器同时停机。要处理这种模式的问题,我们需要在这些机器上同时找到可以触发问题的条件。通常,这些机器要么在大约同一时间点出现问题,要么从某个时间点开始陆续出现问题。对于前一种情况,更常见的情况是物理设备故障导致其上运行的所有虚拟机全部宕机,或者远程管理软件同时杀死多个系统的关键进程;对于后一种情况,可能的一个原因是用户在所有实例上部署了相同的有问题的模块(软件、驱动程序)。实例被大规模攻击是另一个常见原因。比如在WannaCry勒索病毒肆虐的时候,经常会出现一些公司或者部门的机器全部蓝屏的情况。在该案例中,用户安装阿里云云监控产品后,某大型云服务器不断宕机。为了证明我们的清白,我们花费了大量的身心精力来深入分析这个问题。通过这个案例分享,希望对读者有所启发。损坏的内核堆栈我们处理操作系统崩溃的唯一正确方法是使用内存转储。不管是Linux还是Windows,系统宕机后,都可以自动或手动生成内存转储。分析Linux内存转储的第一步,我们使用crash工具打开内存转储,使用sys命令观察系统的基本信息和宕机的直接原因。对于这个问题,导致宕机的直接原因是“Kernelpanic-notsyncing:stack-protector:Kernelstackiscorruptedin:ffffxxxxxxxx87eb”,如图2所示。图2关于这条消息,我们不得不从字面上看。"kernelpanic-notsyncing:"这部分内容是在kernelfunctionpanic中输出的。每当调用panic函数时,都必须输出这部分内容,所以这部分内容与问题没有直接关系。而“stack-protector:Kernelstackiscorruptedin:”这部分,在内核函数__stack_chk_fail中,这个函数是一个堆栈检查函数,它会检查堆栈,并在出现问题时调用panic函数生成内存转储报告是发现问题。它报告的问题是堆栈损坏。关于这个功能,我们后面会进一步分析。而地址ffffxxxxxxxx87eb就是函数__builtin_return_address(0)的返回值。当此函数的参数为??0时,此函数的输出值为调用它的函数的返回地址。这句话现在有点迷惑,但是后面分析调用栈后,问题就清楚了。函数调用栈分析宕机问题的核心是分析panic调用栈。从图3的调用栈来看,乍一看是system_call_fastpath调用了__stack_chk_fail,然后__stack_chk_fail调用了panic,报了stackcorruption的问题。但是如果你和类似的栈做一点比较,你会发现并没有那么简单。图3和图4是从system_call_fastpath函数开始的类似调用堆栈。不知道大家能不能看出这个调用栈和上面调用栈的区别。实际上,以system_call_fastpath函数开头的调用栈说明这是一个系统调用(systemcall)的内核调用栈。图4图4中的调用堆栈表示用户模式进程。有一个epoll系统调用,然后这个调用就进入了内核态。图3中的调用栈显然是有问题的,因为即使我们翻遍所有文档,也找不到对应内核__stack_chk_fail函数的系统调用。这里需要提醒的是,这导致了另一个在分析内存转储时需要注意的问题,即bt打印的调用栈有时是错误的。所谓的调用栈其实并不是一种数据结构。bt打印出来的调用栈,其实就是真实的数据结构,线程内核栈,按照一定的算法重构出来的。而这个重构过程其实就是函数调用过程的逆向工程。栈的特点相信大家都知道,就是先进后出。函数调用和栈的使用请参考图5,可以看出每次函数调用都会在栈上分配一定的空间。当CPU执行每条函数调用指令call时,会顺便将call指令的下一条指令压入栈中。这些“下一条指令”就是所谓的函数返回地址。图5这时,我们再回过头来看直接导致Panic的部分,也就是函数__builtin_return_address(0)的返回值。这个返回值其实就是调用__stack_chk_fail的call指令的下一条指令,这条指令属于caller函数。该指令的地址记录为ffffxxxxxxxx87eb。如图6所示,我们使用sym命令查看该地址附近的函数名。显然,这个地址不属于函数system_call_fastpath,也不属于内核的任何函数。这也再次验证了panic调用栈错误的结论。图6是原始栈,如图7所示,我们可以使用bt-r命令查看。因为rawstack往往有好几页,这里只截图了__stack_chk_fail相关的部分。在图7这部分,有3个关键数据需要注意。paniccall__crash_kexec函数的返回值,是panic函数一条指令的地址;__stack_chk_fail调用panic函数的返回值。同样,它是__stack_chk_fail函数的返回值一条指令的地址;ffffxxxxxxxx87eb该指令地址属于另一个未知函数,它调用了__stack_chk_fail。Syscallnumber和Syscalltable对应一个系统调用是因为system_call_fastpath函数的调用栈,panic调用栈坏了,所以这时候我们自然会想这个调用栈对应的是什么系统调用。在linux操作系统实现中,系统调用是作为异常实现的。操作系统通过这个异常,将与系统调用相关的参数通过寄存器传递给内核。我们在使用bt命令打印出调用栈的时候,也会输出调用栈上发生的异常上下文,即异常发生时寄存器保存的值。对于系统调用(异常),关键寄存器是RAX,如图8所示。它保存着系统调用号。我们找一个正常的调用栈来验证一下这个结论。0xe8是十进制的232。图8使用crash工具,sys-c命令可以查看内核系统调用表。我们可以看到232对应的系统调用号是epoll,如图9所示。图9这时候我们回过头来看“函数调用栈”部分的图3,会发现RAX在exceptioncontext为0。正常情况下,这个系统调用号对应的是read函数,如图10所示。图10从图11可以看出,问题的系统调用表明显被修改了。可以修改系统调用表(systemcalltable)的常用代码有两种,比较辩证。一种是杀毒软件,另一种是病毒或木马程序。当然还有另一种情况,就是一个蹩脚的内核驱动在不知不觉中改写了系统调用表。此外,我们还可以看到,改写后的函数地址显然与__stack_chk_fail函数原来报出的地址非常接近。这也可以证明系统调用确实走错了read函数,最后踩到了__stack_chk_fail函数。图11原始数据基于以上数据,要完全说服客户,还是有点经验主义的。更重要的是,我们甚至无法区分问题是由杀毒软件还是木马引起的。这时,我们花了很多时间试图从内存转储中挖掘出地址ffffxxxxxxxx87eb的更多信息。有一些基本的尝试,比如试图找到这个地址对应的内核模块等等,但是都失败了。该地址既不属于任何内核模块,也不被任何已知内核函数引用。这个时候我们做了一件事,就是把这个地址前后所有已经执行过(到物理页)的页连续打印出来,然后用rd命令打印出来,然后看有没有任何可以用作签名定位问题的奇怪字符串。这样,我们在相邻地址找到了如下字符串,如图12所示,显然这些字符串应该是函数名。我们可以看到hack_open和hack_read这两个函数,分别对应hacked系统调用0和2,还有disable_write_protection等函数。这些函数名清楚地表明这是一段“不平凡”的代码。图12后记宕机问题的内存转储分析需要我们有足够的耐心。我个人的一个体会是:点点滴滴,就是不放过任何一点信息。由于机制本身的原因以及内存转储生成过程中的一些随机因素,难免会出现数据不一致的情况。因此,在很多情况下,一个小小的结论需要从不同的角度去验证。