├── lib ├── src │ ├── analysis │ │ ├── util.rs │ │ ├── mod.rs │ │ ├── incomplete_sib.rs │ │ ├── nas_null_cipher.rs │ │ ├── connection_redirect_downgrade.rs │ │ └── test_analyzer.rs │ ├── lib.rs │ ├── util.rs │ ├── log_codes.rs │ └── hdlc.rs └── Cargo.toml ├── daemon ├── web │ ├── .npmrc │ ├── src │ │ ├── routes │ │ │ ├── +layout.js │ │ │ └── +layout.svelte │ │ ├── lib │ │ │ ├── index.ts │ │ │ ├── components │ │ │ │ ├── DeleteAllButton.svelte │ │ │ │ ├── DownloadLink.svelte │ │ │ │ ├── DeleteButton.svelte │ │ │ │ ├── ManifestTable.svelte │ │ │ │ ├── ReAnalyzeButton.svelte │ │ │ │ ├── RecordingControls.svelte │ │ │ │ ├── AnalysisView.svelte │ │ │ │ ├── ManifestTableRow.svelte │ │ │ │ ├── ApiRequestButton.svelte │ │ │ │ ├── LogView.svelte │ │ │ │ └── AnalysisStatus.svelte │ │ │ ├── systemStats.ts │ │ │ ├── action_errors.svelte.ts │ │ │ ├── ndjson.ts │ │ │ ├── stores │ │ │ │ └── breakpoint.ts │ │ │ ├── ndjson.spec.ts │ │ │ ├── analysisManager.svelte.ts │ │ │ ├── analysis.svelte.spec.ts │ │ │ ├── utils.svelte.ts │ │ │ └── manifest.svelte.ts │ │ ├── app.css │ │ ├── theme.ts │ │ ├── app.d.ts │ │ └── app.html │ ├── static │ │ ├── favicon.png │ │ ├── rayhunter_text.png │ │ └── rayhunter_orca_only.png │ ├── postcss.config.js │ ├── .prettierignore │ ├── .gitignore │ ├── .prettierrc │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── svelte.config.js │ ├── eslint.config.js │ ├── package.json │ └── vite.config.ts ├── images │ ├── eff.png │ └── orca.gif ├── src │ ├── display │ │ ├── headless.rs │ │ ├── mod.rs │ │ ├── tplink.rs │ │ ├── orbic.rs │ │ ├── wingtech.rs │ │ ├── tplink_framebuffer.rs │ │ ├── tmobile.rs │ │ └── uz801.rs │ ├── battery │ │ ├── tmobile.rs │ │ ├── wingtech.rs │ │ ├── orbic.rs │ │ └── tplink.rs │ ├── error.rs │ ├── config.rs │ └── pcap.rs └── Cargo.toml ├── .gitignore ├── installer-gui ├── .prettierignore ├── src-tauri │ ├── build.rs │ ├── icons │ │ ├── icon.ico │ │ ├── icon.png │ │ ├── 32x32.png │ │ ├── 64x64.png │ │ ├── icon.icns │ │ ├── 128x128.png │ │ ├── StoreLogo.png │ │ ├── 128x128@2x.png │ │ ├── Square30x30Logo.png │ │ ├── Square44x44Logo.png │ │ ├── Square71x71Logo.png │ │ ├── Square89x89Logo.png │ │ ├── Square107x107Logo.png │ │ ├── Square142x142Logo.png │ │ ├── Square150x150Logo.png │ │ ├── Square284x284Logo.png │ │ └── Square310x310Logo.png │ ├── .gitignore │ ├── src │ │ ├── main.rs │ │ └── lib.rs │ ├── capabilities │ │ └── default.json │ ├── Cargo.toml │ └── tauri.conf.json ├── static │ ├── favicon.png │ └── rayhunter_text.png ├── src │ ├── routes │ │ ├── +layout.svelte │ │ └── +layout.ts │ ├── app.css │ └── app.html ├── .gitignore ├── .prettierrc ├── svelte.config.js ├── tsconfig.json ├── vite.config.js ├── eslint.config.js ├── package.json └── README.md ├── tools ├── .gitignore ├── devenv.dockerfile ├── requirements.txt ├── run-docker-devenv ├── README.md ├── pcap_check.py ├── nasparse_test.py ├── nasparse.py └── asn1grep.py ├── doc ├── Rayhunter_0.5.0.png ├── rayhunter_config.png ├── analyzing-a-capture.md ├── updating-rayhunter.md ├── tplink-m7310.md ├── installation.md ├── SUMMARY.md ├── support-feedback-community.md ├── moxee.md ├── uninstalling.md ├── introduction.md ├── reanalyzing.md ├── supported-devices.md ├── orbic.md ├── pinephone.md ├── using-rayhunter.md ├── tmobile-tmohs1.md ├── configuration.md ├── faq.md ├── tplink-m7350.md ├── installing-from-release.md └── wingtech-ct2mhs01.md ├── .git-blame-ignore-revs ├── CODE_OF_CONDUCT.md ├── installer ├── src │ ├── main.rs │ ├── orbic_auth.rs │ ├── tmobile.rs │ └── output.rs ├── build.rs └── Cargo.toml ├── SECURITY.md ├── book.toml ├── rootshell ├── Cargo.toml └── src │ └── main.rs ├── make.sh ├── .gitattributes ├── telcom-parser ├── Cargo.toml ├── tests │ └── lte_rrc_test.rs ├── src │ └── lib.rs ├── README.md └── specs │ └── PC5-RRC-Definitions.asn ├── .cargo ├── audit.toml └── config.toml ├── check └── Cargo.toml ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug.yaml │ ├── feature.yaml │ └── installer-bug.yaml └── workflows │ └── release.yml ├── docker_make.sh ├── Cargo.toml ├── dist ├── scripts │ ├── rayhunter_daemon │ └── misc-daemon └── config.toml.in ├── logo └── text.svg ├── README.md └── CONTRIBUTING.md /lib/src/analysis/util.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /daemon/web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /book 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /installer-gui/.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .cache 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /daemon/web/src/routes/+layout.js: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /daemon/images/eff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/daemon/images/eff.png -------------------------------------------------------------------------------- /daemon/images/orca.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/daemon/images/orca.gif -------------------------------------------------------------------------------- /doc/Rayhunter_0.5.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/doc/Rayhunter_0.5.0.png -------------------------------------------------------------------------------- /doc/rayhunter_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/doc/rayhunter_config.png -------------------------------------------------------------------------------- /daemon/web/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 9fe75ac961c57e508bf7488ce51d596750fa8d37 2 | 76ffdf6bada515c9a5f63a600e6f1502288c147a 3 | -------------------------------------------------------------------------------- /daemon/web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/daemon/web/static/favicon.png -------------------------------------------------------------------------------- /tools/devenv.dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.86-bullseye 2 | 3 | RUN rustup target add armv7-unknown-linux-musleabihf 4 | -------------------------------------------------------------------------------- /doc/analyzing-a-capture.md: -------------------------------------------------------------------------------- 1 | # How we analyze a capture 2 | 3 | Teams of highly trained squirrels. Video coming soon! -------------------------------------------------------------------------------- /installer-gui/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/static/favicon.png -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project is governed by [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode). 2 | -------------------------------------------------------------------------------- /daemon/web/static/rayhunter_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/daemon/web/static/rayhunter_text.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /tools/requirements.txt: -------------------------------------------------------------------------------- 1 | asn1tools==0.166.0 2 | bitstruct==8.19.0 3 | diskcache==5.6.3 4 | pycrate==0.7.8 5 | pyparsing==3.1.2 6 | -------------------------------------------------------------------------------- /daemon/web/src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /installer-gui/static/rayhunter_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/static/rayhunter_text.png -------------------------------------------------------------------------------- /daemon/web/static/rayhunter_orca_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/daemon/web/static/rayhunter_orca_only.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Tauri 2 | # will have schema files for capabilities auto-completion 3 | /gen/schemas 4 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /daemon/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /daemon/web/.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | 6 | # Static Assets 7 | static/pico.min.css 8 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /installer-gui/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EFForg/rayhunter/HEAD/installer-gui/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /installer-gui/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {@render children()} 7 | -------------------------------------------------------------------------------- /daemon/web/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {@render children()} 7 | -------------------------------------------------------------------------------- /installer-gui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /installer-gui/src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @theme { 4 | --color-rayhunter-blue: #4e4eb1; 5 | --color-rayhunter-dark-blue: #3f3da0; 6 | --color-rayhunter-green: #94ea18; 7 | } 8 | -------------------------------------------------------------------------------- /installer/src/main.rs: -------------------------------------------------------------------------------- 1 | #[tokio::main(flavor = "current_thread")] 2 | async fn main() { 3 | if let Err(e) = installer::main_cli().await { 4 | eprintln!("{e:?}"); 5 | std::process::exit(1); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Security vulnerabilities can be reported using GitHub's [private vulnerability reporting tool](https://github.com/EFForg/rayhunter/security/advisories/new). 6 | -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["The Rayhunter Team"] 3 | language = "en" 4 | src = "doc" 5 | title = "Rayhunter - An IMSI Catcher Catcher" 6 | 7 | [output.html] 8 | edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}" 9 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | installer_gui_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": ["core:default", "opener:default"] 7 | } 8 | -------------------------------------------------------------------------------- /installer-gui/src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | // Tauri doesn't have a Node.js server to do proper SSR 2 | // so we will use adapter-static to prerender the app (SSG) 3 | // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info 4 | export const prerender = true; 5 | export const ssr = false; 6 | -------------------------------------------------------------------------------- /rootshell/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rootshell" 3 | version = "0.8.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | nix = { version = "0.29.0", features = ["user"] } 10 | -------------------------------------------------------------------------------- /daemon/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Env 14 | .env 15 | .env.* 16 | !.env.example 17 | !.env.test 18 | 19 | # Vite 20 | vite.config.js.timestamp-* 21 | vite.config.ts.timestamp-* 22 | -------------------------------------------------------------------------------- /lib/src/analysis/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod analyzer; 2 | pub mod connection_redirect_downgrade; 3 | pub mod imsi_requested; 4 | pub mod incomplete_sib; 5 | pub mod information_element; 6 | pub mod nas_null_cipher; 7 | pub mod null_cipher; 8 | pub mod priority_2g_downgrade; 9 | pub mod test_analyzer; 10 | pub mod util; 11 | -------------------------------------------------------------------------------- /doc/updating-rayhunter.md: -------------------------------------------------------------------------------- 1 | # Updating Rayhunter 2 | 3 | Great news: if you've successfully installed Rayhunter, you already know how to update it! Our update process is identical to the installation process: simply repeat the steps for installing Rayhunter via a [release](./installing-from-release.md) or from [source](./installing-from-source.md). 4 | -------------------------------------------------------------------------------- /daemon/web/src/theme.ts: -------------------------------------------------------------------------------- 1 | /** These are the default Tailwind CSS breakpoints. 2 | * We're defining them here so they can be referenced 3 | * programmatically in other parts of the application. 4 | */ 5 | export const breakpoints = { 6 | sm: '640px', 7 | md: '768px', 8 | lg: '1024px', 9 | xl: '1280px', 10 | '2xl': '1536px', 11 | } as const; 12 | -------------------------------------------------------------------------------- /daemon/web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "trailingComma": "es5", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /daemon/web/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /installer-gui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "trailingComma": "es5", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /doc/tplink-m7310.md: -------------------------------------------------------------------------------- 1 | # TP-Link M7310 2 | 3 | Supported in Rayhunter since version 0.4.0. 4 | 5 | The TP-Link M7310 works similarly to the [M7350](./tplink-m7350.md) and is 6 | essentially an older, more expensive version of it. The installation procedure 7 | is identical, `./installer tplink`. 8 | 9 | Hardware version v1.0 has been successfully tested, later versions may work as 10 | well. 11 | -------------------------------------------------------------------------------- /make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | pushd daemon/web 3 | npm install 4 | npm run build 5 | popd 6 | cargo build-daemon-firmware-devel 7 | adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"' 8 | adb push target/armv7-unknown-linux-musleabihf/firmware-devel/rayhunter-daemon \ 9 | /data/rayhunter/rayhunter-daemon 10 | echo "rebooting the device..." 11 | adb shell '/bin/rootshell -c "reboot"' 12 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/DeleteAllButton.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 12 |
13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Files that are distributed onto the Rayhunter device always have to have 2 | # Unix-style line endings, even if the installer is built on Windows with 3 | # autocrlf enabled. 4 | # Using CRLF for the init scripts will make them fail to execute on TP-Link. 5 | # See https://github.com/EFForg/rayhunter/issues/489 6 | 7 | dist/config.toml.in eol=lf 8 | dist/scripts/misc-daemon eol=lf 9 | dist/scripts/rayhunter_daemon eol=lf 10 | -------------------------------------------------------------------------------- /installer-gui/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | # Installing Rayhunter 2 | 3 | So, you've got one of the [supported devices](./supported-devices.md), and are ready to start catching IMSI catchers. You have two options for installing Rayhunter: 4 | 5 | * [installing from a release (recommended)](./installing-from-release.md) 6 | * [installing from source](./installing-from-source.md) 7 | 8 | Already have Rayhunter installed but looking to update? 9 | 10 | * [Updating Rayhunter](./updating-rayhunter.md) -------------------------------------------------------------------------------- /telcom-parser/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "telcom-parser" 3 | version = "0.8.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | asn1-compiler = "0.7.1" 10 | asn1-codecs = "0.7.1" 11 | asn1_codecs_derive = "0.7.1" 12 | bitvec = { version = "1.0", features = ["serde"] } 13 | log = "0.4" 14 | thiserror = "1.0.56" 15 | serde = { version = "1.0.196", features = ["derive"] } 16 | -------------------------------------------------------------------------------- /daemon/src/display/headless.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use tokio::sync::mpsc::Receiver; 3 | use tokio_util::sync::CancellationToken; 4 | use tokio_util::task::TaskTracker; 5 | 6 | use crate::config; 7 | use crate::display::DisplayState; 8 | 9 | pub fn update_ui( 10 | _task_tracker: &TaskTracker, 11 | _config: &config::Config, 12 | _shutdown_token: CancellationToken, 13 | _ui_update_rx: Receiver, 14 | ) { 15 | info!("Headless mode, not spawning UI."); 16 | } 17 | -------------------------------------------------------------------------------- /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | ignore = [ 3 | # RSA Marvin Attack in `rsa`, dragged in through rustcrypto (dev builds) 4 | # and adb_client (USB signing only, unrelated to marvin attack which 5 | # targets decryption). 6 | "RUSTSEC-2023-0071", 7 | # paste crate being unmaintained is not important. it's not dealing with 8 | # user-input. we could get rid of this warning by disabling the image 9 | # dependency in adb-client. 10 | "RUSTSEC-2024-0436", 11 | ] 12 | -------------------------------------------------------------------------------- /check/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rayhunter-check" 3 | version = "0.8.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | rayhunter = { path = "../lib" } 8 | futures = { version = "0.3.30", default-features = false } 9 | log = "0.4.20" 10 | tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] } 11 | pcap-file-tokio = "0.1.0" 12 | clap = { version = "4.5.2", features = ["derive"] } 13 | simple_logger = "5.0.0" 14 | walkdir = "2.5.0" 15 | -------------------------------------------------------------------------------- /daemon/web/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Pull Request Checklist 2 | 3 | - [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them. 4 | - [ ] Added or updated any documentation as needed to support the changes in this PR. 5 | - [ ] Code has been linted and run through `cargo fmt` 6 | - [ ] If any new functionality has been added, unit tests were also added 7 | - [ ] [./CONTRIBUTING.md](../CONTRIBUTING.md) has been read 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Questions and community 4 | url: https://efforg.github.io/rayhunter/support-feedback-community.html 5 | about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help on GitHub discussions or Mattermost 6 | - name: Rayhunter Security Policy 7 | url: https://github.com/EFForg/rayhunter/security/advisories/new 8 | about: Please report security vulnerabilities here. 9 | -------------------------------------------------------------------------------- /daemon/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | import { breakpoints } from './src/theme'; 3 | 4 | export default { 5 | content: ['./src/**/*.{html,js,svelte,ts}'], 6 | 7 | theme: { 8 | extend: { 9 | colors: { 10 | 'rayhunter-blue': '#4e4eb1', 11 | 'rayhunter-dark-blue': '#3f3da0', 12 | 'rayhunter-green': '#94ea18', 13 | }, 14 | screens: breakpoints, 15 | }, 16 | }, 17 | 18 | plugins: [], 19 | } as Config; 20 | -------------------------------------------------------------------------------- /installer-gui/svelte.config.js: -------------------------------------------------------------------------------- 1 | // Tauri doesn't have a Node.js server to do proper SSR 2 | // so we will use adapter-static to prerender the app (SSG) 3 | // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info 4 | import adapter from '@sveltejs/adapter-static'; 5 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 6 | 7 | /** @type {import('@sveltejs/kit').Config} */ 8 | const config = { 9 | preprocess: vitePreprocess(), 10 | kit: { 11 | adapter: adapter(), 12 | }, 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /docker_make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd bin/web 3 | npm run build 4 | cd .. 5 | docker build -t rayhunter-devenv -f tools/devenv.dockerfile . 6 | echo ' build!' 7 | docker run --user $UID:$GID -v ./:/workdir -w /workdir -it rayhunter-devenv sh -c 'cargo build --release --target="armv7-unknown-linux-musleabihf"' 8 | adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"' 9 | adb push target/armv7-unknown-linux-musleabihf/release/rayhunter-daemon /data/rayhunter/rayhunter-daemon 10 | echo "rebooting the device..." 11 | adb shell '/bin/rootshell -c "reboot"' 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "lib", 5 | "daemon", 6 | "check", 7 | "rootshell", 8 | "telcom-parser", 9 | "installer", 10 | "installer-gui/src-tauri", 11 | ] 12 | # at least for now, let's keep installer-gui out of the list of default 13 | # packages. installer-gui is still experimental and requires many new packages 14 | # both from cargo and the underlying operating system 15 | default-members = [ 16 | "lib", 17 | "daemon", 18 | "check", 19 | "rootshell", 20 | "telcom-parser", 21 | "installer", 22 | ] 23 | resolver = "2" 24 | -------------------------------------------------------------------------------- /telcom-parser/tests/lte_rrc_test.rs: -------------------------------------------------------------------------------- 1 | use asn1_codecs::{PerCodecData, uper::UperCodec}; 2 | use telcom_parser::lte_rrc::BCCH_DL_SCH_Message; 3 | 4 | fn hex_to_bin(hex: &str) -> Vec { 5 | (0..hex.len()) 6 | .step_by(2) 7 | .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) 8 | .collect() 9 | } 10 | 11 | #[test] 12 | fn test() { 13 | let data = hex_to_bin("484c469010600018fd1a9207e22103108ac21bdc09802292cdd20000"); 14 | let mut asn_data = PerCodecData::from_slice_uper(&data); 15 | let sib1 = BCCH_DL_SCH_Message::uper_decode(&mut asn_data); 16 | dbg!(&sib1); 17 | } 18 | -------------------------------------------------------------------------------- /tools/run-docker-devenv: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Tool to cross-compile outside of Linux. 4 | # 5 | # On MacOS, OrbStack is recommended, but other docker distributions will work 6 | # too. 7 | # 8 | # Usage: 9 | # ./tools/run-docker-devenv 10 | # 11 | # Inside the shell: 12 | # cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --release 13 | # 14 | # Your output binary is in ./target/armv7-unknown-linux-musleabihf/release/rayhunter-daemon 15 | 16 | docker build -t rayhunter-devenv -f tools/devenv.dockerfile . 17 | exec docker run --user $UID:$GID -v ./:/workdir -w /workdir -it rayhunter-devenv "$@" 18 | -------------------------------------------------------------------------------- /telcom-parser/src/lib.rs: -------------------------------------------------------------------------------- 1 | use asn1_codecs::{PerCodecData, PerCodecError, uper::UperCodec}; 2 | use thiserror::Error; 3 | #[allow(warnings, unused, unreachable_patterns, non_camel_case_types)] 4 | pub mod lte_rrc; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum ParsingError { 8 | #[error("Failed to decode UPER data: {0}")] 9 | UperDecodeError(PerCodecError), 10 | } 11 | 12 | pub fn decode(data: &[u8]) -> Result 13 | where 14 | T: UperCodec, 15 | { 16 | let mut asn_data = PerCodecData::from_slice_uper(data); 17 | T::uper_decode(&mut asn_data).map_err(ParsingError::UperDecodeError) 18 | } 19 | -------------------------------------------------------------------------------- /daemon/src/battery/tmobile.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::{ 4 | battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file}, 5 | error::RayhunterError, 6 | }; 7 | 8 | const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity"; 9 | const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online"; 10 | 11 | pub async fn get_battery_state() -> Result { 12 | Ok(BatteryState { 13 | level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?, 14 | is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?, 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | ## Rayhunter tools 2 | 3 | ### `asn1grep.py`: a script for finding a datatype in ASN.1 files 4 | 5 | `asn1grep` parses our ASN.1 spec files, then searches for a given datatype by recursively descending through the LTE-RRC types we care about. it then prints out each result as a "path" through the highly nested datatypes. 6 | 7 | Setup: 8 | 1. `python -m venv .venv && . .venv/bin/activate` 9 | 2. `pip install -r requirements.txt` 10 | 11 | Usage: 12 | ``` 13 | » python asn1grep.py IMSI 14 | searching for IMSI 15 | PCCH-Message [message [message.c1 [c1 [c1.paging [paging [pagingRecordList[0] [ [ue-Identity [ue-Identity.imsi [IMSI]]]]]]]]]] 16 | ``` 17 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/DownloadLink.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | {text} 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub mod analysis; 4 | pub mod diag; 5 | pub mod gsmtap; 6 | pub mod gsmtap_parser; 7 | pub mod hdlc; 8 | pub mod log_codes; 9 | pub mod pcap; 10 | pub mod qmdl; 11 | pub mod util; 12 | 13 | // bin/check.rs may target windows and does not use this mod 14 | #[cfg(target_family = "unix")] 15 | pub mod diag_device; 16 | 17 | // re-export telcom_parser, since we use its types in our API 18 | pub use telcom_parser; 19 | 20 | #[derive(PartialEq, Debug, Clone, Deserialize, Serialize)] 21 | #[serde(rename_all = "lowercase")] 22 | pub enum Device { 23 | Orbic, 24 | Tplink, 25 | Tmobile, 26 | Wingtech, 27 | Pinephone, 28 | Uz801, 29 | } 30 | -------------------------------------------------------------------------------- /daemon/src/battery/wingtech.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::{ 4 | battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file}, 5 | error::RayhunterError, 6 | }; 7 | 8 | const BATTERY_LEVEL_FILE: &str = 9 | "/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity"; 10 | const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online"; 11 | 12 | pub async fn get_battery_state() -> Result { 13 | Ok(BatteryState { 14 | level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?, 15 | is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?, 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /daemon/web/src/lib/systemStats.ts: -------------------------------------------------------------------------------- 1 | export interface SystemStats { 2 | disk_stats: DiskStats; 3 | memory_stats: MemoryStats; 4 | runtime_metadata: RuntimeMetadata; 5 | battery_status?: BatteryStatus; 6 | } 7 | 8 | export interface RuntimeMetadata { 9 | rayhunter_version: string; 10 | system_os: string; 11 | arch: string; 12 | } 13 | 14 | export interface DiskStats { 15 | partition: string; 16 | total_size: string; 17 | used_size: string; 18 | available_size: string; 19 | used_percent: string; 20 | mounted_on: string; 21 | } 22 | 23 | export interface MemoryStats { 24 | total: string; 25 | used: string; 26 | free: string; 27 | } 28 | 29 | export interface BatteryStatus { 30 | level: number; 31 | is_plugged_in: boolean; 32 | } 33 | -------------------------------------------------------------------------------- /daemon/src/display/mod.rs: -------------------------------------------------------------------------------- 1 | use rayhunter::analysis::analyzer::EventType; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | mod generic_framebuffer; 5 | 6 | pub mod headless; 7 | pub mod orbic; 8 | pub mod tmobile; 9 | pub mod tplink; 10 | pub mod tplink_framebuffer; 11 | pub mod tplink_onebit; 12 | pub mod uz801; 13 | pub mod wingtech; 14 | 15 | #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] 16 | pub enum DisplayState { 17 | /// We're recording but no warning has been found yet. 18 | Recording, 19 | /// We're not recording. 20 | Paused, 21 | /// A non-informational event has been detected. 22 | /// 23 | /// Note that EventType::Informational is never sent through this. If it is, it's the same as 24 | /// Recording 25 | WarningDetected { event_type: EventType }, 26 | } 27 | -------------------------------------------------------------------------------- /daemon/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /installer-gui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /daemon/web/src/lib/action_errors.svelte.ts: -------------------------------------------------------------------------------- 1 | export class ActionError extends Error { 2 | // The number of this an identical error has happened. 3 | // This is shown as a number next to the error in the UI. 4 | times = $state(1); 5 | 6 | constructor(message: string, cause: Error) { 7 | super(message); 8 | this.cause = cause; 9 | } 10 | } 11 | 12 | export const action_errors: ActionError[] = $state([]); 13 | 14 | export function add_error(e: Error, msg: string): void { 15 | for (const existing of action_errors) { 16 | if (existing.message === msg) { 17 | existing.times += 1; 18 | return; 19 | } 20 | } 21 | const action_error = new ActionError(msg, e); 22 | action_errors.unshift(action_error); 23 | console.log(action_errors.length); 24 | } 25 | -------------------------------------------------------------------------------- /dist/scripts/rayhunter_daemon: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | set -e 4 | 5 | case "$1" in 6 | start) 7 | echo -n "Starting rayhunter: " 8 | # Below line may be replaced by the installer with device-specific startup commands, such as mounting the SD card. 9 | #RAYHUNTER-PRESTART 10 | start-stop-daemon -S -b --make-pidfile --pidfile /tmp/rayhunter.pid \ 11 | --startas /bin/sh -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1" 12 | echo "done" 13 | ;; 14 | stop) 15 | echo -n "Stopping rayhunter: " 16 | start-stop-daemon -K -p /tmp/rayhunter.pid 17 | echo "done" 18 | ;; 19 | restart) 20 | $0 stop 21 | $0 start 22 | ;; 23 | *) 24 | echo "Usage rayhunter_daemon { start | stop | restart }" >&2 25 | exit 1 26 | ;; 27 | esac 28 | 29 | exit 0 30 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "installer-gui" 3 | version = "0.8.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [lib] 9 | # The `_lib` suffix may seem redundant but it is necessary 10 | # to make the lib name unique and wouldn't conflict with the bin name. 11 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 12 | name = "installer_gui_lib" 13 | crate-type = ["staticlib", "cdylib", "rlib"] 14 | 15 | [build-dependencies] 16 | tauri-build = { version = "2", features = [] } 17 | 18 | [dependencies] 19 | tauri = { version = "2", features = [] } 20 | tauri-plugin-opener = "2" 21 | serde = { version = "1", features = ["derive"] } 22 | serde_json = "1" 23 | anyhow = "1.0.100" 24 | installer = { path = "../../installer" } 25 | -------------------------------------------------------------------------------- /lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rayhunter" 3 | version = "0.8.0" 4 | edition = "2024" 5 | description = "Realtime cellular data decoding and analysis for IMSI catcher detection" 6 | 7 | 8 | [lib] 9 | name = "rayhunter" 10 | path = "src/lib.rs" 11 | 12 | [dependencies] 13 | bytes = "1.5.0" 14 | chrono = { version = "0.4.31", features = ["serde"] } 15 | crc = "3.0.1" 16 | deku = { version = "0.18.0", features = ["logging"] } 17 | libc = "0.2.150" 18 | log = "0.4.20" 19 | nix = { version = "0.29.0", features = ["feature"] } 20 | pcap-file-tokio = "0.1.0" 21 | pycrate-rs = { git = "https://github.com/EFForg/pycrate-rs" } 22 | thiserror = "1.0.50" 23 | telcom-parser = { path = "../telcom-parser" } 24 | tokio = { version = "1.44.2", default-features = false, features = ["time", "rt", "macros", "fs"] } 25 | futures = { version = "0.3.30", default-features = false } 26 | serde = { version = "1.0.197", features = ["derive"] } 27 | serde_json = "1.0" 28 | num_enum = "0.7.4" 29 | 30 | [dev-dependencies] 31 | -------------------------------------------------------------------------------- /daemon/src/battery/orbic.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::{ 4 | battery::{BatteryState, is_plugged_in_from_file}, 5 | error::RayhunterError, 6 | }; 7 | 8 | const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level"; 9 | const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en"; 10 | 11 | pub async fn get_battery_state() -> Result { 12 | Ok(BatteryState { 13 | level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE) 14 | .await 15 | .map_err(RayhunterError::TokioError)? 16 | .chars() 17 | .next() 18 | { 19 | Some('1') => Ok(10), 20 | Some('2') => Ok(25), 21 | Some('3') => Ok(50), 22 | Some('4') => Ok(75), 23 | Some('5') => Ok(100), 24 | _ => Err(RayhunterError::BatteryLevelParseError), 25 | }?, 26 | is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /daemon/web/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | 3 | export default { 4 | kit: { 5 | adapter: adapter({ 6 | // default options are shown. On some platforms 7 | // these options are set automatically — see below 8 | pages: 'build', 9 | assets: 'build', 10 | fallback: undefined, 11 | precompress: false, 12 | strict: true, 13 | }), 14 | output: { 15 | // Force everything into one HTML file. SvelteKit will still generate 16 | // a lot of JS files but they are deadweight and will not be included 17 | // in the rust binary. 18 | bundleStrategy: 'inline', 19 | }, 20 | version: { 21 | // Use a deterministic version string for reproducible builds. 22 | // Without this option, SvelteKit will use a timestamp. 23 | name: process.env.GITHUB_SHA || 'dev', 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/DeleteButton.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | -------------------------------------------------------------------------------- /daemon/src/error.rs: -------------------------------------------------------------------------------- 1 | use rayhunter::diag_device::DiagDeviceError; 2 | use thiserror::Error; 3 | 4 | use crate::qmdl_store::RecordingStoreError; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum RayhunterError { 8 | #[error("Config file parsing error: {0}")] 9 | ConfigFileParsingError(#[from] toml::de::Error), 10 | #[error("Diag intialization error: {0}")] 11 | DiagInitError(DiagDeviceError), 12 | #[error("Tokio error: {0}")] 13 | TokioError(#[from] tokio::io::Error), 14 | #[error("QmdlStore error: {0}")] 15 | QmdlStoreError(#[from] RecordingStoreError), 16 | #[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")] 17 | NoStoreDebugMode(String), 18 | #[error("Error parsing file to determine battery level")] 19 | BatteryLevelParseError, 20 | #[error("Error parsing file to determine whether device is plugged in")] 21 | BatteryPluggedInStatusParseError, 22 | #[error("The requested functionality is not supported for this device")] 23 | FunctionNotSupportedForDeviceError, 24 | } 25 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "Rayhunter Installer", 4 | "identifier": "com.rayhunter-installer.app", 5 | "build": { 6 | "beforeDevCommand": "npm run dev", 7 | "devUrl": "http://localhost:1420", 8 | "beforeBuildCommand": "npm run build", 9 | "frontendDist": "../build" 10 | }, 11 | "app": { 12 | "windows": [ 13 | { 14 | "title": "Rayhunter Installer", 15 | "width": 800, 16 | "height": 600 17 | } 18 | ], 19 | "security": { 20 | "csp": null 21 | } 22 | }, 23 | "bundle": { 24 | "active": true, 25 | "targets": ["app", "appimage", "deb", "msi", "nsis", "rpm"], 26 | "icon": [ 27 | "icons/32x32.png", 28 | "icons/128x128.png", 29 | "icons/128x128@2x.png", 30 | "icons/icon.icns", 31 | "icons/icon.ico" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /daemon/web/src/lib/ndjson.ts: -------------------------------------------------------------------------------- 1 | export type NewlineDeliminatedJson = any[]; 2 | 3 | export function parse_ndjson(input: string): NewlineDeliminatedJson { 4 | const lines = input.split('\n'); 5 | const result = []; 6 | let current_line = ''; 7 | while (lines.length > 0) { 8 | current_line += lines.shift(); 9 | if (current_line.length === 0) { 10 | continue; 11 | } 12 | try { 13 | const entry = JSON.parse(current_line); 14 | result.push(entry); 15 | current_line = ''; 16 | } catch (e) { 17 | // if this chunk wasn't valid JSON, assume there was an escaped 18 | // newline in the JSON line, so simply continue to the next one. 19 | // however, if we've reached the end of the input, that means we 20 | // were given invalid nd-json 21 | if (lines.length === 0) { 22 | throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`); 23 | } 24 | } 25 | } 26 | return result; 27 | } 28 | -------------------------------------------------------------------------------- /doc/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](./introduction.md) 4 | - [Installation](./installation.md) 5 | - [Installing from the latest release](./installing-from-release.md) 6 | - [Installing from source](./installing-from-source.md) 7 | - [Updating Rayhunter](./updating-rayhunter.md) 8 | - [Configuration](./configuration.md) 9 | - [Uninstalling](./uninstalling.md) 10 | - [Using Rayhunter](./using-rayhunter.md) 11 | - [Rayhunter's heuristics](./heuristics.md) 12 | - [Re-analyzing recordings](./reanalyzing.md) 13 | - [How we analyze a capture](./analyzing-a-capture.md) 14 | - [Supported devices](./supported-devices.md) 15 | - [Orbic/Kajeet RC400L](./orbic.md) 16 | - [TP-Link M7350](./tplink-m7350.md) 17 | - [TP-Link M7310](./tplink-m7310.md) 18 | - [Tmobile TMOHS1](./tmobile-tmohs1.md) 19 | - [UZ801](./uz801.md) 20 | - [Wingtech CT2MHS01](./wingtech-ct2mhs01.md) 21 | - [PinePhone and PinePhone Pro](./pinephone.md) 22 | - [Moxee Hotspot](./moxee.md) 23 | - [Support, feedback, and community](./support-feedback-community.md) 24 | - [Frequently Asked Questions](./faq.md) 25 | -------------------------------------------------------------------------------- /installer-gui/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | 5 | // @ts-expect-error process is a nodejs global 6 | const host = process.env.TAURI_DEV_HOST; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(async () => ({ 10 | plugins: [sveltekit(), tailwindcss()], 11 | 12 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 13 | // 14 | // 1. prevent vite from obscuring rust errors 15 | clearScreen: false, 16 | // 2. tauri expects a fixed port, fail if that port is not available 17 | server: { 18 | port: 1420, 19 | strictPort: true, 20 | host: host || false, 21 | hmr: host 22 | ? { 23 | protocol: 'ws', 24 | host, 25 | port: 1421, 26 | } 27 | : undefined, 28 | watch: { 29 | // 3. tell vite to ignore watching `src-tauri` 30 | ignored: ['**/src-tauri/**'], 31 | }, 32 | }, 33 | })); 34 | -------------------------------------------------------------------------------- /daemon/src/display/tplink.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use tokio::sync::mpsc::Receiver; 3 | use tokio_util::sync::CancellationToken; 4 | use tokio_util::task::TaskTracker; 5 | 6 | use crate::config; 7 | use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit}; 8 | 9 | use std::fs; 10 | 11 | pub fn update_ui( 12 | task_tracker: &TaskTracker, 13 | config: &config::Config, 14 | shutdown_token: CancellationToken, 15 | ui_update_rx: Receiver, 16 | ) { 17 | let display_level = config.ui_level; 18 | if display_level == 0 { 19 | info!("Invisible mode, not spawning UI."); 20 | } 21 | 22 | // Since this is a one-time check at startup, using sync is acceptable 23 | // The alternative would be to make the entire initialization async 24 | if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() { 25 | info!("detected one-bit display"); 26 | tplink_onebit::update_ui(task_tracker, config, shutdown_token, ui_update_rx) 27 | } else { 28 | info!("fallback to framebuffer"); 29 | tplink_framebuffer::update_ui(task_tracker, config, shutdown_token, ui_update_rx) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /rootshell/src/main.rs: -------------------------------------------------------------------------------- 1 | //! a simple shell for uploading to the orbic device. 2 | //! 3 | //! It literally just runs bash as UID/GID 0, with special Android GIDs 3003 4 | //! (AID_INET) and 3004 (AID_NET_RAW). 5 | use std::env; 6 | use std::os::unix::process::CommandExt; 7 | use std::process::Command; 8 | 9 | #[cfg(target_arch = "arm")] 10 | use nix::unistd::Gid; 11 | 12 | fn main() { 13 | let mut args = env::args(); 14 | 15 | // Android's "paranoid network" feature restricts network access to 16 | // processes in specific groups. More info here: 17 | // https://www.elinux.org/Android_Security#Paranoid_network-ing 18 | #[cfg(target_arch = "arm")] 19 | { 20 | let gids = &[ 21 | Gid::from_raw(3003), // AID_INET 22 | Gid::from_raw(3004), // AID_NET_RAW 23 | ]; 24 | nix::unistd::setgroups(gids).expect("setgroups failed"); 25 | } 26 | 27 | // discard argv[0] 28 | let _ = args.next(); 29 | // This call will only return if there is an error 30 | let error = Command::new("/bin/bash").args(args).uid(0).gid(0).exec(); 31 | eprintln!("Error running command: {error}"); 32 | std::process::exit(1); 33 | } 34 | -------------------------------------------------------------------------------- /daemon/web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import js from '@eslint/js'; 3 | import svelte from 'eslint-plugin-svelte'; 4 | import globals from 'globals'; 5 | import ts from 'typescript-eslint'; 6 | 7 | export default ts.config( 8 | { 9 | ignores: ['build/', '.svelte-kit/**', 'dist/'], 10 | }, 11 | js.configs.recommended, 12 | ...ts.configs.recommended, 13 | ...svelte.configs['flat/recommended'], 14 | prettier, 15 | ...svelte.configs['flat/prettier'], 16 | { 17 | languageOptions: { 18 | globals: { 19 | ...globals.browser, 20 | ...globals.node, 21 | }, 22 | }, 23 | }, 24 | { 25 | files: ['**/*.svelte'], 26 | 27 | languageOptions: { 28 | parserOptions: { 29 | parser: ts.parser, 30 | }, 31 | }, 32 | }, 33 | { 34 | rules: { 35 | '@typescript-eslint/no-unused-vars': [ 36 | 'error', 37 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 38 | ], 39 | '@typescript-eslint/no-explicit-any': 'off', 40 | }, 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /installer-gui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import js from '@eslint/js'; 3 | import svelte from 'eslint-plugin-svelte'; 4 | import globals from 'globals'; 5 | import ts from 'typescript-eslint'; 6 | 7 | export default ts.config( 8 | { 9 | ignores: ['build/', '.svelte-kit/**', 'dist/'], 10 | }, 11 | js.configs.recommended, 12 | ...ts.configs.recommended, 13 | ...svelte.configs['flat/recommended'], 14 | prettier, 15 | ...svelte.configs['flat/prettier'], 16 | { 17 | languageOptions: { 18 | globals: { 19 | ...globals.browser, 20 | ...globals.node, 21 | }, 22 | }, 23 | }, 24 | { 25 | files: ['**/*.svelte'], 26 | 27 | languageOptions: { 28 | parserOptions: { 29 | parser: ts.parser, 30 | }, 31 | }, 32 | }, 33 | { 34 | rules: { 35 | '@typescript-eslint/no-unused-vars': [ 36 | 'error', 37 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 38 | ], 39 | '@typescript-eslint/no-explicit-any': 'off', 40 | }, 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | labels: ["bug"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | options: 9 | - label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Bug Report Details 14 | description: | 15 | Please provide the following information, if applicable: 16 | placeholder: | 17 | • **Rayhunter Version**: (e.g., v0.2.6) 18 | • **Capture Date**: (YYYY-MM-DD, e.g., 2025-05-01) 19 | • **Capture Location**: (If comfortable disclosing, what region or country were you in? e.g., Washington State) 20 | • **Device and Model**: (Device you installed Rayhunter on, e.g., Orbic RC400L) 21 | • **What happened?**: (What steps did you take to get to your issue? Tell us what you see!) 22 | • **Expected behavior**: (Rayhunter's behavior differed from what I expected because...) 23 | • **Relevant log output**: (Rayhunter data captures - QMDL and PCAP logs - or error codes) 24 | validations: 25 | required: true 26 | -------------------------------------------------------------------------------- /daemon/web/src/lib/stores/breakpoint.ts: -------------------------------------------------------------------------------- 1 | // stores/breakpoint.ts 2 | import { readable, type Readable } from 'svelte/store'; 3 | import { breakpoints } from '../../theme'; 4 | 5 | type Breakpoint = keyof typeof breakpoints; 6 | 7 | // Store that tracks if a specific breakpoint matches 8 | export function createBreakpointStore(breakpoint: Breakpoint): Readable { 9 | return readable(false, (set) => { 10 | const width = breakpoints[breakpoint]; 11 | const mediaQuery = window.matchMedia(`(min-width: ${width})`); 12 | 13 | // Set initial value 14 | set(mediaQuery.matches); 15 | 16 | // Update on change 17 | const handler = (e: MediaQueryListEvent) => set(e.matches); 18 | mediaQuery.addEventListener('change', handler); 19 | 20 | // Cleanup 21 | return () => mediaQuery.removeEventListener('change', handler); 22 | }); 23 | } 24 | 25 | // Create stores for each breakpoint 26 | export const screenIsSmUp: Readable = createBreakpointStore('sm'); 27 | export const screenIsMdUp: Readable = createBreakpointStore('md'); 28 | export const screenIsLgUp: Readable = createBreakpointStore('lg'); 29 | export const screenIsXlUp: Readable = createBreakpointStore('xl'); 30 | -------------------------------------------------------------------------------- /doc/support-feedback-community.md: -------------------------------------------------------------------------------- 1 | # Support, Feedback, and Community 2 | 3 | If you're using Rayhunter (or trying to), we'd love to hear from you! Check out one of the following forums for contacting the Rayhunter developers and community: 4 | 5 | * If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal). 6 | * If you're having issues installing or using Rayhunter, please [open an issue](https://github.com/EFForg/rayhunter/issues) on our Github repo. 7 | * If you'd like to propose a feature, heuristic, or device for Rayhunter, [start a discussion](https://github.com/EFForg/rayhunter/discussions) in our Github repo 8 | * For anything else, join us in the `#rayhunter` or `#rayhunter-developers` channel of [EFF's Mattermost](https://opensource.eff.org/signup_user_complete/?id=r1b6cnta9bysxk6im3kuabiu1y&md=link&sbr=su) instance to chat! 9 | -------------------------------------------------------------------------------- /doc/moxee.md: -------------------------------------------------------------------------------- 1 | # KonnectONE Moxee Hotspot (K779HSDL) 2 | 3 | Supported in Rayhunter since version 0.6.0. 4 | 5 | The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be 6 | primarily for the US market. 7 | 8 | - [KonnectONE product page](https://www.konnectone.com/specs-hotspot) 9 | - [Moxee product page](https://www.moxee.com/hotspot) 10 | 11 | ## Supported bands 12 | 13 | According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL), the device supports the following LTE bands: 14 | 15 | | Band | Frequency | 16 | |------|-------------------------| 17 | | 2 | 1900 MHz (PCS) | 18 | | 4 | 1700/2100 MHz (AWS-1) | 19 | | 5 | 850 MHz (CLR) | 20 | | 12 | 700 MHz (Lower SMH) | 21 | | 13 | 700 MHz (Upper SMH) | 22 | | 25 | 1900 MHz (Extended PCS) | 23 | | 26 | 850 MHz (Extended) | 24 | | 41 | 2500 MHz (TDD) | 25 | | 66 | 1700/2100 MHz (E-AWS) | 26 | | 71 | 600 MHz | 27 | 28 | ## Installation 29 | 30 | Connect to the hotspot's network using WiFi or USB tethering and run: 31 | 32 | ```sh 33 | ./installer orbic --admin-password 'mypassword' 34 | ``` 35 | 36 | The password (in place of `mypassword`) is under the battery. 37 | 38 | ## Obtaining a shell 39 | 40 | ```sh 41 | ./installer util orbic-shell 42 | ``` 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature or improvement to Rayhunter 3 | labels: ["enhancement"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | options: 9 | - label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) 10 | required: true 11 | - type: textarea 12 | id: problem 13 | attributes: 14 | label: What problem does this feature solve or what does it enhance? 15 | description: Explain what this feature addresses, ors the benefit it provides. 16 | placeholder: For example, "Currently, users have to manually do X, which is time-consuming." 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: solution 21 | attributes: 22 | label: Proposed Solution 23 | description: Describe the solution you'd like to see implemented. 24 | placeholder: For example, "Implement a new button that automatically does X." 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: alternatives 29 | attributes: 30 | label: Alternatives Considered 31 | description: Have you considered any alternative solutions? 32 | placeholder: For example, "We considered Y, but Z is a better approach because..." 33 | -------------------------------------------------------------------------------- /daemon/src/battery/tplink.rs: -------------------------------------------------------------------------------- 1 | use crate::{battery::BatteryState, error::RayhunterError}; 2 | 3 | pub async fn get_battery_state() -> Result { 4 | let uci_battery = tokio::process::Command::new("uci") 5 | .arg("get") 6 | .arg("battery.battery_mgr.power_level") 7 | .output() 8 | .await?; 9 | 10 | let uci_plugged_in = tokio::process::Command::new("uci") 11 | .arg("get") 12 | .arg("battery.battery_mgr.is_charging") 13 | .output() 14 | .await?; 15 | 16 | if !uci_battery.status.success() { 17 | return Err(RayhunterError::BatteryLevelParseError); 18 | } 19 | 20 | if !uci_plugged_in.status.success() { 21 | return Err(RayhunterError::BatteryPluggedInStatusParseError); 22 | } 23 | 24 | let uci_battery = String::from_utf8_lossy(&uci_battery.stdout) 25 | .trim_end() 26 | .parse() 27 | .map_err(|_| RayhunterError::BatteryLevelParseError)?; 28 | 29 | let uci_plugged_in = match String::from_utf8_lossy(&uci_plugged_in.stdout).trim_end() { 30 | "0" => Ok(false), 31 | "1" => Ok(true), 32 | _ => Err(RayhunterError::BatteryPluggedInStatusParseError), 33 | }?; 34 | 35 | Ok(BatteryState { 36 | level: uci_battery, 37 | is_plugged_in: uci_plugged_in, 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /installer-gui/src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | use tauri::Emitter; 2 | 3 | async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> { 4 | tauri::async_runtime::spawn_blocking(move || { 5 | installer::run_with_callback( 6 | // TODO: we should split using something similar to shlex in python 7 | args.split_whitespace(), 8 | Some(Box::new(move |output| { 9 | app_handle 10 | .emit("installer-output", output) 11 | .expect("Error sending Rayhunter CLI installer output to GUI frontend"); 12 | })), 13 | ) 14 | }) 15 | .await? 16 | } 17 | 18 | #[tauri::command] 19 | async fn install_rayhunter(app_handle: tauri::AppHandle, args: String) -> Result<(), String> { 20 | // the return value of tauri commands needs to be serializable by serde which we accomplish 21 | // here by converting anyhow::Error to a string 22 | run_installer(app_handle, args) 23 | .await 24 | .map_err(|error| format!("{error:?}")) 25 | } 26 | 27 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 28 | pub fn run() { 29 | tauri::Builder::default() 30 | .plugin(tauri_plugin_opener::init()) 31 | .invoke_handler(tauri::generate_handler![install_rayhunter]) 32 | .run(tauri::generate_context!()) 33 | .expect("error while running tauri application"); 34 | } 35 | -------------------------------------------------------------------------------- /installer/build.rs: -------------------------------------------------------------------------------- 1 | use core::str; 2 | use std::path::Path; 3 | use std::process::exit; 4 | 5 | fn main() { 6 | println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN"); 7 | let include_dir = Path::new(concat!( 8 | env!("CARGO_MANIFEST_DIR"), 9 | "/../target/armv7-unknown-linux-musleabihf/firmware/" 10 | )); 11 | set_binary_var(include_dir, "FILE_ROOTSHELL", "rootshell"); 12 | set_binary_var(include_dir, "FILE_RAYHUNTER_DAEMON", "rayhunter-daemon"); 13 | } 14 | 15 | fn set_binary_var(include_dir: &Path, var: &str, file: &str) { 16 | if std::env::var_os("NO_FIRMWARE_BIN").is_some() { 17 | let out_dir = std::env::var("OUT_DIR").unwrap(); 18 | std::fs::create_dir_all(&out_dir).unwrap(); 19 | let blank = Path::new(&out_dir).join("blank"); 20 | std::fs::write(&blank, []).unwrap(); 21 | println!("cargo::rustc-env={var}={}", blank.display()); 22 | return; 23 | } 24 | if std::env::var_os(var).is_none() { 25 | let binary = include_dir.join(file); 26 | if !binary.exists() { 27 | println!( 28 | "cargo::error=Firmware binary {file} not present at {}", 29 | binary.display() 30 | ); 31 | exit(0); 32 | } 33 | println!("cargo::rustc-env={var}={}", binary.display()); 34 | println!("cargo::rerun-if-changed={}", binary.display()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tools/pcap_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import nasparse 3 | from scapy.utils import RawPcapNgReader 4 | import sys 5 | 6 | TYPE_LTE_NAS = 0x12 7 | UDP_LEN = 28 8 | 9 | def process_pcap(pcap_path): 10 | print('Opening {}...'.format(pcap_path)) 11 | 12 | count = 0 13 | for pkt_data, pkt_metadata in RawPcapNgReader(pcap_path): 14 | count += 1 15 | 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 16 | header_end = gsmtap_len + UDP_LEN #length of UDP/IP header plus GSMTAP header 17 | 18 | gsmtap_hdr = pkt_data[UDP_LEN:header_end] 19 | 20 | if gsmtap_hdr[2] != TYPE_LTE_NAS: 21 | continue 22 | 23 | # uplink status is the 7th bit of the 5th byte of the GSMTAP header. 24 | # Uplink (Mobile originated) = 0 Downlink (mobile terminated) = 1 25 | uplink = (gsmtap_hdr[4] & 0b01000000) >> 6 26 | buffer = pkt_data[header_end:] 27 | msg = nasparse.parse_nas_message(buffer, uplink) 28 | triggered, message = nasparse.heur_ue_imsi_sent(msg) 29 | if triggered: 30 | print(f"Frame {count} triggered heuristic: {message}") 31 | 32 | if __name__ == "__main__": 33 | if len(sys.argv) != 2: 34 | print("usage: pcap_check.py [path/to/pcap/file]") 35 | exit(1) 36 | 37 | pcap_path = sys.argv[1] 38 | process_pcap(pcap_path) -------------------------------------------------------------------------------- /daemon/web/src/lib/ndjson.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parse_ndjson } from './ndjson'; 3 | 4 | describe('parsing newline-deliminated json', () => { 5 | it('parses normal JSON', () => { 6 | const json = JSON.stringify({ foo: 100 }); 7 | const result = parse_ndjson(json); 8 | expect(result).toHaveLength(1); 9 | expect(result[0]).toEqual({ foo: 100 }); 10 | }); 11 | 12 | it('parses simple newline-deliminated json', () => { 13 | const json_a = JSON.stringify({ a: 100 }); 14 | const json_b = JSON.stringify({ b: 200 }); 15 | const result = parse_ndjson(`${json_a}\n${json_b}`); 16 | expect(result).toHaveLength(2); 17 | expect(result[0]).toEqual({ a: 100 }); 18 | expect(result[1]).toEqual({ b: 200 }); 19 | }); 20 | 21 | it('parses newline-deliminated json with escaped newlines within', () => { 22 | const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' }); 23 | const json_b = JSON.stringify({ b: 200 }); 24 | const result = parse_ndjson(`${json_a}\n${json_b}`); 25 | expect(result).toHaveLength(2); 26 | expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' }); 27 | expect(result[1]).toEqual({ b: 200 }); 28 | }); 29 | 30 | it('actually errors out on invalid ndjson', () => { 31 | expect(() => parse_ndjson('invalid\njson')).toThrow(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/src/analysis/incomplete_sib.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1}; 4 | 5 | use super::analyzer::{Analyzer, Event, EventType}; 6 | use super::information_element::{InformationElement, LteInformationElement}; 7 | 8 | pub struct IncompleteSibAnalyzer {} 9 | 10 | impl Analyzer for IncompleteSibAnalyzer { 11 | fn get_name(&self) -> Cow<'_, str> { 12 | Cow::from("Incomplete SIB") 13 | } 14 | 15 | fn get_description(&self) -> Cow<'_, str> { 16 | Cow::from("Tests whether a SIB1 message contains a full chain of followup sibs") 17 | } 18 | 19 | fn get_version(&self) -> u32 { 20 | 2 21 | } 22 | 23 | fn analyze_information_element( 24 | &mut self, 25 | ie: &InformationElement, 26 | _packet_num: usize, 27 | ) -> Option { 28 | if let InformationElement::LTE(lte_ie) = ie 29 | && let LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie 30 | && let BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message 31 | && let BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1 32 | && sib1.scheduling_info_list.0.len() < 2 33 | { 34 | return Some(Event { 35 | event_type: EventType::Informational, 36 | message: "SIB1 scheduling info list was malformed".to_string(), 37 | }); 38 | } 39 | None 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /telcom-parser/README.md: -------------------------------------------------------------------------------- 1 | # Autogenerated telcom packet parsing 2 | 3 | This crate contains [ASN.1](https://en.wikipedia.org/wiki/ASN.1) specs for various telcom message payloads, as well as autogenerated 4 | Rust code for parsing these messages. We're using [hampi](https://github.com/ystero-dev/hampi/) as a parser generator, and it seems 5 | 3GPP protocols are encoded in the unaligned Packed Encoding Rules (or uPER) codec. 6 | 7 | ## Generating the parser 8 | 9 | To install the hampi compiler, run: 10 | 11 | ``` 12 | > cargo install asn1-compiler 13 | ``` 14 | 15 | To generate the parser for LTE RRC, run: 16 | 17 | ``` 18 | > rs-asn1c --codec uper --module src/lte_rrc.rs -- specs/EUTRA* specs/PC5-RRC-Definitions.asn 19 | ``` 20 | 21 | ## Sourcing the ASN.1 files 22 | 23 | 3GPP, who develops the standards for 4G (and all the other G's) publishes ASN.1 specs for their protocols in these horrific Microsoft Word docs (e.g. [here](https://portal.3gpp.org/desktopmodules/Specifications/SpecificationDetails.aspx?specificationId=2440)). The ASN.1 blocks are denoted by `--ASN1START` and `--ASN1STOP` text, so extracting them automatically is possible using a script like [hampi's](https://github.com/ystero-dev/hampi/blob/master/examples/specs/parse_spec.py). Instead of doing this ourselves, we just sourced ours from [these](https://obj-sys.com/products/asn1apis/lte_3gpp_apis.php#lte_4g_apis). 24 | 25 | # TODO 26 | * implement proof of concept binary using this to parse QMDL, summarize the packets 27 | -------------------------------------------------------------------------------- /daemon/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build && gzip -9 ./build/index.html", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "test:unit": "vitest", 12 | "test": "npm run test:unit -- --run", 13 | "format": "prettier --write .", 14 | "lint": "prettier --check . && eslint .", 15 | "fix": "eslint --fix ." 16 | }, 17 | "devDependencies": { 18 | "@sveltejs/adapter-auto": "^3.0.0", 19 | "@sveltejs/adapter-static": "^3.0.5", 20 | "@sveltejs/kit": "^2.13.0", 21 | "@sveltejs/vite-plugin-svelte": "^6.2.1", 22 | "@types/eslint": "^9.6.0", 23 | "@types/node": "^24.7.0", 24 | "autoprefixer": "^10.4.20", 25 | "eslint": "^9.7.0", 26 | "eslint-config-prettier": "^9.1.0", 27 | "eslint-plugin-svelte": "^2.36.0", 28 | "globals": "^15.0.0", 29 | "prettier": "^3.3.2", 30 | "prettier-plugin-svelte": "^3.2.6", 31 | "svelte": "^5.0.0", 32 | "svelte-check": "^4.0.0", 33 | "tailwindcss": "^3.4.9", 34 | "typescript": "^5.0.0", 35 | "typescript-eslint": "^8.0.0", 36 | "vite": "^7.1.11", 37 | "vitest": "^3.2.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /installer-gui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "installer-gui", 3 | "version": "0.1.0", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "format": "prettier --write .", 13 | "lint": "prettier --check . && eslint .", 14 | "fix": "eslint --fix .", 15 | "tauri": "tauri" 16 | }, 17 | "dependencies": { 18 | "@tailwindcss/vite": "^4.1.16", 19 | "@tauri-apps/api": "^2", 20 | "@tauri-apps/plugin-opener": "^2", 21 | "tailwindcss": "^4.1.16" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.38.0", 25 | "@sveltejs/adapter-static": "^3.0.6", 26 | "@sveltejs/kit": "^2.9.0", 27 | "@sveltejs/vite-plugin-svelte": "^5.0.0", 28 | "@tauri-apps/cli": "^2", 29 | "eslint": "^9.38.0", 30 | "eslint-config-prettier": "^10.1.8", 31 | "eslint-plugin-svelte": "^3.13.0", 32 | "globals": "^16.4.0", 33 | "prettier": "^3.6.2", 34 | "prettier-plugin-svelte": "^3.4.0", 35 | "svelte": "^5.0.0", 36 | "svelte-check": "^4.0.0", 37 | "typescript": "~5.6.2", 38 | "typescript-eslint": "^8.46.2", 39 | "vite": "^6.0.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /daemon/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rayhunter-daemon" 3 | version = "0.8.0" 4 | edition = "2024" 5 | rust-version = "1.88.0" 6 | 7 | [features] 8 | default = ["rustcrypto-tls"] 9 | rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"] 10 | ring-tls = ["reqwest/rustls-tls-webpki-roots"] 11 | 12 | [dependencies] 13 | rayhunter = { path = "../lib" } 14 | toml = "0.8.8" 15 | serde = { version = "1.0.193", features = ["derive"] } 16 | tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] } 17 | axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } 18 | thiserror = "1.0.52" 19 | libc = "0.2.150" 20 | log = "0.4.20" 21 | env_logger = { version = "0.11", default-features = false } 22 | tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] } 23 | futures-macro = "0.3.30" 24 | include_dir = "0.7.3" 25 | chrono = { version = "0.4.31", features = ["serde"] } 26 | tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] } 27 | futures = { version = "0.3.30", default-features = false } 28 | serde_json = "1.0.114" 29 | image = { version = "0.25.1", default-features = false, features = ["png", "gif"] } 30 | tempfile = "3.10.1" 31 | async_zip = { version = "0.0.17", features = ["tokio"] } 32 | anyhow = "1.0.98" 33 | reqwest = { version = "0.12.20", default-features = false } 34 | rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } 35 | async-trait = "0.1.88" 36 | -------------------------------------------------------------------------------- /telcom-parser/specs/PC5-RRC-Definitions.asn: -------------------------------------------------------------------------------- 1 | -- This file was generated by the Objective Systems ASN1C Compiler 2 | -- (http://www.obj-sys.com). Version: 7.7.2, Date: 13-Oct-2023. 3 | 4 | PC5-RRC-Definitions DEFINITIONS AUTOMATIC TAGS ::= BEGIN 5 | 6 | IMPORTS 7 | 8 | TDD-ConfigSL-r12 9 | FROM EUTRA-RRC-Definitions ; 10 | 11 | -- Productions 12 | 13 | MasterInformationBlock-SL ::= SEQUENCE { 14 | sl-Bandwidth-r12 [0] ENUMERATED { n6(0), n15(1), n25(2), n50(3), n75(4), 15 | n100(5) }, 16 | tdd-ConfigSL-r12 [1] TDD-ConfigSL-r12, 17 | directFrameNumber-r12 [2] BIT STRING (SIZE (10)), 18 | directSubframeNumber-r12 [3] INTEGER (0..9), 19 | inCoverage-r12 [4] BOOLEAN, 20 | reserved-r12 [5] BIT STRING (SIZE (19)) 21 | } 22 | 23 | SBCCH-SL-BCH-MessageType ::= MasterInformationBlock-SL 24 | 25 | MasterInformationBlock-SL-V2X-r14 ::= SEQUENCE { 26 | sl-Bandwidth-r14 [0] ENUMERATED { n6(0), n15(1), n25(2), n50(3), n75(4), 27 | n100(5) }, 28 | tdd-ConfigSL-r14 [1] TDD-ConfigSL-r12, 29 | directFrameNumber-r14 [2] BIT STRING (SIZE (10)), 30 | directSubframeNumber-r14 [3] INTEGER (0..9), 31 | inCoverage-r14 [4] BOOLEAN, 32 | reserved-r14 [5] BIT STRING (SIZE (27)) 33 | } 34 | 35 | SBCCH-SL-BCH-MessageType-V2X-r14 ::= MasterInformationBlock-SL-V2X-r14 36 | 37 | SBCCH-SL-BCH-Message ::= SEQUENCE { 38 | message [0] SBCCH-SL-BCH-MessageType 39 | } 40 | 41 | SBCCH-SL-BCH-Message-V2X-r14 ::= SEQUENCE { 42 | message [0] SBCCH-SL-BCH-MessageType-V2X-r14 43 | } 44 | 45 | END 46 | -------------------------------------------------------------------------------- /daemon/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | 4 | export default defineConfig({ 5 | server: { 6 | proxy: { 7 | '/api': { 8 | target: 'http://localhost:8080', 9 | changeOrigin: true, 10 | secure: false, 11 | configure: (proxy, _options) => { 12 | proxy.on('error', (err, _req, _res) => { 13 | console.log('proxy err:', err); 14 | }); 15 | proxy.on('proxyReq', (proxyReq, req, _res) => { 16 | console.log('Sending Request to the Target:', req.method, req.url); 17 | }); 18 | proxy.on('proxyRes', (proxyRes, req, _res) => { 19 | console.log( 20 | 'Received Response from the Target:', 21 | proxyRes.statusCode, 22 | req.url 23 | ); 24 | }); 25 | }, 26 | }, 27 | }, 28 | }, 29 | plugins: [sveltekit()], 30 | build: { 31 | // Force everything into one HTML file. SvelteKit will still generate 32 | // a lot of JS files but they are deadweight and will not be included 33 | // in the rust binary. 34 | assetsInlineLimit: Infinity, 35 | }, 36 | test: { 37 | include: ['src/**/*.{test,spec}.{js,ts}'], 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /daemon/src/display/orbic.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::display::DisplayState; 3 | use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; 4 | use async_trait::async_trait; 5 | 6 | use tokio::sync::mpsc::Receiver; 7 | use tokio_util::sync::CancellationToken; 8 | use tokio_util::task::TaskTracker; 9 | 10 | const FB_PATH: &str = "/dev/fb0"; 11 | 12 | #[derive(Copy, Clone, Default)] 13 | struct Framebuffer; 14 | 15 | #[async_trait] 16 | impl GenericFramebuffer for Framebuffer { 17 | fn dimensions(&self) -> Dimensions { 18 | // TODO actually poll for this, maybe w/ fbset? 19 | Dimensions { 20 | height: 128, 21 | width: 128, 22 | } 23 | } 24 | 25 | async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) { 26 | let mut raw_buffer = Vec::new(); 27 | for (r, g, b) in buffer { 28 | let mut rgb565: u16 = (r as u16 & 0b11111000) << 8; 29 | rgb565 |= (g as u16 & 0b11111100) << 3; 30 | rgb565 |= (b as u16) >> 3; 31 | raw_buffer.extend(rgb565.to_le_bytes()); 32 | } 33 | 34 | tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap(); 35 | } 36 | } 37 | 38 | pub fn update_ui( 39 | task_tracker: &TaskTracker, 40 | config: &config::Config, 41 | shutdown_token: CancellationToken, 42 | ui_update_rx: Receiver, 43 | ) { 44 | generic_framebuffer::update_ui( 45 | task_tracker, 46 | config, 47 | Framebuffer, 48 | shutdown_token, 49 | ui_update_rx, 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /dist/config.toml.in: -------------------------------------------------------------------------------- 1 | # cat config.toml 2 | qmdl_store_path = "/data/rayhunter/qmdl" 3 | port = 8080 4 | debug_mode = false 5 | colorblind_mode = false 6 | # Device selection. This will be overwritten by the installer. Defaults to "orbic". 7 | #device = "orbic" 8 | # UI Levels: 9 | # 10 | # Orbic and TP-Link with color display: 11 | # 0 = invisible mode, no indicator that rayhunter is running 12 | # 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings) 13 | # 2 = Demo Mode, display a fun orca gif 14 | # 3 = display the EFF logo 15 | # 16 | # TP-Link with one-bit display: 17 | # 0 = invisible mode 18 | # 1..3 = show emoji for status. :) for running, ! for warnings, no mouth for paused. 19 | ui_level = 1 20 | 21 | # 0 = rayhunter does not read button presses 22 | # 1 = double-tapping the power button starts/stops recordings 23 | key_input_mode = 0 24 | 25 | # If set, attempts to send a notification to the url when a new warning is triggered 26 | ntfy_url = "" 27 | # What notification types to enable. Does nothing if the above ntfy_url is not set. 28 | enabled_notifications = ["Warning", "LowBattery"] 29 | 30 | # Analyzer Configuration 31 | # Enable/disable specific IMSI catcher detection heuristics 32 | # See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details 33 | [analyzers] 34 | imsi_requested = true 35 | connection_redirect_2g_downgrade = true 36 | lte_sib6_and_7_downgrade = true 37 | null_cipher = true 38 | nas_null_cipher = true 39 | incomplete_sib = true 40 | test_analyzer = false 41 | -------------------------------------------------------------------------------- /installer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "installer" 3 | version = "0.8.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | name = "installer" 8 | crate-type = ["rlib"] 9 | 10 | [[bin]] 11 | name = "installer" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | aes = "0.8.4" 16 | anyhow = "1.0.98" 17 | axum = { version = "0.8.3", features = ["http1", "tokio"], default-features = false } 18 | base64_light = "0.1.5" 19 | block-padding = "0.3.3" 20 | bytes = "1.10.1" 21 | clap = { version = "4.5.37", features = ["derive"] } 22 | env_logger = "0.11.8" 23 | hyper = "1.6.0" 24 | hyper-util = "0.1.11" 25 | md5 = "0.7.0" 26 | md5crypt = "1.0.0" 27 | nusb = "0.1.13" 28 | reqwest = { version = "0.12.15", features = ["json"], default-features = false } 29 | serde = { version = "1.0.219", features = ["derive"] } 30 | sha2 = "0.10.8" 31 | tokio = { version = "1.44.2", features = ["io-util", "macros", "rt"], default-features = false } 32 | tokio-retry2 = "0.5.7" 33 | tokio-stream = "0.1.17" 34 | futures = "0.3" 35 | 36 | [target.'cfg(unix)'.dependencies] 37 | termios = "0.3" 38 | 39 | [target.'cfg(all(target_os = "linux", not(target_os = "android")))'.dependencies.adb_client] 40 | git = "https://github.com/EFForg/adb_client.git" 41 | rev = "e511662394e4fa32865c154c40f81a3d846f700c" 42 | default-features = false 43 | features = ["trans-nusb"] 44 | 45 | [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.adb_client] 46 | git = "https://github.com/EFForg/adb_client.git" 47 | rev = "e511662394e4fa32865c154c40f81a3d846f700c" 48 | default-features = false 49 | features = ["trans-libusb"] 50 | -------------------------------------------------------------------------------- /doc/uninstalling.md: -------------------------------------------------------------------------------- 1 | # Uninstalling 2 | 3 | There is no automated uninstallation routine, so this page documents the routine for some devices. 4 | 5 | ## Orbic 6 | 7 | Run `./installer util orbic-shell --admin-password mypassword`. Refer to the 8 | installation instructions for how to find out the admin password. 9 | 10 | Inside, run: 11 | 12 | ```shell 13 | echo 3 > /usrdata/mode.cfg # only relevant if you previously installed via ADB installer 14 | rm -rf /data/rayhunter /etc/init.d/rayhunter_daemon /bin/rootshell 15 | reboot 16 | ``` 17 | 18 | Your device is now Rayhunter-free, and should no longer be rooted. 19 | 20 | ## TPLink 21 | 22 | 1. Run `./installer util tplink-shell` to obtain rootshell on the device. 23 | 3. `rm /data/rayhunter /etc/init.d/rayhunter_daemon` 24 | 4. `update-rc.d rayhunter_daemon remove` 25 | 5. (hardware revision v4.0+ only) In `Settings > NAT Settings > Port Triggers` in TP-Link's admin UI, remove any leftover port triggers. 26 | 27 | ## UZ801 28 | 29 | 0. (Optional): Back up the qmdl folder with all of the captures: 30 | `adb pull /data/rayhunter/qmdl .` 31 | 1. Run `adb shell` to get a root shell on the device 32 | 2. Delete the /data/rayhunter folder: `rm -rf /data/rayhunter` 33 | 3. Modify the initmifiservice.sh script to remove the rayhunter 34 | startup line: 35 | ```sh 36 | mount -o remount,rw /system 37 | busybox vi /system/bin/initmifiservice.sh 38 | ``` 39 | Then type 999G (shift+g), then type dd. Then press the colon key (:) and type wq. Finally, press Enter. 40 | 4. Lastly, run `setprop persist.sys.usb.config rndis`. 41 | 5. Type `reboot` to reboot the device. 42 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/ManifestTable.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | {#if $screenIsLgUp} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {#each entries as entry, i} 31 | 32 | {/each} 33 | 34 |
IDStartedLast MessageSizeDownloadAnalysis
35 | {:else} 36 | 37 |
38 | {#each entries as entry} 39 | 40 | {/each} 41 |
42 | {/if} 43 | -------------------------------------------------------------------------------- /doc/introduction.md: -------------------------------------------------------------------------------- 1 | # Rayhunter 2 | 3 | Rayhunter Logo - An Orca taking a bite out of a cellular signal bar 4 | 5 | Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md). 6 | It's also designed to be as easy to install and use as possible, regardless of your level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers. 7 | 8 | → Check out the [installation guide](./installation.md) to get started. 9 | 10 | → To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). 11 | 12 | → For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](./support-feedback-community.md)! 13 | 14 | **LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program. 15 | 16 | *Good Hunting!* 17 | -------------------------------------------------------------------------------- /logo/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /installer-gui/README.md: -------------------------------------------------------------------------------- 1 | # Rayhunter GUI Installer 2 | 3 | This directory contains experimental work on a Rayhunter GUI installer based on [Tauri](https://tauri.app/). 4 | 5 | ## Dependencies 6 | 7 | Before building the GUI installer, you'll first need to install its dependencies. 8 | 9 | ### Tauri Dependencies 10 | 11 | You'll need to install [Tauri's dependencies](https://tauri.app/start/prerequisites/). In addition to Rust, you'll need [Node.js/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). If you're on Linux, also be sure to install the necessary [system dependencies](https://tauri.app/start/prerequisites/#linux) from your package manager. 12 | 13 | ### Rayhunter CLI Installer 14 | 15 | The GUI installer pulls in the CLI installer as a library. Like with the CLI installer, the firmware binary needs to be present and can be overridden with the same envvars. See `../installer/build.rs` for options. 16 | 17 | For example, to build the firmware in development mode and then provide the path explicitly: 18 | 19 | ```bash 20 | cargo build-daemon-firmware-devel 21 | 22 | (cd installer-gui && FILE_RAYHUNTER_DAEMON=$PWD/../target/armv7-unknown-linux-musleabihf/firmware-devel/rayhunter-daemon npm run tauri android build) 23 | ``` 24 | 25 | ## Building 26 | 27 | After preparing dependencies, the GUI installer can be built by: 28 | 29 | 1. Running `npm install` in this directory. 30 | 2. Running `npm run tauri dev`. 31 | 32 | This will build the GUI installer in development mode. While this command is running, any changes to either the frontend or backend code will cause the installer to be reloaded or rebuilt. 33 | 34 | You can also run `npm run tauri build` to create the final GUI installer artifacts for your OS as is done in CI. 35 | -------------------------------------------------------------------------------- /lib/src/analysis/nas_null_cipher.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use pycrate_rs::nas::NASMessage; 4 | use pycrate_rs::nas::emm::EMMMessage; 5 | use pycrate_rs::nas::generated::emm::emm_security_mode_command::NASSecAlgoCiphAlgo::EPSEncryptionAlgorithmEEA0Null; 6 | 7 | use super::analyzer::{Analyzer, Event, EventType}; 8 | use super::information_element::{InformationElement, LteInformationElement}; 9 | 10 | pub struct NasNullCipherAnalyzer {} 11 | 12 | impl Analyzer for NasNullCipherAnalyzer { 13 | fn get_name(&self) -> Cow<'_, str> { 14 | Cow::from("NAS Null Cipher Requested") 15 | } 16 | 17 | fn get_description(&self) -> Cow<'_, str> { 18 | Cow::from( 19 | "Tests whether the MME requests to use a null cipher in the NAS security mode command", 20 | ) 21 | } 22 | 23 | fn get_version(&self) -> u32 { 24 | 1 25 | } 26 | 27 | fn analyze_information_element( 28 | &mut self, 29 | ie: &InformationElement, 30 | _packet_num: usize, 31 | ) -> Option { 32 | let payload = match ie { 33 | InformationElement::LTE(inner) => match &**inner { 34 | LteInformationElement::NAS(payload) => payload, 35 | _ => return None, 36 | }, 37 | _ => return None, 38 | }; 39 | 40 | if let NASMessage::EMMMessage(EMMMessage::EMMSecurityModeCommand(req)) = payload 41 | && req.nas_sec_algo.inner.ciph_algo == EPSEncryptionAlgorithmEEA0Null 42 | { 43 | return Some(Event { 44 | event_type: EventType::High, 45 | message: "NAS Security mode command requested null cipher".to_string(), 46 | }); 47 | } 48 | None 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/installer-bug.yaml: -------------------------------------------------------------------------------- 1 | name: Installer Issue 2 | description: File an bug related to an installer issue. 3 | labels: ["bug", "installer"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | options: 9 | - label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) 10 | required: true 11 | - type: input 12 | attributes: 13 | label: Rayhunter Version 14 | placeholder: 'v0.5.0' 15 | validations: 16 | required: true 17 | - type: dropdown 18 | attributes: 19 | label: Device 20 | description: | 21 | What device are you trying to install Rayhunter on? 22 | options: 23 | - Orbic RC400L 24 | - Tplink M7350 25 | - Tplink M7310 26 | - Tmobile TMOHS1 27 | - Wingtech CT2MHS0 28 | - Pinephone 29 | - Other / I'm not sure 30 | validations: 31 | required: true 32 | - type: dropdown 33 | attributes: 34 | label: Installer OS 35 | description: What operating system are running the installer from 36 | multiple: false 37 | options: 38 | - Linux 39 | - macOS 40 | - Windows 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Describe the Issue 46 | description: | 47 | Please describe the issue you're having installing Rayhunter. 48 | Include the logs outputed by the installer program. If the installer 49 | is crashing, please try running the installer with `RUST_BACKTRACE=1` 50 | environment variable set so we can see exactly where the installer is 51 | crashing. 52 | validations: 53 | required: true 54 | -------------------------------------------------------------------------------- /daemon/src/display/wingtech.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::display::DisplayState; 3 | use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; 4 | /// Display support for the Wingtech CT2MHS01 hotspot. 5 | /// 6 | /// Tested on (from `/etc/wt_version`): 7 | /// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP 8 | /// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 9 | /// WT_HARDWARE_VERSION=89323_1_20 10 | use async_trait::async_trait; 11 | 12 | use tokio::sync::mpsc::Receiver; 13 | use tokio_util::sync::CancellationToken; 14 | use tokio_util::task::TaskTracker; 15 | 16 | const FB_PATH: &str = "/dev/fb0"; 17 | 18 | #[derive(Copy, Clone, Default)] 19 | struct Framebuffer; 20 | 21 | #[async_trait] 22 | impl GenericFramebuffer for Framebuffer { 23 | fn dimensions(&self) -> Dimensions { 24 | Dimensions { 25 | height: 128, 26 | width: 160, 27 | } 28 | } 29 | 30 | async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) { 31 | let mut raw_buffer = Vec::new(); 32 | for (r, g, b) in buffer { 33 | let mut rgb565: u16 = (r as u16 & 0b11111000) << 8; 34 | rgb565 |= (g as u16 & 0b11111100) << 3; 35 | rgb565 |= (b as u16) >> 3; 36 | raw_buffer.extend(rgb565.to_le_bytes()); 37 | } 38 | 39 | tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap(); 40 | } 41 | } 42 | 43 | pub fn update_ui( 44 | task_tracker: &TaskTracker, 45 | config: &config::Config, 46 | shutdown_token: CancellationToken, 47 | ui_update_rx: Receiver, 48 | ) { 49 | generic_framebuffer::update_ui( 50 | task_tracker, 51 | config, 52 | Framebuffer, 53 | shutdown_token, 54 | ui_update_rx, 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /doc/reanalyzing.md: -------------------------------------------------------------------------------- 1 | # Re-analyzing recordings 2 | 3 | Every once in a while, Rayhunter refines its heuristics to detect more kinds of 4 | suspicious behavior, and to reduce noise from incorrect alerts. 5 | 6 | This means that your old green recordings may actually contain data that is now 7 | deemed suspicious, and also old red recordings may become green. 8 | 9 | You can re-analyze any old recording inside of Rayhunter by clicking on "N 10 | warnings" to expand details, then clicking the "re-analyze" button. 11 | 12 | ## Analyzing recordings on Desktop 13 | 14 | If you have a PCAP or QMDL file but no rayhunter, you can analyze it on desktop 15 | using the `rayhunter-check` CLI tool. That tool contains the same heuristics as 16 | Rayhunter and will also work on traffic data captured with other tools, such as 17 | QCSuper. 18 | 19 | Since 0.6.1, `rayhunter-check` is included in the release zipfile. 20 | 21 | You can build `rayhunter-check` from source with the following command: 22 | `cargo build --bin rayhunter-check` 23 | 24 | ## Usage 25 | ```sh 26 | rayhunter-check [OPTIONS] --path 27 | 28 | Options: 29 | -p, --path Path to the PCAP, or QMDL file. If given a directory will 30 | recursively scan all pcap, qmdl, and subdirectories 31 | -P, --pcapify Turn QMDL file into PCAP 32 | --show-skipped Show skipped messages 33 | -q, --quiet Print only warnings 34 | -d, --debug Print debug info 35 | -h, --help Print help 36 | -V, --version Print version 37 | ``` 38 | ### Examples 39 | `rayhunter-check -p ~/Downloads/myfile.qmdl` 40 | 41 | `rayhunter-check -p ~/Downloads/myfile.pcap` 42 | 43 | `rayhunter-check -p ~/Downloads #Check all files in downloads` 44 | 45 | `rayhunter-check -d -p ~/Downloads/myfile.qmdl #run in debug mode` 46 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/ReAnalyzeButton.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 40 | {#snippet icon()} 41 | 42 | 46 | 47 | {/snippet} 48 | 49 | -------------------------------------------------------------------------------- /lib/src/util.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[cfg(target_family = "unix")] 4 | use nix::sys::utsname::uname; 5 | 6 | /// Expose binary and system information. 7 | #[derive(Serialize, Deserialize, Debug)] 8 | pub struct RuntimeMetadata { 9 | /// The cargo package version from this library's cargo.toml, e.g., "1.2.3". 10 | pub rayhunter_version: String, 11 | /// The operating system `sysname` and optionally `release`. e.g., "Linux 3.18.48" or "linux". 12 | pub system_os: String, 13 | /// The CPU architecture in use. e.g., "armv7l" or "arm". 14 | pub arch: String, 15 | } 16 | 17 | impl Default for RuntimeMetadata { 18 | fn default() -> Self { 19 | Self::new() 20 | } 21 | } 22 | 23 | impl RuntimeMetadata { 24 | /// Return the binary and system information, attempting to retrieve 25 | /// attributes from `uname(2)` and falling back to values from 26 | /// `std::env::consts`. 27 | pub fn new() -> Self { 28 | let build_target = RuntimeMetadata { 29 | rayhunter_version: env!("CARGO_PKG_VERSION").to_owned(), 30 | arch: std::env::consts::ARCH.to_string(), 31 | system_os: std::env::consts::OS.to_string(), 32 | }; 33 | 34 | #[cfg(target_family = "windows")] 35 | return build_target; 36 | 37 | #[cfg(target_family = "unix")] 38 | match uname() { 39 | Ok(utsname) => RuntimeMetadata { 40 | rayhunter_version: env!("CARGO_PKG_VERSION").to_owned(), 41 | arch: format!("{}", utsname.machine().to_string_lossy()), 42 | system_os: format!( 43 | "{} {}", 44 | utsname.sysname().to_string_lossy(), 45 | utsname.release().to_string_lossy(), 46 | ), 47 | }, 48 | Err(_) => build_target, 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rayhunter 2 | ![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg) 3 | 4 | ![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) 5 | 6 | Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts, it can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html). 7 | It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives. 8 | 9 | → Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started. 10 | 11 | → To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). 12 | 13 | → For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)! 14 | 15 | → To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/). 16 | 17 | **LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program. 18 | 19 | *Good Hunting!* 20 | -------------------------------------------------------------------------------- /tools/nasparse_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import unittest 3 | import nasparse 4 | 5 | 6 | class TestNasparse(unittest.TestCase): 7 | imsi_sent_msg = '07412208391185184409309005f0700000100030023ed031d127298080211001000010810600000000830600000000000d00000300ff0003130184000a000005000010005c0a009011034f18a6f15d0103c1000000000000' 8 | sec_imsi_sent_msg = '1727db4b7c0207412208391185184409309005f0700000100030023ed031d127298080211001000010810600000000830600000000000d00000300ff0003130184000a000005000010005c0a009011034f18a6f15d0103c1' 9 | non_nas_msg = 'deadbeefcafe' 10 | other_nas_msg = '074413780004023fd121' 11 | other_nas_mt_msg = "023fd12100000000000000000000000000000000000000000000000000000000" 12 | ciphered_nas_msg = "27ed6146bd0162a5d62d62e1ce501720dc8bd84f1167fd" 13 | 14 | def run_heur(self, msg): 15 | buf = nasparse.parse_nas_message(msg) 16 | return nasparse.heur_ue_imsi_sent(buf)[0] 17 | 18 | def test_imsi_sent(self): 19 | self.assertEqual(self.run_heur(self.imsi_sent_msg), True, "imsi_sent_msg should trigger heuristic") 20 | 21 | def test_sec_imsi_sent(self): 22 | self.assertEqual(self.run_heur(self.imsi_sent_msg), True, "sec_imsi_sent_msg should trigger heuristic") 23 | 24 | def test_non_nas_msg(self): 25 | with self.assertRaises(TypeError): 26 | self.run_heur(self.non_nas_msg) 27 | 28 | def test_other_nas(self): 29 | self.assertEqual(self.run_heur(self.other_nas_msg), False, "other_nas_msg should not trigger heuristic") 30 | 31 | def test_other_nas_mt(self): 32 | self.assertEqual(self.run_heur(self.other_nas_mt_msg), False, "other_nas_mt_msg should not trigger heuristic") 33 | 34 | def test_ciphered_nas(self): 35 | self.assertEqual(self.run_heur(self.ciphered_nas_msg), False, "ciphered_nas_msg should not trigger heuristic") 36 | 37 | if __name__ == '__main__': 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /doc/supported-devices.md: -------------------------------------------------------------------------------- 1 | # Supported devices 2 | 3 | Be sure to check your location's [supported frequencies](https://www.frequencycheck.com/) against a device page before obtaining a device. 4 | 5 | ## 1. Recommended devices 6 | These devices have been extensively tested by the core developers and are widely used. **Use one of these devices if you can.** 7 | 8 | | Device | Recommended region | 9 | | ------ | ------ | 10 | | [Orbic RC400L](./orbic.md) Sometimes also branded Kajeet RC400L | Americas | 11 | | [TP-Link M7350](./tplink-m7350.md) | Africa, Europe, Middle East | 12 | 13 | The TP-Link M7350 also works in the Americas but is usually more expensive. 14 | 15 | ![device_regions](device_regions.svg) 16 | _Derivative work of [this file](https://commons.wikimedia.org/wiki/File:International_Telecommunication_Union_regions_with_dividing_lines.svg) by [Maximillian Dörrbecker](https://de.wikipedia.org/wiki/User:Chumwa) licensed [CC BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5)_ 17 | 18 | ## 2. Functional devices 19 | Rayhunter is confirmed to work on these devices. 20 | 21 | | Device | Recommended region | 22 | | ------ | ------ | 23 | | [Wingtech CT2MHS01](./wingtech-ct2mhs01.md) | Americas | 24 | | [Tmobile TMOHS1](./tmobile-tmohs1.md) | Americas | 25 | | [TP-Link M7310](./tplink-m7310.md) | Africa, Europe, Middle East | 26 | | [PinePhone and PinePhone Pro](./pinephone.md) | Global | 27 | | [FY UZ801](./uz801.md) | Asia, Europe | 28 | | [Moxee hotspot](./moxee.md) | Americas | 29 | 30 | ## Adding new devices 31 | Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it. 32 | 33 | If you have a device in mind which you'd like Rayhunter to support, please [open a discussion on our Github](https://github.com/EFForg/rayhunter/discussions)! 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # To use: navigate on Github to Actions, select "Release rayhunter" on the left, click "Run workflow" > "Run workflow" on the right. 2 | # https://github.com/EFForg/rayhunter/actions/workflows/release.yml 3 | name: Release rayhunter 4 | on: 5 | workflow_dispatch: 6 | 7 | env: 8 | GH_TOKEN: ${{ github.token }} 9 | 10 | jobs: 11 | check_version_same: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | - name: Ensure all Cargo.toml files have the same version defined. 20 | run: | 21 | defined_versions=$(find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l) 22 | find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; 23 | echo number of defined versions = $defined_versions 24 | if [ $defined_versions != "1" ] 25 | then 26 | echo "all Cargo.toml files must have the same version defined" 27 | exit 1 28 | fi 29 | 30 | main: 31 | needs: check_version_same 32 | permissions: 33 | contents: write 34 | id-token: write 35 | packages: write 36 | pages: write 37 | uses: ./.github/workflows/main.yml 38 | 39 | release: 40 | runs-on: ubuntu-latest 41 | needs: main 42 | permissions: 43 | contents: write 44 | steps: 45 | - uses: actions/checkout@v4 46 | with: 47 | persist-credentials: false 48 | - uses: actions/download-artifact@v4 49 | - name: Create release 50 | run: | 51 | version=$(grep ^version lib/Cargo.toml | cut -d' ' -f3 | tr -d '"') 52 | gh release create --generate-notes -t "Rayhunter v$version" "v$version" rayhunter-v${version}-*/rayhunter-v${version}*.zi* 53 | -------------------------------------------------------------------------------- /tools/nasparse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import pycrate_mobile 3 | from pycrate_mobile import NASLTE 4 | import pycrate_core 5 | import binascii 6 | import sys 7 | import pprint 8 | from enum import Enum 9 | 10 | import pycrate_mobile.TS24301_EMM 11 | 12 | EPS_IMSI_ATTACH = 2 13 | 14 | def parse_nas_message(buffer, uplink=None): 15 | if isinstance(buffer, str): #handle string argument or raw bytes 16 | bin = binascii.unhexlify(buffer) 17 | else: 18 | bin = buffer 19 | if uplink: 20 | parsed = NASLTE.parse_NASLTE_MO(bin) 21 | elif uplink == None: #We don't know if its an up or downlink 22 | parsed = NASLTE.parse_NASLTE_MO(bin) 23 | if parsed[0] == None: 24 | parsed = NASLTE.parse_NASLTE_MT(bin) 25 | else: 26 | parsed = NASLTE.parse_NASLTE_MT(bin) 27 | 28 | if parsed[0] is None: # Not a NAS Packet 29 | raise TypeError("Not a nas packet") 30 | return parsed[0] 31 | 32 | def heur_ue_imsi_sent(msg): 33 | output = "device transmitted IMSI to base station!" 34 | 35 | if type(msg) not in [pycrate_mobile.TS24301_EMM.EMMAttachRequest, pycrate_mobile.TS24301_EMM.EMMSecProtNASMessage]: 36 | return (False, None) 37 | 38 | if isinstance(msg, pycrate_mobile.TS24301_EMM.EMMSecProtNASMessage): 39 | try: 40 | msg = msg['EMMAttachRequest'] 41 | except pycrate_core.elt.EltErr: 42 | return (False, None) 43 | 44 | if msg['EPSAttachType']['V'].to_int() == EPS_IMSI_ATTACH: #EPSAttachType Value is 'Combined EPS/IMSI Attach (2)' 45 | return (True, output) 46 | return (False, None) 47 | 48 | 49 | if __name__ == "__main__": 50 | if len(sys.argv) != 2: 51 | print("usage: nasparse.py [hex encoded nas message]") 52 | exit(1) 53 | 54 | buffer = sys.argv[1] 55 | msg = parse_nas_message(buffer) 56 | pprint.pprint(msg) 57 | triggered, message = heur_ue_imsi_sent(msg) 58 | if triggered: 59 | print(message) 60 | exit(1) -------------------------------------------------------------------------------- /daemon/src/config.rs: -------------------------------------------------------------------------------- 1 | use log::warn; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use rayhunter::Device; 5 | use rayhunter::analysis::analyzer::AnalyzerConfig; 6 | 7 | use crate::error::RayhunterError; 8 | use crate::notifications::NotificationType; 9 | 10 | #[derive(Debug, Clone, Deserialize, Serialize)] 11 | #[serde(default)] 12 | pub struct Config { 13 | pub qmdl_store_path: String, 14 | pub port: u16, 15 | pub debug_mode: bool, 16 | pub device: Device, 17 | pub ui_level: u8, 18 | pub colorblind_mode: bool, 19 | pub key_input_mode: u8, 20 | pub ntfy_url: Option, 21 | pub enabled_notifications: Vec, 22 | pub analyzers: AnalyzerConfig, 23 | } 24 | 25 | impl Default for Config { 26 | fn default() -> Self { 27 | Config { 28 | qmdl_store_path: "/data/rayhunter/qmdl".to_string(), 29 | port: 8080, 30 | debug_mode: false, 31 | device: Device::Orbic, 32 | ui_level: 1, 33 | colorblind_mode: false, 34 | key_input_mode: 0, 35 | analyzers: AnalyzerConfig::default(), 36 | ntfy_url: None, 37 | enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], 38 | } 39 | } 40 | } 41 | 42 | pub async fn parse_config

(path: P) -> Result 43 | where 44 | P: AsRef, 45 | { 46 | if let Ok(config_file) = tokio::fs::read_to_string(&path).await { 47 | Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?) 48 | } else { 49 | warn!("unable to read config file, using default config"); 50 | Ok(Config::default()) 51 | } 52 | } 53 | 54 | pub struct Args { 55 | pub config_path: String, 56 | } 57 | 58 | pub fn parse_args() -> Args { 59 | let args: Vec = std::env::args().collect(); 60 | if args.len() != 2 { 61 | println!("Usage: {} /path/to/config/file", args[0]); 62 | std::process::exit(1); 63 | } 64 | Args { 65 | config_path: args[1].clone(), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/RecordingControls.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

11 | {#if server_is_recording} 12 | 18 | {#snippet icon()} 19 | 32 | {/snippet} 33 | 34 | {:else} 35 | 41 | {#snippet icon()} 42 | 57 | {/snippet} 58 | 59 | {/if} 60 |
61 | -------------------------------------------------------------------------------- /daemon/web/src/lib/analysisManager.svelte.ts: -------------------------------------------------------------------------------- 1 | import { get_report, type AnalysisReport } from './analysis.svelte'; 2 | import { req } from './utils.svelte'; 3 | 4 | export enum AnalysisStatus { 5 | // rayhunter is currently analyzing this entry (note that this is distinct 6 | // from the currently-recording entry) 7 | Running, 8 | // this entry is queued to be analyzed 9 | Queued, 10 | // analysis is finished, and the new report can be accessed 11 | Finished, 12 | } 13 | 14 | type AnalysisStatusJson = { 15 | running: string | null; 16 | queued: string[]; 17 | finished: string[]; 18 | }; 19 | 20 | export type AnalysisResult = { 21 | name: string; 22 | status: AnalysisStatus; 23 | }; 24 | 25 | export class AnalysisManager { 26 | public status: Map = $state(new Map()); 27 | public reports: Map = $state(new Map()); 28 | public set_queued_status(name: string) { 29 | this.status.set(name, AnalysisStatus.Queued); 30 | this.reports.delete(name); 31 | } 32 | 33 | public async update() { 34 | const status: AnalysisStatusJson = JSON.parse(await req('GET', '/api/analysis')); 35 | if (status.running) { 36 | this.status.set(status.running, AnalysisStatus.Running); 37 | } 38 | 39 | for (const entry of status.queued) { 40 | this.status.set(entry, AnalysisStatus.Queued); 41 | } 42 | 43 | for (const entry of status.finished) { 44 | // if entry was already finished, nothing to do 45 | if (this.status.get(entry) === AnalysisStatus.Finished) { 46 | continue; 47 | } 48 | 49 | this.status.set(entry, AnalysisStatus.Finished); 50 | 51 | // fetch the analysis report 52 | this.reports.delete(entry); 53 | get_report(entry) 54 | .then((report) => { 55 | this.reports.set(entry, report); 56 | }) 57 | .catch((err) => { 58 | this.reports.set(entry, `Failed to get analysis: ${err}`); 59 | }); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /doc/orbic.md: -------------------------------------------------------------------------------- 1 | # Orbic/Kajeet RC400L 2 | 3 | The Orbic RC400L is an inexpensive LTE modem primarily designed for the US market, and the original device for which Rayhunter is developed. 4 | 5 | It is also sometimes sold under the brand Kajeet RC400L. This is the exact same hardware and can be treated the same. 6 | 7 | You can buy an Orbic [using bezos 8 | bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y), 9 | or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l). 10 | 11 | [Please check whether the Orbic works in your country](https://www.frequencycheck.com/countries/), and whether the Orbic RC400L supports the right frequency bands for your purpose before buying. 12 | 13 | ## Supported Bands 14 | 15 | | Frequency | Band | 16 | | ------- | ------------------ | 17 | | 5G (wideband,midband,nationwide) | n260/n261, n77, n2/5/48/66 | 18 | | 4G | 2/4/5/12/13/48/66 | 19 | | Global & Roaming | n257/n78 | 20 | | Wifi 2.4Ghz | b/g/n | 21 | | Wifi 5Ghz | a/ac/ax | 22 | | Wifi 6 | 🮱 | 23 | 24 | ## Two kinds of installers 25 | 26 | The orbic's installation routine underwent many different changes: 27 | 28 | 1. The ADB-based shellscript prior to version 0.3.0 29 | 2. The Rust-based, ADB-based installer since version 0.3.0 30 | 3. Then, starting with 0.6.0, an alternative installer `./installer 31 | orbic-network` that is supposed to work more reliably, can run over the 32 | Orbic's WiFi connection and without the need to manually install USB drivers 33 | on Windows. 34 | 4. Starting with 0.8.0, `orbic-network` has been renamed to `orbic`, and the 35 | old `./installer orbic` is now called `./installer orbic-usb`. 36 | 37 | It's possible that many tutorials out there still refer to some of the old 38 | installation routines. 39 | 40 | ## Obtaining a shell 41 | 42 | After running the installer, there will not be a rootshell and ADB will not be 43 | enabled. Instead you can use `./installer util orbic-shell`. 44 | 45 | If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can 46 | obtain a root shell by running `adb shell` or `./installer util shell`. Then, 47 | inside of that shell you can run `/bin/rootshell` to obtain "fakeroot." 48 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | # Build the daemon with "firmware" profile and "ring" TLS backend. 3 | # Requires a cross-compiler (see github actions workflows) and is very slow to build. 4 | build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features ring-tls" 5 | # Build the daemon with "firmware-devel" profile and "rustcrypto" backend. 6 | # Works with just the Rust toolchain, and is medium-slow to build. Binaries are slightly larger. 7 | build-daemon-firmware-devel = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware-devel" 8 | 9 | [target.aarch64-apple-darwin] 10 | linker = "rust-lld" 11 | rustflags = ["-C", "target-feature=+crt-static"] 12 | 13 | [target.aarch64-unknown-linux-musl] 14 | linker = "rust-lld" 15 | rustflags = ["-C", "target-feature=+crt-static"] 16 | 17 | # apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf 18 | [target.armv7-unknown-linux-gnueabihf] 19 | linker = "arm-linux-gnueabihf-gcc" 20 | rustflags = ["-C", "target-feature=+crt-static"] 21 | 22 | [target.armv7-unknown-linux-musleabihf] 23 | linker = "rust-lld" 24 | rustflags = ["-C", "target-feature=+crt-static"] 25 | 26 | [target.armv7-unknown-linux-musleabi] 27 | linker = "rust-lld" 28 | rustflags = ["-C", "target-feature=+crt-static"] 29 | 30 | # Disable rust-lld for x86 macOS because the linker crashers when compiling 31 | # the installer in release mode with debug info on. 32 | # [target.x86_64-apple-darwin] 33 | # linker = "rust-lld" 34 | # rustflags = ["-C", "target-feature=+crt-static"] 35 | 36 | [target.x86_64-unknown-linux-musl] 37 | linker = "rust-lld" 38 | rustflags = ["-C", "target-feature=+crt-static"] 39 | 40 | [profile.release] 41 | # keep line numbers in stack traces for non-firmware binaries 42 | debug = "limited" 43 | lto = "fat" 44 | opt-level = "z" 45 | strip = "debuginfo" 46 | 47 | [profile.firmware-devel] 48 | inherits = "release" 49 | opt-level = "s" 50 | lto = false 51 | 52 | # optimizations to reduce the binary size of firmware binaries 53 | [profile.firmware] 54 | inherits = "release" 55 | strip = true 56 | codegen-units = 1 57 | panic = "abort" 58 | debug = false 59 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/AnalysisView.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 | {#if entry.analysis_report === undefined} 20 |

Report unavailable, try refreshing.

21 | {:else if typeof entry.analysis_report === 'string'} 22 |

Error getting analysis report: {entry.analysis_report}

23 | {:else} 24 | {@const metadata: ReportMetadata = entry.analysis_report.metadata} 25 |
26 | {#if !current} 27 |
28 | 29 |
30 | {/if} 31 | {#if entry.analysis_report.rows.length > 0} 32 | 33 | {:else} 34 |

No warnings to display!

35 | {/if} 36 | {#if metadata !== undefined && metadata.rayhunter !== undefined} 37 |
38 |

Metadata

39 |

Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}

40 |

Device system OS: {metadata.rayhunter.system_os}

41 |
42 |
43 |

Analyzers

44 | {#each metadata.analyzers as analyzer} 45 |

{analyzer.name}: {analyzer.description}

46 | {/each} 47 |
48 | {:else} 49 |

N/A (analysis generated by an older version of rayhunter)

50 | {/if} 51 |
52 | {/if} 53 |
54 | -------------------------------------------------------------------------------- /doc/pinephone.md: -------------------------------------------------------------------------------- 1 | # PinePhone and PinePhone Pro 2 | 3 | The PinePhone and PinePhone Pro both use a Qualcomm mdm9607 modem as part of their [Quectel EG25-G LTE module](https://www.quectel.com/product/lte-eg25-g/). The EG25-G has global LTE band support and contains a GNSS positioning module. Rayhunter does not currently make direct use of GNSS. 4 | 5 | The modem is fully capable of running Rayhunter, but lacks both a screen and a network connection. The modem exposes an AT interface that can enable adb. 6 | 7 | ## Hardware 8 | - 9 | - 10 | 11 | ## Supported bands 12 | 13 | | Band | Frequency | 14 | | ---- | ----------------- | 15 | | 1 | 2100 MHz (IMT) | 16 | | 2 | 1900 MHz (PCS) | 17 | | 3 | 1800 MHz (DCS) | 18 | | 4 | 1700 MHz (AWS-1) | 19 | | 5 | 850 MHz (CLR) | 20 | | 7 | 2600 MHz (IMT-E) | 21 | | 8 | 900 MHz (E-GSM) | 22 | | 12 | 700 MHz (LSMH) | 23 | | 13 | 700 MHz (USMH) | 24 | | 18 | 850 MHz (LSMH) | 25 | | 19 | 850 MHz (L800) | 26 | | 20 | 800 MHz (DD) | 27 | | 25 | 1900 MHz (E-PCS) | 28 | | 26 | 850 MHz (E-CLR) | 29 | | 28 | 700 MHz (APT) | 30 | | 38 | 2600 MHz (IMT-E) | 31 | | 39 | 850 MHz (E-CLR) | 32 | | 40 | 2300 MHz (S-Band) | 33 | | 41 | 2500 MHz (BRS) | 34 | 35 | Note that the Quectel EG25-G does not support LTE band 48 (CBRS 3500MHz), used in the US for unlicensed 4G/5G connectivity. 36 | 37 | ## Installing 38 | Download and extract the installer *on a shell on the PinePhone itself*. Unlike other Rayhunter installers, this has to be run on the device itself. Then run: 39 | 40 | ```sh 41 | ./installer pinephone 42 | ``` 43 | 44 | ## Accessing Rayhunter 45 | Because the modem does not have its own display or network interface, Rayhunter is only accessible on the pinephone by forwarding tcp over adb. 46 | 47 | ```sh 48 | adb forward tcp:8080 tcp:8080 49 | ``` 50 | 51 | ## Shell access 52 | Use this command to enable adb access: 53 | 54 | ```sh 55 | ./installer util pinephone-start-adb 56 | adb shell 57 | ``` 58 | 59 | ## Power saving (disable adb) 60 | The modem won't be able to sleep (power save) with adb enabled, even if Rayhunter is stopped. Disable adb with the following command: 61 | 62 | ```sh 63 | ./installer util pinephone-stop-adb 64 | ``` 65 | -------------------------------------------------------------------------------- /daemon/web/src/lib/analysis.svelte.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { AnalysisRowType, parse_finished_report } from './analysis.svelte'; 3 | import { type NewlineDeliminatedJson } from './ndjson'; 4 | 5 | const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [ 6 | { 7 | analyzers: [ 8 | { 9 | name: 'Analyzer 1', 10 | description: 'A first analyzer', 11 | version: 2, 12 | }, 13 | { 14 | name: 'Analyzer 2', 15 | description: 'A second analyzer', 16 | version: 2, 17 | }, 18 | ], 19 | report_version: 2, 20 | }, 21 | { 22 | skipped_message_reason: 'The reason why the message was skipped', 23 | }, 24 | { 25 | packet_timestamp: '2024-08-19T03:33:54.318Z', 26 | events: [ 27 | null, 28 | { 29 | event_type: 'Low', 30 | message: 'Something nasty happened', 31 | }, 32 | ], 33 | }, 34 | ]; 35 | 36 | describe('analysis report parsing', () => { 37 | it('parses v2 example analysis', () => { 38 | const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON); 39 | expect(report.metadata.report_version).toEqual(2); 40 | expect(report.metadata.analyzers).toEqual([ 41 | { 42 | name: 'Analyzer 1', 43 | description: 'A first analyzer', 44 | version: 2, 45 | }, 46 | { 47 | name: 'Analyzer 2', 48 | description: 'A second analyzer', 49 | version: 2, 50 | }, 51 | ]); 52 | expect(report.rows).toHaveLength(2); 53 | expect(report.rows[0].type).toBe(AnalysisRowType.Skipped); 54 | if (report.rows[1].type === AnalysisRowType.Analysis) { 55 | const row = report.rows[1]; 56 | expect(row.events).toHaveLength(2); 57 | expect(row.events[0]).toBeNull(); 58 | const event = row.events[1]; 59 | const expected_timestamp = new Date('2024-08-19T03:33:54.318Z'); 60 | expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime()); 61 | expect(event!.event_type).toEqual('Low'); 62 | } else { 63 | throw 'wrong row type'; 64 | } 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /lib/src/analysis/connection_redirect_downgrade.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use super::analyzer::{Analyzer, Event, EventType}; 4 | use super::information_element::{InformationElement, LteInformationElement}; 5 | use telcom_parser::lte_rrc::{ 6 | DL_DCCH_MessageType, DL_DCCH_MessageType_c1, RRCConnectionReleaseCriticalExtensions, 7 | RRCConnectionReleaseCriticalExtensions_c1, RedirectedCarrierInfo, 8 | }; 9 | 10 | // Based on HITBSecConf presentation "Forcing a targeted LTE cellphone into an 11 | // eavesdropping network" by Lin Huang 12 | pub struct ConnectionRedirect2GDowngradeAnalyzer {} 13 | 14 | // TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones 15 | impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer { 16 | fn get_name(&self) -> Cow<'_, str> { 17 | Cow::from("Connection Release/Redirected Carrier 2G Downgrade") 18 | } 19 | 20 | fn get_description(&self) -> Cow<'_, str> { 21 | Cow::from("Tests if a cell releases our connection and redirects us to a 2G cell.") 22 | } 23 | 24 | fn get_version(&self) -> u32 { 25 | 1 26 | } 27 | 28 | fn analyze_information_element( 29 | &mut self, 30 | ie: &InformationElement, 31 | _packet_num: usize, 32 | ) -> Option { 33 | if let InformationElement::LTE(lte_ie) = ie 34 | && let LteInformationElement::DlDcch(msg_cont) = &**lte_ie 35 | && let DL_DCCH_MessageType::C1(c1) = &msg_cont.message 36 | && let DL_DCCH_MessageType_c1::RrcConnectionRelease(release) = c1 37 | && let RRCConnectionReleaseCriticalExtensions::C1(c1) = &release.critical_extensions 38 | && let RRCConnectionReleaseCriticalExtensions_c1::RrcConnectionRelease_r8(r8_ies) = c1 39 | && let Some(carrier_info) = &r8_ies.redirected_carrier_info 40 | { 41 | match carrier_info { 42 | RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event { 43 | event_type: EventType::High, 44 | message: "Detected 2G downgrade".to_owned(), 45 | }), 46 | _ => Some(Event { 47 | event_type: EventType::Informational, 48 | message: format!("RRCConnectionRelease CarrierInfo: {carrier_info:?}"), 49 | }), 50 | } 51 | } else { 52 | None 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tools/asn1grep.py: -------------------------------------------------------------------------------- 1 | import asn1tools 2 | import sys 3 | 4 | ASN_FILES = [ 5 | '../telcom-parser/specs/PC5-RRC-Definitions.asn', 6 | '../telcom-parser/specs/EUTRA-RRC-Definitions.asn', 7 | ] 8 | 9 | TERMINATING_TYPE_NAMES = [ 10 | 'DL-CCCH-Message', 11 | 'DL-DCCH-Message', 12 | 'UL-CCCH-Message', 13 | 'UL-DCCH-Message', 14 | 'BCCH-BCH-Message', 15 | 'BCCH-DL-SCH-Message', 16 | 'PCCH-Message', 17 | 'MCCH-Message', 18 | 'SC-MCCH-Message-r13', 19 | 'BCCH-BCH-Message-MBMS', 20 | 'BCCH-DL-SCH-Message-BR', 21 | 'BCCH-DL-SCH-Message-MBMS', 22 | 'SBCCH-SL-BCH-Message', 23 | 'SBCCH-SL-BCH-Message-V2X-r14', 24 | ] 25 | 26 | def load_asn(): 27 | return asn1tools.compile_files(ASN_FILES, cache_dir=".cache") 28 | 29 | def get_terminating_types(rrc_asn): 30 | return [rrc_asn.types[name] for name in TERMINATING_TYPE_NAMES] 31 | 32 | def search_type(haystack, needle): 33 | if haystack.type_name == needle or haystack.name == needle: 34 | return [needle] 35 | 36 | result = [] 37 | if 'members' in haystack.__dict__: 38 | for name, member in haystack.name_to_member.items(): 39 | for member_result in search_type(member, needle): 40 | result.append(f"{haystack.name} ({haystack.type_name}).{name}\n {member_result}") 41 | elif 'root_members' in haystack.__dict__: 42 | for member in haystack.root_members: 43 | for member_result in search_type(member, needle): 44 | result.append(f"{haystack.name} ({haystack.type_name})\n {member_result}") 45 | elif 'element_type' in haystack.__dict__: 46 | for element_result in search_type(haystack.element_type, needle): 47 | result.append(f"{haystack.name}[0] ({haystack.type_name})\n {element_result}") 48 | elif 'inner' in haystack.__dict__: 49 | for inner_result in search_type(haystack.inner, needle): 50 | result.append(inner_result) 51 | 52 | return result 53 | 54 | 55 | if __name__ == "__main__": 56 | type_name = sys.argv[1] 57 | print(f"searching for {type_name}") 58 | 59 | rrc_asn = load_asn() 60 | terminating_types = get_terminating_types(rrc_asn) 61 | needle = rrc_asn.types.get(type_name) 62 | if needle == None: 63 | raise ValueError(f"couldn't find type {type}") 64 | 65 | for haystack in terminating_types: 66 | for result in search_type(haystack.type, type_name): 67 | print(result + '\n') 68 | -------------------------------------------------------------------------------- /daemon/web/src/lib/utils.svelte.ts: -------------------------------------------------------------------------------- 1 | import { add_error } from './action_errors.svelte'; 2 | import { Manifest } from './manifest.svelte'; 3 | import type { SystemStats } from './systemStats'; 4 | 5 | export interface AnalyzerConfig { 6 | imsi_requested: boolean; 7 | connection_redirect_2g_downgrade: boolean; 8 | lte_sib6_and_7_downgrade: boolean; 9 | null_cipher: boolean; 10 | nas_null_cipher: boolean; 11 | incomplete_sib: boolean; 12 | test_analyzer: boolean; 13 | } 14 | 15 | export enum enabled_notifications { 16 | Warning = 'Warning', 17 | LowBattery = 'LowBattery', 18 | } 19 | 20 | export interface Config { 21 | ui_level: number; 22 | colorblind_mode: boolean; 23 | key_input_mode: number; 24 | ntfy_url: string; 25 | enabled_notifications: enabled_notifications[]; 26 | analyzers: AnalyzerConfig; 27 | } 28 | 29 | export async function req(method: string, url: string): Promise { 30 | const response = await fetch(url, { 31 | method: method, 32 | }); 33 | const body = await response.text(); 34 | if (response.status >= 200 && response.status < 300) { 35 | return body; 36 | } else { 37 | throw new Error(body); 38 | } 39 | } 40 | 41 | // A wrapper around req that reports errors to the UI 42 | export async function user_action_req( 43 | method: string, 44 | url: string, 45 | error_msg: string 46 | ): Promise { 47 | try { 48 | return await req(method, url); 49 | } catch (error) { 50 | if (error instanceof Error) { 51 | console.log('beeeo'); 52 | add_error(error, error_msg); 53 | } 54 | return undefined; 55 | } 56 | } 57 | 58 | export async function get_manifest(): Promise { 59 | const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest')); 60 | return new Manifest(manifest_json); 61 | } 62 | 63 | export async function get_system_stats(): Promise { 64 | return JSON.parse(await req('GET', '/api/system-stats')); 65 | } 66 | 67 | export async function get_logs(): Promise { 68 | return await req('GET', '/api/log'); 69 | } 70 | 71 | export async function get_config(): Promise { 72 | return JSON.parse(await req('GET', '/api/config')); 73 | } 74 | 75 | export async function set_config(config: Config): Promise { 76 | const response = await fetch('/api/config', { 77 | method: 'POST', 78 | headers: { 79 | 'Content-Type': 'application/json', 80 | }, 81 | body: JSON.stringify(config), 82 | }); 83 | 84 | if (!response.ok) { 85 | const error = await response.text(); 86 | throw new Error(error); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /doc/using-rayhunter.md: -------------------------------------------------------------------------------- 1 | # Using Rayhunter 2 | 3 | Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn yellow dots, orange dashes, or solid red](./faq.md#red) once a potential IMSI catcher has been found, depending on the severity of the alert, until the device is rebooted or a new recording is started through the web UI. 4 | 5 | ![Rayhunter_0 5 0](./Rayhunter_0.5.0.png) 6 | 7 | It also serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, delete captures, and view heuristic analyses of captures. 8 | 9 | ## The web UI 10 | 11 | You can access this UI in one of two ways: 12 | 13 | * **Connect over WiFi:** Connect your phone/laptop to your device's WiFi 14 | network and visit (orbic) 15 | or (tplink). 16 | 17 | Click past your browser warning you about the connection not being secure; Rayhunter doesn't have HTTPS yet. 18 | 19 | On the **Orbic**, you can find the WiFi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon. 20 | On the **TP-Link**, you can find the WiFi network password by going to the TP-Link's menu > Advanced > Wireless > Basic Settings. 21 | 22 | * **Connect over USB (Orbic):** Connect your device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit . 23 | * For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the `releases/platform-tools/` folder to somewhere else in your path or you can install it manually. 24 | * You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet). 25 | * On MacOS, the easiest way to install ADB is with Homebrew: First [install Homebrew](https://brew.sh/), then run `brew install android-platform-tools`. 26 | 27 | * **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you. 28 | 29 | ## Key shortcuts 30 | 31 | As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md). 32 | -------------------------------------------------------------------------------- /installer/src/orbic_auth.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use base64_light::base64_encode; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Helper function to swap characters in a string 6 | fn swap_chars(s: &str, pos1: usize, pos2: usize) -> String { 7 | let mut chars: Vec = s.chars().collect(); 8 | if pos1 < chars.len() && pos2 < chars.len() { 9 | chars.swap(pos1, pos2); 10 | } 11 | chars.into_iter().collect() 12 | } 13 | 14 | /// Apply character swapping based on secret (unchanged from original algorithm) 15 | fn apply_secret_swapping(mut text: String, secret_num: u32) -> String { 16 | for i in 0..4 { 17 | let byte = (secret_num >> (i * 8)) & 0xff; 18 | let pos1 = (byte as usize) % text.len(); 19 | let pos2 = i % text.len(); 20 | text = swap_chars(&text, pos1, pos2); 21 | } 22 | text 23 | } 24 | 25 | /// Encode password using Orbic's custom algorithm 26 | /// 27 | /// This function is a lot simpler than the original JavaScript because it always uses the same 28 | /// character set regardless of "password type", and any randomly generated values are hardcoded. 29 | pub fn encode_password( 30 | password: &str, 31 | secret: &str, 32 | timestamp: &str, 33 | timestamp_start: u64, 34 | ) -> Result { 35 | let current_time = std::time::SystemTime::now() 36 | .duration_since(std::time::UNIX_EPOCH) 37 | .unwrap() 38 | .as_secs(); 39 | 40 | // MD5 hash the password and use fixed prefix "a7" instead of random chars 41 | let password_md5 = format!("{:x}", md5::compute(password)); 42 | let mut spliced_password = format!("a7{}", password_md5); 43 | 44 | let secret_num = u32::from_str_radix(secret, 16).context("Failed to parse secret as hex")?; 45 | 46 | spliced_password = apply_secret_swapping(spliced_password, secret_num); 47 | 48 | let timestamp_hex = 49 | u32::from_str_radix(timestamp, 16).context("Failed to parse timestamp as hex")?; 50 | let time_delta = format!( 51 | "{:x}", 52 | timestamp_hex + (current_time - timestamp_start) as u32 53 | ); 54 | 55 | // Use fixed hex "6137" instead of hex encoding of random values 56 | let message = format!("6137x{}:{}", time_delta, spliced_password); 57 | 58 | let result = base64_encode(&message); 59 | let result = apply_secret_swapping(result, secret_num); 60 | 61 | Ok(result) 62 | } 63 | 64 | #[derive(Debug, Serialize)] 65 | pub struct LoginRequest { 66 | pub username: String, 67 | pub password: String, 68 | } 69 | 70 | #[derive(Debug, Deserialize)] 71 | pub struct LoginInfo { 72 | pub retcode: u32, 73 | #[serde(rename = "priKey")] 74 | pub pri_key: String, 75 | } 76 | 77 | #[derive(Debug, Deserialize)] 78 | pub struct LoginResponse { 79 | pub retcode: u32, 80 | } 81 | -------------------------------------------------------------------------------- /lib/src/analysis/test_analyzer.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1}; 4 | 5 | use super::analyzer::{Analyzer, Event, EventType}; 6 | use super::information_element::{InformationElement, LteInformationElement}; 7 | use deku::bitvec::*; 8 | 9 | pub struct TestAnalyzer {} 10 | 11 | impl Analyzer for TestAnalyzer { 12 | fn get_name(&self) -> Cow<'_, str> { 13 | Cow::from("Test Analyzer") 14 | } 15 | 16 | fn get_description(&self) -> Cow<'_, str> { 17 | Cow::from( 18 | "This is an analyzer which can be used to test that your rayhunter is working. It will generate an alert for every SIB1 message (a beacon from the cell tower) that it sees. Do not leave this on when you are hunting or it will be very noisy.", 19 | ) 20 | } 21 | 22 | fn get_version(&self) -> u32 { 23 | 1 24 | } 25 | 26 | fn analyze_information_element( 27 | &mut self, 28 | ie: &InformationElement, 29 | _packet_num: usize, 30 | ) -> Option { 31 | if let InformationElement::LTE(lte_ie) = ie 32 | && let LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie 33 | && let BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message 34 | && let BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1 35 | { 36 | let cid = sib1 37 | .cell_access_related_info 38 | .cell_identity 39 | .0 40 | .as_bitslice() 41 | .load_be::(); 42 | let plmn = &sib1.cell_access_related_info.plmn_identity_list.0; 43 | let mcc_string: String; 44 | 45 | // MCC are always 3 digits 46 | if let Some(mcc) = &plmn[0].plmn_identity.mcc { 47 | mcc_string = format!("{}{}{}", mcc.0[0].0, mcc.0[1].0, mcc.0[2].0); 48 | } else { 49 | mcc_string = "nomcc".to_string(); 50 | } 51 | let mnc = &plmn[0].plmn_identity.mnc; 52 | let mnc_string: String; 53 | // MNC can be 2 or 3 digits 54 | if mnc.0.len() == 3 { 55 | mnc_string = format!("{}{}{}", mnc.0[0].0, mnc.0[1].0, mnc.0[2].0); 56 | } else if mnc.0.len() == 2 { 57 | mnc_string = format!("{}{}", mnc.0[0].0, mnc.0[1].0); 58 | } else { 59 | mnc_string = format!("{:?}", mnc.0); 60 | } 61 | 62 | return Some(Event { 63 | event_type: EventType::Low, 64 | message: format!( 65 | "SIB1 received CID: {}, PLMN: {}-{}", 66 | cid, mcc_string, mnc_string 67 | ), 68 | }); 69 | } 70 | None 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /dist/scripts/misc-daemon: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | case "$1" in 6 | start) 7 | echo -n "Starting miscellaneous daemons: " 8 | search_dir="/sys/bus/msm_subsys/devices/" 9 | for entry in `ls $search_dir` 10 | do 11 | subsys_temp=`cat $search_dir/$entry/name` 12 | if [ "$subsys_temp" == "modem" ] 13 | then 14 | break 15 | fi 16 | done 17 | counter=0 18 | while [ ${counter} -le 10 ] 19 | do 20 | msstate=`cat $search_dir/$entry/state` 21 | if [ "$msstate" == "ONLINE" ] 22 | then 23 | break 24 | fi 25 | counter=$(( $counter + 1 )) 26 | sleep 1 27 | done 28 | 29 | if [ -f /etc/init.d/init_qcom_audio ] 30 | then 31 | /etc/init.d/init_qcom_audio start 32 | fi 33 | 34 | if [ -f /sbin/reboot-daemon ] 35 | then 36 | /sbin/reboot-daemon & 37 | fi 38 | 39 | if [ -f /etc/init.d/start_atfwd_daemon ] 40 | then 41 | /etc/init.d/start_atfwd_daemon start 42 | fi 43 | 44 | if [ -f /etc/init.d/rayhunter_daemon ] 45 | then 46 | /etc/init.d/rayhunter_daemon start 47 | fi 48 | 49 | if [ -f /etc/init.d/start_stop_qti_ppp_le ] 50 | then 51 | /etc/init.d/start_stop_qti_ppp_le start 52 | fi 53 | 54 | if [ -f /etc/init.d/start_loc_launcher ] 55 | then 56 | /etc/init.d/start_loc_launcher start 57 | fi 58 | 59 | echo -n "Completed starting miscellaneous daemons" 60 | ;; 61 | stop) 62 | echo -n "Stopping miscellaneous daemons: " 63 | 64 | 65 | if [ -f /etc/init.d/start_atfwd_daemon ] 66 | then 67 | /etc/init.d/start_atfwd_daemon stop 68 | fi 69 | 70 | if [ -f /etc/init.d/start_loc_launcher ] 71 | then 72 | /etc/init.d/start_loc_launcher stop 73 | fi 74 | 75 | if [ -f /etc/init.d/rayhunter_daemon ] 76 | then 77 | /etc/init.d/rayhunter_daemon stop 78 | fi 79 | 80 | if [ -f /etc/init.d/init_qcom_audio ] 81 | then 82 | /etc/init.d/init_qcom_audio stop 83 | fi 84 | 85 | if [ -f /etc/init.d/start_stop_qti_ppp_le ] 86 | then 87 | /etc/init.d/start_stop_qti_ppp_le stop 88 | fi 89 | 90 | echo -n "Completed stopping miscellaneous daemons" 91 | ;; 92 | restart) 93 | $0 stop 94 | $0 start 95 | ;; 96 | *) 97 | echo "Usage misc-daemon { start | stop | restart}" >&2 98 | exit 1 99 | ;; 100 | esac 101 | 102 | exit 0 103 | -------------------------------------------------------------------------------- /doc/tmobile-tmohs1.md: -------------------------------------------------------------------------------- 1 | # Tmobile TMOHS1 2 | 3 | The Tmobile TMOHS1 hotspot is a Qualcomm mdm9607-based device with many similarities to the Wingtech CT2MHS01 hotspot. The TMOHS1 has no screen, only 5 LEDs, two of which are RGB. 4 | 5 | ## Hardware 6 | Cheap used versions of the device can be found easily on Ebay, and also from these sellers: 7 | - 8 | - 9 | - 10 | 11 | Rayhunter has been tested on: 12 | 13 | ```sh 14 | WT_INNER_VERSION=SW_Q89527AA1_V045_M11_TMO_USR_MP 15 | WT_PRODUCTION_VERSION=TMOHS1_00.05.20 16 | WT_HARDWARE_VERSION=89527_1_11 17 | ``` 18 | 19 | Please consider sharing the contents of your device's /etc/wt_version file here. 20 | 21 | ## Supported bands 22 | 23 | The TMOHS1 is primarily an ITU Region 2 device, although Bands 5 (CLR) and 41 (BRS) may be suitable for roaming in Region 3. 24 | 25 | According to FCC ID 2APXW-TMOHS1 Test Report No. I20Z61602-WMD02 ([part 1](https://fcc.report/FCC-ID/2APXW-TMOHS1/4987033.pdf), [part 2](https://fcc.report/FCC-ID/2APXW-TMOHS1/4987034.pdf)), the TMOHS1 supports the following LTE bands: 26 | 27 | | Band | Frequency | 28 | | ---- | ---------------- | 29 | | 2 | 1900 MHz (PCS) | 30 | | 4 | 1700 MHz (AWS-1) | 31 | | 5 | 850 MHz (CLR) | 32 | | 12 | 700 MHz (LSMH) | 33 | | 25 | 1900 MHz (E-PCS) | 34 | | 26 | 850 MHz (E-CLR) | 35 | | 41 | 2500 MHz (BRS) | 36 | | 66 | 1700 MHz (E-AWS) | 37 | | 71 | 600 MHz (USDD) | 38 | 39 | ## Installing 40 | Connect to the TMOHS1's network over WiFi or USB tethering. 41 | 42 | The device will not accept web requests until after the default password is changed. 43 | If you have not previously logged in, log in using the default password printed under the battery and change the admin password. 44 | 45 | Then run the installer: 46 | 47 | ```sh 48 | ./installer tmobile --admin-password Admin0123! # replace with your own password 49 | ``` 50 | 51 | ## LED modes 52 | | Rayhunter state | LED indicator | 53 | | ---------------- | ------------------------------ | 54 | | Recording | Signal LED slowly blinks blue. | 55 | | Paused | WiFi LED blinks white. | 56 | | Warning Detected | Signal LED slowly blinks red. | 57 | 58 | ## Obtaining a shell 59 | Even when rayhunter is running, for security reasons the TMOHS1 will not have telnet or adb enabled during normal operation. 60 | 61 | Use either command below to enable telnet or adb access: 62 | 63 | ```sh 64 | ./installer util tmobile-start-telnet --admin-password Admin0123! 65 | telnet 192.168.0.1 66 | ``` 67 | 68 | ```sh 69 | ./installer util tmobile-start-adb --admin-password Admin0123! 70 | adb shell 71 | ``` 72 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/ManifestTableRow.svelte: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | {entry.name} 41 | {date_formatter.format(entry.start_time)} 42 | {(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'} 45 | {entry.get_readable_qmdl_size()} 46 | 47 |
48 | 49 | 50 | 51 |
52 | 53 | 56 | {#if current} 57 | 58 | {:else} 59 | 60 | 65 | 66 | {/if} 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /daemon/src/display/tplink_framebuffer.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use std::os::fd::AsRawFd; 3 | use tokio::fs::OpenOptions; 4 | use tokio::io::AsyncWriteExt; 5 | use tokio_util::sync::CancellationToken; 6 | 7 | use crate::config; 8 | use crate::display::DisplayState; 9 | use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; 10 | 11 | use tokio::sync::mpsc::Receiver; 12 | use tokio_util::task::TaskTracker; 13 | 14 | const FB_PATH: &str = "/dev/fb0"; 15 | 16 | struct Framebuffer; 17 | 18 | #[repr(C)] 19 | struct fb_fillrect { 20 | dx: u32, 21 | dy: u32, 22 | width: u32, 23 | height: u32, 24 | color: u32, 25 | rop: u32, 26 | } 27 | 28 | #[async_trait] 29 | impl GenericFramebuffer for Framebuffer { 30 | fn dimensions(&self) -> Dimensions { 31 | // TODO actually poll for this, maybe w/ fbset? 32 | Dimensions { 33 | height: 128, 34 | width: 128, 35 | } 36 | } 37 | 38 | async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) { 39 | // for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c 40 | let dimensions = self.dimensions(); 41 | let width = dimensions.width; 42 | let height = buffer.len() as u32 / width; 43 | let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap(); 44 | let mut arg = fb_fillrect { 45 | dx: 0, 46 | dy: 0, 47 | width, 48 | height, 49 | color: 0xffff, // not sure what this is 50 | rop: 0, 51 | }; 52 | 53 | let mut raw_buffer = Vec::new(); 54 | for (r, g, b) in buffer { 55 | let mut rgb565: u16 = (r as u16 & 0b11111000) << 8; 56 | rgb565 |= (g as u16 & 0b11111100) << 3; 57 | rgb565 |= (b as u16) >> 3; 58 | // note: big-endian! 59 | raw_buffer.extend(rgb565.to_be_bytes()); 60 | } 61 | 62 | f.write_all(&raw_buffer).await.unwrap(); 63 | 64 | // ioctl is a synchronous operation, but it's fast enough that it shouldn't block 65 | unsafe { 66 | let res = libc::ioctl( 67 | f.as_raw_fd(), 68 | 0x4619, // FBIORECT_DISPLAY 69 | &mut arg as *mut _, 70 | std::mem::size_of::(), 71 | ); 72 | 73 | if res < 0 { 74 | panic!("failed to send FBIORECT_DISPLAY ioctl, {res}"); 75 | } 76 | } 77 | } 78 | } 79 | 80 | pub fn update_ui( 81 | task_tracker: &TaskTracker, 82 | config: &config::Config, 83 | shutdown_token: CancellationToken, 84 | ui_update_rx: Receiver, 85 | ) { 86 | generic_framebuffer::update_ui( 87 | task_tracker, 88 | config, 89 | Framebuffer, 90 | shutdown_token, 91 | ui_update_rx, 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /daemon/src/display/tmobile.rs: -------------------------------------------------------------------------------- 1 | /// Display module for Tmobile TMOHS1, blink LEDs on the front of the device. 2 | /// DisplayState::Recording => Signal LED slowly blinks blue. 3 | /// DisplayState::Paused => WiFi LED blinks white. 4 | /// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red. 5 | use log::{error, info}; 6 | use tokio::sync::mpsc; 7 | use tokio_util::sync::CancellationToken; 8 | use tokio_util::task::TaskTracker; 9 | 10 | use std::time::Duration; 11 | 12 | use crate::config; 13 | use crate::display::DisplayState; 14 | 15 | macro_rules! led { 16 | ($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }}; 17 | } 18 | 19 | async fn start_blinking(path: String) { 20 | tokio::fs::write(&path, "1").await.ok(); 21 | } 22 | 23 | async fn stop_blinking(path: String) { 24 | tokio::fs::write(&path, "0").await.ok(); 25 | } 26 | 27 | pub fn update_ui( 28 | task_tracker: &TaskTracker, 29 | config: &config::Config, 30 | shutdown_token: CancellationToken, 31 | mut ui_update_rx: mpsc::Receiver, 32 | ) { 33 | let mut invisible: bool = false; 34 | if config.ui_level == 0 { 35 | info!("Invisible mode, not spawning UI."); 36 | invisible = true; 37 | } 38 | task_tracker.spawn(async move { 39 | let mut state = DisplayState::Recording; 40 | let mut last_state = DisplayState::Paused; 41 | 42 | loop { 43 | if shutdown_token.is_cancelled() { 44 | info!("received UI shutdown"); 45 | break; 46 | } 47 | match ui_update_rx.try_recv() { 48 | Ok(new_state) => state = new_state, 49 | Err(mpsc::error::TryRecvError::Empty) => {} 50 | Err(e) => error!("error receiving ui update message: {e}"), 51 | }; 52 | if invisible || state == last_state { 53 | tokio::time::sleep(Duration::from_secs(1)).await; 54 | continue; 55 | } 56 | match state { 57 | DisplayState::Paused => { 58 | stop_blinking(led!("signal_blue")).await; 59 | stop_blinking(led!("signal_red")).await; 60 | start_blinking(led!("wlan_white")).await; 61 | } 62 | DisplayState::Recording => { 63 | stop_blinking(led!("wlan_white")).await; 64 | stop_blinking(led!("signal_red")).await; 65 | start_blinking(led!("signal_blue")).await; 66 | } 67 | DisplayState::WarningDetected { .. } => { 68 | stop_blinking(led!("wlan_white")).await; 69 | stop_blinking(led!("signal_blue")).await; 70 | start_blinking(led!("signal_red")).await; 71 | } 72 | } 73 | last_state = state; 74 | tokio::time::sleep(Duration::from_secs(1)).await; 75 | } 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /daemon/src/display/uz801.rs: -------------------------------------------------------------------------------- 1 | /// Display module for Uz801, light LEDs on the front of the device. 2 | /// DisplayState::Recording => Green LED is solid. 3 | /// DisplayState::Paused => Signal LED is solid blue (wifi LED). 4 | /// DisplayState::WarningDetected => Signal LED is solid red. 5 | use log::{error, info}; 6 | use tokio::sync::mpsc; 7 | use tokio_util::sync::CancellationToken; 8 | use tokio_util::task::TaskTracker; 9 | 10 | use std::time::Duration; 11 | 12 | use crate::config; 13 | use crate::display::DisplayState; 14 | 15 | macro_rules! led { 16 | ($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }}; 17 | } 18 | 19 | async fn led_on(path: String) { 20 | tokio::fs::write(&path, "1").await.ok(); 21 | } 22 | 23 | async fn led_off(path: String) { 24 | tokio::fs::write(&path, "0").await.ok(); 25 | } 26 | 27 | pub fn update_ui( 28 | task_tracker: &TaskTracker, 29 | config: &config::Config, 30 | shutdown_token: CancellationToken, 31 | mut ui_update_rx: mpsc::Receiver, 32 | ) { 33 | let mut invisible: bool = false; 34 | if config.ui_level == 0 { 35 | info!("Invisible mode, not spawning UI."); 36 | invisible = true; 37 | } 38 | task_tracker.spawn(async move { 39 | let mut state = DisplayState::Recording; 40 | let mut last_state = DisplayState::Paused; 41 | let mut last_update = std::time::Instant::now(); 42 | 43 | loop { 44 | if shutdown_token.is_cancelled() { 45 | info!("received UI shutdown"); 46 | break; 47 | } 48 | match ui_update_rx.try_recv() { 49 | Ok(new_state) => state = new_state, 50 | Err(mpsc::error::TryRecvError::Empty) => {} 51 | Err(e) => error!("error receiving ui update message: {e}"), 52 | }; 53 | 54 | // Update LEDs if state changed or if 5 seconds have passed since last update 55 | let now = std::time::Instant::now(); 56 | let should_update = !invisible 57 | && (state != last_state 58 | || now.duration_since(last_update) >= Duration::from_secs(5)); 59 | 60 | if should_update { 61 | match state { 62 | DisplayState::Paused => { 63 | led_off(led!("red")).await; 64 | led_off(led!("green")).await; 65 | led_on(led!("wifi")).await; 66 | } 67 | DisplayState::Recording => { 68 | led_off(led!("red")).await; 69 | led_off(led!("wifi")).await; 70 | led_on(led!("green")).await; 71 | } 72 | DisplayState::WarningDetected { .. } => { 73 | led_off(led!("green")).await; 74 | led_off(led!("wifi")).await; 75 | led_on(led!("red")).await; 76 | } 77 | } 78 | last_state = state; 79 | last_update = now; 80 | } 81 | 82 | tokio::time::sleep(Duration::from_secs(1)).await; 83 | } 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/ApiRequestButton.svelte: -------------------------------------------------------------------------------- 1 | 71 | 72 | 98 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/LogView.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 | {#if shown} 46 |
50 |
51 | Log 52 | 69 |
70 |
71 |
{content}
72 |
73 |
74 | {/if} 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to Rayhunter 2 | 3 | ## Filing issues and starting discussions 4 | 5 | Our issue tracker is [on GitHub](https://github.com/EFForg/rayhunter/issues). 6 | 7 | - If your rayhunter has found an IMSI-catcher, we strongly encourage you to 8 | [send us that information 9 | privately.](https://efforg.github.io/rayhunter/faq.html#help-rayhunters-line-is-redorangeyellowdotteddashed-what-should-i-do) via Signal. 10 | 11 | - Issues should be actionable. If you don't have a 12 | specific feature request or bug report, consider [creating a 13 | discussion](https://github.com/EFForg/rayhunter/discussions) or [joining our Mattermost](https://efforg.github.io/rayhunter/support-feedback-community.html) instead. 14 | 15 | Example of a good bug report: 16 | 17 | - "Installer broken on TP-Link M7350 v3.0" 18 | - "Display does not update to green after finding" 19 | - "The documentation is wrong" (though we encourage you to file a pull request directly) 20 | 21 | Example of a good feature request: 22 | 23 | - "Use LED on device XYZ for showing recording status" 24 | 25 | Example of something that belongs into discussion: 26 | 27 | - "In region XYZ, do I need an activated SIM?" 28 | - "Where to buy this device in region XYZ?" 29 | - "Can this device be supported?" While this is a valid feature 30 | request, we just get this request too often, and without some exploratory 31 | work done upfront it's often unclear initially if that device can be 32 | supported at all. 33 | 34 | - The issue templates are mostly there to give you a clue what kind of 35 | information is needed from you, and whether your request belongs into the issue 36 | tracker. Fill them out to be on the safe side, but they are not mandatory. 37 | 38 | ## Contributing patches 39 | 40 | To edit documentation or fix a bug, make a pull request. If you're about to 41 | write a substantial amount of code or implement a new feature, we strongly 42 | encourage you to talk to us before implementing it or check if any issues have 43 | been opened for it already. Otherwise there is a chance we will reject your 44 | contribution after you have spent time on it. 45 | 46 | On the other hand, for small documentation fixes you can file a PR without 47 | filing an issue. 48 | 49 | Otherwise: 50 | 51 | - Refer to [installing from 52 | source](https://efforg.github.io/rayhunter/installing-from-source.html) for 53 | how to build Rayhunter from the git repository. 54 | 55 | - Ensure that `cargo fmt` and `cargo clippy` have been run. 56 | 57 | - If you add new features, please do your best to both write tests for and also 58 | manually test them. Our test coverage isn't great, but as new features are 59 | added we are trying to prevent it from becoming worse. 60 | 61 | If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html) 62 | 63 | ## Making releases 64 | 65 | This one is for maintainers of Rayhunter. 66 | 67 | 1. Make a PR changing the versions in `Cargo.toml` and other files. 68 | This could be automated better but right now it's manual. You can do this easily with sed: 69 | `sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml` 70 | 71 | 2. Merge PR and make a tag. 72 | 73 | 3. [Run release workflow.](https://github.com/EFForg/rayhunter/actions/workflows/release.yml) 74 | 75 | 4. Write changelog, edit it into the release, announce on mattermost. 76 | -------------------------------------------------------------------------------- /doc/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Rayhunter can be configured through web user interface or by editing `/data/rayhunter/config.toml` on the device. 4 | 5 | ![rayhunter_config](./rayhunter_config.png) 6 | 7 | Through web UI you can set: 8 | - **Device UI Level**, which defines what Rayhunter shows on device's built-in screen. *Device UI Level* could be: 9 | - *Invisible mode*: Rayhunter does not show anything on the built-in screen 10 | - *Subtle mode (colored line)*: Rayhunter shows green line if there are no warnings, red line if there are warnings (warnings could be checked through web UI) and white line if Rayhunter is not recording. 11 | - *Demo mode (orca gif)*, which shows image of orcas *and* colored line. 12 | - *EFF logo*, which shows EFF logo and *and* colored line. 13 | - **Device Input Mode**, which defines behavior of built-in power button of the device. *Device Input Mode* could be: 14 | - *Disable button control*: built-in power button of the device is not used by Rayhunter. 15 | - *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button. 16 | - **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness. 17 | - **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/). 18 | - **Enabled Notification Types** allows enabling or disabling the following types of notifications: 19 | - *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes. 20 | - *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI. 21 | - With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well. 22 | 23 | If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in). 24 | -------------------------------------------------------------------------------- /installer/src/tmobile.rs: -------------------------------------------------------------------------------- 1 | /// Installer for the TMobile TMOHS1 hotspot. 2 | /// 3 | /// Tested on (from `/etc/wt_version`): 4 | /// WT_INNER_VERSION=SW_Q89527AA1_V045_M11_TMO_USR_MP 5 | /// WT_PRODUCTION_VERSION=TMOHS1_00.05.20 6 | /// WT_HARDWARE_VERSION=89527_1_11 7 | use std::net::SocketAddr; 8 | use std::str::FromStr; 9 | use std::time::Duration; 10 | 11 | use anyhow::Result; 12 | use tokio::time::sleep; 13 | 14 | use crate::TmobileArgs as Args; 15 | use crate::output::{print, println}; 16 | use crate::util::{http_ok_every, telnet_send_command, telnet_send_file}; 17 | use crate::wingtech::start_telnet; 18 | 19 | pub async fn install( 20 | Args { 21 | admin_ip, 22 | admin_password, 23 | }: Args, 24 | ) -> Result<()> { 25 | run_install(admin_ip, admin_password).await 26 | } 27 | 28 | async fn run_install(admin_ip: String, admin_password: String) -> Result<()> { 29 | print!("Starting telnet ... "); 30 | start_telnet(&admin_ip, &admin_password).await?; 31 | sleep(Duration::from_millis(200)).await; 32 | println!("ok"); 33 | 34 | print!("Connecting via telnet to {admin_ip} ... "); 35 | let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); 36 | telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", true).await?; 37 | println!("ok"); 38 | 39 | telnet_send_command(addr, "mount -o remount,rw /", "exit code 0", true).await?; 40 | 41 | telnet_send_file( 42 | addr, 43 | "/data/rayhunter/config.toml", 44 | crate::CONFIG_TOML 45 | .replace("#device = \"orbic\"", "device = \"tmobile\"") 46 | .as_bytes(), 47 | true, 48 | ) 49 | .await?; 50 | 51 | let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); 52 | telnet_send_file( 53 | addr, 54 | "/data/rayhunter/rayhunter-daemon", 55 | rayhunter_daemon_bin, 56 | true, 57 | ) 58 | .await?; 59 | telnet_send_command( 60 | addr, 61 | "chmod 755 /data/rayhunter/rayhunter-daemon", 62 | "exit code 0", 63 | true, 64 | ) 65 | .await?; 66 | telnet_send_file( 67 | addr, 68 | "/etc/init.d/misc-daemon", 69 | include_bytes!("../../dist/scripts/misc-daemon"), 70 | true, 71 | ) 72 | .await?; 73 | telnet_send_command( 74 | addr, 75 | "chmod 755 /etc/init.d/misc-daemon", 76 | "exit code 0", 77 | true, 78 | ) 79 | .await?; 80 | telnet_send_file( 81 | addr, 82 | "/etc/init.d/rayhunter_daemon", 83 | crate::RAYHUNTER_DAEMON_INIT.as_bytes(), 84 | true, 85 | ) 86 | .await?; 87 | telnet_send_command( 88 | addr, 89 | "chmod 755 /etc/init.d/rayhunter_daemon", 90 | "exit code 0", 91 | true, 92 | ) 93 | .await?; 94 | 95 | println!("Rebooting device and waiting 30 seconds for it to start up."); 96 | telnet_send_command(addr, "reboot", "exit code 0", true).await?; 97 | sleep(Duration::from_secs(30)).await; 98 | 99 | print!("Testing rayhunter ... "); 100 | let max_failures = 10; 101 | http_ok_every( 102 | format!("http://{admin_ip}:8080/index.html"), 103 | Duration::from_secs(3), 104 | max_failures, 105 | ) 106 | .await?; 107 | println!("ok"); 108 | println!("rayhunter is running at http://{admin_ip}:8080"); 109 | 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /daemon/src/pcap.rs: -------------------------------------------------------------------------------- 1 | use crate::ServerState; 2 | 3 | use anyhow::Error; 4 | use axum::body::Body; 5 | use axum::extract::{Path, State}; 6 | use axum::http::StatusCode; 7 | use axum::http::header::CONTENT_TYPE; 8 | use axum::response::{IntoResponse, Response}; 9 | use log::error; 10 | use rayhunter::diag::DataType; 11 | use rayhunter::gsmtap_parser; 12 | use rayhunter::pcap::GsmtapPcapWriter; 13 | use rayhunter::qmdl::QmdlReader; 14 | use std::sync::Arc; 15 | use tokio::io::{AsyncRead, AsyncWrite, duplex}; 16 | use tokio_util::io::ReaderStream; 17 | 18 | // Streams a pcap file chunk-by-chunk to the client by reading the QMDL data 19 | // written so far. This is done by spawning a thread which streams chunks of 20 | // pcap data to a channel that's piped to the client. 21 | pub async fn get_pcap( 22 | State(state): State>, 23 | Path(mut qmdl_name): Path, 24 | ) -> Result { 25 | let qmdl_store = state.qmdl_store_lock.read().await; 26 | if qmdl_name.ends_with("pcapng") { 27 | qmdl_name = qmdl_name.trim_end_matches(".pcapng").to_string(); 28 | } 29 | let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or(( 30 | StatusCode::NOT_FOUND, 31 | format!("couldn't find manifest entry with name {qmdl_name}"), 32 | ))?; 33 | if entry.qmdl_size_bytes == 0 { 34 | return Err(( 35 | StatusCode::SERVICE_UNAVAILABLE, 36 | "QMDL file is empty, try again in a bit!".to_string(), 37 | )); 38 | } 39 | let qmdl_size_bytes = entry.qmdl_size_bytes; 40 | let qmdl_file = qmdl_store 41 | .open_entry_qmdl(entry_index) 42 | .await 43 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; 44 | // the QMDL reader should stop at the last successfully written data chunk 45 | // (entry.size_bytes) 46 | let (reader, writer) = duplex(1024); 47 | 48 | tokio::spawn(async move { 49 | if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await { 50 | error!("failed to generate PCAP: {e:?}"); 51 | } 52 | }); 53 | 54 | let headers = [(CONTENT_TYPE, "application/vnd.tcpdump.pcap")]; 55 | let body = Body::from_stream(ReaderStream::new(reader)); 56 | Ok((headers, body).into_response()) 57 | } 58 | 59 | pub async fn generate_pcap_data( 60 | writer: W, 61 | qmdl_file: R, 62 | qmdl_size_bytes: usize, 63 | ) -> Result<(), Error> 64 | where 65 | W: AsyncWrite + Unpin + Send, 66 | R: AsyncRead + Unpin, 67 | { 68 | let mut pcap_writer = GsmtapPcapWriter::new(writer).await?; 69 | pcap_writer.write_iface_header().await?; 70 | 71 | let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes)); 72 | while let Some(container) = reader.get_next_messages_container().await? { 73 | if container.data_type != DataType::UserSpace { 74 | continue; 75 | } 76 | 77 | for maybe_msg in container.into_messages() { 78 | match maybe_msg { 79 | Ok(msg) => { 80 | let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?; 81 | if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg { 82 | pcap_writer 83 | .write_gsmtap_message(gsmtap_msg, timestamp) 84 | .await?; 85 | } 86 | } 87 | Err(e) => error!("error parsing message: {e:?}"), 88 | } 89 | } 90 | } 91 | 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /installer/src/output.rs: -------------------------------------------------------------------------------- 1 | //! Output handling for the installer 2 | //! 3 | //! This module provides custom print macros that can be intercepted by setting 4 | //! a callback function. This is essential for FFI usage where stdout/stderr 5 | //! redirection doesn't work reliably (especially on Android). 6 | 7 | use std::io::Write; 8 | use std::sync::Mutex; 9 | 10 | /// Type for the output callback function 11 | type OutputCallbackFn = Box; 12 | 13 | /// Global output callback storage 14 | static OUTPUT_CALLBACK: Mutex> = Mutex::new(None); 15 | 16 | /// Set the global output callback 17 | /// 18 | /// All output from `println!` and `eprintln!` will be sent to this callback. 19 | /// If no callback is set, output goes to stdout/stderr as normal. 20 | /// 21 | /// Returns a guard that when dropped, resets the callback. 22 | pub(crate) fn set_output_callback(callback: F) -> OutputCallbackGuard 23 | where 24 | F: Fn(&str) + Send + Sync + 'static, 25 | { 26 | *OUTPUT_CALLBACK.lock().unwrap() = Some(Box::new(callback)); 27 | OutputCallbackGuard 28 | } 29 | 30 | pub struct OutputCallbackGuard; 31 | 32 | impl Drop for OutputCallbackGuard { 33 | fn drop(&mut self) { 34 | clear_output_callback(); 35 | } 36 | } 37 | 38 | /// Clear the global output callback 39 | pub(crate) fn clear_output_callback() { 40 | *OUTPUT_CALLBACK.lock().unwrap() = None; 41 | } 42 | 43 | /// Write a line to the output (either callback or stdout) 44 | pub(crate) fn write_output_line(s: &str) { 45 | if let Ok(guard) = OUTPUT_CALLBACK.lock() 46 | && let Some(ref callback) = *guard 47 | { 48 | callback(s); 49 | callback("\n"); 50 | return; 51 | } 52 | // Fallback to stdout if no callback or lock failed 53 | std::println!("{}", s); 54 | let _ = std::io::stdout().flush(); 55 | } 56 | 57 | /// Write an error line to the output (either callback or stderr) 58 | pub(crate) fn write_error_line(s: &str) { 59 | if let Ok(guard) = OUTPUT_CALLBACK.lock() 60 | && let Some(ref callback) = *guard 61 | { 62 | callback(s); 63 | callback("\n"); 64 | return; 65 | } 66 | // Fallback to stderr if no callback or lock failed 67 | std::eprintln!("{}", s); 68 | let _ = std::io::stderr().flush(); 69 | } 70 | 71 | /// Write raw output without newline (either callback or stdout) 72 | pub(crate) fn write_output_raw(s: &str) { 73 | if let Ok(guard) = OUTPUT_CALLBACK.lock() 74 | && let Some(ref callback) = *guard 75 | { 76 | callback(s); 77 | return; 78 | } 79 | // Fallback to stdout if no callback or lock failed 80 | std::print!("{}", s); 81 | let _ = std::io::stdout().flush(); 82 | } 83 | 84 | /// Shadow println! macro to respect the output callback 85 | macro_rules! println { 86 | () => { 87 | $crate::output::write_output_line("") 88 | }; 89 | ($($arg:tt)*) => {{ 90 | $crate::output::write_output_line(&format!($($arg)*)) 91 | }}; 92 | } 93 | pub(crate) use println; 94 | 95 | /// Shadow eprintln! macro to respect the output callback 96 | macro_rules! eprintln { 97 | () => { 98 | $crate::output::write_error_line("") 99 | }; 100 | ($($arg:tt)*) => {{ 101 | $crate::output::write_error_line(&format!($($arg)*)) 102 | }}; 103 | } 104 | pub(crate) use eprintln; 105 | 106 | /// Shadow print! macro to respect the output callback 107 | macro_rules! print { 108 | ($($arg:tt)*) => {{ 109 | $crate::output::write_output_raw(&format!($($arg)*)) 110 | }}; 111 | } 112 | pub(crate) use print; 113 | -------------------------------------------------------------------------------- /daemon/web/src/lib/components/AnalysisStatus.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 | 93 | -------------------------------------------------------------------------------- /doc/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ### Do I need an active SIM card to use Rayhunter? 4 | 5 | **It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but that sim card does not have to be actively registered with a service plan. If you want to use the device as a hotspot in addition to a research device, or get [notifications](./configuration.md), an active plan would of course be necessary. 6 | 7 | ### How can I test that my device is working? 8 | You can enable the `Test Heuristic` under `Analyzer Heuristic Settings` in the config section on your web dashboard. This will cause an alert to trigger every time your device sees a cell tower, you might need to reboot your device or move around a bit to get this one to trigger, but it will be very noisy once it does. People have also tested it by building IMSI catchers at home, but we don't recommend that, since it violates FCC regulations and will probably upset your neighbors. 9 | 10 | 11 | 12 | ### Help, Rayhunter's line is red/orange/yellow/dotted/dashed! What should I do? 13 | 14 | Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area and tell your friends to do the same! 15 | 16 | If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (Zip file downloaded from the web interface) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal). 17 | 18 | Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time. 19 | 20 | 21 | ### Should I get a locked or unlocked orbic device? What is the difference? 22 | 23 | If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked. 24 | 25 | ### How do I re-enable USB tethering after installing Rayhunter? 26 | 27 | Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands: 28 | 29 | ```sh 30 | ./installer util shell "echo 9 > /usrdata/mode.cfg" 31 | ./installer util shell reboot 32 | ``` 33 | 34 | To disable tethering again: 35 | 36 | ```sh 37 | ./installer util shell "echo 3 > /usrdata/mode.cfg" 38 | ./installer util shell reboot 39 | ``` 40 | 41 | See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings. 42 | 43 | 44 | ### How do I disable the WiFi hotspot on the Orbic RC400L? 45 | 46 | To disable both WiFi bands: 47 | 48 | ```sh 49 | adb shell 50 | /bin/rootshell -c "sed -i 's/1<\/state>/0<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot" 51 | ``` 52 | 53 | To re-enable WiFi: 54 | 55 | ```sh 56 | adb shell 57 | /bin/rootshell -c "sed -i 's/0<\/state>/1<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot" 58 | ``` 59 | -------------------------------------------------------------------------------- /lib/src/log_codes.rs: -------------------------------------------------------------------------------- 1 | //! Enumerates some relevant diag log codes. Copied from QCSuper 2 | 3 | // These are 2G-related log types. 4 | 5 | pub const LOG_GSM_RR_SIGNALING_MESSAGE_C: u32 = 0x512f; 6 | 7 | pub const DCCH: u32 = 0x00; 8 | pub const BCCH: u32 = 0x01; 9 | pub const L2_RACH: u32 = 0x02; 10 | pub const CCCH: u32 = 0x03; 11 | pub const SACCH: u32 = 0x04; 12 | pub const SDCCH: u32 = 0x05; 13 | pub const FACCH_F: u32 = 0x06; 14 | pub const FACCH_H: u32 = 0x07; 15 | pub const L2_RACH_WITH_NO_DELAY: u32 = 0x08; 16 | 17 | // These are GPRS-related log types. 18 | 19 | pub const LOG_GPRS_MAC_SIGNALLING_MESSAGE_C: u32 = 0x5226; 20 | 21 | pub const PACCH_RRBP_CHANNEL: u32 = 0x03; 22 | pub const UL_PACCH_CHANNEL: u32 = 0x04; 23 | pub const DL_PACCH_CHANNEL: u32 = 0x83; 24 | 25 | pub const PACKET_CHANNEL_REQUEST: u32 = 0x20; 26 | 27 | // These are 5G-related log types. 28 | 29 | pub const LOG_NR_RRC_OTA_MSG_LOG_C: u32 = 0xb821; 30 | 31 | // These are 4G-related log types. 32 | 33 | pub const LOG_LTE_RRC_OTA_MSG_LOG_C: u32 = 0xb0c0; 34 | pub const LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C: u32 = 0xb0e2; 35 | pub const LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C: u32 = 0xb0e3; 36 | pub const LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C: u32 = 0xb0ec; 37 | pub const LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C: u32 = 0xb0ed; 38 | 39 | pub const LTE_BCCH_BCH_V0: u32 = 1; 40 | pub const LTE_BCCH_DL_SCH_V0: u32 = 2; 41 | pub const LTE_MCCH_V0: u32 = 3; 42 | pub const LTE_PCCH_V0: u32 = 4; 43 | pub const LTE_DL_CCCH_V0: u32 = 5; 44 | pub const LTE_DL_DCCH_V0: u32 = 6; 45 | pub const LTE_UL_CCCH_V0: u32 = 7; 46 | pub const LTE_UL_DCCH_V0: u32 = 8; 47 | 48 | pub const LTE_BCCH_BCH_V14: u32 = 1; 49 | pub const LTE_BCCH_DL_SCH_V14: u32 = 2; 50 | pub const LTE_MCCH_V14: u32 = 4; 51 | pub const LTE_PCCH_V14: u32 = 5; 52 | pub const LTE_DL_CCCH_V14: u32 = 6; 53 | pub const LTE_DL_DCCH_V14: u32 = 7; 54 | pub const LTE_UL_CCCH_V14: u32 = 8; 55 | pub const LTE_UL_DCCH_V14: u32 = 9; 56 | 57 | pub const LTE_BCCH_BCH_V9: u32 = 8; 58 | pub const LTE_BCCH_DL_SCH_V9: u32 = 9; 59 | pub const LTE_MCCH_V9: u32 = 10; 60 | pub const LTE_PCCH_V9: u32 = 11; 61 | pub const LTE_DL_CCCH_V9: u32 = 12; 62 | pub const LTE_DL_DCCH_V9: u32 = 13; 63 | pub const LTE_UL_CCCH_V9: u32 = 14; 64 | pub const LTE_UL_DCCH_V9: u32 = 15; 65 | 66 | pub const LTE_BCCH_BCH_V19: u32 = 1; 67 | pub const LTE_BCCH_DL_SCH_V19: u32 = 3; 68 | pub const LTE_MCCH_V19: u32 = 6; 69 | pub const LTE_PCCH_V19: u32 = 7; 70 | pub const LTE_DL_CCCH_V19: u32 = 8; 71 | pub const LTE_DL_DCCH_V19: u32 = 9; 72 | pub const LTE_UL_CCCH_V19: u32 = 10; 73 | pub const LTE_UL_DCCH_V19: u32 = 11; 74 | 75 | pub const LTE_BCCH_BCH_NB: u32 = 45; 76 | pub const LTE_BCCH_DL_SCH_NB: u32 = 46; 77 | pub const LTE_PCCH_NB: u32 = 47; 78 | pub const LTE_DL_CCCH_NB: u32 = 48; 79 | pub const LTE_DL_DCCH_NB: u32 = 49; 80 | pub const LTE_UL_CCCH_NB: u32 = 50; 81 | pub const LTE_UL_DCCH_NB: u32 = 52; 82 | 83 | // These are 3G-related log types. 84 | 85 | pub const RRCLOG_SIG_UL_CCCH: u32 = 0; 86 | pub const RRCLOG_SIG_UL_DCCH: u32 = 1; 87 | pub const RRCLOG_SIG_DL_CCCH: u32 = 2; 88 | pub const RRCLOG_SIG_DL_DCCH: u32 = 3; 89 | pub const RRCLOG_SIG_DL_BCCH_BCH: u32 = 4; 90 | pub const RRCLOG_SIG_DL_BCCH_FACH: u32 = 5; 91 | pub const RRCLOG_SIG_DL_PCCH: u32 = 6; 92 | pub const RRCLOG_SIG_DL_MCCH: u32 = 7; 93 | pub const RRCLOG_SIG_DL_MSCH: u32 = 8; 94 | pub const RRCLOG_EXTENSION_SIB: u32 = 9; 95 | pub const RRCLOG_SIB_CONTAINER: u32 = 10; 96 | 97 | // 3G layer 3 packets: 98 | 99 | pub const WCDMA_SIGNALLING_MESSAGE: u32 = 0x412f; 100 | 101 | // Upper layers 102 | 103 | pub const LOG_DATA_PROTOCOL_LOGGING_C: u32 = 0x11eb; 104 | 105 | pub const LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C: u32 = 0x713a; 106 | -------------------------------------------------------------------------------- /doc/tplink-m7350.md: -------------------------------------------------------------------------------- 1 | # TP-Link M7350 2 | 3 | Supported in Rayhunter since version 0.3.0. 4 | 5 | The TP-Link M7350 supports many more frequency bands than Orbic and therefore works in Europe and also in some Asian and African countries. 6 | 7 | ## Supported Bands 8 | 9 | | Technology | Bands | 10 | | ---------- | ----- | 11 | | 4G LTE | B1/B3/B7/B8/B20 (2100/1800/2600/900/800 MHz) | 12 | | 3G | B1/B8 (2100/900 MHz) | 13 | | 2G | 850/900/1800/1900 MHz | 14 | 15 | *Source: [TP-Link Official Product Page](https://www.tp-link.com/baltic/service-provider/lte-3g/m7350/)* 16 | 17 | ## Hardware versions 18 | 19 | The TP-Link comes in many different *hardware versions*. Support for installation varies: 20 | 21 | * `1.0`, `2.0`: **Not supported**, devs are not able to obtain a device 22 | * `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.** 23 | * `6.2`: **One user reported it is working, not tested** 24 | * `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332)) 25 | * `9.0`: **Working since 0.3.2.** 26 | 27 | TP-Link versions newer than `3.0` have cyan packaging and a color display. Version `3.0` has a one-bit display and white packaging. 28 | 29 | You can find the exact hardware version of each device under the battery or next to the barcode on the outer packaging, for example `V3.0` or `V5.2`. 30 | 31 | When filing bug reports, particularly with the installer, please always specify the exact hardware version. 32 | 33 | You can get your TP-Link M7350 from: 34 | 35 | * First check for used offers on local sites, sometimes it's much cheaper there. 36 | * [Geizhals price comparison](https://geizhals.eu/?fs=tp-link+m7350). 37 | * [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313). 38 | 39 | ## Installation & Usage 40 | 41 | Follow the [release installation guide](./installing-from-release.md). Substitute `./installer orbic` for `./installer tplink` in other documentation. The Rayhunter UI will be available at . 42 | 43 | ## Obtaining a shell 44 | 45 | You can obtain a root shell with the following command: 46 | 47 | ```sh 48 | ./installer util tplink-shell 49 | ``` 50 | 51 | ## Display states 52 | 53 | If your device has a color display, Rayhunter will show the same red/green/white line at the top of the display as it does on Orbic, each color meaning "warning"/"recording"/"paused" respectively. See [Using Rayhunter](./using-rayhunter.md). 54 | 55 | If your device has a one-bit (black-and-white) display, Rayhunter will instead show an emoji to indicate status: 56 | 57 | * `!` means "warning (potential IMSI catcher)" 58 | * `:)` (smiling) means "recording" 59 | * `:` (face with no mouth) means "paused" 60 | 61 | ## Power-saving mode/sleep 62 | 63 | By default the device will go to sleep after N minutes of no devices being connected. In that mode it will also turn off connections to cell phone towers. 64 | In order for Rayhunter to record continuously, you have to turn off this sleep mode in TP-Link's admin panel (go to **Advanced** - **Power Saving**) or keep e.g. your phone connected on the TP-Link's WiFi. 65 | 66 | ## Port triggers 67 | 68 | On hardware revisions starting with v4.0, the installer will modify settings to 69 | add two port triggers. You can look at `Settings > NAT Settings > Port 70 | Triggers` in TP-Link's admin UI to see them. 71 | 72 | 1. One port trigger "rayhunter-root" to launch the telnet shell. This is only needed for installation, and can be removed after upgrade. You can reinstall it using `./installer util tplink-shell`. 73 | 2. One port trigger "rayhunter-daemon" to auto-start Rayhunter on boot. If you remove this, Rayhunter will have to be started manually from shell. 74 | 75 | ## Other links 76 | 77 | For more information on the device and instructions on how to install Rayhunter without an installer (i.e. manually), please see [rayhunter-tplink-m7350](https://github.com/m0veax/rayhunter-tplink-m7350/) 78 | -------------------------------------------------------------------------------- /daemon/web/src/lib/manifest.svelte.ts: -------------------------------------------------------------------------------- 1 | import { get_report, type AnalysisReport } from './analysis.svelte'; 2 | import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte'; 3 | 4 | interface JsonManifest { 5 | entries: JsonManifestEntry[]; 6 | current_entry: JsonManifestEntry | null; 7 | } 8 | 9 | interface JsonManifestEntry { 10 | name: string; 11 | start_time: string; 12 | last_message_time: string; 13 | qmdl_size_bytes: number; 14 | } 15 | 16 | export class Manifest { 17 | public entries: ManifestEntry[] = []; 18 | public current_entry: ManifestEntry | undefined; 19 | 20 | constructor(json: JsonManifest) { 21 | for (const entry of json.entries) { 22 | this.entries.push(new ManifestEntry(entry)); 23 | } 24 | if (json.current_entry !== null) { 25 | this.current_entry = new ManifestEntry(json['current_entry']); 26 | } 27 | 28 | // sort entries in reverse chronological order 29 | this.entries.reverse(); 30 | } 31 | 32 | async set_analysis_status(manager: AnalysisManager) { 33 | for (const entry of this.entries) { 34 | entry.analysis_status = manager.status.get(entry.name); 35 | entry.analysis_report = manager.reports.get(entry.name); 36 | } 37 | 38 | if (this.current_entry) { 39 | try { 40 | this.current_entry.analysis_report = await get_report(this.current_entry.name); 41 | } catch (err) { 42 | this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`; 43 | } 44 | 45 | // the current entry should always be considered "finished", as its 46 | // analysis report is always available 47 | this.current_entry.analysis_status = AnalysisStatus.Finished; 48 | } 49 | } 50 | } 51 | 52 | export class ManifestEntry { 53 | public name = $state(''); 54 | public start_time: Date; 55 | public last_message_time: Date | undefined = $state(undefined); 56 | public qmdl_size_bytes = $state(0); 57 | public analysis_size_bytes = $state(0); 58 | public analysis_status: AnalysisStatus | undefined = $state(undefined); 59 | public analysis_report: AnalysisReport | string | undefined = $state(undefined); 60 | 61 | constructor(json: JsonManifestEntry) { 62 | this.name = json.name; 63 | this.qmdl_size_bytes = json.qmdl_size_bytes; 64 | this.start_time = new Date(json.start_time); 65 | if (json.last_message_time) { 66 | this.last_message_time = new Date(json.last_message_time); 67 | } 68 | } 69 | 70 | get_readable_qmdl_size(): string { 71 | if (this.qmdl_size_bytes === 0) return '0 Bytes'; 72 | const k = 1024; 73 | const dm = 2; 74 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 75 | const i = Math.floor(Math.log(this.qmdl_size_bytes) / Math.log(k)); 76 | return `${Number.parseFloat((this.qmdl_size_bytes / k ** i).toFixed(dm))} ${sizes[i]}`; 77 | } 78 | 79 | get_num_warnings(): number | undefined { 80 | if (this.analysis_report === undefined || typeof this.analysis_report === 'string') { 81 | return undefined; 82 | } 83 | return this.analysis_report.statistics.num_warnings; 84 | } 85 | 86 | get_pcap_url(): string { 87 | return `/api/pcap/${this.name}.pcapng`; 88 | } 89 | 90 | get_qmdl_url(): string { 91 | return `/api/qmdl/${this.name}.qmdl`; 92 | } 93 | 94 | get_zip_url(): string { 95 | return `/api/zip/${this.name}.zip`; 96 | } 97 | 98 | get_analysis_report_url(): string { 99 | return `/api/analysis-report/${this.name}`; 100 | } 101 | 102 | get_delete_url(): string { 103 | return `/api/delete-recording/${this.name}`; 104 | } 105 | 106 | get_reanalyze_url(): string { 107 | return `/api/analysis/${this.name}`; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /doc/installing-from-release.md: -------------------------------------------------------------------------------- 1 | # Installing from the latest release 2 | 3 | Make sure you've got one of Rayhunter's [supported devices](./supported-devices.md). These instructions have only been tested on macOS and Ubuntu 24.04. If they fail, you will need to [install Rayhunter from source](./installing-from-source.md). 4 | 5 | 1. **For the TP-Link only,** insert a FAT-formatted SD card. This will be used to store all recordings. 6 | 2. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform: 7 | - for Linux on x64 architecture: `linux-x64` 8 | - for Linux on ARM64 architecture: `linux-aarch64` 9 | - for Linux on armv7/v8 (32-bit) architecture: `linux-armv7` 10 | - for MacOS on Intel (old macbooks) architecture: `macos-intel` 11 | - for MacOS on ARM (M1/M2 etc.) architecture: `macos-arm` 12 | - for Windows: `windows-x86_64` 13 | 14 | 3. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!) 15 | 16 | ```bash 17 | unzip ~/Downloads/rayhunter-vX.X.X-PLATFORM.zip 18 | cd ~/Downloads/rayhunter-vX.X.X-PLATFORM 19 | ``` 20 | 21 | On Windows you can decompress using the file browser, then navigate to the 22 | folder that contains `installer.exe`, **hold Shift**, Right-Click inside the 23 | folder, then click "Open in PowerShell". 24 | 25 | 4. **Connect to your device.** 26 | 27 | First turn on your device by holding the power button on the front. 28 | 29 | Then connect to the device using either WiFi or USB tethering. 30 | 31 | You know you are in the right network when you can access 32 | (Orbic) or (TP-Link) and see the 33 | hardware's own admin menu. 34 | 35 | 5. **On MacOS only**, you have to run `xattr -d 36 | com.apple.quarantine installer` to allow execution of 37 | the binary. 38 | 39 | 6. **Run the installer.** 40 | 41 | ```bash 42 | # For Orbic: 43 | ./installer orbic --admin-password 'mypassword' 44 | # Or install over USB if you want ADB and a root shell (not recommended for most users) 45 | ./installer orbic-usb 46 | 47 | # For TP-Link: 48 | ./installer tplink 49 | ``` 50 | 51 | * On Verizon Orbic, the password is the WiFi password. 52 | * On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g` 53 | * On Moxee-brand devices, check under the battery for the password. 54 | * You can reset the password by pressing the button under the back case until the unit restarts. 55 | 56 | TP-Link does not require an `--admin-password` parameter. 57 | 58 | For other devices, check `./installer --help` or the 59 | respective page in the sidebar under "Supported 60 | Devices." 61 | 62 | 7. The installer will eventually tell you it's done, and the device will reboot. 63 | 64 | 8. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device. 65 | 66 | ## Troubleshooting 67 | 68 | * If you are having trouble installing Rayhunter and you're connecting to your device over USB, try using a different USB cable to connect the device to your computer. If you are using a USB hub, try using a different one or directly connecting the device to a USB port on your computer. A faulty USB connection can cause the Rayhunter installer to fail. 69 | 70 | * You can test your device by enabling the test heuristic. This will be very noisy and fire an alert every time you see a new tower. Be sure to turn it off when you are done testing. 71 | 72 | * On MacOS if you encounter an error that says "No Orbic device found," it may because you have the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done. 73 | 74 | ```bash 75 | ./installer --help 76 | ./installer util --help 77 | ``` 78 | -------------------------------------------------------------------------------- /lib/src/hdlc.rs: -------------------------------------------------------------------------------- 1 | //! HDLC stands for "High-level Data Link Control", which the diag protocol uses 2 | //! to encapsulate its messages. QCSuper's docs describe this in more detail 3 | //! here: 4 | //! 5 | 6 | use bytes::Buf; 7 | use crc::Crc; 8 | use thiserror::Error; 9 | 10 | use crate::diag::{ 11 | ESCAPED_MESSAGE_ESCAPE_CHAR, ESCAPED_MESSAGE_TERMINATOR, MESSAGE_ESCAPE_CHAR, 12 | MESSAGE_TERMINATOR, 13 | }; 14 | 15 | #[derive(Debug, Clone, Error, PartialEq)] 16 | pub enum HdlcError { 17 | #[error("Invalid checksum (expected {0}, got {1})")] 18 | InvalidChecksum(u16, u16), 19 | #[error("Invalid HDLC escape sequence: [0x7d, {0}]")] 20 | InvalidEscapeSequence(u8), 21 | #[error("No trailing character found (expected 0x7e, got {0}))")] 22 | NoTrailingCharacter(u8), 23 | #[error("Missing checksum")] 24 | MissingChecksum, 25 | #[error("Data too short to be HDLC encapsulated")] 26 | TooShort, 27 | } 28 | 29 | pub fn hdlc_encapsulate(data: &[u8], crc: &Crc) -> Vec { 30 | let mut result: Vec = Vec::with_capacity(data.len()); 31 | 32 | for &b in data { 33 | match b { 34 | MESSAGE_TERMINATOR => result.extend([MESSAGE_ESCAPE_CHAR, ESCAPED_MESSAGE_TERMINATOR]), 35 | MESSAGE_ESCAPE_CHAR => { 36 | result.extend([MESSAGE_ESCAPE_CHAR, ESCAPED_MESSAGE_ESCAPE_CHAR]) 37 | } 38 | _ => result.push(b), 39 | } 40 | } 41 | 42 | for b in crc.checksum(data).to_le_bytes() { 43 | match b { 44 | MESSAGE_TERMINATOR => result.extend([MESSAGE_ESCAPE_CHAR, ESCAPED_MESSAGE_TERMINATOR]), 45 | MESSAGE_ESCAPE_CHAR => { 46 | result.extend([MESSAGE_ESCAPE_CHAR, ESCAPED_MESSAGE_ESCAPE_CHAR]) 47 | } 48 | _ => result.push(b), 49 | } 50 | } 51 | 52 | result.push(MESSAGE_TERMINATOR); 53 | result 54 | } 55 | 56 | pub fn hdlc_decapsulate(data: &[u8], crc: &Crc) -> Result, HdlcError> { 57 | if data.len() < 3 { 58 | return Err(HdlcError::TooShort); 59 | } 60 | 61 | if data[data.len() - 1] != MESSAGE_TERMINATOR { 62 | return Err(HdlcError::NoTrailingCharacter(data[data.len() - 1])); 63 | } 64 | 65 | let mut unescaped = Vec::with_capacity(data.len()); 66 | let mut escaping = false; 67 | for &b in &data[..data.len() - 1] { 68 | if escaping { 69 | match b { 70 | ESCAPED_MESSAGE_TERMINATOR => unescaped.push(MESSAGE_TERMINATOR), 71 | ESCAPED_MESSAGE_ESCAPE_CHAR => unescaped.push(MESSAGE_ESCAPE_CHAR), 72 | _ => return Err(HdlcError::InvalidEscapeSequence(b)), 73 | } 74 | escaping = false; 75 | } else if b == MESSAGE_ESCAPE_CHAR { 76 | escaping = true 77 | } else { 78 | unescaped.push(b); 79 | } 80 | } 81 | 82 | // pop off the u16 checksum, check it against what we calculated 83 | let checksum_hi = unescaped.pop().ok_or(HdlcError::MissingChecksum)?; 84 | let checksum_lo = unescaped.pop().ok_or(HdlcError::MissingChecksum)?; 85 | let checksum = [checksum_lo, checksum_hi].as_slice().get_u16_le(); 86 | if checksum != crc.checksum(&unescaped) { 87 | return Err(HdlcError::InvalidChecksum( 88 | checksum, 89 | crc.checksum(&unescaped), 90 | )); 91 | } 92 | 93 | Ok(unescaped) 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use super::*; 99 | 100 | #[test] 101 | fn test_hdlc_encapsulate() { 102 | let crc = Crc::::new(&crate::diag::CRC_CCITT_ALG); 103 | let data = vec![0x01, 0x02, 0x03, 0x04]; 104 | let expected = vec![1, 2, 3, 4, 145, 57, 126]; 105 | let encapsulated = hdlc_encapsulate(&data, &crc); 106 | assert_eq!(&encapsulated, &expected); 107 | assert_eq!(hdlc_decapsulate(&encapsulated, &crc), Ok(data)); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /doc/wingtech-ct2mhs01.md: -------------------------------------------------------------------------------- 1 | # Wingtech CT2MHS01 2 | 3 | Supported in Rayhunter since version 0.4.0. 4 | 5 | The Wingtech CT2MHS01 hotspot is a Qualcomm mdm9650-based device with a screen available for US$15-35. This device is often used as a base platform for white labeled versions like the T-Mobile TMOHS1. AT&T branded versions of the hotspot seem to be the most abundant. 6 | 7 | ## Supported bands 8 | 9 | There are likely variants of the device for all three ITU regions. 10 | 11 | According to FCC ID 2APXW-CT2MHS01 Test Report No. [I20N02441-RF-LTE](https://fcc.report/FCC-ID/2APXW-CT2MHS01/4957451), the ITU Region 2 American version of the device supports the following LTE bands: 12 | 13 | | Band | Frequency | 14 | | ---- | ---------------- | 15 | | 2 | 1900 MHz (PCS) | 16 | | 5 | 850 MHz (CLR) | 17 | | 12 | 700 MHz (LSMH) | 18 | | 14 | 700 MHz (USMH) | 19 | | 30 | 2300 MHz (WCS) | 20 | | 66 | 1700 MHz (E-AWS) | 21 | 22 | Note that Band 5 (850 MHz, CLR) is suitable for roaming in ITU regions 2 and 3. 23 | 24 | ## Hardware 25 | Wingtechs are abundant on ebay and can also be found on Amazon: 26 | - 27 | - 28 | - 29 | - 30 | 31 | ## Installing 32 | Connect to the Wingtech's network over WiFi or USB tethering, then run the installer: 33 | 34 | ```sh 35 | ./installer wingtech --admin-password 12345678 # replace with your own password 36 | ``` 37 | 38 | ## Obtaining a shell 39 | Even when Rayhunter is running, for security reasons the Wingtech will not have telnet or adb enabled during normal operation. 40 | 41 | Use either command below to enable telnet or adb access: 42 | 43 | ```sh 44 | ./installer util wingtech-start-telnet --admin-password 12345678 45 | telnet 192.168.1.1 46 | ``` 47 | 48 | ```sh 49 | ./installer util wingtech-start-adb --admin-password 12345678 50 | adb shell 51 | ``` 52 | 53 | ## Developing 54 | The device has a framebuffer-driven screen at /dev/fb0 that behaves 55 | similarly to the Orbic RC400L, although the userspace program 56 | `displaygui` refreshes the screen significantly more often than on the 57 | Orbic. This causes the green line on the screen to subtly flicker and 58 | only be displayed during some frames. Subsequent work to fully control 59 | the display without removing the OEM interface is desired. 60 | 61 | Rayhunter has been tested on: 62 | 63 | ```sh 64 | WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP 65 | WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 66 | WT_HARDWARE_VERSION=89323_1_20 67 | ``` 68 | 69 | Please consider sharing the contents of your device's /etc/wt_version file here. 70 | 71 | ## Troubleshooting 72 | 73 | ### My hotspot won't turn on after rebooting when installing over WiFi 74 | 75 | Reinsert the battery and turn the device back on, Rayhunter should be installed and running. Sometimes the Wingtech hotspot gets stuck off and ignores the power button after a reboot until the battery is reseated. 76 | 77 | You do not need to run the installer again. 78 | 79 | You'll likely see the following messages, where the installer is stuck at `Testing rayhunter ... `. 80 | 81 | ```sh 82 | Starting telnet ... ok 83 | Connecting via telnet to 192.168.1.1 ... ok 84 | Sending file /data/rayhunter/config.toml ... ok 85 | Sending file /data/rayhunter/rayhunter-daemon ... ok 86 | Sending file /etc/init.d/rayhunter_daemon ... ok 87 | Rebooting device and waiting 30 seconds for it to start up. 88 | Testing rayhunter ... 89 | ``` 90 | 91 | If you eventually see: 92 | 93 | ```sh 94 | Testing rayhunter ... 95 | Failed to install rayhunter on the Wingtech CT2MHS01 96 | 97 | Caused by: 98 | 0: error sending request for url (http://192.168.1.1:8080/index.html) 99 | 1: client error (Connect) 100 | 2: tcp connect error: Network is unreachable (os error 101) 101 | 3: Network is unreachable (os error 101) 102 | ``` 103 | 104 | Make sure your computer is connected to the hotspot's WiFi network. 105 | --------------------------------------------------------------------------------