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.
Audit of every bruteforce module surfaced two recurring bug classes. Fixed the
verified, contained ones here.
FALSE POSITIVES (a wrong cred was being stored as loot):
- snmp: success was 'response contains byte 0xa2 anywhere' — matched request-id /
length / payload bytes and accepted error/Report PDUs. Now structurally parses
the datagram and requires a GetResponse PDU (0xa2) at its proper position.
- proxy: HTTP CONNECT/forward success was resp.contains("200") — matched
'Content-Length: 200', Date, Via, etc. Now parses the status line. SOCKS5 now
also checks the RFC1929 version byte before trusting the status.
- elasticsearch: 200 = success, but an unsecured ES node returns 200 to EVERY
request → every password a false positive. Now confirms an unauthenticated GET
is 401 before accepting the creds.
- couchdb: 200 = success without inspecting the body. Now requires {"ok":true}.
- memcached: trusted the status field without checking the response was a SASL-Auth
reply. Now validates magic 0x81 + opcode 0x21 first.
- l2tp: any UDP reply with the top 2 bits set = success. Now also requires the
L2TPv2 version nibble.
- http_basic: a redirect to a non-login page counted as success even when the
endpoint didn't enforce auth. Now gated on the baseline-401 check.
- sample_cred_check: a registered template that stored admin:admin on any host
whose /login returns 200. Now baselines the unauthenticated request first.
RTSP-CLASS LOCKOUT (a definitive negative from a RESPONDING server was returned as
a retryable Error, burning retries + tripping the consecutive-error give-up):
- ftp: post-handshake login rejections with unusual wording (430/532/localized)
were Unknown->retryable; now treated as AuthFailed (connection drops stay
retryable).
- mysql: ERR packets with codes other than 1044/1045 were retryable:true; the
server clearly responded, so now never retryable, and the common auth-denial
codes (1130/1226/1227/1698/1862) map to AuthFailed.
- http_basic: definitive 4xx (400/404/405/406/410/422) now AuthFailed instead of
Unknown->retryable; 429/5xx stay retryable.
FALSE NEGATIVE:
- postgres: connected to database=<user>; a valid superuser whose own DB doesn't
exist failed startup with 3D000 and was read as a bad login. Now uses the
'postgres' maintenance DB.
Build: 0 errors, 0 warnings. (Pre-existing strict-audit hits in mysql/postgres are
the protocol-mandated SHA1/MD5 auth — not introduced here.)
A real RTSP camera commonly answers DESCRIBE with 404 Not Found when the stream
PATH is wrong (e.g. default '/'), not the credentials. The probe was treating
any non-200/401/403 status line as LoginResult::Error, which (a) misreported
clean negatives as 'Errors' and (b) — because the engine's lockout/give-up
heuristic counts CONSECUTIVE errors — made a live, responding host look dead and
trip a 30s pause + eventual give-up.
Now: parse the RTSP status code; 200 = Success, any other well-formed RTSP code
(401/403 reject, 404 wrong path, 3xx/5xx) = AuthFailed (a definitive negative for
that credential — no retry, resets the consecutive-error counter). Only a reply
that isn't an RTSP status line at all stays a (non-retryable) Error. 404s are
logged at debug ('setg rtsp_path' is the likely fix). Also decode the banner with
from_utf8_lossy + parse the first line instead of truncating to 64 bytes.
creds_helper previously hardcoded BruteforceConfig.max_retries = 1. 'setg
bruteforce_retries N' now sets the per-combo retry count for retryable
(transient/connection) errors; unset or 0 keeps the historical default of 1.
show options: + bruteforce_retries. Build: 0 errors, 0 warnings.
- bruteforce_mask (hydra -x): 'setg bruteforce_mask MIN:MAX:CHARSET' enumerates
candidate passwords. CHARSET placeholders mirror hydra: a->a-z, A->A-Z, 1->0-9;
any other char is literal. e.g. '1:4:a1' = 1-4 chars of [a-z0-9]. Generated via
an odometer counter, hard-capped at MAX_COMBOS with a warning. Mask candidates
run AFTER the wordlist (lowest yield); skipped in credential_file_only mode.
New: utils::bruteforce::generate_mask_passwords + creds_helper wiring.
- bruteforce_resume (batch-level): 'setg bruteforce_resume y' makes a streamed
large-wordlist run record the last fully-completed 500k batch, keyed by
target+port+wordlist+size+mode, under ~/.rustsploit/checkpoints/<key>.bfr. An
interrupted run skips already-tried batches on restart and clears the marker on
clean completion / first success. New checkpoint markers: read/write/clear_
bruteforce_marker, mirroring the existing seq-marker pattern.
show options: + bruteforce_mask / bruteforce_resume. Build: 0 errors, 0 warnings.
- Cross-batch give-up: run_bruteforce split into a wrapper + run_bruteforce_with_abort
taking a shared abort flag; the streaming driver reuses one flag so a host the
engine gives up on stops the WHOLE run instead of re-pausing every 500k batch.
- credential_file_only: 'setg credential_file_only y' makes the combo file the
ONLY credential source (exact hydra -C; ignores user/pass wordlists + streaming).
- cred_stop_mode (host|user|all): host = stop whole host on first success
(default); user = stop a username once its password is found, keep other users
(medusa); all = find every valid credential (hydra default). Engine reads the
setg (no BruteforceConfig field change -> no ripple across 14 callers).
show options: + credential_file_only / cred_stop_mode. Build: 0 errors, 0 warnings.
1. Batch concurrency cap: in a mass-scan fan-out, per-host bruteforce concurrency
is capped (4) so it no longer multiplies with scheduler host-concurrency into
an RLIMIT_NOFILE-exhausting socket count.
2. Combo-file (hydra -C): 'setg credential_file <user:pass file>' is loaded via
the engine's load_credential_file and tried alongside defaults. Verified:
2-pair file -> +2 attempts.
3. Delay/jitter (hydra -w): 'setg bruteforce_delay_ms' + 'setg bruteforce_jitter_ms'
now feed BruteforceConfig.delay_ms/jitter_ms (were hardcoded 0).
4. Host give-up (medusa): run_bruteforce now aborts a host after MAX_LOCKOUT_PAUSES
(3) consecutive-error lockout pauses with no success, instead of pausing-and-
grinding forever. run_subnet_bruteforce already had give-up.
Also surfaced credential_file/bruteforce_delay_ms/bruteforce_jitter_ms in
'show options'. Build: 0 errors, 0 warnings.
Known limitation: streaming-path give-up is per-batch (not cross-batch).
Adds the highest-yield hydra feature to the shared creds_helper: for each
username, optionally also try an empty password (null), the username as its own
password (same), and the reversed username. Opt-in via 'setg cred_extras' with a
subset of n/s/r (e.g. nsr); default off. Appended to the defaults-first rows so
they run before the wordlist; the engine's existing combo dedup avoids retrying
a pair already in defaults. Skipped for password-only services.
Also surfaced username_wordlist/password_wordlist/cred_extras in 'show options'.
Verified: elasticsearch_bruteforce + cred_extras=nsr + 1 username -> 6 defaults +
null + reversed (same deduped against the admin/admin default) = 8 attempts.
creds_helper required a password wordlist (cfg_prompt_existing_file with no
default), so in a mass scan with no 'setg password_wordlist' every host errored
'Missing required prompt key' before the module's built-in DEFAULTS were ever
tried — making bruteforce sweeps look stuck/broken (the RTSP report). Wordlists
are now OPTIONAL when the module ships defaults: a missing wordlist falls back to
defaults-only instead of erroring. Modules with no defaults (postgres/mysql/snmp)
still require a wordlist. Verified: batch-mode elasticsearch_bruteforce with no
wordlist runs its 6 defaults and completes (was: error).
First run (or a clean ~/.rustsploit) has no history file; a NotFound on
rl.load_history is normal and was printing '[!] Failed to load history: No such
file or directory'. Now NotFound is logged at debug; only real failures
(permissions, corruption) are surfaced loudly.