CellGuard app: on-device IMSI-catcher detection (Kotlin/Compose)

- foreground DetectorService: cgcap capture + on-device decode
- framework CellInfo source: real serving+neighbor towers, signal, carrier name
- FrameDecoder: 0x070F cell-info SIT decode
- block-2G/3G + detection toggles via cgctl; libsu root bridge
- silent status notification + edge-triggered alerts
This commit is contained in:
sssnake
2026-07-12 09:21:54 -07:00
commit 38719683c7
25 changed files with 2269 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
package com.seteclabs.cellguard.decode
import com.seteclabs.cellguard.model.Tower
/**
* Kotlin port of tools/cg_decode.py — decodes Shannon CP->AP frames captured by
* cgcap.ko and flags IMSI-catcher / cell-site-simulator (CSS) signatures.
*
* Frame layering (RE'd + validated against live frames):
* 0x00 u16 magic = 0xABCD 0x08 u8 channel (0xf6 = umts_ipc1 SIT)
* 0x02 u16 counter 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
*
* 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
*
* Pure Kotlin, no Android dependencies.
*/
object FrameDecoder {
const val MAGIC = 0xABCD
const val SIT_CELL_INFO_LIST = 0x070F
private val RAT = mapOf(
0 to "GSM", 1 to "CDMA", 2 to "LTE", 3 to "WCDMA", 4 to "TDSCDMA", 5 to "NR",
)
// stride includes the 3-byte record header (rat|registered|conn_status)
private val STRIDE = mapOf(
0 to 0xA4, 1 to 0x2B, 2 to 0x13C, 3 to 0x114, 4 to 0x110, 5 to 0x18C,
)
private val WEAK_RAT = setOf("GSM", "WCDMA", "TDSCDMA", "CDMA") // 2G/3G = downgrade risk
private val RX_LINE = Regex("""^(\d+)\s+RX\s+ch=0x([0-9a-f]+).*?\s([0-9a-f]{8,})""")
private val PLMN_RUN = Regex("""(\d{6})""")
// ---- little-endian readers (bounds-checked, matching the Python helpers) ----
private fun u16(b: ByteArray, o: Int): Int =
if (o + 1 < b.size) (b[o].toInt() and 0xFF) or ((b[o + 1].toInt() and 0xFF) shl 8) else 0
private fun u32(b: ByteArray, o: Int): Long {
if (o + 4 > b.size) return 0L
var v = 0L
for (i in 0 until 4) v = v or ((b[o + i].toLong() and 0xFF) shl (8 * i))
return v
}
private fun u64(b: ByteArray, o: Int): Long {
if (o + 8 > b.size) return 0L
var v = 0L
for (i in 0 until 8) v = v or ((b[o + i].toLong() and 0xFF) shl (8 * i))
return v
}
private fun s32(b: ByteArray, o: Int): Int {
val v = u32(b, o)
return (if (v and 0x80000000L != 0L) v - (1L shl 32) else v).toInt()
}
/** ASCII string up to first NUL, non-ASCII -> U+FFFD, '#' stripped (matches astr). */
private fun astr(b: ByteArray, o: Int, n: Int): String {
val sb = StringBuilder()
val end = minOf(o + n, b.size)
var i = o
while (i < end) {
val c = b[i].toInt() and 0xFF
i++
if (c == 0) break
if (c == '#'.code) continue
sb.append(if (c < 128) c.toChar() else '<27>')
}
return sb.toString()
}
// ---- per-RAT struct parsers (offsets relative to struct = record[+3 .. +stride]) ----
private fun parseLte(s: ByteArray, registered: Boolean): Tower = Tower(
rat = "LTE",
registered = registered,
mcc = astr(s, 0, 3),
mnc = astr(s, 3, 3),
ci = u32(s, 6),
pci = u32(s, 0xa).toInt(),
tac = u32(s, 0xe).toInt(),
earfcn = u32(s, 0x12).toInt(),
rsrp = if (s.size > 0x124) -s32(s, 0x121) else null,
rsrq = if (s.size > 0x128) s32(s, 0x125) else null,
op = astr(s, 0x1a, 32),
)
private fun parseNr(s: ByteArray, registered: Boolean): Tower = Tower(
rat = "NR",
registered = registered,
mcc = astr(s, 0, 3),
mnc = astr(s, 3, 3),
ci = u64(s, 6), // nci
pci = u32(s, 0xe).toInt(),
tac = u32(s, 0x12).toInt(),
earfcn = u32(s, 0x16).toInt(), // arfcn
rsrp = if (s.size > 0x11c) -s32(s, 0x119) else null,
rsrq = null, // NR carries SINR (@0x121), not RSRQ
op = astr(s, 0x1a, 32),
)
private fun parseGsm(s: ByteArray, registered: Boolean): Tower = Tower(
rat = "GSM",
registered = registered,
mcc = astr(s, 0, 3),
mnc = astr(s, 3, 3),
ci = u32(s, 0xa), // cid
pci = 0, // GSM has no PCI
tac = u32(s, 6).toInt(), // lac
earfcn = u32(s, 0xe).toInt(), // arfcn
rsrp = if (s.size > 0x98) s32(s, 0x95) else null, // rssi
rsrq = null,
op = astr(s, 0x13, 32),
)
private fun parseStruct(rat: Int, registered: Boolean, s: ByteArray): Tower = when (rat) {
0 -> parseGsm(s, registered)
2 -> parseLte(s, registered)
5 -> parseNr(s, registered)
else -> Tower(
rat = RAT[rat] ?: rat.toString(),
registered = registered,
mcc = "", mnc = "", ci = 0L, pci = 0, tac = 0, earfcn = 0,
rsrp = null, rsrq = null, op = "",
)
}
/** Decode a SIT 0x070F cell-info list from its params (frame[0x14:]). */
fun decodeCellInfo(params: ByteArray): List<Tower> {
val count = u32(params, 4)
val cells = ArrayList<Tower>()
var off = 8
while (off + 3 < params.size && cells.size < count && count < 64) {
val rat = params[off].toInt() and 0xFF
val registered = (params[off + 1].toInt() and 0xFF) != 0
val stride = STRIDE[rat] ?: 0
if (stride == 0) break
val struct = params.copyOfRange(off + 3, minOf(off + stride, params.size))
cells.add(parseStruct(rat, registered, struct))
off += stride
}
return cells
}
/**
* Decode a single frame's raw bytes. Returns the cell list for a 0x070F
* CellInfoList frame, or an empty list for any other (or invalid) frame.
*/
fun decodeFrame(frame: ByteArray): List<Tower> {
if (frame.size < 16 || u16(frame, 0) != MAGIC) return emptyList()
val sid = u16(frame, 0x0e)
if (sid != SIT_CELL_INFO_LIST) return emptyList()
val params = frame.copyOfRange(0x14, frame.size)
return decodeCellInfo(params)
}
/** Parse /proc/cgcap/frames text: every 0x070F CellInfoList frame -> towers. */
fun decodeFrames(text: String): List<Tower> {
val out = ArrayList<Tower>()
for (frame in loadRx(text)) out.addAll(decodeFrame(frame))
return out
}
/** Parse RX lines to raw payload byte arrays (matches load_rx). */
private fun loadRx(text: String): List<ByteArray> {
val out = ArrayList<ByteArray>()
for (line in text.lineSequence()) {
val m = RX_LINE.find(line) ?: continue
out.add(hexToBytes(m.groupValues[3]))
}
return out
}
private fun hexToBytes(hexIn: String): ByteArray {
val hex = if (hexIn.length % 2 == 0) hexIn else hexIn.substring(0, hexIn.length - 1)
val out = ByteArray(hex.length / 2)
for (i in out.indices) {
out[i] = ((hex[i * 2].digitToInt(16) shl 4) or hex[i * 2 + 1].digitToInt(16)).toByte()
}
return out
}
/**
* First 6-digit PLMN run seen across all RX frame params (deterministic
* analogue of the Python plmns set), usable as the expected serving PLMN.
*/
fun firstPlmn(text: String): String? {
for (frame in loadRx(text)) {
if (frame.size < 0x14) continue
val params = frame.copyOfRange(0x14, frame.size)
val ascii = String(CharArray(params.size) { (params[it].toInt() and 0xFF).toChar() })
PLMN_RUN.find(ascii)?.let { return it.groupValues[1] }
}
return null
}
/**
* CSS / IMSI-catcher heuristics for a cell-info snapshot. Returns human-readable
* suspect strings (matches the Python heuristics()).
*
* @param cells decoded towers for one snapshot
* @param servingPlmn expected serving PLMN (mcc+mnc), e.g. from [firstPlmn]
*/
fun heuristics(cells: List<Tower>, servingPlmn: String?): List<String> {
val alerts = ArrayList<String>()
val serving = cells.filter { it.registered }
for (c in serving) {
if (c.rat in WEAK_RAT) {
alerts.add("DOWNGRADE: serving on ${c.rat} (2G/3G) — CSS downgrade signature")
}
val pl = c.mcc + c.mnc
if (!servingPlmn.isNullOrEmpty() && pl.isNotEmpty() && pl != servingPlmn) {
alerts.add("PLMN MISMATCH: serving cell PLMN $pl != expected $servingPlmn")
}
}
// NOTE: an empty neighbor list is NOT a reliable CSS signal on its own —
// an idle modem (esp. on WiFi-calling) legitimately reports zero
// neighbors, which produced false alerts. Only real signals remain:
// active 2G/3G downgrade and serving-PLMN mismatch.
return alerts
}
}