Most Android root detection starts with the same boring question:
Is su visible?
Is Magisk installed?
Is Xposed installed?
That is useful, but it is also the easiest layer to lie to.
When you start looking at modern Android modding stacks like Magisk, Zygisk, LSPosed, KernelSU, APatch, Shamiko, Hide My Applist, Frida, and native hook frameworks, the problem becomes less about asking “is the phone rooted?” and more about asking:
What does this process see?
Is this view filtered?
Does another process see something different?
Are my native function pointers still pointing where they should?
Are my memory mappings consistent?
Is my own native code still the same code I loaded?
That is the idea behind SecurityRiskAndroid.
This project is not a magic root detector. It is a small reverse-engineering lab for studying Android runtime tampering from inside the app process.
The goal is to build a layered runtime-risk detector using JNI, native memory inspection, /proc view comparison, ART side-effect checks, root-assisted diagnostics, and isolated-process comparison.
The Threat Model
A normal root check assumes that suspicious artifacts are visible.
For example:
static jboolean checkRootPaths(void) {
const char *paths[] = {
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/data/local/xbin/su",
"/magisk/.core/bin/su",
"/debug_ramdisk/.magisk",
"/data/adb/magisk",
"/data/adb/ksu",
"/data/adb/ksud",
"/data/adb/apatch",
NULL
};
for (int i = 0; paths[i]; i++) {
if (access(paths[i], F_OK) == 0) {
LOGI("Root path artifact found: path=%s", paths[i]);
return JNI_TRUE;
}
}
return JNI_FALSE;
}
This is the classic first layer.
But the real issue is that access() itself can be hooked, or the app process can be given a filtered filesystem view. A clean result here only means:
No root artifact was visible from this app process.
It does not prove the device is clean.
That mindset shift changes the whole design.
Instead of building a single “root detector,” I started treating the app process as a potentially untrusted witness. The detector needs to compare multiple witnesses:
libc view
raw syscall view
/proc maps view
dl_iterate_phdr view
ART / ClassLoader view
PackageManager view
/data/app filesystem view
normal app process view
isolated service process view
optional root-assisted view
The interesting signal is often not one artifact. It is disagreement between views.
Native Logging as a Reverse Engineering Tool
Before doing any detection, I wanted visibility.
A lot of Android anti-hooking code only returns a final boolean. That is annoying when testing because you do not know which signal triggered. So the native layer keeps a log buffer that the Java UI can read.
#ifndef VERBOSE
#define VERBOSE 1
#endif
#define NATIVE_LOG_BUFFER_MAX (256 * 1024)
static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
static char g_log_buffer[NATIVE_LOG_BUFFER_MAX];
static size_t g_log_len = 0;
The logger writes both to Android logcat and to an in-memory native buffer:
static void secLog(int prio, const char *level, const char *fmt, ...) {
char msg[1024];
va_list ap;
va_start(ap, fmt);
vsnprintf(msg, sizeof(msg), fmt, ap);
va_end(ap);
__android_log_print(prio, TAG, "%s", msg);
char line[1280];
snprintf(line, sizeof(line), "%lld [%s] pid=%d tid=%lu %s",
wall_time_ms(), level ? level : "I",
getpid(), current_tid(), msg);
pthread_mutex_lock(&g_log_lock);
append_log_locked(line);
pthread_mutex_unlock(&g_log_lock);
}
This makes the app more useful as a reverse-engineering playground.
Instead of just showing:
VERDICT:BLOCK
the UI can show:
[I] Hook/root artifact found in raw maps: term=zygisk
[I] Possible /proc/self/maps filtering: libc_lines=201 raw_lines=209
[I] Runtime pointer owner mismatch: label=JNI.NewStringUTF
[I] ROOT_VIEW_DELTA=DETECTED
For research builds, verbose logs are good.
For hardened builds, verbose logs are dangerous because they explain the detector to the attacker. That is why the code has a VERBOSE flag.
Raw Syscall vs libc: Detecting a Filtered View
One of the more interesting checks is comparing the same file through two different read paths.
A hook framework may intercept fopen() / fread() / libc wrappers. So the code also reads using raw syscalls:
static char *read_file_raw_dynamic(const char *path, size_t *out_len) {
int fd = (int)syscall(__NR_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return NULL;
// read using syscall(__NR_read)
}
Then the libc path uses normal file APIs:
static char *read_file_libc_dynamic(const char *path, size_t *out_len) {
FILE *fp = fopen(path, "re");
if (!fp) return NULL;
// read using fread()
}
This gives a simple but useful comparison:
static jboolean checkMapsFilteringMismatch(void) {
size_t raw_len = 0, libc_len = 0;
char *raw = read_file_raw_dynamic("/proc/self/maps", &raw_len);
char *libc = read_file_libc_dynamic("/proc/self/maps", &libc_len);
int raw_lines = count_lines(raw);
int libc_lines = count_lines(libc);
int raw_has_artifact = has_suspicious_text_precise(raw);
int libc_has_artifact = has_suspicious_text_precise(libc);
if ((raw_lines - libc_lines) > 3 ||
(raw_has_artifact && !libc_has_artifact)) {
LOGI("Possible /proc/self/maps filtering: libc_lines=%d raw_lines=%d",
libc_lines, raw_lines);
return JNI_TRUE;
}
return JNI_FALSE;
}
This does not prove the syscall path is impossible to fake. A stronger attacker can filter both paths.
But it catches a weaker class of hooks where libc is filtered but raw syscalls still leak the real mapping.
This is one of the core ideas in the project:
Do not ask one API for the truth. Ask multiple layers and compare their stories.
/proc/self/maps: The Runtimeās Footprint
A lot of runtime tampering leaves traces in memory maps.
The checker parses /proc/self/maps into a small structure:
typedef struct MapEntry {
uintptr_t start;
uintptr_t end;
unsigned long offset;
char perms[5];
char path[PATH_MAX];
} MapEntry;
Then it looks for suspicious executable mappings:
if (r && w && x) {
LOGI("RWX memory mapping found");
verboseLogMapEntry("suspicious RWX mapping", &e);
suspicious = 1;
}
It also checks executable memfd, deleted executable mappings, and anonymous executable regions:
if ((contains_nocase(e.path, "/memfd:") ||
contains_nocase(e.path, "memfd:")) && sz >= 4096) {
LOGI("Unknown executable memfd mapping found");
verboseLogMapEntry("unknown executable memfd", &e);
suspicious = 1;
}
But Android itself uses JIT memory, so the detector needs an allowlist:
static int isNormalArtJitPath(const char *path) {
return contains_nocase(path, "memfd:jit-cache") ||
contains_nocase(path, "memfd:jit-zygote-cache") ||
contains_nocase(path, "dalvik-jit-code-cache") ||
contains_nocase(path, "jit-code-cache") ||
contains_nocase(path, "dalvik-main space") ||
contains_nocase(path, "dalvik-zygote space");
}
This is where reverse engineering gets messy.
A naive detector says:
memfd + executable = suspicious
But a real detector needs to understand what the Android runtime normally does.
Otherwise the detector becomes noisy and blocks normal devices.
False Positive Control: The “ksu” Problem
One of the funniest bugs in this kind of detector is substring matching.
At one point, checking for ksu can accidentally match unrelated libraries like:
libvndksupport.so
That string contains ksu, but it has nothing to do with KernelSU.
So the matcher needs false-positive control:
static int isKnownFalsePositivePath(const char *path) {
if (!path) return 0;
// "ksu" can appear inside libvndksupport.so.
if (contains_nocase(path, "libvndksupport.so")) return 1;
return 0;
}
And ksu should be treated as suspicious only when it looks token-like:
if (basename_equals_nocase(path, "ksu") ||
basename_equals_nocase(path, "su") ||
contains_nocase(path, "/ksu/") ||
contains_nocase(path, "/data/adb/ksu") ||
contains_nocase(path, "/data/adb/ksud")) {
if (matched) *matched = "ksu-token";
return 1;
}
This matters because runtime detection is full of tradeoffs.
If the detector is too strict, it false-positives.
If it is too loose, it misses real artifacts.
A reverse-engineering tool needs to show enough detail that you can tune those rules based on real device output.
Checking Native Code Integrity
A common attack is to patch the native library after it is loaded.
So the checker resolves its own executable mapping by using the address of JNI_OnLoad as a probe:
uintptr_t probe = (uintptr_t)&JNI_OnLoad;
if (probe >= e.start && probe < e.end && executable) {
code_start = e.start;
code_end = e.end;
code_len = (size_t)(e.end - e.start);
code_file_offset = e.offset;
snprintf(self_so_path, sizeof(self_so_path), "%s", e.path);
}
At startup, it hashes the live executable memory:
static void initMemoryBaseline(void) {
uint64_t live_hash = fnv1a64_mem((const void *)code_start, code_len);
baseline_xor = live_hash ^ baseline_key1;
baseline_rot = rotl64(live_hash ^ baseline_key2, 17);
LOGI("Memory baseline initialized: range=0x%lx-0x%lx size=%lu path=%s",
(unsigned long)code_start,
(unsigned long)code_end,
(unsigned long)code_len,
safe_str(self_so_path));
}
Later it recomputes the hash:
static jboolean checkMemoryIntegrityLive(void) {
uint64_t decoded1 = baseline_xor ^ baseline_key1;
uint64_t decoded2 = rotl64(baseline_rot, 64 - 17) ^ baseline_key2;
if (decoded1 != decoded2) {
LOGI("Baseline storage mismatch");
return JNI_TRUE;
}
uint64_t current = fnv1a64_mem((const void *)code_start, code_len);
if (current != decoded1) {
LOGI("Live code hash mismatch");
return JNI_TRUE;
}
return JNI_FALSE;
}
There is also a disk-vs-memory comparison:
if (disk_hash != live_hash) {
LOGI("Disk-vs-memory code hash mismatch");
return JNI_TRUE;
}
This is not unbreakable. An attacker can patch the baseline, hook the checker, or patch the final verdict.
But it raises the cost. The attacker now has to understand and patch more than one return value.
JNI Table Integrity
A Java hook can intercept Java methods.
A native hook can go deeper and manipulate JNI function tables.
So the checker inspects whether important JNI function pointers still belong to trusted runtime libraries like libart.so, libnativehelper.so, or libandroid_runtime.so.
static int isTrustedRuntimeOwner(const char *path) {
return contains_nocase(path, "libart.so") ||
contains_nocase(path, "libnativehelper.so") ||
contains_nocase(path, "libandroid_runtime.so");
}
The pointer owner is checked with dladdr() and also compared against executable mappings:
static int checkTrustedRuntimePointer(const char *label, void *addr) {
Dl_info info;
memset(&info, 0, sizeof(info));
if (!dladdr(addr, &info) || !info.dli_fname) {
LOGI("Runtime pointer owner unresolved: label=%s addr=%p", label, addr);
return 1;
}
if (!isTrustedRuntimeOwner(info.dli_fname)) {
LOGI("Runtime pointer owner mismatch: label=%s addr=%p owner=%s",
label, addr, info.dli_fname);
return 1;
}
return 0;
}
Then selected JNI table entries are scored:
static jboolean checkJniTableIntegrity(JNIEnv *env) {
const struct JNINativeInterface *table = *env;
int score = 0;
score += checkTrustedRuntimePointer("JNI.NewStringUTF",
(void *)table->NewStringUTF) ? 3 : 0;
score += checkTrustedRuntimePointer("JNI.GetMethodID",
(void *)table->GetMethodID) ? 3 : 0;
score += checkTrustedRuntimePointer("JNI.CallVoidMethod",
(void *)table->CallVoidMethod) ? 3 : 0;
if (score >= 3) {
LOGI("JNI function table integrity suspicious: score=%d", score);
return JNI_TRUE;
}
return JNI_FALSE;
}
The JavaVM table gets similar treatment:
score += checkTrustedRuntimePointer("JavaVM.GetEnv",
(void *)table->GetEnv) ? 3 : 0;
score += checkTrustedRuntimePointer("JavaVM.AttachCurrentThread",
(void *)table->AttachCurrentThread) ? 3 : 0;
This is one of the more reverse-engineering-oriented parts of the project because it does not look for a package name. It looks at runtime ownership.
The question becomes:
Who owns the function pointer I am about to trust?
Linker and GOT/PLT Checks
Another hook layer is imported native functions.
The checker resolves critical symbols and checks whether they still belong to expected owners:
static const CriticalSymbolProbe probes[] = {
{"fopen", "libc.so", 4, 1},
{"read", "libc.so", 4, 1},
{"open", "libc.so", 4, 1},
{"openat", "libc.so", 4, 1},
{"mprotect", "libc.so", 4, 1},
{"dlopen", "libdl.so", 4, 1},
{"dlsym", "libdl.so", 4, 1},
{"android_dlopen_ext", "libdl.so", 4, 1},
{NULL, NULL, 0, 0}
};
It also does a simple inline hook heuristic by checking if the function starts with a branch or jump instruction.
For AArch64:
return ((insn & 0xFC000000U) == 0x14000000U) ||
((insn & 0xFC000000U) == 0x94000000U);
That detects branch-like prologues such as direct B or BL at function entry.
This is only a heuristic. Real functions can start with branches, and advanced hooks can avoid obvious prologue patches.
But again, it is not used alone. It contributes to a risk score.
The GOT/PLT ownership scan goes one step further: it walks the current moduleās dynamic relocation entries and checks whether imported callsites resolve to the expected library owner.
Conceptually:
For each imported symbol:
if symbol is interesting:
read GOT/PLT slot
resolve target with dladdr()
check owner library
So if a callsite that should point to libc.so suddenly points to a foreign executable mapping, that is a strong signal.
PHDR vs Maps: Two Linker Views
Another useful comparison is:
What does dl_iterate_phdr() say is loaded?
What does /proc/self/maps say is executable?
The checker walks program headers using dl_iterate_phdr() and checks whether executable segments are visible in maps.
If a module exists according to the linker but its executable segment is missing from the maps view, that suggests filtering or inconsistency.
if (!mapsHasExecRange(ctx->maps, seg_start)) {
LOGI("Executable module segment missing from maps view: module=%s",
basename_safe(name));
ctx->hidden_exec_segment = 1;
}
This is another “compare witnesses” check.
Not every mismatch is malicious, but in combination with other signals, it becomes interesting.
ART and ClassLoader Side Effects
Some Android frameworks do not leave only native traces. They also leave Java/ART side effects.
The checker has probes for:
ART bridge classes
ART stack traces
ClassLoader / DexPathList entries
foreign dex/apk/jar/vdex/oat mappings
PackageManager visibility anomalies
/data/app vs PackageManager inconsistencies
mock-location related classes and providers
framework runtime sanity
The project treats these as side-effect checks.
A hiding module may remove its package from PackageManager, but the runtime may still have foreign dex paths, bridge classes, stack frames, or mapped artifacts.
This is why the detector does not only ask:
Is package com.some.framework installed?
It also asks:
Is framework code already inside my runtime?
Did PackageManager and /data/app disagree?
Did the ClassLoader expose unexpected dex paths?
Did the stack trace leak bridge code?
That is much closer to how reverse engineering actually feels: you are not looking for one magic indicator. You are looking for leaks.
Fast Checks vs Deep Checks
Some probes are cheap. Some are expensive.
Reading /proc/self/status is cheap. Walking installed packages, scanning disk artifacts, reading smaps, checking ports, doing ART reflection, or running root-assisted diagnostics can be slow.
So the checker splits execution into fast and deep paths.
The fast path runs immediately:
static ThreatReport runFastChecksInternal(JNIEnv *env) {
ThreatReport r;
memset(&r, 0, sizeof(r));
r.root_paths = checkRootPaths();
r.root_mounts = checkRootMounts();
r.maps_artifacts = checkMapsArtifactsRaw();
r.suspicious_maps = checkSuspiciousExecutableMaps();
r.debugger = checkDebugger();
r.suspicious_threads = checkSuspiciousThreads();
r.suspicious_fds = checkSuspiciousFds();
r.linker_hooks = checkLinkerAndInlineHooks();
r.jni_table = checkJniTableIntegrity(env);
r.jvm_table = checkJavaVmTableIntegrity();
r.memory_live = checkMemoryIntegrityLive();
r.score = scoreThreatReport(&r);
return r;
}
The deep path does heavier work:
static ThreatReport runDeepChecksInternal(JNIEnv *env) {
ThreatReport r;
memset(&r, 0, sizeof(r));
r.maps_filtered = checkMapsFilteringMismatch();
r.proc_view_mismatch = checkProcViewConsistency();
r.smaps_consistency = checkSmapsConsistency();
r.got_plt_hooks = checkGotPltOwnership();
r.modules_phdr = checkLoadedModulesPhdr();
r.self_breakpoints = checkSelfBreakpoints();
r.art_bridge_classes = checkArtBridgeClasses(env);
r.art_stack = checkArtStack(env);
r.art_classloader = checkArtClassLoader(env);
r.art_dex_maps = checkArtDexMaps();
r.package_risk = checkPackageRiskAndInconsistency(
env, &r.package_inconsistency);
r.location_environment = checkLocationEnvironment(env);
r.framework_runtime = checkFrameworkRuntime(env);
r.memory_disk = checkMemoryIntegrityDisk();
r.score = scoreThreatReport(&r);
return r;
}
This is important for UX.
A security check that freezes the app at startup is not production-friendly.
So the initial result can return:
SCORE:1|VERDICT:CLEAN|DEEP_SCAN:PENDING|GOT_PLT:PENDING|PACKAGE_RISK:PENDING
Then later, after the deep worker finishes:
SCORE:8|VERDICT:BLOCK|DEEP_SCAN:CACHED|PACKAGE_RISK:DETECTED|GOT_PLT:CLEAN
Randomized Deep Scan Timing
The deep scan does not start at a perfectly deterministic time.
static void *deep_scan_worker(void *arg) {
int initial_delay_ms = randomized_delay_ms(80, 900);
VLOGI("Deep async scan randomized startup delay: %d ms",
initial_delay_ms);
sleep_ms_interruptible(initial_delay_ms);
// attach native thread to JVM
// run deep checks
// cache result
}
This is not “security by randomness.” It just avoids making every heavy check happen at the exact same predictable point.
For an attacker, deterministic timing is convenient.
A randomized delay is a small friction layer.
Root-Assisted Diagnostics
Root-assisted checks are intentionally treated as diagnostic evidence, not as the default app-process view.
If su is available and granted, the checker can inspect things the normal app process may not see:
/data/adb
module files
root manager traces
process lists
ports and sockets
mounts
The interesting part is the delta.
If the normal app process says:
ROOT_PATHS:CLEAN
ROOT_MOUNTS:CLEAN
but the root-assisted view says:
ROOT_ASSISTED_MODULES:DETECTED
ROOT_ASSISTED_PROCESS:DETECTED
then the final report can mark:
ROOT_VIEW_DELTA:DETECTED
The merge logic captures that idea:
dst->root_view_delta =
(deep->root_assisted_granted &&
!dst->root_paths &&
!dst->root_mounts &&
(deep->root_assisted_root_view ||
deep->root_assisted_modules ||
deep->root_assisted_process ||
deep->root_assisted_ports));
This means:
The normal app view looked clean,
but the root view found evidence.
That is exactly the kind of signal that matters when testing hiding tools.
Also important:
ROOT_ASSISTED_ASYNC:DENIED
does not mean clean.
It only means the diagnostic root path was not granted.
Isolated Process Comparison
The Java side also runs the checker from an isolated Android service process.
This creates another witness.
The UI can show:
ISO_SCORE:4
ISO_VERDICT:WARNING
ISO_PACKAGE_RISK:CLEAN
DELTA_PACKAGE_RISK:MAIN=DETECTED ISO=CLEAN
The isolated service uses Messenger IPC because it runs in a separate process. A normal local Binder cast is not appropriate across process boundaries.
The point is not that the isolated process is magically trusted.
The point is that hooks and filters may not affect both processes identically.
When the main process and isolated process disagree, that disagreement is useful evidence.
Risk Scoring Instead of One-Shot Blocking
The final design uses weighted scoring.
A weak signal should not instantly block a user.
For example, USB debugging may be enabled on a developer phone. That is suspicious in some contexts, but not enough by itself.
Memory tampering or JNI table owner mismatch is much stronger.
The scoring function reflects that:
score += r->jni_table ? 8 : 0;
score += r->jvm_table ? 8 : 0;
score += r->got_plt_hooks ? 8 : 0;
score += r->memory_live ? 10 : 0;
score += r->memory_disk ? 8 : 0;
score += r->root_view_delta ? 6 : 0;
score += r->usb_debugging ? 1 : 0;
score += r->bootloader_unlocked ? 3 : 0;
score += r->build_props ? 3 : 0;
Then the verdict is simple:
score >= 8 -> BLOCK
score >= 4 -> WARNING
score < 4 -> CLEAN
This is more realistic than a boolean root check.
A single weak signal can produce a warning.
Multiple medium signals can become a block.
One very strong signal can block immediately.
Example Output
A first fast result might look clean while deeper checks are still pending:
SCORE:1|VERDICT:CLEAN|DEEP_SCAN:PENDING|MAPS_FILTERED:PENDING|GOT_PLT:PENDING|PACKAGE_RISK:PENDING|ROOT_ASSISTED_ASYNC:PENDING
After the deep scan finishes:
SCORE:8|VERDICT:BLOCK|DEEP_SCAN:CACHED|PACKAGE_RISK:DETECTED|FRAMEWORK_RUNTIME:CLEAN|GOT_PLT:CLEAN|SMAPS_CONSISTENCY:CLEAN
After root-assisted diagnostics are granted:
ROOT_ASSISTED_ASYNC:GRANTED|ROOT_ASSISTED_MODULES:DETECTED|ROOT_ASSISTED_PROCESS:DETECTED|ROOT_VIEW_DELTA:DETECTED
With isolated-process comparison:
ISO_SCORE:4
ISO_VERDICT:WARNING
ISO_PACKAGE_RISK:CLEAN
DELTA_PACKAGE_RISK:MAIN=DETECTED ISO=CLEAN
That output is intentionally verbose during research.
It makes the app useful not only as a detector, but also as a small lab for studying how hiding frameworks behave.
What This Catches Well
This design is useful for detecting or studying:
Frida / Gum traces
Xposed / LSPosed side effects
Zygisk / Magisk / KernelSU / APatch artifacts
suspicious executable mappings
unknown executable memfd regions
RWX mappings
deleted executable mappings
debugger attachment
suspicious thread names
suspicious file descriptors
JNI table tampering
JavaVM table tampering
GOT / PLT import redirection
inline hook prologues
PHDR vs maps inconsistency
PackageManager hiding behavior
/data/app vs package visibility mismatch
mock-location environment signals
root-view vs app-view disagreement
native .text patching
disk-vs-memory native library mismatch
It is especially useful when testing modules that try to hide from the target app process.
What This Does Not Solve
This is not impossible to bypass.
A strong attacker can still:
Patch the checker
Patch the final verdict
Hook JNI return values
Filter raw syscall output too
Make libc and syscall views both fake-clean
Patch GOT/PLT after checks complete
Disable background threads
Patch the score calculation
Return fake clean values from Java reflection
Hide artifacts from both main and isolated processes
Fake or deny root-assisted output
Patch the native library and update the baseline
This is why the project should be viewed as one layer.
For real production hardening, this should be combined with:
Server-side validation
Play Integrity API
hardware-backed attestation where available
certificate pinning
runtime challenge-response
short-lived server nonces
obfuscation
native-side sensitive logic
multiple independent detection paths
delayed or randomized enforcement
The biggest mistake would be to make this the only gate:
if (runAllChecks().contains("CLEAN")) allowSensitiveAction();
That final Java string is itself an attack surface.
The Reverse Engineering Lesson
The main lesson from this project is that Android runtime security is not a single yes/no question.
A rooted or hooked runtime can lie.
So the detector should not ask one witness.
It should ask many:
What does access() see?
What does raw syscall openat() see?
What does /proc/self/maps show?
What does dl_iterate_phdr() show?
What does the JNI table point to?
What does the JavaVM table point to?
What does the ClassLoader expose?
What does PackageManager claim?
What does /data/app reveal?
What does the isolated process see?
What does the root-assisted view see?
The useful signal is often the mismatch.
A clean app-process root path check is not proof of safety.
A single weak signal is not proof of compromise.
But layered evidence gives a much better picture of the runtime.
That is the core idea behind SecurityRiskAndroid:
Treat the Android app process like a potentially filtered reality, then compare that reality against as many independent views as possible.
This project is not a final anti-tamper product.
It is a reverse-engineering notebook written in code.
Want to Check It Out or Help?
The project is open on GitHub here
https://github.com/radito/SecurityRiskAndroid
If you want to test it directly, I also prepared a ready-to-build APK here
https://github.com/radito/SecurityRiskAndroid/releases
Try it on your own device, rooted test phone, emulator, Magisk setup, KernelSU setup, LSPosed environment, Frida lab, or whatever Android runtime you usually play with.
I am especially interested in real-world test results: false positives, missed detections, weird device behavior, hiding-framework bypasses, and ideas for better native-side probes.
This project is still a research playground, not a final product. But that is also the fun part. Android runtime security is messy, and the best way to understand it is to actually observe what the process sees, what it does not see, and where different runtime views start disagreeing.
If you find something interesting, break it, improve it, or open a discussion.
Loading Comments ...