模糊测试之书-十七-
模糊测试之书(十七)
原文:
exploringjs.com/ts/book/index.html译者:飞龙
测试图形用户界面
在本章中,我们探讨如何为图形用户界面(GUI)生成测试,从我们之前的 Web 测试示例中抽象出来。基于提取用户界面元素和激活它们的一般方法,我们的技术可以推广到任意图形用户界面,从富 Web 应用程序到移动应用,并通过表单和导航元素系统地探索用户界面。
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('79-HRgFot4k')
先决条件
- 我们基于 Web 测试章节中介绍的 Web 服务器构建。
概述
要使用本章提供的代码(导入代码),请编写
>>> from fuzzingbook.GUIFuzzer import <identifier>
然后利用以下功能。
本章演示了如何使用 Selenium 在 Web 浏览器中编程式地与用户界面交互。它提供了一个实验性的GUICoverageFuzzer类,该类通过系统地与所有可用的用户界面元素交互来自动探索用户界面。
函数start_webdriver()在后台启动一个无头 Web 浏览器,并返回一个GUI 驱动程序作为进一步通信的句柄。
>>> gui_driver = start_webdriver()
The geckodriver version (0.34.0) detected in PATH at /Users/zeller/bin/geckodriver might not be compatible with the detected firefox version (135.03); currently, geckodriver 0.35.0 is recommended for firefox 135.*, so it is advised to delete the driver in PATH and retry
我们让浏览器打开我们想要调查的服务器的 URL(在这种情况下,来自 Web 模糊测试章节的易受攻击的服务器)并获取屏幕截图。
>>> gui_driver.get(httpd_url)
>>> Image(gui_driver.get_screenshot_as_png())

GUICoverageFuzzer类探索用户界面,并构建一个语法,该语法编码了所有状态以及从一种状态移动到另一种状态所需的用户交互。它与一个GUIRunner配对,该GUIRunner与 GUI 驱动程序进行交互。
>>> gui_fuzzer = GUICoverageFuzzer(gui_driver)
>>> gui_runner = GUIRunner(gui_driver)
explore_all()方法从 Web 用户界面中提取所有状态和所有转换。
>>> gui_fuzzer.explore_all(gui_runner)
语法嵌入了一个有限状态自动化,最好将其可视化为此。
>>> fsm_diagram(gui_fuzzer.grammar)
GUI Fuzzer 的fuzz()方法产生一系列交互,这些交互通过有限状态机(FSM)的路径进行。由于GUICoverageFuzzer是从CoverageFuzzer派生出来的(见基于覆盖的语法模糊测试章节),它自动覆盖(a)尽可能多的状态之间的转换以及(b)尽可能多的表单元素。在我们的例子中,第一组操作通过“订单表单”链接探索转换;第二组操作然后继续到“
>>> gui_driver.get(httpd_url)
>>> actions = gui_fuzzer.fuzz()
>>> print(actions)
fill('city', 'U')
fill('email', 'r@z')
fill('name', 'H')
check('terms', True)
fill('zip', '3')
submit('submit')
click('order form')
fill('city', 'q')
fill('email', 'v@p')
fill('name', 's')
check('terms', True)
fill('zip', '4')
submit('submit')
这些操作可以输入到 GUI 运行器中,它将在给定的 GUI 驱动程序上执行它们。
>>> gui_driver.get(httpd_url)
>>> result, outcome = gui_runner.run(actions)
>>> Image(gui_driver.get_screenshot_as_png())

fuzz()的进一步调用将进一步覆盖模型——例如,探索条款和条件。
内部,GUIFuzzer和GUICoverageFuzzer使用一个子类GUIGrammarMiner,该子类实现了 GUI 及其所有状态的分析。通过子类化GUIGrammarMiner可以扩展 GUI 的解释;GUIFuzzer构造函数允许通过miner关键字参数传递一个挖掘器。
类似于GUICoverageFuzzer的工具将提供对用户界面的“深度”探索,甚至填写表单以探索其背后的内容。但请注意,GUICoverageFuzzer是实验性的:它只支持 HTML 表单和链接功能的一个子集,并且不考虑 JavaScript。
使用 Selenium 的 GUI 模糊测试工具。">
构造函数。
driver - 要使用的 Selenium 驱动器。
miner - 要使用的挖掘器(默认:GUIGrammarMiner(driver))
stay_on_host - 如果为 True(默认),则不探索外部链接。
log_gui_exploration - 如果设置,打印出探索步骤。
disp_gui_exploration - 如果设置,显示当前网页的截图
以及在探索过程中的 FSM 图。
其他关键字参数传递给GrammarFuzzer超类。">
返回原始 URL">
在给定的 GUIRunner runner 上运行模糊测试器。">
返回当前(预期)状态符号">
返回状态符号的序列。">
将语法设置为new_grammar。">
更新现有状态的动作。">
找到新的状态;相应地扩展语法。">
从当前网页确定当前状态。">
使用推导树高效地从语法生成字符串。">
从grammar生成字符串,从start_symbol开始。
如果提供了min_nonterminals或max_nonterminals,则使用它们作为限制。
用于生成非终结符的数量。
如果设置了disp,则显示中间推导树。
如果设置了log,则将中间步骤作为文本输出到标准输出。">
从语法生成字符串。">
从语法中生成一个推导树。">
模糊器的基类。">
构造函数">
返回模糊输入">
使用模糊输入运行 runner">
使用模糊输入运行 runner,trials 次数">
系统性地探索当前网页的所有状态">
构造函数。所有参数都传递给 GUIFuzzer 超类。
探索 GUI 的所有状态,直到 max_actions(默认 100)。">
从语法生成,旨在覆盖所有扩展。">
在选择扩展时,优先选择未被覆盖的扩展。">
在生成过程中跟踪语法覆盖。
从 grammar 生成字符串,从 start_symbol 开始。
如果提供了 min_nonterminals 或 max_nonterminals,则使用它们作为限制。
对于生成的非终结符数量。
如果 disp 被设置,显示中间推导树。
如果 log 已设置,则将中间步骤作为文本显示在标准输出上。">
执行给定的动作字符串中的动作">
构造函数。driver 是一个 Selenium 网页驱动器">
在当前网站上执行动作字符串 inp。
返回一对 (inp, outcome)">
设置检查元素 name 为 state">
点击元素 name">
使用 value 填充文本元素 name">
点击提交元素 name">
在当前网站上搜索名为 name 的元素。
匹配可以通过名称或链接文本进行。">
测试输入的基类。">
Initialize">
运行带有给定输入的运行者">
获取可能的 GUI 交互序列的语法">
构造函数。
driver - 由 Selenium 产生的网页驱动程序。
stay_on_host - 如果为 True(默认),则不跟随指向其他主机的链接。《init()`
如果允许我们跟随 link URL">
确定当前网页上的所有链接动作">
确定当前网页上的所有按钮动作">
确定当前网页上的所有输入动作">
返回当前网站上所有可能动作的集合。
可在子类中重载。">
返回当前网站上动作的状态语法。
可在子类中重载。">
为 grammar 中的某些状态返回一个新的符号`">
自动化 GUI 交互
在 Web 测试章节 中,我们展示了如何通过直接使用 HTTP 协议与 Web 服务器交互,并处理检索到的 HTML 页面来识别用户界面元素。虽然这些技术对于仅基于 HTML 的用户界面效果良好,但一旦存在使用 JavaScript 在浏览器中执行代码并生成和更改用户界面而不需要与浏览器交互的交互元素,它们就会失效。
在本章中,我们因此采用了一种不同的用户界面测试方法。而不是使用 HTTP 和 HTML 作为交互机制,我们利用一个专门的 UI 测试框架,这使得我们能够
-
查询待测试程序中的可用用户界面元素,并且
-
查询 UI 元素如何进行交互。
尽管我们再次使用 Web 服务器来展示我们的方法,但这种方法很容易推广到 任意用户界面。实际上,我们使用的 UI 测试框架 Selenium 也提供了适用于 Android 应用程序的变体。
我们的 Web 服务器,再次
与 Web 测试章节 中的情况一样,我们运行一个 Web 服务器,允许我们订购产品。
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 Set, FrozenSet, List, Optional, Tuple, Any
import [os](https://docs.python.org/3/library/os.html)
import [sys](https://docs.python.org/3/library/sys.html)
from WebFuzzer import init_db, start_httpd, webbrowser, print_httpd_messages
from WebFuzzer import print_url, ORDERS_DB
import [html](https://docs.python.org/3/library/html.html)
db = init_db()
这是我们的 Web 服务器地址:
httpd_process, httpd_url = start_httpd()
print_url(httpd_url)
http://127.0.0.1:8800
使用 webbrowser(),我们可以检索主页的 HTML,并使用 HTML() 来渲染它。
from [IPython.display](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html) import display, Image
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import HTML, rich_output
HTML(webbrowser(httpd_url))
127.0.0.1 - - [16/Jan/2025 11:12:35] "GET / HTTP/1.1" 200 -
使用 Selenium 进行远程控制
让我们看看上面的 GUI。与 Web 测试章节不同,我们不假设我们可以访问当前页面的 HTML 源代码。我们假设的是,有一组用户界面元素我们可以与之交互。
Selenium是一个通过自动化浏览器交互来测试 Web 应用的框架。Selenium 提供了一个 API,允许用户启动 Web 浏览器,查询用户界面的状态,并与单个用户界面元素交互。Selenium API 在多种语言中可用;我们使用Selenium Python API。
Selenium 网络驱动程序是程序与由程序控制的浏览器之间的接口。以下代码在后台启动一个 Web 浏览器,然后我们通过网络驱动程序来控制它。
from [selenium](https://selenium-python.readthedocs.io/) import webdriver
我们支持 Firefox 和 Google Chrome。
BROWSER = 'firefox' # Set to 'chrome' if you prefer Chrome
设置 Firefox
对于 Firefox,您必须确保geckodriver 程序在您的路径中。
import [shutil](https://docs.python.org/3/library/shutil.html)
if BROWSER == 'firefox':
assert shutil.which('geckodriver') is not None, \
"Please install the 'geckodriver' executable " \
"from https://github.com/mozilla/geckodriver/releases"
设置 Chrome
对于 Chrome,您可能需要确保chromedriver 程序在您的路径中。
if BROWSER == 'chrome':
assert shutil.which('chromedriver') is not None, \
"Please install the 'chromedriver' executable " \
"from https://chromedriver.chromium.org"
运行无头浏览器
浏览器是无头的,这意味着它不会显示在屏幕上。
HEADLESS = True
注意:如果笔记本服务器在本地运行(即在您看到此处的同一台机器上),您也可以将HEADLESS设置为False,并在执行笔记本单元时直接在屏幕上看到结果。这对于交互式会话非常推荐。
启动 Web 驱动程序
此代码启动 Selenium 网络驱动程序。
def start_webdriver(browser=BROWSER, headless=HEADLESS, zoom=1.4):
# Set headless option
if browser == 'firefox':
options = webdriver.FirefoxOptions()
if headless:
# See https://www.browserstack.com/guide/firefox-headless
options.add_argument("--headless")
elif browser == 'chrome':
options = webdriver.ChromeOptions()
if headless:
# See https://www.selenium.dev/blog/2023/headless-is-going-away/
options.add_argument("--headless=new")
else:
assert False, "Select 'firefox' or 'chrome' as browser"
# Start the browser, and obtain a _web driver_ object such that we can interact with it.
if browser == 'firefox':
# For firefox, set a higher resolution for our screenshots
options.set_preference("layout.css.devPixelsPerPx", repr(zoom))
gui_driver = webdriver.Firefox(options=options)
# We set the window size such that it fits our order form exactly;
# this is useful for not wasting too much space when taking screen shots.
gui_driver.set_window_size(700, 300)
elif browser == 'chrome':
gui_driver = webdriver.Chrome(options=options)
gui_driver.set_window_size(700, 210 if headless else 340)
return gui_driver
gui_driver = start_webdriver(browser=BROWSER, headless=HEADLESS)
The geckodriver version (0.34.0) detected in PATH at /Users/zeller/bin/geckodriver might not be compatible with the detected firefox version (135.03); currently, geckodriver 0.35.0 is recommended for firefox 135.*, so it is advised to delete the driver in PATH and retry
我们现在可以以编程方式与浏览器交互。首先,我们让它导航到我们的 Web 服务器的 URL:
gui_driver.get(httpd_url)
我们看到实际上访问了主页,以及一个(失败的)获取页面图标的请求:
print_httpd_messages()
127.0.0.1 - - [16/Jan/2025 11:12:38] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2025 11:12:38] "GET /favicon.ico HTTP/1.1" 404 -
要查看“无头”浏览器显示的内容,我们可以获取一个屏幕截图。我们看到它实际上显示了主页。
Image(gui_driver.get_screenshot_as_png())

填写表格
要通过 Selenium 和浏览器与网页交互,我们可以查询 Selenium 以获取单个元素。例如,我们可以访问具有name属性(如 HTML 中定义)的 UI 元素,其值为"name"。
from [selenium.webdriver.common.by](https://selenium-python.readthedocs.io/) import By
name = gui_driver.find_element(By.NAME, "name")
一旦我们有了元素,我们就可以与之交互。由于name是一个文本字段,我们可以使用send_keys()方法发送一个字符串;该字符串将被转换为适当的按键。
name.send_keys("Jane Doe")
在屏幕截图中,我们可以看到name字段现在已填写:
Image(gui_driver.get_screenshot_as_png())

同样,我们可以填写电子邮件、城市和邮政编码字段:
email = gui_driver.find_element(By.NAME, "email")
email.send_keys("j.doe@example.com")
city = gui_driver.find_element(By.NAME, 'city')
city.send_keys("Seattle")
zip = gui_driver.find_element(By.NAME, 'zip')
zip.send_keys("98104")
Image(gui_driver.get_screenshot_as_png())

条款和条件复选框未填写,而是使用click()方法进行点击。
terms = gui_driver.find_element(By.NAME, 'terms')
terms.click()
Image(gui_driver.get_screenshot_as_png())

表单现在已完全填写。通过点击提交按钮,我们可以下订单:
submit = gui_driver.find_element(By.NAME, 'submit')
submit.click()
我们看到订单正在处理中,并且 Web 浏览器已经切换到了确认页面。
print_httpd_messages()
127.0.0.1 - - [16/Jan/2025 11:12:39] INSERT INTO orders VALUES ('tshirt', 'Jane Doe', 'j.doe@example.com', 'Seattle', '98104')
127.0.0.1 - - [16/Jan/2025 11:12:39] "GET /order?item=tshirt&name=Jane+Doe&email=j.doe%40example.com&city=Seattle&zip=98104&terms=on&submit=Place+order HTTP/1.1" 200 -
Image(gui_driver.get_screenshot_as_png())

导航
正如我们填写表单一样,我们也可以通过点击链接来浏览网站。让我们回到主页:
gui_driver.back()
Image(gui_driver.get_screenshot_as_png())

我们可以查询 Web 驱动程序以获取特定类型的所有元素。例如,查询 HTML 锚点元素(<a>)会给我们页面上的所有链接。
links = gui_driver.find_elements(By.TAG_NAME, "a")
我们可以查询 UI 元素的属性——例如,页面第一个锚点链接到的 URL:
links[0].get_attribute('href')
'http://127.0.0.1:8800/terms'
点击它会发生什么?非常简单:我们会切换到被引用的 Web 页面。
links[0].click()
print_httpd_messages()
127.0.0.1 - - [16/Jan/2025 11:12:39] "GET /terms HTTP/1.1" 200 -
Image(gui_driver.get_screenshot_as_png())

好的。让我们再次回到我们的主页。
gui_driver.back()
print_httpd_messages()
Image(gui_driver.get_screenshot_as_png())

编写测试用例
上述调用,与用户界面自动交互,通常用于Selenium 测试——即与网站交互的代码片段,偶尔检查是否一切如预期工作。以下代码,例如,就像上面那样下订单。然后它检索title元素并检查标题是否包含“谢谢”消息,表示成功。
def test_successful_order(driver, url):
name = "Walter White"
email = "white@jpwynne.edu"
city = "Albuquerque"
zip_code = "87101"
driver.get(url)
driver.find_element(By.NAME, "name").send_keys(name)
driver.find_element(By.NAME, "email").send_keys(email)
driver.find_element(By.NAME, 'city').send_keys(city)
driver.find_element(By.NAME, 'zip').send_keys(zip_code)
driver.find_element(By.NAME, 'terms').click()
driver.find_element(By.NAME, 'submit').click()
title = driver.find_element(By.ID, 'title')
assert title is not None
assert title.text.find("Thank you") >= 0
confirmation = driver.find_element(By.ID, "confirmation")
assert confirmation is not None
assert confirmation.text.find(name) >= 0
assert confirmation.text.find(email) >= 0
assert confirmation.text.find(city) >= 0
assert confirmation.text.find(zip_code) >= 0
return True
test_successful_order(gui_driver, httpd_url)
True
类似地,我们可以为失败的订单、取消订单、更改订单等设置自动测试用例。所有这些测试用例都会在程序代码的任何更改后自动运行,确保 Web 应用程序仍然正常工作。
当然,编写这样的测试需要相当多的努力。因此,在本章的剩余部分,我们再次探讨如何自动生成它们。
获取用户界面操作
要自动与用户界面交互,我们首先需要找出有哪些元素,以及它们支持哪些用户交互(或简称为操作)。
用户界面元素
我们从查找可用的用户元素开始。让我们回到订单表单。
gui_driver.get(httpd_url)
Image(gui_driver.get_screenshot_as_png())

使用find_elements(By.TAG_NAME, )(和其他类似的find_elements_...()函数),我们可以检索特定类型的所有元素,例如 HTML input元素。
ui_elements = gui_driver.find_elements(By.TAG_NAME, "input")
对于每个元素,我们可以使用get_attribute()检索其 HTML 属性。因此,我们可以检索每个输入元素的name和type(如果已定义)。
for element in ui_elements:
print("Name: %-10s | Type: %-10s | Text: %s" %
(element.get_attribute('name'),
element.get_attribute('type'),
element.text))
Name: name | Type: text | Text:
Name: email | Type: email | Text:
Name: city | Type: text | Text:
Name: zip | Type: number | Text:
Name: terms | Type: checkbox | Text:
Name: submit | Type: submit | Text:
ui_elements = gui_driver.find_elements(By.TAG_NAME, "a")
for element in ui_elements:
print("Name: %-10s | Type: %-10s | Text: %s" %
(element.get_attribute('name'),
element.get_attribute('type'),
element.text))
Name: | Type: | Text: terms and conditions
用户界面操作
类似于我们在关于 Web 模糊测试的章节中所做的那样,我们的想法现在是为用户界面挖掘一个语法——首先是一个单独的用户界面页面(即单个 Web 页面),然后是应用程序提供的所有页面。这个想法是,语法定义了合法的操作序列——点击和按键——这些可以在应用程序上应用。
我们假设以下操作:
-
fill(<name>, <text>)– 使用文本<text>填充名为<name>的 UI 输入元素。 -
check(<name>, <value>)– 将 UI 复选框<name>设置为给定的值<value>(True 或 False) -
submit(<name>)– 通过点击 UI 元素<name>提交表单。 -
click(<name>)– 点击 UI 元素<name>,通常用于跟随链接。
例如,这个动作序列会填写订单表单:
fill('name', "Walter White")
fill('email', "white@jpwynne.edu")
fill('city', "Albuquerque")
fill('zip', "87101")
check('terms', True)
submit('submit')
我们故意将操作集合定义得较小 – 对于真实用户界面,还必须定义如滑动、双击、长按、右键点击、修饰键等交互。Selenium 支持所有这些;但为了简单起见,我们专注于最重要的交互集合。
获取操作
在挖掘动作语法的第一步中,我们需要能够检索可能的交互。我们引入了一个名为 GUIGrammarMiner 的类,它被设置为精确执行此操作。
class GUIGrammarMiner:
"""Retrieve a grammar of possible GUI interaction sequences"""
def __init__(self, driver, stay_on_host: bool = True) -> None:
"""Constructor.
`driver` - a web driver as produced by Selenium.
`stay_on_host` - if True (default), no not follow links to other hosts.
"""
self.driver = driver
self.stay_on_host = stay_on_host
self.grammar: Grammar = {}
实现检索操作
我们的首要任务是获取可能的交互集合。给定单个 UI 页面,GUIGrammarMiner 的 mine_input_actions() 方法返回一个如上定义的操作集合。它首先获取所有 input 元素,然后是 button 元素,最后是链接(a 元素),并将它们合并到一个集合中。(我们在这里使用 frozenset,因为我们想稍后使用这个集合作为索引。)
class GUIGrammarMiner(GUIGrammarMiner):
def mine_state_actions(self) -> FrozenSet[str]:
"""Return a set of all possible actions on the current Web site.
Can be overloaded in subclasses."""
return frozenset(self.mine_input_element_actions()
| self.mine_button_element_actions()
| self.mine_a_element_actions())
def mine_input_element_actions(self) -> Set[str]:
return set() # to be defined later
def mine_button_element_actions(self) -> Set[str]:
return set() # to be defined later
def mine_a_element_actions(self) -> Set[str]:
return set() # to be defined later
输入元素操作
挖掘输入操作会遍历输入元素集合,并根据输入类型返回一个操作。例如,如果输入字段是文本,则相关操作是 fill();对于复选框,操作是 check()。
相应的值取决于类型;例如,如果输入字段是数字,则值变为 <number>。由于这些操作后来成为语法的一部分,它们将在语法扩展期间扩展为实际值。
from [selenium.common.exceptions](https://selenium-python.readthedocs.io/) import StaleElementReferenceException
class GUIGrammarMiner(GUIGrammarMiner):
def mine_input_element_actions(self) -> Set[str]:
"""Determine all input actions on the current Web page"""
actions = set()
for elem in self.driver.find_elements(By.TAG_NAME, "input"):
try:
input_type = elem.get_attribute("type")
input_name = elem.get_attribute("name")
if input_name is None:
input_name = elem.text
if input_type in ["checkbox", "radio"]:
actions.add("check('%s', <boolean>)" % html.escape(input_name))
elif input_type in ["text", "number", "email", "password"]:
actions.add("fill('%s', '<%s>')" % (html.escape(input_name), html.escape(input_type)))
elif input_type in ["button", "submit"]:
actions.add("submit('%s')" % html.escape(input_name))
elif input_type in ["hidden"]:
pass
else:
# TODO: Handle more types here
actions.add("fill('%s', <%s>)" % (html.escape(input_name), html.escape(input_type)))
except StaleElementReferenceException:
pass
return actions
在我们的订单表单上应用此方法,我们看到该方法为我们提供了所有输入操作:
gui_grammar_miner = GUIGrammarMiner(gui_driver)
gui_grammar_miner.mine_input_element_actions()
{"check('terms', <boolean>)",
"fill('city', '<text>')",
"fill('email', '<email>')",
"fill('name', '<text>')",
"fill('zip', '<number>')",
"submit('submit')"}
按钮元素操作
按钮挖掘的工作原理类似:
class GUIGrammarMiner(GUIGrammarMiner):
def mine_button_element_actions(self) -> Set[str]:
"""Determine all button actions on the current Web page"""
actions = set()
for elem in self.driver.find_elements(By.TAG_NAME, "button"):
try:
button_type = elem.get_attribute("type")
button_name = elem.get_attribute("name")
if button_name is None:
button_name = elem.text
if button_type == "submit":
actions.add("submit('%s')" % html.escape(button_name))
elif button_type != "reset":
actions.add("click('%s')" % html.escape(button_name))
except StaleElementReferenceException:
pass
return actions
我们的订单表单没有 button 元素。(提交按钮是一个 input 元素,已在上面处理过)。
gui_grammar_miner = GUIGrammarMiner(gui_driver)
gui_grammar_miner.mine_button_element_actions()
set()
链接元素操作
在跟随链接时,我们需要确保我们保持在当前主机上 – 我们只想探索单个网站,而不是整个互联网。为此,我们检查链接的 href 属性,以检查它是否仍然指向同一主机。如果它不指向同一主机,我们将给它一个特殊操作 ignore(),正如其名所示,在执行这些操作时将被忽略。尽管如此,我们仍然返回一个操作,因为我们使用操作集合来表征应用程序中的状态。
from [urllib.parse](https://docs.python.org/3/library/urllib.parse.html) import urljoin, urlsplit
class GUIGrammarMiner(GUIGrammarMiner):
def mine_a_element_actions(self) -> Set[str]:
"""Determine all link actions on the current Web page"""
actions = set()
for elem in self.driver.find_elements(By.TAG_NAME, "a"):
try:
a_href = elem.get_attribute("href")
if a_href is not None:
if self.follow_link(a_href):
actions.add("click('%s')" % html.escape(elem.text))
else:
actions.add("ignore('%s')" % html.escape(elem.text))
except StaleElementReferenceException:
pass
return actions
要检查我们是否可以跟随链接,follow_link() 方法会检查 URL:
class GUIGrammarMiner(GUIGrammarMiner):
def follow_link(self, link: str) -> bool:
"""Return True iff we are allowed to follow the `link` URL"""
if not self.stay_on_host:
return True
current_url = self.driver.current_url
target_url = urljoin(current_url, link)
return urlsplit(current_url).hostname == urlsplit(target_url).hostname
在我们的应用程序中,我们不允许跟随到 foo.bar 的链接:
gui_grammar_miner = GUIGrammarMiner(gui_driver)
gui_grammar_miner.follow_link("ftp://foo.bar/")
False
然而,通过 localhost 的链接工作良好:
gui_grammar_miner.follow_link("https://127.0.0.1/")
True
当将此应用于其他用户界面时,我们会采取类似措施以确保我们保持在同一应用程序中。
在我们的页面上运行此方法会得到链接集合:
gui_grammar_miner = GUIGrammarMiner(gui_driver)
gui_grammar_miner.mine_a_element_actions()
{"click('terms and conditions')"}
让我们看看GUIGrammarMiner的实际应用,使用它的mine_state_actions()方法来检索我们当前页面上的所有元素。我们看到我们获得了输入元素动作、按钮元素动作和链接元素动作。
gui_grammar_miner = GUIGrammarMiner(gui_driver)
gui_grammar_miner.mine_state_actions()
frozenset({"check('terms', <boolean>)",
"click('terms and conditions')",
"fill('city', '<text>')",
"fill('email', '<email>')",
"fill('name', '<text>')",
"fill('zip', '<number>')",
"submit('submit')"})
我们假设我们可以从包含的交互元素集合中识别用户界面状态——也就是说,上述集合确定了当前的 Web 页面。这与 Web 模糊测试形成对比,在那里我们假设 URL 可以唯一地描述一个页面——但是随着 JavaScript 的出现,URL 可以保持不变,尽管页面内容发生变化,而且除了 Web 之外的用户界面可能没有唯一 URL 的概念。因此,我们说,UI 可以被唯一交互的方式定义了其状态。
用户界面模型
用户界面作为有限状态机
现在我们已经能够从页面中检索 UI 元素了,让我们去系统地探索用户界面。想法是将用户界面表示为一个有限状态机——也就是说,通过与单个用户界面元素交互可以到达的一系列状态。
让我们通过查看我们的 Web 服务器来展示这样一个有限状态机的例子。以下图表显示了我们的服务器可能处于的状态:
初始时,我们处于<Order Form>状态。从这里,我们可以点击Terms and Conditions,然后我们会进入Terms and Conditions状态,显示具有相同标题的页面。我们还可以填写表单并下订单,最终进入Thank You状态(再次显示具有相同标题的页面)。从<Terms and Conditions>和<Thank You>,我们可以通过点击order form链接返回订单表单。
状态机作为语法
为了系统地探索用户界面,我们必须检索其有限状态机,并最终覆盖所有状态和转换。在存在表单的情况下,这种探索是困难的,因为我们需要一个特殊的机制来填写表单并提交值以到达下一个状态。不过,有一个技巧,它允许我们为状态和(表单)值提供单一表示。我们可以将有限状态机嵌入到语法中,然后它被用于状态和表单值。
要将有限状态机嵌入到语法中,我们按以下步骤进行:
-
有限状态机中的每一个状态 \(\langle s \rangle\) 变成语法中的符号 \(\langle s \rangle\)。
-
有限状态机中从\(\langle s \rangle\)到\(\langle t \rangle\)的每一个转换和动作\(a_1, a_2, \dots\)成为语法中\(\langle s \rangle\)的选择,形式为\(a_1, a_2, dots\) \(\langle t \rangle\)。
上述有限状态机因此被编码到语法中
<start> ::= <Order Form>
<Order Form> ::= click('Terms and Conditions') <Terms and Conditions> |
fill(...) submit('submit') <Thank You>
<Terms and Conditions> ::= click('order form') <Order Form>
<Thank You> ::= click('order form') <Order Form>
扩展这个语法会得到一系列动作,导航通过用户界面:
fill(...) submit('submit') click('order form') click('Terms and Conditions') click('order form') ...
这个流实际上是 无限的(因为人们可以永远与 UI 交互);为了使其结束,可以引入一个替代的 <end>,它简单地扩展为空字符串,没有任何扩展(状态)跟随。
获取状态语法
让我们扩展 GUIGrammarMiner,使其能够从用户界面在其 当前状态 中检索语法。
实现提取状态语法
我们首先定义一个常量 GUI_GRAMMAR,作为所有输入类型的模板。我们将使用它来填写表格。
\todo{}:拥有一个公共基类 GrammarMiner,带有 __init__() 和 mine_grammar() 方法
from Grammars import new_symbol
from Grammars import nonterminals, START_SYMBOL
from Grammars import extend_grammar, unreachable_nonterminals, crange, srange
from Grammars import syntax_diagram, is_valid_grammar, Grammar
class GUIGrammarMiner(GUIGrammarMiner):
START_STATE = "<state>"
UNEXPLORED_STATE = "<unexplored>"
FINAL_STATE = "<end>"
GUI_GRAMMAR: Grammar = ({
START_SYMBOL: [START_STATE],
UNEXPLORED_STATE: [""],
FINAL_STATE: [""],
"<text>": ["<string>"],
"<string>": ["<character>", "<string><character>"],
"<character>": ["<letter>", "<digit>", "<special>"],
"<letter>": crange('a', 'z') + crange('A', 'Z'),
"<number>": ["<digits>"],
"<digits>": ["<digit>", "<digits><digit>"],
"<digit>": crange('0', '9'),
"<special>": srange(". !"),
"<email>": ["<letters>@<letters>"],
"<letters>": ["<letter>", "<letters><letter>"],
"<boolean>": ["True", "False"],
# Use a fixed password in case we need to repeat it
"<password>": ["abcABC.123"],
"<hidden>": ["<string>"],
})
syntax_diagram(GUIGrammarMiner.GUI_GRAMMAR)
start
unexplored
end
text
string
character
letter
number
digits
digit
special
email
letters
boolean
password
hidden
方法 mine_state_grammar() 会遍历从页面中挖掘出的操作(使用 mine_state_actions()),并为当前状态创建一个语法。对于每个 click() 和 submit() 操作,它假设会跟随一个新的状态,并将适当的状态符号引入语法中——这个状态符号现在会被标记为 <unexplored>,但稍后会在看到适当的状态时进行扩展。
class GUIGrammarMiner(GUIGrammarMiner):
def new_state_symbol(self, grammar: Grammar) -> str:
"""Return a new symbol for some state in `grammar`"""
return new_symbol(grammar, self.START_STATE)
def mine_state_grammar(self, grammar: Grammar = {},
state_symbol: Optional[str] = None) -> Grammar:
"""Return a state grammar for the actions on the current Web site.
Can be overloaded in subclasses."""
grammar = extend_grammar(self.GUI_GRAMMAR, grammar)
if state_symbol is None:
state_symbol = self.new_state_symbol(grammar)
grammar[state_symbol] = []
alternatives = []
form = ""
submit = None
for action in self.mine_state_actions():
if action.startswith("submit"):
submit = action
elif action.startswith("click"):
link_target = self.new_state_symbol(grammar)
grammar[link_target] = [self.UNEXPLORED_STATE]
alternatives.append(action + '\n' + link_target)
elif action.startswith("ignore"):
pass
else: # fill(), check() actions
if len(form) > 0:
form += '\n'
form += action
if submit is not None:
if len(form) > 0:
form += '\n'
form += submit
if len(form) > 0:
form_target = self.new_state_symbol(grammar)
grammar[form_target] = [self.UNEXPLORED_STATE]
alternatives.append(form + '\n' + form_target)
alternatives += [self.FINAL_STATE]
grammar[state_symbol] = alternatives
# Remove unused parts
for nonterminal in unreachable_nonterminals(grammar):
del grammar[nonterminal]
assert is_valid_grammar(grammar)
return grammar
为了更好地查看状态结构,函数 fsm_diagram() 将结果状态语法显示为有限状态机。(这假设语法实际上编码了一个状态机。)
from [collections](https://docs.python.org/3/library/collections.html) import deque
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import unicode_escape
def fsm_diagram(grammar: Grammar, start_symbol: str = START_SYMBOL) -> Any:
"""Produce a FSM diagram for the state grammar `grammar`.
`start_symbol` - the start symbol (default: START_SYMBOL)"""
from [graphviz](https://graphviz.readthedocs.io/) import Digraph
from [IPython.display](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html) import display
def left_align(label: str) -> str:
"""Render `label` as left-aligned in dot"""
return dot_escape(label.replace('\n', r'\l')).replace(r'\\l', '\\l')
dot = Digraph(comment="Grammar as Finite State Machine")
symbols = deque([start_symbol])
symbols_seen = set()
while len(symbols) > 0:
symbol = symbols.popleft()
symbols_seen.add(symbol)
dot.node(symbol, dot_escape(unicode_escape(symbol)))
for expansion in grammar[symbol]:
assert type(expansion) == str # no opts() here
nts = nonterminals(expansion)
if len(nts) > 0:
target_symbol = nts[-1]
if target_symbol not in symbols_seen:
symbols.append(target_symbol)
label = expansion.replace(target_symbol, '')
dot.edge(symbol, target_symbol, left_align(unicode_escape(label)))
return display(dot)
```</details>
让我们展示 `GUIGrammarMiner()` 的实际应用。它的方法 `mine_state_grammar()` 提取当前网页的语法:
```py
gui_grammar_miner = GUIGrammarMiner(gui_driver)
state_grammar = gui_grammar_miner.mine_state_grammar()
state_grammar
{'<start>': ['<state>'],
'<unexplored>': [''],
'<end>': [''],
'<text>': ['<string>'],
'<string>': ['<character>', '<string><character>'],
'<character>': ['<letter>', '<digit>', '<special>'],
'<letter>': ['a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z'],
'<number>': ['<digits>'],
'<digits>': ['<digit>', '<digits><digit>'],
'<digit>': ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
'<special>': ['.', ' ', '!'],
'<email>': ['<letters>@<letters>'],
'<letters>': ['<letter>', '<letters><letter>'],
'<boolean>': ['True', 'False'],
'<state>': ["click('terms and conditions')\n<state-1>",
"fill('city', '<text>')\nfill('email', '<email>')\nfill('name', '<text>')\ncheck('terms', <boolean>)\nfill('zip', '<number>')\nsubmit('submit')\n<state-2>",
'<end>'],
'<state-1>': ['<unexplored>'],
'<state-2>': ['<unexplored>']}
为了更好地查看状态语法的结构,我们可以将其可视化为状态机。我们看到它很好地反映了我们可以从我们的 Web 服务器主页看到的内容:
fsm_diagram(state_grammar)
从起始状态 (<state>),我们可以点击“条款和条件”,结束于 <state-1>,或者填写表格,结束于 <state-2>。
state_grammar[GUIGrammarMiner.START_STATE]
["click('terms and conditions')\n<state-1>",
"fill('city', '<text>')\nfill('email', '<email>')\nfill('name', '<text>')\ncheck('terms', <boolean>)\nfill('zip', '<number>')\nsubmit('submit')\n<state-2>",
'<end>']
这两个状态都尚未探索:
state_grammar['<state-1>']
['<unexplored>']
state_grammar['<state-2>']
['<unexplored>']
state_grammar['<unexplored>']
['']
给定语法,我们可以使用我们的任何语法模糊器来创建有效的输入序列:
from GrammarFuzzer import GrammarFuzzer
gui_fuzzer = GrammarFuzzer(state_grammar)
while True:
action = gui_fuzzer.fuzz()
if action.find('submit(') > 0:
break
print(action)
fill('city', '.')
fill('email', 'EB@iYN')
fill('name', '.')
check('terms', True)
fill('zip', '3')
submit('submit')
然而,这些操作还必须被执行,以便我们可以探索用户界面。这就是我们在下一节要做的事情。
执行用户界面操作
为了执行操作,我们引入了一个名为 GUIRunner 的 Runner 类。它的 run() 方法执行在操作字符串中给出的操作。
from Fuzzer import Runner
class GUIRunner(Runner):
"""Execute the actions in a given action string"""
def __init__(self, driver) -> None:
"""Constructor. `driver` is a Selenium Web driver"""
self.driver = driver
实现执行 UI 操作
我们实现 run() 的方式相当简单:我们引入了四个名为 fill()、check()、submit() 和 click() 的方法,并在操作字符串上运行 exec(),以便让 Python 解释器调用这些方法。
在第三方输入上运行exec()是危险的,因为 UI 元素的名称可能包含有效的 Python 代码。我们限制对上面定义的四个函数的访问,并将__builtins__设置为空字典,这样在exec()期间内置的 Python 函数不可用。这将防止意外发生,但正如我们将在信息流章节中看到的那样,仍然可能注入 Python 代码。为了防止此类注入攻击,我们使用html.escape()在所有第三方字符串中引用角度和引号字符。
class GUIRunner(GUIRunner):
def run(self, inp: str) -> Tuple[str, str]:
"""Execute the action string `inp` on the current Web site.
Return a pair (`inp`, `outcome`)."""
def fill(name, value):
self.do_fill(html.unescape(name), html.unescape(value))
def check(name, state):
self.do_check(html.unescape(name), state)
def submit(name):
self.do_submit(html.unescape(name))
def click(name):
self.do_click(html.unescape(name))
exec(inp, {'__builtins__': {}},
{
'fill': fill,
'check': check,
'submit': submit,
'click': click,
})
return inp, self.PASS
要在动作中识别元素,我们首先通过其名称搜索它们,然后通过显示的链接文本搜索。
from [selenium.common.exceptions](https://selenium-python.readthedocs.io/) import NoSuchElementException
from [selenium.common.exceptions](https://selenium-python.readthedocs.io/) import ElementClickInterceptedException, ElementNotInteractableException
class GUIRunner(GUIRunner):
def find_element(self, name: str) -> Any:
"""Search for an element named `name` on the current Web site.
Matches can occur by name or by link text."""
try:
return self.driver.find_element(By.NAME, name)
except NoSuchElementException:
return self.driver.find_element(By.LINK_TEXT, name)
动作的实现只是简单地委托给适当的 Selenium 方法,引入显式延迟,以便页面可以重新加载和刷新。
from [selenium.webdriver.support.ui](https://selenium-python.readthedocs.io/) import WebDriverWait
class GUIRunner(GUIRunner):
# Delays (in seconds)
DELAY_AFTER_FILL = 0.1
DELAY_AFTER_CHECK = 0.1
DELAY_AFTER_SUBMIT = 1.5
DELAY_AFTER_CLICK = 1.5
class GUIRunner(GUIRunner):
def do_fill(self, name: str, value: str) -> None:
"""Fill the text element `name` with `value`"""
element = self.find_element(name)
element.send_keys(value)
WebDriverWait(self.driver, self.DELAY_AFTER_FILL)
class GUIRunner(GUIRunner):
def do_check(self, name: str, state: bool) -> None:
"""Set the check element `name` to `state`"""
element = self.find_element(name)
if bool(state) != bool(element.is_selected()):
element.click()
WebDriverWait(self.driver, self.DELAY_AFTER_CHECK)
class GUIRunner(GUIRunner):
def do_submit(self, name: str) -> None:
"""Click on the submit element `name`"""
element = self.find_element(name)
element.click()
WebDriverWait(self.driver, self.DELAY_AFTER_SUBMIT)
class GUIRunner(GUIRunner):
def do_click(self, name: str) -> None:
"""Click on the element `name`"""
element = self.find_element(name)
element.click()
WebDriverWait(self.driver, self.DELAY_AFTER_CLICK)
```</details>
让我们尝试`GUIRunner`及其`run()`方法。我们在 Web 服务器上创建一个运行器,并让它执行一个`fill()`动作:
```py
gui_driver.get(httpd_url)
gui_runner = GUIRunner(gui_driver)
gui_runner.run("fill('name', 'Walter White')")
("fill('name', 'Walter White')", 'PASS')
Image(gui_driver.get_screenshot_as_png())

submit() 动作提交订单。(注意,我们的 Web 服务器根本不努力验证表单。)
gui_runner.run("submit('submit')")
("submit('submit')", 'PASS')
Image(gui_driver.get_screenshot_as_png())

当然,我们也可以执行从语法生成的动作序列。这允许我们再次填写表单,使用与表单中给出的类型匹配的值。
gui_driver.get(httpd_url)
gui_fuzzer = GrammarFuzzer(state_grammar)
while True:
action = gui_fuzzer.fuzz()
if action.find('submit(') > 0:
break
print(action)
fill('city', 'S0.')
fill('email', 'o@i')
fill('name', 'MF')
check('terms', False)
fill('zip', '7')
submit('submit')
gui_runner.run(action)
("fill('city', 'S0.')\nfill('email', 'o@i')\nfill('name', 'MF')\ncheck('terms', False)\nfill('zip', '7')\nsubmit('submit')\n",
'PASS')
Image(gui_driver.get_screenshot_as_png())

探索用户界面
到目前为止,我们的语法检索和动作执行仅限于当前用户界面状态(即当前显示的页面)。为了系统地探索用户界面,我们必须探索所有状态,特别是以<unexplored>结尾的状态——并且每次我们达到新状态时,再次检索其语法,这样我们才能到达其他状态。由于某些状态只能通过生成输入来访问,因此测试生成和用户界面探索同时进行。
因此,我们引入了一个GUIFuzzer类,它为所有表单生成输入,并跟随所有链接,每次遇到新状态时都会更新其语法(即其作为有限状态机的用户界面模型)。
实现 GUIFuzzer
同时探索状态和更新语法是一个相当复杂的操作,因此在我们能够使用它之前,我们需要引入相当多的方法。GUIFuzzer构造函数设置了三个重要的属性:
-
state_symbol:这保存了当前状态的符号(例如<state-1>)。 -
state:这保存了当前状态的动作集合,由GUIGrammarMiner方法的mine_state_actions()返回。 -
states_seen:这将看到的(如state所示)状态映射到相应的符号。
让我们在初始化后展示这三个属性。
from Grammars import is_nonterminal
from GrammarFuzzer import GrammarFuzzer
class GUIFuzzer(GrammarFuzzer):
"""A fuzzer for GUIs, using Selenium."""
def __init__(self, driver, *,
miner: Optional[GUIGrammarMiner] = None,
stay_on_host: bool = True,
log_gui_exploration: bool = False,
disp_gui_exploration: bool = False,
**kwargs) -> None:
"""Constructor.
`driver` - the Selenium driver to use.
`miner` - the miner to use (default: `GUIGrammarMiner(driver)`)
`stay_on_host` - if True (default), do not explore external links.
`log_gui_exploration` - if set, print out exploration steps.
`disp_gui_exploration` - if set, display screenshot of current Web page
as well as FSM diagrams during exploration.
Other keyword arguments are passed to the `GrammarFuzzer` superclass.
"""
self.driver = driver
if miner is None:
miner = GUIGrammarMiner(driver)
self.miner = miner
self.stay_on_host = True
self.log_gui_exploration = log_gui_exploration
self.disp_gui_exploration = disp_gui_exploration
self.initial_url = driver.current_url
self.states_seen = {} # Maps states to symbols
self.state_symbol = self.miner.START_STATE
self.state: FrozenSet[str] = self.miner.mine_state_actions()
self.states_seen[self.state] = self.state_symbol
grammar = self.miner.mine_state_grammar()
super().__init__(grammar, **kwargs)
gui_driver.get(httpd_url)
初始状态符号始终是<state>:
gui_fuzzer = GUIFuzzer(gui_driver)
gui_fuzzer.state_symbol
'<state>'
当前状态由可用的 UI 动作来表征:
gui_fuzzer.state
frozenset({"check('terms', <boolean>)",
"click('terms and conditions')",
"fill('city', '<text>')",
"fill('email', '<email>')",
"fill('name', '<text>')",
"fill('zip', '<number>')",
"submit('submit')"})
states_seen 将此状态映射到其符号:
gui_fuzzer.states_seen[gui_fuzzer.state]
'<state>'
restart()方法将我们带回到初始 URL 并重置状态。这是我们每次新探索时使用的。
class GUIFuzzer(GUIFuzzer):
def restart(self) -> None:
"""Get back to original URL"""
self.driver.get(self.initial_url)
self.state = frozenset(self.miner.START_STATE)
当从语法中生成一系列动作时,我们想知道我们将处于哪个最终状态。我们可以从生成的推导树中检索这个路径——它是最后被扩展的符号。
while True:
action = gui_fuzzer.fuzz()
if action.find('click(') >= 0:
break
from GrammarFuzzer import display_tree, DerivationTree
tree = gui_fuzzer.derivation_tree
display_tree(tree)
class GUIFuzzer(GUIFuzzer):
def fsm_path(self, tree: DerivationTree) -> List[str]:
"""Return sequence of state symbols."""
(node, children) = tree
if node == self.miner.UNEXPLORED_STATE:
return []
elif children is None or len(children) == 0:
return [node]
else:
return [node] + self.fsm_path(children[-1])
这是有限状态机中通向“模糊”状态的路径:
gui_fuzzer = GUIFuzzer(gui_driver)
gui_fuzzer.fsm_path(tree)
['<start>', '<state>', '<state-1>']
这是它的最后一个元素:
class GUIFuzzer(GUIFuzzer):
def fsm_last_state_symbol(self, tree: DerivationTree) -> str:
"""Return current (expected) state symbol"""
for state in reversed(self.fsm_path(tree)):
if is_nonterminal(state):
return state
assert False
gui_fuzzer = GUIFuzzer(gui_driver)
gui_fuzzer.fsm_last_state_symbol(tree)
'<state-1>'
当我们运行(run())fuzzer 时,我们创建一个动作(通过fuzz()),检索并更新我们在运行此动作后应该处于的状态符号(state_symbol)。在实际上在给定的GUIRunner中运行动作后,我们检索并更新当前状态,使用update_state()。
class GUIFuzzer(GUIFuzzer):
def run(self, runner: GUIRunner) -> Tuple[str, str]:
"""Run the fuzzer on the given GUIRunner `runner`."""
assert isinstance(runner, GUIRunner)
self.restart()
action = self.fuzz()
self.state_symbol = self.fsm_last_state_symbol(self.derivation_tree)
if self.log_gui_exploration:
print("Action", action.strip(), "->", self.state_symbol)
result, outcome = runner.run(action)
if self.state_symbol != self.miner.FINAL_STATE:
self.update_state()
return self.state_symbol, outcome
当更新当前状态时,我们检查我们是否处于一个新状态或之前见过的状态,并分别调用update_new_state()或update_existing_state()。
class GUIFuzzer(GUIFuzzer):
def update_state(self) -> None:
"""Determine current state from current Web page"""
if self.disp_gui_exploration:
display(Image(self.driver.get_screenshot_as_png()))
self.state = self.miner.mine_state_actions()
if self.state not in self.states_seen:
self.states_seen[self.state] = self.state_symbol
self.update_new_state()
else:
self.update_existing_state()
找到新状态意味着我们为这个新找到的状态挖掘一个新的语法,并用它更新我们的现有语法。
class GUIFuzzer(GUIFuzzer):
def set_grammar(self, new_grammar: Grammar) -> None:
"""Set grammar to `new_grammar`."""
self.grammar = new_grammar
if self.disp_gui_exploration and rich_output():
display(fsm_diagram(self.grammar))
class GUIFuzzer(GUIFuzzer):
def update_new_state(self) -> None:
"""Found new state; extend grammar accordingly"""
if self.log_gui_exploration:
print("In new state", unicode_escape(self.state_symbol),
unicode_escape(repr(self.state)))
state_grammar = self.miner.mine_state_grammar(grammar=self.grammar,
state_symbol=self.state_symbol)
del state_grammar[START_SYMBOL]
del state_grammar[self.miner.START_STATE]
self.set_grammar(extend_grammar(self.grammar, state_grammar))
def update_existing_state(self) -> None:
pass # See below
如果我们找到一个现有状态,我们需要合并这两个状态。例如,如果我们发现我们处于现有的<state-1>而不是预期的<state-3>,我们将语法中所有<state-3>的实例替换为<state-1>。replace_symbol()方法负责重命名;update_existing_state()相应地设置语法。
from Grammars import exp_string, exp_opts
def replace_symbol(grammar: Grammar,
old_symbol: str, new_symbol: str) -> Grammar:
"""Return a grammar in which all occurrences of `old_symbol` are replaced by `new_symbol`"""
new_grammar: Grammar = {}
for symbol in grammar:
new_expansions = []
for expansion in grammar[symbol]:
new_expansion_string = exp_string(expansion).replace(old_symbol, new_symbol)
if len(exp_opts(expansion)) > 0:
new_expansion = (new_expansion_string, exp_opts(expansion))
else:
new_expansion = new_expansion_string
new_expansions.append(new_expansion)
new_grammar[symbol] = new_expansions
# Remove unused parts
for nonterminal in unreachable_nonterminals(new_grammar):
del new_grammar[nonterminal]
return new_grammar
class GUIFuzzer(GUIFuzzer):
def update_existing_state(self) -> None:
"""Update actions of existing state"""
if self.log_gui_exploration:
print("In existing state", self.states_seen[self.state])
if self.state_symbol != self.states_seen[self.state]:
if self.log_gui_exploration:
print("Replacing expected state %s by %s" %
(self.state_symbol, self.states_seen[self.state]))
new_grammar = replace_symbol(self.grammar, self.state_symbol,
self.states_seen[self.state])
self.state_symbol = self.states_seen[self.state]
self.set_grammar(new_grammar)
这就结束了我们对GUIFuzzer的定义。
让我们使用GUIFuzzer,启用其日志机制来查看它在做什么。
gui_driver.get(httpd_url)
gui_fuzzer = GUIFuzzer(gui_driver, log_gui_exploration=True, disp_gui_exploration=True)
第一次运行它会产生一个新的状态:
gui_fuzzer.run(gui_runner)
Action click('terms and conditions') -> <state-1>

In new state <state-1> frozenset({"ignore('Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.')", "click('order form')"})
None
('<state-1>', 'PASS')
下一个动作填写订单表单。
gui_fuzzer.run(gui_runner)
Action click('terms and conditions') -> <end>
('<end>', 'PASS')
gui_fuzzer.run(gui_runner)
Action click('terms and conditions') -> <end>
('<end>', 'PASS')
到目前为止,我们的 GUI 模型已经相当完整了。为了系统地覆盖所有状态,随机探索并不足够高效。
覆盖状态
在探索以及测试期间,我们希望覆盖所有状态以及状态之间的转换。我们如何实现这一点?
结果表明我们已经有这个了。我们的GrammarCoverageFuzzer来自基于覆盖的语法测试章节,它努力系统地覆盖语法中的所有扩展选项。在有限状态模型中,这些扩展选项转化为状态之间的转换。因此,将GrammarCoverageFuzzer的覆盖策略应用到我们的状态语法中会自动覆盖一个转换接着另一个。
我们如何将这些特性应用到GUIFuzzer中?使用多重继承,我们可以创建一个GUICoverageFuzzer类,它结合了GUIFuzzer的run()方法和GrammarCoverageFuzzer的覆盖选择。
from GrammarCoverageFuzzer import GrammarCoverageFuzzer
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import inheritance_conflicts
由于__init__()构造函数在两个超类中都已定义,我们需要定义自己的构造函数,使其同时服务:
inheritance_conflicts(GUIFuzzer, GrammarCoverageFuzzer)
['__init__']
class GUICoverageFuzzer(GUIFuzzer, GrammarCoverageFuzzer):
"""Systematically explore all states of the current Web page"""
def __init__(self, *args, **kwargs):
"""Constructor. All args are passed to the `GUIFuzzer` superclass."""
GUIFuzzer.__init__(self, *args, **kwargs)
self.reset_coverage()
使用GUICoverageFuzzer,我们可以设置一个explore_all()方法,它会持续运行 fuzzer,直到没有未探索的状态为止:
class GUICoverageFuzzer(GUICoverageFuzzer):
def explore_all(self, runner: GUIRunner, max_actions=100) -> None:
"""Explore all states of the GUI, up to `max_actions` (default 100)."""
actions = 0
while (self.miner.UNEXPLORED_STATE in self.grammar and
actions < max_actions):
actions += 1
if self.log_gui_exploration:
print("Run #" + repr(actions))
try:
self.run(runner)
except ElementClickInterceptedException:
pass
except ElementNotInteractableException:
pass
except NoSuchElementException:
pass
让我们利用这个来全面探索我们的 Web 服务器:
gui_driver.get(httpd_url)
gui_fuzzer = GUICoverageFuzzer(gui_driver)
gui_fuzzer.explore_all(gui_runner)
成功!我们已经覆盖了所有状态:
fsm_diagram(gui_fuzzer.grammar)
我们可以检索到目前为止覆盖的扩展,当然这覆盖了所有状态。
gui_fuzzer.covered_expansions
{'<boolean> -> False',
'<boolean> -> True',
'<character> -> <digit>',
'<character> -> <letter>',
'<character> -> <special>',
'<digit> -> 0',
'<digit> -> 1',
'<digit> -> 2',
'<digit> -> 3',
'<digit> -> 4',
'<digit> -> 5',
'<digit> -> 6',
'<digit> -> 7',
'<digit> -> 8',
'<digit> -> 9',
'<digits> -> <digit>',
'<digits> -> <digits><digit>',
'<email> -> <letters>@<letters>',
'<end> -> ',
'<letter> -> A',
'<letter> -> B',
'<letter> -> D',
'<letter> -> H',
'<letter> -> J',
'<letter> -> K',
'<letter> -> L',
'<letter> -> M',
'<letter> -> P',
'<letter> -> Q',
'<letter> -> W',
'<letter> -> Y',
'<letter> -> b',
'<letter> -> g',
'<letter> -> h',
'<letter> -> l',
'<letter> -> m',
'<letter> -> n',
'<letter> -> q',
'<letter> -> t',
'<letter> -> v',
'<letter> -> y',
'<letters> -> <letter>',
'<letters> -> <letters><letter>',
'<number> -> <digits>',
'<special> -> .',
'<start> -> <state>',
'<state-1> -> <end>',
'<state-1> -> <unexplored>',
"<state-1> -> click('order form')\n<state-3>",
'<state-2> -> <end>',
'<state-2> -> <unexplored>',
"<state-2> -> click('order form')\n<state-4>",
'<state-3> -> <unexplored>',
'<state-4> -> <unexplored>',
'<state> -> <end>',
"<state> -> click('terms and conditions')\n<state-1>",
"<state> -> fill('city', '<text>')\nfill('email', '<email>')\nfill('name', '<text>')\ncheck('terms', <boolean>)\nfill('zip', '<number>')\nsubmit('submit')\n<state-2>",
'<string> -> <character>',
'<string> -> <string><character>',
'<text> -> <string>',
'<unexplored> -> '}
尽管如此,我们还没有看到所有覆盖的扩展。一些数字和字母尚未使用。
gui_fuzzer.missing_expansion_coverage()
{'<letter> -> C',
'<letter> -> E',
'<letter> -> F',
'<letter> -> G',
'<letter> -> I',
'<letter> -> N',
'<letter> -> O',
'<letter> -> R',
'<letter> -> S',
'<letter> -> T',
'<letter> -> U',
'<letter> -> V',
'<letter> -> X',
'<letter> -> Z',
'<letter> -> a',
'<letter> -> c',
'<letter> -> d',
'<letter> -> e',
'<letter> -> f',
'<letter> -> i',
'<letter> -> j',
'<letter> -> k',
'<letter> -> o',
'<letter> -> p',
'<letter> -> r',
'<letter> -> s',
'<letter> -> u',
'<letter> -> w',
'<letter> -> x',
'<letter> -> z',
'<special> -> ',
'<special> -> !',
"<state-1> -> click('order form')\n<state>",
"<state-2> -> click('order form')\n<state>"}
重复运行 fuzzer 最终也会覆盖这些扩展,导致订单表单中的字母和数字覆盖率。
探索大型网站
我们的 GUI fuzzer 足够健壮,可以处理探索非平凡网站,例如fuzzingbook.org。让我们演示这一点:
gui_driver.get("https://www.fuzzingbook.org/html/Fuzzer.html")
Image(gui_driver.get_screenshot_as_png())

book_runner = GUIRunner(gui_driver)
book_fuzzer = GUICoverageFuzzer(gui_driver, log_gui_exploration=True) # , disp_gui_exploration=True)
我们探索了该网站的前几个状态,这些状态在ACTIONS中定义:
ACTIONS = 5
book_fuzzer.explore_all(book_runner, max_actions=ACTIONS)
Run #1
Action click('discussed above') -> <state-7>
In existing state <state>
Replacing expected state <state-7> by <state>
Run #2
Action click('use the code provided in this chapter') -> <state-11>
In new state <state-11> frozenset({"ignore('installation instructions')", "click('the chapter on fuzzers')", "click('')", "click('Fuzzer')", "ignore('official instructions')", "click('Cite')", "ignore('apt.txt file in the binder/ folder')", "ignore('Last change: 2023-11-11 18:18:06+01:00')", "ignore('Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License')", "ignore('Pipenv')", "ignore('')", "ignore('bookutils')", "click('fuzzingbook.Fuzzer')", "click('The Fuzzing Book')", "ignore('requirements.txt file within the project root folder')", "ignore('MIT License')", "ignore('the project page')", "ignore('Imprint')", "ignore('pyenv-win')", "ignore('bookutils.setup')", "click('fuzzingbook.')"})
Run #3
Action click('Intro_Testing') -> <state-10>
In existing state <state>
Replacing expected state <state-10> by <state>
Run #4
Action click('chapter on mining function specifications') -> <state-5>
In new state <state-5> frozenset({"click('ExpectError')", "ignore('Last change: 2024-11-09 17:07:29+01:00')", "ignore('itertools')", "ignore('showast')", "ignore('curated list')", "click('symbolic fuzzing')", "click('Cite')", "ignore('tempfile')", "click('part on semantic fuzzing techniques')", "ignore('Ernst et al, 2001')", "ignore('Use the notebook')", "click('symbolic')", "ignore('MonkeyType')", "ignore('DAIKON dynamic invariant detector')", "click('introduction to testing')", "click('Grammars')", "click('The Fuzzing Book')", "click('GrammarFuzzer')", "click('symbolic interpretation')", "click('domain-specific fuzzing techniques')", "click('Intro_Testing')", "ignore('subprocess')", "click('fuzzingbook.DynamicInvariants')", "click('our chapter with the same name')", "click('concolic fuzzer')", "click('concolic')", "click('chapter on testing')", "ignore('code snippet from StackOverflow')", "ignore('Pacheco et al, 2005')", "ignore('sys')", "ignore('ast')", "ignore('"The state of type hints in Python"')", "click('chapter on coverage')", "ignore('functools')", "ignore('Ammons et al, 2002')", "ignore('Mypy')", "click('the next part')", "ignore('')", "ignore('Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License')", "click('chapter on information flow')", "ignore('typing')", "ignore('bookutils.setup')", "ignore('bookutils')", "ignore('MIT License')", "ignore('PyAnnotate')", "ignore('Imprint')", "click('')", "ignore('inspect')", "click('use the code provided in this chapter')", "click('Coverage')"})
Run #5
Action click('chapter on testing') -> <state-14>
In new state <state-14> frozenset({"click('ExpectError')", "check('11b29b38-9eb5-11ef-9f1d-6298cf1a578f', <boolean>)", "ignore('Beizer et al, 1990')", "click('Timer')", "click('Cite')", "click('Timer module')", "check('1251ba2e-9eb5-11ef-9f1d-6298cf1a578f', <boolean>)", "ignore('random')", "ignore('Use the notebook')", "click('use fuzzing to test programs with random inputs')", "ignore('Shellsort')", "click('The Fuzzing Book')", "click('import it')", "ignore('Newton\xe2\x80\x93Raphson method')", "click('00_Table_of_Contents.ipynb')", "ignore('Python tutorial')", "submit('')", "ignore('math.isclose()')", "click('Web Page')", "ignore('Pezz\xc3\xa8 et al, 2008')", "ignore('"Effective Software Testing: A Developer's Guide"')", "click('Background')", "ignore('Maur\xc3\xadcio Aniche, 2022')", "ignore('Last change: 2023-11-11 18:18:06+01:00')", "check('119cfd32-9eb5-11ef-9f1d-6298cf1a578f', <boolean>)", "ignore('Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License')", "ignore('')", "ignore('bookutils')", "ignore('bookutils.setup')", "ignore('MIT License')", "ignore('Myers et al, 2004')", "ignore('Imprint')", "click('')", "click('Guide for Authors')"})
在执行了第一个ACTIONS动作之后,我们可以看到有限状态模型相当复杂,仍有数十个转换等待探索。大多数尚未探索的状态最终将与现有状态合并,每个章节对应一个状态。尽管如此,遍历所有页面上的所有链接仍需要相当长的时间。
# Inspect this graph in the notebook to see it in full glory
fsm_diagram(book_fuzzer.grammar)
我们现在拥有了所有需要的基礎能力:我们可以自动探索大型网站;我们可以通过填写表单来探索“深层”功能;并且我们可以让基于覆盖率的 fuzzer 自动关注尚未探索的状态。尽管如此,还有更多的事情可以做;练习将给你一些想法。
gui_driver.quit()
经验教训
-
Selenium是一个用于与用户界面交互的强大框架,特别是 Web 用户界面。
-
一个有限状态模型可以编码用户界面状态和转换。
-
将用户界面模型编码到语法中,整合了生成文本(用于表单)和生成用户交互(用于导航)。
-
要系统地探索用户界面,覆盖所有状态转换,这相当于覆盖等价语法中的所有扩展选择。
我们已经完成了,所以我们需要清理。我们关闭了 Web 服务器,退出了 Web 驱动程序(以及相关的浏览器),并最终清理了 Selenium 留下的临时文件。
httpd_process.terminate()
gui_driver.quit()
import [os](https://docs.python.org/3/library/os.html)
for temp_file in [ORDERS_DB, "geckodriver.log", "ghostdriver.log"]:
if os.path.exists(temp_file):
os.remove(temp_file)
下一步
从这里,你可以学习如何
- 在大规模中进行模糊测试。在同一系统上运行无数 fuzzer
背景
图形用户界面的自动测试是一个丰富的领域——无论是在研究还是在实践中。
GUI 的覆盖率标准以及如何实现它们首先在[Memon 等人,2001]中讨论。Memon 还介绍了GUI Ripping的概念[Memon 等人,2003]——这是一个软件 GUI 通过与其所有用户界面元素交互而自动遍历的过程。
CrawlJax 工具[Mesbah 等人,2012]使用 Web 用户界面的动态状态变化来识别候选交互元素。与我们的方法类似,它使用可交互的用户界面元素集合作为有限状态模型中的一个状态。
Alex 框架 使用类似的方法来学习 Web 应用程序的自动机。从一个测试输入集合开始,它生成一个应用程序的混合模式行为模型。
练习
尽管我们的 GUI 模糊器目前功能强大,但仍有一些可能性可以进一步优化和扩展。以下是一些启动想法。享受用户界面模糊测试!
练习 1:保持本地状态
而不是让每个 run() 都从非常开始,让挖掘器从当前状态开始,并探索从那里可达的状态。
练习 2:返回
利用网络驱动程序的 back() 方法回到早期状态,然后我们可以再次开始探索。(注意,在非 Web 用户界面中可能没有“后退”功能。)
练习 3:避免不良表单值
检测某些表单值是 无效的,这样挖掘器就不会再次生成它们。
练习 4:保存表单值
保存 成功的 表单值,这样测试者就不必一次又一次地推断它们。
练习 5:相同名称,相同状态
当挖掘器找到一个它已经见过的名称的链接时,它很可能也会导致一个已经见过的状态;因此,可以降低其探索的优先级。
练习 6:组合覆盖
扩展语法挖掘器,以便对于每个布尔值,都有一个单独的值需要覆盖。
练习 7:隐式延迟
而不是使用 显式(给定)的延迟,使用 隐式 延迟并等待特定元素出现。这些元素可能来自对状态的先前探索。
练习 8:预言机
扩展语法挖掘器,使其也能生成 预言机 —— 例如,检查特定 UI 元素的存在。
练习 9:更多 UI 元素
在你选择的网站上运行挖掘器。找出还需要支持哪些其他类型的用户界面元素和操作。
本项目的内容根据 Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议 许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,根据 MIT 许可协议 许可。 最后更改:2024-01-31 17:32:50+01:00 • 引用 • 印记
如何引用这项工作
安德烈亚斯·策勒,拉胡尔·戈皮纳特,马塞尔·博 hme,戈登·弗莱泽,以及克里斯蒂安·霍勒:"测试图形用户界面"。收录于安德烈亚斯·策勒,拉胡尔·戈皮纳特,马塞尔·博 hme,戈登·弗莱泽,以及克里斯蒂安·霍勒所著的"模糊测试书籍"中。www.fuzzingbook.org/html/GUIFuzzer.html。检索时间:2024-01-31 17:32:50+01:00.
@incollection{fuzzingbook2024:GUIFuzzer,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Testing Graphical User Interfaces},
year = {2024},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/GUIFuzzer.html}},
note = {Retrieved 2024-01-31 17:32:50+01:00},
url = {https://www.fuzzingbook.org/html/GUIFuzzer.html},
urldate = {2024-01-31 17:32:50+01:00}
}
第六部分:管理模糊测试
本部分讨论了如何管理大规模的模糊测试。
-
大规模模糊测试 讨论了如何创建用于模糊测试的大规模基础设施,运行数百万次测试并管理其结果。
-
何时停止模糊测试 详细说明了如何估计何时足够的模糊测试已经足够——以及何时可以让你的计算机处理其他任务。
本项目的内容受 Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议 的许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,受 MIT 许可协议 的许可。 最后修改:2020-10-13 15:12:25+02: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/06_Managing_Fuzzing.html. Retrieved 2020-10-13 15:12:25+02:00.
@incollection{fuzzingbook2020:06_Managing_Fuzzing,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Part VI: Managing Fuzzing},
year = {2020},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/06_Managing_Fuzzing.html}},
note = {Retrieved 2020-10-13 15:12:25+02:00},
url = {https://www.fuzzingbook.org/html/06_Managing_Fuzzing.html},
urldate = {2020-10-13 15:12:25+02:00}
}
大规模模糊测试
在过去的章节中,我们总是关注仅在一台机器上持续几秒钟的模糊测试。然而,在现实世界中,模糊器在数十台甚至数千台机器上运行;持续数小时、数天甚至数周;针对一个程序或数十个程序。在这种情况下,需要一种基础设施来从单个模糊器运行中收集失败数据,并在中央存储库中聚合这些数据。在本章中,我们将检查这样的基础设施,即 Mozilla 的FuzzManager框架。
先决条件
-
本章需要基本的测试知识,例如来自测试简介。
-
本章需要基本的模糊器分叉知识,例如来自模糊测试简介。
import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils)
import Fuzzer
摘要
要使用本章提供的代码(导入),请编写
>>> from fuzzingbook.FuzzingInTheLarge import <identifier>
然后利用以下功能。
Python FuzzManager 包允许从大量(模糊测试的)程序中程序性地提交失败。可以查询崩溃及其详细信息,将它们收集到桶中以确保它们将得到相同的处理,还可以检索程序及其测试的覆盖率信息以进行调试。
从多个模糊器收集崩溃
到目前为止,我们所有的模糊测试场景都只有一个模糊器在一台机器上测试一个程序。失败会立即显示,并由启动模糊器的人快速诊断。然而,现实世界的测试是不同的。模糊测试仍然是完全自动化的;但现在,我们谈论的是多个模糊器在多台机器上测试多个程序(及其版本),产生多个必须由多个人处理的失败。这引发了如何管理所有这些活动和它们之间相互作用的问题。
协调多个模糊器的一种常见方法是拥有一个中央存储库,该存储库收集所有崩溃及其崩溃信息。每当模糊器检测到失败时,它通过网络连接到崩溃服务器,然后在该数据库中存储崩溃信息。
结果的崩溃数据库可以查询以找出哪些失败已发生——通常使用 Web 界面。它还可以与其他流程活动集成。最重要的是,崩溃数据库中的条目可以链接到错误数据库,反之亦然,这样错误(=崩溃)可以分配给个别开发者。
在这样的基础设施中,收集崩溃不仅限于模糊器。野外发生的崩溃和故障也可以自动报告给崩溃服务器。在工业界,拥有收集生产运行中数千个崩溃的崩溃数据库并不罕见——特别是如果涉及的软件每天被数百万人所使用。
这样的数据库中存储了哪些信息?
-
最重要的是产品的标识符——即产品名称、版本信息以及平台和操作系统。没有这些信息,开发者无法判断该错误是否仍然存在于最新版本中,或者它是否已经被修复。
-
对于调试,对开发者最有帮助的信息是重现步骤——在模糊测试场景中,这将是对相关程序的输入。(在生产场景中,出于明显的隐私原因,不会收集用户的输入。)
-
对于调试来说,第二有用的工具是堆栈跟踪,这样开发者可以检查在失败时刻哪些内部功能正在运行。一个覆盖率图也同样有用,因为开发者可以查询哪些函数被执行了,哪些没有。
-
如果收集了通用故障,开发者还需要知道预期的行为是什么;对于崩溃,这很简单,因为用户不会期望他们的软件崩溃。
如果模糊器(或相关程序)相应设置,所有这些信息都可以自动收集。
在本章中,我们将探讨一个自动化所有这些步骤的平台。FuzzManager 平台允许
-
收集失败运行中的失败数据,
-
将此数据输入到集中式服务器中,并
-
通过 Web 界面查询服务器。
在本章中,我们将展示如何使用 FuzzManager 进行基本步骤,包括崩溃提交和分类以及覆盖率测量任务。
运行崩溃服务器
FuzzManager 是一个用于管理大规模模糊测试过程的工具链。它在某种程度上是模块化的,这意味着你可以使用你需要的部分;它在某种程度上是灵活的,因为它不强制特定的流程。它包括一个服务器,其任务是收集崩溃数据,以及各种收集工具,它们收集崩溃数据并发送到服务器。
设置服务器
要运行这个笔记本中的示例,我们需要运行一个崩溃服务器——也就是说,FuzzManager 服务器。你可以
-
运行你自己的服务器。为此,你需要遵循FuzzManager 页面上“服务器设置”部分列出的安装步骤。
FuzzManager文件夹应该与这个笔记本在同一文件夹中。 -
让笔记本启动(并停止)服务器。以下命令会自动执行这些操作。尽管这些命令仅适用于这个笔记本,但如果你想要在自己的服务器上进行实验,请手动运行,如上所述。
import [os](https://docs.python.org/3/library/os.html)
import [sys](https://docs.python.org/3/library/sys.html)
import [shutil](https://docs.python.org/3/library/shutil.html)
我们首先从仓库获取最新的服务器代码。
if os.path.exists('FuzzManager'):
shutil.rmtree('FuzzManager')
基础仓库是 https://github.com/MozillaSecurity/FuzzManager,但我们使用 uds-se 仓库,因为这个仓库有 FuzzManager 的 0.4.1 稳定版本。
!git clone https://github.com/uds-se/FuzzManager
Cloning into 'FuzzManager'...
remote: Enumerating objects: 11755, done.
remote: Counting objects: 100% (11755/11755), done.
remote: Compressing objects: 100% (3726/3726), done.
remote: Total 11755 (delta 7943), reused 11674 (delta 7862), pack-reused 0 (from 0)
Receiving objects: 100% (11755/11755), 5.33 MiB | 9.57 MiB/s, done.
Resolving deltas: 100% (7943/7943), done.
WARNING: Ignoring version 4.2.2 of celery since it has invalid metadata:
Requested celery<4.3,>=4.1.1 from https://files.pythonhosted.org/packages/24/e9/9741a5a8b83253e27293e77bd4319c84306019dfbfa4cc43fa250243c12f/celery-4.2.2-py2.py3-none-any.whl (from -r FuzzManager/server/requirements.txt (line 9)) has invalid metadata: Expected matching RIGHT_PARENTHESIS for LEFT_PARENTHESIS, after version specifier
pytz (>dev)
~^
Please use pip<24.1 if you need to use this version. WARNING: Ignoring version 4.2.1 of celery since it has invalid metadata:
Requested celery<4.3,>=4.1.1 from https://files.pythonhosted.org/packages/e8/58/2a0b1067ab2c12131b5c089dfc579467c76402475c5231095e36a43b749c/celery-4.2.1-py2.py3-none-any.whl (from -r FuzzManager/server/requirements.txt (line 9)) has invalid metadata: Expected matching RIGHT_PARENTHESIS for LEFT_PARENTHESIS, after version specifier
pytz (>dev)
~^
Please use pip<24.1 if you need to use this version. WARNING: Ignoring version 4.2.0 of celery since it has invalid metadata:
Requested celery<4.3,>=4.1.1 from https://files.pythonhosted.org/packages/ea/75/d7d1eaeb6c90c7442f7b96242a6d4ebcf1cf075f9c51957d061fb8264d24/celery-4.2.0-py2.py3-none-any.whl (from -r FuzzManager/server/requirements.txt (line 9)) has invalid metadata: Expected matching RIGHT_PARENTHESIS for LEFT_PARENTHESIS, after version specifier
pytz (>dev)
~^
Please use pip<24.1 if you need to use this version. WARNING: Ignoring version 4.1.1 of celery since it has invalid metadata:
Requested celery<4.3,>=4.1.1 from https://files.pythonhosted.org/packages/99/fa/4049b26bfe71992ecf979acd39b87e55b493608613054089d975418015b7/celery-4.1.1-py2.py3-none-any.whl (from -r FuzzManager/server/requirements.txt (line 9)) has invalid metadata: Expected matching RIGHT_PARENTHESIS for LEFT_PARENTHESIS, after version specifier
pytz (>dev)
~^
Please use pip<24.1 if you need to use this version. ERROR: Ignored the following yanked versions: 5.0.6, 5.2.5 ERROR: Could not find a version that satisfies the requirement celery<4.3,>=4.1.1 (from versions: 0.1.2, 0.1.4, 0.1.6, 0.1.7, 0.1.8, 0.1.10, 0.1.11, 0.1.12, 0.1.13, 0.1.14, 0.1.15, 0.2.0, 0.3.0, 0.3.7, 0.3.20, 0.4.0, 0.4.1, 0.6.0, 0.8.0, 0.8.1, 0.8.2, 0.8.3, 0.8.4, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.0.5, 1.0.6, 2.0.0, 2.0.1, 2.0.2, 2.0.3, 2.1.0, 2.1.1, 2.1.2, 2.1.3, 2.1.4, 2.2.0, 2.2.1, 2.2.2, 2.2.3, 2.2.4, 2.2.5, 2.2.6, 2.2.7, 2.2.8, 2.2.9, 2.2.10, 2.3.0, 2.3.1, 2.3.2, 2.3.3, 2.3.4, 2.3.5, 2.4.0, 2.4.1, 2.4.2, 2.4.3, 2.4.4, 2.4.5, 2.4.6, 2.4.7, 2.5.0, 2.5.1, 2.5.2, 2.5.3, 2.5.5, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 3.0.5, 3.0.6, 3.0.7, 3.0.8, 3.0.9, 3.0.10, 3.0.11, 3.0.12, 3.0.13, 3.0.14, 3.0.15, 3.0.16, 3.0.17, 3.0.18, 3.0.19, 3.0.20, 3.0.21, 3.0.22, 3.0.23, 3.0.24, 3.0.25, 3.1.0, 3.1.1, 3.1.2, 3.1.3, 3.1.4, 3.1.5, 3.1.6, 3.1.7, 3.1.8, 3.1.9, 3.1.10, 3.1.11, 3.1.12, 3.1.13, 3.1.14, 3.1.15, 3.1.16, 3.1.17, 3.1.18, 3.1.19, 3.1.20, 3.1.21, 3.1.22, 3.1.23, 3.1.24, 3.1.25, 3.1.26.post1, 3.1.26.post2, 4.0.0rc3, 4.0.0rc4, 4.0.0rc5, 4.0.0rc6, 4.0.0rc7, 4.0.0, 4.0.1, 4.0.2, 4.1.0, 4.1.1, 4.2.0rc1, 4.2.0rc2, 4.2.0rc3, 4.2.0rc4, 4.2.0, 4.2.1, 4.2.2, 4.3.0rc1, 4.3.0rc2, 4.3.0rc3, 4.3.0, 4.3.1, 4.4.0rc1, 4.4.0rc2, 4.4.0rc3, 4.4.0rc4, 4.4.0rc5, 4.4.0, 4.4.1, 4.4.2, 4.4.3, 4.4.4, 4.4.5, 4.4.6, 4.4.7, 5.0.0a1, 5.0.0a2, 5.0.0b1, 5.0.0rc1, 5.0.0rc2, 5.0.0rc3, 5.0.0, 5.0.1, 5.0.2, 5.0.3, 5.0.4, 5.0.5, 5.1.0b1, 5.1.0b2, 5.1.0rc1, 5.1.0, 5.1.1, 5.1.2, 5.2.0b1, 5.2.0b2, 5.2.0b3, 5.2.0rc1, 5.2.0rc2, 5.2.0, 5.2.1, 5.2.2, 5.2.3, 5.2.4, 5.2.6, 5.2.7, 5.3.0a1, 5.3.0b1, 5.3.0b2, 5.3.0rc1, 5.3.0rc2, 5.3.0, 5.3.1, 5.3.4, 5.3.5, 5.3.6, 5.4.0rc1, 5.4.0rc2, 5.4.0, 5.5.0b1, 5.5.0b2, 5.5.0b3, 5.5.0b4, 5.5.0rc1) ERROR: No matching distribution found for celery<4.3,>=4.1.1
!cd FuzzManager; {sys.executable} server/manage.py migrate > /dev/null
我们使用这个实用的技巧创建了一个名为demo的用户,密码也是demo。
!(cd FuzzManager; echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('demo', 'demo@fuzzingbook.org', 'demo')" | {sys.executable} server/manage.py shell)
我们为这个用户创建了一个令牌。这个令牌将后来被用于自动命令进行身份验证。
import [subprocess](https://docs.python.org/3/library/subprocess.html)
import [sys](https://docs.python.org/3/library/sys.html)
os.chdir('FuzzManager')
result = subprocess.run(['python',
'server/manage.py',
'get_auth_token',
'demo'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
os.chdir('..')
err = result.stderr.decode('ascii')
if len(err) > 0:
print(err, file=sys.stderr, end="")
token = result.stdout
token = token.decode('ascii').strip()
token
'5371d2409244c4a7ad22874ffab40c9c4d2b4883'
令牌存储在我们主目录下的~/.fuzzmanagerconf中。这是完整的配置:
[Main]
sigdir = /home/example/fuzzingbook
serverhost = 127.0.0.1
serverport = 8000
serverproto = http
serverauthtoken = 5371d2409244c4a7ad22874ffab40c9c4d2b4883
tool = fuzzingbook
``` <details> <details id="Excursion:-Starting-the-Server"><summary>启动服务器</summary>
服务器设置好后,我们可以启动它。在命令行中,我们使用
```py
$ cd FuzzManager; python server/manage.py runserver
在我们的笔记本中,我们可以使用为模糊测试 Web 服务器引入的Process框架来程序化地完成这个操作。我们让 FuzzManager 服务器在自己的进程中运行,我们在后台启动它。
对于多进程,我们使用multiprocess模块——这是标准 Python multiprocessing模块的一个变体,它也可以在笔记本中使用。如果你在笔记本之外运行此代码,你也可以使用multiprocessing。
from [multiprocess](https://pypi.org/project/multiprocess/) import Process
import [subprocess](https://docs.python.org/3/library/subprocess.html)
def run_fuzzmanager():
def run_fuzzmanager_forever():
os.chdir('FuzzManager')
proc = subprocess.Popen(['python', 'server/manage.py',
'runserver'],
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True)
while True:
line = proc.stdout.readline()
print(line, end='')
fuzzmanager_process = Process(target=run_fuzzmanager_forever)
fuzzmanager_process.start()
return fuzzmanager_process
当服务器运行时,你将能够在下面看到它的输出。
fuzzmanager_process = run_fuzzmanager()
import [time](https://docs.python.org/3/library/time.html)
# wait a bit after interactions
DELAY_AFTER_START = 3
DELAY_AFTER_CLICK = 1.5
time.sleep(DELAY_AFTER_START)
``` <details>
### 登录
现在服务器已经启动并运行,可以通过这个 URL 在本地主机上访问*FuzzManager*。
```py
fuzzmanager_url = "http://127.0.0.1:8000"
要登录,请使用用户名demo和密码demo。在这个笔记本中,我们通过使用在关于 GUI 模糊测试的章节中引入的Selenium接口来程序化地完成这个操作。
from [IPython.display](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html) import display, Image
from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import HTML, rich_output
from GUIFuzzer import start_webdriver # minor dependency
对于交互式会话,将headless设置为False;然后你可以在与这个笔记本交互的同时与FuzzManager交互。
gui_driver = start_webdriver(headless=True, zoom=1.2)
gui_driver.set_window_size(1400, 600)
gui_driver.get(fuzzmanager_url)
这是FuzzManager的起始屏幕:

我们现在通过发送demo作为用户名和密码进行登录,然后点击登录按钮。
登录后,我们发现数据库是空的。一旦我们收集了崩溃,崩溃就会出现在这里。

收集崩溃
要填充我们的数据库,我们需要一些崩溃。让我们看看simply-buggy,这是一个包含用于说明目的的简单 C++程序的示例仓库。
!git clone https://github.com/uds-se/simply-buggy
Cloning into 'simply-buggy'...
remote: Enumerating objects: 22, done.
remote: Total 22 (delta 0), reused 0 (delta 0), pack-reused 22 (from 1)
Receiving objects: 100% (22/22), 4.90 KiB | 4.90 MiB/s, done.
Resolving deltas: 100% (9/9), done.
make命令编译我们的目标程序,包括我们的第一个目标,即simple-crash示例。与程序一起,还有一个生成的配置文件。
!(cd simply-buggy && make)
clang++ -fsanitize=address -g -o maze maze.cpp
clang++ -fsanitize=address -g -o out-of-bounds out-of-bounds.cpp
clang++ -fsanitize=address -g -o simple-crash simple-crash.cpp
让我们看看simple-crash的源代码,它在simple-crash.cpp中。如你所见,源代码相当简单:通过写入一个(near)-NULL 指针强制崩溃。这应该在大多数机器上立即崩溃。
/*
* simple-crash - A simple NULL crash.
*
* WARNING: This program neither makes sense nor should you code like it is
* done in this program. It is purely for demo purposes and uses
* bad and meaningless coding habits on purpose.
*/
int crash() {
int* p = (int*)0x1;
*p = 0xDEADBEEF;
return *p;
}
int main(int argc, char** argv) {
return crash();
}
为生成的二进制文件生成的配置文件simple-crash.fuzzmanagerconf也包含一些直接的信息,例如程序的版本和其他在提交崩溃时需要或至少有用的元数据。
[Main]
platform = x86-64
product = simple-crash-simple-crash
product_version = 83038f74e812529d0fc172a718946fbec385403e
os = linux
[Metadata]
pathPrefix = /Users/zeller/Projects/fuzzingbook/notebooks/simply-buggy/
buildFlags = -fsanitize=address -g
让我们运行这个程序!正如预期的那样,我们立即得到了一个崩溃跟踪:
!simply-buggy/simple-crash
simple-crash(47892,0x1fd223840) malloc: nano zone abandoned due to inability to reserve vm space.
AddressSanitizer:DEADLYSIGNAL
=================================================================
==47892==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000001 (pc 0x0001005c3e0c bp 0x00016f83ea10 sp 0x00016f83e9d0 T0) ==47892==The signal is caused by a WRITE memory access.
==47892==Hint: address points to the zero page.
#0 0x1005c3e0c in crash() simple-crash.cpp:11
#1 0x1005c3e94 in main simple-crash.cpp:16
#2 0x197d8c270 (<unknown module>)
==47892==Register values:
x[0] = 0x0000000000000001 x[1] = 0x000000016f83ec98 x[2] = 0x000000016f83eca8 x[3] = 0x000000016f83ef08
x[4] = 0x0000000000000001 x[5] = 0x0000000000000000 x[6] = 0x0000000000000000 x[7] = 0x00000000000004a0
x[8] = 0x0000007000020000 x[9] = 0x00000000deadbeef x[10] = 0x0000000000000001 x[11] = 0x000000016f83e9d0
x[12] = 0x0000000000001170 x[13] = 0x0000000100008000 x[14] = 0x0000000000004000 x[15] = 0x00000001a5bcef55
x[16] = 0x0000000198141344 x[17] = 0x0000000205482578 x[18] = 0x0000000000000000 x[19] = 0x00000001fcf8c050
x[20] = 0x00000001fcf8c0a0 x[21] = 0x00000001fcf8c050 x[22] = 0x000000016f83eb28 x[23] = 0x000000016f83eb28
x[24] = 0x0000000197d86000 x[25] = 0x0000000000000000 x[26] = 0x0000000000000000 x[27] = 0x0000000000000000
x[28] = 0x0000000000000000 fp = 0x000000016f83ea10 lr = 0x00000001005c3e98 sp = 0x000000016f83e9d0
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV simple-crash.cpp:11 in crash()
==47892==ABORTING
现在,我们实际上想做的就是从 Python 运行这个二进制文件,检测它是否崩溃,收集跟踪信息并将其提交到服务器。让我们从一个简单的脚本开始,这个脚本只会运行我们给它提供的程序并检测 ASan 跟踪的存在:
import [subprocess](https://docs.python.org/3/library/subprocess.html)
cmd = ["simply-buggy/simple-crash"]
result = subprocess.run(cmd, stderr=subprocess.PIPE)
stderr = result.stderr.decode().splitlines()
crashed = False
for line in stderr:
if "ERROR: AddressSanitizer" in line:
crashed = True
break
if crashed:
print("Yay, we crashed!")
else:
print("Move along, nothing to see...")
Yay, we crashed!
使用这个脚本,我们现在可以运行二进制文件,并且确实检测到它崩溃了。但是,我们如何将这条信息发送到崩溃服务器呢?让我们从 FuzzManager 工具箱中添加一些功能。
程序配置
一个 ProgramConfiguration 主要是一个容器类,存储着程序的各种属性,例如产品名称、平台、版本和运行时选项。默认情况下,它从为测试程序创建的 .fuzzmanagerconf 文件中读取信息。
sys.path.append('FuzzManager')
from FTB.ProgramConfiguration import ProgramConfiguration
configuration = ProgramConfiguration.fromBinary('simply-buggy/simple-crash')
(configuration.product, configuration.platform)
('simple-crash-simple-crash', 'x86-64')
崩溃信息
一个 CrashInfo 对象存储了有关崩溃的所有必要数据,包括
-
你的程序的 stdout 输出
-
你的程序的 stderr 输出
-
由 GDB 或 AddressSanitizer 生成的崩溃信息
-
一个
ProgramConfiguration实例
from FTB.Signatures.CrashInfo import CrashInfo
让我们收集 simply-crash 运行的信息:
cmd = ["simply-buggy/simple-crash"]
result = subprocess.run(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
stderr = result.stderr.decode().splitlines()
stderr[0:3]
['AddressSanitizer:DEADLYSIGNAL',
'=================================================================',
'==47914==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000001 (pc 0x000102897e0c bp 0x00016d56aab0 sp 0x00016d56aa70 T0)']
stdout = result.stdout.decode().splitlines()
stdout
[]
这将读取并解析我们的 ASan 跟踪到更通用的格式,返回给我们一个通用的 CrashInfo 对象,我们可以检查并将其提交到服务器:
crashInfo = CrashInfo.fromRawCrashData(stdout, stderr, configuration)
print(crashInfo)
Crash trace:
# 00 crash
# 01 main
# 02 <unknow
Crash address: 0x1
Last 5 lines on stderr:
x[24] = 0x0000000197d86000 x[25] = 0x0000000000000000 x[26] = 0x0000000000000000 x[27] = 0x0000000000000000
x[28] = 0x0000000000000000 fp = 0x000000016d56aab0 lr = 0x0000000102897e98 sp = 0x000000016d56aa70
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV simple-crash.cpp:11 in crash()
==47914==ABORTING
收集器
最后一步是将崩溃信息发送到我们的崩溃管理器。Collector 是一个与崩溃管理器服务器通信的功能。Collector 提供了一个简单的客户端接口,允许你的客户端提交崩溃以及下载和匹配现有签名以避免重复报告频繁的问题。
from Collector.Collector import Collector
我们实例化了收集器实例;这将是我们与服务器通信的入口点。
collector = Collector()
要提交崩溃信息,我们使用收集器的 submit() 方法:
collector.submit(crashInfo)
{'rawStdout': '',
'rawStderr': 'AddressSanitizer:DEADLYSIGNAL\n=================================================================\n==47914==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000001 (pc 0x000102897e0c bp 0x00016d56aab0 sp 0x00016d56aa70 T0)\n==47914==The signal is caused by a WRITE memory access.\n==47914==Hint: address points to the zero page.\n #0 0x102897e0c in crash() simple-crash.cpp:11\n #1 0x102897e94 in main simple-crash.cpp:16\n #2 0x197d8c270 (<unknown module>)\n\n==47914==Register values:\n x[0] = 0x0000000000000001 x[1] = 0x000000016d56ad30 x[2] = 0x000000016d56ad40 x[3] = 0x000000016d56af90 \n x[4] = 0x0000000000000001 x[5] = 0x0000000000000000 x[6] = 0x0000000000000000 x[7] = 0x00000000000004a0 \n x[8] = 0x0000007000020000 x[9] = 0x00000000deadbeef x[10] = 0x0000000000000001 x[11] = 0x000000016d56aa70 \nx[12] = 0x0000000000001170 x[13] = 0x0000000100008000 x[14] = 0x0000000000004000 x[15] = 0x00000001a5bcef55 \nx[16] = 0x0000000198141344 x[17] = 0x0000000205482578 x[18] = 0x0000000000000000 x[19] = 0x00000001fcf8c050 \nx[20] = 0x00000001fcf8c0a0 x[21] = 0x00000001fcf8c050 x[22] = 0x000000016d56abc8 x[23] = 0x000000016d56abc8 \nx[24] = 0x0000000197d86000 x[25] = 0x0000000000000000 x[26] = 0x0000000000000000 x[27] = 0x0000000000000000 \nx[28] = 0x0000000000000000 fp = 0x000000016d56aab0 lr = 0x0000000102897e98 sp = 0x000000016d56aa70 \nAddressSanitizer can not provide additional info.\nSUMMARY: AddressSanitizer: SEGV simple-crash.cpp:11 in crash()\n==47914==ABORTING',
'rawCrashData': '',
'metadata': '{"pathPrefix": "/Users/zeller/Projects/fuzzingbook/notebooks/simply-buggy/", "buildFlags": "-fsanitize=address -g"}',
'testcase_size': 0,
'testcase_quality': 0,
'testcase_isbinary': False,
'platform': 'x86-64',
'product': 'simple-crash-simple-crash',
'product_version': '83038f74e812529d0fc172a718946fbec385403e',
'os': 'linux',
'client': 'Braeburn.fritz.box',
'tool': 'fuzzingbook',
'env': '',
'args': '',
'bucket': None,
'id': 1,
'shortSignature': '[@ crash]',
'crashAddress': '0x1'}
检查崩溃
我们现在已向本地的 FuzzManager 演示实例提交了一些内容。如果你在本地机器上运行崩溃服务器,你可以访问 127.0.0.1:8000/crashmanager/crashes/,你应该能看到刚刚提交的崩溃信息。你可以查询产品、版本、操作系统以及进一步的崩溃详情。

如果你点击崩溃 ID,你可以进一步检查提交的数据。

由于 Collector 可以从任何程序中调用(只要它们配置为与正确的服务器通信),你现在可以从任何地方收集崩溃——远程机器上的模糊器、测试期间发生的崩溃,甚至生产期间的崩溃。
崩溃桶
收集崩溃的一个挑战是相同的崩溃会多次发生。如果一个产品掌握在数百万用户手中,那么成千上万的用户可能会遇到相同的错误,因此相同的崩溃。因此,数据库将包含成千上万的条目,它们都是由同一个错误引起的。因此,有必要识别那些相似的失败,并将它们分组在一个称为崩溃桶或简称为桶的集合中。
在FuzzManager中,一个桶是通过一个崩溃签名定义的,这是一个匹配一组错误的谓词列表。这样的谓词可以引用许多功能,其中最重要的是
-
当前的程序计数器,报告崩溃时刻执行的指令;
-
来自堆栈跟踪的元素,显示在崩溃时刻哪些函数是活动的。
我们可以在查看单个崩溃时立即创建这样的签名:

点击红色创建按钮为这个崩溃创建一个桶。一个崩溃签名将被提出以匹配这个和未来相同类型的崩溃:

通过点击保存来接受它。
您将被重定向到新创建的桶,它显示了大小(包含多少个崩溃)、其错误报告状态(桶可以链接到像 Bugzilla 这样的外部错误跟踪器)以及许多其他有用的信息。
崩溃签名
如果您点击顶部菜单中的签名条目,您也应该看到您新创建的条目。

您可以看到这个签名指的是在从main()调用时(duh!)在start()(一个内部 OS 函数)中发生的函数crash()的崩溃。我们还看到了当前的崩溃地址。
桶和它们的签名是 FuzzManager 中的一个核心概念。如果您从多个来源收到大量的崩溃报告,桶化可以轻松地对崩溃进行分组并过滤重复项。
粗粒度签名
灵活的签名系统最初从一个最初提出的细粒度签名开始,但可以根据需要调整以捕获相同错误的变体并使跟踪更容易。
在下一个示例中,我们将查看一个更复杂的示例,该示例从文件中读取数据并创建多个崩溃签名 - 一个名为out-of-bounds.cpp的文件:
/*
* out-of-bounds - A simple multi-signature out-of-bounds demo.
*
* WARNING: This program neither makes sense nor should you code like it is
* done in this program. It is purely for demo purposes and uses
* bad and meaningless coding habits on purpose.
*/
#include <cstring>
#include <fstream>
#include <iostream>
void printFirst(char* data, size_t count) {
std::string first(data, count);
std::cout << first << std::endl;
}
void printLast(char* data, size_t count) {
std::string last(data + strlen(data) - count, count);
std::cout << last << std::endl;
}
int validateAndPerformAction(char* buffer, size_t size) {
if (size < 2) {
std::cerr << "Buffer is too short." << std::endl;
return 1;
}
uint8_t action = buffer[0];
uint8_t count = buffer[1];
char* data = buffer + 2;
if (!count) {
std::cerr << "count must be non-zero." << std::endl;
return 1;
}
// Forgot to check count vs. the length of data here, doh!
if (!action) {
std::cerr << "Action can't be zero." << std::endl;
return 1;
} else if (action >= 128) {
printLast(data, count);
return 0;
} else {
printFirst(data, count);
return 0;
}
}
int main(int argc, char** argv) {
if (argc < 2) {
std::cerr << "Usage is: " << argv[0] << " <file>" << std::endl;
exit(1);
}
std::ifstream input(argv[1], std::ifstream::binary);
if (!input) {
std::cerr << "Error opening file." << std::endl;
exit(1);
}
input.seekg(0, input.end);
int size = input.tellg();
input.seekg(0, input.beg);
if (size < 0) {
std::cerr << "Error seeking in file." << std::endl;
exit(1);
}
char* buffer = new char[size];
input.read(buffer, size);
if (!input) {
std::cerr << "Error while reading file." << std::endl;
exit(1);
}
int ret = validateAndPerformAction(buffer, size);
delete[] buffer;
return ret;
}
与上一个程序相比,这个程序看起来更复杂,但不用担心,它实际上并没有做很多:
-
main()函数中的代码只是读取命令行上提供的文件,并将内容放入传递给validateAndPerformAction()的缓冲区中。 -
validateAndPerformAction()函数从缓冲区中提取两个字节(action和count),并将剩余的部分视为data。根据action的值,它随后调用printFirst()或printLast(),分别打印data的前count个字节或最后一个count个字节。
如果这听起来毫无意义,那是因为它确实如此。这个程序的全部想法是,在validateAndPerformAction()中缺少安全检查(即count不大于data的长度),但非法访问发生在两个打印函数中的任何一个之后。因此,我们预计这个程序将生成至少两个(略有不同)的崩溃签名——一个使用printFirst(),另一个使用printLast()。
让我们尝试使用基于最后一个 Python 脚本的非常简单的模糊测试来测试它。
由于FuzzManager在处理输入中的 8 位字符时可能会遇到问题,我们引入了一个escapelines()函数,该函数将文本转换为可打印的 ASCII 字符。
`escapelines()`实现
def isascii(s):
return all([0 <= ord(c) <= 127 for c in s])
isascii('Hello,')
True
def escapelines(bytes):
def ascii_chr(byte):
if 0 <= byte <= 127:
return chr(byte)
return r"\x%02x" % byte
def unicode_escape(line):
ret = "".join(map(ascii_chr, line))
assert isascii(ret)
return ret
return [unicode_escape(line) for line in bytes.splitlines()]
escapelines(b"Hello,\nworld!")
['Hello,', 'world!']
escapelines(b"abc\xffABC")
['abc\\xffABC']
```</details>
现在来看实际的脚本。如上所述,我们设置了一个收集器,每当发生崩溃时,它都会收集并发送崩溃信息。
```py
cmd = ["simply-buggy/out-of-bounds"]
# Connect to crash server
collector = Collector()
random.seed(2048)
crash_count = 0
TRIALS = 20
for itnum in range(0, TRIALS):
rand_len = random.randint(1, 1024)
rand_data = bytes([random.randrange(0, 256) for i in range(rand_len)])
(fd, current_file) = tempfile.mkstemp(prefix="fuzztest", text=True)
os.write(fd, rand_data)
os.close(fd)
current_cmd = []
current_cmd.extend(cmd)
current_cmd.append(current_file)
result = subprocess.run(current_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout = [] # escapelines(result.stdout)
stderr = escapelines(result.stderr)
crashed = False
for line in stderr:
if "ERROR: AddressSanitizer" in line:
crashed = True
break
print(itnum, end=" ")
if crashed:
sys.stdout.write("(Crash) ")
# This reads the simple-crash.fuzzmanagerconf file
configuration = ProgramConfiguration.fromBinary(cmd[0])
# This reads and parses our ASan trace into a more generic format,
# returning us a generic "CrashInfo" object that we can inspect
# and/or submit to the server.
crashInfo = CrashInfo.fromRawCrashData(stdout, stderr, configuration)
# Submit the crash
collector.submit(crashInfo, testCase = current_file)
crash_count += 1
os.remove(current_file)
print("")
print("Done, submitted %d crashes after %d runs." % (crash_count, TRIALS))
0 (Crash) 1 2 (Crash) 3 4 5 6 7 8 (Crash) 9 10 11 12 (Crash) 13 14 (Crash) 15 16 17 18 19
Done, submitted 5 crashes after 20 runs.
如果您运行此脚本,您将看到其进度并注意到它产生了相当多的崩溃。确实,如果您访问FuzzManager 崩溃页面,您将注意到已经积累了许多不同类型的崩溃:

选择第一个崩溃并为它创建一个桶,就像您上次做的那样。保存后,您会注意到并非所有的崩溃都进入了桶。原因是我们的程序创建了几个不同的堆栈,它们在某种程度上相似但并不完全相同。这在模糊测试现实世界应用程序时是一个常见问题。
幸运的是,有一种简单的方法来处理这个问题。当您在桶页面上时,点击桶的优化按钮。FuzzManager 将自动建议您更改签名。通过点击Edit with Changes然后Save来接受更改。重复这些步骤,直到所有崩溃都成为桶的一部分。经过 3 到 4 次迭代后,您的签名可能看起来像这样:
{
"symptoms": [
{
"type": "output",
"src": "stderr",
"value": "/ERROR: AddressSanitizer: heap-buffer-overflow/"
},
{
"type": "stackFrames",
"functionNames": [
"?",
"?",
"?",
"validateAndPerformAction",
"main",
"__libc_start_main",
"_start"
]
},
{
"type": "crashAddress",
"address": "> 0xFF"
}
]
}
如您在stackFrames签名症状中看到的,validateAndPerformAction函数仍然存在于堆栈帧中,因为此函数在所有崩溃的所有堆栈跟踪中都是通用的;实际上,这就是错误所在。但是,较低的堆栈部分已经被泛化为任意函数(?),因为它们在提交的崩溃集中各不相同。
Optimize 函数旨在尽可能自动化这个过程:它尝试通过适应未分类的崩溃来扩展签名,然后检查修改后的签名是否会触及其他现有桶。这个假设是其他桶确实是其他错误,即如果您首先从崩溃中创建了两个桶,优化将不再工作。此外,如果现有的桶数据稀疏,并且您有很多未分类的崩溃,算法可能会提出包含同一桶中不同错误崩溃的更改。无法完全自动检测和防止这种情况,因此该过程是半自动化的,需要您审查所有建议的更改。
收集代码覆盖率
在 覆盖率章节中,我们看到了如何测量代码覆盖率可以有助于评估模糊器性能。代码覆盖率中的漏洞可以揭示特别难以到达的位置以及模糊器本身的错误。因为这是整体模糊操作的重要部分,FuzzManager 支持可视化存储库的 每个模糊覆盖率 – 即,我们可以交互式地 检查 在模糊过程中哪些代码被覆盖了,哪些没有被覆盖。
为了说明在 FuzzManager 中的覆盖率收集和可视化,我们来看一个简单的 C++ 程序,maze.cpp 示例:
/*
* maze - A simple constant maze that crashes at some point.
*
* WARNING: This program neither makes sense nor should you code like it is
* done in this program. It is purely for demo purposes and uses
* bad and meaningless coding habits on purpose.
*/
#include <cstdlib>
#include <iostream>
int boom() {
int* p = (int*)0x1;
*p = 0xDEADBEEF;
return *p;
}
int main(int argc, char** argv) {
if (argc != 5) {
std::cerr << "All I'm asking for is four numbers..." << std::endl;
return 1;
}
int num1 = atoi(argv[1]);
if (num1 > 0) {
int num2 = atoi(argv[2]);
if (num1 > 2040109464) {
if (num2 < 0) {
std::cerr << "You found secret 1" << std::endl;
return 0;
}
} else {
if ((unsigned int)num2 == 3735928559) {
unsigned int num3 = atoi(argv[3]);
if (num3 == 3405695742) {
int num4 = atoi(argv[4]);
if (num4 == 1111638594) {
std::cerr << "You found secret 2" << std::endl;
boom();
return 0;
}
}
}
}
}
return 0;
}
如您所见,这个程序所做的只是从命令行读取一些数字,将它们与一些魔法常数和任意标准进行比较,如果一切顺利,您就会达到程序中的两个秘密之一。达到这些秘密之一也会触发一个失败。
在我们开始这个程序的工作之前,我们重新编译程序以支持覆盖率。为了使用 Clang 或 GCC 生成代码覆盖率,程序通常需要使用特殊的 CFLAGS,如 --coverage 来构建和链接。在我们的例子中,Makefile 会为我们完成这项工作:
!(cd simply-buggy && make clean && make coverage)
rm -f ./maze ./out-of-bounds ./simple-crash
clang++ -fsanitize=address -g --coverage -o maze maze.cpp
clang++ -fsanitize=address -g --coverage -o out-of-bounds out-of-bounds.cpp
clang++ -fsanitize=address -g --coverage -o simple-crash simple-crash.cpp
此外,如果我们想使用 FuzzManager 来查看我们的代码,我们需要进行初始的仓库设置(本质上是为服务器提供其自己的工作副本,以便从我们的 git 仓库中拉取源代码)。通常,客户端和服务器运行在不同的机器上,因此这涉及到在服务器上检出仓库并告诉它在哪里可以找到它(以及它使用的版本控制系统):
!git clone https://github.com/uds-se/simply-buggy simply-buggy-server
Cloning into 'simply-buggy-server'...
remote: Enumerating objects: 22, done.
remote: Total 22 (delta 0), reused 0 (delta 0), pack-reused 22 (from 1)
Receiving objects: 100% (22/22), 4.90 KiB | 4.90 MiB/s, done.
Resolving deltas: 100% (9/9), done.
!cd FuzzManager; {sys.executable} server/manage.py setup_repository simply-buggy GITSourceCodeProvider ../simply-buggy-server
Successfully created repository 'simply-buggy' with provider 'GITSourceCodeProvider' located at ../simply-buggy-server
我们现在假设我们知道一些魔法常数(在实践中,我们有时会了解一些关于目标的信息,但可能遗漏了一些细节)并使用这些常数模糊化程序:
import [random](https://docs.python.org/3/library/random.html)
import [subprocess](https://docs.python.org/3/library/subprocess.html)
random.seed(0)
cmd = ["simply-buggy/maze"]
constants = [3735928559, 1111638594]
TRIALS = 1000
for itnum in range(0, TRIALS):
current_cmd = []
current_cmd.extend(cmd)
for _ in range(0, 4):
if random.randint(0, 9) < 3:
current_cmd.append(str(constants[
random.randint(0, len(constants) - 1)]))
else:
current_cmd.append(str(random.randint(-2147483647, 2147483647)))
result = subprocess.run(current_cmd, stderr=subprocess.PIPE)
stderr = result.stderr.decode().splitlines()
crashed = False
if stderr and "secret" in stderr[0]:
print(stderr[0])
for line in stderr:
if "ERROR: AddressSanitizer" in line:
crashed = True
break
if crashed:
print("Found the bug!")
break
print("Done!")
You found secret 1
You found secret 1
You found secret 1
You found secret 1
You found secret 1
Done!
如您所见,通过 1000 次运行,我们几次发现了秘密 1,但秘密 2(以及崩溃)仍然缺失。为了确定如何改进这一点,我们现在将查看 覆盖率数据。
我们使用 Mozilla 的 grcov 工具来捕获图形覆盖率信息。
!export PATH=$HOME/.cargo/bin:$PATH; grcov simply-buggy/ -t coveralls+ --commit-sha $(cd simply-buggy && git rev-parse HEAD) --token NONE -p `pwd`/simply-buggy/ > coverage.json
!cd FuzzManager; {sys.executable} -mCovReporter --repository simply-buggy --description "Test1" --submit ../coverage.json
我们现在可以转到 FuzzManager 覆盖率页面来查看我们的源代码及其覆盖率。

点击第一个 ID 来浏览您刚刚提交的覆盖率数据。

您首先会看到simply-buggy存储库中所有文件的完整列表,除了maze.cpp文件外,其他所有文件都显示 0%覆盖率(因为我们没有对这些二进制文件做任何操作,自从我们用覆盖率支持重新构建它们以来)。现在点击maze.cpp并逐行检查覆盖率:

突出显示为绿色的行已被执行;左侧绿色条上的数字表示执行次数。突出显示为红色的行尚未执行。有两个观察结果:
-
第 34 行的 if 语句仍然被覆盖,但随后的行是红色的。这是因为我们的模糊器错过了该语句中检查的常量,所以很明显我们需要向我们的常量列表中添加。
-
从第 26 行到第 27 行,覆盖率突然下降。这两行都被覆盖了,但计数器显示我们在这超过 95%的情况下失败了。这解释了为什么我们很少找到秘密 1。如果这是一个真实程序,我们现在将试图找出那个分支后面有多少额外的代码,并在必要时调整概率,以便更频繁地触碰到它。
当然,maze程序如此之小,以至于可以用肉眼看到这些问题。但在现实中,对于复杂的程序,模糊工具卡住的地方往往不明显。识别这些情况可以极大地帮助提高模糊测试的结果。
为了完整性,现在让我们添加缺失的常量后重新运行程序:
random.seed(0)
cmd = ["simply-buggy/maze"]
# Added the missing constant here
constants = [3735928559, 1111638594, 3405695742]
for itnum in range(0,1000):
current_cmd = []
current_cmd.extend(cmd)
for _ in range(0,4):
if random.randint(0, 9) < 3:
current_cmd.append(str(
constants[random.randint(0, len(constants) - 1)]))
else:
current_cmd.append(str(random.randint(-2147483647, 2147483647)))
result = subprocess.run(current_cmd, stderr=subprocess.PIPE)
stderr = result.stderr.decode().splitlines()
crashed = False
if stderr:
print(stderr[0])
for line in stderr:
if "ERROR: AddressSanitizer" in line:
crashed = True
break
if crashed:
print("Found the bug!")
break
print("Done!")
You found secret 1
You found secret 2
Found the bug!
Done!
如预期的那样,我们现在找到了包括我们的崩溃在内的秘密 2。
经验教训
-
当使用多台机器(a)、多个程序(b)或多个模糊器(c)进行模糊测试时,使用崩溃服务器如FuzzManager来收集和存储崩溃。
-
可能由相同失败引起的崩溃应该收集在桶中,以确保它们都能得到相同的处理。
-
中心收集模糊器覆盖率可以帮助揭示模糊器的问题。
下一步
在下一章中,我们将学习如何
- 估计代码中剩余多少个错误以及何时测试足够。
背景
本章基于FuzzManager的实现。它的GitHub 页面包含了大量有关如何使用它的额外信息。
博客文章"Mozilla 的浏览器模糊测试"讨论了 FuzzManager 在 Mozilla 进行大规模浏览器测试中的应用背景。
论文“什么是一个好的错误报告?”[Bettenburg 等人,2008]列出了开发者期望从错误报告中获得的基本信息,他们如何使用这些信息,以及用于哪些目的。
练习
练习 1:自动崩溃报告
创建一个 Python 函数,可以在程序开始时调用,以便自动向FuzzManager服务器报告崩溃和异常。它将自动跟踪程序名称(如果可能,输出);崩溃(引发的异常)应转换为 ASan 格式,以便FuzzManager可以读取它们。
使用笔记本进行练习并查看解决方案。
本项目的内容受Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,均受MIT License许可。最后修改时间:2024-01-17 22:03:36+01:00。引用 版权信息
如何引用本作品
Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, and Christian Holler: "Fuzzing in the Large". In Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, and Christian Holler, "The Fuzzing Book", www.fuzzingbook.org/html/FuzzingInTheLarge.html. Retrieved 2024-01-17 22:03:36+01:00.
@incollection{fuzzingbook2024:FuzzingInTheLarge,
author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
booktitle = {The Fuzzing Book},
title = {Fuzzing in the Large},
year = {2024},
publisher = {CISPA Helmholtz Center for Information Security},
howpublished = {\url{https://www.fuzzingbook.org/html/FuzzingInTheLarge.html}},
note = {Retrieved 2024-01-17 22:03:36+01:00},
url = {https://www.fuzzingbook.org/html/FuzzingInTheLarge.html},
urldate = {2024-01-17 22:03:36+01:00}
}


浙公网安备 33010602011771号