Probabilistic Grammar Fuzzing

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.

from bookutils import YouTubeVideo
YouTubeVideo('9htOliNwopc')

Prerequisites

Synopsis

To use the code provided in this chapter, write

>>> from fuzzingbook.ProbabilisticGrammarFuzzer import <identifier>

and then make use of the following features.

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:

>>> from Grammars import US_PHONE_GRAMMAR, extend_grammar, opts
>>> PROBABILISTIC_US_PHONE_GRAMMAR: Grammar = extend_grammar(US_PHONE_GRAMMAR,
>>> {
>>>       "<lead-digit>": [
>>>                           "2", "3", "4", "5", "6", "7", "8",
>>>                           ("9", opts(prob=0.9))
>>>                       ],
>>> })

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.

ProbabilisticGrammarFuzzer ProbabilisticGrammarFuzzer check_grammar() choose_node_expansion() supported_opts() GrammarFuzzer GrammarFuzzer __init__() fuzz() fuzz_tree() ProbabilisticGrammarFuzzer->GrammarFuzzer Fuzzer Fuzzer run() runs() GrammarFuzzer->Fuzzer Legend Legend •  public_method() •  private_method() •  overloaded_method() Hover over names to see doc

The Law of Leading Digits

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 [Simon Newcomb, 1881] and later formalized by Benford in [Frank Benford, 1938]. 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$).

import math
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')]

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.

Specifying Probabilities

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 Fuzzer import Fuzzer
from GrammarFuzzer import GrammarFuzzer, all_terminals, display_tree, DerivationTree
from Grammars import is_valid_grammar, EXPR_GRAMMAR, START_SYMBOL, crange
from Grammars import opts, exp_string, exp_opt, set_opts
from Grammars import Grammar, Expansion
from typing import List, Dict, Set, Optional, cast, Any, Tuple
PROBABILISTIC_EXPR_GRAMMAR: Grammar = {
    "<start>":
        ["<expr>"],

    "<expr>":
        [("<term> + <expr>", opts(prob=0.1)),
         ("<term> - <expr>", opts(prob=0.2)),
         "<term>"],

    "<term>":
        [("<factor> * <term>", opts(prob=0.1)),
         ("<factor> / <term>", opts(prob=0.1)),
         "<factor>"
         ],

    "<factor>":
        ["+<factor>", "-<factor>", "(<expr>)",
            "<leadinteger>", "<leadinteger>.<integer>"],

    "<leadinteger>":
        ["<leaddigit><integer>", "<leaddigit>"],

    # Benford's law: frequency distribution of leading digits
    "<leaddigit>":
        [("1", opts(prob=0.301)),
         ("2", opts(prob=0.176)),
         ("3", opts(prob=0.125)),
         ("4", opts(prob=0.097)),
         ("5", opts(prob=0.079)),
         ("6", opts(prob=0.067)),
         ("7", opts(prob=0.058)),
         ("8", opts(prob=0.051)),
         ("9", opts(prob=0.046)),
         ],

    # Remaining digits are equally distributed
    "<integer>":
        ["<digit><integer>", "<digit>"],

    "<digit>":
        ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"],
}
assert is_valid_grammar(PROBABILISTIC_EXPR_GRAMMAR, supported_opts={'prob'})

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)'
from GrammarCoverageFuzzer import GrammarCoverageFuzzer  # minor dependency
f = GrammarCoverageFuzzer(PROBABILISTIC_EXPR_GRAMMAR)
f.fuzz()
'1.30'

Computing Probabilities

Let us define functions that access probabilities for given expansions. While doing so, they also check for inconsistencies.

Distributing Probabilities

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)

    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}

Checking Probabilities

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)
from ExpectError import ExpectError
with ExpectError():
    assert is_valid_probabilistic_grammar({"<start>": [("1", opts(prob=0.5))]})
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_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)

Expanding by Probability

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.

import random
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.

Directed Fuzzing

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%:

from Grammars import URL_GRAMMAR, extend_grammar
probabilistic_url_grammar = extend_grammar(URL_GRAMMAR)
set_prob(probabilistic_url_grammar, "<scheme>", "ftps", 0.8)
assert is_valid_probabilistic_grammar(probabilistic_url_grammar)
probabilistic_url_grammar["<scheme>"]
['http', 'https', 'ftp', ('ftps', {'prob': 0.8})]

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:

from Grammars import EXPR_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'

Probabilities in Context

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:

from GrammarCoverageFuzzer import duplicate_context  # minor dependency
probabilistic_ip_address_grammar = extend_grammar(IP_ADDRESS_GRAMMAR)
duplicate_context(probabilistic_ip_address_grammar, "<address>")
probabilistic_ip_address_grammar["<address>"]
['<octet-1>.<octet-2>.<octet-3>.<octet-4>']

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.

Learning Probabilities from Samples

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:

  1. Test common features. The idea is that during testing, one may want to focus on frequently occurring (or frequently used) features first, to ensure correct functionality for the most common usages.
  2. Test uncommon features. Here, the idea is to have test generation focus on features that are rarely seen (or not seen at all) in inputs. This is the same motivation as with grammar coverage, but from a probabilistic standpoint.
  3. Focus on specific slices. One may have a set of inputs that is of particular interest (for instance, because they exercise a critical functionality, or recently have discovered bugs). Using this learned distribution for fuzzing allows us to focus on precisely these functionalities of interest.

Let us first introduce counting expansions and learning probabilities, and then detail these scenarios.

Counting Expansions

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:

from Parser import Parser, EarleyParser
IP_ADDRESS_TOKENS = {"<octet>"}  # EarleyParser needs explicit tokens
parser = EarleyParser(IP_ADDRESS_GRAMMAR)
tree, *_ = parser.parse("127.0.0.1")
display_tree(tree)
0 <start> 1 <address> 0->1 2 <octet> 1->2 4 . (46) 1->4 5 <octet> 1->5 7 . (46) 1->7 8 <octet> 1->8 10 . (46) 1->10 11 <octet> 1->11 3 127 2->3 6 0 (48) 5->6 9 0 (48) 8->9 12 1 (49) 11->12

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.

from GrammarCoverageFuzzer import expansion_key  # minor dependency
from Grammars import is_nonterminal
class ExpansionCountMiner:
    def __init__(self, parser: Parser, log: bool = False) -> None:
        assert isinstance(parser, Parser)
        self.grammar = extend_grammar(parser.grammar())
        self.parser = parser
        self.log = log
        self.reset()

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.

Assigning 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.

Testing Common Features

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
0 <start> 1 <url> 0->1 2 <scheme> 1->2 4 :// 1->4 5 <authority> 1->5 11 <path> 1->11 15 <query> 1->15 3 https 2->3 6 <host> 5->6 8 : (58) 5->8 9 <port> 5->9 7 cispa.saarland 6->7 10 80 9->10 12 / (47) 11->12 13 <id> 11->13 14 def 13->14 16 ? (63) 15->16 17 <params> 15->17 18 <param> 17->18 25 & (38) 17->25 26 <params> 17->26 19 <id> 18->19 21 = (61) 18->21 22 <nat> 18->22 20 def 19->20 23 <digit> 22->23 24 7 (55) 23->24 27 <param> 26->27 28 <id> 27->28 30 = (61) 27->30 31 <id> 27->31 29 x23 28->29 32 abc 31->32

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.

Testing Uncommon Features

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.

import copy
def invert_expansion(expansion: List[Expansion]) -> List[Expansion]:
    def sort_by_prob(x: Tuple[int, float]) -> float:
        index, prob = x
        return prob if prob is not None else 0.0

    inverted_expansion: List[Expansion] = copy.deepcopy(expansion)
    indexes_and_probs = [(index, exp_prob(alternative))
                         for index, alternative in enumerate(expansion)]
    indexes_and_probs.sort(key=sort_by_prob)
    indexes = [i for (i, _) in indexes_and_probs]

    for j in range(len(indexes)):
        k = len(indexes) - 1 - j
        # print(indexes[j], "gets", indexes[k])
        inverted_expansion[indexes[j]][1]['prob'] = expansion[indexes[k]][1]['prob']

    return inverted_expansion

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.

Learning Probabilities from Input Slices

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.

from Coverage import Coverage, cgi_decode
from Grammars import CGI_GRAMMAR
cgi_fuzzer = GrammarFuzzer(CGI_GRAMMAR)
trials = 100
coverage = {}

for i in range(trials):
    cgi_input = cgi_fuzzer.fuzz()
    with Coverage() as cov:
        cgi_decode(cgi_input)
    coverage[cgi_input] = cov.coverage()

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.

Detecting Unnatural Numbers

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.

from scipy.stats import chisquare

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 :-)

Lessons Learned

  • By specifying probabilities, one can steer fuzzing towards input features of interest.
  • Learning probabilities from samples allows one to focus on features that are common or uncommon in input samples.
  • Learning probabilities from a subset of samples allows one to produce more similar inputs.

Next Steps

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!

Background

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" [Patra et al, 2016] 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" [Esteban Pavese et al, 2018].

Our exposition of Benford's law follows this article.

Exercises

Exercise 1: Probabilistic Fuzzing with Coverage

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.

from bookutils import inheritance_conflicts
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().

Exercise 2: Learning from Past Bugs

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.

  1. Download js-vuln-db, a set of JavaScript engine vulnerabilities. Each vulnerability comes with code that exercises it.
  2. Extract all number literals from the code, using re.findall() with appropriate regular expressions.
  3. Convert these literals to (decimal) numeric values and count their respective occurrences.
  4. Create a grammar 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 :-)

Creative Commons License The content of this project is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. The source code that is part of the content, as well as the source code used to format and display that content is licensed under the MIT License. Last change: 2024-11-09 17:07:29+01:00CiteImprint