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:
231
app/src/main/java/com/seteclabs/cellguard/decode/FrameDecoder.kt
Normal file
231
app/src/main/java/com/seteclabs/cellguard/decode/FrameDecoder.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user