附录B Rust如何调试代码

本文通过调试Rust语言的一个安全漏洞来展示Rust如何调试代码。Rust语言在2018年9月曝光过一个安全漏洞,编号为CVE-2018-1000657[1]。Rust官方的GitHub仓库也有issues[2]的相关讨论。

该漏洞的成因如下:

· 混用了VecDeque<T>容器中“逻辑”容量和“物理”容量引发的UB。

· Rust产生segfault的条件,正是因为产生了UB。

· Rust里产生UB,只可能是在Unsafe Rust之下。

· 这个UB是因为逻辑漏洞导致指针错乱,然后导致std::ptr::write指针覆盖了合法数据。但这个不是段错误的原因。

· Rust在函数执行完之后,自动执行析构函数,也就是VecDeque的析构函数,其中也用到了unsafe,因为指针是错乱的,那么析构也错乱了。析构错乱导致合法的内存数据被释放,发生Segfault。

接下来使用LLDB对相关issues中的代码进行调试,以便验证漏洞分析是否正确。LLDB是macOS平台下的工具,命令和Linux平台的GDB基本相似。

B.1 环境配置

首先,使用rustup install 1.20.0 命令安装好有漏洞的Rust版本。别忘记使用rustup default 1.20.0选择该版本为Rust默认版本。

有关调试工具,笔者使用的是VSCode,需要安装CodeLLDB插件(Mac环境,Linux请用GDB相关)。环境配置好之后,使用cargo new lldb_demo 命令在src/main.rs 文件中保存issues中相关的示例代码。

代码大致如下:

完整代码可以在随书源码src/appendix/lldb.rs中找到。当然,你可以使用lldb命令进行调试,安装rust-lldb,但是不如使用VSCode方便。如图B-1所示,在VSCode Debug界面选择好配置,可以直接选择Add Configuration...来添加新的配置。

图B-1:在VSCode Debug界面选择配置

如图B-2所示,在选择Debug配置时,只需要选择LLDB:Debug Cargo Output就可以自动配置。然后,就可以开始进行调试了。

图B-2:选择Debug配置

B.2 调试代码

经过前文的分析,已经知道在哪里设置断点。如图B-3所示,在main函数中设置断点,因为问题出在main函数调用结束后的析构函数中。当然,只有这两个断点是不够的。但是可以开始进行调试了。

图B-3:在main函数中设置好断点

选择Debug界面,并单击该界面左上角的绿色三角形按钮,就可以开始调试代码。

如图B-4所示,刚开始缓慢单击Step Over(F10)按钮,也就是调试悬浮窗口的第二个按钮。

图B-4:单击Step Over(F10)按钮调试代码

直到程序执行完main函数,有结果输出为止,如图B-5所示。

图B-5:单击Step Over(F10)按钮直到有结果输出为止

此时观察VSCode侧边栏左侧的CALL STACK栏目,如图B-6所示。

图B-6:CALL STACK栏展示了当前的函数调用栈

这里首先需要介绍一个知识点:

· main函数执行的时候,Rust提供了一个很小的运行时std::rt::lang_start,会将main函数作为一个闭包传进去。

· lang_start支持Gloabl Heap和栈回溯支持。main函数中如果出现了panic,则会由它来负责恢复。

· Rust是基于LLVM的,实际上异常处理会分为两个阶段:搜索阶段和清理(cleanup)阶段。在搜索阶段,会检查 panic,并决定是否捕获它。在清理阶段,会决定到底运行哪个(如果有的话)清理代码对当前堆栈进行清理。它会调用析构函数和内存释放等。

前面分析漏洞的成因,可能是因为逻辑Bug导致析构函数释放了合法的内存,进而引起段错误。现在调试是想确认到底是不是这个原因。所以需要在rt::lang_start调用的时候打上断点,这样才可以更精细地调试到底层的每个细节。所以需要单击 CALL STACK 栏中的std::rt::lang_start,这时调试界面会跳转到一个汇编界面,在默认选中的那行代码设置好断点,如图B-7所示。

图B-7:在CALL STACK中选中std::rt::lang_start并设置断点

此时再次单击Step Over应该会跳入汇编界面,如图B-8所示。

图B-8:单击Step Over(F10)按钮跳到汇编界面

此时使用Step Into(F11)按钮,单步递进调试代码。看到左侧CALLSTACK调用栈已经执行到了core::ptr::drop_in_place函数,这应该是VecDeque调用析构函数,正在释放内存。继续Step Into,会看到另外一个core::ptr::drop_in_place函数调用,如图B-9所示。

图B-9:单击Step Into(F11)看到执行了另外一个drop_in_place函数

现在回顾一下VecDeque函数的析构函数定义。

看来此时代码已经释放了VecDeque的内存。但是此时代码还在正常运行,并未报出段错误。所以,继续使用Step Into单步递进调试,发现VecDeque::drop开始调用,如图B-10所示。

图B-10:单击Step Into(F11)看到执行了vec_deque::drop函数

继续使用Step Into,发现drop函数执行完毕,代码依旧正常运行,说明段错误不是在析构函数的时候发生的。继续调试。

因为当前是main函数在执行。在析构函数执行完毕,main函数退出之前,Rust会将内存再归还给操作系统。那么接下来运行的代码应该都是做这一部分工作。在调试过程中,还可以通过左上角的VARIABLES栏观察函数调用中变量值的变化,如图B-11所示。

图B-11:在Step Into过程中,通过VARIABLES窗口观察变量值的变化

这个调试过程需要比较长的时间,在这个过程中,还能看到VecDeque底层的RawVec在析构函数调用之后,多次调用dealloc_buffer来释放内存,如图B-12所示。

图B-12:在Step Into过程中,能观察到多次dealloc_buffer 被调用

继续调试,会看到heap::dealloc被调用,这意味着堆内存被释放,如图B-13所示。

图B-13:在Step Into过程中,看到heap::dealloc被调用

还会看到jemalloc的相关函数被调用,如图B-14所示。

图B-14:在Step Into过程中,看到jemalloc的dealloc方法被调用

在Rust 1.20中,Rust的默认内存分配器是Jemalloc,这里调用dealloc,意味着Jemallloc把内存归还给操作系统。直到此时,代码依旧正常运行。

直到最后的清理阶段完成之后,代码崩溃了,让VSCode出现了死锁,如图B-15所示。

图B-15:代码崩溃

直到执行完std::syscommon::at_exit_imp::cleanup之后,段错误才发生。std::syscommon::at_exit_imp是rt运行时的最后退出阶段,此时代码执行完毕,要将内存归还给操作系统。

同时,VSCode LLDB Debug工具抛出了EXC_BAD_ACCESS错误,并且此时代码调用停留在pthread_mutex_lock调用处。pthread_mutex_lock其实是调用libc库中的一个系统API,已经到操作系统底层了。抛出EXC_BAD_ACCESS错误一般是由“调用了已经释放的内存空间,或者说重复释放了某个地址空间”而引起的。

分析到这里,真相已经浮出了水面。

B.3 总结

(1)前文中分析段错误产生的原因经过了LLDB的实证。

(2)因为容量使用错误,导致指针混乱。

(3)在main函数析构函数调用之后,因为指针混乱,将不该释放的内存释放掉了。但是此时并未发生panic。

(4)在main函数退出运行时的时候,需要将内存归还给操作系统。此时调用了另外一个cleanup方法,在给操作系统归还内存的过程中,通过抛出的错误EXC_BAD_ACCESS分析,应该是调用了本来不该释放但已经释放的内存空间。

(5)错误发生在操作系统接口pthread_mutex_lock中,Rust根本无法捕捉,所以发生段错误。


[1] https://cve.mitre.org/cgi-bin/cvename.cgi?name=%20CVE-2018-1000657.

[2] https://github.com/rust-lang/rust/issues/44800.