feat: dicionário completo, regras de mutação, máscara, .hc22000 e WPS (reaver)

- crack.rs: dicionário padrão trocado pra wpa-full.txt.gz (rockyou +
  Probable-Wordlists top204k mesclados/deduplicados, 9,7M senhas,
  comprimido e embutido via LazyLock), PBKDF2 ~640x mais rápido
  (paralelização por núcleo + HMAC reaproveitado em vez de reconstruído a
  cada iteração), força bruta por máscara (rules.rs::Mask, sintaxe
  ?d?l?u?s?a) e regras de mutação tipo hashcat (maiúscula, sufixos
  numéricos/símbolos, leet simples).
- hashcat.rs: exportação de handshake/PMKID em .hc22000 (individual ou em
  lote), pro hashcat de verdade em vez de só o dicionário embutido.
- wps.rs: ataque WPS PIN via reaver (binário real do
  reaver-wps-fork-t6x, GPL-2, extraído em tempo de execução) e bully
  quando instalado no sistema.
- assets/wordlists/LICENSE.wpa-full documenta a atribuição (CC BY-SA 4.0
  do Probable-Wordlists; rockyou.txt como dado de vazamento de uso livre
  consolidado em pesquisa de segurança, mesmo padrão do Kali/SecLists).
This commit is contained in:
Lucas Rayan committed 2026-10-24 14:53:54 -03:00
1 parent 5477b8fb03
commit f83654506e
9 files changed
+979 -29

No files matched your search

+2
View File
@@ -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"
@@ -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.
Binary file not shown.
+316 -29
View File
@@ -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<Sha1>;
@@ -29,6 +33,37 @@ type HmacMd5 = Hmac<Md5>;
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<String> = 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<u8> {
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<R: BufRead, F: FnMut(u64)>(
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<String> = Vec::new();
let mut line = String::new();
loop {
line.clear();
@@ -294,20 +349,62 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
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<Option<String>> = 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<R: BufRead, F: FnMut(u64)>(
}
}
pub fn crack_handshake<F: FnMut(u64)>(
pub fn crack_handshake<F: FnMut(u64) + Send>(
hs: &Handshake,
wordlist: impl AsRef<Path>,
apply_rules: bool,
progress: F,
) -> Result<CrackReport> {
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<F: FnMut(u64) + Send>(
hs: &Handshake,
apply_rules: bool,
progress: F,
) -> Result<CrackReport> {
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> {
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<F: FnMut(u64)>(
pub fn crack_pcap<F: FnMut(u64) + Send>(
pcap: impl AsRef<Path>,
wordlist: impl AsRef<Path>,
bssid: Option<&str>,
station: Option<&str>,
apply_rules: bool,
progress: F,
) -> Result<CrackReport> {
let mut reader = PcapReader::open(pcap)?;
@@ -351,7 +474,88 @@ pub fn crack_pcap<F: FnMut(u64)>(
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<F: FnMut(u64)>(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<Option<String>> = 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<F: FnMut(u64) + Send>(
hs: &Handshake,
mask: &Mask,
progress: F,
) -> Result<CrackReport> {
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::<Vec<_>>()
.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<u8> {
(0..s.len())
.step_by(2)
+86
View File
@@ -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<String> {
let bytes: Vec<String> = 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<String> {
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<Item = &'a Handshake>) -> Vec<String> {
let mut lines: Vec<String> = 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***"
);
}
}
+232
View File
@@ -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<String> {
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<Vec<char>>,
}
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<Self, String> {
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<u64> {
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<String> {
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);
}
}
+212
View File
@@ -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<PathBuf> {
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<WpsTool> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String> {
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<String>, Option<String>) {
let lower = line.to_ascii_lowercase();
let grab = |needle: &str| -> Option<String> {
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"]));
}
}