Draft: partial GSM parsing support and analyzers#938
Draft: partial GSM parsing support and analyzers#938mo-krauti wants to merge 3 commits intoEFForg:mainfrom
Conversation
|
I would absolutely welcome this pull request! We would love to be able to parse GSM connections ( not to mention putting them into the pcaps!) and I would love to be able to write some GSM heuristics (which you already have some!) |
|
Am I correct in understanding that this isn't parsing the entire GSM protocol, just the parts necessary for the heuristics you have created here? I'm not necessarily opposed to that but I would love to know more about the heuristics you have written, what they are looking for, and the reasoning behind them. |
Thank you for your interest :)
Oh is extra logic needed to parse gsm from qmdl? I only tested using the rayhunter-check with pcaps from scat as I do not have a device supported by rayhunter.
Okay I can seperate them
But yes, I am only parsing the parts I need for my analyzers.
I wrote a little report for my university (written in German)
|
|
@mo-krauti is this something you are still interested in working on? One piece of feedback is that it would be nice to have the GPS parsing automatically generated from GSM specs as we did with LTE-RRC/NAS. I don't think that needs to be included in this pull request but it could be a separate pull request if that's something you are interested in as well. As is I'm pretty much ready to accept this pull request once the tests pass! |
BeigeBox
left a comment
There was a problem hiding this comment.
Took some time to go through this. First off thanks for the work here, this is a lot of ground to cover and I can tell you actually read the specs going through it.
A few bigger picture things worth sorting out before digging into the code level details:
The analyzers don't actually have anything to analyze on-device right now. gsmtap_parser.rs only emits LteRrc and LteNas, and I don't see a matching LogBody variant in diag.rs for the GSM log codes. So on a real Orbic or Moxee nothing is going to produce a GSM IE for these to look at. Probably why it's still draft, but it either needs a companion PR or the gsmtap and diag side needs to land as part of this one before end to end testing is even possible.
This is also becoming a third parser style in the tree. telcom-parser is ASN.1 generated, pycrate is spec derived, and now deku by hand. Cooperq mentioned wanting auto-gen. Worth sorting out up front whether hand written deku is the pattern we want for 2G or if this should be generated like the others. If we're going hand written, a readme in lib/src/gsm/ explaining the convention would help the next person that shows up and looks at this.
On threat model, a lot of 2G is sunset now (AT&T, Verizon, most of Europe). Depending on who we're defending against and where, these analyzers might run on a lot of devices that never see a 2G connection. Two things come out of that, probably should feature-gate GSM like we do tft-ui so we don't ship parser bytes to devices that can't use them, and there's the on-by-default question. A line in the PR description about the target adversary would help with that call.
Minor thing but worth flagging, publishing these exact thresholds gives IMSI catcher operators a cheat sheet for avoiding them. Not a blocker since our LTE stuff has the same property, but worth calling out in docs so users don't get overconfident in what a clean report actually means.
Placement wise my gut says a sibling crate (gsm-parser) or fold it into telcom-parser. Same argument untitaker made for pulling wifi-station out. Lets it be versioned independently and feature-gated out of the firmware cleanly.
None of this is a no, the feature fills a real gap the LTE analyzers don't cover and I'd love to see it land. Just want these scope questions settled before getting deep into the details.
|
|
||
| pub fn parse_l3(block: &[u8], pseudo_length: bool) -> Result<L3Frame, InformationElementError> { | ||
| if pseudo_length { | ||
| let (_rest, val) = PseudoLengthL3Frame::from_bytes((block.as_ref(), 0)).unwrap(); |
There was a problem hiding this comment.
These unwraps are going to take down the daemon when the first malformed packet comes in off the air. Truncated, unknown enum value, whatever, it all comes back as Err and crashes. Looks like you already knew this was going to be a ? path, your commented out line at 12 was heading that direction.
Should be a one liner:
let (_rest, val) = PseudoLengthL3Frame::from_bytes((block.as_ref(), 0))
.map_err(|_| InformationElementError::GsmDecodingError)?;Same thing on line 13. A test that feeds in a 1 byte input would lock this in.
| // 3GPP TS 24.008 Section 9.4.9 | ||
| #[derive(Debug, PartialEq, DekuRead, DekuWrite)] | ||
| pub struct GMMAuthenticationAndCipheringRequest { | ||
| #[deku(pad_bits_before = "1")] |
There was a problem hiding this comment.
I think these two fields are in the wrong order. Per 24.008 ciphering algorithm sits in the high nibble of this octet, and IMEISV request doesn't pair with it here, it shows up later in the message as a separate IEI. Reading MSB first the 3 bit ciphering algorithm should come out first.
Downstream effect is gsm_ciphering_mode.rs is pulling whatever happens to be in the IMEISV bits and reporting that as the GEA value. Worth checking against a known good AuthenticationAndCiphering byte to confirm.
| // 3GPP TS 24.008 Section 9.2.10 | ||
| #[derive(Debug, PartialEq, DekuRead, DekuWrite)] | ||
| pub struct MMIdentityRequest { | ||
| #[deku(pad_bits_before = "1", pad_bits_after = "4")] |
There was a problem hiding this comment.
Identity type is in the low nibble of this octet (bits 3-1) per 24.008, not the high nibble. What's currently being read as identity type is actually sitting in the spare half octet, so every Identity Request is going to decode whatever happens to be up there.
Fix should be roughly pad_bits_before = "5" with no pad_bits_after, so the 3 bit field reads from bits 3-1. A known good byte as a test would catch this.
| let event_type = match ciphering_mode_command.cipher_mode_setting | ||
| { | ||
| CipherModeSetting::A5_5 | CipherModeSetting::A5_6 | CipherModeSetting::A5_7 => EventType::Informational, | ||
| CipherModeSetting::A5_3 | CipherModeSetting::A5_4 => EventType::Low, |
There was a problem hiding this comment.
A5/1 is still the default on a lot of real 2G networks, so flagging every A5/1 attach as High is going to bury the signal under legit traffic. Same deal for GEA1 on GPRS. The way null_cipher.rs handles this is only actual null cipher hits High, everything else is lower, and I think that's the right bar here too.
Probably more like:
- NoCiphering and CipheringNotUsed: High (the real attack signal)
- A5/1, A5/2, GEA1, GEA2: Low or Medium, weak but extremely common on legit networks
- A5/3+, GEA3+: Informational
- Reserved probably belongs in Informational rather than High, since reserved codepoints are undefined not weak
Worth keeping in mind an IMSI catcher that does A5/3 with real auth looks identical to a legit network from this analyzer's perspective. So even after recalibrating, this is more useful as telemetry than a standalone detection.
| if analyzer_config.imsi_requested { | ||
| harness.add_analyzer(Box::new(ImsiRequestedAnalyzer::new())); | ||
| } | ||
| harness.add_analyzer(Box::new(GsmCellReselectionHysteresisAnalyzer {})); |
There was a problem hiding this comment.
These three are registered unconditionally, but every other analyzer in here goes through an AnalyzerConfig flag (imsi_requested, null_cipher, etc above). Probably want to add fields for each and gate on them so users can turn the noisy ones off without a rebuild.
Small sidebar, the use log::info; added at the top of this file doesn't look like it's used anywhere, clippy will trip on that.
|
Don't have anything to contribute to this, but 2G in Europe is more alive than 3G is. We use it mostly for coverage of emergency calls. Hence any downgrade to 2G can't be automatically classified as an IMSI catcher the same way it can be in the US.
…On Tue, Apr 21, 2026, at 21:21, Ember wrote:
***@***.**** commented on this pull request.
Took some time to go through this. First off thanks for the work here, this is a lot of ground to cover and I can tell you actually read the specs going through it.
A few bigger picture things worth sorting out before digging into the code level details:
The analyzers don't actually have anything to analyze on-device right now. `gsmtap_parser.rs` only emits LteRrc and LteNas, and I don't see a matching `LogBody` variant in `diag.rs` for the GSM log codes. So on a real Orbic or Moxee nothing is going to produce a GSM IE for these to look at. Probably why it's still draft, but it either needs a companion PR or the gsmtap and diag side needs to land as part of this one before end to end testing is even possible.
This is also becoming a third parser style in the tree. `telcom-parser` is ASN.1 generated, pycrate is spec derived, and now deku by hand. Cooperq mentioned wanting auto-gen. Worth sorting out up front whether hand written deku is the pattern we want for 2G or if this should be generated like the others. If we're going hand written, a readme in `lib/src/gsm/` explaining the convention would help the next person that shows up and looks at this.
On threat model, a lot of 2G is sunset now (AT&T, Verizon, most of Europe). Depending on who we're defending against and where, these analyzers might run on a lot of devices that never see a 2G connection. Two things come out of that, probably should feature-gate GSM like we do tft-ui so we don't ship parser bytes to devices that can't use them, and there's the on-by-default question. A line in the PR description about the target adversary would help with that call.
Minor thing but worth flagging, publishing these exact thresholds gives IMSI catcher operators a cheat sheet for avoiding them. Not a blocker since our LTE stuff has the same property, but worth calling out in docs so users don't get overconfident in what a clean report actually means.
Placement wise my gut says a sibling crate (gsm-parser) or fold it into telcom-parser. Same argument untitaker made for pulling wifi-station out. Lets it be versioned independently and feature-gated out of the firmware cleanly.
None of this is a no, the feature fills a real gap the LTE analyzers don't cover and I'd love to see it land. Just want these scope questions settled before getting deep into the details.
In lib/src/gsm/layer3.rs <#938?email_source=notifications&email_token=AAGMPRKQRR6E43CNUBQ6PQT4W7CZ3A5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMJVGAZDKOJSGUYKM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSXXA4S7OJSXM2LFO5PWG3DJMNVQ#discussion_r3119816336>:
> @@ -0,0 +1,44 @@
+use crate::analysis::information_element::InformationElementError;
+use crate::gsm::mobility_management::MobilityManagementMessage;
+use crate::gsm::grps_mobility_management::GPRSMobilityManagementMessage;
+use crate::gsm::radio_resource_management::RadioResourceManagementMessage;
+use deku::prelude::*;
+
+pub fn parse_l3(block: &[u8], pseudo_length: bool) -> Result<L3Frame, InformationElementError> {
+ if pseudo_length {
+ let (_rest, val) = PseudoLengthL3Frame::from_bytes((block.as_ref(), 0)).unwrap();
These unwraps are going to take down the daemon when the first malformed packet comes in off the air. Truncated, unknown enum value, whatever, it all comes back as `Err` and crashes. Looks like you already knew this was going to be a `?` path, your commented out line at 12 was heading that direction.
Should be a one liner:
let (_rest, val) = PseudoLengthL3Frame::from_bytes((block.as_ref(), 0))
.map_err(|_| InformationElementError::GsmDecodingError)?;
Same thing on line 13. A test that feeds in a 1 byte input would lock this in.
In lib/src/gsm/grps_mobility_management.rs <#938?email_source=notifications&email_token=AAGMPRKQRR6E43CNUBQ6PQT4W7CZ3A5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMJVGAZDKOJSGUYKM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSXXA4S7OJSXM2LFO5PWG3DJMNVQ#discussion_r3119816343>:
> + Unknown,
+}
+
+// 3GPP TS 24.008 Section 9.4.12
+#[derive(Debug, PartialEq, DekuRead, DekuWrite)]
+pub struct GMMIdentityRequest {
+ #[deku(pad_bits_before = "1")]
+ pub force_to_standby: ForceToStandby,
+ #[deku(pad_bits_before = "1")]
+ pub identity_type: IdentityType2,
+}
+
+// 3GPP TS 24.008 Section 9.4.9
+#[derive(Debug, PartialEq, DekuRead, DekuWrite)]
+pub struct GMMAuthenticationAndCipheringRequest {
+ #[deku(pad_bits_before = "1")]
I think these two fields are in the wrong order. Per 24.008 ciphering algorithm sits in the high nibble of this octet, and IMEISV request doesn't pair with it here, it shows up later in the message as a separate IEI. Reading MSB first the 3 bit ciphering algorithm should come out first.
Downstream effect is `gsm_ciphering_mode.rs` is pulling whatever happens to be in the IMEISV bits and reporting that as the GEA value. Worth checking against a known good AuthenticationAndCiphering byte to confirm.
In lib/src/gsm/mobility_management.rs <#938?email_source=notifications&email_token=AAGMPRKQRR6E43CNUBQ6PQT4W7CZ3A5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMJVGAZDKOJSGUYKM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSXXA4S7OJSXM2LFO5PWG3DJMNVQ#discussion_r3119816348>:
> +use crate::gsm::information_elements::*;
+
+// 3GPP TS 24.008 Table 10.2
+#[derive(Debug, PartialEq, DekuRead, DekuWrite)]
+#[deku(id_type = "u8")]
+pub enum MobilityManagementMessage {
+ #[deku(id = 0b00011000)]
+ IdentityRequest(MMIdentityRequest),
+ #[deku(id_pat = "_")]
+ Unknown,
+}
+
+// 3GPP TS 24.008 Section 9.2.10
+#[derive(Debug, PartialEq, DekuRead, DekuWrite)]
+pub struct MMIdentityRequest {
+ #[deku(pad_bits_before = "1", pad_bits_after = "4")]
Identity type is in the low nibble of this octet (bits 3-1) per 24.008, not the high nibble. What's currently being read as identity type is actually sitting in the spare half octet, so every Identity Request is going to decode whatever happens to be up there.
Fix should be roughly `pad_bits_before = "5"` with no `pad_bits_after`, so the 3 bit field reads from bits 3-1. A known good byte as a test would catch this.
In lib/src/analysis/gsm_ciphering_mode.rs <#938?email_source=notifications&email_token=AAGMPRKQRR6E43CNUBQ6PQT4W7CZ3A5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMJVGAZDKOJSGUYKM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSXXA4S7OJSXM2LFO5PWG3DJMNVQ#discussion_r3119816351>:
> + fn analyze_information_element(
+ &mut self,
+ ie: &InformationElement,
+ _packet_num: usize,
+ ) -> Option<super::analyzer::Event> {
+ if let InformationElement::GSM(gsm_ie) = ie
+ && let GsmInformationElement::DTAP(l3_frame) = &**gsm_ie
+ && let ProtocolDiscrimiminatedMessage::RadioResourceManagement(
+ RadioResourceManagementMessage::CipheringModeCommand(ciphering_mode_command),
+ ) = &l3_frame.protocol_discriminated_messages
+ {
+ debug!("CipherModeSetting");
+ let event_type = match ciphering_mode_command.cipher_mode_setting
+ {
+ CipherModeSetting::A5_5 | CipherModeSetting::A5_6 | CipherModeSetting::A5_7 => EventType::Informational,
+ CipherModeSetting::A5_3 | CipherModeSetting::A5_4 => EventType::Low,
A5/1 is still the default on a lot of real 2G networks, so flagging every A5/1 attach as `High` is going to bury the signal under legit traffic. Same deal for GEA1 on GPRS. The way `null_cipher.rs` handles this is only actual null cipher hits `High`, everything else is lower, and I think that's the right bar here too.
Probably more like:
• NoCiphering and CipheringNotUsed: High (the real attack signal)
• A5/1, A5/2, GEA1, GEA2: Low or Medium, weak but extremely common on legit networks
• A5/3+, GEA3+: Informational
• Reserved probably belongs in Informational rather than High, since reserved codepoints are undefined not weak
Worth keeping in mind an IMSI catcher that does A5/3 with real auth looks identical to a legit network from this analyzer's perspective. So even after recalibrating, this is more useful as telemetry than a standalone detection.
In lib/src/analysis/analyzer.rs <#938?email_source=notifications&email_token=AAGMPRKQRR6E43CNUBQ6PQT4W7CZ3A5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMJVGAZDKOJSGUYKM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSXXA4S7OJSXM2LFO5PWG3DJMNVQ#discussion_r3119816355>:
> @@ -323,6 +327,9 @@ impl Harness {
if analyzer_config.imsi_requested {
harness.add_analyzer(Box::new(ImsiRequestedAnalyzer::new()));
}
+ harness.add_analyzer(Box::new(GsmCellReselectionHysteresisAnalyzer {}));
These three are registered unconditionally, but every other analyzer in here goes through an `AnalyzerConfig` flag (`imsi_requested`, `null_cipher`, etc above). Probably want to add fields for each and gate on them so users can turn the noisy ones off without a rebuild.
Small sidebar, the `use log::info;` added at the top of this file doesn't look like it's used anywhere, clippy will trip on that.
—
Reply to this email directly, view it on GitHub <#938?email_source=notifications&email_token=AAGMPRKJFTA67WIJYHZS3R34W7CZ3A5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMJVGAZDKOJSGUYKM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOS6XA4S7OJSXM2LFO5PW433UNFTGSY3BORUW63TTL5RWY2LDNM#pullrequestreview-4150259250>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAGMPRM7W4IMLYVNDV2SKDD4W7CZ3AVCNFSM6AAAAACWHAKPQ6VHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHM2DCNJQGI2TSMRVGA>.
You are receiving this because you are subscribed to this thread.Message ID: ***@***.***>
|
Pull Request Checklist
cargo fmt.You must check one of:
I have only used ChatGPT for a small number of code lines, like less than 15 probably which I did understand. I authored all of the other code myself including all deku structs and enums for parsing the GSMTAP packets. I have read the appropriate 3GPP specs for that, which I have also always linked in my comments.
I know that the MR is not in a state that in which it could be merged. I did this as a self chosen project for my university. Do you have interest in adding GSM functionality? Or is it currently out of scope, because you do not have enough maintainers?
If you are interested, I am willing to continue to work on it. I could improve the structure and add documentation and unit tests. Right now the code has some quirks, but it was enough to finish my university project so I do not have to continue it.
I think adding GSM support would be beneficial especially for countries that did not already shut off the GSM networks, like here in Germany.
Anyways, thank you for this tool and also your great other work :)