Add Android forensics, IOC threat-intel DB, Compose companion; scrub secrets from configs
This commit is contained in:
871
core/android_forensics.py
Normal file
871
core/android_forensics.py
Normal file
@@ -0,0 +1,871 @@
|
||||
"""
|
||||
Autarch Android Forensics — forensic acquisition and IOC scanning for Android devices.
|
||||
by: SsSnake
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
_manager = None
|
||||
|
||||
|
||||
def get_android_forensics_manager():
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = AutarchAndroidManager()
|
||||
return _manager
|
||||
|
||||
|
||||
class AutarchAndroidManager:
|
||||
"""Forensic acquisition and IOC scanning for Android devices."""
|
||||
|
||||
IOC_DATA_DIR = Path(__file__).parent.parent / 'data' / 'ioc'
|
||||
ACQ_DATA_DIR = Path(__file__).parent.parent / 'data' / 'android_forensics'
|
||||
|
||||
def __init__(self):
|
||||
self._ioc_db = None # lazy-loaded
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _hw(self):
|
||||
from core.hardware import get_hardware_manager
|
||||
return get_hardware_manager()
|
||||
|
||||
def _adb(self, serial, cmd, timeout=60):
|
||||
"""Run an ADB shell command, return stdout string."""
|
||||
result = self._hw().adb_shell_raw(serial, cmd, timeout=timeout)
|
||||
if isinstance(result, dict):
|
||||
return result.get('output', '') or ''
|
||||
return str(result or '')
|
||||
|
||||
def _acq_dir(self, serial, acq_id):
|
||||
d = self.ACQ_DATA_DIR / serial / acq_id
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def _new_acq_id(self):
|
||||
return datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
# ── IOC Database ────────────────────────────────────────────────
|
||||
|
||||
def load_ioc_database(self, force=False):
|
||||
"""Lazy-load and merge all IOC data from data/ioc/. Returns merged dict."""
|
||||
if self._ioc_db is not None and not force:
|
||||
return self._ioc_db
|
||||
|
||||
db = {
|
||||
'packages': {}, # pkg_name -> {name, source, severity}
|
||||
'certs': {}, # sha1/sha256 -> {name, source}
|
||||
'domains': {}, # domain -> {name, source}
|
||||
'ips': set(),
|
||||
'file_paths': {}, # path -> {name, source}
|
||||
'processes': {}, # proc_name -> {name, source}
|
||||
'hashes': {}, # sha256 -> {name, source}
|
||||
}
|
||||
|
||||
# 1. Stalkerware ioc.yaml + watchware.yaml
|
||||
for fname in ('ioc.yaml', 'watchware.yaml'):
|
||||
fpath = self.IOC_DATA_DIR / 'stalkerware' / fname
|
||||
if fpath.exists():
|
||||
self._parse_stalkerware_yaml(fpath, db)
|
||||
|
||||
# 2. Amnesty investigations
|
||||
amnesty_dir = self.IOC_DATA_DIR / 'spyware' / 'amnesty'
|
||||
if amnesty_dir.exists():
|
||||
for campaign_dir in sorted(amnesty_dir.iterdir()):
|
||||
if campaign_dir.is_dir():
|
||||
self._parse_investigation(campaign_dir, db)
|
||||
|
||||
# 3. MVT indicators
|
||||
mvt_dir = self.IOC_DATA_DIR / 'spyware' / 'mvt'
|
||||
if mvt_dir.exists():
|
||||
for campaign_dir in sorted(mvt_dir.iterdir()):
|
||||
if campaign_dir.is_dir():
|
||||
self._parse_investigation(campaign_dir, db)
|
||||
|
||||
# 4. Citizen Lab campaigns
|
||||
cl_dir = self.IOC_DATA_DIR / 'spyware' / 'citizen_lab'
|
||||
if cl_dir.exists():
|
||||
for campaign_dir in sorted(cl_dir.iterdir()):
|
||||
if campaign_dir.is_dir():
|
||||
self._parse_investigation(campaign_dir, db)
|
||||
|
||||
# 5. iMazing indicators (recursive)
|
||||
imazing_dir = self.IOC_DATA_DIR / 'spyware' / 'imazing'
|
||||
if imazing_dir.exists():
|
||||
for item in imazing_dir.rglob('*'):
|
||||
if item.is_dir():
|
||||
self._parse_investigation(item, db)
|
||||
|
||||
# 6. Existing stalkerware_signatures.json
|
||||
sigs_path = Path(__file__).parent.parent / 'data' / 'stalkerware_signatures.json'
|
||||
if sigs_path.exists():
|
||||
self._parse_sigs_json(sigs_path, db)
|
||||
|
||||
# Convert ips set to list for JSON serialisation
|
||||
db['ips'] = list(db['ips'])
|
||||
|
||||
self._ioc_db = db
|
||||
self._save_index(db)
|
||||
return db
|
||||
|
||||
def _parse_stalkerware_yaml(self, path, db):
|
||||
"""Parse stalkerware/watchware ioc.yaml into db."""
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
return
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='ignore') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
for vendor, info in data.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
source = f'stalkerware/{path.name}:{vendor}'
|
||||
for pkg in info.get('packages', []) or []:
|
||||
if pkg:
|
||||
db['packages'][pkg] = {'name': vendor, 'source': source, 'severity': 'high'}
|
||||
for cert in info.get('certificates', []) or []:
|
||||
if cert:
|
||||
db['certs'][cert.lower()] = {'name': vendor, 'source': source}
|
||||
for domain in (info.get('domains', []) or []) + (info.get('c2', []) or []) + (info.get('website', []) or []):
|
||||
if domain:
|
||||
db['domains'][domain.lower()] = {'name': vendor, 'source': source}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_investigation(self, campaign_dir, db):
|
||||
"""Parse a campaign directory for IOC text files and STIX2."""
|
||||
name = campaign_dir.name
|
||||
source = f'spyware/{campaign_dir.parent.name}/{name}'
|
||||
|
||||
for f in campaign_dir.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
fname = f.name.lower()
|
||||
|
||||
if fname in ('domains.txt', 'domains'):
|
||||
self._load_txt_lines(f, db['domains'], name, source)
|
||||
elif fname in ('package_names.txt', 'package_names'):
|
||||
self._load_txt_lines(f, db['packages'], name, source, extra={'severity': 'critical'})
|
||||
elif fname in ('processes.txt', 'processes'):
|
||||
self._load_txt_lines(f, db['processes'], name, source)
|
||||
elif fname in ('file_paths.txt', 'file_paths'):
|
||||
self._load_txt_lines(f, db['file_paths'], name, source)
|
||||
elif fname in ('ips.txt', 'ip_addresses.txt', 'ip-addresses.txt'):
|
||||
self._load_txt_set(f, db['ips'])
|
||||
elif fname.endswith('.stix2') or fname.endswith('_stix.json'):
|
||||
self._parse_stix(f, db, name, source)
|
||||
elif fname in ('sha256.txt', 'sha256.csv'):
|
||||
self._load_hashes(f, db['hashes'], name, source)
|
||||
elif fname == 'package_cert_hashes.txt':
|
||||
self._load_txt_lines(f, db['certs'], name, source)
|
||||
elif fname.endswith('.json') and 'ioc' in fname:
|
||||
self._parse_ioc_json(f, db, name, source)
|
||||
elif fname.endswith('.csv') and 'ioc' in fname:
|
||||
self._parse_ioc_csv(f, db, name, source)
|
||||
|
||||
def _load_txt_lines(self, path, target_dict, name, source, extra=None):
|
||||
"""Load one-indicator-per-line text file into a dict."""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip().lower()
|
||||
if line and not line.startswith('#'):
|
||||
entry = {'name': name, 'source': source}
|
||||
if extra:
|
||||
entry.update(extra)
|
||||
target_dict[line] = entry
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_txt_set(self, path, target_set):
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
target_set.add(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_hashes(self, path, target_dict, name, source):
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(',')
|
||||
h = parts[0].strip().lower()
|
||||
if h and len(h) in (32, 40, 64):
|
||||
target_dict[h] = {'name': name, 'source': source}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_stix(self, path, db, name, source):
|
||||
"""Parse a STIX2 JSON file for indicators."""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='ignore') as f:
|
||||
data = json.load(f)
|
||||
objects = data.get('objects', []) if isinstance(data, dict) else []
|
||||
for obj in objects:
|
||||
if obj.get('type') != 'indicator':
|
||||
continue
|
||||
pattern = obj.get('pattern', '')
|
||||
for m in re.findall(r"domain-name:value\s*=\s*'([^']+)'", pattern):
|
||||
db['domains'][m.lower()] = {'name': name, 'source': source}
|
||||
for m in re.findall(r"file:hashes\.'[^']+'\s*=\s*'([a-fA-F0-9]+)'", pattern):
|
||||
db['hashes'][m.lower()] = {'name': name, 'source': source}
|
||||
for m in re.findall(r"process:name\s*=\s*'([^']+)'", pattern):
|
||||
db['processes'][m.lower()] = {'name': name, 'source': source}
|
||||
for m in re.findall(r"(?:file:name|directory:path)\s*=\s*'([^']+)'", pattern):
|
||||
db['file_paths'][m] = {'name': name, 'source': source}
|
||||
for m in re.findall(r"ipv[46]-addr:value\s*=\s*'([^']+)'", pattern):
|
||||
db['ips'].add(m)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_ioc_json(self, path, db, name, source):
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='ignore') as f:
|
||||
data = json.load(f)
|
||||
items = data.get('items', data.get('iocs', [])) if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
itype = item.get('type', '').lower()
|
||||
val = str(item.get('value', item.get('indicator', ''))).strip().lower()
|
||||
if not val:
|
||||
continue
|
||||
if 'domain' in itype:
|
||||
db['domains'][val] = {'name': name, 'source': source}
|
||||
elif 'ip' in itype:
|
||||
db['ips'].add(val)
|
||||
elif 'hash' in itype or 'sha' in itype or 'md5' in itype:
|
||||
db['hashes'][val] = {'name': name, 'source': source}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_ioc_csv(self, path, db, name, source):
|
||||
try:
|
||||
import csv
|
||||
with open(path, encoding='utf-8', errors='ignore', newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
val = (row.get('value') or row.get('indicator') or row.get('domain') or '').strip().lower()
|
||||
itype = (row.get('type') or row.get('indicator_type') or '').lower()
|
||||
if not val:
|
||||
continue
|
||||
if 'domain' in itype:
|
||||
db['domains'][val] = {'name': name, 'source': source}
|
||||
elif 'hash' in itype or 'sha256' in itype:
|
||||
db['hashes'][val] = {'name': name, 'source': source}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_sigs_json(self, path, db):
|
||||
"""Merge existing stalkerware_signatures.json into db."""
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for vendor, info in data.get('stalkerware', {}).items():
|
||||
source = f'stalkerware_signatures.json:{vendor}'
|
||||
sev = info.get('severity', 'high')
|
||||
for pkg in info.get('packages', []):
|
||||
if pkg and pkg not in db['packages']:
|
||||
db['packages'][pkg] = {'name': vendor, 'source': source, 'severity': sev}
|
||||
for spyware in data.get('government_spyware', []):
|
||||
name = spyware.get('name', 'unknown')
|
||||
source = f'stalkerware_signatures.json:{name}'
|
||||
for ind in spyware.get('indicators', []):
|
||||
itype = ind.get('type', '')
|
||||
val = ind.get('value', '')
|
||||
if not val:
|
||||
continue
|
||||
if itype == 'process':
|
||||
db['processes'][val.lower()] = {'name': name, 'source': source}
|
||||
elif itype == 'domain':
|
||||
db['domains'][val.lower()] = {'name': name, 'source': source, 'severity': 'critical'}
|
||||
elif itype == 'file_path':
|
||||
db['file_paths'][val] = {'name': name, 'source': source}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_index(self, db):
|
||||
try:
|
||||
def _count_dirs(d):
|
||||
return sum(1 for p in d.iterdir() if p.is_dir()) if d.exists() else 0
|
||||
|
||||
ips = db.get('ips', [])
|
||||
index = {
|
||||
'generated': datetime.now().isoformat(),
|
||||
'counts': {
|
||||
'packages': len(db.get('packages', {})),
|
||||
'certs': len(db.get('certs', {})),
|
||||
'domains': len(db.get('domains', {})),
|
||||
'ips': len(ips) if not isinstance(ips, set) else len(ips),
|
||||
'file_paths': len(db.get('file_paths', {})),
|
||||
'processes': len(db.get('processes', {})),
|
||||
'hashes': len(db.get('hashes', {})),
|
||||
},
|
||||
'sources': {
|
||||
'stalkerware_ioc_yaml': (self.IOC_DATA_DIR / 'stalkerware' / 'ioc.yaml').exists(),
|
||||
'amnesty_investigations': _count_dirs(self.IOC_DATA_DIR / 'spyware' / 'amnesty'),
|
||||
'mvt_campaigns': _count_dirs(self.IOC_DATA_DIR / 'spyware' / 'mvt'),
|
||||
'citizen_lab_campaigns': _count_dirs(self.IOC_DATA_DIR / 'spyware' / 'citizen_lab'),
|
||||
'yara_rules': sum(1 for _ in (self.IOC_DATA_DIR / 'yara').rglob('*.yar*'))
|
||||
if (self.IOC_DATA_DIR / 'yara').exists() else 0,
|
||||
},
|
||||
}
|
||||
self.IOC_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.IOC_DATA_DIR / 'index.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(index, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_ioc_stats(self):
|
||||
"""Return IOC database statistics (from index.json if available)."""
|
||||
idx_path = self.IOC_DATA_DIR / 'index.json'
|
||||
if idx_path.exists():
|
||||
try:
|
||||
with open(idx_path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
db = self.load_ioc_database()
|
||||
return {'counts': {k: len(v) for k, v in db.items() if isinstance(v, (dict, list))}}
|
||||
|
||||
def reload_ioc_database(self):
|
||||
"""Force reload all IOC data from disk."""
|
||||
self._ioc_db = None
|
||||
return self.load_ioc_database(force=True)
|
||||
|
||||
# ── Acquisition Methods ─────────────────────────────────────────
|
||||
|
||||
def acquire_packages(self, serial):
|
||||
"""Get list of installed packages with paths and system/user classification."""
|
||||
try:
|
||||
all_out = self._adb(serial, 'pm list packages -f', timeout=30)
|
||||
sys_out = self._adb(serial, 'pm list packages -s', timeout=30)
|
||||
|
||||
# Build system package set from -s output (format: "package:com.pkg.name")
|
||||
sys_pkgs = set()
|
||||
for line in sys_out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('package:'):
|
||||
sys_pkgs.add(line[8:].strip())
|
||||
|
||||
# Parse -f output (format: "package:/path/to/base.apk=com.pkg.name")
|
||||
packages = []
|
||||
for line in all_out.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith('package:'):
|
||||
continue
|
||||
line = line[8:] # strip "package:"
|
||||
# Split on last '=' to handle paths that may contain '='
|
||||
eq_pos = line.rfind('=')
|
||||
if eq_pos == -1:
|
||||
pkg_name = line
|
||||
apk_path = ''
|
||||
else:
|
||||
apk_path = line[:eq_pos]
|
||||
pkg_name = line[eq_pos + 1:]
|
||||
pkg_name = pkg_name.strip()
|
||||
if pkg_name:
|
||||
packages.append({
|
||||
'package': pkg_name,
|
||||
'path': apk_path.strip(),
|
||||
'system': pkg_name in sys_pkgs,
|
||||
})
|
||||
return {'ok': True, 'packages': packages, 'total': len(packages)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'packages': []}
|
||||
|
||||
def acquire_processes(self, serial):
|
||||
"""Get list of running processes."""
|
||||
try:
|
||||
out = self._adb(serial, 'ps -A', timeout=30)
|
||||
lines = [l for l in out.splitlines() if l.strip()]
|
||||
return {'ok': True, 'output': out, 'count': max(0, len(lines) - 1)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'output': ''}
|
||||
|
||||
def acquire_getprop(self, serial):
|
||||
"""Get all system properties via getprop."""
|
||||
try:
|
||||
out = self._adb(serial, 'getprop', timeout=30)
|
||||
props = {}
|
||||
for line in out.splitlines():
|
||||
m = re.match(r'\[(.+)\]:\s*\[(.*)\]\s*$', line)
|
||||
if m:
|
||||
props[m.group(1)] = m.group(2)
|
||||
return {'ok': True, 'props': props, 'output': out}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'props': {}}
|
||||
|
||||
def acquire_settings(self, serial):
|
||||
"""Get global, secure, and system settings."""
|
||||
try:
|
||||
result = {}
|
||||
for ns in ('global', 'secure', 'system'):
|
||||
out = self._adb(serial, f'settings list {ns}', timeout=30)
|
||||
settings = {}
|
||||
for line in out.splitlines():
|
||||
if '=' in line:
|
||||
k, _, v = line.partition('=')
|
||||
settings[k.strip()] = v.strip()
|
||||
result[ns] = settings
|
||||
return {'ok': True, 'settings': result}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'settings': {}}
|
||||
|
||||
def acquire_services(self, serial):
|
||||
"""List all running Android services."""
|
||||
try:
|
||||
out = self._adb(serial, 'service list', timeout=30)
|
||||
return {'ok': True, 'output': out, 'count': len(out.splitlines())}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'output': ''}
|
||||
|
||||
def acquire_dumpsys(self, serial):
|
||||
"""Full dumpsys output (may be large)."""
|
||||
try:
|
||||
out = self._adb(serial, 'dumpsys', timeout=180)
|
||||
return {'ok': True, 'output': out, 'size': len(out)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'output': ''}
|
||||
|
||||
def acquire_logcat(self, serial):
|
||||
"""Recent logcat entries (last 1000 lines)."""
|
||||
try:
|
||||
out = self._adb(serial, 'logcat -d -t 1000', timeout=60)
|
||||
return {'ok': True, 'output': out, 'lines': len(out.splitlines())}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'output': ''}
|
||||
|
||||
def acquire_tmp_files(self, serial):
|
||||
"""List files in /data/local/tmp/."""
|
||||
try:
|
||||
out = self._adb(serial, 'ls -la /data/local/tmp/ 2>/dev/null', timeout=15)
|
||||
return {'ok': True, 'output': out, 'lines': len(out.splitlines())}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'output': ''}
|
||||
|
||||
def acquire_files(self, serial, max_depth=5):
|
||||
"""Recursive file listing from common paths."""
|
||||
try:
|
||||
paths = ['/data/local/tmp', '/sdcard/Android/data', '/data/data',
|
||||
'/system/app', '/data/app']
|
||||
results = []
|
||||
for base in paths:
|
||||
out = self._adb(
|
||||
serial,
|
||||
f'find {base} -maxdepth {max_depth} -type f 2>/dev/null | head -500',
|
||||
timeout=30,
|
||||
)
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
results.append({'path': line})
|
||||
return {'ok': True, 'files': results, 'total': len(results)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'files': []}
|
||||
|
||||
def acquire_logs(self, serial):
|
||||
"""Full logcat with thread-time format (last 2000 lines)."""
|
||||
try:
|
||||
out = self._adb(serial, 'logcat -d -v threadtime -t 2000', timeout=90)
|
||||
return {'ok': True, 'output': out, 'lines': len(out.splitlines())}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'output': ''}
|
||||
|
||||
def acquire_bugreport_info(self, serial):
|
||||
"""Collect bugreport header info (device state, battery, memory) without full zip."""
|
||||
try:
|
||||
sections = {}
|
||||
for name, cmd in [
|
||||
('memory', 'cat /proc/meminfo'),
|
||||
('cpuinfo', 'cat /proc/cpuinfo | head -40'),
|
||||
('uptime', 'cat /proc/uptime'),
|
||||
('mounts', 'cat /proc/mounts'),
|
||||
]:
|
||||
sections[name] = self._adb(serial, cmd, timeout=15)
|
||||
return {'ok': True, 'sections': sections}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'sections': {}}
|
||||
|
||||
# ── IOC Scanning ────────────────────────────────────────────────
|
||||
|
||||
def scan_packages(self, serial, packages_data=None):
|
||||
"""Match installed packages against IOC package names."""
|
||||
db = self.load_ioc_database()
|
||||
if packages_data is None:
|
||||
result = self.acquire_packages(serial)
|
||||
if not result.get('ok'):
|
||||
return {'ok': False, 'error': result.get('error'), 'findings': []}
|
||||
packages_data = result['packages']
|
||||
|
||||
findings = []
|
||||
for pkg_info in packages_data:
|
||||
pkg = pkg_info.get('package', '').strip()
|
||||
if pkg and pkg in db['packages']:
|
||||
match = db['packages'][pkg]
|
||||
findings.append({
|
||||
'package': pkg,
|
||||
'path': pkg_info.get('path', ''),
|
||||
'system': pkg_info.get('system', False),
|
||||
'threat_name': match['name'],
|
||||
'source': match['source'],
|
||||
'severity': match.get('severity', 'high'),
|
||||
'type': 'package_match',
|
||||
})
|
||||
return {
|
||||
'ok': True,
|
||||
'total_scanned': len(packages_data),
|
||||
'findings': findings,
|
||||
'clean': len(findings) == 0,
|
||||
}
|
||||
|
||||
def scan_certificates(self, serial):
|
||||
"""Check APK signing cert hashes against known-bad certs in IOC database."""
|
||||
db = self.load_ioc_database()
|
||||
findings = []
|
||||
if not db.get('certs'):
|
||||
return {'ok': True, 'findings': [], 'note': 'No cert IOCs loaded'}
|
||||
try:
|
||||
pkg_result = self.acquire_packages(serial)
|
||||
# Only check user-installed (non-system) packages; limit to 100 for speed
|
||||
packages = [p for p in pkg_result.get('packages', []) if not p.get('system')][:100]
|
||||
for pkg_info in packages:
|
||||
pkg = pkg_info.get('package', '')
|
||||
if not pkg:
|
||||
continue
|
||||
out = self._adb(serial, f'pm dump {pkg} 2>/dev/null | grep -A2 "Signing"', timeout=15)
|
||||
for line in out.splitlines():
|
||||
m = re.search(r'([a-fA-F0-9]{40,64})', line)
|
||||
if m:
|
||||
cert_hash = m.group(1).lower()
|
||||
if cert_hash in db['certs']:
|
||||
match = db['certs'][cert_hash]
|
||||
findings.append({
|
||||
'package': pkg,
|
||||
'cert_hash': cert_hash,
|
||||
'threat_name': match['name'],
|
||||
'source': match['source'],
|
||||
'severity': 'critical',
|
||||
'type': 'cert_match',
|
||||
})
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'findings': findings}
|
||||
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
|
||||
|
||||
def scan_network_indicators(self, serial):
|
||||
"""Check DNS/proxy settings and active TCP connections against IOC domains/IPs."""
|
||||
db = self.load_ioc_database()
|
||||
findings = []
|
||||
try:
|
||||
props = self.acquire_getprop(serial).get('props', {})
|
||||
for key, val in props.items():
|
||||
if 'dns' in key.lower() and val and val.lower() in db['domains']:
|
||||
match = db['domains'][val.lower()]
|
||||
findings.append({
|
||||
'type': 'dns_property', 'key': key, 'value': val,
|
||||
'threat_name': match['name'], 'source': match['source'], 'severity': 'high',
|
||||
})
|
||||
|
||||
ioc_ips = set(db.get('ips', []))
|
||||
if ioc_ips:
|
||||
tcp_out = self._adb(serial, 'cat /proc/net/tcp 2>/dev/null', timeout=15)
|
||||
for line in tcp_out.splitlines()[1:]:
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
remote_hex = parts[2]
|
||||
ip_hex, port_hex = remote_hex.split(':')
|
||||
# /proc/net/tcp stores IPs in little-endian hex
|
||||
ip = '.'.join(str(int(ip_hex[i:i+2], 16)) for i in (6, 4, 2, 0))
|
||||
port = int(port_hex, 16)
|
||||
if ip not in ('0.0.0.0', '127.0.0.1') and ip in ioc_ips:
|
||||
findings.append({
|
||||
'type': 'tcp_connection', 'remote_ip': ip, 'remote_port': port,
|
||||
'severity': 'critical', 'source': 'network_scan',
|
||||
'threat_name': 'Known malicious IP',
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'findings': findings}
|
||||
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
|
||||
|
||||
def scan_file_indicators(self, serial):
|
||||
"""Check device for known spyware artifact file paths."""
|
||||
db = self.load_ioc_database()
|
||||
if not db.get('file_paths'):
|
||||
return {'ok': True, 'findings': [], 'note': 'No file path IOCs loaded'}
|
||||
findings = []
|
||||
try:
|
||||
for fpath, match in list(db['file_paths'].items())[:200]:
|
||||
out = self._adb(serial, f'ls "{fpath}" 2>/dev/null', timeout=5)
|
||||
if out.strip() and 'No such file' not in out:
|
||||
findings.append({
|
||||
'type': 'file_path',
|
||||
'path': fpath,
|
||||
'threat_name': match['name'],
|
||||
'source': match['source'],
|
||||
'severity': 'critical',
|
||||
})
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'findings': findings}
|
||||
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
|
||||
|
||||
def scan_process_indicators(self, serial):
|
||||
"""Cross-reference running processes against known spyware process names."""
|
||||
db = self.load_ioc_database()
|
||||
if not db.get('processes'):
|
||||
return {'ok': True, 'findings': [], 'note': 'No process IOCs loaded'}
|
||||
findings = []
|
||||
try:
|
||||
proc_output = self.acquire_processes(serial).get('output', '').lower()
|
||||
for proc_name, match in db['processes'].items():
|
||||
if proc_name and proc_name in proc_output:
|
||||
findings.append({
|
||||
'type': 'process_match',
|
||||
'process': proc_name,
|
||||
'threat_name': match['name'],
|
||||
'source': match['source'],
|
||||
'severity': 'critical',
|
||||
})
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e), 'findings': findings}
|
||||
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
|
||||
|
||||
# ── SLM Heuristic Scanner ────────────────────────────────────────
|
||||
|
||||
def _heuristic_scan(self, data_type, items, threshold=0.65):
|
||||
"""Run SLM heuristic pass on a list of items via the local LLM.
|
||||
data_type: 'packages' | 'processes' | 'network' | 'files'
|
||||
Returns list of {item, score, reason, category} for items scoring >= threshold."""
|
||||
if not items:
|
||||
return []
|
||||
try:
|
||||
from core.llm import get_llm, LLMError
|
||||
llm = get_llm()
|
||||
if not llm or not llm.is_loaded:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
prompts = {
|
||||
'packages': (
|
||||
'Classify each Android package name for stalkerware/spyware/adware risk. '
|
||||
'Score 0.0-1.0. High risk: names impersonating system apps, random strings, '
|
||||
'unknown publishers with device-admin-sounding names. '
|
||||
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"stalkerware|adware|suspicious|clean"}]'
|
||||
),
|
||||
'processes': (
|
||||
'Classify each Android process name for malware risk. Score 0.0-1.0. '
|
||||
'Suspicious: random hex strings, names mimicking system processes with slight misspellings. '
|
||||
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"malware|suspicious|clean"}]'
|
||||
),
|
||||
'network': (
|
||||
'Classify each domain or IP for C2/malware beacon risk. Score 0.0-1.0. '
|
||||
'High risk: DGA-looking names, dynamic DNS (duckdns, no-ip, ddns), unusual TLDs, high-entropy subdomains. '
|
||||
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"c2_beacon|dga_domain|suspicious|clean"}]'
|
||||
),
|
||||
'files': (
|
||||
'Classify each Android file path for spyware/stalkerware artifact risk. Score 0.0-1.0. '
|
||||
'High risk: hidden directories, paths in /data/local/tmp, surveillance-sounding names. '
|
||||
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"spyware|suspicious|clean"}]'
|
||||
),
|
||||
}
|
||||
base_prompt = prompts.get(data_type, prompts['packages'])
|
||||
|
||||
findings = []
|
||||
# Process in chunks of 50 (on-device SLM token budget)
|
||||
for i in range(0, len(items), 50):
|
||||
chunk = items[i:i + 50]
|
||||
prompt = f'{base_prompt}\n\nItems:\n' + '\n'.join(str(x) for x in chunk)
|
||||
try:
|
||||
raw = get_llm().generate(prompt, max_tokens=800)
|
||||
# Extract JSON array from response
|
||||
m = re.search(r'\[.*\]', raw, re.DOTALL)
|
||||
if not m:
|
||||
continue
|
||||
parsed = json.loads(m.group(0))
|
||||
for entry in parsed:
|
||||
if isinstance(entry, dict) and float(entry.get('score', 0)) >= threshold:
|
||||
findings.append({
|
||||
'item': str(entry.get('item', '')),
|
||||
'score': float(entry.get('score', 0)),
|
||||
'reason': str(entry.get('reason', '')),
|
||||
'category': str(entry.get('category', 'suspicious')),
|
||||
'confidence': float(entry.get('score', 0)),
|
||||
'heuristic': True,
|
||||
'data_type': data_type,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return findings
|
||||
|
||||
# ── Composite Operations ─────────────────────────────────────────
|
||||
|
||||
def full_acquisition(self, serial):
|
||||
"""Run all acquisition modules and all IOC scans. Save results to disk."""
|
||||
acq_id = self._new_acq_id()
|
||||
acq_dir = self._acq_dir(serial, acq_id)
|
||||
|
||||
report = {
|
||||
'acq_id': acq_id,
|
||||
'serial': serial,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'modules': {},
|
||||
'ioc_findings': [],
|
||||
}
|
||||
|
||||
module_map = {
|
||||
'packages': self.acquire_packages,
|
||||
'processes': self.acquire_processes,
|
||||
'getprop': self.acquire_getprop,
|
||||
'settings': self.acquire_settings,
|
||||
'services': self.acquire_services,
|
||||
'logcat': self.acquire_logcat,
|
||||
'tmp_files': self.acquire_tmp_files,
|
||||
'files': self.acquire_files,
|
||||
'logs': self.acquire_logs,
|
||||
'bugreport': self.acquire_bugreport_info,
|
||||
}
|
||||
|
||||
for mod_name, func in module_map.items():
|
||||
try:
|
||||
result = func(serial)
|
||||
report['modules'][mod_name] = result
|
||||
with open(acq_dir / f'{mod_name}.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, default=str)
|
||||
except Exception as e:
|
||||
report['modules'][mod_name] = {'ok': False, 'error': str(e)}
|
||||
|
||||
# IOC scans — reuse already-acquired package list
|
||||
packages_data = report['modules'].get('packages', {}).get('packages', [])
|
||||
all_findings = []
|
||||
for scan_name, scan_func in [
|
||||
('packages', lambda s: self.scan_packages(s, packages_data)),
|
||||
('network', self.scan_network_indicators),
|
||||
('files', self.scan_file_indicators),
|
||||
('processes', self.scan_process_indicators),
|
||||
]:
|
||||
try:
|
||||
scan_result = scan_func(serial)
|
||||
for finding in scan_result.get('findings', []):
|
||||
finding['scan_type'] = scan_name
|
||||
all_findings.append(finding)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
report['ioc_findings'] = all_findings
|
||||
report['scan_summary'] = {
|
||||
'total_findings': len(all_findings),
|
||||
'critical': sum(1 for f in all_findings if f.get('severity') == 'critical'),
|
||||
'high': sum(1 for f in all_findings if f.get('severity') == 'high'),
|
||||
'clean': len(all_findings) == 0,
|
||||
}
|
||||
|
||||
with open(acq_dir / 'report.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, indent=2, default=str)
|
||||
|
||||
return report
|
||||
|
||||
def get_acquisition_list(self, serial):
|
||||
"""List past acquisitions for a device."""
|
||||
device_dir = self.ACQ_DATA_DIR / serial
|
||||
if not device_dir.exists():
|
||||
return []
|
||||
acquisitions = []
|
||||
for acq_dir in sorted(device_dir.iterdir(), reverse=True):
|
||||
if not acq_dir.is_dir():
|
||||
continue
|
||||
report_path = acq_dir / 'report.json'
|
||||
entry = {'acq_id': acq_dir.name, 'has_report': report_path.exists()}
|
||||
if report_path.exists():
|
||||
try:
|
||||
with open(report_path, encoding='utf-8') as f:
|
||||
r = json.load(f)
|
||||
entry['timestamp'] = r.get('timestamp', acq_dir.name)
|
||||
entry['modules'] = list(r.get('modules', {}).keys())
|
||||
entry['findings'] = r.get('scan_summary', {}).get('total_findings', 0)
|
||||
except Exception:
|
||||
pass
|
||||
acquisitions.append(entry)
|
||||
return acquisitions
|
||||
|
||||
def export_report(self, serial, acq_id):
|
||||
"""Load a saved acquisition report from disk."""
|
||||
report_path = self.ACQ_DATA_DIR / serial / acq_id / 'report.json'
|
||||
if not report_path.exists():
|
||||
return {'ok': False, 'error': 'Report not found'}
|
||||
try:
|
||||
with open(report_path, encoding='utf-8') as f:
|
||||
return {'ok': True, 'report': json.load(f), 'path': str(report_path)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
# ── Direct (WebUSB) Mode ─────────────────────────────────────────
|
||||
|
||||
def get_direct_commands(self, operation):
|
||||
"""Return ADB shell commands for browser-side WebUSB execution."""
|
||||
commands = {
|
||||
'packages': 'pm list packages -f',
|
||||
'packages_sys': 'pm list packages -s',
|
||||
'processes': 'ps -A',
|
||||
'getprop': 'getprop',
|
||||
'settings_global': 'settings list global',
|
||||
'settings_secure': 'settings list secure',
|
||||
'settings_system': 'settings list system',
|
||||
'services': 'service list',
|
||||
'logcat': 'logcat -d -t 1000',
|
||||
'tmp_files': 'ls -la /data/local/tmp/ 2>/dev/null',
|
||||
'tcp_connections': 'cat /proc/net/tcp 2>/dev/null',
|
||||
}
|
||||
if operation == 'full':
|
||||
return commands
|
||||
cmd = commands.get(operation)
|
||||
return {operation: cmd} if cmd else {}
|
||||
|
||||
def parse_direct_output(self, operation, raw_output):
|
||||
"""Parse raw ADB output returned from a WebUSB browser client."""
|
||||
if operation == 'packages':
|
||||
packages = []
|
||||
for line in raw_output.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith('package:'):
|
||||
continue
|
||||
line = line[8:]
|
||||
eq_pos = line.rfind('=')
|
||||
if eq_pos == -1:
|
||||
pkg_name, apk_path = line, ''
|
||||
else:
|
||||
apk_path, pkg_name = line[:eq_pos], line[eq_pos + 1:]
|
||||
pkg_name = pkg_name.strip()
|
||||
if pkg_name:
|
||||
packages.append({'package': pkg_name, 'path': apk_path.strip(), 'system': False})
|
||||
return {'ok': True, 'packages': packages, 'total': len(packages)}
|
||||
|
||||
elif operation == 'processes':
|
||||
lines = [l for l in raw_output.splitlines() if l.strip()]
|
||||
return {'ok': True, 'output': raw_output, 'count': max(0, len(lines) - 1)}
|
||||
|
||||
elif operation == 'getprop':
|
||||
props = {}
|
||||
for line in raw_output.splitlines():
|
||||
m = re.match(r'\[(.+)\]:\s*\[(.*)\]\s*$', line)
|
||||
if m:
|
||||
props[m.group(1)] = m.group(2)
|
||||
return {'ok': True, 'props': props, 'output': raw_output}
|
||||
|
||||
else:
|
||||
return {'ok': True, 'output': raw_output}
|
||||
Reference in New Issue
Block a user