bruteforce: cross-batch give-up, combo-file exclusive mode, stop-mode (host/user/all)

- Cross-batch give-up: run_bruteforce split into a wrapper + run_bruteforce_with_abort
  taking a shared abort flag; the streaming driver reuses one flag so a host the
  engine gives up on stops the WHOLE run instead of re-pausing every 500k batch.
- credential_file_only: 'setg credential_file_only y' makes the combo file the
  ONLY credential source (exact hydra -C; ignores user/pass wordlists + streaming).
- cred_stop_mode (host|user|all): host = stop whole host on first success
  (default); user = stop a username once its password is found, keep other users
  (medusa); all = find every valid credential (hydra default). Engine reads the
  setg (no BruteforceConfig field change -> no ripple across 14 callers).

show options: + credential_file_only / cred_stop_mode. Build: 0 errors, 0 warnings.
This commit is contained in:
s-b-repo
2026-06-13 16:41:58 +02:00
parent c3d0556f77
commit d0e07e8d97
3 changed files with 126 additions and 20 deletions
+2
View File
@@ -1312,6 +1312,8 @@ async fn display_all_options(ctx: &ShellContext) {
("password_wordlist", "Bruteforce password wordlist path", ""),
("cred_extras", "Bruteforce extra creds: hydra-style n/s/r (null/same/reversed), e.g. nsr", ""),
("credential_file", "Bruteforce user:pass combo file (hydra -C)", ""),
("credential_file_only","Use ONLY credential_file, ignore wordlists (exact hydra -C) (y/n)", ""),
("cred_stop_mode", "Stop granularity on success: host / user / all (default host)", ""),
("bruteforce_delay_ms","Per-attempt delay to dodge lockout (ms, hydra -w)", ""),
("bruteforce_jitter_ms","Random extra delay added to bruteforce_delay_ms (ms)", ""),
];
+92 -13
View File
@@ -493,6 +493,38 @@ const MAX_COMBOS: usize = 10_000_000;
/// before the engine gives up on a host instead of pausing-and-grinding forever.
const MAX_LOCKOUT_PAUSES: u64 = 3;
/// When to stop a host's bruteforce on a successful login. Controlled by
/// `setg cred_stop_mode` (host | user | all); defaults from `stop_on_success`.
#[derive(Clone, Copy, PartialEq)]
enum StopMode {
/// Stop the whole host after the first valid credential (hydra `-f` / the
/// historical default).
Host,
/// Stop trying a username once ITS password is found, but keep going for
/// other usernames (medusa default).
User,
/// Never stop on success — find every valid credential (hydra default).
All,
}
/// Resolve the stop mode from `setg cred_stop_mode`, falling back to the config's
/// `stop_on_success` (true => Host, false => All) when unset/unknown.
fn resolve_stop_mode(default_stop_on_success: bool) -> StopMode {
let fallback = if default_stop_on_success { StopMode::Host } else { StopMode::All };
match crate::tenant::resolve().global_options().try_get("cred_stop_mode") {
Some(v) => match v.trim().to_lowercase().as_str() {
"host" | "first" => StopMode::Host,
"user" | "peruser" | "per_user" => StopMode::User,
"all" | "findall" | "find_all" | "none" => StopMode::All,
other => {
tracing::debug!("unknown cred_stop_mode '{other}'; using default");
fallback
}
},
None => fallback,
}
}
/// Generate credential pairs with full combo mode control.
/// - Linear: pairs user\[i\] with pass\[i\], cycling the shorter list.
/// - Combo: full cross product (user × pass).
@@ -703,6 +735,23 @@ pub async fn run_bruteforce<F, Fut>(
combos: Vec<(String, String)>,
try_login: F,
) -> Result<BruteforceResult>
where
F: Fn(String, u16, String, String) -> Fut + Send + Sync + 'static + Clone,
Fut: Future<Output = LoginResult> + Send,
{
run_bruteforce_with_abort(config, combos, try_login, Arc::new(AtomicBool::new(false))).await
}
/// Like [`run_bruteforce`] but with a caller-supplied `abort` flag so a
/// multi-batch driver (the streaming path) can give up ACROSS batches: when the
/// engine trips `abort` (host blocking/dead, no success), the driver stops
/// feeding it the rest of the wordlist instead of re-pausing every batch.
pub async fn run_bruteforce_with_abort<F, Fut>(
config: &BruteforceConfig,
combos: Vec<(String, String)>,
try_login: F,
abort: Arc<AtomicBool>,
) -> Result<BruteforceResult>
where
F: Fn(String, u16, String, String) -> Fut + Send + Sync + 'static + Clone,
Fut: Future<Output = LoginResult> + Send,
@@ -731,11 +780,18 @@ where
let errors: BruteforceErrorList = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let paused = Arc::new(AtomicBool::new(false));
// Medusa-style "give up on this host": set when repeated lockout pauses make
// no progress. Unlike `stop` (which is gated on stop_on_success), `abort`
// always halts the run so a dead/blocking host can't grind the whole wordlist.
let abort = Arc::new(AtomicBool::new(false));
// Medusa-style "give up on this host": `abort` (caller-supplied) is set when
// repeated lockout pauses make no progress. Unlike `stop` (gated on
// stop_on_success), `abort` always halts the run so a dead/blocking host
// can't grind the whole wordlist — and the streaming driver reuses the same
// flag across batches.
let lockout_pauses = Arc::new(AtomicU64::new(0));
// Stop granularity (host / per-user / find-all). `found_users` backs per-user
// mode: once a username's password is found, further attempts for that user
// are skipped while other users keep going.
let stop_mode = resolve_stop_mode(config.stop_on_success);
let found_users: Arc<Mutex<std::collections::HashSet<String>>> =
Arc::new(Mutex::new(std::collections::HashSet::new()));
let semaphore = Arc::new(Semaphore::new(config.concurrency.max(1)));
// Progress reporter
@@ -757,7 +813,7 @@ where
if abort.load(Ordering::Relaxed) {
break;
}
if config.stop_on_success && stop.load(Ordering::Relaxed) {
if stop_mode == StopMode::Host && stop.load(Ordering::Relaxed) {
break;
}
@@ -783,7 +839,7 @@ where
let stats_c = stats.clone();
let try_login_c = try_login.clone();
let verbose = config.verbose;
let stop_on_success = config.stop_on_success;
let found_users_c = found_users.clone();
let max_retries = config.max_retries;
let delay_ms = config.delay_ms;
let jitter_ms = config.jitter_ms;
@@ -798,7 +854,9 @@ where
let _permit = permit;
let __body = async move {
if abort_c.load(Ordering::Relaxed) { return; }
if stop_on_success && stop_c.load(Ordering::Relaxed) { return; }
if stop_mode == StopMode::Host && stop_c.load(Ordering::Relaxed) { return; }
// Per-user mode: skip a username once its password has been found.
if stop_mode == StopMode::User && found_users_c.lock().await.contains(&user) { return; }
// Respect global rate-limit pause
while paused_c.load(Ordering::Acquire) {
@@ -821,8 +879,16 @@ where
}
found_c.lock().await.push((display.clone(), user.clone(), pass.clone()));
stats_c.record_success();
if stop_on_success {
stop_c.store(true, Ordering::Relaxed);
match stop_mode {
// Stop the whole host on the first valid credential.
StopMode::Host => stop_c.store(true, Ordering::Relaxed),
// Remember this user so other workers skip it, but keep
// testing the remaining usernames.
StopMode::User => {
found_users_c.lock().await.insert(user.clone());
}
// Find-all: keep going.
StopMode::All => {}
}
break;
}
@@ -983,24 +1049,37 @@ where
})
});
// Shared across batches so a host the engine gives up on (medusa-style) is
// not re-attempted batch after batch — the give-up propagates the whole run.
let abort = Arc::new(AtomicBool::new(false));
// Honour the stop mode for the cross-batch early-out: only Host mode stops
// streaming after the first success; user/all modes keep streaming.
let stop_after_first = resolve_stop_mode(config_ref.stop_on_success) == StopMode::Host;
let mut batch_idx = 0usize;
while let Some(pass_batch) = batch_rx.recv().await {
batch_idx += 1;
crate::mprintln!("{}", format!("[*] Processing batch {} ({} passwords)", batch_idx, pass_batch.len()).cyan());
let combos = generate_combos_mode(&usernames, &pass_batch, mode_val);
let result = run_bruteforce(config_ref, combos, try_login_ref.clone()).await?;
let result = run_bruteforce_with_abort(config_ref, combos, try_login_ref.clone(), abort.clone()).await?;
aggregate.found.extend(result.found);
aggregate.errors.extend(result.errors);
if config_ref.stop_on_success && !aggregate.found.is_empty() {
if abort.load(Ordering::Relaxed) {
crate::mprintln!(
"{}",
format!("[!] Stopping streamed bruteforce of {} — host gave up (no progress)", config_ref.target).yellow()
);
break;
}
if stop_after_first && !aggregate.found.is_empty() {
return Ok(aggregate);
}
}
reader_handle.await.context("batch reader task panicked")??;
if !extra_combos.is_empty() {
if !extra_combos.is_empty() && !abort.load(Ordering::Relaxed) {
crate::mprintln!("{}", format!("[*] Processing {} extra combos from credential file", extra_combos.len()).cyan());
let result = run_bruteforce(config_ref, extra_combos, try_login_ref.clone()).await?;
let result = run_bruteforce_with_abort(config_ref, extra_combos, try_login_ref.clone(), abort.clone()).await?;
aggregate.found.extend(result.found);
aggregate.errors.extend(result.errors);
}
+32 -7
View File
@@ -164,10 +164,15 @@ where
// `MAX_FILE_SIZE` cap turns a big rockyou-class list into a hard error.
// Instead, route them into the streaming brute-force engine, which reads
// the file in bounded batches. Small files keep the exact eager path.
let stream_passwords = password_path
.as_deref()
.map(|p| file_size(p) > STREAMING_THRESHOLD)
.unwrap_or(false);
// Hydra `-C` exclusive mode: when `setg credential_file_only y`, the combo
// file replaces the user/pass lists, so the password wordlist (and its
// streaming path) is ignored entirely.
let cred_file_only = opt_bool("credential_file_only");
let stream_passwords = !cred_file_only
&& password_path
.as_deref()
.map(|p| file_size(p) > STREAMING_THRESHOLD)
.unwrap_or(false);
// For the small-file path we load eagerly so we can validate emptiness and
// preserve the historical "defaults first" combo ordering. No password
@@ -238,7 +243,12 @@ where
pairs.len(),
cred_file
);
defaults.extend(pairs);
if cred_file_only {
// Exact hydra -C: the file is the ONLY credential source.
defaults = pairs;
} else {
defaults.extend(pairs);
}
}
Err(e) => return Err(anyhow!("credential_file '{}': {}", cred_file, e)),
}
@@ -292,9 +302,13 @@ where
)
.await?
} else {
// Defaults first — common cred lists are the highest-yield rows.
// Defaults first — common cred lists are the highest-yield rows. In
// credential_file_only mode `defaults` already holds the file's pairs and
// the user/pass wordlists are intentionally ignored.
let mut combos = defaults;
combos.extend(generate_combos_mode(&usernames, &passwords, mode));
if !cred_file_only {
combos.extend(generate_combos_mode(&usernames, &passwords, mode));
}
run_bruteforce(&bf_config, combos, probe_for_engine).await?
};
result.print_found();
@@ -431,6 +445,17 @@ fn cred_extras() -> CredExtras {
}
}
/// Read a boolean-ish global option (`setg <key> y`); false when unset.
fn opt_bool(key: &str) -> bool {
match crate::tenant::resolve().global_options().try_get(key) {
Some(v) => matches!(
v.trim().to_lowercase().as_str(),
"y" | "yes" | "true" | "1" | "on"
),
None => false,
}
}
/// Read a non-empty global option (`setg <key>`), or `None` if unset/blank.
fn opt_str(key: &str) -> Option<String> {
crate::tenant::resolve()