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
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -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
|
||||||
65
META-INF/com/google/android/update-binary
Executable file
65
META-INF/com/google/android/update-binary
Executable file
@@ -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."
|
||||||
1
META-INF/com/google/android/updater-script
Normal file
1
META-INF/com/google/android/updater-script
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#MAGISK
|
||||||
38
action.sh
Executable file
38
action.sh
Executable file
@@ -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"
|
||||||
40
cgctl.sh
Executable file
40
cgctl.sh
Executable file
@@ -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
|
||||||
97
cgdetect.sh
Executable file
97
cgdetect.sh
Executable file
@@ -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
|
||||||
193
common/docs/anti-forensics-hardening.md
Normal file
193
common/docs/anti-forensics-hardening.md
Normal file
@@ -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 <img>` 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.
|
||||||
203
common/docs/cellguard-dossier.md
Normal file
203
common/docs/cellguard-dossier.md
Normal file
@@ -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 <ch> <u16 len LE> <payload>`**. 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`.
|
||||||
27
common/kmod/Makefile
Normal file
27
common/kmod/Makefile
Normal file
@@ -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'"
|
||||||
126
common/kmod/build-stock-gki.sh
Executable file
126
common/kmod/build-stock-gki.sh
Executable file
@@ -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<sha12>-ab<bid>-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 <artifact> -> $ART/<artifact>
|
||||||
|
[ -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'"
|
||||||
BIN
common/kmod/cellguard-6.6.102-android15-stock.ko
Normal file
BIN
common/kmod/cellguard-6.6.102-android15-stock.ko
Normal file
Binary file not shown.
1080
common/kmod/cellguard.c
Normal file
1080
common/kmod/cellguard.c
Normal file
File diff suppressed because it is too large
Load Diff
BIN
common/kmod/cellguard.ko
Normal file
BIN
common/kmod/cellguard.ko
Normal file
Binary file not shown.
53
common/kmod/cellguard_ioctl.h
Normal file
53
common/kmod/cellguard_ioctl.h
Normal file
@@ -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 <linux/types.h>
|
||||||
|
#include <linux/ioctl.h>
|
||||||
|
|
||||||
|
#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 */
|
||||||
304
common/kmod/cgcap.c
Normal file
304
common/kmod/cgcap.c
Normal file
@@ -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 <linux/module.h>
|
||||||
|
#include <linux/kernel.h>
|
||||||
|
#include <linux/init.h>
|
||||||
|
#include <linux/fs.h>
|
||||||
|
#include <linux/kprobes.h>
|
||||||
|
#include <linux/uaccess.h>
|
||||||
|
#include <linux/slab.h>
|
||||||
|
#include <linux/proc_fs.h>
|
||||||
|
#include <linux/seq_file.h>
|
||||||
|
#include <linux/spinlock.h>
|
||||||
|
#include <linux/ktime.h>
|
||||||
|
#include <linux/skbuff.h>
|
||||||
|
|
||||||
|
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);
|
||||||
BIN
common/kmod/cgcap.ko
Normal file
BIN
common/kmod/cgcap.ko
Normal file
Binary file not shown.
129
common/tools/cg_decode.py
Normal file
129
common/tools/cg_decode.py
Normal file
@@ -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()
|
||||||
6
module.prop
Normal file
6
module.prop
Normal file
@@ -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).
|
||||||
23
post-fs-data.sh
Executable file
23
post-fs-data.sh
Executable file
@@ -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
|
||||||
70
ratlock.sh
Executable file
70
ratlock.sh
Executable file
@@ -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
|
||||||
17
sepolicy/cellguard.rule
Normal file
17
sepolicy/cellguard.rule
Normal file
@@ -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 }
|
||||||
57
service.sh
Executable file
57
service.sh
Executable file
@@ -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"
|
||||||
326
system/bin/cellguard
Executable file
326
system/bin/cellguard
Executable file
@@ -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 <cmd>").
|
||||||
|
# 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 <name>
|
||||||
|
#####################################################################
|
||||||
|
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
|
||||||
22
uninstall.sh
Executable file
22
uninstall.sh
Executable file
@@ -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"
|
||||||
190
webroot/css/style.css
Normal file
190
webroot/css/style.css
Normal file
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%238ea0b5' d='M0 0l5 6 5-6z'/></svg>");
|
||||||
|
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); } }
|
||||||
69
webroot/index.html
Normal file
69
webroot/index.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0b0f14">
|
||||||
|
<title>CellGuard</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="bar">
|
||||||
|
<div class="title">
|
||||||
|
<span class="logo">◉</span>
|
||||||
|
<h1>CellGuard</h1>
|
||||||
|
</div>
|
||||||
|
<div class="state" id="headerState">—</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Primary toggle: block 2G/3G (anti-downgrade enforcement) -->
|
||||||
|
<section class="card toggle-card">
|
||||||
|
<div class="toggle-copy">
|
||||||
|
<h2>Block 2G / 3G</h2>
|
||||||
|
<p id="toggleDesc">Force LTE / 5G(NR) only. Re-locks the modem on any downgrade.</p>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="toggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- IMSI-catcher detection (runs in the background daemon) -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>IMSI-catcher detection</h2>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="dToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row"><span class="k">Status</span><span class="v" id="dVerdict">—</span></div>
|
||||||
|
<div class="row"><span class="k">Serving RAT</span><span class="v" id="sRat">—</span></div>
|
||||||
|
<div class="row"><span class="k">Operator</span><span class="v" id="sOp">—</span></div>
|
||||||
|
<div class="row"><span class="k">PLMN</span><span class="v mono" id="sPlmn">—</span></div>
|
||||||
|
<div class="row sub"><span class="k">Cell CI / PCI / TAC</span><span class="v mono" id="sCell">—</span></div>
|
||||||
|
<div class="row sub"><span class="k">EARFCN</span><span class="v mono" id="sEarfcn">—</span></div>
|
||||||
|
<div class="row sub"><span class="k">Diag frames</span><span class="v" id="sFrames">0</span></div>
|
||||||
|
<div class="row sub"><span class="k">RAT lock</span><span class="v" id="sLock">—</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Alert log -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Alerts</h2>
|
||||||
|
<button id="clrBtn" class="btn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="alerts" class="cells"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<span id="footVer">cellguard</span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span id="footMsg">idle</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
webroot/js/app.js
Normal file
135
webroot/js/app.js
Normal file
@@ -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 `<span class="badge ${cls}">${esc(r || "?")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<span class="badge badge-5g">LTE + 5G only</span>`
|
||||||
|
: `<span class="badge badge-2g">2G/3G allowed</span>`;
|
||||||
|
|
||||||
|
const bad = s.suspect && s.suspect !== "none";
|
||||||
|
ui.dVerdict.innerHTML = bad
|
||||||
|
? `<span class="badge badge-2g">${esc(s.suspect)}</span>`
|
||||||
|
: `<span class="badge badge-5g">clear</span>`;
|
||||||
|
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 =>
|
||||||
|
`<div class="cell-row"><div class="left">${esc(l)}</div></div>`).join("")
|
||||||
|
: `<div class="empty">No alerts.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user