模糊测试之书-八-

模糊测试之书(八)

原文:exploringjs.com/ts/book/index.html

译者:飞龙

协议:CC BY-NC-SA 4.0

基于语法的灰盒模糊测试

原文:www.fuzzingbook.org/html/GreyboxGrammarFuzzer.html

在本章中,我们介绍了对我们句法模糊测试技术的扩展,所有这些扩展都利用了 现有输入的 句法部分。

  1. 我们展示了如何在模糊测试期间利用输入片段的 字典。想法是将这样的字典集成到 变异器 中,然后注入这些片段(通常是关键字和其他重要项)到种群中。

  2. 我们展示了如何将 解析 和 模糊测试 与语法结合。这允许在保持句法正确性的同时 变异 现有输入,并在生成新输入的同时 重用 现有输入的片段。正如本章所展示的,基于语言的解析和生成组合在实践中取得了高度成功:LangFuzz 模糊器为 JavaScript 解释器找到了超过 2,600 个漏洞。

  3. 在前面的章节中,我们以 黑盒 方式使用语法——也就是说,我们使用它们来生成输入,而不考虑正在测试的程序。在本章中,我们介绍了基于变异的 语法灰盒模糊测试:利用 正在测试的程序反馈 来引导测试生成向特定目标的技术。正如 词汇灰盒模糊测试 一样,这种反馈主要是 覆盖率,允许我们引导基于语法的测试向未覆盖的代码部分。这部分灵感来自 AFLSmart 模糊器,它结合了解析和变异模糊测试。

from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('hSGzcjUj7Vs') 

先决条件

  • 我们基于 关于灰盒模糊测试(无语法)的章节 的几个概念。

  • 如标题所示,你应该知道如何使用语法进行模糊测试 语法章节。

概述

要 使用本章提供的代码,请编写

>>> from fuzzingbook.GreyboxGrammarFuzzer import <identifier> 

然后利用以下功能。

本章介绍了受 LangFuzzAFLSmart 模糊器启发的基于语言的灰盒模糊测试的高级方法。

使用字典进行模糊测试

与随机变异字符串不同,DictMutator 类允许从字典中插入标记,从而提高模糊测试器的性能。字典以字符串列表的形式提供,从中随机选择元素并插入,除了给定的变异,如删除或插入单个字节。

>>> dict_mutator = DictMutator(["<a>", "</a>", "<a/>", "='a'"])
>>> seeds = ["<html><head><title>Hello</title></head><body>World<br/></body></html>"]
>>> for i in range(10):
>>>     print(dict_mutator.mutate(seeds[0]))
<html><head><title>Hello</title></head><body>World<br/>>/body></html>
<html><head><title>Hello</title></head><body>World<br/></body></ht7ml>
<html><head><title>Hello</title></hgad><body>World<br/></body></html>
<html><head><title>Hello</title></head><body>World<br/<a/>></body></html>
<html><head><title>Hello</title></head><body>World<br+></body></html>
<html><head><title>Hello</title></qhead><body>World<br/></body></html>
<html><head><title>Hello</title></head><body>World<br='a'/></body></html>
<html><head><title>Hello</title></head><body>Wormd<br/></body></html>
<html><head><title>Hello</title></head><body>Wyorld<br/></body></html>
<html><head><title>Hello<</a>/title></head><body>World<br/></body></html> 

这个 DictMutator 可以作为 GreyboxFuzzer 的参数使用:

>>> runner = FunctionCoverageRunner(my_parser)
>>> dict_fuzzer = GreyboxFuzzer(seeds, dict_mutator, PowerSchedule())
>>> dict_fuzzer_outcome = dict_fuzzer.runs(runner, trials=5) 

DictMutator <a xlink:href="#" xlink:title="class DictMutator:

使用字典中的关键字变换字符串">字典变换器 <a xlink:href="#" xlink:title="init(self, dictionary: List[str]) -> None:

构造函数。dictionary 是要使用的关键字列表。">init() <a xlink:href="#" xlink:title="insert_from_dictionary(self, s: str) -> str:

返回插入字典中关键字后的字符串 insert_from_dictionary() 变换器 <a xlink:href="GreyboxFuzzer.html" xlink:title="class Mutator:

变换字符串">变换器 <a xlink:href="GreyboxFuzzer.html" xlink:title="class Mutator:

构造函数">init() DictMutator->Mutator 图例 图例 •  public_method() •  private_method() •  overloaded_method() 将鼠标悬停在名称上以查看文档

使用输入片段进行模糊测试

LangFuzzer 类引入了一个 语言感知 模糊器,可以重新组合现有输入中的片段——灵感来源于高效的 LangFuzz 模糊器。其核心是一个 FragmentMutator 类,该类将一个 解析器 作为参数:

>>> parser = EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS)
>>> mutator = FragmentMutator(parser) 

模糊器本身使用种子列表、上述 FragmentMutator 和一个功率计划进行初始化:

>>> seeds = ["<html><head><title>Hello</title></head><body>World<br/></body></html>"]
>>> schedule = PowerSchedule()
>>> lang_fuzzer = LangFuzzer(seeds, mutator, schedule)
>>> for i in range(10):
>>>     print(lang_fuzzer.fuzz())
<html><head><title>Hello</title></head><body>World<br/></body></html>
<html><head><title>Hello</title></head>World<br/></body></html>
<html>World<body>World<br/></body></html>
<html><title>Hello</title></head><title>World<br/></body></html>
<html><head><title><head>World</head></title></head>World<br/></body></html>
<html><body>World<br/></body><body>World<br/></body></html>

Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 

<html><body>WorldHello</body>

Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 

<html><head><title>Hello</title></head><body><head><head>World</head></title></head><body>World<br/></body><br/></body></html>
<html><head><title></title></head><body>World<br/></body></html>
<html><head><title>Hello</title></head><body><head><title><head>World</head></title></head><body>World<br/></body><br/><br/></body></html> 

LangFuzzer <a xlink:href="#" xlink:title="class LangFuzzer:

黑盒模糊器正在变异输入片段。大致基于 LangFuzz。">LangFuzzer <a xlink:href="#" xlink:title="create_candidate(self) -> GreyboxFuzzer.Seed:

返回由模糊种群中的种子生成的输入">create_candidate() AdvancedMutationFuzzer <a xlink:href="GreyboxFuzzer.html" xlink:title="class AdvancedMutationFuzzer:

基于变异的模糊的基础类。">AdvancedMutationFuzzer <a xlink:href="GreyboxFuzzer.html" xlink:title="init(self, seeds: List[str], mutator: GreyboxFuzzer.Mutator, schedule: GreyboxFuzzer.PowerSchedule) -> None:

构造函数。

seeds - 要变异的(输入)字符串列表。

mutator - 要应用的变异器。

schedule - 要应用的功率计划。">init() <a xlink:href="GreyboxFuzzer.html" xlink:title="fuzz(self) -> str:

返回每个种子一次,然后生成新的输入">fuzz() LangFuzzer->AdvancedMutationFuzzer Fuzzer <a xlink:href="Fuzzer.html" xlink:title="class Fuzzer:

模糊器的基类">Fuzzer <a xlink:href="Fuzzer.html" xlink:title="init(self) -> None:

构造函数">init() <a xlink:href="Fuzzer.html" xlink:title="fuzz(self) -> str:

返回模糊输入">fuzz() <a xlink:href="Fuzzer.html" xlink:title="run(self, runner: Fuzzer.Runner = <Fuzzer.Runner object>) -> Tuple[subprocess.CompletedProcess, str]:

使用模糊输入运行runner">run() <a xlink:href="Fuzzer.html" xlink:title="runs(self, runner: Fuzzer.Runner = <Fuzzer.PrintRunner object>, trials: int = 10) -> List[Tuple[subprocess.CompletedProcess, str]]:

使用模糊输入,运行runner,共trials次">runs() AdvancedMutationFuzzer->Fuzzer 片段突变器 <a xlink:href="#" xlink:title="class FragmentMutator:

使用来自池的输入片段突变输入">片段突变器 <a xlink:href="#" xlink:title="init(self, parser):

初始化突变器">init() <a xlink:href="#" xlink:title="add_to_fragment_pool(self, seed: SeedWithStructure) -> None:

将种子的所有片段添加到片段池中">add_to_fragment_pool() <a xlink:href="#" xlink:title="add_fragment(self, fragment: DerivationTree) -> None:

递归地将片段添加到片段池中">add_fragment() <a xlink:href="#" xlink:title="count_nodes(self, fragment: DerivationTree) -> int:

返回片段中的节点数">count_nodes() <a xlink:href="#" xlink:title="delete_fragment(self, seed: SeedWithStructure) -> SeedWithStructure:

删除一个随机片段">delete_fragment() <a xlink:href="#" xlink:title="is_excluded(self, symbol: str) -> bool:

如果一个片段以特定内容开始,则返回 true

符号及其所有后代可以被排除">is_excluded() <a xlink:href="#" xlink:title="mutate(self, seed: SeedWithStructure) -> SeedWithStructure:

实现结构感知的变异。缓存种子。">mutate() <a xlink:href="#" xlink:title="recursive_delete(self, fragment: DerivationTree) -> DerivationTree:

递归查找要删除的片段">recursive_delete() <a xlink:href="#" xlink:title="recursive_swap(self, fragment: DerivationTree) -> DerivationTree:

递归查找要交换的片段。">recursive_swap() <a xlink:href="#" xlink:title="swap_fragment(self, seed: SeedWithStructure) -> SeedWithStructure:

用具有相同符号的另一个片段替换随机片段">swap_fragment() Mutator <a xlink:href="GreyboxFuzzer.html" xlink:title="class Mutator:

变异字符串">Mutator <a xlink:href="GreyboxFuzzer.html" xlink:title="init(self) -> None:

Constructor">init() FragmentMutator->Mutator 图例 图例 •  公共方法() •  私有方法() •  重载方法() 将鼠标悬停在名称上以查看文档

基于输入区域的模糊测试

GreyboxGrammarFuzzer 类使用两个突变体:

  • 一个 树突变体(一个 RegionMutator 对象),它可以解析现有字符串以识别该字符串中的 区域 以进行交换或删除。

  • 一个 字节突变体 用于应用位和字符级别的突变。

>>> tree_mutator = RegionMutator(parser)
>>> byte_mutator = Mutator() 

GreyboxGrammarFuzzer 类的 调度 可以是一个常规的 PowerSchedule 对象。然而,AFLSmartSchedule 提供了一个更复杂的调度,它将更多 能量 分配给具有更高有效性的种子。

>>> schedule = AFLSmartSchedule(parser) 

GreyboxGrammarFuzzer 构造函数接受一组种子以及两个突变体和调度:

>>> aflsmart_fuzzer = GreyboxGrammarFuzzer(seeds, byte_mutator, tree_mutator, schedule) 

由于它依赖于代码覆盖率,它通常与 FunctionCoverageRunner 结合使用:

>>> runner = FunctionCoverageRunner(my_parser)
>>> aflsmart_outcome = aflsmart_fuzzer.runs(runner, trials=5) 

GreyboxGrammarFuzzer <a xlink:href="#" xlink:title="class GreyboxGrammarFuzzer:

使用语法的灰盒模糊测试器。">GreyboxGrammarFuzzer <a xlink:href="#" xlink:title="init(self, seeds: List[str], byte_mutator: GreyboxFuzzer.Mutator, tree_mutator: FragmentMutator, schedule: GreyboxFuzzer.PowerSchedule) -> None:

构造函数。

seeds - 要变异的输入集合。

byte_mutator - 字节级别的变异器。

tree_mutator = 树级别的变异器。

schedule - 功率调度。">init() <a xlink:href="#" xlink:title="create_candidate(self) -> str:

返回由结构变异生成的输入。

种子在种群中的变异。">create_candidate() GreyboxFuzzer <a xlink:href="GreyboxFuzzer.html" xlink:title="class GreyboxFuzzer:

覆盖率引导的变异模糊测试。">GreyboxFuzzer <a xlink:href="GreyboxFuzzer.html" xlink:title="run(self, runner: MutationFuzzer.FunctionCoverageRunner) -> Tuple[Any, str]:

在跟踪覆盖率的同时运行函数(inp)。">

如果达到新的覆盖率,

将输入添加到种群并添加其覆盖率到种群覆盖率。">run() GreyboxGrammarFuzzer->GreyboxFuzzer AdvancedMutationFuzzer <a xlink:href="GreyboxFuzzer.html" xlink:title="class AdvancedMutationFuzzer:

基于变异的模糊测试的基础类。">AdvancedMutationFuzzer <a xlink:href="GreyboxFuzzer.html" xlink:title="init(self, seeds: List[str], mutator: GreyboxFuzzer.Mutator, schedule: GreyboxFuzzer.PowerSchedule) -> None:

构造函数。

seeds - 要变异的(输入)字符串列表。

mutator - 要应用的变异器。

schedule - 要应用的动力计划。">init() <a xlink:href="GreyboxFuzzer.html" xlink:title="fuzz(self) -> str:

返回每个种子一次,然后生成新的输入">fuzz() GreyboxFuzzer->AdvancedMutationFuzzer Fuzzer <a xlink:href="Fuzzer.html" xlink:title="class Fuzzer:

模糊器的基类。">Fuzzer <a xlink:href="Fuzzer.html" xlink:title="init(self) -> None:

构造函数">init() <a xlink:href="Fuzzer.html" xlink:title="fuzz(self) -> str:

返回模糊输入">fuzz() <a xlink:href="Fuzzer.html" xlink:title="run(self, runner: Fuzzer.Runner = <Fuzzer.Runner object>) -> Tuple[subprocess.CompletedProcess, str]:

使用模糊输入运行runner">run() <a xlink:href="Fuzzer.html" xlink:title="runs(self, runner: Fuzzer.Runner = <Fuzzer.PrintRunner object>, trials: int = 10) -> List[Tuple[subprocess.CompletedProcess, str]]:

使用模糊输入运行runnertrials次">runs() AdvancedMutationFuzzer->Fuzzer AFLSmartSchedule <a xlink:href="#" xlink:title="class AFLSmartSchedule:

定义如何将模糊测试时间分配给种群。">AFLSmartSchedule <a xlink:href="#" xlink:title="init(self, parser: Parser.EarleyParser, exponent: float = 1.0):

构造函数">init() <a xlink:href="#" xlink:title="assignEnergy(self, population: Sequence[GreyboxFuzzer.Seed]):

按有效性程度分配指数能量">assignEnergy() <a xlink:href="#" xlink:title="degree_of_validity(self, seed: GreyboxFuzzer.Seed) -> float:

返回可解析的种子比例">degree_of_validity() <a xlink:href="#" xlink:title="parsable(self, seed: GreyboxFuzzer.Seed) -> str:

返回可解析的子串">parsable() PowerSchedule <a xlink:href="GreyboxFuzzer.html" xlink:title="class PowerSchedule:

定义如何将模糊测试时间分配给种群。">PowerSchedule <a xlink:href="GreyboxFuzzer.html" xlink:title="init(self) -> None:

构造函数">init() AFLSmartSchedule->PowerSchedule RegionMutator <a xlink:href="#" xlink:title="class RegionMutator:

使用来自池的输入片段突变输入">RegionMutator <a xlink:href="#" xlink:title="add_to_fragment_pool(self, seed: SeedWithRegions) -> None:

在种子文件中标记片段和区域">add_to_fragment_pool() <a xlink:href="#" xlink:title="delete_fragment(self, seed: SeedWithRegions) -> SeedWithRegions:

删除一个随机区域">delete_fragment() <a xlink:href="#" xlink:title="swap_fragment(self, seed: SeedWithRegions) -> SeedWithRegions:

选择一个随机区域并将其与片段交换

以相同的符号开始">swap_fragment() FragmentMutator <a xlink:href="#" xlink:title="class FragmentMutator:

使用池中的输入片段突变输入">FragmentMutator <a xlink:href="#" xlink:title="init(self, parser):

初始化突变体">init() <a xlink:href="#" xlink:title="add_to_fragment_pool(self, seed: SeedWithStructure) -> None:

将种子片段的所有片段添加到片段池中">add_to_fragment_pool() <a xlink:href="#" xlink:title="add_fragment(self, fragment: DerivationTree) -> None:

递归地将片段添加到片段池中">add_fragment() <a xlink:href="#" xlink:title="count_nodes(self, fragment: DerivationTree) -> int:

返回片段中的节点数">count_nodes() <a xlink:href="#" xlink:title="delete_fragment(self, seed: SeedWithStructure) -> SeedWithStructure:

删除一个随机片段">delete_fragment() <a xlink:href="#" xlink:title="is_excluded(self, symbol: str) -> bool:

如果一个片段以特定的开头返回 true

符号及其所有后代可以被排除">is_excluded() <a xlink:href="#" xlink:title="mutate(self, seed: SeedWithStructure) -> SeedWithStructure:

实现结构感知变异。缓存种子。">mutate() <a xlink:href="#" xlink:title="recursive_delete(self, fragment: DerivationTree) -> DerivationTree:

递归查找要删除的片段">recursive_delete() <a xlink:href="#" xlink:title="recursive_swap(self, fragment: DerivationTree) -> DerivationTree:

递归查找要交换的片段。">recursive_swap() <a xlink:href="#" xlink:title="swap_fragment(self, seed: SeedWithStructure) -> SeedWithStructure:

用具有相同符号的另一个随机片段替换">swap_fragment() RegionMutator->FragmentMutator Mutator <a xlink:href="GreyboxFuzzer.html" xlink:title="class Mutator:

修改字符串">Mutator <a xlink:href="GreyboxFuzzer.html" xlink:title="init(self) -> None:

构造函数">init() FragmentMutator->Mutator 图例 图例 •  public_method() •  private_method() •  overloaded_method() 将鼠标悬停在名称上以查看文档

背景

首先,我们 回忆 一些用于变异模糊器的基本成分。

  • Seed。一个 种子 是一个输入,模糊器通过应用一系列变异来生成新的输入。

  • Mutator。一个 变异器 实现了一组变异操作,这些操作应用于输入产生略微修改后的输入。

  • PowerSchedule。一个 功率计划能量 分配给一个种子。具有更高能量的种子在整个模糊测试活动中被模糊测试的频率更高。

  • AdvancedMutationFuzzer。我们的 变异黑盒模糊器 通过对输入群体中的种子进行变异来生成输入。

  • GreyboxFuzzer。我们的 灰盒模糊器 动态地向种子群体中添加输入,以增加覆盖率。

  • FunctionCoverageRunner。我们的 功能覆盖率运行器 收集给定 Python 函数执行的覆盖率信息。

让我们尝试对这些概念有一个感觉。

import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) 
from [typing](https://docs.python.org/3/library/typing.html) import List, Set, Dict, Sequence, cast 
from Fuzzer import Fuzzer
from GreyboxFuzzer import Mutator, Seed, PowerSchedule
from GreyboxFuzzer import AdvancedMutationFuzzer, GreyboxFuzzer
from MutationFuzzer import FunctionCoverageRunner 

以下命令对输入 "Hello World" 应用了变异。

Mutator().mutate("Hello World") 
'Lello World'

默认功率计划将能量均匀分配到所有种子。让我们检查这是否有效。

我们从三个种子中选择了 10k 次。正如我们在 hits 计数器中看到的那样,每个种子大约有三分之一的概率被选中。

population = [Seed("A"), Seed("B"), Seed("C")]
schedule = PowerSchedule()
hits = {
    "A": 0,
    "B": 0,
    "C": 0
}

for i in range(10000):
    seed = schedule.choose(population)
    hits[seed.data] += 1

hits 
{'A': 3387, 'B': 3255, 'C': 3358}

在解释功能覆盖率运行器之前,让我们以 Python 的 HTML 解析器为例导入...

from [html.parser](https://docs.python.org/3/library/html.parser.html) import HTMLParser 

...并创建一个 包装函数,将每个输入传递给一个新的解析器对象。

def my_parser(inp: str) -> None:
    parser = HTMLParser()
    parser.feed(inp) 

FunctionCoverageRunner 构造函数接受一个 Python function 来执行。run() 函数接受一个输入,将其传递给 Python function,并收集这次执行的覆盖率信息。coverage() 函数返回一个包含元组 (function name, line number) 的列表,表示 Python function 中每个已覆盖的语句。

runner = FunctionCoverageRunner(my_parser)
runner.run("Hello World")
cov = runner.coverage()

list(cov)[:5]  # Print 5 statements covered in HTMLParser 
[('my_parser', 3),
 ('goahead', 163),
 ('updatepos', 47),
 ('goahead', 245),
 ('reset', 100)]

我们的灰盒模糊器需要一个种子种群、突变器和功率计划。让我们从一个“空”的种子语料库开始生成 5000 个模糊输入。

import [time](https://docs.python.org/3/library/time.html)
import [random](https://docs.python.org/3/library/random.html) 
n = 5000
seed_input = " "  # empty seed
runner = FunctionCoverageRunner(my_parser)
fuzzer = GreyboxFuzzer([seed_input], Mutator(), PowerSchedule()) 
start = time.time()
fuzzer.runs(runner, trials=n)
end = time.time() 
"It took the fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n) 
'It took the fuzzer 0.98 seconds to generate and execute 5000 inputs.'

"During this fuzzing campaign, we covered %d statements." % len(runner.coverage()) 
'During this fuzzing campaign, we covered 79 statements.'

使用字典进行模糊测试

为了模糊我们的 HTML 解析器,可能有必要向突变模糊器告知输入中的重要关键词——即重要的 HTML 关键词。一般思路是拥有一个 预定义的有用输入字典,然后在突变输入时插入这些输入。

这个概念在以下图中得到了说明。在突变输入时,我们可能会插入字典中给出的关键词(红色)。

图片

为了实现这个概念,我们将我们的突变器扩展到考虑字典中的关键词。

class DictMutator(Mutator):
  """Mutate strings using keywords from a dictionary"""

    def __init__(self, dictionary: List[str]) -> None:
  """Constructor. `dictionary` is the list of keywords to use."""
        super().__init__()
        self.dictionary = dictionary
        self.mutators.append(self.insert_from_dictionary)

    def insert_from_dictionary(self, s: str) -> str:
  """Returns s with a keyword from the dictionary inserted"""
        pos = random.randint(0, len(s))
        random_keyword = random.choice(self.dictionary)
        return s[:pos] + random_keyword + s[pos:] 

让我们尝试添加一些 HTML 标签和属性,看看使用 DictMutator 的覆盖率是否增加。

runner = FunctionCoverageRunner(my_parser)
dict_mutator = DictMutator(["<a>", "</a>", "<a/>", "='a'"])
dict_fuzzer = GreyboxFuzzer([seed_input], dict_mutator, PowerSchedule())

start = time.time()
dict_fuzzer.runs(runner, trials=n)
end = time.time()

"It took the fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n) 
'It took the fuzzer 2.78 seconds to generate and execute 5000 inputs.'

显然,这需要更长的时间。根据我们的经验,这意味着覆盖了更多的代码:

"During this fuzzing campaign, we covered %d statements." % len(runner.coverage()) 
'During this fuzzing campaign, we covered 108 statements.'

模糊器在覆盖率的比较方面如何?

from Coverage import population_coverage 
import [matplotlib.pyplot](https://matplotlib.org/) as plt 
_, dict_cov = population_coverage(dict_fuzzer.inputs, my_parser)
_, fuzz_cov = population_coverage(fuzzer.inputs, my_parser)
line_dict, = plt.plot(dict_cov, label="With Dictionary")
line_fuzz, = plt.plot(fuzz_cov, label="Without Dictionary")
plt.legend(handles=[line_dict, line_fuzz])
plt.xlim(0, n)
plt.title('Coverage over time')
plt.xlabel('# of inputs')
plt.ylabel('lines covered'); 

图片

总结。 通知模糊器关于重要关键词的信息已经大大有助于快速实现大量覆盖率。

试试看。 打开这一章作为 Jupyter notebook,并将其他与 HTML 相关的关键词添加到字典中,以查看覆盖率的差异是否实际上增加了(在相同的 5k 生成测试输入预算下)。

阅读。 AFL 的作者 Michał Zalewski 写了几篇关于 使用字典制作语法从空气中拉出 JPEG 的优秀博客文章!

使用输入片段进行模糊测试

虽然字典有助于将重要关键词注入种子输入,但它们不允许保持生成输入的结构完整性。相反,我们需要让模糊器意识到 输入结构。我们可以使用 语法 来做到这一点。我们的第一个方法

  1. 解析 种子输入,

  2. 将它们反汇编成输入片段,并

  3. 根据语法的规则重新组装这些片段以生成新的文件。

这种 解析模糊测试 的组合可以非常强大。例如,我们可以 交换 输入中现有的子结构:

图片

我们还可以用新生成的子结构 替换 现有的子结构:

图片

所有这些操作都在 推导树 上进行,这些树可以随时解析成字符串并生成字符串。

解析和重新组合 JavaScript,或如何在四周内赚取 50,000 美元

在“使用代码片段进行模糊测试”[Holler et al, 2012]中,Holler、Herzig 和 Zeller 将这些步骤应用于模糊测试 JavaScript 解释器。他们使用 JavaScript 语法来

  1. 解析(有效的)JavaScript 输入到解析树,

  2. 将它们反汇编成片段(子树),

  3. 重新组合这些片段,使其再次成为有效的 JavaScript 程序,

  4. 将这些程序输入到 JavaScript 解释器中进行执行

与大多数模糊测试场景一样,目标是使 JavaScript 解释器崩溃。以下是一个 LangFuzz 生成的 JavaScript 代码示例(来自[Holler et al, 2012]),它导致 Mozilla JavaScript 解释器崩溃:

var  haystack  =  "foo";
var  re_text  =  "^foo";
haystack  +=  "x";
re_text  +=  "(x)";
var  re  =  new  RegExp(re_text);
re.test(haystack);
RegExp.input  =  Number();
print(RegExp.$1); 

从 JavaScript 解释器的崩溃中,通常可以构建一个利用,不仅会使解释器崩溃,而且会在攻击者的控制下执行代码。因此,这样的崩溃是严重的缺陷,这也是为什么如果你报告它们,你会得到漏洞赏金。

在运行他的LangFuzz工具的前四周内,该论文的第一作者 Christian Holler 获得了超过 50,000 美元的漏洞赏金。到目前为止,LangFuzz 已经在 Mozilla Firefox、Google Chrome 和 Microsoft Edge 的 JavaScript 浏览器中发现了超过 2,600 个漏洞。如果你使用这些浏览器中的任何一个(比如在你的 Android 手机上),解析和模糊测试的组合在使你的浏览会话更安全方面做出了重大贡献。

(请注意,这些是 Holler 和 Zeller,他们是这本书的共同作者。如果你曾经想知道为什么我们会在基于语法的模糊测试上花费几章内容,那是因为我们在它上面有一些很好的经验。)

解析和重新组合 HTML

在这本书中,让我们先专注于 HTML 输入。为了为我们的 Python HTMLParser生成有效的 HTML 输入,我们首先应该定义一个简单的语法。它允许定义带有属性的 HTML 标签。我们的上下文无关语法不要求开标签和闭标签必须匹配。然而,我们将看到这样的上下文相关特性可以在派生的输入片段中保持,因此也在生成的输入中。

import [string](https://docs.python.org/3/library/string.html) 
from Grammars import is_valid_grammar, srange, Grammar 
XML_TOKENS: Set[str] = {"<id>", "<text>"} 
XML_GRAMMAR: Grammar = {
    "<start>": ["<xml-tree>"],
    "<xml-tree>": ["<text>",
                   "<xml-open-tag><xml-tree><xml-close-tag>",
                   "<xml-openclose-tag>",
                   "<xml-tree><xml-tree>"],
    "<xml-open-tag>":      ["<<id>>", "<<id> <xml-attribute>>"],
    "<xml-openclose-tag>": ["<<id>/>", "<<id> <xml-attribute>/>"],
    "<xml-close-tag>":     ["</<id>>"],
    "<xml-attribute>":     ["<id>=<id>", "<xml-attribute> <xml-attribute>"],
    "<id>":                ["<letter>", "<id><letter>"],
    "<text>":              ["<text><letter_space>", "<letter_space>"],
    "<letter>":            srange(string.ascii_letters + string.digits +
                                  "\"" + "'" + "."),
    "<letter_space>":      srange(string.ascii_letters + string.digits +
                                  "\"" + "'" + " " + "\t"),
} 
assert is_valid_grammar(XML_GRAMMAR) 

为了将输入解析成推导树,我们使用 Earley 解析器。

from Parser import EarleyParser, Parser
from GrammarFuzzer import display_tree, DerivationTree 

让我们在一个简单的 HTML 输入上运行解析器,并显示所有可能的解析树。解析树表示根据给定语法的输入结构。

from [IPython.display](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html) import display 
parser = EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS)

for tree in parser.parse("<html>Text</html>"):
    display(display_tree(tree)) 

0 1 0->1 2 1->2 7 1->7 10 1->10 3 < (60) 2->3 4 2->4 6 > (62) 2->6 5 html 4->5 8 7->8 9 Text 8->9 11 </ 10->11 12</

如我们所见,输入从一个开标签开始,包含一些文本,并以一个闭标签结束。非常好。这是一个我们可以工作的结构。

构建片段池

我们现在准备实现我们的第一个输入结构感知变异器。让我们用表示空片段池的字典 fragments 来初始化变异器。它包含语法中每个符号的键(以及空集作为值)。

class FragmentMutator(Mutator):
  """Mutate inputs with input fragments from a pool"""

    def __init__(self, parser: EarleyParser) -> None:
  """Initialize empty fragment pool and add parser"""
        self.parser = parser
        self.fragments: Dict[str, List[DerivationTree]] = \
            {k: [] for k in self.parser.cgrammar}
        super().__init__() 

FragmentMutator 递归地添加片段。一个 片段 是解析树中的一个子树,由当前节点的符号和子节点(即子片段)组成。我们可以排除以标记、终结符或不是语法一部分的符号开始的片段。

from Parser import terminals 
class FragmentMutator(FragmentMutator):
    def add_fragment(self, fragment: DerivationTree) -> None:
  """Recursively adds fragments to the fragment pool"""
        (symbol, children) = fragment
        if not self.is_excluded(symbol):
            self.fragments[symbol].append(fragment)
            assert children is not None
            for subfragment in children:
                self.add_fragment(subfragment)

    def is_excluded(self, symbol: str) -> bool:
  """Returns true if a fragment starting with a specific
 symbol and all its decendents can be excluded"""
        return (symbol not in self.parser.grammar() or
                symbol in self.parser.tokens or
                symbol in terminals(self.parser.grammar())) 

解析可能需要很长时间,尤其是在解析过程中存在太多歧义时。为了保持突变模糊测试的效率,我们将解析时间限制在 200 毫秒。

from Timeout import Timeout 

函数 add_to_fragment_pool() 解析一个种子(不超过 200 毫秒)并将所有片段添加到片段池中。如果 seed 的解析成功,则将属性 seed.has_structure 设置为 True。否则,设置为 False

class SeedWithStructure(Seed):
  """Seeds augmented with structure info"""

    def __init__(self, data: str) -> None:
        super().__init__(data)
        self.has_structure = False
        self.structure: DerivationTree = ("<empty>", []) 
class FragmentMutator(FragmentMutator):
    def add_to_fragment_pool(self, seed: SeedWithStructure) -> None:
  """Adds all fragments of a seed to the fragment pool"""
        try:  # only allow quick parsing of 200ms max
            with Timeout(0.2):
                seed.structure = next(self.parser.parse(seed.data))
                self.add_fragment(seed.structure)
            seed.has_structure = True
        except (SyntaxError, TimeoutError):
            seed.has_structure = False 

让我们看看 FragmentMutator 如何为一个简单的 HTML 种子输入填充片段池。我们用 EarleyParser 初始化变异器,它本身是用我们的 XML_GRAMMAR 初始化的。

from GrammarFuzzer import tree_to_string 
valid_seed = SeedWithStructure(
    "<html><head><title>Hello</title></head><body>World<br/></body></html>")
fragment_mutator = FragmentMutator(EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS))
fragment_mutator.add_to_fragment_pool(valid_seed)

for key in fragment_mutator.fragments:
    print(key)
    for f in fragment_mutator.fragments[key]:
        print("|-%s" % tree_to_string(f)) 
<start>
|-<html><head><title>Hello</title></head><body>World<br/></body></html>
<xml-tree>
|-<html><head><title>Hello</title></head><body>World<br/></body></html>
|-<head><title>Hello</title></head><body>World<br/></body>
|-<head><title>Hello</title></head>
|-<title>Hello</title>
|-Hello
|-<body>World<br/></body>
|-World<br/>
|-World
|-<br/>
<xml-open-tag>
|-<html>
|-<head>
|-<title>
|-<body>
<xml-openclose-tag>
|-<br/>
<xml-close-tag>
|-</title>
|-</head>
|-</body>
|-</html>
<xml-attribute>
<id>
<text>
<letter>
<letter_space>

对于语法中的许多符号,我们已经收集了许多片段。有几个开闭标签和几个以 xml-tree 符号开始的有趣片段。

总结。对于语法中的每个有趣符号,FragmentMutator 都有一组片段。这些片段是通过首先解析要变异的输入提取出来的。

基于片段的变异

我们可以使用片段池中的片段来生成新的输入。每个正在变异的种子都被分解成片段,并进行了记忆化——即只分解一次。

class FragmentMutator(FragmentMutator):
    def __init__(self, parser: EarleyParser) -> None:
  """Initialize mutators"""
        super().__init__(parser)
        self.seen_seeds: List[SeedWithStructure] = []

    def mutate(self, seed: SeedWithStructure) -> SeedWithStructure:
  """Implement structure-aware mutation. Memoize seeds."""
        if seed not in self.seen_seeds:
            self.seen_seeds.append(seed)
            self.add_to_fragment_pool(seed)

        return super().mutate(seed) 

我们的第一个结构变异操作符是 swap_fragments(),它选择种子中的随机片段,并用池中的随机片段替换它。我们确保两个片段都以相同的符号开始。例如,我们可能用片段池中的另一个闭标签替换种子 HTML 中的闭标签。

为了选择一个随机的片段,变异器计算与起始符号关联的根片段下的所有片段(n_count)。

class FragmentMutator(FragmentMutator):
    def count_nodes(self, fragment: DerivationTree) -> int:
  """Returns the number of nodes in the fragment"""
        symbol, children = fragment
        if self.is_excluded(symbol):
            return 0

        assert children is not None
        return 1 + sum(map(self.count_nodes, children)) 

为了交换选定的片段——使用“全局”变量 self.to_swap 识别——种子解析树被递归遍历。

class FragmentMutator(FragmentMutator):
    def recursive_swap(self, fragment: DerivationTree) -> DerivationTree:
  """Recursively finds the fragment to swap."""
        symbol, children = fragment
        if self.is_excluded(symbol):
            return symbol, children

        self.to_swap -= 1
        if self.to_swap == 0: 
            return random.choice(list(self.fragments[symbol]))

        assert children is not None
        return symbol, list(map(self.recursive_swap, children)) 

我们的结构变异器选择介于 2(即排除 start 符号)和总片段数(n_count)之间的随机数,并使用递归交换来生成新的片段。新的片段被序列化为字符串并作为新的种子返回。

class FragmentMutator(FragmentMutator):
    def __init__(self, parser: EarleyParser) -> None:
        super().__init__(parser)
        self.mutators = [self.swap_fragment]

    def swap_fragment(self, seed: SeedWithStructure) -> SeedWithStructure:
  """Substitutes a random fragment with another with the same symbol"""
        if seed.has_structure:
            n_nodes = self.count_nodes(seed.structure)
            self.to_swap = random.randint(2, n_nodes)
            new_structure = self.recursive_swap(seed.structure)

            new_seed = SeedWithStructure(tree_to_string(new_structure))
            new_seed.has_structure = True
            new_seed.structure = new_structure
            return new_seed

        return seed 
valid_seed = SeedWithStructure(
    "<html><head><title>Hello</title></head><body>World<br/></body></html>")
lf_mutator = FragmentMutator(parser)
print(valid_seed)
lf_mutator.mutate(valid_seed) 
<html><head><title>Hello</title></head><body>World<br/></body></html>

<title><head><title>Hello</title></head><body>World<br/></body></html>

如我们所见,一个片段已被另一个片段替换。

我们可以使用类似的递归遍历来 移除 一个随机的片段。

class FragmentMutator(FragmentMutator):
    def recursive_delete(self, fragment: DerivationTree) -> DerivationTree:
  """Recursively finds the fragment to delete"""
        symbol, children = fragment
        if self.is_excluded(symbol):
            return symbol, children

        self.to_delete -= 1
        if self.to_delete == 0:
            return symbol, []

        assert children is not None
        return symbol, list(map(self.recursive_delete, children)) 

我们也应该定义相应的结构删除操作符。

class FragmentMutator(FragmentMutator):
    def __init__(self, parser):
        super().__init__(parser)
        self.mutators.append(self.delete_fragment)

    def delete_fragment(self, seed: SeedWithStructure) -> SeedWithStructure:
  """Delete a random fragment"""
        if seed.has_structure:
            n_nodes = self.count_nodes(seed.structure)
            self.to_delete = random.randint(2, n_nodes)
            new_structure = self.recursive_delete(seed.structure)

            new_seed = SeedWithStructure(tree_to_string(new_structure))
            new_seed.has_structure = True
            new_seed.structure = new_structure
            # do not return an empty new_seed
            if not new_seed.data:
                return seed
            else:
                return new_seed

        return seed 

摘要. 我们现在拥有了结构感知模糊的所有成分。我们的变异器将所有种子分解成片段,然后将这些片段添加到片段池中。我们的变异器在给定的种子中随机交换相同类型的片段。我们的变异器还会删除给定种子中的随机片段。这允许在生成的输入与给定语法之间保持高度的合法性。

尝试一下。尝试添加其他结构变异操作符。一个添加操作符如何知道在给定的种子文件中,从某个特定符号开始的片段可以添加的位置?

基于片段的模糊测试

我们现在可以定义一个结构感知模糊器,这是在 LangFuzz 中首创的。为了实现 LangFuzz,我们修改了我们的黑盒变异模糊器,使其能够堆叠多达四个结构变异。

class LangFuzzer(AdvancedMutationFuzzer):
  """Blackbox fuzzer mutating input fragments. Roughly based on `LangFuzz`."""

    def create_candidate(self) -> Seed:
  """Returns an input generated by fuzzing a seed in the population"""
        candidate = self.schedule.choose(self.population)
        trials = random.randint(1, 4)
        for i in range(trials):
            candidate = self.mutator.mutate(candidate)

        return candidate 

好的,让我们先试一下我们的第一个结构感知模糊器。我们谨慎地设置 n=300。

n = 300
runner = FunctionCoverageRunner(my_parser)
mutator = FragmentMutator(EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS))
schedule = PowerSchedule()

lang_fuzzer = LangFuzzer([valid_seed.data], mutator, schedule)

start = time.time()
lang_fuzzer.runs(runner, trials=n)
end = time.time()

"It took LangFuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n) 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 

'It took LangFuzzer 94.94 seconds to generate and execute 300 inputs.'

我们观察到结构变异非常慢。尽管我们的解析时间预算为 200ms,但情况如此。相比之下,我们的黑盒模糊器本身每秒可以生成大约 10k 个输入!

runner = FunctionCoverageRunner(my_parser)
mutator = Mutator()
schedule = PowerSchedule()

blackFuzzer = AdvancedMutationFuzzer([valid_seed.data], mutator, schedule)

start = time.time()
blackFuzzer.runs(runner, trials=n)
end = time.time()

"It took a blackbox fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n) 
'It took a blackbox fuzzer 0.10 seconds to generate and execute 300 inputs.'

的确,我们的黑盒模糊器几乎在一瞬间就完成了。

尝试一下。我们可以通过延迟解析来处理这种开销。在模糊测试活动的开始阶段,字节级变异器可以高效地取得进展时,延迟解析建议我们只在模糊测试活动后期,当结构变异变得可行时才投入时间。

blackbox_coverage = len(runner.coverage())
"During this fuzzing campaign, the blackbox fuzzer covered %d statements." % blackbox_coverage 
'During this fuzzing campaign, the blackbox fuzzer covered 94 statements.'

让我们打印一些模糊测试活动的统计数据。由于我们以后需要更频繁地打印统计数据,我们应该将其封装成一个函数。为了测量覆盖率,我们导入 population_coverage 函数。它接受一组输入和一个 Python 函数,在函数上执行输入并收集覆盖率信息。具体来说,它返回一个元组(all_coverage, cumulative_coverage),其中all_coverage是所有输入覆盖的语句集合,cumulative_coverage是随着执行输入数量的增加而覆盖的语句数量。我们只对后者感兴趣,以便绘制覆盖率随时间的变化。

from Coverage import population_coverage 
def print_stats(fuzzer, parser: Parser) -> None:
    coverage, _ = population_coverage(fuzzer.inputs, my_parser)

    has_structure = 0
    for seed in fuzzer.inputs:
        # reuse memoized information
        if hasattr(seed, "has_structure"):
            if seed.has_structure: 
                has_structure += 1
        else:
            if isinstance(seed, str):
                seed = Seed(seed)
            try:
                with Timeout(0.2):
                    next(parser.parse(seed.data))
                has_structure += 1
            except (SyntaxError, TimeoutError):
                pass

    print("From the %d generated inputs, %d (%0.2f%%) can be parsed.\n"
          "In total, %d statements are covered." % (
            len(fuzzer.inputs),
            has_structure,
            100 * has_structure / len(fuzzer.inputs),
            len(coverage))) 

对于 LangFuzzer,让我们看看 LangFuzzer 生成的输入中有多少是有效的(即可解析的),以及覆盖了多少条语句。

print_stats(lang_fuzzer, EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS)) 
From the 300 generated inputs, 172 (57.33%) can be parsed.
In total, 93 statements are covered.

仅使用字节级变异(没有语法)的变异模糊器的统计数据是什么?

print_stats(blackFuzzer, EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS)) 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 

From the 300 generated inputs, 34 (11.33%) can be parsed.
In total, 167 statements are covered.

摘要. 我们生成的片段级黑盒模糊器(LangFuzzer)比我们的字节级模糊器生成的有效输入更多,但代码覆盖率却更低。因此,生成不严格遵循提供语法的输入是有价值的。

与 Greybox Fuzzing 集成

在以下内容中,我们将片段级黑盒模糊(LangFuzz 风格)与字节级灰色盒模糊(AFL 风格)相结合。额外的覆盖率反馈可能允许我们更快地提高代码覆盖率。

灰色盒模糊器将所有增加代码覆盖率的生成输入添加到种子种群中。输入在两个阶段生成,最多堆叠四个结构突变和最多 32 个字节级突变。

class GreyboxGrammarFuzzer(GreyboxFuzzer):
  """Greybox fuzzer using grammars."""

    def __init__(self, seeds: List[str],
                 byte_mutator: Mutator, tree_mutator: FragmentMutator,
                 schedule: PowerSchedule) -> None:
  """Constructor.
 `seeds` - set of inputs to mutate.
 `byte_mutator` - a byte-level mutator.
 `tree_mutator` = a tree-level mutator.
 `schedule` - a power schedule.
 """
        super().__init__(seeds, byte_mutator, schedule)
        self.tree_mutator = tree_mutator

    def create_candidate(self) -> str:
  """Returns an input generated by structural mutation 
 of a seed in the population"""
        seed = cast(SeedWithStructure, self.schedule.choose(self.population))

        # Structural mutation
        trials = random.randint(0, 4)
        for i in range(trials):
            seed = self.tree_mutator.mutate(seed)

        # Byte-level mutation
        candidate = seed.data
        if trials == 0 or not seed.has_structure or random.randint(0, 1) == 1:
            dumb_trials = min(len(seed.data), 1 << random.randint(1, 5))
            for i in range(dumb_trials):
                candidate = self.mutator.mutate(candidate)

        return candidate 

让我们使用标准字节级突变器和上面介绍过的基于片段的结构突变器来运行我们的集成模糊器。

runner = FunctionCoverageRunner(my_parser)
byte_mutator = Mutator()
tree_mutator = FragmentMutator(EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS))
schedule = PowerSchedule()

gg_fuzzer = GreyboxGrammarFuzzer([valid_seed.data], byte_mutator, tree_mutator, schedule)

start = time.time()
gg_fuzzer.runs(runner, trials=n)
end = time.time()

"It took the greybox grammar fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n) 
'It took the greybox grammar fuzzer 0.69 seconds to generate and execute 300 inputs.'

print_stats(gg_fuzzer, EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS)) 
From the 300 generated inputs, 2 (0.67%) can be parsed.
In total, 171 statements are covered.

总结。我们的结构灰色盒模糊器

  • 运行速度比基于片段的 LangFuzzer 快,

  • 比基于片段的 LangFuzzer 和普通的黑盒突变模糊器都实现了更多的覆盖率,并且

  • 生成有效的输入比普通的黑盒突变模糊器还要少。

基于输入区域的模糊

在上一节中,我们已经看到,大多数作为种子添加的输入与我们的给定语法相比是无效的。然而,为了应用基于片段的突变器,我们需要成功解析种子。否则,整个基于片段的方法就变得毫无用处。问题随之而来:如何从(无法成功解析的)种子中推导出结构?

为了达到这个目的,我们引入了基于区域的突变的概念,这一概念最初是在AFLSmart结构灰色盒模糊器中探索的[范-全·潘等人, 2018]。AFLSmart 实现了基于字节、基于片段和基于区域的突变,以及基于有效性的功率调度。我们定义了基于区域的突变器,其中区域是输入中可以与语法中的符号相关联的连续字节序列。

基于输入区域的模糊的基本思想在以下图中展示。在解析输入(到推导树)之后,我们可以识别与推导树特定子树相关的区域。然后,这些区域可以被删除或交换。

即使输入不完整或不有效,也可以确定这样的区域,从而实现鲁棒的解析。

确定符号区域

我们如何为给定的输入获得区域?Earley 解析器的chart_parse()函数为字符串生成一个解析表。对于字符串中的每个字母,此表给出潜在的符号以及可能属于同一符号的相邻字母的区域

invalid_seed = Seed("<html><body><i>World</i><br/>>/body></html>")
parser = EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS)
table = parser.chart_parse(invalid_seed.data, parser.start_symbol())
for column in table:
    print(column)
    print("---") 
None chart[0]

---
< chart[1]

---
h chart[2]
<letter>:= h |(1,2)
<id>:= <letter> |(1,2)
---
t chart[3]
<letter>:= t |(2,3)
<id>:= <id> <letter> |(1,3)
---
m chart[4]
<letter>:= m |(3,4)
<id>:= <id> <letter> |(1,4)
---
l chart[5]
<letter>:= l |(4,5)
<id>:= <id> <letter> |(1,5)
---
> chart[6]
<xml-open-tag>:= < <id> > |(0,6)
---
< chart[7]

---
b chart[8]
<letter>:= b |(7,8)
<id>:= <letter> |(7,8)
---
o chart[9]
<letter>:= o |(8,9)
<id>:= <id> <letter> |(7,9)
---
d chart[10]
<letter>:= d |(9,10)
<id>:= <id> <letter> |(7,10)
---
y chart[11]
<letter>:= y |(10,11)
<id>:= <id> <letter> |(7,11)
---
> chart[12]
<xml-open-tag>:= < <id> > |(6,12)
---
< chart[13]

---
i chart[14]
<letter>:= i |(13,14)
<id>:= <letter> |(13,14)
---
> chart[15]
<xml-open-tag>:= < <id> > |(12,15)
---
W chart[16]
<letter_space>:= W |(15,16)
<text>:= <letter_space> |(15,16)
<xml-tree>:= <text> |(15,16)
---
o chart[17]
<letter_space>:= o |(16,17)
<text>:= <text> <letter_space> |(15,17)
<text>:= <letter_space> |(16,17)
<xml-tree>:= <text> |(15,17)
<xml-tree>:= <text> |(16,17)
<xml-tree>:= <xml-tree> <xml-tree> |(15,17)
---
r chart[18]
<letter_space>:= r |(17,18)
<text>:= <text> <letter_space> |(15,18)
<text>:= <text> <letter_space> |(16,18)
<text>:= <letter_space> |(17,18)
<xml-tree>:= <text> |(15,18)
<xml-tree>:= <text> |(16,18)
<xml-tree>:= <text> |(17,18)
<xml-tree>:= <xml-tree> <xml-tree> |(15,18)
<xml-tree>:= <xml-tree> <xml-tree> |(16,18)
---
l chart[19]
<letter_space>:= l |(18,19)
<text>:= <text> <letter_space> |(15,19)
<text>:= <text> <letter_space> |(16,19)
<text>:= <text> <letter_space> |(17,19)
<text>:= <letter_space> |(18,19)
<xml-tree>:= <text> |(15,19)
<xml-tree>:= <text> |(16,19)
<xml-tree>:= <text> |(17,19)
<xml-tree>:= <text> |(18,19)
<xml-tree>:= <xml-tree> <xml-tree> |(15,19)
<xml-tree>:= <xml-tree> <xml-tree> |(16,19)
<xml-tree>:= <xml-tree> <xml-tree> |(17,19)
---
d chart[20]
<letter_space>:= d |(19,20)
<text>:= <text> <letter_space> |(15,20)
<text>:= <text> <letter_space> |(16,20)
<text>:= <text> <letter_space> |(17,20)
<text>:= <text> <letter_space> |(18,20)
<text>:= <letter_space> |(19,20)
<xml-tree>:= <text> |(15,20)
<xml-tree>:= <text> |(16,20)
<xml-tree>:= <text> |(17,20)
<xml-tree>:= <text> |(18,20)
<xml-tree>:= <text> |(19,20)
<xml-tree>:= <xml-tree> <xml-tree> |(15,20)
<xml-tree>:= <xml-tree> <xml-tree> |(16,20)
<xml-tree>:= <xml-tree> <xml-tree> |(17,20)
<xml-tree>:= <xml-tree> <xml-tree> |(18,20)
---
< chart[21]

---
/ chart[22]

---
i chart[23]
<letter>:= i |(22,23)
<id>:= <letter> |(22,23)
---
> chart[24]
<xml-close-tag>:= < / <id> > |(20,24)
<xml-tree>:= <xml-open-tag> <xml-tree> <xml-close-tag> |(12,24)
---
< chart[25]

---
b chart[26]
<letter>:= b |(25,26)
<id>:= <letter> |(25,26)
---
r chart[27]
<letter>:= r |(26,27)
<id>:= <id> <letter> |(25,27)
---
/ chart[28]

---
> chart[29]
<xml-openclose-tag>:= < <id> / > |(24,29)
<xml-tree>:= <xml-openclose-tag> |(24,29)
<xml-tree>:= <xml-tree> <xml-tree> |(12,29)
---
> chart[30]

---
/ chart[31]

---
b chart[32]

---
o chart[33]

---
d chart[34]

---
y chart[35]

---
> chart[36]

---
< chart[37]

---
/ chart[38]

---
h chart[39]

---
t chart[40]

---
m chart[41]

---
l chart[42]

---
> chart[43]

---

本表中与潜在符号相关联的列数对应于可以成功解析的字母数量。换句话说,我们可以使用这个表来计算最长可解析子串。

cols = [col for col in table if col.states]
parsable = invalid_seed.data[:len(cols)-1]

print("'%s'" % invalid_seed)
parsable 
'<html><body><i>World</i><br/>>/body></html>'

'<html><body><i>World</i><br/>'

从这个角度,我们可以计算输入的有效性程度

validity = 100 * len(parsable) / len(invalid_seed.data)

"%0.1f%% of the string can be parsed successfully." % validity 
'67.4% of the string can be parsed successfully.'

总结。与输入片段不同,即使解析器无法生成整个解析树,也可以推导出输入区域。

基于区域的变异

为了模糊无效种子,基于区域的变异器将语法中的符号与种子中的区域(即索引子字符串)相关联。首先尝试从种子中挖掘片段的 add_to_fragment_pool() 方法。如果失败,区域变异器使用 Earley 解析器来推导解析表。对于每一列(即字母),它提取符号和相应的区域。这允许变异器为每个符号存储区域集。

class SeedWithRegions(SeedWithStructure):
  """Seeds augmented with structure info"""

    def __init__(self, data: str) -> None:
        super().__init__(data)
        self.has_regions = False
        self.regions: Dict[str, Set] = {} 
class RegionMutator(FragmentMutator):
    def add_to_fragment_pool(self, seed: SeedWithRegions) -> None:
  """Mark fragments and regions in a seed file"""
        super().add_to_fragment_pool(seed)
        if not seed.has_structure:
            try:
                with Timeout(0.2):
                    seed.regions = {k: set() for k in self.parser.cgrammar}
                    for column in self.parser.chart_parse(seed.data,
                                                          self.parser.start_symbol()):
                        for state in column.states:
                            if (not self.is_excluded(state.name) and
                                    state.e_col.index - state.s_col.index > 1 and
                                    state.finished()):
                                seed.regions[state.name].add((state.s_col.index,
                                                              state.e_col.index))
                seed.has_regions = True
            except TimeoutError:
                seed.has_regions = False
        else:
            seed.has_regions = False 

这就是我们的无效种子中这些区域的外观。一个区域由种子字符串中的起始和结束索引组成。

invalid_seed = SeedWithRegions("<html><body><i>World</i><br/>>/body></html>")
mutator = RegionMutator(parser)
mutator.add_to_fragment_pool(invalid_seed)
for symbol in invalid_seed.regions:
    print(symbol)
    for (s, e) in invalid_seed.regions[symbol]:
        print("|-(%d,%d) : %s" % (s, e, invalid_seed.data[s:e])) 
<start>
<xml-tree>
|-(16,20) : orld
|-(12,24) : <i>World</i>
|-(17,20) : rld
|-(18,20) : ld
|-(16,19) : orl
|-(15,17) : Wo
|-(12,29) : <i>World</i><br/>
|-(17,19) : rl
|-(15,20) : World
|-(24,29) : <br/>
|-(15,19) : Worl
|-(16,18) : or
|-(15,18) : Wor
<xml-open-tag>
|-(6,12) : <body>
|-(12,15) : <i>
|-(0,6) : <html>
<xml-openclose-tag>
|-(24,29) : <br/>
<xml-close-tag>
|-(20,24) : </i>
<xml-attribute>
<id>
<text>
<letter>
<letter_space>

现在我们知道种子中的哪些区域属于哪个符号,我们可以定义基于区域的交换和删除操作符。

class RegionMutator(RegionMutator):
    def swap_fragment(self, seed: SeedWithRegions) -> SeedWithRegions:
  """Chooses a random region and swaps it with a fragment
 that starts with the same symbol"""
        if not seed.has_structure and seed.has_regions:
            regions = [r for r in seed.regions
                       if (len(seed.regions[r]) > 0 and
                           len(self.fragments[r]) > 0)]
            if len(regions) == 0:
                return seed

            key = random.choice(list(regions))
            s, e = random.choice(list(seed.regions[key]))
            swap_structure = random.choice(self.fragments[key])
            swap_string = tree_to_string(swap_structure)
            new_seed = SeedWithRegions(seed.data[:s] + swap_string + seed.data[e:])
            new_seed.has_structure = False
            new_seed.has_regions = False
            return new_seed
        else:
            return super().swap_fragment(seed) 
class RegionMutator(RegionMutator):
    def delete_fragment(self, seed: SeedWithRegions) -> SeedWithRegions:
  """Deletes a random region"""
        if not seed.has_structure and seed.has_regions:
            regions = [r for r in seed.regions
                       if len(seed.regions[r]) > 0]
            if len(regions) == 0:
                return seed

            key = random.choice(list(regions))
            s, e = (0, 0)
            while (e - s < 2):
                s, e = random.choice(list(seed.regions[key]))

            new_seed = SeedWithRegions(seed.data[:s] + seed.data[e:])
            new_seed.has_structure = False
            new_seed.has_regions = False
            return new_seed
        else:
            return super().delete_fragment(seed) 

让我们尝试我们的新区域变异器。我们将一个简单、有效的种子添加到片段池中,并尝试变异无效种子。

simple_seed = SeedWithRegions("<b>Text</b>")
mutator = RegionMutator(parser)
mutator.add_to_fragment_pool(simple_seed)

print(invalid_seed)
mutator.mutate(invalid_seed) 
<html><body><i>World</i><br/>>/body></html>

<html><body>>/body></html>

总结。我们可以使用 Earley 解析器生成解析表,并将输入中的区域分配到语法中的符号。我们的区域变异器可以用来自片段池的以相同符号开始的片段替换这些区域,或者完全删除这些区域。

试试看。实现一个区域池(类似于片段池)和一个 swap_region() 变异器。你可以通过将此章节作为 Jupyter notebook 打开来执行自己的代码。

与 Greybox Fuzzing 集成

让我们通过将其与我们的结构感知灰盒模糊器集成来尝试我们闪亮的新区域变异器。

runner = FunctionCoverageRunner(my_parser)
byte_mutator = Mutator()
tree_mutator = RegionMutator(EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS))
schedule = PowerSchedule()

regionFuzzer = GreyboxGrammarFuzzer([valid_seed.data], byte_mutator, tree_mutator, schedule)

start = time.time()
regionFuzzer.runs(runner, trials = n)
end = time.time()

"""
It took the structural greybox fuzzer with region mutator
%0.2f seconds to generate and execute %d inputs.
""" % (end - start, n) 
'\nIt took the structural greybox fuzzer with region mutator\n6.83 seconds to generate and execute 300 inputs.\n'

我们可以看到,具有基于区域变异器的结构灰盒模糊器比仅基于片段的变异器慢。这是因为基于区域的结构变异适用于所有种子。相比之下,基于片段的变异器仅适用于极少数可解析的种子。否则,仅应用(非常高效的)字节级变异器。

让我们打印出种群中种子的平均有效性度数。

def print_more_stats(fuzzer: GreyboxGrammarFuzzer, parser: EarleyParser):
    print_stats(fuzzer, parser)
    validity = 0.0
    total = 0
    for seed in fuzzer.population:
        if not seed.data: continue
        table = parser.chart_parse(seed.data, parser.start_symbol())
        cols = [col for col in table if col.states]
        parsable = invalid_seed.data[:len(cols)-1]
        validity += len(parsable) / len(seed.data)
        total += 1

    print("On average, %0.1f%% of a seed in the population can be successfully parsed." % (100 * validity / total)) 
print_more_stats(regionFuzzer, parser) 
From the 300 generated inputs, 11 (3.67%) can be parsed.
In total, 168 statements are covered.
On average, 18.2% of a seed in the population can be successfully parsed.

总结。与基于片段的变异相比,基于区域的灰盒模糊器在变异上实现了更高的覆盖率,但生成的有效输入数量较少。更高的覆盖率是通过利用至少一些结构来解释无法成功解析的种子。

关注有效种子

在上一节中,我们有一个问题:低(有效性度数)。为了解决这个问题,基于有效性的功率计划将更多的能量分配给具有更高有效性度的种子。换句话说,模糊器花费更多的时间模糊更有效的种子

import [math](https://docs.python.org/3/library/math.html) 
class AFLSmartSchedule(PowerSchedule):
    def __init__(self, parser: EarleyParser, exponent: float = 1.0):
        self.parser = parser
        self.exponent = exponent

    def parsable(self, seed: Seed) -> str:
  """Returns the substring that is parsable"""
        table = self.parser.chart_parse(seed.data, self.parser.start_symbol())
        cols = [col for col in table if col.states]
        return seed.data[:len(cols)-1]

    def degree_of_validity(self, seed: Seed) -> float:
  """Returns the proportion of a seed that is parsable"""
        if not hasattr(seed, 'validity'):
            seed.validity = (len(self.parsable(seed)) / len(seed.data)
                             if len(seed.data) > 0 else 0)
        return seed.validity

    def assignEnergy(self, population: Sequence[Seed]):
  """Assign exponential energy proportional to degree of validity"""
        for seed in population:
            seed.energy = ((self.degree_of_validity(seed) / math.log(len(seed.data))) ** self.exponent
                           if len(seed.data) > 1 else 0) 

让我们通过传递一个有效种子来玩玩有效性度数...

smart_schedule = AFLSmartSchedule(parser)
print("%11s: %s" % ("Entire seed", simple_seed))
print("%11s: %s" % ("Parsable", smart_schedule.parsable(simple_seed)))

"Degree of validity: %0.2f%%" % (100 * smart_schedule.degree_of_validity(simple_seed)) 
Entire seed: <b>Text</b>
   Parsable: <b>Text</b>

'Degree of validity: 100.00%'

...以及一个无效的种子。

print("%11s: %s" % ("Entire seed", invalid_seed))
print("%11s: %s" % ("Parsable", smart_schedule.parsable(invalid_seed)))

"Degree of validity: %0.2f%%" % (100 * smart_schedule.degree_of_validity(invalid_seed)) 
Entire seed: <html><body><i>World</i><br/>>/body></html>
   Parsable: <html><body><i>World</i><br/>

'Degree of validity: 67.44%'

很好。我们可以计算有效性度数作为可解析字符串的比例。

让我们将基于有效性的功率调度方案插入到结构感知灰盒模糊器中。

runner = FunctionCoverageRunner(my_parser)
byte_mutator = Mutator()
tree_mutator = RegionMutator(EarleyParser(XML_GRAMMAR, tokens=XML_TOKENS))
schedule = AFLSmartSchedule(parser)

aflsmart_fuzzer = GreyboxGrammarFuzzer([valid_seed.data], byte_mutator, 
                                       tree_mutator, schedule)

start = time.time()
aflsmart_fuzzer.runs(runner, trials=n)
end = time.time()

"It took AFLSmart %0.2f seconds to generate and execute %d inputs." % (end - start, n) 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 
Exception ignored in: <bound method IPythonKernel._clean_thread_parent_frames of <ipykernel.ipkernel.IPythonKernel object at 0x103912720>>
Traceback (most recent call last):
  File "/Users/zeller/.local/lib/python3.12/site-packages/ipykernel/ipkernel.py", line 775, in _clean_thread_parent_frames
    def _clean_thread_parent_frames(

  File "Timeout.ipynb", line 43, in timeout_handler
    }

TimeoutError: 

'It took AFLSmart 26.16 seconds to generate and execute 300 inputs.'

print_more_stats(aflsmart_fuzzer, parser) 
From the 300 generated inputs, 9 (3.00%) can be parsed.
In total, 159 statements are covered.
On average, 22.6% of a seed in the population can be successfully parsed.

总结。确实,通过花费更多时间对具有更高有效性的种子进行模糊测试,我们也生成了具有更高有效性的输入。更多的输入在给定的语法中是完全有效的。

阅读更多内容。在原始的 AFLSmart 论文中了解更多关于基于区域的模糊、延迟解析和基于有效性的调度信息:“智能灰盒模糊”由 Pham 及其合作者撰写。下载并改进 AFLSmart:github.com/aflsmart/aflsmart

矿化种子

到现在为止,应该已经很清楚,种子的选择在很大程度上会影响模糊测试的成功。一方面是可变性——我们的种子应该尽可能覆盖尽可能多的不同特征,以增加覆盖率。然而,另一方面是种子诱导错误的可能性——也就是说,如果一个种子之前参与了导致失败,那么这个种子的变异可能再次导致失败。这是因为对过去失败的修复通常能够成功地让具体的失败不再发生,但有时可能无法捕捉到所有可能导致失败的条件。因此,即使原始的失败被修复,原始导致失败的输入周围的错误可能性仍然较高。因此,使用已知之前导致失败的输入作为种子是有益的。

为了使事情更有背景,Holler 的LangFuzz模糊器使用 CVE 报告中的 JavaScript 输入作为种子。这些是在错误已经被修复时发布的导致失败的输入;因此,它们已经不再造成伤害。然而,通过使用这些输入作为种子,LangFuzz 会创建大量所有特征的变异和重组,其中许多会(并且确实)一次又一次地找到错误。

经验教训

  • 字典有助于将重要关键词注入生成的输入中。

  • 基于片段的变异首先将种子分解成片段,然后将这些片段重新组装以生成新的输入。片段是种子解析树中的子树。然而,基于片段的变异要求种子可以成功解析,这可能对于由基于覆盖率的灰盒模糊器发现的种子来说并不成立。

  • 基于区域的变异将输入中的区域标记为属于语法中的某个符号。例如,它可能将子串</a>识别为闭合标签。然后,这些区域可以被删除或替换为属于同一符号的片段或区域。与基于片段的变异不同,基于区域的变异适用于所有种子——即使是那些只能部分解析的种子。然而,生成的输入的有效性程度仍然相当低。

  • 基于有效性的功率调度将更多能量投入到具有更高有效性的种子上。生成的输入也具有更高的有效性。

  • 从先前导致失败的输入的存储库中挖掘种子会导致与过去失败相关的输入片段,从而增加了在附近找到更多失败的可能性。

背景

本章基于以下两个作品:

  • LangFuzz 模糊器 [Holler 等人,2012] 是一个高效的(并且有效的!)基于语法的模糊器,主要用于(主要是)JavaScript。它使用语法来解析种子,并将生成的部分与它们的输入重新组合,迄今为止在 JavaScript 解释器中发现了 2,600 个错误。

  • 智能灰盒模糊(AFLSmart)将基于覆盖率的模糊和基于语法的(结构化)模糊结合起来,如[范全权等人,2018]所述。结果产生的 AFLSMART 工具在广泛使用、经过良好测试的工具和库中发现了 42 个零日漏洞;迄今为止已分配了 17 个 CVE 编号。

最近的工作也将基于语法的模糊和覆盖率结合起来。

  • Superion [王军杰等人,2019] 等同于我们上面提到的“与灰盒模糊结合”的部分——也就是说,LangFuzz 和灰盒模糊的结合,但没有 AFL 风格的字节级变异。Superion 可以提升代码覆盖率(即行覆盖率和函数覆盖率分别为 16.7%和 8.8%),以及超过 AFL 和 jsfunfuzz 的漏洞发现能力。根据作者的说法,他们发现了 30 个新的错误,其中他们发现了 21 个新的漏洞,分配了 16 个 CVE 编号,并获得了 3.2K 美元的漏洞赏金。

  • Nautilus [阿舍尔曼等人,2019] 也结合了基于语法的模糊和覆盖率反馈。它维护所有种子和生成输入的解析树。为了允许 AFL 风格的字节级变异,它“折叠”子树回到字节级表示。这有一个优点,就是不需要重新解析生成的种子;然而,随着时间的推移,Nautilus 退化到无结构意识的灰盒模糊,因为它不会重新解析折叠的子树来重建后续种子(其中大多数解析树已折叠)的输入结构。Nautilus 在 mruby、PHP、ChakraCore 和 Lua 中识别了错误;报告这些错误获得了 2600 美元的奖金,并分配了 6 个 CVE 编号。

  • FormatFuzzer是一个用于高效、高质量生成和解析二进制输入的框架。它接受一个描述二进制输入格式的二进制模板,并生成一个可执行文件,该文件可以生成并解析给定的二进制格式。例如,从 GIF 的二进制模板中,FormatFuzzer 可以生成一个 GIF 生成器——也称为 GIF fuzzer。FormatFuzzer 生成的生成器效率极高,每秒可以生成数千个有效的测试输入。FormatFuzzer 在黑盒设置中运行,但也可以与 AFL 集成,生成旨在实现最大覆盖率的有效输入。

下一步

本章结束了我们对语法模糊技术的讨论。

  • 在下一章中,我们讨论了在发生故障后如何减少导致故障的输入,只保留那些对于重现故障必要的输入部分。

  • 下一部分将从语法模糊转向语义模糊,考虑代码语义进行目标测试生成。

练习

练习 1:大型灰盒模糊器对决

使用我们实现的灰盒技术,并在基准测试中评估它们。哪种技术(以及哪种子技术)有什么影响,为什么?同时考虑 Superion [Junjie Wang et al, 2019] 和 Nautilus [Cornelius Aschermann et al, 2019]的具体方法,甚至可能在这些方法使用的基准测试中。

使用笔记本来练习并查看解决方案。

Creative Commons License本项目的内内容根据Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License授权。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,根据MIT License授权。最后更改:2024-01-21 13:07:50+01:00 • 引用 • 印记

如何引用这篇作品

Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, 和 Christian Holler: "使用语法的灰盒模糊测试". 在 Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, 和 Christian Holler 的 "模糊测试书籍", www.fuzzingbook.org/html/GreyboxGrammarFuzzer.html. 获取时间:2024-01-21 13:07:50+01:00.

@incollection{fuzzingbook2024:GreyboxGrammarFuzzer,
    author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
    booktitle = {The Fuzzing Book},
    title = {Greybox Fuzzing with Grammars},
    year = {2024},
    publisher = {CISPA Helmholtz Center for Information Security},
    howpublished = {\url{https://www.fuzzingbook.org/html/GreyboxGrammarFuzzer.html}},
    note = {Retrieved 2024-01-21 13:07:50+01:00},
    url = {https://www.fuzzingbook.org/html/GreyboxGrammarFuzzer.html},
    urldate = {2024-01-21 13:07:50+01:00}
}

减少导致失败的输入

原文:www.fuzzingbook.org/html/Reducer.html

通过构造,模糊测试器生成的输入可能难以阅读。这会在调试期间引起问题,当人类需要分析失败的确切原因时。在本章中,我们介绍了将导致失败的输入自动减少并简化到最小的技术,以简化调试过程。

from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('JOv1xGVdXAU') 

先决条件

  • 简单的"delta debugging"减少技术没有特定的先决条件。

  • 由于减少通常与模糊测试一起使用,阅读关于基本模糊测试的章节是个好主意。

  • 后续基于语法的技巧需要了解推导树和解析。

概述

要使用本章提供的代码,请编写

>>> from fuzzingbook.Reducer import <identifier> 

然后利用以下功能。

一个减少器接受一个导致失败的输入,并将其减少到仍然可以重现失败的最小值。本章提供了实现此类减少器的Reducer类。

这里有一个简单的例子:一个算术表达式在 Python 解释器中引起错误:

>>> !python -c 'x = 1 + 2 * 3 / 0'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ZeroDivisionError: division by zero 

我们能否将这个输入减少到最小?要使用Reducer,首先需要构建一个Runner,其结果在精确错误发生时为FAIL。因此,我们构建了一个ZeroDivisionRunner,其run()方法在发生ZeroDivisionError时将特别返回FAIL结果。

>>> from Fuzzer import ProgramRunner
>>> import [subprocess](https://docs.python.org/3/library/subprocess.html)
>>> class ZeroDivisionRunner(ProgramRunner):
>>>     """Make outcome 'FAIL' if ZeroDivisionError occurs"""
>>> 
>>>     def run(self, inp: str = "") -> Tuple[subprocess.CompletedProcess, Outcome]:
>>>         process, outcome = super().run(inp)
>>>         if process.stderr.find('ZeroDivisionError') >= 0:
>>>             outcome = 'FAIL'
>>>         return process, outcome 

如果我们将这个表达式输入到ZeroDivisionRunner中,它将产生一个FAIL的结果,正如设计的那样。

>>> python_input = "x = 1 + 2 * 3 / 0"
>>> python_runner = ZeroDivisionRunner("python")
>>> process, outcome = python_runner.run(python_input)
>>> outcome
'FAIL' 

Delta Debugging 是一种简单且稳健的减少算法。我们可以将DeltaDebuggingReducer与这个运行器绑定,并让它确定导致python程序失败的子串:

>>> dd = DeltaDebuggingReducer(python_runner)
>>> dd.reduce(python_input)
'3/0' 

输入被减少到最小:我们得到了除以零的本质。

DeltaDebuggingReducer <a xlink:href="#" xlink:title="class DeltaDebuggingReducer:

使用 delta debugging 减少输入。">DeltaDebuggingReducer <a xlink:href="#" xlink:title="reduce(self, inp: str) -> str:

使用 delta debugging 减少输入inp。返回减少后的输入。">reduce() CachingReducer <a xlink:href="#" xlink:title="class CachingReducer:

一个同时缓存测试结果的减少器。">CachingReducer <a xlink:href="#" xlink:title="reset(self):

将测试计数器重置为零。在子类中扩展。">reset() <a xlink:href="#" xlink:title="test(self, inp):

使用输入inp进行测试。返回结果。

在子类中扩展。">test() DeltaDebuggingReducer->CachingReducer Reducer <a xlink:href="#" xlink:title="class Reducer:

减少器的基础类。">Reducer <a xlink:href="#" xlink:title="init(self, runner: Fuzzer.Runner, log_test: bool = False) -> None:

将减少器附加到给定的runner">init() <a xlink:href="#" xlink:title="reduce(self, inp: str) -> str:

减少输入inp。返回减少后的输入。

在子类中定义。">reduce() <a xlink:href="#" xlink:title="reset(self) -> None:

将测试计数器重置为零。在子类中扩展。">reset() <a xlink:href="#" xlink:title="test(self, inp: str) -> str:

使用输入inp进行测试。返回结果。

在子类中定义。">test() CachingReducer->Reducer GrammarReducer <a xlink:href="#" xlink:title="class GrammarReducer:

使用语法减少输入。">GrammarReducer <a xlink:href="#" xlink:title="init(self, runner: Fuzzer.Runner, parser: Parser.Parser, *, log_test: bool = False, log_reduce: bool = False):

构造函数。

runner是将要使用的运行器。

parser是将要使用的解析器。

log_test - 如果设置,显示测试和结果。

log_reduce - 如果设置,显示减少步骤。">init() <a xlink:href="#" xlink:title="reduce(self, inp):

减少输入inp。返回减少后的输入。

在子类中定义。">reduce() alternate_reductions() parse() reduce_subtree() reduce_tree() <a xlink:href="#" xlink:title="subtrees_with_symbol(self, tree: DerivationTree, symbol: str, depth: int = -1, ignore_root: bool = True) -> List[DerivationTree]:

tree中查找所有根节点为symbol的子树。

如果ignore_root为真,忽略tree的根节点。">subtrees_with_symbol() <a xlink:href="#" xlink:title="symbol_reductions(self, tree: DerivationTree, symbol: str, depth: int = -1):

找到给定符号">symbol_reductions() GrammarReducer->CachingReducer 图例 图例 •  public_method() •  private_method() •  overloaded_method() 将鼠标悬停在名称上以查看文档

为什么缩减?

在这一点上,我们已经看到了许多测试生成技术,它们都以某种形式产生输入以触发故障。如果它们成功了——也就是说,程序实际上失败了——我们必须找出故障发生的原因以及如何修复它。

这里有一个这样的例子。我们有一个名为MysteryRunner的类,它有一个run()方法,根据其代码,它偶尔会失败。但这种情况实际上在什么情况下会发生?我们故意隐藏了确切的条件,以便使其不明显。

import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) 
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import quiz 
from [typing](https://docs.python.org/3/library/typing.html) import Tuple, List, Sequence, Any, Optional 
from ExpectError import ExpectError 
from Fuzzer import RandomFuzzer, Runner, Outcome 
import [re](https://docs.python.org/3/library/re.html) 
class MysteryRunner(Runner):
    def run(self, inp: str) -> Tuple[str, Outcome]:
        x = inp.find(chr(0o17 + 0o31))
        y = inp.find(chr(0o27 + 0o22))
        if x >= 0 and y >= 0 and x < y:
            return (inp, Runner.FAIL)
        else:
            return (inp, Runner.PASS) 

让我们对函数进行模糊测试,直到找到失败的输入。

mystery = MysteryRunner()
random_fuzzer = RandomFuzzer()
while True:
    inp = random_fuzzer.fuzz()
    result, outcome = mystery.run(inp)
    if outcome == mystery.FAIL:
        break 
failing_input = result
failing_input 
' 7:,>((/$$-/->.;.=;(.%!:50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#'

这里的输入中有些东西导致MysteryRunner失败。但究竟是什么?

手动输入缩减

调试过程中的一个重要步骤是缩减——也就是说,识别那些导致故障发生的相关情况,并(如果可能)省略那些不相关的部分。正如 Kernighan 和 Pike [Kernighan et al, 1999]所说:

对于问题的每一个情况,检查它是否与问题发生相关。如果不相关,请将其从问题报告中或相关测试用例中移除。

特别对于输入,他们建议一个分而治之的过程:

通过二分搜索进行操作。丢弃一半的输入并查看输出是否仍然错误;如果不是,返回到上一个状态并丢弃另一半的输入。

这是我们很容易尝试的事情,使用我们最后生成的输入:

failing_input 
' 7:,>((/$$-/->.;.=;(.%!:50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#'

例如,如果我们只输入前半部分,我们可以看到错误是否仍然发生:

half_length = len(failing_input) // 2   # // is integer division
first_half = failing_input[:half_length]
mystery.run(first_half) 
(" 7:,>((/$$-/->.;.=;(.%!:50#7*8=$&&=$9!%6(4=&69':", 'PASS')

不行——只有前半部分是不够的。也许后半部分可以?

second_half = failing_input[half_length:]
mystery.run(second_half) 
('\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#', 'PASS')

这也没有好到哪里去。我们可能仍然可以通过移除更小的块来继续进行——比如说,一个字符接一个字符。如果我们的测试是确定性的并且容易重复,那么很明显,这个过程最终将产生一个简化的输入。但是,这仍然是一个相当低效的过程,尤其是对于长输入。我们需要的是一个 策略,这个策略可以有效地最小化导致失败的输入——一个可以自动化的策略。

Delta Debugging

一种有效减少导致失败的输入的策略是 delta debugging [Zeller et al, 2002]。Delta Debugging 实现了上面列出的 "二分搜索" 策略,但有一个转折:如果两个部分都没有失败(如上所述),它将继续从输入中移除越来越小的块,直到消除单个字符。因此,在移除前半部分之后,我们移除四分之一,然后是二分之一,以此类推。

让我们在我们的例子中说明这一点,看看如果我们移除四分之一会发生什么。

quarter_length = len(failing_input) // 4
input_without_first_quarter = failing_input[quarter_length:]
mystery.run(input_without_first_quarter) 
('50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#',
 'FAIL')

啊!这失败了,并且将我们的失败输入减少了 25%。让我们再移除一个四分之一。

input_without_first_and_second_quarter = failing_input[quarter_length * 2:]
mystery.run(input_without_first_and_second_quarter) 
('\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#', 'PASS')

这并不太令人惊讶,因为我们之前已经有过这样的例子:

second_half 
'\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#'

input_without_first_and_second_quarter 
'\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#'

那么,移除三分之一怎么样?

input_without_first_and_third_quarter = failing_input[quarter_length:
                                                      quarter_length * 2] + failing_input[quarter_length * 3:]
mystery.run(input_without_first_and_third_quarter) 
("50#7*8=$&&=$9!%6(4=&69':<7+1<2!4$>92+$1<(3%&5''>#", 'PASS')

好的。让我们移除四分之一。

input_without_first_and_fourth_quarter = failing_input[quarter_length:quarter_length * 3]
mystery.run(input_without_first_and_fourth_quarter) 
('50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+', 'FAIL')

是的!这成功了。我们的输入现在缩小了 50%。

我们现在已经尝试移除组成原始失败字符串 \(\frac{1}{2}\)\(\frac{1}{4}\) 的部分。在下一轮迭代中,我们将移除更小的部分——\(\frac{1}{8}\)\(\frac{1}{16}\),以此类推。我们继续进行,直到我们只剩下 \(\frac{1}{97}\)——即单个字符。

然而,这是我们可以愉快地让计算机为我们做的事情。我们首先引入一个 Reducer 类,作为所有类型减法器的抽象超类。test() 方法运行单个测试(如果需要,则进行日志记录);reduce() 方法最终将输入简化到最小。

class Reducer:
  """Base class for reducers."""

    def __init__(self, runner: Runner, log_test: bool = False) -> None:
  """Attach reducer to the given `runner`"""
        self.runner = runner
        self.log_test = log_test
        self.reset()

    def reset(self) -> None:
  """Reset the test counter to zero. To be extended in subclasses."""
        self.tests = 0

    def test(self, inp: str) -> Outcome:
  """Test with input `inp`. Return outcome.
 To be extended in subclasses."""

        result, outcome = self.runner.run(inp)
        self.tests += 1
        if self.log_test:
            print("Test #%d" % self.tests, repr(inp), repr(len(inp)), outcome)
        return outcome

    def reduce(self, inp: str) -> str:
  """Reduce input `inp`. Return reduced input.
 To be defined in subclasses."""

        self.reset()
        # Default: Don't reduce
        return inp 

CachingReducer 变体保存测试结果,这样我们就不必反复运行相同的测试:

class CachingReducer(Reducer):
  """A reducer that also caches test outcomes"""

    def reset(self):
        super().reset()
        self.cache = {}

    def test(self, inp):
        if inp in self.cache:
            return self.cache[inp]

        outcome = super().test(inp)
        self.cache[inp] = outcome
        return outcome 

接下来是 Delta Debugging 算法。Delta Debugging 实现了上述策略:它首先移除大小为 \(\frac{1}{2}\) 的大块;如果这样做不失败,然后我们继续移除大小为 \(\frac{1}{4}\) 的小块,然后是 \(\frac{1}{8}\),以此类推。

我们的实现几乎与 Zeller 在 [Zeller et al, 2002] 中的 Python 代码相同;唯一的区别是它已经被调整以在 Python 3 和我们的 Runner 框架上工作。变量 n(最初为 2)表示粒度——在每一步中,移除大小为 \(\frac{1}{n}\) 的块。如果没有测试失败(some_complement_is_failing 为 False),则 n 被加倍——直到它达到输入的长度。

class DeltaDebuggingReducer(CachingReducer):
  """Reduce inputs using delta debugging."""

    def reduce(self, inp: str) -> str:
  """Reduce input `inp` using delta debugging. Return reduced input."""

        self.reset()
        assert self.test(inp) != Runner.PASS

        n = 2     # Initial granularity
        while len(inp) >= 2:
            start = 0.0
            subset_length = len(inp) / n
            some_complement_is_failing = False

            while start < len(inp):
                complement = inp[:int(start)] + \
                    inp[int(start + subset_length):]

                if self.test(complement) == Runner.FAIL:
                    inp = complement
                    n = max(n - 1, 2)
                    some_complement_is_failing = True
                    break

                start += subset_length

            if not some_complement_is_failing:
                if n == len(inp):
                    break
                n = min(n * 2, len(inp))

        return inp 

为了了解DeltaDebuggingReducer是如何工作的,让我们将其运行在我们的失败输入上。随着每一步的进行,我们看到剩余的输入如何越来越小,直到只剩下两个字符:

dd_reducer = DeltaDebuggingReducer(mystery, log_test=True)
dd_reducer.reduce(failing_input) 
Test #1 ' 7:,>((/$$-/->.;.=;(.%!:50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#' 97 FAIL
Test #2 '\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#' 49 PASS
Test #3 " 7:,>((/$$-/->.;.=;(.%!:50#7*8=$&&=$9!%6(4=&69':" 48 PASS
Test #4 '50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+<7+1<2!4$>92+$1<(3%&5\'\'>#' 73 FAIL
Test #5 "50#7*8=$&&=$9!%6(4=&69':<7+1<2!4$>92+$1<(3%&5''>#" 49 PASS
Test #6 '50#7*8=$&&=$9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+' 48 FAIL
Test #7 '\'<3+0-3.24#7=!&60)2/+";+' 24 PASS
Test #8 "50#7*8=$&&=$9!%6(4=&69':" 24 PASS
Test #9 '9!%6(4=&69\':\'<3+0-3.24#7=!&60)2/+";+' 36 FAIL
Test #10 '9!%6(4=&69\':=!&60)2/+";+' 24 FAIL
Test #11 '=!&60)2/+";+' 12 PASS
Test #12 "9!%6(4=&69':" 12 PASS
Test #13 '=&69\':=!&60)2/+";+' 18 PASS
Test #14 '9!%6(4=!&60)2/+";+' 18 FAIL
Test #15 '9!%6(42/+";+' 12 PASS
Test #16 '9!%6(4=!&60)' 12 FAIL
Test #17 '=!&60)' 6 PASS
Test #18 '9!%6(4' 6 PASS
Test #19 '6(4=!&60)' 9 FAIL
Test #20 '6(460)' 6 FAIL
Test #21 '60)' 3 PASS
Test #22 '6(4' 3 PASS
Test #23 '(460)' 5 FAIL
Test #24 '460)' 4 PASS
Test #25 '(0)' 3 FAIL
Test #26 '0)' 2 PASS
Test #27 '(' 1 PASS
Test #28 '()' 2 FAIL
Test #29 ')' 1 PASS

'()'

现在我们知道为什么MysteryRunner失败了——只要输入包含两个匹配的括号就足够了。Delta Debugging 在 29 步中确定了这一点。其结果是1-minimal,这意味着包含的每个字符都是产生错误所必需的;删除任何(如上所述的测试#27#29所示)都不会使测试失败。这个特性由 delta debugging 算法保证,在其最后阶段,它总是尝试逐个删除字符。

如上所述的简化测试用例具有许多优点:

  • 一个简化的测试用例减少了程序员的认知负荷。测试用例更短且更专注,因此不会让程序员负担无关的细节。简化的输入通常会导致更短的执行时间和更小的程序状态,这两者都会在理解错误时减少搜索空间。在我们的案例中,我们消除了大量的无关输入——只有简化输入中包含的两个字符是相关的。

  • 一个简化的测试用例更容易沟通。这里只需要总结:MysteryRunner 在"()"上失败,这比MysteryRunner 在 4100 字符的输入(附件)上失败要好得多。

  • 一个简化的测试用例有助于识别重复项。如果已经报告了类似的错误,并且它们都被简化到相同的原因(即输入包含匹配的括号),那么很明显,所有这些错误都是同一根本原因的不同症状——并且可以通过一个代码修复一次性解决。

Delta Debugging 有多有效?在最佳情况下(当左半部分或右半部分失败时),测试的数量与输入长度\(n\)的对数成比例(即\(O(\log_2 n)\));这与二分搜索的复杂度相同。然而,在最坏的情况下,delta debugging 可能需要与\(n²\)成比例的测试次数(即\(O(n²)\))——这种情况发生在我们下降到字符粒度时,我们必须反复尝试删除所有字符,结果发现删除最后一个字符会导致失败 [Zeller et al, 2002]。(这是一个相当病态的情况,尽管如此。)

通常,delta debugging 是一个易于实现、易于部署和易于使用的稳健算法——前提是底层测试用例是确定性的,并且运行速度足够快,可以保证进行多次实验。由于这些是使模糊测试有效的相同先决条件,delta debugging 是模糊测试的一个优秀伴侣。

测验

如果被测试的函数没有失败会发生什么?

事实上,DeltaDebugger检查其假设是否成立。如果不成立,则断言失败。

with ExpectError():
    dd_reducer.reduce("I am a passing input") 
Test #1 'I am a passing input' 20 PASS

Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_29923/3080932113.py", line 2, in <module>
    dd_reducer.reduce("I am a passing input")
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_29923/2870451724.py", line 8, in reduce
    assert self.test(inp) != Runner.PASS
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError (expected)

基于语法的输入简化

如果输入语言在句法上很复杂,delta 调试可能需要进行多次尝试来简化,甚至可能无法简化输入。因此,在本章的后半部分,我们介绍了一个名为基于语法的简化算法(或简称为 GRABR)的算法,该算法利用语法来简化句法复杂的输入。

词汇简化与句法规则

尽管 delta 调试在一般情况下很稳健,但在某些情况下它可能效率低下,甚至完全失败。作为一个例子,考虑一些表达式输入,例如1 + (2 * 3)。Delta 调试需要多次测试来简化导致失败的输入,但最终它会返回一个最小输入

expr_input = "1 + (2 * 3)"
dd_reducer = DeltaDebuggingReducer(mystery, log_test=True)
dd_reducer.reduce(expr_input) 
Test #1 '1 + (2 * 3)' 11 FAIL
Test #2 '2 * 3)' 6 PASS
Test #3 '1 + (' 5 PASS
Test #4 '+ (2 * 3)' 9 FAIL
Test #5 '+ ( 3)' 6 FAIL
Test #6 ' 3)' 3 PASS
Test #7 '+ (' 3 PASS
Test #8 ' ( 3)' 5 FAIL
Test #9 '( 3)' 4 FAIL
Test #10 '3)' 2 PASS
Test #11 '( ' 2 PASS
Test #12 '(3)' 3 FAIL
Test #13 '()' 2 FAIL
Test #14 ')' 1 PASS
Test #15 '(' 1 PASS

'()'

然而,在上面的测试中,只有少数几个实际上代表了语法上有效的算术表达式。在实际环境中,我们可能想要测试一个实际上能够解析此类表达式并拒绝所有无效输入的程序。我们定义了一个名为EvalMysteryRunner的类,它首先根据我们表达式语法的规则解析给定的输入,并且只有当它符合条件时,才会传递给我们的原始MysteryRunner。这模拟了测试表达式解释器的情况,在这种情况下,只有有效的输入才能触发错误。

from Grammars import EXPR_GRAMMAR 
from Parser import EarleyParser, Parser  # minor dependency 
class EvalMysteryRunner(MysteryRunner):
    def __init__(self) -> None:
        self.parser = EarleyParser(EXPR_GRAMMAR)

    def run(self, inp: str) -> Tuple[str, Outcome]:
        try:
            tree, *_ = self.parser.parse(inp)
        except SyntaxError:
            return (inp, Runner.UNRESOLVED)

        return super().run(inp) 
eval_mystery = EvalMysteryRunner() 

在这种情况下,结果证明 delta 调试完全失败。它应用的任何简化都不会产生语法上有效的输入,因此输入的整体复杂性与之前一样。

dd_reducer = DeltaDebuggingReducer(eval_mystery, log_test=True)
dd_reducer.reduce(expr_input) 
Test #1 '1 + (2 * 3)' 11 FAIL
Test #2 '2 * 3)' 6 UNRESOLVED
Test #3 '1 + (' 5 UNRESOLVED
Test #4 '+ (2 * 3)' 9 UNRESOLVED
Test #5 '1 2 * 3)' 8 UNRESOLVED
Test #6 '1 + ( 3)' 8 UNRESOLVED
Test #7 '1 + (2 *' 8 UNRESOLVED
Test #8 ' + (2 * 3)' 10 UNRESOLVED
Test #9 '1+ (2 * 3)' 10 UNRESOLVED
Test #10 '1 (2 * 3)' 9 UNRESOLVED
Test #11 '1 + 2 * 3)' 10 UNRESOLVED
Test #12 '1 + ( * 3)' 10 UNRESOLVED
Test #13 '1 + (2 3)' 9 UNRESOLVED
Test #14 '1 + (2 *3)' 10 UNRESOLVED
Test #15 '1 + (2 * ' 9 UNRESOLVED
Test #16 '1  (2 * 3)' 10 UNRESOLVED
Test #17 '1 +(2 * 3)' 10 UNRESOLVED
Test #18 '1 + (2* 3)' 10 UNRESOLVED
Test #19 '1 + (2  3)' 10 UNRESOLVED
Test #20 '1 + (2 * )' 10 UNRESOLVED
Test #21 '1 + (2 * 3' 10 UNRESOLVED

'1 + (2 * 3)'

如果被测试的程序对输入的有效性有多个约束,这种行为是可能的。Delta 调试并不了解这些约束(也不了解输入结构的一般情况),因此它可能会一次又一次地违反这些约束。

基于语法的简化方法

为了简化具有高句法复杂性的输入,我们使用另一种方法:而不是简化输入字符串,我们简化表示其结构的。一般想法是,从一个推导树开始,这个树是通过解析输入得到的,然后用相同类型的较小子树替换子树。这些替代子树可以来自

  1. 从树本身,或者

  2. 通过使用树中的元素应用替代语法扩展。

让我们用一个例子来展示这两种策略。我们从一个算术表达式的推导树开始:

from Grammars import Grammar
from GrammarFuzzer import all_terminals, expansion_to_children, display_tree 
derivation_tree, *_ = EarleyParser(EXPR_GRAMMAR).parse(expr_input)
display_tree(derivation_tree) 

0 1 0->1 2 1->2 7 + 1->7 8 1->8 3 2->3 4 3->4 5 4->5 6 1 (49) 5->6 9 8->9 10 9->10 11 ( (40) 10->11 12 10->12 24 ) (41) 10->24 13 12->13 14 13->14 18 * 13->18 19 13->19 15 14->15 16 15->16 17 2 (50) 16->17 20 19->20 21 20->21 22 21->22 23 3 (51) 22->23

通过替换子树进行简化

为了简化这个树,我们可以用树中较低位置的某个<expr>子树替换树中较高位置的任何<expr>符号。例如,我们可以用最上面的<expr>替换它的右<expr>子树,得到字符串(2 + 3)

import [copy](https://docs.python.org/3/library/copy.html) 
new_derivation_tree = copy.deepcopy(derivation_tree)
# We really should have some query language
sub_expr_tree = new_derivation_tree[1][0][1][2]
display_tree(sub_expr_tree) 

0 1 0->1 2 1->2 3 ( (40) 2->3 4 2->4 16 ) (41) 2->16 5 4->5 6 5->6 10 * 5->10 11 5->11 7 6->7 8 7->8 9 2 (50) 8->9 12 11->12 13 12->13 14 13->14 15 3 (51) 14->15

new_derivation_tree[1][0] = sub_expr_tree
display_tree(new_derivation_tree) 

0 1 0->1 2 1->2 3 2->3 4 ( (40) 3->4 5 3->5 17 ) (41) 3->17 6 5->6 7 6->7 11 * 6->11 12 6->12 8 7->8 9 8->9 10 2 (50) 9->10 13 12->13 14 13->14 15 14->15 16 3 (51) 15->16

all_terminals(new_derivation_tree) 
'(2 * 3)'

用一个子树替换另一个子树只有在单个元素如<expr>在我们的树中多次出现时才有效。在上面的简化new_derivation_tree中,我们只能再次替换一个<expr>树。

通过替代扩展进行简化

简化此树的第二种方法是应用 替代展开。也就是说,对于符号,我们检查是否存在具有较少子节点的替代展开。然后,我们用替代展开替换符号,并从树中填充所需的符号。

例如,考虑上面的 new_derivation_tree。对 <term> 应用的展开是

<term> ::= <term> * <factor> 

让我们用以下替代展开来替换它:

<term> ::= <factor>
term_tree = new_derivation_tree[1][0][1][0][1][0][1][1][1][0]
display_tree(term_tree) 

0 1 0->1 5 * 0->5 6 0->6 2 1->2 3 2->3 4 2 (50) 3->4 7 6->7 8 7->8 9 8->9 10 3 (51) 9->10

shorter_term_tree = term_tree[1][2]
display_tree(shorter_term_tree) 

0 1 0->1 2 1->2 3 2->3 4 3 (51) 3->4

new_derivation_tree[1][0][1][0][1][0][1][1][1][0] = shorter_term_tree
display_tree(new_derivation_tree) 

0 1 0->1 2 1->2 3 2->3 4 ( (40) 3->4 5 3->5 11 ) (41) 3->11 6 5->6 7 6->7 8 7->8 9 8->9 10 3 (51) 9->10

all_terminals(new_derivation_tree) 
'(3)'

如果我们用(较小的)子树替换推导子树,并且如果我们寻找再次产生较小子树的替代展开,我们可以系统地简化输入。这可能会比 delta 调试快得多,因为我们的输入始终是语法有效的。然而,我们需要一个策略来确定何时应用哪个简化规则。这就是我们在本节剩余部分要开发的。

用于语法减少的类

我们引入 GrammarReducer 类,它再次是一个 Reducer。请注意,我们派生自 CachingReducer,因为该策略会产生多个重复。

class GrammarReducer(CachingReducer):
  """Reduce inputs using grammars"""

    def __init__(self, runner: Runner, parser: Parser, *,
                 log_test: bool = False, log_reduce: bool = False):
  """Constructor.
 `runner` is the runner to be used.
 `parser` is the parser to be used.
 `log_test` - if set, show tests and results.
 `log_reduce` - if set, show reduction steps.
 """

        super().__init__(runner, log_test=log_test)
        self.parser = parser
        self.grammar = parser.grammar()
        self.start_symbol = parser.start_symbol()
        self.log_reduce = log_reduce
        self.try_all_combinations = False 

一些辅助工具

我们定义了一些辅助函数,我们将在我们的策略中需要它们。tree_list_to_string() 做名字暗示的事情,从一个推导树的列表创建一个字符串:

from GrammarFuzzer import DerivationTree 
def tree_list_to_string(q: List[DerivationTree]) -> str:
    return "[" + ", ".join([all_terminals(tree) for tree in q]) + "]" 
tree_list_to_string([derivation_tree, derivation_tree]) 
'[1 + (2 * 3), 1 + (2 * 3)]'

函数 possible_combinations() 接收一个列表的列表 \([[x_1, x_2], [y_1, y_2], \dots]\) 并创建一个组合列表 \([[x_1, y_1], [x_1, y_2], [x_2, y_1], [x_2, y_2], \dots]\).

def possible_combinations(list_of_lists: List[List[Any]]) -> List[List[Any]]:
    if len(list_of_lists) == 0:
        return []

    ret = []
    for e in list_of_lists[0]:
        if len(list_of_lists) == 1:
            ret.append([e])
        else:
            for c in possible_combinations(list_of_lists[1:]):
                new_combo = [e] + c
                ret.append(new_combo)

    return ret 
possible_combinations([[1, 2], ['a', 'b']]) 
[[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']]

函数 number_of_nodes()max_height() 分别返回给定树的节点数和最大高度。

def number_of_nodes(tree: DerivationTree) -> int:
    (symbol, children) = tree
    if children is None:
        return 1

    return 1 + sum([number_of_nodes(c) for c in children]) 
number_of_nodes(derivation_tree) 
25

def max_height(tree: DerivationTree) -> int:
    (symbol, children) = tree
    if children is None or len(children) == 0:
        return 1

    return 1 + max([max_height(c) for c in children]) 
max_height(derivation_tree) 
12

简化策略

现在我们来实现我们的两种简化策略——替换子树和替代展开。

寻找子树

方法 subtrees_with_symbol() 返回给定树中所有根节点等于给定符号的子树。如果设置了 ignore_root(默认值),则不比较 tree 的根节点。(下面将讨论 depth 参数。)

class GrammarReducer(GrammarReducer):
    def subtrees_with_symbol(self, tree: DerivationTree,
                             symbol: str, depth: int = -1,
                             ignore_root: bool = True) -> List[DerivationTree]:
  """Find all subtrees in `tree` whose root is `symbol`.
 If `ignore_root` is true, ignore the root note of `tree`."""

        ret = []
        (child_symbol, children) = tree
        if depth <= 0 and not ignore_root and child_symbol == symbol:
            ret.append(tree)

        # Search across all children
        if depth != 0 and children is not None:
            for c in children:
                ret += self.subtrees_with_symbol(c,
                                                 symbol,
                                                 depth=depth - 1,
                                                 ignore_root=False)

        return ret 

这里有一个例子:这些都是我们推导树 derivation_tree 中包含 <term> 的所有子树。

grammar_reducer = GrammarReducer(
    mystery,
    EarleyParser(EXPR_GRAMMAR),
    log_reduce=True) 
all_terminals(derivation_tree) 
'1 + (2 * 3)'

[all_terminals(t) for t in grammar_reducer.subtrees_with_symbol(
    derivation_tree, "<term>")] 
['1', '(2 * 3)', '2 * 3', '3']

如果我们想要替换 <term> 子树以简化树,这些是我们可以用它们替换的子树。

替代展开

我们的第二种策略,通过替代展开简化,要复杂一些。我们首先获取给定符号的可能展开(从具有最少子节点的那些开始)。对于每个展开,我们使用 subtrees_with_symbols()(上面)从子树中填充符号的值。然后我们选择第一个可能的组合(或者如果设置了属性 try_all_combinations,则选择所有组合)。

class GrammarReducer(GrammarReducer):
    def alternate_reductions(self, tree: DerivationTree, symbol: str, 
                             depth: int = -1):
        reductions = []

        expansions = self.grammar.get(symbol, [])
        expansions.sort(
            key=lambda expansion: len(
                expansion_to_children(expansion)))

        for expansion in expansions:
            expansion_children = expansion_to_children(expansion)

            match = True
            new_children_reductions = []
            for (alt_symbol, _) in expansion_children:
                child_reductions = self.subtrees_with_symbol(
                    tree, alt_symbol, depth=depth)
                if len(child_reductions) == 0:
                    match = False   # Child not found; cannot apply rule
                    break

                new_children_reductions.append(child_reductions)

            if not match:
                continue  # Try next alternative

            # Use the first suitable combination
            for new_children in possible_combinations(new_children_reductions):
                new_tree = (symbol, new_children)
                if number_of_nodes(new_tree) < number_of_nodes(tree):
                    reductions.append(new_tree)
                    if not self.try_all_combinations:
                        break

        # Sort by number of nodes
        reductions.sort(key=number_of_nodes)

        return reductions 
grammar_reducer = GrammarReducer(
    mystery,
    EarleyParser(EXPR_GRAMMAR),
    log_reduce=True) 
all_terminals(derivation_tree) 
'1 + (2 * 3)'

这里是 <term> 的所有组合:

grammar_reducer.try_all_combinations = True
print([all_terminals(t)
       for t in grammar_reducer.alternate_reductions(derivation_tree, "<term>")]) 
['1', '2', '3', '1 * 1', '1 * 3', '2 * 1', '2 * 3', '3 * 1', '3 * 3', '(2 * 3)', '1 * 2 * 3', '2 * 2 * 3', '3 * 2 * 3', '1 * (2 * 3)', '(2 * 3) * 1', '(2 * 3) * 3', '2 * (2 * 3)', '3 * (2 * 3)']

然而,默认情况下,只是简单地返回这些中的第一个:

grammar_reducer.try_all_combinations = False
[all_terminals(t) for t in grammar_reducer.alternate_reductions(
    derivation_tree, "<term>")] 
['1', '1 * 1']

两种策略结合

现在,让我们合并两种策略。为了用给定的符号替换子树,我们首先搜索已经存在的子树(使用 subtrees_with_symbol());然后进行备选扩展(使用 alternate_expansions())。

class GrammarReducer(GrammarReducer):
    def symbol_reductions(self, tree: DerivationTree, symbol: str, 
                          depth: int = -1):
  """Find all expansion alternatives for the given symbol"""
        reductions = (self.subtrees_with_symbol(tree, symbol, depth=depth)
                      + self.alternate_reductions(tree, symbol, depth=depth))

        # Filter duplicates
        unique_reductions = []
        for r in reductions:
            if r not in unique_reductions:
                unique_reductions.append(r)

        return unique_reductions 
grammar_reducer = GrammarReducer(
    mystery,
    EarleyParser(EXPR_GRAMMAR),
    log_reduce=True) 
all_terminals(derivation_tree) 
'1 + (2 * 3)'

这些是 <expr> 节点的可能简化方式。注意我们首先返回子树(1 + (2 * 3)(2 * 3)2 * 3),然后再进行 <expr> 的备选扩展(1)。

reductions = grammar_reducer.symbol_reductions(derivation_tree, "<expr>")
tree_list_to_string([r for r in reductions]) 
'[1 + (2 * 3), (2 * 3), 2 * 3, 1]'

这些是 <term> 节点的可能简化方式。再次强调,我们首先有推导树的子树,然后是备选扩展 1 * 1

reductions = grammar_reducer.symbol_reductions(derivation_tree, "<term>")
tree_list_to_string([r for r in reductions]) 
'[1, (2 * 3), 2 * 3, 3, 1 * 1]'

简化策略

现在,我们能够为树中的每个符号返回多个备选方案。这就是我们在简化策略的核心函数 reduce_subtree() 中应用的内容。从 subtree 开始,对于每个子节点,我们找到可能的简化。对于每个简化,我们用简化替换子节点并测试结果(完整)树。如果它失败,我们的简化是成功的;否则,我们将子节点放回原位并尝试下一个简化。最终,我们将 reduce_subtree() 应用到所有子节点上,也将它们简化。

class GrammarReducer(GrammarReducer):
    def reduce_subtree(self, tree: DerivationTree,
                       subtree: DerivationTree, depth: int = -1):
        symbol, children = subtree
        if children is None or len(children) == 0:
            return False

        if self.log_reduce:
            print("Reducing", all_terminals(subtree), "with depth", depth)

        reduced = False
        while True:
            reduced_child = False
            for i, child in enumerate(children):
                if child is None:
                    continue

                (child_symbol, _) = child
                for reduction in self.symbol_reductions(
                        child, child_symbol, depth):
                    if number_of_nodes(reduction) >= number_of_nodes(child):
                        continue

                    # Try this reduction
                    if self.log_reduce:
                        print(
                            "Replacing",
                            all_terminals(
                                children[i]),
                            "by",
                            all_terminals(reduction))
                    children[i] = reduction
                    if self.test(all_terminals(tree)) == Runner.FAIL:
                        # Success
                        if self.log_reduce:
                            print("New tree:", all_terminals(tree))
                        reduced = reduced_child = True
                        break
                    else:
                        # Didn't work out - restore
                        children[i] = child

            if not reduced_child:
                if self.log_reduce:
                    print("Tried all alternatives for", all_terminals(subtree))
                break

        # Run recursively
        for c in children:
            if self.reduce_subtree(tree, c, depth):
                reduced = True

        return reduced 

我们现在只需要几个驱动器。reduce_tree() 方法是进入 reduce_subtree() 的主要入口点:

class GrammarReducer(GrammarReducer):
    def reduce_tree(self, tree):
        return self.reduce_subtree(tree, tree) 

自定义方法 parse() 将给定输入转换为推导树:

class GrammarReducer(GrammarReducer):
    def parse(self, inp):
        tree, *_ = self.parser.parse(inp)
        if self.log_reduce:
            print(all_terminals(tree))
        return tree 

reduce() 方法是唯一的入口点,解析输入然后简化它。

class GrammarReducer(GrammarReducer):
    def reduce(self, inp):
        tree = self.parse(inp)
        self.reduce_tree(tree)
        return all_terminals(tree) 
```</details>

让我们在实际中尝试我们的 `GrammarReducer` 类,针对输入 `expr_input` 和 `mystery()` 函数。我们能够多快将其简化?

```py
expr_input 
'1 + (2 * 3)'

grammar_reducer = GrammarReducer(
    eval_mystery,
    EarleyParser(EXPR_GRAMMAR),
    log_test=True)
grammar_reducer.reduce(expr_input) 
Test #1 '(2 * 3)' 7 FAIL
Test #2 '2 * 3' 5 PASS
Test #3 '3' 1 PASS
Test #4 '2' 1 PASS
Test #5 '(3)' 3 FAIL

'(3)'

成功!仅用五步,我们的 GrammarReducer 就将输入简化到导致失败的最小值。注意,所有测试都是通过构造语法有效的,避免了导致 delta 调试停滞的 UNRESOLVED 结果。

深度优先策略

即使五步已经很好,我们仍然可以做得更好。如果我们查看上面的日志,我们会看到在测试 #2 中,输入(树)简化为 2 * 3 后,我们的 GrammarReducer 首先尝试用 23 替换树,它们是备选的 <term> 子树。这当然可能有效;但如果有很多可能的子树,我们的策略将花费相当多的时间逐一尝试。

如上所述,Delta 调试遵循尝试将输入大约减半的想法,因此快速向最小输入推进。通过用更小的子树替换树,我们可能显著简化树,但可能需要多次尝试才能做到。更好的策略是首先只考虑大的子树——既用于子树替换也用于备选扩展。为了找到这样的大的子树,我们限制在子树中搜索可能替换的深度——首先查看直接后代,然后查看更低的后代。

这是subtrees_with_symbol()中使用的depth参数的作用,并通过调用函数传递。如果设置,则只返回给定深度的符号。以下是一个例子,再次从我们的推导树derivation_tree开始:

grammar_reducer = GrammarReducer(
    mystery,
    EarleyParser(EXPR_GRAMMAR),
    log_reduce=True) 
all_terminals(derivation_tree) 
'1 + (2 * 3)'

display_tree(derivation_tree) 

0 1 0->1 2 1->2 7 + 1->7 8 1->8 3 2->3 4 3->4 5 4->5 6 1 (49) 5->6 9 8->9 10 9->10 11 ( (40) 10->11 12 10->12 24 ) (41) 10->24 13 12->13 14 13->14 18 * 13->18 19 13->19 15 14->15 16 15->16 17 2 (50) 16->17 20 19->20 21 20->21 22 21->22 23 3 (51) 22->23

在深度为 1 时,没有<term>符号:

[all_terminals(t) for t in grammar_reducer.subtrees_with_symbol(
    derivation_tree, "<term>", depth=1)] 
[]

在深度为 2 时,我们在左侧有<term>子树:

[all_terminals(t) for t in grammar_reducer.subtrees_with_symbol(
    derivation_tree, "<term>", depth=2)] 
['1']

在深度为 3 时,我们在右侧有<term>子树:

[all_terminals(t) for t in grammar_reducer.subtrees_with_symbol(
    derivation_tree, "<term>", depth=3)] 
['(2 * 3)']

现在的想法是从深度 0 开始,随着我们的进行逐步增加:

class GrammarReducer(GrammarReducer):
    def reduce_tree(self, tree):
        depth = 0
        while depth < max_height(tree):
            reduced = self.reduce_subtree(tree, tree, depth)
            if reduced:
                depth = 0    # Start with new tree
            else:
                depth += 1   # Extend search for subtrees
        return tree 
grammar_reducer = GrammarReducer(
    mystery,
    EarleyParser(EXPR_GRAMMAR),
    log_test=True)
grammar_reducer.reduce(expr_input) 
Test #1 '(2 * 3)' 7 FAIL
Test #2 '(3)' 3 FAIL
Test #3 '3' 1 PASS

'(3)'

我们看到,在我们的设置中,深度优先策略甚至需要更少的步骤。

比较策略

我们通过构建一个非常长的表达式来结束,以展示基于文本的 delta 调试和我们的基于语法的减少之间的差异:

from GrammarFuzzer import GrammarFuzzer 
long_expr_input = GrammarFuzzer(EXPR_GRAMMAR, min_nonterminals=100).fuzz()
long_expr_input 
'++---((-2 / 3 / 3 - -+1 / 5 - 2) * ++6 / +8 * 4 / 9 / 2 * 8 + ++(5) * 3 / 8 * 0 + 3 * 3 + 4 / 0 / 6 + 9) * ++++(+--9 * -3 * 7 / 4 + --(4) / 3 - 0 / 3 + 5 + 0) * (1 * 6 - 1 / 9 * 5 - 9 / 0 + 7) * ++(8 - 1) * +1 * 7 * 0 + ((1 + 4) / 4 * 8 * 9 * 4 + 4 / (4) * 1 - (4) * 8 * 5 + 1 + 4) / (+(2 - 1 - 9) * 5 + 3 + 6 - 2) * +3 * (3 - 7 + 8) / 4 - -(9 * 4 - 1 * 0 + 5) / (5 / 9 * 5 + 2) * 7 + ((7 - 5 + 3) / 1 * 8 - 8 - 9) * --+1 * 4 / 4 - 4 / 7 * 4 - 3 / 6 * 1 - 2 - 7 - 8'

使用语法,我们只需要进行少量测试就能找到导致失败的输入:

from Timer import Timer 
grammar_reducer = GrammarReducer(eval_mystery, EarleyParser(EXPR_GRAMMAR))
with Timer() as grammar_time:
    print(grammar_reducer.reduce(long_expr_input)) 
(9)

grammar_reducer.tests 
10

grammar_time.elapsed_time() 
0.0751019999734126

与此相反,delta 调试需要数量级更多的测试(以及相应的时间)。再次强调,减少的效果并不像基于语法的减少器那样完美。

dd_reducer = DeltaDebuggingReducer(eval_mystery)
with Timer() as dd_time:
    print(dd_reducer.reduce(long_expr_input)) 
((2 - 1 - 2) * 8 + (5) - (4)) / ((2) * 3) * (9) / 3 / 1 - 8

dd_reducer.tests 
900

dd_time.elapsed_time() 
1.6051183340023272

我们看到,如果一个输入在语法上复杂,使用语法来减少输入是最佳选择。

经验教训

  • 将导致失败的输入减少到最小有助于测试和调试。

  • Delta 调试是一个简单且健壮的算法,可以轻松减少测试用例。

  • 对于语法复杂的输入,基于语法的减少要快得多,并且结果更好。

下一步

我们下一章将重点介绍 Web GUI Fuzzing,这是另一个生成和减少测试用例至关重要的领域。

背景

这里讨论的“词法”delta 调试算法源于[Zeller 等人,2002];实际上,这正是 Zeller 在 2002 年所使用的 Python 实现。系统地减少输入的想法已经被发现多次,尽管不如 delta 调试那样自动和通用。Slutz 等人,1998,例如,讨论了 SQL 数据库中 SQL 语句的系统化减少;这个过程作为人工工作被 Kernighan 等人,1999很好地描述了。

关于 delta debugging 在处理语法复杂输入时的不足,最初是在 编译器测试 中讨论的,并且很快发现 减少树输入 而不是字符串输入是一个替代方案。分层 delta debugging (HDD) [Misherghi 等人,2006] 在解析树的子树上应用 delta debugging,系统地减少解析树到最小。广义树减少 [Herfert 等人,2017] 将这一想法推广到应用任意 模式,例如在子树中将一个项替换为兼容项,就像 subtrees_with_symbol() 所做的那样。使用 语法 来减少输入最初是在 Perses 工具 [Sun 等人,2018] 中实现的;我们的算法实现了非常相似的战略。寻找替代扩展(如 alternate_reductions())是本章的一个贡献。

虽然将 delta debugging 应用于代码行可以做得相当不错,但 语法 和特别是 语言特定 的方法可以为当前编程语言做更好的工作:

  • C-Reduce [Regehr 等人,2012] 是一个专门针对编程语言减少的减少器。除了 delta debugging 或树变换风格的减少外,C-Reduce 还提供了超过 30 种源到源转换,这些转换将聚合替换为标量,删除定义和所有调用位置上的函数参数,将函数更改为返回 void 并删除所有 return 语句,等等。虽然这些原则专门针对 C 语言(并用于测试 C 编译器),但它们适用于遵循 ALGOL 类语法的任意编程语言。

  • Kalhauge 和 Palsberg [Kalhauge 等人,2019] 介绍了 依赖图二进制减少,这是一个减少任意具有依赖关系的输入的通用解决方案。他们的 J-Reduce 工具专门针对 Java 程序,并且比 delta debugging 快得多,并且达到了更高的减少率。

减少输入在 基于属性的测试 的上下文中也效果良好;也就是说,为单个函数生成数据结构,然后可以在失败时对其进行减少(“缩小”)。Hypothesis fuzzer 有许多特定类型的缩小策略;这篇博客文章讨论了其一些特性。

在调试书籍中关于 "减少故障诱导输入" 的章节 提供了一个更易于部署的 delta debugging 的替代实现 DeltaDebugger;在这里,只需简单地

with DeltaDebugger() as dd:
    fun(args...)
dd 

减少 args 中用于失败(抛出异常)的函数 fun() 的输入。该章节还讨论了进一步的用法示例,包括将 代码 简化到最小。

David McIver 的这篇 博客文章 包含了大量关于如何在实践中应用缩减的见解,特别是不同抽象级别的多次运行。

练习

最佳缩减输入的方法仍然是一个研究不充分的研究领域,有很多机会。

练习 1:基于变异的模糊测试与缩减

当使用种群进行模糊测试时,偶尔缩减每个元素的长度可能很有用,这样未来的后代也会更短,这通常可以加快它们的测试速度。

考虑 基于变异的模糊测试章节 中的 MutationFuzzer 类。扩展它,以便每当向种群添加新输入时,它首先使用 delta 调试进行缩减。

使用笔记本 进行练习并查看解决方案。

练习 2:通过生产进行缩减

如上所述的基于语法的输入缩减可能是一个好的算法,但绝不是唯一的替代方案。一个有趣的问题是“缩减”是否应该仅限于已经存在的元素,或者是否允许创建元素。这些元素可能不在原始输入中,但仍然可以生成一个更小的输入,从而仍然能够重现原始的失败。

使用笔记本 进行练习并查看解决方案。

例如,考虑以下语法:

<number> ::= <float> | <integer> | <not-a-number>
<float> ::= <digits>.<digits>
<integer> ::= <digits>
<not-a-number> ::= NaN
<digits> ::= [0-9]+

假设输入 100.99 失败。我们可能能够将其缩减到最小值,例如 1.9。然而,我们不能将其缩减到 <integer><not-a-number>,因为这些符号在原始输入中不存在。通过允许为这些符号创建替代,我们也可以测试如 1NaN 这样的输入,并进一步泛化程序失败输入的类别。

使用笔记本 进行练习并查看解决方案。

创建一个 GenerativeGrammarReducer 类,作为 GrammarReducer 的子类;相应地扩展 reduce_subtree() 方法。

使用笔记本 进行练习并查看解决方案。

练习 3:大规模缩减竞赛

为之前定义的语法创建一个基准,包括:

  1. 一组输入,使用 GrammarFuzzer 和其衍生工具从这些语法生成;

  2. 一组测试,用于检查单个符号以及这些符号的成对和三重组合的出现:

    • 如果输入不是语法有效的,测试应该未解决

    • 如果符号(或其成对或三重组合)出现,测试应该失败

    • 在所有其他情况下,测试应该通过

在基准测试中比较 delta 调试和基于语法的调试。实现 HDD [Misherghi 等人,2006] 和 广义树缩减 [Herfert 等人,2017] 并将它们添加到比较中。哪种方法表现最好,以及在什么情况下?

使用笔记本 来完成练习并查看解决方案。

Creative Commons License 本项目的内容受Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,受MIT 许可协议许可。 最后修改:2023-11-11 18:18:06+01:00 • 引用 • 版权信息

如何引用本作品

Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, 和 Christian Holler: "减少故障诱导输入". 在 Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, 和 Christian Holler, "模糊测试书", www.fuzzingbook.org/html/Reducer.html. Retrieved 2023-11-11 18:18:06+01:00.

@incollection{fuzzingbook2023:Reducer,
    author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
    booktitle = {The Fuzzing Book},
    title = {Reducing Failure-Inducing Inputs},
    year = {2023},
    publisher = {CISPA Helmholtz Center for Information Security},
    howpublished = {\url{https://www.fuzzingbook.org/html/Reducer.html}},
    note = {Retrieved 2023-11-11 18:18:06+01:00},
    url = {https://www.fuzzingbook.org/html/Reducer.html},
    urldate = {2023-11-11 18:18:06+01:00}
}

posted @ 2025-12-13 18:14  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报