- 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.
404 lines
13 KiB
Rust
404 lines
13 KiB
Rust
//! Montagem dos handshakes WPA/WPA2 a partir dos quadros EAPOL vistos no ar.
|
|
//!
|
|
//! Um handshake é identificado pelo par (BSSID, estação). Guardamos as quatro
|
|
//! mensagens quando aparecem, o PMKID da mensagem 1 e os quadros crus
|
|
//! necessários para exportar um `.pcap` que o `aircrack-ng`/hashcat também
|
|
//! entendam.
|
|
|
|
use crate::dot11;
|
|
use crate::eapol::{self, EapolKey};
|
|
use std::collections::HashMap;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RawFrame {
|
|
pub message: u8,
|
|
pub raw: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Handshake {
|
|
pub bssid: String,
|
|
pub station: String,
|
|
pub ssid: Option<String>,
|
|
pub anonce: Option<[u8; 32]>,
|
|
pub snonce: Option<[u8; 32]>,
|
|
pub pmkid: Option<[u8; 16]>,
|
|
pub key_version: u8,
|
|
/// Bitmask das mensagens vistas (bit 0 = mensagem 1).
|
|
pub messages: u8,
|
|
/// Quadro EAPOL da mensagem 2, com o MIC — necessário para conferir a
|
|
/// senha candidata.
|
|
pub eapol_msg2: Option<Vec<u8>>,
|
|
pub raw: Vec<RawFrame>,
|
|
pub first_seen_unix: u64,
|
|
pub last_seen_unix: u64,
|
|
pub password: Option<String>,
|
|
/// Temporal Key (CCMP/AES) derivada quando `password` é preenchido.
|
|
/// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre
|
|
/// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID,
|
|
/// estação) específico — não abre o tráfego de outros clientes, já que
|
|
/// a PTK é efêmera por associação.
|
|
pub tk: Option<[u8; 16]>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct HandshakeInfo {
|
|
pub bssid: String,
|
|
pub station: String,
|
|
pub ssid: Option<String>,
|
|
pub kind: String,
|
|
pub messages: String,
|
|
pub complete: bool,
|
|
pub crackable: bool,
|
|
pub pmkid: Option<String>,
|
|
pub key_version: u8,
|
|
pub password: Option<String>,
|
|
pub raw_frames: usize,
|
|
pub first_seen_unix: u64,
|
|
pub last_seen_unix: u64,
|
|
}
|
|
|
|
impl Handshake {
|
|
pub fn message_list(&self) -> String {
|
|
let mut seen = Vec::new();
|
|
for msg in 1..=4 {
|
|
if self.messages & (1 << (msg - 1)) != 0 {
|
|
seen.push(msg.to_string());
|
|
}
|
|
}
|
|
seen.join(",")
|
|
}
|
|
|
|
pub fn kind(&self) -> &'static str {
|
|
match (self.pmkid.is_some(), self.eapol_msg2.is_some()) {
|
|
(true, true) => "EAPOL + PMKID",
|
|
(true, false) => "PMKID",
|
|
(false, _) => "EAPOL",
|
|
}
|
|
}
|
|
|
|
pub fn is_complete(&self) -> bool {
|
|
self.pmkid.is_some()
|
|
|| (self.anonce.is_some() && self.snonce.is_some() && self.eapol_msg2.is_some())
|
|
}
|
|
|
|
pub fn is_crackable(&self) -> bool {
|
|
let ssid_ok = self.ssid.as_ref().is_some_and(|s| !s.is_empty());
|
|
if !ssid_ok {
|
|
return false;
|
|
}
|
|
if self.pmkid.is_some() {
|
|
return true;
|
|
}
|
|
self.anonce.is_some() && self.eapol_msg2.is_some() && matches!(self.key_version, 1 | 2)
|
|
}
|
|
|
|
pub fn info(&self) -> HandshakeInfo {
|
|
HandshakeInfo {
|
|
bssid: self.bssid.clone(),
|
|
station: self.station.clone(),
|
|
ssid: self.ssid.clone(),
|
|
kind: self.kind().to_string(),
|
|
messages: self.message_list(),
|
|
complete: self.is_complete(),
|
|
crackable: self.is_crackable(),
|
|
pmkid: self.pmkid.map(|p| hex(&p)),
|
|
key_version: self.key_version,
|
|
password: self.password.clone(),
|
|
raw_frames: self.raw.len(),
|
|
first_seen_unix: self.first_seen_unix,
|
|
last_seen_unix: self.last_seen_unix,
|
|
}
|
|
}
|
|
|
|
/// Quadros crus (radiotap + 802.11) das mensagens vistas, em ordem.
|
|
pub fn raw_frames(&self) -> Vec<Vec<u8>> {
|
|
let mut v = self.raw.clone();
|
|
v.sort_by_key(|f| f.message);
|
|
v.into_iter().map(|f| f.raw).collect()
|
|
}
|
|
|
|
fn store_raw(&mut self, message: u8, raw: &[u8]) {
|
|
if let Some(existing) = self.raw.iter_mut().find(|f| f.message == message) {
|
|
existing.raw = raw.to_vec();
|
|
} else {
|
|
self.raw.push(RawFrame {
|
|
message,
|
|
raw: raw.to_vec(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct HandshakeTracker {
|
|
map: HashMap<(String, String), Handshake>,
|
|
}
|
|
|
|
impl HandshakeTracker {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Processa um quadro 802.11. Retorna `true` quando o quadro era EAPOL e
|
|
/// atualizou (ou criou) um handshake.
|
|
pub fn ingest(
|
|
&mut self,
|
|
frame: &dot11::Frame<'_>,
|
|
raw: &[u8],
|
|
ssid_hint: Option<&str>,
|
|
now: u64,
|
|
) -> bool {
|
|
let Some(payload) = frame.eapol() else {
|
|
return false;
|
|
};
|
|
let Some(key) = eapol::parse(payload) else {
|
|
return false;
|
|
};
|
|
if key.message == 0 || !key.pairwise {
|
|
return false;
|
|
}
|
|
|
|
// Em data frames com DS, o BSSID está em `frame.bssid`; a estação é
|
|
// quem recebe (1/3, AP -> STA) ou quem transmite (2/4, STA -> AP).
|
|
let bssid = frame.bssid_str();
|
|
let station = match key.message {
|
|
1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)),
|
|
_ => frame.addr2.map(|m| dot11::mac_to_string(&m)),
|
|
};
|
|
let (Some(bssid), Some(station)) = (bssid, station) else {
|
|
return false;
|
|
};
|
|
if bssid == station {
|
|
return false;
|
|
}
|
|
|
|
let entry = self
|
|
.map
|
|
.entry((bssid.clone(), station.clone()))
|
|
.or_insert_with(|| Handshake {
|
|
bssid,
|
|
station,
|
|
first_seen_unix: now,
|
|
..Default::default()
|
|
});
|
|
if entry.ssid.is_none() {
|
|
if let Some(ssid) = ssid_hint.filter(|s| !s.is_empty()) {
|
|
entry.ssid = Some(ssid.to_string());
|
|
}
|
|
}
|
|
if entry.first_seen_unix == 0 {
|
|
entry.first_seen_unix = now;
|
|
}
|
|
entry.last_seen_unix = now;
|
|
entry.messages |= 1 << (key.message - 1);
|
|
entry.key_version = key.key_version;
|
|
entry.store_raw(key.message, raw);
|
|
apply_key(entry, &key);
|
|
true
|
|
}
|
|
|
|
/// Atualiza o SSID de handshakes já vistos (quando o beacon chega depois
|
|
/// do EAPOL, o que acontece em capturas começando no meio do handshake).
|
|
pub fn update_ssid(&mut self, bssid: &str, ssid: &str) {
|
|
if ssid.is_empty() {
|
|
return;
|
|
}
|
|
for hs in self.map.values_mut() {
|
|
if hs.bssid == bssid && hs.ssid.is_none() {
|
|
hs.ssid = Some(ssid.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn get(&self, bssid: &str, station: &str) -> Option<&Handshake> {
|
|
self.map.get(&(bssid.to_string(), station.to_string()))
|
|
}
|
|
|
|
pub fn get_mut(&mut self, bssid: &str, station: &str) -> Option<&mut Handshake> {
|
|
self.map.get_mut(&(bssid.to_string(), station.to_string()))
|
|
}
|
|
|
|
pub fn all(&self) -> Vec<&Handshake> {
|
|
let mut v: Vec<_> = self.map.values().collect();
|
|
v.sort_by(|a, b| {
|
|
b.last_seen_unix
|
|
.cmp(&a.last_seen_unix)
|
|
.then_with(|| a.bssid.cmp(&b.bssid))
|
|
});
|
|
v
|
|
}
|
|
|
|
pub fn infos(&self) -> Vec<HandshakeInfo> {
|
|
self.all().into_iter().map(Handshake::info).collect()
|
|
}
|
|
|
|
/// Primeiro handshake que pode ser quebrado; filtros opcionais por BSSID
|
|
/// e estação.
|
|
pub fn first_crackable(
|
|
&self,
|
|
bssid: Option<&str>,
|
|
station: Option<&str>,
|
|
) -> Option<&Handshake> {
|
|
self.all().into_iter().find(|h| {
|
|
h.is_crackable()
|
|
&& bssid.map_or(true, |b| b.eq_ignore_ascii_case(&h.bssid))
|
|
&& station.map_or(true, |s| s.eq_ignore_ascii_case(&h.station))
|
|
})
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.map.is_empty()
|
|
}
|
|
}
|
|
|
|
/// 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 => {
|
|
hs.anonce = Some(key.nonce);
|
|
if key.pmkid.is_some() {
|
|
hs.pmkid = key.pmkid;
|
|
}
|
|
}
|
|
2 => {
|
|
hs.snonce = Some(key.nonce);
|
|
hs.eapol_msg2 = Some(key.frame.clone());
|
|
}
|
|
3 => hs.anonce = Some(key.nonce),
|
|
4 => hs.snonce = Some(key.nonce),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
use std::fmt::Write;
|
|
let _ = write!(s, "{b:02x}");
|
|
}
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::eapol::KEY_DATA_OFFSET;
|
|
use crate::testutil::{frame_with_eapol, key_frame};
|
|
|
|
fn key_frame_for(message: u8, nonce: [u8; 32], key_data: &[u8]) -> Vec<u8> {
|
|
key_frame(message, 2, nonce, key_data)
|
|
}
|
|
|
|
fn ap() -> [u8; 6] {
|
|
[0x11; 6]
|
|
}
|
|
fn sta() -> [u8; 6] {
|
|
[0x22; 6]
|
|
}
|
|
|
|
#[test]
|
|
fn assembles_complete_handshake() {
|
|
let mut tracker = HandshakeTracker::new();
|
|
let m1 = key_frame_for(1, [0xaa; 32], &[]);
|
|
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
|
|
let parsed1 = dot11::parse(&f1).expect("frame");
|
|
assert!(tracker.ingest(&parsed1, &f1, Some("MinhaRede"), 10));
|
|
|
|
let m2 = key_frame_for(2, [0xbb; 32], &[]);
|
|
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
|
|
let parsed2 = dot11::parse(&f2).expect("frame");
|
|
assert!(tracker.ingest(&parsed2, &f2, Some("MinhaRede"), 11));
|
|
|
|
let hs = tracker
|
|
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
|
|
.expect("hs");
|
|
assert_eq!(hs.ssid.as_deref(), Some("MinhaRede"));
|
|
assert_eq!(hs.messages, 0b11);
|
|
assert!(hs.is_complete());
|
|
assert!(hs.is_crackable());
|
|
assert_eq!(hs.anonce, Some([0xaa; 32]));
|
|
assert_eq!(hs.snonce, Some([0xbb; 32]));
|
|
assert_eq!(hs.eapol_msg2.as_deref(), Some(&m2[..]));
|
|
assert_eq!(hs.raw_frames().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn captures_pmkid_from_message_one() {
|
|
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
|
|
data.extend_from_slice(&[0xcd; 16]);
|
|
let m1 = key_frame_for(1, [0xaa; 32], &data);
|
|
assert_eq!(m1.len(), KEY_DATA_OFFSET + data.len());
|
|
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
|
|
let mut tracker = HandshakeTracker::new();
|
|
let parsed = dot11::parse(&f1).expect("frame");
|
|
assert!(tracker.ingest(&parsed, &f1, Some("Rede"), 1));
|
|
let hs = tracker
|
|
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
|
|
.expect("hs");
|
|
assert_eq!(hs.pmkid, Some([0xcd; 16]));
|
|
assert!(hs.is_complete());
|
|
assert!(hs.is_crackable());
|
|
assert_eq!(hs.kind(), "PMKID");
|
|
}
|
|
|
|
#[test]
|
|
fn late_beacon_fills_ssid() {
|
|
let m1 = key_frame_for(1, [1; 32], &[]);
|
|
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
|
|
let mut tracker = HandshakeTracker::new();
|
|
let parsed = dot11::parse(&f1).expect("frame");
|
|
tracker.ingest(&parsed, &f1, None, 1);
|
|
assert!(!tracker
|
|
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
|
|
.unwrap()
|
|
.is_crackable());
|
|
tracker.update_ssid("11:11:11:11:11:11", "RedeTardia");
|
|
assert!(
|
|
tracker
|
|
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
|
|
.unwrap()
|
|
.ssid
|
|
.as_deref()
|
|
== Some("RedeTardia")
|
|
);
|
|
}
|
|
}
|