37 Commits

Author SHA1 Message Date
Cooper Quintin
ee83613757 update readme 2025-02-27 17:29:48 -08:00
Cooper Quintin
840f8ad8b0 stop before upload in case file is locked from writing by running process 2025-02-10 11:26:27 -08:00
Cooper Quintin
c9ac834ca7 show warnings in web UI 2025-02-10 11:26:27 -08:00
Cooper Quintin
8629aacf6b switch default to not see trace messages, switch arg from quiet to verbose 2025-02-10 11:26:27 -08:00
Cooper Quintin
a3fd1479f9 rename qmdl path so that downloaded files have a qmdl extension 2025-02-10 11:26:27 -08:00
Cooper Quintin
049c563f02 fix shortcodes on rayhunter_check 2025-02-10 11:26:27 -08:00
Cooper Quintin
a33b5a3418 Update README.md
Co-authored-by: Will Greenberg <willg@eff.org>
2025-01-31 17:00:44 -08:00
Cooper Quintin
107ba58296 warn if running install scritps from git tree 2025-01-31 17:00:44 -08:00
Cooper Quintin
d016279172 some tweaks to readme 2025-01-31 17:00:44 -08:00
Will Greenberg
5a084f1abb lib: set uplink flag for NAS 2025-01-30 11:33:14 -08:00
Will Greenberg
3619df32ab check: give qmdl-path a shorthand arg 2025-01-28 11:02:19 -08:00
Will Greenberg
34d87d1fd7 this macro isn't public, so docstrings won't work 2025-01-28 11:02:19 -08:00
Will Greenberg
da4952e70f fix docstring code 2025-01-28 11:02:19 -08:00
Will Greenberg
30323b8329 Keep old 2G downgrade analyzer 2025-01-28 11:02:19 -08:00
Will Greenberg
28b0f409db fix attribution 2025-01-28 11:02:19 -08:00
Will Greenberg
12640cc878 Rewrite our 2G downgrade analyzer 2025-01-28 11:02:19 -08:00
Will Greenberg
26eda5904f Better wording on IMSI requested warning 2025-01-28 11:02:19 -08:00
Will Greenberg
3e26e61b05 check: don't count informational events as warnings, better logging 2025-01-28 11:02:19 -08:00
Will Greenberg
565c0f1e67 serial: fix UTF-8 panic on macOS 2025-01-26 17:05:42 -08:00
Will Greenberg
6bd36921d8 consider early IMSI request medium sev 2025-01-08 15:23:59 -08:00
Will Greenberg
c83ae30be8 fix language 2025-01-08 15:23:59 -08:00
Will Greenberg
fa612241a5 lib: add IMSI requested heuristic 2025-01-08 15:23:59 -08:00
Will Greenberg
10592bbd9d lib: add inbound/outbound field to NAS 2025-01-06 16:24:11 -08:00
Will Greenberg
327eaddcd7 rayhunter-check: pcapify qmdl 2025-01-06 16:24:11 -08:00
Will Greenberg
32149c3b37 Update tools/nasparse.py 2024-12-17 14:46:31 -08:00
Cooper Quintin
e47d4dacc4 raise error on non nas message 2024-12-17 14:46:31 -08:00
Cooper Quintin
4009e3d1ed fix nits 2024-12-17 14:46:31 -08:00
Cooper Quintin
b2cd735a07 proof of concept pcap reader for nas heuristic 2024-12-17 14:46:31 -08:00
Cooper Quintin
94e9a88a91 PoC of python nas heuristic 2024-12-17 14:46:31 -08:00
Cooper Quintin
f4a6c834d2 remove false positive IMSI heuristic until we get a NAS parser 2024-12-09 10:53:58 -08:00
Cooper Quintin
95e8f846d3 propegate colorblind mode beyond start/stop 2024-11-26 11:05:13 -08:00
Cooper Quintin
15f128add1 remove unneeded import 2024-11-26 11:05:13 -08:00
Cooper Quintin
87f9cc403b add colorblind mode. Fixes #77 2024-11-26 11:05:13 -08:00
Cooper Quintin
7addf3a67f fix reboot timeout 2024-11-18 17:10:16 -08:00
Cooper Quintin
4d8cc9b738 Revert "name binary rayhunter-daemon"
This reverts commit 9cd5ce3394.
2024-11-18 16:16:43 -08:00
Cooper Quintin
b0d797d206 name binary rayhunter-daemon 2024-11-18 16:16:43 -08:00
Will Greenberg
1ae3b5020b fix installer script
With the odd permissions issues we've been seeing, we should use
AT_SYSCMD for all mv operations into /data
2024-11-18 16:16:43 -08:00
30 changed files with 556 additions and 66 deletions

86
Cargo.lock generated
View File

@@ -482,6 +482,16 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.52.0",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.6"
@@ -602,6 +612,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive-into-owned"
version = "0.2.0"
@@ -1310,6 +1329,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -1360,6 +1385,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.32.2"
@@ -1487,6 +1521,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@@ -1679,6 +1719,7 @@ dependencies = [
"rayhunter",
"serde",
"serde_json",
"simple_logger",
"tempfile",
"thiserror",
"tokio",
@@ -1901,6 +1942,18 @@ dependencies = [
"quote",
]
[[package]]
name = "simple_logger"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb"
dependencies = [
"colored",
"log",
"time",
"windows-sys 0.48.0",
]
[[package]]
name = "slab"
version = "0.4.9"
@@ -2065,6 +2118,39 @@ dependencies = [
"weezl",
]
[[package]]
name = "time"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tokio"
version = "1.36.0"

View File

@@ -1,46 +1,30 @@
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter
```
@@@@@@@ @@@@@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@@@@@ @@@@@@@@ @@@@@@@
@@! @@@ @@! @@@ @@! !@@ @@! @@@ @@! @@@ @@!@!@@@ @@! @@! @@! @@@
@!@!!@! @!@!@!@! !@!@! @!@!@!@! @!@ !@! @!@@!!@! @!! @!!!:! @!@!!@!
!!: :!! !!: !!! !!: !!: !!! !!: !!! !!: !!! !!: !!: !!: :!!
: : : : : : .: : : : :.:: : :: : : : :: ::: : : :
_ _ _ _ _ _ _ _
)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_
O .
O ' '
o ' .
o .'
__________.-' '...___
.-' ### '''...__
/ a### ## ''--.._ ______
'. # ######## ' .-'
'-._ ..**********#### ___...---'''\ '
'-._ __________...---''' \ l
\ | apc '._|
\__;
```
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg)
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot.
**THIS CODE IS PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS**
## The Hardware
Code is built and tested for the Orbic RC400L mobile hotspot, it may work on other orbics and other
linux/qualcom devices but this is the only one we have tested on. Buy the orbic [using bezos bucks](https://www.amazon.com/gp/product/B09CLS6Z7X/)
linux/qualcom devices but this is the only one we have tested on.
Buy the orbic [using bezos bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y)
Or on [Ebay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l)
## Setup
*NOTE: We don't currently support automated installs on windows, you will have to follow the manual install instructions below*
1. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and extract it.
2. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
**If you are installing from the cloned github repository please see the development instructions below, running `install-linux.sh` from the git tree will not work.**
2. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`). The Linux installer has only been tested on the latest version of Ubuntu. If it fails you will need to follow the install steps outlined in **Development** below.
3. Once finished, rayhunter should be running! You can verify this by visiting the web UI as described below.
## Usage
Once installed, rayhunter will run automatically whenever your Orbic device is running. It serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, and view heuristic analyses of captures. You can access this UI in one of two ways:

View File

@@ -32,3 +32,4 @@ clap = { version = "4.5.2", features = ["derive"] }
serde_json = "1.0.114"
image = "0.25.1"
tempfile = "3.10.1"
simple_logger = "5.0.0"

View File

@@ -1,5 +1,6 @@
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
use log::{info, warn};
use rayhunter::{analysis::analyzer::{EventType, Harness}, diag::DataType, gsmtap_parser, pcap::GsmtapPcapWriter, qmdl::QmdlReader};
use tokio::fs::{metadata, read_dir, File};
use clap::Parser;
use futures::TryStreamExt;
@@ -9,14 +10,20 @@ mod dummy_analyzer;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(short, long)]
#[arg(short = 'p', long)]
qmdl_path: PathBuf,
#[arg(short = 'c', long)]
pcapify: bool,
#[arg(long)]
show_skipped: bool,
#[arg(long)]
enable_dummy_analyzer: bool,
#[arg(short, long)]
verbose: bool,
}
async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool) {
@@ -38,34 +45,81 @@ async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool
}
for analysis in row.analysis {
for maybe_event in analysis.events {
if let Some(event) = maybe_event {
warnings += 1;
println!("{}: {:?}", analysis.timestamp, event);
let Some(event) = maybe_event else { continue };
match event.event_type {
EventType::Informational => {
info!(
"{}: INFO - {} {}",
qmdl_path,
analysis.timestamp,
event.message,
);
}
EventType::QualitativeWarning { severity } => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
qmdl_path,
severity,
analysis.timestamp,
event.message,
);
warnings += 1;
}
}
}
}
}
if show_skipped && skipped > 0 {
println!("{}: messages skipped:", qmdl_path);
info!("{}: messages skipped:", qmdl_path);
for (reason, count) in skipped_reasons.iter() {
println!(" - {}: \"{}\"", count, reason);
info!(" - {}: \"{}\"", count, reason);
}
}
println!("{}: {} messages analyzed, {} warnings, {} messages skipped", qmdl_path, total_messages, warnings, skipped);
info!("{}: {} messages analyzed, {} warnings, {} messages skipped", qmdl_path, total_messages, warnings, skipped);
}
async fn pcapify(qmdl_path: &PathBuf) {
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open qmdl file");
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
let mut pcap_path = qmdl_path.clone();
pcap_path.set_extension("pcap");
let pcap_file = &mut File::create(&pcap_path).await.expect("failed to open pcap file");
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
while let Some(container) = qmdl_reader.get_next_messages_container().await.expect("failed to get container") {
for maybe_msg in container.into_messages() {
if let Ok(msg) = maybe_msg {
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
pcap_writer.write_gsmtap_message(parsed, timestamp).await.expect("failed to write");
}
}
}
}
info!("wrote pcap to {:?}", &pcap_path);
}
#[tokio::main]
async fn main() {
env_logger::init();
let args = Args::parse();
let level = if args.verbose {
log::LevelFilter::Trace
} else {
log::LevelFilter::Warn
};
simple_logger::SimpleLogger::new()
.with_colors(true)
.without_timestamps()
.with_level(level)
.init().unwrap();
let mut harness = Harness::new_with_all_analyzers();
if args.enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}
println!("Analyzers:");
info!("Analyzers:");
for analyzer in harness.get_metadata().analyzers {
println!(" - {}: {}", analyzer.name, analyzer.description);
info!(" - {}: {}", analyzer.name, analyzer.description);
}
let metadata = metadata(&args.qmdl_path).await.expect("failed to get metadata");
@@ -75,10 +129,19 @@ async fn main() {
let name = entry.file_name();
let name_str = name.to_str().unwrap();
if name_str.ends_with(".qmdl") {
analyze_file(&mut harness, entry.path().to_str().unwrap(), args.show_skipped).await;
let path = entry.path();
let path_str = path.to_str().unwrap();
analyze_file(&mut harness, path_str, args.show_skipped).await;
if args.pcapify {
pcapify(&path).await;
}
}
}
} else {
analyze_file(&mut harness, args.qmdl_path.to_str().unwrap(), args.show_skipped).await;
let path = args.qmdl_path.to_str().unwrap();
analyze_file(&mut harness, path, args.show_skipped).await;
if args.pcapify {
pcapify(&args.qmdl_path).await;
}
}
}

View File

@@ -9,6 +9,7 @@ struct ConfigFile {
debug_mode: Option<bool>,
ui_level: Option<u8>,
enable_dummy_analyzer: Option<bool>,
colorblind_mode: Option<bool>,
}
#[derive(Debug)]
@@ -18,6 +19,7 @@ pub struct Config {
pub debug_mode: bool,
pub ui_level: u8,
pub enable_dummy_analyzer: bool,
pub colorblind_mode: bool,
}
impl Default for Config {
@@ -28,6 +30,7 @@ impl Default for Config {
debug_mode: false,
ui_level: 1,
enable_dummy_analyzer: false,
colorblind_mode: false,
}
}
}
@@ -42,6 +45,7 @@ pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError> where P: AsRef
parsed_config.debug_mode.map(|v| config.debug_mode = v);
parsed_config.ui_level.map(|v| config.ui_level = v);
parsed_config.enable_dummy_analyzer.map(|v| config.enable_dummy_analyzer = v);
parsed_config.colorblind_mode.map(|v| config.colorblind_mode = v);
}
Ok(config)
}

View File

@@ -59,6 +59,7 @@ async fn run_server(
debug_mode: config.debug_mode,
analysis_status_lock,
analysis_sender,
colorblind_mode: config.colorblind_mode,
});
let app = Router::new()
@@ -142,12 +143,17 @@ fn run_ctrl_c_thread(
fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>, mut ui_update_rx: Receiver<framebuffer::DisplayState>) -> JoinHandle<()> {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/");
let mut display_color: framebuffer::Color565;
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
let mut display_color = framebuffer::Color565::Green;
if config.colorblind_mode {
display_color = framebuffer::Color565::Blue;
} else {
display_color = framebuffer::Color565::Green;
}
task_tracker.spawn_blocking(move || {
let mut fb: Framebuffer = Framebuffer::new();

View File

@@ -129,8 +129,16 @@ pub async fn start_recording(State(state): State<Arc<ServerState>>) -> Result<(S
let qmdl_writer = QmdlWriter::new(qmdl_file);
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StartRecording((qmdl_writer, analysis_file))).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?;
state.ui_update_sender.send(framebuffer::DisplayState::Recording).await
let display_state: framebuffer::DisplayState;
if state.colorblind_mode {
display_state = framebuffer::DisplayState::RecordingCBM;
} else {
display_state = framebuffer::DisplayState::Recording;
}
state.ui_update_sender.send(display_state).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}

View File

@@ -27,6 +27,7 @@ pub enum DisplayState {
Recording,
Paused,
WarningDetected,
RecordingCBM,
}
impl From<DisplayState> for Color565 {
@@ -34,6 +35,7 @@ impl From<DisplayState> for Color565 {
match state {
DisplayState::Paused => Color565::White,
DisplayState::Recording => Color565::Green,
DisplayState::RecordingCBM => Color565::Blue,
DisplayState::WarningDetected => Color565::Red,
}
}

View File

@@ -22,13 +22,15 @@ pub struct ServerState {
pub ui_update_sender: Sender<framebuffer::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub debug_mode: bool
pub debug_mode: bool,
pub colorblind_mode: bool,
}
pub async fn get_qmdl(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name)
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx)
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_idx)))?;
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("error opening QMDL file: {}", e)))?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);

View File

@@ -80,6 +80,11 @@ async function updateEntryAnalysisResult(entry) {
entry.analysis_result = `0 warnings!`;
} else {
entry.analysis_result = `!!! ${entry.analysis.warnings.length} warnings !!!`;
for(warning of entry.analysis.warnings){
msg = `${warning.timestamp}: ${warning.warning.events[1].message}`
console.log(msg)
entry.analysis_result += `<br>${msg}`
}
}
}
@@ -136,11 +141,11 @@ function createEntryRow(entry, isCurrent) {
row.appendChild(pcapTd);
const qmdlTd = document.createElement('td');
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}`, 'qmdl'));
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}.qmdl`, 'qmdl'));
row.appendChild(qmdlTd);
const analysisResult = document.createElement('td');
analysisResult.innerText = entry.analysis_result;
analysisResult.innerHTML = entry.analysis_result;
if (entry.analysis.warnings.length > 0) {
row.classList.add("warning");
}

View File

@@ -1,6 +1,9 @@
# cat config.toml
qmdl_store_path = "/data/rayhunter/qmdl"
port = 8080
debug_mode = false
enable_dummy_analyzer = false
colorblind_mode = false
# UI Levels:
# 0 = invisible mode, no indicator that rayhunter is running
# 1 = Subtle mode, display a green line at the top of the screen when rayhunter is running

View File

@@ -65,16 +65,20 @@ _at_syscmd() {
setup_rayhunter() {
_at_syscmd "mkdir -p /data/rayhunter"
_adb_push config.toml.example /data/rayhunter/config.toml
_adb_push rayhunter-daemon /data/rayhunter/
_adb_push config.toml.example /tmp/config.toml
_at_syscmd "mv /tmp/config.toml /data/rayhunter"
_adb_push rayhunter-daemon /tmp/rayhunter-daemon
_at_syscmd "mv /tmp/rayhunter-daemon /data/rayhunter"
_adb_push scripts/rayhunter_daemon /tmp/rayhunter_daemon
_at_syscmd "mv /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"
_adb_push scripts/misc-daemon /tmp/misc-daemon
_at_syscmd "cp /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"
_at_syscmd "cp /tmp/misc-daemon /etc/init.d/misc-daemon"
_at_syscmd "mv /tmp/misc-daemon /etc/init.d/misc-daemon"
_at_syscmd "chmod 755 /etc/init.d/rayhunter_daemon"
_at_syscmd "chmod 755 /etc/init.d/misc-daemon"
echo -n "waiting for reboot..."
_at_syscmd reboot
_at_syscmd "shutdown -r -t 1 now"
# first wait for shutdown (it can take ~10s)
until ! _adb_shell true 2> /dev/null

View File

@@ -1,6 +1,13 @@
#!/bin/env bash
set -e
export SERIAL_PATH="./serial-ubuntu-latest/serial"
if [ ! -x "$SERIAL_PATH" ]; then
echo "The serial binary cannot be found at $SERIAL_PATH. If you are running this from the git tree please instead run it from the latest release bundle https://github.com/EFForg/rayhunter/releases"
exit 1
fi
if ! command -v adb &> /dev/null; then
if [ ! -d ./platform-tools ] ; then
echo "adb not found, downloading local copy"
@@ -12,6 +19,5 @@ else
export ADB=`which adb`
fi
export SERIAL_PATH="./serial-ubuntu-latest/serial"
. "$(dirname "$0")"/install-common.sh
install

9
dist/install-mac.sh vendored
View File

@@ -1,6 +1,12 @@
#!/usr/bin/env bash
set -e
export SERIAL_PATH="./serial-macos-latest/serial"
if [ ! -x "$SERIAL_PATH" ]; then
echo "The serial binary cannot be found at $SERIAL_PATH. If you are running this from the git tree please instead run it from the latest release bundle at https://github.com/EFForg/rayhunter/releases"
exit 1
fi
if ! command -v adb &> /dev/null; then
if [ ! -d ./platform-tools ]; then
echo "adb not found, downloading local copy"
@@ -12,6 +18,5 @@ else
export ADB=`which adb`
fi
export SERIAL_PATH="./serial-macos-latest/serial"
. "$(dirname "$0")"/install-common.sh
install

View File

@@ -4,7 +4,13 @@ use serde::Serialize;
use crate::{diag::MessagesContainer, gsmtap_parser};
use super::{imsi_provided::ImsiProvidedAnalyzer, information_element::InformationElement, lte_downgrade::LteSib6And7DowngradeAnalyzer, null_cipher::NullCipherAnalyzer};
use super::{
imsi_requested::ImsiRequestedAnalyzer,
information_element::InformationElement,
connection_redirect_downgrade::ConnectionRedirect2GDowngradeAnalyzer,
priority_2g_downgrade::LteSib6And7DowngradeAnalyzer,
null_cipher::NullCipherAnalyzer,
};
/// Qualitative measure of how severe a Warning event type is.
/// The levels should break down like this:
@@ -18,7 +24,7 @@ pub enum Severity {
High,
}
/// [QualitativeWarning] events will always be shown to the user in some manner,
/// `QualitativeWarning` events will always be shown to the user in some manner,
/// while `Informational` ones may be hidden based on user settings.
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type")]
@@ -112,8 +118,9 @@ impl Harness {
pub fn new_with_all_analyzers() -> 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{}));
harness.add_analyzer(Box::new(ImsiProvidedAnalyzer{}));
harness.add_analyzer(Box::new(NullCipherAnalyzer{}));
harness

View File

@@ -0,0 +1,42 @@
use std::borrow::Cow;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement};
use telcom_parser::lte_rrc::{DL_DCCH_Message, DL_DCCH_MessageType, DL_DCCH_MessageType_c1, RRCConnectionReleaseCriticalExtensions, RRCConnectionReleaseCriticalExtensions_c1, RedirectedCarrierInfo};
use super::util::unpack;
// Based on HITBSecConf presentation "Forcing a targeted LTE cellphone into an
// eavesdropping network" by Lin Huang
pub struct ConnectionRedirect2GDowngradeAnalyzer {
}
// TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones
impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer {
fn get_name(&self) -> Cow<str> {
Cow::from("Connection Release/Redirected Carrier 2G Downgrade")
}
fn get_description(&self) -> Cow<str> {
Cow::from("Tests if a cell releases our connection and redirects us to a 2G cell.")
}
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
unpack!(InformationElement::LTE(lte_ie) = ie);
unpack!(LteInformationElement::DlDcch(DL_DCCH_Message { message }) = lte_ie);
unpack!(DL_DCCH_MessageType::C1(c1) = message);
unpack!(DL_DCCH_MessageType_c1::RrcConnectionRelease(release) = c1);
unpack!(RRCConnectionReleaseCriticalExtensions::C1(c1) = &release.critical_extensions);
unpack!(RRCConnectionReleaseCriticalExtensions_c1::RrcConnectionRelease_r8(r8_ies) = c1);
unpack!(Some(carrier_info) = &r8_ies.redirected_carrier_info);
match carrier_info {
RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event {
event_type: EventType::QualitativeWarning { severity: Severity::High },
message: format!("Detected 2G downgrade"),
}),
_ => Some(Event {
event_type: EventType::Informational,
message: format!("RRCConnectionRelease CarrierInfo: {:?}", carrier_info),
}),
}
}
}

View File

@@ -0,0 +1,59 @@
use std::borrow::Cow;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement};
const PACKET_THRESHHOLD: usize = 150;
pub struct ImsiRequestedAnalyzer {
packet_num: usize,
}
impl ImsiRequestedAnalyzer {
pub fn new() -> Self {
Self { packet_num: 0 }
}
}
impl Analyzer for ImsiRequestedAnalyzer {
fn get_name(&self) -> Cow<str> {
Cow::from("IMSI Requested")
}
fn get_description(&self) -> Cow<str> {
Cow::from("Tests whether the ME sends an IMSI Identity Request NAS message")
}
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
self.packet_num += 1;
let InformationElement::LTE(LteInformationElement::NAS(payload)) = ie else {
return None;
};
// NAS identity request, ID type IMSI
if payload == &[0x07, 0x55, 0x01] {
if self.packet_num < PACKET_THRESHHOLD {
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::Medium
},
message: format!(
"NAS IMSI identity request detected, however it was within \
the first {} packets of this analysis. If you just \
turned your device on, this is likely a \
false-positive.",
PACKET_THRESHHOLD
)
})
} else {
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High
},
message: format!("NAS IMSI identity request detected"),
})
}
}
None
}
}

View File

@@ -5,7 +5,7 @@
use telcom_parser::{decode, lte_rrc};
use thiserror::Error;
use crate::gsmtap::{GsmtapType, LteRrcSubtype, GsmtapMessage};
use crate::gsmtap::{GsmtapMessage, GsmtapType, LteNasSubtype, LteRrcSubtype};
#[derive(Error, Debug)]
pub enum InformationElementError {
@@ -40,6 +40,9 @@ pub enum LteInformationElement {
SbcchSlBch(lte_rrc::SBCCH_SL_BCH_Message),
SbcchSlBchV2x(lte_rrc::SBCCH_SL_BCH_Message_V2X_r14),
// FIXME: actually parse NAS messages
NAS(Vec<u8>),
// FIXME: unclear which message these "NB" types map to
//DlCcchNb(),
//DlDcchNb(),
@@ -79,6 +82,9 @@ impl TryFrom<&GsmtapMessage> for InformationElement {
};
Ok(InformationElement::LTE(lte))
},
GsmtapType::LteNas(LteNasSubtype::Plain) => {
Ok(InformationElement::LTE(LteInformationElement::NAS(gsmtap_msg.payload.clone())))
},
_ => Err(InformationElementError::UnsupportedGsmtapType(gsmtap_msg.header.gsmtap_type)),
}
}

View File

@@ -1,5 +1,8 @@
pub mod analyzer;
pub mod information_element;
pub mod lte_downgrade;
pub mod priority_2g_downgrade;
pub mod connection_redirect_downgrade;
pub mod imsi_provided;
pub mod imsi_requested;
pub mod null_cipher;
pub mod util;

32
lib/src/analysis/util.rs Normal file
View File

@@ -0,0 +1,32 @@
// Unpacks a pattern, or returns None.
//
// # Examples
// You can use `unpack!` to unroll highly nested enums like this:
// ```
// enum Foo {
// A(Bar),
// B,
// }
//
// enum Bar {
// C(Baz)
// }
//
// struct Baz;
//
// fn get_bang(foo: Foo) -> Option<Baz> {
// unpack!(Foo::A(bar) = foo);
// unpack!(Bar::C(baz) = bar);
// baz
// }
// ```
//
macro_rules! unpack {
($pat:pat = $val:expr) => {
let $pat = $val else { return None; };
};
}
// this is apparently how you make a macro publicly usable from this module
pub(crate) use unpack;

View File

@@ -183,6 +183,8 @@ pub enum LogBody {
// * 0xb0ed: plain EMM NAS message (outgoing)
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
Nas4GMessage {
#[deku(ctx = "log_type")]
direction: Nas4GMessageDirection,
ext_header_version: u8,
rrc_rel: u8,
rrc_version_minor: u8,
@@ -211,6 +213,19 @@ pub enum LogBody {
}
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16", id = "log_type")]
pub enum Nas4GMessageDirection {
// * 0xb0e2: plain ESM NAS message (incoming)
// * 0xb0e3: plain ESM NAS message (outgoing)
// * 0xb0ec: plain EMM NAS message (incoming)
// * 0xb0ed: plain EMM NAS message (outgoing)
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
Downlink,
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
Uplink,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
pub enum LteRrcOtaPacket {

View File

@@ -17,7 +17,7 @@ pub enum GsmtapType {
UmtsRlcMac,
UmtsRrc(UmtsRrcSubtype),
LteRrc(LteRrcSubtype), /* LTE interface */
LteMac, /* LTE MAC interface */
LteMac, /* LTE MAC interface */
LteMacFramed, /* LTE MAC with context hdr */
OsmocoreLog, /* libosmocore logging */
QcDiag, /* Qualcomm DIAG frame */
@@ -200,6 +200,11 @@ pub struct GsmtapHeader {
#[deku(update = "self.gsmtap_type.get_type()")]
pub packet_type: u8,
pub timeslot: u8,
#[deku(bits = 1)]
pub pcs_band_indicator: bool,
#[deku(bits = 1)]
pub uplink: bool,
#[deku(bits = 14)]
pub arfcn: u16,
pub signal_dbm: i8,
pub signal_noise_ratio_db: u8,
@@ -222,6 +227,8 @@ impl GsmtapHeader {
header_len: 4,
packet_type: gsmtap_type.get_type(),
timeslot: 0,
pcs_band_indicator: false,
uplink: false,
arfcn: 0,
signal_dbm: 0,
signal_noise_ratio_db: 0,

View File

@@ -99,7 +99,6 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
_ => return Err(GsmtapParserError::InvalidLteRrcOtaExtHeaderVersion(ext_header_version)),
};
let mut header = GsmtapHeader::new(gsmtap_type);
// Wireshark GSMTAP only accepts 14 bits of ARFCN
header.arfcn = packet.get_earfcn().try_into().unwrap_or(0);
header.frame_number = packet.get_sfn();
header.subslot = packet.get_subfn();
@@ -108,9 +107,10 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
payload: packet.take_payload(),
}))
},
LogBody::Nas4GMessage { msg, .. } => {
LogBody::Nas4GMessage { msg, direction, .. } => {
// currently we only handle "plain" (i.e. non-secure) NAS messages
let header = GsmtapHeader::new(GsmtapType::LteNas(LteNasSubtype::Plain));
let mut header = GsmtapHeader::new(GsmtapType::LteNas(LteNasSubtype::Plain));
header.uplink = matches!(direction, Nas4GMessageDirection::Uplink);
Ok(Some(GsmtapMessage {
header,
payload: msg,

View File

@@ -60,7 +60,7 @@ impl<T> QmdlReader<T> where T: AsyncRead + Unpin {
})
}
async fn get_next_messages_container(&mut self) -> Result<Option<MessagesContainer>, std::io::Error> {
pub async fn get_next_messages_container(&mut self) -> Result<Option<MessagesContainer>, std::io::Error> {
if let Some(max_bytes) = self.max_bytes {
if self.bytes_read >= max_bytes {
if self.bytes_read > max_bytes {

View File

@@ -1,4 +1,5 @@
#!/bin/sh
cargo build --release --target="armv7-unknown-linux-gnueabihf" #--features debug
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"'
adb push target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon /data/rayhunter/rayhunter-daemon
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon restart"'
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon start"'

View File

@@ -78,7 +78,10 @@ fn send_command<T: UsbContext>(handle: &mut DeviceHandle<T>, command: &str) {
.read_bulk(0x82, &mut response, timeout)
.expect("Failed to read response");
let responsestr = str::from_utf8(&response).expect("Failed to parse response");
// For some reason, on macOS the response buffer gets filled with garbage data that's
// rarely valid UTF-8. Luckily we only care about the first couple bytes, so just drop
// the garbage with `from_utf8_lossy` and look for our expected success string.
let responsestr = String::from_utf8_lossy(&response);
if !responsestr.contains("\r\nOK\r\n") {
println!("Received unexpected response{0}", responsestr);
std::process::exit(1);

60
tools/nasparse.py Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/python3
import pycrate_mobile
from pycrate_mobile import NASLTE
import pycrate_core
import binascii
import sys
import pprint
from enum import Enum
import pycrate_mobile.TS24301_EMM
EPS_IMSI_ATTACH = 2
def parse_nas_message(buffer, uplink=None):
if isinstance(buffer, str): #handle string argument or raw bytes
bin = binascii.unhexlify(buffer)
else:
bin = buffer
if uplink:
parsed = NASLTE.parse_NASLTE_MO(bin)
elif uplink == None: #We don't know if its an up or downlink
parsed = NASLTE.parse_NASLTE_MO(bin)
if parsed[0] == None:
parsed = NASLTE.parse_NASLTE_MT(bin)
else:
parsed = NASLTE.parse_NASLTE_MT(bin)
if parsed[0] is None: # Not a NAS Packet
raise TypeError("Not a nas packet")
return parsed[0]
def heur_ue_imsi_sent(msg):
output = "device transmitted IMSI to base station!"
if type(msg) not in [pycrate_mobile.TS24301_EMM.EMMAttachRequest, pycrate_mobile.TS24301_EMM.EMMSecProtNASMessage]:
return (False, None)
if isinstance(msg, pycrate_mobile.TS24301_EMM.EMMSecProtNASMessage):
try:
msg = msg['EMMAttachRequest']
except pycrate_core.elt.EltErr:
return (False, None)
if msg['EPSAttachType']['V'].to_int() == EPS_IMSI_ATTACH: #EPSAttachType Value is 'Combined EPS/IMSI Attach (2)'
return (True, output)
return (False, None)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: nasparse.py [hex encoded nas message]")
exit(1)
buffer = sys.argv[1]
msg = parse_nas_message(buffer)
pprint.pprint(msg)
triggered, message = heur_ue_imsi_sent(msg)
if triggered:
print(message)
exit(1)

38
tools/nasparse_test.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/python3
import unittest
import nasparse
class TestNasparse(unittest.TestCase):
imsi_sent_msg = '07412208391185184409309005f0700000100030023ed031d127298080211001000010810600000000830600000000000d00000300ff0003130184000a000005000010005c0a009011034f18a6f15d0103c1000000000000'
sec_imsi_sent_msg = '1727db4b7c0207412208391185184409309005f0700000100030023ed031d127298080211001000010810600000000830600000000000d00000300ff0003130184000a000005000010005c0a009011034f18a6f15d0103c1'
non_nas_msg = 'deadbeefcafe'
other_nas_msg = '074413780004023fd121'
other_nas_mt_msg = "023fd12100000000000000000000000000000000000000000000000000000000"
ciphered_nas_msg = "27ed6146bd0162a5d62d62e1ce501720dc8bd84f1167fd"
def run_heur(self, msg):
buf = nasparse.parse_nas_message(msg)
return nasparse.heur_ue_imsi_sent(buf)[0]
def test_imsi_sent(self):
self.assertEqual(self.run_heur(self.imsi_sent_msg), True, "imsi_sent_msg should trigger heuristic")
def test_sec_imsi_sent(self):
self.assertEqual(self.run_heur(self.imsi_sent_msg), True, "sec_imsi_sent_msg should trigger heuristic")
def test_non_nas_msg(self):
with self.assertRaises(TypeError):
self.run_heur(self.non_nas_msg)
def test_other_nas(self):
self.assertEqual(self.run_heur(self.other_nas_msg), False, "other_nas_msg should not trigger heuristic")
def test_other_nas_mt(self):
self.assertEqual(self.run_heur(self.other_nas_mt_msg), False, "other_nas_mt_msg should not trigger heuristic")
def test_ciphered_nas(self):
self.assertEqual(self.run_heur(self.ciphered_nas_msg), False, "ciphered_nas_msg should not trigger heuristic")
if __name__ == '__main__':
unittest.main()

38
tools/pcap_check.py Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/python3
import nasparse
from scapy.utils import RawPcapNgReader
import sys
TYPE_LTE_NAS = 0x12
UDP_LEN = 28
def process_pcap(pcap_path):
print('Opening {}...'.format(pcap_path))
count = 0
for pkt_data, pkt_metadata in RawPcapNgReader(pcap_path):
count += 1
gsmtap_len = pkt_data[UDP_LEN+1] * 4 # gsmtap header length is stored in the 2nd byte of GSMTAP as a number of 32 bit words
header_end = gsmtap_len + UDP_LEN #length of UDP/IP header plus GSMTAP header
gsmtap_hdr = pkt_data[UDP_LEN:header_end]
if gsmtap_hdr[2] != TYPE_LTE_NAS:
continue
# uplink status is the 7th bit of the 5th byte of the GSMTAP header.
# Uplink (Mobile originated) = 0 Downlink (mobile terminated) = 1
uplink = (gsmtap_hdr[4] & 0b01000000) >> 6
buffer = pkt_data[header_end:]
msg = nasparse.parse_nas_message(buffer, uplink)
triggered, message = nasparse.heur_ue_imsi_sent(msg)
if triggered:
print(f"Frame {count} triggered heuristic: {message}")
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: pcap_check.py [path/to/pcap/file]")
exit(1)
pcap_path = sys.argv[1]
process_pcap(pcap_path)