Files

130 lines
5.7 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
cg_decode.py decode Shannon CP->AP frames captured by cgcap.ko, and flag
IMSI-catcher / cell-site-simulator (CSS) signatures.
Input: text from /proc/cgcap/frames (RX lines). Each RX line ends with the raw
hex of skb->data captured at io_dev_recv_skb_single_from_link_dev.
Frame layering (RE'd + validated against 234 live frames):
0x00 u16 magic = 0xABCD 0x08 u8 channel (0xf6 = umts_ipc1 SIT)
0x02 u16 counter 0x09 u8 counter2
0x04 u16 0xC000 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 (indication)
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
All struct offsets proven from vendor.radio.protocol.sit.stream.so fill fns.
"""
import sys, re
def u16(b, o): return b[o] | (b[o+1] << 8) if o+1 < len(b) else 0
def u32(b, o): return int.from_bytes(b[o:o+4], 'little') if o+4 <= len(b) else 0
def u64(b, o): return int.from_bytes(b[o:o+8], 'little') if o+8 <= len(b) else 0
def s32(b, o):
v = u32(b, o); return v - (1 << 32) if v & 0x80000000 else v
def astr(b, o, n):
return b[o:o+n].split(b'\x00')[0].decode('ascii', 'replace').replace('#', '')
RAT = {0: 'GSM', 1: 'CDMA', 2: 'LTE', 3: 'WCDMA', 4: 'TDSCDMA', 5: 'NR'}
STRIDE = {0: 0xA4, 1: 0x2B, 2: 0x13C, 3: 0x114, 4: 0x110, 5: 0x18C}
WEAK_RAT = {'GSM', 'WCDMA', 'TDSCDMA', 'CDMA'} # 2G/3G = downgrade risk
SIT_NAMES = {
0x070C: "NET/GetCellInfoList", 0x070E: "NET/RegState", 0x070F: "NET/CellInfoList",
0x0742: "MISC/0x42", 0x0906: "PS/0x06", 0x0702: "MISC/0x02",
}
def parse_lte(s):
return dict(rat='LTE', mcc=astr(s,0,3), mnc=astr(s,3,3), ci=u32(s,6), pci=u32(s,0xa),
tac=u32(s,0xe), earfcn=u32(s,0x12), op=astr(s,0x1a,32),
rsrp=-s32(s,0x121) if len(s)>0x124 else None,
rsrq=s32(s,0x125) if len(s)>0x128 else None)
def parse_nr(s):
return dict(rat='NR', mcc=astr(s,0,3), mnc=astr(s,3,3), nci=u64(s,6), pci=u32(s,0xe),
tac=u32(s,0x12), arfcn=u32(s,0x16), op=astr(s,0x1a,32),
rsrp=-s32(s,0x119) if len(s)>0x11c else None,
sinr=s32(s,0x121) if len(s)>0x124 else None)
def parse_gsm(s):
return dict(rat='GSM', mcc=astr(s,0,3), mnc=astr(s,3,3), lac=u32(s,6), cid=u32(s,0xa),
arfcn=u32(s,0xe), bsic=s[0x12] if len(s)>0x12 else None, op=astr(s,0x13,32),
rssi=s32(s,0x95) if len(s)>0x98 else None)
PARSER = {0: parse_gsm, 2: parse_lte, 5: parse_nr}
def decode_cellinfo(params):
"""params = SIT params (frame[0x14:]); list = len@0, count@4, records@8."""
count = u32(params, 4)
cells, off = [], 8
while off + 3 < len(params) and len(cells) < count and count < 64:
rat = params[off]; reg = params[off+1]
stride = STRIDE.get(rat, 0)
if not stride:
break
struct = params[off+3: off+stride]
c = PARSER.get(rat, lambda s: {'rat': RAT.get(rat, rat)})(struct)
c['registered'] = bool(reg)
cells.append(c)
off += stride
return cells
def load_rx(text):
out = []
for ln in text.splitlines():
m = re.match(r'^(\d+)\s+RX\s+ch=0x([0-9a-f]+).*?\s([0-9a-f]{8,})', ln)
if m:
h = m.group(3); out.append((int(m.group(1)), bytes.fromhex(h[:len(h)-len(h)%2])))
return out
def heuristics(cells, serving_plmn):
"""Return CSS/IMSI-catcher flags for a cell-info snapshot."""
alerts = []
serving = [c for c in cells if c.get('registered')]
for c in serving:
if c.get('rat') in WEAK_RAT:
alerts.append(f"DOWNGRADE: serving on {c['rat']} (2G/3G) — CSS downgrade signature")
pl = (c.get('mcc', '') + c.get('mnc', ''))
if serving_plmn and pl and pl != serving_plmn:
alerts.append(f"PLMN MISMATCH: serving cell PLMN {pl} != expected {serving_plmn}")
if serving and not [c for c in cells if not c.get('registered')]:
alerts.append("NO NEIGHBORS: empty neighbor list — CSS often suppresses neighbors")
return alerts
def main():
text = open(sys.argv[1]).read() if len(sys.argv) > 1 else sys.stdin.read()
frames = load_rx(text)
ids, plmns, cellframes = {}, set(), 0
print(f"RX frames: {len(frames)}")
for seq, b in frames:
if len(b) < 16 or u16(b, 0) != 0xABCD:
continue
sid = u16(b, 0x0e)
ids[sid] = ids.get(sid, 0) + 1
params = b[0x14:]
for p in re.findall(rb'(\d{6})', params):
plmns.add(p.decode())
if sid == 0x070F:
cellframes += 1
cells = decode_cellinfo(params)
sp = next(iter(plmns), None)
print(f"\n[seq {seq}] CellInfoList: {len(cells)} cells")
for c in cells:
tag = "SERVING" if c.get('registered') else "neighbor"
print(f" {tag} {c.get('rat'):6} PLMN={c.get('mcc','')}{c.get('mnc','')} "
f"pci={c.get('pci')} tac={c.get('tac')} earfcn={c.get('earfcn') or c.get('arfcn')} "
f"rsrp={c.get('rsrp')} op={c.get('op','')!r}")
for a in heuristics(cells, sp):
print(f" !! {a}")
print(f"\nPLMNs: {sorted(plmns)} 0x070F cell frames: {cellframes}")
print("SIT id counts:", {f"0x{k:04x}": v for k, v in sorted(ids.items(), key=lambda x: -x[1])[:12]})
if cellframes == 0:
print("\n(no 0x070F cell-list frames captured yet — trigger getCellInfo to exercise it)")
if __name__ == '__main__':
main()