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:
sssnake
2026-07-12 09:21:54 -07:00
commit 9a3e3a0501
27 changed files with 3289 additions and 0 deletions

View 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 L0L3 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.

View 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
View 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
View 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'"

Binary file not shown.

1080
common/kmod/cellguard.c Normal file

File diff suppressed because it is too large Load Diff

BIN
common/kmod/cellguard.ko Normal file

Binary file not shown.

View 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
View 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

Binary file not shown.

129
common/tools/cg_decode.py Normal file
View 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()