Files
sabia/crates/sabia-core/src/ccmp.rs
T
Lucas Rayan 5477b8fb03 fix: captura de handshake real (endianness EAPOL) e enriquece o parsing 802.11
- eapol.rs: key_info/replay_counter/key_data_len liam little-endian; são
  big-endian (802.1X segue ordem de rede), então handshakes reais nunca
  fechavam mesmo com deauth funcionando. testutil.rs e os geradores de
  quadro de teste corrigidos junto, mais um teste contra bytes de wire
  reais (não só contra o próprio encoder).
- inject.rs: deauth/disassoc agora grava Sequence Control crescente por
  quadro — reenviar o mesmo quadro era descartado como duplicata pelo
  driver/AP depois do primeiro envio.
- dot11.rs/scan.rs: parsing de AP/cliente bem mais completo (PMF
  obrigatório, WPS detalhado, fabricante por OUI, país, largura de banda,
  padrão PHY, privacidade) e histórico de sinal/eventos por cliente.
- handshake.rs/ccmp.rs: deriva a Temporal Key (CCMP) quando a senha é
  quebrada, o suficiente pra decifrar o tráfego em claro do par
  (BSSID, estação) cujo handshake foi capturado.
2026-10-24 14:53:32 -03:00

174 lines
6.8 KiB
Rust

//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
//! de um handshake quebrado.
//!
//! Isso não serve pra forjar nem reinjetar nada — só pra enxergar o conteúdo
//! em claro (DHCP, mDNS) de um cliente cuja senha da rede já foi recuperada
//! por dicionário, e assim extrair hostname/fabricante de verdade em vez de
//! só o MAC. A chave é par (BSSID, estação): só decifra tráfego do cliente
//! cujo próprio handshake foi capturado e quebrado, nunca de terceiros —
//! cada associação tem sua PTK efêmera, então a senha da rede sozinha não
//! abre o tráfego de quem a gente não viu entrar.
//!
//! Implementa só o modo CTR (a parte que decifra o conteúdo). O MIC
//! (CBC-MAC) não é conferido: a intenção aqui é mostrar o payload de um
//! tráfego que a gente já pode decifrar porque a senha é conhecida, não
//! provar integridade de um quadro adulterado — e implementar o AAD/CBC-MAC
//! certinho exigiria replicar o mascaramento do cabeçalho 802.11 inteiro
//! sem ganho prático pro que isso serve aqui.
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP
/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`.
/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o
/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-QoS).
///
/// Retorna o LLC/SNAP + payload em claro (o mesmo formato que um quadro não
/// protegido teria), sem os 8 bytes de MIC cifrado no final.
pub fn decrypt_data(
tk: &[u8; 16],
transmitter: &[u8; 6],
qos_tid: u8,
body: &[u8],
) -> Option<Vec<u8>> {
const HEADER_LEN: usize = 8;
const MIC_LEN: usize = 8;
if body.len() < HEADER_LEN + MIC_LEN {
return None;
}
let pn0 = body[0];
let pn1 = body[1];
let key_id_octet = body[3];
// ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de
// verdade (podia ser WEP de 4 bytes por coincidência de layout).
if key_id_octet & 0x20 == 0 {
return None;
}
let pn2 = body[4];
let pn3 = body[5];
let pn4 = body[6];
let pn5 = body[7];
let ciphertext_and_mic = &body[HEADER_LEN..];
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
// Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte
// mais significativo (PN5) ao menos significativo (PN0).
let mut nonce = [0u8; 13];
nonce[0] = qos_tid & 0x0f;
nonce[1..7].copy_from_slice(transmitter);
nonce[7] = pn5;
nonce[8] = pn4;
nonce[9] = pn3;
nonce[10] = pn2;
nonce[11] = pn1;
nonce[12] = pn0;
let cipher = Aes128::new(tk.into());
let mut out = Vec::with_capacity(ciphertext.len());
let mut counter: u16 = 1; // o bloco de contador 0 cifra só o MIC, não dado.
for chunk in ciphertext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01; // flag octet do CCMP counter mode (L=1, sem AAD aqui)
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
cipher.encrypt_block(&mut keystream);
for (c, k) in chunk.iter().zip(keystream.iter()) {
out.push(c ^ k);
}
counter = counter.wrapping_add(1);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma
/// que a cifra de bloco usada embaixo está correta antes de confiar no
/// contador CTR por cima dela.
#[test]
fn aes128_matches_fips197_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f,
];
let plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
let expected: [u8; 16] = [
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4,
0xc5, 0x5a,
];
let cipher = Aes128::new(&key.into());
let mut block = plaintext.into();
cipher.encrypt_block(&mut block);
assert_eq!(block.as_slice(), &expected);
}
/// Round-trip de auto-consistência: decifrar um texto que a própria
/// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é
/// simétrico) tem que devolver o original. Isso pega bug de indexação
/// no nonce/contador mesmo sem um vetor de teste oficial do CCMP à mão.
#[test]
fn ctr_round_trip_recovers_plaintext() {
let tk = [0x42u8; 16];
let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let plaintext = b"AA AA 03 00 00 00 08 00 payload de teste bem maior que um bloco de 16 bytes para cobrir mais de uma rodada do contador";
// Monta um "quadro" CCMP cifrando manualmente com a mesma lógica de
// keystream (ela é simétrica: XOR com o keystream cifra e decifra).
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado
let mut nonce = [0u8; 13];
nonce[0] = 3; // TID
nonce[1..7].copy_from_slice(&transmitter);
nonce[7] = header[7];
nonce[8] = header[6];
nonce[9] = header[5];
nonce[10] = header[4];
nonce[11] = header[1];
nonce[12] = header[0];
let cipher = Aes128::new(&tk.into());
let mut ciphertext = Vec::with_capacity(plaintext.len());
let mut counter: u16 = 1;
for chunk in plaintext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01;
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
cipher.encrypt_block(&mut keystream);
for (p, k) in chunk.iter().zip(keystream.iter()) {
ciphertext.push(p ^ k);
}
counter = counter.wrapping_add(1);
}
ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação
let mut body = Vec::new();
body.extend_from_slice(&header);
body.extend_from_slice(&ciphertext);
let out = decrypt_data(&tk, &transmitter, 3, &body).expect("decifra");
assert_eq!(out, plaintext);
}
#[test]
fn rejects_body_without_extiv_bit() {
let tk = [0u8; 16];
let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
#[test]
fn rejects_body_too_short() {
let tk = [0u8; 16];
let body = [0u8; 10];
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
}