Fuzzing stateful systems: noPac, Part 2: exhaustive enumeration
Here, we take a look at structured fuzzing of stateful systems, specifically using the example of a historical high-level semantic/logic bug (as opposed to low-level memory corruption bug) in Active Directory.
Disclaimer: Goal is understanding how to make software better and more secure through good testing. The bug is old, publicly known, and has been fixed/patched; I did not find it originally. All testing was performed in a dedicated lab environment on own hardware and software instances. If you run affected versions, please update. The accompanying code snippets are for educational purposes only (I’m not publishing a fully runnable version).
With that out of the way…
Re-framing the problem#
Part 1 approached the problem by random blind fuzzing. Here, I chose a different approach that probably fits this problem better: instead of random search, we generate all valid programs given a limit in maximum program length, while pruning the search space by dropping semantically duplicate programs to reduce the number of programs that need to be executed against the crash oracle (which is very expensive).
Components#
Some of the code is available in
this git repository
in the exhaustive-enumeration subfolder. However, it has purposefully been
altered to not be able to execute against a real Active Directory instance and
does not contain relevant executor or helper script implementations for doing
so.
|-- main.rs # Program entrypoint
|-- cli.rs # Commandline argument parsing
|-- input.rs # Input type definitions (program as list of actions)
|-- enumerator.rs # Enumerator entry-point
|-- executor.rs # Execute one input against the lab AD environment
# and collect feedback/coverage (not included)
|-- error.rs # Error types
|-- feedback.rs # Feedback information (behavioral coverage map)
`-- stats.rs # Statistics (number of execs, number of program prunes, ...)Enumerating programs#
The input type definitions are essentially the same as in the previous part.
enumerator.rs implements the main component: a simple depth-first search among
all syntactically valid and semantically unique programs, which is finite /
bounded by the execution budget and parameter value space.
Operation Modes#
The whole tool has basically three modes of operation:
| |
- with
--dry-runit computes some statistics about the effectiveness of search space pruning, which is explained in detail in a later section. - without an argument, it enumerates programs and executes them against a hardcoded lab AD instance (code for this is purposefully not part of the repository);
- with
--repro-testcase <idx>it enumerates programs until theidx-th program is hit, which is then executed against the lab AD;
Whether to perform search space pruning or not maps to the following enum:
| |
The entrypoint called from main constructs a new Iterator with default
execution budget:
| |
The DFS state is held inside the Enumerator struct:
| |
Search space pruning#
Programs are built call-by-call. For each next call to choose, all ActionKinds
are iterated. Then for each parameter, all possible values (either from the existing
environment, or from the (finite) space of possible type-directed leaf-values)
are iterated.
The environment is by-design constructed to avoid unnecessary semantic duplicates regarding both alpha-renaming of variables and leaf values.
For pruning based on the call sequence, the tool implements pruning strategies that fall into the following two categories:
- pruning based on properties that can be expressed depending only on partial programs / call prefixes: at each program node, child/successor expansions are filtered. This prunes as early as possible, discarding whole subtrees of the space, and is therefore very effective. This is the preferred method, however, not all properties can (easily) be expressed in that way.
- pruning based on properties on whole programs: this applies filtering at the latest possible stage, when a full program has already been constructed. It is therefore not as effective as early search space pruning.
More specifically, the tool implements six strategies, whose effectiveness is
tracked together with number of executed programs, and programs that produced
the desired behavior, as part of a Stats struct:
| |
| |
The specific counters map to the following strategy implementations:
auth_oracle_terminal:src/enumerator.rs206 207 208 209 210 211 212if let EnumMode::Prune = mode && let Some(c) = pgm.calls.last() && c.kind == ActionKind::AuthorizationOracle { prune_stats.tally(PruneReason::AuthOracleTerminal); return Vec::new(); }partial_order_reduction_write_batch: identify the previous write (inside the current write-batch), if the written attributes are not sorted (with respect to an arbitrary but fixed ordering), the current call is unnecessary.src/enumerator.rs419 420 421 422 423 424 425 426 427 428 429 430 431 432fn prune_reason_partial(cand: &Program) -> Option<PruneReason> { // All the filters here only apply when the last call was a write. let last = cand.calls.len() - 1; let (a, v) = write_effect(cand, &cand.calls[last])?; if let Some(b) = cand.calls[..last] .iter() .rev() .take_while(|c| !c.kind.is_read_barrier()) .find_map(|c| write_attr(cand, c)) && a < b { return Some(PruneReason::PartialOrderReductionWriteBatch); }dead_store_batch: if there is a previous write inside the current write-batch to the same attribute, that call was unnecessary (last-write-wins).src/enumerator.rs434 435 436 437 438 439 440 441if cand.calls[..last] .iter() .rev() .take_while(|c| !c.kind.is_read_barrier()) .any(|c| write_attr(cand, c).is_some_and(|b| b == a)) { return Some(PruneReason::DeadStoreBatch); }redundant_store_batch: identify the previous write (even across read-boundaries) to the same attribute, if it writes the same value, the current call is unnecessary.src/enumerator.rs443 444 445 446 447 448 449 450 451 452let prev = cand.calls[..last].iter().rev().find_map(|c| { match write_effect(cand, c) { Some((b, w)) if a == b => Some(w), _ => None, } }); if prev == Some(v) { return Some(PruneReason::RedundantStore); }trailing_dead_write: on the whole program, identify the last read; any writes after that are unnecessary since their effect is never observed. Note that this only works incidentally here, since all resource-producing calls do not have side-effects (writes), and vice-versa. Note also that the read is identified regardless of the read attribute; it would be another improvement to compare per-attribute reads/writes.src/enumerator.rs457 458 459 460 461 462 463 464 465 466 467 468fn prune_reason_whole(pgm: &Program) -> Option<PruneReason> { let after_last_read = pgm .calls .iter() .rposition(|c| c.kind.is_read_barrier()) .map_or(0, |i| i + 1); if pgm.calls[after_last_read..] .iter() .any(|c| c.kind.is_write()) { return Some(PruneReason::TrailingDeadWrite); }unused_ret: on the whole program, check if any return value is unused.src/enumerator.rs470 471 472 473 474 475 476for (i, c) in pgm.calls.iter().enumerate() { if let Some(r) = c.ret && !pgm.calls[i + 1..].iter().any(|c2| c2.params.contains(&r)) { return Some(PruneReason::UnusedRet); } }
All these strategies are relatively general and independent of the specific problem domain; they do not require much a-priori knowledge of the bug.
With smarter pruning, even more could be expressed as prefix-dependent properties, allowing more early pruning; but the current code strikes a good balance between being understandable and easy to express, and the resulting effectiveness, imho. Especially since overall the oracle execution times dominate compute time. Nonetheless, in general, with exponentially (in program length) large subtrees, (especially early) pruning is important.
Feedback#
Similar to part 1, feedback is a rudimentary behavioral coverage map. It is not really needed here since programs are enumerated in the same order exhaustively, regardless of any runtime dynamic feedback. But it can nonetheless be used to potentially classify different programs by the response of the system under test.
| |
| |
Example runs#
Now we can test each operation mode of the tool.
Running through all 6-call programs#
Exhaustive enumeration of all 6-call programs triggers the desired behavior.
Reproducing a single program#
This can also be reproduced directly, by supplying the index of the program in the enumeration:
./exhaustive-enumeration$ cargo run -- --repro-testcase <idx>
[+] Running with args Args { repro_testcase: Some(<idx>), dry_run: false }
[+] Running program:
...
[+] RunResult { exit: Crash, cov: Feedback( ... ) }All programs before it have to be generated and are simply discarded. I also thought about implementing indexable generation (bijection mapping between a number and a structured representation), but found this to be not easily doable. The approach would be similar to e.g. Rust’s arbitrary or QuickCheck, but surjective and injective; in arbitrary, several possible byte strings can map to the same value; e.g. booleans are generated by consuming a full random byte, and making the decision based on one bit only.
There are ways of generating syntactically valid programs (or more generally algebraic datatypes) in an indexable way like with “Feat: functional enumeration of algebraic types”.1 However, extending that with variously-expressed semantic properties (the whole point of search space pruning) re-introduces gaps and breaks the bijection again. Overall, I found simple DFS with manually expressed (arbitrary) properties the easier way here.
Quantifying search space pruning effectiveness#
In hindsight search space pruning is absolutely essential, but at first I just didn’t think of it. To underline how hopeless naive blind fuzzing without any search space reduction (or stronger guiding feedback) is, here are some numbers: (Since enumeration/generation is completely independent of dynamic runtime feedback, these experiments can be run fully isolated without any real AD lab environment)
With execution budget of a maximum of 6 calls per program, we get these numbers:
./exhaustive-enumeration$ cargo run --release -- --dry-run
[+] Running with args Args { repro_testcase: None, dry_run: true }
[+] Enumerating Baseline
[=] subtree-prunes 0 | program-prunes 0 | execs 756434 | crashes 0
...
[=] subtree-prunes 0 | program-prunes 0 | execs 4972299 | crashes 0
...
[+] Enumerating Prune
[=] subtree-prunes 131903 | program-prunes 531062 | execs 2217 | crashes 0
...
[=] subtree-prunes 587620 | program-prunes 1916769 | execs 3896 | crashes 0
...
[=] Final statistics comparison
[=] baseline: subtree-prunes 0 | program-prunes 0 | execs 5607403 | crashes 0
[=] pruned: subtree-prunes 838800 | program-prunes 2499378 | execs 4051 | crashes 0
================================================================================
[=] Summarized impact of search space pruning on number of executions / oracle invocations
[=] would-be executed: 5607403 (no pruning) | 4051 (pruning)
[=] total pruned programs: 5603352 (99.928% of all programs)
[=] executions are reduced by a factor of 1384.20
========================================
[=] pruned by partial program properties 3103974 (55.395% of all prunes)
[=] pruned by whole-program properties 2499378 (44.605% of all prunes)Modifying the default execution budget, and running it with an execution budget of 7 calls (the per-action-kind limits are unchanged), yields:
./exhaustive-enumeration$ cargo run --release -- --dry-run
[+] Running with args Args { repro_testcase: None, dry_run: true }
[+] Enumerating Baseline
[=] subtree-prunes 0 | program-prunes 0 | execs 715439 | crashes 0
...
[=] subtree-prunes 0 | program-prunes 0 | execs 55599831 | crashes 0
...
[+] Enumerating Prune
[=] subtree-prunes 229613 | program-prunes 722701 | execs 947 | crashes 0
...
[=] subtree-prunes 7372984 | program-prunes 14159623 | execs 4694 | crashes 0
...
[=] Final statistics comparison
[=] baseline: subtree-prunes 0 | program-prunes 0 | execs 56283595 | crashes 0
[=] pruned: subtree-prunes 7712784 | program-prunes 14542458 | execs 4699 | crashes 0
================================================================================
[=] Summarized impact of search space pruning on number of executions / oracle invocations
[=] would-be executed: 56283595 (no pruning) | 4699 (pruning)
[=] total pruned programs: 56278896 (99.992% of all programs)
[=] executions are reduced by a factor of 11977.78
========================================
[=] pruned by partial program properties 41736438 (74.160% of all prunes)
[=] pruned by whole-program properties 14542458 (25.840% of all prunes)With a maximum of 8 calls per program (the per-action-kind limits are unchanged) we get:
./exhaustive-enumeration$ cargo run --release -- --dry-run
[+] Running with args Args { repro_testcase: None, dry_run: true }
[+] Enumerating Baseline
[=] subtree-prunes 0 | program-prunes 0 | execs 740090 | crashes 0
...
[=] subtree-prunes 0 | program-prunes 0 | execs 412896251 | crashes 0
...
[+] Enumerating Prune
[=] subtree-prunes 257826 | program-prunes 707059 | execs 285 | crashes 0
...
[=] subtree-prunes 35659268 | program-prunes 46543943 | execs 4698 | crashes 0
...
[=] Final statistics comparison
[=] baseline: subtree-prunes 0 | program-prunes 0 | execs 413284939 | crashes 0
[=] pruned: subtree-prunes 35854128 | program-prunes 46711770 | execs 4699 | crashes 0
================================================================================
[=] Summarized impact of search space pruning on number of executions / oracle invocations
[=] would-be executed: 413284939 (no pruning) | 4699 (pruning)
[=] total pruned programs: 413280240 (99.999% of all programs)
[=] executions are reduced by a factor of 87951.68
========================================
[=] pruned by partial program properties 366568470 (88.697% of all prunes)
[=] pruned by whole-program properties 46711770 (11.303% of all prunes)Summarized across the three execution budgets:
| Max calls | Baseline execs | Pruned execs | Reduction factor | Pruned early (prefix) | Pruned late (whole-program) |
|---|---|---|---|---|---|
| 6 | 5,607,403 | 4,051 | 1,384x | 55.395% | 44.605% |
| 7 | 56,283,595 | 4,699 | 11,978x | 74.160% | 25.840% |
| 8 | 413,284,939 | 4,699 | 87,952x | 88.697% | 11.303% |
This underlines the importance of pruning subtrees early.
Conclusion#
Compared to what I did in part 1, the end result this time is a much better implementation in almost all regards. It is easier to understand. It takes much less code to write. It achieves a better result on the metric I care about (number of executed programs / search space size reduction, since the oracle is prohibitively expensive). It is more efficient regarding runtime (time-complexity) – since search space pruning happens incrementally, as early as possible, not after the fact – and regarding memory (space-complexity) – there is no explicit visited set keeping track of all (normalized) programs. And it explores a larger semantically meaningful search space than the implementation from part 1 (here we have longer programs, and more possible leaf values).
There are of course still things to improve, but overall I think it’s an OK state to stop in.
Jonas Duregård, Patrik Jansson, and Meng Wang. 2012. Feat: functional enumeration of algebraic types. SIGPLAN Not. 47, 12 (December 2012), 61–72. https://doi.org/10.1145/2430532.2364515 ↩︎