Spaces:
Sleeping
Sleeping
Aniket2006 commited on
Commit Β·
9bce975
1
Parent(s): 95f6f5f
sync: update apk intelligence, identity, risk fusion logic, and YARA rules from backend
Browse files- app/services/apk_intelligence/analyzer.py +3 -0
- app/services/apk_intelligence/engines/androguard_engine.py +68 -4
- app/services/apk_intelligence/engines/apkid_engine.py +17 -10
- app/services/apk_intelligence/engines/ml_engine.py +7 -3
- app/services/apk_intelligence/engines/reverse_engineering_engine.py +69 -7
- app/services/apk_intelligence/models.py +7 -0
- app/services/apk_intelligence/threat_explainer.py +12 -5
- app/services/identity/bank_registry.py +20 -0
- app/services/identity/trusted_publisher_registry.py +125 -0
- app/services/risk/fusion.py +169 -16
- rules/android_banker.yar +25 -17
- rules/otp_stealer.yar +21 -10
- rules/rat.yar +15 -10
- rules/spyware_collector.yar +21 -8
- rules/trojan_dropper.yar +14 -7
app/services/apk_intelligence/analyzer.py
CHANGED
|
@@ -252,6 +252,9 @@ class APKAnalyzer:
|
|
| 252 |
engine_results=engine_results,
|
| 253 |
local_risk=metadata.localRisk,
|
| 254 |
package_name=metadata.packageName,
|
|
|
|
|
|
|
|
|
|
| 255 |
)
|
| 256 |
|
| 257 |
response.explanation = run_threat_explanation(engine_results, response)
|
|
|
|
| 252 |
engine_results=engine_results,
|
| 253 |
local_risk=metadata.localRisk,
|
| 254 |
package_name=metadata.packageName,
|
| 255 |
+
certificate_hash=metadata.certificateHash,
|
| 256 |
+
app_name=metadata.appName,
|
| 257 |
+
installer_package_name=metadata.installerPackageName,
|
| 258 |
)
|
| 259 |
|
| 260 |
response.explanation = run_threat_explanation(engine_results, response)
|
app/services/apk_intelligence/engines/androguard_engine.py
CHANGED
|
@@ -51,6 +51,35 @@ _SUSPICIOUS_RECEIVER_ACTIONS = frozenset({
|
|
| 51 |
|
| 52 |
_ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
class AndroguardEngine:
|
| 56 |
"""
|
|
@@ -108,6 +137,9 @@ class AndroguardEngine:
|
|
| 108 |
|
| 109 |
try:
|
| 110 |
apk = APK(str(apk_path))
|
|
|
|
|
|
|
|
|
|
| 111 |
except Exception as exc:
|
| 112 |
logger.warning("Androguard: cannot parse %s β %s", apk_path, exc)
|
| 113 |
return AndroguardResult(error=f"Invalid APK: {exc}")
|
|
@@ -123,10 +155,14 @@ class AndroguardEngine:
|
|
| 123 |
perm_set = set(permissions)
|
| 124 |
is_obfuscated = _has_obfuscated_names(components)
|
| 125 |
native_code = bool(list(apk.get_libraries()))
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
logger.info(
|
| 132 |
"Androguard: pkg=%s perms=%d activities=%d services=%d receivers=%d "
|
|
@@ -166,6 +202,9 @@ class AndroguardEngine:
|
|
| 166 |
self._cert_sha256_from_der(cert_der) if cert_der else self._extract_cert_sha256(apk)
|
| 167 |
),
|
| 168 |
certificate_intel=self._build_certificate_intel(cert_der),
|
|
|
|
|
|
|
|
|
|
| 169 |
)
|
| 170 |
|
| 171 |
def _get_cert_der(self, apk) -> Optional[bytes]:
|
|
@@ -394,6 +433,13 @@ def _safe_str(val: object) -> str:
|
|
| 394 |
return str(val) if val is not None else ""
|
| 395 |
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
def _has_obfuscated_names(components: ApkComponents) -> bool:
|
| 398 |
"""True if any component name ends with a 1-2 character class segment."""
|
| 399 |
all_names = components.activities + components.services + components.receivers
|
|
@@ -404,6 +450,24 @@ def _has_obfuscated_names(components: ApkComponents) -> bool:
|
|
| 404 |
return False
|
| 405 |
|
| 406 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
def _build_ml_features(
|
| 408 |
permissions: list[str],
|
| 409 |
components: ApkComponents,
|
|
|
|
| 51 |
|
| 52 |
_ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
| 53 |
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# Patch androguard's DIMENSION_UNITS to cover all 16 unit indices that
|
| 56 |
+
# COMPLEX_UNIT_MASK (0x0F) can produce. Upstream only defines 6 entries
|
| 57 |
+
# (px/dip/sp/pt/in/mm). Packed or obfuscated APKs often have resource
|
| 58 |
+
# entries with unit indices 6β15, which causes an IndexError inside
|
| 59 |
+
# get_resource_dimen β a DEBUG log storm. Extending the list with
|
| 60 |
+
# placeholder names makes the lookup always succeed.
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
def _patch_androguard_dimension_units() -> None:
|
| 63 |
+
try:
|
| 64 |
+
import androguard.core.axml as _axml # type: ignore[import-untyped]
|
| 65 |
+
|
| 66 |
+
_UNIT_MASK = 0x0F # COMPLEX_UNIT_MASK β max possible index is 15
|
| 67 |
+
_needed = _UNIT_MASK + 1 # 16 entries
|
| 68 |
+
_shortfall = _needed - len(_axml.DIMENSION_UNITS)
|
| 69 |
+
if _shortfall > 0:
|
| 70 |
+
for _i in range(len(_axml.DIMENSION_UNITS), _needed):
|
| 71 |
+
_axml.DIMENSION_UNITS.append(f"unknown_unit{_i}")
|
| 72 |
+
logger.debug(
|
| 73 |
+
"Patched androguard DIMENSION_UNITS: added %d placeholder unit(s) "
|
| 74 |
+
"to prevent IndexError on non-standard APK resource tables.",
|
| 75 |
+
_shortfall,
|
| 76 |
+
)
|
| 77 |
+
except Exception:
|
| 78 |
+
pass # androguard not installed β nothing to patch
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
_patch_androguard_dimension_units()
|
| 82 |
+
|
| 83 |
|
| 84 |
class AndroguardEngine:
|
| 85 |
"""
|
|
|
|
| 137 |
|
| 138 |
try:
|
| 139 |
apk = APK(str(apk_path))
|
| 140 |
+
if not apk.is_valid_APK():
|
| 141 |
+
logger.warning("Androguard: invalid APK or cannot parse manifest for %s", apk_path)
|
| 142 |
+
return AndroguardResult(error="Invalid APK: Failed to parse AndroidManifest.xml")
|
| 143 |
except Exception as exc:
|
| 144 |
logger.warning("Androguard: cannot parse %s β %s", apk_path, exc)
|
| 145 |
return AndroguardResult(error=f"Invalid APK: {exc}")
|
|
|
|
| 155 |
perm_set = set(permissions)
|
| 156 |
is_obfuscated = _has_obfuscated_names(components)
|
| 157 |
native_code = bool(list(apk.get_libraries()))
|
| 158 |
+
# Actual dynamic code loading β DexClassLoader fetches and executes code
|
| 159 |
+
# from an arbitrary file/byte array at runtime. This used to be approximated
|
| 160 |
+
# by the REQUEST_INSTALL_PACKAGES permission, which is wrong: that permission
|
| 161 |
+
# is for self-update/in-app-install flows (Spotify, Instagram both hold it)
|
| 162 |
+
# and has nothing to do with loading code dynamically. PathClassLoader is
|
| 163 |
+
# deliberately excluded β it's the default loader for every app's own code
|
| 164 |
+
# since Android 5.0, so its presence is universal and not a signal at all.
|
| 165 |
+
dynamic_code_loading = _has_dex_class_loader(apk)
|
| 166 |
|
| 167 |
logger.info(
|
| 168 |
"Androguard: pkg=%s perms=%d activities=%d services=%d receivers=%d "
|
|
|
|
| 202 |
self._cert_sha256_from_der(cert_der) if cert_der else self._extract_cert_sha256(apk)
|
| 203 |
),
|
| 204 |
certificate_intel=self._build_certificate_intel(cert_der),
|
| 205 |
+
min_sdk_version=_safe_int(_safe_call(apk.get_min_sdk_version)),
|
| 206 |
+
target_sdk_version=_safe_int(_safe_call(apk.get_target_sdk_version)),
|
| 207 |
+
main_activity=_safe_call(apk.get_main_activity) or None,
|
| 208 |
)
|
| 209 |
|
| 210 |
def _get_cert_der(self, apk) -> Optional[bytes]:
|
|
|
|
| 433 |
return str(val) if val is not None else ""
|
| 434 |
|
| 435 |
|
| 436 |
+
def _safe_int(val: object) -> Optional[int]:
|
| 437 |
+
try:
|
| 438 |
+
return int(val) if val is not None else None
|
| 439 |
+
except (TypeError, ValueError):
|
| 440 |
+
return None
|
| 441 |
+
|
| 442 |
+
|
| 443 |
def _has_obfuscated_names(components: ApkComponents) -> bool:
|
| 444 |
"""True if any component name ends with a 1-2 character class segment."""
|
| 445 |
all_names = components.activities + components.services + components.receivers
|
|
|
|
| 450 |
return False
|
| 451 |
|
| 452 |
|
| 453 |
+
def _has_dex_class_loader(apk: object) -> bool:
|
| 454 |
+
"""True if the APK's DEX bytecode references DexClassLoader/InMemoryDexClassLoader
|
| 455 |
+
β the loaders apps use to fetch and execute code from an arbitrary source at
|
| 456 |
+
runtime. PathClassLoader is intentionally not checked here (see caller comment)."""
|
| 457 |
+
try:
|
| 458 |
+
get_all_dex = getattr(apk, "get_all_dex", None)
|
| 459 |
+
dex_blobs = list(get_all_dex()) if callable(get_all_dex) else [apk.get_dex()]
|
| 460 |
+
except Exception:
|
| 461 |
+
return False
|
| 462 |
+
|
| 463 |
+
for blob in dex_blobs:
|
| 464 |
+
if not blob:
|
| 465 |
+
continue
|
| 466 |
+
if b"DexClassLoader" in blob:
|
| 467 |
+
return True
|
| 468 |
+
return False
|
| 469 |
+
|
| 470 |
+
|
| 471 |
def _build_ml_features(
|
| 472 |
permissions: list[str],
|
| 473 |
components: ApkComponents,
|
app/services/apk_intelligence/engines/apkid_engine.py
CHANGED
|
@@ -59,24 +59,22 @@ _OBFUSCATOR_SIGS: list[tuple[str, bytes]] = [
|
|
| 59 |
]
|
| 60 |
|
| 61 |
_ANTI_ANALYSIS_SIGS: list[tuple[str, bytes]] = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
("emulator-check", b"isemulator"),
|
| 63 |
-
("emulator-check", b"ro.product.model"),
|
| 64 |
-
("emulator-check", b"ro.hardware"),
|
| 65 |
("emulator-check", b"goldfish"),
|
| 66 |
("emulator-check", b"vbox86"),
|
| 67 |
-
("emulator-check", b"android.os.build"),
|
| 68 |
("debugger-check", b"isdebuggerconnected"),
|
| 69 |
-
("debugger-check", b"android.os.debug"),
|
| 70 |
("debugger-check", b"jdwp"),
|
| 71 |
("anti-vm", b"vmware"),
|
| 72 |
("anti-vm", b"virtualbox"),
|
| 73 |
("anti-vm", b"bluestacks"),
|
| 74 |
("anti-vm", b"noxplayer"),
|
| 75 |
-
("anti-frida", b"frida"),
|
| 76 |
("anti-frida", b"frida-gadget"),
|
| 77 |
("anti-frida", b"gum-js-loop"),
|
| 78 |
-
("anti-frida", b"frida_"),
|
| 79 |
-
("root-check", b"busybox"),
|
| 80 |
("root-check", b"/system/xbin/su"),
|
| 81 |
]
|
| 82 |
|
|
@@ -220,14 +218,23 @@ def _build_result(
|
|
| 220 |
if packers:
|
| 221 |
score += 0.4
|
| 222 |
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
score += 0.3
|
|
|
|
|
|
|
| 225 |
|
| 226 |
-
#
|
|
|
|
|
|
|
| 227 |
if len(obfuscators) > 1:
|
| 228 |
score += 0.2
|
| 229 |
elif obfuscators:
|
| 230 |
-
score += 0.
|
| 231 |
|
| 232 |
risk_score = round(min(score, 1.0), 4)
|
| 233 |
|
|
|
|
| 59 |
]
|
| 60 |
|
| 61 |
_ANTI_ANALYSIS_SIGS: list[tuple[str, bytes]] = [
|
| 62 |
+
# "ro.product.model", "ro.hardware", and "android.os.build"/"android.os.debug"
|
| 63 |
+
# were dropped β they're just the Build/Debug API surface used by virtually
|
| 64 |
+
# every app for device-info logging and analytics, not evidence of deliberate
|
| 65 |
+
# evasion. Keep only strings that specifically name an evasion technique or
|
| 66 |
+
# a specific emulator/VM/instrumentation product.
|
| 67 |
("emulator-check", b"isemulator"),
|
|
|
|
|
|
|
| 68 |
("emulator-check", b"goldfish"),
|
| 69 |
("emulator-check", b"vbox86"),
|
|
|
|
| 70 |
("debugger-check", b"isdebuggerconnected"),
|
|
|
|
| 71 |
("debugger-check", b"jdwp"),
|
| 72 |
("anti-vm", b"vmware"),
|
| 73 |
("anti-vm", b"virtualbox"),
|
| 74 |
("anti-vm", b"bluestacks"),
|
| 75 |
("anti-vm", b"noxplayer"),
|
|
|
|
| 76 |
("anti-frida", b"frida-gadget"),
|
| 77 |
("anti-frida", b"gum-js-loop"),
|
|
|
|
|
|
|
| 78 |
("root-check", b"/system/xbin/su"),
|
| 79 |
]
|
| 80 |
|
|
|
|
| 218 |
if packers:
|
| 219 |
score += 0.4
|
| 220 |
|
| 221 |
+
# A single anti-analysis category (e.g. just a debugger-state check used for
|
| 222 |
+
# crash-reporting telemetry) is common in legitimate hardened release builds.
|
| 223 |
+
# An evasion *suite* β multiple distinct categories together (anti-emulator +
|
| 224 |
+
# anti-debug + anti-frida etc.) β is the real signal genuine malware exhibits
|
| 225 |
+
# to survive sandbox/dynamic analysis.
|
| 226 |
+
if len(anti_analysis) > 1:
|
| 227 |
score += 0.3
|
| 228 |
+
elif anti_analysis:
|
| 229 |
+
score += 0.05
|
| 230 |
|
| 231 |
+
# ProGuard/R8 is the default release-hardening toolchain for virtually all
|
| 232 |
+
# production Android apps β "heavy" obfuscation (multiple distinct tools
|
| 233 |
+
# stacked together) is the unusual, more suspicious case.
|
| 234 |
if len(obfuscators) > 1:
|
| 235 |
score += 0.2
|
| 236 |
elif obfuscators:
|
| 237 |
+
score += 0.02
|
| 238 |
|
| 239 |
risk_score = round(min(score, 1.0), 4)
|
| 240 |
|
app/services/apk_intelligence/engines/ml_engine.py
CHANGED
|
@@ -114,9 +114,13 @@ class MLFeatureExtractor:
|
|
| 114 |
perm_location = 1.0 if _PERM_LOCATION in perm_set else 0.0
|
| 115 |
|
| 116 |
# ββ Component features βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
# ββ Behaviour features βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
sms_access = 1.0 if sf and sf.sms_access else 0.0
|
|
|
|
| 114 |
perm_location = 1.0 if _PERM_LOCATION in perm_set else 0.0
|
| 115 |
|
| 116 |
# ββ Component features βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
# Clamp raw counts before weighting β large legitimate apps routinely
|
| 118 |
+
# ship dozens of activities/services/receivers, which would otherwise
|
| 119 |
+
# rack up score purely from being feature-rich rather than malicious.
|
| 120 |
+
_COMPONENT_COUNT_CAP = 5.0
|
| 121 |
+
receiver_count = min(float(len(comp.receivers) if comp else 0), _COMPONENT_COUNT_CAP)
|
| 122 |
+
service_count = min(float(len(comp.services) if comp else 0), _COMPONENT_COUNT_CAP)
|
| 123 |
+
exported_count = min(float(sf.exported_count if sf else 0), _COMPONENT_COUNT_CAP)
|
| 124 |
|
| 125 |
# ββ Behaviour features βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
sms_access = 1.0 if sf and sf.sms_access else 0.0
|
app/services/apk_intelligence/engines/reverse_engineering_engine.py
CHANGED
|
@@ -28,6 +28,7 @@ from app.services.apk_intelligence.engines.apkid_engine import _PACKER_SIGS
|
|
| 28 |
from app.services.apk_intelligence.models import (
|
| 29 |
AndroguardResult,
|
| 30 |
CapabilityFinding,
|
|
|
|
| 31 |
NetworkIndicator,
|
| 32 |
ReverseEngineeringResult,
|
| 33 |
)
|
|
@@ -119,6 +120,22 @@ _IGNORED_IPS = frozenset({"0.0.0.0", "127.0.0.1", "255.255.255.255"})
|
|
| 119 |
_PASTEBIN_RAW_RE = re.compile(rb"pastebin\.com/raw/", re.IGNORECASE)
|
| 120 |
_TELEGRAM_BOT_RE = re.compile(rb"api\.telegram\.org/bot", re.IGNORECASE)
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
def _confidence(n_hits: int, has_corroboration: bool) -> Optional[str]:
|
| 124 |
"""HIGH = >=2 signature hits AND a corroborating permission/flag.
|
|
@@ -132,10 +149,12 @@ def _confidence(n_hits: int, has_corroboration: bool) -> Optional[str]:
|
|
| 132 |
|
| 133 |
def _confidence_no_corroboration(n_hits: int) -> Optional[str]:
|
| 134 |
"""For capabilities with no manifest-level corroborating signal:
|
| 135 |
-
HIGH = >=
|
| 136 |
-
|
|
|
|
|
|
|
| 137 |
return "HIGH"
|
| 138 |
-
if n_hits >=
|
| 139 |
return "MEDIUM"
|
| 140 |
return None
|
| 141 |
|
|
@@ -185,7 +204,7 @@ class ReverseEngineeringEngine:
|
|
| 185 |
self._find_overlay_phishing(buf_lower, perms),
|
| 186 |
self._find_remote_access(buf_lower),
|
| 187 |
self._find_accessibility_abuse(buf_lower, perms),
|
| 188 |
-
self._find_credential_harvesting(buf_lower),
|
| 189 |
self._find_dynamic_code_loading(buf_lower, androguard),
|
| 190 |
self._find_persistence(buf_lower, perms),
|
| 191 |
]
|
|
@@ -199,12 +218,14 @@ class ReverseEngineeringEngine:
|
|
| 199 |
capabilities = [f for f in findings if f is not None]
|
| 200 |
|
| 201 |
certificate = androguard.identity.certificate_intel if androguard.identity else None
|
|
|
|
| 202 |
|
| 203 |
return ReverseEngineeringResult(
|
| 204 |
capabilities=capabilities,
|
| 205 |
native_libraries=native_libraries,
|
| 206 |
network_indicators=network_indicators,
|
| 207 |
certificate=certificate,
|
|
|
|
| 208 |
)
|
| 209 |
|
| 210 |
# ββ Scan buffer (mirrors yara_engine._extract_scan_buffer) ββββββββββββββββββββ
|
|
@@ -339,11 +360,20 @@ class ReverseEngineeringEngine:
|
|
| 339 |
mitre_id="T1418",
|
| 340 |
)
|
| 341 |
|
| 342 |
-
def _find_credential_harvesting(self, buf: bytes) -> Optional[CapabilityFinding]:
|
| 343 |
n, hits = self._count_hits(buf, _CREDENTIAL_HARVESTING_SIGS)
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
if confidence is None:
|
| 346 |
return None
|
|
|
|
| 347 |
return CapabilityFinding(
|
| 348 |
capability_id="credential_harvesting",
|
| 349 |
name="Credential Harvesting via WebView",
|
|
@@ -354,7 +384,7 @@ class ReverseEngineeringEngine:
|
|
| 354 |
"usernames and passwords."
|
| 355 |
),
|
| 356 |
confidence=confidence,
|
| 357 |
-
evidence=
|
| 358 |
mitre_id="T1417",
|
| 359 |
)
|
| 360 |
|
|
@@ -498,6 +528,38 @@ class ReverseEngineeringEngine:
|
|
| 498 |
|
| 499 |
return indicators
|
| 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
def _find_suspicious_network(self, indicators: list[NetworkIndicator]) -> Optional[CapabilityFinding]:
|
| 502 |
flagged = [i for i in indicators if i.context]
|
| 503 |
if len(flagged) >= 2:
|
|
|
|
| 28 |
from app.services.apk_intelligence.models import (
|
| 29 |
AndroguardResult,
|
| 30 |
CapabilityFinding,
|
| 31 |
+
IOCSet,
|
| 32 |
NetworkIndicator,
|
| 33 |
ReverseEngineeringResult,
|
| 34 |
)
|
|
|
|
| 120 |
_PASTEBIN_RAW_RE = re.compile(rb"pastebin\.com/raw/", re.IGNORECASE)
|
| 121 |
_TELEGRAM_BOT_RE = re.compile(rb"api\.telegram\.org/bot", re.IGNORECASE)
|
| 122 |
|
| 123 |
+
# ββ IOC extraction (emails, crypto constants, Firebase) ββββββββββββββββββββββββ
|
| 124 |
+
|
| 125 |
+
_MAX_IOCS_PER_CATEGORY = 10
|
| 126 |
+
|
| 127 |
+
_EMAIL_RE = re.compile(rb"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
|
| 128 |
+
_CRYPTO_RE = re.compile(
|
| 129 |
+
rb"\b(AES|DES|3DES|TripleDES|RSA|Blowfish|RC4|ChaCha20|HMAC-?SHA(?:1|256)?)\b",
|
| 130 |
+
re.IGNORECASE,
|
| 131 |
+
)
|
| 132 |
+
_FIREBASE_MARKERS = (
|
| 133 |
+
b".firebaseio.com",
|
| 134 |
+
b".firebaseapp.com",
|
| 135 |
+
b"firebasestorage.googleapis.com",
|
| 136 |
+
b"firebase-settings.crashlytics.com",
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
|
| 140 |
def _confidence(n_hits: int, has_corroboration: bool) -> Optional[str]:
|
| 141 |
"""HIGH = >=2 signature hits AND a corroborating permission/flag.
|
|
|
|
| 149 |
|
| 150 |
def _confidence_no_corroboration(n_hits: int) -> Optional[str]:
|
| 151 |
"""For capabilities with no manifest-level corroborating signal:
|
| 152 |
+
HIGH = >=4 hits, MEDIUM = >=2 hits. A single common API string (e.g.
|
| 153 |
+
"loadurl", "jobscheduler") is too common in ordinary apps to mean
|
| 154 |
+
anything on its own."""
|
| 155 |
+
if n_hits >= 4:
|
| 156 |
return "HIGH"
|
| 157 |
+
if n_hits >= 2:
|
| 158 |
return "MEDIUM"
|
| 159 |
return None
|
| 160 |
|
|
|
|
| 204 |
self._find_overlay_phishing(buf_lower, perms),
|
| 205 |
self._find_remote_access(buf_lower),
|
| 206 |
self._find_accessibility_abuse(buf_lower, perms),
|
| 207 |
+
self._find_credential_harvesting(buf_lower, perms),
|
| 208 |
self._find_dynamic_code_loading(buf_lower, androguard),
|
| 209 |
self._find_persistence(buf_lower, perms),
|
| 210 |
]
|
|
|
|
| 218 |
capabilities = [f for f in findings if f is not None]
|
| 219 |
|
| 220 |
certificate = androguard.identity.certificate_intel if androguard.identity else None
|
| 221 |
+
iocs = self._extract_iocs(buf, network_indicators)
|
| 222 |
|
| 223 |
return ReverseEngineeringResult(
|
| 224 |
capabilities=capabilities,
|
| 225 |
native_libraries=native_libraries,
|
| 226 |
network_indicators=network_indicators,
|
| 227 |
certificate=certificate,
|
| 228 |
+
iocs=iocs,
|
| 229 |
)
|
| 230 |
|
| 231 |
# ββ Scan buffer (mirrors yara_engine._extract_scan_buffer) ββββββββββββββββββββ
|
|
|
|
| 360 |
mitre_id="T1418",
|
| 361 |
)
|
| 362 |
|
| 363 |
+
def _find_credential_harvesting(self, buf: bytes, perms: FrozenSet[str]) -> Optional[CapabilityFinding]:
|
| 364 |
n, hits = self._count_hits(buf, _CREDENTIAL_HARVESTING_SIGS)
|
| 365 |
+
# All 4 signatures here (addJavascriptInterface, setJavascriptEnabled,
|
| 366 |
+
# passwordTransformationMethod, loadUrl) are bog-standard APIs for any
|
| 367 |
+
# WebView-based OAuth/payment login screen β confirmed false-positive HIGH
|
| 368 |
+
# on Google Docs, Instagram, Spotify, and an unrelated Flutter hackathon app,
|
| 369 |
+
# none of which harvest credentials. Genuine overlay-phishing credential theft
|
| 370 |
+
# specifically needs to draw its fake form *over* another app, so require the
|
| 371 |
+
# overlay permission as corroboration rather than trusting the bare hit count.
|
| 372 |
+
has_perm = _OVERLAY_PERM in perms
|
| 373 |
+
confidence = _confidence(n, has_perm)
|
| 374 |
if confidence is None:
|
| 375 |
return None
|
| 376 |
+
evidence = hits[:5] + ([_OVERLAY_PERM] if has_perm else [])
|
| 377 |
return CapabilityFinding(
|
| 378 |
capability_id="credential_harvesting",
|
| 379 |
name="Credential Harvesting via WebView",
|
|
|
|
| 384 |
"usernames and passwords."
|
| 385 |
),
|
| 386 |
confidence=confidence,
|
| 387 |
+
evidence=evidence[:6],
|
| 388 |
mitre_id="T1417",
|
| 389 |
)
|
| 390 |
|
|
|
|
| 528 |
|
| 529 |
return indicators
|
| 530 |
|
| 531 |
+
def _extract_iocs(self, buf: bytes, network_indicators: list[NetworkIndicator]) -> IOCSet:
|
| 532 |
+
"""Categorize already-extracted network indicators plus independently
|
| 533 |
+
regex emails/crypto constants/Firebase markers β reuses the
|
| 534 |
+
domain/url/ip pass already done by _extract_network_indicators."""
|
| 535 |
+
domains = [i.value for i in network_indicators if i.type == "domain"][:_MAX_IOCS_PER_CATEGORY]
|
| 536 |
+
urls = [i.value for i in network_indicators if i.type == "url"][:_MAX_IOCS_PER_CATEGORY]
|
| 537 |
+
ips = [i.value for i in network_indicators if i.type == "ip"][:_MAX_IOCS_PER_CATEGORY]
|
| 538 |
+
|
| 539 |
+
emails = sorted({
|
| 540 |
+
m.group().decode("ascii", errors="ignore")
|
| 541 |
+
for m in _EMAIL_RE.finditer(buf)
|
| 542 |
+
})[:_MAX_IOCS_PER_CATEGORY]
|
| 543 |
+
|
| 544 |
+
crypto_constants = sorted({
|
| 545 |
+
m.group().decode("ascii", errors="ignore").upper()
|
| 546 |
+
for m in _CRYPTO_RE.finditer(buf)
|
| 547 |
+
})[:_MAX_IOCS_PER_CATEGORY]
|
| 548 |
+
|
| 549 |
+
firebase_urls = sorted({
|
| 550 |
+
url for url in (i.value for i in network_indicators if i.type == "url")
|
| 551 |
+
if any(marker in url.lower().encode() for marker in _FIREBASE_MARKERS)
|
| 552 |
+
})[:_MAX_IOCS_PER_CATEGORY]
|
| 553 |
+
|
| 554 |
+
return IOCSet(
|
| 555 |
+
domains=domains,
|
| 556 |
+
urls=urls,
|
| 557 |
+
ips=ips,
|
| 558 |
+
emails=emails,
|
| 559 |
+
firebase_urls=firebase_urls,
|
| 560 |
+
crypto_constants=crypto_constants,
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
def _find_suspicious_network(self, indicators: list[NetworkIndicator]) -> Optional[CapabilityFinding]:
|
| 564 |
flagged = [i for i in indicators if i.context]
|
| 565 |
if len(flagged) >= 2:
|
app/services/apk_intelligence/models.py
CHANGED
|
@@ -49,6 +49,13 @@ class ApkAnalysisRequest(BaseModel):
|
|
| 49 |
certificateHash: Optional[str] = Field(None, description="SHA-256 of the signing certificate (hex)")
|
| 50 |
permissions: List[str] = Field(default_factory=list, description="Permissions declared in the APK manifest")
|
| 51 |
localRisk: float = Field(0.0, ge=0.0, le=100.0, description="Local risk score (0β100) from the SDK")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
@field_validator("permissions", mode="before")
|
| 54 |
@classmethod
|
|
|
|
| 49 |
certificateHash: Optional[str] = Field(None, description="SHA-256 of the signing certificate (hex)")
|
| 50 |
permissions: List[str] = Field(default_factory=list, description="Permissions declared in the APK manifest")
|
| 51 |
localRisk: float = Field(0.0, ge=0.0, le=100.0, description="Local risk score (0β100) from the SDK")
|
| 52 |
+
installerPackageName: Optional[str] = Field(
|
| 53 |
+
None,
|
| 54 |
+
description="Package name of the app that installed this APK (e.g. com.android.vending "
|
| 55 |
+
"for Play Store), via PackageManager.getInstallSourceInfo/getInstallerPackageName. "
|
| 56 |
+
"Null for not-yet-installed files β Android only records install-source "
|
| 57 |
+
"attribution for already-installed packages.",
|
| 58 |
+
)
|
| 59 |
|
| 60 |
@field_validator("permissions", mode="before")
|
| 61 |
@classmethod
|
app/services/apk_intelligence/threat_explainer.py
CHANGED
|
@@ -910,8 +910,15 @@ class ThreatExplanationEngine:
|
|
| 910 |
|
| 911 |
re_result = r.reverse_engineering
|
| 912 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
remote_access = self._find_capability(re_result, "remote_access")
|
| 914 |
-
if remote_access:
|
| 915 |
mid, mname = _MITRE["remote_access"]
|
| 916 |
vectors.append(AttackVector(
|
| 917 |
name="Remote Access / Screen Streaming",
|
|
@@ -922,7 +929,7 @@ class ThreatExplanationEngine:
|
|
| 922 |
))
|
| 923 |
|
| 924 |
persistence = self._find_capability(re_result, "persistence")
|
| 925 |
-
if persistence:
|
| 926 |
mid, mname = _MITRE["persistence"]
|
| 927 |
vectors.append(AttackVector(
|
| 928 |
name="Persistence via Boot Receiver / Scheduled Tasks",
|
|
@@ -933,7 +940,7 @@ class ThreatExplanationEngine:
|
|
| 933 |
))
|
| 934 |
|
| 935 |
suspicious_network = self._find_capability(re_result, "suspicious_network")
|
| 936 |
-
if suspicious_network:
|
| 937 |
mid, mname = _MITRE["suspicious_network"]
|
| 938 |
vectors.append(AttackVector(
|
| 939 |
name="Command-and-Control Communication",
|
|
@@ -944,7 +951,7 @@ class ThreatExplanationEngine:
|
|
| 944 |
))
|
| 945 |
|
| 946 |
credential_harvesting = self._find_capability(re_result, "credential_harvesting")
|
| 947 |
-
if credential_harvesting:
|
| 948 |
mid, mname = _MITRE["credential_theft"]
|
| 949 |
vectors.append(AttackVector(
|
| 950 |
name="Credential Harvesting via WebView Injection",
|
|
@@ -955,7 +962,7 @@ class ThreatExplanationEngine:
|
|
| 955 |
))
|
| 956 |
|
| 957 |
native_code_hiding = self._find_capability(re_result, "native_code_hiding")
|
| 958 |
-
if native_code_hiding:
|
| 959 |
mid, mname = _MITRE["packer"]
|
| 960 |
vectors.append(AttackVector(
|
| 961 |
name="Native Code Obfuscation",
|
|
|
|
| 910 |
|
| 911 |
re_result = r.reverse_engineering
|
| 912 |
|
| 913 |
+
# Only escalate HIGH-confidence reverse-engineering findings into named
|
| 914 |
+
# attack-pattern vectors (and therefore the STIX/UI bundle). MEDIUM
|
| 915 |
+
# findings stay available on the raw scan result, but a single common
|
| 916 |
+
# API string (e.g. "loadurl", "jobscheduler") isn't enough corroboration
|
| 917 |
+
# to label an app with a MITRE attack pattern β this mirrors the
|
| 918 |
+
# HIGH-only gating already used in risk/fusion.py's _compute_confidence
|
| 919 |
+
# and in _classify_threat above.
|
| 920 |
remote_access = self._find_capability(re_result, "remote_access")
|
| 921 |
+
if remote_access and remote_access.confidence == "HIGH":
|
| 922 |
mid, mname = _MITRE["remote_access"]
|
| 923 |
vectors.append(AttackVector(
|
| 924 |
name="Remote Access / Screen Streaming",
|
|
|
|
| 929 |
))
|
| 930 |
|
| 931 |
persistence = self._find_capability(re_result, "persistence")
|
| 932 |
+
if persistence and persistence.confidence == "HIGH":
|
| 933 |
mid, mname = _MITRE["persistence"]
|
| 934 |
vectors.append(AttackVector(
|
| 935 |
name="Persistence via Boot Receiver / Scheduled Tasks",
|
|
|
|
| 940 |
))
|
| 941 |
|
| 942 |
suspicious_network = self._find_capability(re_result, "suspicious_network")
|
| 943 |
+
if suspicious_network and suspicious_network.confidence == "HIGH":
|
| 944 |
mid, mname = _MITRE["suspicious_network"]
|
| 945 |
vectors.append(AttackVector(
|
| 946 |
name="Command-and-Control Communication",
|
|
|
|
| 951 |
))
|
| 952 |
|
| 953 |
credential_harvesting = self._find_capability(re_result, "credential_harvesting")
|
| 954 |
+
if credential_harvesting and credential_harvesting.confidence == "HIGH":
|
| 955 |
mid, mname = _MITRE["credential_theft"]
|
| 956 |
vectors.append(AttackVector(
|
| 957 |
name="Credential Harvesting via WebView Injection",
|
|
|
|
| 962 |
))
|
| 963 |
|
| 964 |
native_code_hiding = self._find_capability(re_result, "native_code_hiding")
|
| 965 |
+
if native_code_hiding and native_code_hiding.confidence == "HIGH":
|
| 966 |
mid, mname = _MITRE["packer"]
|
| 967 |
vectors.append(AttackVector(
|
| 968 |
name="Native Code Obfuscation",
|
app/services/identity/bank_registry.py
CHANGED
|
@@ -158,3 +158,23 @@ def find_bank_by_display_name(display_name: str) -> Optional[BankAppProfile]:
|
|
| 158 |
def all_official_packages() -> set[str]:
|
| 159 |
"""Return the complete set of all known official bank package names."""
|
| 160 |
return set(_PACKAGE_TO_BANK.keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def all_official_packages() -> set[str]:
|
| 159 |
"""Return the complete set of all known official bank package names."""
|
| 160 |
return set(_PACKAGE_TO_BANK.keys())
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ββ Brand-keyword detection (coarser net than name-similarity/squatting) βββββββ
|
| 164 |
+
# Catches cases like "SBI Quick Pay Helper" that don't score high enough on
|
| 165 |
+
# difflib similarity against "YONO SBI" but still trade on a banking brand
|
| 166 |
+
# without being a recognized official banking app.
|
| 167 |
+
_BRAND_KEYWORDS: tuple[str, ...] = (
|
| 168 |
+
"sbi", "yono", "hdfc", "icici", "axis bank", "kotak",
|
| 169 |
+
"pnb", "bank of baroda", "canara bank", "union bank",
|
| 170 |
+
"indian bank", "kyc update", "kyc verification",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def find_brand_keyword(text: str) -> Optional[str]:
|
| 175 |
+
"""Return the matched banking-brand keyword if `text` contains one, else None."""
|
| 176 |
+
normalised = text.lower()
|
| 177 |
+
for keyword in _BRAND_KEYWORDS:
|
| 178 |
+
if keyword in normalised:
|
| 179 |
+
return keyword
|
| 180 |
+
return None
|
app/services/identity/trusted_publisher_registry.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
identity/trusted_publisher_registry.py
|
| 3 |
+
β Registry of well-known, non-banking app publishers and their signing certificates.
|
| 4 |
+
|
| 5 |
+
Used by risk/fusion.py to apply a trust discount when an APK's package name AND
|
| 6 |
+
signing certificate both match a known-good publisher. This is what stops a
|
| 7 |
+
signed Instagram/Spotify/Google Docs build β which legitimately holds SMS,
|
| 8 |
+
camera, contacts, and self-update capabilities β from being scored the same
|
| 9 |
+
way as an unsigned/unknown APK that holds the exact same capabilities.
|
| 10 |
+
|
| 11 |
+
This is a capability allowlist by *identity*, not by *behaviour*: a package+cert
|
| 12 |
+
match means "this exact build is the real, signed app", which is a much
|
| 13 |
+
stronger signal than any static capability heuristic can produce on its own.
|
| 14 |
+
|
| 15 |
+
cert_sha256 values below were captured directly from APKMirror-distributed
|
| 16 |
+
builds during testing β replace/expand with certs sourced from Play Console
|
| 17 |
+
or Play Integrity API attestation in production, the same caveat that already
|
| 18 |
+
applies to bank_registry.BANK_REGISTRY.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from enum import Enum
|
| 25 |
+
from typing import Optional
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TrustTier(str, Enum):
|
| 29 |
+
TRUSTED = "TRUSTED" # package + certificate both match a known-good publisher
|
| 30 |
+
NEUTRAL = "NEUTRAL" # no registry entry β neither vouched for nor flagged
|
| 31 |
+
UNKNOWN = "UNKNOWN" # package matches a known publisher but cert does not (possible clone)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class TrustedPublisherProfile:
|
| 36 |
+
"""Immutable profile for a well-known, legitimate (non-banking) app publisher."""
|
| 37 |
+
vendor: str
|
| 38 |
+
package: str
|
| 39 |
+
cert_sha256: tuple[str, ...] = field(default_factory=tuple) # hex, lowercase; multiple = signing rotation
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
TRUSTED_PUBLISHERS: list[TrustedPublisherProfile] = [
|
| 43 |
+
TrustedPublisherProfile(
|
| 44 |
+
vendor="Meta",
|
| 45 |
+
package="com.instagram.android",
|
| 46 |
+
cert_sha256=("5f3e50f435583c9ae626302a71f7340044087a7e2c60adacfc254205a993e305",),
|
| 47 |
+
),
|
| 48 |
+
TrustedPublisherProfile(
|
| 49 |
+
vendor="Meta",
|
| 50 |
+
package="com.whatsapp",
|
| 51 |
+
cert_sha256=("3987d043d10aefaf5a8710b3671418fe57e0e19b653c9df82558feb5ffce5d44",),
|
| 52 |
+
),
|
| 53 |
+
TrustedPublisherProfile(
|
| 54 |
+
vendor="Spotify",
|
| 55 |
+
package="com.spotify.music",
|
| 56 |
+
cert_sha256=("6505b181933344f93893d586e399b94616183f04349cb572a9e81a3335e28ffd",),
|
| 57 |
+
),
|
| 58 |
+
TrustedPublisherProfile(
|
| 59 |
+
vendor="Google",
|
| 60 |
+
package="com.google.android.apps.docs.editors.docs",
|
| 61 |
+
cert_sha256=("3d7a1223019aa39d9ea0e3436ab7c0896bfb4fb679f4de5fe7c23f326c8f994a",),
|
| 62 |
+
),
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
_PACKAGE_TO_PUBLISHER: dict[str, TrustedPublisherProfile] = {}
|
| 66 |
+
_CERT_TO_PUBLISHER: dict[str, TrustedPublisherProfile] = {}
|
| 67 |
+
|
| 68 |
+
for _profile in TRUSTED_PUBLISHERS:
|
| 69 |
+
_PACKAGE_TO_PUBLISHER[_profile.package.lower()] = _profile
|
| 70 |
+
for _cert in _profile.cert_sha256:
|
| 71 |
+
_CERT_TO_PUBLISHER[_cert.lower()] = _profile
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_trust_tier(package_name: str, certificate_hash: Optional[str]) -> tuple[TrustTier, Optional[str]]:
|
| 75 |
+
"""
|
| 76 |
+
Resolve the trust tier for a package + certificate pair.
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
(tier, vendor) β vendor is the matched publisher name, or None if no match.
|
| 80 |
+
"""
|
| 81 |
+
profile = _PACKAGE_TO_PUBLISHER.get(package_name.lower())
|
| 82 |
+
if profile is None:
|
| 83 |
+
return TrustTier.NEUTRAL, None
|
| 84 |
+
|
| 85 |
+
if not certificate_hash:
|
| 86 |
+
# Known package, but we have no cert to verify against β can't vouch for it
|
| 87 |
+
return TrustTier.NEUTRAL, None
|
| 88 |
+
|
| 89 |
+
if certificate_hash.lower() in (c.lower() for c in profile.cert_sha256):
|
| 90 |
+
return TrustTier.TRUSTED, profile.vendor
|
| 91 |
+
|
| 92 |
+
# Package name matches a known publisher but the certificate doesn't β
|
| 93 |
+
# this is exactly the clone-attack pattern bank_registry watches for.
|
| 94 |
+
return TrustTier.UNKNOWN, profile.vendor
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ββ Installer-source signal βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
# A package+cert allowlist only ever covers the handful of apps someone has
|
| 99 |
+
# manually verified β it can't scale to "any unknown legitimate app". The
|
| 100 |
+
# installer attribution (PackageManager.getInstallSourceInfo /
|
| 101 |
+
# getInstallerPackageName on the Android side) is identity-agnostic: it tells
|
| 102 |
+
# you the app went through a store's review process without needing to know
|
| 103 |
+
# which app it is in advance. It's deliberately a *weaker* discount than a
|
| 104 |
+
# verified cert match below β store review isn't airtight (malware has
|
| 105 |
+
# reached the Play Store before), so this should never outrank a confirmed
|
| 106 |
+
# bank-impersonation/brand-keyword/cert-mismatch finding. See risk/fusion.py
|
| 107 |
+
# for how the two are layered.
|
| 108 |
+
KNOWN_APP_STORE_INSTALLERS: frozenset[str] = frozenset({
|
| 109 |
+
"com.android.vending", # Google Play Store
|
| 110 |
+
"com.google.android.packageinstaller", # Google Play, some OEM/AOSP paths
|
| 111 |
+
"com.android.packageinstaller", # AOSP package installer
|
| 112 |
+
"com.sec.android.app.samsungapps", # Samsung Galaxy Store
|
| 113 |
+
"com.amazon.venezia", # Amazon Appstore
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def is_known_app_store(installer_package_name: Optional[str]) -> bool:
|
| 118 |
+
"""True if `installer_package_name` is a recognized official app store.
|
| 119 |
+
|
| 120 |
+
Note: this is almost always unset for the "scan a freshly downloaded APK
|
| 121 |
+
before the user installs it" flow β Android only records install-source
|
| 122 |
+
attribution for already-installed packages, so this signal only becomes
|
| 123 |
+
available on a later re-scan or when re-checking an existing app's update.
|
| 124 |
+
"""
|
| 125 |
+
return bool(installer_package_name) and installer_package_name in KNOWN_APP_STORE_INSTALLERS
|
app/services/risk/fusion.py
CHANGED
|
@@ -31,10 +31,17 @@ from app.services.apk_intelligence.models import (
|
|
| 31 |
EngineResults,
|
| 32 |
ImpersonationResult,
|
| 33 |
MLResult,
|
|
|
|
| 34 |
ThreatIntelligenceResponse,
|
| 35 |
Verdict,
|
| 36 |
YaraResult,
|
| 37 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
from app.services.risk.escalation import EscalationResult, detect_escalation
|
| 39 |
|
| 40 |
logger = logging.getLogger(__name__)
|
|
@@ -45,6 +52,23 @@ _WEIGHT_YARA = 0.25
|
|
| 45 |
_WEIGHT_ANDROGUARD = 0.15
|
| 46 |
_WEIGHT_IMPERSONATION = 0.10
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
# ββ Confidence model constants βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
_CONFIDENCE_BASE = 0.5
|
| 50 |
_CONFIDENCE_PER_CATEGORY = 0.08
|
|
@@ -62,14 +86,23 @@ def fuse_results(
|
|
| 62 |
engine_results: EngineResults,
|
| 63 |
local_risk: float,
|
| 64 |
package_name: str,
|
|
|
|
|
|
|
|
|
|
| 65 |
) -> ThreatIntelligenceResponse:
|
| 66 |
"""
|
| 67 |
Fuse all engine results into a final ThreatIntelligenceResponse.
|
| 68 |
|
| 69 |
Args:
|
| 70 |
-
engine_results:
|
| 71 |
-
local_risk:
|
| 72 |
-
package_name:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
Returns:
|
| 75 |
ThreatIntelligenceResponse matching the Android SDK wire format.
|
|
@@ -78,8 +111,23 @@ def fuse_results(
|
|
| 78 |
fused_score = 0.0
|
| 79 |
|
| 80 |
# ββ 1. ML contribution ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
ml = engine_results.ml
|
| 82 |
ml_score = _score_ml(ml, explanations)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
fused_score += ml_score * _WEIGHT_ML
|
| 84 |
|
| 85 |
# ββ 2. YARA contribution ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -108,12 +156,43 @@ def fuse_results(
|
|
| 108 |
explanations.append(f"Android SDK local risk score is high ({local_risk:.0f}/100).")
|
| 109 |
fused_score = min(fused_score + local_boost, 1.0)
|
| 110 |
|
| 111 |
-
# ββ 7.
|
| 112 |
-
#
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
fused_score = max(fused_score, 0.45)
|
| 115 |
|
| 116 |
-
# Confirmed impersonation β always MALICIOUS
|
| 117 |
if imp.is_impersonating and imp.similarity_score >= 0.90:
|
| 118 |
fused_score = max(fused_score, 0.80)
|
| 119 |
explanations.append(
|
|
@@ -121,7 +200,29 @@ def fuse_results(
|
|
| 121 |
f"(target: {imp.impersonated_bank}, score: {imp.similarity_score:.0%})."
|
| 122 |
)
|
| 123 |
|
| 124 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
from app.config import get_settings
|
| 126 |
settings = get_settings()
|
| 127 |
|
|
@@ -138,34 +239,72 @@ def fuse_results(
|
|
| 138 |
explanations.insert(0, f"Fused risk score: {fused_score:.3f} (verdict: {verdict.value}).")
|
| 139 |
|
| 140 |
logger.info(
|
| 141 |
-
"Risk fusion for %s β fused=%.3f, verdict=%s, yara=%s, ml_prob=%.3f",
|
| 142 |
package_name,
|
| 143 |
fused_score,
|
| 144 |
verdict.value,
|
|
|
|
| 145 |
yara.rule_names,
|
| 146 |
ml.malware_probability,
|
| 147 |
)
|
| 148 |
|
| 149 |
-
# ββ
|
| 150 |
escalation = detect_escalation(engine_results)
|
| 151 |
|
| 152 |
-
# ββ
|
| 153 |
confidence_score = _compute_confidence(engine_results, fused_score, verdict, escalation)
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
return ThreatIntelligenceResponse(
|
| 156 |
verdict=verdict,
|
| 157 |
malware_probability=round(fused_score, 4),
|
| 158 |
yara_matches=yara.rule_names,
|
| 159 |
-
threat_family=
|
| 160 |
explanations=explanations,
|
| 161 |
confidence_score=confidence_score,
|
| 162 |
escalation=escalation.escalate,
|
| 163 |
escalation_reasons=escalation.reasons,
|
|
|
|
| 164 |
)
|
| 165 |
|
| 166 |
|
| 167 |
# ββ Per-engine scorers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
def _score_ml(ml: MLResult, explanations: list[str]) -> float:
|
| 170 |
if ml.error:
|
| 171 |
explanations.append(f"ML engine error: {ml.error}")
|
|
@@ -195,10 +334,17 @@ def _score_androguard(a: AndroguardResult, explanations: list[str]) -> float:
|
|
| 195 |
return 0.0
|
| 196 |
score = 0.0
|
| 197 |
if a.is_obfuscated:
|
| 198 |
-
|
|
|
|
|
|
|
| 199 |
explanations.append("APK appears to be obfuscated.")
|
| 200 |
if a.dynamic_code_loading:
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
explanations.append("Dynamic code loading detected (DexClassLoader pattern).")
|
| 203 |
for act in a.suspicious_activities:
|
| 204 |
score += 0.15
|
|
@@ -226,10 +372,17 @@ def _score_apkid(apkid: APKidResult, explanations: list[str]) -> float:
|
|
| 226 |
names = ", ".join(apkid.packers)
|
| 227 |
explanations.append(f"APKiD: packer(s) detected β {names}.")
|
| 228 |
boost += 0.08
|
| 229 |
-
if apkid.anti_analysis:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
techniques = ", ".join(apkid.anti_analysis[:4])
|
| 231 |
-
explanations.append(f"APKiD: anti-analysis
|
| 232 |
boost += 0.05
|
|
|
|
|
|
|
|
|
|
| 233 |
if len(apkid.obfuscators) > 1:
|
| 234 |
explanations.append(f"APKiD: multiple obfuscators detected ({len(apkid.obfuscators)}).")
|
| 235 |
boost += 0.02
|
|
|
|
| 31 |
EngineResults,
|
| 32 |
ImpersonationResult,
|
| 33 |
MLResult,
|
| 34 |
+
ReputationInfo,
|
| 35 |
ThreatIntelligenceResponse,
|
| 36 |
Verdict,
|
| 37 |
YaraResult,
|
| 38 |
)
|
| 39 |
+
from app.services.identity.bank_registry import find_brand_keyword, is_official_package
|
| 40 |
+
from app.services.identity.trusted_publisher_registry import (
|
| 41 |
+
TrustTier,
|
| 42 |
+
get_trust_tier,
|
| 43 |
+
is_known_app_store,
|
| 44 |
+
)
|
| 45 |
from app.services.risk.escalation import EscalationResult, detect_escalation
|
| 46 |
|
| 47 |
logger = logging.getLogger(__name__)
|
|
|
|
| 52 |
_WEIGHT_ANDROGUARD = 0.15
|
| 53 |
_WEIGHT_IMPERSONATION = 0.10
|
| 54 |
|
| 55 |
+
# ββ Identity-layer constants βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
# A verified package+cert match against a known-good publisher is a much
|
| 57 |
+
# stronger signal than any static capability heuristic β capabilities like
|
| 58 |
+
# SMS access, camera+location+contacts, or a self-update permission are
|
| 59 |
+
# *expected* in a signed build of a real app and shouldn't be scored the same
|
| 60 |
+
# way they would be for an unsigned/unknown APK exhibiting the same behaviour.
|
| 61 |
+
_TRUSTED_PUBLISHER_DISCOUNT = 0.25 # multiply fused_score by this when TRUSTED
|
| 62 |
+
# Weaker than the cert-match discount above: passing app-store review is real
|
| 63 |
+
# but not airtight identity verification (malware has reached the Play Store
|
| 64 |
+
# before), so this must never be allowed to outrank a confirmed
|
| 65 |
+
# impersonation/brand-keyword/cert-mismatch finding β see ordering in
|
| 66 |
+
# fuse_results, where hard floors are applied via max() *after* this discount.
|
| 67 |
+
_KNOWN_STORE_INSTALL_DISCOUNT = 0.6
|
| 68 |
+
_PUBLISHER_CERT_MISMATCH_FLOOR = 0.55 # known publisher package, wrong cert β likely clone
|
| 69 |
+
_BRAND_KEYWORD_WITHOUT_MATCH_FLOOR = 0.75 # banking brand name, not an official bank app
|
| 70 |
+
_UNCORROBORATED_ML_CAP = 0.35 # see "1. ML contribution" below
|
| 71 |
+
|
| 72 |
# ββ Confidence model constants βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
_CONFIDENCE_BASE = 0.5
|
| 74 |
_CONFIDENCE_PER_CATEGORY = 0.08
|
|
|
|
| 86 |
engine_results: EngineResults,
|
| 87 |
local_risk: float,
|
| 88 |
package_name: str,
|
| 89 |
+
certificate_hash: Optional[str] = None,
|
| 90 |
+
app_name: Optional[str] = None,
|
| 91 |
+
installer_package_name: Optional[str] = None,
|
| 92 |
) -> ThreatIntelligenceResponse:
|
| 93 |
"""
|
| 94 |
Fuse all engine results into a final ThreatIntelligenceResponse.
|
| 95 |
|
| 96 |
Args:
|
| 97 |
+
engine_results: Aggregated outputs from all analysis engines.
|
| 98 |
+
local_risk: Local SDK risk score (0β100).
|
| 99 |
+
package_name: APK package name (for logging and identity checks).
|
| 100 |
+
certificate_hash: SHA-256 of the signing certificate, used to resolve
|
| 101 |
+
publisher/bank identity trust (see identity/ registries).
|
| 102 |
+
app_name: Human-readable app label, used for brand-keyword checks.
|
| 103 |
+
installer_package_name: Package name of the app that installed this APK (e.g.
|
| 104 |
+
com.android.vending for Play Store). Usually None for
|
| 105 |
+
not-yet-installed files β see trusted_publisher_registry.
|
| 106 |
|
| 107 |
Returns:
|
| 108 |
ThreatIntelligenceResponse matching the Android SDK wire format.
|
|
|
|
| 111 |
fused_score = 0.0
|
| 112 |
|
| 113 |
# ββ 1. ML contribution ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
# Interim mitigation for a known model-calibration issue: apk_classifier.pkl
|
| 115 |
+
# was trained on a heavily imbalanced dataset (10,110 malware vs. 1,795 benign
|
| 116 |
+
# samples) and outputs 90%+ malware probability even on near-empty feature
|
| 117 |
+
# vectors for ordinary unsigned apps with no real risk signal β confirmed on
|
| 118 |
+
# two known-benign hackathon-project APKs with no SMS/overlay/accessibility
|
| 119 |
+
# permissions and no YARA matches. Until the model is retrained on a better-
|
| 120 |
+
# balanced dataset, an uncorroborated ML score (no other engine agrees
|
| 121 |
+
# there's anything to see) gets capped rather than trusted at full weight.
|
| 122 |
ml = engine_results.ml
|
| 123 |
ml_score = _score_ml(ml, explanations)
|
| 124 |
+
if not _has_corroborating_signal(engine_results) and ml_score > _UNCORROBORATED_ML_CAP:
|
| 125 |
+
explanations.append(
|
| 126 |
+
f"ML model probability ({ml_score:.0%}) is not corroborated by any other "
|
| 127 |
+
f"engine β capping its contribution in the fused score (known model "
|
| 128 |
+
f"calibration issue, see fusion.py)."
|
| 129 |
+
)
|
| 130 |
+
ml_score = _UNCORROBORATED_ML_CAP
|
| 131 |
fused_score += ml_score * _WEIGHT_ML
|
| 132 |
|
| 133 |
# ββ 2. YARA contribution ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 156 |
explanations.append(f"Android SDK local risk score is high ({local_risk:.0f}/100).")
|
| 157 |
fused_score = min(fused_score + local_boost, 1.0)
|
| 158 |
|
| 159 |
+
# ββ 7. Identity layer β certificate/publisher/installer trust βββββββββββββ
|
| 160 |
+
# Resolved before the hard overrides below. A verified package+cert match
|
| 161 |
+
# means this exact build is the real, signed app; a known-app-store
|
| 162 |
+
# installer is weaker (review isn't airtight) but identity-agnostic β it
|
| 163 |
+
# scales to any app, not just ones manually added to the publisher registry.
|
| 164 |
+
trust_tier, trusted_vendor = get_trust_tier(package_name, certificate_hash)
|
| 165 |
+
store_installed = trust_tier != TrustTier.TRUSTED and is_known_app_store(installer_package_name)
|
| 166 |
+
|
| 167 |
+
# Apply identity-based discounts to the capability-weighted score itself β
|
| 168 |
+
# deliberately *before* the hard floors below, so neither discount can ever
|
| 169 |
+
# dilute a confirmed clone/impersonation/brand-keyword finding (those are
|
| 170 |
+
# applied via max() afterward and always win, regardless of any discount
|
| 171 |
+
# applied here).
|
| 172 |
+
if trust_tier == TrustTier.TRUSTED:
|
| 173 |
+
pre_discount_score = fused_score
|
| 174 |
+
fused_score = round(fused_score * _TRUSTED_PUBLISHER_DISCOUNT, 4)
|
| 175 |
+
explanations.append(
|
| 176 |
+
f"Certificate matches verified publisher '{trusted_vendor}' β "
|
| 177 |
+
f"applying trust discount (raw score {pre_discount_score:.3f} β {fused_score:.3f})."
|
| 178 |
+
)
|
| 179 |
+
elif store_installed:
|
| 180 |
+
pre_discount_score = fused_score
|
| 181 |
+
fused_score = round(fused_score * _KNOWN_STORE_INSTALL_DISCOUNT, 4)
|
| 182 |
+
explanations.append(
|
| 183 |
+
f"Installed via a recognized app store (installer={installer_package_name}) β "
|
| 184 |
+
f"applying a moderate trust discount (raw score {pre_discount_score:.3f} β {fused_score:.3f})."
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# ββ 8. Hard overrides (always win β applied via max() after any discount) β
|
| 188 |
+
# YARA match β at least SUSPICIOUS, *unless* the publisher is verified trusted
|
| 189 |
+
# or the app came through a known store (a signed Instagram/Spotify build, or
|
| 190 |
+
# any Play-Store-installed app, matching a capability-only YARA rule is
|
| 191 |
+
# expected; an unknown, unattributed APK doing the same is not).
|
| 192 |
+
if yara.rule_names and trust_tier != TrustTier.TRUSTED and not store_installed:
|
| 193 |
fused_score = max(fused_score, 0.45)
|
| 194 |
|
| 195 |
+
# Confirmed banking-app impersonation β always MALICIOUS
|
| 196 |
if imp.is_impersonating and imp.similarity_score >= 0.90:
|
| 197 |
fused_score = max(fused_score, 0.80)
|
| 198 |
explanations.append(
|
|
|
|
| 200 |
f"(target: {imp.impersonated_bank}, score: {imp.similarity_score:.0%})."
|
| 201 |
)
|
| 202 |
|
| 203 |
+
# Package name matches a known publisher (banking or otherwise) but the
|
| 204 |
+
# certificate doesn't β classic clone/repackage pattern, independent of
|
| 205 |
+
# whether the impersonation engine's name/package-similarity checks fired.
|
| 206 |
+
if trust_tier == TrustTier.UNKNOWN:
|
| 207 |
+
fused_score = max(fused_score, _PUBLISHER_CERT_MISMATCH_FLOOR)
|
| 208 |
+
explanations.append(
|
| 209 |
+
f"Package name matches known publisher '{trusted_vendor}' but the signing "
|
| 210 |
+
f"certificate does not β possible clone or repackaged app."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Banking-brand keyword in the package/app name without being a recognized
|
| 214 |
+
# official banking app β catches near-miss fakes that don't score high
|
| 215 |
+
# enough on name-similarity to trip the dedicated impersonation check.
|
| 216 |
+
if not imp.is_impersonating and not is_official_package(package_name):
|
| 217 |
+
keyword = find_brand_keyword(f"{package_name} {app_name or ''}")
|
| 218 |
+
if keyword:
|
| 219 |
+
fused_score = max(fused_score, _BRAND_KEYWORD_WITHOUT_MATCH_FLOOR)
|
| 220 |
+
explanations.append(
|
| 221 |
+
f"App name/package references banking brand '{keyword}' but is not a "
|
| 222 |
+
f"recognized official banking app β high risk of being a fake banking clone."
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# ββ 9. Verdict determination ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 226 |
from app.config import get_settings
|
| 227 |
settings = get_settings()
|
| 228 |
|
|
|
|
| 239 |
explanations.insert(0, f"Fused risk score: {fused_score:.3f} (verdict: {verdict.value}).")
|
| 240 |
|
| 241 |
logger.info(
|
| 242 |
+
"Risk fusion for %s β fused=%.3f, verdict=%s, trust=%s, yara=%s, ml_prob=%.3f",
|
| 243 |
package_name,
|
| 244 |
fused_score,
|
| 245 |
verdict.value,
|
| 246 |
+
trust_tier.value,
|
| 247 |
yara.rule_names,
|
| 248 |
ml.malware_probability,
|
| 249 |
)
|
| 250 |
|
| 251 |
+
# ββ 10. Escalation flag βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 252 |
escalation = detect_escalation(engine_results)
|
| 253 |
|
| 254 |
+
# ββ 11. Numeric confidence score ββββββββββββββββββββββββββββββββββββββββββ
|
| 255 |
confidence_score = _compute_confidence(engine_results, fused_score, verdict, escalation)
|
| 256 |
|
| 257 |
+
# ββ 12. Reputation summary (wire field) βββββββββββββββββββββββββββββββββββ
|
| 258 |
+
reputation = ReputationInfo(
|
| 259 |
+
known_samples=0,
|
| 260 |
+
certificate_trust=(
|
| 261 |
+
"TRUSTED" if trust_tier == TrustTier.TRUSTED
|
| 262 |
+
else "SUSPICIOUS" if trust_tier == TrustTier.UNKNOWN
|
| 263 |
+
else "NEUTRAL"
|
| 264 |
+
),
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# A CLEAN verdict shouldn't carry a threat family label β once the trust
|
| 268 |
+
# discount (or any other override) has cleared the app, naming a "family"
|
| 269 |
+
# from suppressed static findings would contradict the verdict shown.
|
| 270 |
+
threat_family = (
|
| 271 |
+
None if verdict == Verdict.CLEAN
|
| 272 |
+
else ml.threat_family or _infer_threat_family(engine_results)
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
return ThreatIntelligenceResponse(
|
| 276 |
verdict=verdict,
|
| 277 |
malware_probability=round(fused_score, 4),
|
| 278 |
yara_matches=yara.rule_names,
|
| 279 |
+
threat_family=threat_family,
|
| 280 |
explanations=explanations,
|
| 281 |
confidence_score=confidence_score,
|
| 282 |
escalation=escalation.escalate,
|
| 283 |
escalation_reasons=escalation.reasons,
|
| 284 |
+
reputation=reputation,
|
| 285 |
)
|
| 286 |
|
| 287 |
|
| 288 |
# ββ Per-engine scorers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 289 |
|
| 290 |
+
def _has_corroborating_signal(r: EngineResults) -> bool:
|
| 291 |
+
"""True if at least one *reliable* engine independently flags risk β
|
| 292 |
+
manifest-confirmed security features, an actual YARA rule match, confirmed
|
| 293 |
+
impersonation, a real evasion suite, or a high-confidence capability finding.
|
| 294 |
+
Used to gate the ML cap above: these are all signals derived from real
|
| 295 |
+
parsed manifest data or multi-signature corroboration, not single bare
|
| 296 |
+
string hits, so they're trustworthy enough to vouch for an ML score."""
|
| 297 |
+
sf = r.androguard.security_features
|
| 298 |
+
return bool(
|
| 299 |
+
r.yara.rule_names
|
| 300 |
+
or r.impersonation.is_impersonating
|
| 301 |
+
or (sf and (sf.sms_access or sf.overlay or sf.accessibility_service))
|
| 302 |
+
or r.apkid.packers
|
| 303 |
+
or len(r.apkid.anti_analysis) > 1
|
| 304 |
+
or any(c.confidence == "HIGH" for c in r.reverse_engineering.capabilities)
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
def _score_ml(ml: MLResult, explanations: list[str]) -> float:
|
| 309 |
if ml.error:
|
| 310 |
explanations.append(f"ML engine error: {ml.error}")
|
|
|
|
| 334 |
return 0.0
|
| 335 |
score = 0.0
|
| 336 |
if a.is_obfuscated:
|
| 337 |
+
# R8/ProGuard minification is the default for virtually all production
|
| 338 |
+
# release builds β name mangling alone isn't a meaningful malice signal.
|
| 339 |
+
score += 0.10
|
| 340 |
explanations.append("APK appears to be obfuscated.")
|
| 341 |
if a.dynamic_code_loading:
|
| 342 |
+
# DexClassLoader usage on its own is a feature-delivery/plugin pattern used
|
| 343 |
+
# by many large legitimate apps (Play Feature Delivery, A/B experiments) β
|
| 344 |
+
# not inherently malicious. It's a mild signal here; the stronger escalation
|
| 345 |
+
# path is YARA's TROJAN_DROPPER rule, which requires it paired with an
|
| 346 |
+
# actual silent-install permission before treating it as a dropper chain.
|
| 347 |
+
score += 0.10
|
| 348 |
explanations.append("Dynamic code loading detected (DexClassLoader pattern).")
|
| 349 |
for act in a.suspicious_activities:
|
| 350 |
score += 0.15
|
|
|
|
| 372 |
names = ", ".join(apkid.packers)
|
| 373 |
explanations.append(f"APKiD: packer(s) detected β {names}.")
|
| 374 |
boost += 0.08
|
| 375 |
+
if len(apkid.anti_analysis) > 1:
|
| 376 |
+
# Multiple distinct evasion categories together (anti-emulator + anti-debug +
|
| 377 |
+
# anti-frida etc.) is the real evasion-suite signal. A single category alone
|
| 378 |
+
# (e.g. one debugger-state check used for crash-reporting telemetry) is common
|
| 379 |
+
# in legitimate hardened release builds and gets a much smaller bump below.
|
| 380 |
techniques = ", ".join(apkid.anti_analysis[:4])
|
| 381 |
+
explanations.append(f"APKiD: anti-analysis evasion suite detected β {techniques}.")
|
| 382 |
boost += 0.05
|
| 383 |
+
elif apkid.anti_analysis:
|
| 384 |
+
explanations.append(f"APKiD: anti-analysis technique detected β {apkid.anti_analysis[0]}.")
|
| 385 |
+
boost += 0.01
|
| 386 |
if len(apkid.obfuscators) > 1:
|
| 387 |
explanations.append(f"APKiD: multiple obfuscators detected ({len(apkid.obfuscators)}).")
|
| 388 |
boost += 0.02
|
rules/android_banker.yar
CHANGED
|
@@ -18,16 +18,20 @@ rule BANKING_TROJAN
|
|
| 18 |
|
| 19 |
strings:
|
| 20 |
// ββ Known banking trojan family identifiers ββββββββββββββββββββββββββ
|
| 21 |
-
// These strings appear in DEX string pools or embedded asset paths
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
$
|
| 27 |
-
$
|
| 28 |
-
$
|
| 29 |
-
$
|
| 30 |
-
$
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
// ββ Credential harvesting field names in layout XML or DEX βββββββββββ
|
| 33 |
$cred_card = "cardNumber" ascii
|
|
@@ -38,9 +42,7 @@ rule BANKING_TROJAN
|
|
| 38 |
$cred_pass_edit = "editPassword" ascii
|
| 39 |
$cred_pass_hint = "passwordHint" ascii
|
| 40 |
|
| 41 |
-
// ββ Overlay / screen-draw
|
| 42 |
-
// wide catches UTF-16 in binary AXML manifest; ascii catches DEX string pool
|
| 43 |
-
$overlay_perm = "SYSTEM_ALERT_WINDOW" ascii wide
|
| 44 |
$overlay_type = "TYPE_APPLICATION_OVERLAY" ascii
|
| 45 |
$overlay_draw = "drawOverApps" ascii
|
| 46 |
|
|
@@ -53,11 +55,17 @@ rule BANKING_TROJAN
|
|
| 53 |
// Definitive family string hit
|
| 54 |
1 of ($fam_*)
|
| 55 |
or
|
| 56 |
-
// Credential field cluster β genuine banking trojans harvest many fields
|
| 57 |
-
3
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
or
|
| 59 |
-
//
|
| 60 |
-
|
|
|
|
|
|
|
| 61 |
or
|
| 62 |
// Explicit fake-login string
|
| 63 |
1 of ($fake_login*)
|
|
|
|
| 18 |
|
| 19 |
strings:
|
| 20 |
// ββ Known banking trojan family identifiers ββββββββββββββββββββββββββ
|
| 21 |
+
// These strings appear in DEX string pools or embedded asset paths.
|
| 22 |
+
// Bounded with a non-letter lookalike on both sides β plain substring
|
| 23 |
+
// matching false-positived on unrelated identifiers that merely contain
|
| 24 |
+
// the token mid-word, e.g. "eventbot" inside Instagram/Spotify's
|
| 25 |
+
// "...DirectEventBottomSheetFragment..." UI class names.
|
| 26 |
+
$fam_bankbot = /[^a-zA-Z]bankbot[^a-zA-Z]/ ascii nocase
|
| 27 |
+
$fam_cerberus = /[^a-zA-Z]cerberus[^a-zA-Z]/ ascii nocase
|
| 28 |
+
$fam_flubot = /[^a-zA-Z]flubot[^a-zA-Z]/ ascii nocase
|
| 29 |
+
$fam_sharkbot = /[^a-zA-Z]sharkbot[^a-zA-Z]/ ascii nocase
|
| 30 |
+
$fam_eventbot = /[^a-zA-Z]eventbot[^a-zA-Z]/ ascii nocase
|
| 31 |
+
$fam_anubis = /[^a-zA-Z]anubis_c2[^a-zA-Z]/ ascii nocase
|
| 32 |
+
$fam_godfather = /[^a-zA-Z]godfather[^a-zA-Z]/ ascii nocase
|
| 33 |
+
$fam_octo = /[^a-zA-Z]octo\.panel[^a-zA-Z]/ ascii nocase
|
| 34 |
+
$fam_joker = /[^a-zA-Z]joker\.payload[^a-zA-Z]/ ascii nocase
|
| 35 |
|
| 36 |
// ββ Credential harvesting field names in layout XML or DEX βββββββββββ
|
| 37 |
$cred_card = "cardNumber" ascii
|
|
|
|
| 42 |
$cred_pass_edit = "editPassword" ascii
|
| 43 |
$cred_pass_hint = "passwordHint" ascii
|
| 44 |
|
| 45 |
+
// ββ Overlay / screen-draw behavior ββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
| 46 |
$overlay_type = "TYPE_APPLICATION_OVERLAY" ascii
|
| 47 |
$overlay_draw = "drawOverApps" ascii
|
| 48 |
|
|
|
|
| 55 |
// Definitive family string hit
|
| 56 |
1 of ($fam_*)
|
| 57 |
or
|
| 58 |
+
// Credential field cluster β genuine banking trojans harvest many fields in one
|
| 59 |
+
// fake-login form. 3 distinct field names was satisfiable by an app that simply
|
| 60 |
+
// has a checkout form (cardNumber, expiryDate) *and*, completely unrelated, a
|
| 61 |
+
// separate login form (etPassword) elsewhere β not necessarily one coordinated
|
| 62 |
+
// overlay. Require 4 to make incidental cross-feature overlap far less likely.
|
| 63 |
+
4 of ($cred_*)
|
| 64 |
or
|
| 65 |
+
// Actual overlay-window creation (not just the bare SYSTEM_ALERT_WINDOW
|
| 66 |
+
// permission, which any chat-head/floating-widget/PiP app legitimately
|
| 67 |
+
// declares) paired with a credential field
|
| 68 |
+
(1 of ($overlay_type, $overlay_draw) and 1 of ($cred_*))
|
| 69 |
or
|
| 70 |
// Explicit fake-login string
|
| 71 |
1 of ($fake_login*)
|
rules/otp_stealer.yar
CHANGED
|
@@ -17,11 +17,17 @@ rule OTP_STEALER
|
|
| 17 |
date = "2024-01-01"
|
| 18 |
|
| 19 |
strings:
|
| 20 |
-
//
|
| 21 |
-
//
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
// Android SMS API class and content-provider URI (DEX string pool, ASCII)
|
| 27 |
$api_class = "SmsMessage" ascii
|
|
@@ -39,15 +45,20 @@ rule OTP_STEALER
|
|
| 39 |
$kw_otp4 = "verificationCode" ascii
|
| 40 |
|
| 41 |
condition:
|
| 42 |
-
// Two or more SMS
|
| 43 |
(2 of ($p_*))
|
| 44 |
or
|
| 45 |
-
// SMS API usage combined with
|
| 46 |
(1 of ($api_*) and 1 of ($p_*))
|
| 47 |
or
|
| 48 |
-
//
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
or
|
| 51 |
-
// Pure DEX: two SMS API strings β sufficient without permission match
|
|
|
|
|
|
|
|
|
|
| 52 |
(2 of ($api_*, $smali_sms))
|
| 53 |
}
|
|
|
|
| 17 |
date = "2024-01-01"
|
| 18 |
|
| 19 |
strings:
|
| 20 |
+
// Manifest-DECLARED permissions only β <uses-permission> entries are stored
|
| 21 |
+
// as UTF-16 LE in the binary AXML manifest, so wide-only reliably means "the
|
| 22 |
+
// app's manifest actually requests this." Deliberately *not* ascii: an ascii
|
| 23 |
+
// hit for "READ_SMS" is just as likely a bundled cross-platform permission
|
| 24 |
+
// library (e.g. Flutter's permission_handler ships every Android permission
|
| 25 |
+
// name as a constant) referencing the string without the app ever declaring
|
| 26 |
+
// or using it β confirmed false-positive on a real hackathon Flutter app
|
| 27 |
+
// with zero SMS permissions in its actual manifest.
|
| 28 |
+
$p_read = "READ_SMS" wide
|
| 29 |
+
$p_receive = "RECEIVE_SMS" wide
|
| 30 |
+
$p_send = "SEND_SMS" wide
|
| 31 |
|
| 32 |
// Android SMS API class and content-provider URI (DEX string pool, ASCII)
|
| 33 |
$api_class = "SmsMessage" ascii
|
|
|
|
| 45 |
$kw_otp4 = "verificationCode" ascii
|
| 46 |
|
| 47 |
condition:
|
| 48 |
+
// Two or more manifest-declared SMS permissions
|
| 49 |
(2 of ($p_*))
|
| 50 |
or
|
| 51 |
+
// SMS API usage combined with an actual manifest-declared permission
|
| 52 |
(1 of ($api_*) and 1 of ($p_*))
|
| 53 |
or
|
| 54 |
+
// OTP keyword combined with actual SMS API usage, not just a declared
|
| 55 |
+
// permission β a bare "otp" substring plus a single SMS permission is
|
| 56 |
+
// satisfied by any ordinary app implementing standard OTP/2FA login.
|
| 57 |
+
(1 of ($kw_*) and 1 of ($api_*, $smali_sms))
|
| 58 |
or
|
| 59 |
+
// Pure DEX: two SMS API strings β sufficient without permission match.
|
| 60 |
+
// This is the fallback for cases where the manifest's permission
|
| 61 |
+
// declaration is itself obfuscated/stripped but the SMS-reading code
|
| 62 |
+
// is genuinely present.
|
| 63 |
(2 of ($api_*, $smali_sms))
|
| 64 |
}
|
rules/rat.yar
CHANGED
|
@@ -24,7 +24,6 @@ rule REMOTE_ACCESS_TROJAN
|
|
| 24 |
$acc_service = "AccessibilityService" ascii
|
| 25 |
$acc_event = "onAccessibilityEvent" ascii
|
| 26 |
$acc_action = "performGlobalAction" ascii
|
| 27 |
-
$acc_node = "AccessibilityNodeInfo" ascii
|
| 28 |
|
| 29 |
// ββ Screen capture / remote viewing βββββββββββββββββββββββββββββββββ
|
| 30 |
$screen_proj = "MediaProjection" ascii
|
|
@@ -32,9 +31,6 @@ rule REMOTE_ACCESS_TROJAN
|
|
| 32 |
$screen_cap = "screencap" ascii nocase
|
| 33 |
|
| 34 |
// ββ Remote shell / command execution ββββββββββββββββββββββββββββββββ
|
| 35 |
-
// Use exact Smali descriptor to avoid matching RuntimeException
|
| 36 |
-
$cmd_runtime = "Ljava/lang/Runtime;" ascii
|
| 37 |
-
$cmd_exec = "getRuntime" ascii
|
| 38 |
$cmd_shell = "/system/bin/sh" ascii
|
| 39 |
$cmd_builder = "ProcessBuilder" ascii
|
| 40 |
|
|
@@ -47,12 +43,21 @@ rule REMOTE_ACCESS_TROJAN
|
|
| 47 |
// BIND_ACCESSIBILITY_SERVICE permission (wide = binary AXML, ascii = DEX string pool)
|
| 48 |
$acc_perm
|
| 49 |
or
|
| 50 |
-
// Accessibility callback method + service class β rules out compat-lib stub matches
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
or
|
| 53 |
-
// Screen capture combined with accessibility
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
or
|
| 56 |
-
// Remote shell
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
|
|
|
| 24 |
$acc_service = "AccessibilityService" ascii
|
| 25 |
$acc_event = "onAccessibilityEvent" ascii
|
| 26 |
$acc_action = "performGlobalAction" ascii
|
|
|
|
| 27 |
|
| 28 |
// ββ Screen capture / remote viewing βββββββββββββββββββββββββββββββββ
|
| 29 |
$screen_proj = "MediaProjection" ascii
|
|
|
|
| 31 |
$screen_cap = "screencap" ascii nocase
|
| 32 |
|
| 33 |
// ββ Remote shell / command execution ββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
| 34 |
$cmd_shell = "/system/bin/sh" ascii
|
| 35 |
$cmd_builder = "ProcessBuilder" ascii
|
| 36 |
|
|
|
|
| 43 |
// BIND_ACCESSIBILITY_SERVICE permission (wide = binary AXML, ascii = DEX string pool)
|
| 44 |
$acc_perm
|
| 45 |
or
|
| 46 |
+
// Accessibility callback method + service class β rules out compat-lib stub matches.
|
| 47 |
+
// $acc_node is deliberately excluded here: AccessibilityNodeInfo is referenced by
|
| 48 |
+
// the AndroidX/support-lib accessibility compliance code bundled in almost every app.
|
| 49 |
+
($acc_event and 1 of ($acc_service, $acc_action))
|
| 50 |
or
|
| 51 |
+
// Screen capture combined with actual accessibility *automation* (event callback or
|
| 52 |
+
// synthesized global action) β not just service/node presence, which legitimate
|
| 53 |
+
// screen-cast/Cast-receiver code pulls in alongside MediaProjection/createVirtualDisplay.
|
| 54 |
+
(1 of ($screen_*) and 1 of ($acc_event, $acc_action))
|
| 55 |
or
|
| 56 |
+
// Remote shell execution β explicit shell binary path, not just generic
|
| 57 |
+
// Runtime/getRuntime/ProcessBuilder references used by many legitimate
|
| 58 |
+
// native-lib loaders and crash reporters β paired with a C2 indicator
|
| 59 |
+
($cmd_shell and 1 of ($c2_*))
|
| 60 |
+
or
|
| 61 |
+
// Shell path built and executed via ProcessBuilder
|
| 62 |
+
($cmd_shell and $cmd_builder)
|
| 63 |
}
|
rules/spyware_collector.yar
CHANGED
|
@@ -29,21 +29,34 @@ rule SPYWARE_COLLECTOR
|
|
| 29 |
$call_log = "READ_CALL_LOG" ascii wide
|
| 30 |
$outgoing = "PROCESS_OUTGOING_CALLS" ascii wide
|
| 31 |
$calllog_api = "Landroid/provider/CallLog" ascii
|
|
|
|
| 32 |
|
| 33 |
// ββ Audio / camera recording βββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
-
$audio
|
| 35 |
-
$cam
|
|
|
|
|
|
|
| 36 |
|
| 37 |
// ββ Persistence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
$boot = "RECEIVE_BOOT_COMPLETED" ascii wide
|
| 39 |
|
| 40 |
condition:
|
| 41 |
-
// Location +
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
or
|
| 44 |
-
// Contact/call-log
|
| 45 |
-
(1 of ($
|
|
|
|
| 46 |
or
|
| 47 |
-
// Location-manager API paired with call-log provider access in DEX
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
|
|
|
| 29 |
$call_log = "READ_CALL_LOG" ascii wide
|
| 30 |
$outgoing = "PROCESS_OUTGOING_CALLS" ascii wide
|
| 31 |
$calllog_api = "Landroid/provider/CallLog" ascii
|
| 32 |
+
$contacts_api = "Landroid/provider/ContactsContract;" ascii
|
| 33 |
|
| 34 |
// ββ Audio / camera recording βββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
$audio = "RECORD_AUDIO" ascii wide
|
| 36 |
+
$cam = "android.permission.CAMERA" ascii wide
|
| 37 |
+
$audio_api = "MediaRecorder" ascii
|
| 38 |
+
$cam_api = "CameraManager" ascii
|
| 39 |
|
| 40 |
// ββ Persistence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
$boot = "RECEIVE_BOOT_COMPLETED" ascii wide
|
| 42 |
|
| 43 |
condition:
|
| 44 |
+
// Location + contacts/call-log + audio/camera *API usage*, not just the bare
|
| 45 |
+
// permission declarations β large legitimate apps (Docs: Drive/voice-typing/
|
| 46 |
+
// camera-scan; Instagram: geotagging/stories) hold all of these permissions for
|
| 47 |
+
// unrelated single-purpose features without ever invoking the other domains'
|
| 48 |
+
// APIs in combination. Actual class-level usage across all three domains
|
| 49 |
+
// together is a much stronger spyware signal.
|
| 50 |
+
($loc_api and 1 of ($calllog_api, $contacts_api) and 1 of ($audio_api, $cam_api)
|
| 51 |
+
and 1 of ($loc_fine, $loc_coarse) and 1 of ($contacts, $call_log, $outgoing) and 1 of ($audio, $cam))
|
| 52 |
or
|
| 53 |
+
// Contact/call-log API usage + microphone recording API + boot persistence
|
| 54 |
+
(1 of ($calllog_api, $contacts_api) and $audio_api and $boot
|
| 55 |
+
and 1 of ($contacts, $call_log, $outgoing) and $audio)
|
| 56 |
or
|
| 57 |
+
// Location-manager API paired with call-log provider access in DEX,
|
| 58 |
+
// corroborated by the matching dangerous permissions β the bare class-name
|
| 59 |
+
// strings alone are present in any app bundling Play Services/GMS, even
|
| 60 |
+
// when the location/call-log capability is never invoked.
|
| 61 |
+
($loc_api and $calllog_api and 1 of ($loc_fine, $loc_coarse) and 1 of ($contacts, $call_log))
|
| 62 |
}
|
rules/trojan_dropper.yar
CHANGED
|
@@ -34,16 +34,23 @@ rule TROJAN_DROPPER
|
|
| 34 |
$write_secure = "WRITE_SECURE_SETTINGS" ascii wide
|
| 35 |
|
| 36 |
// ββ Dynamic code loading β fetches/executes secondary payloads βββββββββ
|
| 37 |
-
$dex_loader = "DexClassLoader"
|
| 38 |
-
$pkg_installer = "Landroid/content/pm/PackageInstaller;" ascii
|
| 39 |
|
| 40 |
condition:
|
| 41 |
-
// Boot persistence +
|
| 42 |
-
|
|
|
|
| 43 |
or
|
| 44 |
-
// Two or more silent package-management / takeover permissions
|
|
|
|
|
|
|
| 45 |
(2 of ($req_install, $install_pkgs, $del_pkgs, $device_admin, $force_lock, $write_secure))
|
| 46 |
or
|
| 47 |
-
// Silent-install permission paired with dynamic code loading
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
|
|
|
| 34 |
$write_secure = "WRITE_SECURE_SETTINGS" ascii wide
|
| 35 |
|
| 36 |
// ββ Dynamic code loading β fetches/executes secondary payloads βββββββββ
|
| 37 |
+
$dex_loader = "DexClassLoader" ascii
|
|
|
|
| 38 |
|
| 39 |
condition:
|
| 40 |
+
// Boot persistence + device-admin/lockscreen takeover β rarely legitimate
|
| 41 |
+
// together, unlike a bare silent-install permission (see below)
|
| 42 |
+
($boot and 1 of ($device_admin, $force_lock, $write_secure))
|
| 43 |
or
|
| 44 |
+
// Two or more silent package-management / takeover permissions together β
|
| 45 |
+
// a single one (e.g. REQUEST_INSTALL_PACKAGES for a self-update flow) is
|
| 46 |
+
// common in legitimate apps and isn't enough on its own
|
| 47 |
(2 of ($req_install, $install_pkgs, $del_pkgs, $device_admin, $force_lock, $write_secure))
|
| 48 |
or
|
| 49 |
+
// Silent-install permission paired with actual dynamic code loading β the
|
| 50 |
+
// dangerous combo a ghostpush-style dropper needs to fetch and run a payload.
|
| 51 |
+
// PackageInstaller class references alone were dropped from this branch: that's
|
| 52 |
+
// the standard Android self-update/in-app-update API surface, present in any
|
| 53 |
+
// legitimate app with an update flow (e.g. Spotify, which has REQUEST_INSTALL_PACKAGES
|
| 54 |
+
// + a PackageInstaller reference but no dynamic code loading at all).
|
| 55 |
+
(1 of ($req_install, $install_pkgs, $del_pkgs) and $dex_loader)
|
| 56 |
}
|