Files
CellGuard/common/kmod/cellguard.c
sssnake 9a3e3a0501 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
2026-07-12 09:21:54 -07:00

1081 lines
30 KiB
C

// SPDX-License-Identifier: GPL-2.0
/*
* cellguard.ko — Kernel-enforced cellular security policy
*
* Blocks 2G (GERAN) and null-cipher connections, forces the modem to
* register only on LTE (E-UTRAN) and 5G-SA (NR). A watchdog kthread
* polls registration/cipher state and re-asserts the lock within ~1s
* of any downgrade attempt.
*
* /dev/cellguard — character device (write: "on" | "off" |
* "release" | "scan"; ioctls below)
* /proc/cellguard/status — human-readable state
* /proc/cellguard/cells — last scan result (from AT+COPS=?)
*
* Target: Pixel 10 Pro Fold (rango), Tensor G5, Shannon 5400 modem.
* The module opens the tertiary Shannon AT channel (/dev/umts_atc1)
* so it does not race RIL (umts_router) or RadioControl (umts_atc0).
*/
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#include <linux/mutex.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/ioctl.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/version.h>
#include <linux/string.h>
#include <linux/ctype.h>
#include <linux/namei.h>
#include <linux/kprobes.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("snake");
MODULE_DESCRIPTION("Block 2G / null-cipher cellular; force highest-security RAT");
MODULE_VERSION("1.0");
MODULE_IMPORT_NS(VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver);
#define DEVICE_NAME "cellguard"
#define CLASS_NAME "cellguard"
#define CMD_BUF_SIZE 512
#define RESP_BUF_SIZE 8192
#define SCAN_BUF_SIZE 16000
/* ioctl */
#define CG_MAGIC 'C'
#define CG_IOC_ENABLE _IO(CG_MAGIC, 1)
#define CG_IOC_DISABLE _IO(CG_MAGIC, 2)
#define CG_IOC_RELEASE _IO(CG_MAGIC, 3)
#define CG_IOC_GET_STATUS _IOR(CG_MAGIC, 4, struct cg_status)
#define CG_IOC_SCAN _IOR(CG_MAGIC, 5, struct cg_scan)
#define CG_IOC_SET_POLICY _IOW(CG_MAGIC, 6, struct cg_policy)
struct cg_status {
int32_t enabled;
int32_t modem_ok;
char modem_path[64];
char rat[16]; /* "LTE", "NR", "UTRAN", "GERAN", "?" */
int32_t rat_code; /* 0=UNK 2=GSM 3=UTRAN 7=LTE 13=NR */
int32_t cipher_known;
int32_t cipher_null;
char operator_name[32];
char mcc_mnc[16];
int32_t rsrp; /* LTE/NR dBm (0 if unknown) */
int32_t tx_pwr;
uint64_t downgrades_blocked;
uint64_t relocks;
uint64_t polls;
};
struct cg_scan {
char data[SCAN_BUF_SIZE];
uint32_t data_len;
int32_t status; /* 0=OK, negative=error */
};
struct cg_policy {
int32_t block_2g; /* 1=block GERAN (default) */
int32_t block_3g; /* 1=block UTRAN (default) */
int32_t block_null_cipher; /* 1=block A5/0 (default) */
int32_t poll_interval_ms; /* default 1500 */
};
/*
* Per-carrier LTE/NR band profile. Band numbers terminate with 0.
* Masks are built from these at push time.
*/
struct carrier_profile {
char name[16];
int lte_bands[32];
int nr_bands[32];
};
/* US carriers. Band sets as of 2026 deployment data. */
static const struct carrier_profile carrier_profiles[] = {
{ "none", {0}, {0} },
{ "tmobile", {2,4,5,12,25,41,66,71,0}, {25,41,66,71,258,260,261,0} },
{ "verizon", {2,4,5,13,46,48,66,0}, {2,5,66,77,260,261,0} },
{ "att", {2,4,5,12,14,17,29,30,66,0}, {2,5,14,30,66,77,260,261,0} },
{ "uscellular", {2,4,5,12,25,26,66,71,0}, {66,71,77,0} },
{ "dish", {29,66,70,71,0}, {29,66,70,71,0} },
{ "cricket", {2,4,5,12,14,17,29,30,66,0}, {2,5,14,30,66,77,260,261,0} },
{ "metro", {2,4,5,12,25,41,66,71,0}, {25,41,66,71,258,260,261,0} },
{ "googlefi", {2,4,5,12,25,41,66,71,0}, {25,41,66,71,258,260,261,0} },
{ "generic_us", {2,4,5,12,13,14,17,25,26,29,30,41,46,48,66,71,0},
{2,5,14,25,30,41,66,70,71,77,78,260,261,0} },
};
#define N_CARRIERS ARRAY_SIZE(carrier_profiles)
/* Shannon AT channels, in order of preference.
* umts_atc1 is tertiary — not claimed by RIL or RadioControl.
*/
static const char *modem_paths[] = {
"/dev/umts_atc1",
"/dev/nr_atc0",
"/dev/umts_atc0",
"/dev/umts_router",
"/dev/umts_router0",
NULL
};
static int major;
static struct class *cg_class;
static struct cdev cg_cdev;
static struct device *cg_dev;
static struct file *modem_filp;
static char modem_path_used[64];
static DEFINE_MUTEX(at_mutex);
static struct task_struct *watchdog_thread;
static struct cg_policy policy = {
.block_2g = 1,
.block_3g = 1,
.block_null_cipher = 1,
.poll_interval_ms = 1500,
};
static bool guard_enabled;
/* Current carrier + band-lock state */
static int current_carrier; /* index into carrier_profiles; 0=none */
static int bands_locked; /* 1 if last push returned OK */
static char bands_push_method[32]; /* which AT form the modem accepted */
/* Cached state (updated by watchdog) */
static char cur_rat[16] = "?";
static int cur_rat_code;
static int cur_cipher_known;
static int cur_cipher_null;
static char cur_operator[32] = "";
static char cur_mcc_mnc[16] = "";
static int cur_rsrp;
static int cur_tx_pwr = -140;
static int cur_pwr_known;
static uint64_t stat_downgrades;
static uint64_t stat_relocks;
static uint64_t stat_polls;
/* Last AT+COPS=? scan result (raw) */
static char scan_buf[SCAN_BUF_SIZE];
static int scan_len;
static DEFINE_MUTEX(scan_mutex);
/* Global AT response buffer to avoid stack frame overflow */
static char *at_resp_buf;
#define AT_RESP_SIZE RESP_BUF_SIZE
/* ---------- GKI protected-symbol resolution ----------
*
* Stock GKI (CONFIG_MODULE_SIG_PROTECT) refuses to load an *unsigned*
* module that link-imports a protected core symbol. kernel_read() and
* kernel_write() are both protected, so importing them yields
* "Protected symbol: kernel_write (err -13)" at insmod time.
*
* Instead we resolve them at load time via kallsyms. The only link-time
* dependency this adds is the kprobe API (register/unregister_kprobe),
* which is part of the stable vendor KMI. Net effect: the module loads
* unsigned on any stock GKI build, and is portable across devices/builds
* because nothing protected is bound at link time.
*/
typedef ssize_t (*cg_kernel_read_t)(struct file *, void *, size_t, loff_t *);
typedef ssize_t (*cg_kernel_write_t)(struct file *, const void *, size_t,
loff_t *);
static cg_kernel_read_t cg_kernel_read;
static cg_kernel_write_t cg_kernel_write;
/* Obtain kallsyms_lookup_name() (itself unexported) via a one-shot kprobe. */
static unsigned long cg_lookup_name(const char *name)
{
static unsigned long (*kln)(const char *name);
if (!kln) {
struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" };
int ret = register_kprobe(&kp);
if (ret < 0) {
pr_err("cellguard: kprobe bootstrap failed (%d)\n", ret);
return 0;
}
kln = (void *)kp.addr;
unregister_kprobe(&kp);
}
return kln ? kln(name) : 0;
}
static int cg_resolve_protected(void)
{
cg_kernel_read = (cg_kernel_read_t) cg_lookup_name("kernel_read");
cg_kernel_write = (cg_kernel_write_t)cg_lookup_name("kernel_write");
if (!cg_kernel_read || !cg_kernel_write) {
pr_err("cellguard: failed to resolve kernel_read/kernel_write\n");
return -ENOENT;
}
return 0;
}
/* ---------- modem I/O ---------- */
static struct file *open_modem(void)
{
struct file *f;
struct path path;
int i, ret;
for (i = 0; modem_paths[i]; i++) {
ret = kern_path(modem_paths[i], LOOKUP_FOLLOW, &path);
if (ret)
continue;
f = dentry_open(&path, O_RDWR | O_NONBLOCK, current_cred());
path_put(&path);
if (!IS_ERR(f)) {
strscpy(modem_path_used, modem_paths[i],
sizeof(modem_path_used));
pr_info("cellguard: using AT channel %s\n",
modem_paths[i]);
return f;
}
}
return ERR_PTR(-ENODEV);
}
/*
* Send one AT command, collect response up to OK/ERROR or timeout.
* Must be called with at_mutex held.
*/
static int at_cmd(const char *cmd, char *resp, int resp_size, int timeout_ms)
{
char fullcmd[CMD_BUF_SIZE];
loff_t pos = 0;
ssize_t w, r;
int elapsed = 0, total = 0;
int clen;
if (!modem_filp || IS_ERR(modem_filp))
return -ENODEV;
clen = scnprintf(fullcmd, sizeof(fullcmd), "%s\r\n", cmd);
w = cg_kernel_write(modem_filp, fullcmd, clen, &pos);
if (w < 0)
return (int)w;
memset(resp, 0, resp_size);
pos = 0;
while (elapsed < timeout_ms && total < resp_size - 1) {
r = cg_kernel_read(modem_filp, resp + total,
resp_size - 1 - total, &pos);
if (r > 0) {
total += r;
resp[total] = '\0';
if (strstr(resp, "\r\nOK\r\n") ||
strstr(resp, "\r\nERROR\r\n") ||
strstr(resp, "+CME ERROR:") ||
strstr(resp, "+CMS ERROR:"))
break;
} else {
msleep(50);
elapsed += 50;
}
}
if (total == 0)
return -ETIMEDOUT;
return total;
}
/* ---------- band mask helpers ---------- */
/*
* Build a hex bitmap from a zero-terminated band list. Band N maps to
* bit (N-1). Output is up to 64 hex digits (256 bits), leading zeros
* trimmed. Returns the length written.
*/
static int hex_mask_from_bands(const int *bands, char *out, int out_sz)
{
uint64_t mask[4] = { 0, 0, 0, 0 };
int i, top, written;
for (i = 0; bands[i]; i++) {
int b = bands[i] - 1;
if (b < 0 || b >= 256)
continue;
mask[b >> 6] |= 1ULL << (b & 63);
}
/* Find highest non-zero word to trim leading zeros */
for (top = 3; top > 0 && mask[top] == 0; top--) ;
if (top == 0)
written = scnprintf(out, out_sz, "%llx", mask[0]);
else if (top == 1)
written = scnprintf(out, out_sz, "%llx%016llx",
mask[1], mask[0]);
else if (top == 2)
written = scnprintf(out, out_sz, "%llx%016llx%016llx",
mask[2], mask[1], mask[0]);
else
written = scnprintf(out, out_sz, "%llx%016llx%016llx%016llx",
mask[3], mask[2], mask[1], mask[0]);
return written;
}
/*
* Build a comma-separated list of band numbers (for AT forms that
* accept a list instead of a bitmap).
*/
static int list_from_bands(const int *bands, char *out, int out_sz)
{
int i, n = 0;
out[0] = '\0';
for (i = 0; bands[i]; i++) {
int w = scnprintf(out + n, out_sz - n,
i == 0 ? "%d" : ",%d", bands[i]);
if (w <= 0)
break;
n += w;
}
return n;
}
/*
* Try several known Shannon band-lock AT command forms in order. Stops
* at the first that returns OK. Records which form worked so status
* output can show it.
*
* Returns 1 if any form was accepted, 0 otherwise. Called with
* at_mutex held.
*/
static int try_band_lock(const char *rat_tag, int rat_id,
const int *bands)
{
char mask_hex[80];
char mask_list[256];
char cmd[320];
int i, n;
/* Empty band list -> nothing to do. */
if (!bands[0])
return 0;
hex_mask_from_bands(bands, mask_hex, sizeof(mask_hex));
list_from_bands(bands, mask_list, sizeof(mask_list));
struct variant {
const char *fmt;
bool use_list;
bool use_property;
};
static const struct variant lte_variants[] = {
{ "AT+GOOGSETPROPERTY=\"NASU.LTE Band Priority\",\"%s\"", true, true },
{ "AT+LBANDSEL=0x%s", false, false },
{ "AT+LBANDSEL=%s", false, false },
{ "AT+BANDIND=%s", false, false },
{ "AT$BANDSEL=7,%s", false, false },
{ "AT$BANDLOCK=7,%s", false, false },
{ "AT+BANDSEL=7,%s", true, false },
{ "AT+QCFG=\"band\",0,%s,0,1", false, false },
{ NULL, false, false }
};
static const struct variant nr_variants[] = {
{ "AT+NRBANDSEL=0x%s", false, false },
{ "AT+NRBANDSEL=%s", false, false },
{ "AT+NRBANDIND=%s", false, false },
{ "AT$BANDSEL=12,%s", false, false },
{ "AT$BANDLOCK=12,%s", false, false },
{ "AT+BANDSEL=12,%s", true, false },
{ "AT+QCFG=\"nr5g_band\",%s", false, false },
{ NULL, false, false }
};
const struct variant *variants =
(rat_id == 12) ? nr_variants : lte_variants;
for (i = 0; variants[i].fmt; i++) {
const char *arg = variants[i].use_list ? mask_list : mask_hex;
scnprintf(cmd, sizeof(cmd), variants[i].fmt, arg);
n = at_cmd(cmd, at_resp_buf, AT_RESP_SIZE, 3000);
if (n > 0 && (strstr(at_resp_buf, "\r\nOK\r\n") ||
strstr(at_resp_buf, "\nOK\n"))) {
pr_info("cellguard: %s band-lock via '%s' accepted\n",
rat_tag, variants[i].fmt);
snprintf(bands_push_method, sizeof(bands_push_method),
"%s:%s", rat_tag, variants[i].fmt);
return 1;
}
}
return 0;
}
static void push_band_lock(void)
{
const struct carrier_profile *p;
int lte_ok, nr_ok;
bands_locked = 0;
bands_push_method[0] = '\0';
if (current_carrier <= 0 || current_carrier >= N_CARRIERS)
return;
if (!modem_filp)
return;
p = &carrier_profiles[current_carrier];
/* at_mutex is held by the caller (push_lock_policy). */
lte_ok = try_band_lock("LTE", 7, p->lte_bands);
nr_ok = try_band_lock("NR", 12, p->nr_bands);
if (lte_ok || nr_ok)
bands_locked = 1;
}
/* ---------- policy push ---------- */
/*
* Lock modem to LTE+NR, enable ciphering-indication URCs, and keep
* the 2G/3G RATs disabled. The commands below are 3GPP-standard
* where possible, with Shannon-specific fallbacks.
*
* AT+WS46=28 — select E-UTRAN + NR (TS 27.007)
* AT+CEMODE=2 — LTE/NR PS+CS only (Samsung NVRAM hint)
* AT+CGDCONT... — not touched
* AT$CIPHERINGIND=1 — enable URC on cipher-algorithm change
* AT+CSCS="GSM" — ensure ASCII responses
*
* We do NOT modify SIM/network search lists; user can still roam.
*/
static void push_lock_policy(void)
{
int ws46;
if (!modem_filp)
return;
/* Compose WS46 value from policy:
* 25 = UTRAN+E-UTRAN+NR (3G allowed)
* 28 = E-UTRAN+NR only (hardened, default)
* 29 = GERAN+UTRAN+E-UTRAN (2G allowed)
* 30 = E-UTRAN only
* 31 = NR only
*/
if (policy.block_2g && policy.block_3g)
ws46 = 28;
else if (policy.block_2g && !policy.block_3g)
ws46 = 25;
else
ws46 = 22; /* default (all) */
mutex_lock(&at_mutex);
at_cmd("ATE0", at_resp_buf, AT_RESP_SIZE, 1000);
at_cmd("AT+CMEE=1", at_resp_buf, AT_RESP_SIZE, 1000);
at_cmd("AT+CSCS=\"GSM\"", at_resp_buf, AT_RESP_SIZE, 1000);
{
char cmd[32];
scnprintf(cmd, sizeof(cmd), "AT+WS46=%d", ws46);
at_cmd(cmd, at_resp_buf, AT_RESP_SIZE, 3000);
}
/* Shannon-specific: ciphering indication URC */
at_cmd("AT$CIPHERINGIND=1", at_resp_buf, AT_RESP_SIZE, 1000);
at_cmd("AT+CIPHERINGIND=1", at_resp_buf, AT_RESP_SIZE, 1000);
/* Google/Pixel specific: Null cipher kill switch */
if (policy.block_null_cipher)
at_cmd("AT+GOOGSETPROPERTY=\"NASU.NULL.CIPHER.INTEGRITY.CTRL\",\"0\"", at_resp_buf, AT_RESP_SIZE, 1000);
else
at_cmd("AT+GOOGSETPROPERTY=\"NASU.NULL.CIPHER.INTEGRITY.CTRL\",\"1\"", at_resp_buf, AT_RESP_SIZE, 1000);
/* Enable registration URCs so we see RAT changes fast */
at_cmd("AT+CREG=2", at_resp_buf, AT_RESP_SIZE, 1000);
at_cmd("AT+CGREG=2", at_resp_buf, AT_RESP_SIZE, 1000);
at_cmd("AT+CEREG=2", at_resp_buf, AT_RESP_SIZE, 1000);
at_cmd("AT+C5GREG=2", at_resp_buf, AT_RESP_SIZE, 1000);
/* Apply carrier band lock BEFORE the CFUN cycle so the radio
* comes back up with the restricted band set active.
*/
push_band_lock();
/* Kick the radio so the new WS46 takes effect */
at_cmd("AT+CFUN=4", at_resp_buf, AT_RESP_SIZE, 3000);
msleep(300);
at_cmd("AT+CFUN=1", at_resp_buf, AT_RESP_SIZE, 5000);
stat_relocks++;
mutex_unlock(&at_mutex);
}
/*
* Release the lock: reselect all RATs so normal user preference
* resumes. Called on disable/unload.
*/
static void release_lock_policy(void)
{
if (!modem_filp)
return;
mutex_lock(&at_mutex);
at_cmd("AT+WS46=22", at_resp_buf, AT_RESP_SIZE, 3000);
at_cmd("AT+CFUN=4", at_resp_buf, AT_RESP_SIZE, 3000);
msleep(300);
at_cmd("AT+CFUN=1", at_resp_buf, AT_RESP_SIZE, 5000);
mutex_unlock(&at_mutex);
}
/* ---------- state polling ---------- */
static int parse_int_field(const char *s, int field, int *out)
{
int i = 0;
const char *p = s;
while (*p && i < field) {
if (*p++ == ',')
i++;
}
while (*p == ' ' || *p == '"')
p++;
if (!isdigit(*p) && *p != '-')
return -1;
*out = simple_strtol(p, NULL, 10);
return 0;
}
/*
* +CREG AcT codes (TS 27.007):
* 0 GSM (2G)
* 1 GSM Compact (2G)
* 2 UTRAN (3G)
* 3 EDGE (2G)
* 4 UTRAN/HSDPA (3G)
* 5 UTRAN/HSUPA (3G)
* 6 UTRAN/HSPA+ (3G)
* 7 E-UTRAN (4G)
* 8 EC-GSM-IoT (2G IoT)
* 9 E-UTRAN NB-S1 (4G IoT)
* 10 E-UTRAN (4G)
* 11 NR connected to 5GC (5G-SA)
* 13 NR (5G-SA)
*/
static void classify_rat(int act, char *name, int name_sz, int *is_2g, int *is_3g)
{
*is_2g = 0;
*is_3g = 0;
switch (act) {
case 0: case 1: case 3: case 8:
strscpy(name, "GERAN", name_sz); *is_2g = 1; break;
case 2: case 4: case 5: case 6:
strscpy(name, "UTRAN", name_sz); *is_3g = 1; break;
case 7: case 9: case 10:
strscpy(name, "LTE", name_sz); break;
case 11: case 13:
strscpy(name, "NR", name_sz); break;
default:
strscpy(name, "?", name_sz); break;
}
}
/*
* One poll cycle. Returns 1 if a downgrade was detected and relock
* should be pushed, 0 otherwise.
*/
static int poll_once(void)
{
char *p;
int act = -1, stat = -1;
int is_2g = 0, is_3g = 0;
int downgraded = 0;
int n;
if (!modem_filp)
return 0;
mutex_lock(&at_mutex);
stat_polls++;
/* Prefer +C5GREG, fall back to +CEREG, then +CREG */
n = at_cmd("AT+C5GREG?", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "+C5GREG:"))) {
if (parse_int_field(p + 8, 0, &stat) == 0 &&
parse_int_field(p + 8, 3, &act) == 0 &&
(stat == 1 || stat == 5)) {
/* got NR registration */
} else {
act = -1;
}
}
if (act < 0) {
n = at_cmd("AT+CEREG?", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "+CEREG:"))) {
if (parse_int_field(p + 7, 0, &stat) == 0 &&
parse_int_field(p + 7, 3, &act) == 0 &&
(stat == 1 || stat == 5)) {
/* registered */
} else {
act = -1;
}
}
}
if (act < 0) {
n = at_cmd("AT+CREG?", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "+CREG:"))) {
if (parse_int_field(p + 6, 0, &stat) == 0 &&
parse_int_field(p + 6, 3, &act) == 0 &&
(stat == 1 || stat == 5)) {
/* registered on legacy */
} else {
act = -1;
}
}
}
if (act >= 0) {
classify_rat(act, cur_rat, sizeof(cur_rat), &is_2g, &is_3g);
cur_rat_code = act;
if ((policy.block_2g && is_2g) ||
(policy.block_3g && is_3g))
downgraded = 1;
} else {
strscpy(cur_rat, "?", sizeof(cur_rat));
cur_rat_code = 0;
}
/* Operator name + MCC/MNC */
n = at_cmd("AT+COPS?", at_resp_buf, AT_RESP_SIZE, 2000);
if (n > 0 && (p = strstr(at_resp_buf, "+COPS:"))) {
char *q = strchr(p, '"');
if (q) {
char *e;
q++;
e = strchr(q, '"');
if (e) {
int len = min_t(int, (int)(e - q),
(int)sizeof(cur_operator) - 1);
memcpy(cur_operator, q, len);
cur_operator[len] = '\0';
}
}
}
n = at_cmd("AT+COPS=3,2", at_resp_buf, AT_RESP_SIZE, 1500);
(void)n;
n = at_cmd("AT+COPS?", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "+COPS:"))) {
char *q = strchr(p, '"');
if (q) {
char *e;
q++;
e = strchr(q, '"');
if (e) {
int len = min_t(int, (int)(e - q),
(int)sizeof(cur_mcc_mnc) - 1);
memcpy(cur_mcc_mnc, q, len);
cur_mcc_mnc[len] = '\0';
}
}
}
/* Restore long alpha format for next poll */
at_cmd("AT+COPS=3,0", at_resp_buf, AT_RESP_SIZE, 1000);
/* Signal (LTE/NR): +CESQ rsrp is field 5 (0..97), dBm = -140 + v */
n = at_cmd("AT+CESQ", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "+CESQ:"))) {
int rsrp_raw;
if (parse_int_field(p + 6, 5, &rsrp_raw) == 0 && rsrp_raw < 97)
cur_rsrp = -140 + rsrp_raw;
}
/* Google specific: TX power */
cur_pwr_known = 0;
cur_tx_pwr = -140;
n = at_cmd("AT+GOOGGETTXPWR", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "+GOOGGETTXPWR:"))) {
/* Format: +GOOGGETTXPWR: " -12.5" or similar */
char *q = strchr(p, '"');
if (q) {
cur_pwr_known = 1;
cur_tx_pwr = (int)simple_strtol(q + 1, NULL, 10);
}
}
/* Cipher check — Shannon $CIPHERINGIND or 3GPP (rarely supported).
* We mark "unknown" if the modem doesn't expose it; downgrade to
* 2G is the practical null-cipher indicator we care about.
*/
cur_cipher_known = 0;
cur_cipher_null = 0;
n = at_cmd("AT$CIPHERINGIND?", at_resp_buf, AT_RESP_SIZE, 1500);
if (n > 0 && (p = strstr(at_resp_buf, "$CIPHERINGIND:"))) {
int ind;
if (parse_int_field(p + 14, 0, &ind) == 0) {
cur_cipher_known = 1;
/* ind==0 => no ciphering / weak cipher */
if (ind == 0) {
cur_cipher_null = 1;
if (policy.block_null_cipher)
downgraded = 1;
}
}
}
if (downgraded)
stat_downgrades++;
mutex_unlock(&at_mutex);
return downgraded;
}
static int watchdog_fn(void *data)
{
while (!kthread_should_stop()) {
if (guard_enabled) {
if (poll_once())
push_lock_policy();
}
msleep(policy.poll_interval_ms > 0 ?
policy.poll_interval_ms : 1500);
}
return 0;
}
/* ---------- scan ---------- */
static int run_scan(void)
{
char *buf;
int n;
buf = kmalloc(SCAN_BUF_SIZE, GFP_KERNEL);
if (!buf)
return -ENOMEM;
mutex_lock(&at_mutex);
/* AT+COPS=? is slow (up to 180s on cold SIM) */
n = at_cmd("AT+COPS=?", buf, SCAN_BUF_SIZE, 180000);
mutex_unlock(&at_mutex);
if (n > 0) {
mutex_lock(&scan_mutex);
memcpy(scan_buf, buf, n);
scan_len = n;
mutex_unlock(&scan_mutex);
}
kfree(buf);
return n;
}
/* ---------- chardev / ioctl ---------- */
static ssize_t cg_write(struct file *f, const char __user *u, size_t n, loff_t *ppos)
{
char cmd[32];
size_t len = min(n, sizeof(cmd) - 1);
if (copy_from_user(cmd, u, len))
return -EFAULT;
cmd[len] = '\0';
while (len > 0 && (cmd[len-1] == '\n' || cmd[len-1] == '\r' ||
cmd[len-1] == ' '))
cmd[--len] = '\0';
if (!strcmp(cmd, "on")) {
guard_enabled = true;
push_lock_policy();
} else if (!strcmp(cmd, "off") || !strcmp(cmd, "release")) {
guard_enabled = false;
release_lock_policy();
} else if (!strcmp(cmd, "scan")) {
run_scan();
} else if (!strcmp(cmd, "relock")) {
push_lock_policy();
} else if (!strncmp(cmd, "carrier ", 8)) {
const char *name = cmd + 8;
int i, found = -1;
for (i = 0; i < N_CARRIERS; i++) {
if (!strcmp(name, carrier_profiles[i].name)) {
found = i;
break;
}
}
if (found < 0)
return -EINVAL;
current_carrier = found;
if (guard_enabled) {
/* Re-push full policy so bands apply immediately
* (CFUN cycle is needed for band changes). */
push_lock_policy();
}
} else {
return -EINVAL;
}
return (ssize_t)n;
}
static long cg_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
switch (cmd) {
case CG_IOC_ENABLE:
guard_enabled = true;
push_lock_policy();
return 0;
case CG_IOC_DISABLE:
guard_enabled = false;
release_lock_policy();
return 0;
case CG_IOC_RELEASE:
release_lock_policy();
return 0;
case CG_IOC_GET_STATUS: {
struct cg_status s;
memset(&s, 0, sizeof(s));
s.enabled = guard_enabled ? 1 : 0;
s.modem_ok = (modem_filp && !IS_ERR(modem_filp)) ? 1 : 0;
strscpy(s.modem_path, modem_path_used, sizeof(s.modem_path));
strscpy(s.rat, cur_rat, sizeof(s.rat));
s.rat_code = cur_rat_code;
s.cipher_known = cur_cipher_known;
s.cipher_null = cur_cipher_null;
strscpy(s.operator_name, cur_operator, sizeof(s.operator_name));
strscpy(s.mcc_mnc, cur_mcc_mnc, sizeof(s.mcc_mnc));
s.rsrp = cur_rsrp;
s.tx_pwr = cur_pwr_known ? cur_tx_pwr : -140;
s.downgrades_blocked = stat_downgrades;
s.relocks = stat_relocks;
s.polls = stat_polls;
if (copy_to_user((void __user *)arg, &s, sizeof(s)))
return -EFAULT;
return 0;
}
case CG_IOC_SCAN: {
struct cg_scan *sc;
int n;
sc = kmalloc(sizeof(*sc), GFP_KERNEL);
if (!sc)
return -ENOMEM;
memset(sc, 0, sizeof(*sc));
n = run_scan();
if (n < 0) {
sc->status = n;
} else {
mutex_lock(&scan_mutex);
sc->data_len = min_t(uint32_t, scan_len,
SCAN_BUF_SIZE - 1);
memcpy(sc->data, scan_buf, sc->data_len);
sc->data[sc->data_len] = '\0';
mutex_unlock(&scan_mutex);
sc->status = 0;
}
if (copy_to_user((void __user *)arg, sc, sizeof(*sc))) {
kfree(sc);
return -EFAULT;
}
kfree(sc);
return 0;
}
case CG_IOC_SET_POLICY: {
struct cg_policy p;
if (copy_from_user(&p, (void __user *)arg, sizeof(p)))
return -EFAULT;
if (p.poll_interval_ms < 200 || p.poll_interval_ms > 60000)
return -EINVAL;
policy = p;
if (guard_enabled)
push_lock_policy();
return 0;
}
default:
return -ENOTTY;
}
}
static const struct file_operations cg_fops = {
.owner = THIS_MODULE,
.write = cg_write,
.unlocked_ioctl = cg_ioctl,
.compat_ioctl = compat_ptr_ioctl,
};
/* ---------- /proc/cellguard/{status,cells,policy} ---------- */
static int status_show(struct seq_file *m, void *v)
{
seq_printf(m, "enabled: %d\n", guard_enabled ? 1 : 0);
seq_printf(m, "modem: %s (%s)\n",
(modem_filp && !IS_ERR(modem_filp)) ? "up" : "down",
modem_path_used);
seq_printf(m, "rat: %s (act=%d)\n", cur_rat, cur_rat_code);
seq_printf(m, "operator: %s [%s]\n", cur_operator, cur_mcc_mnc);
seq_printf(m, "rsrp_dbm: %d\n", cur_rsrp);
seq_printf(m, "tx_pwr_dbm: %d\n", cur_pwr_known ? cur_tx_pwr : -140);
seq_printf(m, "cipher_known: %d\n", cur_cipher_known);
seq_printf(m, "cipher_null: %d\n", cur_cipher_null);
seq_printf(m, "block_2g: %d\n", policy.block_2g);
seq_printf(m, "block_3g: %d\n", policy.block_3g);
seq_printf(m, "block_null: %d\n", policy.block_null_cipher);
seq_printf(m, "poll_ms: %d\n", policy.poll_interval_ms);
seq_printf(m, "carrier: %s\n",
(current_carrier >= 0 && current_carrier < N_CARRIERS) ?
carrier_profiles[current_carrier].name : "none");
seq_printf(m, "bands_locked: %d\n", bands_locked);
seq_printf(m, "bands_method: %s\n",
bands_push_method[0] ? bands_push_method : "none");
seq_printf(m, "downgrades: %llu\n", stat_downgrades);
seq_printf(m, "relocks: %llu\n", stat_relocks);
seq_printf(m, "polls: %llu\n", stat_polls);
return 0;
}
static int status_open(struct inode *i, struct file *f)
{ return single_open(f, status_show, NULL); }
static const struct proc_ops status_pops = {
.proc_open = status_open,
.proc_read = seq_read,
.proc_lseek = seq_lseek,
.proc_release = single_release,
};
static int cells_show(struct seq_file *m, void *v)
{
mutex_lock(&scan_mutex);
if (scan_len > 0)
seq_write(m, scan_buf, scan_len);
else
seq_puts(m, "(no scan yet — echo scan > /dev/cellguard)\n");
mutex_unlock(&scan_mutex);
return 0;
}
static int cells_open(struct inode *i, struct file *f)
{ return single_open(f, cells_show, NULL); }
static const struct proc_ops cells_pops = {
.proc_open = cells_open,
.proc_read = seq_read,
.proc_lseek = seq_lseek,
.proc_release = single_release,
};
static struct proc_dir_entry *proc_root;
/* ---------- module init / exit ---------- */
static int __init cellguard_init(void)
{
int ret;
dev_t dev;
pr_info("cellguard: init\n");
/* Resolve GKI-protected symbols before any modem I/O. */
ret = cg_resolve_protected();
if (ret)
return ret;
at_resp_buf = kmalloc(AT_RESP_SIZE, GFP_KERNEL);
if (!at_resp_buf)
return -ENOMEM;
modem_filp = open_modem();
if (IS_ERR(modem_filp)) {
pr_warn("cellguard: no Shannon AT channel found — loaded "
"but inert until modem appears\n");
modem_filp = NULL;
}
ret = alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
if (ret < 0)
goto err_chrdev;
major = MAJOR(dev);
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0)
cg_class = class_create(CLASS_NAME);
#else
cg_class = class_create(THIS_MODULE, CLASS_NAME);
#endif
if (IS_ERR(cg_class)) { ret = PTR_ERR(cg_class); goto err_class; }
cdev_init(&cg_cdev, &cg_fops);
cg_cdev.owner = THIS_MODULE;
ret = cdev_add(&cg_cdev, MKDEV(major, 0), 1);
if (ret < 0) goto err_cdev;
cg_dev = device_create(cg_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
if (IS_ERR(cg_dev)) { ret = PTR_ERR(cg_dev); goto err_dev; }
proc_root = proc_mkdir("cellguard", NULL);
if (proc_root) {
proc_create("status", 0444, proc_root, &status_pops);
proc_create("cells", 0444, proc_root, &cells_pops);
}
watchdog_thread = kthread_run(watchdog_fn, NULL, "cellguard_wd");
if (IS_ERR(watchdog_thread)) {
ret = PTR_ERR(watchdog_thread);
watchdog_thread = NULL;
goto err_kth;
}
pr_info("cellguard: /dev/%s ready (major=%d)\n", DEVICE_NAME, major);
return 0;
err_kth:
if (proc_root) {
remove_proc_entry("status", proc_root);
remove_proc_entry("cells", proc_root);
proc_remove(proc_root);
}
device_destroy(cg_class, MKDEV(major, 0));
err_dev:
cdev_del(&cg_cdev);
err_cdev:
class_destroy(cg_class);
err_class:
unregister_chrdev_region(MKDEV(major, 0), 1);
err_chrdev:
if (modem_filp) filp_close(modem_filp, NULL);
kfree(at_resp_buf);
return ret;
}
static void __exit cellguard_exit(void)
{
if (watchdog_thread)
kthread_stop(watchdog_thread);
if (guard_enabled)
release_lock_policy();
if (proc_root) {
remove_proc_entry("status", proc_root);
remove_proc_entry("cells", proc_root);
proc_remove(proc_root);
}
device_destroy(cg_class, MKDEV(major, 0));
cdev_del(&cg_cdev);
class_destroy(cg_class);
unregister_chrdev_region(MKDEV(major, 0), 1);
if (modem_filp)
filp_close(modem_filp, NULL);
kfree(at_resp_buf);
pr_info("cellguard: unloaded (downgrades=%llu, relocks=%llu)\n",
stat_downgrades, stat_relocks);
}
module_init(cellguard_init);
module_exit(cellguard_exit);