From 5968d25b58745a4fb58e00abe3d4da87d6dc5540 Mon Sep 17 00:00:00 2001 From: s-b-repo Date: Sat, 13 Jun 2026 15:55:28 +0200 Subject: [PATCH] creds bruteforce: hydra-style -e nsr (null/same/reversed) + expose wordlist/cred_extras options Adds the highest-yield hydra feature to the shared creds_helper: for each username, optionally also try an empty password (null), the username as its own password (same), and the reversed username. Opt-in via 'setg cred_extras' with a subset of n/s/r (e.g. nsr); default off. Appended to the defaults-first rows so they run before the wordlist; the engine's existing combo dedup avoids retrying a pair already in defaults. Skipped for password-only services. Also surfaced username_wordlist/password_wordlist/cred_extras in 'show options'. Verified: elasticsearch_bruteforce + cred_extras=nsr + 1 username -> 6 defaults + null + reversed (same deduped against the admin/admin default) = 8 attempts. --- src/shell.rs | 3 ++ src/utils/creds_helper.rs | 59 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/shell.rs b/src/shell.rs index 40f5d52..8384552 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1308,6 +1308,9 @@ async fn display_all_options(ctx: &ShellContext) { ("exclusions", "Extra networks to skip in mass scans (CIDR,CIDR,...)", ""), ("target_rps", "Per-target rate limit (req/s, 0 = unlimited)", ""), ("module_rps", "Global module rate limit (req/s, 0 = unlimited)", ""), + ("username_wordlist", "Bruteforce username wordlist path", ""), + ("password_wordlist", "Bruteforce password wordlist path", ""), + ("cred_extras", "Bruteforce extra creds: hydra-style n/s/r (null/same/reversed), e.g. nsr", ""), ]; let mut displayed = std::collections::HashSet::new(); diff --git a/src/utils/creds_helper.rs b/src/utils/creds_helper.rs index ebcebc1..545d4ca 100644 --- a/src/utils/creds_helper.rs +++ b/src/utils/creds_helper.rs @@ -184,12 +184,37 @@ where // Defaults are the highest-yield rows. In the eager path they go first; in // the streaming path they're passed as `extra_combos` (the engine runs them // after the streamed batches). - let defaults: Vec<(String, String)> = cfg + let mut defaults: Vec<(String, String)> = cfg .defaults .iter() .map(|(u, p)| (u.to_string(), p.to_string())) .collect(); + // Hydra-style `-e nsr`: for each username also try an empty password (null), + // the username as its own password (same), and the reversed username. Opt in + // via `setg cred_extras ` (e.g. "nsr"); default off. Appended + // to the high-yield "defaults first" rows so they run before the wordlist. + // Skipped for password-only services (no username to derive from). + if !cfg.password_only { + let extras = cred_extras(); + if extras.any() { + for u in &usernames { + if u.is_empty() { + continue; + } + if extras.null { + defaults.push((u.clone(), String::new())); + } + if extras.same { + defaults.push((u.clone(), u.clone())); + } + if extras.reversed { + defaults.push((u.clone(), u.chars().rev().collect::())); + } + } + } + } + let bf_config = BruteforceConfig { target: host.clone(), port, @@ -339,3 +364,35 @@ async fn is_port_open(host: &str, port: u16) -> bool { }; crate::utils::tcp_port_open(ip, port, Duration::from_secs(2)).await } + +/// Hydra-style `-e` extra-credential flags: null password, password == login +/// (same), and reversed login. +struct CredExtras { + null: bool, + same: bool, + reversed: bool, +} + +impl CredExtras { + fn any(&self) -> bool { + self.null || self.same || self.reversed + } +} + +/// Parse `setg cred_extras` (a hydra-style subset of `n`/`s`/`r`, e.g. "nsr"). +/// Empty / "none" / "off" / "no" disables it (the default when unset). +fn cred_extras() -> CredExtras { + let none = CredExtras { null: false, same: false, reversed: false }; + let raw = match crate::tenant::resolve().global_options().try_get("cred_extras") { + Some(s) => s.to_lowercase(), + None => return none, + }; + if matches!(raw.trim(), "" | "none" | "off" | "no" | "0") { + return none; + } + CredExtras { + null: raw.contains('n'), + same: raw.contains('s'), + reversed: raw.contains('r'), + } +}