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