Compare commits

..

12 Commits

Author SHA1 Message Date
Will Greenberg 6b07b4e460 lib: support mac ul as well 2026-02-06 13:13:46 -08:00
Will Greenberg 9b6051380f lib: write ltemac packets as gsmtap 2026-02-06 13:13:46 -08:00
Will Greenberg f8581559e7 lib: add diag parsing for LTE MAC DL packets 2026-02-06 13:13:46 -08:00
Cooper Quintin ed2781a4be appease clippy 2026-02-05 15:41:54 -08:00
Cooper Quintin ffcf683ae5 appease npm 2026-02-05 15:41:54 -08:00
Cooper Quintin 49fd777c83 fix nits and add to config.toml 2026-02-05 15:41:54 -08:00
Cooper Quintin 84a3155a1f remove broken attach request and format 2026-02-05 15:41:54 -08:00
Cooper Quintin 184f4bd7a2 rename to diagnostic and add docs 2026-02-05 15:41:54 -08:00
Cooper Quintin f7759721e3 rebase against main 2026-02-05 15:41:54 -08:00
Cooper Quintin 744d0772c2 add message type 2026-02-05 15:41:54 -08:00
Cooper Quintin 2cd49b3757 show false postive attach reject message 2026-02-05 15:41:54 -08:00
Cooper Quintin e44230c043 imsi revealing message diagnostic heuristic 2026-02-05 15:41:54 -08:00
13 changed files with 259 additions and 30 deletions
Generated
+1 -1
View File
@@ -2753,7 +2753,7 @@ dependencies = [
[[package]]
name = "installer-gui"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"anyhow",
"installer",
@@ -335,6 +335,20 @@
Test Heuristic (noisy!)
</label>
</div>
<div class="flex items-center">
<input
id="diagnostic_analyzer"
type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="diagnostic_analyzer"
class="ml-2 block text-sm text-gray-700"
>
Diagnostic Analyzer
</label>
</div>
</div>
</div>
+1
View File
@@ -10,6 +10,7 @@ export interface AnalyzerConfig {
nas_null_cipher: boolean;
incomplete_sib: boolean;
test_analyzer: boolean;
diagnostic_analyzer: boolean;
}
export enum enabled_notifications {
+1
View File
@@ -39,3 +39,4 @@ null_cipher = true
nas_null_cipher = true
incomplete_sib = true
test_analyzer = false
diagnostic_analyzer = true
+3
View File
@@ -73,6 +73,9 @@ This analyzer tests whether the SIB1 message contains a complete SIB chain (SIB3
On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **IMSI Requested** detection it should be considered as a strong indicator of malicious activity.
### Diagnostic Information
This analyzer displays some diagnostic information about when your device connects and disconnects from certain towers. It is helpful for analysis of suspicious PCAPs. The informational warnings in here can safely be ignored until there is a low, medium, or high severity warning.
### Test Analyzer
This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!
+8 -1
View File
@@ -4,6 +4,7 @@ use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use crate::analysis::diagnostic::DiagnosticAnalyzer;
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType};
use crate::util::RuntimeMetadata;
use crate::{diag::MessagesContainer, gsmtap_parser};
@@ -19,19 +20,21 @@ use super::{
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct AnalyzerConfig {
pub imsi_requested: bool,
pub diagnostic_analyzer: bool,
pub connection_redirect_2g_downgrade: bool,
pub lte_sib6_and_7_downgrade: bool,
pub null_cipher: bool,
pub nas_null_cipher: bool,
pub incomplete_sib: bool,
pub test_analyzer: bool,
pub imsi_requested: bool,
}
impl Default for AnalyzerConfig {
fn default() -> Self {
AnalyzerConfig {
imsi_requested: true,
diagnostic_analyzer: true,
connection_redirect_2g_downgrade: true,
lte_sib6_and_7_downgrade: true,
null_cipher: true,
@@ -346,6 +349,10 @@ impl Harness {
harness.add_analyzer(Box::new(TestAnalyzer {}))
}
if analyzer_config.diagnostic_analyzer {
harness.add_analyzer(Box::new(DiagnosticAnalyzer {}));
}
harness
}
+166
View File
@@ -0,0 +1,166 @@
use crate::analysis::analyzer::{Analyzer, Event, EventType};
use crate::analysis::information_element::{InformationElement, LteInformationElement};
use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage;
use pycrate_rs::nas::generated::emm::emm_attach_reject::EMMCauseEMMCause as AttachRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_detach_request_mt::EPSDetachTypeMTType;
use pycrate_rs::nas::generated::emm::emm_service_reject::EMMCauseEMMCause as ServiceRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_tracking_area_update_reject::EMMCauseEMMCause as TAURejectEMMCause;
use std::borrow::Cow;
pub struct DiagnosticAnalyzer;
impl Default for DiagnosticAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl DiagnosticAnalyzer {
pub fn new() -> Self {
DiagnosticAnalyzer
}
fn is_imsi_exposing_nas(&self, nas_msg: &NASMessage) -> bool {
match nas_msg {
NASMessage::EMMMessage(emm_msg) => match emm_msg {
EMMMessage::EMMIdentityRequest(_) => true, // Alert on all identity requests (IMSI, IMEI, IMEISV)
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
matches!(
reject.emm_cause.inner,
TAURejectEMMCause::IllegalUE
| TAURejectEMMCause::IllegalME
| TAURejectEMMCause::EPSServicesNotAllowed
| TAURejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
| TAURejectEMMCause::TrackingAreaNotAllowed
| TAURejectEMMCause::EPSServicesNotAllowedInThisPLMN
| TAURejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
EMMMessage::EMMAttachReject(reject) => {
matches!(
reject.emm_cause.inner,
AttachRejectEMMCause::IllegalUE
| AttachRejectEMMCause::IllegalME
| AttachRejectEMMCause::EPSServicesNotAllowed
| AttachRejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
| AttachRejectEMMCause::PLMNNotAllowed
| AttachRejectEMMCause::TrackingAreaNotAllowed
| AttachRejectEMMCause::RoamingNotAllowedInThisTrackingArea
| AttachRejectEMMCause::EPSServicesNotAllowedInThisPLMN
| AttachRejectEMMCause::NoSuitableCellsInTrackingArea
| AttachRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
EMMMessage::EMMDetachRequestMT(req) => {
// Original implementation: !(nas_eps.emm.detach_type_dl == 3)
req.eps_detach_type.inner.typ != EPSDetachTypeMTType::IMSIDetach
}
EMMMessage::EMMAttachRequest(_) => {
// just because eps_attach_type is IMSI doesn't mean that the phoen transmitted its IMSI
// It often sends the GUTI instead. We could check the req.epsid structure but it appears to actually
// not be parsed. So for now we are just ignoreing this message
// req.eps_attach_type.inner == EPSAttachTypeV::CombinedEPSIMSIAttach
false
}
EMMMessage::EMMServiceReject(reject) => {
matches!(
reject.emm_cause.inner,
ServiceRejectEMMCause::IllegalUE
| ServiceRejectEMMCause::IllegalME
| ServiceRejectEMMCause::EPSServicesNotAllowed
| ServiceRejectEMMCause::UEIdentityCannotBeDerivedByTheNetwork
| ServiceRejectEMMCause::TrackingAreaNotAllowed
| ServiceRejectEMMCause::EPSServicesNotAllowedInThisPLMN
| ServiceRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
_ => false,
},
_ => false,
}
}
}
impl Analyzer for DiagnosticAnalyzer {
fn get_name(&self) -> Cow<'_, str> {
"Diagnostic detector for messages which might lead to IMSI exposure".into()
}
fn get_description(&self) -> Cow<'_, str> {
"Catches any messages that may lead to IMSI Exposure. Can be quite noisy. \
Useful as a diagnostic for finding out why an IMSI was sent or what \
the reason for a reject message was. Not a useful indicator on its own \
but a helpful diagnostic for understanding why another indicator was \
triggered. Based on the list of IMSI exposing messages identified in \
the 'Marlin' paper."
.into()
}
fn get_version(&self) -> u32 {
1
}
fn analyze_information_element(
&mut self,
ie: &InformationElement,
_packet_num: usize,
) -> Option<Event> {
let lte_ie = match ie {
InformationElement::LTE(inner) => inner,
_ => return None,
};
match lte_ie.as_ref() {
LteInformationElement::NAS(nas_msg) => {
if self.is_imsi_exposing_nas(nas_msg) {
let message_type = match nas_msg {
NASMessage::EMMMessage(emm_msg) => match emm_msg {
EMMMessage::EMMIdentityRequest(request) => {
format!("EMM Identity Request ({:?})", request.id_type.inner)
}
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
format!(
"EMM Tracking Area Update Reject ({:?})",
reject.emm_cause.inner
)
}
EMMMessage::EMMAttachReject(reject) => {
format!("EMM Attach Reject ({:?})", reject.emm_cause.inner)
}
EMMMessage::EMMDetachRequestMT(request) => {
format!(
"EMM Detach Request ({:?}:{:?})",
request.eps_detach_type.inner, request.emm_cause.inner
)
}
EMMMessage::EMMServiceReject(reject) => {
format!("EMM Service Reject ({:?})", reject.emm_cause.inner)
}
EMMMessage::EMMAttachRequest(request) => {
format!("EPS Attach Request ({:?})", request.epsid.inner)
}
_ => "Unknown EMM Message".to_string(),
},
_ => "Unknown NAS Message".to_string(),
};
Some(Event {
event_type: EventType::Informational,
message: format!("Diagnostic: {message_type}."),
})
} else {
None
}
}
_ => None,
}
}
}
-5
View File
@@ -78,12 +78,7 @@ impl ImsiRequestedAnalyzer {
});
}
// Notify on any identity reqeust (IMEI or IMSI)
(_, State::IdentityRequest) => {
self.flag = Some(Event {
event_type: EventType::Informational,
message: "Identity Request happened but its not suspicious yet.".to_string(),
});
self.timeout_counter = 0;
}
+1
View File
@@ -1,5 +1,6 @@
pub mod analyzer;
pub mod connection_redirect_downgrade;
pub mod diagnostic;
pub mod imsi_requested;
pub mod incomplete_sib;
pub mod information_element;
+28
View File
@@ -210,6 +210,16 @@ pub enum LogBody {
#[deku(count = "hdr_len.saturating_sub(8)")]
msg: Vec<u8>,
},
#[deku(id = "0xb063")]
LteMac {
#[deku(ctx = "log_type")]
direction: LteMacMessageDirection,
version: u8,
#[deku(pad_bytes_after = "2")]
num_subpacket: u8,
#[deku(count = "num_subpacket")]
subpackets: Vec<LteMacSubpacket>,
},
#[deku(id = "0x713a")]
UmtsNasOtaMessage {
is_uplink: u8,
@@ -224,6 +234,24 @@ pub enum LogBody {
},
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
pub struct LteMacSubpacket {
pub id: u8,
pub version: u8,
pub size: u16,
#[deku(count = "size - 4")]
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16", id = "log_type")]
pub enum LteMacMessageDirection {
#[deku(id_pat = "0xb063")]
Downlink,
#[deku(id_pat = "0xb064")]
Uplink,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16", id = "log_type")]
pub enum Nas4GMessageDirection {
+15 -12
View File
@@ -40,22 +40,25 @@ pub enum DiagDeviceError {
ParseMessagesContainerError(deku::DekuError),
}
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 13] = [
// Layer 2:
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C,
// Layer 3:
log_codes::LOG_GSM_RR_SIGNALING_MESSAGE_C, // 0x512f
log_codes::WCDMA_SIGNALLING_MESSAGE, // 0x412f
log_codes::LOG_LTE_RRC_OTA_MSG_LOG_C, // 0xb0c0
log_codes::LOG_NR_RRC_OTA_MSG_LOG_C, // 0xb821
log_codes::LOG_GSM_RR_SIGNALING_MESSAGE_C,
log_codes::WCDMA_SIGNALLING_MESSAGE,
log_codes::LOG_LTE_RRC_OTA_MSG_LOG_C,
log_codes::LOG_NR_RRC_OTA_MSG_LOG_C,
// NAS:
log_codes::LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C, // 0x713a
log_codes::LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C, // 0xb0e2
log_codes::LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C, // 0xb0e3
log_codes::LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C, // 0xb0ec
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
log_codes::LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C,
log_codes::LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C,
log_codes::LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C,
log_codes::LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C,
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C,
// MAC
log_codes::LOG_LTE_MAC_DL,
log_codes::LOG_LTE_MAC_UL,
// User IP traffic:
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
log_codes::LOG_DATA_PROTOCOL_LOGGING_C,
];
const BUFFER_LEN: usize = 1024 * 1024 * 10;
+18 -11
View File
@@ -1,6 +1,7 @@
use crate::diag::*;
use crate::gsmtap::*;
use deku::DekuContainerWrite;
use log::error;
use thiserror::Error;
@@ -13,16 +14,13 @@ pub enum GsmtapParserError {
}
pub fn parse(msg: Message) -> Result<Option<(Timestamp, GsmtapMessage)>, GsmtapParserError> {
if let Message::Log {
timestamp, body, ..
} = msg
{
match log_to_gsmtap(body)? {
Some(msg) => Ok(Some((timestamp, msg))),
None => Ok(None),
}
} else {
Ok(None)
let Message::Log { timestamp, body, .. } = msg else {
return Ok(None);
};
match log_to_gsmtap(body)? {
Some(msg) => Ok(Some((timestamp, msg))),
None => Ok(None),
}
}
@@ -151,7 +149,16 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
header,
payload: msg,
}))
}
},
LogBody::LteMac { direction, subpackets, .. } => {
let mut header = GsmtapHeader::new(GsmtapType::LteMac);
header.uplink = matches!(direction, LteMacMessageDirection::Uplink);
let mut payload = Vec::new();
for packet in subpackets {
payload.extend(packet.to_bytes().unwrap());
}
Ok(Some(GsmtapMessage { header, payload }))
},
_ => {
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
Ok(None)
+3
View File
@@ -36,6 +36,9 @@ pub const LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C: u32 = 0xb0e3;
pub const LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C: u32 = 0xb0ec;
pub const LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C: u32 = 0xb0ed;
pub const LOG_LTE_MAC_DL: u32 = 0xb063;
pub const LOG_LTE_MAC_UL: u32 = 0xb064;
pub const LTE_BCCH_BCH_V0: u32 = 1;
pub const LTE_BCCH_DL_SCH_V0: u32 = 2;
pub const LTE_MCCH_V0: u32 = 3;