Skip to content

smitebot: tmux integration + strategy distribution for start#139

Merged
morehouse merged 1 commit into
morehouse:masterfrom
Ashish-Kumar-Dash:tmux-start
Jul 9, 2026
Merged

smitebot: tmux integration + strategy distribution for start#139
morehouse merged 1 commit into
morehouse:masterfrom
Ashish-Kumar-Dash:tmux-start

Conversation

@Ashish-Kumar-Dash

@Ashish-Kumar-Dash Ashish-Kumar-Dash commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Spawn each afl-fuzz runner in its own tmux window instead of invisible background processes, users see individual AFL++ TUIs, can attach/detach via Ctrl-b d, and reconnect over SSH
  • Distribute AFL++ power schedules, format hints, and env vars across runners (ported from AFL_Runner's MultipleCores mode) for campaign diversity instead of running N identical fuzzers
  • A fuzzer_stats older than launch (leftover in a reused output_dir) is ignored, so a stale PID is never recorded as a live runner.
  • On failure, preserve tmux session for inspection (remain-on-exit) instead of killing it

Strategy distribution

Round-robin power schedules; -a binary on ~70% of secondaries; AFL_DISABLE_TRIM on ~60%; AFL_IMPORT_FIRST when < 16 runners; AFL_FINAL_SYNC on the primary; testcache auto-sized from RAM. IR scenarios auto-inject the custom-mutator env vars. Optional tmux_session config key, validated on CampaignConfig.

tmux session layout

tmux session: "lnd-encrypted_bytes-1749465600" (or custom name from campaign.toml)
├── window 0: "runner-0" → afl-fuzz -Y -M 0 ...
├── window 1: "runner-1" → afl-fuzz -Y -S 1 ...
├── window 2: "runner-2" → afl-fuzz -Y -S 2 ...
└── ...

Each runner command is built as a shell string with exec so afl-fuzz replaces the shell process directly. Environment variables are prepended as KEY='val' assignments, and paths are single-quote escaped.

Features

  • One window per runner, not panes — each runner gets the full AFL++ TUI
  • Deterministic flag distribution by runner index (no rand dependency)
  • Verification now polls two signals:
    fresh fuzzer_stats → runner started
    dead tmux window (pane_dead) → runner failed to launch, reported at once
    neither yet → still calibrating; keep polling to a generous 15-min ceiling that only bounds an alive-but-hung runner
  • Don't kill session on failureremain-on-exit preserves error output for debugging;,user cleans up manually or via future stop command
  • Session name collision doubles as duplicate campaign guard - Nested tmux handled: switch-client when $TMUX is set, attach-session otherwise
  • No new dependencies — tmux CLI called via std::process::Command

Ref. #138 #70

@Ashish-Kumar-Dash Ashish-Kumar-Dash changed the title enh(smitebot): tmux integration + strategy distribution for start feat(smitebot): tmux integration + strategy distribution for start Jul 2, 2026
@Ashish-Kumar-Dash

Copy link
Copy Markdown
Contributor Author

Tested locally with 2 runners on lnd/encrypted_bytes:

Screenshot from 2026-07-03 23-50-39

Build + Nyx sharedir setup + tmux session launch logs

Screenshot from 2026-07-03 23-52-16

Runner 0 ([fast], primary -M), ~440 execs/sec, 119 corpus, trim disabled

Screenshot from 2026-07-03 23-52-22

Runner 1 ([explore], secondary -S), ~449 execs/sec, 108 corpus, 7 imported

Strategy distribution, window naming, and Nyx mode all working as expected. Killed the session with tmux kill-session -t , stop command will automate this.

Comment thread smitebot/src/config.rs Outdated
Comment thread smitebot/src/tmux.rs Outdated
Comment thread smitebot/src/tmux.rs Outdated
Comment thread smitebot/src/tmux.rs Outdated
Comment thread smitebot/src/tmux.rs Outdated
Comment thread smitebot/src/commands/start.rs Outdated
Comment on lines +370 to 376
Some(if free_mb > 32_000 {
500
} else if free_mb > 8_000 {
250
} else {
50
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know 50–500 MB is recommended, but is there any reason behind a threshold like >8_000/32_000?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're arbitrary heuristics, if there's a specific threshold you want me to check for then do inform

Comment thread smitebot/src/commands/start.rs
Comment thread smitebot/src/commands/start.rs Outdated
Comment thread smitebot/src/commands/start.rs

@NishantBansal2003 NishantBansal2003 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I think we need a longer timeout for verification. When I first tried running smitebot, it reported an error, but when I checked the tmux windows, both fuzzers were running normally. I haven’t seen this issue again since then, though:
INFO  [smitebot::commands::start] Nyx sharedir ready at /tmp/smitebot/smite-nyx
INFO  [smitebot::commands::start] starting 2 runners in tmux session 'lnd-encrypted_bytes-1783323599'
INFO  [smitebot::commands::start] verifying runners started
ERROR [smitebot::commands::start] runner 0 did not produce fuzzer_stats within timeout
ERROR [smitebot::commands::start] runner 1 did not produce fuzzer_stats within timeout
ERROR [smitebot::commands::start] not all runners produced fuzzer_stats within timeout
INFO  [smitebot::commands::start] inspect tmux session 'lnd-encrypted_bytes-1783323599' for error output, then: tmux kill-session -t lnd-encrypted_bytes-1783323599
  • Also, when startup succeeds, tmux takes over the terminal (with last runner). Does it make sense to keep printing the smitebot logs normally and run tmux in the background instead? Then, once something like attaching to tmux session 'lnd-encrypted_bytes-1783324069' is printed, we could inspect the session ourselves if needed

Comment thread smitebot/src/tmux.rs Outdated
Comment thread smitebot/src/commands/start.rs Outdated
Comment thread smitebot/src/config.rs Outdated
Comment thread smitebot/README.md Outdated
@Ashish-Kumar-Dash

Ashish-Kumar-Dash commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author
  • I think we need a longer timeout for verification. When I first tried running smitebot, it reported an error, but when I checked the tmux windows, both fuzzers were running normally. I haven’t seen this issue again since then, though:
INFO  [smitebot::commands::start] Nyx sharedir ready at /tmp/smitebot/smite-nyx
INFO  [smitebot::commands::start] starting 2 runners in tmux session 'lnd-encrypted_bytes-1783323599'
INFO  [smitebot::commands::start] verifying runners started
ERROR [smitebot::commands::start] runner 0 did not produce fuzzer_stats within timeout
ERROR [smitebot::commands::start] runner 1 did not produce fuzzer_stats within timeout
ERROR [smitebot::commands::start] not all runners produced fuzzer_stats within timeout
INFO  [smitebot::commands::start] inspect tmux session 'lnd-encrypted_bytes-1783323599' for error output, then: tmux kill-session -t lnd-encrypted_bytes-1783323599
  • Also, when startup succeeds, tmux takes over the terminal (with last runner). Does it make sense to keep printing the smitebot logs normally and run tmux in the background instead? Then, once something like attaching to tmux session 'lnd-encrypted_bytes-1783324069' is printed, we could inspect the session ourselves if needed

The old verify_startup in start.rs used a fixed wall-clock timeout (30s, then 60s) to decide if a runner came up. But fuzzer_stats , the file it polls, is only written after AFL++ calibrates every seed, and under Nyx each exec restores a VM snapshot, so first stats routinely lands minutes in. The timeout was racing a startup cost- it passed warm and failed cold or on larger corpora. When it lost, it didn't just error, it reported a healthy campaign as failed and left the Nyx/QEMU VMs running orphaned (the zombie-process problem). Reproduced live- 30s timeout, first stats at ~46s, panes alive the whole time.

The fix stops timing the outcome. It now decides on process state: a fresh fuzzer_stats means started, a dead tmux window means the launch failed (reported immediately, not after a wait); anything else means still calibrating, so keep polling. Cold vs warm vs corpus size no longer changes the verdict. The 15-min ceiling is a backstop for an alive-but-hung runner.

Also closed an adjacent latent bug: on a reused output_dir, a stale fuzzer_stats from a prior run could be read as a fresh start and record a dead PID (which stop would later signal, orphaning the real VMs). Startup now ignores any fuzzer_stats older than launch. Regression test included.

@NishantBansal2003

Copy link
Copy Markdown
Contributor

IIUC, if the output_dir/seed (25+ min of fuzzing) already exists, smitebot start currently just returns an error unless AFL_AUTORESUME=1 is set. So, were you getting the startup failed error with an existing output_dir and AFL_AUTORESUME=1 enabled? If so, there's also a chance that 15 min wouldn't be enough if the seed directory is large (see: https://github.com/AFLplusplus/AFLplusplus/blob/011cd189801830253c66ecd3cd6919ec01b46c34/src/afl-fuzz-init.c#L1995-L2014)

From what I understood from your reply in #119 (comment), smitebot start is only meant for starting a new campaign, so the output_dir should not already exist. Based on that, if the output_dir is present, we could instead treat it as a resume mode (which doesn’t currently exist), or handle resuming separately.

Leaving that case aside, we're only left with starting a fresh campaign, either as a cold or warm start, which I believe should be covered by simply increasing the timeout to 60s (or appropriate). WDYT?

@Ashish-Kumar-Dash

Ashish-Kumar-Dash commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

From what I understood from your reply in #119 (comment), smitebot start is only meant for starting a new campaign, so the output_dir should not already exist. Based on that, if the output_dir is present, we could instead treat it as a resume mode (which doesn’t currently exist), or handle resuming separately.

Yes, that should be covered in smitebot resume, I'll make the changes for start to be new campaign only, I'll add resume mode in later PR. Apologies for the confusion

IIUC, if the output_dir/seed (25+ min of fuzzing) already exists, smitebot start currently just returns an error unless AFL_AUTORESUME=1 is set. So, were you getting the startup failed error with an existing output_dir and AFL_AUTORESUME=1 enabled?

no, It was a plain fresh start.

Leaving that case aside, we're only left with starting a fresh campaign, either as a cold or warm start, which I believe should be covered by simply increasing the timeout to 60s (or appropriate). WDYT?

I'd push back on a fixed 60s. Liveness doesn't depend on guessing how long calibration takes. It fails fast when a runner's window dies, and waits while it's alive, so a healthy-but-slow runner is never reported as failed, regardless of corpus size or cold/warm machine. A fixed 60s is a bet on calibration finishing in time, my measured run was already ~45s, so 60s has ~15s of margin and maybe loses it on a colder machine or larger corpus. The 15-min ceiling is only a checkpost for a truly hung runner, not the success criteria. That's the reason why I made the fix, 60s can wrongly fail a working campaign, liveness only "fails" a runner that has actually died or hung.

If the liveness check ever feels like too much, we can always fall back to 60s/fixed timeout, it's a small change. But since this PR's real weight is the tmux integration + strategy distribution, I'd happy to revisit the timeout approach in a follow-up. When people start using smitebot start regularly, which is what this PR aims, we'd have more data to make an informed decision for later.

Comment thread smitebot/src/config.rs Outdated
Comment thread smitebot/src/config.rs Outdated
Comment thread smitebot/src/commands/start.rs
Comment thread smitebot/src/commands/start.rs Outdated
Comment thread smitebot/src/commands/start.rs Outdated
Launch parallel afl-fuzz runners in a tmux session (one window per
runner) with a deterministic per-runner strategy distribution. Verify
startup by polling fuzzer_stats: fail fast when a runner's window dies,
and bound an alive-but-hung runner with a generous ceiling rather than a
fixed short timeout. Reject a pre-populated output_dir (start does not
resume) and tmux session names containing ':', '.', or '#'.

@morehouse morehouse left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice improvement. I think we still want to tweak this a bit, but let's do it in small follow-up PRs instead of review-looping on this big PR.

Comment on lines +417 to +419
// AFL++ docs: the strategy knobs below apply to "the other secondaries",
// not the primary. See fuzzing_in_depth.md §c "Using multiple cores".
if runner_id > 0 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the -P, -L, and -Z options described in #138?

Comment on lines +410 to +411
let schedule = POWER_SCHEDULES[runner_id as usize % POWER_SCHEDULES.len()];
flags.extend(["-p".to_string(), schedule.to_string()]);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, the AFL doc recommends only setting -p for secondaries, not the master.

fn runner_strategy_primary_gets_final_sync() {
let (flags, envs) = runner_strategy(0, 8, None);
assert!(envs.iter().any(|(k, v)| k == "AFL_FINAL_SYNC" && v == "1"));
assert!(flags.contains(&"-p".to_string()));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second assert doesn't look related to the test name.

Suggested change
assert!(flags.contains(&"-p".to_string()));

Comment thread smitebot/src/config.rs
Comment on lines +41 to +42
/// Custom tmux session name; defaults to the campaign ID when absent.
pub tmux_session: Option<String>,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sample-campaign.toml needs to be updated with this option.

Comment thread smitebot/README.md
@@ -1,65 +1,70 @@
# smitebot

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of the reason review cycles take so long is that the PRs themselves are large and may require multiple hours of the reviewer's time to properly review. I know I won't even think about sitting down to fully review a complex 1k line PR unless I have a couple hours of time available for it. If many changes are needed, that time investment multiplies on each round of review (code review is quadratic).

I think it's generally more efficient to split any unrelated changes into their own small PRs. In fact, I would merge the unrelated README changes immediately if they were in a separate PR. But since they're bundled in here it takes much longer for those changes to get in.

Comment on lines +420 to 431
// -a binary on ~70% of secondary runners.
let secondary_count = runner_count.saturating_sub(1) as usize;
let binary_count = (secondary_count * 7).div_ceil(10);
if ((runner_id - 1) as usize) < binary_count {
flags.extend(["-a".to_string(), "binary".to_string()]);
}

for (child, runner) in children.iter_mut().zip(runners.iter()) {
match child.try_wait() {
Ok(Some(_)) => {
log::error!(
"runner {} (pid {}) died during startup",
runner.id,
runner.pid
);
return false;
}
Ok(None) => {}
Err(e) => {
log::error!(
"failed to check runner {} (pid {}): {e}",
runner.id,
runner.pid
);
return false;
}
}
// AFL_DISABLE_TRIM on ~60% of secondary runners.
let trim_count = (secondary_count * 6).div_ceil(10);
if ((runner_id - 1) as usize) < trim_count {
envs.push(("AFL_DISABLE_TRIM".to_string(), "1".to_string()));
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to shuffle the flag assignments. Currently the first 60% of secondaries disable trim and the first 70% set -a binary. That means there are very few runners that have only one of those options and not the other -- pretty much all runners have both or neither.

Something like this might work well:

  • create a list of option "modifiers" that are assigned some probability of being applied between 0 and 100.
  • for each runner, for each modifier, compute X = hash(runner_id || modifier_name) % 100 and apply the modifier to that runner if X < modifier_probability.

This would still be deterministic and reproducible while giving more variety to the flag combinations.

@morehouse morehouse merged commit f99ac3e into morehouse:master Jul 9, 2026
5 checks passed
@Ashish-Kumar-Dash Ashish-Kumar-Dash changed the title feat(smitebot): tmux integration + strategy distribution for start smitebot: tmux integration + strategy distribution for start Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants