fix(gui): add non-Linux stubs for pmkid_hunt/pmf_test and fix Windows warnings

main.rs failed to compile on Windows: pmkid_hunt and pmf_test were only
implemented under #[cfg(target_os = "linux")], with no fallback stub,
so the Tauri commands calling them did not resolve.

- Add #[cfg(not(target_os = "linux"))] stubs for both, matching the
  existing pattern for every other Linux-only Engine method (returns
  SabiaError::UnsupportedPlatform).
- Mark the DeauthJob/Engine fields and session_ap/client_snapshot
  helpers that only the Linux-only code paths use with
  #[cfg_attr(not(target_os = "linux"), allow(dead_code))], same for
  eviltwin.rs's form_value/url_decode (only called from the
  #[cfg(target_os = "linux")] HTTP handler) — silences the 5
  dead_code warnings a Windows build produced.
- .setup(|app| ...) used app only inside unix/linux/macos-gated
  blocks, so it was unused on Windows; renamed the closure parameter
  and reintroduce it under the matching cfg.
- Add the missing English dictionary entry for the mutation-rules
  checkbox label in app.js (found by visually reviewing a generated
  screenshot: it was rendering in Portuguese in the English UI even
  though check-ui-i18n.mjs reported full coverage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
Lucas RayanandClaude Sonnet 5 committed 2026-10-30 20:44:17 -03:00
1 parent 2dfbbfc64a
commit a904723f96
4 files changed
+302 -270

No files matched your search

+20 -17
View File
@@ -1,4 +1,4 @@
// Evita a janela de console extra no Windows em release. // Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use sabia_core::engine::{Engine, ScanOptions}; use sabia_core::engine::{Engine, ScanOptions};
@@ -506,9 +506,9 @@ fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
state.0.update_apply().map_err(|e| e.to_string()) state.0.update_apply().map_err(|e| e.to_string())
} }
/// `true` quando o processo já tem privilégio suficiente pra tudo que o /// `true` when the process already has sufficient privileges for all
/// Sabiá faz (modo monitor, captura crua). No Windows o manifesto já /// Sabiá operations (monitor mode, raw capture). On Windows the manifest already
/// exige administrador antes mesmo do app abrir, então aqui é sempre `true`. /// requires administrator privileges before launch, so this is always `true`.
#[tauri::command] #[tauri::command]
fn is_elevated() -> bool { fn is_elevated() -> bool {
#[cfg(unix)] #[cfg(unix)]
@@ -521,9 +521,9 @@ fn is_elevated() -> bool {
} }
} }
/// Reabre o app com privilégios de administrador (Linux/macOS). O app novo /// Relaunches the app with administrator privileges (Linux/macOS). The new instance
/// encerra este quando abre; se a autorização falhar ou for cancelada, a /// terminates this one on startup; if authorization fails or is cancelled, the
/// janela continua aqui e a interface mostra o motivo. /// window stays open and the UI displays the reason.
#[tauri::command] #[tauri::command]
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -610,9 +610,9 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta /// Executable path accessible to root. Inside an AppImage, `current_exe` points
/// pra dentro de um ponto de montagem FUSE do usuário, inacessível ao root: /// inside a user FUSE mount that root cannot access:
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`). /// use the `.AppImage` file itself (`APPIMAGE` environment variable).
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> { fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE") if let Some(p) = std::env::var_os("APPIMAGE")
@@ -628,8 +628,11 @@ fn main() {
let engine = Arc::new(Engine::new()); let engine = Arc::new(Engine::new());
tauri::Builder::default() tauri::Builder::default()
.manage(State(engine)) .manage(State(engine))
.setup(|app| { .setup(|_app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo. #[cfg(any(target_os = "linux", target_os = "macos"))]
let app = _app;
// When launched elevated by the unprivileged instance, terminate the old process.
#[cfg(unix)] #[cfg(unix)]
if let Some(pid) = std::env::var("SABIA_REPLACE_PID") if let Some(pid) = std::env::var("SABIA_REPLACE_PID")
.ok() .ok()
@@ -637,7 +640,7 @@ fn main() {
{ {
std::thread::spawn(move || { std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200)); std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário. // SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
unsafe { unsafe {
if libc::geteuid() == 0 { if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM); libc::kill(pid, libc::SIGTERM);
@@ -646,10 +649,10 @@ fn main() {
}); });
} }
// Captura crua e modo monitor exigem root: pede elevação assim // Raw capture and monitor mode require root: request elevation as soon
// que o app abre, sem esperar o usuário procurar um botão. Se // as the app launches, without waiting for the user to look for a button. If
// a autorização falhar, a janela continua aberta e a interface // authorization fails, the window stays open and the UI
// mostra o aviso (com um botão pra tentar de novo). // displays a warning banner (with a retry button).
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
if unsafe { libc::geteuid() != 0 } && std::env::var("SABIA_REPLACE_PID").is_err() { if unsafe { libc::geteuid() != 0 } && std::env::var("SABIA_REPLACE_PID").is_err() {
let handle = app.handle().clone(); let handle = app.handle().clone();
+13 -11
View File
@@ -1,4 +1,4 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável. // Portuguese -> English UI dictionary. {n} marks a variable placeholder.
export const EN = { export const EN = {
// index.html / sidebar // index.html / sidebar
'auditoria de Wi-Fi': 'Wi-Fi auditing', 'auditoria de Wi-Fi': 'Wi-Fi auditing',
@@ -19,7 +19,7 @@ export const EN = {
'modo monitor': 'monitor mode', 'modo monitor': 'monitor mode',
'modo gerenciado': 'managed mode', 'modo gerenciado': 'managed mode',
// elevação / avisos // elevation / notices
'Pedindo privilégio de administrador — necessário pra modo monitor e captura…': 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…':
'Requesting administrator privilege — needed for monitor mode and capture…', 'Requesting administrator privilege — needed for monitor mode and capture…',
'tentar de novo': 'try again', 'tentar de novo': 'try again',
@@ -27,13 +27,13 @@ export const EN = {
'Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.': 'Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.':
'Use only on your own networks or with the explicit authorization of the responsible party. Every monitor mode activation and capture session is logged in the local audit trail.', 'Use only on your own networks or with the explicit authorization of the responsible party. Every monitor mode activation and capture session is logged in the local audit trail.',
// controles // controls
'ativar modo monitor': 'enable monitor mode', 'ativar modo monitor': 'enable monitor mode',
'desativar modo monitor': 'disable monitor mode', 'desativar modo monitor': 'disable monitor mode',
'iniciar captura': 'start capture', 'iniciar captura': 'start capture',
'parar captura': 'stop capture', 'parar captura': 'stop capture',
// resultados // results
'Redes': 'Networks', 'Redes': 'Networks',
'Redes vistas': 'Networks seen', 'Redes vistas': 'Networks seen',
'Linha do tempo': 'Timeline', 'Linha do tempo': 'Timeline',
@@ -72,7 +72,7 @@ export const EN = {
'Nenhuma rede corresponde ao filtro': 'No network matches the filter', 'Nenhuma rede corresponde ao filtro': 'No network matches the filter',
'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.', 'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.',
// erros de UI // UI errors
'Não foi possível trocar o modo:': 'Could not change the mode:', 'Não foi possível trocar o modo:': 'Could not change the mode:',
'Não foi possível iniciar a captura:': 'Could not start the capture:', 'Não foi possível iniciar a captura:': 'Could not start the capture:',
'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}', 'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}',
@@ -82,7 +82,7 @@ export const EN = {
'erro desconhecido': 'unknown error', 'erro desconhecido': 'unknown error',
'rota desconhecida': 'unknown route', 'rota desconhecida': 'unknown route',
// seletor de idioma / configurações // language selector / settings
'Configurações': 'Settings', 'Configurações': 'Settings',
'Tema': 'Theme', 'Tema': 'Theme',
'Claro': 'Light', 'Claro': 'Light',
@@ -133,7 +133,7 @@ export const EN = {
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256', 'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
'MAC inválido: {n}': 'invalid MAC: {n}', 'MAC inválido: {n}': 'invalid MAC: {n}',
// detalhes do alvo e ataques // target details and attacks
'não': 'no', 'não': 'no',
'não anunciado': 'not advertised', 'não anunciado': 'not advertised',
'quebrável': 'crackable', 'quebrável': 'crackable',
@@ -340,7 +340,7 @@ export const EN = {
'{n} · canal {n}': '{n} · channel {n}', '{n} · canal {n}': '{n} · channel {n}',
'{n} ({n})': '{n} ({n})', '{n} ({n})': '{n} ({n})',
// canais, PMKID/PMF, WPS e relatório // channels, PMKID/PMF, WPS, and report
'Ocupação dos canais em {n}': 'Channel occupancy on {n}', 'Ocupação dos canais em {n}': 'Channel occupancy on {n}',
'Sem dados de canal ainda': 'No channel data yet', 'Sem dados de canal ainda': 'No channel data yet',
'Inicie a captura para o Sabiá medir ocupação e sugerir canais.': 'Inicie a captura para o Sabiá medir ocupação e sugerir canais.':
@@ -358,7 +358,7 @@ export const EN = {
'Melhor canal': 'Best channel', 'Melhor canal': 'Best channel',
'Espectro e canais': 'Spectrum and channels', 'Espectro e canais': 'Spectrum and channels',
// saúde do espectro (canais, estilo SMART) // spectrum health (channels, SMART style)
'Boa': 'Good', 'Boa': 'Good',
'Atenção': 'Attention', 'Atenção': 'Attention',
'Saturado': 'Saturated', 'Saturado': 'Saturated',
@@ -478,7 +478,7 @@ export const EN = {
'não foi possível executar {n}: {n}': 'could not run {n}: {n}', 'não foi possível executar {n}: {n}': 'could not run {n}: {n}',
'{n} terminou com código {n}': '{n} exited with code {n}', '{n} terminou com código {n}': '{n} exited with code {n}',
// achados do WIDS (analysis.rs) // WIDS findings (analysis.rs)
'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels', 'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels',
'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.': 'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.':
'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.', 'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.',
@@ -525,7 +525,7 @@ export const EN = {
'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.', 'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.',
'Inconclusivo': 'Inconclusive', 'Inconclusivo': 'Inconclusive',
// deauth contínuo e identificação de clientes // continuous deauth and client identification
'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.': 'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.':
'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.', 'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.',
'Iniciar contínuo': 'Start continuous', 'Iniciar contínuo': 'Start continuous',
@@ -547,6 +547,8 @@ export const EN = {
'The bundled reaver could not be extracted in this run.', 'The bundled reaver could not be extracted in this run.',
'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)', 'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)',
'dicionário próprio, opcional': 'custom wordlist, optional', 'dicionário próprio, opcional': 'custom wordlist, optional',
'Aplicar regras de mutação (maiúscula, sufixos, leet — multiplica o tempo)':
'Apply mutation rules (uppercase, suffixes, leetspeak — multiplies run time)',
'Dispositivo': 'Device', 'Dispositivo': 'Device',
'MAC aleatório': 'Random MAC', 'MAC aleatório': 'Random MAC',
'Cliente {n}': 'Client {n}', 'Cliente {n}': 'Client {n}',
+214 -189
View File
@@ -1,9 +1,9 @@
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e //! Unified service engine shared by both the CLI web server (`sabia ui`) and
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então //! the Tauri desktop app: manages live capture sessions, channel hopping,
//! qualquer coisa que funcione em um funciona idêntico no outro. //! dictionary/mask cracking jobs, deauth/PMKID/PMF/WPS tasks, and Evil Twin.
//!
//!
//! //!
//! Sessões de captura, jobs de quebra, exportação de handshakes e deauth
//! ficam todos aqui — a CLI e a GUI não reimplementam nada.
use crate::audit::AuditLog; use crate::audit::AuditLog;
use crate::crack::{self, CrackReport}; use crate::crack::{self, CrackReport};
@@ -29,15 +29,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions { pub struct ScanOptions {
/// Canal fixo; sem ele, percorre os canais suportados. /// Fixed channel; when `None`, hops across supported channels in `band`.
pub channel: Option<u32>, pub channel: Option<u32>,
/// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`). /// Band filter (`"2.4"`, `"5"`, `"6"`, or `"all"`) used when `channel` is `None`.
pub band: Option<String>, pub band: Option<String>,
/// Tempo em cada canal ao pular. /// Channel hop dwell time in milliseconds.
pub hop_ms: u64, pub hop_ms: u64,
/// Salva os quadros capturados neste `.pcap`. /// Optional `.pcap` output path to mirror all captured frames.
pub pcap_out: Option<PathBuf>, pub pcap_out: Option<PathBuf>,
/// Encerra sozinho depois de N segundos. /// Automatically stop the capture session after this many seconds.
pub duration_secs: Option<u64>, pub duration_secs: Option<u64>,
} }
@@ -108,20 +108,20 @@ pub struct WpsJobInfo {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct EvilTwinJobInfo { pub struct EvilTwinJobInfo {
pub id: String, pub id: String,
/// Interface que captura/desautentica o AP real. /// Monitor-mode interface used to capture and deauthenticate the target AP.
pub iface: String, pub iface: String,
/// Interface que hospeda o clone (precisa ser outra, em modo AP). /// Secondary wireless interface hosting the rogue AP (`hostapd`).
pub ap_iface: String, pub ap_iface: String,
pub ssid: String, pub ssid: String,
pub bssid: String, pub bssid: String,
pub portal_port: u16, pub portal_port: u16,
pub status: String, pub status: String,
/// Quantas vezes alguém submeteu uma senha no portal. /// Number of passphrase submissions received by the captive portal.
pub attempts: u32, pub attempts: u32,
/// Última tentativa: "correct", "wrong" ou "invalid". /// Outcome of the most recent submission: `"correct"`, `"wrong"`, or `"invalid"`.
pub last_attempt: Option<String>, pub last_attempt: Option<String>,
/// Só preenchido quando alguém digita a senha certa — conferida de /// Populated only when a submitted passphrase cryptographically verifies
/// verdade contra o handshake, não é só o que foi digitado. /// against the captured handshake/PMKID.
pub password: Option<String>, pub password: Option<String>,
pub deauth_job: Option<String>, pub deauth_job: Option<String>,
pub error: Option<String>, pub error: Option<String>,
@@ -135,11 +135,11 @@ pub struct EvilTwinJobInfo {
pub stop: Option<Arc<AtomicBool>>, pub stop: Option<Arc<AtomicBool>>,
} }
/// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X) — ver /// WPA2/WPA3-Enterprise (802.1X) Evil Twin job status (see
/// `eviltwin_enterprise.rs`. Diferente do `EvilTwinJobInfo` (PSK), não tem /// `eviltwin_enterprise.rs`). Captures EAP (PEAP/TTLS -> MSCHAPv2) challenge/response
/// portal/senha: a credencial é capturada automaticamente pelo próprio /// hashes via `hostapd-wpe` for offline cracking.
/// 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)] #[derive(Debug, Clone, Serialize)]
pub struct EnterpriseTwinJobInfo { pub struct EnterpriseTwinJobInfo {
pub id: String, pub id: String,
@@ -224,8 +224,11 @@ pub struct DeauthJobInfo {
struct DeauthJob { struct DeauthJob {
info: Arc<Mutex<DeauthJobInfo>>, info: Arc<Mutex<DeauthJobInfo>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
handle: Option<std::thread::JoinHandle<()>>, handle: Option<std::thread::JoinHandle<()>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
locked_channel: Option<u32>, locked_channel: Option<u32>,
} }
@@ -246,12 +249,16 @@ pub struct Engine {
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>, jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
job_seq: AtomicU64, job_seq: AtomicU64,
deauth_jobs: Mutex<HashMap<String, DeauthJob>>, deauth_jobs: Mutex<HashMap<String, DeauthJob>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
deauth_seq: AtomicU64, deauth_seq: AtomicU64,
wps_jobs: Mutex<HashMap<String, Arc<Mutex<WpsJobInfo>>>>, wps_jobs: Mutex<HashMap<String, Arc<Mutex<WpsJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
wps_seq: AtomicU64, wps_seq: AtomicU64,
eviltwin_jobs: Mutex<HashMap<String, Arc<Mutex<EvilTwinJobInfo>>>>, eviltwin_jobs: Mutex<HashMap<String, Arc<Mutex<EvilTwinJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
eviltwin_seq: AtomicU64, eviltwin_seq: AtomicU64,
enterprise_twin_jobs: Mutex<HashMap<String, Arc<Mutex<EnterpriseTwinJobInfo>>>>, enterprise_twin_jobs: Mutex<HashMap<String, Arc<Mutex<EnterpriseTwinJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
enterprise_twin_seq: AtomicU64, enterprise_twin_seq: AtomicU64,
audit_path: PathBuf, audit_path: PathBuf,
audit_lock: Arc<Mutex<()>>, audit_lock: Arc<Mutex<()>>,
@@ -296,13 +303,13 @@ impl Engine {
&self.audit_path &self.audit_path
} }
/// Chamado pelo handler de SIGINT/SIGTERM do processo que hospeda o /// Invoked by the host process SIGINT/SIGTERM handler before exit.
/// engine (CLI/Tauri) antes de sair. Faz sincronamente o que a thread /// Synchronously stops any active Evil Twin (`hostapd`/`dnsmasq`), removes
/// de limpeza do Evil Twin faria no fim normal do job: mata /// `iptables` redirect rules, and returns wireless interfaces to NetworkManager.
/// 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")] #[cfg(target_os = "linux")]
pub fn shutdown_cleanup(&self) { pub fn shutdown_cleanup(&self) {
use std::process::Command; use std::process::Command;
@@ -375,13 +382,13 @@ impl Engine {
if !enable { if !enable {
self.stop_scan(name); self.stop_scan(name);
} }
// Visto ao vivo: com a interface ainda sob o NetworkManager, ele // Detach the interface from NetworkManager (`nmcli device set <iface> managed no`)
// reage ao modo monitor como uma desconexão e reconecta a interface // before enabling monitor mode so NM does not immediately reset it back to
// de volta em managed — às vezes rápido o bastante pra já estar // managed mode, and reattach it when returning to managed mode.
// 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 { if enable {
let _ = std::process::Command::new("nmcli") let _ = std::process::Command::new("nmcli")
.args(["device", "set", name, "managed", "no"]) .args(["device", "set", name, "managed", "no"])
@@ -419,9 +426,9 @@ impl Engine {
Ok(()) Ok(())
} }
/// Trava a captura em um canal (ou volta a pular canais com `None`). /// Locks the active capture session onto a fixed channel (or resumes hopping with `None`).
/// Usado por deauth, PMKID e WPS para não perder o alvo enquanto o /// Used by deauth, PMKID, and WPS jobs so frames are transmitted and received on the
/// ataque está em andamento. /// target AP's channel.
pub fn lock_channel(&self, name: &str, channel: Option<u32>) -> Result<()> { pub fn lock_channel(&self, name: &str, channel: Option<u32>) -> Result<()> {
if let Some(channel) = channel { if let Some(channel) = channel {
netlink::set_channel(name, channel)?; netlink::set_channel(name, channel)?;
@@ -469,8 +476,8 @@ impl Engine {
}; };
let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000)); let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000));
// Substitui uma sessão anterior (se houver) só depois que a nova // Replace any previous capture session only after the new capture socket
// captura abriu, para não derrubar a antiga se algo falhar. // opens cleanly.
self.stop_scan(name); self.stop_scan(name);
let table = Arc::new(Mutex::new(ScanTable::new())); let table = Arc::new(Mutex::new(ScanTable::new()));
@@ -648,14 +655,14 @@ impl Engine {
let analysis = table.analysis(); let analysis = table.analysis();
let stats = table.stats(); let stats = table.stats();
let running = session.running.load(Ordering::SeqCst); let running = session.running.load(Ordering::SeqCst);
// Uma placa pode aceitar o netlink de "modo monitor" (nenhum erro // Detect silent monitor-mode failure: some laptop Wi-Fi adapters accept
// na hora de ativar) e ainda assim nunca entregar um quadro de // switching to monitor mode via netlink without error yet never deliver
// verdade pro socket de captura — comum em Wi-Fi interna de // any 802.11 frames to the raw socket. After 12s with zero frames,
// notebook, cujo firmware ignora o pedido de modo promíscuo. 12s // surface a warning to the user.
// 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 let warning = if running
&& stats.frames == 0 && stats.frames == 0
&& unix_now().saturating_sub(session.started_unix) >= 12 && unix_now().saturating_sub(session.started_unix) >= 12
@@ -684,7 +691,7 @@ impl Engine {
Ok(snapshot) Ok(snapshot)
} }
/// Compatibilidade com a API antiga: só os APs da sessão. /// Returns the list of access points from the current capture session.
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> { pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
self.sessions self.sessions
.lock() .lock()
@@ -703,8 +710,8 @@ impl Engine {
.unwrap_or_default() .unwrap_or_default()
} }
/// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao /// Parses an offline `.pcap` file and returns a `ScanSnapshot` without
/// vivo (sem sessão e sem tocar no rádio). /// touching any wireless hardware.
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> { pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
let path = path.as_ref(); let path = path.as_ref();
let mut reader = PcapReader::open(path)?; let mut reader = PcapReader::open(path)?;
@@ -732,9 +739,9 @@ impl Engine {
}) })
} }
/// Modelo completo do relatório: snapshot atual + trilha de auditoria. /// Builds the full audit report model (scan snapshot + WIDS analysis + audit trail).
/// A interface renderiza com Paged.js; a mesma estrutura vira HTML /// Rendered in the UI via Paged.js or exported as standalone HTML.
/// standalone no servidor local. ///
pub fn report(&self, name: &str) -> Result<crate::report::ReportModel> { pub fn report(&self, name: &str) -> Result<crate::report::ReportModel> {
let snapshot = self.scan_snapshot(name)?; let snapshot = self.scan_snapshot(name)?;
let audit = self.audit_entries(Some(60)).unwrap_or_default(); let audit = self.audit_entries(Some(60)).unwrap_or_default();
@@ -918,9 +925,9 @@ impl Engine {
) )
} }
/// Quebra com o dicionário embutido (9,7 milhões de senhas reais, /// Starts a background dictionary crack job using the built-in 9.7M WPA wordlist.
/// filtradas pro comprimento de WPA), sem exigir nenhum arquivo externo. ///
/// É o padrão do botão “Quebrar senha”. ///
pub fn start_crack_default( pub fn start_crack_default(
&self, &self,
name: &str, name: &str,
@@ -1048,11 +1055,11 @@ impl Engine {
Ok(id) Ok(id)
} }
/// Força bruta por máscara (ex.: `?d?d?d?d?d?d?d?d` pra 8 dígitos), sem /// Starts a background mask brute-force job (e.g. `?d?d?d?d?d?d?d?d`),
/// dicionário. `max_candidates` é um limite de segurança — máscaras /// rejecting keyspaces larger than `max_candidates`.
/// 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( pub fn start_crack_mask(
&self, &self,
name: &str, name: &str,
@@ -1196,8 +1203,8 @@ impl Engine {
self.deauth_directional(name, bssid, station, count, reason, "ap", "deauth") self.deauth_directional(name, bssid, station, count, reason, "ap", "deauth")
} }
/// `direction`: `ap` (AP→STA), `sta` (STA→AP, exige estação) ou `both`. /// `direction`: `"ap"` (AP->STA), `"sta"` (STA->AP, requires `client`), or `"both"`.
/// `frame_kind`: `deauth` ou `disassoc`. /// `frame_kind`: `"deauth"` or `"disassoc"`.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn deauth_directional( pub fn deauth_directional(
@@ -1294,11 +1301,11 @@ impl Engine {
Ok(report) Ok(report)
} }
/// Inicia uma desautenticação contínua (até `stop_deauth_job`). O canal da /// Starts a background deauthentication job (until `stop_deauth_job`), locking
/// captura é travado no canal do AP enquanto o job roda, senão o salto de /// the capture channel to the target AP and optionally stopping automatically
/// canais faria os quadros saírem na frequência errada e perderia o /// once a crackable handshake is captured (`stop_on_handshake`).
/// handshake. `stop_on_handshake` encerra sozinho quando o par ///
/// (BSSID, estação) fica quebrável. ///
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn start_deauth_job( pub fn start_deauth_job(
@@ -1484,8 +1491,8 @@ impl Engine {
if max_secs.is_some_and(|s| started.elapsed() >= Duration::from_secs(s)) { if max_secs.is_some_and(|s| started.elapsed() >= Duration::from_secs(s)) {
break; break;
} }
// Confere antes de uma nova rajada para não interromper um // Check whether a handshake already arrived before sending another burst.
// handshake que chegou enquanto o job esperava a reassociação. //
if stop_on_handshake && handshake_ready() { if stop_on_handshake && handshake_ready() {
handshake = true; handshake = true;
break; break;
@@ -1671,10 +1678,10 @@ impl Engine {
ids ids
} }
/// Atalho para “forçar handshake”: escolhe um cliente associado (quando /// Shortcut to force a WPA handshake: picks an associated client (if none
/// nenhum foi indicado), trava o canal do AP e envia rajadas espaçadas. /// specified), locks onto the AP channel, and sends spaced deauth bursts
/// O espaço entre as rajadas é essencial: deauth a cada 120 ms mantém o /// so the client has time to reassociate and complete the 4-way handshake.
/// cliente fora do ar e pode impedir a reassociação que produz o EAPOL. ///
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn capture_handshake( pub fn capture_handshake(
&self, &self,
@@ -1757,10 +1764,10 @@ impl Engine {
Err(SabiaError::UnsupportedPlatform) Err(SabiaError::UnsupportedPlatform)
} }
/// Tenta provocar a mensagem 1 com PMKID: autentica e envia um /// Attempts clientless PMKID capture by sending Open Authentication followed
/// association request com RSN/PMKID usando um MAC local forjado. É o /// by an Association Request with an RSN IE from a locally administered MAC.
/// mesmo gatilho do ataque clientless, mas depende do AP — muitos só ///
/// incluem PMKID em roaming real. ///
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result<PmkidReport> { pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result<PmkidReport> {
use crate::inject::{self, RawInjector}; use crate::inject::{self, RawInjector};
@@ -1860,6 +1867,12 @@ impl Engine {
Ok(report) Ok(report)
} }
#[cfg(not(target_os = "linux"))]
pub fn pmkid_hunt(&self, _name: &str, _bssid: &str, _attempts: u32) -> Result<PmkidReport> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn session_ap(&self, name: &str, bssid: &str) -> Result<Option<AccessPoint>> { fn session_ap(&self, name: &str, bssid: &str) -> Result<Option<AccessPoint>> {
let sessions = self.sessions.lock().unwrap(); let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| { let session = sessions.get(name).ok_or_else(|| {
@@ -1872,9 +1885,9 @@ impl Engine {
.find(|a| a.bssid.eq_ignore_ascii_case(bssid))) .find(|a| a.bssid.eq_ignore_ascii_case(bssid)))
} }
/// Teste prático de PMF: desautentica nas duas direções e observa se a /// Practical 802.11w (PMF) test: injects unprotected deauth frames in both
/// estação volta a associar. Não é uma prova criptográfica — é o mesmo /// directions and observes whether the station reassociates.
/// sinal que um ataque real usaria. ///
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn pmf_test( pub fn pmf_test(
&self, &self,
@@ -1946,6 +1959,18 @@ impl Engine {
Ok(report) Ok(report)
} }
#[cfg(not(target_os = "linux"))]
pub fn pmf_test(
&self,
_name: &str,
_bssid: &str,
_station: &str,
_reason: u16,
) -> Result<PmfReport> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn client_snapshot(&self, name: &str, station: &str) -> Result<Option<crate::scan::Client>> { fn client_snapshot(&self, name: &str, station: &str) -> Result<Option<crate::scan::Client>> {
let sessions = self.sessions.lock().unwrap(); let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| { let session = sessions.get(name).ok_or_else(|| {
@@ -1959,8 +1984,8 @@ impl Engine {
crate::wps::tools() crate::wps::tools()
} }
/// Inicia reaver/bully como processo filho. Sem PIN, o reaver usa Pixie /// Spawns `reaver` or `bully` as a child process (defaulting to Pixie Dust when
/// Dust. A saída é lida ao vivo e o PIN/PSK é extraído quando aparece. /// `pin` is omitted) and streams output lines while parsing any recovered PIN/PSK.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn start_wps( pub fn start_wps(
&self, &self,
@@ -2181,7 +2206,7 @@ impl Engine {
.ok_or_else(|| SabiaError::InvalidInput("job WPS não encontrado".into()))?; .ok_or_else(|| SabiaError::InvalidInput("job WPS não encontrado".into()))?;
let pid = job.lock().unwrap().pid; let pid = job.lock().unwrap().pid;
if let Some(pid) = pid { if let Some(pid) = pid {
// SAFETY: sinal para o processo filho que este próprio Sabiá criou. // SAFETY: sends a signal to the child process spawned by this instance.
unsafe { unsafe {
libc::kill(pid as i32, libc::SIGTERM); libc::kill(pid as i32, libc::SIGTERM);
} }
@@ -2202,16 +2227,16 @@ impl Engine {
Err(SabiaError::UnsupportedPlatform) Err(SabiaError::UnsupportedPlatform)
} }
/// Evil Twin + portal cativo: clona um AP já visto nesta sessão (SSID /// Evil Twin + captive portal: clones a target AP on a secondary `ap_iface`
/// aberto, numa segunda interface em modo AP) e sobe um portal que pede /// (open SSID) with `hostapd` + `dnsmasq` and serves a captive portal that
/// a senha — conferida na hora contra um handshake real já capturado /// verifies submitted passphrases against a captured WPA handshake/PMKID.
/// 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. ///
///
/// Requires `hostapd`, `dnsmasq`, `iptables`, and a second AP-capable wireless interface.
/// ///
/// 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")] #[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn start_evil_twin( pub fn start_evil_twin(
@@ -2304,13 +2329,13 @@ impl Engine {
) )
.map_err(SabiaError::Io)?; .map_err(SabiaError::Io)?;
// Estado em disco pra recuperação: se este processo morrer (Ctrl+C // Persist recovery state to disk before modifying network state so
// sem handler, kill -9, queda de energia) antes da thread de // `self_heal_eviltwin_leftovers` can clean up orphaned `hostapd`/`dnsmasq`
// limpeza rodar, as duas interfaces ficam presas em `managed no` // processes, `iptables` rules, and `nmcli managed no` flags if killed.
// 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 state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write( let _ = fs::write(
&state_path, &state_path,
@@ -2324,17 +2349,17 @@ impl Engine {
.to_string(), .to_string(),
); );
// Se o NetworkManager (ou outro gerenciador) estiver cuidando dessa // Detach both `ap_iface` and `capture_iface` from NetworkManager before
// interface, ele reafirma a própria conexão por cima e o hostapd // starting `hostapd` so NM neither overrides the rogue AP interface nor
// nunca consegue de fato assumir o rádio — fica "rodando" sem subir // attempts to auto-connect a saved profile on the capture interface.
// 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") let _ = Command::new("nmcli")
.args(["device", "set", ap_iface, "managed", "no"]) .args(["device", "set", ap_iface, "managed", "no"])
.status(); .status();
@@ -2342,8 +2367,8 @@ impl Engine {
.args(["device", "set", name, "managed", "no"]) .args(["device", "set", name, "managed", "no"])
.status(); .status();
// Endereço do gateway na interface do clone, antes de subir o // Assign the gateway IP to `ap_iface` before starting `hostapd`/`dnsmasq`.
// hostapd/dnsmasq — sem isso o DHCP não tem o que anunciar. //
let _ = Command::new("ip") let _ = Command::new("ip")
.args(["addr", "flush", "dev", ap_iface]) .args(["addr", "flush", "dev", ap_iface])
.status(); .status();
@@ -2354,13 +2379,13 @@ impl Engine {
.args(["link", "set", ap_iface, "up"]) .args(["link", "set", ap_iface, "up"])
.status(); .status();
// A saída do hostapd ia pro vácuo (Stdio::null()); quando ele morre // Redirect `hostapd` output to a log file and verify it stays alive after
// na hora (config que o driver recusa, canal/banda que a placa não // startup so immediate driver/channel failures return a clear error.
// suporta de verdade, etc.) a única pista era um processo //
// "<defunct>" 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_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_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 hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?;
@@ -2406,11 +2431,11 @@ impl Engine {
let _ = Command::new("iptables").args(&rule).status(); let _ = Command::new("iptables").args(&rule).status();
} }
// Sem parada automática daqui de dentro: a thread que termina o // Cap continuous deauth at 30 minutes (or until `stop_evil_twin` / `stop_deauth_job`).
// 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 { let deauth_job = if deauth_real_ap {
self.start_deauth_job_burst( self.start_deauth_job_burst(
name, name,
@@ -2502,8 +2527,8 @@ impl Engine {
}, },
); );
// Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o // Cleanup: stop `hostapd`/`dnsmasq`, remove `iptables` rules, and restore
// job de deauth do AP real — sempre, sucesso ou não. // both interfaces to NetworkManager.
unsafe { unsafe {
libc::kill(hostapd_pid as i32, libc::SIGTERM); libc::kill(hostapd_pid as i32, libc::SIGTERM);
} }
@@ -2515,20 +2540,20 @@ impl Engine {
for rule in crate::eviltwin::iptables_cleanup_rules(&ap_iface_owned, portal_port) { for rule in crate::eviltwin::iptables_cleanup_rules(&ap_iface_owned, portal_port) {
let _ = Command::new("iptables").args(&rule).status(); let _ = Command::new("iptables").args(&rule).status();
} }
// Devolve as duas interfaces pro NetworkManager, senão ficam // Return both `ap_iface` and `capture_iface` to NetworkManager.
// 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") let _ = Command::new("nmcli")
.args(["device", "set", &ap_iface_owned, "managed", "yes"]) .args(["device", "set", &ap_iface_owned, "managed", "yes"])
.status(); .status();
let _ = Command::new("nmcli") let _ = Command::new("nmcli")
.args(["device", "set", &iface_name, "managed", "yes"]) .args(["device", "set", &iface_name, "managed", "yes"])
.status(); .status();
// Limpeza terminou direitinho: o arquivo de recuperação não é // Cleanup succeeded: remove the crash-recovery state file.
// 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 _ = std::fs::remove_file(&state_path_for_thread);
let mut guard = thread_job.lock().unwrap(); let mut guard = thread_job.lock().unwrap();
@@ -2613,29 +2638,29 @@ impl Engine {
if let Some(stop) = &guard.stop { if let Some(stop) = &guard.stop {
stop.store(true, Ordering::SeqCst); stop.store(true, Ordering::SeqCst);
} }
// O accept() não-bloqueante só confere o `stop` a cada ~80ms — o // Wake the non-blocking `accept()` loop with a local connection so it exits immediately.
// listener às vezes só é liberado numa conexão local de "empurrão". //
let port = guard.portal_port; let port = guard.portal_port;
let capture_iface = guard.iface.clone(); let capture_iface = guard.iface.clone();
drop(guard); drop(guard);
let _ = std::net::TcpStream::connect(("127.0.0.1", port)); 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 // Also stop the companion deauth job if one was started alongside the Evil Twin.
// job de deauth acoplado de volta; isso aqui, que tem `&self`, pode. //
self.stop_deauth_for_iface(&capture_iface); self.stop_deauth_for_iface(&capture_iface);
Ok(()) Ok(())
} }
/// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X): clona o AP numa /// Starts a WPA2/WPA3-Enterprise (802.1X) Evil Twin using `hostapd-wpe`
/// segunda interface anunciando o mesmo SSID+canal e deixa o /// on `ap_iface` to capture EAP (PEAP/TTLS -> MSCHAPv2) challenge/response
/// `hostapd-wpe` aceitar o handshake EAP (PEAP/TTLS→MSCHAPv2) de /// credentials (see `eviltwin_enterprise.rs`).
/// 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). ///
///
/// Requires the target AP to advertise 802.1X Enterprise security and
/// `hostapd-wpe` + `openssl` to be installed.
///
/// ///
/// 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")] #[cfg(target_os = "linux")]
pub fn start_evil_twin_enterprise( pub fn start_evil_twin_enterprise(
&self, &self,
@@ -2708,10 +2733,10 @@ impl Engine {
crate::eviltwin_enterprise::DEFAULT_EAP_USER_FILE, crate::eviltwin_enterprise::DEFAULT_EAP_USER_FILE,
) )
.map_err(SabiaError::Io)?; .map_err(SabiaError::Io)?;
// Mesmo certificado autoassinado serve de "CA" e de certificado do // Use the generated self-signed certificate as both `ca_cert` and
// servidor — não é uma cadeia de verdade, só o suficiente pra abrir // `server_cert` to establish the outer PEAP/TTLS TLS tunnel.
// 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( let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args(
&key_path.to_string_lossy(), &key_path.to_string_lossy(),
&cert_path.to_string_lossy(), &cert_path.to_string_lossy(),
@@ -2743,11 +2768,11 @@ impl Engine {
) )
.map_err(SabiaError::Io)?; .map_err(SabiaError::Io)?;
// Mesmo esquema de recuperação do Evil Twin de PSK (bug-093): // Persist crash-recovery state so `self_heal_eviltwin_leftovers` can
// `self_heal_eviltwin_leftovers` lê qualquer `state-*.json` sobrando // restore interfaces if the process exits unexpectedly.
// 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 state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write( let _ = fs::write(
&state_path, &state_path,
@@ -2766,11 +2791,11 @@ impl Engine {
.args(["device", "set", name, "managed", "no"]) .args(["device", "set", name, "managed", "no"])
.status(); .status();
// Mesmo diagnóstico do bug-093 pro hostapd puro: grava a saída pra // Log `hostapd-wpe` output and check for immediate startup exit.
// conseguir explicar um crash imediato em vez de deixar um //
// "<defunct>" 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_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_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 hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?;
@@ -2841,9 +2866,9 @@ impl Engine {
Err(SabiaError::UnsupportedPlatform) Err(SabiaError::UnsupportedPlatform)
} }
/// Relê o log do hostapd-wpe (se o job ainda estiver rodando) e devolve /// Re-parses the `hostapd-wpe` log file and returns the current job status.
/// 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<EnterpriseTwinJobInfo> { pub fn enterprise_twin_job(&self, id: &str) -> Option<EnterpriseTwinJobInfo> {
let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?; let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?;
{ {
@@ -2878,11 +2903,11 @@ impl Engine {
out out
} }
/// Mata o hostapd-wpe e devolve as duas interfaces pro NetworkManager — /// Stops `hostapd-wpe`, restores both wireless interfaces to NetworkManager,
/// síncrono (tem `&self`, sem thread solta pra coordenar), ao contrário /// and preserves the captured credentials log file on disk for offline cracking.
/// 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")] #[cfg(target_os = "linux")]
pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> { pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> {
let job = self let job = self
@@ -3005,14 +3030,14 @@ fn append_audit(
let _ = log.record(action, target, &text); let _ = log.record(action, target, &text);
} }
/// Desfaz um Evil Twin que não terminou direito numa execução anterior /// Cleans up leftover Evil Twin state from a previously terminated process:
/// (processo morto por Ctrl+C sem handler, `kill -9`, queda de energia): /// kills orphaned `hostapd`/`dnsmasq` processes, removes `iptables` redirect
/// mata hostapd/dnsmasq órfãos, remove as regras de iptables e devolve as /// rules, and returns both wireless interfaces to NetworkManager.
/// duas interfaces (clone + captura) pro NetworkManager. Sem isso elas /// Runs best-effort whenever an `Engine` instance is initialized.
/// 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 <iface> managed yes` na mão. ///
/// Roda uma vez, best-effort, toda vez que um `Engine` é criado. ///
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn self_heal_eviltwin_leftovers() { fn self_heal_eviltwin_leftovers() {
use std::process::Command; use std::process::Command;
@@ -3024,8 +3049,8 @@ fn self_heal_eviltwin_leftovers() {
return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
}; };
self_heal_eviltwin_leftovers_for_uid(uid); self_heal_eviltwin_leftovers_for_uid(uid);
// Também cobre o euid atual (sudo normalmente roda como root/0, que é // Scan both the real user UID and the current effective UID (`0` under sudo).
// o euid real do processo mesmo com SUDO_UID setado). //
self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
fn self_heal_eviltwin_leftovers_for_uid(uid: u32) { fn self_heal_eviltwin_leftovers_for_uid(uid: u32) {
@@ -3263,8 +3288,8 @@ mod tests {
fn evil_twin_requires_a_crackable_handshake_for_that_bssid() { fn evil_twin_requires_a_crackable_handshake_for_that_bssid() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log")); 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 // Only a beacon was ingested (no handshake yet), so the captive portal
// tem como conferir senha nenhuma no portal ainda. // cannot verify passphrases yet.
let mut table = ScanTable::new(); let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40); let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon); table.ingest(&beacon);
@@ -3290,8 +3315,8 @@ mod tests {
#[test] #[test]
fn evil_twin_fails_clearly_when_hostapd_is_missing() { fn evil_twin_fails_clearly_when_hostapd_is_missing() {
// Neste ambiente de CI/sandbox hostapd não está instalado de // Exercise the missing-binary error path when `hostapd` is absent.
// propósito — exercita o caminho de erro de verdade, não um mock. //
if crate::wps::find_in_path("hostapd").is_some() { if crate::wps::find_in_path("hostapd").is_some() {
eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)"); eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return; return;
@@ -3323,7 +3348,7 @@ mod tests {
#[test] #[test]
fn evil_twin_enterprise_rejects_a_psk_network() { fn evil_twin_enterprise_rejects_a_psk_network() {
// seed_session() cria um beacon com AKM 2 (PSK) — não é Enterprise. // `seed_session()` seeds a WPA2-PSK beacon (AKM 2), which is rejected by Enterprise Evil Twin.
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log")); let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test"); seed_session(&engine, "wlan-test");
@@ -3346,9 +3371,9 @@ mod tests {
#[test] #[test]
fn evil_twin_enterprise_accepts_an_802_1x_network_up_to_the_hostapd_wpe_check() { 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ó // AKM 1 = 802.1X (Enterprise) passes security validation and reaches external binary checks.
// 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() { if crate::wps::find_in_path("hostapd-wpe").is_some() {
eprintln!("hostapd-wpe instalado nesta máquina — pulando (o teste é pro caso sem ele)"); eprintln!("hostapd-wpe instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return; return;
+55 -53
View File
@@ -1,27 +1,27 @@
//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto, //! Evil Twin + captive portal: clones a target AP observed during capture (as an
//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação //! open network with the same SSID) alongside continuous deauthentication of the
//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de //! real AP, presenting a captive portal that prompts for the network passphrase.
//! verdade por uma página de "reconectar", como qualquer portal cativo de
//! hotel/aeroporto faz.
//! //!
//! A diferença pro phishing comum: a senha digitada **nunca é aceita de
//! graça**. É conferida na hora contra o handshake real já capturado
//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado
//! na quebra por dicionário) — só confirma "conectado" quando a senha bate
//! de verdade com a rede. Isso também evita que a página vire um jeito de
//! coletar qualquer string digitada: ela só diz "certo" pra senha certa.
//! //!
//! Só funciona contra um AP que já apareceu na sessão de captura atual (não //!
//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização //! Submitted passphrases are verified immediately against the captured WPA
//! explícita das outras abas de ataque. //! handshake or PMKID ([`crate::crack::CrackTarget::verify`]): the portal only
//! reports success when the passphrase cryptographically matches the target network.
//!
//!
//!
//!
//! Requires an active target from the current capture session and explicit
//! operator authorization.
//!
use crate::crack::CrackTarget; use crate::crack::CrackTarget;
use std::fmt::Write as _; use std::fmt::Write as _;
/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito — /// Generates the `hostapd` configuration for the open clone AP.
/// é o portal cativo que pede a senha, não o rádio. Praticamente todo ///
/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o ///
/// SSID certo, sem perguntar nada ao usuário. ///
pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String { pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!(out, "interface={iface}"); let _ = writeln!(out, "interface={iface}");
@@ -38,10 +38,10 @@ pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>)
out out
} }
/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve /// Generates the `dnsmasq` configuration: serves DHCP on the clone subnet and
/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/ /// resolves all DNS queries to the gateway IP so OS captive-portal detection
/// Windows usam pra detectar portal cativo (é o que faz o popup de login /// automatically opens the login prompt.
/// abrir sozinho no dispositivo da vítima). ///
pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String { pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!(out, "interface={iface}"); let _ = writeln!(out, "interface={iface}");
@@ -49,15 +49,15 @@ pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) ->
let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1); let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1);
let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway
let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS
let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós let _ = writeln!(out, "address=/#/{gateway_ip}"); // wildcard DNS pointing to our gateway
let _ = writeln!(out, "no-resolv"); let _ = writeln!(out, "no-resolv");
out out
} }
/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada /// Builds `iptables` rules (one argument list per command) redirecting HTTP
/// `Vec<String>` é um `iptables <args>`) pra mandar todo HTTP da sub-rede do /// traffic from the clone AP subnet to the local captive portal listener.
/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra ///
/// internet de propósito — o clone nunca dá acesso de verdade, só o portal. ///
pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> { pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
let redirect = |proto: &str, dport: u16| { let redirect = |proto: &str, dport: u16| {
vec![ vec![
@@ -80,7 +80,7 @@ pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
vec![redirect("tcp", 80)] vec![redirect("tcp", 80)]
} }
/// As mesmas regras, pra desfazer (`-D` em vez de `-A`) ao parar o job. /// Matching `iptables -D` rules to tear down redirection when the job stops.
pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> { pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
iptables_rules(iface, portal_port) iptables_rules(iface, portal_port)
.into_iter() .into_iter()
@@ -94,27 +94,27 @@ pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>>
} }
fn sanitize_conf_value(value: &str) -> String { fn sanitize_conf_value(value: &str) -> String {
// hostapd.conf não escapa valores — corta quebra de linha, que seria // Strip newlines and control characters to prevent `hostapd.conf` directive injection.
// injeção de outra diretiva de config. //
value.replace(['\n', '\r'], "") value.replace(['\n', '\r'], "")
} }
/// Resultado de uma tentativa de login no portal. /// Outcome of a captive portal passphrase submission.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortalAttempt { pub enum PortalAttempt {
/// Senha bateu com o handshake real — confirmado criptograficamente, /// Passphrase matched the captured handshake/PMKID.
/// não é só "aceitar o que a pessoa digitou". ///
Correct, Correct,
WrongPassword, WrongPassword,
/// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso. /// Outside valid WPA PSK length (8–63 characters).
InvalidLength, InvalidLength,
} }
/// Confere a senha submetida no portal contra o handshake real do alvo — /// Verifies a submitted passphrase against the target's captured `CrackTarget`.
/// mesma verificação criptográfica usada na quebra por dicionário ///
/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado" ///
/// quando isso bate; do contrário, pede de novo, exatamente como um portal ///
/// cativo legítimo faria com uma senha errada. ///
pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt { pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
if !(8..=63).contains(&password.len()) { if !(8..=63).contains(&password.len()) {
return PortalAttempt::InvalidLength; return PortalAttempt::InvalidLength;
@@ -126,8 +126,8 @@ pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
} }
} }
/// Página de login do portal — pede a senha da rede clonada. Texto genérico /// Renders the captive portal HTML page for the cloned SSID.
/// de "reconectar à rede", sem imitar nenhuma marca específica. ///
pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String { pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String {
let notice = if wrong_password { let notice = if wrong_password {
r#"<p class="err">Senha incorreta. Tente de novo.</p>"# r#"<p class="err">Senha incorreta. Tente de novo.</p>"#
@@ -176,12 +176,12 @@ display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0
) )
} }
/// Servidor HTTP minúsculo do portal — sem dependência nova, não precisa /// Minimal captive portal HTTP server: serves the login page on any `GET`
/// ser um servidor de verdade, só responder GET (qualquer caminho vira a /// (triggering OS captive-portal popups) and verifies `password=...` on
/// página de login, o que também satisfaz a detecção de portal cativo do /// `POST /connect` against `target`. Runs until a valid passphrase is
/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e /// submitted or `stop` is set.
/// confere contra o handshake real). Roda até achar a senha certa, o `stop` ///
/// virar true, ou o accept falhar. ///
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn run_portal( pub fn run_portal(
listener: std::net::TcpListener, listener: std::net::TcpListener,
@@ -226,8 +226,8 @@ fn handle_connection(
.ok(); .ok();
let mut buf = vec![0u8; 8192]; let mut buf = vec![0u8; 8192];
let mut total = 0usize; let mut total = 0usize;
// Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos // Read until the end of HTTP headers and declared `Content-Length`.
// que um navegador manda pra um form simples. //
loop { loop {
let n = stream.read(&mut buf[total..]).ok()?; let n = stream.read(&mut buf[total..]).ok()?;
if n == 0 { if n == 0 {
@@ -286,9 +286,10 @@ fn handle_connection(
found_password found_password
} }
/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples /// Extracts a URL-decoded `key=value` field from an `application/x-www-form-urlencoded` body.
/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma ///
/// crate de parsing de formulário por causa disso). ///
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn form_value(body: &str, key: &str) -> Option<String> { fn form_value(body: &str, key: &str) -> Option<String> {
body.split('&').find_map(|pair| { body.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?; let (k, v) = pair.split_once('=')?;
@@ -300,6 +301,7 @@ fn form_value(body: &str, key: &str) -> Option<String> {
}) })
} }
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn url_decode(s: &str) -> String { fn url_decode(s: &str) -> String {
let bytes = s.as_bytes(); let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len()); let mut out = Vec::with_capacity(bytes.len());
@@ -433,7 +435,7 @@ mod tests {
}) })
}); });
// Dá um tempinho pro accept() estar escutando antes do primeiro connect. // Brief pause so the listener thread enters `accept()` before the client connects.
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));
let post = |password: &str| -> String { let post = |password: &str| -> String {