Let us give grammars even more power by assigning probabilities to individual expansions. This allows us to control how many of each element should be produced, and thus allows us to target our generated tests towards specific functionality. We also show how to learn such probabilities from given sample inputs, and specifically direct our tests towards input features that are uncommon in these samples.
Prerequisites
In all our examples so far, you may have noted that inputs generated by a program differ quite a bit from "natural" inputs as they occur in real life. This is true even for innocuous elements such as numbers – yes, the numbers we have generated so far actually differ from numbers in the real world. This is because in real-life sets of numerical data, the leading significant digit is likely to be small: Actually, on average, the leading digit 1
occurs more than six times as often as the leading digit 8
or 9
. It has been shown that this result applies to a wide variety of data sets, including electricity bills, street addresses, stock prices, house prices, population numbers, death rates, lengths of rivers, physical and mathematical constants (Wikipedia).
This law of leading digits was first observed by Newcomb \cite{Newcomb1881} and later formalized by Benford in \cite{Benford1938}. Let us take a look at the conditions that determine the first digit of a number. We can easily compute the first digit by converting the number into a string and take the first character:
def first_digit_via_string(x: int) -> int:
return ord(repr(x)[0]) - ord('0')
first_digit_via_string(2001)
2
To do this mathematically, though, we have to take the fractional part of their logarithm, or formally
$$ d = 10^{\{\log_{10}(x)\}} $$where $\{x\}$ is the fractional part of $x$ (i.e. $\{1.234\} = 0.234$).
def first_digit_via_log(x: int) -> int:
frac, whole = math.modf(math.log10(x))
return int(10 ** frac)
first_digit_via_log(2001)
2
Most sets of "naturally" occurring numbers should not have any bias in the fractional parts of their logarithms, and hence, the fractional part $\{\log_{10}(x)\}$ is typically uniformly distributed. However, the fractional parts for the individual digits are not evenly distributed.
For a number to start with a digit $d$, the condition $d < 10^{\{\log_{10}(x)\}} < d + 1$ must hold. To start with the digit 1, the fractional part $\{\log_{10}(x)\}$ must thus be in the range
(math.log10(1), math.log10(2))
(0.0, 0.3010299956639812)
To start with the digit 2, though, it must be in the range
(math.log10(2), math.log10(3))
(0.3010299956639812, 0.47712125471966244)
which is much smaller. Formally, the probability $P(d)$ for a leading digit $d$ (again, assuming uniformly distributed fractional parts) is known as Benford's law: $$ P(d) = \log_{10}(d + 1) - \log_{10}(d) $$ which gives us:
def prob_leading_digit(d: int) -> float:
return math.log10(d + 1) - math.log10(d)
Let us compute these probabilities for all digits:
digit_probs = [prob_leading_digit(d) for d in range(1, 10)]
[(d, "%.2f" % digit_probs[d - 1]) for d in range(1, 10)]
[(1, '0.30'), (2, '0.18'), (3, '0.12'), (4, '0.10'), (5, '0.08'), (6, '0.07'), (7, '0.06'), (8, '0.05'), (9, '0.05')]
# ignore
import matplotlib.pyplot as plt # type: ignore
# ignore
labels = range(1, 10)
fig1, ax1 = plt.subplots()
ax1.pie(digit_probs, labels=labels, shadow=True, # type: ignore
autopct='%1.1f%%', counterclock=False, startangle=90)
ax1.axis('equal');
We see that a leading 1 is indeed six times as probable as a leading 9.
Benford's law has a number of applications. Most notably, it can be used to detect "non-natural" numbers, i.e. numbers that apparently were created randomly rather than coming from a "natural" source. if you write a scientific paper and fake data by putting in random numbers (for instance, using our grammar fuzzer on integers), you will likely violate Benford's law, and this can indeed be spotted. On the other hand, how would we proceed if we wanted to create numbers that adhere to Benford's law? To this end, we need to be able to encode probabilities such as the above in our grammar, such that we can ensure that a leading digit is indeed a 1
in 30% of all cases.
The goal of this chapter is to assign probabilities to individual expansions in the grammar, such that we can express that some expansion alternatives should be favored over others. This is not only useful to generate "natural"-looking numbers, but even more so to direct test generation towards a specific goal. If you recently have changed some code in your program, you would probably like to generate inputs that exercise precisely this code. By raising the probabilities on the input elements associated with the changed code, you will get more tests that exercise the changed code.
Our concept for expressing probabilities is to annotate individual expansions with attributes such as probabilities, using the annotation mechanism introduced in the chapter on grammars. To this end, we allow that an expansion cannot only be a string, but also a pair of a string and a set of attributes, as in
"<expr>":
[("<term> + <expr>", opts(prob=0.1)),
("<term> - <expr>", opts(prob=0.2)),
"<term>"]
Here, the opts()
function would allow us to express probabilities for choosing the individual expansions. The addition would have a probability of 10%, the subtraction of 20%. The remaining probability (in this case 70%) is equally distributed over the non-attributed expansions (in this case the single last one).
We can now use pairs with opts()
to assign probabilities to our expression grammar:
from GrammarFuzzer import GrammarFuzzer, all_terminals, display_tree, DerivationTree
from Grammars import is_valid_grammar, EXPR_GRAMMAR, START_SYMBOL, crange
from Grammars import opts, exp_string, exp_opt, set_opts
from Grammars import Grammar, Expansion
This is how the grammar expansions are represented internally:
leaddigits: List[Expansion] = PROBABILISTIC_EXPR_GRAMMAR["<leaddigit>"]
leaddigits
[('1', {'prob': 0.301}), ('2', {'prob': 0.176}), ('3', {'prob': 0.125}), ('4', {'prob': 0.097}), ('5', {'prob': 0.079}), ('6', {'prob': 0.067}), ('7', {'prob': 0.058}), ('8', {'prob': 0.051}), ('9', {'prob': 0.046})]
However, we typically access the expansion string and the associated probability via designated helper functions, exp_string()
(from the chapter on Grammars) and exp_prob()
:
leaddigit_expansion = leaddigits[0]
leaddigit_expansion
('1', {'prob': 0.301})
exp_string(leaddigit_expansion)
'1'
def exp_prob(expansion: Expansion) -> float:
"""Return the options of an expansion"""
return exp_opt(expansion, 'prob')
exp_prob(leaddigit_expansion)
0.301
Our existing fuzzers are all set up to work with grammars annotated this way. They simply ignore all annotations.
f = GrammarFuzzer(PROBABILISTIC_EXPR_GRAMMAR)
f.fuzz()
'4 + ++--7.0 - -7 - +++7.3 * (1 * 3 + 5 / 3 / 5 + 2) * 38 * (2 + 8)'
f = GrammarCoverageFuzzer(PROBABILISTIC_EXPR_GRAMMAR)
f.fuzz()
'1.30'
Let us define functions that access probabilities for given expansions. While doing so, they also check for inconsistencies.
Here is how we distribute probabilities for expansions without specified probabilities. Given an expansion rule
$$S ::= a_1\:|\: a_2 \:|\: \dots \:|\: a_n \:|\: u_1 \:|\: u_2 \:|\: \dots u_m$$with $n \ge 0$ alternatives $a_i$ for which the probability $p(a_i)$ is specified and $m \ge 0$ alternatives $u_j$ for which the probability $p(u_j)$ is unspecified, the "remaining" probability is distributed equally over all $u_j$; in other words,
$$p(u_j) = \frac{1 - \sum_{i = 1}^{n}p(a_i)}{m}$$If no probabilities are specified ($n = 0$), then all expansions have the same probability.
The overall sum of probabilities must be 1:
$$\sum_{i = 1}^{n} p(a_i) + \sum_{j = 1}^{m} p(u_i) = 1$$We check these properties while distributing probabilities.
The function exp_probabilities()
returns a mapping of all expansions in a rule to their respective probabilities.
def exp_probabilities(expansions: List[Expansion],
nonterminal: str ="<symbol>") \
-> Dict[Expansion, float]:
probabilities = [exp_prob(expansion) for expansion in expansions]
prob_dist = prob_distribution(probabilities, nonterminal) # type: ignore
prob_mapping: Dict[Expansion, float] = {}
for i in range(len(expansions)):
expansion = exp_string(expansions[i])
prob_mapping[expansion] = prob_dist[i]
return prob_mapping
The gist of exp_probabilities()
is handled in prob_distribution()
, which does the actual checking and computation.
def prob_distribution(probabilities: List[Optional[float]],
nonterminal: str = "<symbol>"):
epsilon = 0.00001
number_of_unspecified_probabilities = probabilities.count(None)
if number_of_unspecified_probabilities == 0:
sum_probabilities = cast(float, sum(probabilities))
assert abs(sum_probabilities - 1.0) < epsilon, \
nonterminal + ": sum of probabilities must be 1.0"
return probabilities
sum_of_specified_probabilities = 0.0
for p in probabilities:
if p is not None:
sum_of_specified_probabilities += p
assert 0 <= sum_of_specified_probabilities <= 1.0, \
nonterminal + ": sum of specified probabilities must be between 0.0 and 1.0"
default_probability = ((1.0 - sum_of_specified_probabilities)
/ number_of_unspecified_probabilities)
all_probabilities = []
for p in probabilities:
if p is None:
p = default_probability
all_probabilities.append(p)
assert abs(sum(all_probabilities) - 1.0) < epsilon
return all_probabilities
Here's the mapping exp_probabilities()
returns for the annotated <leaddigit>
element:
print(exp_probabilities(PROBABILISTIC_EXPR_GRAMMAR["<leaddigit>"]))
{'1': 0.301, '2': 0.176, '3': 0.125, '4': 0.097, '5': 0.079, '6': 0.067, '7': 0.058, '8': 0.051, '9': 0.046}
If no expansion is annotated, all expansions have the same likelihood of being selected, as in our previous grammar fuzzers.
print(exp_probabilities(PROBABILISTIC_EXPR_GRAMMAR["<digit>"]))
{'0': 0.1, '1': 0.1, '2': 0.1, '3': 0.1, '4': 0.1, '5': 0.1, '6': 0.1, '7': 0.1, '8': 0.1, '9': 0.1}
Here's how exp_probabilities()
distributes any remaining probability across non-annotated expansions:
exp_probabilities(PROBABILISTIC_EXPR_GRAMMAR["<expr>"])
{'<term> + <expr>': 0.1, '<term> - <expr>': 0.2, '<term>': 0.7}
We can use the checking capabilities of exp_probabilities()
to check a probabilistic grammar for consistency:
def is_valid_probabilistic_grammar(grammar: Grammar,
start_symbol: str = START_SYMBOL) -> bool:
if not is_valid_grammar(grammar, start_symbol):
return False
for nonterminal in grammar:
expansions = grammar[nonterminal]
_ = exp_probabilities(expansions, nonterminal)
return True
assert is_valid_probabilistic_grammar(PROBABILISTIC_EXPR_GRAMMAR)
assert is_valid_probabilistic_grammar(EXPR_GRAMMAR)
with ExpectError():
assert is_valid_probabilistic_grammar({"<start>": [("1", opts(prob=0.5))]})
Traceback (most recent call last): File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/1729613243.py", line 2, in <cell line: 1> assert is_valid_probabilistic_grammar({"<start>": [("1", opts(prob=0.5))]}) File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/3185228479.py", line 8, in is_valid_probabilistic_grammar _ = exp_probabilities(expansions, nonterminal) File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/4013279880.py", line 5, in exp_probabilities prob_dist = prob_distribution(probabilities, nonterminal) # type: ignore File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/1431748745.py", line 8, in prob_distribution assert abs(sum_probabilities - 1.0) < epsilon, \ AssertionError: <start>: sum of probabilities must be 1.0 (expected)
with ExpectError():
assert is_valid_probabilistic_grammar(
{"<start>": [("1", opts(prob=1.5)), "2"]})
Traceback (most recent call last): File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/2913569331.py", line 2, in <cell line: 1> assert is_valid_probabilistic_grammar( File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/3185228479.py", line 8, in is_valid_probabilistic_grammar _ = exp_probabilities(expansions, nonterminal) File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/4013279880.py", line 5, in exp_probabilities prob_dist = prob_distribution(probabilities, nonterminal) # type: ignore File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_17833/1431748745.py", line 16, in prob_distribution assert 0 <= sum_of_specified_probabilities <= 1.0, \ AssertionError: <start>: sum of specified probabilities must be between 0.0 and 1.0 (expected)
Now that we have seen how to specify probabilities for a grammar, we can actually implement probabilistic expansion. In our ProbabilisticGrammarFuzzer
, it suffices to overload one method, namely choose_node_expansion()
. For each of the children we can choose from (typically all expansions of a symbol), we determine their probability (using exp_probabilities()
defined above), and make a weighted choice using random.choices()
with a weight
argument.
class ProbabilisticGrammarFuzzer(GrammarFuzzer):
"""A grammar-based fuzzer respecting probabilities in grammars."""
def check_grammar(self) -> None:
super().check_grammar()
assert is_valid_probabilistic_grammar(self.grammar, self.start_symbol)
def supported_opts(self) -> Set[str]:
return super().supported_opts() | {'prob'}
class ProbabilisticGrammarFuzzer(ProbabilisticGrammarFuzzer):
def choose_node_expansion(self, node: DerivationTree,
children_alternatives: List[Any]) -> int:
(symbol, tree) = node
expansions = self.grammar[symbol]
probabilities = exp_probabilities(expansions)
weights: List[float] = []
for children in children_alternatives:
expansion = all_terminals((symbol, children))
children_weight = probabilities[expansion]
if self.log:
print(repr(expansion), "p =", children_weight)
weights.append(children_weight)
if sum(weights) == 0:
# No alternative (probably expanding at minimum cost)
return random.choices(
range(len(children_alternatives)))[0]
else:
return random.choices(
range(len(children_alternatives)), weights=weights)[0]
Our probabilistic grammar fuzzer works just like the non-probabilistic grammar fuzzer, except that it actually respects probability annotations. Let us generate a couple of "natural" numbers that respect Benford's law:
natural_fuzzer = ProbabilisticGrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR, start_symbol="<leadinteger>")
print([natural_fuzzer.fuzz() for i in range(20)])
['2588', '106', '10', '2', '7', '1', '95', '4', '192', '8', '2', '1', '1', '2', '2', '208', '1036', '5592', '157', '1442']
In contrast, these numbers are pure random:
integer_fuzzer = GrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR, start_symbol="<leadinteger>")
print([integer_fuzzer.fuzz() for i in range(20)])
['3', '1', '56', '5', '408251024', '7', '27', '2', '9', '6456', '7', '32', '1', '4', '7', '19', '2', '6', '2', '5']
Are the "natural" numbers really more "natural" than the random ones? To show that ProbabilisticGrammarFuzzer
indeed respects the probabilistic annotations, let us create a specific fuzzer for the lead digit:
leaddigit_fuzzer = ProbabilisticGrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR, start_symbol="<leaddigit>")
leaddigit_fuzzer.fuzz()
'8'
If we generate thousands of lead digits, their distribution should again follow Benford's law:
trials = 10000
count = {}
for c in crange('0', '9'):
count[c] = 0
for i in range(trials):
count[leaddigit_fuzzer.fuzz()] += 1
print([(digit, count[digit] / trials) for digit in count])
[('0', 0.0), ('1', 0.3003), ('2', 0.1756), ('3', 0.1222), ('4', 0.0938), ('5', 0.0816), ('6', 0.0651), ('7', 0.06), ('8', 0.0537), ('9', 0.0477)]
Quod erat demonstrandum! The distribution is pretty much exactly as originally specified. We now have a fuzzer where we can exercise control by specifying probabilities.
Assigning probabilities to individual expansions gives us great control over which inputs should be generated. By choosing probabilities wisely, we can direct fuzzing towards specific functions and features – for instance, towards functions that are particularly critical, prone to failures, or that have been recently changed.
As an example, consider the URL grammar from the chapter on grammars. Let us assume we have just made a change to our implementation of the secure FTP protocol. By assigning a higher probability to the ftps
scheme, we can generate more URLs that will specifically test this functionality.
First, let us define a helper function that sets a particular option:
Here's a specialization just for probabilities:
def set_prob(grammar: Grammar, symbol: str,
expansion: Expansion, prob: Optional[float]) -> None:
"""Set the probability of the given expansion of grammar[symbol]"""
set_opts(grammar, symbol, expansion, opts(prob=prob))
Let us use set_prob()
to give the ftps
expansion a probability of 80%:
probabilistic_url_grammar = extend_grammar(URL_GRAMMAR)
set_prob(probabilistic_url_grammar, "<scheme>", "ftps", 0.8)
assert is_valid_probabilistic_grammar(probabilistic_url_grammar)
probabilistic_url_grammar["<scheme>"]
['http', 'https', 'ftp', ('ftps', {'prob': 0.8})]
If we use this grammar for fuzzing, we will get plenty of ftps:
prefixes:
prob_url_fuzzer = ProbabilisticGrammarFuzzer(probabilistic_url_grammar)
for i in range(10):
print(prob_url_fuzzer.fuzz())
ftps://cispa.saarland:80 ftps://user:password@cispa.saarland/ ftps://fuzzingbook.com/abc ftps://fuzzingbook.com/x48 ftps://user:password@fuzzingbook.com/ ftps://www.google.com:2?x18=8 ftps://user:password@www.google.com:6 ftps://user:password@cispa.saarland/def ftps://user:password@cispa.saarland/def?def=52 ftps://user:password@cispa.saarland/
In a similar vein, we can direct URL generation towards specific hosts or ports; we can favor URLs with queries, fragments, or logins – or URLs without these. All it takes is to set appropriate probabilities.
By setting the probability of an expansion to zero, we can effectively disable specific expansions:
set_prob(probabilistic_url_grammar, "<scheme>", "ftps", 0.0)
assert is_valid_probabilistic_grammar(probabilistic_url_grammar)
prob_url_fuzzer = ProbabilisticGrammarFuzzer(probabilistic_url_grammar)
for i in range(10):
print(prob_url_fuzzer.fuzz())
ftp://user:password@cispa.saarland/x00 https://user:password@www.google.com/?def=6&def=x18&def=def https://user:password@fuzzingbook.com:7/?def=abc https://user:password@www.google.com:8080/ ftp://www.google.com/?abc=36&x34=5 http://user:password@cispa.saarland/ https://www.google.com/ https://user:password@www.google.com:85/?def=18 http://user:password@www.google.com:80/ http://fuzzingbook.com:80/?abc=def
Note that even if we set the probability of an expansion to zero, we may still see the expansion taken. This can happen during the "closing" phase of our grammar fuzzer, when the expansion is closed at minimum cost. At this stage, even expansions with "zero" probability will be taken if this is necessary for closing the expansion.
Let us illustrate this feature using the <expr>
rule from our expression grammar:
probabilistic_expr_grammar = extend_grammar(EXPR_GRAMMAR)
probabilistic_expr_grammar["<expr>"]
['<term> + <expr>', '<term> - <expr>', '<term>']
If we set the probability of the <term>
expansion to zero, the string should expand again and again.
set_prob(probabilistic_expr_grammar, "<expr>", "<term>", 0.0)
assert is_valid_probabilistic_grammar(probabilistic_expr_grammar)
Still, in the "closing" phase, subexpressions will eventually expand into <term>
, as it is the only way to close the expansion. Tracking choose_node_expansion()
shows that it is invoked with only one possible expansion <term>
, which has to be taken even though its specified probability is zero.
prob_expr_fuzzer = ProbabilisticGrammarFuzzer(probabilistic_expr_grammar)
prob_expr_fuzzer.fuzz()
'44 / 7 / 1 * 3 / 6 - +1.63 + 3 * 7 + 1 - 2'
While specified probabilities give us a means to control which expansions are taken how often, this control by itself may not be enough. As an example, consider the following grammar for IPv4 addresses:
def decrange(start: int, end: int) -> List[Expansion]:
"""Return a list with string representations of numbers in the range [start, end)"""
return [repr(n) for n in range(start, end)]
IP_ADDRESS_GRAMMAR: Grammar = {
"<start>": ["<address>"],
"<address>": ["<octet>.<octet>.<octet>.<octet>"],
# ["0", "1", "2", ..., "255"]
"<octet>": decrange(0, 256)
}
print(IP_ADDRESS_GRAMMAR["<octet>"][:20])
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19']
assert is_valid_grammar(IP_ADDRESS_GRAMMAR)
We can easily use this grammar to create IP addresses:
ip_fuzzer = ProbabilisticGrammarFuzzer(IP_ADDRESS_GRAMMAR)
ip_fuzzer.fuzz()
'34.155.77.136'
However, if we want to assign a specific probability to one of the four octets, we are out of luck. All we can do is to assign the same probability distribution for all four octets:
probabilistic_ip_address_grammar = extend_grammar(IP_ADDRESS_GRAMMAR)
set_prob(probabilistic_ip_address_grammar, "<octet>", "127", 0.8)
probabilistic_ip_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_ip_address_grammar)
probabilistic_ip_fuzzer.fuzz()
'127.127.127.127'
If we want to assign different probabilities to each of the four octets, what do we do?
The answer lies in the concept of context, which we already have seen while discussing coverage-driven fuzzers. As with coverage-driven fuzzing, the idea is to duplicate the element whose probability we want to set dependent on its context. In our case, this means to duplicate the <octet>
element to four individual ones, each of which can then get an individual probability distribution. We can do this programmatically, using the duplicate_context()
method:
probabilistic_ip_address_grammar = extend_grammar(IP_ADDRESS_GRAMMAR)
duplicate_context(probabilistic_ip_address_grammar, "<address>")
probabilistic_ip_address_grammar["<address>"]
['<octet-1>.<octet-2>.<octet-3>.<octet-4>']
We can now assign different probabilities to each of the <octet>
symbols. For instance, we can force specific expansions by setting their probability to 100%:
set_prob(probabilistic_ip_address_grammar, "<octet-1>", "127", 1.0)
set_prob(probabilistic_ip_address_grammar, "<octet-2>", "0", 1.0)
assert is_valid_probabilistic_grammar(probabilistic_ip_address_grammar)
The remaining two octets <octet-3>
and <octet-4>
have no specific probabilities set. During fuzzing, all their expansions (all octets) are thus still available:
probabilistic_ip_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_ip_address_grammar)
[probabilistic_ip_fuzzer.fuzz() for i in range(5)]
['127.0.201.77', '127.0.98.36', '127.0.12.232', '127.0.146.161', '127.0.245.151']
Just as with coverage, we can duplicate grammar rules arbitrarily often to get more and more finer-grained control over probabilities. However, this finer-grained control also comes at the cost of having to maintain these probabilities. In the next section, we will therefore discuss means to assign and tune such probabilities automatically.
Probabilities need not be set manually all the time. They can also be learned from other sources, notably by counting how frequently individual expansions occur in a given set of inputs. This is useful in a number of situations, including:
Let us first introduce counting expansions and learning probabilities, and then detail these scenarios.
We start with implementing a means to take a set of inputs and determine the number of expansions in that set. To this end, we need the parsers introduced in the previous chapter to transform a string input into a derivation tree. For our IP address grammar, this is how this works:
IP_ADDRESS_TOKENS = {"<octet>"} # EarleyParser needs explicit tokens
parser = EarleyParser(IP_ADDRESS_GRAMMAR)
tree, *_ = parser.parse("127.0.0.1")
display_tree(tree)
In a tree such as this one, we can now count individual expansions. In the above tree, for instance, we have two expansions of <octet>
into 0
, one into 1
, and one into 127
. The expansion <octet>
into 0
makes up 50% of all expansions seen; the expansions into 127
and 1
make up 25% each, and the other ones 0%. These are the probabilities we'd like to assign to our "learned" grammar.
We introduce a class ExpansionCountMiner
which allows us to count how frequently individual expansions take place. Its initialization method takes a parser (say, an EarleyParser
) that would be initialized with the appropriate grammar.
class ExpansionCountMiner:
def __init__(self, parser: Parser, log: bool = False) -> None:
assert isinstance(parser, Parser)
self.grammar = extend_grammar(parser.grammar())
self.parser = parser
self.log = log
self.reset()
The attribute expansion_counts
holds the expansions seen; adding a tree with add_tree()
traverses the given tree and adds all expansions seen.
class ExpansionCountMiner(ExpansionCountMiner):
def reset(self) -> None:
self.expansion_counts: Dict[str, int] = {}
def add_coverage(self, symbol: str, children: List[DerivationTree]) -> None:
key = expansion_key(symbol, children)
if self.log:
print("Found", key)
if key not in self.expansion_counts:
self.expansion_counts[key] = 0
self.expansion_counts[key] += 1
def add_tree(self, tree: DerivationTree) -> None:
(symbol, children) = tree
if not is_nonterminal(symbol):
return
assert children is not None
direct_children: List[DerivationTree] = [
(symbol, None) if is_nonterminal(symbol)
else (symbol, []) for symbol, c in children]
self.add_coverage(symbol, direct_children)
for c in children:
self.add_tree(c)
The method count_expansions()
is the one facing the public; it takes a list of inputs, parses them, and processes the resulting trees. The method counts()
returns the counts found.
class ExpansionCountMiner(ExpansionCountMiner):
def count_expansions(self, inputs: List[str]) -> None:
for inp in inputs:
tree, *_ = self.parser.parse(inp)
self.add_tree(tree)
def counts(self) -> Dict[str, int]:
return self.expansion_counts
Let us try this out on our IP address grammar. We create an ExpansionCountMiner
for our IP address grammar:
expansion_count_miner = ExpansionCountMiner(EarleyParser(IP_ADDRESS_GRAMMAR))
We parse a (small) set of IP addresses and count the expansions occurring:
expansion_count_miner.count_expansions(["127.0.0.1", "1.2.3.4"])
expansion_count_miner.counts()
{'<start> -> <address>': 2, '<address> -> <octet>.<octet>.<octet>.<octet>': 2, '<octet> -> 127': 1, '<octet> -> 0': 2, '<octet> -> 1': 2, '<octet> -> 2': 1, '<octet> -> 3': 1, '<octet> -> 4': 1}
You see that we have one expansion into 127
, and two into 0
. These are the counts we can use to assign probabilities.
The distribution of counts, as determined by ExpansionCountMiner
is what we can use to assign probabilities to our grammar. To this end, we introduce a subclass ProbabilisticGrammarMiner
whose method set_expansion_probabilities()
processes all expansions of a given symbol, checks whether it occurs in a given count distribution, and assigns probabilities using the following formula.
Given a set $T$ of derivation trees (as mined from samples), we determine the probabilities $p_i$ for each alternative $a_i$ of a symbol $S \rightarrow a_1 | \dots | a_n$ as
$$p_i = \frac{\text{Expansions of $S \rightarrow a_i$ in $T$}}{\text{Expansions of $S$ in $T$}}$$Should $S$ not occur at all in $T$, then $p_i$ is unspecified.
Here is the implementation of set_expansion_probabilities()
, implementing the above formula:
class ProbabilisticGrammarMiner(ExpansionCountMiner):
def set_probabilities(self, counts: Dict[str, int]):
for symbol in self.grammar:
self.set_expansion_probabilities(symbol, counts)
def set_expansion_probabilities(self, symbol: str, counts: Dict[str, int]):
expansions = self.grammar[symbol]
if len(expansions) == 1:
set_prob(self.grammar, symbol, expansions[0], None)
return
expansion_counts = [
counts.get(
expansion_key(
symbol,
expansion),
0) for expansion in expansions]
total = sum(expansion_counts)
for i, expansion in enumerate(expansions):
p = expansion_counts[i] / total if total > 0 else None
# if self.log:
# print("Setting", expansion_key(symbol, expansion), p)
set_prob(self.grammar, symbol, expansion, p)
The typical use of ProbabilisticGrammarMiner
is through mine_probabilistic_grammar()
, which first determines a distribution from a set of inputs, and then sets the probabilities accordingly.
class ProbabilisticGrammarMiner(ProbabilisticGrammarMiner):
def mine_probabilistic_grammar(self, inputs: List[str]) -> Grammar:
self.count_expansions(inputs)
self.set_probabilities(self.counts())
return self.grammar
Let us put this to use. We create a grammar miner for IP addresses:
probabilistic_grammar_miner = ProbabilisticGrammarMiner(
EarleyParser(IP_ADDRESS_GRAMMAR))
We now use mine_probabilistic_grammar()
to mine the grammar:
probabilistic_ip_address_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar([
"127.0.0.1", "1.2.3.4"])
assert is_valid_probabilistic_grammar(probabilistic_ip_address_grammar)
Here's the resulting distribution of octets in our grammar:
[expansion for expansion in probabilistic_ip_address_grammar['<octet>']
if exp_prob(expansion) > 0]
[('0', {'prob': 0.25}), ('1', {'prob': 0.25}), ('2', {'prob': 0.125}), ('3', {'prob': 0.125}), ('4', {'prob': 0.125}), ('127', {'prob': 0.125})]
If we use these probabilities for fuzzing, we will get the same distribution of octets as in our sample:
probabilistic_ip_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_ip_address_grammar)
[probabilistic_ip_fuzzer.fuzz() for i in range(10)]
['4.2.2.0', '2.1.4.0', '0.1.3.127', '0.3.0.127', '4.0.2.1', '3.127.0.0', '2.2.1.1', '4.0.1.0', '2.4.0.1', '0.0.3.127']
By learning from a sample, we can thus adjust our fuzzing towards the (syntactic) properties of this very sample.
Let us now get to our three usage scenarios. The first scenario is to create probability distributions right out of a sample, and to use these very distributions during test generation. This helps to focus test generation on those features that are most commonly used, which thus minimizes the risk of customers encountering failures.
To illustrate testing of common features, we choose the URL domain. Let us assume that we are running some Web-related service, and this is a sample of the URLs our customers access most:
URL_SAMPLE: List[str] = [
"https://user:password@cispa.saarland:80/",
"https://fuzzingbook.com?def=56&x89=3&x46=48&def=def",
"https://cispa.saarland:80/def?def=7&x23=abc",
"https://fuzzingbook.com:80/",
"https://fuzzingbook.com:80/abc?def=abc&abc=x14&def=abc&abc=2&def=38",
"ftps://fuzzingbook.com/x87",
"https://user:password@fuzzingbook.com:6?def=54&x44=abc",
"http://fuzzingbook.com:80?x33=25&def=8",
"http://fuzzingbook.com:8080/def",
]
Using the Earley parser from the chapter on parsers, we can parse any of these inputs into a parse tree; we have to specify a token set, though.
URL_TOKENS: Set[str] = {"<scheme>", "<userinfo>", "<host>", "<port>", "<id>"}
url_parser = EarleyParser(URL_GRAMMAR, tokens=URL_TOKENS)
url_input = URL_SAMPLE[2]
print(url_input)
tree, *_ = url_parser.parse(url_input)
display_tree(tree)
https://cispa.saarland:80/def?def=7&x23=abc
Let us apply our ProbabilisticGrammarMiner
class on these inputs, using the above url_parser
parser, and obtain a probabilistic URL grammar:
probabilistic_grammar_miner = ProbabilisticGrammarMiner(url_parser)
probabilistic_url_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar(
URL_SAMPLE)
These are the counts we obtained during parsing:
print(probabilistic_grammar_miner.counts())
{'<start> -> <url>': 9, '<url> -> <scheme>://<authority><path><query>': 9, '<scheme> -> https': 6, '<authority> -> <userinfo>@<host>:<port>': 2, '<userinfo> -> user:password': 2, '<host> -> cispa.saarland': 2, '<port> -> 80': 5, '<path> -> /': 2, '<query> -> <query>': 4, '<authority> -> <host>': 2, '<host> -> fuzzingbook.com': 7, '<path> -> <path>': 3, '<query> -> ?<params>': 5, '<params> -> <param>&<params>': 10, '<param> -> <id>=<nat>': 9, '<id> -> def': 11, '<nat> -> <digit><digit>': 5, '<digit> -> 5': 3, '<digit> -> 6': 1, '<id> -> x89': 1, '<nat> -> <digit>': 4, '<digit> -> 3': 2, '<id> -> x46': 1, '<digit> -> 4': 2, '<digit> -> 8': 3, '<params> -> <param>': 5, '<param> -> <id>=<id>': 6, '<authority> -> <host>:<port>': 5, '<path> -> /<id>': 4, '<digit> -> 7': 1, '<id> -> x23': 1, '<id> -> abc': 7, '<id> -> x14': 1, '<digit> -> 2': 2, '<scheme> -> ftps': 1, '<id> -> x87': 1, '<port> -> 6': 1, '<id> -> x44': 1, '<scheme> -> http': 2, '<id> -> x33': 1, '<port> -> 8080': 1}
These counts translate into individual probabilities. We see that in our sample, most URLs use the https:
scheme, whereas there is no input using the ftp:
scheme.
probabilistic_url_grammar['<scheme>']
[('http', {'prob': 0.2222222222222222}), ('https', {'prob': 0.6666666666666666}), ('ftp', {'prob': 0.0}), ('ftps', {'prob': 0.1111111111111111})]
Likewise, we see that most given URLs have multiple parameters:
probabilistic_url_grammar['<params>']
[('<param>', {'prob': 0.3333333333333333}), ('<param>&<params>', {'prob': 0.6666666666666666})]
When we use this probabilistic grammar for fuzzing, these distributions are reflected in our generated inputs – no ftp:
schemes either, and most inputs have multiple parameters.
g = ProbabilisticGrammarFuzzer(probabilistic_url_grammar)
[g.fuzz() for i in range(10)]
['https://fuzzingbook.com/def?def=abc&def=abc&def=def&def=abc&abc=def&def=def&abc=def', 'http://fuzzingbook.com:80/def?def=7&def=abc&abc=88', 'https://cispa.saarland/def?def=2', 'http://user:password@fuzzingbook.com:80/def?abc=abc&def=78', 'http://cispa.saarland:80/?def=54&def=abc', 'https://fuzzingbook.com:80/def?def=def', 'http://fuzzingbook.com:80/abc?abc=abc&abc=def&def=85&abc=7&def=6&abc=2&def=abc', 'https://fuzzingbook.com/abc?def=32&def=3&abc=4', 'http://fuzzingbook.com/def?abc=24&def=def&def=48', 'https://cispa.saarland:80/?abc=abc']
Being able to replicate a probability distribution learned from a sample is not only important for focusing on commonly used features. It can also help in achieving valid inputs, in particular if one learns probabilities in context, as discussed above: If within a given context, some elements are more likely than others (because they depend on each other), a learned probability distribution will reflect this; and hence, inputs generated from this learned probability distribution will have a higher chance to be valid, too. We will explore this further in the exercises, below.
So far, we have focused on common features; but from a testing perspective, one may just as well test uncommon features – that is, features that rarely occur in our usage samples and therefore would be less exercised in practice. This is a common scenario in security testing, where one focuses on uncommon (and possibly lesser-known) features, as fewer users means fewer bugs reported, and thus more bugs left to be found and exploited.
To have our probabilistic grammar fuzzer focus on uncommon features, we change the learned probabilities such that commonly occurring features (i.e., those with a high learned probability) get a low probability, and vice versa: The last shall be first, and the first last. A particularly simple way to achieve such an inversion of probabilities is to swap them: The alternatives with the highest and lowest probability swaps their probabilities, as so the alternatives with the second-highest and second-lowest probability, the alternatives with the third highest and lowest, and so on.
The function invert_expansion()
takes an expansion (a list of alternatives) from a grammar and returns a new inverted expansion in which the probabilities have been swapped according to the rule above. It creates a list of indexes, sorts it by increasing probability, and then for each $n$-th element, assigns it the probability of the $n$-th last element in the indexes.
def invert_expansion(expansion: List[Expansion]) -> List[Expansion]:
def sort_by_prob(x: Tuple[int, float]) -> float:
index, prob = x
return prob if prob is not None else 0.0
inverted_expansion: List[Expansion] = copy.deepcopy(expansion)
indexes_and_probs = [(index, exp_prob(alternative))
for index, alternative in enumerate(expansion)]
indexes_and_probs.sort(key=sort_by_prob)
indexes = [i for (i, _) in indexes_and_probs]
for j in range(len(indexes)):
k = len(indexes) - 1 - j
# print(indexes[j], "gets", indexes[k])
inverted_expansion[indexes[j]][1]['prob'] = expansion[indexes[k]][1]['prob'] # type: ignore
return inverted_expansion
Here's invert_expansion()
in action. This is our original probability distribution for URL schemes:
probabilistic_url_grammar['<scheme>']
[('http', {'prob': 0.2222222222222222}), ('https', {'prob': 0.6666666666666666}), ('ftp', {'prob': 0.0}), ('ftps', {'prob': 0.1111111111111111})]
And this is the "inverted" distribution. We see that the ftp:
scheme, which previously had a probability of zero, now has the highest probability, whereas the most common scheme, https:
, now has the previous zero probability of the ftp:
scheme.
invert_expansion(probabilistic_url_grammar['<scheme>'])
[('http', {'prob': 0.1111111111111111}), ('https', {'prob': 0.0}), ('ftp', {'prob': 0.6666666666666666}), ('ftps', {'prob': 0.2222222222222222})]
One nice feature of this swapping of probabilities is that the sum of probabilities stays unchanged; no normalization is needed. Another nice feature is that the inversion of the inversion returns the original distribution:
invert_expansion(invert_expansion(probabilistic_url_grammar['<scheme>']))
[('http', {'prob': 0.2222222222222222}), ('https', {'prob': 0.6666666666666666}), ('ftp', {'prob': 0.0}), ('ftps', {'prob': 0.1111111111111111})]
Note that our implementation does not universally satisfy this property: If two alternatives $a_1$ and $a_2$ in the expansion share the same probability, then the second inversion may assign different probabilities to $a_1$ and $a_2$.
We can apply this inversion of expansions across the entire grammar:
def invert_probs(grammar: Grammar) -> Grammar:
inverted_grammar = extend_grammar(grammar)
for symbol in grammar:
inverted_grammar[symbol] = invert_expansion(grammar[symbol])
return inverted_grammar
This means that probabilities would be swapped for each and every expansion:
probabilistic_url_grammar["<digit>"]
[('0', {'prob': 0.0}), ('1', {'prob': 0.0}), ('2', {'prob': 0.14285714285714285}), ('3', {'prob': 0.14285714285714285}), ('4', {'prob': 0.14285714285714285}), ('5', {'prob': 0.21428571428571427}), ('6', {'prob': 0.07142857142857142}), ('7', {'prob': 0.07142857142857142}), ('8', {'prob': 0.21428571428571427}), ('9', {'prob': 0.0})]
inverted_probabilistic_url_grammar = invert_probs(probabilistic_url_grammar)
inverted_probabilistic_url_grammar["<digit>"]
[('0', {'prob': 0.21428571428571427}), ('1', {'prob': 0.21428571428571427}), ('2', {'prob': 0.07142857142857142}), ('3', {'prob': 0.07142857142857142}), ('4', {'prob': 0.0}), ('5', {'prob': 0.0}), ('6', {'prob': 0.14285714285714285}), ('7', {'prob': 0.14285714285714285}), ('8', {'prob': 0.0}), ('9', {'prob': 0.14285714285714285})]
If we now use this "inverted" grammar for fuzzing, the generated inputs will focus on the complement of the input samples. We will get plenty of tests of user/password features, as well as ftp:
schemes – in essence, all the features present in our language, but rarely used (if at all) in our input samples.
g = ProbabilisticGrammarFuzzer(inverted_probabilistic_url_grammar)
[g.fuzz() for i in range(10)]
['ftp://www.google.com', 'ftp://www.google.com/', 'ftp://www.google.com/', 'ftp://user:password@cispa.saarland', 'ftps://www.google.com/', 'ftp://user:password@www.google.com/', 'ftp://user:password@www.google.com', 'ftp://www.google.com/', 'ftp://user:password@www.google.com', 'ftp://user:password@www.google.com/']
Besides having only common or only uncommon features, one can also create mixed forms – for instance, testing uncommon features in a common context. This can be helpful for security testing, where one may want an innocuous (common) "envelope" combined with an (uncommon) "payload". It all depends on where and how we tune the probabilities.
In our previous examples, we have learned from all inputs to generate common or uncommon inputs. However, we can also learn from a subset of inputs to focus on the features present in that subset (or, conversely, to avoid its features). If we know, for instance, that there is some subset of inputs that covers a functionality of interest (say, because it is particularly critical or because it has been recently changed), we can learn from this very subset and focus our test generation on its features.
To illustrate this approach, let us use the CGI grammar introduced in the chapter on coverage. We have a special interest in Line 25 in our CGI decoder – that is, the line that processes a %
character followed by two valid hexadecimal digits:
...
elif c == '%':
digit_high, digit_low = s[i + 1], s[i + 2]
i += 2
if digit_high in hex_values and digit_low in hex_values:
v = hex_values[digit_high] * 16 + hex_values[digit_low] ### Line 25
t += chr(v)
...
Let us assume that we do not know precisely under which conditions Line 25 is executed – but still, we'd like to test it thoroughly. With our probability learning tools, we can learn these conditions, though. We start with a set of random inputs and consider the subset that covers Line 25.
cgi_fuzzer = GrammarFuzzer(CGI_GRAMMAR)
trials = 100
coverage = {}
for i in range(trials):
cgi_input = cgi_fuzzer.fuzz()
with Coverage() as cov:
cgi_decode(cgi_input)
coverage[cgi_input] = cov.coverage()
These are all the random inputs that cover Line 25:
coverage_slice = [cgi_input for cgi_input in coverage
if ('cgi_decode', 25) in coverage[cgi_input]]
print(coverage_slice)
['%36%c1%d2c2%4f++e', '%2c%90+', 'c++%8b', 'a+%95', '%76%00', '+5', '4', '+', 'a', '%71', '%a2', '%39%51%db%7c%66', '++', '%2a', 'd', '%b9225', '++c', '%13+b', '%32', '2', '+2+', '-1%b11%d8', '%08', 'd+4', '%a3', '%fe', 'e', '1++', '+%82%ed%42', '%d5', '%5bc', '51', '%b0', '%47', 'b+%20', '%d7', '0+%17', '%a5', '%84', 'e+4%fc', 'd%6f+++1a', '3', 'd+%95+', '%1e', '%244', '%3c', '5%75+%99%3c', '+-', 'b', '%80%74+a', '%a7', '21', 'ae', '%c1%da', '%c5+', 'b%44', '%70%c4_3', '1', 'dd+ad', '4%63', '%364+', '%79%ab', '%8a%f6', '%53%43', '+++%55+b5', '%51+++', '+%28', '1%1c+', '+%41%9b', '%0d%20', '+%3d+%c2']
Actually, about half of the inputs cover Line 25:
len(coverage_slice) / trials
0.71
Let us now learn a probabilistic grammar from this slice of inputs:
probabilistic_grammar_miner = ProbabilisticGrammarMiner(
EarleyParser(CGI_GRAMMAR))
probabilistic_cgi_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar(
coverage_slice)
assert is_valid_probabilistic_grammar(probabilistic_cgi_grammar)
We see that percentage signs are very likely to occur:
probabilistic_cgi_grammar['<letter>']
[('<plus>', {'prob': 0.2556818181818182}), ('<percent>', {'prob': 0.42045454545454547}), ('<other>', {'prob': 0.32386363636363635})]
Using this grammar, we can now generate tests that specifically target Line 25:
probabilistic_cgi_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_cgi_grammar)
print([probabilistic_cgi_fuzzer.fuzz() for i in range(20)])
['5', '%3e', '+4%e6', '%19+', '%da+', '%5c', '%28%5e', '%b5+', '2d4', '455%8c', '5%cb', '%4c+%5c4e5+e+%aa%db%1d', 'a%1c%13', 'e+', '%08%cc', 'b3', '3', '%c0%25', '+', '++c+%54d']
trials = 100
coverage = {}
for i in range(trials):
cgi_input = probabilistic_cgi_fuzzer.fuzz()
with Coverage() as cov:
cgi_decode(cgi_input)
coverage[cgi_input] = cov.coverage()
We see that the fraction of inputs that cover Line 25 is much higher already, showing that our focusing works:
coverage_slice: List[str] = [cgi_input for cgi_input in coverage
if ('cgi_decode', 25) in coverage[cgi_input]]
len(coverage_slice) / trials
0.88
Repeating this one more time yields an even higher focusing:
for run in range(3):
probabilistic_cgi_grammar = probabilistic_grammar_miner.mine_probabilistic_grammar(
coverage_slice)
probabilistic_cgi_fuzzer = ProbabilisticGrammarFuzzer(
probabilistic_cgi_grammar)
trials = 100
coverage = {}
for i in range(trials):
cgi_input = probabilistic_cgi_fuzzer.fuzz()
with Coverage() as cov:
cgi_decode(cgi_input)
coverage[cgi_input] = cov.coverage()
coverage_slice = [cgi_input for cgi_input in coverage
if ('cgi_decode', 25) in coverage[cgi_input]]
len(coverage_slice) / trials
0.83
By learning (and re-learning) probabilities from a subset of sample inputs, we can specialize fuzzers towards the properties of that subset – in our case, inputs that contain percentage signs and valid hexadecimal letters. The degree to which we can specialize things is induced by the number of variables we can control – in our case, the probabilities for the individual rules. Adding more context to the grammar, as discussed above, will increase the number of variables, and thus the amount of specialization.
A high degree of specialization, however, limits our possibilities to explore combinations that fall outside the selected scope, and limit our possibilities to find bugs induced by these combinations. This trade-off is known as exploration vs. exploitation in machine learning – shall one try to explore as many (possibly shallow) combinations as possible, or focus (exploit) specific areas? In the end, it all depends on where the bugs are, and where we are most likely to find them. Assigning and learning probabilities allows us to control the search strategies – from the common to the uncommon to specific subsets.
Let us close this chapter by getting back to our introductory example. We said that Benford's law allows us not only to produce, but also to detect "unnatural" lead digit distributions such as the ones produced by simple random choices.
If we use the regular GrammarFuzzer
class (which ignores probabilities) to generate (random) lead digits, this is the distribution we get for each leading digit:
sample_size = 1000
random_integer_fuzzer = GrammarFuzzer(
PROBABILISTIC_EXPR_GRAMMAR,
start_symbol="<leaddigit>")
random_integers = [random_integer_fuzzer.fuzz() for i in range(sample_size)]
random_counts = [random_integers.count(str(c)) for c in crange('1', '9')]
random_counts
[112, 104, 106, 103, 133, 97, 126, 120, 99]
(For simplicity, we use the simple list count()
method here rather than deploying the full-fledged ProbabilisticGrammarMiner
.)
If we had a natural distribution of lead digits, this is what we would expect:
expected_prob_counts = [
exp_prob(
PROBABILISTIC_EXPR_GRAMMAR["<leaddigit>"][i]) *
sample_size for i in range(9)]
print(expected_prob_counts)
[301.0, 176.0, 125.0, 97.0, 79.0, 67.0, 58.0, 51.0, 46.0]
And if we had a random distribution, we would expect an equal distribution:
expected_random_counts = [sample_size / 9 for i in range(9)]
print(expected_random_counts)
[111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111, 111.11111111111111]
Which distribution better matches our random_counts
lead digits? To this end, we run a $\chi^2$-test to compare the distribution we found (random_counts
) against the "natural" lead digit distribution expected_prob_counts
and the random distribution expected_random_counts
.
It turns out that there is a zero chance (pvalue
= 0.0) that the observed distribution follows a "natural" distribution:
chisquare(random_counts, expected_prob_counts)
Power_divergenceResult(statistic=435.87462280458345, pvalue=3.925216460200427e-89)
However, there is a 97% chance that the observed behavior follows a random distribution:
chisquare(random_counts, expected_random_counts)
Power_divergenceResult(statistic=11.42, pvalue=0.17901776899017763)
Hence, if you find some numbers published and doubt their validity, you can run the above test to check whether they are likely to be natural. Better yet, insist that authors use Jupyter notebooks to produce their results, such that you can check every step of the calculation :-)
A probabilistic grammar allows attaching individual probabilities to production rules. To set the probability of an individual expansion S
to the value X
(between 0 and 1), replace it with a pair
(S, opts(prob=X))
If we want to ensure that 90% of phone numbers generated have an area code starting with 9
, we can write:
PROBABILISTIC_US_PHONE_GRAMMAR: Grammar = extend_grammar(US_PHONE_GRAMMAR,
{
"<lead-digit>": [
"2", "3", "4", "5", "6", "7", "8",
("9", opts(prob=0.9))
],
})
A ProbabilisticGrammarFuzzer
will extract and interpret these options. Here is an example:
probabilistic_us_phone_fuzzer = ProbabilisticGrammarFuzzer(PROBABILISTIC_US_PHONE_GRAMMAR)
[probabilistic_us_phone_fuzzer.fuzz() for i in range(5)]
['(918)925-2501', '(981)925-0792', '(934)995-5029', '(955)999-7801', '(964)927-0877']
As you can see, the large majority of area codes now starts with 9
.
# ignore
from ClassDiagram import display_class_hierarchy
# ignore
display_class_hierarchy([ProbabilisticGrammarFuzzer],
public_methods=[
Fuzzer.run,
Fuzzer.runs,
GrammarFuzzer.__init__,
GrammarFuzzer.fuzz,
GrammarFuzzer.fuzz_tree,
ProbabilisticGrammarFuzzer.__init__,
],
types={
'DerivationTree': DerivationTree,
'Expansion': Expansion,
'Grammar': Grammar
},
project='fuzzingbook')
Now that we have brought together probabilities and grammars (and revisited parsers and grammars), we have created a foundation for many applications. Our next chapters will focus on
Enjoy!
The idea of mining probabilities by parsing a corpus of data was first covered in "Learning to Fuzz: Application-Independent Fuzz Testing with Probabilistic, Generative Models of Input Data" \cite{Patra2016} which also learns and applies probabilistic rules for derivation trees. Applying this idea on probabilistic grammars as well as inverting probabilities or learning from slices was first executed in the work "Inputs from Hell: Generating Uncommon Inputs from Common Samples" \cite{Pavese2018}.
Our exposition of Benford's law follows this article.
Create a class ProbabilisticGrammarCoverageFuzzer
that extends GrammarCoverageFuzzer
with probabilistic capabilities. The idea is to first cover all uncovered expansions (like GrammarCoverageFuzzer
) and once all expansions are covered, to proceed by probabilities (like ProbabilisticGrammarFuzzer
).
To this end, define new instances of the choose_covered_node_expansion()
and choose_uncovered_node_expansion()
methods that choose an expansion based on the given weights.
If you are an advanced programmer, realize the class via multiple inheritance from GrammarCoverageFuzzer
and ProbabilisticGrammarFuzzer
to achieve this.
Multiple inheritance is a tricky thing. If you have two classes $A'$ and $A''$ which both inherit from $A$, the same method $m()$ of $A$ may be overloaded in both $A'$ and $A''$. If one now inherits from both $A'$ and $A''$, and calls $m()$, which of the $m()$ implementations should be called? Python "resolves" this conflict by simply invoking the one $m()$ method in the class one inherits from first.
To avoid such conflicts, one can check whether the order in which one inherits makes a difference. The method inheritance_conflicts()
compares the attributes with each other; if they refer to different code, you have to resolve the conflict.
inheritance_conflicts(GrammarCoverageFuzzer, ProbabilisticGrammarFuzzer)
['choose_node_expansion']
This is a method you have to implement for multiple inheritance besides choose_covered_node_expansion()
and choose_uncovered_node_expansion()
.
Learning from a set of inputs can be extremely valuable if one learns from inputs that are known to have caused failures before. In this exercise, you will go and learn distributions from past vulnerabilities.
js-vuln-db
, a set of JavaScript engine vulnerabilities. Each vulnerability comes with code that exercises it.re.findall()
with appropriate regular expressions.RISKY_NUMBERS
that produces these numbers with probabilities reflecting the above counts.Of course, there is more to vulnerabilities than just a specific numbers, but some numbers are more likely to induce errors than others. The next time you fuzz a system, do not generate numbers randomly; instead, pick one from RISKY_NUMBERS
:-)