diff --git a/crates/sabia-core/src/ccmp.rs b/crates/sabia-core/src/ccmp.rs new file mode 100644 index 0000000..c796fb8 --- /dev/null +++ b/crates/sabia-core/src/ccmp.rs @@ -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> { + 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()); + } +} diff --git a/crates/sabia-core/src/dot11.rs b/crates/sabia-core/src/dot11.rs index 2cad044..6a5b2f7 100644 --- a/crates/sabia-core/src/dot11.rs +++ b/crates/sabia-core/src/dot11.rs @@ -79,7 +79,33 @@ pub struct ManagementInfo { pub akm: Vec, pub wps: bool, pub pmf: bool, + pub pmf_required: bool, pub country: Option, + pub beacon_interval: Option, + pub privacy: bool, + pub phy: Vec, + pub width_mhz: Option, + pub wps_locked: Option, + pub wps_version: Option, + pub wps_manufacturer: Option, + pub wps_model: Option, + pub wps_model_number: Option, + pub wps_device_name: Option, + pub wps_serial: Option, +} + +/// 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, + pub hostname: Option, + pub os: Option, + pub device: Option, + pub manufacturer: Option, + pub model: Option, + pub service: Option, } #[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 { + 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> { + 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 { + (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::().ok()), + parts.next().and_then(|p| p.parse::().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 { + 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 { + 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 { + 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 { + 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, 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 { 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 { + 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 { + 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!( diff --git a/crates/sabia-core/src/eapol.rs b/crates/sabia-core/src/eapol.rs index b28ae6d..6b4afcd 100644 --- a/crates/sabia-core/src/eapol.rs +++ b/crates/sabia-core/src/eapol.rs @@ -55,7 +55,14 @@ pub fn parse(buf: &[u8]) -> Option { 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 { 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]; diff --git a/crates/sabia-core/src/handshake.rs b/crates/sabia-core/src/handshake.rs index 8a10ab0..4971f9a 100644 --- a/crates/sabia-core/src/handshake.rs +++ b/crates/sabia-core/src/handshake.rs @@ -33,6 +33,12 @@ pub struct Handshake { pub first_seen_unix: u64, pub last_seen_unix: u64, pub password: Option, + /// 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 => { diff --git a/crates/sabia-core/src/inject.rs b/crates/sabia-core/src/inject.rs index 12f2386..7384423 100644 --- a/crates/sabia-core/src/inject.rs +++ b/crates/sabia-core/src/inject.rs @@ -17,13 +17,26 @@ fn radiotap_min() -> [u8; 8] { } fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec { + 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 { 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 Vec { + 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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); + } } diff --git a/crates/sabia-core/src/oui.rs b/crates/sabia-core/src/oui.rs new file mode 100644 index 0000000..0deffb1 --- /dev/null +++ b/crates/sabia-core/src/oui.rs @@ -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::() + .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}"); + } + } +} diff --git a/crates/sabia-core/src/scan.rs b/crates/sabia-core/src/scan.rs index b84f826..f9a52ab 100644 --- a/crates/sabia-core/src/scan.rs +++ b/crates/sabia-core/src/scan.rs @@ -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, pub akm: Vec, pub pmf: bool, + pub pmf_required: bool, pub wps: bool, + pub wps_locked: Option, + pub wps_version: Option, + pub wps_manufacturer: Option, + pub wps_model: Option, + pub wps_model_number: Option, + pub wps_device_name: Option, + pub wps_serial: Option, + pub vendor: Option, + pub country: Option, + pub phy: Vec, + pub width_mhz: Option, + pub beacon_interval: Option, + pub privacy: bool, pub signal_dbm: Option, pub max_signal_dbm: Option, 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, +} + #[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, + pub vendor: Option, + pub manufacturer: Option, + pub model: Option, + pub device_name: Option, + pub hostname: Option, + pub ip: Option, + pub os: Option, + pub device: Option, + pub random_mac: bool, pub bssid: Option, pub signal_dbm: Option, pub max_signal_dbm: Option, @@ -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, + /// Linha do tempo curta: primeiro visto, probe, associação, desautenticação. + pub events: Vec, } #[derive(Debug, Clone, Copy, Default)] @@ -58,6 +101,17 @@ pub struct ScanSnapshot { pub access_points: Vec, pub clients: Vec, pub handshakes: Vec, + 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, + /// 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, } #[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, } 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 { 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")); + } } diff --git a/crates/sabia-core/src/testutil.rs b/crates/sabia-core/src/testutil.rs index dceec85..9a87f95 100644 --- a/crates/sabia-core/src/testutil.rs +++ b/crates/sabia-core/src/testutil.rs @@ -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()); diff --git a/crates/sabia-core/tests/common/mod.rs b/crates/sabia-core/tests/common/mod.rs index 55772fc..abbceb5 100644 --- a/crates/sabia-core/tests/common/mod.rs +++ b/crates/sabia-core/tests/common/mod.rs @@ -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()); diff --git a/crates/sabia-core/tests/scan_e2e.rs b/crates/sabia-core/tests/scan_e2e.rs index 478a569..d4fd914 100644 --- a/crates/sabia-core/tests/scan_e2e.rs +++ b/crates/sabia-core/tests/scan_e2e.rs @@ -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();