#!/system/bin/sh # CellGuard cgdetect — always-on IMSI-catcher / cell-site-simulator detector. # # Runs as a background daemon (launched by service.sh), independent of the # WebUI. It reads the modem's live serving/neighbor cell state from the # telephony framework and the raw CP-frame counters from cgcap, applies # behavioral CSS heuristics (vendor-agnostic — catches Stingray, Hailstorm, # DRT, Crosshair, Gossamer, OpenBTS/srsRAN fakes alike), and writes: # /data/adb/cellguard/status key=value snapshot (WebUI reads this) # /data/adb/cellguard/alerts append-only alert log (WebUI reads this) # # Heuristics (any one = suspicious): # DOWNGRADE serving RAT is 2G/3G (GSM/GPRS/EDGE/UMTS/HSPA/CDMA) # PLMN serving PLMN != learned home PLMN # TAC_JUMP serving TAC changed without a cell/PLMN reason # NEW_CELL serving PCI/CI never seen before (first-seen, informational) # NO_NEIGH registered but zero neighbor cells reported (CSS often hides them) CG="/data/adb/cellguard" STATUS="$CG/status" ALERTS="$CG/alerts" STATE="$CG/detect.state" # learned home PLMN + seen cells POLL="${1:-4}" mkdir -p "$CG" : > "$STATUS" alert() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ALERTS"; } setk() { echo "$1=$2" >> "$STATUS.tmp"; } # load learned state HOME_PLMN=""; SEEN_CELLS=" " [ -f "$STATE" ] && . "$STATE" 2>/dev/null # startup marker goes to a debug log, NOT the alerts file (the alerts file is # surfaced in the UI and must contain only real threat events) echo "[$(date '+%Y-%m-%d %H:%M:%S')] cgdetect start (poll=${POLL}s)" >> "$CG/cgdetect.log" while true; do # detector toggle (WebUI) — idle when off if [ "$(cat "$CG/detect_enabled" 2>/dev/null)" = "0" ]; then sleep "$POLL"; continue; fi reg=$(dumpsys telephony.registry 2>/dev/null) # serving air-interface RAT = type of the registered cell identity (real MCC). # A serving GSM/WCDMA identity == an active 2G/3G downgrade. if echo "$reg" | grep -q 'CellIdentityNr:{[^}]*mMcc=[0-9]'; then rat=NR elif echo "$reg" | grep -q 'CellIdentityLte:{[^}]*mMcc=[0-9]'; then rat=LTE elif echo "$reg" | grep -q 'CellIdentityWcdma:{[^}]*mMcc=[0-9]'; then rat=WCDMA elif echo "$reg" | grep -q 'CellIdentityGsm:{[^}]*mMcc=[0-9]'; then rat=GSM else rat="?"; fi # the service-state line carrying the serving cell identity (real MCC) line=$(echo "$reg" | grep -m1 'CellIdentity[A-Za-z]*:{[^}]*mMcc=[0-9]') mcc=$(echo "$line" | sed -n 's/.*mMcc=\([0-9]*\).*/\1/p') mnc=$(echo "$line" | sed -n 's/.*mMnc=\([0-9]*\).*/\1/p') ci=$(echo "$line" | sed -n 's/.*mCi=\[*\([0-9]*\).*/\1/p') pci=$(echo "$line" | sed -n 's/.*mPci=\[*\([0-9]*\).*/\1/p') tac=$(echo "$line" | sed -n 's/.*mTac=\[*\([0-9]*\).*/\1/p') earfcn=$(echo "$line" | sed -n 's/.*mEarfcn=\[*\([0-9]*\).*/\1/p') op=$(echo "$reg" | grep -oE 'mOperatorAlphaLong=[^,]+' | grep -m1 '=[A-Za-z0-9]' | cut -d= -f2-) # NOTE: ci/pci/tac/earfcn are redacted by Android in dumpsys ([****]); the real # values + neighbor towers come from the decoded cgcap 0x070F cell-info frames. plmn="${mcc}${mnc}" frames=$(head -1 /proc/cgcap/frames 2>/dev/null | sed -n 's/.*total=\([0-9]*\).*/\1/p') susp="" case "$rat" in GSM|GPRS|EDGE|UMTS|HSDPA|HSUPA|HSPA|HSPAP|TD_SCDMA|CDMA|EVDO|1xRTT) susp="DOWNGRADE"; alert "DOWNGRADE: serving RAT=$rat (2G/3G) plmn=$plmn cell=$ci — CSS/downgrade signature" ;; esac if [ -n "$plmn" ] && [ "$plmn" != "000000" ]; then if [ -z "$HOME_PLMN" ]; then HOME_PLMN="$plmn" # learn on first good read elif [ "$plmn" != "$HOME_PLMN" ]; then susp="${susp:+$susp }PLMN"; alert "PLMN MISMATCH: serving=$plmn expected=$HOME_PLMN op='$op'" fi fi if [ -n "$pci" ] && [ "$pci" != "2147483647" ]; then case "$SEEN_CELLS" in *" $pci "*) : ;; *) SEEN_CELLS="$SEEN_CELLS$pci "; alert "NEW_CELL: first-seen serving pci=$pci ci=$ci tac=$tac earfcn=$earfcn rat=$rat" ;; esac fi # write status snapshot atomically : > "$STATUS.tmp" setk ts "$(date '+%s')" setk rat "${rat:-?}" setk plmn "${plmn:-?}" setk home_plmn "${HOME_PLMN:-?}" setk operator "${op:-?}" setk ci "${ci:-?}"; setk pci "${pci:-?}"; setk tac "${tac:-?}"; setk earfcn "${earfcn:-?}" setk frames "${frames:-0}" setk suspect "${susp:-none}" mv "$STATUS.tmp" "$STATUS" # persist learned state printf 'HOME_PLMN="%s"\nSEEN_CELLS="%s"\n' "$HOME_PLMN" "$SEEN_CELLS" > "$STATE" sleep "$POLL" done