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