Skip to content

Commit 26d67cb

Browse files
authored
Merge pull request #1 from daedalus/copilot/bayes-theorem-advantage
Seed Thompson-sampling bandit with informative priors from static target profiling
2 parents c09b486 + ec33513 commit 26d67cb

6 files changed

Lines changed: 188 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Coverage-guided binary fuzzer with static target analysis, statistical novelty s
2020
- **TargetProfiler**: ELF static analysis at startup — extracts string constants, function boundaries, magic bytes, and input format hints
2121
- **Auto-populated dictionary**: interesting strings (format specifiers, error messages, keywords) and magic bytes extracted from `.rodata`
2222
- **Format-aware seed generation**: produces structurally meaningful initial seeds (PNG headers, text protocols, JSON, XML, HTML) based on inferred format
23+
- **Informative Bayesian priors**: `format_operator_priors()` seeds the Thompson-sampling bandit's Beta prior toward structure-aware operators (e.g. `png_chunk_mutate`) and dictionary operators when static analysis detects a matching format or extractable tokens, instead of always starting from the uninformative Beta(1, 1)
2324
- **Hot-function weighting**: seeds exercising high-branch-density functions get a proportional boost in selection
2425
- **Crash ETA estimation**: blends static risky density (keyword heuristic) with dynamic I(byte_position; crash) mutual information from actual executions, plus Good-Turing edge estimates and calibrated discovery rate — the MI signal strengthens as fuzzing accumulates near-miss data
2526

‎src/fuzzer_tool/core/montecarlo.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222

2323
log = logging.getLogger(__name__)
2424

25+
# Lower clamp for Beta(alpha, beta) parameters passed to init_arm(): keeps
26+
# random.betavariate() numerically stable for malformed/degenerate priors
27+
# (e.g. a caller passing 0 or a negative value) without silently overriding
28+
# intentionally weak-but-valid priors above this threshold.
29+
MIN_BETA_PARAM = 1e-6
30+
2531

2632
class MonteCarloScheduler:
2733
"""Thompson sampling bandit for mutation ops + CEM byte distribution.
@@ -43,6 +49,11 @@ class MonteCarloScheduler:
4349
"""
4450

4551
ELITE_MAX = 200
52+
# Declares that init_arm() accepts an informative (prior_alpha, prior_beta)
53+
# override, unlike MOptScheduler/ReplicatorScheduler/EloTracker which use
54+
# non-Bayesian internal representations (particle positions, population
55+
# simplex, Elo ratings).
56+
supports_priors = True
4657

4758
def __init__(
4859
self,
@@ -79,15 +90,25 @@ def __init__(
7990
# Blend factor: 0.0 = pure Thompson, 1.0 = pure pairwise
8091
self.pairwise_blend = pairwise_blend
8192

82-
def init_arm(self, name: str) -> None:
83-
"""Register a mutation operator arm with prior (1, 1).
93+
def init_arm(self, name: str, prior_alpha: float = 1.0, prior_beta: float = 1.0) -> None:
94+
"""Register a mutation operator arm with a Beta prior.
95+
96+
Defaults to the uninformative Beta(1, 1) prior. Callers with prior
97+
knowledge about an operator's likely usefulness (e.g. static target
98+
profiling indicating a specific file format) can pass a stronger
99+
prior to bias early Thompson sampling before any evidence has been
100+
observed. A no-op if the arm is already registered — the prior only
101+
applies at first registration and is never overwritten by later
102+
calls, matching the existing idempotent behavior of this method.
84103
85104
Args:
86105
name: Name of the mutation operator.
106+
prior_alpha: Prior alpha (successes + 1). Must be > 0.
107+
prior_beta: Prior beta (failures + 1). Must be > 0.
87108
"""
88109
if name not in self.arm_alpha:
89-
self.arm_alpha[name] = 1.0
90-
self.arm_beta[name] = 1.0
110+
self.arm_alpha[name] = max(prior_alpha, MIN_BETA_PARAM)
111+
self.arm_beta[name] = max(prior_beta, MIN_BETA_PARAM)
91112

92113
def select_op(self, ops: list[str], prev_op: str | None = None) -> str:
93114
"""Select mutation operator via Thompson sampling with pairwise transitions.

‎src/fuzzer_tool/core/target_profiler.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,3 +612,68 @@ def _compute_call_depths(self, profile: TargetProfile):
612612
seen.add(p)
613613
unique.append(p)
614614
profile.entry_points = unique
615+
616+
617+
# Format signature -> structure-aware mutation operators that are almost
618+
# certainly useful for that format (see core.mutations.FORMAT_MUTATIONS).
619+
_FORMAT_OPERATOR_HINTS: dict[str, tuple[str, ...]] = {
620+
"png": ("png_chunk_mutate", "png_crc_fix"),
621+
"jpeg": ("jpeg_chunk_mutate", "jpeg_crc_fix"),
622+
"gzip": ("gzip_chunk_mutate",),
623+
"bz2": ("gzip_chunk_mutate",),
624+
"xz": ("gzip_chunk_mutate",),
625+
"zlib": ("zlib_chunk_mutate",),
626+
"riff": ("bmp_chunk_mutate",),
627+
}
628+
629+
# Dictionary/token-aware mutation operators (see core.mutations.DICT_MUTATIONS).
630+
_DICT_OPERATORS: tuple[str, ...] = (
631+
"dict_insert",
632+
"dict_replace",
633+
"dict_overwrite",
634+
"dict_prepend",
635+
"dict_append",
636+
"checksum_repair",
637+
"token_dup",
638+
)
639+
640+
# Beta prior for operators the profile suggests are relevant: same total
641+
# "pseudo-observation" mass as the uninformative default (1, 1), but shifted
642+
# toward success so Thompson sampling favors them before real evidence
643+
# arrives. Weak enough that a handful of real failures will correct it.
644+
_BOOSTED_PRIOR: tuple[float, float] = (2.0, 1.0)
645+
646+
647+
def format_operator_priors(profile: "TargetProfile") -> dict[str, tuple[float, float]]:
648+
"""Derive informative Beta priors for mutation operators from a profile.
649+
650+
Static analysis (magic bytes, boundary markers, extracted interesting
651+
strings, format signature) is prior knowledge about which
652+
structure-aware mutation operators are likely to be useful *before*
653+
any executions have happened. This lets the Thompson-sampling bandit
654+
(:class:`fuzzer_tool.core.montecarlo.MonteCarloScheduler`) start with a
655+
Beta prior biased toward those operators instead of the uninformative
656+
Beta(1, 1) used for every arm by default.
657+
658+
Args:
659+
profile: A populated :class:`TargetProfile`.
660+
661+
Returns:
662+
Mapping of operator name to a (prior_alpha, prior_beta) override.
663+
Operators not present in the mapping should use the default
664+
Beta(1, 1) prior.
665+
"""
666+
priors: dict[str, tuple[float, float]] = {}
667+
668+
fmt = profile.format_signature
669+
if fmt:
670+
for op in _FORMAT_OPERATOR_HINTS.get(fmt, ()):
671+
priors[op] = _BOOSTED_PRIOR
672+
673+
# Boost dict-aware operators whenever static analysis extracted at least
674+
# one usable token (non-empty list is truthy; empty list is falsy).
675+
if profile.magic_bytes or profile.boundary_markers or profile.interesting_strings:
676+
for op in _DICT_OPERATORS:
677+
priors[op] = _BOOSTED_PRIOR
678+
679+
return priors

‎src/fuzzer_tool/services/fuzzer.py‎

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -544,22 +544,43 @@ def __init__(
544544

545545
self._tracer = CrashTracer(target)
546546

547-
def _register_arms(scheduler):
548-
"""Register all mutation arms on a scheduler (mc, mopt, replicator, elo)."""
547+
def _register_arms(scheduler, priors=None):
548+
"""Register all mutation arms on a scheduler (mc, mopt, replicator, elo).
549+
550+
Args:
551+
scheduler: Scheduler exposing init_arm(name).
552+
priors: Optional dict of operator name -> (prior_alpha,
553+
prior_beta) overrides. Only meaningful for the
554+
Beta-Bernoulli Thompson-sampling scheduler; ignored for
555+
schedulers whose init_arm() doesn't accept a prior.
556+
"""
557+
priors = priors or {}
558+
559+
def _init(op):
560+
prior = priors.get(op) if getattr(scheduler, "supports_priors", False) else None
561+
if prior is not None and len(prior) == 2:
562+
scheduler.init_arm(op, *prior)
563+
else:
564+
scheduler.init_arm(op)
565+
549566
for op in MUTATIONS:
550-
scheduler.init_arm(op)
567+
_init(op)
551568
for op in DICT_MUTATIONS:
552-
scheduler.init_arm(op)
553-
scheduler.init_arm("markov_bytes")
554-
scheduler.init_arm("cem_bytes")
569+
_init(op)
570+
_init("markov_bytes")
571+
_init("cem_bytes")
555572
if self.grammar:
556-
scheduler.init_arm("grammar_mutate")
557-
scheduler.init_arm("grammar_tree_mutate")
573+
_init("grammar_mutate")
574+
_init("grammar_tree_mutate")
558575
for op in FORMAT_MUTATIONS:
559-
scheduler.init_arm(op)
576+
_init(op)
577+
578+
from fuzzer_tool.core.target_profiler import format_operator_priors
579+
580+
_format_priors = format_operator_priors(self._profile)
560581

561582
if self.mc and self.mc_bandit:
562-
_register_arms(self.mc)
583+
_register_arms(self.mc, _format_priors)
563584
if self._mopt:
564585
_register_arms(self._mopt)
565586
if self._replicator:

‎tests/test_montecarlo.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ def test_init_arm_idempotent(self):
2525
mc.init_arm("bit_flip")
2626
assert mc.arm_alpha["bit_flip"] == 1.0
2727

28+
def test_init_arm_informative_prior(self):
29+
mc = MonteCarloScheduler()
30+
mc.init_arm("png_chunk_mutate", prior_alpha=2.0, prior_beta=1.0)
31+
assert mc.arm_alpha["png_chunk_mutate"] == 2.0
32+
assert mc.arm_beta["png_chunk_mutate"] == 1.0
33+
34+
def test_init_arm_prior_not_overwritten_on_reregister(self):
35+
mc = MonteCarloScheduler()
36+
mc.init_arm("bit_flip", prior_alpha=5.0, prior_beta=1.0)
37+
mc.init_arm("bit_flip") # default prior, arm already registered
38+
assert mc.arm_alpha["bit_flip"] == 5.0
39+
40+
def test_init_arm_prior_clamped_positive(self):
41+
mc = MonteCarloScheduler()
42+
mc.init_arm("op", prior_alpha=0.0, prior_beta=-1.0)
43+
assert mc.arm_alpha["op"] > 0
44+
assert mc.arm_beta["op"] > 0
45+
2846
def test_select_op(self):
2947
mc = MonteCarloScheduler()
3048
mc.init_arm("bit_flip")

‎tests/test_target_profiler.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import struct
55
import tempfile
6+
import math
67

78
import pytest
89

@@ -11,6 +12,8 @@
1112
TargetProfiler,
1213
TargetProfile,
1314
FunctionInfo,
15+
_FORMAT_OPERATOR_HINTS,
16+
format_operator_priors,
1417
)
1518

1619

@@ -186,3 +189,48 @@ def test_boundary_markers_are_bytes(self):
186189
p.boundary_markers = [b":", b"/", b"\n"]
187190
for bm in p.boundary_markers:
188191
assert isinstance(bm, bytes)
192+
193+
194+
class TestFormatOperatorPriors:
195+
def test_no_hints_when_profile_empty(self):
196+
p = TargetProfile()
197+
assert format_operator_priors(p) == {}
198+
199+
def test_png_format_boosts_png_operators(self):
200+
p = TargetProfile()
201+
p.format_signature = "png"
202+
priors = format_operator_priors(p)
203+
assert priors["png_chunk_mutate"] == (2.0, 1.0)
204+
assert priors["png_crc_fix"] == (2.0, 1.0)
205+
assert "jpeg_chunk_mutate" not in priors
206+
207+
def test_unknown_format_no_format_hints(self):
208+
p = TargetProfile()
209+
p.format_signature = "text"
210+
priors = format_operator_priors(p)
211+
all_format_ops = {op for ops in _FORMAT_OPERATOR_HINTS.values() for op in ops}
212+
assert not (set(priors) & all_format_ops)
213+
214+
def test_magic_bytes_boost_dict_operators(self):
215+
p = TargetProfile()
216+
p.magic_bytes = [b"\x89PNG\r\n\x1a\n"]
217+
priors = format_operator_priors(p)
218+
assert priors["dict_insert"] == (2.0, 1.0)
219+
assert priors["checksum_repair"] == (2.0, 1.0)
220+
221+
def test_boundary_markers_boost_dict_operators(self):
222+
p = TargetProfile()
223+
p.boundary_markers = [b":"]
224+
priors = format_operator_priors(p)
225+
assert "dict_replace" in priors
226+
227+
def test_priors_are_positive_finite_floats(self):
228+
p = TargetProfile()
229+
p.format_signature = "gzip"
230+
p.magic_bytes = [b"\x1f\x8b"]
231+
priors = format_operator_priors(p)
232+
for alpha, beta in priors.values():
233+
assert alpha > 0
234+
assert beta > 0
235+
assert math.isfinite(alpha)
236+
assert math.isfinite(beta)

0 commit comments

Comments
 (0)