diff --git a/Cargo.lock b/Cargo.lock index 9c829c39..ca80d02b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7168,8 +7168,7 @@ checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" [[package]] name = "wifi-station" version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb84ca59dc42c3818d652f7bbd0715abe3bc8f74d160831e865b3946d39fc19" +source = "git+https://github.com/jacklund/wifi-station?branch=add-bssid#26045857d891208b3c04836a91f6082b569bb5bb" dependencies = [ "anyhow", "chrono", diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 4d51b02e..a28e6a95 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -21,7 +21,7 @@ apidocs = ["dep:utoipa", "wifi-station/utoipa"] [dependencies] rayhunter = { path = "../lib" } -wifi-station = "0.10.1" +wifi-station = { git = "https://github.com/jacklund/wifi-station", branch = "add-bssid" } toml = "0.8.8" serde = { version = "1.0.193", features = ["derive"] } serde_repr = "0.1" diff --git a/daemon/src/analysis.rs b/daemon/src/analysis.rs index 41efd959..5fec3a07 100644 --- a/daemon/src/analysis.rs +++ b/daemon/src/analysis.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Duration; use std::{cmp, future, pin}; use axum::Json; @@ -7,18 +8,22 @@ use axum::{ http::StatusCode, }; use futures::TryStreamExt; -use log::{error, info}; +use log::{debug, error, info}; use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness}; use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::qmdl::QmdlReader; use serde::Serialize; use tokio::fs::File; use tokio::io::{AsyncWriteExt, BufWriter}; -use tokio::sync::mpsc::Receiver; +use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::{RwLock, RwLockWriteGuard}; use tokio_util::task::TaskTracker; +use wifi_station::WifiNetwork; -use crate::qmdl_store::{FileKind, RecordingStore}; +use crate::display; +use crate::notifications::{Notification, NotificationType}; +use crate::qmdl_store::FileKind; +use crate::qmdl_store::RecordingStore; use crate::server::ServerState; pub struct AnalysisWriter { @@ -108,6 +113,7 @@ impl AnalysisStatus { pub enum AnalysisCtrlMessage { NewFilesQueued, RecordingFinished(String), + WifiNetworksDetected(Vec), Exit, } @@ -195,6 +201,8 @@ pub fn run_analysis_thread( qmdl_store_lock: Arc>, analysis_status_lock: Arc>, analyzer_config: AnalyzerConfig, + ui_update_sender: Sender, + notification_channel: Sender, ) { task_tracker.spawn(async move { loop { @@ -215,6 +223,43 @@ pub fn run_analysis_thread( let mut status = analysis_status_lock.write().await; status.finished.push(name); } + Some(AnalysisCtrlMessage::WifiNetworksDetected(networks)) => { + debug!( + "Networks detected, configured OUIs: {:?}", + analyzer_config.wifi_ouis.join(",") + ); + if !analyzer_config.wifi_ouis.is_empty() { + let mut harness = Harness::new_with_config(&analyzer_config); + let mut events = harness + .analyze_wifi_ouis(networks.iter().map(|n| n.bssid.clone()).collect()); + debug!("Called analyze_wifi_ouis, got events: {:?}", events); + if !events.is_empty() { + events.sort_by_key(|a| a.event_type); + if let Some(max_event) = events.pop() + && max_event.event_type > EventType::Informational + { + info!("a heuristic triggered on this run!"); + notification_channel + .send(Notification::new( + NotificationType::Warning, + format!( + "Rayhunter has detected a {:?} severity event", + max_event.event_type, + ), + Some(Duration::from_secs(60 * 5)), + )) + .await + .expect("Failed to send to notification channel"); + ui_update_sender + .send(display::DisplayState::WarningDetected { + event_type: max_event.event_type, + }) + .await + .expect("couldn't send ui update message: {}"); + } + } + } + } Some(AnalysisCtrlMessage::Exit) | None => return, } } diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 0aa68e6c..34d9da36 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -86,6 +86,8 @@ pub struct Config { pub dns_servers: Option>, /// WebDAV upload configuration. The upload worker runs whenever `webdav.url` is non-empty. pub webdav: WebdavConfig, + /// Optional WiFi OUIs for analysis + pub wifi_ouis: Option>, } /// Configuration for uploading finished QMDL recordings to a WebDAV server. @@ -148,6 +150,7 @@ impl Default for Config { wifi_enabled: false, dns_servers: None, webdav: WebdavConfig::default(), + wifi_ouis: None, } } } diff --git a/daemon/src/lib.rs b/daemon/src/lib.rs index c415088e..dc395184 100644 --- a/daemon/src/lib.rs +++ b/daemon/src/lib.rs @@ -10,6 +10,7 @@ pub mod key_input; pub mod notifications; pub mod pcap; pub mod qmdl_store; +pub mod scan; pub mod server; pub mod stats; pub mod update; diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 3be95e4f..9417046e 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -10,6 +10,7 @@ mod key_input; mod notifications; mod pcap; mod qmdl_store; +mod scan; mod server; mod stats; mod update; @@ -26,6 +27,7 @@ use crate::gps::{get_gps, post_gps}; use crate::notifications::{NotificationService, run_notification_worker}; use crate::pcap::get_pcap; use crate::qmdl_store::RecordingStore; +use crate::scan::run_wifi_scanner; use crate::server::{ ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip, scan_wifi, serve_static, set_config, set_time_offset, test_notification, @@ -281,6 +283,8 @@ async fn run_with_config( qmdl_store_lock.clone(), analysis_status_lock.clone(), config.analyzers.clone(), + ui_update_tx.clone(), + notification_service.new_handler(), ); run_shutdown_thread( @@ -343,19 +347,23 @@ async fn run_with_config( let state = Arc::new(ServerState { config_path: args.config_path.clone(), - config, + config: config.clone(), qmdl_store_lock: qmdl_store_lock.clone(), diag_device_ctrl_sender: diag_tx, analysis_status_lock, analysis_sender: analysis_tx, daemon_restart_token: restart_token.clone(), - ui_update_sender: Some(ui_update_tx), + ui_update_sender: Some(ui_update_tx.clone()), wifi_status, wifi_scan_lock: tokio::sync::Mutex::new(()), gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)), update_status_lock: update_status_lock.clone(), }); - run_server(&task_tracker, state, shutdown_token.clone()).await; + run_server(&task_tracker, state.clone(), shutdown_token.clone()).await; + + if config.analyzers.wifi_oui_analyzer { + run_wifi_scanner(&task_tracker, state, shutdown_token.clone()).await; + } task_tracker.close(); task_tracker.wait().await; diff --git a/daemon/src/scan.rs b/daemon/src/scan.rs new file mode 100644 index 00000000..698f14a4 --- /dev/null +++ b/daemon/src/scan.rs @@ -0,0 +1,44 @@ +use log::{debug, info, warn}; +use std::sync::Arc; +use std::time::Duration; +use tokio::{select, task::JoinHandle, time}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; +use wifi_station::{STA_IFACE, scan_wifi_networks}; + +use crate::{analysis::AnalysisCtrlMessage, server::ServerState}; + +pub async fn run_wifi_scanner( + task_tracker: &TaskTracker, + state: Arc, + shutdown_token: CancellationToken, +) -> JoinHandle<()> { + info!("starting wifi scanner"); + + task_tracker.spawn(async move { + loop { + select! { + _ = shutdown_token.cancelled() => break, + _ = time::sleep(Duration::from_secs(15)) => { + if state.wifi_scan_lock.try_lock().is_err() { + warn!("WiFi scan already in progress"); + continue; + } + debug!("Calling scan_wifi_networks()"); + match scan_wifi_networks(STA_IFACE).await { + Ok(networks) => { + debug!("Found {} networks", networks.len()); + if let Err(e) = state.analysis_sender.send( + AnalysisCtrlMessage::WifiNetworksDetected(networks) + ).await { + warn!("couldn't send analysis message: {e}"); + } + } + Err(e) => { + warn!("Error scanning wifi networks: {e}"); + } + } + } + } + } + }) +} diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte index fd19c485..a8add675 100644 --- a/daemon/web/src/lib/components/ConfigForm.svelte +++ b/daemon/web/src/lib/components/ConfigForm.svelte @@ -29,12 +29,14 @@ let scanning = $state(false); let scanResults = $state([]); let dnsServersInput = $state(''); + let wifiOUIsInput = $state(''); async function load_config() { try { loading = true; config = await get_config(); dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : ''; + wifiOUIsInput = config.analyzers.wifi_ouis ? config.analyzers.wifi_ouis.join(', ') : ''; message = ''; messageType = null; poll_wifi_status(); @@ -58,6 +60,15 @@ .filter((s) => s.length > 0) : null; + const trimmed_ouis = wifiOUIsInput.trim(); + config.analyzers.wifi_ouis = + trimmed_ouis.length > 0 + ? trimmed_ouis + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + : null; + try { saving = true; await set_config(config); @@ -764,6 +775,40 @@ Diagnostic Analyzer + +
+ + +
+ + {#if config.analyzers.wifi_oui_analyzer} +
+ + +

+ Comma-separated triplets of hex octets, of the form "AB:CD:EF". + Used when WiFi OUI Analyzer is active. +

+
+ {/if} diff --git a/daemon/web/src/lib/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index 57f81f19..d72675f3 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -11,6 +11,8 @@ export interface AnalyzerConfig { incomplete_sib: boolean; test_analyzer: boolean; diagnostic_analyzer: boolean; + wifi_oui_analyzer: boolean; + wifi_ouis: string[] | null; } export enum enabled_notifications { diff --git a/dist/config.toml.in b/dist/config.toml.in index 5d98f6d4..867d2211 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -83,3 +83,4 @@ nas_null_cipher = true incomplete_sib = true test_analyzer = false diagnostic_analyzer = true +wifi_oui_analyzer = true diff --git a/lib/src/analysis/analyzer.rs b/lib/src/analysis/analyzer.rs index 426e9345..f105b224 100644 --- a/lib/src/analysis/analyzer.rs +++ b/lib/src/analysis/analyzer.rs @@ -14,7 +14,7 @@ use super::{ imsi_requested::ImsiRequestedAnalyzer, incomplete_sib::IncompleteSibAnalyzer, information_element::InformationElement, nas_null_cipher::NasNullCipherAnalyzer, null_cipher::NullCipherAnalyzer, priority_2g_downgrade::LteSib6And7DowngradeAnalyzer, - test_analyzer::TestAnalyzer, + test_analyzer::TestAnalyzer, wifi_oui_analyzer::WifiOUIAnalyzer, }; /// A list of booleans which stores information about which analyzers are enabled @@ -30,6 +30,8 @@ pub struct AnalyzerConfig { pub incomplete_sib: bool, pub test_analyzer: bool, pub imsi_requested: bool, + pub wifi_oui_analyzer: bool, + pub wifi_ouis: Vec, } impl Default for AnalyzerConfig { @@ -43,6 +45,8 @@ impl Default for AnalyzerConfig { nas_null_cipher: true, incomplete_sib: true, test_analyzer: false, + wifi_oui_analyzer: true, + wifi_ouis: Vec::new(), } } } @@ -365,6 +369,10 @@ impl Harness { harness.add_analyzer(Box::new(DiagnosticAnalyzer {})); } + if analyzer_config.wifi_oui_analyzer { + harness.add_analyzer(Box::new(WifiOUIAnalyzer::new(&analyzer_config.wifi_ouis))); + } + harness } @@ -372,6 +380,13 @@ impl Harness { self.analyzers.push(analyzer); } + pub fn analyze_wifi_ouis(&mut self, bssids: Vec) -> Vec { + self.analyze_information_element(&InformationElement::WifiBSSIDList(bssids)) + .iter() + .flat_map(|e| e.clone()) + .collect::>() + } + pub fn analyze_pcap_packet(&mut self, packet: EnhancedPacketBlock) -> AnalysisRow { self.packet_num += 1; @@ -548,4 +563,17 @@ mod tests { ); assert!(row.events[2].is_none()); } + + #[test] + fn test_analyze_wifi_ouis() { + let mut analyzer_config = AnalyzerConfig::default(); + analyzer_config.wifi_oui_analyzer = true; + analyzer_config.wifi_ouis = vec!["AA:BB:CC".to_string()]; + let mut harness = Harness::new_with_config(&analyzer_config); + let events = harness.analyze_wifi_ouis(vec!["00:11:22:33:44:55".to_string()]); + assert!(events.is_empty()); + let events = harness.analyze_wifi_ouis(vec!["AA:BB:CC:33:44:55".to_string()]); + assert_eq!(1, events.len()); + assert_eq!(EventType::High, events[0].event_type); + } } diff --git a/lib/src/analysis/information_element.rs b/lib/src/analysis/information_element.rs index 54089bef..b68f1971 100644 --- a/lib/src/analysis/information_element.rs +++ b/lib/src/analysis/information_element.rs @@ -26,6 +26,7 @@ pub enum InformationElement { // so we box it to prevent the size of the enum (any variant) from blowing up. LTE(Box), FiveG, + WifiBSSIDList(Vec), } #[derive(Debug)] diff --git a/lib/src/analysis/mod.rs b/lib/src/analysis/mod.rs index 4d30779c..fd825bd7 100644 --- a/lib/src/analysis/mod.rs +++ b/lib/src/analysis/mod.rs @@ -9,3 +9,4 @@ pub mod null_cipher; pub mod priority_2g_downgrade; pub mod test_analyzer; pub mod util; +pub mod wifi_oui_analyzer; diff --git a/lib/src/analysis/wifi_oui_analyzer.rs b/lib/src/analysis/wifi_oui_analyzer.rs new file mode 100644 index 00000000..78dcf8cf --- /dev/null +++ b/lib/src/analysis/wifi_oui_analyzer.rs @@ -0,0 +1,60 @@ +use log::debug; + +use crate::analysis::{ + analyzer::{Analyzer, Event, EventType}, + information_element::InformationElement, +}; + +pub struct WifiOUIAnalyzer { + wifi_ouis: Vec, +} + +impl WifiOUIAnalyzer { + pub fn new(ouis: &[String]) -> Self { + Self { + wifi_ouis: ouis.to_vec(), + } + } +} + +impl Analyzer for WifiOUIAnalyzer { + fn get_name(&self) -> std::borrow::Cow<'_, str> { + "WifiOUIAnalyzer".into() + } + + fn get_description(&self) -> std::borrow::Cow<'_, str> { + "blah blah blah".into() + } + + fn get_version(&self) -> u32 { + 1 + } + + fn analyze_information_element( + &mut self, + ie: &InformationElement, + _packet_num: usize, + ) -> Option { + if let InformationElement::WifiBSSIDList(bssids) = ie { + debug!("WifiOUIAnalyzer got BSSIDs {:?}", bssids); + if !self.wifi_ouis.is_empty() { + for bssid in bssids { + if self + .wifi_ouis + .iter() + .find(|oui| bssid.to_uppercase().starts_with(&oui.to_uppercase())) + .is_some() + { + debug!("Found match for bssid {bssid}"); + return Some(Event { + event_type: EventType::High, + message: "Detected possible IMSI catcher wifi endpoint".to_string(), + }); + } + } + } + } + + None + } +}