Ruzzy:覆盖引导的Ruby模糊测试工具,开启Ruby安全新纪元

Ruzzy是首款覆盖引导的Ruby模糊测试工具,支持纯Ruby代码和Ruby C扩展,利用libFuzzer和Sanitizer技术快速发现内存损坏和异常漏洞,提升Ruby软件安全性。

引入Ruzzy:覆盖引导的Ruby模糊测试工具

Trail of Bits很高兴推出Ruzzy,这是一款针对纯Ruby代码和Ruby C扩展的覆盖引导模糊测试工具。模糊测试有助于发现处理不可信输入的软件中的错误。在纯Ruby中,这些错误可能导致意外异常,进而引发拒绝服务;在Ruby C扩展中,则可能导致内存损坏。值得注意的是,Ruby社区一直缺少用于此类错误检测的模糊测试工具,我们决定通过构建Ruzzy来填补这一空白。

Ruzzy深受Google的Python模糊测试工具Atheris的启发。与Atheris类似,Ruzzy使用libFuzzer进行覆盖检测和模糊测试引擎。在测试C扩展时,Ruzzy还支持AddressSanitizer和UndefinedBehaviorSanitizer。

本文将介绍我们构建Ruzzy的动机,简要概述工具的安装和运行,并讨论一些有趣的实现细节。Ruby爱好者们,欢呼吧!Ruzzy* 在这里,为Ruby仓库的韧性开启新时代。

*如果你好奇,Ruzzy只是Ruby和fuzz(或fuzzer)的合成词。

将模糊测试引入Ruby

Trail of Bits测试手册对模糊测试的定义如下:

模糊测试是一种动态测试方法,向系统输入畸形或不可预测的数据,以检测安全问题、错误或系统故障。我们认为它是测试套件中必不可少的工具。

在开发高保障软件时,模糊测试是一种重要的测试方法,即使在Ruby中也是如此。考虑AFL的广泛成果、rust-fuzz的成果,以及OSS-Fuzz声称通过模糊测试帮助发现和修复了超过10,000个安全漏洞和36,000个错误。如前所述,Python有Atheris,Java有Jazzer。Ruby社区也值得拥有一个高质量、现代的模糊测试工具。

这并不是说以前没有构建过Ruby模糊测试工具。它们确实存在:kisaten、afl-ruby、FuzzBert,可能还有一些我们遗漏的。然而,所有这些工具似乎要么无人维护,要么难以使用,要么缺少功能,或者兼而有之。为了解决这些挑战,Ruzzy建立在三个原则之上:

  1. 模糊测试纯Ruby代码和Ruby C扩展
  2. 通过提供RubyGems安装过程和简单接口使模糊测试变得容易
  3. 与广泛的libFuzzer生态系统集成

有了这些,让我们来测试一下这个工具。

安装和运行Ruzzy

Ruzzy仓库文档齐全,因此本文将提供安装和运行工具的简化版本。这里的目的是快速概述使用Ruzzy的样子。更多信息,请查看仓库。

首先,Ruzzy需要Linux环境和最新版本的Clang(我们测试回溯到版本14.0.0)。Clang的发布版本可以在其GitHub发布页面找到。如果你在Mac或Windows计算机上,可以使用Docker Desktop on Mac或Windows作为Linux环境。然后,你可以使用Ruzzy的Docker开发环境来运行工具。解决了这些,让我们开始吧。

运行以下命令从RubyGems安装Ruzzy:

1
2
3
4
5
6
MAKE="make --environment-overrides V=1" \
CC="/path/to/clang" \
CXX="/path/to/clang++" \
LDSHARED="/path/to/clang -shared" \
LDSHAREDXX="/path/to/clang++ -shared" \
    gem install ruzzy

这些环境变量确保工具正确编译和安装。它们将在本文后面详细探讨。确保更新/path/to部分指向你的clang安装。

模糊测试Ruby C扩展

为了便于测试工具,Ruzzy包含一个带有堆使用后释放错误的“虚拟”C扩展。本节将演示使用Ruzzy模糊测试这个易受攻击的C扩展。

首先,我们需要配置Ruzzy所需的sanitizer选项:

1
export ASAN_OPTIONS="allocator_may_return_null=1:detect_leaks=0:use_sigaltstack=0"

接下来,开始模糊测试:

1
2
LD_PRELOAD=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
    ruby -e 'require "ruzzy"; Ruzzy.dummy'

LD_PRELOAD是必需的,原因与Atheris需要它相同。也就是说,它使用一个特殊的共享对象,提供对libFuzzer的sanitizers的访问。现在Ruzzy正在模糊测试,它应该很快产生类似以下的崩溃:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 2527961537
...
==45==ERROR: AddressSanitizer: heap-use-after-free on address 0x50c0009bab80 at pc 0xffff99ea1b44 bp 0xffffce8a67d0 sp 0xffffce8a67c8
...
SUMMARY: AddressSanitizer: heap-use-after-free /var/lib/gems/3.1.0/gems/ruzzy-0.7.0/ext/dummy/dummy.c:18:24 in _c_dummy_test_one_input
...
==45==ABORTING
MS: 4 EraseBytes-CopyPart-CopyPart-ChangeBit-; base unit: 410e5346bca8ee150ffd507311dd85789f2e171e
0x48,0x49,
HI
artifact_prefix='./'; Test unit written to ./crash-253420c1158bc6382093d409ce2e9cff5806e980
Base64: SEk=

模糊测试纯Ruby代码

模糊测试纯Ruby代码需要两个Ruby脚本:一个跟踪器脚本和一个模糊测试harness。跟踪器脚本是由于Ruby解释器的实现细节而必需的。每个跟踪器脚本看起来几乎相同。唯一的区别是你正在跟踪的Ruby脚本的名称。

首先,跟踪器脚本。我们称它为test_tracer.rb

1
2
3
require 'ruzzy'

Ruzzy.trace('test_harness.rb')

接下来,模糊测试harness。模糊测试harness包装一个模糊测试目标并将其传递给模糊测试引擎。在这种情况下,我们有一个简单的模糊测试目标,当接收到输入“FUZZ”时崩溃。这是一个人为的例子,但它演示了Ruzzy发现最大化代码覆盖并产生崩溃的输入的能力。我们称这个harness为test_harness.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
require 'ruzzy'

def fuzzing_target(input)
  if input.length == 4
    if input[0] == 'F'
      if input[1] == 'U'
        if input[2] == 'Z'
          if input[3] == 'Z'
            raise
          end
        end
      end
    end
  end
end

test_one_input = lambda do |data|
  fuzzing_target(data) # 你的模糊测试目标在这里
  return 0
end

Ruzzy.fuzz(test_one_input)

你可以使用以下命令启动模糊测试过程:

1
2
LD_PRELOAD=$(ruby -e 'require "ruzzy"; print Ruzzy::ASAN_PATH') \
    ruby test_tracer.rb

这应该很快产生类似以下的崩溃:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 2311041000
...
/app/ruzzy/bin/test_harness.rb:12:in `block in ': unhandled exception
    from /var/lib/gems/3.1.0/gems/ruzzy-0.7.0/lib/ruzzy.rb:15:in `c_fuzz'
    from /var/lib/gems/3.1.0/gems/ruzzy-0.7.0/lib/ruzzy.rb:15:in `fuzz'
    from /app/ruzzy/bin/test_harness.rb:35:in `'
    from bin/test_tracer.rb:7:in `require_relative'
    from bin/test_tracer.rb:7:in `'
...
SUMMARY: libFuzzer: fuzz target exited
MS: 1 CopyPart-; base unit: 24b4b428cf94c21616893d6f94b30398a49d27cc
0x46,0x55,0x5a,0x5a,
FUZZ
artifact_prefix='./'; Test unit written to ./crash-aea2e3923af219a8956f626558ef32f30a914ebc
Base64: RlVaWg==

Ruzzy使用libFuzzer的覆盖引导检测来发现产生崩溃的输入(“FUZZ”)。这是Ruzzy的关键贡献之一:对纯Ruby代码的覆盖引导支持。我们将在下一节讨论覆盖支持等。

有趣的实现细节

你不需要理解本节来使用Ruzzy,但模糊测试通常更像艺术而非科学,因此我们想分享一些细节来帮助揭开这种黑暗艺术的神秘面纱。我们当然从描述Atheris和Jazzer的博客文章中学到了很多,所以我们想回馈社区。当然,创建这样的工具涉及许多有趣的细节,但我们将专注于三个:创建Ruby模糊测试harness、使用libFuzzer编译Ruby C扩展,以及为纯Ruby代码添加覆盖支持。

创建Ruby模糊测试harness

开始模糊测试活动时,首先需要的是一个模糊测试harness。Trail of Bits测试手册对模糊测试harness的定义如下:

Harness处理给定目标的测试设置。Harness包装软件并初始化它,使其准备好执行测试用例。Harness将目标集成到测试环境中。

当模糊测试Ruby代码时,自然我们也想用Ruby编写我们的模糊测试harness。这符合本文开头的目标2:使模糊测试Ruby简单易用。然而,当我们考虑libFuzzer是用C/C++编写时,问题就出现了。当使用libFuzzer作为库时,我们需要将一个C函数指针传递给LLVMFuzzerRunDriver以启动模糊测试过程。我们如何将任意Ruby代码传递给C/C++库?

使用像Ruby-FFI这样的外部函数接口(FFI)是一种可能性。然而,FFI通常用于相反的方向:从Ruby调用C/C++代码。Ruby C扩展似乎是另一种可能性,但我们仍然需要找到一种方法将任意Ruby代码传递给C扩展。在深入研究了Ruby C扩展API之后,我们发现了rb_proc_call函数。这个函数允许我们使用Ruby C扩展来桥接Ruby代码和libFuzzer C/C++实现。

在Ruby中,Proc是“代码块的封装,可以存储在局部变量中,传递给方法或另一个Proc,并且可以被调用。Proc是Ruby中的一个基本概念,也是其函数式编程特性的核心。”完美,这正是我们需要的。在Ruby中,所有lambda函数也是Procs,所以我们可以编写如下的模糊测试harness:

1
2
3
4
5
6
7
8
9
require 'json'
require 'ruzzy'

json_target = lambda do |data|
  JSON.parse(data)
  return 0
end

Ruzzy.fuzz(json_target)

在这个例子中,json_target lambda函数被传递给Ruzzy.fuzz。在幕后,Ruzzy使用两种语言特性来桥接Ruby代码和C接口:Ruby Procs和C函数指针。首先,Ruzzy使用函数指针调用LLVMFuzzerRunDriver。然后,每次调用该函数指针时,它调用rb_proc_call来执行Ruby目标。这允许C/C++模糊测试引擎重复调用带有模糊数据的Ruby目标。考虑上面的例子,由于所有lambda函数都是Procs,这实现了从C/C++库调用任意Ruby代码的目标。

与所有好的高级概述一样,这是对Ruzzy工作原理的过度简化。你可以在cruzzy.c中查看确切的实现。

使用libFuzzer编译Ruby C扩展

在我们继续之前,重要的是要理解我们正在考虑两种Ruby C扩展:钩入libFuzzer模糊测试引擎的Ruzzy C扩展和成为我们模糊测试目标的Ruby C扩展。前一节讨论了Ruzzy C扩展实现。本节讨论Ruby C扩展目标。这些是第三方库,使用我们想要模糊测试的Ruby C扩展。

要模糊测试Ruby C扩展,我们需要一种方法使用libFuzzer及其相关的sanitizers编译扩展。编译C/C++代码进行模糊测试需要特殊的编译时标志,因此我们需要一种方法将这些标志注入C扩展编译过程。动态添加这些标志很重要,因为我们希望安装和模糊测试Ruby gems,而无需修改底层代码。

mkmf或MakeMakefile模块是编译Ruby C扩展的主要接口。gem安装过程调用一个gem特定的Ruby脚本,通常名为extconf.rb,它调用mkmf模块。过程大致如下:

1
gem install -> extconf.rb -> mkmf -> Makefile -> gcc/clang/CC -> extension.so

不幸的是,默认情况下mkmf不尊重常见的C/C++编译环境变量,如CCCXXCFLAGS。然而,我们可以通过设置以下环境变量来强制此行为:MAKE="make --environment-overrides"。这告诉make环境变量覆盖Makefile变量。有了这个,我们可以使用以下命令安装包含C扩展的Ruby gems,并带有适当的模糊测试标志:

1
2
3
4
5
6
7
8
MAKE="make --environment-overrides V=1" \
CC="/path/to/clang" \
CXX="/path/to/clang++" \
LDSHARED="/path/to/clang -shared" \
LDSHAREDXX="/path/to/clang++ -shared" \
CFLAGS="-fsanitize=address,fuzzer-no-link -fno-omit-frame-pointer -fno-common -fPIC -g" \
CXXFLAGS="-fsanitize=address,fuzzer-no-link -fno-omit-frame-pointer -fno-common -fPIC -g" \
    gem install msgpack

我们正在安装的gem是msgpack,一个包含C扩展组件的gem示例。由于它反序列化二进制数据,它是一个很好的模糊测试目标。从这里,如果我们想模糊测试msgpack,我们将创建一个msgpack模糊测试harness并启动模糊测试过程。

如果你想找到更多模糊测试目标,在GitHub上搜索extconf.rb文件是我们发现好的C扩展候选的最佳方法之一。

为纯Ruby代码添加覆盖支持

如果不是Ruby C扩展,我们想模糊测试纯Ruby代码怎么办?也就是说,不包含C扩展组件的Ruby项目。如果通过冗长的、非官方支持的环境变量修改安装时功能是一个hacky解决方案,那么接下来的内容不适合胆小的人。但是,嘿,一个有效的解决方案加上一点艺术自由总比没有解决方案好。

首先,我们需要覆盖覆盖支持的动机。模糊测试器通过分析覆盖信息获得一些“智能”。这很像单元和集成测试提供的代码覆盖信息。在模糊测试时,大多数模糊测试器优先考虑解锁新代码分支的输入。这增加了它们发现崩溃和错误的可能性。当模糊测试Ruby C扩展时,Ruzzy可以将C代码的覆盖检测推给Clang。对于纯Ruby代码,我们没有这样的奢侈。

在实现Ruzzy时,我们发现了一个极其有用的功能:Ruby覆盖模块。问题是它不能轻易被C扩展实时调用。如果你记得,Ruzzy使用自己的C扩展将模糊测试harness代码传递给LLVMFuzzerRunDriver。为了实现我们的纯Ruby覆盖“智能”,我们需要在模糊测试引擎执行时实时将Ruby覆盖信息传递给libFuzzer。覆盖模块很好,如果你有一个已知的执行开始和停止点,但如果你需要持续收集覆盖信息并将其传递给libFuzzer,则不行。然而,我们知道覆盖模块必须以某种方式实现,因此我们深入研究了Ruby解释器的C实现以了解更多。

进入Ruby事件钩子。TracePoint模块是用于监听某些类型事件(如调用函数、从例程返回、执行一行代码等)的官方Ruby API。当这些事件触发时,你可以执行一个回调函数来处理事件。所以,这听起来很棒,正是我们需要的。当我们试图跟踪覆盖信息时,我们真正想做的是监听分支事件。这就是覆盖模块正在做的事情,所以我们知道它一定在底层某处存在。

幸运的是,公共Ruby C API通过rb_add_event_hook2函数提供对此事件钩子功能的访问。这个函数接受一个要钩子的事件列表和一个在这些事件之一触发时执行的回调函数。通过在源代码中挖掘一点,我们发现可能的事件列表看起来与TracePoint模块中的列表非常相似:

1
2
3
4
5
 37    #define RUBY_EVENT_NONE      0x0000 /**< No events. */
 38    #define RUBY_EVENT_LINE      0x0001 /**< Encountered a new line. */
 39    #define RUBY_EVENT_CLASS     0x0002 /**< Encountered a new class. */
 40    #define RUBY_EVENT_END       0x0004 /**< Encountered an end of a class clause. */
       ...

Ruby事件钩子类型

如果你继续挖掘,你会注意到明显缺少一种类型的事件:覆盖事件。但为什么?覆盖模块似乎正在处理这些事件。如果你继续挖掘,你会发现实际上有覆盖事件,这就是覆盖模块的工作方式,但你无法访问它们。它们被定义为Ruby C API的私有、内部专用部分:

1
2
3
 2182    /* #define RUBY_EVENT_RESERVED_FOR_INTERNAL_USE 0x030000 */ /* from vm_core.h */
 2183    #define RUBY_EVENT_COVERAGE_LINE                0x010000
 2184    #define RUBY_EVENT_COVERAGE_BRANCH              0x020000

私有覆盖事件钩子类型

这是坏消息。好消息是,我们可以自己定义RUBY_EVENT_COVERAGE_BRANCH事件钩子,并在我们的代码中将其设置为正确的常量值,rb_add_event_hook2仍然会尊重它。所以我们可以使用Ruby的内置覆盖跟踪!我们可以实时将这些数据输入libFuzzer,它将相应地模糊测试。讨论如何将这些数据输入libFuzzer超出了本文的范围,但如果你想了解更多,我们使用SanitizerCoverage的内联8位计数器、PC表和数据流跟踪。

还有一件事。

在我们的测试期间,即使我们添加了正确的事件钩子,我们仍然没有成功钩子覆盖事件。覆盖模块一定在做一些我们没有看到的事情。如果我们调用Coverage.start(branches: true),根据覆盖文档,那么事情按预期工作。这里的细节涉及在Ruby解释器源代码中进行大量侦查,所以我们将长话短说。据我们所知,似乎调用Coverage.start,这有效地调用Coverage.setup,初始化Ruby解释器中的一些全局状态,允许钩子覆盖事件。此初始化功能也是私有、内部专用API的一部分。我们能想到的最简单的解决方案

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计