Skip to content

Commit 4d61990

Browse files
figtracerampagent
andcommitted
feat(evm): symbolic-assist worker (concolic-lite v1) for coverage-guided fuzzing
Adds a directed-mutation worker that periodically replays a corpus seed under a branch-trace inspector, finds the deepest unseen opposite-side branch ("frontier"), and proposes ABI-aware calldata rewrites that flip the branch — without invoking an SMT solver. Inspired by Echidna's Echidna.SymExec.Exploration and the SaferMaker writeup (https://hackmd.io/@SaferMaker/EVM-Sym-Exec). Architecture: - BranchTraceInspector (crates/evm/evm/src/inspectors/branch_trace.rs): observation-only REVM Inspector that records every JUMPI together with the immediately-preceding compare opcode (EQ/LT/GT/SLT/SGT/ISZERO) and its concrete operands. Uses the same edge-id hashing as EdgeCovInspector so frontier ids can be looked up directly in the corpus history map. - crates/evm/evm/src/executors/symexec/: - types.rs: FrontierKey, FrontierStats, SymExecState, Candidate, hard caps. - select.rs: seed scoring (favored > productive > shorter), deepest unseen-frontier picker, attempt bookkeeping. - mutate.rs: ABI-aware Redqueen-style calldata rewrites for scalar args (uintN/intN/bool/address/bytesN). Skips dynamic types in v1. - mod.rs: SymBackend trait + HeuristicAbiRewrite v1 backend + run_symexec_assist top-level loop. Trait stays in place so v2 can swap in z3/hevm/halmos without changing the loop. - WorkerCorpus helpers: is_master, symexec_seed_pool, history_map_snapshot, master_sync_dir, symexec_validate. The validator clones the executor and history map and only reports new edges; it never mutates the live history map, so the next calibrate() picks up the persisted file in the normal way without poisoning the corpus. - Config: FuzzCorpusConfig.symexec_assist (bool, default false) + symexec_assist_interval (u32, default 200) + symexec_assist_active() guard (requires coverage-guided + EVM edge coverage; sancov-edges disables it). - Wiring: - executors/fuzz/mod.rs: master worker only, periodic call after each sync, stateful = false. - executors/invariant/mod.rs: single worker, periodic call at the top of the run loop, stateful = true (commits prefix txs during replay/validation, resolves the final tx's ABI dynamically via FuzzRunIdentifiedContracts. - Inspector plumbing: branch_trace propagates through InspectorStack -> InspectorData -> RawCallResult.branch_trace (mirrors the existing edge_coverage path). Validation gate ensures only candidates that produce a real new EVM edge are persisted to <corpus_dir>/worker0/sync/, where the existing corpus protocol distributes them to other workers. Hard CPU budget per cycle: 1 seed, 1 frontier, <=8 candidates, max 3 attempts per frontier per campaign. Tests: - forge cli: symexec_assist_solves_equality_guard (stateless fuzz) — handler with require(x == MAGIC) where MAGIC is a 32-byte distinctive value; asserts the corpus directory contains the magic bytes after the run. - forge cli: symexec_assist_solves_equality_guard_invariant (stateful invariant) — same handler exposed via targetContract; trivial invariant; same structural corpus check. All existing tests still pass: forge cli fuzz tests: 16 passed (incl. new regression) forge cli invariant tests: 68 passed (incl. new regression) foundry-evm lib tests: 21 passed foundry-config lib tests: 154 passed Future extensions kept open by the SymBackend trait: - Z3PathFlip backend (real SMT) - ExternalHevm / ExternalHalmos backends - Mutating the final-tx sender for access-control branches EOF ) Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp <amp@ampcode.com>
1 parent c6fcd87 commit 4d61990

15 files changed

Lines changed: 1251 additions & 4 deletions

File tree

crates/config/src/fuzz.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,18 @@ pub struct FuzzCorpusConfig {
135135
/// Whether to capture comparison operands from sancov-instrumented crates
136136
/// and inject them into the fuzz dictionary. Independent of `sancov_edges`.
137137
pub sancov_trace_cmp: bool,
138+
/// Whether to enable the symbolic-assist worker. When set, the master
139+
/// fuzz worker periodically replays a corpus seed under a branch-trace
140+
/// inspector, proposes ABI-aware calldata mutations that flip an
141+
/// uncovered branch, validates them through the normal coverage gate,
142+
/// and writes accepted candidates into its `sync/` directory so the
143+
/// existing corpus protocol distributes them.
144+
/// Requires `corpus_dir` to be set and EVM edge coverage to be enabled
145+
/// (i.e. `sancov_edges` off).
146+
pub symexec_assist: bool,
147+
/// Number of fuzz runs between symbolic-assist cycles on the master
148+
/// worker. Ignored when `symexec_assist` is `false`.
149+
pub symexec_assist_interval: u32,
138150
}
139151

140152
impl FuzzCorpusConfig {
@@ -178,6 +190,13 @@ impl FuzzCorpusConfig {
178190
pub const fn is_coverage_guided(&self) -> bool {
179191
self.corpus_dir.is_some()
180192
}
193+
194+
/// Whether the symbolic-assist worker should run. v1 only supports the
195+
/// EVM edge-coverage signal, so we require coverage-guided mode and
196+
/// disable assist when sancov edges are taking over.
197+
pub const fn symexec_assist_active(&self) -> bool {
198+
self.symexec_assist && self.is_coverage_guided() && !self.sancov_edges
199+
}
181200
}
182201

183202
impl Default for FuzzCorpusConfig {
@@ -190,6 +209,8 @@ impl Default for FuzzCorpusConfig {
190209
show_edge_coverage: false,
191210
sancov_edges: false,
192211
sancov_trace_cmp: false,
212+
symexec_assist: false,
213+
symexec_assist_interval: 200,
193214
}
194215
}
195216
}

crates/evm/evm/src/executors/corpus.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,88 @@ impl WorkerCorpus {
11161116
Ok(())
11171117
}
11181118

1119+
// -- Symbolic-assist helpers ------------------------------------------
1120+
//
1121+
// These accessors are used by `crate::executors::symexec::run_symexec_assist`
1122+
// to drive the master worker's symbolic-assist cycle without exposing
1123+
// `CorpusEntry` internals to the rest of the crate.
1124+
1125+
/// Whether this is the master worker (the only one allowed to drive
1126+
/// symbolic assist in v1).
1127+
pub const fn is_master(&self) -> bool {
1128+
self.id == 0
1129+
}
1130+
1131+
/// Snapshot a small candidate pool of seeds for symbolic-assist seed
1132+
/// scoring. Returns at most `MAX` entries (kept tiny so scoring stays
1133+
/// cheap).
1134+
pub fn symexec_seed_pool(&self) -> Vec<crate::executors::symexec::SeedSnapshot> {
1135+
const MAX: usize = 16;
1136+
self.in_memory_corpus
1137+
.iter()
1138+
.rev()
1139+
.take(MAX)
1140+
.map(|e| crate::executors::symexec::SeedSnapshot {
1141+
uuid: e.uuid,
1142+
tx_seq: e.tx_seq.clone(),
1143+
is_favored: e.is_favored,
1144+
new_finds_produced: e.new_finds_produced,
1145+
})
1146+
.collect()
1147+
}
1148+
1149+
/// Returns a copy of the current EVM history map. Used by the symbolic
1150+
/// worker to detect frontier (currently-unseen) edges without holding
1151+
/// the corpus mutably for the duration of replay.
1152+
pub fn history_map_snapshot(&self) -> Vec<u8> {
1153+
self.history_map.clone()
1154+
}
1155+
1156+
/// Path to the master worker's `sync/` directory. The symbolic-assist
1157+
/// worker writes accepted candidates here so the next `sync()` cycle
1158+
/// imports them.
1159+
pub fn master_sync_dir(&self) -> Option<PathBuf> {
1160+
self.config.corpus_dir.as_ref().map(|d| d.join(format!("{WORKER}0")).join(SYNC_DIR))
1161+
}
1162+
1163+
/// Validate a candidate sequence produced by the symbolic-assist
1164+
/// worker: re-run it through a clone of `executor` and report whether
1165+
/// it produced at least one previously-unseen EVM edge against a
1166+
/// snapshot of the history map. Does *not* mutate `self.history_map`
1167+
/// — the next `calibrate()` call will fold in the persisted file's
1168+
/// coverage in the normal way.
1169+
///
1170+
/// `stateful` mirrors `WorkerCorpus::new`'s replay behavior: when
1171+
/// `true`, intermediate txs are committed so later txs see the prefix
1172+
/// state (invariant tests). When `false`, each tx runs against the
1173+
/// post-`setUp` baseline (stateless fuzz).
1174+
pub fn symexec_validate<FEN: FoundryEvmNetwork>(
1175+
&self,
1176+
executor: &Executor<FEN>,
1177+
candidate: &[BasicTxDetails],
1178+
stateful: bool,
1179+
) -> Result<bool> {
1180+
if !self.config.collect_evm_edge_coverage() {
1181+
return Ok(false);
1182+
}
1183+
let mut executor = executor.clone();
1184+
let mut history = self.history_map.clone();
1185+
let mut sancov_history = self.sancov_history_map.clone();
1186+
let mut produced_new = false;
1187+
for tx in candidate {
1188+
let mut result = execute_tx(&mut executor, tx)?;
1189+
let (new_coverage, _is_edge) =
1190+
result.merge_all_coverage(&mut history, &mut sancov_history);
1191+
if new_coverage {
1192+
produced_new = true;
1193+
}
1194+
if stateful {
1195+
executor.commit(&mut result);
1196+
}
1197+
}
1198+
Ok(produced_new)
1199+
}
1200+
11191201
/// Helper to check if a tx can be replayed.
11201202
fn can_replay_tx(
11211203
tx: &BasicTxDetails,

crates/evm/evm/src/executors/fuzz/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::executors::{
22
DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult,
33
corpus::{GlobalCorpusMetrics, WorkerCorpus},
4+
symexec::{self, SymExecState},
45
};
56
use alloy_dyn_abi::JsonAbiExt;
67
use alloy_json_abi::Function;
@@ -499,6 +500,18 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
499500
let sync_threshold = SYNC_INTERVAL + sync_offset;
500501
let mut runs_since_sync = sync_threshold; // Always sync at the start.
501502
let mut last_metrics_report = Instant::now();
503+
504+
// Symbolic-assist state — only initialised on the master worker
505+
// (`worker_id == 0`). The master periodically replays a corpus seed
506+
// under a branch-trace inspector, proposes ABI-aware mutations
507+
// that flip an unseen branch, validates them via normal coverage,
508+
// and writes accepted candidates into its own `sync/` directory so
509+
// the existing corpus protocol distributes them.
510+
let symexec_active =
511+
worker_id == 0 && self.config.corpus.symexec_assist_active();
512+
let symexec_interval = self.config.corpus.symexec_assist_interval.max(1);
513+
let mut runs_since_symexec: u32 = 0;
514+
let mut symexec_state = SymExecState::default();
502515
// Continue while:
503516
// 1. Global state allows (not timed out, not at global limit, no failure found)
504517
// 2. Worker hasn't reached its specific run limit
@@ -540,6 +553,38 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
540553
runs_since_sync = 0;
541554
}
542555

556+
if symexec_active {
557+
runs_since_symexec += 1;
558+
if runs_since_symexec >= symexec_interval {
559+
let timer = Instant::now();
560+
match symexec::run_symexec_assist(
561+
&mut corpus,
562+
&executor,
563+
Some(func),
564+
None,
565+
&mut symexec_state,
566+
/* stateful */ false,
567+
) {
568+
Ok(n) if n > 0 => {
569+
trace!(
570+
target: "corpus",
571+
"symexec assist accepted {n} candidates in {:?}",
572+
timer.elapsed()
573+
);
574+
}
575+
Ok(_) => {}
576+
Err(err) => {
577+
debug!(
578+
target: "corpus",
579+
%err,
580+
"symexec assist cycle errored"
581+
);
582+
}
583+
}
584+
runs_since_symexec = 0;
585+
}
586+
}
587+
543588
let fuzz_run = self.config.run.unwrap_or(worker.runs + 1);
544589
if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
545590
&& let Some(seed) = self.config.seed

crates/evm/evm/src/executors/invariant/mod.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::{
22
executors::{
33
DURATION_BETWEEN_METRICS_REPORT, EarlyExit, EvmError, Executor, FuzzTestTimer,
4-
RawCallResult, corpus::WorkerCorpus,
4+
RawCallResult,
5+
corpus::WorkerCorpus,
6+
symexec::{self, SymExecState},
57
},
68
inspectors::Fuzzer,
79
};
@@ -462,7 +464,47 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
462464
// Invariant runs with edge coverage if corpus dir is set or showing edge coverage.
463465
let edge_coverage_enabled = self.config.corpus.collect_edge_coverage();
464466

467+
// Symbolic-assist state for invariant tests. The invariant runner
468+
// is single-worker, so the assist always runs (when enabled) on
469+
// the master corpus.
470+
let symexec_active = self.config.corpus.symexec_assist_active();
471+
let symexec_interval = self.config.corpus.symexec_assist_interval.max(1);
472+
let mut runs_since_symexec: u32 = 0;
473+
let mut symexec_state = SymExecState::default();
474+
465475
'stop: while continue_campaign(runs) {
476+
if symexec_active {
477+
runs_since_symexec += 1;
478+
if runs_since_symexec >= symexec_interval {
479+
let timer = Instant::now();
480+
match symexec::run_symexec_assist(
481+
&mut corpus_manager,
482+
&self.executor,
483+
None,
484+
Some(&invariant_test.targeted_contracts),
485+
&mut symexec_state,
486+
/* stateful */ true,
487+
) {
488+
Ok(n) if n > 0 => {
489+
trace!(
490+
target: "corpus",
491+
"symexec assist accepted {n} candidates in {:?}",
492+
timer.elapsed()
493+
);
494+
}
495+
Ok(_) => {}
496+
Err(err) => {
497+
debug!(
498+
target: "corpus",
499+
%err,
500+
"symexec assist cycle errored"
501+
);
502+
}
503+
}
504+
runs_since_symexec = 0;
505+
}
506+
}
507+
466508
// Per-run failure count snapshot used to gate `afterInvariant` below.
467509
let failures_before_run = invariant_test.test_data.failures.errors.len();
468510

crates/evm/evm/src/executors/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub use invariant::InvariantExecutor;
6363

6464
mod corpus;
6565
mod sancov;
66+
pub mod symexec;
6667
mod trace;
6768

6869
pub use trace::TracingExecutor;
@@ -961,6 +962,8 @@ pub struct RawCallResult<FEN: FoundryEvmNetwork = EthEvmNetwork> {
961962
pub line_coverage: Option<HitMaps>,
962963
/// The edge coverage info collected during the call
963964
pub edge_coverage: Option<Vec<u8>>,
965+
/// Branch observations collected by the symbolic-assist worker.
966+
pub branch_trace: Option<crate::inspectors::BranchTrace>,
964967
/// Sancov edge coverage from instrumented native Rust crates (e.g. precompiles).
965968
/// Tracked separately from EVM edge coverage to avoid ID-space collisions.
966969
pub sancov_coverage: Option<Vec<u8>>,
@@ -998,6 +1001,7 @@ impl<FEN: FoundryEvmNetwork> Default for RawCallResult<FEN> {
9981001
traces: None,
9991002
line_coverage: None,
10001003
edge_coverage: None,
1004+
branch_trace: None,
10011005
sancov_coverage: None,
10021006
sancov_cmp_values: None,
10031007
transactions: None,
@@ -1217,6 +1221,7 @@ fn convert_executed_result<FEN: FoundryEvmNetwork>(
12171221
traces,
12181222
line_coverage,
12191223
edge_coverage,
1224+
branch_trace,
12201225
cheatcodes,
12211226
chisel_state,
12221227
reverter,
@@ -1244,6 +1249,7 @@ fn convert_executed_result<FEN: FoundryEvmNetwork>(
12441249
traces,
12451250
line_coverage,
12461251
edge_coverage,
1252+
branch_trace,
12471253
sancov_coverage: None,
12481254
sancov_cmp_values: None,
12491255
transactions,

0 commit comments

Comments
 (0)