From 47dbab0155eaa34a3591c8964a2dc1b3ebf43dcb Mon Sep 17 00:00:00 2001 From: Jackson Coxson Date: Sun, 17 Aug 2025 20:44:53 -0600 Subject: [PATCH] Implement bt_packet_logger --- Cargo.lock | 2 +- idevice/Cargo.toml | 2 + idevice/src/services/bt_packet_logger.rs | 204 +++++++++++++++++++++++ idevice/src/services/mod.rs | 2 + tools/Cargo.toml | 4 + tools/src/bt_packet_logger.rs | 104 ++++++++++++ tools/src/pcap.rs | 60 +++++++ 7 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 idevice/src/services/bt_packet_logger.rs create mode 100644 tools/src/bt_packet_logger.rs create mode 100644 tools/src/pcap.rs diff --git a/Cargo.lock b/Cargo.lock index a33c11e..e5df891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1132,7 +1132,7 @@ dependencies = [ [[package]] name = "idevice" -version = "0.1.38" +version = "0.1.39" dependencies = [ "async-stream", "base64", diff --git a/idevice/Cargo.toml b/idevice/Cargo.toml index 0471810..d6ed576 100644 --- a/idevice/Cargo.toml +++ b/idevice/Cargo.toml @@ -66,6 +66,7 @@ ring = ["rustls/ring", "tokio-rustls/ring"] afc = ["dep:chrono"] amfi = [] +bt_packet_logger = [] companion_proxy = [] core_device = ["xpc", "dep:uuid"] core_device_proxy = ["dep:serde_json", "dep:json", "dep:byteorder"] @@ -101,6 +102,7 @@ xpc = ["dep:indexmap", "dep:uuid", "dep:async-stream"] full = [ "afc", "amfi", + "bt_packet_logger", "companion_proxy", "core_device", "core_device_proxy", diff --git a/idevice/src/services/bt_packet_logger.rs b/idevice/src/services/bt_packet_logger.rs new file mode 100644 index 0000000..a4883e5 --- /dev/null +++ b/idevice/src/services/bt_packet_logger.rs @@ -0,0 +1,204 @@ +//! Abstraction for BTPacketLogger +//! You must have the Bluetooth profile installed, or you'll get no data. +//! https://developer.apple.com/bug-reporting/profiles-and-logs/?name=bluetooth + +use std::pin::Pin; + +use futures::Stream; +use log::{debug, warn}; + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the BTPacketLogger service on the device. +/// You must have the Bluetooth profile installed, or you'll get no data. +/// +/// ``https://developer.apple.com/bug-reporting/profiles-and-logs/?name=bluetooth`` +pub struct BtPacketLoggerClient { + /// The underlying device connection with established logger service + pub idevice: Idevice, +} + +#[derive(Debug, Clone)] +pub struct BtFrame { + pub hdr: BtHeader, + pub kind: BtPacketKind, + /// H4-ready payload (first byte is H4 type: 0x01 cmd, 0x02 ACL, 0x03 SCO, 0x04 evt) + pub h4: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct BtHeader { + /// Advisory length for [kind + payload]; may not equal actual frame len - 12 + pub length: u32, // BE on the wire + pub ts_secs: u32, // BE + pub ts_usecs: u32, // BE +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BtPacketKind { + HciCmd, // 0x00 + HciEvt, // 0x01 + AclSent, // 0x02 + AclRecv, // 0x03 + ScoSent, // 0x08 + ScoRecv, // 0x09 + Other(u8), +} + +impl BtPacketKind { + fn from_byte(b: u8) -> Self { + match b { + 0x00 => BtPacketKind::HciCmd, + 0x01 => BtPacketKind::HciEvt, + 0x02 => BtPacketKind::AclSent, + 0x03 => BtPacketKind::AclRecv, + 0x08 => BtPacketKind::ScoSent, + 0x09 => BtPacketKind::ScoRecv, + x => BtPacketKind::Other(x), + } + } + fn h4_type(self) -> Option { + match self { + BtPacketKind::HciCmd => Some(0x01), + BtPacketKind::AclSent | BtPacketKind::AclRecv => Some(0x02), + BtPacketKind::ScoSent | BtPacketKind::ScoRecv => Some(0x03), + BtPacketKind::HciEvt => Some(0x04), + BtPacketKind::Other(_) => None, + } + } +} + +impl IdeviceService for BtPacketLoggerClient { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.bluetooth.BTPacketLogger") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl BtPacketLoggerClient { + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Read a single *outer* frame and return one parsed record from it. + /// (This service typically delivers one record per frame.) + pub async fn next_packet( + &mut self, + ) -> Result)>, IdeviceError> { + // 2-byte outer length is **little-endian** + let len = self.idevice.read_raw(2).await?; + if len.len() != 2 { + return Ok(None); // EOF + } + let frame_len = u16::from_le_bytes([len[0], len[1]]) as usize; + + if !(13..=64 * 1024).contains(&frame_len) { + return Err(IdeviceError::UnexpectedResponse); + } + + let frame = self.idevice.read_raw(frame_len).await?; + if frame.len() != frame_len { + return Err(IdeviceError::NotEnoughBytes(frame.len(), frame_len)); + } + + // Parse header at fixed offsets (BE u32s) + let (hdr, off) = BtHeader::parse(&frame).ok_or(IdeviceError::UnexpectedResponse)?; + // packet_type at byte 12, payload starts at 13 + let kind = BtPacketKind::from_byte(frame[off]); + let payload = &frame[off + 1..]; // whatever remains + + // Optional soft check of advisory header.length + let advisory = hdr.length as usize; + let actual = 1 + payload.len(); // kind + payload + if advisory != actual { + debug!( + "BTPacketLogger advisory length {} != actual {}, proceeding", + advisory, actual + ); + } + + // Build H4 buffer (prepend type byte) + let mut h4 = Vec::with_capacity(1 + payload.len()); + if let Some(t) = kind.h4_type() { + h4.push(t); + } else { + return Ok(None); + } + h4.extend_from_slice(payload); + + Ok(Some((hdr, kind, h4))) + } + + /// Continuous stream of parsed frames. + pub fn into_stream( + mut self, + ) -> Pin> + Send>> { + Box::pin(async_stream::try_stream! { + loop { + // outer length (LE) + let len = self.idevice.read_raw(2).await?; + if len.len() != 2 { break; } + let frame_len = u16::from_le_bytes([len[0], len[1]]) as usize; + if !(13..=64 * 1024).contains(&frame_len) { + warn!("invalid frame_len {}", frame_len); + continue; + } + + // frame bytes + let frame = self.idevice.read_raw(frame_len).await?; + if frame.len() != frame_len { + Err(IdeviceError::NotEnoughBytes(frame.len(), frame_len))?; + } + + // header + kind + payload + let (hdr, off) = BtHeader::parse(&frame).ok_or(IdeviceError::UnexpectedResponse)?; + let kind = BtPacketKind::from_byte(frame[off]); + let payload = &frame[off + 1..]; + + // soft advisory check + let advisory = hdr.length as usize; + let actual = 1 + payload.len(); + if advisory != actual { + debug!("BTPacketLogger advisory length {} != actual {}", advisory, actual); + } + + // make H4 buffer + let mut h4 = Vec::with_capacity(1 + payload.len()); + if let Some(t) = kind.h4_type() { + h4.push(t); + } else { + // unknown kind + continue; + } + h4.extend_from_slice(payload); + + yield BtFrame { hdr, kind, h4 }; + } + }) + } +} + +impl BtHeader { + /// Parse 12-byte header at the start of a frame. + /// Returns (header, next_offset) where next_offset == 12 (start of packet_type). + fn parse(buf: &[u8]) -> Option<(Self, usize)> { + if buf.len() < 12 { + return None; + } + let length = u32::from_be_bytes(buf[0..4].try_into().ok()?); + let ts_secs = u32::from_be_bytes(buf[4..8].try_into().ok()?); + let ts_usecs = u32::from_be_bytes(buf[8..12].try_into().ok()?); + Some(( + BtHeader { + length, + ts_secs, + ts_usecs, + }, + 12, + )) + } +} + diff --git a/idevice/src/services/mod.rs b/idevice/src/services/mod.rs index fd341ec..6b14605 100644 --- a/idevice/src/services/mod.rs +++ b/idevice/src/services/mod.rs @@ -2,6 +2,8 @@ pub mod afc; #[cfg(feature = "amfi")] pub mod amfi; +#[cfg(feature = "bt_packet_logger")] +pub mod bt_packet_logger; #[cfg(feature = "companion_proxy")] pub mod companion_proxy; #[cfg(feature = "core_device")] diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 54d0d79..717838a 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -109,6 +109,10 @@ path = "src/mobilebackup2.rs" name = "diagnosticsservice" path = "src/diagnosticsservice.rs" +[[bin]] +name = "bt_packet_logger" +path = "src/bt_packet_logger.rs" + [dependencies] idevice = { path = "../idevice", features = ["full"], default-features = false } tokio = { version = "1.43", features = ["full"] } diff --git a/tools/src/bt_packet_logger.rs b/tools/src/bt_packet_logger.rs new file mode 100644 index 0000000..eef5d2d --- /dev/null +++ b/tools/src/bt_packet_logger.rs @@ -0,0 +1,104 @@ +// Jackson Coxson + +use clap::{Arg, Command}; +use futures_util::StreamExt; +use idevice::{IdeviceService, bt_packet_logger::BtPacketLoggerClient}; +use tokio::io::AsyncWrite; + +use crate::pcap::{write_pcap_header, write_pcap_record}; + +mod common; +mod pcap; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let matches = Command::new("amfi") + .about("Capture Bluetooth packets") + .arg( + Arg::new("host") + .long("host") + .value_name("HOST") + .help("IP address of the device"), + ) + .arg( + Arg::new("pairing_file") + .long("pairing-file") + .value_name("PATH") + .help("Path to the pairing file"), + ) + .arg( + Arg::new("udid") + .value_name("UDID") + .help("UDID of the device (overrides host/pairing file)") + .index(1), + ) + .arg( + Arg::new("about") + .long("about") + .help("Show about information") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("out") + .long("out") + .value_name("PCAP") + .help("Write PCAP to this file (use '-' for stdout)"), + ) + .get_matches(); + + if matches.get_flag("about") { + println!("bt_packet_logger - capture bluetooth packets"); + println!("Copyright (c) 2025 Jackson Coxson"); + return; + } + + let udid = matches.get_one::("udid"); + let host = matches.get_one::("host"); + let pairing_file = matches.get_one::("pairing_file"); + let out = matches.get_one::("out").map(String::to_owned); + + let provider = match common::get_provider(udid, host, pairing_file, "amfi-jkcoxson").await { + Ok(p) => p, + Err(e) => { + eprintln!("{e}"); + return; + } + }; + + let logger_client = BtPacketLoggerClient::connect(&*provider) + .await + .expect("Failed to connect to amfi"); + + let mut s = logger_client.into_stream(); + + // Open output (default to stdout if --out omitted) + let mut out_writer: Box = match out.as_deref() { + Some("-") | None => Box::new(tokio::io::stdout()), + Some(path) => Box::new(tokio::fs::File::create(path).await.expect("open pcap")), + }; + + // Write global header + write_pcap_header(&mut out_writer) + .await + .expect("pcap header"); + + // Drain stream to PCAP + while let Some(res) = s.next().await { + match res { + Ok(frame) => { + write_pcap_record( + &mut out_writer, + frame.hdr.ts_secs, + frame.hdr.ts_usecs, + frame.kind, + &frame.h4, + ) + .await + .unwrap_or_else(|e| eprintln!("pcap write error: {e}")); + } + Err(e) => eprintln!("Failed to get next packet: {e:?}"), + } + } +} diff --git a/tools/src/pcap.rs b/tools/src/pcap.rs new file mode 100644 index 0000000..a752ecf --- /dev/null +++ b/tools/src/pcap.rs @@ -0,0 +1,60 @@ +use idevice::bt_packet_logger::BtPacketKind; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +// Classic PCAP (big-endian) global header for DLT_BLUETOOTH_HCI_H4_WITH_PHDR (201) +const PCAP_GLOBAL_HEADER_BE: [u8; 24] = [ + 0xA1, 0xB2, 0xC3, 0xD4, // magic (big-endian stream) + 0x00, 0x02, // version maj + 0x00, 0x04, // version min + 0x00, 0x00, 0x00, 0x00, // thiszone + 0x00, 0x00, 0x00, 0x00, // sigfigs + 0x00, 0x00, 0x08, 0x00, // snaplen = 2048 + 0x00, 0x00, 0x00, 201, // network = 201 (HCI_H4_WITH_PHDR) +]; + +#[inline] +fn be32(x: u32) -> [u8; 4] { + [(x >> 24) as u8, (x >> 16) as u8, (x >> 8) as u8, x as u8] +} + +#[inline] +fn dir_flag(kind: BtPacketKind) -> Option { + use BtPacketKind::*; + Some(match kind { + HciCmd | AclSent | ScoSent => 0, + HciEvt | AclRecv | ScoRecv => 1, + _ => return None, + }) +} + +pub async fn write_pcap_header(w: &mut W) -> std::io::Result<()> { + w.write_all(&PCAP_GLOBAL_HEADER_BE).await +} + +pub async fn write_pcap_record( + w: &mut W, + ts_sec: u32, + ts_usec: u32, + kind: BtPacketKind, + h4_payload: &[u8], // starts with H4 type followed by HCI bytes +) -> std::io::Result<()> { + // Prepend 4-byte direction flag to the packet body + let Some(dir) = dir_flag(kind) else { + return Ok(()); + }; + let cap_len = 4u32 + h4_payload.len() as u32; + + // PCAP record header (big-endian fields to match magic above) + // ts_sec, ts_usec, incl_len, orig_len + let mut rec = [0u8; 16]; + rec[0..4].copy_from_slice(&be32(ts_sec)); + rec[4..8].copy_from_slice(&be32(ts_usec)); + rec[8..12].copy_from_slice(&be32(cap_len)); + rec[12..16].copy_from_slice(&be32(cap_len)); + + // Write: rec hdr, dir flag (as 4 BE bytes), then H4 bytes + w.write_all(&rec).await?; + w.write_all(&be32(dir)).await?; + w.write_all(h4_payload).await?; + Ok(()) +}