Open-source Android telemetry SDK for understanding the device ecosystem of your userbase.
APK integrity · key attestation · bootloader integrity · runtime tampering · root indicators · emulator probe · cloner detection · runtime DEX-injection · 8-layer native anti-hooking stack.
Not a RASP. Not a kill-switch. Just structured, deterministic JSON your backend can analyze.
Same release APK, three devices. Left — clean Pixel 9 Pro. Middle — Pixel 6 Pro running KernelSU + LSPosed, Signals card lifts findings into product-shaped verdicts. Right — Findings card sorted worst-first, tap to expand the diagnostic details map.
Most Android apps shipping device-tampering checks reach for one of:
- Google Play Integrity API — black-box, requires Google Play Services, can't run on AOSP/FOSS devices, doesn't tell you why a device is suspect.
- A commercial RASP — expensive, partly closed-source, opaque detection logic, vendor lock-in.
- A simple root checker (RootBeer, SafetyNetHelper) — covers maybe 20% of the threat surface a real attacker uses.
DeviceIntelligence sits in the gap. It's:
- Fully open-source (Kotlin + native C++) — every detection rule is auditable. No closed binary blobs.
- Free-of-Google — no Play Services dependency; works on AOSP, OpenGApps, GrapheneOS, CalyxOS, etc. Hardware key attestation still works on those builds because it's a Keymaster API call, not a Google API call.
- Detection-rich — covers the techniques real attackers actually use: Frida agents, Xposed/LSPosed/EdXposed, Pine, SandHook, YAHFA, Cydia Substrate, Magisk, Zygisk, Riru, Taichi, app cloners, and runtime DEX injection (
InMemoryDexClassLoader/DexClassLoaderpayloads). 8 layers of in-process anti-hooking with circular-bypass design. - Backend-agnostic — emits one deterministic JSON. Send it to your own backend, Datadog, BigQuery, anywhere. The SDK itself performs no network calls and ships no analytics — collection and policy stay entirely in your hands.
Use it when you need device-tampering evidence richer than Play Integrity's pass/fail bit, want server-side control of the policy decision, can't or won't take the Play Services dependency, or want to audit the detection logic rather than trust a vendor's claims.
Don't use it when you want a turnkey "block this user" decision baked into the SDK — that's not what this is. DeviceIntelligence reports facts; your backend decides what to do about them.
Distributed via JitPack.
settings.gradle.kts
pluginManagement {
repositories { maven("https://jitpack.io"); gradlePluginPortal(); google() }
resolutionStrategy {
eachPlugin {
if (requested.id.id == "io.ssemaj.deviceintelligence") {
useModule(
"com.github.iamjosephmj.DeviceIntelligence:" +
"deviceintelligence-gradle:${requested.version}"
)
}
}
}
}
dependencyResolutionManagement {
repositories { google(); mavenCentral(); maven("https://jitpack.io") }
}app/build.gradle.kts
plugins {
id("io.ssemaj.deviceintelligence") version "2.0.0"
}Ships native binaries for arm64-v8a, x86_64, and armeabi-v7a (32-bit ARM). On 32-bit devices, all detectors run except integrity.art, which reports INCONCLUSIVE because the underlying ArtMethod field-offset table is currently 64-bit-only.
Four entry points, pick the one that matches your use case.
One-shot collect — your app starts, you want one structured snapshot, you ship it to your backend.
lifecycleScope.launch {
val report = DeviceIntelligence.collect(context) // TelemetryReport
val json = DeviceIntelligence.collectJson(context) // canonical JSON
val signals = report.toIntegritySignals()
if (IntegritySignal.HOOKING_FRAMEWORK_DETECTED in signals) {
// Send to your backend, gate the action, raise a flag — your call.
}
}Periodic observe — long-running session, you want a fresh snapshot every N seconds (e.g. catch a Frida agent that attaches mid-flow).
DeviceIntelligence.observe(context, interval = 2.seconds)
.onEach { report -> render(report) }
.launchIn(lifecycleScope)Cumulative session observe — same as observe() but accumulates findings across emissions. A transient hook that fires once and detaches stays visible with stillActive=false. Use this when your UI / backend correlation should never lose sight of a signal the moment it stops appearing.
DeviceIntelligence.observeSession(context, interval = 2.seconds)
.onEach { session: SessionFindings ->
render(session.findings) // List<TrackedFinding>
ship(session.toJson()) // canonical wire format
}
.launchIn(lifecycleScope)Each TrackedFinding carries firstSeenAtEpochMs, lastSeenAtEpochMs, observationCount, and stillActive on top of the underlying Finding.
Java / synchronous boundary — for Java consumers, worker threads, JNI bridges.
TelemetryReport report = DeviceIntelligence.collectBlocking(context);
String json = DeviceIntelligence.collectJsonBlocking(context);kotlinx-coroutines-android is the only runtime dependency.
| Detector | id | What it observes |
|---|---|---|
| APK integrity | integrity.apk |
APK bytes vs. the build-time fingerprint baked by the Gradle plugin |
| Bootloader integrity | integrity.bootloader |
TEE-spoofing / cached-chain detection on attestation.key |
| ART integrity | integrity.art |
In-process ART tampering across 5 vectors (Frida, Xposed, LSPosed, Pine, …) |
| Key attestation | attestation.key |
TEE / StrongBox attestation: Verified Boot, bootloader lock, OS patch level |
| Runtime environment | runtime.environment |
Debugger / ptrace / native integrity stack (text hash, GOT, injected libs) + runtime DEX-injection (InMemoryDexClassLoader / DexClassLoader payloads) + Frida 16+ Gum memfd-JIT attribution |
| Root indicators | runtime.root |
su binary, Magisk artifacts, test-keys, root-manager apps, Shamiko-bypass cross-checks (init mount-namespace + @magisk_daemon socket), MagiskTrustUserCerts TLS-trust-store MITM |
| Emulator probe | runtime.emulator |
CPU-instruction-level signals (arm64 MRS / x86_64 CPUID hypervisor bit) |
| App cloner | runtime.cloner |
Foreign APK mappings, mount-namespace inconsistencies, UID mismatches |
Each detector emits granular Findings; the IntegritySignal mapper collapses ~40 finding kinds into 11 product-shaped verdicts for UI / feature-flag code:
IntegritySignal |
Meaning |
|---|---|
APK_TAMPERED |
APK on disk modified, repackaged, signer mismatch, or installer not allowlisted. |
APK_FINGERPRINT_UNAVAILABLE |
The build-time fingerprint asset is missing/corrupt — couldn't make a verdict either way. |
BOOTLOADER_INTEGRITY_FAILED |
Hardware key-attestation chain has anomalies, or device claims StrongBox but attests at a lower level. |
TEE_ATTESTATION_DEGRADED |
Local advisory verdict on the attestation chain came back below MEETS_STRONG_INTEGRITY, OR the leaf cert's KeyDescription extension is in CBOR/EAT format (KeyMint 200+) and field-level parsing is deferred to backend re-verification. |
HOOKING_FRAMEWORK_DETECTED |
Active code-level hooking — Frida (incl. Frida 16+ Gum memfd-JIT attribution), Xposed/LSPosed/EdXposed, Pine, SandHook, Substrate, ART-internals tampering, runtime DEX injection, RWX trampolines, .text drift, GOT rewrites. |
INJECTED_NATIVE_CODE |
Unknown post-baseline .so or anonymous executable mapping; precondition for hooking but not yet proof of one. |
ROOT_INDICATORS_PRESENT |
su binary, Magisk artifact, test-keys build, which su succeeds, root-manager app installed, Magisk visible in PID 1's mount namespace (Shamiko bypass), @magisk_daemon abstract socket bound, or a tmpfs over /apex/com.android.conscrypt (MagiskTrustUserCerts TLS-MITM enablement — treat as hard block). |
EMULATOR_DETECTED |
CPU-instruction-level signals — arm64 MRS or x86_64 CPUID hypervisor bit. |
APP_CLONED |
Foreign APK mappings, mount-namespace inconsistencies, UID mismatches. |
DEBUGGER_ATTACHED |
JVM debugger or ptrace tracer attached. |
DEBUG_FLAG_MISMATCH |
App's FLAG_DEBUGGABLE disagrees with ro.debuggable. |
HARDWARE_ATTESTED_USERSPACE_TAMPERED |
Strongest single signal. Hardware attestation reports verifiedBootState=Verified AND a userspace hook finding fires in the same report. Either TEE compromise or post-attestation injection (Magisk + Shamiko, etc.). Backends should treat as highest-confidence compromise signal. |
val report = DeviceIntelligence.collect(context).toIntegritySignalReport()
when {
// Hardware-attested AND userspace-tampered = the highest-confidence
// signal the SDK can produce. Treat as a hard block.
IntegritySignal.HARDWARE_ATTESTED_USERSPACE_TAMPERED in report.signals -> hardBlock()
IntegritySignal.HOOKING_FRAMEWORK_DETECTED in report.signals -> denyPayment()
IntegritySignal.ROOT_INDICATORS_PRESENT in report.signals -> warnUser()
IntegritySignal.EMULATOR_DETECTED in report.signals -> requireExtra2FA()
else -> allow()
}
report.evidence[IntegritySignal.HOOKING_FRAMEWORK_DETECTED]?.forEach { finding ->
log.info("hook detected — kind=${finding.kind} subject=${finding.subject}")
}Not a RASP. It does not block sessions, kill processes, or interrupt any flow. It only observes. Build enforcement on the JSON your backend ingests; keep the policy off-device.
DeviceIntelligence ships its own offensive verification harnesses — Frida scripts and a real LSPosed module that intentionally trip each detector. Detection isn't claimed; it's verified against the same tools an attacker would use, on real hardware (Pixel 6 Pro running KernelSU + LSPosed; secondary Pixel 9 Pro for clean baseline).
Cross-OEM stability. Beyond the per-detector verification on Pixels, collect() / observe() / observeSession() have been validated for runtime stability across Sauce Labs' real-device farm — every Android 11+ (API 30–36) device in the farm, spanning the major OEM forks (Samsung One UI, Xiaomi HyperOS / MIUI, Vivo OriginOS, Honor MagicOS, OPPO ColorOS, OnePlus OxygenOS, Motorola, plus AOSP-equivalent Pixels). "Stability" here means: the native lib loads, every detector runs to completion, the JSON parses, no crashes on any tested device. Attack-scenario coverage (LSPosed / Frida actually firing detections) is verified on the Pixel 6 Pro reference rig.
Cross-ABI stability. All three native ABIs the AAR ships have been runtime-validated in the same Sauce Labs sweep:
| ABI | Status |
|---|---|
arm64-v8a |
full coverage — every detector works |
x86_64 |
full coverage — every detector works |
armeabi-v7a |
runtime-stable since 0.8.0 (validated on Sauce Labs 32-bit ARM devices). Every detector works EXCEPT integrity.art, which reports INCONCLUSIVE because the underlying ArtMethod field-offset table is 64-bit-only. Characterising 32-bit ART struct layouts is tracked as a future minor-version research task. |
| Surface | Validated with | Status |
|---|---|---|
| ART method-hook vectors A–F | tools/red-team/frida-vector-{a,c,d,e,f}.js — 5 independent JNI-level Frida scripts |
shipped |
Frida-Java's cls.method.implementation |
tools/red-team/frida-vector-frida-java.js |
shipped |
| LSPosed Java-side method hooks | samples/lsposed-tester — real LSPosed module installs hooks; StackGuard + StackWatchdog catch them |
shipped |
| Runtime DEX injection (CTF Flag 1) | LSPosed-driven InMemoryDexClassLoader + disk-backed DexClassLoader from /data/local/tmp/ |
shipped (0.6.0) |
| Pre-baseline DEX injection (Zygisk timing) | samples/lsposed-tester EarlyDexInjectionHook — synchronous inject in handleLoadPackage |
shipped via unattributable_dex_at_baseline (0.6.0) |
| Newer hook frameworks (Dobby/Whale/YAHFA/FastHook/il2cpp-dumper) | tools/red-team/maps-newer-frameworks.js — Frida prctl(PR_SET_VMA_ANON_NAME) page renaming |
shipped (0.9.0) |
| Hardware attestation × userspace tampering correlation | composes existing detector findings — JVM unit tests + Pixel 6 Pro live data | shipped (1.0.0) |
| Magisk + Shamiko hide-module bypass | /proc/1/mountinfo cross-check (init namespace can't be unshared per-process) + @magisk_daemon abstract Unix socket via /proc/self/net/unix — JVM unit tests with hand-crafted procfs fixtures. Finding kinds: magisk_in_init_mountinfo, magisk_daemon_socket_present |
shipped (1.x) |
| MagiskTrustUserCerts TLS-trust-store MITM | tmpfs bind-mount over /apex/com.android.conscrypt in /proc/self/mountinfo — JVM unit tests, CRITICAL severity (active TLS interception, not just root presence). Finding kind: tls_trust_store_tampered |
shipped (1.x) |
| Frida 16+ Gum memfd-backed JIT | /memfd:jit-cache + rwxp + region size >8 MB pattern in /proc/self/maps — JVM unit tests; fires alongside the generic rwx_memory_mapping for backend Frida-attribution pivot. Finding kind: frida_memfd_jit_present |
shipped (1.x) |
| EAT/CBOR attestation format detection | KeyDescriptionParser heuristic: when legacy ASN.1 parse fails AND the unwrapped extension starts with a CBOR map byte (0xA0–0xBF), emits attestation_eat_format_detected (LOW) so backends know parsed fields need server-side re-verification. Full CBOR-EAT field-level parsing tracked for a follow-up minor |
shipped — format detection only (1.x) |
| Real Zygisk module | TBD — see tools/red-team/CTF_ROADMAP.md |
planned |
| Samsung Knox warranty-bit parsing | Samsung Knox attestation extension OID prefix is detected on the leaf, but warranty-bit byte parsing requires on-device Samsung validation tracked for a follow-up minor | planned |
Full step-by-step validation runbook for the Pixel 6 Pro: tools/red-team/FLAG1_RUNBOOK.md.
DeviceIntelligence.collectJson(context) returns a single deterministic
document. The shape is stable across releases that share the same
schema_version (currently 2). For every Finding, the fields
kind / severity / subject / message are stable; details is
opaque diagnostic data — useful for forensics, but its keys may change
between releases without a schema_version bump, so don't key on them
server-side.
status vs findings answer different questions. status
(ok / inconclusive / error) means "did the detector run?";
findings[] means "what did it see?". A rooted device looks like
status: "ok" plus a non-empty findings[]. Drive your "device looks
tampered" decision off summary.detectors_with_findings, not status.
Per-collect vs cumulative session. collectJson() and
TelemetryReport.toJson() emit one snapshot of the moment the
collect ran. SessionFindings.toJson() (from observeSession) emits
a cumulative session view — same wire shape per finding plus
first_seen_at_epoch_ms / last_seen_at_epoch_ms /
observation_count / still_active, with a latest_report_summary
correlation block. Both share schema_version; pick whichever
matches your backend's correlation model.
Full clean-device report (click to expand)
Captured live from a clean Pixel 9 Pro. Locale, timezone, install
timestamps, vpn_active, boot_count, and APK random suffixes were
swapped for generic values; everything else (StrongBox-backed
attestation, Tensor G4 SoC, Mali GPU, 120Hz panel, GMS signer SHA) is
the unmodified real value. For tripped-detector examples, see
docs/DETECTORS.md.
{
"schema_version": 2,
"library_version": "1.0.0",
"collected_at_epoch_ms": 1777400000000,
"collection_duration_ms": 8325,
"device": {
"manufacturer": "Google",
"model": "Pixel 9 Pro",
"sdk_int": 36,
"abi": "arm64-v8a",
"fingerprint": "google/caiman/caiman:16/CP1A.260405.005/15001963:user/release-keys",
"total_ram_mb": 15583,
"cpu_cores": 8,
"screen_density_dpi": 480,
"screen_resolution": "1280x2856",
"has_fingerprint_hw": true,
"has_telephony_hw": true,
"sensor_count": 41,
"boot_count": 142,
"vpn_active": false,
"strongbox_available": true,
"brand": "google",
"board": "caiman",
"hardware": "caiman",
"product": "caiman",
"device": "caiman",
"bootloader_version": "ripcurrentpro-16.4-14791556",
"radio_version": "g5400c-251201-260127-B-14784805,g5400c-251201-260127-B-14784805",
"build_host": "67911e6f684b",
"build_user": "android-build",
"build_type": "user",
"build_tags": "release-keys",
"build_time_epoch_ms": 1773135125000,
"supported_abis_all": ["arm64-v8a"],
"soc_manufacturer": "Google",
"soc_model": "Tensor G4",
"gl_es_version": "3.2",
"egl_implementation": "mali",
"default_locale": "en-US",
"system_locales": ["en-US"],
"timezone_id": "America/Los_Angeles",
"timezone_offset_minutes": -480,
"auto_time_enabled": true,
"auto_time_zone_enabled": true,
"display_refresh_rate_hz": 120.0,
"display_supported_refresh_rates_hz": [1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 24.0, 30.0, 40.0, 60.0, 120.0],
"display_hdr_types": ["HDR10", "HLG", "HDR10_PLUS"],
"device_secure": true,
"biometrics_enrolled": true,
"adb_enabled": false,
"developer_options_enabled": false,
"battery_present": true,
"battery_technology": "Li-ion",
"battery_health": "good",
"battery_plug_type": "none",
"thermal_status": "none",
"boot_epoch_ms": 1776800000000,
"play_services_availability": "success",
"play_services_version_code": 261533035,
"play_store_version_code": 85101930,
"gms_signer_sha256": "5f2391277b1dbd489000467e4c2fa6af802430080457dce2f618992e9dfb5402"
},
"app": {
"package_name": "io.ssemaj.sample",
"apk_path": "/data/app/.../io.ssemaj.sample-.../base.apk",
"installer_package": null,
"signer_cert_sha256": ["a91535782adbd690b915679d456628153166d35527ea867ab830bccd730065a4"],
"build_variant": "debug",
"library_plugin_version": "1.0.0",
"first_install_epoch_ms": 1775000000000,
"last_update_epoch_ms": 1777300000000,
"target_sdk_version": 36,
"install_source": {
"installing_package": null,
"originating_package": null,
"initiating_package": "com.android.shell"
},
"signer_cert_validity": [
{ "not_before_epoch_ms": 1771714645000, "not_after_epoch_ms": 2717794645000 }
],
"attestation": {
"chain_sha256": "dd12ccf2a857860f3712b45bcfebb7b917d4e0b9187cca0d0e50e9b119f5c9b8",
"chain_length": 5,
"attestation_security_level": "StrongBox",
"keymaster_security_level": "StrongBox",
"software_backed": false,
"verified_boot_state": "Verified",
"device_locked": true,
"os_patch_level": 202604,
"attested_package_name": "io.ssemaj.sample",
"attested_signer_cert_sha256": ["a91535782adbd690b915679d456628153166d35527ea867ab830bccd730065a4"],
"verdict_device_recognition": "MEETS_BASIC_INTEGRITY,MEETS_DEVICE_INTEGRITY,MEETS_STRONG_INTEGRITY",
"verdict_app_recognition": "RECOGNIZED",
"verdict_reason": null,
"verdict_authoritative": false,
"unavailable_reason": null
}
},
"detectors": [
{ "id": "integrity.apk", "status": "ok", "duration_ms": 841, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "integrity.bootloader", "status": "ok", "duration_ms": 243, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "integrity.art", "status": "ok", "duration_ms": 4, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "attestation.key", "status": "ok", "duration_ms": 495, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "runtime.environment", "status": "ok", "duration_ms": 5525, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "runtime.root", "status": "ok", "duration_ms": 458, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "runtime.emulator", "status": "ok", "duration_ms": 0, "inconclusive_reason": null, "error_message": null, "findings": [] },
{ "id": "runtime.cloner", "status": "ok", "duration_ms": 0, "inconclusive_reason": null, "error_message": null, "findings": [] }
],
"summary": {
"total_findings": 0,
"findings_by_severity": { "low": 0, "medium": 0, "high": 0, "critical": 0 },
"findings_by_kind": {},
"detectors_with_findings": [],
"detectors_inconclusive": [],
"detectors_errored": []
}
}A clean device emits empty findings[] everywhere and
summary.total_findings: 0. You can alert on total_findings > 0
server-side without parsing each detector individually.
git clone https://github.com/iamjosephmj/DeviceIntelligence.git
cd DeviceIntelligence
./gradlew :samples:minimal:installDebug
adb shell am start -n io.ssemaj.sample/.MainActivity| Permission | Required by | Default | Opt-out / opt-in |
|---|---|---|---|
QUERY_ALL_PACKAGES |
runtime.root root_manager_app_installed channel |
on | Strip via tools:node="remove" |
ACCESS_NETWORK_STATE |
DeviceContext.vpnActive |
off | enableVpnDetection.set(true) |
USE_BIOMETRIC |
DeviceContext.biometricsEnrolled |
off | enableBiometricsDetection.set(true) |
When you opt out of vpnActive / biometricsEnrolled, the field
reports null (not false).
Zero data collection. The SDK does not make any network calls, does not contact any vendor cloud, and does not transmit any telemetry to anyone. The output of DeviceIntelligence.collectJson(context) stays in your app's process; what your app chooses to upload — and to where — is entirely up to you.
GDPR-friendly by design. Because the SDK transmits no data, it is neither a data controller nor a data processor under GDPR. Your app remains the sole data controller for any device telemetry it chooses to forward to its own backend, and the consent / retention / DSAR posture is yours to define. The library itself reads no advertising ID (GAID), no ANDROID_ID, no IMEI/IMSI, no SIM serial, no account identifiers, and no contact / location / file-system content. Everything available in the TelemetryReport is documented end-to-end in the JSON contract section above so you can audit exactly what fields exist before deciding what to ship server-side.
docs/DETECTORS.md— full per-detector reference (threat model, finding kinds, sample tripped JSON, costs, caveats)NATIVE_INTEGRITY_DESIGN.md— design of the 8-layer (G0–G7) anti-hooking stackCHANGELOG.md— version history from 0.5.2 → 1.0.0 with wire-format impact notes per releaseSECURITY.md— vulnerability disclosure process, response SLOs, supported-versions policytools/red-team/— Frida scripts that intentionally trip eachintegrity.artfinding (Vectors A/C/D/E/F + Frida-Java)tools/red-team/CTF_ROADMAP.md— capture-the-flag roadmap of every detection technique on the backlog (Flag 1 — DEX injection — captured 0.6.0; Flag 2 — newer hook frameworks — captured 0.9.0; Flag 5 — attestation × runtime correlation — captured 1.0.0)tools/red-team/FLAG1_RUNBOOK.md— Pixel 6 Pro on-device validation runbook for the Flag 1 DEX-injection detectorsamples/lsposed-tester/— real LSPosed module that drives runtime DEX injection against the sample app, used to verify the detector against production attacker tooling rather than just Frida
Apache 2.0 — see LICENSE.


