Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
350cdd8
Fix ACTZipper::val_count counting from the zipper root, not the focus
imlvts Sep 15, 2026
9420b9e
Give ACTZipper::to_sibling an answer for an off-trie focus
imlvts Sep 15, 2026
3a98201
Keep a value in subtract when the other side only passes through its …
imlvts Sep 15, 2026
3a16a57
Fix graft_child_maps and graft_masked_branches (finding 15)
imlvts Sep 15, 2026
3e839e8
Fix join_into replacing or misreporting a destination that holds the …
imlvts Sep 15, 2026
4062aa8
Drop dangling paths from meet results
imlvts Sep 15, 2026
3dae731
Make meet/join value bias independent of node layout
imlvts Sep 15, 2026
23d5d12
Fuzz: separate the ACT ascend_until defect from finding 9
imlvts Sep 15, 2026
315b41c
Fix ACTZipper ascend_until landing on the wrong stop above an off-tri…
imlvts Sep 16, 2026
2d3b6f2
Report Identity from the dense restrict when nothing was dropped
imlvts Sep 16, 2026
f8a4599
Return Identity from the integer psubtract when nothing was subtracted
imlvts Sep 16, 2026
3a2b499
Answer a sibling step from a focus that is not in the trie
imlvts Sep 16, 2026
ecd7e21
Don't filter branches through a dangling path in remove_unmasked_bran…
imlvts Sep 16, 2026
e6ced6a
Drop an unvalidated dangling branch from the dense restrict
imlvts Sep 16, 2026
c882a38
Don't report a change for dropping a shadowed dangling slot
imlvts Sep 16, 2026
ff41532
Revive the Rust reference model, and bring it up to the current Lean one
imlvts Sep 16, 2026
9833f0f
Run the reference model against the crate in one process, and under AFL
imlvts Sep 16, 2026
43e023f
Name the trailer lines in the in-process fuzzer's own summary
imlvts Sep 16, 2026
acc1bca
Keep a dangling-child residue the classifier cannot see
imlvts Sep 16, 2026
af3638a
Say where AFL actually files a divergence on a stock Linux box
imlvts Sep 16, 2026
46da390
Stop skipping the sibling-byte moves at the zipper root
imlvts Sep 16, 2026
29da7ec
Stop skipping insert_prefix with an empty prefix
imlvts Sep 16, 2026
470eea8
Compare restrict's status instead of masking it
imlvts Sep 16, 2026
f99f40d
Compare join_k_path_into's bool instead of masking it
imlvts Sep 16, 2026
33bde3a
Drop the skip:quarantined vocabulary, which nothing declines by any more
imlvts Sep 16, 2026
6c065a4
Correct the input counts the lifted-suppression notes claim
imlvts Sep 16, 2026
7842407
Emit graft_child_maps in generated repros instead of a quarantine com…
imlvts Sep 16, 2026
1daf3ff
Emit insert_prefix with an empty prefix in generated repros
imlvts Sep 16, 2026
03626d0
Drop a reached dangling slot from the dense subtract against a list o…
imlvts Sep 16, 2026
618f8ce
Don't claim COUNTER_IDENT for a meet key that runs into an empty onwa…
imlvts Sep 16, 2026
a7bc950
Keep dangling paths in meet results, as master does
imlvts Sep 16, 2026
75c869a
Specify meet as an intersection of locations, and prune as dropping d…
imlvts Sep 16, 2026
e014741
Compare meet with prune = false only; prune = true may skip shared nodes
imlvts Sep 16, 2026
ada5458
Test the meet rule for dangling paths (fails until the crate follows it)
imlvts Sep 16, 2026
6002fb9
Restore the node stack after prune_path without ascending
imlvts Sep 16, 2026
7662195
Keep a byte both dense nodes have when its contents meet to nothing
imlvts Sep 16, 2026
96088cf
Never remove the focus in meet_into, and make prune drop dangling pat…
imlvts Sep 16, 2026
15a392d
Meet list nodes slot by slot, keeping the part of each key both sides…
imlvts Sep 16, 2026
f3365be
Give each shrink.py process its own temp file
imlvts Sep 16, 2026
98b9ded
Keep the first k-path's value when drop_head reorders a list node's keys
imlvts Sep 16, 2026
a6940e8
Find the onward child in either slot when stepping to a previous list…
imlvts Sep 16, 2026
e28448e
Make the repro generator skip and call exactly what the harness does
imlvts Sep 16, 2026
9f382a6
Report Identity when subtract_into drops only an empty link beside a …
imlvts Sep 16, 2026
342426b
Add a crash-only fuzz mode for the API the differential harness canno…
imlvts Sep 16, 2026
10a6332
Write down the crash-only fuzzer's findings
imlvts Sep 16, 2026
00b5174
Fix ZipperHead exclusive path at the head's own root
imlvts Sep 17, 2026
8c937f8
Fix zipper_head on a write zipper with a borrowed path
imlvts Sep 17, 2026
3158a51
Fix get_trie_ref on a read zipper that owns its root
imlvts Sep 17, 2026
c0f178a
Fix ProductZipper is_shared at a factor root
imlvts Sep 17, 2026
374dba7
Fix ProductZipper::val_count outside the last factor
imlvts Sep 17, 2026
3cddba7
Fix graft_child_maps below a long root path
imlvts Sep 17, 2026
b20bcfb
Fix get_val_with_witness at an owned zipper's root
imlvts Sep 17, 2026
59edba9
Fix PrefixZipper::descend_first_k_path with k = 0 in the source
imlvts Sep 17, 2026
8905e7c
Fix UB joining a list node with a cell node
imlvts Sep 17, 2026
6c56544
Fix ZipperHead exclusive paths over an empty node
imlvts Sep 17, 2026
7f9c59a
Fix the default k-path walk looping forever at a leaf
imlvts Sep 17, 2026
96bfa48
Fix ProductZipper factor bookkeeping at the root
imlvts Sep 17, 2026
169afc9
Fix product zipper sibling steps at the root
imlvts Sep 17, 2026
bfd2d0e
Fix OverlayZipper focus_byte at the root
imlvts Sep 17, 2026
3c4e277
Fix OverlayZipper::descend_to_val when the second source stops first
imlvts Sep 17, 2026
0080b69
Fix PrefixZipper over a source rooted at a missing path
imlvts Sep 17, 2026
3de7914
Fix TrieRef lookups with a node key longer than its buffer
imlvts Sep 17, 2026
6dfde49
Fix merkleize with several dangling paths
imlvts Sep 17, 2026
503a43c
Fix exclusive zippers rooted at an emptied link
imlvts Sep 17, 2026
24f3125
Fix PrefixZipper fork rooted at the focus
imlvts Sep 17, 2026
9954e44
Allow empty child nodes when joining dangling slots
imlvts Sep 17, 2026
bd41c79
Fix use-after-free between ZipperHead readers and writers
imlvts Sep 17, 2026
bda1eac
crash_fuzz: rerun signal crashes alone, trace more
imlvts Sep 17, 2026
9b8d9ad
Fix ProductZipper sibling step failing at a factor root
imlvts Sep 17, 2026
460ff78
Fix TrieRef::is_shared on an empty node
imlvts Sep 17, 2026
9e15a70
Fix list node join of two empty children under one key
imlvts Sep 17, 2026
6c4e2e5
crash_fuzz: trace dangling paths, masks and paths
imlvts Sep 17, 2026
ee7546e
CRASH_FINDINGS: note the fuzz-fixes-v3 fixes
imlvts Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
381 changes: 381 additions & 0 deletions differential/CRASH_FINDINGS.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions differential/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,26 @@ description = "The Rust side of the differential fuzzing harness for pathmap's z

[dependencies]
pathmap = { path = "..", features = ["arena_compact"] }
# `crash_fuzz` attributes segfaults and aborts to an input from a signal handler.
libc = "0.2"
# Only for `bin/afl_differential.rs`, behind the `afl` feature, so a plain
# `cargo build -p differential` neither needs it nor builds it. See that file.
afl = { version = "0.18", optional = true }

[features]
afl = ["dep:afl"]

# Coverage-guided front end for the in-process comparator. Declared explicitly
# rather than left to src/bin autodiscovery so it can carry `required-features`:
# without it, `cargo build -p differential` would try to compile a file that
# needs the `afl` crate. Build it with `cargo afl build`, not `cargo build` --
# the instrumentation is a rustc flag `cargo afl` sets.
[[bin]]
name = "afl_differential"
path = "src/bin/afl_differential.rs"
required-features = ["afl"]

[[bin]]
name = "afl_crash"
path = "src/bin/afl_crash.rs"
required-features = ["afl"]
89 changes: 89 additions & 0 deletions differential/crash_shrink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Shrink an input that makes `crash_fuzz` fail, keeping the failure the same.

differential/crash_shrink.py <input.bin> [-o out.bin] [--timeout SECS] [--bin PATH]

The failure is identified by its kind and, for a panic, its site (file:line and
the message with numbers blanked), as `crash_fuzz --keep-going` groups them. A
hang is identified by the kind alone and tested with a short timeout, so shrink
hangs with a timeout comfortably above how long the input takes to get stuck.

Greedy: delete chunks of halving size from the end towards the start, then try
lowering each byte, keeping any change that preserves the failure.
"""
import argparse, os, re, subprocess, sys, tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def signature(binary, blob, timeout, tmp):
with open(tmp, "wb") as f:
f.write(blob)
try:
p = subprocess.run([binary, tmp, "-j", "1", "--timeout", str(timeout)],
capture_output=True, timeout=timeout + 20)
except subprocess.TimeoutExpired:
return "hang"
err = p.stderr.decode(errors="replace")
m = re.search(r"CRASH .*?kind=(\w+).*?(?:msg=(.*))?$", err, re.M)
if not m:
return None if p.returncode in (0,) else "exit %d" % p.returncode
kind, msg = m.group(1), m.group(2) or ""
if kind == "panic":
site = re.sub(r"\b\d+\b", "N", msg.split(" | ")[0])
site = re.sub(r"(src/[\w/]+\.rs):N:N", lambda s: s.group(0), site)
loc = re.search(r"panicked at (\S+?):(\d+):(\d+)", msg)
return "panic %s:%s %s" % (loc.group(1), loc.group(2), site.split(": ", 1)[-1]) if loc else "panic " + site
if kind == "signal":
return "signal " + msg.split(" ")[1] if msg else "signal"
return kind


def main():
ap = argparse.ArgumentParser()
ap.add_argument("input")
ap.add_argument("-o", "--out")
ap.add_argument("--timeout", type=int, default=3)
ap.add_argument("--bin", default=os.path.join(ROOT, "target", "release", "crash_fuzz"))
a = ap.parse_args()
blob = open(a.input, "rb").read()
tmp = os.path.join(tempfile.gettempdir(), "crash-shrink-%d.bin" % os.getpid())
want = signature(a.bin, blob, a.timeout, tmp)
if want is None:
sys.exit("input does not fail")
print("signature:", want, file=sys.stderr)

def ok(b):
return signature(a.bin, b, a.timeout, tmp) == want

chunk = max(1, len(blob) // 2)
while chunk >= 1:
i = len(blob) - chunk
changed = False
while i >= 0:
cand = blob[:i] + blob[i + chunk:]
if ok(cand):
blob = cand
changed = True
i -= chunk
print("chunk %d -> %d bytes" % (chunk, len(blob)), file=sys.stderr)
if not changed:
chunk //= 2
for i in range(len(blob)):
for v in (0, 1, blob[i] // 2):
if v < blob[i]:
cand = blob[:i] + bytes([v]) + blob[i + 1:]
if ok(cand):
blob = cand
break
out = a.out or re.sub(r"(\.bin)?$", ".min.bin", a.input, count=1)
open(out, "wb").write(blob)
try:
os.unlink(tmp)
except OSError:
pass
print("%s: %d bytes, %s" % (out, len(blob), want))


if __name__ == "__main__":
main()
26 changes: 26 additions & 0 deletions differential/src/bin/afl_crash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//! Coverage-guided front end for the crash-only table in [`differential::crash`].
//!
//! ```text
//! cargo afl build --release -p differential --features afl --bin afl_crash
//! mkdir -p out/afl-crash-in && target/release/crash_fuzz --random 64 --dump 0 > out/afl-crash-in/0
//! cargo afl fuzz -i out/afl-crash-in -o out/afl-crash-out -t 5000 target/release/afl_crash
//! ```
//!
//! `cargo afl build` turns on debug assertions and overflow checks, so replay
//! findings with a `crash_fuzz` built the same way (see its docs); a plain
//! release `crash_fuzz` runs most of them clean.
//!
//! AFL runs each input in a forked child, so a panic, an abort and a hang each
//! cost one child and are saved (`crashes/`, `hangs/`). Replay them with
//! `crash_fuzz <files>`, which names the panic site, and group them with
//! `crash_fuzz --keep-going <files>`. As with `afl_differential`, check
//! `hangs/` as well as `crashes/`: with a piped `core_pattern`, AFL can file
//! a crash as a hang.

use differential::crash::run;

fn main() {
afl::fuzz!(|data: &[u8]| {
run(data);
});
}
110 changes: 110 additions & 0 deletions differential/src/bin/afl_differential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! Coverage-guided differential fuzzing: the Rust reference model against the
//! real crate, driven by AFL++ instead of by a random byte generator.
//!
//! ```text
//! cargo install cargo-afl # once
//! cargo afl build --release -p differential --features afl
//! ./lean/afl-seed.sh out/afl-in # or any corpus
//! cargo afl fuzz -i out/afl-in -o out/afl-out \
//! target/release/afl_differential
//! ```
//!
//! # Why a second front end at all
//!
//! `in_process` generates uniformly random bytes. That is the right shape for
//! *measuring* — a rate over a known distribution, comparable with
//! `differential.py`'s — and the wrong shape for *finding*: the divergence
//! classes it turns up sit between 1 in 30,000 and 1 in 2,250,000 inputs, which
//! is what a blind sampler costs when the interesting programs are a thin set.
//! AFL keeps the inputs that reached new edges and mutates those, so a 40-op
//! program that got a write zipper into an unusual node representation becomes
//! the stem for the next thousand. Same comparison, same two op tables, better
//! search.
//!
//! The wire format suits it: every operand is a byte, reduced mod a small number
//! at the point of use, so AFL's byte flips, arithmetic and splices all land on
//! op selectors and path bytes rather than being rejected by a parser. There is
//! no checksum, no length prefix over the whole input, and a truncated input is
//! a valid shorter program.
//!
//! # Why this is safe where `in_process` has to be careful
//!
//! `in_process` runs every input in one long-lived process, so a panic out of
//! the middle of a trie mutation cannot be caught and recovered from —
//! `pathmap` is not unwind-safe, and dropping half-updated refcounted nodes
//! while the stack unwinds corrupts the heap. It therefore reports from a panic
//! *hook* and exits.
//!
//! AFL removes the problem rather than working around it. Each input runs in a
//! child forked from the fork server, and `afl::fuzz!` installs a hook that
//! **aborts** rather than unwinds. So a panicking input kills one child, is
//! written to `out/afl-out/default/crashes/`, and fuzzing continues from the
//! next one. Corruption cannot outlive the input that caused it. That is the
//! same property the subprocess design in `differential.py` bought, at a
//! fraction of the cost, and it is why this file does not reproduce
//! `in_process`'s panic-hook dance.
//!
//! A *divergence* is reported the same way a panic is — by panicking — so AFL
//! saves the input. Replay one with the plain comparator, which prints the
//! differing line rather than a backtrace:
//!
//! ```text
//! target/release/in_process out/afl-out/default/crashes/id:* out/afl-out/default/hangs/id:*
//! ./lean/differential.py out/afl-out/default/crashes/* # KNOWN-table breakdown
//! ```
//!
//! # Look in `hangs/` as well as `crashes/`
//!
//! Which of the two a divergence lands in is a property of the *machine*, not of
//! the finding. AFL decides "crashed" by reaping the child and reading its
//! signal, and when `/proc/sys/kernel/core_pattern` is a pipe — apport, systemd
//! -coredump, any distro default — the kernel hands the corpse to that helper
//! first, so AFL's wait races the helper and times out instead. It says so at
//! startup ("To avoid having crashes misinterpreted as timeouts...") and
//! `AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1` only silences the refusal to start.
//!
//! Measured here: a 180s run saved **0 crashes and 18 hangs**, and all 18 hangs
//! replay through `in_process` as real divergences. So always sweep both
//! directories; a genuine timeout (an infinite loop in the crate, itself a
//! finding) is then the input in `hangs/` that `in_process` does *not* flag.
//!
//! `echo core | sudo tee /proc/sys/kernel/core_pattern` (or `cargo afl
//! system-config`) puts them back in `crashes/`, and needs root.
//!
//! # `crashes/` is not a list of new bugs
//!
//! It fills up with the *known* residual defects (`meet_keeps_dangling` and
//! friends) within the first minute, because to this target they are
//! indistinguishable from a new finding. Triage is `differential.py`'s job: it
//! owns the one `KNOWN` table.

use differential::harness::run as crate_run;
use differential::reference::fuzz::run as model_run;

fn main() {
afl::fuzz!(|data: &[u8]| {
// An empty or near-empty input decodes to `EMPTY` on both sides; let AFL
// keep it as a seed anyway, it costs one comparison.
let model = model_run(data, false);
let real = crate_run(data, false);
if model == real {
return;
}
// Panicking is the reporting channel: `afl::fuzz!`'s hook turns it into
// an abort, which AFL records as a crash and saves the input for.
let first = model
.lines()
.zip(real.lines())
.enumerate()
.find(|(_, (a, b))| a != b)
.map(|(i, (a, b))| format!("line {i}\n model: {a}\n crate: {b}"))
.unwrap_or_else(|| {
format!(
"length {} (model) vs {} (crate) lines",
model.lines().count(),
real.lines().count()
)
});
panic!("model/crate divergence:\n{first}");
});
}
Loading