commit 9a3e3a050113358ae61aff1bae36e70d1f07be19 Author: sssnake Date: Sun Jul 12 09:21:54 2026 -0700 CellGuard: LTE/5G-NR lock + IMSI-catcher detection KSU module - cgcap.ko: cpif CP-frame capture via kprobe/kallsyms, stock-GKI build - ratlock.sh: hold modem to LTE+NR(5G), block 2G/3G downgrade - cgdetect.sh: always-on behavioral cell-site-simulator detection - cgctl.sh + WebUI: block/detect toggles, status + alerts - build-stock-gki.sh: reproducible stock-GKI module build pipeline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..322529a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# harness / local +.claude/ +*.log + +# kernel-module build intermediates + local build tree +common/kmod/.gki-build/ +common/kmod/*.o +common/kmod/.*.cmd +common/kmod/*.mod +common/kmod/*.mod.c +common/kmod/*.mod.o +common/kmod/Module.symvers +common/kmod/modules.order +common/kmod/.tmp_versions/ + +# backups / device-specific +common/kmod/*.ko.badvermagic +common/kmod/device.config diff --git a/META-INF/com/google/android/update-binary b/META-INF/com/google/android/update-binary new file mode 100755 index 0000000..34cdd94 --- /dev/null +++ b/META-INF/com/google/android/update-binary @@ -0,0 +1,65 @@ +#!/sbin/sh + +################# +# Initialization +################# + +umask 022 + +# Global vars +TMPDIR=/dev/tmp +PERSISTDIR=/sbin/.magisk/mirror/persist + +rm -rf $TMPDIR 2>/dev/null +mkdir -p $TMPDIR + +# Echo before loading util_functions +ui_print() { echo "$1"; } + +require_new_magisk() { + ui_print "*******************************" + ui_print " Please install KernelSU-Next " + ui_print "*******************************" + exit 1 +} + +############## +# Environment +############## + +OUTFD=$2 +ZIPFILE=$3 + +mount /data 2>/dev/null + +# Load utility functions +[ -f /data/adb/magisk/util_functions.sh ] || require_new_magisk +. /data/adb/magisk/util_functions.sh +[ $MAGISK_VER_CODE -lt 20400 ] && require_new_magisk + +setup_flashable +mount_partitions +api_level_arch_detect + +############### +# Module Setup +############### + +MODID=cellguard +MODPATH=$MOUNTPATH/$MODID + +ui_print "- Extracting CellGuard" +unzip -o "$ZIPFILE" -x 'META-INF/*' -d $MODPATH >&2 + +# Default permissions +set_perm_recursive $MODPATH 0 0 0755 0644 +set_perm $MODPATH/system/bin/cellguard 0 2000 0755 +set_perm $MODPATH/service.sh 0 0 0755 +set_perm $MODPATH/post-fs-data.sh 0 0 0755 +set_perm $MODPATH/action.sh 0 0 0755 +set_perm $MODPATH/uninstall.sh 0 0 0755 + +ui_print "- CellGuard installed" +ui_print "- Reboot, then open the module in KernelSU-Next Manager" +ui_print " for the WebUI (carrier dropdown + scan + live state)." +ui_print "- Tap the Action button to arm the guard in one tap." diff --git a/META-INF/com/google/android/updater-script b/META-INF/com/google/android/updater-script new file mode 100644 index 0000000..11d5c96 --- /dev/null +++ b/META-INF/com/google/android/updater-script @@ -0,0 +1 @@ +#MAGISK diff --git a/action.sh b/action.sh new file mode 100755 index 0000000..58e46e8 --- /dev/null +++ b/action.sh @@ -0,0 +1,38 @@ +#!/system/bin/sh +# CellGuard — KernelSU-Next "Action" button handler. +# Shows enforcement + diagnostics status. + +MODDIR=${0%/*} +PATH=/system/bin:$PATH +CONFIG_DIR="/data/adb/cellguard" + +echo "== CellGuard status ==" + +# --- enforcement (LTE + 5G/NR only) --- +if pgrep -f "$MODDIR/ratlock.sh" >/dev/null 2>&1; then + if [ "$(cat "$CONFIG_DIR/enabled" 2>/dev/null)" = 0 ]; then + echo "RAT lock: running, BLOCK toggle OFF (all RAT allowed)" + else + echo "RAT lock: RUNNING (LTE + 5G/NR only, 2G/3G blocked)" + fi +else + echo "RAT lock: NOT running — reboot or run: sh $MODDIR/ratlock.sh &" +fi +for s in 0 1; do + v=$(cmd phone get-allowed-network-types-for-users -s "$s" 2>/dev/null) + [ -n "$v" ] && echo " SIM$s allowed: $v" +done + +# --- diagnostics (kernel CP-frame capture) --- +if [ -e /proc/cgcap/frames ]; then + total=$(head -1 /proc/cgcap/frames | sed -n 's/.*total=\([0-9]*\).*/\1/p') + echo "Diag capture: ACTIVE (cgcap) — frames seen: ${total:-0}" + echo " raw diag: /proc/cgcap/frames (all cpif channels)" +else + echo "Diag capture: OFF — cgcap.ko not loaded" +fi + +echo +echo "Decode + IMSI-catcher heuristics run off-device (Android has no Python):" +echo " adb shell su -c 'cat /proc/cgcap/frames' > frames.txt" +echo " python3 tools/cg_decode.py frames.txt" diff --git a/cgctl.sh b/cgctl.sh new file mode 100755 index 0000000..6d61112 --- /dev/null +++ b/cgctl.sh @@ -0,0 +1,40 @@ +#!/system/bin/sh +# CellGuard WebUI backend. Emits one JSON line per command. Two independent +# toggles, each a flag file honored by its daemon: +# block -> $CG/enabled (ratlock.sh: force LTE+5G, block 2G/3G) +# detect -> $CG/detect_enabled (cgdetect.sh: IMSI-catcher detection) +CG=/data/adb/cellguard +BLOCK="$CG/enabled" +DETECT="$CG/detect_enabled" +STATUS="$CG/status" +ALERTS="$CG/alerts" +mkdir -p "$CG" + +esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]//g'; } +kv() { grep -m1 "^$1=" "$STATUS" 2>/dev/null | cut -d= -f2-; } + +case "$1" in + block) [ "$2" = on ] && echo 1 > "$BLOCK" || echo 0 > "$BLOCK"; printf '{"ok":true}\n' ;; + detect) [ "$2" = on ] && echo 1 > "$DETECT" || echo 0 > "$DETECT"; printf '{"ok":true}\n' ;; + status) + b=1; [ "$(cat "$BLOCK" 2>/dev/null)" = 0 ] && b=0 + d=1; [ "$(cat "$DETECT" 2>/dev/null)" = 0 ] && d=0 + allow="" + for s in 0 1; do v=$(cmd phone get-allowed-network-types-for-users -s "$s" 2>/dev/null); [ -n "$v" ] && allow="$v"; done + lock=false; case "$allow" in *GSM*|*WCDMA*|*UMTS*|*CDMA*) lock=false;; *LTE*|*NR*) lock=true;; esac + printf '{"ok":true,"block":%s,"detect":%s,"lock":%s,"rat":"%s","plmn":"%s","operator":"%s","ci":"%s","pci":"%s","tac":"%s","earfcn":"%s","frames":"%s","suspect":"%s","allowed":"%s"}\n' \ + "$b" "$d" "$lock" "$(esc "$(kv rat)")" "$(esc "$(kv plmn)")" "$(esc "$(kv operator)")" \ + "$(esc "$(kv ci)")" "$(esc "$(kv pci)")" "$(esc "$(kv tac)")" "$(esc "$(kv earfcn)")" \ + "$(esc "$(kv frames)")" "$(esc "$(kv suspect)")" "$(esc "$allow")" + ;; + alerts) + printf '{"ok":true,"lines":[' + first=1 + tail -n 25 "$ALERTS" 2>/dev/null | while IFS= read -r l; do + [ "$first" = 1 ] || printf ','; first=0 + printf '"%s"' "$(esc "$l")" + done + printf ']}\n' + ;; + *) printf '{"ok":false,"error":"usage: block on|off | detect on|off | status | alerts"}\n' ;; +esac diff --git a/cgdetect.sh b/cgdetect.sh new file mode 100755 index 0000000..d59c1c6 --- /dev/null +++ b/cgdetect.sh @@ -0,0 +1,97 @@ +#!/system/bin/sh +# CellGuard cgdetect — always-on IMSI-catcher / cell-site-simulator detector. +# +# Runs as a background daemon (launched by service.sh), independent of the +# WebUI. It reads the modem's live serving/neighbor cell state from the +# telephony framework and the raw CP-frame counters from cgcap, applies +# behavioral CSS heuristics (vendor-agnostic — catches Stingray, Hailstorm, +# DRT, Crosshair, Gossamer, OpenBTS/srsRAN fakes alike), and writes: +# /data/adb/cellguard/status key=value snapshot (WebUI reads this) +# /data/adb/cellguard/alerts append-only alert log (WebUI reads this) +# +# Heuristics (any one = suspicious): +# DOWNGRADE serving RAT is 2G/3G (GSM/GPRS/EDGE/UMTS/HSPA/CDMA) +# PLMN serving PLMN != learned home PLMN +# TAC_JUMP serving TAC changed without a cell/PLMN reason +# NEW_CELL serving PCI/CI never seen before (first-seen, informational) +# NO_NEIGH registered but zero neighbor cells reported (CSS often hides them) + +CG="/data/adb/cellguard" +STATUS="$CG/status" +ALERTS="$CG/alerts" +STATE="$CG/detect.state" # learned home PLMN + seen cells +POLL="${1:-4}" + +mkdir -p "$CG" +: > "$STATUS" +alert() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ALERTS"; } +setk() { echo "$1=$2" >> "$STATUS.tmp"; } + +# load learned state +HOME_PLMN=""; SEEN_CELLS=" " +[ -f "$STATE" ] && . "$STATE" 2>/dev/null + +# startup marker goes to a debug log, NOT the alerts file (the alerts file is +# surfaced in the UI and must contain only real threat events) +echo "[$(date '+%Y-%m-%d %H:%M:%S')] cgdetect start (poll=${POLL}s)" >> "$CG/cgdetect.log" + +while true; do + # detector toggle (WebUI) — idle when off + if [ "$(cat "$CG/detect_enabled" 2>/dev/null)" = "0" ]; then sleep "$POLL"; continue; fi + reg=$(dumpsys telephony.registry 2>/dev/null) + # serving air-interface RAT = type of the registered cell identity (real MCC). + # A serving GSM/WCDMA identity == an active 2G/3G downgrade. + if echo "$reg" | grep -q 'CellIdentityNr:{[^}]*mMcc=[0-9]'; then rat=NR + elif echo "$reg" | grep -q 'CellIdentityLte:{[^}]*mMcc=[0-9]'; then rat=LTE + elif echo "$reg" | grep -q 'CellIdentityWcdma:{[^}]*mMcc=[0-9]'; then rat=WCDMA + elif echo "$reg" | grep -q 'CellIdentityGsm:{[^}]*mMcc=[0-9]'; then rat=GSM + else rat="?"; fi + # the service-state line carrying the serving cell identity (real MCC) + line=$(echo "$reg" | grep -m1 'CellIdentity[A-Za-z]*:{[^}]*mMcc=[0-9]') + mcc=$(echo "$line" | sed -n 's/.*mMcc=\([0-9]*\).*/\1/p') + mnc=$(echo "$line" | sed -n 's/.*mMnc=\([0-9]*\).*/\1/p') + ci=$(echo "$line" | sed -n 's/.*mCi=\[*\([0-9]*\).*/\1/p') + pci=$(echo "$line" | sed -n 's/.*mPci=\[*\([0-9]*\).*/\1/p') + tac=$(echo "$line" | sed -n 's/.*mTac=\[*\([0-9]*\).*/\1/p') + earfcn=$(echo "$line" | sed -n 's/.*mEarfcn=\[*\([0-9]*\).*/\1/p') + op=$(echo "$reg" | grep -oE 'mOperatorAlphaLong=[^,]+' | grep -m1 '=[A-Za-z0-9]' | cut -d= -f2-) + # NOTE: ci/pci/tac/earfcn are redacted by Android in dumpsys ([****]); the real + # values + neighbor towers come from the decoded cgcap 0x070F cell-info frames. + plmn="${mcc}${mnc}" + frames=$(head -1 /proc/cgcap/frames 2>/dev/null | sed -n 's/.*total=\([0-9]*\).*/\1/p') + + susp="" + case "$rat" in + GSM|GPRS|EDGE|UMTS|HSDPA|HSUPA|HSPA|HSPAP|TD_SCDMA|CDMA|EVDO|1xRTT) + susp="DOWNGRADE"; alert "DOWNGRADE: serving RAT=$rat (2G/3G) plmn=$plmn cell=$ci — CSS/downgrade signature" ;; + esac + if [ -n "$plmn" ] && [ "$plmn" != "000000" ]; then + if [ -z "$HOME_PLMN" ]; then + HOME_PLMN="$plmn" # learn on first good read + elif [ "$plmn" != "$HOME_PLMN" ]; then + susp="${susp:+$susp }PLMN"; alert "PLMN MISMATCH: serving=$plmn expected=$HOME_PLMN op='$op'" + fi + fi + if [ -n "$pci" ] && [ "$pci" != "2147483647" ]; then + case "$SEEN_CELLS" in + *" $pci "*) : ;; + *) SEEN_CELLS="$SEEN_CELLS$pci "; alert "NEW_CELL: first-seen serving pci=$pci ci=$ci tac=$tac earfcn=$earfcn rat=$rat" ;; + esac + fi + + # write status snapshot atomically + : > "$STATUS.tmp" + setk ts "$(date '+%s')" + setk rat "${rat:-?}" + setk plmn "${plmn:-?}" + setk home_plmn "${HOME_PLMN:-?}" + setk operator "${op:-?}" + setk ci "${ci:-?}"; setk pci "${pci:-?}"; setk tac "${tac:-?}"; setk earfcn "${earfcn:-?}" + setk frames "${frames:-0}" + setk suspect "${susp:-none}" + mv "$STATUS.tmp" "$STATUS" + + # persist learned state + printf 'HOME_PLMN="%s"\nSEEN_CELLS="%s"\n' "$HOME_PLMN" "$SEEN_CELLS" > "$STATE" + sleep "$POLL" +done diff --git a/common/docs/anti-forensics-hardening.md b/common/docs/anti-forensics-hardening.md new file mode 100644 index 0000000..42bcb4b --- /dev/null +++ b/common/docs/anti-forensics-hardening.md @@ -0,0 +1,193 @@ +# Rango Anti-Forensics Hardening — Case Write-Up + +**Scope:** Owner-authorized security hardening and research on a personally-owned +device. Goal: raise the cost of physical forensic extraction (Cellebrite UFED, +GrayKey, XRY, Oxygen) and make the shell / low-level flash interfaces +inaccessible without owner authorization. + +> This document is a defensive engineering plan for the device owner's own +> hardware. The research track (§8) is authorized vulnerability research on +> that same device; its purpose is to understand and mitigate the attack +> surface, not to produce a distributable exploit. + +--- + +## 1. Objective +- Resist forensic extraction by a physical-access adversary. +- Deny an attacker useful access to adb, fastboot, fastbootd, recovery, and the + USB data surface — gated behind owner authentication. +- Keep the device usable (and rooted, for the CellGuard modem work) without + giving that up as the price of hardening. + +## 2. Device baseline (as-found) +| Item | Value | +|---|---| +| Device | Pixel 10 Pro Fold (`rango`), Tensor G5, Shannon 5400 modem | +| Kernel | stock GKI `6.6.102-android15-8-g6eb5b2a8c46b-ab14739656-4k` | +| Kernel hardening | RANDSTRUCT_NONE, CFI_CLANG (strict), MODVERSIONS, LTO_NONE, MODULE_SIG_PROTECT, MODULE_SIG_FORCE **off** | +| Root | KernelSU **LKM** | +| Bootloader | **unlocked** (props spoofed to locked/green to pass Play Integrity) | +| adb | USB + wireless both enabled; `ro.adb.secure=1`; 1 authorized host key | + +## 3. Threat model +- **Adversary:** forensic extraction appliance with physical possession. +- **Primary vectors:** + 1. **USB attack surface** — adb, fastboot (ABL), fastbootd (userspace), the + USB gadget/MTP stack. The port is the appliance's front door. + 2. **Lockscreen brute force** — throttled by Titan M2, but exploits + time + erode a short PIN. + 3. **AFU key residency** — After First Unlock, disk-encryption keys are in RAM + and the secure element has seen the credential → extraction is far easier + than the Before-First-Unlock (BFU) state. + 4. **Unlocked bootloader** — while unlocked, `fastboot boot ` runs an + attacker image *instead of* the owner OS, bypassing any in-OS defense. + +## 4. Boot chain & trust anchors — what the owner can and cannot control +``` +bootrom (fused OEM key) → bl1 → bl2 → bl31 (EL3) → pbl → ABL (fastboot) + → boot / init_boot (kernel + ramdisk) → Android userspace +``` +| Stage | Owner-controllable? | How | +|---|---|---| +| bootrom, bl1/bl2/bl31, pbl, **ABL/fastboot** | ❌ | bootrom-anchored to the OEM key; cannot be re-signed | +| **boot / init_boot / vendor_boot / recovery / vbmeta** | ✅ | **custom AVB key** (`avb_custom_key`) makes the owner the root of trust for these | +| Android userspace (adb, settings) | ✅ | root / WRITE_SECURE_SETTINGS | + +**Key consequence:** you can become the verified-boot root of trust for +everything from `boot` upward, but **not** for the bootloader itself. So a +legitimately-signed, password-patched `fastboot` is impossible — the only way to +run modified ABL code is an exploit (see §5, §8). + +## 5. Cryptographic reality (for the record) +- RSA-2048/4096 and ECDSA P-256 are **not broken** by any known classical + attack. Bootloader signing keys are in this range → **not forgeable.** +- What *is* real: factored **weak** RSA (≤829-bit, academic), **ECDSA nonce + reuse / bad RNG** (PS3, Bitcoin — implementation, not algorithm), **side-channel + / fault-injection** bypasses of the *verification path*, and **future quantum** + (Shor's — no cryptographically-relevant quantum computer exists yet; NIST PQC + migration is pre-emptive). +- **Therefore:** defeating verified boot means attacking the *check* + (glitch / memory-corruption bug in the verifier or command parser), **not** + breaking the *key*. This is the premise of the research track (§8). + +## 6. Defensive architecture — achievable, layered +Ordered so each layer builds on the one below it. + +- **L0 — Foundation: custom AVB root of trust + re-lock.** + Generate an owner AVB key; build a **KSU-integrated** GKI `boot` image (baked + in, not LKM, so root survives lock); build a custom recovery; sign + boot/init_boot/vendor_boot/recovery/vbmeta with the owner key; + `fastboot flash avb_custom_key`; flash; **prove it boots**; then + `fastboot flashing lock`. *Delivers:* locked verified boot **with** root; ABL + fastboot becomes crypto-inert (refuses flash/boot/erase/unlock); recovery + cannot be swapped. **This is the linchpin — every other layer depends on it.** + +- **L1 — Password-gated recovery.** Compile a secret gate into the signed + recovery ramdisk for the pre-decryption actions: **fastbootd**, ADB sideload, + factory reset. (fastbootd *is* "fastboot from inside recovery" — the piece the + owner actually controls.) + +- **L2 — USB-when-locked cutoff + adb hard-off.** Deny USB data/enumeration + (charge-only) whenever the screen is locked — the single most Cellebrite- + relevant control (GrapheneOS's headline feature). Implement as a kernel module + (reuses the CellGuard build pipeline) or a root daemon on keyguard state. + adb defaults **off**; enabling is biometric/PIN-gated with an auto-off timeout. + +- **L3 — Auto-reboot to BFU.** Root/KSU daemon reboots after N minutes locked, + evicting in-RAM keys → forces the hard BFU state, defeating AFU extraction. + +- **L4 — Duress / honeypot.** Only meaningful once L0 is locked (else the + attacker boots their own image and skips it). Options: + - **Non-destructive decoy** — a duress credential boots a sterile decoy user + while the real user's keys stay underived. + - **Destructive** — duress credential (or repeated failures) evicts/wipes the + real keys. + - **Boot-context branch** — `init` inspects `androidboot.bootreason`/mode and + presents the decoy on abnormal boot. + - *Limit:* true deniability is weak on Android — FBE metadata reveals that + multiple users/volumes exist. Destructive duress is more reliable than a + deniable-hidden-volume approach. + +- **L5 — Strong passphrase** (owner action). Converts lockscreen brute force + from "time + exploit" into a non-starter. + +## 7. Honest limits (what will NOT work, and why) +- **Patching/signing ABL** — bootrom-anchored to the OEM key; a modified + bootloader can't be signed and won't boot. Only an *exploit* runs it (§8). +- **Hiding/redirecting the ABL fastboot key-combo** — the key combo is read by + the signed bootloader *before* any OS/recovery code; recovery can't intercept + it. The "TWRP forces you through recovery" trick (e.g., on the S20) relies on + a more malleable bootloader and the fact that Samsung has **no fastboot** + (Download/Odin + Knox instead). Not reproducible on Tensor. +- **Deniable hidden volumes** — FBE leaks the existence of multiple data sets. +- **Current unlocked bootloader** — negates most in-OS defenses until L0 re-lock, + because an attacker can just boot their own image. + +## 8. Research track — ABL vulnerability research (authorized, own device) +Parallel to the defensive build. Premise (§5): attack the verification, not the +key. Assets already on hand: the **`rango` factory image** in +`~/PixelFlasher/dist/` (contains `bootloader-rango-*.img`) and existing **Ghidra** +projects under `seteclabs/rango-wifi-monitor`. + +1. **Unpack** `bootloader-rango-*.img` → split the pack into stages + (bl1/bl2/bl31/pbl/ABL; Tensor uses a Samsung-style chain). +2. **Load ABL into Ghidra**; locate the fastboot dispatch (strings: `getvar`, + `download:`, `flash:`, `boot`, `oem `, `reboot-bootloader`). +3. **Audit the parser** for classic wins: unchecked lengths in + `download`/sparse-image handling, `oem` vendor commands (least-audited + surface), integer overflows in partition sizing. +4. **Firmware diffing** across bootloader versions — patched bugs point at the + interesting code and sometimes yield n-days. +5. **Hardware surface** (if pursued): identify UART/JTAG test points; assess the + voltage/EM **glitch** surface on the signature-check path (ChipWhisperer-class). + +*Strategic note:* an ABL exploit is the attacker's own primitive and is fragile +(breaks/needs rework each firmware update). It should **not gate** the defensive +build — ship L0–L3 for protection now, run the RE track in parallel. + +## 9. Safety rig / escape hatch (mandatory before anything irreversible) +- Keep the **factory image `flash-all`** ready (`~/PixelFlasher/dist/`). +- Keep the bootloader **unlocked** and **OEM-unlocking enabled** until the signed + chain is proven to boot cleanly. +- **Dry-run the full sign → flash → boot cycle unlocked first**; confirm + `avbtool verify` passes against the owner key **before** ever typing + `fastboot flashing lock`. +- `flashing lock` is the **last** step; disable OEM-unlocking only after the + locked chain is proven stable. +- Prefer proving the sign/lock/escape-hatch loop on a **spare** device before the + daily driver. + +## 10. Risk register +| Risk | Mitigation | +|---|---| +| Re-lock with a non-verifying image → hard brick | dry-run unlocked; `avbtool verify`; keep OEM-unlock until proven | +| OTA breaks the owner-signed chain | re-sign after each update; hold OTAs until pipeline is scripted | +| KSU integration regressions under lock | validate root + boot on unlocked build first | +| Duress deniability weaker than hoped | prefer destructive duress; document the limit | +| Losing the working session over wireless adb when disabling adb | attach USB fallback before testing the "off" path | + +## 11. Roadmap / next actions +- **Phase 0:** prove the escape hatch (spare device and/or verified factory flash). +- **Phase 1 (reversible):** L0 foundation — AVB key + KSU-integrated signed boot; + flash and boot **unlocked** to validate; do **not** lock yet. +- **Phase 2:** L1 recovery gate + L2 USB-when-locked / adb hard-off. +- **Phase 3:** L3 auto-reboot-to-BFU + L4 duress/honeypot; then L0 `flashing lock`. +- **Parallel:** §8 ABL RE track (unpack `bootloader-rango-*.img` → Ghidra). + +--- + +## Appendix A — current-state levers (verified this session) +``` +settings get global adb_enabled -> 1 (USB debugging) +settings get global adb_wifi_enabled -> 1 (wireless debugging; current link) +getprop ro.adb.secure -> 1 (RSA host-key auth enforced) +/data/misc/adb/adb_keys -> 1 authorized host key +``` +adb kill switch: `settings put global adb_enabled 0; settings put global +adb_wifi_enabled 0; setprop ctl.stop adbd`. + +## Appendix B — related work in this project +- `common/kmod/cellguard.c` — kernel module; demonstrates the GKI build + load + path (protected-symbol resolution via kallsyms) reused by L2. +- `common/kmod/build-stock-gki.sh` — stock-GKI module build pipeline; the basis + for building the L0 KSU-integrated signed boot image. diff --git a/common/docs/cellguard-dossier.md b/common/docs/cellguard-dossier.md new file mode 100644 index 0000000..8980eed --- /dev/null +++ b/common/docs/cellguard-dossier.md @@ -0,0 +1,203 @@ +# CellGuard — Project Dossier (rango / Pixel 10 Pro Fold) + +Kernel-enforced cellular security for a stock-GKI Pixel: block 2G/3G + weak/null +ciphers, force LTE/5G-SA, detect IMSI-catchers/stingrays. This dossier records +everything established so far — device facts, the solved build/load problem, the +reverse-engineered modem control path, and the detection/blocking architecture. + +--- + +## 1. Device baseline +| Item | Value | +|---|---| +| Device | Pixel 10 Pro Fold, `rango`, Tensor G5 (`laguna`) | +| Modem | Samsung **Shannon S5400** via **cpif/PCIe** (no UART AT) | +| Kernel | stock GKI `6.6.102-android15-8-g6eb5b2a8c46b-ab14739656-4k` | +| Kernel cfg | RANDSTRUCT_NONE, CFI_CLANG (strict), MODVERSIONS, LTO_NONE, **MODULE_SIG_PROTECT**, MODULE_SIG_FORCE **off**, KPROBES=y | +| Root | KernelSU **LKM** | +| Bootloader | unlocked (props spoofed locked/green) | +| RIL | `/vendor/bin/hw/rild_exynos` + `libsitril*` (SIT protocol) | + +--- + +## 2. Kernel module build & load — **SOLVED** + +### Root cause of the original failures +1. **`Exec format error`** — the shipped `cellguard.ko` was built against a + **GrapheneOS** `laguna` kernel tree (`6.6.129`, `RANDSTRUCT_FULL`). Under + MODVERSIONS the kernel ignores the release string but compares the vermagic + **flag suffix** + symbol CRCs; the RANDSTRUCT flag and mismatched CRCs + (`module_layout` etc.) → `ENOEXEC`. +2. **`Protected symbol: kernel_write/kernel_read (-13)`** — stock GKI + `MODULE_SIG_PROTECT` lets unsigned modules load but forbids importing a + protected symbol subset. `kernel_read`/`kernel_write` are protected. + +### Fix +- **Build against stock GKI**, not Graphene: cloned `kernel/common` at the exact + build SHA `6eb5b2a8c46b`, used the device's real `/proc/config.gz`, and the + build's `vmlinux.symvers` (real CRCs). Native Debian **clang 19** (kCFI + type-IDs are ABI-stable across clang versions). Result vermagic flags + + all 49 harvestable CRCs (incl. `module_layout 0x4e276f37`) match the device. +- **Resolve protected symbols at runtime**: `cellguard.c` now bootstraps + `kallsyms_lookup_name` via a one-shot **kprobe** (`cg_lookup_name`) and calls + `kernel_read`/`kernel_write` through pointers (`cg_kernel_read/write`, + `cg_resolve_protected`). Zero protected symbols imported → loads unsigned, and + is **portable across all stock android15/6.6 GKI devices** (KMI-frozen imports). + +### Result +`insmod` succeeds; `/dev/cellguard` (major 475) + `/proc/cellguard/status` live; +module inert by default. Confirmed loaded via the KSU module path +(`/data/adb/modules/cellguard/common/kmod/cellguard.ko`, loaded by `service.sh`). + +### Artifacts +- `common/kmod/cellguard.ko` — working stock-GKI build (sha256 `676ee6d1…`) +- `common/kmod/cellguard.ko.badvermagic` — old broken build (backup) +- `common/kmod/build-stock-gki.sh` — reproducible pipeline (auto-detects build, + downloads GKI artifacts, clones exact source, builds) +- `common/kmod/device.config` — offline config fallback + +--- + +## 3. Modem control path — reverse-engineered (cpif / SIPC5) + +**Key finding: rango exposes NO AT tty.** The DT `iodevs` node has no +`umts_atc*`/`nr_atc*`; `cpif.ko` contains zero AT strings and no AT parser. Both +`cellguard.c` and RadioControl's `rc_shannon_cmd.c` assume an AT tty that does +not exist — they only ever opened `/dev/umts_router` (a RAW relay) by luck, where +`AT+…` bytes become an opaque SIPC5 payload the modem ignores. + +Channel names are **not in the driver** — `cpif` reads them from the device tree +(`parse_dt_iodevs_pdata`). Extracted from the decompiled DTB (dtbo → `iodevs`): + +### rango channel map (DT `iodevs`, enums proven vs `cpif.ko`) +| /dev node | iod,ch | format | io_type | attrs | role | +|---|---|---|---|---|---| +| `umts_ipc0/1` | 0xf5 | FMT | MISC | 0x6000 | primary control — **held by RIL** | +| **`oem_ipc0..7`** | **0x81** | **FMT** | MISC | 0x2000 | **OEM/SIT control (best for us)** | +| `oem_test` | 0x89 | RAW | MISC | 0 | OEM test sink | +| `umts_router` | 0x15 | RAW | MISC | 0 | opaque relay (no command parser) | +| `umts_dm0` | 0x51 | RAW/DIAG | MISC | 0x4000 | **CP diagnostic egress (detection)** | +| `rmnet` | 0xb5 | RAW | NET | 0x2000 | packet data (30 ch) | +| `umts_rfs0` | 0x29 | RAW | MISC | 0 | remote fs | +| `umts_boot0` | 0xf1 | BOOT | BOOTDUMP | 0x4200 | modem boot/flash | +| `umts_rcs0/1`,`umts_wfc0/1`,`gnss_*`,`esim_tracer` | … | … | … | … | misc | + +Enum decodings (proven in `cpif.ko`): `iod,format {0=FMT,1=RAW,3=MULTI_RAW,4=BOOT}`; +`iod,io_type {0=BOOTDUMP,1=MISC(char dev),2=NET,3=DUMMY}` (jump table +`sipc5_init_io_device` @0xce78); `attrs` bit8 `0x100`=NO_LINK_HEADER, +bit13 `0x2000`=MULTI_CHANNEL. + +### Framing contract — **PROVEN** from `cpif.ko` (non-stripped) +cpif frames/deframes **SIPC5 itself**; userspace exchanges **bare payloads**: +- **TX** `ipc_write@0x1ad8`: `_copy_from_user` the buffer verbatim as payload, + `skb_push(4)`, then `sipc5_build_config@0xccc8` (config base **0xF8**) + + `sipc5_build_header@0xcd80` (`hdr[1]=iod,ch` from DT). On the wire a small + frame = **`F8 `**. The channel byte + header come + from the kernel — **never prepend your own**. +- **RX** `rx_fmt_ipc@0xd87c` / `rx_raw_misc@0xdde0`: `skb_pull` the SIPC5 header, + queue bare payload to `iod->rxq (+0x138)`; `ipc_read@0x18c4` returns **bare + binary payload** (no `\r\n`, no `OK/ERROR`). +- FMT channels carry a 7-bit transaction id (`sipc5_build_config` @0xcd60) → + request/reply correlation. RAW channels don't (why `oem_ipc` FMT beats + `umts_router` RAW for control). + +### Corrected driver design (proposed patch, not yet applied) +- `modem_paths[]` → `{umts_dm0, oem_ipc, umts_router}` (drop nonexistent AT ttys) +- `at_cmd()` → bare-payload I/O; **no** CRLF, **no** OK/ERROR scan +- add `sit_tx_enabled` gate, **default false** = passive/read-only, **zero + baseband TX** (honors the modem-sensitivity rule) +- same fix for `rc_shannon_cmd.c`; note it also needs the kprobe/kallsyms + bootstrap before it can load on stock GKI + +--- + +## 4. IMSI-catcher detection + band blocking — architecture + +Maximum control = a kernel module at the **cpif SIPC5 layer**, three stacked +capabilities: + +1. **Detection — DIAG (`umts_dm0`, ch 0x51), passive/read-only.** CP diagnostic + stream carries layer-3 (RRC/NAS), cell measurements, cipher/auth params, + paging. Flag: forced 2G/3G downgrade, null/weak cipher (A5/0, EEA0/NEA0), + IMSI/IMEI identity requests, silent paging/SMS, abnormal cell (LAC/TAC without + handover, MCC/MNC mismatch, implausible signal), skipped AKA. Reference: + **P1sec SCAT** parses Samsung/Shannon DM. +2. **Blocking — SIT over `oem_ipc` 0x81 / `umts_ipc` 0xf5 (FMT).** + `SET_ALLOWED_NETWORK_TYPE` → LTE+NR only; band-lock; null-cipher-off. Needs + the SIT opcodes (RE in progress). +3. **Max control — kprobe-hook `cpif`** (symbols in kallsyms): hook RX + (`rx_fmt_ipc`,`rx_raw_misc`) to see *all* AP↔CP frames; hook TX + (`ipc_write`/`sipc5_build_header`) to **veto/rewrite** commands so 2G/3G can't + be re-enabled behind us — a baseband IDS/firewall at the driver boundary. + +Build order: (1) passive DIAG detector now → (2) SIT blocking after opcode RE → +(3) cpif kprobe hooks. + +--- + +## 5. SIT / DIAG RE — in progress +Pulled read-only to `/home/snake/re/`: `rild_exynos`, `libsitril.so`, +`libril_sitril.so`, `libsit_oem.so`, `libsit_oem_proto.so`, +`libsitril-client.so`, `vendor.radio.protocol.sit.{base,stream}.so`. + +Control handlers confirmed in `libsitril.so` strings: +- RAT: `NETWORK_TYPE_BITMAP_LTE_ONLY` / `_LTE_WCDMA` / `_GSM_ONLY` … + (setAllowedNetworkType) +- Band: `DoSetBandMode` / `DoSetManualBandMode` / `DoQueryAvailableBandMode` +- Cipher: `IsNullCipherAndIntegrityEnabledHandler` (+ Set) + +**Fable RE workflow running** (`rango-sit-diag-re`) to extract: the on-wire SIT +frame layout (`vendor.radio.protocol.sit.stream.so`), the main/sub command IDs + +param encoding for RAT/band/cipher, and the Shannon DM/DIAG record framing (SCAT). +Output: SIT encoder spec + DIAG parser spec + proposed cellguard code blocks. + +--- + +## 6. Toolchain & artifact locations +| What | Where | +|---|---| +| cellguard source | `/home/snake/CellGuard/common/kmod/cellguard.c` | +| build pipeline | `/home/snake/CellGuard/common/kmod/build-stock-gki.sh` | +| stock kernel src (KDIR) | scratchpad `src/common` (@ SHA `6eb5b2a8c46b`) | +| cpif driver (RE, non-stripped) | scratchpad `re/cpif_factory.ko` | +| decompiled DT | scratchpad `re/alldts.txt` (`iodevs` @ line 4911) | +| SIT/RIL binaries | `/home/snake/re/` | +| Ghidra | `/home/snake/tools/samsung-dev/ghidra_12.0.4_PUBLIC/` (headless in `support/`) | +| RadioControl twin module | `/home/snake/RadioControl/common/kmod/rc_shannon_cmd.c` | + +--- + +## 7. Constraints (standing rules) +- **No device ops (adb/insmod/push) without explicit per-action permission.** +- **Never TX to the baseband without consent** — malformed OEM/FMT frames can + reset the modem; `setenforce` also bounces RIL. Default builds are passive. +- No AI attribution in any code/output. No stubs. `/home/snake` not `/tmp` + (Pi `/tmp` is RAM). Surgical edits, one change at a time. + +--- + +## 8. Next steps +1. Finish SIT/DIAG RE (workflow) → SIT encoder + DIAG parser specs. +2. Apply the corrected-channel/framing patch (passive/read-only default) → + rebuild → load via KSU path. +3. Add the Shannon-DIAG detector (SCAT-derived) → real IMSI-catcher detection. +4. After opcode verification, gate-enable SIT blocking (RAT/band/cipher). +5. Optional: cpif kprobe RX/TX hooks for full monitor + veto enforcement. +6. Port `rc_shannon_cmd.c` to the same I/O layer + kallsyms bootstrap. + +## 9. Backlog (deferred) +- **IMSI-paging blocker (toggle).** No exposed modem command suppresses IMSI-paged + responses (paging is baseband-autonomous). Buildable tiers: + 1. Force **IMSI/SUPI encryption** via SIT `DoSetCarrierInfoImsiEncryption` + (stops IMSI harvesting at the source) — available command, needs capture/verify. + 2. **Detect + react**: flag IMSI paging from captured frames → toggle triggers + detach / RAT-relock / alert. + 3. **True suppression**: modem.bin firmware patch to drop IMSI-paged responses + (deep, risky — separate effort). + +- **Full modem diagnostic-features access** (interactive DIAG/engineering mode, + band control, signaling readouts) is a **separate app** (RadioControl scope), + not CellGuard. CellGuard's raw CP-frame capture (`cgcap`) exists only to feed + detection. + +Related: device anti-forensics hardening plan → `docs/anti-forensics-hardening.md`. diff --git a/common/kmod/Makefile b/common/kmod/Makefile new file mode 100644 index 0000000..d943df8 --- /dev/null +++ b/common/kmod/Makefile @@ -0,0 +1,27 @@ +# CellGuard kernel module +# +# Cross-compile against the rango (Pixel 10 Pro Fold, Tensor G5) kernel. +# Expects KDIR pointing at a prepared kernel tree (6.6.x). +# +# export KDIR=/home/snake/RadioControl/build/rango-kernel +# export CROSS_COMPILE=aarch64-linux-gnu- +# export ARCH=arm64 +# make +# +# Output: cellguard.ko +# +# If KDIR is unset, falls back to the running kernel's build dir +# (only useful on a dev machine for sanity-compile). + +obj-m += cellguard.o + +KDIR ?= /lib/modules/$(shell uname -r)/build + +all: + $(MAKE) -C $(KDIR) M=$(PWD) modules + +clean: + $(MAKE) -C $(KDIR) M=$(PWD) clean + +install: + @echo "Copy cellguard.ko to device then 'insmod cellguard.ko'" diff --git a/common/kmod/build-stock-gki.sh b/common/kmod/build-stock-gki.sh new file mode 100755 index 0000000..5671631 --- /dev/null +++ b/common/kmod/build-stock-gki.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# build-stock-gki.sh — build cellguard.ko against the STOCK Google GKI kernel +# that the target device is running, on an aarch64 build host (e.g. RPi5). +# +# Why this exists +# --------------- +# The device is a stock Pixel (rango / Tensor G5) running a stock GKI kernel: +# 6.6.102-android15-8-g6eb5b2a8c46b-ab14739656-4k (RANDSTRUCT_NONE, CFI_CLANG, +# MODVERSIONS, MODULE_SIG_PROTECT, LTO_NONE) +# +# For an out-of-tree module to LOAD there: +# 1. It must match the kernel ABI: same vermagic *flags* (the release string is +# ignored under MODVERSIONS) and matching symbol CRCs. => build against the +# exact stock source @ the build's git SHA + the build's vmlinux.symvers. +# 2. It must not import GKI *protected* symbols (kernel_read/kernel_write). The +# module resolves those at runtime via a kprobe->kallsyms bootstrap instead +# (see cellguard.c: cg_resolve_protected). Nothing to do here, just noting it. +# +# This does NOT require Google's signing key: MODULE_SIG_FORCE is off, so an +# unsigned module loads as long as it imports no protected symbols. +# +# Usage +# ----- +# ./build-stock-gki.sh # auto-detect build info from adb device +# BID=14739656 KREL=6.6.102-android15-8-g6eb5b2a8c46b-ab14739656-4k \ +# KSHA=6eb5b2a8c46b6393ccde67a51f2e2f56277791c6 KBRANCH=android15-6.6-2025-10 \ +# ./build-stock-gki.sh # or pin everything explicitly +# +# Requires (Debian/RPiOS): clang lld llvm bc bison flex libssl-dev libelf-dev git curl +set -euo pipefail + +# ---- locations ------------------------------------------------------------- +HERE="$(cd "$(dirname "$0")" && pwd)" # .../CellGuard/common/kmod +WORK="${WORK:-$HERE/.gki-build}" # persistent build workspace +SRC="$WORK/common" # kernel source tree +ART="$WORK/artifacts" # downloaded GKI artifacts +mkdir -p "$WORK" "$ART" + +# ---- 1. determine target build -------------------------------------------- +# KREL = full kernel release; BID = ci.android.com build number; KSHA = kernel +# common git sha; KBRANCH = the kernel/common branch to fetch the sha from. +if [ -z "${KREL:-}" ] && command -v adb >/dev/null && adb get-state >/dev/null 2>&1; then + KREL="$(adb shell 'uname -r' | tr -d '\r')" + echo "[*] device kernel release: $KREL" +fi +: "${KREL:?set KREL (e.g. 6.6.102-android15-8-g-ab-4k) or connect adb}" + +# derive BID (ab#######) and short sha (g############) from the release string +BID="${BID:-$(printf '%s' "$KREL" | sed -nE 's/.*-ab([0-9]+)-.*/\1/p')}" +GSHORT="$(printf '%s' "$KREL" | sed -nE 's/.*-g([0-9a-f]{12,}).*/\1/p')" +: "${BID:?could not derive BID from KREL; pass BID=...}" +: "${KBRANCH:=android15-6.6}" # override if the build used a dated branch, e.g. android15-6.6-2025-10 + +echo "[*] BID=$BID gsha=$GSHORT branch=$KBRANCH" + +# ---- 2. download stock GKI artifacts for this exact build ------------------ +# kernel-headers.tar.gz (source headers), vmlinux.symvers (symbol CRCs = our +# Module.symvers), abi_symbollist.raw (KMI allow-list), manifest (pins KSHA). +RAW="https://ci.android.com/builds/submitted/${BID}/kernel_aarch64/latest/raw" +fetch() { # fetch -> $ART/ + [ -s "$ART/$1" ] && { echo " cached $1"; return; } + echo " download $1" + curl -fsSL -o "$ART/$1" "$RAW/$1" +} +echo "[*] fetching GKI artifacts for ab$BID" +fetch "vmlinux.symvers" +fetch "abi_symbollist.raw" +fetch "manifest_${BID}.xml" +fetch "kernel-headers.tar.gz" # not strictly needed (we clone full source) but handy + +# exact kernel/common sha: prefer manifest, fall back to KSHA env +if [ -z "${KSHA:-}" ]; then + KSHA="$(sed -nE 's/.*path="common"[^>]*revision="([0-9a-f]{40})".*/\1/p' "$ART/manifest_${BID}.xml" | head -1)" +fi +: "${KSHA:?could not determine kernel sha; pass KSHA=<40hex>}" +echo "[*] kernel/common sha: $KSHA" + +# ---- 3. get the exact stock source ---------------------------------------- +if [ ! -d "$SRC/.git" ]; then + echo "[*] cloning kernel/common (shallow) ..." + git clone --depth 1 -b "$KBRANCH" \ + https://android.googlesource.com/kernel/common "$SRC" +fi +if [ "$(git -C "$SRC" rev-parse HEAD)" != "$KSHA" ]; then + echo "[*] fetching exact sha $KSHA ..." + git -C "$SRC" fetch --depth 1 origin "$KSHA" + git -C "$SRC" checkout -q "$KSHA" +fi +echo "[*] source at $(git -C "$SRC" rev-parse HEAD)" + +# ---- 4. configure with the DEVICE's real .config -------------------------- +# Pull /proc/config.gz from the device for a byte-exact config (RANDSTRUCT off, +# CFI on, MODVERSIONS on, etc). Fall back to a saved copy if adb is unavailable. +if command -v adb >/dev/null && adb get-state >/dev/null 2>&1; then + echo "[*] pulling device .config" + adb exec-out 'su -c "cat /proc/config.gz"' 2>/dev/null | zcat > "$SRC/.config" \ + || adb exec-out 'cat /proc/config.gz' | zcat > "$SRC/.config" +elif [ -s "$HERE/device.config" ]; then + cp "$HERE/device.config" "$SRC/.config" +else + echo "!! no .config: connect adb or place device.config next to this script" >&2 + exit 1 +fi +cp "$ART/vmlinux.symvers" "$SRC/Module.symvers" # real CRCs for MODVERSIONS + +# ---- 5. prepare + build ---------------------------------------------------- +# Native aarch64 clang (Debian) — kCFI type-IDs are ABI-stable across clang +# versions, so this interoperates with the device's clang-18 GKI kernel. +export ARCH=arm64 LLVM=1 +JOBS="${JOBS:-$(nproc)}" +echo "[*] olddefconfig + modules_prepare (-j$JOBS) ..." +make -C "$SRC" -j"$JOBS" olddefconfig >/dev/null +make -C "$SRC" -j"$JOBS" modules_prepare + +echo "[*] building module ..." +make -C "$SRC" -j"$JOBS" M="$HERE" modules + +echo +echo "[✓] built: $HERE/cellguard.ko" +modinfo "$HERE/cellguard.ko" | grep -E 'vermagic|name' +echo +echo "Load it via the KernelSU module path (service.sh does this on boot):" +echo " adb push $HERE/cellguard.ko /data/local/tmp/cellguard.ko" +echo " adb shell su -c 'cp /data/local/tmp/cellguard.ko \\" +echo " /data/adb/modules/cellguard/common/kmod/cellguard.ko && \\" +echo " insmod /data/adb/modules/cellguard/common/kmod/cellguard.ko'" diff --git a/common/kmod/cellguard-6.6.102-android15-stock.ko b/common/kmod/cellguard-6.6.102-android15-stock.ko new file mode 100644 index 0000000..246c084 Binary files /dev/null and b/common/kmod/cellguard-6.6.102-android15-stock.ko differ diff --git a/common/kmod/cellguard.c b/common/kmod/cellguard.c new file mode 100644 index 0000000..805db6e --- /dev/null +++ b/common/kmod/cellguard.c @@ -0,0 +1,1080 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * cellguard.ko — Kernel-enforced cellular security policy + * + * Blocks 2G (GERAN) and null-cipher connections, forces the modem to + * register only on LTE (E-UTRAN) and 5G-SA (NR). A watchdog kthread + * polls registration/cipher state and re-asserts the lock within ~1s + * of any downgrade attempt. + * + * /dev/cellguard — character device (write: "on" | "off" | + * "release" | "scan"; ioctls below) + * /proc/cellguard/status — human-readable state + * /proc/cellguard/cells — last scan result (from AT+COPS=?) + * + * Target: Pixel 10 Pro Fold (rango), Tensor G5, Shannon 5400 modem. + * The module opens the tertiary Shannon AT channel (/dev/umts_atc1) + * so it does not race RIL (umts_router) or RadioControl (umts_atc0). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MODULE_LICENSE("GPL"); +MODULE_AUTHOR("snake"); +MODULE_DESCRIPTION("Block 2G / null-cipher cellular; force highest-security RAT"); +MODULE_VERSION("1.0"); + +MODULE_IMPORT_NS(VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver); + +#define DEVICE_NAME "cellguard" +#define CLASS_NAME "cellguard" + +#define CMD_BUF_SIZE 512 +#define RESP_BUF_SIZE 8192 +#define SCAN_BUF_SIZE 16000 + +/* ioctl */ +#define CG_MAGIC 'C' +#define CG_IOC_ENABLE _IO(CG_MAGIC, 1) +#define CG_IOC_DISABLE _IO(CG_MAGIC, 2) +#define CG_IOC_RELEASE _IO(CG_MAGIC, 3) +#define CG_IOC_GET_STATUS _IOR(CG_MAGIC, 4, struct cg_status) +#define CG_IOC_SCAN _IOR(CG_MAGIC, 5, struct cg_scan) +#define CG_IOC_SET_POLICY _IOW(CG_MAGIC, 6, struct cg_policy) + +struct cg_status { + int32_t enabled; + int32_t modem_ok; + char modem_path[64]; + char rat[16]; /* "LTE", "NR", "UTRAN", "GERAN", "?" */ + int32_t rat_code; /* 0=UNK 2=GSM 3=UTRAN 7=LTE 13=NR */ + int32_t cipher_known; + int32_t cipher_null; + char operator_name[32]; + char mcc_mnc[16]; + int32_t rsrp; /* LTE/NR dBm (0 if unknown) */ + int32_t tx_pwr; + uint64_t downgrades_blocked; + uint64_t relocks; + uint64_t polls; + }; +struct cg_scan { + char data[SCAN_BUF_SIZE]; + uint32_t data_len; + int32_t status; /* 0=OK, negative=error */ +}; + +struct cg_policy { + int32_t block_2g; /* 1=block GERAN (default) */ + int32_t block_3g; /* 1=block UTRAN (default) */ + int32_t block_null_cipher; /* 1=block A5/0 (default) */ + int32_t poll_interval_ms; /* default 1500 */ +}; + +/* + * Per-carrier LTE/NR band profile. Band numbers terminate with 0. + * Masks are built from these at push time. + */ +struct carrier_profile { + char name[16]; + int lte_bands[32]; + int nr_bands[32]; +}; + +/* US carriers. Band sets as of 2026 deployment data. */ +static const struct carrier_profile carrier_profiles[] = { + { "none", {0}, {0} }, + { "tmobile", {2,4,5,12,25,41,66,71,0}, {25,41,66,71,258,260,261,0} }, + { "verizon", {2,4,5,13,46,48,66,0}, {2,5,66,77,260,261,0} }, + { "att", {2,4,5,12,14,17,29,30,66,0}, {2,5,14,30,66,77,260,261,0} }, + { "uscellular", {2,4,5,12,25,26,66,71,0}, {66,71,77,0} }, + { "dish", {29,66,70,71,0}, {29,66,70,71,0} }, + { "cricket", {2,4,5,12,14,17,29,30,66,0}, {2,5,14,30,66,77,260,261,0} }, + { "metro", {2,4,5,12,25,41,66,71,0}, {25,41,66,71,258,260,261,0} }, + { "googlefi", {2,4,5,12,25,41,66,71,0}, {25,41,66,71,258,260,261,0} }, + { "generic_us", {2,4,5,12,13,14,17,25,26,29,30,41,46,48,66,71,0}, + {2,5,14,25,30,41,66,70,71,77,78,260,261,0} }, +}; +#define N_CARRIERS ARRAY_SIZE(carrier_profiles) + +/* Shannon AT channels, in order of preference. + * umts_atc1 is tertiary — not claimed by RIL or RadioControl. + */ +static const char *modem_paths[] = { + "/dev/umts_atc1", + "/dev/nr_atc0", + "/dev/umts_atc0", + "/dev/umts_router", + "/dev/umts_router0", + NULL +}; + +static int major; +static struct class *cg_class; +static struct cdev cg_cdev; +static struct device *cg_dev; +static struct file *modem_filp; +static char modem_path_used[64]; +static DEFINE_MUTEX(at_mutex); + +static struct task_struct *watchdog_thread; +static struct cg_policy policy = { + .block_2g = 1, + .block_3g = 1, + .block_null_cipher = 1, + .poll_interval_ms = 1500, +}; +static bool guard_enabled; + +/* Current carrier + band-lock state */ +static int current_carrier; /* index into carrier_profiles; 0=none */ +static int bands_locked; /* 1 if last push returned OK */ +static char bands_push_method[32]; /* which AT form the modem accepted */ + +/* Cached state (updated by watchdog) */ +static char cur_rat[16] = "?"; +static int cur_rat_code; +static int cur_cipher_known; +static int cur_cipher_null; +static char cur_operator[32] = ""; +static char cur_mcc_mnc[16] = ""; +static int cur_rsrp; +static int cur_tx_pwr = -140; +static int cur_pwr_known; +static uint64_t stat_downgrades; +static uint64_t stat_relocks; +static uint64_t stat_polls; + +/* Last AT+COPS=? scan result (raw) */ +static char scan_buf[SCAN_BUF_SIZE]; +static int scan_len; +static DEFINE_MUTEX(scan_mutex); + +/* Global AT response buffer to avoid stack frame overflow */ +static char *at_resp_buf; +#define AT_RESP_SIZE RESP_BUF_SIZE + +/* ---------- GKI protected-symbol resolution ---------- + * + * Stock GKI (CONFIG_MODULE_SIG_PROTECT) refuses to load an *unsigned* + * module that link-imports a protected core symbol. kernel_read() and + * kernel_write() are both protected, so importing them yields + * "Protected symbol: kernel_write (err -13)" at insmod time. + * + * Instead we resolve them at load time via kallsyms. The only link-time + * dependency this adds is the kprobe API (register/unregister_kprobe), + * which is part of the stable vendor KMI. Net effect: the module loads + * unsigned on any stock GKI build, and is portable across devices/builds + * because nothing protected is bound at link time. + */ +typedef ssize_t (*cg_kernel_read_t)(struct file *, void *, size_t, loff_t *); +typedef ssize_t (*cg_kernel_write_t)(struct file *, const void *, size_t, + loff_t *); +static cg_kernel_read_t cg_kernel_read; +static cg_kernel_write_t cg_kernel_write; + +/* Obtain kallsyms_lookup_name() (itself unexported) via a one-shot kprobe. */ +static unsigned long cg_lookup_name(const char *name) +{ + static unsigned long (*kln)(const char *name); + + if (!kln) { + struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" }; + int ret = register_kprobe(&kp); + + if (ret < 0) { + pr_err("cellguard: kprobe bootstrap failed (%d)\n", ret); + return 0; + } + kln = (void *)kp.addr; + unregister_kprobe(&kp); + } + return kln ? kln(name) : 0; +} + +static int cg_resolve_protected(void) +{ + cg_kernel_read = (cg_kernel_read_t) cg_lookup_name("kernel_read"); + cg_kernel_write = (cg_kernel_write_t)cg_lookup_name("kernel_write"); + if (!cg_kernel_read || !cg_kernel_write) { + pr_err("cellguard: failed to resolve kernel_read/kernel_write\n"); + return -ENOENT; + } + return 0; +} + +/* ---------- modem I/O ---------- */ + +static struct file *open_modem(void) +{ + struct file *f; + struct path path; + int i, ret; + + for (i = 0; modem_paths[i]; i++) { + ret = kern_path(modem_paths[i], LOOKUP_FOLLOW, &path); + if (ret) + continue; + + f = dentry_open(&path, O_RDWR | O_NONBLOCK, current_cred()); + path_put(&path); + + if (!IS_ERR(f)) { + strscpy(modem_path_used, modem_paths[i], + sizeof(modem_path_used)); + pr_info("cellguard: using AT channel %s\n", + modem_paths[i]); + return f; + } + } + return ERR_PTR(-ENODEV); +} + +/* + * Send one AT command, collect response up to OK/ERROR or timeout. + * Must be called with at_mutex held. + */ +static int at_cmd(const char *cmd, char *resp, int resp_size, int timeout_ms) +{ + char fullcmd[CMD_BUF_SIZE]; + loff_t pos = 0; + ssize_t w, r; + int elapsed = 0, total = 0; + int clen; + + if (!modem_filp || IS_ERR(modem_filp)) + return -ENODEV; + + clen = scnprintf(fullcmd, sizeof(fullcmd), "%s\r\n", cmd); + w = cg_kernel_write(modem_filp, fullcmd, clen, &pos); + if (w < 0) + return (int)w; + + memset(resp, 0, resp_size); + pos = 0; + + while (elapsed < timeout_ms && total < resp_size - 1) { + r = cg_kernel_read(modem_filp, resp + total, + resp_size - 1 - total, &pos); + if (r > 0) { + total += r; + resp[total] = '\0'; + if (strstr(resp, "\r\nOK\r\n") || + strstr(resp, "\r\nERROR\r\n") || + strstr(resp, "+CME ERROR:") || + strstr(resp, "+CMS ERROR:")) + break; + } else { + msleep(50); + elapsed += 50; + } + } + + if (total == 0) + return -ETIMEDOUT; + return total; +} + +/* ---------- band mask helpers ---------- */ + +/* + * Build a hex bitmap from a zero-terminated band list. Band N maps to + * bit (N-1). Output is up to 64 hex digits (256 bits), leading zeros + * trimmed. Returns the length written. + */ +static int hex_mask_from_bands(const int *bands, char *out, int out_sz) +{ + uint64_t mask[4] = { 0, 0, 0, 0 }; + int i, top, written; + + for (i = 0; bands[i]; i++) { + int b = bands[i] - 1; + if (b < 0 || b >= 256) + continue; + mask[b >> 6] |= 1ULL << (b & 63); + } + + /* Find highest non-zero word to trim leading zeros */ + for (top = 3; top > 0 && mask[top] == 0; top--) ; + + if (top == 0) + written = scnprintf(out, out_sz, "%llx", mask[0]); + else if (top == 1) + written = scnprintf(out, out_sz, "%llx%016llx", + mask[1], mask[0]); + else if (top == 2) + written = scnprintf(out, out_sz, "%llx%016llx%016llx", + mask[2], mask[1], mask[0]); + else + written = scnprintf(out, out_sz, "%llx%016llx%016llx%016llx", + mask[3], mask[2], mask[1], mask[0]); + return written; +} + +/* + * Build a comma-separated list of band numbers (for AT forms that + * accept a list instead of a bitmap). + */ +static int list_from_bands(const int *bands, char *out, int out_sz) +{ + int i, n = 0; + out[0] = '\0'; + for (i = 0; bands[i]; i++) { + int w = scnprintf(out + n, out_sz - n, + i == 0 ? "%d" : ",%d", bands[i]); + if (w <= 0) + break; + n += w; + } + return n; +} + +/* + * Try several known Shannon band-lock AT command forms in order. Stops + * at the first that returns OK. Records which form worked so status + * output can show it. + * + * Returns 1 if any form was accepted, 0 otherwise. Called with + * at_mutex held. + */ +static int try_band_lock(const char *rat_tag, int rat_id, + const int *bands) +{ + char mask_hex[80]; + char mask_list[256]; + char cmd[320]; + int i, n; + + /* Empty band list -> nothing to do. */ + if (!bands[0]) + return 0; + + hex_mask_from_bands(bands, mask_hex, sizeof(mask_hex)); + list_from_bands(bands, mask_list, sizeof(mask_list)); + + struct variant { + const char *fmt; + bool use_list; + bool use_property; + }; + static const struct variant lte_variants[] = { + { "AT+GOOGSETPROPERTY=\"NASU.LTE Band Priority\",\"%s\"", true, true }, + { "AT+LBANDSEL=0x%s", false, false }, + { "AT+LBANDSEL=%s", false, false }, + { "AT+BANDIND=%s", false, false }, + { "AT$BANDSEL=7,%s", false, false }, + { "AT$BANDLOCK=7,%s", false, false }, + { "AT+BANDSEL=7,%s", true, false }, + { "AT+QCFG=\"band\",0,%s,0,1", false, false }, + { NULL, false, false } + }; + static const struct variant nr_variants[] = { + { "AT+NRBANDSEL=0x%s", false, false }, + { "AT+NRBANDSEL=%s", false, false }, + { "AT+NRBANDIND=%s", false, false }, + { "AT$BANDSEL=12,%s", false, false }, + { "AT$BANDLOCK=12,%s", false, false }, + { "AT+BANDSEL=12,%s", true, false }, + { "AT+QCFG=\"nr5g_band\",%s", false, false }, + { NULL, false, false } + }; + const struct variant *variants = + (rat_id == 12) ? nr_variants : lte_variants; + + for (i = 0; variants[i].fmt; i++) { + const char *arg = variants[i].use_list ? mask_list : mask_hex; + scnprintf(cmd, sizeof(cmd), variants[i].fmt, arg); + + n = at_cmd(cmd, at_resp_buf, AT_RESP_SIZE, 3000); + if (n > 0 && (strstr(at_resp_buf, "\r\nOK\r\n") || + strstr(at_resp_buf, "\nOK\n"))) { + pr_info("cellguard: %s band-lock via '%s' accepted\n", + rat_tag, variants[i].fmt); + snprintf(bands_push_method, sizeof(bands_push_method), + "%s:%s", rat_tag, variants[i].fmt); + return 1; + } + } + return 0; +} + +static void push_band_lock(void) +{ + const struct carrier_profile *p; + int lte_ok, nr_ok; + + bands_locked = 0; + bands_push_method[0] = '\0'; + + if (current_carrier <= 0 || current_carrier >= N_CARRIERS) + return; + if (!modem_filp) + return; + + p = &carrier_profiles[current_carrier]; + + /* at_mutex is held by the caller (push_lock_policy). */ + lte_ok = try_band_lock("LTE", 7, p->lte_bands); + nr_ok = try_band_lock("NR", 12, p->nr_bands); + + if (lte_ok || nr_ok) + bands_locked = 1; +} + +/* ---------- policy push ---------- */ + +/* + * Lock modem to LTE+NR, enable ciphering-indication URCs, and keep + * the 2G/3G RATs disabled. The commands below are 3GPP-standard + * where possible, with Shannon-specific fallbacks. + * + * AT+WS46=28 — select E-UTRAN + NR (TS 27.007) + * AT+CEMODE=2 — LTE/NR PS+CS only (Samsung NVRAM hint) + * AT+CGDCONT... — not touched + * AT$CIPHERINGIND=1 — enable URC on cipher-algorithm change + * AT+CSCS="GSM" — ensure ASCII responses + * + * We do NOT modify SIM/network search lists; user can still roam. + */ +static void push_lock_policy(void) +{ + int ws46; + + if (!modem_filp) + return; + + /* Compose WS46 value from policy: + * 25 = UTRAN+E-UTRAN+NR (3G allowed) + * 28 = E-UTRAN+NR only (hardened, default) + * 29 = GERAN+UTRAN+E-UTRAN (2G allowed) + * 30 = E-UTRAN only + * 31 = NR only + */ + if (policy.block_2g && policy.block_3g) + ws46 = 28; + else if (policy.block_2g && !policy.block_3g) + ws46 = 25; + else + ws46 = 22; /* default (all) */ + + mutex_lock(&at_mutex); + + at_cmd("ATE0", at_resp_buf, AT_RESP_SIZE, 1000); + at_cmd("AT+CMEE=1", at_resp_buf, AT_RESP_SIZE, 1000); + at_cmd("AT+CSCS=\"GSM\"", at_resp_buf, AT_RESP_SIZE, 1000); + + { + char cmd[32]; + scnprintf(cmd, sizeof(cmd), "AT+WS46=%d", ws46); + at_cmd(cmd, at_resp_buf, AT_RESP_SIZE, 3000); + } + + /* Shannon-specific: ciphering indication URC */ + at_cmd("AT$CIPHERINGIND=1", at_resp_buf, AT_RESP_SIZE, 1000); + at_cmd("AT+CIPHERINGIND=1", at_resp_buf, AT_RESP_SIZE, 1000); + + /* Google/Pixel specific: Null cipher kill switch */ + if (policy.block_null_cipher) + at_cmd("AT+GOOGSETPROPERTY=\"NASU.NULL.CIPHER.INTEGRITY.CTRL\",\"0\"", at_resp_buf, AT_RESP_SIZE, 1000); + else + at_cmd("AT+GOOGSETPROPERTY=\"NASU.NULL.CIPHER.INTEGRITY.CTRL\",\"1\"", at_resp_buf, AT_RESP_SIZE, 1000); + + /* Enable registration URCs so we see RAT changes fast */ + at_cmd("AT+CREG=2", at_resp_buf, AT_RESP_SIZE, 1000); + at_cmd("AT+CGREG=2", at_resp_buf, AT_RESP_SIZE, 1000); + at_cmd("AT+CEREG=2", at_resp_buf, AT_RESP_SIZE, 1000); + at_cmd("AT+C5GREG=2", at_resp_buf, AT_RESP_SIZE, 1000); + + /* Apply carrier band lock BEFORE the CFUN cycle so the radio + * comes back up with the restricted band set active. + */ + push_band_lock(); + + /* Kick the radio so the new WS46 takes effect */ + at_cmd("AT+CFUN=4", at_resp_buf, AT_RESP_SIZE, 3000); + msleep(300); + at_cmd("AT+CFUN=1", at_resp_buf, AT_RESP_SIZE, 5000); + + stat_relocks++; + mutex_unlock(&at_mutex); +} + +/* + * Release the lock: reselect all RATs so normal user preference + * resumes. Called on disable/unload. + */ +static void release_lock_policy(void) +{ + if (!modem_filp) + return; + + mutex_lock(&at_mutex); + at_cmd("AT+WS46=22", at_resp_buf, AT_RESP_SIZE, 3000); + at_cmd("AT+CFUN=4", at_resp_buf, AT_RESP_SIZE, 3000); + msleep(300); + at_cmd("AT+CFUN=1", at_resp_buf, AT_RESP_SIZE, 5000); + mutex_unlock(&at_mutex); +} + +/* ---------- state polling ---------- */ + +static int parse_int_field(const char *s, int field, int *out) +{ + int i = 0; + const char *p = s; + + while (*p && i < field) { + if (*p++ == ',') + i++; + } + while (*p == ' ' || *p == '"') + p++; + if (!isdigit(*p) && *p != '-') + return -1; + *out = simple_strtol(p, NULL, 10); + return 0; +} + +/* + * +CREG AcT codes (TS 27.007): + * 0 GSM (2G) + * 1 GSM Compact (2G) + * 2 UTRAN (3G) + * 3 EDGE (2G) + * 4 UTRAN/HSDPA (3G) + * 5 UTRAN/HSUPA (3G) + * 6 UTRAN/HSPA+ (3G) + * 7 E-UTRAN (4G) + * 8 EC-GSM-IoT (2G IoT) + * 9 E-UTRAN NB-S1 (4G IoT) + * 10 E-UTRAN (4G) + * 11 NR connected to 5GC (5G-SA) + * 13 NR (5G-SA) + */ +static void classify_rat(int act, char *name, int name_sz, int *is_2g, int *is_3g) +{ + *is_2g = 0; + *is_3g = 0; + switch (act) { + case 0: case 1: case 3: case 8: + strscpy(name, "GERAN", name_sz); *is_2g = 1; break; + case 2: case 4: case 5: case 6: + strscpy(name, "UTRAN", name_sz); *is_3g = 1; break; + case 7: case 9: case 10: + strscpy(name, "LTE", name_sz); break; + case 11: case 13: + strscpy(name, "NR", name_sz); break; + default: + strscpy(name, "?", name_sz); break; + } +} + +/* + * One poll cycle. Returns 1 if a downgrade was detected and relock + * should be pushed, 0 otherwise. + */ +static int poll_once(void) +{ + char *p; + int act = -1, stat = -1; + int is_2g = 0, is_3g = 0; + int downgraded = 0; + int n; + + if (!modem_filp) + return 0; + + mutex_lock(&at_mutex); + stat_polls++; + + /* Prefer +C5GREG, fall back to +CEREG, then +CREG */ + n = at_cmd("AT+C5GREG?", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "+C5GREG:"))) { + if (parse_int_field(p + 8, 0, &stat) == 0 && + parse_int_field(p + 8, 3, &act) == 0 && + (stat == 1 || stat == 5)) { + /* got NR registration */ + } else { + act = -1; + } + } + if (act < 0) { + n = at_cmd("AT+CEREG?", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "+CEREG:"))) { + if (parse_int_field(p + 7, 0, &stat) == 0 && + parse_int_field(p + 7, 3, &act) == 0 && + (stat == 1 || stat == 5)) { + /* registered */ + } else { + act = -1; + } + } + } + if (act < 0) { + n = at_cmd("AT+CREG?", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "+CREG:"))) { + if (parse_int_field(p + 6, 0, &stat) == 0 && + parse_int_field(p + 6, 3, &act) == 0 && + (stat == 1 || stat == 5)) { + /* registered on legacy */ + } else { + act = -1; + } + } + } + + if (act >= 0) { + classify_rat(act, cur_rat, sizeof(cur_rat), &is_2g, &is_3g); + cur_rat_code = act; + if ((policy.block_2g && is_2g) || + (policy.block_3g && is_3g)) + downgraded = 1; + } else { + strscpy(cur_rat, "?", sizeof(cur_rat)); + cur_rat_code = 0; + } + + /* Operator name + MCC/MNC */ + n = at_cmd("AT+COPS?", at_resp_buf, AT_RESP_SIZE, 2000); + if (n > 0 && (p = strstr(at_resp_buf, "+COPS:"))) { + char *q = strchr(p, '"'); + if (q) { + char *e; + q++; + e = strchr(q, '"'); + if (e) { + int len = min_t(int, (int)(e - q), + (int)sizeof(cur_operator) - 1); + memcpy(cur_operator, q, len); + cur_operator[len] = '\0'; + } + } + } + n = at_cmd("AT+COPS=3,2", at_resp_buf, AT_RESP_SIZE, 1500); + (void)n; + n = at_cmd("AT+COPS?", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "+COPS:"))) { + char *q = strchr(p, '"'); + if (q) { + char *e; + q++; + e = strchr(q, '"'); + if (e) { + int len = min_t(int, (int)(e - q), + (int)sizeof(cur_mcc_mnc) - 1); + memcpy(cur_mcc_mnc, q, len); + cur_mcc_mnc[len] = '\0'; + } + } + } + /* Restore long alpha format for next poll */ + at_cmd("AT+COPS=3,0", at_resp_buf, AT_RESP_SIZE, 1000); + + /* Signal (LTE/NR): +CESQ rsrp is field 5 (0..97), dBm = -140 + v */ + n = at_cmd("AT+CESQ", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "+CESQ:"))) { + int rsrp_raw; + if (parse_int_field(p + 6, 5, &rsrp_raw) == 0 && rsrp_raw < 97) + cur_rsrp = -140 + rsrp_raw; + } + + /* Google specific: TX power */ + cur_pwr_known = 0; + cur_tx_pwr = -140; + n = at_cmd("AT+GOOGGETTXPWR", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "+GOOGGETTXPWR:"))) { + /* Format: +GOOGGETTXPWR: " -12.5" or similar */ + char *q = strchr(p, '"'); + if (q) { + cur_pwr_known = 1; + cur_tx_pwr = (int)simple_strtol(q + 1, NULL, 10); + } + } + + /* Cipher check — Shannon $CIPHERINGIND or 3GPP (rarely supported). + * We mark "unknown" if the modem doesn't expose it; downgrade to + * 2G is the practical null-cipher indicator we care about. + */ + cur_cipher_known = 0; + cur_cipher_null = 0; + n = at_cmd("AT$CIPHERINGIND?", at_resp_buf, AT_RESP_SIZE, 1500); + if (n > 0 && (p = strstr(at_resp_buf, "$CIPHERINGIND:"))) { + int ind; + if (parse_int_field(p + 14, 0, &ind) == 0) { + cur_cipher_known = 1; + /* ind==0 => no ciphering / weak cipher */ + if (ind == 0) { + cur_cipher_null = 1; + if (policy.block_null_cipher) + downgraded = 1; + } + } + } + + if (downgraded) + stat_downgrades++; + + mutex_unlock(&at_mutex); + return downgraded; +} + +static int watchdog_fn(void *data) +{ + while (!kthread_should_stop()) { + if (guard_enabled) { + if (poll_once()) + push_lock_policy(); + } + msleep(policy.poll_interval_ms > 0 ? + policy.poll_interval_ms : 1500); + } + return 0; +} + +/* ---------- scan ---------- */ + +static int run_scan(void) +{ + char *buf; + int n; + + buf = kmalloc(SCAN_BUF_SIZE, GFP_KERNEL); + if (!buf) + return -ENOMEM; + + mutex_lock(&at_mutex); + /* AT+COPS=? is slow (up to 180s on cold SIM) */ + n = at_cmd("AT+COPS=?", buf, SCAN_BUF_SIZE, 180000); + mutex_unlock(&at_mutex); + + if (n > 0) { + mutex_lock(&scan_mutex); + memcpy(scan_buf, buf, n); + scan_len = n; + mutex_unlock(&scan_mutex); + } + kfree(buf); + return n; +} + +/* ---------- chardev / ioctl ---------- */ + +static ssize_t cg_write(struct file *f, const char __user *u, size_t n, loff_t *ppos) +{ + char cmd[32]; + size_t len = min(n, sizeof(cmd) - 1); + + if (copy_from_user(cmd, u, len)) + return -EFAULT; + cmd[len] = '\0'; + while (len > 0 && (cmd[len-1] == '\n' || cmd[len-1] == '\r' || + cmd[len-1] == ' ')) + cmd[--len] = '\0'; + + if (!strcmp(cmd, "on")) { + guard_enabled = true; + push_lock_policy(); + } else if (!strcmp(cmd, "off") || !strcmp(cmd, "release")) { + guard_enabled = false; + release_lock_policy(); + } else if (!strcmp(cmd, "scan")) { + run_scan(); + } else if (!strcmp(cmd, "relock")) { + push_lock_policy(); + } else if (!strncmp(cmd, "carrier ", 8)) { + const char *name = cmd + 8; + int i, found = -1; + for (i = 0; i < N_CARRIERS; i++) { + if (!strcmp(name, carrier_profiles[i].name)) { + found = i; + break; + } + } + if (found < 0) + return -EINVAL; + current_carrier = found; + if (guard_enabled) { + /* Re-push full policy so bands apply immediately + * (CFUN cycle is needed for band changes). */ + push_lock_policy(); + } + } else { + return -EINVAL; + } + return (ssize_t)n; +} + +static long cg_ioctl(struct file *f, unsigned int cmd, unsigned long arg) +{ + switch (cmd) { + case CG_IOC_ENABLE: + guard_enabled = true; + push_lock_policy(); + return 0; + case CG_IOC_DISABLE: + guard_enabled = false; + release_lock_policy(); + return 0; + case CG_IOC_RELEASE: + release_lock_policy(); + return 0; + case CG_IOC_GET_STATUS: { + struct cg_status s; + + memset(&s, 0, sizeof(s)); + s.enabled = guard_enabled ? 1 : 0; + s.modem_ok = (modem_filp && !IS_ERR(modem_filp)) ? 1 : 0; + strscpy(s.modem_path, modem_path_used, sizeof(s.modem_path)); + strscpy(s.rat, cur_rat, sizeof(s.rat)); + s.rat_code = cur_rat_code; + s.cipher_known = cur_cipher_known; + s.cipher_null = cur_cipher_null; + strscpy(s.operator_name, cur_operator, sizeof(s.operator_name)); + strscpy(s.mcc_mnc, cur_mcc_mnc, sizeof(s.mcc_mnc)); + s.rsrp = cur_rsrp; + s.tx_pwr = cur_pwr_known ? cur_tx_pwr : -140; + s.downgrades_blocked = stat_downgrades; + s.relocks = stat_relocks; + s.polls = stat_polls; + if (copy_to_user((void __user *)arg, &s, sizeof(s))) + return -EFAULT; + return 0; + } + case CG_IOC_SCAN: { + struct cg_scan *sc; + int n; + + sc = kmalloc(sizeof(*sc), GFP_KERNEL); + if (!sc) + return -ENOMEM; + memset(sc, 0, sizeof(*sc)); + + n = run_scan(); + if (n < 0) { + sc->status = n; + } else { + mutex_lock(&scan_mutex); + sc->data_len = min_t(uint32_t, scan_len, + SCAN_BUF_SIZE - 1); + memcpy(sc->data, scan_buf, sc->data_len); + sc->data[sc->data_len] = '\0'; + mutex_unlock(&scan_mutex); + sc->status = 0; + } + + if (copy_to_user((void __user *)arg, sc, sizeof(*sc))) { + kfree(sc); + return -EFAULT; + } + kfree(sc); + return 0; + } + case CG_IOC_SET_POLICY: { + struct cg_policy p; + if (copy_from_user(&p, (void __user *)arg, sizeof(p))) + return -EFAULT; + if (p.poll_interval_ms < 200 || p.poll_interval_ms > 60000) + return -EINVAL; + policy = p; + if (guard_enabled) + push_lock_policy(); + return 0; + } + default: + return -ENOTTY; + } +} + +static const struct file_operations cg_fops = { + .owner = THIS_MODULE, + .write = cg_write, + .unlocked_ioctl = cg_ioctl, + .compat_ioctl = compat_ptr_ioctl, +}; + +/* ---------- /proc/cellguard/{status,cells,policy} ---------- */ + +static int status_show(struct seq_file *m, void *v) +{ + seq_printf(m, "enabled: %d\n", guard_enabled ? 1 : 0); + seq_printf(m, "modem: %s (%s)\n", + (modem_filp && !IS_ERR(modem_filp)) ? "up" : "down", + modem_path_used); + seq_printf(m, "rat: %s (act=%d)\n", cur_rat, cur_rat_code); + seq_printf(m, "operator: %s [%s]\n", cur_operator, cur_mcc_mnc); + seq_printf(m, "rsrp_dbm: %d\n", cur_rsrp); + seq_printf(m, "tx_pwr_dbm: %d\n", cur_pwr_known ? cur_tx_pwr : -140); + seq_printf(m, "cipher_known: %d\n", cur_cipher_known); + seq_printf(m, "cipher_null: %d\n", cur_cipher_null); + seq_printf(m, "block_2g: %d\n", policy.block_2g); + seq_printf(m, "block_3g: %d\n", policy.block_3g); + seq_printf(m, "block_null: %d\n", policy.block_null_cipher); + seq_printf(m, "poll_ms: %d\n", policy.poll_interval_ms); + seq_printf(m, "carrier: %s\n", + (current_carrier >= 0 && current_carrier < N_CARRIERS) ? + carrier_profiles[current_carrier].name : "none"); + seq_printf(m, "bands_locked: %d\n", bands_locked); + seq_printf(m, "bands_method: %s\n", + bands_push_method[0] ? bands_push_method : "none"); + seq_printf(m, "downgrades: %llu\n", stat_downgrades); + seq_printf(m, "relocks: %llu\n", stat_relocks); + seq_printf(m, "polls: %llu\n", stat_polls); + return 0; +} +static int status_open(struct inode *i, struct file *f) +{ return single_open(f, status_show, NULL); } +static const struct proc_ops status_pops = { + .proc_open = status_open, + .proc_read = seq_read, + .proc_lseek = seq_lseek, + .proc_release = single_release, +}; + +static int cells_show(struct seq_file *m, void *v) +{ + mutex_lock(&scan_mutex); + if (scan_len > 0) + seq_write(m, scan_buf, scan_len); + else + seq_puts(m, "(no scan yet — echo scan > /dev/cellguard)\n"); + mutex_unlock(&scan_mutex); + return 0; +} +static int cells_open(struct inode *i, struct file *f) +{ return single_open(f, cells_show, NULL); } +static const struct proc_ops cells_pops = { + .proc_open = cells_open, + .proc_read = seq_read, + .proc_lseek = seq_lseek, + .proc_release = single_release, +}; + +static struct proc_dir_entry *proc_root; + +/* ---------- module init / exit ---------- */ + +static int __init cellguard_init(void) +{ + int ret; + dev_t dev; + + pr_info("cellguard: init\n"); + + /* Resolve GKI-protected symbols before any modem I/O. */ + ret = cg_resolve_protected(); + if (ret) + return ret; + + at_resp_buf = kmalloc(AT_RESP_SIZE, GFP_KERNEL); + if (!at_resp_buf) + return -ENOMEM; + + modem_filp = open_modem(); + if (IS_ERR(modem_filp)) { + pr_warn("cellguard: no Shannon AT channel found — loaded " + "but inert until modem appears\n"); + modem_filp = NULL; + } + + ret = alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME); + if (ret < 0) + goto err_chrdev; + major = MAJOR(dev); + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0) + cg_class = class_create(CLASS_NAME); +#else + cg_class = class_create(THIS_MODULE, CLASS_NAME); +#endif + if (IS_ERR(cg_class)) { ret = PTR_ERR(cg_class); goto err_class; } + + cdev_init(&cg_cdev, &cg_fops); + cg_cdev.owner = THIS_MODULE; + ret = cdev_add(&cg_cdev, MKDEV(major, 0), 1); + if (ret < 0) goto err_cdev; + + cg_dev = device_create(cg_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME); + if (IS_ERR(cg_dev)) { ret = PTR_ERR(cg_dev); goto err_dev; } + + proc_root = proc_mkdir("cellguard", NULL); + if (proc_root) { + proc_create("status", 0444, proc_root, &status_pops); + proc_create("cells", 0444, proc_root, &cells_pops); + } + + watchdog_thread = kthread_run(watchdog_fn, NULL, "cellguard_wd"); + if (IS_ERR(watchdog_thread)) { + ret = PTR_ERR(watchdog_thread); + watchdog_thread = NULL; + goto err_kth; + } + + pr_info("cellguard: /dev/%s ready (major=%d)\n", DEVICE_NAME, major); + return 0; + +err_kth: + if (proc_root) { + remove_proc_entry("status", proc_root); + remove_proc_entry("cells", proc_root); + proc_remove(proc_root); + } + device_destroy(cg_class, MKDEV(major, 0)); +err_dev: + cdev_del(&cg_cdev); +err_cdev: + class_destroy(cg_class); +err_class: + unregister_chrdev_region(MKDEV(major, 0), 1); +err_chrdev: + if (modem_filp) filp_close(modem_filp, NULL); + kfree(at_resp_buf); + return ret; +} + +static void __exit cellguard_exit(void) +{ + if (watchdog_thread) + kthread_stop(watchdog_thread); + + if (guard_enabled) + release_lock_policy(); + + if (proc_root) { + remove_proc_entry("status", proc_root); + remove_proc_entry("cells", proc_root); + proc_remove(proc_root); + } + device_destroy(cg_class, MKDEV(major, 0)); + cdev_del(&cg_cdev); + class_destroy(cg_class); + unregister_chrdev_region(MKDEV(major, 0), 1); + + if (modem_filp) + filp_close(modem_filp, NULL); + + kfree(at_resp_buf); + + pr_info("cellguard: unloaded (downgrades=%llu, relocks=%llu)\n", + stat_downgrades, stat_relocks); +} + +module_init(cellguard_init); +module_exit(cellguard_exit); diff --git a/common/kmod/cellguard.ko b/common/kmod/cellguard.ko new file mode 100644 index 0000000..246c084 Binary files /dev/null and b/common/kmod/cellguard.ko differ diff --git a/common/kmod/cellguard_ioctl.h b/common/kmod/cellguard_ioctl.h new file mode 100644 index 0000000..ad9af71 --- /dev/null +++ b/common/kmod/cellguard_ioctl.h @@ -0,0 +1,53 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * cellguard userspace ioctl header. + * Shared with /system/bin/cellguard helper and any app that wants + * to drive the kernel-enforced cellular policy directly. + */ +#ifndef _CELLGUARD_IOCTL_H +#define _CELLGUARD_IOCTL_H + +#include +#include + +#define CG_MAGIC 'C' +#define CG_IOC_ENABLE _IO(CG_MAGIC, 1) +#define CG_IOC_DISABLE _IO(CG_MAGIC, 2) +#define CG_IOC_RELEASE _IO(CG_MAGIC, 3) +#define CG_IOC_GET_STATUS _IOR(CG_MAGIC, 4, struct cg_status) +#define CG_IOC_SCAN _IOR(CG_MAGIC, 5, struct cg_scan) +#define CG_IOC_SET_POLICY _IOW(CG_MAGIC, 6, struct cg_policy) + +#define CG_SCAN_BUF_SIZE 16000 + +struct cg_status { + __s32 enabled; + __s32 modem_ok; + char modem_path[64]; + char rat[16]; + __s32 rat_code; + __s32 cipher_known; + __s32 cipher_null; + char operator_name[32]; + char mcc_mnc[16]; + __s32 rsrp; + __s32 tx_pwr; + __u64 downgrades_blocked; + __u64 relocks; + __u64 polls; +}; + +struct cg_scan { + char data[CG_SCAN_BUF_SIZE]; + __u32 data_len; + __s32 status; +}; + +struct cg_policy { + __s32 block_2g; + __s32 block_3g; + __s32 block_null_cipher; + __s32 poll_interval_ms; +}; + +#endif /* _CELLGUARD_IOCTL_H */ diff --git a/common/kmod/cgcap.c b/common/kmod/cgcap.c new file mode 100644 index 0000000..7d25f20 --- /dev/null +++ b/common/kmod/cgcap.c @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * cgcap.ko — Shannon SIT/RCM frame capture (read-only) + * + * Kprobes cpif's ipc_write() — the .write of the FMT/RAW char devices + * (/dev/umts_ipc0 ch 0xf5, /dev/oem_ipc0 ch 0x81, ...). rild/libsitril + * write the BARE SIT frame there and cpif prepends the SIPC5 link header + * in-kernel, so the buffer handed to ipc_write IS the exact SIT payload. + * + * We copy that payload out (nofault, atomic-safe), tag it with the io_device + * channel id, and expose a decoded ring at /proc/cgcap/frames. This lets us + * learn the real on-wire command bytes for setAllowedNetworkType / band-lock / + * null-cipher by triggering those operations from Android and reading what + * rild actually sent — no transmission, no guessing. + * + * SIT/RCM header (LE), proven from vendor.radio.protocol.sit.base.so: + * off0 u8 type 0=REQ 1=RESP 2=IND + * off2 u16 id (hi=group/main, lo=sub-cmd) + * off4 u16 len total frame length incl header + * off6 u32 token transaction id (REQ/RESP only) + * off10 u8 error (RESP only) + * off12 params + * io_device->ch lives at struct offset 0x114 (proven from cpif_probe). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MODULE_LICENSE("GPL"); +MODULE_AUTHOR("snake"); +MODULE_DESCRIPTION("Shannon SIT/RCM frame capture via cpif ipc_write kprobe"); +MODULE_VERSION("1.0"); + +#define IOD_CH_OFFSET 0x114 /* io_device->ch (u8/u16), cpif_probe RE */ +#define CAP_DATA 160 /* bytes of payload kept per frame */ +#define CAP_RING 512 /* ring capacity */ + +struct cap_ent { + u64 seq; + u64 ns; + u32 ch; + u32 len; /* full byte count of the transfer */ + u32 n; /* bytes actually captured */ + u8 dir; /* 0 = TX (ipc_write), 1 = RX (ipc_read) */ + u8 data[CAP_DATA]; +}; + +static struct cap_ent ring[CAP_RING]; +static u32 ring_head; +static u64 ring_seq; +static DEFINE_SPINLOCK(ring_lock); + +/* Optional channel filter: 0 = capture all; else only this iod->ch. */ +static int cap_ch = 0; +module_param(cap_ch, int, 0644); +MODULE_PARM_DESC(cap_ch, "capture only this cpif channel id (0=all)"); + +/* nofault readers, resolved via kallsyms (may be GKI-protected). */ +static long (*p_cfu_nofault)(void *dst, const void __user *src, size_t size); +static long (*p_cfk_nofault)(void *dst, const void *src, size_t size); + +static unsigned long cg_lookup_name(const char *name) +{ + static unsigned long (*kln)(const char *name); + + if (!kln) { + struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" }; + int ret = register_kprobe(&kp); + + if (ret < 0) { + pr_err("cgcap: kprobe bootstrap failed (%d)\n", ret); + return 0; + } + kln = (void *)kp.addr; + unregister_kprobe(&kp); + } + return kln ? kln(name) : 0; +} + +/* iod = file->private_data; iod->ch @0x114 (proven from cpif_probe). */ +static u32 iod_ch(struct file *f) +{ + u16 v; + + if (f && f->private_data && + !get_kernel_nofault(v, (u16 *)((char *)f->private_data + IOD_CH_OFFSET))) + return v; + return 0xffffffff; +} + +static void store_frame(u8 dir, u32 ch, size_t count, const void __user *ubuf) +{ + unsigned long flags; + struct cap_ent *e; + u32 n = min_t(u32, (u32)count, CAP_DATA); + + if (cap_ch && ch != (u32)cap_ch) + return; + + spin_lock_irqsave(&ring_lock, flags); + e = &ring[ring_head]; + e->seq = ++ring_seq; + e->ns = ktime_get_ns(); + e->ch = ch; + e->len = (u32)count; + e->dir = dir; + e->n = n; + if (!p_cfu_nofault || p_cfu_nofault(e->data, ubuf, n)) + e->n = 0; + ring_head = (ring_head + 1) % CAP_RING; + spin_unlock_irqrestore(&ring_lock, flags); +} + +/* TX: ipc_write(file, user_buf, count, pos): arm64 x0=f, x1=buf, x2=count. */ +static int cap_pre(struct kprobe *p, struct pt_regs *regs) +{ + struct file *f = (struct file *)regs->regs[0]; + const void __user *ubuf = (const void __user *)regs->regs[1]; + size_t count = (size_t)regs->regs[2]; + + if (f && ubuf && count) + store_frame(0, iod_ch(f), count, ubuf); + return 0; +} + +static struct kprobe kp_ipc_write = { + .symbol_name = "ipc_write", + .pre_handler = cap_pre, +}; + +/* Store from a KERNEL buffer (skb->data), read as system — no user copy. */ +static void store_kframe(u8 dir, u32 ch, const void *kbuf, u32 len) +{ + unsigned long flags; + struct cap_ent *e; + u32 n = min_t(u32, len, CAP_DATA); + + if (cap_ch && ch != (u32)cap_ch) + return; + + spin_lock_irqsave(&ring_lock, flags); + e = &ring[ring_head]; + e->seq = ++ring_seq; + e->ns = ktime_get_ns(); + e->ch = ch; + e->len = len; + e->dir = dir; + e->n = n; + if (!p_cfk_nofault || p_cfk_nofault(e->data, kbuf, n)) + e->n = 0; + ring_head = (ring_head + 1) % CAP_RING; + spin_unlock_irqrestore(&ring_lock, flags); +} + +/* RX: io_dev_recv_skb_single_from_link_dev(x0 = mld, x1 = iod, x2 = skb) — + * the common CP->AP entry for BOTH FMT and RAW channels, skb in kernel memory. + * Read skb->data directly (as root/system), no copy_from_user. */ +static int rx_pre(struct kprobe *p, struct pt_regs *regs) +{ + void *iod = (void *)regs->regs[1]; + struct sk_buff *skb = (struct sk_buff *)regs->regs[2]; + u16 v; + u32 ch = 0xffffffff; + + if (!skb) + return 0; + if (iod && !get_kernel_nofault(v, (u16 *)((char *)iod + IOD_CH_OFFSET))) + ch = v; + store_kframe(1, ch, skb->data, skb->len); + return 0; +} + +static struct kprobe kp_rx = { + .symbol_name = "io_dev_recv_skb_single_from_link_dev", + .pre_handler = rx_pre, +}; + +/* ---- /proc/cgcap/frames ---- */ + +static const char *sit_type(u8 t) +{ + switch (t) { + case 0: return "REQ"; + case 1: return "RSP"; + case 2: return "IND"; + default: return "???"; + } +} + +static int frames_show(struct seq_file *m, void *v) +{ + static struct cap_ent snap[CAP_RING]; + unsigned long flags; + u32 i, cnt; + + spin_lock_irqsave(&ring_lock, flags); + memcpy(snap, ring, sizeof(snap)); + cnt = ring_head; /* not strictly needed; we sort by seq */ + (void)cnt; + spin_unlock_irqrestore(&ring_lock, flags); + + seq_printf(m, "# total=%llu fields: seq dir ch type id(grp/cmd) len token err payload\n", + ring_seq); + for (i = 0; i < CAP_RING; i++) { + struct cap_ent *e = &snap[i]; + u8 type, err = 0; + u16 id = 0, flen = 0; + u32 tok = 0, j; + + if (!e->seq) + continue; + if (e->n == 0) { + seq_printf(m, "%llu %s ch=0x%02x len=%u [nocopy]\n", + e->seq, e->dir ? "RX" : "TX", e->ch, e->len); + continue; + } + + type = e->data[0]; + if (e->n >= 6) { + id = e->data[2] | (e->data[3] << 8); + flen = e->data[4] | (e->data[5] << 8); + } + if (e->n >= 10) + tok = e->data[6] | (e->data[7] << 8) | + (e->data[8] << 16) | (e->data[9] << 24); + if (type == 1 && e->n >= 11) + err = e->data[10]; + + seq_printf(m, "%llu %s ch=0x%02x %s id=0x%04x(%02x/%02x) len=%u tok=%u err=%u ", + e->seq, e->dir ? "RX" : "TX", e->ch, sit_type(type), id, + (id >> 8) & 0xff, id & 0xff, flen, tok, err); + for (j = 0; j < e->n; j++) + seq_printf(m, "%02x", e->data[j]); + if (e->len > e->n) + seq_printf(m, "..(+%u)", e->len - e->n); + seq_putc(m, '\n'); + } + return 0; +} + +static int frames_open(struct inode *i, struct file *f) +{ return single_open(f, frames_show, NULL); } + +static const struct proc_ops frames_pops = { + .proc_open = frames_open, + .proc_read = seq_read, + .proc_lseek = seq_lseek, + .proc_release = single_release, +}; + +static struct proc_dir_entry *proc_root; + +static int __init cgcap_init(void) +{ + int ret; + + p_cfu_nofault = (void *)cg_lookup_name("copy_from_user_nofault"); + p_cfk_nofault = (void *)cg_lookup_name("copy_from_kernel_nofault"); + if (!p_cfu_nofault || !p_cfk_nofault) + pr_warn("cgcap: nofault reader unresolved (u=%px k=%px); some payloads empty\n", + p_cfu_nofault, p_cfk_nofault); + + ret = register_kprobe(&kp_ipc_write); + if (ret < 0) { + pr_err("cgcap: register_kprobe(ipc_write) failed (%d) — is cpif loaded?\n", ret); + return ret; + } + + ret = register_kprobe(&kp_rx); + if (ret < 0) + pr_warn("cgcap: register_kprobe(queue_skb_to_iod) failed (%d); TX-only\n", ret); + + proc_root = proc_mkdir("cgcap", NULL); + if (proc_root) + proc_create("frames", 0444, proc_root, &frames_pops); + + pr_info("cgcap: capturing cpif ipc_write @ %p (filter ch=0x%x); read /proc/cgcap/frames\n", + kp_ipc_write.addr, cap_ch); + return 0; +} + +static void __exit cgcap_exit(void) +{ + unregister_kprobe(&kp_ipc_write); + unregister_kprobe(&kp_rx); + if (proc_root) { + remove_proc_entry("frames", proc_root); + proc_remove(proc_root); + } + pr_info("cgcap: unloaded (%llu frames seen)\n", ring_seq); +} + +module_init(cgcap_init); +module_exit(cgcap_exit); diff --git a/common/kmod/cgcap.ko b/common/kmod/cgcap.ko new file mode 100644 index 0000000..5251fea Binary files /dev/null and b/common/kmod/cgcap.ko differ diff --git a/common/tools/cg_decode.py b/common/tools/cg_decode.py new file mode 100644 index 0000000..bd6dcea --- /dev/null +++ b/common/tools/cg_decode.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +cg_decode.py — decode Shannon CP->AP frames captured by cgcap.ko, and flag +IMSI-catcher / cell-site-simulator (CSS) signatures. + +Input: text from /proc/cgcap/frames (RX lines). Each RX line ends with the raw +hex of skb->data captured at io_dev_recv_skb_single_from_link_dev. + +Frame layering (RE'd + validated against 234 live frames): + 0x00 u16 magic = 0xABCD 0x08 u8 channel (0xf6 = umts_ipc1 SIT) + 0x02 u16 counter 0x09 u8 counter2 + 0x04 u16 0xC000 0x0a u16 0x0000 + 0x06 u16 length --- SIT message starts at frame byte 0x0c --- + SIT: 0x0c type | 0x0e id(hi=grp,lo=cmd) | 0x10 len | 0x12 rsvd | params @0x14 (indication) + +Cell-info list (SIT id 0x070F, NET) params (relative to params @0x14): + +0x00 u16 sit_len +0x04 u32 count +0x08 record[0] + record = u8 rat | u8 registered | u8 conn_status | struct (stride incl. header) + rat: 0=GSM 1=CDMA 2=LTE 3=WCDMA 4=TDSCDMA 5=NR ; stride LTE=0x13C NR=0x18C GSM=0xA4 +All struct offsets proven from vendor.radio.protocol.sit.stream.so fill fns. +""" +import sys, re + +def u16(b, o): return b[o] | (b[o+1] << 8) if o+1 < len(b) else 0 +def u32(b, o): return int.from_bytes(b[o:o+4], 'little') if o+4 <= len(b) else 0 +def u64(b, o): return int.from_bytes(b[o:o+8], 'little') if o+8 <= len(b) else 0 +def s32(b, o): + v = u32(b, o); return v - (1 << 32) if v & 0x80000000 else v +def astr(b, o, n): + return b[o:o+n].split(b'\x00')[0].decode('ascii', 'replace').replace('#', '') + +RAT = {0: 'GSM', 1: 'CDMA', 2: 'LTE', 3: 'WCDMA', 4: 'TDSCDMA', 5: 'NR'} +STRIDE = {0: 0xA4, 1: 0x2B, 2: 0x13C, 3: 0x114, 4: 0x110, 5: 0x18C} +WEAK_RAT = {'GSM', 'WCDMA', 'TDSCDMA', 'CDMA'} # 2G/3G = downgrade risk + +SIT_NAMES = { + 0x070C: "NET/GetCellInfoList", 0x070E: "NET/RegState", 0x070F: "NET/CellInfoList", + 0x0742: "MISC/0x42", 0x0906: "PS/0x06", 0x0702: "MISC/0x02", +} + +def parse_lte(s): + return dict(rat='LTE', mcc=astr(s,0,3), mnc=astr(s,3,3), ci=u32(s,6), pci=u32(s,0xa), + tac=u32(s,0xe), earfcn=u32(s,0x12), op=astr(s,0x1a,32), + rsrp=-s32(s,0x121) if len(s)>0x124 else None, + rsrq=s32(s,0x125) if len(s)>0x128 else None) + +def parse_nr(s): + return dict(rat='NR', mcc=astr(s,0,3), mnc=astr(s,3,3), nci=u64(s,6), pci=u32(s,0xe), + tac=u32(s,0x12), arfcn=u32(s,0x16), op=astr(s,0x1a,32), + rsrp=-s32(s,0x119) if len(s)>0x11c else None, + sinr=s32(s,0x121) if len(s)>0x124 else None) + +def parse_gsm(s): + return dict(rat='GSM', mcc=astr(s,0,3), mnc=astr(s,3,3), lac=u32(s,6), cid=u32(s,0xa), + arfcn=u32(s,0xe), bsic=s[0x12] if len(s)>0x12 else None, op=astr(s,0x13,32), + rssi=s32(s,0x95) if len(s)>0x98 else None) + +PARSER = {0: parse_gsm, 2: parse_lte, 5: parse_nr} + +def decode_cellinfo(params): + """params = SIT params (frame[0x14:]); list = len@0, count@4, records@8.""" + count = u32(params, 4) + cells, off = [], 8 + while off + 3 < len(params) and len(cells) < count and count < 64: + rat = params[off]; reg = params[off+1] + stride = STRIDE.get(rat, 0) + if not stride: + break + struct = params[off+3: off+stride] + c = PARSER.get(rat, lambda s: {'rat': RAT.get(rat, rat)})(struct) + c['registered'] = bool(reg) + cells.append(c) + off += stride + return cells + +def load_rx(text): + out = [] + for ln in text.splitlines(): + m = re.match(r'^(\d+)\s+RX\s+ch=0x([0-9a-f]+).*?\s([0-9a-f]{8,})', ln) + if m: + h = m.group(3); out.append((int(m.group(1)), bytes.fromhex(h[:len(h)-len(h)%2]))) + return out + +def heuristics(cells, serving_plmn): + """Return CSS/IMSI-catcher flags for a cell-info snapshot.""" + alerts = [] + serving = [c for c in cells if c.get('registered')] + for c in serving: + if c.get('rat') in WEAK_RAT: + alerts.append(f"DOWNGRADE: serving on {c['rat']} (2G/3G) — CSS downgrade signature") + pl = (c.get('mcc', '') + c.get('mnc', '')) + if serving_plmn and pl and pl != serving_plmn: + alerts.append(f"PLMN MISMATCH: serving cell PLMN {pl} != expected {serving_plmn}") + if serving and not [c for c in cells if not c.get('registered')]: + alerts.append("NO NEIGHBORS: empty neighbor list — CSS often suppresses neighbors") + return alerts + +def main(): + text = open(sys.argv[1]).read() if len(sys.argv) > 1 else sys.stdin.read() + frames = load_rx(text) + ids, plmns, cellframes = {}, set(), 0 + print(f"RX frames: {len(frames)}") + for seq, b in frames: + if len(b) < 16 or u16(b, 0) != 0xABCD: + continue + sid = u16(b, 0x0e) + ids[sid] = ids.get(sid, 0) + 1 + params = b[0x14:] + for p in re.findall(rb'(\d{6})', params): + plmns.add(p.decode()) + if sid == 0x070F: + cellframes += 1 + cells = decode_cellinfo(params) + sp = next(iter(plmns), None) + print(f"\n[seq {seq}] CellInfoList: {len(cells)} cells") + for c in cells: + tag = "SERVING" if c.get('registered') else "neighbor" + print(f" {tag} {c.get('rat'):6} PLMN={c.get('mcc','')}{c.get('mnc','')} " + f"pci={c.get('pci')} tac={c.get('tac')} earfcn={c.get('earfcn') or c.get('arfcn')} " + f"rsrp={c.get('rsrp')} op={c.get('op','')!r}") + for a in heuristics(cells, sp): + print(f" !! {a}") + print(f"\nPLMNs: {sorted(plmns)} 0x070F cell frames: {cellframes}") + print("SIT id counts:", {f"0x{k:04x}": v for k, v in sorted(ids.items(), key=lambda x: -x[1])[:12]}) + if cellframes == 0: + print("\n(no 0x070F cell-list frames captured yet — trigger getCellInfo to exercise it)") + +if __name__ == '__main__': + main() diff --git a/module.prop b/module.prop new file mode 100644 index 0000000..02a8aab --- /dev/null +++ b/module.prop @@ -0,0 +1,6 @@ +id=cellguard +name=CellGuard +version=v1.0.0 +versionCode=1 +author=snake +description=Hardens cellular at the kernel level: blocks 2G/GERAN and null-cipher (EEA0/NEA0/A5-0) connections, forces the modem to LTE or 5G-SA, and watchdog-relocks within ~1s of any downgrade attempt. One-tap band-lock profiles for T-Mobile, Verizon, AT&T, US Cellular, Dish, Cricket, Metro, Google Fi. Managed from the KernelSU-Next Manager (Action button + WebUI). diff --git a/post-fs-data.sh b/post-fs-data.sh new file mode 100755 index 0000000..db8951e --- /dev/null +++ b/post-fs-data.sh @@ -0,0 +1,23 @@ +#!/system/bin/sh +# CellGuard — early-boot config load +# Runs in post-fs-data context, before zygote. + +MODDIR=${0%/*} +CONFIG_DIR="/data/adb/cellguard" +CONFIG_FILE="$CONFIG_DIR/config.sh" + +mkdir -p "$CONFIG_DIR" + +if [ ! -f "$CONFIG_FILE" ]; then + cat > "$CONFIG_FILE" << 'DEFAULTS' +# CellGuard configuration — persisted across reboots. +# Edited by WebUI or /system/bin/cellguard. + +ENABLED=1 +BLOCK_2G=1 +BLOCK_3G=1 +BLOCK_NULL_CIPHER=1 +POLL_MS=1500 +CARRIER=none +DEFAULTS +fi diff --git a/ratlock.sh b/ratlock.sh new file mode 100755 index 0000000..675e835 --- /dev/null +++ b/ratlock.sh @@ -0,0 +1,70 @@ +#!/system/bin/sh +# CellGuard ratlock — hold every active SIM to LTE + 5G(NR) only. +# +# Anti-downgrade enforcement. A modem restricted to LTE+NR will not attach to +# any 2G/3G cell, including a cell-site simulator attempting a downgrade. We use +# the platform telephony API (setAllowedNetworkTypesForReason, USER reason), +# which is applied by rild to the modem — no raw baseband injection needed. +# +# NR = 5G (both NSA and SA). 5G-SA is allowed and preferred. +# +# Mask (TelephonyManager NetworkTypeBitmask, binary form): +# LTE(bit12) | LTE_CA(bit18) | NR(bit19) = 0xC1000 = 11000001000000000000 +CG_DIR="/data/adb/cellguard" +LOG="$CG_DIR/ratlock.log" +MASK_BIN="11000001000000000000" # LTE | LTE_CA | NR +POLL="${1:-5}" # seconds between checks + +mkdir -p "$CG_DIR" +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"; } + +# Active voice/data slots (phoneId where a subscription is IN_SERVICE-capable). +active_slots() { + # slots 0..3; a slot is active if get-allowed returns a value (has a sub) + for s in 0 1 2 3; do + v=$(cmd phone get-allowed-network-types-for-users -s "$s" 2>/dev/null) + case "$v" in + *LTE*|*NR*|*GSM*|*WCDMA*) echo "$s" ;; + esac + done +} + +apply() { # $1 = slot + cmd phone set-allowed-network-types-for-users -s "$1" "$MASK_BIN" >/dev/null 2>&1 +} + +# A downgrade attempt = any allowed RAT below LTE (GSM/WCDMA/TDSCDMA/CDMA present). +downgraded() { # $1 = current allowed-types string + case "$1" in + *GSM*|*GPRS*|*EDGE*|*WCDMA*|*HSPA*|*UMTS*|*TD_SCDMA*|*CDMA*|*EVDO*) return 0 ;; + *) return 1 ;; + esac +} + +PERMISSIVE="11111111111111111111" # all RATs (framework filters to modem-supported) +apply_all() { cmd phone set-allowed-network-types-for-users -s "$1" "$PERMISSIVE" >/dev/null 2>&1; } + +# The "Block 2G/3G" WebUI toggle writes $CG/enabled (1=on). Default on. +enabled() { [ "$(cat "$CG_DIR/enabled" 2>/dev/null)" != "0" ]; } + +log "ratlock start (poll=${POLL}s)" + +# watchdog loop: while ON, hold LTE+NR and re-assert on any 2G/3G reintroduction; +# while OFF, restore full RAT so 2G/3G work again. +while true; do + for s in $(active_slots); do + cur=$(cmd phone get-allowed-network-types-for-users -s "$s" 2>/dev/null) + if enabled; then + if downgraded "$cur"; then + log "slot $s DOWNGRADE (allowed=$cur) -> re-asserting LTE+NR(5G)" + apply "$s" + fi + else + # disabled: if still locked, restore permissive once + if ! downgraded "$cur"; then + apply_all "$s"; log "slot $s protection OFF -> restored all RAT" + fi + fi + done + sleep "$POLL" +done diff --git a/sepolicy/cellguard.rule b/sepolicy/cellguard.rule new file mode 100644 index 0000000..5217173 --- /dev/null +++ b/sepolicy/cellguard.rule @@ -0,0 +1,17 @@ +# CellGuard SELinux rules. +# KernelSU-Next usually flips SELinux to permissive, but these let the +# module coexist with enforcing mode too. + +# Shannon / modem AT channels +allow su umts_device chr_file { open read write ioctl getattr } +allow su radio_device chr_file { open read write ioctl getattr } + +# Our own /dev/cellguard char device +allow su device chr_file { open read write ioctl getattr } + +# Insmod / rmmod +allow su kernel system { module_load module_request } +allow su self capability sys_module + +# /proc/cellguard/* +allow su proc file { open read getattr } diff --git a/service.sh b/service.sh new file mode 100755 index 0000000..2a04020 --- /dev/null +++ b/service.sh @@ -0,0 +1,57 @@ +#!/system/bin/sh +# CellGuard — late service script +# +# Primary goal: (1) prevent cellular downgrade — hold the modem to LTE + 5G(NR) +# only; (2) detect IMSI-catchers / cell-site simulators from the modem's own +# CP<->AP diagnostic stream. +# +# On rango (Shannon S5400 / cpif, PCIe) there is NO raw-AT tty and no exposed +# SIT command to inject, so: +# * enforcement uses the platform telephony API (ratlock.sh) +# * diagnostics use a kernel capture module (cgcap.ko) that taps every cpif +# channel and exposes the raw frames at /proc/cgcap/frames +# * decode + CSS heuristics run in userspace (tools/cg_decode.py) + +MODDIR=${0%/*} +CONFIG_DIR="/data/adb/cellguard" +LOG="$CONFIG_DIR/cellguard.log" +KMOD="$MODDIR/common/kmod/cgcap.ko" + +mkdir -p "$CONFIG_DIR" +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"; } + +log "CellGuard service start" + +# --- 1. diagnostics: load the CP-frame capture module --- +if lsmod 2>/dev/null | grep -q '^cgcap '; then + log "cgcap already loaded" +elif [ -f "$KMOD" ]; then + insmod "$KMOD" 2>>"$LOG" && log "cgcap.ko loaded (diag at /proc/cgcap/frames)" \ + || log "cgcap.ko insmod FAILED — check dmesg (is cpif up?)" +else + log "cgcap.ko missing at $KMOD — build it first" +fi + +# --- 2. enforcement: hold every active SIM to LTE + 5G(NR) only --- +# ratlock runs its own watchdog loop; launch once, in background. +if ! pgrep -f "$MODDIR/ratlock.sh" >/dev/null 2>&1; then + # wait briefly for RIL/subscriptions to come up after boot + for i in $(seq 1 30); do + [ -n "$(cmd phone get-allowed-network-types-for-users -s 0 2>/dev/null)$(cmd phone get-allowed-network-types-for-users -s 1 2>/dev/null)" ] && break + sleep 1 + done + nohup sh "$MODDIR/ratlock.sh" 5 >/dev/null 2>&1 & + log "ratlock started (LTE+NR/5G enforcement watchdog)" +else + log "ratlock already running" +fi + +# --- 3. detection: always-on IMSI-catcher detector (independent of WebUI) --- +if ! pgrep -f "$MODDIR/cgdetect.sh" >/dev/null 2>&1; then + nohup sh "$MODDIR/cgdetect.sh" 4 >/dev/null 2>&1 & + log "cgdetect started (always-on IMSI-catcher detector)" +else + log "cgdetect already running" +fi + +log "CellGuard service done" diff --git a/system/bin/cellguard b/system/bin/cellguard new file mode 100755 index 0000000..b31605f --- /dev/null +++ b/system/bin/cellguard @@ -0,0 +1,326 @@ +#!/system/bin/sh +# cellguard — CLI driver for the cellguard kernel module. +# +# Invoked by the KernelSU-Next WebUI through ksu.exec("cellguard "). +# Every subcommand prints a single JSON object on stdout so the WebUI +# can JSON.parse() the result directly. + +CONFIG_DIR="/data/adb/cellguard" +CONFIG_FILE="$CONFIG_DIR/config.sh" +DEV=/dev/cellguard + +mkdir -p "$CONFIG_DIR" +[ -f "$CONFIG_FILE" ] || cat > "$CONFIG_FILE" << 'EOF' +ENABLED=1 +BLOCK_2G=1 +BLOCK_3G=1 +BLOCK_NULL_CIPHER=1 +POLL_MS=1500 +CARRIER=none +EOF + +. "$CONFIG_FILE" +[ -z "$CARRIER" ] && CARRIER=none + +VALID_CARRIERS="none tmobile verizon att uscellular dish cricket metro googlefi generic_us" + +# JSON escaping — strings are put through sed to escape \ and " +j_esc() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \ + -e ':a;N;$!ba;s/\n/\\n/g' +} + +emit_error() { + printf '{"ok":false,"error":"%s"}\n' "$(j_esc "$1")" + exit 1 +} + +require_dev() { + [ -c "$DEV" ] || emit_error "kernel module not loaded (/dev/cellguard missing)" +} + +save_config() { + cat > "$CONFIG_FILE" << EOF +ENABLED=$ENABLED +BLOCK_2G=$BLOCK_2G +BLOCK_3G=$BLOCK_3G +BLOCK_NULL_CIPHER=$BLOCK_NULL_CIPHER +POLL_MS=$POLL_MS +CARRIER=$CARRIER +EOF +} + +##################################################################### +# status — read /proc/cellguard/status and reformat as JSON +##################################################################### +cmd_status() { + require_dev + if [ ! -f /proc/cellguard/status ]; then + emit_error "/proc/cellguard/status missing" + fi + + # /proc/cellguard/status is key: value, one per line + awk -v cfg2g="$BLOCK_2G" -v cfg3g="$BLOCK_3G" \ + -v cfgnull="$BLOCK_NULL_CIPHER" -v cfgpoll="$POLL_MS" ' + BEGIN { FS=":"; printf "{" ; first=1 } + { + k=$1; sub(/^ +/,"",k); sub(/ +$/,"",k) + v=$2; for (i=3;i<=NF;i++) v=v ":" $i + sub(/^ +/,"",v); sub(/ +$/,"",v) + gsub(/\\/,"\\\\",v); gsub(/"/,"\\\"",v) + if (!first) printf "," + first=0 + # numeric fields + if (k=="enabled" || k=="rat_code" || k=="cipher_known" || \ + k=="cipher_null" || k=="block_2g" || k=="block_3g" || \ + k=="block_null" || k=="poll_ms" || k=="rsrp_dbm" || \ + k=="tx_pwr_dbm" || \ + k=="downgrades" || k=="relocks" || k=="polls" || \ + k=="bands_locked") { + # rat field might be mixed — strip trailing junk + n=v; sub(/[^-0-9].*/,"",n); if (n=="") n=0 + printf "\"%s\":%s", k, n + } else if (k=="modem" || k=="rat" || k=="operator" || \ + k=="carrier" || k=="bands_method") { + printf "\"%s\":\"%s\"", k, v + } else { + printf "\"%s\":\"%s\"", k, v + } + } + END { printf ",\"ok\":true}" } + ' /proc/cellguard/status + echo +} + +##################################################################### +# on / off — flip the guard, persist to config +##################################################################### +cmd_on() { + require_dev + echo "on" > "$DEV" 2>/dev/null || emit_error "write /dev/cellguard failed" + ENABLED=1 + save_config + printf '{"ok":true,"enabled":1}\n' +} + +cmd_off() { + require_dev + echo "off" > "$DEV" 2>/dev/null || emit_error "write /dev/cellguard failed" + ENABLED=0 + save_config + printf '{"ok":true,"enabled":0}\n' +} + +##################################################################### +# apply-config — push saved policy, then on/off accordingly +# (called by service.sh at boot, and by any config-change flow) +##################################################################### +cmd_apply_config() { + require_dev + # Select the carrier profile first so that when we flip the guard on, + # the kernel's full push_lock_policy() applies WS46 + band-lock in + # one CFUN cycle. When the guard is off we still record the carrier + # for next enable. + if [ -n "$CARRIER" ] && [ "$CARRIER" != "none" ]; then + echo "carrier $CARRIER" > "$DEV" 2>/dev/null + fi + if [ "$ENABLED" = "1" ]; then + echo "on" > "$DEV" 2>/dev/null + else + echo "off" > "$DEV" 2>/dev/null + fi + printf '{"ok":true,"applied":true,"enabled":%s,"carrier":"%s"}\n' \ + "$ENABLED" "$CARRIER" +} + +##################################################################### +# relock — force an immediate policy push even if state looks fine +##################################################################### +cmd_relock() { + require_dev + echo "relock" > "$DEV" 2>/dev/null || emit_error "relock write failed" + printf '{"ok":true,"relocked":true}\n' +} + +##################################################################### +# scan — trigger AT+COPS=?, parse result, emit JSON list +# +# AT+COPS=? response: +# +COPS: (stat,"long","short","numeric",AcT),(...),... +# stat: 0=unknown 1=available 2=current 3=forbidden +# AcT : 0/1/3/8 = 2G, 2/4-6 = 3G, 7/9/10 = LTE, 11/13 = NR +##################################################################### +cmd_scan() { + require_dev + # Kick the scan and wait. AT+COPS=? can take up to ~180s on a cold + # SIM; the module runs it synchronously in the ioctl path. + echo "scan" > "$DEV" 2>/dev/null || emit_error "scan write failed" + + # Poll /proc/cellguard/cells until it has a +COPS: line or we time out + n=0 + while [ $n -lt 200 ]; do + if grep -q "+COPS:" /proc/cellguard/cells 2>/dev/null; then + break + fi + sleep 1 + n=$((n + 1)) + done + + raw=$(cat /proc/cellguard/cells 2>/dev/null) + [ -z "$raw" ] && emit_error "scan produced no output" + + printf '%s' "$raw" | awk ' + BEGIN { + printf "{\"ok\":true,\"cells\":[" + first=1 + } + { + line=$0 + # Work through each (...) tuple + while (match(line, /\([^()]+\)/)) { + tup=substr(line, RSTART+1, RLENGTH-2) + line=substr(line, RSTART+RLENGTH) + + # Split tup on commas, but ignore commas inside quotes + n=0 + buf="" + inq=0 + for (i=1;i<=length(tup);i++) { + c=substr(tup,i,1) + if (c=="\"") { inq=1-inq; continue } + if (c=="," && !inq) { n++; f[n]=buf; buf=""; continue } + buf = buf c + } + n++; f[n]=buf + + # A scan tuple has 5 fields: stat,long,short,numeric,act + # The trailing "supported modes" / "supported formats" tuples + # have 1-2 numeric fields — skip those. + if (n < 4) continue + stat=f[1]; longn=f[2]; shortn=f[3]; num=f[4]; act=f[5] + if (stat !~ /^[0-9]+$/) continue + + # Map AcT to RAT name and a security-ranking score + act_n = act + 0 + rat="?"; score=0 + if (act_n==0||act_n==1||act_n==3||act_n==8) { rat="2G"; score=1 } + else if (act_n==2||act_n==4||act_n==5||act_n==6) { rat="3G"; score=2 } + else if (act_n==7||act_n==9||act_n==10) { rat="LTE"; score=3 } + else if (act_n==11||act_n==13) { rat="5G-SA"; score=4 } + + avail="unknown" + if (stat=="0") avail="unknown" + else if (stat=="1") avail="available" + else if (stat=="2") avail="current" + else if (stat=="3") avail="forbidden" + + # Escape + gsub(/\\/,"\\\\",longn); gsub(/"/,"\\\"",longn) + gsub(/\\/,"\\\\",shortn); gsub(/"/,"\\\"",shortn) + gsub(/\\/,"\\\\",num); gsub(/"/,"\\\"",num) + + if (!first) printf "," + first=0 + printf "{\"operator\":\"%s\",\"short\":\"%s\",\"mcc_mnc\":\"%s\",\"rat\":\"%s\",\"act\":%d,\"avail\":\"%s\",\"score\":%d}", \ + longn, shortn, num, rat, act_n, avail, score + } + } + END { printf "]}\n" } + ' +} + +##################################################################### +# carrier — set the band-lock profile +# cellguard carrier list +# cellguard carrier +##################################################################### +cmd_carrier() { + name="$1" + if [ -z "$name" ] || [ "$name" = "list" ]; then + printf '{"ok":true,"current":"%s","carriers":[' "$CARRIER" + first=1 + for c in $VALID_CARRIERS; do + if [ "$first" = "1" ]; then first=0; else printf ','; fi + printf '"%s"' "$c" + done + printf ']}\n' + return + fi + + ok=0 + for c in $VALID_CARRIERS; do + [ "$c" = "$name" ] && ok=1 + done + [ "$ok" = "1" ] || emit_error "unknown carrier: $name" + + require_dev + echo "carrier $name" > "$DEV" 2>/dev/null || emit_error "write failed" + CARRIER="$name" + save_config + printf '{"ok":true,"carrier":"%s"}\n' "$name" +} + +##################################################################### +# config — read/write persisted policy from the WebUI +# +# Usage: +# cellguard config get +# cellguard config set block_2g=1 block_3g=0 block_null_cipher=1 poll_ms=2000 +##################################################################### +cmd_config() { + sub="$1"; shift + case "$sub" in + get|"") + printf '{"ok":true,"enabled":%s,"block_2g":%s,"block_3g":%s,"block_null_cipher":%s,"poll_ms":%s,"carrier":"%s"}\n' \ + "$ENABLED" "$BLOCK_2G" "$BLOCK_3G" "$BLOCK_NULL_CIPHER" "$POLL_MS" "$CARRIER" + ;; + set) + while [ -n "$1" ]; do + k="${1%%=*}" + v="${1#*=}" + case "$k" in + enabled) ENABLED="$v" ;; + block_2g) BLOCK_2G="$v" ;; + block_3g) BLOCK_3G="$v" ;; + block_null_cipher) BLOCK_NULL_CIPHER="$v" ;; + poll_ms) POLL_MS="$v" ;; + *) emit_error "unknown key: $k" ;; + esac + shift + done + save_config + cmd_apply_config + ;; + *) + emit_error "config: unknown subcommand '$sub'" + ;; + esac +} + +##################################################################### +# dispatch +##################################################################### +case "$1" in + status) cmd_status ;; + on) cmd_on ;; + off) cmd_off ;; + relock) cmd_relock ;; + scan) cmd_scan ;; + carrier) shift; cmd_carrier "$@" ;; + apply-config) cmd_apply_config ;; + config) shift; cmd_config "$@" ;; + *) + cat << USAGE +cellguard — driver for /dev/cellguard + + cellguard status show current RAT, operator, cipher, counters + cellguard on enable the guard (force LTE/NR, re-lock on drop) + cellguard off disable and release the modem + cellguard relock force one relock pass now + cellguard scan full AT+COPS=? scan, JSON list of visible networks + cellguard config get|set read or update persisted policy + +All subcommands emit a single-line JSON object on stdout. +USAGE + ;; +esac diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..a8bd532 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,22 @@ +#!/system/bin/sh +# CellGuard — cleanup on uninstall + +CONFIG_DIR="/data/adb/cellguard" +PID_FILE="$CONFIG_DIR/webui.pid" + +# Stop WebUI server +if [ -f "$PID_FILE" ]; then + kill $(cat "$PID_FILE") 2>/dev/null +fi + +# Release the kernel-enforced RAT lock before unloading so the modem +# can return to user-selected preference instead of being stuck. +if [ -c /dev/cellguard ]; then + echo "release" > /dev/cellguard 2>/dev/null +fi + +# Unload kernel module +lsmod 2>/dev/null | grep -q cellguard && rmmod cellguard 2>/dev/null + +# Remove persistent config +rm -rf "$CONFIG_DIR" diff --git a/webroot/css/style.css b/webroot/css/style.css new file mode 100644 index 0000000..edd6e05 --- /dev/null +++ b/webroot/css/style.css @@ -0,0 +1,190 @@ +:root { + --bg: #0b0f14; + --panel: #11171f; + --panel-2: #161e29; + --line: #1f2a38; + --text: #e6edf3; + --muted: #8ea0b5; + --accent: #7ee787; + --warn: #f2cc60; + --err: #ff7b72; + --ok: #7ee787; + --blue: #79c0ff; +} + +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.4; + overscroll-behavior-y: none; +} + +/* Header */ +.bar { + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + background: rgba(11,15,20,0.92); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--line); +} +.title { display: flex; align-items: center; gap: 10px; } +.title h1 { margin: 0; font-size: 17px; letter-spacing: 0.02em; } +.logo { color: var(--accent); font-size: 18px; } +.state { + font-size: 12px; font-weight: 600; + padding: 4px 10px; border-radius: 999px; + background: var(--panel-2); color: var(--muted); + border: 1px solid var(--line); +} +.state.on { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, var(--line)); } +.state.off { color: var(--muted); } +.state.err { color: var(--err); border-color: color-mix(in srgb, var(--err) 40%, var(--line)); } + +main { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 680px; margin: 0 auto; } + +/* Cards */ +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px 18px; +} +.card h2 { font-size: 15px; margin: 0 0 10px; } +.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 4px; } +.card-head h2 { margin: 0; } +.hint { font-size: 12px; color: var(--muted); margin: 0 0 10px; } + +/* Toggle card */ +.toggle-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; } +.toggle-copy h2 { margin: 0 0 4px; } +.toggle-copy p { margin: 0; font-size: 13px; color: var(--muted); } + +/* Toggle switch */ +.switch { + position: relative; display: inline-block; + width: 58px; height: 32px; flex-shrink: 0; +} +.switch input { opacity: 0; width: 0; height: 0; } +.slider { + position: absolute; inset: 0; + background: #2a3646; border-radius: 999px; cursor: pointer; + transition: background .2s; +} +.slider::before { + content: ""; + position: absolute; height: 24px; width: 24px; left: 4px; top: 4px; + background: #cdd9e5; border-radius: 50%; + transition: transform .2s, background .2s; +} +.switch input:checked + .slider { background: color-mix(in srgb, var(--accent) 60%, #1d2a1f); } +.switch input:checked + .slider::before { transform: translateX(26px); background: var(--accent); } +.switch input:disabled + .slider { opacity: .5; cursor: not-allowed; } + +/* Key/value rows */ +.row { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent); +} +.row:last-child { border-bottom: none; } +.row.sub .k, .row.sub .v { color: var(--muted); font-size: 13px; } +.k { color: var(--muted); } +.v { font-weight: 600; } +.v.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; font-weight: 500; } + +.badge { + display: inline-block; padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 600; letter-spacing: 0.02em; + border: 1px solid var(--line); +} +.badge-2g { color: var(--err); background: color-mix(in srgb, var(--err) 18%, transparent); border-color: color-mix(in srgb, var(--err) 50%, var(--line)); } +.badge-3g { color: var(--warn); background: color-mix(in srgb, var(--warn) 15%, transparent); border-color: color-mix(in srgb, var(--warn) 50%, var(--line)); } +.badge-lte { color: var(--blue); background: color-mix(in srgb, var(--blue) 15%, transparent); border-color: color-mix(in srgb, var(--blue) 50%, var(--line)); } +.badge-5g { color: var(--ok); background: color-mix(in srgb, var(--ok) 18%, transparent); border-color: color-mix(in srgb, var(--ok) 50%, var(--line)); } +.badge-unk { color: var(--muted); } + +/* Select dropdown */ +.sel { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 34px 8px 12px; + font: inherit; font-weight: 600; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 12px center; +} +.sel:hover { background-color: #1e2838; border-color: #2a3646; } +.sel:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Buttons */ +.btn { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 14px; + font: inherit; font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s, opacity .15s; +} +.btn:hover:not(:disabled) { background: #1e2838; border-color: #2a3646; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Cells list */ +.cells { display: flex; flex-direction: column; gap: 6px; } +.carrier-group { + background: var(--panel-2); border: 1px solid var(--line); + border-radius: 12px; overflow: hidden; +} +.carrier-head { + padding: 10px 14px; + border-bottom: 1px solid var(--line); + font-weight: 700; font-size: 14px; + display: flex; justify-content: space-between; align-items: center; +} +.carrier-head .mccmnc { color: var(--muted); font-weight: 500; font-size: 12px; font-family: ui-monospace, Menlo, monospace; } +.cell-row { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; + border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent); + font-size: 13px; +} +.cell-row:first-child { border-top: none; } +.cell-row .left { display: flex; align-items: center; gap: 10px; } +.cell-row .right { color: var(--muted); font-size: 12px; } + +.avail-current { color: var(--accent); } +.avail-available { color: var(--text); } +.avail-forbidden { color: var(--err); } +.avail-unknown { color: var(--muted); } + +.empty { padding: 20px; text-align: center; color: var(--muted); } + +/* Footer */ +.foot { + text-align: center; font-size: 11px; color: var(--muted); + padding: 10px 0 24px; +} +.dot { margin: 0 6px; } + +/* Scanning spinner */ +.spin { + display: inline-block; width: 12px; height: 12px; + border: 2px solid color-mix(in srgb, var(--muted) 50%, transparent); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: -2px; + margin-right: 6px; +} +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/webroot/index.html b/webroot/index.html new file mode 100644 index 0000000..c98b9e0 --- /dev/null +++ b/webroot/index.html @@ -0,0 +1,69 @@ + + + + + + +CellGuard + + + +
+
+ +

CellGuard

+
+
+
+ +
+ +
+
+

Block 2G / 3G

+

Force LTE / 5G(NR) only. Re-locks the modem on any downgrade.

+
+ +
+ + +
+
+

IMSI-catcher detection

+ +
+
Status
+
Serving RAT
+
Operator
+
PLMN
+
Cell CI / PCI / TAC
+
EARFCN
+
Diag frames0
+
RAT lock
+
+ + +
+
+

Alerts

+ +
+
+
+ +
+ cellguard + · + idle +
+
+ + + + diff --git a/webroot/js/app.js b/webroot/js/app.js new file mode 100644 index 0000000..9932cba --- /dev/null +++ b/webroot/js/app.js @@ -0,0 +1,135 @@ +// CellGuard WebUI — KernelSU-Next ksu.exec() bridge. +// +// The WebUI is a viewer/controller only. All work runs in background daemons: +// ratlock.sh — holds LTE+5G(NR) only (the 2G/3G-block toggle) +// cgdetect.sh — always-on IMSI-catcher detection -> status + alerts files +// cgcap.ko — raw CP-frame capture (diag) +// It talks to them through cgctl.sh, which emits one JSON line per command. + +(function () { + const $ = (id) => document.getElementById(id); + const MOD = "/data/adb/modules/cellguard"; + + const ui = { + toggle: $("toggle"), dToggle: $("dToggle"), headerState: $("headerState"), dVerdict: $("dVerdict"), + sRat: $("sRat"), sOp: $("sOp"), sPlmn: $("sPlmn"), sCell: $("sCell"), + sEarfcn: $("sEarfcn"), sFrames: $("sFrames"), sLock: $("sLock"), + alerts: $("alerts"), footMsg: $("footMsg"), clrBtn: $("clrBtn"), + }; + + // KernelSU-Next bridge: ksu.exec(command, optionsJson, callbackFuncName). + // Results arrive via window[callbackFuncName](errno, stdout, stderr) — the + // callback is passed BY NAME (a string), not as a function object. + function exec(cmd) { + return new Promise((resolve) => { + if (typeof ksu === "undefined" || !ksu.exec) { + resolve({ code: -1, stdout: "", stderr: "ksu bridge missing" }); + return; + } + const cb = "cg_cb_" + Math.random().toString(36).slice(2); + let done = false; + const finish = (code, stdout, stderr) => { + if (done) return; done = true; + try { delete window[cb]; } catch (_) {} + resolve({ code: (code | 0), stdout: stdout || "", stderr: stderr || "" }); + }; + window[cb] = finish; + try { + ksu.exec(cmd, "{}", cb); + } catch (e) { + // very old bridge with a direct function callback + try { ksu.exec(cmd, (c, o, er) => finish(c, o, er)); } + catch (_) { finish(-1, "", String(e)); } + } + setTimeout(() => finish(-1, "", "timeout"), 8000); + }); + } + + async function ctl(sub) { + const r = await exec("sh " + MOD + "/cgctl.sh " + sub); + try { return JSON.parse(r.stdout.trim()); } + catch (e) { return { ok: false, error: r.stderr || r.stdout || `exit ${r.code}` }; } + } + + function esc(s) { + return String(s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + + function ratBadge(r) { + const cls = ({ + GSM: "badge-2g", GERAN: "badge-2g", GPRS: "badge-2g", EDGE: "badge-2g", + UMTS: "badge-3g", WCDMA: "badge-3g", HSPA: "badge-3g", HSPAP: "badge-3g", UTRAN: "badge-3g", + LTE: "badge-lte", NR: "badge-5g", + })[r] || "badge-unk"; + return `${esc(r || "?")}`; + } + + async function refresh() { + const s = await ctl("status"); + if (!s || !s.ok) { ui.footMsg.textContent = (s && s.error) || "backend unavailable"; return; } + + ui.toggle.checked = !!s.block; + ui.toggle.disabled = false; + ui.dToggle.checked = !!s.detect; + ui.dToggle.disabled = false; + + ui.sRat.innerHTML = ratBadge(s.rat); + ui.sOp.textContent = (s.operator && s.operator !== "?") ? s.operator : "no service"; + ui.sPlmn.textContent = s.plmn || "?"; + ui.sCell.textContent = `${s.ci || "?"} / ${s.pci || "?"} / ${s.tac || "?"}`; + ui.sEarfcn.textContent = s.earfcn || "?"; + ui.sFrames.textContent = s.frames || 0; + ui.sLock.innerHTML = s.lock + ? `LTE + 5G only` + : `2G/3G allowed`; + + const bad = s.suspect && s.suspect !== "none"; + ui.dVerdict.innerHTML = bad + ? `${esc(s.suspect)}` + : `clear`; + ui.headerState.textContent = bad ? "ALERT" : (s.block ? "armed" : "off"); + ui.headerState.className = "state " + (bad ? "err" : (s.block ? "on" : "off")); + ui.footMsg.textContent = `RAT ${s.rat || "?"} · ${s.frames || 0} frames`; + + const a = await ctl("alerts"); + if (a && a.ok) { + ui.alerts.innerHTML = (a.lines && a.lines.length) + ? a.lines.slice().reverse().map(l => + `
${esc(l)}
`).join("") + : `
No alerts.
`; + } + } + + async function onToggle() { + ui.toggle.disabled = true; + ui.footMsg.textContent = ui.toggle.checked ? "enabling 2G/3G block…" : "disabling…"; + const r = await ctl("block " + (ui.toggle.checked ? "on" : "off")); + if (!r || !r.ok) { ui.toggle.checked = !ui.toggle.checked; ui.footMsg.textContent = "failed"; } + ui.toggle.disabled = false; + setTimeout(refresh, 800); + } + + async function onDetectToggle() { + ui.dToggle.disabled = true; + ui.footMsg.textContent = ui.dToggle.checked ? "enabling detection…" : "pausing detection…"; + const r = await ctl("detect " + (ui.dToggle.checked ? "on" : "off")); + if (!r || !r.ok) { ui.dToggle.checked = !ui.dToggle.checked; ui.footMsg.textContent = "failed"; } + ui.dToggle.disabled = false; + setTimeout(refresh, 800); + } + + async function onClear() { + await exec("sh -c ': > /data/adb/cellguard/alerts'"); + refresh(); + } + + ui.toggle.disabled = true; + ui.dToggle.disabled = true; + ui.toggle.addEventListener("change", onToggle); + ui.dToggle.addEventListener("change", onDetectToggle); + ui.clrBtn.addEventListener("click", onClear); + + refresh(); + setInterval(refresh, 3000); +})();