Allow enabling/disabling analyzers from config file (#382)

Co-authored-by: Will Greenberg <willg@eff.org>
This commit is contained in:
Markus Unterwaditzer
2025-06-10 21:37:38 +02:00
committed by GitHub
parent fb2149f0c8
commit 86e08f9a85
8 changed files with 95 additions and 27 deletions

View File

@@ -8,7 +8,7 @@ use axum::{
};
use futures::TryStreamExt;
use log::{debug, error, info};
use rayhunter::analysis::analyzer::Harness;
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader;
use serde::Serialize;
@@ -35,8 +35,12 @@ pub struct AnalysisWriter {
// lets us simply append new rows to the end without parsing the entire JSON
// object beforehand.
impl AnalysisWriter {
pub async fn new(file: File, enable_dummy_analyzer: bool) -> Result<Self, std::io::Error> {
let mut harness = Harness::new_with_all_analyzers();
pub async fn new(
file: File,
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<Self, std::io::Error> {
let mut harness = Harness::new_with_config(analyzer_config);
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
}
@@ -131,6 +135,7 @@ async fn perform_analysis(
name: &str,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<(), String> {
info!("Opening QMDL and analysis file for {}...", name);
let (analysis_file, qmdl_file, entry_index) = {
@@ -150,9 +155,10 @@ async fn perform_analysis(
(analysis_file, qmdl_file, entry_index)
};
let mut analysis_writer = AnalysisWriter::new(analysis_file, enable_dummy_analyzer)
.await
.map_err(|e| format!("{:?}", e))?;
let mut analysis_writer =
AnalysisWriter::new(analysis_file, enable_dummy_analyzer, analyzer_config)
.await
.map_err(|e| format!("{:?}", e))?;
let file_size = qmdl_file
.metadata()
.await
@@ -196,6 +202,7 @@ pub fn run_analysis_thread(
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
loop {
@@ -204,9 +211,13 @@ pub fn run_analysis_thread(
let count = queued_len(analysis_status_lock.clone()).await;
for _ in 0..count {
let name = dequeue_to_running(analysis_status_lock.clone()).await;
if let Err(err) =
perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer)
.await
if let Err(err) = perform_analysis(
&name,
qmdl_store_lock.clone(),
enable_dummy_analyzer,
&analyzer_config,
)
.await
{
error!("failed to analyze {}: {}", name, err);
}

View File

@@ -2,7 +2,7 @@ use clap::Parser;
use futures::TryStreamExt;
use log::{info, warn};
use rayhunter::{
analysis::analyzer::{EventType, Harness},
analysis::analyzer::{AnalyzerConfig, EventType, Harness},
diag::DataType,
gsmtap_parser,
pcap::GsmtapPcapWriter,
@@ -33,7 +33,7 @@ struct Args {
}
async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_all_analyzers();
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}
@@ -141,7 +141,7 @@ async fn main() {
.unwrap();
info!("Analyzers:");
let mut harness = Harness::new_with_all_analyzers();
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
if args.enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}

View File

@@ -1,7 +1,9 @@
use crate::error::RayhunterError;
use serde::Deserialize;
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
@@ -12,6 +14,7 @@ pub struct Config {
pub enable_dummy_analyzer: bool,
pub colorblind_mode: bool,
pub key_input_mode: u8,
pub analyzers: AnalyzerConfig,
}
impl Default for Config {
@@ -24,6 +27,7 @@ impl Default for Config {
enable_dummy_analyzer: false,
colorblind_mode: false,
key_input_mode: 1,
analyzers: AnalyzerConfig::default(),
}
}
}

View File

@@ -199,6 +199,7 @@ async fn main() -> Result<(), RayhunterError> {
qmdl_store_lock.clone(),
analysis_tx.clone(),
config.enable_dummy_analyzer,
config.analyzers.clone(),
);
info!("Starting UI");
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
@@ -215,6 +216,7 @@ async fn main() -> Result<(), RayhunterError> {
qmdl_store_lock.clone(),
analysis_status_lock.clone(),
config.enable_dummy_analyzer,
config.analyzers.clone(),
);
run_ctrl_c_thread(
&task_tracker,

View File

@@ -8,6 +8,7 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn};
use rayhunter::analysis::analyzer::AnalyzerConfig;
use rayhunter::diag::DataType;
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
@@ -36,12 +37,13 @@ pub fn run_diag_read_thread(
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer).await
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer, &analyzer_config).await
.expect("failed to create analysis writer"));
loop {
tokio::select! {
@@ -63,7 +65,7 @@ pub fn run_diag_read_thread(
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer, &analyzer_config).await
.expect("failed to write to analysis file"));
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {

View File

@@ -20,3 +20,12 @@ ui_level = 1
# 0 = rayhunter does not read button presses
# 1 = double-tapping the power button starts/stops recordings
key_input_mode = 1
# Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
[analyzers]
imsi_requested = true
connection_redirect_2g_downgrade = true
lte_sib6_and_7_downgrade = true
null_cipher = false

View File

@@ -1,3 +1,15 @@
# Heuristics
TODO
Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [config.toml](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.example) file.
## Available Analyzers
- **IMSI Requested**: Tests whether the ME sends an IMSI Identity Request NAS message
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell
releases our connection and redirects us to a 2G cell. This heuristic only
makes sense in the US, European users may want to disable it.
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7
which include 2G/3G frequencies with higher priorities
- **Null Cipher** (disabled by default): Tests whether the cell suggests using a null cipher (EEA0).
This is currently disabled by default due to a parsing bug triggering false
positives.

View File

@@ -1,5 +1,5 @@
use chrono::{DateTime, FixedOffset};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use crate::util::RuntimeMetadata;
@@ -8,9 +8,32 @@ use crate::{diag::MessagesContainer, gsmtap_parser};
use super::{
connection_redirect_downgrade::ConnectionRedirect2GDowngradeAnalyzer,
imsi_requested::ImsiRequestedAnalyzer, information_element::InformationElement,
priority_2g_downgrade::LteSib6And7DowngradeAnalyzer,
null_cipher::NullCipherAnalyzer, priority_2g_downgrade::LteSib6And7DowngradeAnalyzer,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AnalyzerConfig {
pub imsi_requested: bool,
pub connection_redirect_2g_downgrade: bool,
pub lte_sib6_and_7_downgrade: bool,
pub null_cipher: bool,
}
impl Default for AnalyzerConfig {
fn default() -> Self {
AnalyzerConfig {
imsi_requested: true,
connection_redirect_2g_downgrade: true,
lte_sib6_and_7_downgrade: true,
// FIXME: our RRC parser is reporting false positives for this due to an
// upstream hampi bug (https://github.com/ystero-dev/hampi/issues/133).
// once that's fixed, we should regenerate our parser and re-enable this
null_cipher: false,
}
}
}
/// Qualitative measure of how severe a Warning event type is.
/// The levels should break down like this:
/// * Low: if combined with a large number of other Warnings, user should investigate
@@ -122,16 +145,21 @@ impl Harness {
}
}
pub fn new_with_all_analyzers() -> Self {
pub fn new_with_config(analyzer_config: &AnalyzerConfig) -> Self {
let mut harness = Harness::new();
harness.add_analyzer(Box::new(ImsiRequestedAnalyzer::new()));
harness.add_analyzer(Box::new(ConnectionRedirect2GDowngradeAnalyzer {}));
harness.add_analyzer(Box::new(LteSib6And7DowngradeAnalyzer {}));
// FIXME: our RRC parser is reporting false positives for this due to an
// upstream hampi bug (https://github.com/ystero-dev/hampi/issues/133).
// once that's fixed, we should regenerate our parser and re-enable this
// harness.add_analyzer(Box::new(NullCipherAnalyzer{}));
if analyzer_config.imsi_requested {
harness.add_analyzer(Box::new(ImsiRequestedAnalyzer::new()));
}
if analyzer_config.connection_redirect_2g_downgrade {
harness.add_analyzer(Box::new(ConnectionRedirect2GDowngradeAnalyzer {}));
}
if analyzer_config.lte_sib6_and_7_downgrade {
harness.add_analyzer(Box::new(LteSib6And7DowngradeAnalyzer {}));
}
if analyzer_config.null_cipher {
harness.add_analyzer(Box::new(NullCipherAnalyzer {}));
}
harness
}