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

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.