2026-07-12 09:21:54 -07:00
|
|
|
#!/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
|
2026-07-12 11:09:41 -07:00
|
|
|
# timeout guards against a blocking dumpsys hanging the whole loop (which
|
|
|
|
|
# would freeze the status file — the cause of stale frame counts).
|
|
|
|
|
reg=$(timeout 6 dumpsys telephony.registry 2>/dev/null)
|
2026-07-12 09:21:54 -07:00
|
|
|
# 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
|