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.
总结 。我们的结构灰色盒模糊器
基于输入区域的模糊
在上一节中,我们已经看到,大多数作为种子添加的输入与我们的给定语法相比是无效的 。然而,为了应用基于片段的突变器,我们需要成功解析种子。否则,整个基于片段的方法就变得毫无用处。问题随之而来:如何从(无法成功解析的)种子中推导出结构?
为了达到这个目的,我们引入了基于区域的突变的概念,这一概念最初是在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 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')
先决条件
概述
要使用本章提供的代码,请编写
>>> 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 调试并不了解这些约束(也不了解输入结构的一般情况),因此它可能会一次又一次地违反这些约束。
基于语法的简化方法
为了简化具有高句法复杂性的输入,我们使用另一种方法:而不是简化输入字符串,我们简化表示其结构的树 。一般想法是,从一个推导树 开始,这个树是通过解析输入得到的,然后用相同类型的较小子树替换子树 。这些替代子树可以来自
从树本身,或者
通过使用树中的元素应用替代语法扩展。
让我们用一个例子来展示这两种策略。我们从一个算术表达式的推导树开始:
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 首先尝试用 2 和 3 替换树,它们是备选的 <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
我们看到,如果一个输入在语法上复杂,使用语法来减少输入是最佳选择。
经验教训
下一步
我们下一章将重点介绍 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>,因为这些符号在原始输入中不存在。通过允许为这些符号创建替代 ,我们也可以测试如 1 或 NaN 这样的输入,并进一步泛化程序失败输入的类别。
使用笔记本 进行练习并查看解决方案。
创建一个 GenerativeGrammarReducer 类,作为 GrammarReducer 的子类;相应地扩展 reduce_subtree() 方法。
使用笔记本 进行练习并查看解决方案。
练习 3:大规模缩减竞赛
为之前定义的语法创建一个基准 ,包括:
一组输入 ,使用 GrammarFuzzer 和其衍生工具从这些语法生成;
一组测试 ,用于检查单个符号以及这些符号的成对和三重组合的出现:
在基准测试中比较 delta 调试和基于语法的调试。实现 HDD [Misherghi 等人,2006 ] 和 广义树缩减 [Herfert 等人,2017 ] 并将它们添加到比较中。哪种方法表现最好,以及在什么情况下?
使用笔记本 来完成练习并查看解决方案。
本项目的内容受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}
}