模糊测试之书-七-
模糊测试之书(七)
原文:
exploringjs.com/ts/book/index.html译者:飞龙
概率语法模糊测试
让我们通过为单个扩展分配 概率 来赋予语法更多的能力。这允许我们控制每个元素应该产生多少,从而允许我们 针对 生成测试以特定功能为目标。我们还展示了如何从给定的样本输入中学习这样的概率,并具体将测试直接针对这些样本中不常见的输入特征。
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('9htOliNwopc')
先决条件
-
您应该已经阅读了 关于语法的章节。
-
我们的实现与在 "Efficient Grammar Fuzzing" 中引入的基于语法的 fuzzer 挂钩。
-
为了从样本中学习概率,我们使用了 parsers。
概述
要 使用本章提供的代码,请编写
>>> from fuzzingbook.ProbabilisticGrammarFuzzer import <identifier>
然后利用以下功能。
一个 概率 语法允许将单个 概率 附着到产生规则上。要将单个扩展 S 的概率设置为值 X(介于 0 和 1 之间),将其替换为
(S, opts(prob=X))
如果我们想确保生成的 90% 电话号码的区号以 9 开头,我们可以编写:
>>> from Grammars import US_PHONE_GRAMMAR, extend_grammar, opts
>>> PROBABILISTIC_US_PHONE_GRAMMAR: Grammar = extend_grammar(US_PHONE_GRAMMAR,
>>> {
>>> "<lead-digit>": [
>>> "2", "3", "4", "5", "6", "7", "8",
>>> ("9", opts(prob=0.9))
>>> ],
>>> })
ProbabilisticGrammarFuzzer 将提取并解释这些选项。以下是一个示例:
>>> probabilistic_us_phone_fuzzer = ProbabilisticGrammarFuzzer(PROBABILISTIC_US_PHONE_GRAMMAR)
>>> [probabilistic_us_phone_fuzzer.fuzz() for i in range(5)]
['(918)925-2501',
'(981)925-0792',
'(934)995-5029',
'(955)999-7801',
'(964)927-0877']
如您所见,现在大多数区号都以 9 开头。
尊重语法中概率的基于语法的 fuzzer。">
检查传递的语法">
返回 children_alternatives 中要选择的扩展的索引。
'children_alternatives':node 的可能子列表。
默认为随机。在子类中重载。">
支持的选项集合。应在子类中重载。">
使用推导树高效地从语法中生成字符串。">
从grammar生成字符串,以start_symbol开始。
如果提供了min_nonterminals或max_nonterminals,则使用它们作为限制。
对于生成的非终结符数量。
如果设置了disp,则显示中间推导树。
如果设置了log,则以文本形式在标准输出中显示中间步骤。">
从语法中生成一个字符串。">
从语法中生成一个推导树。">
模糊器的基类。">
使用模糊输入运行 runner,如下所示 `
使用模糊输入,trials 次运行 runner,如下所示 `
首位数字定律
在我们迄今为止的所有例子中,你可能已经注意到程序生成的输入与现实生活中出现的“自然”输入有很大不同。即使是像数字这样的无害元素也是如此——是的,我们迄今为止生成的数字实际上与现实世界中的数字是不同的。这是因为现实生活中的数值数据集中,首位有效数字很可能是小的:实际上,平均而言,首位数字 1 出现的频率比首位数字 8 或 9 高出 六倍。已经证明,这一结果适用于各种数据集,包括电费账单、街道地址、股票价格、房价、人口数量、死亡率、河流长度、物理和数学常数(维基百科)。
这个首位数字定律最初是由 Newcomb 观察到的 [Simon Newcomb, 1881],后来由 Benford 在 [Frank Benford, 1938] 中形式化。让我们看看决定一个数字首位数字的条件。我们可以通过将数字转换为字符串并取第一个字符来轻松地计算首位数字:
def first_digit_via_string(x: int) -> int:
return ord(repr(x)[0]) - ord('0')
first_digit_via_string(2001)
2
要进行数学处理,我们必须取它们对数的分数部分,或者正式地
\(\{x\}\) 是 \(x\) 的分数部分(即 \(\{1.234\} = 0.234\))。
import [math](https://docs.python.org/3/library/math.html)
def first_digit_via_log(x: int) -> int:
frac, whole = math.modf(math.log10(x))
return int(10 ** frac)
first_digit_via_log(2001)
2
大多数“自然”出现的数字集合在其对数分数部分中不应有任何偏差,因此,分数部分 \(\{\log_{10}(x)\}\) 通常均匀分布。然而,个别数字的分数部分并不均匀分布。
对于一个数字以数字 \(d\) 开头,必须满足条件 \(d < 10^{\{\log_{10}(x)\}} < d + 1\)。因此,要使数字以数字 1 开头,分数部分 \(\{\log_{10}(x)\}\) 必须在以下范围内
(math.log10(1), math.log10(2))
(0.0, 0.3010299956639812)
然而,要开始计算数字 2 的概率,它必须在以下范围内
(math.log10(2), math.log10(3))
(0.3010299956639812, 0.47712125471966244)
这个范围要小得多。形式上,首位数字 \(d\) 的概率 \(P(d)\)(再次假设分数部分均匀分布)被称为本福特定律:$$ P(d) = \log_{10}(d + 1) - \log_{10}(d) $$ 这给我们:
def prob_leading_digit(d: int) -> float:
return math.log10(d + 1) - math.log10(d)
让我们计算所有数字的概率:
digit_probs = [prob_leading_digit(d) for d in range(1, 10)]
[(d, "%.2f" % digit_probs[d - 1]) for d in range(1, 10)]
[(1, '0.30'),
(2, '0.18'),
(3, '0.12'),
(4, '0.10'),
(5, '0.08'),
(6, '0.07'),
(7, '0.06'),
(8, '0.05'),
(9, '0.05')]

我们看到,首位数字 1 的概率确实是首位数字 9 的六倍。
本福特定律有许多应用。最值得注意的是,它可以用来检测“非自然”数字,即那些看起来是随机创建而不是来自“自然”来源的数字。如果你写一篇科学论文并伪造数据,通过插入随机数(例如,使用我们的语法模糊器[GrammarFuzzer.html]对整数进行操作),你可能会违反本福特定律,这确实可以被察觉。另一方面,如果我们想要创建符合本福特定律的数字,我们该如何进行?为此,我们需要能够在我们的语法中编码上述概率,以确保在所有情况下,首位数字确实是 1 的 30%。
指定概率
本章的目标是为语法中的单个扩展分配概率,这样我们就可以表达某些扩展选项应该比其他选项更受青睐。这不仅对生成“自然”外观的数字有用,而且更有助于指导测试生成向特定目标。如果你最近更改了程序中的某些代码,你可能会希望生成测试这些更改代码的输入。通过提高与更改代码相关的输入元素的概率,你将得到更多测试这些更改代码的测试。
我们表达概率的概念是使用注释机制(在关于语法的章节中介绍)注释单个展开式,例如概率等属性。为此,我们允许展开式不仅可以是一个字符串,也可以是一个字符串和一组属性的 对,如下所示
"<expr>":
[("<term> + <expr>", opts(prob=0.1)),
("<term> - <expr>", opts(prob=0.2)),
"<term>"]
在这里,opts() 函数将允许我们表达选择单个展开式的概率。加法有 10% 的概率,减法有 20%。剩余的概率(在这种情况下为 70%)将平均分配给所有未指定的展开式(在这种情况下是最后一个)。
现在我们可以使用带有 opts() 的对来为我们的表达式语法分配概率:
import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils)
from Fuzzer import Fuzzer
from GrammarFuzzer import GrammarFuzzer, all_terminals, display_tree, DerivationTree
from Grammars import is_valid_grammar, EXPR_GRAMMAR, START_SYMBOL, crange
from Grammars import opts, exp_string, exp_opt, set_opts
from Grammars import Grammar, Expansion
from [typing](https://docs.python.org/3/library/typing.html) import List, Dict, Set, Optional, cast, Any, Tuple
PROBABILISTIC_EXPR_GRAMMAR: Grammar = {
"<start>":
["<expr>"],
"<expr>":
[("<term> + <expr>", opts(prob=0.1)),
("<term> - <expr>", opts(prob=0.2)),
"<term>"],
"<term>":
[("<factor> * <term>", opts(prob=0.1)),
("<factor> / <term>", opts(prob=0.1)),
"<factor>"
],
"<factor>":
["+<factor>", "-<factor>", "(<expr>)",
"<leadinteger>", "<leadinteger>.<integer>"],
"<leadinteger>":
["<leaddigit><integer>", "<leaddigit>"],
# Benford's law: frequency distribution of leading digits
"<leaddigit>":
[("1", opts(prob=0.301)),
("2", opts(prob=0.176)),
("3", opts(prob=0.125)),
("4", opts(prob=0.097)),
("5", opts(prob=0.079)),
("6", opts(prob=0.067)),
("7", opts(prob=0.058)),
("8", opts(prob=0.051)),
("9", opts(prob=0.046)),
],
# Remaining digits are equally distributed
"<integer>":
["<digit><integer>", "<digit>"],
"<digit>":
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"],
}
assert is_valid_grammar(PROBABILISTIC_EXPR_GRAMMAR, supported_opts={'prob'})
这是语法展开式在内部表示的方式:
leaddigits: List[Expansion] = PROBABILISTIC_EXPR_GRAMMAR["<leaddigit>"]
leaddigits
[('1', {'prob': 0.301}),
('2', {'prob': 0.176}),
('3', {'prob': 0.125}),
('4', {'prob': 0.097}),
('5', {'prob': 0.079}),
('6', {'prob': 0.067}),
('7', {'prob': 0.058}),
('8', {'prob': 0.051}),
('9', {'prob': 0.046})]
然而,我们通常通过指定的辅助函数访问展开字符串和相关的概率,即 exp_string()(来自关于语法的章节)和 exp_prob():
leaddigit_expansion = leaddigits[0]
leaddigit_expansion
('1', {'prob': 0.301})
exp_string(leaddigit_expansion)
'1'
def exp_prob(expansion: Expansion) -> float:
"""Return the options of an expansion"""
return exp_opt(expansion, 'prob')
exp_prob(leaddigit_expansion)
0.301
我们现有的模糊器都设置为使用这种方式注释的语法。它们简单地忽略所有注释。
f = GrammarFuzzer(PROBABILISTIC_EXPR_GRAMMAR)
f.fuzz()
'4 + ++--7.0 - -7 - +++7.3 * (1 * 3 + 5 / 3 / 5 + 2) * 38 * (2 + 8)'
from GrammarCoverageFuzzer import GrammarCoverageFuzzer # minor dependency
f = GrammarCoverageFuzzer(PROBABILISTIC_EXPR_GRAMMAR)
f.fuzz()
'1.30'
计算概率
让我们定义访问给定展开式概率的函数。在这样做的同时,它们也会检查不一致性。
分布概率
这是如何为没有指定概率的展开式分配概率的。给定一个展开规则
对于 \(n \ge 0\) 个具有指定概率 \(p(a_i)\) 的备选方案 \(a_i\) 和 \(m \ge 0\) 个具有未指定概率 \(p(u_j)\) 的备选方案 \(u_j\),"剩余"的概率将平均分配给所有 \(u_j\);换句话说,
如果没有指定概率(\(n = 0\)),则所有展开式具有相同的概率。
概率的总体和必须为 1:
在分配概率的同时,我们检查这些属性。
函数 exp_probabilities() 返回一个映射,将规则中的所有展开式映射到它们各自的概率。
def exp_probabilities(expansions: List[Expansion],
nonterminal: str ="<symbol>") \
-> Dict[Expansion, float]:
probabilities = [exp_prob(expansion) for expansion in expansions]
prob_dist = prob_distribution(probabilities, nonterminal)
prob_mapping: Dict[Expansion, float] = {}
for i in range(len(expansions)):
expansion = exp_string(expansions[i])
prob_mapping[expansion] = prob_dist[i]
return prob_mapping
exp_probabilities() 的核心处理在 prob_distribution() 中完成,它执行实际的检查和计算。
def prob_distribution(probabilities: List[Optional[float]],
nonterminal: str = "<symbol>"):
epsilon = 0.00001
number_of_unspecified_probabilities = probabilities.count(None)
if number_of_unspecified_probabilities == 0:
sum_probabilities = cast(float, sum(probabilities))
assert abs(sum_probabilities - 1.0) < epsilon, \
nonterminal + ": sum of probabilities must be 1.0"
return probabilities
sum_of_specified_probabilities = 0.0
for p in probabilities:
if p is not None:
sum_of_specified_probabilities += p
assert 0 <= sum_of_specified_probabilities <= 1.0, \
nonterminal + ": sum of specified probabilities must be between 0.0 and 1.0"
default_probability = ((1.0 - sum_of_specified_probabilities)
/ number_of_unspecified_probabilities)
all_probabilities = []
for p in probabilities:
if p is None:
p = default_probability
all_probabilities.append(p)
assert abs(sum(all_probabilities) - 1.0) < epsilon
return all_probabilities
这是 exp_probabilities() 为注释的 <leaddigit> 元素返回的映射:
print(exp_probabilities(PROBABILISTIC_EXPR_GRAMMAR["<leaddigit>"]))
{'1': 0.301, '2': 0.176, '3': 0.125, '4': 0.097, '5': 0.079, '6': 0.067, '7': 0.058, '8': 0.051, '9': 0.046}
如果没有展开式被注释,所有展开式被选中的可能性相同,就像我们之前的语法模糊器一样。
print(exp_probabilities(PROBABILISTIC_EXPR_GRAMMAR["<digit>"]))
{'0': 0.1, '1': 0.1, '2': 0.1, '3': 0.1, '4': 0.1, '5': 0.1, '6': 0.1, '7': 0.1, '8': 0.1, '9': 0.1}
这是 exp_probabilities() 如何将任何剩余的概率分配给未注释的展开式:
exp_probabilities(PROBABILISTIC_EXPR_GRAMMAR["<expr>"])
{'<term> + <expr>': 0.1, '<term> - <expr>': 0.2, '<term>': 0.7}
检查概率
我们可以使用 exp_probabilities() 的检查功能来检查概率语法的一致性:
def is_valid_probabilistic_grammar(grammar: Grammar,
start_symbol: str = START_SYMBOL) -> bool:
if not is_valid_grammar(grammar, start_symbol):
return False
for nonterminal in grammar:
expansions = grammar[nonterminal]
_ = exp_probabilities(expansions, nonterminal)
return True
assert is_valid_probabilistic_grammar(PROBABILISTIC_EXPR_GRAMMAR)
assert is_valid_probabilistic_grammar(EXPR_GRAMMAR)
from ExpectError import ExpectError
with ExpectError():
assert is_valid_probabilistic_grammar({"<start>": [("1", opts(prob=0.5))]})
Traceback (most recent call last):
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/1729613243.py", line 2, in <module>
assert is_valid_probabilistic_grammar({"<start>": [("1", opts(prob=0.5))]})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/3185228479.py", line 8, in is_valid_probabilistic_grammar
_ = exp_probabilities(expansions, nonterminal)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/4013279880.py", line 5, in exp_probabilities
prob_dist = prob_distribution(probabilities, nonterminal) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/1431748745.py", line 8, in prob_distribution
assert abs(sum_probabilities - 1.0) < epsilon, \
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: <start>: sum of probabilities must be 1.0 (expected)
with ExpectError():
assert is_valid_probabilistic_grammar(
{"<start>": [("1", opts(prob=1.5)), "2"]})
Traceback (most recent call last):
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/2913569331.py", line 2, in <module>
assert is_valid_probabilistic_grammar(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/3185228479.py", line 8, in is_valid_probabilistic_grammar
_ = exp_probabilities(expansions, nonterminal)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/4013279880.py", line 5, in exp_probabilities
prob_dist = prob_distribution(probabilities, nonterminal) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11325/1431748745.py", line 16, in prob_distribution
assert 0 <= sum_of_specified_probabilities <= 1.0, \
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: <start>: sum of specified probabilities must be between 0.0 and 1.0 (expected)
按概率展开
现在我们已经看到了如何为语法指定概率,我们实际上可以实施概率扩展。在我们的 ProbabilisticGrammarFuzzer 中,只需要重载一个方法,即 choose_node_expansion()。对于我们可以选择的每个子节点(通常是符号的所有扩展),我们确定它们的概率(使用上面定义的 exp_probabilities()),并使用带有 weight 参数的 random.choices() 进行加权选择。
import [random](https://docs.python.org/3/library/random.html)
class ProbabilisticGrammarFuzzer(GrammarFuzzer):
"""A grammar-based fuzzer respecting probabilities in grammars."""
def check_grammar(self) -> None:
super().check_grammar()
assert is_valid_probabilistic_grammar(self.grammar, self.start_symbol)
def supported_opts(self) -> Set[str]:
return super().supported_opts() | {'prob'}
class ProbabilisticGrammarFuzzer(ProbabilisticGrammarFuzzer):
def choose_node_expansion(self, node: DerivationTree,
children_alternatives: List[Any]) -> int:
(symbol, tree) = node
expansions = self.grammar[symbol]
probabilities = exp_probabilities(expansions)
weights: List[float] = []
for children in children_alternatives:
expansion = all_terminals((symbol, children))
children_weight = probabilities[expansion]
if self.log:
print(repr(expansion), "p =", children_weight)
weights.append(children_weight)
if sum(weights) == 0:
# No alternative (probably expanding at minimum cost)
return random.choices(
range(len(children_alternatives)))[0]
else:
return random.choices(
range(len(children_alternatives)), weights=weights)[0]
我们的概率语法模糊测试器与非概率语法模糊测试器的工作方式相同,只不过它实际上尊重概率注释。让我们生成一些遵循本福特定律的“自然”数字:
natural_fuzzer = ProbabilisticGrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR, start_symbol="<leadinteger>")
print([natural_fuzzer.fuzz() for i in range(20)])
['2588', '106', '10', '2', '7', '1', '95', '4', '192', '8', '2', '1', '1', '2', '2', '208', '1036', '5592', '157', '1442']
相比之下,这些数字是纯随机的:
integer_fuzzer = GrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR, start_symbol="<leadinteger>")
print([integer_fuzzer.fuzz() for i in range(20)])
['3', '1', '56', '5', '408251024', '7', '27', '2', '9', '6456', '7', '32', '1', '4', '7', '19', '2', '6', '2', '5']
“自然”数字真的比随机的数字更“自然”吗?为了证明 ProbabilisticGrammarFuzzer 确实尊重概率注释,让我们为首位数字创建一个特定的模糊测试器:
leaddigit_fuzzer = ProbabilisticGrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR, start_symbol="<leaddigit>")
leaddigit_fuzzer.fuzz()
'8'
如果我们生成数千个首位数字,它们的分布应该再次遵循本福特定律:
trials = 10000
count = {}
for c in crange('0', '9'):
count[c] = 0
for i in range(trials):
count[leaddigit_fuzzer.fuzz()] += 1
print([(digit, count[digit] / trials) for digit in count])
[('0', 0.0), ('1', 0.3003), ('2', 0.1756), ('3', 0.1222), ('4', 0.0938), ('5', 0.0816), ('6', 0.0651), ('7', 0.06), ('8', 0.0537), ('9', 0.0477)]
命题得证!分布几乎完全符合最初指定的。我们现在有一个可以通过指定概率来控制的模糊测试器。
定向模糊测试
为单个扩展分配概率使我们能够很好地控制应该生成哪些输入。通过明智地选择概率,我们可以引导模糊测试针对特定的功能和特性——例如,针对特别关键、容易出错或最近更改的功能。
例如,考虑来自语法章节的 URL 语法。让我们假设我们刚刚对我们的安全 FTP 协议的实现进行了修改。通过提高 ftps 方案的概率,我们可以生成更多专门测试此功能的 URL。
首先,让我们定义一个设置特定选项的辅助函数:
这里有一个专门用于概率的特化:
def set_prob(grammar: Grammar, symbol: str,
expansion: Expansion, prob: Optional[float]) -> None:
"""Set the probability of the given expansion of grammar[symbol]"""
set_opts(grammar, symbol, expansion, opts(prob=prob))
让我们使用 set_prob() 给 ftps 扩展分配 80%的概率:
from Grammars import URL_GRAMMAR, extend_grammar
probabilistic_url_grammar = extend_grammar(URL_GRAMMAR)
set_prob(probabilistic_url_grammar, "<scheme>", "ftps", 0.8)
assert is_valid_probabilistic_grammar(probabilistic_url_grammar)
probabilistic_url_grammar["<scheme>"]
['http', 'https', 'ftp', ('ftps', {'prob': 0.8})]
如果我们用这种语法进行模糊测试,我们将得到大量的 ftps: 前缀:
prob_url_fuzzer = ProbabilisticGrammarFuzzer(probabilistic_url_grammar)
for i in range(10):
print(prob_url_fuzzer.fuzz())
ftps://cispa.saarland:80
ftps://user:password@cispa.saarland/
ftps://fuzzingbook.com/abc
ftps://fuzzingbook.com/x48
ftps://user:password@fuzzingbook.com/
ftps://www.google.com:2?x18=8
ftps://user:password@www.google.com:6
ftps://user:password@cispa.saarland/def
ftps://user:password@cispa.saarland/def?def=52
ftps://user:password@cispa.saarland/
类似地,我们可以将 URL 生成定向到特定的主机或端口;我们可以优先选择带有查询、片段或登录信息的 URL,或者不包含这些信息的 URL。只需设置适当的概率即可。
通过将扩展的概率设置为零,我们可以有效地禁用特定的扩展:
set_prob(probabilistic_url_grammar, "<scheme>", "ftps", 0.0)
assert is_valid_probabilistic_grammar(probabilistic_url_grammar)
prob_url_fuzzer = ProbabilisticGrammarFuzzer(probabilistic_url_grammar)
for i in range(10):
print(prob_url_fuzzer.fuzz())
ftp://user:password@cispa.saarland/x00
https://user:password@www.google.com/?def=6&def=x18&def=def
https://user:password@fuzzingbook.com:7/?def=abc
https://user:password@www.google.com:8080/
ftp://www.google.com/?abc=36&x34=5
http://user:password@cispa.saarland/
https://www.google.com/
https://user:password@www.google.com:85/?def=18
http://user:password@www.google.com:80/
http://fuzzingbook.com:80/?abc=def
注意,即使我们将扩展的概率设置为零,我们仍然可能看到扩展被采用。这可能在我们的语法模糊测试器的“关闭”阶段发生,此时扩展以最小成本关闭。在这个阶段,即使扩展的概率为零,如果这是关闭扩展所必需的,也会被采用。
让我们使用我们表达式语法中的 <expr> 规则来说明这个特性:
from Grammars import EXPR_GRAMMAR
probabilistic_expr_grammar = extend_grammar(EXPR_GRAMMAR)
probabilistic_expr_grammar["<expr>"]
['<term> + <expr>', '<term> - <expr>', '<term>']
如果我们将 <term> 扩展的概率设置为零,字符串应该会不断扩展。
set_prob(probabilistic_expr_grammar, "<expr>", "<term>", 0.0)
assert is_valid_probabilistic_grammar(probabilistic_expr_grammar)
然而,在“关闭”阶段,子表达式最终会展开为 <term>,因为这是关闭展开的唯一方式。跟踪 choose_node_expansion() 显示它只使用了一个可能展开 <term>,即使其指定的概率为零,也必须采取它。
prob_expr_fuzzer = ProbabilisticGrammarFuzzer(probabilistic_expr_grammar)
prob_expr_fuzzer.fuzz()
'44 / 7 / 1 * 3 / 6 - +1.63 + 3 * 7 + 1 - 2'
上下文中的概率
虽然指定的概率为我们提供了控制哪些展开以及它们被采取多频繁的手段,但这种控制本身可能不足以。例如,考虑以下用于 IPv4 地址的语法:
def decrange(start: int, end: int) -> List[Expansion]:
"""Return a list with string representations of numbers in the range [start, end)"""
return [repr(n) for n in range(start, end)]
IP_ADDRESS_GRAMMAR: Grammar = {
"<start>": ["<address>"],
"<address>": ["<octet>.<octet>.<octet>.<octet>"],
# ["0", "1", "2", ..., "255"]
"<octet>": decrange(0, 256)
}
print(IP_ADDRESS_GRAMMAR["<octet>"][:20])
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19']
assert is_valid_grammar(IP_ADDRESS_GRAMMAR)
我们可以轻松地使用这种语法来创建 IP 地址:
ip_fuzzer = ProbabilisticGrammarFuzzer(IP_ADDRESS_GRAMMAR)
ip_fuzzer.fuzz()
'34.155.77.136'
然而,如果我们想要为四个八位字节中的任何一个分配一个特定的概率,我们就无能为力了。我们所能做的就是为所有四个八位字节分配相同的概率分布:
probabilistic_ip_address_grammar = extend_grammar(IP_ADDRESS_GRAMMAR)
set_prob(probabilistic_ip_address_grammar, "<octet>", "127", 0.8)
probabilistic_ip_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_ip_address_grammar)
probabilistic_ip_fuzzer.fuzz()
'127.127.127.127'
如果我们想要为四个八位字节中的每一个分配 不同的 概率,我们该怎么办?
答案在于 上下文 的概念,我们在讨论 语法覆盖模糊器 时已经见过。与基于覆盖的模糊测试一样,想法是 复制 我们想要根据其上下文设置概率的元素。在我们的例子中,这意味着复制 <octet> 元素为四个单独的元素,每个元素都可以获得一个单独的概率分布。我们可以通过 duplicate_context() 方法程序化地做到这一点:
from GrammarCoverageFuzzer import duplicate_context # minor dependency
probabilistic_ip_address_grammar = extend_grammar(IP_ADDRESS_GRAMMAR)
duplicate_context(probabilistic_ip_address_grammar, "<address>")
probabilistic_ip_address_grammar["<address>"]
['<octet-1>.<octet-2>.<octet-3>.<octet-4>']
我们现在可以为每个 <octet> 符号分配不同的概率。例如,我们可以通过将它们的概率设置为 100% 来强制特定的展开:
set_prob(probabilistic_ip_address_grammar, "<octet-1>", "127", 1.0)
set_prob(probabilistic_ip_address_grammar, "<octet-2>", "0", 1.0)
assert is_valid_probabilistic_grammar(probabilistic_ip_address_grammar)
剩下的两个八位字节 <octet-3> 和 <octet-4> 没有设置特定的概率。在模糊测试期间,因此它们的所有展开(所有八位字节)仍然可用:
probabilistic_ip_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_ip_address_grammar)
[probabilistic_ip_fuzzer.fuzz() for i in range(5)]
['127.0.201.77',
'127.0.98.36',
'127.0.12.232',
'127.0.146.161',
'127.0.245.151']
就像覆盖一样,我们可以任意多次复制语法规则以获得更多更细粒度的概率控制。然而,这种更细粒度的控制也带来了必须维护这些概率的代价。因此,在下一节中,我们将讨论自动分配和调整此类概率的方法。
从样本中学习概率
概率不一定总是需要手动设置。它们也可以从其他来源 学习,特别是通过计算 在给定输入集中单个展开发生的频率。这在许多情况下都很有用,包括:
-
测试 常见 的特性。想法是在测试过程中,人们可能首先想要关注频繁发生(或频繁使用)的特性,以确保最常见的用法正确无误。
-
测试 不常见 的特性。在这里,想法是让测试生成器专注于在输入中很少见(或根本不见)的特性。这与 语法覆盖 的动机相同,但从概率的角度来看。
-
关注特定的切片。可能有一组输入特别有趣(例如,因为它们锻炼了关键功能,或者最近发现了错误)。使用这种学习分布进行模糊测试使我们能够关注这些感兴趣的功能。
让我们先介绍计数扩展和学习概率,然后详细说明这些场景。
计数扩展
我们首先实现一种方法来取一组输入并确定该集合中的扩展数量。为此,我们需要在上一章中引入的解析器将字符串输入转换为推导树。对于我们的 IP 地址语法,这是如何工作的:
from Parser import Parser, EarleyParser
IP_ADDRESS_TOKENS = {"<octet>"} # EarleyParser needs explicit tokens
parser = EarleyParser(IP_ADDRESS_GRAMMAR)
tree, *_ = parser.parse("127.0.0.1")
display_tree(tree)
在这样的树中,我们现在可以计数单个扩展。例如,在上面的树中,我们有<octet>扩展到0的两个,一个扩展到1,一个扩展到127。<octet>扩展到0占所有看到的扩展的 50%;扩展到127和1各占 25%,其他占 0%。这是我们想要分配给我们的“学习”语法的概率。
我们引入了一个类ExpansionCountMiner,它允许我们计数单个扩展发生的频率。它的初始化方法接受一个解析器(例如,一个EarleyParser),该解析器将使用适当的语法进行初始化。
from GrammarCoverageFuzzer import expansion_key # minor dependency
from Grammars import is_nonterminal
class ExpansionCountMiner:
def __init__(self, parser: Parser, log: bool = False) -> None:
assert isinstance(parser, Parser)
self.grammar = extend_grammar(parser.grammar())
self.parser = parser
self.log = log
self.reset()
属性expansion_counts保存了看到的扩展;使用add_tree()添加一个树遍历给定的树并添加所有看到的扩展。
class ExpansionCountMiner(ExpansionCountMiner):
def reset(self) -> None:
self.expansion_counts: Dict[str, int] = {}
def add_coverage(self, symbol: str, children: List[DerivationTree]) -> None:
key = expansion_key(symbol, children)
if self.log:
print("Found", key)
if key not in self.expansion_counts:
self.expansion_counts[key] = 0
self.expansion_counts[key] += 1
def add_tree(self, tree: DerivationTree) -> None:
(symbol, children) = tree
if not is_nonterminal(symbol):
return
assert children is not None
direct_children: List[DerivationTree] = [
(symbol, None) if is_nonterminal(symbol)
else (symbol, []) for symbol, c in children]
self.add_coverage(symbol, direct_children)
for c in children:
self.add_tree(c)
方法count_expansions()是面向公众的;它接受一个输入列表,解析它们,并处理生成的树。方法counts()返回找到的计数。
class ExpansionCountMiner(ExpansionCountMiner):
def count_expansions(self, inputs: List[str]) -> None:
for inp in inputs:
tree, *_ = self.parser.parse(inp)
self.add_tree(tree)
def counts(self) -> Dict[str, int]:
return self.expansion_counts
让我们在我们的 IP 地址语法上试一试。我们为我们的 IP 地址语法创建一个ExpansionCountMiner:
expansion_count_miner = ExpansionCountMiner(EarleyParser(IP_ADDRESS_GRAMMAR))
我们解析一组(小的)IP 地址并计数发生的扩展:
expansion_count_miner.count_expansions(["127.0.0.1", "1.2.3.4"])
expansion_count_miner.counts()
{'<start> -> <address>': 2,
'<address> -> <octet>.<octet>.<octet>.<octet>': 2,
'<octet> -> 127': 1,
'<octet> -> 0': 2,
'<octet> -> 1': 2,
'<octet> -> 2': 1,
'<octet> -> 3': 1,
'<octet> -> 4': 1}
你可以看到我们有一个扩展到127,两个扩展到0。这些是我们可以用来自定义概率的计数。
分配概率
由ExpansionCountMiner确定的计数分布是我们可以用来自定义我们语法的概率。为此,我们引入了一个子类ProbabilisticGrammarMiner,其方法set_expansion_probabilities()处理给定符号的所有扩展,检查它是否出现在给定的计数分布中,并使用以下公式分配概率。
给定一个从样本中挖掘出的推导树集合\(T\),我们确定符号\(S \rightarrow a_1 | \dots | a_n\)的每个替代\(a_i\)的概率\(p_i\)。
如果\(S\)在\(T\)中根本不出现,那么\(p_i\)是未指定的。
下面是set_expansion_probabilities()的实现,实现了上述公式:
class ProbabilisticGrammarMiner(ExpansionCountMiner):
def set_probabilities(self, counts: Dict[str, int]):
for symbol in self.grammar:
self.set_expansion_probabilities(symbol, counts)
def set_expansion_probabilities(self, symbol: str, counts: Dict[str, int]):
expansions = self.grammar[symbol]
if len(expansions) == 1:
set_prob(self.grammar, symbol, expansions[0], None)
return
expansion_counts = [
counts.get(
expansion_key(
symbol,
expansion),
0) for expansion in expansions]
total = sum(expansion_counts)
for i, expansion in enumerate(expansions):
p = expansion_counts[i] / total if total > 0 else None
# if self.log:
# print("Setting", expansion_key(symbol, expansion), p)
set_prob(self.grammar, symbol, expansion, p)
ProbabilisticGrammarMiner的典型用法是通过mine_probabilistic_grammar()函数,它首先从一组输入中确定一个分布,然后相应地设置概率。
class ProbabilisticGrammarMiner(ProbabilisticGrammarMiner):
def mine_probabilistic_grammar(self, inputs: List[str]) -> Grammar:
self.count_expansions(inputs)
self.set_probabilities(self.counts())
return self.grammar
让我们将其付诸实践。我们为 IP 地址创建一个语法挖掘器:
probabilistic_grammar_miner = ProbabilisticGrammarMiner(
EarleyParser(IP_ADDRESS_GRAMMAR))
我们现在使用mine_probabilistic_grammar()来挖掘语法:
probabilistic_ip_address_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar([
"127.0.0.1", "1.2.3.4"])
assert is_valid_probabilistic_grammar(probabilistic_ip_address_grammar)
这是我们的语法中八位字节的结果分布:
[expansion for expansion in probabilistic_ip_address_grammar['<octet>']
if exp_prob(expansion) > 0]
[('0', {'prob': 0.25}),
('1', {'prob': 0.25}),
('2', {'prob': 0.125}),
('3', {'prob': 0.125}),
('4', {'prob': 0.125}),
('127', {'prob': 0.125})]
如果我们使用这些概率进行模糊测试,我们将获得与样本中相同的八位字节分布:
probabilistic_ip_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_ip_address_grammar)
[probabilistic_ip_fuzzer.fuzz() for i in range(10)]
['4.2.2.0',
'2.1.4.0',
'0.1.3.127',
'0.3.0.127',
'4.0.2.1',
'3.127.0.0',
'2.2.1.1',
'4.0.1.0',
'2.4.0.1',
'0.0.3.127']
通过从样本中学习,我们可以调整我们的模糊测试,以适应这个样本的(句法)特性。
测试常见特性
现在我们来探讨我们的三个使用场景。第一个场景是从样本中直接创建概率分布,并在测试生成期间使用这些分布。这有助于将测试生成集中在那些最常用的特性上,从而最大限度地减少客户遇到失败的风险。
为了说明常见特性的测试,我们选择 URL 域名。让我们假设我们正在运行一些与 Web 相关的服务,这是我们客户访问最多的 URL 样本:
URL_SAMPLE: List[str] = [
"https://user:password@cispa.saarland:80/",
"https://fuzzingbook.com?def=56&x89=3&x46=48&def=def",
"https://cispa.saarland:80/def?def=7&x23=abc",
"https://fuzzingbook.com:80/",
"https://fuzzingbook.com:80/abc?def=abc&abc=x14&def=abc&abc=2&def=38",
"ftps://fuzzingbook.com/x87",
"https://user:password@fuzzingbook.com:6?def=54&x44=abc",
"http://fuzzingbook.com:80?x33=25&def=8",
"http://fuzzingbook.com:8080/def",
]
使用关于解析器的章节中的 Earley 解析器,我们可以将这些输入解析成解析树;不过,我们必须要指定一个标记集。
URL_TOKENS: Set[str] = {"<scheme>", "<userinfo>", "<host>", "<port>", "<id>"}
url_parser = EarleyParser(URL_GRAMMAR, tokens=URL_TOKENS)
url_input = URL_SAMPLE[2]
print(url_input)
tree, *_ = url_parser.parse(url_input)
display_tree(tree)
https://cispa.saarland:80/def?def=7&x23=abc
让我们将我们的ProbabilisticGrammarMiner类应用于这些输入,使用上述url_parser解析器,并获取一个概率 URL 语法:
probabilistic_grammar_miner = ProbabilisticGrammarMiner(url_parser)
probabilistic_url_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar(
URL_SAMPLE)
这些是我们解析过程中获得的数据:
print(probabilistic_grammar_miner.counts())
{'<start> -> <url>': 9, '<url> -> <scheme>://<authority><path><query>': 9, '<scheme> -> https': 6, '<authority> -> <userinfo>@<host>:<port>': 2, '<userinfo> -> user:password': 2, '<host> -> cispa.saarland': 2, '<port> -> 80': 5, '<path> -> /': 2, '<query> -> <query>': 4, '<authority> -> <host>': 2, '<host> -> fuzzingbook.com': 7, '<path> -> <path>': 3, '<query> -> ?<params>': 5, '<params> -> <param>&<params>': 10, '<param> -> <id>=<nat>': 9, '<id> -> def': 11, '<nat> -> <digit><digit>': 5, '<digit> -> 5': 3, '<digit> -> 6': 1, '<id> -> x89': 1, '<nat> -> <digit>': 4, '<digit> -> 3': 2, '<id> -> x46': 1, '<digit> -> 4': 2, '<digit> -> 8': 3, '<params> -> <param>': 5, '<param> -> <id>=<id>': 6, '<authority> -> <host>:<port>': 5, '<path> -> /<id>': 4, '<digit> -> 7': 1, '<id> -> x23': 1, '<id> -> abc': 7, '<id> -> x14': 1, '<digit> -> 2': 2, '<scheme> -> ftps': 1, '<id> -> x87': 1, '<port> -> 6': 1, '<id> -> x44': 1, '<scheme> -> http': 2, '<id> -> x33': 1, '<port> -> 8080': 1}
这些计数转换成个别概率。我们看到在我们的样本中,大多数 URL 使用https:方案,而没有输入使用ftp:方案。
probabilistic_url_grammar['<scheme>']
[('http', {'prob': 0.2222222222222222}),
('https', {'prob': 0.6666666666666666}),
('ftp', {'prob': 0.0}),
('ftps', {'prob': 0.1111111111111111})]
同样,我们看到大多数给定的 URL 都有多个参数:
probabilistic_url_grammar['<params>']
[('<param>', {'prob': 0.3333333333333333}),
('<param>&<params>', {'prob': 0.6666666666666666})]
当我们使用这种概率语法进行模糊测试时,这些分布反映在我们生成的输入中——没有ftp:方案,并且大多数输入都有多个参数。
g = ProbabilisticGrammarFuzzer(probabilistic_url_grammar)
[g.fuzz() for i in range(10)]
['https://fuzzingbook.com/def?def=abc&def=abc&def=def&def=abc&abc=def&def=def&abc=def',
'http://fuzzingbook.com:80/def?def=7&def=abc&abc=88',
'https://cispa.saarland/def?def=2',
'http://user:password@fuzzingbook.com:80/def?abc=abc&def=78',
'http://cispa.saarland:80/?def=54&def=abc',
'https://fuzzingbook.com:80/def?def=def',
'http://fuzzingbook.com:80/abc?abc=abc&abc=def&def=85&abc=7&def=6&abc=2&def=abc',
'https://fuzzingbook.com/abc?def=32&def=3&abc=4',
'http://fuzzingbook.com/def?abc=24&def=def&def=48',
'https://cispa.saarland:80/?abc=abc']
能够复制从样本中学习到的概率分布不仅对于关注常用特性很重要。它还可以帮助实现有效输入,特别是如果一个人学习概率在上下文中,如上所述:如果在给定上下文中,某些元素比其他元素更有可能(因为它们相互依赖),学习到的概率分布将反映这一点;因此,从这个学习到的概率分布生成的输入将有更高的可能性是有效的。我们将在下面的练习中进一步探讨这一点。
测试不常见特性
到目前为止,我们一直关注常见特性;但从测试的角度来看,也可以测试不常见特性——也就是说,这些特性在我们的使用样本中很少出现,因此在实践中练习得较少。这在安全测试中是一个常见的场景,其中人们关注不常见(可能还不太为人所知)的特性,因为用户较少意味着报告的漏洞较少,因此还有更多的漏洞被发现和利用。
为了让我们的概率语法模糊器专注于不常见特征,我们改变学习到的概率,使得常见出现的特征(即那些具有高学习概率的特征)得到低概率,反之亦然:最后的成为第一,第一成为最后。实现这种概率逆运算的一个特别简单的方法是交换它们:概率最高和最低的备选方案交换它们的概率,以此类推,概率第二高和第二低的备选方案,概率第三高和最低的备选方案,等等。
函数invert_expansion()从一个语法中接受一个扩展(备选方案的列表),并返回一个新的逆扩展,其中概率根据上述规则进行了交换。它创建一个索引列表,按增加的概率排序,然后为每个第\(n\)个元素分配索引中第\(n\)个最后一个元素的概率。
import [copy](https://docs.python.org/3/library/copy.html)
def invert_expansion(expansion: List[Expansion]) -> List[Expansion]:
def sort_by_prob(x: Tuple[int, float]) -> float:
index, prob = x
return prob if prob is not None else 0.0
inverted_expansion: List[Expansion] = copy.deepcopy(expansion)
indexes_and_probs = [(index, exp_prob(alternative))
for index, alternative in enumerate(expansion)]
indexes_and_probs.sort(key=sort_by_prob)
indexes = [i for (i, _) in indexes_and_probs]
for j in range(len(indexes)):
k = len(indexes) - 1 - j
# print(indexes[j], "gets", indexes[k])
inverted_expansion[indexes[j]][1]['prob'] = expansion[indexes[k]][1]['prob']
return inverted_expansion
下面是invert_expansion()函数的作用示例。这是我们原始的 URL 方案的概率分布:
probabilistic_url_grammar['<scheme>']
[('http', {'prob': 0.2222222222222222}),
('https', {'prob': 0.6666666666666666}),
('ftp', {'prob': 0.0}),
('ftps', {'prob': 0.1111111111111111})]
这是"逆"分布。我们看到,之前概率为零的ftp:方案现在具有最高的概率,而最常用的方案https:现在具有之前ftp:方案的零概率。
invert_expansion(probabilistic_url_grammar['<scheme>'])
[('http', {'prob': 0.1111111111111111}),
('https', {'prob': 0.0}),
('ftp', {'prob': 0.6666666666666666}),
('ftps', {'prob': 0.2222222222222222})]
这种概率交换的一个优点是概率的总和保持不变;不需要归一化。另一个优点是,逆运算的逆运算会返回原始分布:
invert_expansion(invert_expansion(probabilistic_url_grammar['<scheme>']))
[('http', {'prob': 0.2222222222222222}),
('https', {'prob': 0.6666666666666666}),
('ftp', {'prob': 0.0}),
('ftps', {'prob': 0.1111111111111111})]
注意,我们的实现并不普遍满足这个属性:如果扩展中的两个备选方案\(a_1\)和\(a_2\)具有相同的概率,那么第二次逆运算可能会将不同的概率分配给\(a_1\)和\(a_2\)。
我们可以在整个语法中应用这种扩展的逆运算:
def invert_probs(grammar: Grammar) -> Grammar:
inverted_grammar = extend_grammar(grammar)
for symbol in grammar:
inverted_grammar[symbol] = invert_expansion(grammar[symbol])
return inverted_grammar
这意味着每个扩展都会交换概率:
probabilistic_url_grammar["<digit>"]
[('0', {'prob': 0.0}),
('1', {'prob': 0.0}),
('2', {'prob': 0.14285714285714285}),
('3', {'prob': 0.14285714285714285}),
('4', {'prob': 0.14285714285714285}),
('5', {'prob': 0.21428571428571427}),
('6', {'prob': 0.07142857142857142}),
('7', {'prob': 0.07142857142857142}),
('8', {'prob': 0.21428571428571427}),
('9', {'prob': 0.0})]
inverted_probabilistic_url_grammar = invert_probs(probabilistic_url_grammar)
inverted_probabilistic_url_grammar["<digit>"]
[('0', {'prob': 0.21428571428571427}),
('1', {'prob': 0.21428571428571427}),
('2', {'prob': 0.07142857142857142}),
('3', {'prob': 0.07142857142857142}),
('4', {'prob': 0.0}),
('5', {'prob': 0.0}),
('6', {'prob': 0.14285714285714285}),
('7', {'prob': 0.14285714285714285}),
('8', {'prob': 0.0}),
('9', {'prob': 0.14285714285714285})]
如果我们现在使用这种"逆"语法进行模糊测试,生成的输入将专注于输入样本的补集。我们将得到大量关于用户/密码特征的测试,以及ftp:方案——本质上,我们语言中所有存在的特征,但在我们的输入样本中(如果有的话)很少使用。
g = ProbabilisticGrammarFuzzer(inverted_probabilistic_url_grammar)
[g.fuzz() for i in range(10)]
['ftp://www.google.com',
'ftp://www.google.com/',
'ftp://www.google.com/',
'ftp://user:password@cispa.saarland',
'ftps://www.google.com/',
'ftp://user:password@www.google.com/',
'ftp://user:password@www.google.com',
'ftp://www.google.com/',
'ftp://user:password@www.google.com',
'ftp://user:password@www.google.com/']
除了只有常见或只有不常见特征之外,还可以创建混合形式——例如,在常见环境中测试不常见特征。这在安全测试中可能很有用,其中可能需要一个无害的(常见)"信封"与一个(不常见)"有效载荷"相结合。这完全取决于我们在哪里以及如何调整概率。
从输入片段中学习概率
在我们之前的例子中,我们已经从所有输入中学习以生成常见或不常见的输入。然而,我们也可以从输入的子集中学习,以关注该子集中存在的特征(或者相反,避免其特征)。例如,如果我们知道某些输入子集涵盖了某个感兴趣的功能(比如,因为它特别关键或因为最近进行了更改),我们可以从这个子集学习,并将测试生成集中在其特征上。
为了说明这种方法,让我们使用在覆盖率章节中引入的 CGI 语法。在我们的 CGI 解码器中,我们对第 25 行特别感兴趣——即处理一个%字符后跟两个有效十六进制数字的行:
...
elif c == '%':
digit_high, digit_low = s[i + 1], s[i + 2]
i += 2
if digit_high in hex_values and digit_low in hex_values:
v = hex_values[digit_high] * 16 + hex_values[digit_low] ### Line 25
t += chr(v)
...
假设我们并不确切知道第 25 行在什么条件下被执行——但即便如此,我们仍然希望对其进行彻底测试。然而,借助我们的概率学习工具,我们可以学习这些条件。我们从一组随机输入开始,并考虑涵盖第 25 行的子集。
from Coverage import Coverage, cgi_decode
from Grammars import CGI_GRAMMAR
cgi_fuzzer = GrammarFuzzer(CGI_GRAMMAR)
trials = 100
coverage = {}
for i in range(trials):
cgi_input = cgi_fuzzer.fuzz()
with Coverage() as cov:
cgi_decode(cgi_input)
coverage[cgi_input] = cov.coverage()
这些都是涵盖第 25 行的随机输入:
coverage_slice = [cgi_input for cgi_input in coverage
if ('cgi_decode', 25) in coverage[cgi_input]]
print(coverage_slice)
['%36%c1%d2c2%4f++e', '%2c%90+', 'c++%8b', 'a+%95', '%76%00', '+5', '4', '+', 'a', '%71', '%a2', '%39%51%db%7c%66', '++', '%2a', 'd', '%b9225', '++c', '%13+b', '%32', '2', '+2+', '-1%b11%d8', '%08', 'd+4', '%a3', '%fe', 'e', '1++', '+%82%ed%42', '%d5', '%5bc', '51', '%b0', '%47', 'b+%20', '%d7', '0+%17', '%a5', '%84', 'e+4%fc', 'd%6f+++1a', '3', 'd+%95+', '%1e', '%244', '%3c', '5%75+%99%3c', '+-', 'b', '%80%74+a', '%a7', '21', 'ae', '%c1%da', '%c5+', 'b%44', '%70%c4_3', '1', 'dd+ad', '4%63', '%364+', '%79%ab', '%8a%f6', '%53%43', '+++%55+b5', '%51+++', '+%28', '1%1c+', '+%41%9b', '%0d%20', '+%3d+%c2']
实际上,大约一半的输入涵盖了第 25 行:
len(coverage_slice) / trials
0.71
让我们从这些输入片段中学习一个概率语法:
probabilistic_grammar_miner = ProbabilisticGrammarMiner(
EarleyParser(CGI_GRAMMAR))
probabilistic_cgi_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar(
coverage_slice)
assert is_valid_probabilistic_grammar(probabilistic_cgi_grammar)
我们可以看到,百分号出现的可能性非常高:
probabilistic_cgi_grammar['<letter>']
[('<plus>', {'prob': 0.2556818181818182}),
('<percent>', {'prob': 0.42045454545454547}),
('<other>', {'prob': 0.32386363636363635})]
使用这个语法,我们现在可以生成专门针对第 25 行的测试:
probabilistic_cgi_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_cgi_grammar)
print([probabilistic_cgi_fuzzer.fuzz() for i in range(20)])
['5', '%3e', '+4%e6', '%19+', '%da+', '%5c', '%28%5e', '%b5+', '2d4', '455%8c', '5%cb', '%4c+%5c4e5+e+%aa%db%1d', 'a%1c%13', 'e+', '%08%cc', 'b3', '3', '%c0%25', '+', '++c+%54d']
trials = 100
coverage = {}
for i in range(trials):
cgi_input = probabilistic_cgi_fuzzer.fuzz()
with Coverage() as cov:
cgi_decode(cgi_input)
coverage[cgi_input] = cov.coverage()
我们可以看到,涵盖第 25 行的输入比例已经很高,这表明我们的聚焦是有效的:
coverage_slice: List[str] = [cgi_input for cgi_input in coverage
if ('cgi_decode', 25) in coverage[cgi_input]]
len(coverage_slice) / trials
0.88
再重复一次,可以进一步提高聚焦度:
for run in range(3):
probabilistic_cgi_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar(
coverage_slice)
probabilistic_cgi_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_cgi_grammar)
trials = 100
coverage = {}
for i in range(trials):
cgi_input = probabilistic_cgi_fuzzer.fuzz()
with Coverage() as cov:
cgi_decode(cgi_input)
coverage[cgi_input] = cov.coverage()
coverage_slice = [cgi_input for cgi_input in coverage
if ('cgi_decode', 25) in coverage[cgi_input]]
len(coverage_slice) / trials
0.83
通过从样本输入的子集中学习(和重新学习)概率,我们可以将模糊器专门化到该子集的特性——在我们的案例中,包含百分号和有效十六进制字母的输入。我们可以专门化事物的程度是由我们可以控制的变量数量决定的——在我们的案例中,是单个规则的概率。正如上面所讨论的,添加更多上下文到语法中,将增加变量的数量,从而增加专门化的程度。
然而,高度的专业化限制了我们在选定范围之外探索组合的可能性,并限制了发现由这些组合引起的错误的可能性。这种权衡在机器学习中被称为“探索与利用”——是尝试尽可能探索(可能浅显的)多种组合,还是专注于(利用)特定区域?最终,一切都取决于错误在哪里,以及我们最有可能在哪里找到它们。分配和学习概率使我们能够控制搜索策略——从常见到不常见再到特定的子集。
检测不自然数字
让我们通过回到我们的入门示例来结束这一章。我们说过,本福特定律不仅允许我们生成,还允许我们检测“不自然”的首位数字分布,例如简单随机选择产生的那些。
如果我们使用忽略概率的常规 GrammarFuzzer 类(随机)生成首位数字,这将是我们为每个首位数字得到的分布:
sample_size = 1000
random_integer_fuzzer = GrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR,
start_symbol="<leaddigit>")
random_integers = [random_integer_fuzzer.fuzz() for i in range(sample_size)]
random_counts = [random_integers.count(str(c)) for c in crange('1', '9')]
random_counts
[112, 104, 106, 103, 133, 97, 126, 120, 99]
(为了简单起见,我们在这里使用简单的列表 count() 方法,而不是部署完整的 ProbabilisticGrammarMiner。)
如果我们有一个自然分布的首位数字,这是我们预期的:
expected_prob_counts = [
exp_prob(
PROBABILISTIC_EXPR_GRAMMAR["<leaddigit>"][i]) *
sample_size for i in range(9)]
print(expected_prob_counts)
[301.0, 176.0, 125.0, 97.0, 79.0, 67.0, 58.0, 51.0, 46.0]
如果我们有一个随机分布,我们预期会有一个均匀分布:
expected_random_counts = [sample_size / 9 for i in range(9)]
print(expected_random_counts)
[111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111]
哪个分布更好地匹配我们的 random_counts 首位数字?为此,我们运行 \(\chi²\)-测试来比较我们找到的分布(random_counts)与“自然”首位数字分布 expected_prob_counts 和随机分布 expected_random_counts。
from [scipy.stats](https://docs.scipy.org/doc/scipy/reference/) import chisquare
结果表明,观察到的分布遵循“自然”分布的概率为零(pvalue = 0.0):
chisquare(random_counts, expected_prob_counts)
Power_divergenceResult(statistic=np.float64(435.87462280458345), pvalue=np.float64(3.925216460200427e-89))
然而,有 97%的概率,观察到的行为遵循随机分布:
chisquare(random_counts, expected_random_counts)
Power_divergenceResult(statistic=np.float64(11.42), pvalue=np.float64(0.17901776899017763))
因此,如果你发现一些发布的数据并对其有效性表示怀疑,你可以运行上述测试来检查它们是否可能是自然的。更好的是,坚持要求作者使用 Jupyter 笔记本来生成他们的结果,这样你就可以检查计算的每一步 😃
经验教训
-
通过指定概率,可以将模糊测试引导到感兴趣的输入特征。
-
从样本中学习概率允许人们专注于在输入样本中常见或不常见的特征。
-
从样本的子集中学习概率允许人们产生更相似的输入。
下一步
现在我们已经将概率和语法(以及回顾了解析器和语法)结合起来,我们为许多应用奠定了基础。我们接下来的章节将专注于
-
如何[最小化]失败输入(Reducer.html)
-
如何在函数级别上切割和生成测试
-
如何自动测试(Web)用户界面
享受吧!
背景
通过解析数据语料库来挖掘概率的想法首次在“学习模糊测试:使用输入数据的概率生成模型进行独立的应用程序模糊测试”[Patra 等人,2016]中被提出,该研究还学习了应用概率规则进行派生树。首先在“来自地狱的输入:从常见样本生成不常见输入”[Esteban Pavese 等人,2018]中应用了这个想法,并反转概率或从切片中学习。
我们对贝纳德定律的阐述遵循这篇文章。
练习
练习 1:具有覆盖率的概率模糊测试
创建一个扩展 GrammarCoverageFuzzer 的 ProbabilisticGrammarCoverageFuzzer 类,它具有概率能力。想法是首先覆盖所有未覆盖的扩展(如 GrammarCoverageFuzzer),一旦所有扩展都被覆盖,就按概率(如 ProbabilisticGrammarFuzzer)进行。
为了达到这个目的,定义新的 choose_covered_node_expansion() 和 choose_uncovered_node_expansion() 方法实例,这些方法基于给定的权重选择一个扩展。
使用笔记本 来完成练习并查看解决方案。
如果你是一个高级程序员,通过从 GrammarCoverageFuzzer 和 ProbabilisticGrammarFuzzer 的 多重继承 来实现这个类以实现这一点。
多重继承是一个棘手的问题。如果你有两个类 \(A'\) 和 \(A''\),它们都从 \(A\) 继承,\(A\) 的相同方法 $m() 可能会在 $A'$ 和 $A''$ 中被重载。如果你现在从 *两个* $A'$ 和 $A''$ 继承,并调用 $m(),应该调用哪个 $m() 实现呢?Python 通过简单地调用继承的第一个类中的 $m() 方法来“解决”这个冲突。
为了避免此类冲突,可以检查继承的顺序是否会影响结果。inheritance_conflicts() 方法会相互比较属性;如果它们指向不同的代码,你必须解决冲突。
使用笔记本 来完成练习并查看解决方案。
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import inheritance_conflicts
inheritance_conflicts(GrammarCoverageFuzzer, ProbabilisticGrammarFuzzer)
['choose_node_expansion']
除了 choose_covered_node_expansion() 和 choose_uncovered_node_expansion() 之外,你还需要实现这个方法以支持多重继承。
使用笔记本 来完成练习并查看解决方案。
练习 2:从过去的错误中学习
如果从已知之前导致失败的输入中学习,从一组输入中学习可以非常有价值。在这个练习中,你将学习从过去的安全漏洞中学习分布。
-
下载 js-vuln-db,一组 JavaScript 引擎漏洞。每个漏洞都附带用于测试它的代码。
-
使用
re.findall()和适当的正则表达式从代码中提取所有 数字字面量。 -
将这些字面量转换为(十进制)数值并计算它们各自的 occurrence。
-
创建一个语法
RISKY_NUMBERS,以反映上述计数的概率来生成这些数字。
当然,漏洞不仅仅是特定的数字,但有些数字比其他数字更有可能引起错误。下次你对系统进行模糊测试时,不要随机生成数字;相反,从 RISKY_NUMBERS 中选择一个 😃
使用笔记本 来完成练习并查看解决方案。
本项目的内容受Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议的许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,受MIT 许可协议的许可。 最后修改:2024-11-09 17:07:29+01:00 • 引用 • 版权信息
如何引用这篇作品
Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, and Christian Holler: "概率语法模糊测试". In Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, and Christian Holler, "模糊测试书籍", www.fuzzingbook.org/html/ProbabilisticGrammarFuzzer.html. Retrieved 2024-11-09 17:07:29+01:00.
@incollection{fuzzingbook2024:ProbabilisticGrammarFuzzer,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Probabilistic Grammar Fuzzing},
year = {2024},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/ProbabilisticGrammarFuzzer.html}},
note = {Retrieved 2024-11-09 17:07:29+01:00},
url = {https://www.fuzzingbook.org/html/ProbabilisticGrammarFuzzer.html},
urldate = {2024-11-09 17:07:29+01:00}
}
使用生成器进行模糊测试
在本章中,我们展示了如何通过 函数 扩展语法——这些函数在语法扩展期间执行,可以生成、检查或更改生成的元素。向语法中添加函数允许进行非常灵活的测试生成,将语法生成和编程的最佳之处结合起来。
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('6Z35ChunpLY')
先决条件
- 由于本章深入探讨了 高效语法模糊测试章节 中讨论的技术,因此建议对技术有良好的理解。
概述
要使用本章提供的代码(Importing.html),请编写
>>> from fuzzingbook.GeneratorGrammarFuzzer import <identifier>
然后利用以下功能。
本章介绍了将 函数 附接到单个生成规则的能力:
-
一个
pre函数在扩展之前执行。其结果(通常是字符串)可以 替换 实际的扩展。 -
一个
post函数在扩展之后执行。如果它返回一个字符串,则该字符串替换扩展;如果它返回False,则触发新的扩展。
两个函数都可以返回 None 以完全不干扰语法的生成。
要将函数 F 附接到语法中单个扩展 S 上,将 S 替换为
(S, opts(pre=F)) # Set a function to be executed before expansion
或
(S, opts(post=F)) # Set a function to be executed after expansion
这里有一个例子,要从程序给出的列表中获取一个区号,我们可以编写:
>>> from Grammars import US_PHONE_GRAMMAR, extend_grammar, opts
>>> def pick_area_code():
>>> return random.choice(['555', '554', '553'])
>>> PICKED_US_PHONE_GRAMMAR = extend_grammar(US_PHONE_GRAMMAR,
>>> {
>>> "<area>": [("<lead-digit><digit><digit>", opts(pre=pick_area_code))]
>>> })
GeneratorGrammarFuzzer 将提取并解释这些选项。以下是一个示例:
>>> picked_us_phone_fuzzer = GeneratorGrammarFuzzer(PICKED_US_PHONE_GRAMMAR)
>>> [picked_us_phone_fuzzer.fuzz() for i in range(5)]
['(554)732-6097',
'(555)469-0662',
'(553)671-5358',
'(555)686-8011',
'(554)453-4067']
如您所见,现在所有的区号都来自 pick_area_code()。这样的定义允许将程序代码(如 pick_area_code())与语法紧密关联。
PGGCFuzzer 类整合了来自 GrammarFuzzer 类 及其 基于覆盖、基于概率 和 基于生成器 的所有特性。
唯一支持所有 fuzzingbook 功能的基于语法的模糊测试器是">
结合 GeneratorGrammarFuzzer 的特性
以及 ProbabilisticGrammarCoverageFuzzer。《ProbabilisticGeneratorGrammarCoverageFuzzer》
构造函数。
replacement_attempts - see GeneratorGrammarFuzzer constructor.
所有其他关键字参数都传递给 ProbabilisticGrammarFuzzer。《init()`
从语法中生成一个推导树。《fuzz_tree()`
支持的选项集合。应在子类中重载。《supported_opts()`
高效地从语法中生成字符串,使用推导树。《GeneratorGrammarFuzzer》
从 grammar 中生成字符串,从 start_symbol 开始。《Produce strings from grammar, starting with start_symbol.
如果提供了 min_nonterminals 或 max_nonterminals,则使用它们作为限制
用于生成非终结符的数量。
如果设置了 disp,则显示中间推导树。
如果设置了log,则将中间步骤作为文本显示在标准输出上。《__init__()
从语法生成推导树。"><fuzz_tree() apply_result()
返回expandable_children中子树的索引
要选择的用于展开的树。默认为随机。"><choose_tree_expansion() eval_function()
在树中选择一个未展开的符号;展开它。
可在子类中重载。"><expand_tree_once() find_expansion()
在选择后处理儿童。默认情况下,不执行任何操作。 process_chosen_children()
支持的选项集合。应在子类中重载。">
从语法生成,旨在覆盖所有展开。">
从语法中高效地生成字符串,使用推导树。">
从grammar中生成字符串,从start_symbol开始。
如果提供了min_nonterminals或max_nonterminals,则使用它们作为限制
生成非终结符的数量。
如果disp被设置,显示中间的推导树。
如果log被设置,将中间步骤作为文本输出到标准输出。">
从语法中生成一个字符串。">
从语法中生成一个推导树。">
模糊测试器的基类。">
使用模糊输入运行runner">
使用模糊输入运行runner,trials次。《runs()
从语法中生成,旨在覆盖所有扩展。《GrammarCoverageFuzzer
基于语法的模糊测试器,尊重语法中的概率。《ProbabilisticGrammarFuzzer
在选择扩展时,优先选择未被覆盖的扩展。《SimpleGrammarCoverageFuzzer
在生成过程中跟踪语法覆盖。《TrackingGrammarCoverageFuzzer
从grammar生成字符串,从start_symbol开始。《译文:》
如果提供了min_nonterminals或max_nonterminals,则使用它们作为限制。
对于生成的非终结符数量。
如果设置了disp,则显示中间推导树。
如果设置了log,则将中间步骤作为文本显示在标准输出上。《init()`
示例:测试信用卡系统
假设你在一个购物系统中工作,该系统除了其他几个功能外,还允许客户使用信用卡支付。你的任务是测试支付功能。
为了简化问题,我们将假设我们只需要两份数据——一个 16 位的信用卡号和要收费的金额。这两份数据都可以通过语法轻松生成,如下所示:
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 Callable, Set, List, Dict, Optional, Iterator, Any, Union, Tuple, cast
from Fuzzer import Fuzzer
from Grammars import EXPR_GRAMMAR, is_valid_grammar, is_nonterminal, extend_grammar
from Grammars import opts, exp_opt, exp_string, crange, Grammar, Expansion
from GrammarFuzzer import DerivationTree
CHARGE_GRAMMAR: Grammar = {
"<start>": ["Charge <amount> to my credit card <credit-card-number>"],
"<amount>": ["$<float>"],
"<float>": ["<integer>.<digit><digit>"],
"<integer>": ["<digit>", "<integer><digit>"],
"<digit>": crange('0', '9'),
"<credit-card-number>": ["<digits>"],
"<digits>": ["<digit-block><digit-block><digit-block><digit-block>"],
"<digit-block>": ["<digit><digit><digit><digit>"],
}
assert is_valid_grammar(CHARGE_GRAMMAR)
所有这些都工作得很好——我们可以生成任意数量的信用卡号:
from GrammarFuzzer import GrammarFuzzer, all_terminals
g = GrammarFuzzer(CHARGE_GRAMMAR)
[g.fuzz() for i in range(5)]
['Charge $9.40 to my credit card 7166898575638313',
'Charge $8.79 to my credit card 6845418694643271',
'Charge $5.64 to my credit card 6655894657077388',
'Charge $0.60 to my credit card 2596728464872261',
'Charge $8.90 to my credit card 2363769342732142']
然而,当实际上用这些数据测试我们的系统时,我们发现存在两个问题:
-
我们想测试被收费的特定金额——例如,超过信用卡额度的金额。
-
我们发现,10 个信用卡号中有 9 个因为校验和不正确而被拒绝。如果我们想测试信用卡号的拒绝,这没问题——但如果我们想测试处理收费的实际功能,我们需要有效的号码。
我们可以忽略这些问题;毕竟,最终只是一件时间问题,直到生成大量有效数字。至于第一个问题,我们也可以通过适当地更改语法来解决它——比如说,只生成至少有六个前导数字的充电。然而,将此推广到任意范围的值将很麻烦。
然而,第二个问题,信用卡号码的校验和,却更为复杂——至少在语法方面,一个复杂的算术运算,如校验和,不能仅用语法表示——至少不能在我们这里使用的上下文无关语法中。原则上,可以在一个上下文相关语法中这样做,但这将毫无乐趣。我们想要的是一个机制,允许我们将程序性计算附加到我们的语法中,将两者的优点结合起来。
将函数附加到扩展
本章的关键思想是扩展语法,以便可以将Python 函数附加到单个扩展。这些函数可以执行
-
在扩展之前,替换要扩展的元素为计算值;或者
-
扩展后,检查生成的元素,并可能替换它们。
在这两种情况下,函数都是使用在语法章节中引入的opts()扩展机制指定的。因此,它们与符号s的特定扩展\(e\)相关联。
在扩展之前调用的函数
使用pre选项定义的函数在将s扩展为\(e\)之前被调用。它的值替换要生成的扩展\(e\)。为了生成上面信用卡示例的值,我们可以定义一个预扩展生成函数
import [random](https://docs.python.org/3/library/random.html)
def high_charge() -> float:
return random.randint(10000000, 90000000) / 100.0
使用opts(),我们可以将此函数附加到语法:
CHARGE_GRAMMAR.update({
"<float>": [("<integer>.<digit><digit>", opts(pre=high_charge))],
})
目的是,每当<float>被扩展时,函数high_charge就会被调用以生成<float>的值。(在语法中,实际的扩展仍然存在,对于忽略函数的模糊器,如GrammarFuzzer)。
由于与语法相关的函数通常非常简单,我们还可以使用lambda表达式内联它们。lambda 表达式用于匿名函数,这些函数的范围和功能有限。以下是一个示例:
def apply_twice(function, x):
return function(function(x))
apply_twice(lambda x: x * x, 2)
16
在这里,我们不必给要应用的function两次命名(比如,square());相反,我们在调用时内联应用它。
使用lambda,我们的语法看起来是这样的:
CHARGE_GRAMMAR.update({
"<float>": [("<integer>.<digit><digit>",
opts(pre=lambda: random.randint(10000000, 90000000) / 100.0))]
})
在扩展之后调用的函数
使用post选项定义的函数在将s扩展为\(e\)之后被调用,并将\(e\)中符号的扩展值作为参数传递。扩展后的函数可以以两种方式服务:
-
它可以作为扩展值的约束或过滤器,如果扩展有效则返回
True,否则返回False;如果返回False,则尝试另一个扩展。 -
它还可以作为修复,返回一个字符串值;就像预扩展函数一样,返回的值替换了扩展。
对于我们的信用卡示例,我们可以选择两种方式。如果我们有一个函数check_credit_card(s),它对于有效的数字 s 返回 True,对于无效的数字返回 False,我们将选择第一种选项:
CHARGE_GRAMMAR.update({
"<credit-card-number>": [("<digits>", opts(post=lambda digits: check_credit_card(digits)))]
})
使用这样的过滤器,只能生成有效的信用卡。平均而言,每次check_credit_card()满足条件时,仍然需要 10 次尝试。
如果我们有一个函数fix_credit_card(s),它改变数字以使校验和有效并返回“修复”后的数字,我们可以使用这个函数代替:
CHARGE_GRAMMAR.update({
"<credit-card-number>": [("<digits>", opts(post=lambda digits: fix_credit_card(digits)))]
})
在这里,每个数字只生成一次然后修复。这非常高效。
用于信用卡的校验和函数是Luhn 算法,这是一个简单而有效的公式。
def luhn_checksum(s: str) -> int:
"""Compute Luhn's check digit over a string of digits"""
LUHN_ODD_LOOKUP = (0, 2, 4, 6, 8, 1, 3, 5, 7,
9) # sum_of_digits (index * 2)
evens = sum(int(p) for p in s[-1::-2])
odds = sum(LUHN_ODD_LOOKUP[int(p)] for p in s[-2::-2])
return (evens + odds) % 10
def valid_luhn_checksum(s: str) -> bool:
"""Check whether the last digit is Luhn's checksum over the earlier digits"""
return luhn_checksum(s[:-1]) == int(s[-1])
def fix_luhn_checksum(s: str) -> str:
"""Return the given string of digits, with a fixed check digit"""
return s[:-1] + repr(luhn_checksum(s[:-1]))
luhn_checksum("123")
8
fix_luhn_checksum("123x")
'1238'
我们可以在我们的信用卡语法中使用这些函数:
check_credit_card: Callable[[str], bool] = valid_luhn_checksum
fix_credit_card: Callable[[str], str] = fix_luhn_checksum
fix_credit_card("1234567890123456")
'1234567890123458'
用于整合约束的类
虽然指定函数很容易,但我们的语法 fuzzer 将简单地忽略它们,就像它忽略所有扩展一样。尽管如此,它将发出警告:
g = GrammarFuzzer(CHARGE_GRAMMAR)
g.fuzz()
'Charge $4.05 to my credit card 0637034038177393'
我们需要定义一个特殊的 fuzzer,它实际上调用给定的pre和post函数并根据其行为。我们将其命名为GeneratorGrammarFuzzer:
class GeneratorGrammarFuzzer(GrammarFuzzer):
def supported_opts(self) -> Set[str]:
return super().supported_opts() | {"pre", "post", "order"}
我们定义自定义函数来访问pre和post选项:
def exp_pre_expansion_function(expansion: Expansion) -> Optional[Callable]:
"""Return the specified pre-expansion function, or None if unspecified"""
return exp_opt(expansion, 'pre')
def exp_post_expansion_function(expansion: Expansion) -> Optional[Callable]:
"""Return the specified post-expansion function, or None if unspecified"""
return exp_opt(expansion, 'post')
order属性将在本章后面使用。
在扩展之前生成元素
我们的首要任务是实现预扩展函数——即在扩展之前调用的函数,用于替换要扩展的值。为此,我们挂钩到process_chosen_children()方法,该方法在扩展之前获取选定的子项。我们将其设置为调用给定的pre函数并将结果应用于子项,可能替换它们。
import [inspect](https://docs.python.org/3/library/inspect.html)
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def process_chosen_children(self, children: List[DerivationTree],
expansion: Expansion) -> List[DerivationTree]:
function = exp_pre_expansion_function(expansion)
if function is None:
return children
assert callable(function)
if inspect.isgeneratorfunction(function):
# See "generators", below
result = self.run_generator(expansion, function)
else:
result = function()
if self.log:
print(repr(function) + "()", "=", repr(result))
return self.apply_result(result, children)
def run_generator(self, expansion: Expansion, function: Callable):
...
apply_result()方法从预扩展函数中获取结果并将其应用于子项。确切的效果取决于结果类型:
-
一个字符串 \(s\) 将整个扩展替换为 \(s\)。
-
一个列表 \([x_1, x_2, \dots, x_n]\) 对于每个不是
None的 \(x_i\),将第 \(i\) 个符号替换为 \(x_i\)。将None指定为列表元素 \(x_i\) 有助于保持该元素不变。如果 \(x_i\) 不是一个字符串,它将被转换为字符串。 -
None的值将被忽略。如果只想在扩展时调用函数而不影响扩展的字符串,这很有用。 -
布尔值将被忽略。这对于下面讨论的后续扩展函数很有用。
-
所有其他类型都转换为字符串,替换整个扩展。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def apply_result(self, result: Any,
children: List[DerivationTree]) -> List[DerivationTree]:
if isinstance(result, str):
children = [(result, [])]
elif isinstance(result, list):
symbol_indexes = [i for i, c in enumerate(children)
if is_nonterminal(c[0])]
for index, value in enumerate(result):
if value is not None:
child_index = symbol_indexes[index]
if not isinstance(value, str):
value = repr(value)
if self.log:
print(
"Replacing", all_terminals(
children[child_index]), "by", value)
# children[child_index] = (value, [])
child_symbol, _ = children[child_index]
children[child_index] = (child_symbol, [(value, [])])
elif result is None:
pass
elif isinstance(result, bool):
pass
else:
if self.log:
print("Replacing", "".join(
[all_terminals(c) for c in children]), "by", result)
children = [(repr(result), [])]
return children
示例:数值范围
在上述扩展之后,我们完全支持预扩展函数。使用增强的CHARGE_GRAMMAR,我们发现实际上使用了预扩展lambda函数:
charge_fuzzer = GeneratorGrammarFuzzer(CHARGE_GRAMMAR)
charge_fuzzer.fuzz()
'Charge $439383.87 to my credit card 2433506594138520'
日志显示,当调用预扩展函数时发生了更多细节。我们看到扩展 <integer>.<digit><digit> 被直接替换为计算值:
amount_fuzzer = GeneratorGrammarFuzzer(
CHARGE_GRAMMAR, start_symbol="<amount>", log=True)
amount_fuzzer.fuzz()
Tree: <amount>
Expanding <amount> randomly
Tree: $<float>
Expanding <float> randomly
<function <lambda> at 0x1109f2ac0>() = 382087.72
Replacing <integer>.<digit><digit> by 382087.72
Tree: $382087.72
'$382087.72'
'$382087.72'
示例:更多数值范围
我们还可以在其他上下文中使用这样的预扩展函数。假设我们想要生成每个数字都在 100 到 200 之间的算术表达式。我们可以相应地扩展 EXPR_GRAMMAR:
expr_100_200_grammar = extend_grammar(EXPR_GRAMMAR,
{
"<factor>": [
"+<factor>", "-<factor>", "(<expr>)",
# Generate only the integer part with a function;
# the fractional part comes from
# the grammar
("<integer>.<integer>", opts(
pre=lambda: [random.randint(100, 200), None])),
# Generate the entire integer
# from the function
("<integer>", opts(
pre=lambda: random.randint(100, 200))),
],
}
)
expr_100_200_fuzzer = GeneratorGrammarFuzzer(expr_100_200_grammar)
expr_100_200_fuzzer.fuzz()
'(108.6 / 155 + 177) / 118 * 120 * 107 + 151 + 195 / -200 - 150 * 188 / 147 + 112'
支持 Python 生成器
Python 语言有自己的生成器函数概念,我们当然也希望支持它。Python 中的 生成器函数 是一个返回所谓的 迭代器对象 的函数,我们可以逐个迭代它。
在 Python 中创建生成器函数时,定义一个普通函数,使用 yield 语句而不是 return 语句。虽然 return 语句会终止函数,但 yield 语句会暂停其执行,保存所有状态,以便稍后在下一次连续调用中恢复。
这里是一个生成器函数的示例。当第一次调用时,iterate() 产生值 1,然后是 2、3,依此类推:
def iterate():
t = 0
while True:
t = t + 1
yield t
我们可以在循环中使用 iterate,就像 range() 函数(它也是一个生成器函数)一样:
for i in iterate():
if i > 10:
break
print(i, end=" ")
1 2 3 4 5 6 7 8 9 10
我们还可以将 iterate() 作为预扩展生成器函数使用,确保它将创建一个接一个的连续整数:
iterate_grammar = extend_grammar(EXPR_GRAMMAR,
{
"<factor>": [
"+<factor>", "-<factor>", "(<expr>)",
# "<integer>.<integer>",
# Generate one integer after another
# from the function
("<integer>", opts(pre=iterate)),
],
})
为了支持生成器,我们上面的 process_chosen_children() 方法检查一个函数是否是生成器;如果是,它调用 run_generator() 方法。当 run_generator() 在 fuzz_tree()(或 fuzz())调用期间第一次看到该函数时,它调用该函数以创建一个生成器对象;这被保存在 generators 属性中,然后调用。后续调用直接转到生成器,保留状态。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def fuzz_tree(self) -> DerivationTree:
self.reset_generators()
return super().fuzz_tree()
def reset_generators(self) -> None:
self.generators: Dict[str, Iterator] = {}
def run_generator(self, expansion: Expansion,
function: Callable) -> Iterator:
key = repr((expansion, function))
if key not in self.generators:
self.generators[key] = function()
generator = self.generators[key]
return next(generator)
这是否可行?让我们在我们的语法上运行我们的 fuzzer,使用 iterator():
iterate_fuzzer = GeneratorGrammarFuzzer(iterate_grammar)
iterate_fuzzer.fuzz()
'1 * ++++3 / ---+4 - 2 * +--6 / 7 * 10 - (9 - 11) - 5 + (13) * 14 + 8 + 12'
我们看到该表达式包含所有以 1 开头的整数。
除了指定我们自己的 Python 生成器函数,如 iterate(),我们还可以使用内置的 Python 生成器之一,如 range()。这也会生成以 1 开头的整数:
iterate_grammar = extend_grammar(EXPR_GRAMMAR,
{
"<factor>": [
"+<factor>", "-<factor>", "(<expr>)",
("<integer>", opts(pre=range(1, 1000))),
],
})
还可以使用 Python 列表推导式,通过在括号中添加它们的生成器函数:
iterate_grammar = extend_grammar(EXPR_GRAMMAR,
{
"<factor>": [
"+<factor>", "-<factor>", "(<expr>)",
("<integer>", opts(
pre=(x for x in range(1, 1000)))),
],
})
注意,上述两种语法实际上会导致当创建超过 1,000 个整数时,fuzzer 会引发异常,但您会发现修复这个问题非常容易。
最后,yield 实际上是一个表达式,而不是一个语句,因此也可以有一个 lambda 表达式 yield 一个值。如果您发现这个用法有合理的用途,请告诉我们。
扩展后的元素检查和修复
现在,让我们转向我们要支持的第二个函数集——即,后扩展函数。使用它们的最简单方法是,在生成整个树之后运行它们,就像 pre 函数一样处理替换。然而,如果其中一个返回 False,我们将重新开始。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def fuzz_tree(self) -> DerivationTree:
while True:
tree = super().fuzz_tree()
(symbol, children) = tree
result, new_children = self.run_post_functions(tree)
if not isinstance(result, bool) or result:
return (symbol, new_children)
self.restart_expansion()
def restart_expansion(self) -> None:
# To be overloaded in subclasses
self.reset_generators()
方法run_post_functions()递归地应用于推导树的所有节点。对于每个节点,它确定应用的扩展,然后运行与该扩展关联的函数。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
# Return True iff all constraints of grammar are satisfied in TREE
def run_post_functions(self, tree: DerivationTree,
depth: Union[int, float] = float("inf")) \
-> Tuple[bool, Optional[List[DerivationTree]]]:
symbol: str = tree[0]
children: List[DerivationTree] = cast(List[DerivationTree], tree[1])
if children == []:
return True, children # Terminal symbol
try:
expansion = self.find_expansion(tree)
except KeyError:
# Expansion (no longer) found - ignore
return True, children
result = True
function = exp_post_expansion_function(expansion)
if function is not None:
result = self.eval_function(tree, function)
if isinstance(result, bool) and not result:
if self.log:
print(
all_terminals(tree),
"did not satisfy",
symbol,
"constraint")
return False, children
children = self.apply_result(result, children)
if depth > 0:
for c in children:
result, _ = self.run_post_functions(c, depth - 1)
if isinstance(result, bool) and not result:
return False, children
return result, children
辅助方法find_expansion()接受一个子树tree,并确定应用于创建tree中子节点的语法。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def find_expansion(self, tree):
symbol, children = tree
applied_expansion = \
"".join([child_symbol for child_symbol, _ in children])
for expansion in self.grammar[symbol]:
if exp_string(expansion) == applied_expansion:
return expansion
raise KeyError(
symbol +
": did not find expansion " +
repr(applied_expansion))
方法eval_function()是负责实际调用表达式后函数的方法。它创建一个包含所有非终结子节点扩展的参数列表——也就是说,语法扩展中的每个符号都有一个参数。然后调用给定的函数。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def eval_function(self, tree, function):
symbol, children = tree
assert callable(function)
args = []
for (symbol, exp) in children:
if exp != [] and exp is not None:
symbol_value = all_terminals((symbol, exp))
args.append(symbol_value)
result = function(*args)
if self.log:
print(repr(function) + repr(tuple(args)), "=", repr(result))
return result
注意,与预扩展函数不同,表达式后的函数通常处理已经产生的值,所以我们在这里不支持 Python 生成器。
示例:负表达式
让我们在一个示例上尝试这些表达式后的函数。假设我们只想产生评估结果为负数的算术表达式——例如,将这些生成的表达式输入到编译器或其他外部系统中。使用pre函数来构造性地完成这项任务将非常困难。相反,我们可以定义一个约束条件来检查这个特定的属性,使用 Python 的eval()函数。
Python 的eval()函数接受一个字符串,并按照 Python 规则对其进行评估。由于我们生成的表达式语法略不同于 Python,并且 Python 在评估过程中可能会引发算术异常,我们需要一种优雅地处理这些错误的方法。函数eval_with_exception()封装了eval();如果在评估过程中发生异常,它将返回 False——这会导致生产算法产生另一个值。
from ExpectError import ExpectError
def eval_with_exception(s):
# Use "mute=True" to suppress all messages
with ExpectError(print_traceback=False):
return eval(s)
return False
negative_expr_grammar = extend_grammar(EXPR_GRAMMAR,
{
"<start>": [("<expr>", opts(post=lambda s: eval_with_exception(s) < 0))]
}
)
assert is_valid_grammar(negative_expr_grammar)
negative_expr_fuzzer = GeneratorGrammarFuzzer(negative_expr_grammar)
expr = negative_expr_fuzzer.fuzz()
expr
ZeroDivisionError: division by zero (expected)
'(8.9 / 6 * 4 - 0.2 + -7 - 7 - 8 * 6) * 7 * 15.55 - -945.9'
结果确实是负数:
eval(expr)
-5178.726666666667
示例:匹配 XML 标签
表达式后的函数不仅可以用来检查扩展,还可以用来修复它们。为此,我们可以让它们返回一个字符串或字符串列表;就像预扩展函数一样,这些字符串将替换整个扩展或单个符号。
作为示例,考虑XML 文档,它们由匹配的XML 标签内的文本组成。例如,考虑以下 HTML 片段,它是 XML 的一个子集:
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import HTML
HTML("<strong>A bold text</strong>")
粗体文本
这个片段由两个包围文本的 HTML(XML)标签组成;标签名(strong)在打开标签(<strong>)和关闭标签(</strong>)中都存在。
对于一个有限的标签集合(例如,HTML 标签<strong>、<head>、<body>、<form>等等),我们可以定义一个上下文无关文法来解析它;每对标签将组成语法中的一个单独规则。然而,如果标签集合是无限的,比如在通用 XML 中,我们就不能定义一个合适的文法;这是因为约束条件要求关闭标签必须与打开标签匹配是上下文相关的,因此不适合上下文无关文法。
(顺便提一下,如果关闭标签具有标识符 reversed (</gnorts>), 那么一个上下文无关文法可以描述它。将其作为一个编程练习。)
我们可以通过引入适当的后扩展函数来解决此问题,这些函数可以自动使关闭标签与打开标签匹配。让我们从一个简单的语法开始,用于生成 XML 树:
XML_GRAMMAR: Grammar = {
"<start>": ["<xml-tree>"],
"<xml-tree>": ["<<id>><xml-content></<id>>"],
"<xml-content>": ["Text", "<xml-tree>"],
"<id>": ["<letter>", "<id><letter>"],
"<letter>": crange('a', 'z')
}
assert is_valid_grammar(XML_GRAMMAR)
如果我们使用这个语法进行模糊测试,我们会得到非匹配的 XML 标签,这是预期的:
xml_fuzzer = GrammarFuzzer(XML_GRAMMAR)
xml_fuzzer.fuzz()
'<t><qju>Text</m></q>'
设置一个后扩展函数,将第二个标识符设置为在第一个中找到的字符串,可以解决这个问题:
XML_GRAMMAR.update({
"<xml-tree>": [("<<id>><xml-content></<id>>",
opts(post=lambda id1, content, id2: [None, None, id1])
)]
})
assert is_valid_grammar(XML_GRAMMAR)
xml_fuzzer = GeneratorGrammarFuzzer(XML_GRAMMAR)
xml_fuzzer.fuzz()
'<u>Text</u>'
示例:校验和
作为最后一个例子,让我们考虑引言中的校验和问题。有了我们新定义的修复机制,我们现在可以生成有效的信用卡号码:
credit_card_fuzzer = GeneratorGrammarFuzzer(
CHARGE_GRAMMAR, start_symbol="<credit-card-number>")
credit_card_number = credit_card_fuzzer.fuzz()
credit_card_number
'2967308746680770'
assert valid_luhn_checksum(credit_card_number)
有效性扩展到整个语法:
charge_fuzzer = GeneratorGrammarFuzzer(CHARGE_GRAMMAR)
charge_fuzzer.fuzz()
'Charge $818819.97 to my credit card 2817984968014288'
本地检查和修复
到目前为止,我们总是首先生成整个表达式树,然后在之后检查其有效性。这可能会变得昂贵:如果首先生成多个元素,然后发现其中之一无效,我们会在尝试(随机)重新生成匹配输入上花费大量时间。
为了展示这个问题,让我们创建一个表达式语法,其中所有数字都由零和一组成。然而,我们不是通过构造性方法来做这件事,而是在事后使用 post 约束过滤掉所有不符合的表达式:
binary_expr_grammar = extend_grammar(EXPR_GRAMMAR,
{
"<integer>": [("<digit><integer>", opts(post=lambda digit, _: digit in ["0", "1"])),
("<digit>", opts(post=lambda digit: digit in ["0", "1"]))]
}
)
assert is_valid_grammar(binary_expr_grammar)
这可以工作,但非常慢;找到匹配表达式可能需要几秒钟。
binary_expr_fuzzer = GeneratorGrammarFuzzer(binary_expr_grammar)
binary_expr_fuzzer.fuzz()
'(-+0)'
我们可以通过检查约束来解决此问题,不仅针对最终子树,而且在子树一旦完成就进行检查。为此,我们扩展了 expand_tree_once() 方法,使其在子树中的所有符号都展开后立即调用后扩展函数。
class RestartExpansionException(Exception):
pass
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def expand_tree_once(self, tree: DerivationTree) -> DerivationTree:
# Apply inherited method. This also calls `expand_tree_once()` on all
# subtrees.
new_tree: DerivationTree = super().expand_tree_once(tree)
(symbol, children) = new_tree
if all([exp_post_expansion_function(expansion)
is None for expansion in self.grammar[symbol]]):
# No constraints for this symbol
return new_tree
if self.any_possible_expansions(tree):
# Still expanding
return new_tree
return self.run_post_functions_locally(new_tree)
主要工作发生在辅助方法 run_post_functions_locally() 中。它通过将 depth 设置为零,仅在当前节点上运行 run_post_functions() 函数 \(f\),因为任何完成的子树已经运行了它们的后扩展函数。如果 \(f\) 返回 False,则 run_post_functions_locally() 返回一个未展开的符号,这样主驱动程序就可以尝试另一种扩展。它最多尝试 10 次(在构建期间通过 replacement_attempts 参数配置);之后,它引发一个 RestartExpansionException 来从头开始重新创建树。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def run_post_functions_locally(self, new_tree: DerivationTree) -> DerivationTree:
symbol, _ = new_tree
result, children = self.run_post_functions(new_tree, depth=0)
if not isinstance(result, bool) or result:
# No constraints, or constraint satisfied
# children = self.apply_result(result, children)
new_tree = (symbol, children)
return new_tree
# Replace tree by unexpanded symbol and try again
if self.log:
print(
all_terminals(new_tree),
"did not satisfy",
symbol,
"constraint")
if self.replacement_attempts_counter > 0:
if self.log:
print("Trying another expansion")
self.replacement_attempts_counter -= 1
return (symbol, None)
if self.log:
print("Starting from scratch")
raise RestartExpansionException
类构造方法以及 fuzz_tree() 被设置为处理额外的功能:
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def __init__(self, grammar: Grammar, replacement_attempts: int = 10,
**kwargs) -> None:
super().__init__(grammar, **kwargs)
self.replacement_attempts = replacement_attempts
def restart_expansion(self) -> None:
super().restart_expansion()
self.replacement_attempts_counter = self.replacement_attempts
def fuzz_tree(self) -> DerivationTree:
self.replacement_attempts_counter = self.replacement_attempts
while True:
try:
# This is fuzz_tree() as defined above
tree = super().fuzz_tree()
return tree
except RestartExpansionException:
self.restart_expansion()
binary_expr_fuzzer = GeneratorGrammarFuzzer(
binary_expr_grammar, replacement_attempts=100)
binary_expr_fuzzer.fuzz()
'+0 / +-1 - 1 / +0 * -+0 * 0 * 1 / 1'
定义和用途
在上述生成器和约束的基础上,我们也可以处理复杂示例。来自 解析器章节 的 VAR_GRAMMAR 语法定义了多个变量为算术表达式(这些表达式本身也可以包含变量)。在语法上应用简单的 GrammarFuzzer 产生大量的标识符,但每个标识符都有一个独特的名称。
import [string](https://docs.python.org/3/library/string.html)
VAR_GRAMMAR: Grammar = {
'<start>': ['<statements>'],
'<statements>': ['<statement>;<statements>', '<statement>'],
'<statement>': ['<assignment>'],
'<assignment>': ['<identifier>=<expr>'],
'<identifier>': ['<word>'],
'<word>': ['<alpha><word>', '<alpha>'],
'<alpha>': list(string.ascii_letters),
'<expr>': ['<term>+<expr>', '<term>-<expr>', '<term>'],
'<term>': ['<factor>*<term>', '<factor>/<term>', '<factor>'],
'<factor>':
['+<factor>', '-<factor>', '(<expr>)', '<identifier>', '<number>'],
'<number>': ['<integer>.<integer>', '<integer>'],
'<integer>': ['<digit><integer>', '<digit>'],
'<digit>': crange('0', '9')
}
assert is_valid_grammar(VAR_GRAMMAR)
g = GrammarFuzzer(VAR_GRAMMAR)
for i in range(10):
print(g.fuzz())
Gc=F/1*Y+M-D-9;N=n/(m)/m*7
a=79.0;W=o-9;v=2;K=u;D=9
o=y-z+y+4;q=5+W;X=T
M=-98.032*5/o
H=IA-5-1;n=3-t;QQ=5-5
Y=-80;d=D-M+M;Z=4.3+1*r-5+b
ZDGSS=(1*Y-4)*54/0*pcO/4;RI=r*5.0
Q=6+z-6;J=6/t/9/i-3-5+k
x=-GT*+-x*6++-93*5
q=da*T/e--v;x=3+g;bk=u
我们希望的是,在表达式中,只使用之前定义过的标识符。为此,我们在符号表周围引入了一组函数,该符号表跟踪所有已定义的变量。
SYMBOL_TABLE: Set[str] = set()
def define_id(id: str) -> None:
SYMBOL_TABLE.add(id)
def use_id() -> Union[bool, str]:
if len(SYMBOL_TABLE) == 0:
return False
id = random.choice(list(SYMBOL_TABLE))
return id
def clear_symbol_table() -> None:
global SYMBOL_TABLE
SYMBOL_TABLE = set()
为了使用符号表,我们在VAR_GRAMMAR上附加了预展开和后展开函数,这些函数定义和查找符号表中的标识符。我们称我们的扩展语法为CONSTRAINED_VAR_GRAMMAR:
CONSTRAINED_VAR_GRAMMAR = extend_grammar(VAR_GRAMMAR)
首先,我们设置语法,使得每次定义一个标识符后,我们将其名称存储在符号表中:
CONSTRAINED_VAR_GRAMMAR = extend_grammar(CONSTRAINED_VAR_GRAMMAR, {
"<assignment>": [("<identifier>=<expr>",
opts(post=lambda id, expr: define_id(id)))]
})
其次,我们确保在生成标识符时,也从符号表中获取它。(在这里我们使用post,这样如果还没有可用的标识符,我们可以返回False,从而导致另一个展开的产生。)
CONSTRAINED_VAR_GRAMMAR = extend_grammar(CONSTRAINED_VAR_GRAMMAR, {
"<factor>": ['+<factor>', '-<factor>', '(<expr>)',
("<identifier>", opts(post=lambda _: use_id())),
'<number>']
})
最后,每次我们(重新)启动展开时,我们都会清除符号表。这很有用,因为我们可能偶尔需要重新启动展开。
CONSTRAINED_VAR_GRAMMAR = extend_grammar(CONSTRAINED_VAR_GRAMMAR, {
"<start>": [("<statements>", opts(pre=clear_symbol_table))]
})
assert is_valid_grammar(CONSTRAINED_VAR_GRAMMAR)
使用这种语法进行模糊测试确保每个使用的标识符实际上已经定义:
var_grammar_fuzzer = GeneratorGrammarFuzzer(CONSTRAINED_VAR_GRAMMAR)
for i in range(10):
print(var_grammar_fuzzer.fuzz())
DB=+(8/4/7-9+3+3)/2178/+-9
lNIqc=+(1+9-8)/2.9*8/5*0
Sg=(+9/8/6)*++1/(1+7)*8*4
r=+---552
iz=5/7/7;K=1+6*iz*1
q=3-2;MPy=q;p=2*5
zj=+5*-+35.2-+1.5727978+(-(-0/6-7+3))*--+44*1
Tl=((0*9+4-3)-6)/(-3-7*8*8/7)+9
aXZ=-5/-+3*9/3/1-8-+0*0/3+7+4
NA=-(8+a-1)*1.6;g=++7;a=++g*g*g
展开顺序
虽然我们之前的定义/使用示例确保每个使用的变量也是一个已定义的变量,但它并不关心这些定义的顺序。事实上,可能首先展开分号右侧的项,在符号表中创建一个条目,然后稍后用于左侧的表达式。我们可以通过在 Python 中实际评估产生的变量赋值来演示这一点,使用exec()来执行赋值序列。(鲜为人知的事实:Python 确实支持;作为语句分隔符。)
var_grammar_fuzzer = GeneratorGrammarFuzzer(CONSTRAINED_VAR_GRAMMAR)
with ExpectError():
for i in range(100):
s = var_grammar_fuzzer.fuzz()
try:
exec(s, {}, {})
except SyntaxError:
continue
except ZeroDivisionError:
continue
print(s)
f=(9)*kOj*kOj-6/7;kOj=(9-8)*7*1
Traceback (most recent call last):
File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_11764/3970000697.py", line 6, in <module>
exec(s, {}, {})
File "<string>", line 1, in <module>
NameError: name 'kOj' is not defined (expected)
为了解决这个问题,我们允许显式指定展开的顺序。对于我们的先前模糊器,这种顺序无关紧要,因为最终所有符号都会被展开;如果我们有具有副作用展开函数,那么控制展开的顺序(以及相关函数调用的顺序)可能很重要。
为了指定顺序,我们为单个展开分配一个特殊的属性order。这是一个列表,其中包含每个符号的编号,表示展开的顺序,从最小的开始。例如,以下规则指定了分号分隔符左侧应首先展开:
CONSTRAINED_VAR_GRAMMAR = extend_grammar(CONSTRAINED_VAR_GRAMMAR, {
"<statements>": [("<statement>;<statements>", opts(order=[1, 2])),
"<statement>"]
})
同样,我们希望在表达式展开后才产生变量的定义,因为否则,表达式可能已经引用了已定义的变量:
CONSTRAINED_VAR_GRAMMAR = extend_grammar(CONSTRAINED_VAR_GRAMMAR, {
"<assignment>": [("<identifier>=<expr>", opts(post=lambda id, expr: define_id(id),
order=[2, 1]))],
})
辅助函数exp_order()允许我们检索顺序:
def exp_order(expansion):
"""Return the specified expansion ordering, or None if unspecified"""
return exp_opt(expansion, 'order')
为了控制符号扩展的顺序,我们钩入choose_tree_expansion()方法,该方法专门设置为在子类中扩展。它通过expandable_children列表中的可扩展子项进行选择,并将它们与扩展的非终端子项匹配以确定它们的顺序号。具有最低顺序号的可扩展子项的索引min_given_order随后返回,选择这个子项进行扩展。
class GeneratorGrammarFuzzer(GeneratorGrammarFuzzer):
def choose_tree_expansion(self, tree: DerivationTree,
expandable_children: List[DerivationTree]) \
-> int:
"""Return index of subtree in `expandable_children`
to be selected for expansion. Defaults to random."""
(symbol, tree_children) = tree
assert isinstance(tree_children, list)
if len(expandable_children) == 1:
# No choice
return super().choose_tree_expansion(tree, expandable_children)
expansion = self.find_expansion(tree)
given_order = exp_order(expansion)
if given_order is None:
# No order specified
return super().choose_tree_expansion(tree, expandable_children)
nonterminal_children = [c for c in tree_children if c[1] != []]
assert len(nonterminal_children) == len(given_order), \
"Order must have one element for each nonterminal"
# Find expandable child with lowest ordering
min_given_order = None
j = 0
for k, expandable_child in enumerate(expandable_children):
while j < len(
nonterminal_children) and expandable_child != nonterminal_children[j]:
j += 1
assert j < len(nonterminal_children), "Expandable child not found"
if self.log:
print("Expandable child #%d %s has order %d" %
(k, expandable_child[0], given_order[j]))
if min_given_order is None or given_order[j] < given_order[min_given_order]:
min_given_order = k
assert min_given_order is not None
if self.log:
print("Returning expandable child #%d %s" %
(min_given_order, expandable_children[min_given_order][0]))
return min_given_order
这样,我们的模糊测试器现在可以尊重顺序,并且所有变量都得到了适当的定义:
var_grammar_fuzzer = GeneratorGrammarFuzzer(CONSTRAINED_VAR_GRAMMAR)
for i in range(100):
s = var_grammar_fuzzer.fuzz()
if i < 10:
print(s)
try:
exec(s, {}, {})
except SyntaxError:
continue
except ZeroDivisionError:
continue
a=(1)*0*3/8+0/8-4/8-0
r=+0*+8/-4+((9)*2-1-8+6/9)
D=+(2*3+6*0)-(5)/9*0/2;Q=D
C=9*(2-1)*9*0-1.2/6-3*5
G=-25.1
H=+4*4/8.5*4-8*4+(5);D=6
PIF=4841/++(460.1---626)*51755;E=(8)/-PIF+6.8*(7-PIF)*9*PIF;k=8
X=((0)*2/0*6+7*3)/(0-7-9)
x=94.2+25;x=++x/(7)+-9/8/2/x+-1/x;I=x
cBM=51.15;f=81*-+--((2++cBM/cBM*+1*0/0-5+cBM))
实际的编程语言不仅有一个全局作用域,还有多个局部作用域,通常是嵌套的。通过仔细组织全局和局部符号表,我们可以设置一个语法来处理所有这些。然而,在模糊测试编译器和解释器时,我们通常只关注单个函数,对于这些函数来说,一个单一的作用域就足够使大多数输入有效。
全部整合
让我们通过将我们的生成器功能与其他之前引入的语法功能集成来结束这一章,特别是覆盖率驱动的模糊测试和概率语法模糊测试。
集成单个特性的通用思路是通过多重继承,这我们在ProbabilisticGrammarCoverageFuzzer中已经使用过,如概率模糊测试练习中介绍的那样。
生成器和概率模糊测试
概率模糊测试很容易与生成器集成,因为它们都以不同的方式扩展了GrammarFuzzer。
from ProbabilisticGrammarFuzzer import ProbabilisticGrammarFuzzer # minor dependency
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import inheritance_conflicts
inheritance_conflicts(ProbabilisticGrammarFuzzer, GeneratorGrammarFuzzer)
['supported_opts']
我们必须实现supported_opts()作为两个超类的合并。同时,我们也设置了构造函数,使其调用这两个超类。
class ProbabilisticGeneratorGrammarFuzzer(GeneratorGrammarFuzzer,
ProbabilisticGrammarFuzzer):
"""Join the features of `GeneratorGrammarFuzzer`
and `ProbabilisticGrammarFuzzer`"""
def supported_opts(self) -> Set[str]:
return (super(GeneratorGrammarFuzzer, self).supported_opts() |
super(ProbabilisticGrammarFuzzer, self).supported_opts())
def __init__(self, grammar: Grammar, *, replacement_attempts: int = 10,
**kwargs):
"""Constructor.
`replacement_attempts` - see `GeneratorGrammarFuzzer` constructor.
All other keywords go into `ProbabilisticGrammarFuzzer`.
"""
super(GeneratorGrammarFuzzer, self).__init__(
grammar,
replacement_attempts=replacement_attempts)
super(ProbabilisticGrammarFuzzer, self).__init__(grammar, **kwargs)
让我们给我们的联合类做一个简单的测试,使用概率来优先考虑长标识符:
CONSTRAINED_VAR_GRAMMAR.update({
'<word>': [('<alpha><word>', opts(prob=0.9)),
'<alpha>'],
})
pgg_fuzzer = ProbabilisticGeneratorGrammarFuzzer(CONSTRAINED_VAR_GRAMMAR)
pgg_fuzzer.supported_opts()
{'order', 'post', 'pre', 'prob'}
pgg_fuzzer.fuzz()
'a=5+3/8/8+-1/6/9/8;E=6'
生成器和语法覆盖率
基于语法覆盖的模糊测试是一个更大的挑战。不仅仅是因为在两个方法中都有重载;我们可以像上面一样解决这些问题。
from ProbabilisticGrammarFuzzer import ProbabilisticGrammarCoverageFuzzer # minor dependency
from GrammarCoverageFuzzer import GrammarCoverageFuzzer # minor dependency
inheritance_conflicts(ProbabilisticGrammarCoverageFuzzer,
GeneratorGrammarFuzzer)
['__init__', 'supported_opts']
import [copy](https://docs.python.org/3/library/copy.html)
class ProbabilisticGeneratorGrammarCoverageFuzzer(GeneratorGrammarFuzzer,
ProbabilisticGrammarCoverageFuzzer):
"""Join the features of `GeneratorGrammarFuzzer`
and `ProbabilisticGrammarCoverageFuzzer`"""
def supported_opts(self) -> Set[str]:
return (super(GeneratorGrammarFuzzer, self).supported_opts() |
super(ProbabilisticGrammarCoverageFuzzer, self).supported_opts())
def __init__(self, grammar: Grammar, *,
replacement_attempts: int = 10, **kwargs) -> None:
"""Constructor.
`replacement_attempts` - see `GeneratorGrammarFuzzer` constructor.
All other keywords go into `ProbabilisticGrammarFuzzer`.
"""
super(GeneratorGrammarFuzzer, self).__init__(
grammar,
replacement_attempts)
super(ProbabilisticGrammarCoverageFuzzer, self).__init__(
grammar,
**kwargs)
问题在于在扩展过程中,我们可能会生成(并覆盖)后来丢弃的扩展(例如,因为post函数返回False)。因此,我们必须删除这种不再存在于最终生产中的覆盖率。
我们通过在生成最终树之后重建覆盖率来解决这个问题。为此,我们钩入fuzz_tree()方法。我们在创建树之前保存原始覆盖率,然后在之后恢复它。然后我们遍历生成的树,再次添加其覆盖率(add_tree_coverage())。
class ProbabilisticGeneratorGrammarCoverageFuzzer(
ProbabilisticGeneratorGrammarCoverageFuzzer):
def fuzz_tree(self) -> DerivationTree:
self.orig_covered_expansions = copy.deepcopy(self.covered_expansions)
tree = super().fuzz_tree()
self.covered_expansions = self.orig_covered_expansions
self.add_tree_coverage(tree)
return tree
def add_tree_coverage(self, tree: DerivationTree) -> None:
(symbol, children) = tree
assert isinstance(children, list)
if len(children) > 0:
flat_children: List[DerivationTree] = [
(child_symbol, None)
for (child_symbol, _) in children
]
self.add_coverage(symbol, flat_children)
for c in children:
self.add_tree_coverage(c)
作为最后一步,我们确保如果必须从头开始重新启动扩展,我们也恢复之前的覆盖率,这样我们就可以完全重新开始:
class ProbabilisticGeneratorGrammarCoverageFuzzer(
ProbabilisticGeneratorGrammarCoverageFuzzer):
def restart_expansion(self) -> None:
super().restart_expansion()
self.covered_expansions = self.orig_covered_expansions
让我们试试这个。在我们生成一个字符串之后,我们应该在expansion_coverage()中看到其覆盖率:
pggc_fuzzer = ProbabilisticGeneratorGrammarCoverageFuzzer(
CONSTRAINED_VAR_GRAMMAR)
pggc_fuzzer.fuzz()
'H=+-2+7.4*(9)/0-6*8;T=5'
pggc_fuzzer.expansion_coverage()
{'<alpha> -> H',
'<alpha> -> T',
'<assignment> -> <identifier>=<expr>',
'<digit> -> 0',
'<digit> -> 2',
'<digit> -> 4',
'<digit> -> 5',
'<digit> -> 6',
'<digit> -> 7',
'<digit> -> 8',
'<digit> -> 9',
'<expr> -> <term>',
'<expr> -> <term>+<expr>',
'<expr> -> <term>-<expr>',
'<factor> -> (<expr>)',
'<factor> -> +<factor>',
'<factor> -> -<factor>',
'<factor> -> <number>',
'<identifier> -> <word>',
'<integer> -> <digit>',
'<number> -> <integer>',
'<number> -> <integer>.<integer>',
'<start> -> <statements>',
'<statement> -> <assignment>',
'<statements> -> <statement>',
'<statements> -> <statement>;<statements>',
'<term> -> <factor>',
'<term> -> <factor>*<term>',
'<term> -> <factor>/<term>',
'<word> -> <alpha>'}
再次模糊测试最终会覆盖所有标识符中的字母:
[pggc_fuzzer.fuzz() for i in range(10)]
['llcyzc=3.0*02.3*1',
'RfMgRYmd=---2.9',
'p=+(7+3/4)*+-4-3.2*((2)-4)/2',
'z=1-2/4-3*9+3+5',
'v=(2/3)/1/2*8+(3)-7*2-1',
'L=9.5/9-(7)/8/1+2-2;c=L',
'U=+-91535-1-9-(9)/1;i=U',
'g=-8.3*7*5+1*5*9-5;k=1',
'J=+-8-(5/6-1)/7-6+7',
'p=053/-(8*0*3*2/1);t=p']
通过 ProbabilisticGeneratorGrammarCoverageFuzzer,我们现在有一个语法模糊测试器,它结合了高效的语法模糊测试、覆盖率、概率和生成器函数。唯一缺少的是更短的名字。"PGGCFuzzer",也许?
class PGGCFuzzer(ProbabilisticGeneratorGrammarCoverageFuzzer):
"""The one grammar-based fuzzer that supports all fuzzingbook features"""
pass
经验教训
附属于语法扩展的函数可以服务
-
作为生成器,高效地从函数中生成符号展开;
-
作为约束来检查生成的字符串是否满足(复杂的)有效性条件;并且
-
作为修复,对生成的字符串应用更改,例如校验和和标识符。
下一步
通过本章,我们拥有了强大的语法,我们可以在多个领域中使用它们:
-
在关于模糊测试 API 的章节中,我们展示了如何使用
GeneratorGrammarFuzzer功能来生成用于测试的复杂数据结构,结合语法和生成器函数。 -
在关于模糊测试用户界面的章节中,我们使用
GeneratorGrammarFuzzer来生成复杂的用户界面输入。
背景
对于模糊测试 API,生成器函数非常常见。在关于 API 模糊测试的章节中,我们展示了如何将它们与语法结合,以生成更丰富的测试。
生成器函数和语法的组合主要因为我们在全 Python 环境中定义并使用语法。我们不知道有其他基于语法的模糊测试系统具有类似的功能。
练习
练习 1:树处理
到目前为止,我们的 pre 和 post 处理函数都接受和生成字符串。然而,在某些情况下,直接访问推导树可能很有用——例如,访问和检查某些子元素。
你的任务是扩展 GeneratorGrammarFuzzer,使其预处理和后处理函数可以接受和返回推导树。为此,请按照以下步骤操作:
-
扩展
GeneratorGrammarFuzzer,使得一个函数可以返回一个推导树(一个元组)或推导树的列表,然后以与字符串相同的方式替换子树。 -
扩展
GeneratorGrammarFuzzer,添加一个post_tree属性,它接受一个函数就像post一样,除了它的参数会是推导树。
使用笔记本来练习并查看解决方案。
练习 2:属性语法
设置一个机制,通过该机制可以给推导树中的单个元素附加任意属性。扩展函数可以将这样的属性附加到单个符号上(比如,通过返回 opts()),并在后续调用中访问符号的属性。以下是一个示例:
使用笔记本来练习并查看解决方案。
ATTR_GRAMMAR = {
"<clause>": [("<xml-open>Text<xml-close>", opts(post=lambda x1, x2: [None, x1.name]))],
"<xml-open>": [("<<tag>>", opts(post=lambda tag: opts(name=...)))],
"<xml-close>": ["</<tag>>"]
}
使用笔记本 进行练习并查看解决方案。
本项目的内容受 Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议 的许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,受 MIT 许可协议 的许可。 最后更改:2024-01-10 15:45:59+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/GeneratorGrammarFuzzer.html. Retrieved 2024-01-10 15:45:59+01:00.
@incollection{fuzzingbook2024:GeneratorGrammarFuzzer,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Fuzzing with Generators},
year = {2024},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/GeneratorGrammarFuzzer.html}},
note = {Retrieved 2024-01-10 15:45:59+01:00},
url = {https://www.fuzzingbook.org/html/GeneratorGrammarFuzzer.html},
urldate = {2024-01-10 15:45:59+01:00}
}


浙公网安备 33010602011771号