fix: captura de handshake real (endianness EAPOL) e enriquece o parsing 802.11
- eapol.rs: key_info/replay_counter/key_data_len liam little-endian; são big-endian (802.1X segue ordem de rede), então handshakes reais nunca fechavam mesmo com deauth funcionando. testutil.rs e os geradores de quadro de teste corrigidos junto, mais um teste contra bytes de wire reais (não só contra o próprio encoder). - inject.rs: deauth/disassoc agora grava Sequence Control crescente por quadro — reenviar o mesmo quadro era descartado como duplicata pelo driver/AP depois do primeiro envio. - dot11.rs/scan.rs: parsing de AP/cliente bem mais completo (PMF obrigatório, WPS detalhado, fabricante por OUI, país, largura de banda, padrão PHY, privacidade) e histórico de sinal/eventos por cliente. - handshake.rs/ccmp.rs: deriva a Temporal Key (CCMP) quando a senha é quebrada, o suficiente pra decifrar o tráfego em claro do par (BSSID, estação) cujo handshake foi capturado.
This commit is contained in:
1 parent
dab86d1fa7
commit
5477b8fb03
10 files changed
+2523
-19
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user