Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 117 additions & 3 deletions lib/src/diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,18 @@ pub enum LogBody {
#[deku(count = "hdr_len")]
msg: Vec<u8>,
},
#[deku(id = "0xb17f")]
LteMl1ServingCellMeas {
version: u8,
#[deku(ctx = "*version")]
packet: LteMl1ServingCellMeasPacket,
},
// Raw bytes; subpacket parsing happens in gsmtap_parser to extract Timing Advance
#[deku(id = "0xb062")]
LteMacRachResponse {
#[deku(count = "hdr_len")]
payload: Vec<u8>,
},
}

#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
Expand Down Expand Up @@ -344,6 +356,72 @@ impl LteRrcOtaPacket {
}
}

// Qualcomm ML1 (physical layer) serving cell measurement log (0xb17f).
// Format from SCAT: https://github.com/fgsect/scat/blob/master/src/scat/parsers/qualcomm/diagltelogparser.py
// V5 format string (after version byte): '<BHLH2xLLLLLL'
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "version: u8", id = "version")]
pub enum LteMl1ServingCellMeasPacket {
#[deku(id = "4")]
V4 {
rrc_release: u16,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem right, rrc_rel is defined as B (for byte) on both v4 and v5 in scat: https://github.com/fgsect/scat/blob/a289d84d81c85612860ef5408c72a0b6306854d0/src/scat/parsers/qualcomm/diagltelogparser.py#L125-L129

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

reserved: u16,
earfcn: u16,
pci_serv_layer: u16,
meas_rsrp: u32,
avg_rsrp: u32,
rsrq: u32,
rssi: u32,
rxlev: u32,
search_threshold: u32,
},
// V5 expanded earfcn to u32; rrc_release shrunk to u8 with a reserved u16 before earfcn;
// 2-byte padding follows pci_serv_layer (SCAT: 2x)
#[deku(id_pat = "5..=255")]
V5 {
rrc_release: u8,
reserved: u16,
earfcn: u32,
#[deku(pad_bytes_after = "2")]
pci_serv_layer: u16,
meas_rsrp: u32,
avg_rsrp: u32,
rsrq: u32,
rssi: u32,
rxlev: u32,
search_threshold: u32,
},
}

impl LteMl1ServingCellMeasPacket {
pub fn get_earfcn(&self) -> u32 {
match self {
Self::V4 { earfcn, .. } => *earfcn as u32,
Self::V5 { earfcn, .. } => *earfcn,
}
}

// Lower 9 bits are the Physical Cell ID (0–503); upper bits encode serving layer.
pub fn get_pci(&self) -> u16 {
let raw = match self {
Self::V4 { pci_serv_layer, .. } => *pci_serv_layer,
Self::V5 { pci_serv_layer, .. } => *pci_serv_layer,
};
raw & 0x1FF
}

// RSRP lower 12 bits, 1/16 dB steps, -180 dBm base.
// Returns whole dBm clamped to i8 for the GSMTAP signal_dbm header field.
pub fn get_rsrp_dbm(&self) -> i8 {
let raw = match self {
Self::V4 { meas_rsrp, .. } => *meas_rsrp,
Self::V5 { meas_rsrp, .. } => *meas_rsrp,
};
let sixteenth_db = -2880_i32 + (raw & 0x0FFF) as i32;
(sixteenth_db / 16).clamp(i8::MIN as i32, i8::MAX as i32) as i8
}
}

#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(endian = "little")]
pub struct Timestamp {
Expand Down Expand Up @@ -414,6 +492,42 @@ pub fn build_log_mask_request(
mod test {
use super::*;

#[test]
fn test_lte_ml1_v5_rsrp() {
// Probe capture: full diag Message wrapping a 0xb17f log (Version 5, Band 3 / EARFCN 1849).
// Constructed as: opcode(1) + pending(1) + outer_len(2) + inner_len(2) +
// log_type(2=0xb17f LE) + timestamp(8) + body(40) = 56 bytes total
let mut msg_bytes: Vec<u8> = vec![
0x10, 0x00, // opcode=Log, pending=0
56, 0, 56, 0, // outer_length=56, inner_length=56
0x7f, 0xb1, // log_type = 0xb17f (LE)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // timestamp
];
msg_bytes.extend_from_slice(&[
0x05, // version=5
0x01, 0x00, 0x00, 0x39, 0x07, 0x00, 0x00, 0x89, 0x00, 0x00, 0x00, 0xab, 0xb5, 0x5a,
0x00, 0xab, 0xb5, 0x5a, 0x00, 0x1a, 0x69, 0xa4, 0x11, 0x1a, 0x45, 0x0d, 0x00, 0x86,
0xa7, 0xae, 0x02, 0x00, 0x00, 0x00, 0x00, 0x80, 0x1c, 0x00, 0x00,
]);
let msg = Message::from_bytes((&msg_bytes, 0))
.expect("Message parse failed")
.1;
if let Message::Log {
body: LogBody::LteMl1ServingCellMeas { packet, .. },
..
} = msg
{
assert_eq!(packet.get_earfcn(), 1849);
let rsrp = packet.get_rsrp_dbm();
assert!(
rsrp <= -44 && rsrp >= -120,
"RSRP {rsrp} dBm outside valid LTE range"
);
} else {
panic!("unexpected message variant");
}
}

// Just about all of these test cases from manually parsing diag packets w/ QCSuper

#[test]
Expand Down Expand Up @@ -447,11 +561,11 @@ mod test {
log_type,
log_mask_bitsize: bitsize,
log_mask: vec![
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x4, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0xc, 0x30, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0,
0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0,
],
})
);
Expand Down
6 changes: 5 additions & 1 deletion lib/src/diag_device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ 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
// Layer 3:
Expand All @@ -56,6 +56,10 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
// User IP traffic:
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
// LTE physical layer serving cell measurements: RSRP, RSRQ, RSSI
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_AND_EVAL_C, // 0xb17f
// LTE MAC Random Access Channel response: contains Timing Advance
log_codes::LOG_LTE_MAC_RACH_RESPONSE_C, // 0xb062
];

const BUFFER_LEN: usize = 1024 * 1024 * 10;
Expand Down
172 changes: 171 additions & 1 deletion lib/src/gsmtap_parser.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::diag::*;
use crate::gsmtap::*;
use crate::gsmtap::{GsmtapType, *};

use log::error;
use thiserror::Error;
Expand Down Expand Up @@ -152,9 +152,179 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
payload: msg,
}))
}
LogBody::LteMl1ServingCellMeas { packet, .. } => {
// frame_number reused for PCI (normally SFN in RRC frames) so all three
// serving-cell fields are accessible in Wireshark as gsmtap.* columns.
let mut header = GsmtapHeader::new(GsmtapType::QcDiag);
header.signal_dbm = packet.get_rsrp_dbm();
header.arfcn = packet.get_earfcn().try_into().unwrap_or(0);
header.frame_number = packet.get_pci() as u32;
Ok(Some(GsmtapMessage {
header,
payload: vec![],
}))
}
LogBody::LteMacRachResponse { payload } => Ok(parse_rach_response(&payload)),
_ => {
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
Ok(None)
}
}
}

// Parses a 0xb062 RACH response log and reconstructs a 7-byte MAC RAR PDU for Wireshark.
// Returns None if the log contains no MSG2 (no Timing Advance was received).
fn parse_rach_response(payload: &[u8]) -> Option<GsmtapMessage> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this would be expressible in deku but I understand that it's fairly finnicky to do so.

Still, I would suggest to make this function and the one it calls more obviously panic-free. You have a lot of places where bounds checks are done entirely separately from access.

For example, instead of doing this:

if payload.len() < i + 4 {
    return None;
}

let x = payload[i];
let y = payload[i + 3];

you may as well write:

let x = payload.get(i)?;
let y = payload.get(i + 3)?;

...which should be optimized to the same exact thing.

currently when rayhunter panics, the entire thing just shuts down. we could make it more resilient at a higher level, but so far I think we've done well with avoiding panics in the parser.

// Outer header: version(u8) + num_subpackets(u8) + reserved(u16)
if payload.len() < 4 || payload[0] != 0x01 {
return None;
}
let num_subpackets = payload[1] as usize;
let mut offset = 4;

for _ in 0..num_subpackets {
// Subpacket header: id(u8) + version(u8) + size(u16 LE)
if offset + 4 > payload.len() {
break;
}
let sp_id = payload[offset];
let sp_version = payload[offset + 1];
let sp_size = u16::from_le_bytes([payload[offset + 2], payload[offset + 3]]) as usize;
if sp_size < 4 {
break;
}
let sp_end = offset + sp_size;
if sp_end > payload.len() {
break;
}

if sp_id == 0x06 {
// RACH Attempt subpacket
if let Some(msg) = extract_rach_attempt_gsmtap(&payload[offset + 4..sp_end], sp_version)
{
return Some(msg);
}
}

offset = sp_end;
}
None
}

fn extract_rach_attempt_gsmtap(body: &[u8], version: u8) -> Option<GsmtapMessage> {
// Per SCAT diagltelogparser.py, RACH Attempt subpacket layouts:
// v0x02: hdr=4B, msg1=4B(BBh), msg2=7B(HBHh)
// v0x03/0x31: hdr=6B, msg1=4B(BBh), msg2=7B(HBHh)
// v0x32: hdr=6B, msg1=7B(BBhHb), msg2=7B(HBHh)
// rapid_offset is the header byte holding preamble_index & 0x3F (the RAPID)
let (hdr_size, msg1_size, rapid_offset, bitmask_offset) = match version {
0x02 => (4usize, 4usize, 0usize, 3usize),
0x03 | 0x31 => (6, 4, 2, 5),
0x32 => (6, 7, 2, 5),
_ => return None,
};

if body.len() < hdr_size {
return None;
}
let msg_bitmask = body[bitmask_offset];
let rapid = body[rapid_offset] & 0x3F;
let msg1_present = msg_bitmask & 0x01 != 0;
let msg2_present = msg_bitmask & 0x02 != 0;

if !msg2_present {
return None;
}

// MSG2: backoff(u16) + result(u8) + tc_rnti(u16) + ta(u16) = 7 bytes
let msg2_offset = hdr_size + if msg1_present { msg1_size } else { 0 };
if body.len() < msg2_offset + 7 {
return None;
}
let tc_rnti = u16::from_le_bytes([body[msg2_offset + 3], body[msg2_offset + 4]]);
let ta_raw = u16::from_le_bytes([body[msg2_offset + 5], body[msg2_offset + 6]]);
// 0xFFFF is a Qualcomm sentinel meaning the RAR was received but TA was not valid
if ta_raw == 0xFFFF {
return None;
}
let ta = ta_raw & 0x7FF;

// Reconstruct 7-byte MAC RAR PDU (3GPP TS 36.321 §6.1.5):
// subheader: E=0, T=0, RAPID[5:0]
// payload: R(1)|TA[10:3](8) | TA[2:0](3)|ULGrant[19:15](5) | ULGrant[14:7](8) |
// ULGrant[6:0](7)|TC-RNTI[15](1) | TC-RNTI[14:7](8) | TC-RNTI[6:0](7)|0(1)
//
// Use LteMacFramed (0x0f) so Wireshark's mac-lte dissector knows the RNTI type is
// RA-RNTI (type=2) and applies the RAR PDU format. The 4-byte framing prefix is:
// [RadioType=1(FDD)][Direction=1(DL)][RNTIType=2(RA-RNTI)][0x01=payload-marker]
let payload = vec![
0x01u8,
0x01,
0x02,
0x01, // framing: FDD, DL, RA-RNTI, payload-marker
rapid & 0x3F,
((ta >> 3) & 0xFF) as u8,
((ta & 0x07) as u8) << 5,
0u8, // UL grant zeroed; Wireshark only needs TA and TC-RNTI to decode the RAR
((tc_rnti >> 15) & 0x01) as u8,
((tc_rnti >> 7) & 0xFF) as u8,
((tc_rnti & 0x7F) as u8) << 1,
];

let mut header = GsmtapHeader::new(GsmtapType::LteMacFramed);
// Wireshark 4.x does not dispatch GSMTAP type 0x0f to its mac-lte dissector, so
// mac-lte.rar.ta is unavailable. TA is also stored in frame_number (gsmtap.frame_nr).
header.frame_number = ta as u32;
Some(GsmtapMessage { header, payload })
}

#[cfg(test)]
mod tests {
use super::parse_rach_response;
use crate::gsmtap::GsmtapType;

// Builds a minimal 0xb062 payload: outer header + one RACH Attempt subpacket (version 0x03).
// v0x03 body layout: hdr=6B [_, _, rapid, _, _, bitmask], then MSG2=7B [backoff(2), result(1), tc_rnti(2), ta(2)]
fn make_rach_v03_payload(ta_raw: u16, bitmask: u8) -> Vec<u8> {
let rapid: u8 = 43;
let tc_rnti: u16 = 0x1234;
let [ta_lo, ta_hi] = ta_raw.to_le_bytes();
let [rnti_lo, rnti_hi] = tc_rnti.to_le_bytes();
// sp_size covers the 4-byte subpacket header + 6-byte body header + 7-byte MSG2 = 17
vec![
0x01, 0x01, 0x00, 0x00, // outer: version=1, num_subpackets=1, reserved
0x06, 0x03, 17, 0x00, // subpacket: id=0x06, version=0x03, size=17 LE
0x00, 0x00, rapid, 0x00, 0x00, bitmask, // body header (6 bytes)
0x00, 0x00, 0x01, rnti_lo, rnti_hi, ta_lo, ta_hi, // MSG2 (7 bytes)
]
}

#[test]
fn test_rach_response_valid_ta() {
let payload = make_rach_v03_payload(42, 0x02); // 0x02 = msg2 present, msg1 absent
let msg = parse_rach_response(&payload).expect("expected a GsmtapMessage for valid TA");
assert_eq!(msg.header.gsmtap_type, GsmtapType::LteMacFramed);
// TA stored in frame_number for Wireshark compatibility (gsmtap.frame_nr)
assert_eq!(msg.header.frame_number, 42);
// MAC RAR PDU: 4-byte framing prefix + 7-byte RAR PDU = 11 bytes
assert_eq!(msg.payload.len(), 11);
// Verify TA encoding in RAR PDU bytes 5–6 (TA[10:3] and TA[2:0])
// ta=42: ta>>3=5 in byte[5], (ta&7)<<5 = 2<<5 = 0x40 in byte[6]
assert_eq!(msg.payload[5], 5);
assert_eq!(msg.payload[6], 0x40);
}

#[test]
fn test_rach_response_ffff_sentinel_returns_none() {
// 0xFFFF means RAR was received but TA was not valid; must be dropped
let payload = make_rach_v03_payload(0xFFFF, 0x02);
assert!(parse_rach_response(&payload).is_none());
}

#[test]
fn test_rach_response_no_msg2_returns_none() {
// bitmask=0x01 means only MSG1 present; no TA available
let payload = make_rach_v03_payload(42, 0x01);
assert!(parse_rach_response(&payload).is_none());
}
}
4 changes: 4 additions & 0 deletions lib/src/log_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub const LOG_NR_RRC_OTA_MSG_LOG_C: u32 = 0xb821;
// These are 4G-related log types.

pub const LOG_LTE_RRC_OTA_MSG_LOG_C: u32 = 0xb0c0;
// Qualcomm ML1 (physical layer) serving cell measurement report: RSRP, RSRQ, RSSI
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_AND_EVAL_C: u32 = 0xb17f;
// Qualcomm MAC layer RACH response log: contains Timing Advance from Random Access Response
pub const LOG_LTE_MAC_RACH_RESPONSE_C: u32 = 0xb062;
pub const LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C: u32 = 0xb0e2;
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;
Expand Down
Loading