模糊测试之书-三-
模糊测试之书(三)
原文:
exploringjs.com/ts/book/index.html译者:飞龙
灰盒模糊测试
在上一章中,我们介绍了基于突变的模糊测试技术,这是一种通过在给定输入上应用小突变来生成模糊输入的技术。在本章中,我们将展示如何引导这些突变以实现特定的目标,如覆盖率。本章中的算法源自流行的American Fuzzy Lop (AFL)模糊器,特别是其AFLFast和AFLGo版本。我们将探索 AFL 背后的灰盒模糊测试算法以及我们如何利用它来解决自动化漏洞检测的各种问题。
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('vBrNT9q2t1Y')
先决条件
- 建议阅读关于基于突变的模糊测试的介绍 mutation-based fuzzing。
概述
要使用本章提供的代码,请编写
>>> from fuzzingbook.GreyboxFuzzer import <identifier>
然后利用以下功能。
本章介绍了受流行的 AFL 模糊器启发的灰盒模糊测试的高级方法。GreyboxFuzzer类有三个参数。首先,一个种子输入列表:
>>> seed_input = "http://www.google.com/search?q=fuzzing"
>>> seeds = [seed_input]
第二,一个突变器,它改变输入的各个部分。
>>> mutator = Mutator()
第三,一个功率调度,它将模糊测试努力分配给种群:
>>> schedule = PowerSchedule()
这三个参数进入GreyboxFuzzer构造函数:
>>> greybox_fuzzer = GreyboxFuzzer(seeds=seeds, mutator=mutator, schedule=schedule)
GreyboxFuzzer类与FunctionCoverageRunner一起使用:
>>> http_runner = FunctionCoverageRunner(http_program)
>>> outcomes = greybox_fuzzer.runs(http_runner, trials=10000)
模糊测试后,我们可以检查种群:
>>> greybox_fuzzer.population[:20]
[http://www.google.com/search?q=fuzzing,
http://ww.gomgle.com/searciq=fuzzing,
|h>Att&p8?wwnOgle.cooarhl~Cp`uzza',
http2Ot/*gv-VRgogec:om/rearc h=fu~i
g,
http"Ot/*gv-VRgogecom/rearc h=u~i
f,
http"Ot/g$vVRgogEecom/#reabc h=u~a
f,
httT`2Ot//:7ev^VRgoec:uom/re!6dctKc= hS;=fu-yH
/,
http'www&go/le.comm/sea:rh*?q=Ftzzifg,
h4tpw://w.gomMle.m/weazciq=fuezzi.',
h~Att&p8?ws_nOge.#ooarhBlw~Cp`uzza',
ht8P:/wwvgkowcom/eacrh?q=f uzzing,
httT`Ot//:7evVR"goec:uom/re!dctKc=hS;=fu-yH
I,
htp://ww.go/gl%.otm/wearch?q=f5zzing,
httU`Ot//:7evVR"goec:uo2Jm/re#dctKc=hS;=fu-yH
I,
ht4p://ww.gomgle.co.serciq=fuzzing,
httpp"OtSo*gv'VRgkg;eom/earcR >
=u|in
f,
htP:/wwvgMolwcomeachq=f uzzine,
htt://[/wwgwC6.]gogleg/lE.bo/_sEarcq9Afqwz"king,
httT`Ot//:7evVR"goec:uom/re!dctKc=hS;=fu-yH
I,
tPK:,wIfvglwvc#omeaBc`9qOin]
除了简单的PowerSchedule之外,我们还可以有高级的电源调度。
-
AFLFastSchedule将高能量分配给“不寻常”的路径,这些路径不太常被使用。 -
AFLGoSchedule将高能量分配给接近未覆盖程序位置的路径。
AFLGoSchedule类的构造函数需要一个从每个节点到目标位置的distance度量,这是通过分析程序代码确定的。详见本章内容。
计算个别路径被执行的频率。">
重置路径频率">
基于覆盖率的变异模糊测试。">
在跟踪覆盖率的同时运行函数(inp)。
如果我们达到新的覆盖率,
将输入添加到种群及其覆盖率到种群覆盖率">
重置初始种群、种子索引、覆盖率信息">
基于变异的模糊测试的基类。">
构造函数。
seeds - 要变异的(输入)字符串列表。
mutator - 要应用的变异器。
schedule - 应用此处的电源调度。
返回每个种子一次,然后生成新的输入">
返回通过在种群中模糊化种子生成的输入">
重置初始种群和种子索引">
模糊器的基类。">
构造函数">
返回模糊输入">
以模糊输入运行runner">
以模糊输入运行runner,trials次">
在 AFL 中实现的指数级功率调度方案为 <text text-anchor="start" x="186.5" y="-172.07" font-family="Patua One, Helvetica, sans-serif" font-weight="bold" font-size="14.00" fill="#b03a2e">AFLFastSchedule</text>
构造函数">
根据路径频率的倒数分配指数能量">
定义如何将模糊测试时间分配给种群。">
构造函数">
为每个种子分配相同的能量">
根据归一化能量进行选择。">
归一化能量">
将高能量分配给接近目标的种子">
为每个种子分配与能量成反比的能量
到目标函数的平均距离。">
将高能量分配给接近某些目标的种子">
构造函数">
将每个种子的能量与目标函数的平均距离成反比分配
到目标函数的平均距离。">
Mutator 的变体,从字典中插入关键字">
构造函数。
dictionary - 一组字符串列表,可以用作关键字">
返回插入字典中关键字后的s">
变异字符串">
构造函数">
返回随机删除字符后的s">
返回在随机位置翻转随机位的s">
返回插入随机字符后的s">
返回应用随机变异后的s。可以在子类中重载。">
使用附加属性表示输入">
从种子数据初始化">
返回数据作为种子的字符串表示">
返回数据作为种子的字符串表示">
AFL:一个有效的灰盒模糊测试器
本章中的算法源自流行的美国模糊跳蚤 (AFL) 模糊测试器。
AFL 是一个 基于变异的模糊测试器。这意味着,AFL 通过稍微修改种子输入(即变异)或通过将一个输入的前半部分与另一个输入的后半部分连接起来(即拼接)来生成新的输入。
AFL 也是一个 灰盒模糊测试器(既不是黑盒也不是白盒)。这意味着,AFL 利用覆盖率反馈来学习如何深入程序。它不是完全的黑盒,因为 AFL 至少利用了 一些 程序分析。它也不是完全的白盒,因为 AFL 不会基于重量级的程序分析或约束求解。相反,AFL 使用轻量级程序插装来获取有关生成输入的(分支)覆盖率的一些信息。如果生成的输入增加了覆盖率,它将被添加到种子语料库以进行进一步的模糊测试。
为了对程序进行仪器化,AFL 在每次条件跳转指令之后注入一段代码。当执行时,这个所谓的跳板为执行的分支分配一个唯一的标识符并增加与该分支关联的计数器。为了效率,只维护粗略的分支命中计数。换句话说,对于每个输入,模糊器都知道哪些分支以及它们被大致使用了多少次。仪器化通常在编译时完成,即当程序源代码编译成可执行二进制文件时。然而,可以使用诸如虚拟机(例如,QEMU)或动态仪器化工具(例如,Intel PinTool)之类的工具在非仪器化二进制文件上运行 AFL。对于 Python 程序,我们可以在没有任何仪器化的情况下收集覆盖率信息(见收集覆盖率章节)。
灰盒模糊测试的成分
我们首先讨论在变异测试和目标引导中我们需要的最重要部分。
变异器
我们引入了用于变异种子的特定类。
import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils)
from [typing](https://docs.python.org/3/library/typing.html) import List, Set, Any, Tuple, Dict, Union
from [collections.abc](https://docs.python.org/3/library/collections.abc.html) import Sequence
import [random](https://docs.python.org/3/library/random.html)
from Coverage import population_coverage
首先,我们将介绍变异器类。给定一个种子输入inp,变异器返回inp的一个略微修改的版本。在灰盒语法模糊测试章节中,我们扩展了这个类以考虑输入语法,以便进行智能灰盒模糊测试。
class Mutator:
"""Mutate strings"""
def __init__(self) -> None:
"""Constructor"""
self.mutators = [
self.delete_random_character,
self.insert_random_character,
self.flip_random_character
]
对于插入操作,我们在随机位置添加一个随机字符。
class Mutator(Mutator):
def insert_random_character(self, s: str) -> str:
"""Returns s with a random character inserted"""
pos = random.randint(0, len(s))
random_character = chr(random.randrange(32, 127))
return s[:pos] + random_character + s[pos:]
对于删除操作,如果字符串非空,则在随机位置选择一个位置并删除字符。否则,使用插入操作。
class Mutator(Mutator):
def delete_random_character(self, s: str) -> str:
"""Returns s with a random character deleted"""
if s == "":
return self.insert_random_character(s)
pos = random.randint(0, len(s) - 1)
return s[:pos] + s[pos + 1:]
对于替换操作,如果字符串非空,则在随机位置选择一个位置并翻转字符中的一个随机位。否则,使用插入操作。
class Mutator(Mutator):
def flip_random_character(self, s: str) -> str:
"""Returns s with a random bit flipped in a random position"""
if s == "":
return self.insert_random_character(s)
pos = random.randint(0, len(s) - 1)
c = s[pos]
bit = 1 << random.randint(0, 6)
new_c = chr(ord(c) ^ bit)
return s[:pos] + new_c + s[pos + 1:]
主要方法是mutate,它从操作符列表中选择一个随机的变异操作符。
class Mutator(Mutator):
def mutate(self, inp: Any) -> Any: # can be str or Seed (see below)
"""Return s with a random mutation applied. Can be overloaded in subclasses."""
mutator = random.choice(self.mutators)
return mutator(inp)
让我们尝试一下变异器。实际上,你可以与这样的“单元”进行交互,并通过将此章节作为 Jupyter 笔记本加载来尝试其他输入。打开后,使用“Kernel -> Restart & Run All”运行笔记本中的所有单元。
Mutator().mutate("good")
'cood'
种子和功率调度
现在我们引入一个新概念;功率调度。功率调度将宝贵的模糊测试时间在种群中的种子之间分配。我们的目标是最大化在较短时间内导致覆盖率增加的(最进步的)种子所花费的时间。
我们称从种群中选择种子时出现的概率为种子的能量。在整个模糊测试活动中,我们希望优先考虑更有希望的种子。简单来说,我们不希望浪费能量在模糊测试非渐进种子上。我们将决定种子能量的过程称为模糊器的功率调度。例如,AFL 的调度会给更短的、执行速度更快且更频繁地增加覆盖率的种子分配更多的能量。
首先,除了种子数据外,我们还需要将一些信息附加到每个种子上。因此,我们定义以下 Seed 类。
from Coverage import Location
class Seed:
"""Represent an input with additional attributes"""
def __init__(self, data: str) -> None:
"""Initialize from seed data"""
self.data = data
# These will be needed for advanced power schedules
self.coverage: Set[Location] = set()
self.distance: Union[int, float] = -1
self.energy = 0.0
def __str__(self) -> str:
"""Returns data as string representation of the seed"""
return self.data
__repr__ = __str__
下面实现的能量调度方案为每个种子分配相同的能量。一旦种子在种群中,它将被模糊测试的次数与种群中的任何其他种子一样多。
在 Python 中,我们可以将长 for 循环压缩成更小的语句。
-
lambda x: ...返回一个以x作为输入的函数。Lambda 允许快速定义未命名的函数。 -
map(f, l)返回一个列表,其中函数f被应用于列表l中的每个元素。 -
random.choices(l, weights)[0]返回元素l[i],其概率为weights[i]。
class PowerSchedule:
"""Define how fuzzing time should be distributed across the population."""
def __init__(self) -> None:
"""Constructor"""
self.path_frequency: Dict = {}
def assignEnergy(self, population: Sequence[Seed]) -> None:
"""Assigns each seed the same energy"""
for seed in population:
seed.energy = 1
def normalizedEnergy(self, population: Sequence[Seed]) -> List[float]:
"""Normalize energy"""
energy = list(map(lambda seed: seed.energy, population))
sum_energy = sum(energy) # Add up all values in energy
assert sum_energy != 0
norm_energy = list(map(lambda nrg: nrg / sum_energy, energy))
return norm_energy
def choose(self, population: Sequence[Seed]) -> Seed:
"""Choose weighted by normalized energy."""
self.assignEnergy(population)
norm_energy = self.normalizedEnergy(population)
seed: Seed = random.choices(population, weights=norm_energy)[0]
return seed
让我们看看这个能量调度方案是否均匀随机地选择种子。我们要求调度 10k 次从三个种子(A、B、C)的种群中选择一个种子,并记录我们看到每个种子的次数。我们应该看到每个种子大约 3.3k 次。
population = [Seed("A"), Seed("B"), Seed("C")]
schedule = PowerSchedule()
hits = {
"A": 0,
"B": 0,
"C": 0
}
for i in range(10000):
seed = schedule.choose(population)
hits[seed.data] += 1
hits
{'A': 3387, 'B': 3255, 'C': 3358}
看起来不错。每个种子大约有三分之一的时间被选中。
运行器和样本程序
我们将从一个小型的六行样本程序开始。为了在执行过程中收集覆盖率信息,我们导入 基于变异的模糊测试章节 中的 FunctionCoverageRunner 类。
FunctionCoverageRunner 构造函数接受一个 Python 函数 来执行。run 函数接收一个输入,将其传递给 Python 函数,并收集这次执行的覆盖率信息。coverage() 函数返回一个列表,包含 (函数名, 行号) 元组,表示 Python 函数 中每个被覆盖的语句。
from MutationFuzzer import FunctionCoverageRunner, http_program
crashme() 函数对输入 "bad!" 抛出异常。让我们看看对于输入 "good",哪些语句被覆盖。
def crashme(s: str) -> None:
if len(s) > 0 and s[0] == 'b':
if len(s) > 1 and s[1] == 'a':
if len(s) > 2 and s[2] == 'd':
if len(s) > 3 and s[3] == '!':
raise Exception()
crashme_runner = FunctionCoverageRunner(crashme)
crashme_runner.run("good")
list(crashme_runner.coverage())
[('run_function', 132), ('crashme', 2)]
在 crashme 中,输入 "good" 只覆盖了第 2 行的 if-语句。分支条件 len(s) > 0 and s[0] == 'b' 评估为 False。
高级黑盒基于变异的模糊测试
让我们将突变体和能量调度方案整合到一个模糊测试器中。我们将从一个黑盒模糊测试器开始 -- 它不利用任何覆盖率信息。
我们的 AdvancedMutationFuzzer 类是 MutationFuzzer 类的高级和 参数化 版本,后者来自 基于变异的模糊测试章节。它还继承自 Fuzzer 类。目前,我们只需要知道 fuzz() 函数,它返回一个生成的输入,以及 runs() 函数,它执行 fuzz() 指定次数。对于我们的 AdvancedMutationFuzzer 类,我们重写了 fuzz() 函数。
from Fuzzer import Fuzzer
AdvancedMutationFuzzer使用一组初始种子、一个突变器和一个功率计划来构建。在整个模糊测试活动中,它维护一个称为population的种子集合。fuzz函数返回初始种子中的一个未模糊种子,或者对集合中的种子进行模糊测试的结果。create_candidate函数处理后者。它从集合中随机选择一个输入,并应用多个突变。
class AdvancedMutationFuzzer(Fuzzer):
"""Base class for mutation-based fuzzing."""
def __init__(self, seeds: List[str],
mutator: Mutator,
schedule: PowerSchedule) -> None:
"""Constructor.
`seeds` - a list of (input) strings to mutate.
`mutator` - the mutator to apply.
`schedule` - the power schedule to apply.
"""
self.seeds = seeds
self.mutator = mutator
self.schedule = schedule
self.inputs: List[str] = []
self.reset()
def reset(self) -> None:
"""Reset the initial population and seed index"""
self.population = list(map(lambda x: Seed(x), self.seeds))
self.seed_index = 0
def create_candidate(self) -> str:
"""Returns an input generated by fuzzing a seed in the population"""
seed = self.schedule.choose(self.population)
# Stacking: Apply multiple mutations to generate the candidate
candidate = seed.data
trials = min(len(candidate), 1 << random.randint(1, 5))
for i in range(trials):
candidate = self.mutator.mutate(candidate)
return candidate
def fuzz(self) -> str:
"""Returns first each seed once and then generates new inputs"""
if self.seed_index < len(self.seeds):
# Still seeding
self.inp = self.seeds[self.seed_index]
self.seed_index += 1
else:
# Mutating
self.inp = self.create_candidate()
self.inputs.append(self.inp)
return self.inp
好的,让我们试一下突变 fuzzer。给定一个种子,我们要求它生成三个输入。
seed_input = "good"
mutation_fuzzer = AdvancedMutationFuzzer([seed_input], Mutator(), PowerSchedule())
print(mutation_fuzzer.fuzz())
print(mutation_fuzzer.fuzz())
print(mutation_fuzzer.fuzz())
good
gDoodC
/
让我们看看基于突变的黑盒 fuzzer 在 n=30k 个输入的活动中覆盖了多少条语句。
fuzzer 函数runs(crashme_runner, trials=n)生成n个输入,并通过crashme_runner在crashme函数上执行它们。如前所述,crashme_runner还收集覆盖率信息。
import [time](https://docs.python.org/3/library/time.html)
n = 30000
blackbox_fuzzer = AdvancedMutationFuzzer([seed_input], Mutator(), PowerSchedule())
start = time.time()
blackbox_fuzzer.runs(FunctionCoverageRunner(crashme), trials=n)
end = time.time()
"It took the blackbox mutation-based fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the blackbox mutation-based fuzzer 4.21 seconds to generate and execute 30000 inputs.'
为了衡量覆盖率,我们导入 population_coverage 函数。它接受一组输入和一个 Python 函数,在该函数上执行输入并收集覆盖率信息。具体来说,它返回一个元组(all_coverage, cumulative_coverage),其中all_coverage是所有输入覆盖的语句集合,cumulative_coverage是随着执行输入数量的增加而覆盖的语句数量。我们只对后者感兴趣,以便绘制覆盖率随时间的变化。
我们从黑盒 fuzzer 中提取生成的输入,并随着输入数量的增加来衡量覆盖率。
_, blackbox_coverage = population_coverage(blackbox_fuzzer.inputs, crashme)
bb_max_coverage = max(blackbox_coverage)
"The blackbox mutation-based fuzzer achieved a maximum coverage of %d statements." % bb_max_coverage
'The blackbox mutation-based fuzzer achieved a maximum coverage of 2 statements.'
以下生成的输入增加了我们crashme 示例的覆盖率。
[seed_input] + \
[
blackbox_fuzzer.inputs[idx] for idx in range(len(blackbox_coverage))
if blackbox_coverage[idx] > blackbox_coverage[idx - 1]
]
['good', 'bo']
总结。这是一个基于黑盒突变的 fuzzer 是如何工作的。我们集成了突变器来通过模糊提供的初始种子集生成输入,以及功率计划来决定下一个选择哪个种子。
灰盒基于突变的模糊测试
与黑盒 fuzzer 相比,像AFL这样的灰盒 fuzzer 确实利用覆盖率信息。具体来说,灰盒 fuzzer 将增加代码覆盖率的输入添加到种子集合中。
run()方法是从 Fuzzer 类继承的。它被调用以生成并执行恰好一个输入。我们重写这个函数,向population添加一个输入以增加覆盖率。灰盒 fuzzer 属性coverages_seen维护了之前已覆盖的语句集合。
class GreyboxFuzzer(AdvancedMutationFuzzer):
"""Coverage-guided mutational fuzzing."""
def reset(self):
"""Reset the initial population, seed index, coverage information"""
super().reset()
self.coverages_seen = set()
self.population = [] # population is filled during greybox fuzzing
def run(self, runner: FunctionCoverageRunner) -> Tuple[Any, str]:
"""Run function(inp) while tracking coverage.
If we reach new coverage,
add inp to population and its coverage to population_coverage
"""
result, outcome = super().run(runner)
new_coverage = frozenset(runner.coverage())
if new_coverage not in self.coverages_seen:
# We have new coverage
seed = Seed(self.inp)
seed.coverage = runner.coverage()
self.coverages_seen.add(new_coverage)
self.population.append(seed)
return (result, outcome)
让我们试一下我们的灰盒 fuzzer。
seed_input = "good"
greybox_fuzzer = GreyboxFuzzer([seed_input], Mutator(), PowerSchedule())
start = time.time()
greybox_fuzzer.runs(FunctionCoverageRunner(crashme), trials=n)
end = time.time()
"It took the greybox mutation-based fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the greybox mutation-based fuzzer 4.23 seconds to generate and execute 30000 inputs.'
在生成相同数量的测试输入后,灰盒 fuzzer 覆盖了更多的语句吗?
_, greybox_coverage = population_coverage(greybox_fuzzer.inputs, crashme)
gb_max_coverage = max(greybox_coverage)
"Our greybox mutation-based fuzzer covers %d more statements" % (gb_max_coverage - bb_max_coverage)
'Our greybox mutation-based fuzzer covers 2 more statements'
我们示例的种子集合现在包含以下种子。
greybox_fuzzer.population
[good, bo, baof, bad4u]
覆盖率反馈确实很有帮助。新的种子就像面包屑或里程碑,引导模糊器更快地进入更深的代码区域。以下是一个简单的图表,显示了两个模糊器在简单 示例 上的覆盖率随时间的变化。
%matplotlib inline
import [matplotlib.pyplot](https://matplotlib.org/) as plt
line_bb, = plt.plot(blackbox_coverage, label="Blackbox")
line_gb, = plt.plot(greybox_coverage, label="Greybox")
plt.legend(handles=[line_bb, line_gb])
plt.title('Coverage over time')
plt.xlabel('# of inputs')
plt.ylabel('lines covered');

总结。我们已经看到灰盒模糊器“发现”了可以导致更多进展的有趣种子。从输入 good 开始,我们的灰盒模糊器逐渐学会了如何生成输入 bad!,这会引发异常。现在,我们如何能更快地做到这一点?
尝试一下。使用黑盒 基于生成 的模糊器,在一段时间内能实现多少覆盖率?尝试绘制所有三个模糊器的覆盖率图。您可以将黑盒基于生成的模糊器定义为以下内容。
from Fuzzer import RandomFuzzer
blackbox_gen_fuzzer = RandomFuzzer(min_length=4, max_length=4, char_start=32, char_range=96)
您可以通过将此章节作为 Jupyter 笔记本打开来执行自己的代码。
阅读。这是 AFL 的工作的高级视图,AFL 是最成功的漏洞检测工具之一。如果您对技术细节感兴趣,请参阅:github.com/mirrorer/afl/blob/master/docs/technical_details.txt
Boosted Greybox Fuzzing
我们的增强灰盒模糊器为承诺实现更多覆盖的种子分配更多能量。我们改变功率计划,使得执行“不寻常”路径的种子有更多能量。我们所说的 不寻常路径 是指生成的输入很少执行的路径。
为了确定输入执行了哪个路径,我们利用 跟踪覆盖率 部分的 getPathID 函数。
import [pickle](https://docs.python.org/3/library/pickle.html) # serializes an object by producing a byte array from all the information in the object
import [hashlib](https://docs.python.org/3/library/hashlib.html) # produces a 128-bit hash value from a byte array
函数 getPathID 返回一个覆盖集的唯一哈希值。
def getPathID(coverage: Any) -> str:
"""Returns a unique hash for the covered statements"""
pickled = pickle.dumps(sorted(coverage))
return hashlib.md5(pickled).hexdigest()
根据执行路径的异常程度,有几种方法来分配能量。在这种情况下,我们实现了一个 指数功率计划,它计算种子 \(s\) 的能量 \(e(s)\) 如下 $$e(s) = \frac{1}{f(p(s))^a}$$ 其中
-
\(p(s)\) 返回由 \(s\) 执行的路径的 ID,
-
\(f(p)\) 返回路径 \(p\) 被生成输入执行的次数,并且
-
\(a\) 是一个给定的指数。
class AFLFastSchedule(PowerSchedule):
"""Exponential power schedule as implemented in AFL"""
def __init__(self, exponent: float) -> None:
self.exponent = exponent
def assignEnergy(self, population: Sequence[Seed]) -> None:
"""Assign exponential energy inversely proportional to path frequency"""
for seed in population:
seed.energy = 1 / (self.path_frequency[getPathID(seed.coverage)] ** self.exponent)
在灰盒模糊器中,让我们跟踪每个路径 \(p\) 被执行 \(f(p)\) 的次数,并更新功率计划。
class CountingGreyboxFuzzer(GreyboxFuzzer):
"""Count how often individual paths are exercised."""
def reset(self):
"""Reset path frequency"""
super().reset()
self.schedule.path_frequency = {}
def run(self, runner: FunctionCoverageRunner) -> Tuple[Any, str]:
"""Inform scheduler about path frequency"""
result, outcome = super().run(runner)
path_id = getPathID(runner.coverage())
if path_id not in self.schedule.path_frequency:
self.schedule.path_frequency[path_id] = 1
else:
self.schedule.path_frequency[path_id] += 1
return(result, outcome)
好的,让我们在我们的简单 示例 上运行我们的增强灰盒模糊器 \(n=10k\) 次。我们将指数功率计划的指数设置为 \(a=5\)。
n = 10000
seed_input = "good"
fast_schedule = AFLFastSchedule(5)
fast_fuzzer = CountingGreyboxFuzzer([seed_input], Mutator(), fast_schedule)
start = time.time()
fast_fuzzer.runs(FunctionCoverageRunner(crashme), trials=n)
end = time.time()
"It took the fuzzer w/ exponential schedule %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the fuzzer w/ exponential schedule 1.54 seconds to generate and execute 10000 inputs.'
import [numpy](https://numpy.org/) as np
x_axis = np.arange(len(fast_schedule.path_frequency))
y_axis = list(fast_schedule.path_frequency.values())
plt.bar(x_axis, y_axis)
plt.xticks(x_axis)
plt.ylim(0, n)
# plt.yscale("log")
# plt.yticks([10,100,1000,10000])
plt;

print(" path id 'p' : path frequency 'f(p)'")
fast_schedule.path_frequency
path id 'p' : path frequency 'f(p)'
{'e014b68ad4f3bc2daf207e2498d14cbf': 5615,
'0a1008773804033d8a4c0e3aba4b96a0': 2597,
'eae4df5b039511eac56625f47c337d24': 1098,
'b14f545c3b39716a455034d9a0c61b8c': 454,
'11529f85aaa30be08110f3076748e420': 235,
'669903139e4064f7f07bc8b200fd2d26': 1}
它与我们的具有经典功率计划的灰盒模糊器相比如何?
seed_input = "good"
orig_schedule = PowerSchedule()
orig_fuzzer = CountingGreyboxFuzzer([seed_input], Mutator(), orig_schedule)
start = time.time()
orig_fuzzer.runs(FunctionCoverageRunner(crashme), trials=n)
end = time.time()
"It took the fuzzer w/ original schedule %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the fuzzer w/ original schedule 1.43 seconds to generate and execute 10000 inputs.'
x_axis = np.arange(len(orig_schedule.path_frequency))
y_axis = list(orig_schedule.path_frequency.values())
plt.bar(x_axis, y_axis)
plt.xticks(x_axis)
plt.ylim(0, n)
# plt.yscale("log")
# plt.yticks([10,100,1000,10000])
plt;

print(" path id 'p' : path frequency 'f(p)'")
orig_schedule.path_frequency
path id 'p' : path frequency 'f(p)'
{'e014b68ad4f3bc2daf207e2498d14cbf': 6579,
'0a1008773804033d8a4c0e3aba4b96a0': 2381,
'eae4df5b039511eac56625f47c337d24': 737,
'b14f545c3b39716a455034d9a0c61b8c': 241,
'11529f85aaa30be08110f3076748e420': 62}
指数功率计划会从“高频路径”的执行中削减一些,并将它们添加到低频路径中。执行最少的路径要么在传统的功率计划中根本不执行,要么执行得很少。
让我们看看分配给发现种子的能量。
orig_energy = orig_schedule.normalizedEnergy(orig_fuzzer.population)
for (seed, norm_energy) in zip(orig_fuzzer.population, orig_energy):
print("'%s', %0.5f, %s" % (getPathID(seed.coverage),
norm_energy, repr(seed.data)))
'e014b68ad4f3bc2daf207e2498d14cbf', 0.20000, 'good'
'0a1008773804033d8a4c0e3aba4b96a0', 0.20000, 'bgI/d'
'eae4df5b039511eac56625f47c337d24', 0.20000, 'baI/dt'
'b14f545c3b39716a455034d9a0c61b8c', 0.20000, 'badtuS'
'11529f85aaa30be08110f3076748e420', 0.20000, 'bad!`tuS'
fast_energy = fast_schedule.normalizedEnergy(fast_fuzzer.population)
for (seed, norm_energy) in zip(fast_fuzzer.population, fast_energy):
print("'%s', %0.5f, %s" % (getPathID(seed.coverage),
norm_energy, repr(seed.data)))
'e014b68ad4f3bc2daf207e2498d14cbf', 0.00000, 'good'
'0a1008773804033d8a4c0e3aba4b96a0', 0.00000, 'bnd'
'eae4df5b039511eac56625f47c337d24', 0.00000, 'ba.'
'b14f545c3b39716a455034d9a0c61b8c', 0.00000, 'bad.'
'11529f85aaa30be08110f3076748e420', 0.00000, 'bad!\\.'
'669903139e4064f7f07bc8b200fd2d26', 1.00000, 'bad!\\.'
正确。我们新的指数功率计划将大部分能量分配给练习最低频率路径的种子。
让我们比较它们在时间上达到的覆盖率,以我们的简单示例为例。
_, orig_coverage = population_coverage(orig_fuzzer.inputs, crashme)
_, fast_coverage = population_coverage(fast_fuzzer.inputs, crashme)
line_orig, = plt.plot(orig_coverage, label="Original Greybox Fuzzer")
line_fast, = plt.plot(fast_coverage, label="Boosted Greybox Fuzzer")
plt.legend(handles=[line_orig, line_fast])
plt.title('Coverage over time')
plt.xlabel('# of inputs')
plt.ylabel('lines covered');

如预期的那样,增强型灰盒模糊器(使用指数功率计划)达到覆盖率的速度更快。
总结。通过更频繁地模糊练习低频率路径的种子,我们可以以更有效的方式探索程序路径。
尝试一下。你可以尝试快速功率计划的其它指数,或者完全改变功率计划。注意,大的指数可能导致浮点运算中的溢出和不精确,产生意外的结果。你可以通过将此章节作为 Jupyter 笔记本打开来执行自己的代码。
阅读。你可以在论文"基于覆盖率的灰盒模糊作为马尔可夫链"[Böhme 等人,2018]中了解更多关于模糊器增强的信息,并查看 AFL 中的实现[github.com/mboehme/aflfast]。
复杂示例:HTMLParser
让我们比较三个模糊器在一个更现实的例子上的表现,Python 的HTML 解析器。我们在 HTMLParser 上运行所有三个模糊器\(n=5k\)次,从“空”种子开始。
from [html.parser](https://docs.python.org/3/library/html.parser.html) import HTMLParser
# create wrapper function
def my_parser(inp: str) -> None:
parser = HTMLParser() # resets the HTMLParser object for every fuzz input
parser.feed(inp)
n = 5000
seed_input = " " # empty seed
blackbox_fuzzer = AdvancedMutationFuzzer([seed_input], Mutator(), PowerSchedule())
greybox_fuzzer = GreyboxFuzzer([seed_input], Mutator(), PowerSchedule())
boosted_fuzzer = CountingGreyboxFuzzer([seed_input], Mutator(), AFLFastSchedule(5))
start = time.time()
blackbox_fuzzer.runs(FunctionCoverageRunner(my_parser), trials=n)
greybox_fuzzer.runs(FunctionCoverageRunner(my_parser), trials=n)
boosted_fuzzer.runs(FunctionCoverageRunner(my_parser), trials=n)
end = time.time()
"It took all three fuzzers %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took all three fuzzers 11.23 seconds to generate and execute 5000 inputs.'
模糊器在覆盖率方面的比较如何?
_, black_coverage = population_coverage(blackbox_fuzzer.inputs, my_parser)
_, grey_coverage = population_coverage(greybox_fuzzer.inputs, my_parser)
_, boost_coverage = population_coverage(boosted_fuzzer.inputs, my_parser)
line_black, = plt.plot(black_coverage, label="Blackbox Fuzzer")
line_grey, = plt.plot(grey_coverage, label="Greybox Fuzzer")
line_boost, = plt.plot(boost_coverage, label="Boosted Greybox Fuzzer")
plt.legend(handles=[line_boost, line_grey, line_black])
plt.title('Coverage over time')
plt.xlabel('# of inputs')
plt.ylabel('lines covered');

两种灰盒模糊器明显优于黑盒模糊器。原因是灰盒模糊器“发现”了沿途的有趣输入。让我们看看灰盒模糊器和黑盒模糊器生成的最后 10 个输入。
blackbox_fuzzer.inputs[-10:]
['2 ', '', 'A ', '', '`', '!', '', '', '', '(']
greybox_fuzzer.inputs[-10:]
['\x00',
'(0<2q$V\x00"jy}Kz',
'_NrGlffJ%\x14=',
'2&8,gBa',
'',
'W_&k\\<C<hoPqv',
'*&\x05t4<&d@R%',
'0aO<4\x06',
'i:',
'iFK&</\x06']
灰盒模糊器执行更复杂的输入,其中许多包括特殊字符,如开闭括号和箭头(即<, >, [, ])。然而,许多重要关键词,如<html>仍然缺失。
为了让模糊器了解这些重要关键词,我们需要语法;在智能灰盒模糊的部分,我们将它们与上述技术结合起来。
尝试一下。你可以重新运行这些实验来理解模糊实验的变异性。有时,我们声称优于较差模糊器的模糊器似乎并没有优于较差的模糊器。为了做到这一点,你只需要将此章节作为 Jupyter 笔记本打开。
指导灰盒模糊
有时,你只想让模糊器到达源代码中的某些危险位置。这可能是一个你期望缓冲区溢出的位置。或者你想测试代码库中的最近更改。我们如何指导模糊器向这些位置移动?
在本章中,我们将指导灰盒模糊作为优化问题进行介绍。
解决迷宫
为了提供一个你可以轻松改变代码复杂度和目标位置的具有意义的示例,我们从提供的字符串形式的迷宫生成迷宫源代码。这个例子大致基于 Felipe Andres Manzano(快速致意!)关于符号执行的旧博客文章。
你只需指定迷宫为一个字符串。就像这样。
maze_string = """
+-+-----+
|X| |
| | --+ |
| | | |
| +-- | |
| |#|
+-----+-+
"""
代码是通过generate_maze_code()函数生成的。我们将隐藏实现细节,而是解释它做了什么。如果你对编码感兴趣,请点击这里。
from ControlFlow import generate_maze_code
maze_code = generate_maze_code(maze_string)
exec(maze_code)
目标是通过提供输入D(向下),U(向上),L(向左),R(向右)来将“X”移动到“#”。
print(maze("DDDDRRRRUULLUURRRRDDDD")) # Appending one more 'D', you have reached the target.
SOLVED
+-+-----+
| | |
| | --+ |
| | | |
| +-- | |
| |X|
+-----+-+
maze_string中的每个字符代表一个瓦片。对于每个瓦片,生成一个瓦片函数。
-
如果当前瓦片是“良性”(
`),则调用对应于下一个输入字符(D,U,L,R)的瓦片函数。意外的输入字符将被忽略。如果没有更多的输入字符,它将返回“VALID”和当前迷宫状态。 -
如果当前瓦片是“陷阱”(
+,|,-),它将返回“INVALID”和当前迷宫状态。 -
如果当前瓦片是“目标”(
#),它将返回“SOLVED”和当前迷宫状态。
尝试一下。你可以测试其他输入字符序列,甚至完全更改迷宫。为了执行自己的代码,你只需要将此章节作为 Jupyter 笔记本打开。
为了了解生成的代码,让我们看看静态调用图。调用图显示了函数可以执行的顺序。
from ControlFlow import callgraph
callgraph(maze_code)
第一次尝试
我们引入了一个DictMutator类,该类通过从给定字典中插入关键字来突变字符串:
class DictMutator(Mutator):
"""Variant of `Mutator` inserting keywords from a dictionary"""
def __init__(self, dictionary: Sequence[str]) -> None:
"""Constructor.
`dictionary` - a list of strings that can be used as keywords
"""
super().__init__()
self.dictionary = dictionary
self.mutators.append(self.insert_from_dictionary)
def insert_from_dictionary(self, s: str) -> str:
"""Returns `s` with a keyword from the dictionary inserted"""
pos = random.randint(0, len(s))
random_keyword = random.choice(self.dictionary)
return s[:pos] + random_keyword + s[pos:]
为了模糊迷宫,我们将DictMutator类扩展为将字典关键字追加到种子末尾,并从种子末尾删除一个字符。
class MazeMutator(DictMutator):
def __init__(self, dictionary: Sequence[str]) -> None:
super().__init__(dictionary)
self.mutators.append(self.delete_last_character)
self.mutators.append(self.append_from_dictionary)
def append_from_dictionary(self, s: str) -> str:
"""Returns s with a keyword from the dictionary appended"""
random_keyword = random.choice(self.dictionary)
return s + random_keyword
def delete_last_character(self, s: str) -> str:
"""Returns s without the last character"""
if len(s) > 0:
return s[:-1]
return s
让我们尝试一个标准的灰盒模糊器,使用经典的功率调度和我们的扩展迷宫突变器(n=20k)。
n = 20000
seed_input = " " # empty seed
maze_mutator = MazeMutator(["L", "R", "U", "D"])
maze_schedule = PowerSchedule()
maze_fuzzer = GreyboxFuzzer([seed_input], maze_mutator, maze_schedule)
start = time.time()
maze_fuzzer.runs(FunctionCoverageRunner(maze), trials=n)
end = time.time()
"It took the fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the fuzzer 8.27 seconds to generate and execute 20000 inputs.'
我们将需要为几个模糊器打印统计数据。我们为什么不定义一个函数来做这件事呢?
def print_stats(fuzzer: GreyboxFuzzer) -> None:
total = len(fuzzer.population)
solved = 0
invalid = 0
valid = 0
for seed in fuzzer.population:
s = maze(str(seed.data))
if "INVALID" in s:
invalid += 1
elif "VALID" in s:
valid += 1
elif "SOLVED" in s:
solved += 1
if solved == 1:
print("First solution: %s" % repr(seed))
else:
print("??")
print("""Out of %d seeds,
* %4d solved the maze,
* %4d were valid but did not solve the maze, and
* %4d were invalid""" % (total, solved, valid, invalid))
我们的好老灰盒模糊器表现如何?
print_stats(maze_fuzzer)
Out of 1275 seeds,
* 0 solved the maze,
* 319 were valid but did not solve the maze, and
* 956 were invalid
它可能一次也没有解决迷宫。我们如何让模糊器知道种子“离目标有多远”?如果我们知道这一点,我们就可以给那个种子分配更多的能量。
尝试一下。使用AFLFastSchedule和CountingGreyboxFuzzer打印增强模糊器的统计数据。它可能比未增强的灰盒模糊器表现得更好:最不可能的路径恰好是到达目标的路径。你可以通过将此章节作为 Jupyter 笔记本打开来执行自己的代码。
计算函数级距离
使用迷宫代码和目标函数的静态调用图,我们可以计算每个函数\(f\)到目标\(t\)的距离,即\(f\)和\(t\)之间最短路径的长度。
幸运的是,生成的迷宫代码包括一个名为 target_tile 的函数,该函数返回目标函数的名称。
target = target_tile()
target
'tile_6_7'
现在,我们需要在调用图中找到相应的函数。函数 get_callgraph 返回迷宫代码的 networkx 图。Networkx 提供了一些用于图分析的有用函数。
import [networkx](https://networkx.org/) as nx
from ControlFlow import get_callgraph
cg = get_callgraph(maze_code)
for node in cg.nodes():
if target in node:
target_node = node
break
target_node
'callgraphX__tile_6_7'
我们现在可以生成函数级别的距离。字典 distance 包含每个函数到目标函数的距离。如果没有通往目标的路径,我们分配一个最大距离(0xFFFF)。
函数 nx.shortest_path_length(CG, node, target_node) 返回从函数 node 到调用图 CG 中函数 target_node 的最短路径长度。
distance = {}
for node in cg.nodes():
if "__" in node:
name = node.split("__")[-1]
else:
name = node
try:
distance[name] = nx.shortest_path_length(cg, node, target_node)
except:
distance[name] = 0xFFFF
这些是通往目标函数路径上所有分片函数的距离值。
{k: distance[k] for k in list(distance) if distance[k] < 0xFFFF}
{'callgraphX': 1,
'maze': 23,
'tile_2_1': 22,
'tile_2_3': 8,
'tile_2_4': 7,
'tile_2_5': 6,
'tile_2_6': 5,
'tile_2_7': 4,
'tile_3_1': 21,
'tile_3_3': 9,
'tile_3_7': 3,
'tile_4_1': 20,
'tile_4_3': 10,
'tile_4_4': 11,
'tile_4_5': 12,
'tile_4_7': 2,
'tile_5_1': 19,
'tile_5_5': 13,
'tile_5_7': 1,
'tile_6_1': 18,
'tile_6_2': 17,
'tile_6_3': 16,
'tile_6_4': 15,
'tile_6_5': 14,
'tile_6_7': 0}
总结。使用静态调用图和目标函数 \(t\),我们已经展示了如何计算每个函数 \(f\) 到目标 \(t\) 的函数级别距离。
尝试一下。你可以通过将此章节作为 Jupyter 笔记本打开来尝试并执行自己的代码。
-
如果有多个目标,我们如何计算距离?(提示:几何平均)。
-
对于每个函数 \(f\) 的调用图(CG)和控制流图(CFG\(_f\)),我们如何计算基本块(BB)级别的距离?(提示:在 CFG\(_f\) 中,测量通往目标函数路径上函数的调用基本块级别的距离。记住,在函数级别距离较高的函数中,基本块级别的距离也较高。)
阅读。如果你对搜索的其他方面感兴趣,可以通过阅读关于 基于搜索的模糊测试 的章节来跟进。如果你对解决上述问题感兴趣,可以查看我们关于 "有向灰盒模糊测试" 的论文。
有向功率调度
既然我们已经知道了如何计算函数级别的距离,那么让我们尝试实现一个功率调度方案,该方案将更多能量分配给与目标函数平均距离较低的种子。请注意,距离值都是预先计算的。这些值被注入到程序二进制中,就像覆盖率工具一样。在实践中,这使得计算平均距离极其高效。
如果你真的想知道。给定函数 \(s\) 到调用图 \(CG\) 中函数 \(t\) 的函数级别距离 \(d_f(s,t)\),我们的有向功率调度计算种子 \(i\) 到函数 \(t\) 的种子距离 \(d(i,t)\) 为 \(d(i,t)=\dfrac{\sum_{s\in CG} d_f(s,t)}{|CG|}\),其中 \(|CG|\) 是调用图 \(CG\) 中的节点数。
class DirectedSchedule(PowerSchedule):
"""Assign high energy to seeds close to some target"""
def __init__(self, distance: Dict[str, int], exponent: float) -> None:
self.distance = distance
self.exponent = exponent
def __getFunctions__(self, coverage: Set[Location]) -> Set[str]:
functions = set()
for f, _ in set(coverage):
functions.add(f)
return functions
def assignEnergy(self, population: Sequence[Seed]) -> None:
"""Assigns each seed energy inversely proportional
to the average function-level distance to target."""
for seed in population:
if seed.distance < 0:
num_dist = 0
sum_dist = 0
for f in self.__getFunctions__(seed.coverage):
if f in list(self.distance):
sum_dist += self.distance[f]
num_dist += 1
seed.distance = sum_dist / num_dist
seed.energy = (1 / seed.distance) ** self.exponent
让我们看看有向调度与老式的灰盒模糊器相比的表现如何。
directed_schedule = DirectedSchedule(distance, 3)
directed_fuzzer = GreyboxFuzzer([seed_input], maze_mutator, directed_schedule)
start = time.time()
directed_fuzzer.runs(FunctionCoverageRunner(maze), trials=n)
end = time.time()
"It took the fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the fuzzer 10.55 seconds to generate and execute 20000 inputs.'
print_stats(directed_fuzzer)
Out of 2524 seeds,
* 0 solved the maze,
* 916 were valid but did not solve the maze, and
* 1608 were invalid
它可能也没有解决任何一个迷宫,但我们有更多的有效解决方案。所以,肯定有进步。
让我们看看每个种子的距离值。
y = [seed.distance for seed in directed_fuzzer.population]
x = range(len(y))
plt.scatter(x, y)
plt.ylim(0, max(y))
plt.xlabel("Seed ID")
plt.ylabel("Distance");

让我们归一化 y 轴并提高小距离种子的权重。
改进的定向功率调度
改进的定向调度将种子距离在最小和最大距离之间进行归一化。再次,如果你真的想了解的话。对于一个种子 \(i\) 到函数 \(t\) 的种子距离 \(d(i,t)\),我们的改进功率调度计算新的种子距离 \(d'(i,t)\) 如下:$$ d'(i,t)=\begin{cases} 1 & \text{if } d(i,t) = \text{minD} = \text{maxD}\ \text{maxD} - \text{minD} & \text{if } d(i,t) = \text{minD} \neq \text{maxD}\ \frac{\text{maxD} - \text{minD}}{d(i,t)-\text{minD}} & \text{otherwise} \end{cases} $$ 其中 $$\text{minD}=\min_{i\in T}[d(i,t)]$$ 和 $$\text{maxD}=\max_{i\in T}[d(i,t)]$$ 其中 \(T\) 是种子集(即,种群)。
class AFLGoSchedule(DirectedSchedule):
"""Assign high energy to seeds close to the target"""
def assignEnergy(self, population: Sequence[Seed]):
"""Assigns each seed energy inversely proportional
to the average function-level distance to target."""
min_dist: Union[int, float] = 0xFFFF
max_dist: Union[int, float] = 0
for seed in population:
if seed.distance < 0:
num_dist = 0
sum_dist = 0
for f in self.__getFunctions__(seed.coverage):
if f in list(self.distance):
sum_dist += self.distance[f]
num_dist += 1
seed.distance = sum_dist / num_dist
if seed.distance < min_dist:
min_dist = seed.distance
if seed.distance > max_dist:
max_dist = seed.distance
for seed in population:
if seed.distance == min_dist:
if min_dist == max_dist:
seed.energy = 1
else:
seed.energy = max_dist - min_dist
else:
seed.energy = (max_dist - min_dist) / (seed.distance - min_dist)
让我们看看改进的功率调度表现如何。
aflgo_schedule = AFLGoSchedule(distance, 3)
aflgo_fuzzer = GreyboxFuzzer([seed_input], maze_mutator, aflgo_schedule)
start = time.time()
aflgo_fuzzer.runs(FunctionCoverageRunner(maze), trials=n)
end = time.time()
"It took the fuzzer %0.2f seconds to generate and execute %d inputs." % (end - start, n)
'It took the fuzzer 16.13 seconds to generate and execute 20000 inputs.'
print_stats(aflgo_fuzzer)
First solution: lMDD+D4DM69WGR>PR?RRqUUVLJLGUURRRRDDDD
Out of 4071 seeds,
* 649 solved the maze,
* 438 were valid but did not solve the maze, and
* 2984 were invalid
与所有之前的功率调度相比,这个生成数百个解决方案。它已经生成了许多解决方案。
让我们从第一个解决方案中过滤掉所有忽略的输入字符。函数 filter(f, seed.data) 返回 seed.data 中元素 e 的列表,其中函数 f 应用在 e 上返回 True。
for seed in aflgo_fuzzer.population:
s = maze(str(seed.data))
if "SOLVED" in s:
filtered = "".join(list(filter(lambda c: c in "UDLR", seed.data)))
print(filtered)
break
DDDDRRRRUULLUURRRRDDDD
这绝对是对开始时指定的迷宫的解决方案!
摘要. 在预先计算到目标函数级距离之后,我们可以开发一个功率调度方案,为平均函数级距离到目标较小的种子分配更多能量。通过在最小和最大种子距离之间归一化种子距离值,我们可以进一步增强定向功率调度。
尝试一下。实现并评估一个使用最小(而不是平均)函数级距离的简单定向功率。使用最小距离的缺点是什么?为了执行你的代码,你只需要将此章节作为 Jupyter 笔记本打开。
阅读。你可以在同名的论文 "定向灰盒模糊" [Böhme 等人,2017] 中了解更多关于定向灰盒模糊的信息,并在 github.com/aflgo/aflgo 查看 AFL 的实现。
经验教训
-
一个 灰盒模糊器 每秒生成数千个输入。预处理和轻量级仪器
-
允许在模糊测试活动期间保持效率,并且
-
仍然提供了足够的信息来控制进度并稍微引导模糊器。
-
-
功率调度 允许 引导和控制 模糊器。例如,
-
我们的增强灰盒模糊器在执行“不太可能”路径的种子上花费更多能量。希望生成的输入能执行更多不太可能的路径。这反过来又增加了每单位时间内探索的路径数量。
-
我们的定向灰盒模糊器在“更接近”目标位置的种子上花费更多能量。希望生成的输入能更接近目标。
-
-
变异器定义了模糊测试器的搜索空间。为给定程序自定义变异器可以减少搜索空间,仅包含相关输入。在接下来的几章中,我们将学习关于基于字典和基于语法的变异器的内容,以增加生成有效输入的比例。
背景
-
了解更多关于 AFL 的信息:
lcamtuf.coredump.cx/afl/ -
了解 LibFuzzer(另一个著名的灰盒模糊测试器):
llvm.org/docs/LibFuzzer.html -
白盒模糊测试器必须多快地执行每个路径才能比灰盒模糊测试器更有效率? Marcel Böhme 和 Soumya Paul. 2016. 自动软件测试效率的概率分析,IEEE TSE,42:345-360 [Böhme 等人,2016]
下一步
我们的目标仍然是充分覆盖功能,以便我们可以触发尽可能多的错误。为此,我们专注于两类技术:
-
尽可能覆盖尽可能多的指定功能。在这里,我们需要一个输入格式规范,区分个体输入元素,例如(在我们的案例中)数字、运算符、注释和字符串,并尝试覆盖尽可能多的这些元素。我们将随着基于语法的测试的展开来探讨这一点,尤其是在基于语法的变异中。
-
尽可能覆盖尽可能多的实现功能。在讨论基于搜索的测试时,将深入探讨“通过变异系统性地进化”的“种群”概念。此外,符号测试介绍了如何通过解决位于其路径上的条件来系统地到达程序位置。
这两种技术构成了本书的核心;当然,它们也可以相互结合。像往常一样,我们为所有内容提供可运行的代码。享受吧!
我们已经完成了,所以我们要清理:
import [os](https://docs.python.org/3/library/os.html)
if os.path.exists('callgraph.dot'):
os.remove('callgraph.dot')
if os.path.exists('callgraph.py'):
os.remove('callgraph.py')
兼容性
在本章的早期版本中,AdvancedMutationFuzzer类被简单地命名为MutationFuzzer,这导致了与基于变异的模糊测试介绍中的MutationFuzzer类名称混淆。以下声明引入了一个向后兼容的别名。
class MutationFuzzer(AdvancedMutationFuzzer):
pass
练习
待补充。 \todo{}
本项目的内容受 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/GreyboxFuzzer.html. 2023-11-11 18:18:06+01:00 获取。
@incollection{fuzzingbook2023:GreyboxFuzzer,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Greybox Fuzzing},
year = {2023},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/GreyboxFuzzer.html}},
note = {Retrieved 2023-11-11 18:18:06+01:00},
url = {https://www.fuzzingbook.org/html/GreyboxFuzzer.html},
urldate = {2023-11-11 18:18:06+01:00}
}
基于搜索的模糊测试
有时我们不仅对模糊测试尽可能多的不同程序输入感兴趣,还希望推导出特定的测试输入,以实现某些目标,例如到达程序中的特定语句。当我们对我们要找的东西有一个想法时,我们就可以搜索它。搜索算法是计算机科学的核心,但将经典搜索算法如广度优先搜索或深度优先搜索应用于搜索测试是不切实际的,因为这些算法可能需要我们查看所有可能的输入。然而,领域知识可以用来克服这个问题。例如,如果我们能估计几个程序输入中哪一个更接近我们要找的,那么这些信息就可以引导我们更快地达到目标——这种信息被称为启发式方法。启发式方法系统地应用的方式被捕获在元启发式搜索算法中。"Meta"表示这些算法是通用的,并且可以根据不同的问题实例化。元启发式通常从自然界观察到的过程中获得灵感。例如,有一些算法模仿进化过程、群体智能或化学反应。总的来说,它们比穷举搜索方法更有效,因此可以应用于庞大的搜索空间——对于它们来说,程序输入领域的庞大搜索空间不是问题。
先决条件
- 您应该了解代码覆盖率是如何工作的,例如从覆盖率章节中了解。
概述
要使用本章提供的代码(导入代码),请编写
>>> from fuzzingbook.SearchBasedFuzzer import <identifier>
然后利用以下功能。
本章演示了如何使用元启发式搜索算法来寻找达到源代码中特定位置的输入。最简单的搜索算法是爬山法,它应用于简单的test_me示例,如下所示:
>>> hillclimber()
Initial value: -67005, 8742 at fitness 84491.0000
New value: -67006, 8741 at fitness 84490.0000
New value: -67007, 8740 at fitness 84489.0000
New value: -67008, 8739 at fitness 84488.0000
New value: -67009, 8738 at fitness 84487.0000
New value: -67010, 8737 at fitness 84486.0000
New value: -67011, 8736 at fitness 84485.0000
New value: -67012, 8735 at fitness 84484.0000
New value: -67013, 8734 at fitness 84483.0000
New value: -67014, 8733 at fitness 84482.0000
New value: -67015, 8732 at fitness 84481.0000
New value: -67016, 8731 at fitness 84480.0000
New value: -67017, 8730 at fitness 84479.0000
New value: -67018, 8729 at fitness 84478.0000
New value: -67019, 8728 at fitness 84477.0000
New value: -67020, 8727 at fitness 84476.0000
New value: -67021, 8726 at fitness 84475.0000
New value: -67022, 8725 at fitness 84474.0000
New value: -67023, 8724 at fitness 84473.0000
New value: -67024, 8723 at fitness 84472.0000
New value: -67025, 8722 at fitness 84471.0000
...
Found optimum after 58743 iterations at -100000, -50001
不同的方面和挑战需要此算法的不同变体,例如steepest_ascent_hillclimber或restarting_hillclimber。
搜索可以由不同优化目标在适应度函数中捕获的优化目标来引导。一个用于测量我们接近达到源代码中位置的距离的适应度函数使用源代码插桩。要生成cgi_decode的插桩版本,请使用:
>>> cgi_decode_instrumented = create_instrumented_function(cgi_decode)
/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7394/942233608.py:15: DeprecationWarning: ast.Num is deprecated and will be removed in Python 3.14; use ast.Constant instead
args=[ast.Num(self.branch_num),
/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7394/942233608.py:16: DeprecationWarning: ast.Str is deprecated and will be removed in Python 3.14; use ast.Constant instead
ast.Str(node.ops[0].__class__.__name__),
通过执行cgi_decode_instrumented来获得适应度值,这是通过get_fitness_cgi函数完成的:
>>> get_fitness_cgi("Foo")
5.0
复杂的函数如cgi_decode会导致搜索空间大大增加,可以使用诸如遗传算法之类的进化搜索算法来探索:
>>> genetic_algorithm()
Best fitness of initial population: '䫪Ʝ\uf42b 铺뿱ጻ䗷䌮肵篭' - 5.0000000000
Best fitness at generation 1: '\u19cdꥁ캖蝻ⅹ\uf37f 功ᰲ\ued7eᱨ' - 5.00000000
Best fitness at generation 2: '绑䀕\u20c5֜적\udfaeᇒ툧痮Ꮶ' - 5.00000000
Best fitness at generation 3: '끍碼ߝ䣅쾜\u0b7b죅ᦜ\uf1fd䈕' - 5.00000000
Best fitness at generation 4: '甚ᇆꏭ貰꾵訴྿ꙩᏃด' - 5.00000000
Best fitness at generation 5: '\uf644ᇆꏭ貰虀ꎍ\uf6f9 嫛ሎ㺁' - 5.00000000
Best fitness at generation 6: '빫\uf61a\ud85c 熆꾵訴ဍꙩᑓ\ue8e0' - 5.00000000
Best fitness at generation 7: '닅\uf307Ɗ\uefc5 筂鐞嚂ᡥ⃫㺤' - 5.00000000
Best fitness at generation 8: '漻㺅揝䄩薽턫轼\u0dcc\udb87 胮' - 5.00000000
Best fitness at generation 9: '甚ᇩ護㿦腄ꑗ\uf6f9 嫛ም凂' - 5.00000000
Best fitness at generation 10: '끍ᇆ⁔峤羘䶦Ⓛ巖桿\ue8ac' - 5.00000000
Best fitness at generation 11: '㞮械ꏭഡ鰴勂ᇒ툧䧱㺡' - 5.00000000
Best fitness at generation 12: '닅\uf307Ɗ䣅筂鐮\uf697 媭ም凂' - 5.00000000
Best fitness at generation 13: '췵㪈쾟⢥筂鐇勨憣并ꓹ' - 5.00000000
Best fitness at generation 14: '睾\uf2aaメ\uef8b 鰴⥢邹坅櫼砳' - 5.00000000
Best fitness at generation 15: '盾㩭譂䅎웱勂ᇒ텬䧱㺡' - 5.00000000
Best fitness at generation 16: '끍ᇆ₩豻畕傞ᅢ툧䧱A' - 5.00000000
Best fitness at generation 17: '뀳硺ߝ\uefdb 笧勂ᇒ텬桘.' - 5.00000000
Best fitness at generation 18: '㴄ᅕ큕谉畕傞ᅢ툧䧱A' - 5.00000000
Best fitness at generation 19: '滴㪈㹮䣻羘䷴⒲嵟\udc02㺤' - 5.00000000
Best fitness at generation 20: '矖㪈㺂䢶羘䶦ᇒ䙗뭜탤' - 5.00000000
...
Best individual: '쩴篊㬍鍵糄䧱﬩廁\ude21 萇', fitness 5.0000000000
测试生成作为搜索问题
如果我们想要将元启发式搜索算法应用于生成程序的测试数据,那么我们必须做出几个选择:首先,我们需要决定我们的搜索空间究竟是什么。搜索空间是由我们如何表示我们正在寻找的内容来定义的。我们是在寻找单个整数值吗?值的元组?对象?XML 文档?
将程序输入表示为搜索问题
表示高度依赖于我们正在解决的特定测试问题——我们知道我们正在测试哪个程序,因此表示需要编码目标程序的所有输入。让我们以示例函数test_me()作为我们的测试函数:
import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils)
import Fuzzer
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import unicode_escape, terminal_escape
def test_me(x, y):
if x == 2 * (y + 1):
return True
else:
return False
test_me()函数有两个输入参数,根据这两个参数之间的关系返回True或False。test_me()的测试输入由一对值组成,一个用于x,一个用于y。例如:
test_me(0, 0)
False
test_me(4, 2)
False
test_me(22, 10)
True
我们的搜索空间只关注输入,因此测试数据的一个简单表示将是输入元组(x, y)。这个输入空间中的每个点都有八个邻居:
-
x-1, y-1 -
x-1, y -
x-1, y+1 -
x, y+1 -
x+1, y+1 -
x+1, y -
x+1, y-1 -
x, y-1
为了使事情简单起见,让我们先限制搜索空间的大小(我们稍后会改变这一点)。例如,让我们假设我们只想在-1000 到 1000 的范围内寻找值:
MAX = 1000
MIN = -MAX
为了检索搜索空间中任何点的邻居,我们定义了函数neighbors(),它实现了一个基本的摩尔邻域。也就是说,我们查看所有 8 个直接邻居,同时考虑我们刚刚用MAX和MIN定义的边界:
def neighbors(x, y):
return [(x + dx, y + dy) for dx in [-1, 0, 1]
for dy in [-1, 0, 1]
if (dx != 0 or dy != 0)
and ((MIN <= x + dx <= MAX)
and (MIN <= y + dy <= MAX))]
print(neighbors(10, 10))
[(9, 9), (9, 10), (9, 11), (10, 9), (10, 11), (11, 9), (11, 10), (11, 11)]
这完全定义了我们的搜索空间:我们有一个表示,并且我们知道个体之间通过其邻域是如何相互关联的。现在我们只需要找到一个算法来探索这个邻域,以及一个指导算法的启发式方法。
定义搜索景观:适应度函数
所有元启发式算法都是基于使用启发式函数来估计给定候选解的好坏;这种“好坏”通常被称为个体的适应度,估计适应度的启发式函数是适应度函数。适应度函数是一个将搜索空间中的任何点映射到数值的函数,即适应度值。在搜索空间中,与最优解相比,候选解越好,其适应度值就越高。因此,如果你将每个点在搜索空间中的适应度值作为高度绘制出来,你将得到一个景观,其中最优解表示为最高的山峰。
适应度函数取决于生成测试数据时想要达到的目标。假设我们感兴趣的是覆盖test_me()函数中 if 条件的真实分支,即x == 2 * (y + 1)。
给定输入元组与该函数的目标分支有多接近?让我们考虑搜索空间中的一个任意点,例如 (274, 153)。if 条件比较以下值:
x = 274
y = 153
x, 2 * (y + 1)
(274, 308)
为了使分支为真,两个值必须相同。因此,它们差异越大,我们离使比较为真就越远,它们差异越小,我们离使比较为真就越近。因此,我们可以通过计算 x 和 2 * (y + 1) 之间的差异来量化“比较有多假”。因此,我们可以计算这个距离为 abs(x - 2 * (y + 1)):
def calculate_distance(x, y):
return abs(x - 2 * (y + 1))
calculate_distance(274, 153)
34
我们可以使用这个距离值作为我们的适应度函数,因为我们可以很好地衡量我们离最优解有多近。然而,请注意,在这种情况下,“更好”并不意味着“更大”;距离越小越好。这不是问题,因为任何可以最大化值的算法也可以被修改为最小化它。
对于整数元组的搜索空间中的每个值,这个距离值定义了我们在搜索景观中的高度。由于我们的示例搜索空间是二维的,搜索景观是三维的,我们可以绘制它以查看其外观:
import [matplotlib.pyplot](https://matplotlib.org/) as plt
import [numpy](https://numpy.org/) as np
%matplotlib inline
xx = np.outer(np.linspace(-10, 10, 30), np.ones(30))
yy = xx.copy().T
zz = calculate_distance(xx, yy)
fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_surface(xx, yy, zz, cmap=plt.cm.jet, rstride=1, cstride=1, linewidth=0);

最优值,即使 if 条件为真的值,适应度值为 0,在图表的底部可以清楚地看到。离最优值越远,搜索空间中的点就越高。
仪器化
适应度函数应该计算具体测试执行的距离值。也就是说,我们想要运行程序,然后学习这次执行的距离值。然而,分支条件隐藏在目标函数的源代码中,其值原则上可能是到达该条件执行路径上的各种计算的结果。尽管在我们的例子中,条件是一个直接使用函数输入值的等式,但在一般情况下可能不是这样;它也可能是导出值。因此,我们需要直接在条件语句中观察到的值来计算我们的距离度量。
这通常是通过仪器来完成的:我们在分支条件之前或之后立即添加新代码,以跟踪观察到的值并使用这些值计算距离。以下是我们正在测试的程序的一个仪器化版本,它在执行时会打印出距离值:
def test_me_instrumented(x, y):
print("Instrumentation: Input = (%d, %d), distance = %d" %
(x, y, calculate_distance(x, y)))
if x == 2 * (y + 1):
return True
else:
return False
让我们尝试几个示例值:
test_me_instrumented(0, 0)
Instrumentation: Input = (0, 0), distance = 2
False
test_me_instrumented(5, 2)
Instrumentation: Input = (5, 2), distance = 1
False
test_me_instrumented(22, 10)
Instrumentation: Input = (22, 10), distance = 0
True
在计算适应度值时,我们将执行仪器化程序版本,但我们需要某种方法来访问在执行期间计算的距离值。作为这个问题的简单初始解决方案,我们可以在全局变量中添加一个并存储距离计算值。
distance = 0
def test_me_instrumented(x, y):
global distance
distance = calculate_distance(x, y)
if x == 2 * (y + 1):
return True
else:
return False
使用这个test_me()的仪器版本,我们现在可以最终定义我们的适应度函数,该函数简单地运行仪器化的test_me_instrumented()函数,然后检索全局distance变量的值:
def get_fitness(x, y):
global distance
test_me_instrumented(x, y)
fitness = distance
return fitness
让我们在一些示例输入上尝试一下:
get_fitness(0, 0)
2
get_fitness(1, 2)
5
get_fitness(22, 10)
0
爬山示例实现
在确定了表示方法(整数对的二元组)和适应度函数(到目标分支的距离)之后,我们现在可以最终实施我们的搜索算法。让我们使用最简单的元启发式算法:爬山法来探索这个搜索空间。这个隐喻恰当地捕捉了正在发生的事情:该算法试图在我们定义的表示方法定义的搜索空间中爬上一座山。但是,在我们的搜索景观中,最佳值不是那些高处的,而是低处的,所以技术上我们是在下降到山谷中。
爬山算法本身非常简单:
-
选择一个随机的起点
-
确定所有相邻点的适应度值
-
移动到具有最佳适应度值的相邻点
-
如果未找到解决方案,则继续步骤 2
爬山者从一个随机的测试输入开始,即x和y的随机值。对于任何一对随机整数,它们满足条件x == 2 * (y + 1)的可能性相当小。假设随机值是(274, 153)。等式的右侧,2 * (y + 1)计算为 308,所以条件显然是错误的。爬山者现在应该去哪里?让我们看看这个测试输入及其邻居的适应度值:
x, y = 274, 153
print("Origin %d, %d has fitness %d" % (x, y, get_fitness(x, y)))
for nx, ny in neighbors(x, y):
print("neighbor %d, %d has fitness %d" % (nx, ny, get_fitness(nx, ny)))
Origin 274, 153 has fitness 34
neighbor 273, 152 has fitness 33
neighbor 273, 153 has fitness 35
neighbor 273, 154 has fitness 37
neighbor 274, 152 has fitness 32
neighbor 274, 154 has fitness 36
neighbor 275, 152 has fitness 31
neighbor 275, 153 has fitness 33
neighbor 275, 154 has fitness 35
将y增加一个单位会增加等式右侧的值到310。因此,等式左侧的值与等式右侧的值相比,差异更大!所以,增加y似乎不是一个好主意。另一方面,将x增加一个单位改善了情况:等式的左侧和右侧变得更加相似;它们是“不那么不平等”。因此,在(274, 153)的八个可能邻居中,增加x并减少y的邻居((275, 152))似乎最好直观上——条件的输出仍然是错误的,但比原始值“不那么错误”。
现在我们来实现爬山算法。
import [random](https://docs.python.org/3/library/random.html)
LOG_VALUES = 20 # Number of values to log
def hillclimber():
# Create and evaluate starting point
x, y = random.randint(MIN, MAX), random.randint(MIN, MAX)
fitness = get_fitness(x, y)
print("Initial value: %d, %d at fitness %.4f" % (x, y, fitness))
iterations = 0
logs = 0
# Stop once we have found an optimal solution
while fitness > 0:
iterations += 1
# Move to first neighbor with a better fitness
for (nextx, nexty) in neighbors(x, y):
new_fitness = get_fitness(nextx, nexty)
# Smaller fitness values are better
if new_fitness < fitness:
x, y = nextx, nexty
fitness = new_fitness
if logs < LOG_VALUES:
print("New value: %d, %d at fitness %.4f" % (x, y, fitness))
elif logs == LOG_VALUES:
print("...")
logs += 1
break
print("Found optimum after %d iterations at %d, %d" % (iterations, x, y))
hillclimber()
Initial value: 201, -956 at fitness 2111.0000
New value: 200, -956 at fitness 2110.0000
New value: 199, -956 at fitness 2109.0000
New value: 198, -956 at fitness 2108.0000
New value: 197, -956 at fitness 2107.0000
New value: 196, -956 at fitness 2106.0000
New value: 195, -956 at fitness 2105.0000
New value: 194, -956 at fitness 2104.0000
New value: 193, -956 at fitness 2103.0000
New value: 192, -956 at fitness 2102.0000
New value: 191, -956 at fitness 2101.0000
New value: 190, -956 at fitness 2100.0000
New value: 189, -956 at fitness 2099.0000
New value: 188, -956 at fitness 2098.0000
New value: 187, -956 at fitness 2097.0000
New value: 186, -956 at fitness 2096.0000
New value: 185, -956 at fitness 2095.0000
New value: 184, -956 at fitness 2094.0000
New value: 183, -956 at fitness 2093.0000
New value: 182, -956 at fitness 2092.0000
New value: 181, -956 at fitness 2091.0000
...
Found optimum after 1656 iterations at -1000, -501
爬山者首先为x和y选择随机值。我们使用范围在-1000到1000之间的低值(我们之前定义了MIN和MAX),以减少在示例中玩耍时搜索所需的时间。然后,我们通过调用get_fitness()来确定这个起始点的适应度值。回想一下,我们正在尝试找到最小的适应度值,因此我们现在循环,直到我们找到一个适应度值为0(即最优值)。
在这个循环中,我们遍历所有邻居(neighbors),并评估每个邻居的适应度值。一旦我们找到一个具有更好(更小)适应度的邻居,爬山算法就会退出循环,并使用这个邻居作为新的起点。这个简单爬山算法的另一种变体是移除break语句:通过这样做,所有邻居都会被评估,并选择最好的邻居。这被称为最速上升爬山法。你会发现达到最优解所需的迭代次数更少,尽管对于每次迭代,需要执行更多的测试。
def steepest_ascent_hillclimber():
# Create and evaluate starting point
x, y = random.randint(MIN, MAX), random.randint(MIN, MAX)
fitness = get_fitness(x, y)
print("Initial value: %d, %d at fitness %.4f" % (x, y, fitness))
iterations = 0
logs = 0
# Stop once we have found an optimal solution
while fitness > 0:
iterations += 1
# Move to first neighbor with a better fitness
for (nextx, nexty) in neighbors(x, y):
new_fitness = get_fitness(nextx, nexty)
if new_fitness < fitness:
x, y = nextx, nexty
fitness = new_fitness
if logs < LOG_VALUES:
print("New value: %d, %d at fitness %.4f" % (x, y, fitness))
elif logs == LOG_VALUES:
print("...")
logs += 1
print("Found optimum after %d iterations at %d, %d" % (iterations, x, y))
steepest_ascent_hillclimber()
Initial value: -258, 645 at fitness 1550.0000
New value: -259, 644 at fitness 1549.0000
New value: -258, 644 at fitness 1548.0000
New value: -257, 644 at fitness 1547.0000
New value: -258, 643 at fitness 1546.0000
New value: -257, 643 at fitness 1545.0000
New value: -256, 643 at fitness 1544.0000
New value: -257, 642 at fitness 1543.0000
New value: -256, 642 at fitness 1542.0000
New value: -255, 642 at fitness 1541.0000
New value: -256, 641 at fitness 1540.0000
New value: -255, 641 at fitness 1539.0000
New value: -254, 641 at fitness 1538.0000
New value: -255, 640 at fitness 1537.0000
New value: -254, 640 at fitness 1536.0000
New value: -253, 640 at fitness 1535.0000
New value: -254, 639 at fitness 1534.0000
New value: -253, 639 at fitness 1533.0000
New value: -252, 639 at fitness 1532.0000
New value: -253, 638 at fitness 1531.0000
New value: -252, 638 at fitness 1530.0000
...
Found optimum after 517 iterations at 258, 128
我们的示例程序有一个非常好的适应度景观——有一个完美的梯度,爬山算法总是会找到一个解。如果我们绘制观察到的适应度值随时间的变化,我们可以看到这个好的梯度:
def plotting_hillclimber(fitness_function):
data = []
# Create and evaluate starting point
x, y = random.randint(MIN, MAX), random.randint(MIN, MAX)
fitness = fitness_function(x, y)
data += [fitness]
iterations = 0
# Stop once we have found an optimal solution
while fitness > 0:
iterations += 1
# Move to first neighbor with a better fitness
for (nextx, nexty) in neighbors(x, y):
new_fitness = fitness_function(nextx, nexty)
if new_fitness < fitness:
x, y = nextx, nexty
fitness = new_fitness
data += [fitness]
break
print("Found optimum after %d iterations at %d, %d" % (iterations, x, y))
return data
data = plotting_hillclimber(get_fitness)
Found optimum after 429 iterations at -1000, -501
fig = plt.figure()
ax = plt.axes()
xs = range(len(data))
ax.plot(xs, data);

这个梯度是理想适应度景观的结果。在实践中,我们不会总是拥有这样的好梯度。特别是,我们的爬山算法只有在至少有一个邻居具有更好的适应度值时才能很好地工作。如果我们达到一个所有邻居实际上都没有更好的适应度值的位置怎么办?考虑以下函数test_me2:
def test_me2(x, y):
if(x * x == y * y * (x % 20)):
return True
else:
return False
如果我们想要再次覆盖test_me2中的 if 条件的 true 分支,我们可以像之前一样计算距离,即通过计算比较两边的差异。让我们对test_me2()函数进行测试:
def test_me2_instrumented(x, y):
global distance
distance = abs(x * x - y * y * (x % 20))
if(x * x == y * y * (x % 20)):
return True
else:
return False
使用这个测试版本,我们只需要一个适应度函数,它调用测试版本并读取全局distance变量。
def bad_fitness(x, y):
global distance
test_me2_instrumented(x, y)
fitness = distance
return fitness
在我们对这个示例运行爬山算法之前,让我们再次通过绘制来查看搜索景观:
xx = np.outer(np.linspace(-10, 10, 30), np.ones(30))
yy = xx.copy().T
zz = abs(xx * xx - yy * yy * (xx % 20))
fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_surface(xx, yy, zz, cmap=plt.cm.jet, rstride=1, cstride=1, linewidth=0);

在这个阶段,运行使用新适应度函数的爬山算法会很理想,但有一个问题:使用这个适应度函数运行我们的爬山算法不是一个好主意,因为它可能永远不会终止。假设我们已经到达一个所有邻居都具有相同或更差适应度值的位置。爬山算法无法移动,并且永远卡在那里!在搜索景观中,这样的点被称为局部最优解。如果达到这样的点,最简单的方法就是放弃并从新的随机点重新开始。这就是我们在具有随机重启的爬山算法中将要做的。
def restarting_hillclimber(fitness_function):
data = []
# Create and evaluate starting point
x, y = random.randint(MIN, MAX), random.randint(MIN, MAX)
fitness = fitness_function(x, y)
data += [fitness]
print("Initial value: %d, %d at fitness %.4f" % (x, y, fitness))
iterations = 0
# Stop once we have found an optimal solution
while fitness > 0:
changed = False
iterations += 1
# Move to first neighbor with a better fitness
for (nextx, nexty) in neighbors(x, y):
new_fitness = fitness_function(nextx, nexty)
if new_fitness < fitness:
x, y = nextx, nexty
fitness = new_fitness
data += [fitness]
changed = True
break
if not changed:
x, y = random.randint(MIN, MAX), random.randint(MIN, MAX)
fitness = fitness_function(x, y)
data += [fitness]
print("Found optimum after %d iterations at %d, %d" % (iterations, x, y))
return data
变化是微不足道的:我们只需使用布尔标志跟踪是否发生了任何移动,如果我们没有移动到任何邻居,我们就选择一个新的随机位置重新开始。为了方便起见,我们还使爬山算法可以通过适应度函数进行参数化。让我们用我们的bad_fitness试一试,并绘制我们观察到的结果适应度值:
MAX = 1000
MIN = -MAX
data = restarting_hillclimber(bad_fitness)
Initial value: 333, 231 at fitness 582804.0000
Found optimum after 165 iterations at 521, 521
import [matplotlib.pyplot](https://matplotlib.org/) as plt
fig = plt.figure()
ax = plt.axes()
xs = range(len(data))
ax.plot(xs, data);

运行示例几次。有时,我们很幸运,存在一个梯度直接将其带到最优解。但有时在达到最优值之前,你会在搜索过程中看到重启。
我们将x和y的初始值限制在较小的范围[MIN, MAX]内。这在测试生成中是一个常见的技巧,因为在大多数情况下,解决方案往往由小值组成,使用小值开始搜索在很多情况下会使搜索更快。然而,如果所需的解决方案位于我们的搜索空间中的完全不同的地方呢?我们偏向于较小解决方案的偏见意味着爬山者将花费很长时间才能找到解决方案,并且给定固定的搜索预算,它实际上找到解决方案的可能性会更小。为了看看这会产生什么影响,我们可以简单地将1000替换为,比如说,1000000或更多。我们可以调整范围,看看我们简单搜索问题的性能。
MAX = 100000
MIN = -MAX
from Timer import Timer
with Timer() as t:
restarting_hillclimber(get_fitness)
print("Search time: %.2fs" % t.elapsed_time())
Initial value: 64543, -55357 at fitness 175255.0000
Found optimum after 169899 iterations at -100000, -50001
Search time: 0.27s
在大多数情况下,现在搜索将需要更长的时间才能找到解决方案——可能比我们为这样一个简单示例函数准备的等待时间要长!(尽管有时我们会很幸运,随机击中一个好的起始位置)。这如何在“真实”示例中工作呢?不要想象如果还有更多的参数和更大的邻域!
测试一个更复杂的程序
让我们转向一个稍微复杂一点的程序:你已经在覆盖率章节中了解过的 CGI 解码器。
def cgi_decode(s):
"""Decode the CGI-encoded string `s`:
* replace "+" by " "
* replace "%xx" by the character with hex number xx.
Return the decoded string. Raise `ValueError` for invalid inputs."""
# Mapping of hex digits to their integer values
hex_values = {
'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
'5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15,
'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15,
}
t = ""
i = 0
while i < len(s):
c = s[i]
if c == '+':
t += ' '
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]
t += chr(v)
else:
raise ValueError("Invalid encoding")
else:
t += c
i += 1
return t
将 CGI 解码器作为一个搜索问题
cgi_decode()函数有一个字符串类型的输入,定义字符串邻域的一种可能方式是所有具有 1 个编辑距离的可能的字符串。例如,字符串test的每个字符都有四个邻居:
-
uest -
tfst -
tett -
tesu -
sest -
tdst -
tert -
tess
此外,添加任何字符或附加任何字符也会有一个编辑距离为 1,也可以被认为是邻居。为了使事情简单,让我们将输入字符串的长度固定在一个合理的值(例如 10)。在这种情况下,每个个体有 20 个邻居(即每个字符有两个邻居)。
让我们实现一个新的neighbor_strings()函数,它生成这些相邻字符串:
def neighbor_strings(x):
n = []
for pos in range(len(x)):
c = ord(x[pos])
if c < 126:
n += [x[:pos] + chr(c + 1) + x[pos + 1:]]
if c > 32:
n += [x[:pos] + chr(c - 1) + x[pos + 1:]]
return n
neighbor_strings()函数获取输入字符串中每个字符的数值,并创建一个新的字符串,该字符串将字符替换为字母表中该字符的前一个和后一个字符。起初,我们只考虑可打印的 ASCII 字符,其范围在 32-126 之间。
print(neighbor_strings("Hello"))
['Iello', 'Gello', 'Hfllo', 'Hdllo', 'Hemlo', 'Heklo', 'Helmo', 'Helko', 'Hellp', 'Helln']
因此,我们已经为cgi_decode()函数定义了搜索空间。在这个搜索空间中寻找个体的下一个要素是一个适应度函数。
分支距离
test_me()函数由一个 if 条件组成,其中比较了两个整数。在cgi_decode()函数中,我们有三个 if 条件和一条 while 循环,它们都用于比较字符。幸运的是,正如我们已经看到的,我们可以将字符视为数字,因此我们可以使用与test_me()示例中相同的距离估计。然而,也有两个条件检查一个字符是否包含在集合中,例如digit_high in hex_values。一个值接近包含在集合中的程度如何?一个明显的解决方案是将距离到集合中最接近的值的估计作为估计。
import [sys](https://docs.python.org/3/library/sys.html)
def distance_character(target, values):
# Initialize with very large value so that any comparison is better
minimum = sys.maxsize
for elem in values:
distance = abs(target - elem)
if distance < minimum:
minimum = distance
return minimum
distance_character(10, [1, 5, 12, 100])
2
distance_character(10, [0, 50, 80, 200])
10
我们到目前为止所做的进一步简化是假设我们总是希望条件评估为真。在实践中,我们可能也希望 if 条件评估为假。因此,每个 if 条件实际上有两个距离估计,一个用于估计它有多接近为真,另一个用于估计它有多接近为假。如果条件为真,则真距离为 0;如果条件为假,则假距离为 0。也就是说,在比较a == b时,如果a小于b,则根据定义,假距离为0。
当a等于b时,a == b为假的情况下的距离是多少?对a或b的任何改变都会使条件评估为假,因此在这种情况下,我们可以简单地将距离定义为 1。
更一般地,可能有其他类型的比较,例如使用关系运算符。考虑cgi_decode()中的循环条件:i < len(s),即它使用小于比较运算符。将分支距离的概念扩展到覆盖不同类型的比较,并计算真距离和假距离是非常直接的。以下表格显示了如何计算不同类型比较的距离:
| 条件 | 距离(真) | 距离(假) |
|---|---|---|
| a == b | abs(a - b) | 1 |
| a != b | 1 | abs(a - b) |
| a < b | b - a + 1 | a - b |
| a <= b | b - a | a - b + 1 |
| a > b | a - b + 1 | b - a |
注意,几个计算中添加了一个常数1。这样做的原因相当简单:假设我们希望a < b评估为真,并令a = 27和b = 27。这个条件不是真的,但简单地取差值会得到0的结果。为了避免这种情况,我们必须添加一个常数。这个值是1并不重要——任何正数常数都行。
处理复杂条件
在cgi_decode()函数中,我们还可以找到一个稍微复杂一些的谓词,它由两个通过逻辑and连接的条件组成:
if digit_high in hex_values and digit_low in hex_values:
原则上,分支距离被定义为使合取 A and B 为真的距离等于 A 和 B 的分支距离之和,因为两个条件都需要为真。同样,使 A or B 为真的分支距离将是 A 和 B 的两个分支距离中的最小值,因为只要其中一个条件为真,整个表达式就为真。
然而,在实践中并不像那样简单:谓词可以包含嵌套条件和否定,并且需要在应用此计算之前将表达式转换为规范形式。此外,大多数现代编程语言使用 短路求值:如果有一个条件 A or B,并且 A 为真,则 B 不会被评估。如果 B 是一个有副作用的表达式,那么即使短路求值会避免其执行,我们可能会改变程序的行为(通过调用在正常行为中不会执行的副作用),这是不可接受的。
此外,如果分支条件有副作用怎么办?例如,假设分支条件是 x == 2 * foo(y),其中 foo() 是一个接受整数作为输入的函数。天真地添加监控会导致以下代码:
distance = abs(x - 2 * foo(y))
if x == 2 * foo(y):
...
因此,监控会导致 foo() 被执行 两次。假设 foo() 改变了系统的状态(例如,通过打印某些内容,访问文件系统,改变一些状态变量等),那么显然再次调用 foo() 是一个坏主意。克服这个问题的方法之一是 转换 条件,而不是 添加 跟踪调用。例如,可以创建临时变量来保存距离计算所需的价值,然后在这些变量中使用它们作为分支条件:
tmp1 = x
tmp2 = 2 * foo(y)
distance = abs(tmp1 - tmp2)
if tmp1 == tmp2:
...
除了这些问题之外,向程序添加全局变量和方法调用的方法似乎是一种相当笨拙的方法——我们当然不能单独考虑我们程序中的每个分支,并手动监控我们想要测试的程序,特别是如果程序有多个分支,如 cgi_decode() 函数。相反,我们应该考虑如何 自动 监控程序,以便包含必要的附加语句,这样我们就可以计算适应度值。
原子条件的监控
使用全局和临时变量的另一种方法是,用对辅助函数的调用替换实际比较,其中原始表达式作为参数评估,运算符作为额外的参数。假设我们有一个名为 evaluate_condition() 的函数,它接受四个参数:
-
num是一个唯一标识符,用于识别条件; -
op是比较运算符; -
lhs和rhs是操作数。
函数计算两个距离,对于条件:到条件评估为真的距离,以及到条件评估为假的距离。两个结果中总有一个是正确的,因此其中一个总是有距离 0。由于函数替换了原始的比较,它返回真或假,取决于哪个距离是 0。这意味着,示例表达式
if x == 2 * foo(y)
将被替换为
if evaluate_condition(0, "Eq", x, 2 * foo(y))
这样,参数只计算一次,因此可以正确处理副作用。以下是 evaluate_condition() 函数的形态:
def evaluate_condition(num, op, lhs, rhs):
distance_true = 0
distance_false = 0
if op == "Eq":
if lhs == rhs:
distance_false = 1
else:
distance_true = abs(lhs - rhs)
# ... code for other types of conditions
if distance_true == 0:
return True
else:
return False
注意,我们用 0 初始化 distance_true 和 distance_false。因此,如果 lhs 等于 rhs,那么变量 distance_true 保持为 0,反之亦然。
evaluate_condition(1, "Eq", 10, 20)
False
evaluate_condition(2, "Eq", 20, 20)
True
evaluate_condition() 函数尚未执行的操作是存储观察到的距离。显然,我们需要将值存储在某个地方,以便我们可以从我们的适应度函数中访问它。由于 cgi_decode() 程序由多个条件组成,并且对于每个条件,我们可能对真值和假值距离都感兴趣,我们简单地使用两个全局字典 distances_true 和 distances_false,并定义一个辅助函数,该函数将观察到的距离值存储在字典中:
def update_maps(condition_num, d_true, d_false):
global distances_true, distances_false
if condition_num in distances_true.keys():
distances_true[condition_num] = min(
distances_true[condition_num], d_true)
else:
distances_true[condition_num] = d_true
if condition_num in distances_false.keys():
distances_false[condition_num] = min(
distances_false[condition_num], d_false)
else:
distances_false[condition_num] = d_false
变量 condition_num 是我们刚刚评估的条件的唯一标识符。如果这是我们第一次执行这个特定的条件,那么真值和假值距离将简单地存储在相应的字典中。然而,同一个测试可能多次执行同一个条件。例如,cgi_decode() 函数中的循环条件 i < len(s) 在每次循环迭代之前都会被评估。然而,最终我们希望测试有一个单一的适应度值。因为覆盖一个分支只需要至少一次执行达到分支,所以我们只考虑最近的那个。因此,如果 distances_true 和 distances_false 字典已经包含从之前的执行中得到的距离,我们只替换那个值,如果新的执行更接近达到分支;这是通过使用 min() 函数实现的。
现在,我们需要在 evaluate_condition() 内部调用这个函数。让我们也添加对 in 运算符和 < 比较的距离计算,因为我们需要这两个运算符来为 cgi_decode() 示例。此外,cgi_decode() 实际上比较字符和数字。为了确保我们使用正确的类型,我们首先必须将字符转换为数字来计算距离。这是通过 Python 的 ord() 函数完成的。
def evaluate_condition(num, op, lhs, rhs):
distance_true = 0
distance_false = 0
# Make sure the distance can be calculated on number and character
# comparisons
if isinstance(lhs, str):
lhs = ord(lhs)
if isinstance(rhs, str):
rhs = ord(rhs)
if op == "Eq":
if lhs == rhs:
distance_false = 1
else:
distance_true = abs(lhs - rhs)
elif op == "Lt":
if lhs < rhs:
distance_false = rhs - lhs
else:
distance_true = lhs - rhs + 1
# ...
# handle other comparison operators
# ...
elif op == "In":
minimum = sys.maxsize
for elem in rhs.keys():
distance = abs(lhs - ord(elem))
if distance < minimum:
minimum = distance
distance_true = minimum
if distance_true == 0:
distance_false = 1
update_maps(num, distance_true, distance_false)
if distance_true == 0:
return True
else:
return False
以下展示了从 cgi_decode() 到使用 evaluate_condition() 函数的联合仪器配置。对应于两个条件,有两个对 evaluate_condition 的调用,以及将它们连接起来的运算符 and 确保保留了原始的短路行为:
if (evaluate_condition(4, 'In', digit_high, hex_values) and evaluate_condition(5, 'In', digit_low, hex_values))
当然,我们希望自动生成这个配置后的版本。
自动化源代码的配置
在 Python 中,自动替换比较实际上相当简单,使用程序的抽象语法树(AST)。在 AST 中,比较通常是一个具有操作符属性和两个子节点(左侧和右侧操作符)的树节点。要替换这样的比较为evaluate_condition()的调用,只需将 AST 中的比较节点替换为函数调用节点即可,这正是BranchTransformer类使用 Python 的ast模块中的NodeTransformer所做的事情:
import [ast](https://docs.python.org/3/library/ast.html)
class BranchTransformer(ast.NodeTransformer):
branch_num = 0
def visit_FunctionDef(self, node):
node.name = node.name + "_instrumented"
return self.generic_visit(node)
def visit_Compare(self, node):
if node.ops[0] in [ast.Is, ast.IsNot, ast.In, ast.NotIn]:
return node
self.branch_num += 1
return ast.Call(func=ast.Name("evaluate_condition", ast.Load()),
args=[ast.Num(self.branch_num),
ast.Str(node.ops[0].__class__.__name__),
node.left,
node.comparators[0]],
keywords=[],
starargs=None,
kwargs=None)
BranchTransformer使用内置解析器ast.parse()解析目标 Python 程序,该解析器返回 AST。Python 提供了一个 API 来遍历和修改这个 AST。为了用函数调用替换比较,我们使用ast.NodeTransformer,它使用访问者模式,其中 AST 中的每个节点类型都有一个visit_*函数。由于我们感兴趣的是替换比较,我们覆盖了visit_Compare,在那里我们返回一个新节点,其类型为ast.Func,这是一个函数调用节点。该节点的第一个参数是函数evaluate_condition()的名称,参数是evaluate_condition()函数期望的四个参数:
-
分支的数量(我们将其计数保存在
branch_num中), -
操作符(我们简单地使用类名),
-
左侧,以及
-
右侧。
注意,Python 允许比较多个表达式(例如1 < x < 10);为了保持代码简单,我们在这里只处理单个比较,但通过为每个比较调用evaluate_condition函数,代码的扩展是直接的。您会注意到我们还覆盖了visit_FunctionDef;这只是为了通过添加_instrumented来更改方法名,这样我们就可以继续使用原始函数和配置后的函数。
以下代码将cgi_decode()函数的源代码解析为 AST,然后对其进行转换,并再次打印出来(使用ast库中的unparse()函数):
import [inspect](https://docs.python.org/3/library/inspect.html)
import [ast](https://docs.python.org/3/library/ast.html)
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import print_content
source = inspect.getsource(cgi_decode)
node = ast.parse(source)
BranchTransformer().visit(node)
# Make sure the line numbers are ok before printing
node = ast.fix_missing_locations(node)
print_content(ast.unparse(node), '.py')
def cgi_decode_instrumented(s):
"""Decode the CGI-encoded string `s`:
* replace "+" by " "
* replace "%xx" by the character with hex number xx.
Return the decoded string. Raise `ValueError` for invalid inputs."""
hex_values = {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, 'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15, 'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15}
t = ''
i = 0
while evaluate_condition(1, 'Lt', i, len(s)):
c = s[i]
if evaluate_condition(2, 'Eq', c, '+'):
t += ' '
elif evaluate_condition(3, 'Eq', c, '%'):
digit_high, digit_low = (s[i + 1], s[i + 2])
i += 2
if evaluate_condition(4, 'In', digit_high, hex_values) and evaluate_condition(5, 'In', digit_low, hex_values):
v = hex_values[digit_high] * 16 + hex_values[digit_low]
t += chr(v)
else:
raise ValueError('Invalid encoding')
else:
t += c
i += 1
return t
/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7394/942233608.py:15: DeprecationWarning: ast.Num is deprecated and will be removed in Python 3.14; use ast.Constant instead
args=[ast.Num(self.branch_num),
/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7394/942233608.py:16: DeprecationWarning: ast.Str is deprecated and will be removed in Python 3.14; use ast.Constant instead
ast.Str(node.ops[0].__class__.__name__),
要使用配置后的版本计算适应度值,我们需要再次编译配置后的抽象语法树(AST),这通过 Python 的compile()函数完成。然后我们需要使编译后的函数可访问,为此我们首先从sys.modules中检索当前模块,然后使用exec将配置后的函数的编译代码添加到当前模块的函数列表中。之后,cgi_decode_instrumented()函数就可以访问了。
from [typing](https://docs.python.org/3/library/typing.html) import Dict, cast
def create_instrumented_function(f):
source = inspect.getsource(f)
node = ast.parse(source)
node = BranchTransformer().visit(node)
# Make sure the line numbers are ok so that it compiles
node = ast.fix_missing_locations(node)
# Compile and add the instrumented function to the current module
current_module = sys.modules[__name__]
code = compile(cast(ast.Module, node), filename="<ast>", mode="exec")
exec(code, current_module.__dict__)
# Set up the global maps
distances_true: Dict[int, int] = {}
distances_false: Dict[int, int] = {}
# Create instrumented function
# cgi_decode_instrumented =
create_instrumented_function(cgi_decode)
/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7394/942233608.py:15: DeprecationWarning: ast.Num is deprecated and will be removed in Python 3.14; use ast.Constant instead
args=[ast.Num(self.branch_num),
/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7394/942233608.py:16: DeprecationWarning: ast.Str is deprecated and will be removed in Python 3.14; use ast.Constant instead
ast.Str(node.ops[0].__class__.__name__),
assert cgi_decode("Hello+Reader") == cgi_decode_instrumented("Hello+Reader")
cgi_decode_instrumented("Hello+Reader")
'Hello Reader'
distances_true
{1: 0, 2: 0, 3: 35}
distances_false
{1: 0, 2: 0, 3: 0}
如我们从 distances_true 和 distances_false 映射中可以看出,条件 1 和 2 评估为真和假,而条件 3 在执行 cgi_decode_instrumented 时只评估为假。这是预期的,因为 while 循环被进入并离开,输入字符串中有一个空格但没有 % 字符。
创建有效十六进制输入的适应度函数
例如,让我们以测试 cgi_decode() 中解码有效十六进制代码的部分为目标。这意味着我们希望使条件 1 为真,2 为假,3 为真,4 为真。为了表示这样的路径,我们可以简单地累加这些分支的分支距离。然而,简单地累加分支距离存在一个潜在问题:如果一个条件的距离取决于非常大的值的比较,而另一个条件的距离计算取决于小值,那么对大值的改进很可能导致更好的适应度改进,从而偏差搜索。为了避免这种情况,我们需要在累加之前对分支距离进行归一化。
对于范围 [a, b] 的归一化函数,它接受一个数字作为输入,并返回一个值,该值满足 >=a 和 <=b。关于这个函数的重要之处在于,对于任何两个数字 x 和 y,归一化必须保持它们的顺序。也就是说,如果 x<y,那么也必须满足 normalize(x) < normalize(y)。有许多不同的函数可以实现这个结果;一个简单的是 normalize(x) = x/(x+k):它计算成本低,并将范围 [0,1] 内的任何正数值归一化(要将此转换为 [0,b],只需乘以 b)。如果我们使用这个归一化函数,我们也知道最大值:它是 1.0。该函数假设要归一化的值是正数。因子 k 的值定义了曲线的陡峭程度。例如,对于 k=1(这是一个很好的默认值),曲线非常陡峭,值迅速接近但永远不会达到 1。
def normalize(x):
return x / (1.0 + x)
要了解这个归一化函数是如何工作的,我们可以绘制一系列的值。x 轴包含原始值,y 轴显示相应的归一化值:
import [matplotlib.pyplot](https://matplotlib.org/) as plt
fig = plt.figure()
ax = plt.axes()
xs = range(100)
ys = [value / (value + 1.0) for value in xs]
ax.plot(xs, ys);

使用这个归一化函数,我们可以通过在测试的仪器程序下运行来计算一个适应度值,然后累加相关分支的归一化值。然而,并非每个 if 语句都会被每个测试用例执行。对于一个甚至没有被执行的分支,其分支距离是多少?我们需要确保未被执行的分支的分支距离高于执行过程中观察到的任何分支距离。根据我们的归一化,我们知道这个最大值是 1。因此,适应度函数可能看起来像这样:
def get_fitness_cgi(x):
# Reset any distance values from previous executions
global distances_true, distances_false
distances_true = {}
distances_false = {}
# Run the function under test
try:
cgi_decode_instrumented(x)
except BaseException:
pass
# Sum up branch distances
fitness = 0.0
for branch in [1, 3, 4, 5]:
if branch in distances_true:
fitness += normalize(distances_true[branch])
else:
fitness += 1.0
for branch in [2]:
if branch in distances_false:
fitness += normalize(distances_false[branch])
else:
fitness += 1.0
return fitness
为了看到我们的工具的实际效果,让我们计算几个示例输入的适应度值,逐步接近目标。
# Empty string does not even enter the loop
get_fitness_cgi("")
4.5
# String contains no percentage character
get_fitness_cgi("Hello+Reader")
2.9722222222222223
# String contains a percentage character, but no valid hex char
get_fitness_cgi("%UU")
1.9230769230769231
# String contains a percentage character, but only one valid hex char
get_fitness_cgi("%AU")
0.9230769230769231
# String contains a percentage character and two valid hex chars
get_fitness_cgi("%AA")
0.0
递增搜索有效十六进制输入
我们现在已经通过neighbor_strings()函数定义了搜索空间,并且我们已经推导出一个新的适应度函数,所以在我们可以将递增搜索应用于寻找包含有效两位十六进制代码的字符串的问题之前,唯一剩下的点就是推导出一个随机起点,这你已经在之前的变异模糊器章节中做过:
def random_string(l):
s = ""
for i in range(l):
random_character = chr(random.randrange(32, 127))
s = s + random_character
return s
现在我们可以把它们全部放在一起,再次使用相同的递增搜索算法,只是让它生成随机字符串而不是数字,并使用新的适应度函数:
def hillclimb_cgi():
x = random_string(10)
fitness = get_fitness_cgi(x)
print("Initial input: %s at fitness %.4f" % (x, fitness))
while fitness > 0:
changed = False
for (nextx) in neighbor_strings(x):
new_fitness = get_fitness_cgi(nextx)
if new_fitness < fitness:
x = nextx
fitness = new_fitness
changed = True
print("New value: %s at fitness %.4f" % (x, fitness))
break
# Random restart if necessary
if not changed:
x = random_string(10)
fitness = get_fitness_cgi(x)
print("Optimum at %s, fitness %.4f" % (x, fitness))
hillclimb_cgi()
Initial input: o'@[3(rW*M at fitness 2.6667
New value: o&@[3(rW*M at fitness 2.5000
New value: o%@[3(rW*M at fitness 1.5000
New value: o%A[3(rW*M at fitness 0.8571
New value: o%A\3(rW*M at fitness 0.8333
New value: o%A]3(rW*M at fitness 0.8000
New value: o%A³(rW*M at fitness 0.7500
New value: o%A_3(rW*M at fitness 0.6667
New value: o%A`3(rW*M at fitness 0.5000
New value: o%Aa3(rW*M at fitness 0.0000
Optimum at o%Aa3(rW*M, fitness 0.0000
运行几次递增搜索,看看它生成的字符串是否包含有效的十六进制字符。有时只需要几步,有时需要更长的时间,但最终它总是解决问题并生成我们想要的字符串。
进化搜索
如果邻域相对较小,递增搜索效果很好。到目前为止,在cgi_decode()示例中就是这样,因为我们限制了自己使用固定数量的字符(10)并限制了字符范围到可打印的 ASCII 字符。但想象一下,如果我们不是在寻找 ASCII 字符,而是在寻找 UTF-16 Unicode 字符会发生什么?这些字符实际上不允许出现在 URL 中,但让我们看看如果我们改变搜索空间会发生什么:
def random_unicode_string(l):
s = ""
for i in range(l):
# Limits to reflect range of UTF-16
random_character = chr(random.randrange(0, 65536))
s = s + random_character
return s
def unicode_string_neighbors(x):
n = []
for pos in range(len(x)):
c = ord(x[pos])
# Limits to reflect range of UTF-16
if c < 65536:
n += [x[:pos] + chr(c + 1) + x[pos + 1:]]
if c > 0:
n += [x[:pos] + chr(c - 1) + x[pos + 1:]]
return n
UTF-16 字符使用 16 位表示,这给我们提供了 65536 个可能的字符范围。上述函数已适应这些边界。在我们再次运行递增搜索之前,让我们再进行一个更改:我们将添加一个迭代限制,这样我们就可以放弃搜索,而不是永远等待它完成。
def terminal_repr(s):
return terminal_escape(repr(s))
def hillclimb_cgi_limited(max_iterations):
x = random_unicode_string(10)
fitness = get_fitness_cgi(x)
print("Initial input: %s at fitness %.4f" % (terminal_repr(x), fitness))
iteration = 0
logs = 0
while fitness > 0 and iteration < max_iterations:
changed = False
for (nextx) in unicode_string_neighbors(x):
new_fitness = get_fitness_cgi(nextx)
if new_fitness < fitness:
x = nextx
fitness = new_fitness
changed = True
if logs < LOG_VALUES:
print("New value: %s at fitness %.4f" %
(terminal_repr(x), fitness))
elif logs == LOG_VALUES:
print("...")
logs += 1
break
# Random restart if necessary
if not changed:
x = random_string(10)
fitness = get_fitness_cgi(x)
iteration += 1
print("Optimum at %s, fitness %.4f" % (terminal_repr(x), fitness))
hillclimb_cgi_limited(100)
Initial input: '埂\udf19\uf67c듵騛쁥핡勸\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勷\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勶\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勵\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勴\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勳\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勲\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勱\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勰\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勯\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勮\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勭\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勬\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勫\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勪\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勩\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勨\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勧\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勦\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勥\uf172싕' at fitness 3.0000
New value: '埂\udf19\uf67c듵騛쁥핡勤\uf172싕' at fitness 3.0000
...
Optimum at '埂\udf19\uf67c듵騛쁥핡劔\uf172싕', fitness 3.0000
你可以调整迭代限制,看看在这个搜索空间中找到解决方案需要多长时间。除非你随机起点很幸运,否则这通常需要非常不合理的时间。
全局搜索
递增搜索在搜索的每一步中探索个体的局部邻域,如果邻域太大,那么这会花费太长时间。一种替代策略是不限制搜索到局部邻域,而是全局搜索。也就是说,搜索算法被允许在搜索空间周围进行更大的步骤。对递增搜索的一个简单修改就可以将其从局部搜索算法转换为全局搜索算法:不是查看所有直接邻居,而是以允许更大修改的方式对个体进行变异。
一种 变异 是表示在搜索空间中更大步骤的改变。在实现变异时,一个重要的决策是理论上应该能够通过连续应用变异来达到搜索空间中的任何一点。然而,变异通常不应该完全用随机的一个来替换个体。为了使搜索有效,变异应该构成对个体合理的改变,同时仍然保持其大部分特征。对于我们的 10 字符字符串搜索问题,一种可能的变异是将 10 个字符中的 1 个替换,如下所示:
def flip_random_character(s):
pos = random.randint(0, len(s) - 1)
new_c = chr(random.randrange(0, 65536))
return s[:pos] + new_c + s[pos + 1:]
爬山法的一个简单修改是用变异替换对邻域的探索。在每次迭代中,当前个体被变异。产生的后代个体与其父代进行比较,两者中较好者成为搜索空间中的新点,并用于下一次变异迭代。这被称为“随机爬山法”。
def randomized_hillclimb():
x = random_unicode_string(10)
fitness = get_fitness_cgi(x)
print("Initial value: %s at fitness %.4f" %
(terminal_repr(x), fitness))
iterations = 0
while fitness > 0:
mutated = flip_random_character(x)
new_fitness = get_fitness_cgi(mutated)
if new_fitness <= fitness:
x = mutated
fitness = new_fitness
#print("New value: %s at fitness %.4f" %(terminal_repr(x), fitness))
iterations += 1
print("Optimum at %s after %d iterations" %
(terminal_repr(x), iterations))
randomized_hillclimb()
Initial value: '舨ᑆ\uec4f\ue27f 羏\uf314䖗繣厪킨' at fitness 2.9998
Optimum at 'ጤ〆撟%e5 匸㗵暠ᴌ' after 34356 iterations
这个算法通常比标准的爬山法找到解决方案更快,尽管它仍然相当慢!
爬山法和随机版本之间存在一个微妙但至关重要的变化,值得指出:注意比较 new_fitness <= fitness,而在爬山法中我们使用了 new_fitness < fitness。这很重要,因为搜索景观可能存在具有相等适应度值的区域(平台),我们需要以某种方式克服这些区域。在标准的爬山法中,我们通过随机重启来实现这一点。如果随机爬山法拒绝具有相同适应度的后代,它将不断变异相同的个体,并可能难以逃离平台。如果我们用具有相等适应度的替换,我们允许搜索在平台上移动,从而增加最终逃离平台的机会。
随机爬山法也被称为 1+1 进化算法 ((1+1)EA)。进化算法是一种元启发式搜索算法,试图模仿自然进化的过程。自然进化基于种群,环境压力导致自然选择:只有最适应的个体能够生存,而不适应的个体将死亡,因此种群的整体适应度逐渐提高。(1+1)EA 是一种非常具体的进化算法,种群大小为 1,它正好产生 1 个后代。然而,在实践中最常见的搜索算法是遗传算法。
遗传算法
最著名的进化算法之一是遗传算法(GA)。GA 基于这样的想法,即问题解决方案可以遗传编码:染色体由一系列基因组成,其中每个基因编码个体的一个特征(例如,眼睛颜色、头发颜色等)。适应度函数可以评估包含在这个描述中的信息,即所谓的基因型,并评估由此产生的表型的属性,即由这种遗传编码表示的实际解决方案。个体的适应度值是在表型上测量的。
对于我们的cgi_decode()示例,我们可以考虑单个字符是基因,然后染色体是一系列字符。换句话说,遗传编码正是我们迄今为止已经使用过的!然而,对于 GA,表示需要不同于枚举邻域的操作。
遗传算法(GA)通过以下过程模拟自然进化:
-
创建一个随机的染色体初始种群
-
选择适合繁殖的个体
-
通过选择个体的繁殖来生成新种群
-
继续这样做,直到找到最优解或达到某些其他限制。
第一步,创建初始种群,是微不足道的:
def create_population(size):
return [random_unicode_string(10) for i in range(size)]
create_population(10)
['㛇莜戹豔㮝\ue008 力ᒐᱯꊎ',
'\ue295쁉陵ꯃ赖蟽⍬꺲緥㲱',
'\u2e77 騬⊳铁땕\uf189\ue663쌯幆艆',
'龚筓\uf3a9욀놷䓒隯迌쀐∑',
'쓛唁뗌蹽\udcd5 偏躝娒鸭赌',
'蹤⪖ឺ웳ఫ䓻䤷溸ᰒﵘ',
'㲒혋Ꮘ\uea95\udf7b䢶裕㖬눹庑',
'\u2d9d 鼓咝笩窻ꨔ铺扄\ueb57\uf1bf',
'\udd26Ч甄ꃢ\udcb0㼁튰㣋ช봼',
'닇軬欴鞛㈓Ẫ住畿ꔪ칀']
选择过程优先考虑适应性更强的个体,因此我们需要为我们种群中的所有个体确定适应度值。由于我们将在算法的几个地方需要每个个体的适应度值,我们将通过将适应度存储为包含个体的元组的列表来存储它。
def evaluate_population(population):
fitness = [get_fitness_cgi(x) for x in population]
return list(zip(population, fitness))
population = create_population(10)
for (individual, fitness) in evaluate_population(population):
print("%s: %.4f" % (terminal_repr(individual), fitness))
'\ue46fت팣㘇ธ漅ೇ↪䜭㎮': 2.9994
'\ua7e9\ue9e0\ue7de턤댪囿厠‐\ue0c5ﴌ': 2.9999
'䇯Ꜩ잣\ua9ce㙦\uf4aa 歿蘸ཎ㫜': 2.9997
'ⱳ콾哇\uef1f\uf6a8⾃䣖坤Ꮾ둰': 2.9998
'າ伫螉\ue110㝎겱괼䆵喞\u18ac': 2.9997
'ས婎쥤䇉韤\udd06䕮춵磒露': 2.9997
'辺緢噑粲\uf400 嚳寬\x87ꐂ쑹': 2.9899
'뉫㻑\u1fd4㈖鬒樼\u0dcc 朏\ue57eማ': 2.9997
'얨㐁皭់멄\ud97b\ueac9㢂ቅ뮜': 2.9998
'⒃⍜㈪㊷\udbdb 乑蓍\udc48 鱲뻜': 2.9999
在搜索中使用适应度值通常用“适者生存”来解释,但达尔文对进化的一个关键洞察是,选择不仅由生存来定义——个体通过有性繁殖,性选择描述了繁殖过程中的选择压力。这种选择通常受以下两种影响:战斗:竞争雌性的雄性之间的斗争,更强壮(更适应)的雄性获胜;选择还受展示的影响。达尔文的例子是孔雀:孔雀有长长的美丽尾羽,看起来似乎没有作用,也不支持自然选择的概念。然而,孔雀在选择性伴侣时受到雄性外观的影响。令人印象深刻的装饰表明,雄性在遗传上特别健康,这将导致健康的后代。这在遗传算法(GA)中得到了反映:个体的适应度值越好,它与其他个体交配的可能性就越大。一个反映这一概念的简单选择算法是锦标赛选择:少数随机选择的个体竞争被选中,最好的被选中:
def selection(evaluated_population, tournament_size):
competition = random.sample(evaluated_population, tournament_size)
winner = min(competition, key=lambda individual: individual[1])[0]
# Return a copy of the selected individual
return winner[:]
tournament_size 参数指定了从种群中随机选择的个体数量参与锦标赛。这是一个重要的选择,因为它决定了选择压力:锦标赛规模越大,非常好个体的被选中概率就越大。这反过来又增加了这些非常好个体在下一代中占据主导地位的概率,从而减少了多样性并导致过早收敛。相反,如果锦标赛规模太小,那么这会抑制进化。锦标赛的最佳值取决于种群大小,但通常它相当小(例如,5)。让我们运行一个使用示例种群中所有个体的锦标赛,以查看最佳个体是如何被选中的:
population = create_population(10)
fitness = evaluate_population(population)
selected = selection(fitness, 10)
for (individual, fitness_value) in fitness:
print("%s: %.4f" % (terminal_repr(individual), fitness_value))
'둚䘣蹸붢騒ꋎỺ觉\ued2a 焊': 2.9999
'닔㶐ꡡ\udca4 贕굇᳆\ueccd윘赉': 2.9999
'笁깉ရ豴\uea60ᛰ滒鶵)Ⰴ': 2.9998
'㠒㵄\ue14cᤃᇚ\udebe\uf851쿛鏆闊': 2.9998
'ࠝ\udd53ᶂ㮍訬廘랦铘뫘출': 2.9995
'ꡩၔ玤姨乪药汏䗫汔悔': 2.9998
'\uef2a 潦ﳠ鹉卼㴐Ṭ\uf4b0˅얓': 2.9985
'❱\u0ff7╇塐䲫琭煸ᮤឋ퇃': 2.9998
'ڮ焈㱉煉ꅿᦗ툍혐خ꧓': 2.9994
'ἂጋ\uddcc\ue3d7 粖\ude2c⮌鮓讃\ueab6': 2.9998
print("Winner: %s" % terminal_repr(selected))
Winner: '\uef2a 潦ﳠ鹉卼㴐Ṭ\uf4b0˅얓'
就像在自然进化中一样,根据其适应度选择的个体繁殖,以形成新一代。在这次繁殖过程中,就像在自然繁殖中一样,所选父母的遗传物质会结合。这通常是通过一个称为交叉的过程来完成的,其中通过父母的基因产生后代染色体。在我们的情况下,染色体是一系列字符,通过选择一个随机的截止点,并基于该截止点结合父母染色体的两半来产生后代。
def crossover(parent1, parent2):
pos = random.randint(1, len(parent1))
offspring1 = parent1[:pos] + parent2[pos:]
offspring2 = parent2[:pos] + parent1[pos:]
return (offspring1, offspring2)
parent1 = "Hello World"
parent2 = "Goodbye Book"
crossover(parent1, parent2)
('Hello W Book', 'Goodbyeorld')
遗传进化的另一个重要组成部分是突变的概念:有时,后代基因中会有小的变化,这代表了新的遗传物质和新的生理特征。如果突变引入了代表有用特征并导致更好的适应度值的新的遗传物质,那么这种遗传物质将得以生存。如果突变引入了无用的遗传物质,那么产生的个体可能具有较差的适应度值并迅速死亡。
一个重要方面是,突变和交叉都是概率性操作。它们并不总是发生,并且每次发生时效果都不同。对于序列遗传编码,一种常见的方法是首先使用一个概率来决定是否应用突变,然后选择一个基因并对其进行一点修改。或者,我们可以概率性地突变基因,使得平均来说只有一个基因被改变。我们通过计算每个基因突变的概率 P 为 1/(基因数量) 来做到这一点。然后,如果基因发生突变,我们不仅用随机字符替换它,而且使其只有小变化的可能性更大。这是通过从以当前值作为均值的高斯分布中进行采样来实现的。我们任意使用标准差为 100 来使小变化比大变化更可能。
def mutate(chromosome):
mutated = chromosome[:]
P = 1.0 / len(mutated)
for pos in range(len(mutated)):
if random.random() < P:
new_c = chr(int(random.gauss(ord(mutated[pos]), 100) % 65536))
mutated = mutated[:pos] + new_c + mutated[pos + 1:]
return mutated
现在我们终于有了构建完整算法的所有成分:
def genetic_algorithm():
# Generate and evaluate initial population
generation = 0
population = create_population(100)
fitness = evaluate_population(population)
best = min(fitness, key=lambda item: item[1])
best_individual = best[0]
best_fitness = best[1]
print("Best fitness of initial population: %s - %.10f" %
(terminal_repr(best_individual), best_fitness))
logs = 0
# Stop when optimum found, or we run out of patience
while best_fitness > 0 and generation < 1000:
# The next generation will have the same size as the current one
new_population = []
while len(new_population) < len(population):
# Selection
offspring1 = selection(fitness, 10)
offspring2 = selection(fitness, 10)
# Crossover
if random.random() < 0.7:
(offspring1, offspring2) = crossover(offspring1, offspring2)
# Mutation
offspring1 = mutate(offspring1)
offspring2 = mutate(offspring2)
new_population.append(offspring1)
new_population.append(offspring2)
# Once full, the new population replaces the old one
generation += 1
population = new_population
fitness = evaluate_population(population)
best = min(fitness, key=lambda item: item[1])
best_individual = best[0]
best_fitness = best[1]
if logs < LOG_VALUES:
print(
"Best fitness at generation %d: %s - %.8f" %
(generation, terminal_repr(best_individual), best_fitness))
elif logs == LOG_VALUES:
print("...")
logs += 1
print(
"Best individual: %s, fitness %.10f" %
(terminal_repr(best_individual), best_fitness))
让我们看看这在我们的 Unicode 示例中是如何工作的。
genetic_algorithm()
Best fitness of initial population: '俴\x8a 辰燄펧䬍缯檒㤢⦡' - 2.9901960784
Best fitness at generation 1: 'ཬə쫯ὼ酟剺鬈{Ż\ue313' - 2.98850575
Best fitness at generation 2: '俴\x82 辰燄酟剺鬢\x1bŻ\ue313' - 2.90909091
Best fitness at generation 3: 'ྂɵ쫕ὼ酟剺鬈%Ŧ\ue384' - 1.99610895
Best fitness at generation 4: 'ྂɵ쬆ὼ酟剺鬈%ŋ\ue384' - 1.99565217
Best fitness at generation 5: '俴ʐ쫕ἶ酟剺髺%¼\ue384' - 1.98850575
Best fitness at generation 6: 'ྂɵ쫪ὼ酟剒鬈%\x9e\ue37f' - 1.98245614
Best fitness at generation 7: '俴̗쪕ἶ酟剺髱%4\ue378' - 0.99998280
Best fitness at generation 8: '侪\x86쪕ἶ酟剺髱%4\ue378' - 0.99998280
Best fitness at generation 9: '俆ǫ쪸ἶ醿剺髱%4\ue2b5' - 0.99998274
Best fitness at generation 10: '俆ǫ쪸ἶ釘剺髧%4\ue2b5' - 0.99998274
Best fitness at generation 11: '俆ɫ쫕Ồ酟剺髱%4\ue256' - 0.99998271
Best fitness at generation 12: '侪\x86쪕ἶ醿剺髱%4\ue1f9' - 0.99998268
Best fitness at generation 13: '侪ï쪕ἶ醿剺髱%4\ue1f9' - 0.99998268
Best fitness at generation 14: '俆\x86쪕ἶ醿剺髱%4\ue1f9' - 0.99998268
Best fitness at generation 15: '俆Ǩ쪸ἂ醿剺髱%4\ue150' - 0.99998263
Best fitness at generation 16: '俴ʓ쫕ớ鄞务髱%e\ue0f0' - 0.99998260
Best fitness at generation 17: '侪ï쪵ớ酟刖髱%F\ue0a9' - 0.99998258
Best fitness at generation 18: '佺ģ쪵ớ鄞劐髱%e\ue05c' - 0.99998256
Best fitness at generation 19: '侪ï쪵ớ酟刖髱%e\ue05c' - 0.99998256
Best fitness at generation 20: '侪ï쪵ί酃劐髝%e\ue00c' - 0.99998253
...
Best individual: '予ß쥇᳗轐咆隭%2A', fitness 0.0000000000
经验教训
-
元启发式搜索问题由一个算法、一个表示和一个适应度函数组成。
-
对于测试生成,适应度函数通常估计执行接近目标位置的程度。为了确定这个距离,我们在测试执行期间使用仪器来计算距离。
-
当邻域定义良好且不是太大时,像爬山这样的局部搜索算法效果很好。
-
像遗传算法这样的全局搜索算法非常灵活,并且很好地扩展到更大的测试问题。
下一步行动
在本章中,我们研究了相当简单的程序输入。我们也可以将相同的搜索算法应用于进化复杂的测试输入,特别是语法输入。
背景
\todo{添加更多}
搜索的目标通常与覆盖率相关。有关讨论,请参阅测试入门中的书籍。
练习
\todo{稍后添加}
本项目的内容受Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议的许可。内容的一部分源代码,以及用于格式化和显示该内容的源代码,受MIT 许可协议的许可。最后更改日期:2023-11-11 18:18:06+01:00 引用 印记
如何引用此作品
安德烈亚斯·泽勒,拉胡尔·戈皮纳特,马塞尔·博姆,戈登·弗朗西斯,克里斯蒂安·霍勒:"基于搜索的模糊测试"。在安德烈亚斯·泽勒,拉胡尔·戈皮纳特,马塞尔·博姆,戈登·弗朗西斯,克里斯蒂安·霍勒的《模糊测试书www.fuzzingbook.org/html/SearchBasedFuzzer.html]中。检索日期:2023-11-11 18:18:06+01:00。
@incollection{fuzzingbook2023:SearchBasedFuzzer,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Search-Based Fuzzing},
year = {2023},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/SearchBasedFuzzer.html}},
note = {Retrieved 2023-11-11 18:18:06+01:00},
url = {https://www.fuzzingbook.org/html/SearchBasedFuzzer.html},
urldate = {2023-11-11 18:18:06+01:00}
}


浙公网安备 33010602011771号