《消毒器使用手册:内存管理、初始化机制以及竞态条件问题》
一些最为危险的“原生故障”其实是由那些看似运行正常的程序所引发的。 加密操作会生成正确的密文,解析器也会拒绝处理格式错误的输入数据,缓存机制也能通过基准测试,候选发布版本也能通过所有的单元测试和集成测试。 然而,在这些看似正确的结果背后,某些组件仍然认为自己拥有已经被转移的对象控制权;某些成功路径在运行过程中并未初始化输出字段;有些终结器还在等待释放那些实际上已经由其他运行时机制控制的对象;还有两个线程会修改相同的状态,但调度器并没有选择那种会导致竞争条件出现的执行顺序。 然而,预期的输出结果中根本没有任何迹象能够揭示这些隐藏的问题。 第一次出现可观察到的故障可能要几个小时后才会发生:比如分配
一些最为危险的“原生故障”其实是由那些看似运行正常的程序所引发的。
加密操作会生成正确的密文,解析器也会拒绝处理格式错误的输入数据,缓存机制也能通过基准测试,候选发布版本也能通过所有的单元测试和集成测试。
然而,在这些看似正确的结果背后,某些组件仍然认为自己拥有已经被转移的对象控制权;某些成功路径在运行过程中并未初始化输出字段;有些终结器还在等待释放那些实际上已经由其他运行时机制控制的对象;还有两个线程会修改相同的状态,但调度器并没有选择那种会导致竞争条件出现的执行顺序。
然而,预期的输出结果中根本没有任何迹象能够揭示这些隐藏的问题。
第一次出现可观察到的故障可能要几个小时后才会发生:比如分配器重新使用了已被释放的内存区域,垃圾回收机制改变了某些对象的可访问性,系统关闭流程最终处理了那些被遗漏的分配操作,又或者生产环境中的数据流产生了测试套件从未遇到过的同步现象。到那时,导致程序崩溃的代码往往属于漏洞的受害者,而不是漏洞的制造者。
这就是“代码清理工具”存在的意义——它们不仅能帮助人们更容易地调试程序错误,还能为那些普通测试通常会忽略的假设提供实时的验证依据:比如这个地址仍然可以被访问,这个内存分配确实有合法的拥有者,这个值在影响程序执行之前已经被正确初始化了,或者这种对共享内存的访问确实是按照运行时能够检测到的同步规则来进行的。
AddressSanitizer、LeakSanitizer、MemorySanitizer和ThreadSanitizer这四种工具并不是同一种调试模式的不同版本,它们分别关注执行过程中的不同方面。
ASan会监控地址的可访问性以及对象的生命周期;LSan会检查在程序终止时哪些内存分配仍然处于活跃状态,以及这种状态是否合理;MSan会追踪变量的初始化来源;TSan则会重建那些使得并发访问成为可能的逻辑关系。
但真正困难的部分才开始于编译器接受了这些清理工具的启用选项之后。
只有当你真正理解了这些工具实际检测到了什么,它们才能发挥出作用。哪些模块被加入了监控代码?哪些内存分配或同步操作在运行时是无法被检测到的?生成的报告是否足够详细,以便能够识别出有用的信息?程序是否真的走到了导致漏洞出现的那个环节?
而一旦这些工具开始提供可靠的信息,那么这些信息应该被用来做什么呢?是应该在每一个拉取请求中都被检查吗?还是应该被放入一个定期执行的任务中?又或者,在我们还没有完全理解基准测试结果的情况下,先以观察模式来使用这些工具呢?
在管理代码与原生代码的交界处,这些问题显得尤为突出。比如Dart、Java、Python、Swift、C#或Rust等语言编写的封装层可能会提供看起来安全的API,但实际上它们依赖于底层的原生引用计数机制、内存分配器、借用的指针、成功时进行资源转移的约定、回调函数、线程执行环境以及终结器等等。语言本身的界限并不能消除这些规则,而是让它们分散在两个可能对资源所有权和生命周期有着完全不同理解的运行时环境中。
本手册中的许多示例都涉及这类问题:那些表面上看似简单的代码,实际上在内部仍需要遵守原生语言的生命周期规则。
google/webcrypto.dart就是一个反复出现的例子。它将Dart的跨语言接口功能、BoringSSL库、资源释放机制、限定作用域的清理逻辑、构建时的钩子函数以及所有权转移机制结合在一个相对紧凑的系统中。这个例子也很好地说明了一个虽然不那么引人注目,但却非常常见的工程约束:有时候,你想要使用的代码清洗工具会受到周围开发环境的限制,因此你必须决定在当前环境下能够获取哪些有用的运行时信息。
这个问题并非Dart或密码学所特有。在实际系统中,很少能同时拥有一个可以被完美监控的依赖关系图、一个配合良好的运行时环境、完整的符号信息以及无限的持续集成时间。实际面临的问题通常更为具体:当前这个构建过程能够观察到什么,又无法观察到什么?什么样的工作负载才能使现有的信息变得有意义呢?
本手册的核心观点就是:代码清洗工具使得运行时契约得以有效执行。所有权问题在这个过程中扮演了重要角色,尤其是在跨语言接口的场景中;初始化和同步机制也同样如此。模糊测试、压力测试、故障注入以及并发检测技术会生成一些挑战这些契约的测试用例,而代码清洗工具则能够使这些违规行为变得可见。持续集成系统则会决定这些发现最终是会成为长期有效的回归防护措施,还是仅仅是一次性的调试工具。
后续章节将探讨影子内存、泄漏检测机制、初始化过程跟踪、竞态条件检测、跨语言接口中的所有权问题、工作负载设计、报告分类处理方法以及发布策略等内容。我们的目标并不仅仅是运行代码清洗工具,而是要真正理解一次成功的测试能够告诉我们什么,报告又能说明我们代码中存在哪些问题,还有哪些方面超出了这些工具的监测范围。
目录
先决条件
本手册假定读者已经具备基本的原生代码调试能力,以及理解并发程序的相关知识。本书的内容建立在指针、内存分配、线程同步、构建系统以及持续集成技术的基础上,并不会重新介绍C语言或C++的基础知识。
本章的重点在于探讨动态执行过程中的各种现象: sanitizer运行时能够观察到什么,它的报告意味着什么,以及所有权机制、初始化规则和同步机制应如何影响相关的工作流程。
以下是您应该已经掌握的内容:
能够阅读原生代码的堆栈跟踪信息,并熟练地在调用者、被调用者、内存分配路径及资源清理路径之间进行导航。
了解基本的所有权概念,例如借用指针与拥有指针的区别、所有权转移机制、引用计数技术以及确定性的资源释放方式。
在实际应用中掌握堆和栈的内存分配原理,理解为什么内存分配器的搭配方式以及对象的生命周期会影响到程序的执行结果。
了解并发编程的基础知识,包括互斥锁、原子操作、线程创建机制,以及必须明确指定线程执行的顺序,而不能将其视为理所当然。
能够通过命令行构建和运行测试用例,并理解本地调试版本与持续集成构建版本之间的区别。
另外,为了配合书中的示例进行学习,您还需要准备以下条件:
安装最新的Clang/LLVM工具链,确保能够使用
clang、clang++和llvm-symbolizer等工具。对于大多数基于编译器的示例来说,需要Linux或macOS操作系统。对于所有示例以及使用Valgrind进行测试的部分,Linux是最适合的环境。
需要有一种方法可以运行生成的二进制文件,并能够保存它们的日志及崩溃信息。
对于涉及整个进程或外部函数调用的示例来说,可以选择使用Valgrind工具。
对于
webcrypto.dart>案例研究而言,可选地需要安装Dart开发工具。不过进行所有权分析时并不要求具备Dart编程经验。最后,还需要有足够的耐心来仔细阅读堆栈跟踪信息,而不要急于责怪最后一个修改过文件的人 :)
我们不会详细讲解编译器或环境的配置过程。这些命令假设相关编译器和运行时工具已经安装完毕,因此重点会放在代码插入、报告生成、工作流程设计以及策略制定等方面。
同时,我们也不会重新讲解内存管理、多线程编程、模糊测试或编译器构建等基础知识。只有那些会影响到sanitizer分析结果解读方式的细节内容,才会被详细探讨。
正确结果与有效执行之间的差距
在深入研究影子内存、泄漏根源追踪、起源跟踪机制或“发生顺序图”之前,首先需要区分两个容易混淆的概念:功能正确性和执行有效性。
传统的测试会提出一个功能性的问题:给定这样的输入条件,系统是否返回了预期的结果,是否改变了预期的状态,或者产生了预期的副作用? 消毒工具会提出另一个不同的问题:产生该结果的执行过程是否有效? 这些问题有重叠之处,但并不等同。一个函数可能会返回正确的明文内容,但却泄露了本机键信息;一个解析器在拒绝处理格式错误的输入时,可能会错误地读取未初始化的输出长度;一个封装层可能连续数月都能正常工作,而实际上两个不同的对象都认为自己拥有同一个本机句柄;一个无锁缓存虽然能够通过所有确定性测试,但其内部仍可能存在调度器尚未发现的竞争条件。 这种区别正是开发消毒工具时需要考虑的出发点: 该图示说明了为什么一个测试结果合格,并不意味着相应的执行过程就是有效的。消毒工具能够检测出普通基于输出结果的测试所忽略的那些隐藏在运行时的问题。 内存错误很少会在它们最初产生的地方导致程序失败。 最初的错误可能很微小:比如某个引用没有得到正确的更新,指针在所有权发生变化后仍然可以被使用,清理回调函数被错误地移除了应有的作用范围,某个成功执行路径中遗漏了输出参数的值,或者终结器被延迟释放了。 实际的故障可能会在很久之后、在另一个组件中、在另一个线程上,甚至是在程序关闭时才会发生。到那时,崩溃现场堆栈中所显示的代码往往并不是导致错误的那段代码,而是那些在正常执行过程中被调用的代码。 消毒工具之所以能发挥作用,是因为它们能够维护普通执行过程所无法保留的运行时状态。ASan会跟踪内存是否可以被访问以及这种状态在对象生命周期内是如何变化的;MSan会传递初始化状态信息;TSan会记录内存访问操作及同步关系;LSan则会检查进程终止时哪些资源仍然可以被访问。 这些额外的状态信息不仅能帮助我们确定程序崩溃的具体位置,而且根据所使用的工具不同,报告还可能显示对象是在哪里被分配和释放的、未初始化的值是从哪里产生的,或者哪些线程之间的访问操作存在竞争条件。虽然这些报告仍然需要进一步分析解读,但它们已经为我们提供了足够的信息,让我们能够开始重建程序是如何陷入错误状态的。 以一个本机API为例,它需要将一个32字节的摘要写入调用者提供的缓冲区中: 一个合理的约定是:当函数返回true时,说明输出结果以及*output_len字段都已经被正确初始化了。但现在假设在某个成功执行路径中,虽然摘要被写入了缓冲区,但却忘记了设置output_len的值。 有些模块可能没有被添加监控代码;自定义的内存分配机制可能会掩盖你感兴趣的逻辑对象边界;某些同步操作可能发生在TSan无法观察到的代码段中;符号信息可能会缺失,或者优化措施可能会使程序堆栈的结构变得难以被复原;运行时对内存的管理方式也可能与sanitizer的检测模型不匹配;有些错误会被有意地忽略掉,从而不被发现。 实际上,这些问题中的任何一个都可能导致错误被遗漏。即使一个程序被添加了完备的监控代码,它也无法提供任何关于那些从未被执行过的代码路径的信息。 因此,在完成一次彻底的测试之后,真正有意义的问题并不是:“我们现在安全了吗?” 我们现在安全了吗? 而应该是:“这次测试实际上观察到了什么?还有哪些问题没有被发现?” 这才是对动态分析结果应该持有的正确态度。如果sanitizer的检测结果显示为“绿色”,那只能说明它所观察到的那些部分是安全的,但并不能证明程序的其他部分也是安全的。 人们通常会这样介绍sanitizer:ASan可以检测到“释放后仍被访问”的情况以及越界访问行为;LSan能够发现内存泄漏问题;MSan会检测未初始化内存的使用情况;而TSan则能找出数据竞争现象。 这种描述确实没错,但在看到实际的测试报告时,这些信息并没有太大帮助。 一个更有效的理解方式是:思考程序做出了哪些假设,而sanitizer却能够证明这些假设是错误的。在那些与“所有权”相关的代码中,这通常意味着需要弄清楚谁有权使用某项资源,谁负责释放它,以及在这些过程中这些规则是否发生了变化。 对于MSan和TSan来说,这个思考方向还可以扩展到内存的初始化和同步操作:某个操作要能够正常执行,必须满足哪些条件?而实际测试结果是否证明了这些条件得到了满足呢? ASan所关注的“契约”是与内存地址的有效性及生命周期相关的。程序会表现得好像某段内存仍然可以被访问一样,而ASan则会通过运行时状态来判断这一点是否属实。如果某段内存已经被释放、被标记为危险区域,那么对其进行访问就违反了运行时的规则。 LSan则关注的是“所有权”的另一方面。它不是去检查某个对象在其生命周期结束后是否还被访问过,而是要探究为什么某个内存分配的生命周期根本就没有结束。在进程终止时,LSan会从已知的资源起点开始追踪所有可被访问的内存分配情况;那些没有被任何合法路径所关联、仍然处于被分配状态的内存就会被报告为泄漏内存。因此,我认为LSan更应该被称为“终端所有权检测工具”,而不仅仅是“内存泄漏检测工具”。 MSan关注的则是另一个“契约”:内存初始化的来源。可被访问的内存并不一定就是有效的数据。一个从未被初始化的字节,不应该莫名其妙地就被用作条件判断、指针、尺寸值、参数或返回值。MSan会跟踪这些未初始化状态在程序执行过程中的变化过程,并在程序最终开始将这种未初始化的状态视为有效数据时发出警报。 TSan主要处理“同步可见性”问题。两个线程可能都会访问相同的状态,但如果这些访问操作存在冲突,就必须有一种排序规则来使它们变得合法。TSan会重建它所能观察到的同步关系,并报告那些仍然没有按照正确顺序进行的访问操作。这种区分非常重要:程序可能会认为某个自定义的运行时环境、回调协议或外部库提供了同步机制,但TSan却看不到这样的机制存在。 在处理原生API时,这些概念会变得更加具体,因为原生代码往往会直接暴露出所有权相关的规则。 BoringSSL就是一个很有用的例子,尽管这种模式并不局限于密码学领域。它的API规范明确区分了诸如 这些名称实际上起到了确保代码正确性的作用。如果某个封装层将所有这些情况都简化为“某个原生指针”,那么它就会丢弃API原本试图保留的信息。 终结器也可以让同样的问题变得不那么明显。当一个封装层拥有某个原生资源时,通常会为其关联一个终结器。如果后来所有权转移到了另一个对象上,但终结器仍然存在,那么这两个对象都可能会包含针对同一资源的清理逻辑。这些清理操作并不一定需要立即执行,最终的结果取决于哪个清理逻辑会先被执行:可能是两次释放操作同时发生,也可能是对已释放资源的错误访问,又或者是某个对象的生命周期比预期的要长。 MSan也在另一个层面上揭示了类似的问题。假设一个函数报告了成功的结果,而调用者将这种成功理解为“所有输出都已经初始化完毕”。但如果其中一个成功的执行路径没有修改某个输出参数,那么调用者和被调用者实际上是在遵循不同的规则进行操作,尽管他们双方都没有出现明显可疑的内存访问行为。 当同步操作跨越不同的抽象层次时,TSan的报告格式也是相同的。只有当某种锁机制、原子操作、回调协议或运行时事件能够确保操作的正确顺序时,代码才可能是正确的。如果这种顺序并不存在(或者存在于被监控的执行环境无法看到的地方),那么报告就会说明从运行时的角度来看,这些同步操作是不完整的。 因此,当将 sanitizer的报告视为组件之间的分歧而非某个局部错误指令时,它往往会更具参考价值。程序的某一部分认为某个对象仍然处于“活跃状态”,而另一部分却已经释放了它;一方声称结果已经被初始化,另一方则认为从未进行过任何写入操作;一个线程假定另一个线程已经安全地更新了对象的状态,但实际的同步关系图却显示了相反的情况。 对于那些大量使用外部函数调用的代码来说,所有权的判断正是这些分歧最容易产生的地方。 以下这个所有权流动图为那些依赖大量外部函数调用的系统提供了一个非常有用的思维模型: 该图说明了为什么在涉及外部函数调用的场景中,所有权的判断需要通过明确的状态变化来体现。如果某个句柄的借用、转移、终结或作用域设置出现了错误,就可能会导致内存泄漏、双重释放,或者“终结器在对象已被释放后仍被调用”的情况。 这种模式并不仅仅存在于 BoringSSL 中。许多本地 API 都会明确区分那些由特定实体拥有的对象、基于引用计数进行管理的对象、被借用的资源、被转移的句柄、属于特定作用域的内存,以及那些在操作中途失败时必须被释放的临时资源。 虽然这些术语可能有所不同,但核心问题始终是相同的: 正是这些区分使得 sanitizer 的报告结果更易于理解。当所有权的转移过程被明确地记录下来时,ASan 通常能够追踪到导致“对象已被释放后仍被访问”漏洞的具体操作;LSan 的报告也可以与那些从未被正确释放的资源引用关联起来。然而,当这些所有权转移过程在代码中并没有被明确体现出来时,sanitizer 虽然仍然能够发现错误,但其报告结果却需要根据一种在代码中并未被清晰描述的权限模型来进行反向分析才能理解。 同样的原则也适用于 MSan 和 TSan,尽管从严格意义上来说,它们的检测机制并不属于所有权检测机制。API 应该明确规定初始化操作的后续条件;并发代码也应该清楚地说明各种同步关系。这些规则越是少依赖于那些隐含的假设,人们就越容易理解 sanitizer 实际上发现了什么问题。 从这个角度来看,这些工具确实让一些“契约”变成了可执行的规范。它们并没有创造程序中关于所有权、初始化或同步的规则,这些规则本来就存在。sanitizer 只是在运行时观察程序的执行过程,并在发现程序的行为与这些规则相矛盾时,告诉人们这一点而已。 这四种主要的内存检测工具在语法上的差异较小,而它们在检测程序执行过程中的异常行为方面的能力则存在明显差别。 上述这些数据仅是初步估算值。实际的性能影响会受到工作负载特性、内存分配器的行为、依赖关系复杂度、平台类型以及运行时架构等多种因素的影响。 AddressSanitizer常被人们认为是一种快速检测内存损坏问题的工具,但实际上它的检测机制更为具体:ASan会跟踪程序在访问某块内存区域时,该区域是否确实处于可访问状态。 编译器会对所有的读写操作进行标记处理,这样这些操作就可以与运行时维护的元数据进行对比分析。堆对象会被划分为“危险区域”,被释放的内存也会被标记为“不可用状态”;最近被释放的内存通常需要经过一段时间的“隔离处理”后才能被重新使用。 对于栈内存,同样的检测机制也被应用于局部变量的生命周期检测。因此,在启用了相关功能的情况下,ASan能够检测到“作用域结束后仍继续使用变量”的错误,以及“函数返回后仍继续访问已释放的内存”的错误。 正是由于这种检测机制,ASan能够非常有效地发现与内存生命周期相关的错误。例如,某个错误可能在程序崩溃之前就已经发生了,但只有当这种错误最终导致对“已经被视为不可访问的内存区域”的访问时,才会被ASan检测出来。 ASan所能检测到的错误类型非常广泛,包括堆内存、栈内存以及全局变量范围内的越界访问行为、“释放后仍继续使用内存”的错误、“作用域结束后仍继续使用变量”的错误、“函数返回后仍继续访问已释放的内存”的错误、重复释放内存的行为、无效的内存释放操作,以及在支持相应平台的条件下,还能通过该工具检测到内存泄漏问题。 在调试构建过程中,编译器选项应侧重于生成有用的报告,而非追求极致的优化效果。一个实用的配置基准如下: 这种配置会保留调试信息及帧指针,同时避免对兄弟函数的调用进行优化,这样就能确保有用的函数调用不会从堆栈中消失。在运行时,通过`llvm-symbolizer`工具可以检测内存泄漏,并在平台支持的情况下检查变量的初始化顺序。 如果这些选项并不适合你的构建系统,那么它们并不会带来任何特殊的好处。关键在于生成一个其故障原因能够被清晰解释的二进制文件。如果某种调试机制虽然能节省少量的运行时间,但却导致生成的堆栈信息无法被正确解析,那么这种牺牲就是不值得的。 默认情况下,ASan会在检测到第一个错误时就停止执行。在排查问题时,保持这一行为是很有必要的。 一旦程序进入了无效的内存状态,后续出现的故障往往就难以被可靠地分析了。一次错误的写操作可能会破坏相邻的数据,进而导致程序在完全无关的位置出现错误访问。继续运行程序虽然可能会生成更多的错误报告,但并不一定能帮助你找到更严重的缺陷。 通常来说,第一个出现的错误报告才是最有参考价值的,因为此时内存的分配、对象的生命周期变化以及错误的访问操作都还与最初的错误原因密切相关。 ASan确实非常强大,但其检测结果也存在一定的局限性。完全静态链接并不是它所支持的常规编译模式;在64位系统中,ASan会为阴影映射机制预留一大块虚拟地址空间;部分代码插装措施会削弱运行时能够获取的信息精度;自定义的容器和内存分配器也可能掩盖对象的实际边界;此外,在某些情况下,对于那些部分越界或未对齐的访问操作,ASan也会出现检测错误。 这些局限性并不会使ASan变得失效。它们只是明确了“一次成功的ASan调试”应该具备什么条件:即本次测试中执行的所有内存访问操作都没有跨越那些被插装工具能够检测到的地址边界。 ASan之所以能高效运行,主要是因为它采用了紧凑的阴影存储机制,而不是为每次内存分配都创建一个庞大的元数据对象。 在这种映射方式下,一个阴影字节可以表示应用程序中8字节的内存内容。当阴影值为0时,表示这8字节的所有内存都是可访问的;当阴影值为1到7中的某个数值时,表示只有前若干字节是可访问的,其余部分则不可访问;而一些非零值则被用作标记,用于指示某些特殊区域,例如禁止访问的区域、已释放的内存空间或无效的堆栈状态。 该图展示了ASan是如何将应用程序内存映射到“影子内存”中的,这样它就能快速判断某次读写操作是访问了有效内存、部分有效内存,还是受到了污染的内存。 在发生被标记为需要检测的读写操作之前,编译器生成的代码会将应用程序地址映射到相应的“影子字节”,并检查此次访问是否合法。 对于普通的堆内存分配,内存分配器会在对象周围设置“污染区域”。当该对象被释放时,其所在的内存也会被标记为受污染状态,因此通常会被隔离一段时间,而不会立即被回收以便重新使用。 这种延迟机制非常重要。如果没有这个延迟,一个失效的指针就很可能会继续指向同一个地址上仍然有效的数据。通过保持这些区域处于受污染状态,可以让那些在对象被释放后仍被使用的旧指针有更多时间出现错误。 栈内存的检测机制也是基于类似的原理来处理局部变量的。当某个作用域结束时,相关区域会被标记为受污染状态;而额外的检测机制可以在函数返回后继续维持这种受污染状态,从而捕捉那些原本可能因旧栈帧被重新使用而无法被发现的错误。 “影子内存模型”既解释了ASan为何能够快速检测到问题,也揭示了它的一些局限性。 当非法访问发生在编译器和运行时都已知的范围之内时,ASan的检测效果非常好;但当访问操作完全发生在未被标记为需要检测的代码中,或者内联汇编代码绕过了编译器生成的检查机制,又或者内存分配器将多个逻辑上独立的对象合并为一个可寻址的区域时,ASan的效果就会大打折扣。 此外,“逻辑所有权错误”与“可寻址性违规”之间也存在重要的区别。ASan并不知道你的API是否规定了指针的所有权转移情况;它只能检测到内存何时被标记为受污染状态,以及之后是否有人员访问了这些被污染的内存。如果所有权模型的定义是错误的,但在程序运行过程中并没有发生任何非法访问行为,那么ASan也就无能为力了。 如果你不再把最顶层的栈帧视为错误发生的根源,那么通常会更容易理解ASan的报告内容。 对于典型的“堆内存释放后仍被使用”的错误案例,至少有三个关键点需要被还原出来: 最终导致访问无效内存的那次操作 创建该对象的内存分配操作 结束该对象生命周期的内存释放操作 根据报告的内容,你可能还会获得有关“影子内存”的上下文信息以及线程创建的历史记录。 这些栈帧信息实际上反映了人们对同一个对象存在不同的理解或判断。 分配栈可以告诉你对象的生命周期是从哪里开始的;释放栈可以告诉你程序的某个部分是在什么时候认定该对象的生命周期已经结束的;而当前的访问操作则能说明还有哪些代码仍然认为这个对象是可以被使用的。 例如: 就在这里,ASan发现了问题所在。实际上,导致故障的转换操作早在之前就已经发生了。 这种区分在真正的修复过程中非常重要。在目标代码被访问之前添加空值检查,虽然可以避免某些崩溃现象的发生,但仍然会使得对象的所有权状态保持错误的状态。而更好的修复方法应该是那些在某个对象不再属于当前所有者时,程序却未能及时反映这一变化的情况。 只有当ASan所识别的内存分配边界与应用实际关心的边界相一致时,它才能发挥出最佳的效果。 对于普通的`malloc`、`free`、`new`和`delete`等函数来说,这种情况很容易理解。但对于那些使用“内存池”、“slab分配机制”或其他复杂分配策略的程序而言,情况就会变得复杂得多。 以一个内存池为例:它从系统中申请了一块1 MiB大小的内存区域,然后将其分割成数百个64字节大小的对象。从系统分配器的角度来看,这其实只算是一次内存分配操作。除非这个内存池与ASan配合使用,否则某个64字节对象对下一个对象的覆盖操作完全可能发生在仍然可访问的内存区域内。 这样一来,程序就跨越了对象之间的逻辑边界,而ASan可能并没有相应的机制来检测这种错误。 那些支持sanitizer功能的分配器通常会通过以下几种方式来解决这个问题:它们可以主动将未使用的内存空间标记为“有毒”状态;或者通过sanitizer提供的API来标注容器对象的边界;此外,还有一些分配器提供了测试模式,在这种模式下,具体的内存分配操作会由系统分配器来完成,这样ASan就可以单独检测这些操作了。 工作负载的设计也可以起到辅助作用。如果能够积极地重复使用内存空间,那么那些过时的对象引用就更容易与已经发生变化的对象状态发生冲突。通过生成计数器,可以在分配器层面及时发现那些过时的对象引用。相比那些让相同对象长时间保持活跃状态的稳定工作负载来说,反复进行分配/释放/重新分配的操作通常更有利于进行生命周期测试。 需要注意的是,分配器的可见性并不是自动设置的。 如果某个分配器故意隐藏了应用程序所定义的对象边界,那么在构建脚本中启用`-fsanitize=address`选项也无法重新恢复这些边界。要么这个分配器必须让ASan能够看到这些边界,要么测试环境必须采用某种能够使这些边界变得可见的配置方式。 在常见的库边界处,拦截器也起到了类似的作用。它们能够让sanitizer运行时了解那些原本会发生在库代码内部的操作。不过,拦截器并不能替代完整的监控机制;它们只是扩展了ASan的检测范围而已,并不能让那些不透明的代码变得透明可读。 有时候,抑制错误确实是必要的。 有时,错误报告可能来源于第三方库,而你无法立即对这些库进行修改;也可能是某个平台组件已经发布了修复补丁,但这些补丁还没有应用到你所使用的环境中;此外,在某些情况下,sanitizer运行时与程序中的依赖关系之间可能会发生严重的冲突,导致系统在没有临时解决方案的情况下无法继续正常使用。 在那种情况下,应该抑制特定的问题,而不是它周围的相关代码。 过度进行抑制是危险的,因为这会改变运行绿色 sanitizer 的真正意义。如果覆盖整个库、命名空间或调用路径,即使最初导致抑制的原因早已被遗忘,也可能掩盖新的、无关的缺陷。 对于你自己编写的代码来说,使用抑制机制应该是例外情况。如果报告是真实且需要采取行动的,那么仅仅因为修复措施比较繁琐就隐藏这些问题,那就违背了运行 ASan 的初衷了。 当确实需要使用抑制机制时,必须保留足够的信息来说明其存在的理由:应该使用的最具体的匹配条件、根本问题所在、负责解决该问题的组件,以及将来何时会再次审查这些异常情况。 要确保系统能够及时发现新的故障,并将每一个异常都视为当前版本代码中存在的一种特定问题。 与“使用后释放未释放内存”或“缓冲区溢出”这类故障不同,内存泄漏并不会导致程序崩溃。程序可以正常运行并返回正确的结果,所有测试也可以通过,但即便如此,系统中仍可能存在未被释放的内存分配。 在程序执行的某个环节中,这些内存的“所有权”并没有被正确地释放。 LeakSanitizer 就是针对这种情况设计的。ASan 在程序运行过程中会监控所有的内存访问操作,而 LSan 则在程序关闭时发挥作用——此时它可以检查系统中还剩余哪些内存分配,以及这些分配是如何与仍在使用的对象关联起来的。 因此,对于那些清理操作容易被忽略、但不会直接影响程序正常运行的代码来说,LeakSanitizer 的检测效果尤为显著。例如:跨语言封装代码、部分构造过程出现的错误、资源释放路径中的问题、缓存机制、引用计数对象,以及那些在某些程度上依赖资源最终释放的系统等。 Clang 可以将 LSan 作为 ASan 的一部分来运行,也可以单独使用 ` 一个有用的思维模型是“终端所有权会计”原则:当程序进入资源释放阶段时,所有的内存分配要么应该已经被释放,要么必须有正当理由仍然保持可访问状态。LSan 会根据它能够检测到的运行时状态来重新判断属于后一种情况的内存分配。 这种区分非常重要,因为很多与“所有权”相关的问题并不会表现为无效的内存访问。例如:某个引用可能永远都不会被释放;错误处理路径可能会忽略某次资源清理操作;全局变量或缓存机制可能会无意中使某些对象图仍然保持活跃状态。虽然程序可以继续正常运行,但这些“所有权链”却始终没有闭合。 如果你已经配置好了 ASan 并且它能够正常工作,那么在那里启用内存泄漏检测通常是最实际的选择。独立的 LSan 也是可用的,在那些不希望使用完整的 ASan 监测机制或难以将其集成到系统中时,LSan 可以发挥重要作用。不过从历史经验来看,LSan 所受到的测试力度确实比与 ASan 集成在一起的版本要少一些。 某些情况下,内存分配的可访问性也可以是人为设定的。一些运行时环境会保留进程生命周期内的缓存数据;某些库可能会在初始化全局状态后不再对其进行清理,因为进程退出时这些资源自然会被释放。测试框架和托管型运行时环境在关闭过程中可能会显示一些与正在检测的组件无关的内存分配信息。 正因如此,针对整个应用程序的内存泄漏报告往往比针对特定组件的报告更难以解读。 对于库或函数调用接口封装层来说,使用一个小型辅助进程来进行测试会提供更多有用的信息: 这种生命周期越短,就越容易回答这样一个关键问题:在当前这个阶段,哪些内存分配应该是仍然存在的? 对于那些由终结器驱动的代码来说,需要格外小心。当一个托管对象变得不可访问时,并不意味着它的原生资源在进程开始进行泄漏检测之前就已经被释放了。如果测试结果依赖于后续的资源清理操作,那么测试框架就需要考虑运行时的实际清理行为,而不能简单地将进程退出视为一种确定的资源释放机制。 异步清理、工作线程以及后台缓存也会带来类似的问题。如果在正常清理操作完成之前就终止了泄漏检测流程,那么测试结果可能会显示一些实际上在后续会被应用程序释放的内存占用情况。 这些都是与测试工作量相关的问题,并不是忽略LSan工具的理由。测试的目标应该是达到那种你真正想要测量其内存占用情况的状态。 在已有的代码库中添加泄漏检测功能时,最初得到的测试结果往往并不理想。 其中一些问题可能是你自己的代码中存在的内存泄漏;而另一些问题则可能源于依赖库、运行时的关闭机制、进程生命周期内的缓存数据、测试框架中的未完成清理操作,或者是一些需要进一步调查其内存访问情况的内存分配。 如果试图立即解决所有这些问题,通常会得到两种结果之一:要么是一大堆模糊的错误信息,要么是大家都觉得没有必要再重新运行和关注的测试流程。 首先应该确保这些测试结果是可以重现的。 对于你自己编写的代码中存在的明确内存泄漏问题,解决方法通常很简单:修复相关代码并保留能够重现该问题的测试环境。而对于那些与运行时环境或依赖库相关的问题,需要先确定它们是否具有稳定性、在程序关闭时是否真的还能被访问到,以及是否可以通过一个独立的进程来隔离这些问题与正在测试的代码。 只有在对那些你有足够证据能够理解其原因的问题才采取抑制措施,并且要确保这些抑制操作的范围尽可能狭窄。 随着基线状态的逐渐改善,可以逐步收紧对错误结果的判断标准。在针对特定问题的测试中发现的新的内存泄漏问题,很可能会迅速影响到整个测试流程的效率;而对于那些来自托管型运行时环境或大型宿主进程的问题,则可能需要进一步分析其成因之后,才能决定是否应该采取同样的处理方式。 这也正是为什么泄漏的严重程度和泄漏的正确性应该被作为不同的评估指标的原因。在程序启动过程中仅发生一次内存泄漏的四字节分配,可能对程序运行不会产生任何明显影响,但这一现象仍然说明某种所有权路径没有得到正确的处理。这种泄漏是否会导致程序无法正常退出,取决于相关规则;而所有权机制是否存在错误,则是另一个需要考虑的问题。 当某个泄漏问题持续存在,并且新的检测结果再次显示出异常时,LSan就不再仅仅是一种定期执行的清理工具,而是变成了一个用于检测那些之前没有被有效检查过的所有权路径是否出现问题的工具。 ASan能够发现那些仍在使用已经不应再被访问的内存的数据;而MSan则能检测出另一种错误:虽然这些内存地址是可以被正常访问的,但其中存储的值其实从未被正确地初始化。 这种区分非常重要,因为未初始化的数据并不一定会立即导致程序崩溃。它们可能被用来表示长度、标志位、指针偏移量、分支条件,或者作为被传递给其他函数的输出值。程序可能会在很长一段时间内继续使用这些未初始化的值,直到最终执行到MSan认为会导致问题的操作为止。 正因为如此,MSan在检测跨API边界的数据错误时表现得尤为出色。 调用方看到函数返回了成功结果,就会认为API所承诺的所有输出值都已经可以正常使用了;而被调用方可能包含这样一段代码:它虽然返回了成功结果,但实际上并没有生成任何这些输出值。单独来看,这两段代码似乎都是合理的,但只有当调用方使用到了那些从未经过正确初始化的数据时,问题才会显现出来。 Mozilla的NSS漏洞1767590就是一个很好的例子。在这个漏洞中,即使被调用方的某些路径在返回成功结果时并没有实际生成相应的输出值,但系统仍然认为这个输出值已经被初始化了。MSan最终确实检测出了这个未初始化的值,但更值得关注的问题是API的设计本身:对于“成功”这一概念的理解,在调用方和实现代码中的定义是存在差异的。 这种问题在MSan的检测结果中屡见不鲜:错误现象出现在程序的实际使用环节,而问题的根源往往可以追溯到更早的时候,当时某些路径并没有能够生成调用方所期望的状态。 一个基本的MSan配置如下: 虽然追踪数据的起源会增加一定的运行开销和内存消耗,但对于那些难以发现的漏洞来说,这些额外的信息往往是非常有价值的。 如果没有来源信息,报告可能会告诉你某个未初始化的值出现在解码器的深处。而一旦启用了来源追踪功能,你就可以通过多个存储环节追溯到该值最终来源于调用者分配的某个输出结构,从而发现其中某条数据路径从未对相关成员进行过初始化操作。 这就是“知道不良状态是在哪里显现出来的”与“知道它究竟是从哪里开始进入程序执行流程的”之间的区别。 MSan在解析器、编解码器、二进制格式处理、加密消息处理、序列化机制、进程间通信、数据库页面以及具有输出参数的API等领域中表现得尤为有效。这些系统通常会逐步构建对象,并将部分初始化的状态在多个层次之间传递。因此,一个初始化错误可能会在经过一系列操作后才会被常规测试发现。 将未初始化的内存称为“随机字节”虽然是一种简便的说法,但这种方式忽略了让MSan变得如此有用的核心机制。 MSan会维护一种“影子状态”,记录某个值的哪些位已经得到了初始化。副本的创建会传递这种状态;算术运算等其他操作也会将这种状态反映在结果中。当这些值在程序中流动时,它们的初始化历史也会随之被传递下去。 该图示说明了MSan是如何追踪程序中未初始化状态的,并且只有当这些未初始化的值被用于会影响程序执行的结果时,才会生成报告。 因此,某些看似无害的操作实际上可能会让未初始化的状态持续存在一段时间,直到最终被报告出来。 例如: 实际上,正是这个分支语句使得那些未初始化的状态开始对程序的执行产生影响。仅仅修复这个分支并不能解决导致对象部分初始化的问题。 函数边界也是如此。如果某个值被返回给调用者,或者被传递到需要对其初始化的代码中,即使相关内存地址在整个过程中都是有效的,MSan仍然可以报告这种状态变化。 这就是为什么MSan的报告结果往往与ASan的报告有很大不同的原因。使用ASan时,你通常是在重建一个内存地址的生命周期;而使用MSan时,你实际上是在重建一个值的整个历史演变过程。 来源追踪功能为这一历史记录过程增加了另一层信息。在更高层次的追踪中,MSan能够记录下这些被污染的内存最初是从哪里来的,以及后来又被存储到了哪里。虽然这种追踪方式可能会产生较多的冗余信息,并且计算成本也相对较高,但当那些可观察到的使用场景与实际发生的初始化错误之间存在多层抽象层次时,这种功能就显得极其有用了。 当程序执行过程中的关键部分没有被添加监控代码时,MSan就很难发挥其作用了。 假设有一个没有添加监控代码的库将数据写入缓冲区。从应用程序的角度来看,这些字节可能是完全有效的,但MSan并没有检测到这些写操作的发生。后来,当被添加了监控代码的代码使用这个缓冲区时,“影子状态”可能仍然会显示这些字节是无效的。 相反的情况也会发生:无效的数据可能会进入没有添加监控代码的代码中,并在那里被正常使用,而MSan却无法察觉到这一过程。 在常见的库边界处,拦截器确实能起到一定的作用,但它们并不能让那些难以理解的、不透明的代码变得可见。如果初始化状态通过了一些MSan无法理解的依赖关系,那么基于这些数据生成的报告的可信度就会下降。 要想让MSan能够提供高质量的分析结果,通常需要重新构建应用程序以及那些包含关键状态的库,并为它们添加兼容的监控代码。根据具体的环境不同,这种改造可能还会涉及到C/C++运行时环境以及其他低级别的依赖组件。 正因如此,MSan往往需要单独构建一个开发环境,而不仅仅是一个普通的代码检测工具。 对于那些主要使用自行编译生成的依赖库的项目来说,构建这样的环境相对容易一些;而对于那些严重依赖于预编译的本地库、内联汇编代码、专有运行时环境或无法重新构建的系统组件的项目来说,要获得准确的分析结果就会困难得多。 这种开发成本确实是存在的,但MSan所能发现的错误类型也同样具有实际价值。对于那些需要解析攻击者控制的输入数据,或者严重依赖部分生成的结果的数据来说,即使只对系统中的某些特定部分进行检测,这样的投资也是值得的。
MSan经常会发现一些API,其返回值所表达的含义超出了实际实现所能保证的范围。 以这样一个函数为例: 调用者通常会认为“output包含有效结果”以及“*output_len表示有效数据的长度”是这个函数成功执行后的必然结果。 但如果某个成功的执行路径确实写了数据到输出缓冲区中,但却没有更新*output_len的值,那么实际实现的状态就会与API所描述的状态不一致。 对于结构体来说,也会出现类似的问题: 有一些设计方法可以避免这种歧义:在控制流分支之前,可以将输出变量初始化为有效的默认值;函数可以直接返回一个完整构造的结果对象,而不是通过多个独立的指针来传递数据;真正支持成功结果、部分结果或失败结果的API,应该能够明确地表示这些不同的状态,而不要只是使用同一个成功代码来表示所有情况;对于那些内容不完整的缓冲区,应该将其有效长度作为结果的一部分直接返回,而不需要通过额外的渠道来传递这个信息。 哪种设计更为合适,取决于所使用的API。关键在于:调用者应该能够通过接口和返回状态来判断自己被允许使用哪些值。 当MSan检测到初始化缺失的情况时,在某些情况下添加 这份报告值得深入研究,因为它能帮助我们回答一个更为重要的问题:**究竟是哪条路径让调用者误以为某个值已经准备好可以使用,而实际上它并没有准备好?** ThreadSanitizer能够检测到普通测试难以验证的执行过程中的某些环节,尤其是不同线程之间的访问操作顺序问题。 一个程序可能会成功运行数千次,但实际上存在数据竞争的问题——仅仅是因为调度器总是选择那些不会导致问题的执行顺序而已。TSan会记录所有的内存访问操作和同步操作,从而重建出足够的执行流程,以便识别出那些没有按照正确顺序发生的冲突性访问操作。 然而,这种检测能力需要付出巨大的代价:使用TSan会使程序的运行速度降低5到15倍左右,同时还会消耗比正常情况下多得多的内存。此外,它还需要编译器提供大量的辅助支持;构建工具也可能会根据需要进行额外的修改,例如生成位置无关的可执行文件。 正因为这些限制,TSan通常会被单独编译成一个独立的程序,而不会被随意添加到常规的测试二进制文件中。 典型的配置方法非常简单: 通过这个编译选项,程序会被添加额外的检测代码,以便识别数据竞争问题。将`halt_on_error`设置为1可以让第一次检测到数据竞争时立即终止程序的运行;而较大的“历史记录”规模则可以帮助报告提供更多关于之前访问操作的上下文信息。 与其他安全检查工具一样,对于理解分析结果而言,有意义的符号标识比追求最高的编译性能更为重要。TSan的报告往往涉及到多个栈帧、多线程以及它们之间的同步操作;如果构建过程过于激进,导致一些重要的信息丢失,那么原本就已经复杂的数据竞争问题就会变得更加难以分析。 更大的挑战在于如何确保程序其他部分也能正确地识别这些同步操作。 TSan需要监控那些能够保证共享内存访问安全的同步机制。当锁、原子操作、条件变量、线程创建等同步原语通过运行时能够理解的代码段时,这种检测机制的效果会很好;但当某些同步操作发生在预编译的库、自定义的调度器代码、汇编语言代码、本机回调函数或未添加检测代码的运行时组件中时,其可靠性就会大大降低。
错误通常起源于远离崩溃发生的地方
正确的输出结果也可能掩盖无效的执行过程
bool digest(const uint8_t* input, size_t input_len, uint8_t* output, size_t* output_len);
仅检查那32个摘要字节的单元测试仍然可能通过。调用者可能会多次从未初始化的堆栈位置读取这32个字节,从而导致漏洞被掩盖。
MSan提出的问题比“那个值看起来是否正确”更有意义。它关注的是:在程序使用该值之前,这个值是否经过了合法的初始化过程。
与生命周期相关的漏洞往往具有类似的特征。假设某个封装层将一个本机句柄转移给了新的所有者,但仍然保留了原来的终结器。在转移过程中并不一定会出现故障;常规测试也可能在终结器被执行之前就结束了。
然而,在垃圾回收的压力下,旧的封装层最终可能会释放这个句柄,而新的所有者却还以为它仍然有效。此时,下次对该句柄的访问就可能导致“使用已释放的资源”的错误发生。
这些功能测试本身并没有错,它们只是回答了另一个问题。这些测试验证了程序的操作结果,但并未检测对象生命周期的相关行为,也没有分析垃圾回收机制的工作时机,因此无法揭示那些与所有权处理相关的错误。
清理工具仍需要覆盖更多的使用场景
清理工具只能报告在它所观察到的执行过程中发生的违规行为。
ASan无法检测到那些从未被任何代码引用的“无效指针”;如果相互冲突的访问操作从未同时发生,TSan也无法发现竞争条件错误;LSan无法揭示那些在程序运行路径中根本不会被访问到的内存泄漏问题;而如果没有任何输入能够到达跳过初始化操作的代码分支,MSan也就无法暴露相关的初始化错误。
这时,模糊测试、压力测试、故障注入以及并发检测工具就显得非常重要了。它们并不是清理工具的替代品,而是能让那些容易引发问题的执行路径更有可能被观察到。
基于覆盖范围的模糊测试会探索一些不常见的输入路径;生命周期相关的压力测试会反复让对象经历创建、保留、转移、失败、关闭、终结以及销毁等过程;故障注入机制会强制执行程序中那些在正常测试中很少被执行的清理和回滚代码;并发检测工具则会让那些容易导致问题的线程调度行为更频繁地发生,从而便于观察这些现象;而代码回放功能则能防止之前发现的错误再次悄悄地隐藏起来。
清理工具的任务就是让那些导致系统状态无效的情况变得可见,并且理想情况下,还能提供足够的上下文信息,帮助开发者调试这些问题。
正因为如此,十万个短暂的“创建-使用-关闭”循环所揭示的信息,往往比那些通过一个长期存在的对象来处理大量数据的基准测试所能提供的信息更为有用。因为基准测试大部分时间都处于稳定状态,而生命周期相关的测试则更关注那些容易引发所有权错误的关键环节。
动态分析并不能知晓一切
一次成功的清理工具测试确实能提供有用的证据,但它并不能证明代码是安全的。
它只能说明:对于当前的构建版本和当前的使用场景来说,程序中那些被清理工具能够检测到的部分,并没有违反该工具所能检测的规则。
不过,这种检测范围实际上可能比看上去要窄得多。
将sanitizer视为可执行代码的“所有权契约”
get0、get1、set0和set1这样的操作,因为返回或接受相同类型的指针,并不意味着所有权会发生相同的改变。get0风格的操作会“借用”某个资源;get1风格的操作则会获得对该资源的拥有权;set0风格的操作会转移所有权,而set1风格的操作则只会保留一个引用,并不会改变调用者原有的所有权。那些使用引用计数机制的对象会提供诸如*_up_ref这样的操作,而分配到的对象也会具有相应的释放函数。这就是为什么有些封装层会错误地释放被借用的资源,或者在所有权已经转移后仍然继续使用该资源,又或者永远不释放某个引用。指针本身的值并没有改变,真正发生变化的是程序使用或销毁其所指向资源的权限。
现在谁拥有这个资源?这个引用是借用的还是仍然有效的?是什么操作导致了这个资源的生命周期结束?所有权的转移是否只在操作成功的情况下才会发生?如果清理操作被推迟了,在这段延迟期间,谁应该负责完成这项工作?
深入探讨四种内存检测工具
工具名称
检测内容
>典型性能影响
>主要局限性
ASan
在内存的当前使用阶段,对该内存区域的访问是否合法?
性能下降约2倍,并会产生额外的内存开销
对于那些无法被检测到的访问操作或对象边界,ASan无法进行报告
LSan
在程序结束时,哪些内存分配仍然是不必要的?
执行过程中的检测工作较少,大部分检测工作发生在程序结束阶段
“保守性可达性”并不等同于实际的对象所有权信息
MSan
未初始化的内存状态是否影响了程序的执行结果?
性能下降约3倍,而且路径追踪功能会进一步增加性能负担
需要使用经过全面开发的依赖关系分析工具才能有效运行
TSan
运行时是否能够识别出那些因同步问题而导致的冲突访问操作?
性能下降5至15倍,并会产生较大的内存开销
隐藏的同步机制以及未经过检测的代码会使检测结果变得复杂
Valgrind Memcheck
通过全进程二进制代码分析,能够检测出哪些无效访问或内存泄漏行为?
性能下降明显
这种工具更适合用于针对特定问题的检测,或者作为其他检测方法的补充手段,而不适合用于大规模、持续运行的检测场景
AddressSanitizer (ASan):地址可达性与内存生命周期检测
clang++ -O1 -g -fno-omit-frame-pointer -fno-optimize-sibling-calls -fsanitize=address -o my_tests sanitizer_tests.cc
ASAN_SYMBOLIZER_PATH="$(command -v llvm-symbolizer)"
ASAN_OPTIONS="detect_leaks=1:check_initialization_order=1"
./my-tests
ASan阴影内存的工作原理

将第一份报告视为一种因果分析结果来阅读
分配内存以创建对象
-> 将指针传递给包装器
-> 将对象的所有权转移给其他地方
-> 旧的清理代码仍会继续执行
-> 新的所有者尝试访问该对象
-> ASan检测到“释放后仍被使用”的错误自定义分配器与拦截器
抑制错误并不是一种有效的修复方法
LeakSanitizer(LSan):终端所有权检测
-fsanitize=leak` 命令来运行它。大部分耗时的处理工作都是在检测内存泄漏时进行的,而不是在每次普通的内存访问时进行,因此程序主体运行过程中的性能开销通常很小。clang -O1 -g -fsanitize=address -fno-omit-frame-pointer \
-o leak_suite leakSuite.c
ASAN_SYMBOLIZER_PATH="$(command -v llvm-symbolizer)" \
ASAN_options="detect_leaks=1" \
LSAN_OPTIONS="exitcode=23:suppressions=lsan.supp" \
./leak_suite
clang -O1 -g -fsanitize=leak -o leakSuite leakSuite.c
LSAN_options="exitcode=23:suppressions=lsan.supp" ./leak_suite
设置明确的退出代码在持续集成环境中非常有用,因为这样就可以将泄漏检测结果视为普通的任务失败。使用“抑制机制”可以避免那些已知的外部问题干扰到你正在测试的代码,但这种做法也会带来一些负面影响——正如本手册其他部分所提到的,每次使用抑制机制都会降低代码的可读性/可调试性。
只有当泄漏检测的结果能够被清晰地解读时,这种检测方法才具有实际价值。如果某个测试用例会分配资源、使用这些资源完成其生命周期中的操作、然后释放所有所占用的资源并结束执行,那么在程序关闭时如果还发现有未释放的资源,那么这个现象就说明确实存在资源泄漏问题。不过,进程规模越大、运行过程中产生的干扰因素越多,这种解读就越需要谨慎进行。
可达性与程序的初衷并不相同
LSan的检测机制较为保守,因为它并不了解你程序中的资源管理逻辑。在程序关闭时,LSan会检查线程栈、寄存器、全局变量以及线程局部存储区等类似“根节点”的区域,然后通过这些节点来追踪内存分配情况。如果某个内存分配对象仍然可以通过这种路径被访问到,即使程序本应释放它,LSan也可能不会报告这个泄漏问题。
该图示说明了LSan在程序关闭时是如何检测内存泄漏的。那些无法被访问到的内存分配对象确实会被视为泄漏问题,但那些仍然可被访问到的内存,如果程序本应提前释放它们,那么这些内存的存在依然属于逻辑错误。
这就是泄漏检测与常规的生命周期调试之间最重要的区别之一。
假设某个对象在程序关闭时应该被释放,但却有一个失效的指针仍然存在于全局变量中。从程序的角度来看,这种资源清理行为显然出现了问题;但从LSan的角度来看,这个内存分配对象仍然是可访问的。
相反的情况则更容易诊断。如果某个内存分配对象没有任何路径可以通向LSAn能够识别的“根节点”,那么LSan就可以将其报告为泄漏对象,并显示创建该内存分配的对象栈信息。
直接泄漏和间接泄漏这些问题值得一起研究。如果某个父对象的资源释放操作被遗漏了,那么整个内存分配图结构都可能会变得无法被访问到。修复这个父对象的问题,往往可以一次性解决许多下游出现的泄漏问题;而如果单独去排查每一个间接泄漏现象,就会浪费很多时间,因为这些间接泄漏其实都源于同一个“丢失的所有者”。初始化运行时环境
-> 创建原生资源
-> 测试各种成功与失败的情况
-> 释放相关资源的所有权
-> 在适当的情况下允许延迟清理操作
-> 结束进程
逐步开启泄漏检测功能
MemorySanitizer (MSan): 初始化来源追踪
clang++ -O1 -g -fno-omit-frame-pointer \
-fsanitize=memory -fsanitize-memory-track-origins=2 \
-o msan_suite msanSuite.cc
MSAN_symbolIZER_PATH="$(command -v llvm-symbolizer)" \
./msan_suite
-fsanitize=memory选项用于启用内存泄漏检测功能;-fsanitize-memory-track-origins=2选项则要求MSan记录更多关于错误数据来源及其在内存中传播路径的信息。MSan实际上会传播哪些信息
分配了一个栈对象
-> 其中的一个字段未被赋值
-> 该结构被复制
-> 这个字段被复制到一个大小变量中
-> 这个大小变量被用于条件判断
-> MSan报告了这种未初始化的使用情况MSan需要能够通过依赖关系图来了解程序的执行情况
明确规定成功执行的必要条件
bool decode(const uint8_t* input, size_t input_len,
uint8_t* output, size_t* output_len);
result.status = OK;
result.data = initialized;
result.length = never written;
status == OK这个条件并不能提醒调用者“result.data”这个字段是否安全、能否被访问。memset(..., 0, ...)确实是一种有效的解决方法;但在其他情况下,这样做只会将程序的状态变为看似合理的默认值,而实际上仍会导致API契约的模糊性。ThreadSanitizer(TSan):同步可见性
clang++ -O1 -g -fsanitize=thread
-o tsan_suite tsan_suite.cc
TSAN_OPTIONS="halt_on_error=1:history_size=7"
./tsan_suite
“发生顺序”是实际操作中遵循的思维模型

写入操作:
初始化对象
-> 设置/释放准备标志
读取操作:
获取/加载准备标志
-> 读取对象
如果“释放/获取”这一对操作实现得当,并且能够被TSan检测到,那么那些用于初始化对象的写操作就可以在读取操作之前完成。
如果将这种同步机制替换为普通的非同步布尔值,程序在某些机器上可能仍然能够正常运行。但在这种情况下,TSan就无法再判断这些访问操作的顺序了。
自定义的同步机制也会出现同样的问题。例如,用汇编语言实现的锁在硬件层面上可能是完全正确的,但却无法被TSan检测到。从TSan的角度来看,这两侧的操作看起来是没有顺序的,因为连接它们的事件根本就没有被纳入它的检测范围。
因此,当TSan生成报告时,会给出两种可能的调试方向:有时是同步机制确实缺失了;有时是同步机制存在,但TSan无法检测到它。报告本身会列出那些存在冲突的操作,而了解这些操作的边界信息,则可以帮助你判断具体属于哪种情况。
对“良性竞争”要保持怀疑态度
“良性竞争”这个说法其实很危险,因为它描述的是观察到的行为,而不是明确的同步规则。
某个字段看起来似乎无害,因为目前看到的所有值都是可以接受的。但这也仍然存在一些问题。
在C和C++中,真正的数据竞争通常会导致程序出现不可预测的行为。编译器在进行优化时,并不会受到那些在测试阶段使代码看起来安全的时序假设的约束。
被竞争访问的字段也可能参与了某个更大的不变量。一个未同步的标志可能看起来无害,但另一个字段却会随之发生变化。如果只看到其中一个字段发生了变化,而另一个没有变化,就可能会暴露出程序原本并不希望出现的状态。
在维护过程中,这种情况会更加严重。那些在竞争开始时看似无关紧要的值,后来可能会成为影响程序生命周期、回调机制或安全性的因素,而那些旧的未同步访问方式却依然被保留下来。
确实存在一些情况,导致TSan的报告是由于代码中的检测漏洞或者运行时无法看到的同步机制所引起的。对于这些情况,确实需要在这些边界处进行进一步的调查。
当这两个存在冲突的访问操作都出现在你自己的代码中,并且没有任何排序机制来保证它们的顺序时,“良性竞争”这一说法就需要更加充分的解释才能成立——而“我们从未见过它导致问题发生”这样的理由显然不够充分。
通常,最终的解决方法都很简单:使用具有适当内存排序功能的原子操作,将相关状态放在现有的互斥锁的保护下,或者重新设计代码结构,使得这些冲突的操作不再同时发生。
更困难的部分在于,在报告指出代码中存在同步问题之前,要弄清楚这段代码原本依赖的是哪种排序机制。
让这种竞争行为更容易被检测到
TSan仅会报告在观察期间发生的竞争条件现象。
如果工作负载从未产生相关的重叠情况,那么即使有一百万行并发代码存在,它们也能正常运行。相反,如果进行针对某个生命周期边界的重复测试,那么几秒钟内就能发现存在的竞争条件问题。
有用的并发检测工具会刻意增加那些你关心的调度场景发生的概率。
如果两个操作在几乎同时开始执行时应该是安全的,那就让这些线程从一个屏障处同时启动。如果回调完成与对象销毁之间可能存在竞争条件,那么就重复这种操作数千次。如果某个注册表支持并发查找和删除操作,那就让多个线程同时执行这两个操作。
在时间窗口较短的情况下,仅用于测试的目的也可以起到很好的作用:
thread A:
读取状态
-> 执行yield操作
-> 更新共享对象
thread B:
关闭对象
-> 释放共享状态
“yield”操作本身并不会引发错误,它只是为已经符合规范的调度机制提供了更多的执行机会。
类似的技术也适用于回调注册与取消、对象的附加/分离操作、在任务尚未完成时进行系统关闭,以及显式清理操作与后台资源释放之间的竞争条件问题。
随机化测试可以帮助探索更广泛的调度可能性,但必须记录随机数生成器使用的种子值以及操作的执行顺序。如果某个TSan报告只出现一次,且无法在本地重现,那么调查这种问题的难度将会大大增加;而那些可以通过重复执行工作负载来复现的问题,则更容易进行排查。
测试所使用的工作负载应该能够真实反映实际系统的运行行为。人为制造的竞争条件确实有助于检测问题,但那些不真实的对象生命周期设定却毫无意义。测试的目的应该是让已经存在的竞争条件问题更容易被发现,而不是创造一些应用程序根本不会使用的并发模型。
因此,一个优秀的TSan测试工作负载应该把重点放在那些涉及状态转换的操作上:发布和获取数据、注册和取消注册、启动和停止任务、关闭对象以及完成相关操作等。这些地方正是两个线程最有可能在当前哪些状态是安全的这个问题上产生分歧的地方。
结合使用各种检测工具,并了解它们各自无法覆盖的范围
根本不存在一种“启用所有检测工具”的构建方式。
这些检测工具会维护不同的运行时元数据,要求不同的日志记录机制,而且往往需要不同的测试工作负载。有些组合可以自然地集成到同一个二进制文件中,而有些则应该被视为独立的构建版本,它们拥有各自的依赖关系和持续集成流程。
了解这些界限非常重要,因为如果组合方式不当,或者某些关键环节仍然无法被有效检测到,那么即使使用了大量的检测工具,也很难获得有用的结果。
能够协同良好工作的检测工具组合
ASan和UndefinedBehaviorSanitizer通常会被一起启用。它们的检查范围涵盖了执行的不同阶段,在许多Clang构建环境中可以很好地共存使用:
clang++ -O1 -g -fno-omit-frame-pointer \
-fsanitize=address,undefined \
-fno-sanitize-recover=all \
app.cc -o app_sanitized
ASan会监控程序中的地址访问行为及变量的生命周期。而UBSan则能够捕捉到诸如无效的位移操作、错位的地址访问、非法的枚举值使用之类的语言级错误行为,并且还会启用整数溢出检测功能。
虽然UBSan并非本手册的重点讨论内容,但它仍然值得被提及,因为某些内存故障可能起源于ASan无法检测到的地方。
如果计算出的内存分配大小不正确,就可能会导致分配的内存容量不足;错误的类型转换操作则可能导致程序进入无法正常运行的状态;错位的地址访问或算术溢出现象,往往会在程序还未触及ASan的检测范围之前,就已经破坏了程序的正常运行机制。在这种情况下,UBSan发现的错误可能比随后引发的ASan崩溃更为接近程序问题的真正根源。
在选择恢复策略时需要慎重考虑。如果让UBSan继续执行测试,那么在一次测试过程中就能发现多种类型的问题,这对于全面查找缺陷来说非常有用;而如果在第一次检测到问题时就终止测试,那么程序的执行过程就会停留在出现错误的那个状态附近,这样在后续进行回归测试时也更容易分析问题原因。
泄漏检测工具通常也会使用与ASan相同的构建配置。在支持相应平台的系统中,LSan可以在ASan的运行时环境中执行检测任务,并在进程结束时进行分析,这样一来,同一个二进制文件既能够在运行过程中检测地址访问违规行为,也能够在程序终止后检查是否存在内存泄漏问题。
当只需要进行泄漏检测而不需要使用ASan的其他功能时,独立的LSan工具仍然非常有用。这种选择主要是出于实际操作的需要:如果已经存在可靠的ASan构建配置,那么使用其泄漏检测功能通常可以减少所需的构建选项数量。
TSan和MSan应该使用不同的构建配置
TSan应该与ASan使用不同的二进制文件进行构建。
这两种工具都会对内存访问操作进行大量监控,并且会为了完全不同的目的维护运行时状态。TSan需要记录地址访问的历史信息以及同步相关的元数据,而ASan则需要生成“影子地址访问状态”并实现与内存分配器的集成。实际上,它们应该被视为两种独立的检测机制,而不是同一工具的不同版本。
这种分离是有必要的,因为它们的工作负载本身就有所不同。
对于ASan来说,解析器测试、生命周期压力测试以及故障路径覆盖测试能够带来很大的帮助;而对于TSan而言,则需要并发执行环境——例如需要同时进行多个操作、频繁切换线程、处理回调函数之间的竞争、在程序仍在运行时强制关闭系统等等,而且还需要足够的重复次数才能让各种复杂的情况出现。
它们的性能需求也不同:在ASan环境下表现良好的任务,在TSan环境下可能会消耗过多的资源。
MSan还需要单独的构建环境,原因在于:只有当足够多的代码被添加了监控模块时,才能确保对变量初始化过程的跟踪结果是可靠的。
在应用程序中添加 `-fsanitize=memory` 这一选项,同时让那些重要的本机依赖项保持“不可见”状态,可能会破坏 MSan 所试图追踪的初始化过程。正规的 MSan 配置方案通常会呈现出类似“被添加了调试标记的依赖关系图”的结构,而不会只是简单地在普通应用程序构建过程中添加一个编译器选项而已。这会导致构建矩阵变得不对称,不过这也是可以接受的。
对于大多数修改,一个项目可能会同时使用ASan和UBSan来进行漏洞检测;在同一二进制文件中集成泄漏检测工具;而对于那些并发性较高的任务,则会使用TSan;而只有在相关依赖项能够被正确重新构建的环境中,才会使用MSan。真正重要的是了解每种检测工具能够可靠地发现哪些问题,而不是所有这些工具是否都会出现在同一个持续集成阶段。
硬件辅助检测与生产环境中的检查
软件版的ASan仅仅是用来检测程序运行过程中的各种错误的一种方法而已。
硬件辅助的AddressSanitizer以及Arm内存标记扩展技术通过使用带有标签的内存和指针,来检测实际使用的指针与其所指向的内存地址是否一致。在支持64位Arm系统的环境中,这种技术能够使内存检测变得可行,因为在这些系统中,传统ASan所带来的开销会过大,难以被有效利用。
与软件版的ASan相比,这些硬件辅助技术的权衡因素也是不同的。
由于标签空间是有限的,因此过时的指针有时可能会与有效的标签发生冲突,从而逃避检测。哪些平台支持这些检测功能,也会影响这些检查可以在哪里执行。对于栈、全局变量以及内存分配器的工作方式,这些硬件辅助技术的处理方式也与传统ASan有所不同。
这些技术所带来的优势在于,它们能够让人们观察到普通持续集成工具可能永远无法看到的情况:运行时间较长的任务、设备特有的行为模式、实际的生产环境中的压力状况,以及在某些情况下接近生产环境的运行环境。
受保护的内存分配器、强化版的内存分配器、采样式的内存检测机制、崩溃数据收集技术以及“金丝雀测试”方案,都属于这一更广泛的策略范畴。每种技术都以不同的方式来观察程序运行过程中的各种行为,而它们所付出的代价,在一个完全配备了监控工具的测试环境中可能是可以接受的。
只要有可能,这些技术的检测结果就应该被反馈到确定性的测试流程中。一旦能够在ASan环境下重现某个程序的生命周期,那么对这种程序在释放后仍被继续使用的情况进行采样检测就会变得非常有价值;对于那些会持续导致内存占用增加的行为模式,也可以专门设计相应的检测用例;而对于只有在高负载环境下才会出现的并发错误,也可以将其作为TSan压力测试的案例。
生产环境中的检查机制能够让你观察到更多的程序运行情况。不过,编译器自带的检测工具仍然是那些错误最容易被重现的地方,因为这些工具提供了更丰富的监控手段和更严格的控制能力。
绿色 sanitizer矩阵实际上能告诉你什么
一个sanitizer矩阵所回答的问题,比“这个程序是否正确?”要具体得多。
它能够告诉你,在那些被监控的程序运行过程中实际发生了什么。
然而,这仍然无法涵盖所有可能的情况。
业务逻辑可能会出现错误,而所有的检测工具却都不会发出任何警报;授权机制可能会存在缺陷;加密协议可能会被误用;在完全符合内存安全规范的代码中,也有可能存在时间同步问题或其他潜在的安全风险;即使是一个无锁算法,也可能无法满足其预期的线性化保障要求。
即使在内存安全性和并发性方面,工作负载覆盖范围仍然是一个难以突破的界限。那些从未被执行的代码就无法生成任何动态报告。
调试工具的使用又带来了另一个限制:ASan无法检测到那些隐藏在它无法理解的分配器内部的对象边界;MSan会在处理不透明的代码时丢失初始化相关的信息;TSan也无法重建那些在其运行时环境中根本无法被观察到的同步机制。而那些从未被添加过调试代码的第三方模块,其中可能潜藏着一些这些工具都无法发现的错误。
各种“抑制机制”的存在进一步模糊了评估结果的准确性。“绿色运行”状态意味着运行时系统没有发现任何它能够检测到的违规行为;因此,任何抑制机制都会改变“代码无缺陷”这一结论的含义。
对于一次成功的测试结果,明确其具体含义是非常重要的:
针对这个构建版本,在这种工作负载条件下,经过调试的代码运行过程并未违反该安全工具所能检测到的任何规则。
这样的表述虽然听起来较为低调,但实际上是一种非常有用的工程声明。它清楚地指出了目前还需要进一步改进的地方。
如果当前的工作负载强度较低,就应该加强测试条件;如果某个依赖关系不够透明,就应该在可行的范围内对相关的代码进行更全面的调试;如果某些故障类型超出了ASan的检测范围,就应该添加相应的工具来进行检测;而如果剩余的不确定性源于API的设计问题,而不是代码运行时的覆盖范围问题,那么静态分析、代码审查、采用更严格的权限管理机制或修改接口设计,可能会比使用其他安全工具更加有效。
一个完善的安全工具体系应该是通过填补已知的知识空白来不断完善的,而不是简单地增加更多的编译器选项。
跨运行时边界的FFI所有权问题
由于存在不同的资源生命周期管理模型,FFI相关的错误往往会反复出现:托管代码层面可能会使用垃圾回收机制和终结器,而原生代码层面则可能采用显式的释放操作、引用计数机制或其他所有权转移接口。
实际上,这类问题的本质往往并不是“FFI”本身导致的,而是双方对于在某个特定时刻谁应该拥有某项资源存在分歧。
一种常见的错误类型是“所有权转移但没有状态同步”。例如,某个封装层创建或接收了一个原生资源句柄,然后将所有权转移给了其他地方,但仍然认为这个句柄是可以被使用的;指针本身并不会显示所有权的变更情况。除非封装层在所有权转移的过程中改变了资源的状态,否则原来的所有者仍然可以释放这个资源、为其设置清理操作,或者之后再次使用它。
在这种情况下,那些能够明确区分“借用”、“保留”和“转移”所有权操作的API就显得非常重要了。BoringSSL就是一个典型的例子:通过get0、get1、set0和set1这样的函数,可以明确地知道某个引用是被借用的、被拥有的、被采纳的,还是被保留下来的。而如果一个封装层将所有这些情况都统一用同一种指针表示方式来处理,就会丢失原生API所刻意提供的那些信息。这就是为什么被借用的资源句柄会被错误地释放,被转移的资源句柄会被重复使用,以及拥有权的引用会悄悄地导致资源泄漏的原因。
另一种常见的错误是未能正确释放所占用的资源。仅仅知道自己拥有某块内存是不够的,还需要明白应该如何释放这块内存。原生库可能会将内存分配与特定的释放函数关联起来,或者返回那些需要通过特定操作来减少引用计数的对象,又或者将内存的所有权交给特定的管理机制,而不是调用者本身。
BoringSSL再次举了一个简单的例子:通过OPENSSL_malloc分配的内存应该通过OPENSSL_free来释放。如果某个封装层将这些内存视为可以与其他分配方式获取的内存相互替换,那么这种错误可能会在程序结束之前一直不被发现,直到ASan、Valgrind等内存检测工具发现了这种不匹配的情况。
更一般的规则是:负责管理这些资源的封装层应当保留这些资源被释放时的正确机制,而不仅仅是它们的地址。
第三种常见的错误是将资源的最终清理操作误认为是真正的“销毁”操作。虽然最终清理程序确实可以释放某些原生资源,但它并不能确保资源一定会在预期的时间被释放;其执行时机取决于运行时的环境以及垃圾回收机制,而不是程序中明确指出不再需要这些资源的那一点。
当这些原生资源本身具有特定的生命周期时,这种区别就显得尤为重要。文件描述符、原生句柄、加密上下文、GPU对象、数据库句柄等这类资源通常都需要被明确地释放。最终清理程序可以作为备选方案,但它不应该悄悄地成为这些资源的第二个“所有者”。
当资源被转移给其他方时,这种情况会变得尤为危险。如果一个被管理的对象将其原生句柄转移给了新的所有者,但却保留了原有的最终清理程序,那么双方都可能会误以为自己有责任进行清理操作。在这种情况下,封装层应该通过移除最终清理程序、清除相关句柄等方式,确保后续的操作无法继续使用这些资源。
当某些操作在执行过程中失败时,代码中可能会出现另一类错误:在成功的路径上,代码可能是完全正确的,但当操作失败时,整个资源管理机制就会变得混乱不堪。
举个例子,假设某个函数分配了三个临时使用的原生对象,并且只有当导入操作成功时才会转移其中一个对象。那么,在操作失败的情况下,必须释放所有那些从未被转移的对象;而在操作成功的情况下,则需要释放剩下的临时对象,同时不能误释放那个所有权已经发生转移的对象。这些都是需要明确处理的问题:是否在每次程序退出时都会执行清理操作?当执行清理操作时,哪些对象仍然属于需要被清理的范围?
正因为如此,清理操作的范围限定、RAII机制、defer风格的延迟释放机制等等,才显得非常有用。这些机制使得清理操作成为默认行为,而只有在资源的所有权转移成功的情况下,才能明确地免除某个对象被清理的责任。如果没有这样的区分,同样的代码在失败时可能会导致内存泄漏,在成功时则可能导致双重释放错误。
下面是一个简单的C++示例,它展示了当这些状态被明确表示出来时,代码会是什么样子。BoringSSL提供了具体的API来实现这种机制,但这种模式适用于任何使用引用计数来管理的原生资源句柄。#include
#include
#include
struct PKeyDeleter {
void operator()(EVP_PKEY* p) const noexcept { EVP_PKEY_free(p); }
};
using UniquePKey = std::unique_ptr;
class KeySlot {
public:
// 这个槽只持有一个引用。
explicit KeySlot(UniquePKey key) : key_(std::move(key)) {}
// 借用而不改变所有权。
EVP_PKEY* borrow() const noexcept {
return key_.get();
}
// 转移所有权,此时该槽将变为空。
UniquePKey take() {
return std::move(key_);
}
// 创建另一个引用以便共享使用。
UniquePKey clone_ref() const {
if (!key_) {
throw std::logic_error("槽为空");
}
if (EVP_PKEY_up_ref(key_.get()) != 1) {
throw std::runtime_error("EVP_PKEY_up_ref失败");
}
return UniquePKey(key_.get());
}
bool empty() const noexcept {
return key_ == nullptr;
}
private:
UniquePKey key_;
};
borrow()方法只是暴露出对该对象的引用,而不会创建新的所有者;take()方法会转移所有权,使得原来的槽变为空;clone_ref()方法则会明确地创建另一个所有者引用。
类型系统虽然无法防止所有与对象生命周期相关的问题,但它确实能够使人们更难意外地引发这些问题的发生。这样一来,ASan和LSan等静态分析工具也就能够更加准确地检测这些问题:那些无效的访问操作或泄漏的引用,往往能够被追踪到具体的错误环节,而不会在大量难以区分的原始指针中迷失方向。
在管理式的FFI代码中,通常需要更明确地指定各种对象的状态,因为编程语言中的正常对象生命周期机制并不能自动反映底层原生对象的生命周期。不过,包装器仍然可以帮助人们清晰地区分这些差异:
import 'dart:ffi' as ffi;
final class NativeHandle extends ffi.Opaque {}
final class OwnedNativeHandle {
OwnedNativeHandle(this._ptr, this._free, this._finalizer)
: _token = Object() {
_finalizer.attach(this, _ptr.cast(), detach: _token);
}
ffi.Pointer? _ptr;
final void Function(ffi Pointer) _free;
final ffi.NativeFinalizer _finalizer;
final Object _token;
bool get isMoved => _ptr == null;
ffiPointer borrow() {
final ptr = _ptr;
if (ptr == null) {
throw StateError('此处已不再拥有该原生对象');
}
return ptr;
}
ffi Pointer move() {
final ptr = borrow();
_finalizer.detach(_token);
_ptr = null;
return ptr;
}
void close() {
final ptr = _ptr;
if (ptr == null) return;
_finalizerdetach(_token);
_ptr = null;
_free(ptr);
}
}
move()的功能并不仅仅是一个指针的返回操作——它实际上会改变封装对象的状态。一旦所有权发生转移,之前的所有者就无法再次借用该指针,而该对象的最终释放机制也不再被允许释放它。close()遵循同样的规则,因此可以多次调用而不会产生问题,因为在原生代码执行释放操作之前,所有权状态就已经发生了变化。
具体的实现方式会因语言和运行时环境的不同而有所差异。有些系统使用可为空的指针类型,有些则通过专门的状态机制来管理对象的转移过程;有些系统会将指针隐藏在拥有该对象的对象后面,而另一些系统则依赖线性或仿射类型的数据结构。关键在于:任何所有权的转移都必须以一种能够让程序其他部分能够察觉的方式来改变原有所有者的状态。
对于临时性的所有权管理,也需要采取类似的措施。通过使用语法层面的清理机制,可以明确规定“除非所有权发生转移,否则必须释放对象”这一规则:
final class Scope {
final Map
只要这个作用域还拥有某个对象,那么直到发生以下两种情况之一之前,它都会继续保有对该对象的所有权:要么进行清理操作,要么所有权被明确转移到其他地方。这种设计使得在函数提前返回或部分操作失败的情况下,程序的行为更加容易理解,因为默认情况下系统会执行清理操作,而不是“记住所有已经被成功分配的对象”。
不同的内存检测工具也是基于这一相同的原理来工作的。如果一个被转移的对象仍然被之前的所有者释放,ASan可能会捕获到由此导致的“使用已释放的对象”的错误;如果某个错误处理路径忘记了释放它仍然拥有的对象,LSan或专门用于检测内存泄漏的工具可能会在程序结束时发现这个问题;如果一个原生函数在没有完全初始化输出结构的情况下就报告了操作成功,MSan可以揭示出这种边界条件违规的问题;如果关闭操作、回调函数或清理操作的执行顺序在不同线程之间发生了竞争,TSan则能够暴露出这些操作中存在的同步问题。
虽然这些工具报告的症状各不相同,但它们的设计目标往往是相同的:就是要确保所有与所有权相关的操作——如获取所有权、释放对象、转移所有权以及对象的生命周期转换——都能被清晰地理解,从而在每个边界条件下都只有一种合理的解释结果。对于接下来的webcrypto.dart案例研究来说,这种分析框架非常有用。Dart、BoringSSL、本地构建机制以及终结器使得具体实现细节变得复杂,但根本问题其实依然存在——这些都属于任何涉及托管代码与本地代码交互的场景必然会遇到的所有权相关问题:当前谁拥有这个资源?应该如何释放它?当所有权发生转移时会发生什么变化?如果操作在半途中失败了,又会怎样呢?
案例研究:webcrypto.dart问题#278
webcrypto.dart是一个很好的例子,因为在这个项目中,多种不同的所有权机制在相对较小的代码范围内共同发挥作用。在浏览器环境中,该包可以依赖平台提供的Web Crypto实现;而在浏览器之外,它则通过dart:ffi>与本地代码进行交互,并使用BoringSSL作为底层加密库。Dart的构建机制负责生成相应的本地资源文件。
这意味着,同一个操作可能会涉及到Dart对象的可访问性、本地引用计数机制、显式的资源分配与释放过程、终结器的执行,以及构建工具的决定——这些因素共同决定了调试器实际上能够观察到整个处理过程的哪些部分。
如果在某个层面上出现了错误,并不一定会导致整个系统失败。例如,一个封装层可能会返回正确的加密结果,但却留下未正确释放的本地对象;或者,某个终结器可能仍然附着在一个所有权已经转移到的资源上。
问题#278是在为1.0.0版本进行准备工作时被发现的。现有的测试已经相当全面地覆盖了该包的功能层面,但更棘手的问题在于:在这些看似成功的操作背后,实际上发生了什么?本地对象是否被正确释放了?所有权转移后是否会留下“废弃的”资源?作用域和终结器之间是否就清理责任达成了共识?垃圾回收机制的执行时机是否会导致一些普通测试无法发现的错误?
这些问题的答案直接体现在NativeFinalizer、_Scope和_SslAllocator等代码结构中。BoringSSL本身也定义了一些与所有权相关的规则,Dart的封装层必须严格遵守这些规则。有些引用是“借用的”,有些则需要被保留下来,而某些操作则会导致所有权的转移。当所有权转移到本地代码层面时,Dart代码就无法再继续像原来那样认为自己仍然拥有这个资源了。此时,封装层可能需要将这个资源从清理作用域中移除,取消与之关联的终结器,或者直接使这个资源失效。
接下来的自然步骤就是使用更强大的内存安全检测工具来测试这些路径。然而,正是这个时候,周围的工具链成为了问题的根源。
为什么首先选择使用Valgrind
Dart SDK中的内存安全检测机制存在障碍。webcrypto.dart依赖构建机制来生成本地资源文件,而当时启用了内存安全检测功能的测试工具还无法很好地支持这种架构。这个缺陷在Dart SDK的问题#63489中被记录了下来。
在AOT打包及符号化方面还存在第二个问题。按照现有的技术方案,当编译后的Dart代码被打包到运行时环境中时,本地工具无法以报告所需的形式来提取这些符号信息。针对这个问题,有人在问题编号#63435中进行了专门讨论和开发工作。因此,仅仅在webcrypto.dart中添加一个消毒器标志是远远不够的,这样无法生成可靠的消毒器检测机制。该项目首先需要SDK的支持,以便在消毒器的环境下构建正确的原生资产,并生成能够被转化为有用代码的报告。
Valgrind对这种集成的依赖方式有所不同。因此,在PR #295中添加了一个Linux Memcheck检测环节,该环节可以在现有的测试路径上运行,而此时消毒器相关的开发工作仍处于停滞状态。
这些测试任务的重点是针对原生对象的生命周期进行检测,而不是覆盖所有算法。它们会测试AES-GCM、HMAC、ECDH、RSA-OAEP等算法,以及密钥的导入和导出操作、失败后的处理过程,还会反复创建一些生命周期较短的对象。
这些算法本身并不是测试的重点所在。真正重要的是那些会导致不同内存分配和清理机制的操作:例如,在密钥导入过程中可能会生成多个临时对象;而失败的导入操作会触发一些在正常情况下不会被执行的清理动作;频繁创建短期存在的对象也会给内存的最终释放过程带来更大的压力,这比进行那些涉及少量持久对象的长时间吞吐量测试要严重得多。
这就是内存检测工具发挥作用的地方。这类测试不仅能验证相关操作的正确性,还能检测是否存在数据泄漏、资源释放错误,或者在操作被认为已经完成之后仍有对象仍然存在于内存中。
Valgrind还暴露了一个实际存在的问题:每当在托管型运行时环境中使用内存检测工具时,并不是所有被报告的内存分配行为都与正在测试的库有关。
这些测试结果会同时显示“确定存在泄漏”和“可能存在泄漏”的情况,但只有那些被确认为确实存在泄漏的情况才会导致持续集成测试失败。而对于那些可能存在的泄漏,它们仍然会出现在测试输出中,因为有些泄漏可能是由Dart虚拟机的运行机制引起的,而不是由webcrypto.dart本身导致的。在项目能够准确判断这些泄漏的真实性之前,将它们归为同一类别并阻止后续测试流程进行是没有意义的。
这种区分在Valgrind之外也同样重要。一个有效的持续集成检测机制需要那些团队能够清楚理解、并能据此采取相应行动的故障分类标准。对于那些范围更广的问题,虽然也可以记录下来并进行调查,但并不意味着所有这些问题都具有相同的可信度。
这个案例所揭示的一般性规律
这个案例有趣的地方并不在于某个特定的Dart包恰好使用了Valgrind工具。
第一个更重要的启示是:工具链也是消毒器检测机制的重要组成部分。虽然编译器可能完美地支持ASan或LSan,但这并不意味着所有的包、运行时环境、测试工具、原生资产处理流程或AOT编译技术都能生成有用的测试结果。如果相关的代码无法被正确地构建、加载或进行符号化处理,那么仅仅使用消毒器标志也是意义有限的。
第二个启示是:测试任务必须遵循相应的内存所有权模型。单纯增加更多的加密操作本身并没有太大意义,而那些会反复导致内存资源分配、传输、失败、释放等操作的测试才具有实际价值。这一点对于数据库绑定、图像编解码器、语言运行时环境、GPU封装库、JNI代码、Python扩展模块,或是任何其他依赖外部函数调用的系统来说都是成立的。
第三点是,所有权的转移必须在边界的两侧都能够被清晰地观察到。当原生端采用了某种机制来管理对象时,被管理的那一端应该立即停止表现出拥有者的特征。如果资源的清理操作属于某个词法作用域的范围,那么成功的清理操作应该会将该对象从该作用域中移除。最终化器可以作为备选方案,但它不应该与那些能够确定性地完成资源清理的操作默默地争夺对同一资源的控制权。
最后,即使某些工具的集成存在障碍,也不意味着完全无法进行动态检查。在这种情况下,SDK目前还无法支持该项目所期望的那种基于这些工具的测试机制。不过Valgrind仍然提供了一种在持续集成环境中测试原生代码生命周期的方法,而那些缺失的工具支持功能也正在被上游团队关注并逐步解决。
整个进展过程大致如下:
webcrypto.dart项目并没有从这项工作中获得它原本期望得到的工具支持。目前SDK还无法提供这种支持。不过该项目确实得到了可以重复使用的测试用例,以及一个可以在持续集成环境中运行的Linux内存检测机制;同时,上游团队也指出了那些仍然存在的工具缺陷。
这种做法其实具有普遍意义,不仅仅适用于Dart:对于那些目前还可以被观察到的运行时特性,就应该确保它们能够被有效检测到;而对于那些目前还缺乏检测机制的功能,也应该让这些问题保持可见状态,直到相关的工具链能够提供更强大的支持为止。
来自真实系统的故障模式
当真实的故障现象能够揭示出在其他环境中也会出现的错误模式时,它们就具有了最大的价值。这些故障的名称或CVE编号其实并不那么重要,真正重要的是这种故障发生的具体条件、检测工具能够发现哪些问题,以及这些问题究竟应该在哪里得到解决。
模式1:只有在模糊测试环境下才会出现的“使用已释放的对象”的错误
Mozilla的IndexedDB相关漏洞(CVE-2024-7528)就是一个很好的例子,它说明了为什么ASan与模糊测试结合使用会取得如此好的效果。
这种“使用已释放的对象”的错误并不是在那些常规的、设计良好的测试用例中才会被发现的。它需要先达到某种特定的生命周期状态,而基于代码覆盖率的模糊测试正好能够使代码进入这种状态,于是ASan就能够检测到这种非法访问行为,并通过分析对象的分配和释放历史来还原整个事件的发生过程。
关键在于这些事件的发生顺序:生成的测试输入会使得对象进入一种常规测试很少会遇到的生命周期状态,ASan因此能够捕捉到这种非法访问行为,而导致程序崩溃的那些输入数据也会被保留下来。最终的修复措施应该针对造成对象引用失效的那个所有权转移操作来进行,而不是针对那个实际执行了解引操作的代码行。
一旦发现了这样的错误案例,就应该将其纳入回归测试集中。这样,原本是一次性的检测结果就能变成一种长期有效的测试机制。
模式2:在输出结果完全有效之前就返回了成功结果
与CVE-2022-31741相关的NSS S/MIME漏洞,其实比“使用未初始化的内存”这一描述听起来要严重得多。
调用方将返回的成功结果视为可以使用相应输出值的许可。然而,在被调用函数的内部,有些路径可能会在未对输出值进行初始化的情况下就返回成功结果。MSan后来检测到了这些未初始化的值,但真正的漏洞在于返回值与预期的输出结果之间存在不一致。
这种区别非常重要。虽然在调用处将变量清零可能会让某个报告不再出现,但这会导致API的接口设计变得模糊不清。一次成功的调用应该能够明确地规定后续的处理规则:要么输出值已经被完全初始化,要么API需要提供一种方式来明确表示那些处于部分初始化状态的数据。
对于这类漏洞来说,MSan尤其有用,因为它能准确地显示出该变量在何时开始可以被检测到;而通过追踪代码的执行路径,往往也能找到那些跳过了初始化操作的环节。
模式3:在工具监控边界处出现的竞态条件报告
Mozilla在图形处理代码中使用的TSan工具揭示了另一个问题。当部分同步逻辑位于TSan无法检测到的代码段中时,竞态条件报告就很难被正确解读。
假设从TSan的角度来看,两次内存访问的操作顺序是不确定的,但其中某个模块并没有被添加到监控范围内。如果这个模块包含了确保访问操作安全的同步机制,那么TSan就无法还原这些操作的真正顺序;反之亦然:如果在未被监控的代码中发生了真正的竞态条件,那么这类问题可能根本就不会被检测出来。
因此,从一开始就应将工具监控边界纳入竞态条件问题的排查范围。在将某个报告视为误报之前,首先要确认哪些模块被添加到了监控范围内,以及运行时能够检测到哪些同步机制。如果某些操作的顺序取决于那些无法被监控的代码段,那么要么使用更全面的监控工具来重新测试这个问题,要么明确指出这个监控边界的存在原因,并记录下相应的处理措施。
一个危险的错误做法就是认为“TSan无法检测到整个系统”的情况就意味着“不存在竞态条件问题”。
模式4:那些虽然规模很小,但仍然表明存在所有权异常问题的泄漏现象
当泄漏所导致的内存占用量很小时,人们往往很容易忽视这些报告。有时候,做出这样的判断也是合理的。不过,这并不意味着这些泄漏现象就说明内存的所有权关系是正确的。
所谓“确定性泄漏”,指的是某些代码路径确实分配了内存资源,但却从未完成对这些资源的释放操作。即使只有几字节的内存被泄漏,这也可能是一个重要的线索,因为这些泄漏可能发生在循环中,或者隐藏在那些持续时间较长的操作背后,又或者会因为在处理错误时而被不断累积起来。
在分析这类问题时,需要区分以下三个方面:
影响程度: 这种代码路径会多久执行一次?每次执行会占用多少内存?外部因素是否有可能导致它被反复触发?
原因分析: 是哪个模块没有正确释放分配的内存?又是哪个引用使得这些内存资源一直处于未被释放的状态?
应对策略: 这种泄漏现象是否会影响到当前的程序执行流程?有哪些测试或工作负载可以确保这种泄漏不会再发生?
某种泄漏可能影响较小,但仍然属于严重的程序缺陷。这种区分非常重要,尤其是在那些长期运行的服务程序以及那些包含大量外部函数的代码中——因为每次操作都会导致的少量泄漏,随着时间的推移也会累积成严重的问题。
模糊测试、压力测试工具与并发检测机制
安全检测工具只能报告在程序运行过程中实际发生的违规行为。而调试工具虽然能够帮助你了解程序中存在的错误执行情况,但它们并不能让程序自动访问那些可能存在问题的代码路径。
这时,工作负载设计就发挥了重要作用。
模糊测试工具擅长探索输入数据的各种可能组合;压力测试工具会反复让程序经历对象的生命周期变化;并发检测机制则会增加程序中线程并行执行的次数。这些工具与安全检测工具相辅相成,因为它们各自能够帮助人们更容易地发现那些在正常情况下很少出现的错误执行路径。
模糊测试:探索普通输入无法到达的代码路径
LLVM的libFuzzer是一种基于代码覆盖率的进程内模糊测试工具。需要被测试的目标代码会被链接到测试二进制文件中,并通过诸如LLVMFuzzerTestOneInput这样的入口点被调用。libFuzzer会随机修改输入数据,然后通过代码覆盖率检测机制来观察哪些修改会导致程序执行到新的代码路径,并保留那些能够扩大代码覆盖范围的修改方案。
#include
#include
extern "C" void ParseOrImport(const uint8_t* data, size_t size);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
ParseOrImport(data, size);
return 0;
}
使用ASan进行测试时,命令如下:
clang++ -O1 -g -fsanitize=fuzzer,address \
fuzz_target.cc -o fuzz_target
./fuzz_target corpus/
而当程序的依赖关系图可以被充分地用于MSan测试时,命令如下:
clang++ -O1 -g -fsanitize=fuzzer,memory \
-fsanitize-memory-track-origins=2 \
fuzz_target.cc -o fuzz_target_msan
./fuzz_target_msan corpus/
这些测试用例中的目标函数本身往往设计得比较简单,因为真正重要的工作在于确定这些函数的输入边界是什么。
一个合适的模糊测试目标应该是这样的:对其输入进行微小修改后,程序应该能够迅速进入有意义的状态;这种测试目标应该是确定性的、执行速度应该较快,并且不应该存在导致某些输入无法被复现的行为,比如进程退出或者全局状态发生异常变化等等。解析器、解码器、协议处理函数、反序列化工具等都是常见的测试目标,因为对它们的输入进行微小修改,就可能会使程序的执行路径发生显著的变化。
安全检测工具和模糊测试工具的作用是不同的。模糊测试工具负责发现那些可能导致指针失效或者输出数据部分初始化的错误路径,而安全检测工具则会判断出这些错误行为是否会导致程序执行结果无效。
前面提到的NSS S/MIME漏洞,就是这种关系的一个典型例子。有一条格式异常的消息进入了解码流程,尽管解码过程成功完成了,但并不意味着最终生成的输出数据已经正确初始化了。有趣的是,并非输入数据的格式错误才是问题的关键,而是这些输入数据使系统进入了一种违背API初始化规范的状态。一旦模糊测试发现了某个漏洞,就应该保留该测试用例。如果可能的话,应尽量简化该测试用例;使用相同的测试工具重新重现故障现象,修复根本缺陷,然后将简化后的测试用例添加到回归测试数据库中。否则,就意味着模糊测试工具仅仅偶然发现了一次这类漏洞。
压力测试框架:用于模拟各种对象生命周期中的转换操作
当真正需要检测的变量并非输入数据,而是对象的生命周期时,模糊测试的效果就会大打折扣。
一个失效的最终清理操作、一次遗漏的资源引用释放,或者在第十次“创建-关闭”循环后出现错误的资源清理流程,其实并不一定需要那些特殊的异常数据。真正关键的是让这样的对象生命周期事件发生足够多次,或者以正确的顺序进行。
这就是压力测试框架的作用所在——通过模拟各种复杂的对象生命周期过程,来揭示潜在的漏洞。
与其让一个长期存在的对象处理大量的操作,不如创建多个短期存在的对象,并让它们在成功执行与失败执行之间交替进行。反复地获取和释放资源引用,模拟“导入后导出”、“打开后关闭”等操作流程。如果运行环境允许的话,还可以在这些操作之间人为制造垃圾回收的压力。如果内存重用机制很重要,那么就应该创造足够多的操作场景,以确保最近被释放的内存能够被再次利用。
创建对象
-> 使用该对象
-> 关闭对象
创建对象
-> 将对象转移给新的所有者
-> 删除之前的对象
-> 新所有者使用该对象
-> 关闭对象
创建对象
-> 部分初始化对象
-> 操作失败
-> 清理剩余资源
重复这种操作数千次
这种测试方式与传统的吞吐量基准测试截然不同。基准测试通常会长时间使用同一个对象,其所有权始终不变;而压力测试框架则专门用于模拟那些会导致对象所有权发生变化的场景。
这种测试方法不仅适用于FFI封装层,连接池、引用计数缓存、插件生命周期管理、异步请求对象、GPU资源管理、文件描述符处理、临时数据区以及回调函数注册等机制,也同样可以从这种测试方法中受益。
故障注入也是必不可少的
有些资源清理操作很难被有效模拟,因为正常情况下程序很少会在关键点出现错误。
某个操作依次分配了四个资源,
如果只测试成功完成和立即失败的情况,那么中间的一些状态就根本无法被检测到。
一个有效的压力测试框架可以强制让第二个、第三个或第四个操作步骤失败,
从而揭示出潜在的漏洞。
这样往往能够发现以下类型的错误:
- 那些假设资源已经完成初始化的清理操作
- 由于所有权已经转移,导致资源被重复释放的情况
- 对象在仅部分构建完成后就被泄露的情况
- 那些会导致输出结果看起来正常的错误处理路径
- 在设置过程失败后仍然能够正常运行的回调函数或注册机制
对于那些包含大量所有权操作的代码来说,这些中间故障状态往往比输入数据格式错误的情况更为值得研究。
并发探测机制:促使竞态条件发生
TSan在检测覆盖范围方面存在另一个问题:仅仅因为两种操作在理论上可能同时发生,并不能说明它们确实会引发竞态条件。只有当这些冲突性访问真正发生在被观察到的执行过程中时,TSan才能将其报告出来。
简短且具有确定性的单元测试往往无法生成能够体现这类竞争行为的执行序列。
并发探测机制会刻意增加出现竞态条件的概率:让多个工作线程同时从同一个启动点开始执行,重复执行关键操作,并改变工作线程的数量。通过将“创建”操作与“关闭”操作、 “发布”操作与“销毁”操作进行组合,就可以促使它们同时发生。在适当的情况下,仅用于测试的目的而设计的特殊测试用例或简化的执行序列,能够扩大那些通常只存在于少数几条指令之间的竞争窗口。
下面这个故意设计出来的竞态条件示例,能够清楚地说明这一原理:
#include
#include
struct SharedState {
int plain_counter = 0;
std::atomic stop{false};
};
void writer(SharedState* s) {
while (!s->stop.load(std::memory_order_relaxed)) {
s->plain_counter++; // 故意不进行同步操作。
}
}
void reader(SharedState* s) {
for (int i = 0; i < 1000000; ++i) {
(void)s->plain_counter; // 故意不进行同步操作。
}
s->stop.store(true, std::memory_order_relaxed);
}
int main() {
SharedState state;
std::thread t1(writer, &state);
std::thread t2(reader, &state);
t1.join();
t2.join();
}
这个示例中的错误其实并不复杂。其关键在于测试环境的设置:通过重复执行并发操作,使得那些冲突性访问行为很有可能会同时发生。
真正的并发探测机制应该针对系统中已经存在的那些可能导致竞态条件的操作流程来进行设计。例如,如果某个回调操作可以在某个对象被关闭的过程中完成,那么就应该让这两个操作同时发生;如果某个注册表在被读取的同时,另一个线程正在删除其中的条目,那么也应该让这两个操作同时进行。总之,就是要刻意制造出那些在正常情况下永远不会发生的竞争条件。
我们的目标并不是去制造出真实程序根本不可能产生的行为,而是要避免依赖调度器来偶然发现一些虽然合法但不方便使用的执行顺序。
记录随机种子值、操作序列、工作线程数量以及其他与调度相关的参数也是非常重要的。那些在夜间测试中只出现一次、且无法在本地环境中重现的竞态条件问题,要比那些可以重新执行其操作流程的问题更难解决。
将检测结果视为回归测试输入
当模糊测试和压力测试的输出能够在调试结束后仍然保留下来时,这些测试方法的价值就会大大提升。
对于模糊测试工具来说,这意味着需要将那些导致程序崩溃或出现漏洞的输入数据保留下来;对于用于测试整个应用程序生命周期的工具来说,这意味着要保存那些导致故障发生的具体操作序列;而对于并发测试工具来说,这意味着要保留那些能够使竞态条件现象重现的种子值、工作线程数量或调度参数。
OSS-Fuzz在这里是一个非常有用的工具,即使对于那些从未实际使用过该服务的项目来说也是如此。它的工作流程将模糊测试目标、语料库、安全检测工具构建版本、复现工具以及回归测试视为同一个系统的一部分,而不是独立的调试活动。
这种做法值得借鉴:应将模糊测试目标与它们所针对的代码放在一起保存;保留有用的测试用例输入数据;确保在脱离模糊测试环境后也能重现故障现象;并在使用安全检测工具构建版本时重新运行那些之前曾出现故障的测试用例。
关键在于理解这样一个道理:安全检测工具的覆盖范围既取决于构建过程,也取决于具体的测试工作流程。ASan无法检测到那些根本不会被任何程序引用的“无效指针”;MSan无法暴露那些没有任何测试能够触及的“部分初始化状态”;TSan也无法报告那些永远不会实际发生的“数据交错访问现象”。
良好的调试工具能够帮助你及时发现代码执行过程中出现的问题;而合理的测试设计则能确保那些值得关注的错误现象能够频繁地被检测出来。
如何阅读和分类分析安全检测报告
安全检测报告只是一种证据,而非最终的诊断结果。
报告的顶部会显示程序运行时究竟在哪个位置发现了问题。虽然可以从这里开始阅读报告,但往往这个地方并不是错误最初产生的地方。进行有效的故障分类分析时,应该从报告入手,逐步追溯到导致故障的具体环节,直到弄清楚是哪些生命周期管理、初始化操作或同步机制出了问题。
首先,要确保这份报告是可信的
如果一堆原始地址信息无法被有效利用,那么进行任何分析都是毫无意义的。
在修改代码之前,必须确认出现故障的二进制文件包含有可靠的调试信息;这些符号文件确实属于当前正在测试的构建版本;同时还要确保你感兴趣的那个模块确实已经被添加了调试工具。不过,持续集成系统反而会增加出错的可能性:使用过期的符号文件、重新编译后的二进制文件,或者从其他测试任务中复制来的报告,都可能导致生成一份看似合理但实际上毫无用的故障分析结果。
对于使用Clang构建的安全检测工具来说,设置`-g`和`-fno-omit-frame-pointer`选项是一个合理的基准配置;在运行测试时也可以使用`llvm-symbolizer`工具来辅助分析。如果优化措施使得堆栈信息难以被正确重建,那么将优化级别调低一些,并加上`-fno-optimize-sibling-calls`选项,可能会有所帮助。
请务必将经过安全检测的二进制文件及其对应的符号文件与报告一起保存下来。同样,也要保留那些用于安全检测的配置选项、抑制文件、复现工具,以及任何其他有助于重现故障现象的资料。
未进行符号化处理的十六进制地址数字,并不能说明问题的严重性较低;实际上,这恰恰意味着缺乏有效的调试手段来观察程序的执行过程。
要根据工具实际指出的问题来阅读报告
人们很容易将ASan、LSan、MSan、TSan以及Valgrind等工具发现的错误都归为“内存错误”这一类别,但这种做法往往会导致调试方向出现偏差。
例如,ASan检测到的“使用后释放对象”的错误,本质上是一个与程序对象的生命周期管理相关的问题:为什么在某个部分结束了对对象的操作之后,程序的另一个部分仍然认为该对象是可以使用的呢?
LSan的报告提出了另一个不同的问题:既然该内存分配在程序被销毁之前一直存在,那么到底应该由哪个所有者来释放这些资源呢?又是什么因素阻碍了这一所有权的终结呢?
对于MSan来说,有趣的问题通常不是“为什么这个变量的值不为零?”而是要弄清楚程序是如何获得将这个变量视为已初始化的权限的。往往,答案会指向某个输出参数、一个尚未完全构造的对象,或者是一个其后续执行条件比调用者原本假设的要弱的成功路径。
TSan的报告关注的是代码中的访问顺序问题。当出现两次相互冲突的访问操作时,如果运行时无法建立任何同步机制,那么调试的任务就是找出那些本应该决定访问顺序的关键点,然后判断这些关键点是缺失了,还是仅仅被某些调试工具所掩盖了。
对于Valgrind检测到的无效读写操作,需要进一步进行分类分析,因为它们可能反映了多种不同类型的问题:例如内存所有权未正确处理、数组越界错误、内存分配器的误用,或者是由周围运行时环境导致的复杂行为。
这种区分非常重要,因为某些局部修改虽然可以暂时掩盖问题的症状,但却并没有真正修复代码中的缺陷。对于MSan报告中提到的那些缓冲区,将其初始化为零可能会让一些问题看起来消失了,但实际上相关的API接口仍然存在隐患;而在使用TSan检测竞态条件时,添加一些延迟机制也可能使某个测试用例通过,而实际上并没有引入任何同步措施。
将堆栈视为时间线来分析问题
对于那些“使用后未释放”的内存资源而言,当前的访问操作只不过是整个事件链中的其中一个环节而已。
分配堆栈可以告诉你对象的生命周期是从哪里开始的;释放堆栈则能说明是谁决定终止这个对象的生命周期的;而当前堆栈则能显示还有哪些部分没有完成这一过程。
这种“分歧”往往比报告顶部显示的那条具体代码行更为重要,因为它们能帮助我们更全面地理解问题的本质。
这个思路同样适用于其他类型的代码检测工具。对于MSan来说,使用堆栈可以显示那些未初始化的变量在程序中的哪个位置开始发挥了作用;而起源追踪功能则能指出这些变量最初是从哪里进入程序的,而且它们并没有经过有效的初始化过程。对于TSan而言,单独来看,那两次相互冲突的访问操作本身并没有什么意义,只有将线程创建、锁机制、原子操作以及其他同步事件结合起来考虑,才能真正理解问题的全貌。
通常,我们可以把整个问题链简化为如下这样的形式来思考:
对象被创建
-> 所有权发生转移
-> 旧所有者仍然可以访问该对象
-> 新所有者销毁了该对象
-> 旧所有者继续使用已经过时的指针来访问该对象
或者对于那些与初始化相关的问题来说,可以这样描述:
资源被分配出来
-> 稀有情况下,某个分支跳过了初始化步骤
-> 函数仍然返回了成功的结果
-> 调用者接收到了这个“错误”的资源
一旦把整个事件序列写下来,那些导致程序崩溃的代码行往往就不再看起来像是修复问题的关键位置了。
在不移除导致错误发生的根本原因的情况下,尽量减少错误重现的概率
“最小化重现环境”这一概念有时会被过于字面地理解。
真正最小的、有用的重现环境并不一定是代码行数最少的程序,而是那种仍然能够保留导致错误发生的各种条件的环境。
对于那些与函数调用接口相关的生命周期错误来说,移除托管运行时环境可能会同时消除引发这些错误的终结操作。用`malloc`替换内存池分配器可以消除那些会导致失效指针被暴露出来的代码逻辑。将并发测试简化为单线程测试虽然会使程序规模变小,但也会消除竞态条件。
在优化级别、垃圾回收压力、输入数据结构、线程数量、分配器配置以及调试工具的使用范围等方面,也需要同样的谨慎态度。应积极删除与问题无关的代码,但必须保留那些会导致错误发生的核心机制。
一个好的重现环境应该能让人们更容易理解报告中的问题,而无需将这个程序改得面目全非。
纠正所有权或同步方面的错误,而不仅仅是处理报告中提到的访问问题
代码检测工具往往能揭示那些由于早期设计决策导致的、最终变得不安全的问题。
对于失效指针的问题,可以在所有权发生转移后使相应的包装对象失效,从而解决这个问题;而对于内存泄漏的情况,则可能需要让某个对象明确负责释放被保留的引用。MSan的报告可能会促使开发者修改API,以确保程序的成功执行总能产生完整初始化的结果;TSan的发现则可能揭示那些原本被认为“基本是原子操作”的代码实际上需要特定的同步机制。
有时候,正确的解决方法其实只是进行一些局部边界检查或添加初始化语句而已。但在停止调试之前,还有一个问题值得追问:最初是什么导致了这种无效状态的出现呢?
在函数调用接口和库的边界处,这个问题尤为重要,因为多个调用者都可能以略微不同的方式重复出现同样的错误。
在修复问题后,仍要保留导致错误发生的条件
那些在修复漏洞后消失了、但却没有留下回归测试用例的代码检测工具漏洞,几个月后很容易再次被发现。
重现环境应该成为常规工程文档的一部分。那些由模糊测试引发的错误应该被纳入回归测试集;与生命周期相关的错误可以转化为专门的单元测试或拆解测试;而对于竞态条件问题,则需要保留触发这些错误的初始条件、工作线程的数量以及操作顺序,以便后续进行测试。
然后,需要在原来发现问题的相同代码检测工具环境下重新运行这些测试用例。
最后这一点非常重要。即使经过ASan的修复后单元测试通过了,也不能证明相关的错误已经完全消失。真正应该用来验证问题是否得到解决的,应该是最初用于发现错误的那些带有调试功能的测试环境。
因此,故障排查的目的并不仅仅是让报告结果消失,而是要弄清楚为什么程序会进入无效状态,在正确的边界处修复这个问题,并留下能够再次捕获相同错误的测试环境。
持续集成政策与验收标准
一旦这些安全检测工具的报告结果具有可重复性且值得信赖,接下来就需要考虑它们在持续集成流程中应该处于什么位置。
为每个拉取请求都使用所有这些工具,通常并不是正确的解决方案。ASan、MSan、TSan等工具所带来的开销各不相同,而且它们对代码监控的深度要求也有所区别。一个合理的持续集成配置应当充分考虑这些差异,而不应将所有安全检测工具简单地视为同一类“安全保障措施”。
在实际应用中,这些工具可以分为两类:用于保护合并路径的快速检查工具,以及那些能够发现常规测试套件难以发现的问题的较慢运行流程。
ASan通常是 easiest被集成到日常开发流程中的安全检测工具。它的使用成本虽然较高,但通常还是可以接受的;而它所报告的错误类型(如“使用已释放的内存”、“越界访问”等)也确实是一些我们不希望故意保留下来的问题。对于那些以原生代码为主的项目来说,如果构建环境和测试套件能够支持的话,将ASan与UBSan结合使用,作为合并前的安全检查步骤是相当合理的。
泄漏检测工具的情况略有不同。当LSan作为ASan构建流程的一部分被运行时,它也可以被纳入同一检查环节。但是,是否应该立即阻止所有检测到的泄漏问题,其实取决于运行环境的纯净程度。对于那些设计严谨、具有确定性销毁机制的原生代码测试程序来说,通常可以从一开始就进行严格的泄漏检测;而对于那些由管理型运行环境或大型宿主进程支撑的应用程序来说,可能首先需要建立一个基准线,以便将应用程序自身产生的泄漏问题与运行环境中的残留问题区分开来。
TSan通常应该被安排在距离关键合并路径较远的位置。使用TSan进行检测所带来的开销相对较大,而且只有那些能够产生真正有意义的并发行为的代码才适合用它来检查。如果在每次提交代码时都使用TSan运行简短的单元测试套件,虽然成本较高,但检测效果往往并不理想;因此,更适合将TSan安排在夜间运行、作为发布前的预检步骤,或者针对那些涉及大量并发操作的代码变更进行专门检测。
MSan的使用则受到其他限制。它的有效性在很大程度上取决于依赖关系图中的哪些部分可以被有效监控。如果应用程序及其关键库能够在统一的MSan环境下进行构建,那么它就可以成为检测初始化阶段错误的有效工具;然而,如果系统中有一半的组件无法被有效监控,那么强制将MSan纳入常规的拉取请求处理流程中,反而可能会造成更多的混乱。在许多项目中,MSan最终往往会被作为一种专门的构建步骤来使用,而不是作为日常测试流程中的常规环节。
模糊测试在持续集成流程中也扮演着不同的角色。用于寻找新的攻击输入的复杂测试可以持续运行,也可以按照固定的时间表执行;而那些已经被发现的攻击输入,则应该被反复使用,以便更频繁地进行检测。一旦发现了会导致程序崩溃的测试用例,并将其记录到数据库中,就没有必要等到下一次模糊测试才能发现同样的问题再次出现。
因此,一个典型的持续集成政策可能会如下所示:
检测类型
通常适用于哪些场景
什么情况会导致该检测失败
常规测试
所有代码变更时都会进行
当功能测试失败时,会使用这种检测方法
ASan + UBSan
在合适的情况下,在代码合并前进行检测
任何被认定为可靠的、未被屏蔽的缺陷报告都会被用于此检测
泄漏检测
在代码合并前或按照计划进行检测
针对由你自己编写的代码中存在的确定性泄漏问题进行检测
TSan
按计划进行,在代码发布前或针对特定目标进行检测
针对你编写的、经过了调试的代码中存在的竞态条件缺陷进行检测
MSan
在专门的构建过程中或按照计划进行检测
对于那些被认定为可靠的、与未初始化变量相关的缺陷报告,会使用这种检测方法
模糊测试回归
在代码合并前或定期进行 使用某些特定的输入数据,来检测是否会导致程序崩溃、挂起或出现其他异常情况
全面模糊测试
按计划进行或持续不断地进行 新发现的缺陷会被视为需要优先处理的对象,而不会被简单地视为普通的测试失败结果
Valgrind或其他替代分析工具
在编译器的调试工具无法发挥作用的情况下使用 针对那些项目认为具有较高可信度的非法访问行为或泄漏问题进行检测
确切的时间安排其实不如其背后的逻辑重要。那些能够有效检测出真正问题、只需花费五分钟就能完成的测试,其价值要远高于那些需要耗费两小时才能完成却无法确保能发现问题的测试。每晚进行的静态分析工作,可能比那些针对部分被监控的依赖项、且结果并不可靠的测试更有意义。
持续集成流程的成本应该根据检测结果的质量来决定,而不是为了让检查表格看起来更加全面。
明确“红色通道”的含义
某项测试被标记为“红色通道”,并不应该仅仅是因为检测工具显示了一些异常结果;而应该是因为项目认为这类检测结果确实代表了他们不愿意接受的缺陷。
对于内存安全检查来说,判断标准通常很明确:在你负责开发的代码中,如果存在可重现的、未被抑制的内存安全违规问题,那么构建过程就应该失败。
一旦被监控的环境足够完善,使得检测结果能够被信赖,那么对静态分析工具的检查也可以采用类似的标准。例如,一个未初始化的值影响了程序的执行,这种情况绝对不能因为测试最终成功就被视为正常现象。
对于线程安全检查来说,虽然需要更多的背景信息来理解检测范围的边界,但如果你负责开发的代码中确实存在竞争条件导致的问题,那么一旦检测结果得到确认,就应该立即采取相应的措施。 “这种问题只会在线程安全检查环境下出现”这样的说法,并不是一个有效的判断标准。
在处理程序漏洞的问题时,往往需要更多的判断力。例如,在针对特定功能的本地测试中发现的确定性泄漏,与在整个托管运行环境中检测到的潜在泄漏,其严重性是完全不同的。webcrypto.dart这个案例就很有参考价值:一些通过Valgrind工具明确检测出来的泄漏问题,应该被立即处理;而那些可能存在但尚未得到确认的泄漏问题,则可以继续留待后续进一步验证。
这种处理方式比两种极端情况都要合理。忽视所有的漏洞报告会浪费有用的检测结果;而从一开始就对所有存在疑问的内存分配操作都进行错误处理,又会使得整个检查流程变得毫无意义。
将问题发现与缺陷回归测试区分开来
实际上,“寻找新漏洞”与“确认旧漏洞是否再次出现”,这两类测试也有明显的区别。
运行数小时的模糊测试属于问题发现环节;而仅仅在几毫秒内重新执行一次之前保存的模糊输入数据,则属于缺陷回归测试。
执行数百万次操作的线程安全压力测试属于问题发现环节;而对于上个月刚刚修复的竞争条件问题,使用简单的复现工具进行的测试,则属于缺陷回归测试。
针对新漏洞进行的大规模检测工作属于问题发现环节;而仅仅用于复现之前已经修复过的内存泄漏问题的简短辅助程序,同样属于缺陷回归测试。
通常来说,后一类测试应该更接近合并路径。一旦某种复杂的故障问题被转化为可以通过简单、可靠的测试方法来检测的问题,那么继续将其留在最初发现它的复杂测试流程中,就没有任何意义了。
这就是为什么随着时间的推移,某些类型的检查工具会变得越来越便宜的原因:因为复杂的问题的发现过程会产生大量简单的回归测试任务,而这些回归测试本身所需的成本却相对较低。保留足够的上下文以便排查故障
当某个检测工具出现故障时,持续集成系统应该不仅仅是显示错误状态而已。
真正有用的信息包括那些能够重现故障发生过程的数据:被检测过的二进制文件、对应的符号文件、原始报告、检测工具的配置选项、用于抑制错误的文件,以及那些导致故障输入数据、种子值或工作负载参数等。
进行模糊测试时应该保留导致程序崩溃的输入数据;在处理并发任务时,如果存在特定的操作顺序,也应该将其保留下来;针对特定工具链进行的构建过程应记录生成报告时所使用的编译器和检测工具的版本信息。
这些要求并不一定非得与GitHub Actions、GitLab CI、Buildkite、Jenkins或其他持续集成工具相关联。虽然实现方式可能会有所不同,但基本原则是不变的—— tomorrow 的开发人员应该能够直接重现故障发生的过程,而无需对持续集成系统进行逆向工程分析。
将相关政策明确记录下来
在发布版本即将上线的时候,才决定某个检测结果是否会导致发布受阻,这种做法是最糟糕的。
每个检测流程都应该有一套清晰、简洁的规则:规定该流程应该观察什么内容、哪些异常会导致发布失败、哪些信息性报告只是提示性警告,以及允许使用哪些抑制错误的方法。
这些规则是可以随着实际情况的变化而调整的。例如,一个新的泄漏检测流程可能最初只是用于收集数据以了解基准情况,而在去除干扰因素后,它就会成为检测确定性泄漏的关键工具;某些检测任务可能最初是每晚自动运行的,后来当运行效率提高后,才会被应用到特定的拉取请求上;随着更多依赖项能够被纳入检测范围,MSan环境也可能得到进一步扩展。
最重要的是,每个“绿色通道”所代表的含义都必须是稳定且明确的。“ASan检测通过”这个结果应该不仅仅意味着“持续集成命令的退出状态为0”,而应该表示目标代码确实经过了检测,预定的测试工作负载也成功运行了,报告中的信息也被正确解析了,并且没有出现任何会阻碍发布的异常情况。
这就是制定持续集成政策中关于检测工具的部分的核心目的——不是为了让工具矩阵变得越复杂越好,而是要确保每一个“绿色结果”都能真正反映我们所需要的信息。
生产环境策略、抑制错误的方法及相关指标
大多数检测工具都是为测试目的而设计的,并不适合在生产环境中大规模使用。它们的运行成本、占用的内存资源以及所需的开发配置使得它们并不适合许多实际的生产环境。
不过,生产环境对于检测工具来说仍然非常重要。它可以帮助我们了解哪些工作负载真正值得关注,哪些故障在预发布阶段被遗漏了,以及哪些检测流程的设计还不够完善。一个有效的反馈机制是必不可少的——生产环境会暴露出测试过程中未能发现的问题,而这些问题随后就可以被用来开发新的检测工具、重现故障现象或进行回归测试。
在新工具完善之前就将其投入使用
新的检测工具在首次运行时,其检测结果往往并不准确。
现有的代码库中可能已经存在各种缺陷:第三方报告指出的问题、运行时产生的异常、缺失的符号、不完整的监控数据,以及从早期开发工作中遗留下来的问题。如果立即将所有这些问题都视为必须优先处理的障碍,那么团队就会面临大量需要处理的问题,却不清楚哪些问题真正需要立即解决。
实际的操作流程应该是先收集各种报告,了解当前的基线情况。首先修复那些由自己负责维护的代码中的确定性错误,然后将这些问题与由于依赖关系或监控机制缺失而产生的问题区分开来。对于那些值得重点关注的问题,要确保它们能够被复现出来。一旦某个类别的问题被充分理解,以至于新出现的报告能够清晰地反映这一类别的问题,那么这个类别就可以被列为优先处理的对象。
检测流程应该持续朝着这个方向前进。如果一个仅用于观察问题的检测任务永远停留在观察阶段,那么它虽然在不断收集数据,但实际上并没有发挥出任何工程控制的作用。
如果在添加新的检测步骤之前等待基线完全干净,就会产生另一个问题——因为清理工作通常是在发现问题且这些问题反复出现时才进行的。如果等到所有问题都得到解决之后才开始进行这些清理工作,那么这个过程可能会被无限期地推迟。
抑制措施应被视为临时性的
有些抑制措施是不可避免的。例如,第三方库中可能存在某些无法立即修复的缺陷,运行时环境可能会使某些内存分配一直保持激活状态直到系统关闭,或者监控机制的边界问题可能会导致一些暂时无法解决的异常报告。
但问题在于,如果将这些抑制措施当作解决所有问题的手段,那么就会导致一些问题被永久性地掩盖起来。
一个合理的抑制措施应该能让后续的处理人员清楚地了解其存在的理由:它隐藏了哪些报告信息,这些信息从哪里来,为什么现在无法修复,谁负责后续的处理工作,以及这个问题是通过哪个跟踪系统来进行管理的。
这种抑制措施的范围应该尽可能狭窄。如果整个库或命名空间都被纳入抑制范围,那么不仅当前已知的问题会得到解决,后来出现的无关问题也可能被掩盖起来。
从时间角度来看,某些抑制措施所持续的时间往往能提供更多有用的信息。例如,最近发生的五次抑制措施可能与一些正在被处理的上游问题有关,因此这些措施可能是可以管理的;而那些三年以来没有人审查过的五次抑制措施,则可能说明程序目前处于一种不同的状态。
一旦根本性的缺陷得到修复,就应该在同一批代码修改中取消这些抑制措施。如果继续保留这些抑制措施,那么检测工具就会无法发现那些已经不再需要被特别处理的代码问题。
可以将抑制措施视为“可观测性债务”——每一项抑制措施都有其存在的合理性,但同时也会削弱检测工具所提供的准确信息。因此,应该记录下这些抑制措施的数量,跟踪它们的存在时间,并确保那些长期存在的异常信息能够被清楚地看到,从而避免人们忘记它们。
保留足够的构建信息以便日后分析故障原因
在正常情况下,符号化处理似乎只是一种辅助调试的工具,但当检测工具只在持续集成环境中才会出现异常时,这种处理方式就显得非常重要了。
在这种情况下,只有当报告中所提到的信息能够与生成这些报告的具体二进制文件相对应时,这份报告才具有实际意义。如果从同一个代码提交版本重新构建程序,得到的结果并不一定与原来的结果相同——优化措施、链接器的决策方式、生成的代码以及所使用的工具链版本等因素都可能改变程序的结构和运行结果。对于那些值得保留的 sanitizer 工作任务,请保留用于重现故障所需的所有相关数据:被添加了监控代码的二进制文件、对应的调试信息、sanitizer 的原始输出结果、相关的运行时配置选项,以及触发该报告的具体输入数据或工作负载。当构建流程会生成同一程序的多个变体时,构建 ID 以及编译器/运行时的版本信息会非常有用。
对于那些由管理型运行时环境、JIT 编译器、AOT 编译器或插件系统处理的程序,需要格外小心处理,因为它们的本地地址可能并不会直接对应到普通的源文件中。
webcrypto.dart案例研究中提到的 Dart AOT 问题就是一个很好的例子。虽然本地工具能够检测到那些异常执行情况,但如果运行时的布局导致有用的 Dart 符号无法出现在堆栈中,那么这些报告就失去了其实际价值。
一份 sanitizer 报告只有在与生成它的代码建立关联之后,才能发挥出真正的作用。因此,确保这些符号信息能够被正确保留,应该是构建和发布流程中的必要环节,而不是开发人员用来进行本地调试的临时手段。
将生产环境中的检测结果反馈到 sanitizer 工作负载中
目前,常规的 sanitizer 测试矩阵仍然只能覆盖测试环境中产生的执行场景。
在生产环境中,对象的生命周期、请求类型、内存分配器的压力、机器的性能规格、线程调度方式以及程序的长时运行行为都会有所不同。在支持这些特性的平台上,使用受保护的内存分配器、硬件辅助的内存标记机制、采样技术、canary 部署方案以及更丰富的故障监控工具,可以发现那些在持续集成环境中从未出现过的故障。
这些机制能够提供另一种类型的证据。其中一些方法是基于抽样的,或者特定于某种平台环境的,并非确定性的测试方法。它们的价值在于能够捕捉到 sanitizer 测试环境可能永远无法重现的运行场景,而这些发现就可以被反馈到测试系统中去。
当生产环境中出现的故障与某个现有的压力测试用例所覆盖的生命周期阶段无关时,就应该将这个生命周期阶段转化为 ASan 工作负载进行进一步测试。如果监控数据表明某种故障路径会导致内存使用量增加,那就应该专门设计一个针对这种问题的测试用例,并在泄漏检测模式下运行它。如果某些并发故障只在高负载环境下才会出现,那么就请保留相关操作序列,并使用 TSan 压力测试工具来对这些问题进行深入分析。
这样的反馈循环能够确保 sanitizer 工作负载始终与真实系统的运行情况保持一致,而不会试图让生产环境本身变得类似于 sanitizer 测试环境。
评估这些测试机制是否越来越有用
sanitizer 工具会生成大量的数据,但其中真正有用的信息往往能够回答三个实际问题:
我们真的找到了真正的漏洞吗?
我们能够重现这些漏洞吗?
进行这样的测试所花费的成本是否仍然合理?
通常来说,一小部分关键指标就能比一个庞大的仪表盘提供更多的有用信息。
指标
该指标能告诉您什么
随时间出现的新安全漏洞
判断内存相关问题、代码生命周期相关问题或种族歧视相关的缺陷是否仍在持续出现在代码库中
漏洞被抑制的次数及时间间隔
判断那些已知存在的“盲点”是在逐渐减少,还是会永久存在下去
问题复现的成功率
判断这些故障是否真的可以被转化为可行的工程改进措施
生成有用调试信息所需的时间
判断当前的调试基础设施是否能够正常发挥作用
缺陷出现的频率
判断某些故障究竟意味着“存在真正的漏洞”,还是仅仅表示“需要重新运行相关测试即可”
运行时间与计算成本
判断当前这些功能模块是否仍然适合在当前的位置上运行
之前发现的缺陷是否仍能通过回归测试得到有效保护
判断之前的安全漏洞发现结果是否依然受到现有测试机制的保护
这些数字需要具体的背景信息才能被正确理解。
如果为了提高检测效率而放弃了某些重要的符号化处理措施,那么检测流程的加速其实并不能算是一种改进。例如,如果某个TSan检测任务完全没有发现任何问题,那很可能只是因为该任务处理的代码量太小,几乎无法产生并发执行的情况。此外,一些模糊测试工具虽然能够显示出较高的代码覆盖率,但仍然可能忽略那些会导致资源所有权发生变更的关键生命周期阶段。
即使错误数量在减少,这也未必意味着代码质量在提升。这种情况可能是由于代码库中的某些路径不再会被检测到,而不是因为代码本身真的变好了。
只有当这些指标能够帮助我们区分上述不同情况时,它们才具有实际意义。一旦数字本身成为了追求的目标,人们就很容易在改善仪表盘显示效果的同时,实际上却让背后的数据质量变得更差了。
成熟的检测系统是什么样的
一个成熟的检测系统所使用的持续集成工具产生的报告结果应该是清晰且具有实际意义的。检测任务的数量其实并不是最重要的因素。
该系统的各个检测环节能够准确识别各种故障类型,测试报告也可以被其他人复现;那些被暂时抑制的错误也会在适当的时候再次被检测到。旧的错误会变成有价值的回归测试用例,而那些需要耗费大量资源才能检测到的错误,则会在那些快速检测工具无法发现问题的地方被重点排查。生产环境中的问题也会被反馈回检测系统中,从而生成新的测试用例。
随着时间的推移,“我们是否同时运行了ASan、TSan和MSan这些检测工具?”这样的问题已经不再那么重要了;人们更关心的是:“还有哪些类型的错误执行路径能够在这个系统中不被发现呢?”
第二个问题揭示了一些至关重要的漏洞:比如代码覆盖范围不足、依赖关系不明确、符号化处理不够准确、测试工具无法观察到某些资源所有权的变更过程,或者无法检测到线程间的同步问题。
当这些盲点逐渐减少、人们对它们的理解更加深入,并且新的错误也更难隐藏在这些盲点之中时,检测系统的效果就会得到显著提升。
总结与展望
只有当检测工具成为系统构建和测试流程中不可或缺的一部分时,它们才能发挥出最大的作用,而不仅仅是在系统出现故障后才被启用。
ASan、LSan、MSan和TSan这些检测工具虽然能够发现不同类型的错误,但它们的报告都会迫使程序去回答一些它本来就必须考虑的问题:例如,“这块内存是否仍然可以被访问?”“谁负责释放这块内存?”“这个值在使用之前是否已经被正确初始化了?”“是什么同步机制决定了这两个访问操作的顺序?”
这些假设无论有没有检测工具的存在都是存在的。而检测工具的作用就是让其中一些假设在程序运行时能够被观察到。
这也是为什么编译器选项只是整个测试环境配置中的一部分原因——相关的代码必须被添加相应的检测代码,而且测试过程必须能够覆盖那些可能导致这些假设不成立的关键场景。同时,测试报告中也必须包含足够的符号化信息,以便人们能够重现发生的问题。
持续集成系统需要有一套明确的规则,来界定哪些检测结果是可以信任的,哪些还在接受进一步的调查,以及哪些问题确实是当前检测工具无法覆盖的。
在本书中,“资源所有权”这个主题被反复提及,因为硬件的限制使得人们很容易忽略与资源生命周期相关的各种假设。资源的借用、保留、转移和释放等操作,并不仅仅是简单的记账流程,它们直接决定了谁还可以访问这些资源,谁最终必须销毁它们。Finalizer、清理作用域、自定义分配器以及跨语言调用包装器等因素,都会进一步增加程序中可能出现不同结果的情况。
webcrypto.dart这个案例从工具层的角度揭示了同样存在的问题。该项目原本希望使用的清理器路径在上游被阻断了,因此项目最终采用了现有构建系统能够支持的、基于内存检查的方案来继续开发。虽然使用这种方案后,那些与对象生命周期相关的问题仍然可以被检测出来,那些可靠性较高的检测结果也可以得到有效执行,但缺失的清理器支持功能依然存在,并没有被视为已经得到解决。
其中涉及的细节具体与Dart语言、BoringSSL库、构建脚本以及Valgrind工具有关。不过这种问题并非仅限于这些技术。当初始化操作涉及到那些无法被监控的代码时,MSan就会失去检测能力;当同步操作发生在某些不透明的依赖关系内部时,TSan就难以正确解析这些逻辑关系;而对于那些自定义分配器根本不会暴露出来的对象边界,ASan也无法进行有效的检测。
只有当你真正理解了这些对象边界的含义时,“成功运行程序”这一结果才具有实际意义。
在发现错误之后,情况也是如此。一个有用的报告往往可以用来重现问题;而能够重现问题的代码则可以被视为一种回归测试工具。如果多次出现类似的问题,那么改变API可能比添加临时修复措施更为合理。而对于那些只有在高负载环境下才会出现的错误,也应该保留相应的测试用例,以便在未来再次遇到这些问题时能够及时进行检测。
随着时间的推移,这些措施会逐渐提升清理器程序的实际价值。虽然单个报告仍然很重要,但更大的收益在于:这样的代码库中,那些与对象生命周期、初始化过程以及同步操作相关的错误将更难被隐藏起来。
绿色标记的清理器通道并不能证明程序是安全的,它只说明在特定的执行环境下,某些运行时规则得到了遵守。
>在测试结束后,我们需要思考的是:还有哪些问题是没有被这些清理器检测出来的。
清理器并不是用于调试的工具。它们是对你的程序已经依赖的那些运行时规则进行的实际检查。
参考资料与延伸阅读
清理器的文档与设计原理
-
-
-
-
-
-
-
-
-
-
模糊测试与持续测试
所有权、FFI技术及相关案例研究
-
-
-
-
-
-
-
-
-
公开安全事件参考
相关文章
演讲主题:持续重写Spotify的全部代码库
Jo Kelly-Fenton和Aleksandar Mitic介绍了Spotify是如何创建“Honk”这一人工智能编程工具的,该工具专门用于处理大规模代码库迁移工作。他们还分享了关于如何将持续集成测试的运行流程与人工智能工具分离、如何解决自动化拉取请求过程中出现的瓶颈问题,以及如何推动数千个工程代码仓库实现标准化的一些关键经验。 作者:Jo Kelly-Fenton, Aleksandar Mitic
阅读全文
现代质量保证工程师的实际工作内容:远不止是发现漏洞而已
如果你问别人QA工程师是做什么的,你很可能会听到这样一个熟悉的答案: “他们负责测试软件并找出其中的缺陷。” 这种看法非常普遍,而且公平地说,找出缺陷确实是QA工作的重要组成部分。但如果你在现代软件团队中工作几周,你就会很快意识到,这其实只是QA工程师实际工作内容的很小一部分。 在过去十年里,软件开发发生了巨大的变化。各个开发团队不再需要等待数月才能发布新功能;许多机构甚至每周、每天,甚至是每天多次进行系统更新。应用程序已经变得越来越复杂,云服务、API、微服务、移动应用以及第三方集成组件都在幕后共同发挥作用。 随着软件的发展,QA的角色也在不断演变。 如今的QA工程师会在某个功能开始进入测试
阅读全文
在参与者挑战之后,“马尾辫特工”技能系统自行调整了自身的基准参数。
Ponytail是一个仅包含指令文件而非源代码的仓库。在短短九天内,它就获得了44,000个GitHub星标——这一成就得益于它让那些用于自动编码的程序停止了不必要的重复操作。该项目最初宣称自己的代码量减少了80%到94%,但这一数据是基于有缺陷的基准测试结果得出的;在有人指出这一问题后,维护者重新进行了测试,并最终得出了代码量减少了54%这一更准确的数值。 作者:Steef-Jan Wiggers
阅读全文
演讲主题:工程的未来:当代码已经不足以满足需求时,那些真正重要的思维方式
本·格林探讨了在人工智能代码自动化快速发展的时代,软件工程师如何适应这一变化并取得成功。他结合自己创业的经验,解释了一些关键的理念,比如从简单的事情做起、保持对代码的理解能力、优先解决复杂的问题,以及关注这些解决方案对客户的实际影响。他还指出,在代码被自动化的情况下,人类的同理心、自主性以及实际问题解决能力依然是不可替代的。 作者:本·格林
阅读全文