From d0e07e8d9730539dbf03a16cdfc3506e31cf3d9b Mon Sep 17 00:00:00 2001 From: s-b-repo Date: Sat, 13 Jun 2026 16:41:58 +0200 Subject: [PATCH] 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. --- src/shell.rs | 2 + src/utils/bruteforce.rs | 105 +++++++++++++++++++++++++++++++++----- src/utils/creds_helper.rs | 39 +++++++++++--- 3 files changed, 126 insertions(+), 20 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index 0a42450..b7fff96 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -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)", ""), ]; diff --git a/src/utils/bruteforce.rs b/src/utils/bruteforce.rs index 453ffd7..a68f3b5 100644 --- a/src/utils/bruteforce.rs +++ b/src/utils/bruteforce.rs @@ -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( combos: Vec<(String, String)>, try_login: F, ) -> Result +where + F: Fn(String, u16, String, String) -> Fut + Send + Sync + 'static + Clone, + Fut: Future + 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( + config: &BruteforceConfig, + combos: Vec<(String, String)>, + try_login: F, + abort: Arc, +) -> Result where F: Fn(String, u16, String, String) -> Fut + Send + Sync + 'static + Clone, Fut: Future + 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>> = + 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); } diff --git a/src/utils/creds_helper.rs b/src/utils/creds_helper.rs index de94d9b..2800c6c 100644 --- a/src/utils/creds_helper.rs +++ b/src/utils/creds_helper.rs @@ -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 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 `), or `None` if unset/blank. fn opt_str(key: &str) -> Option { crate::tenant::resolve()