Blog
A Look
At A Promon Shield Bypass – Part 2
Description A while back, we successfully bypassed a version
of Promon SHIELD — the commercial mobile app
Description
A while back, we successfully bypassed a version of Promon SHIELD — the commercial mobile app shielding product — running inside a major financial application. We don’t know exactly which version of SHIELD we were up against (Promon doesn’t ship version strings in the binary), but fingerprinting put it somewhere in the 7.x line, with full Android 15 support and the modern raw-syscall detection pipeline enabled.
We’re publishing this writeup not because the bypass itself is novel — Promon already patched around it, as they always do — but because the process of getting there was one of the more interesting deep dives we’ve done in a while.
The other reason this writeup exists: almost all of the work — static analysis, hypothesis generation, Frida script iteration, bypass scripting, test orchestration, even most of the reverse engineering — was driven by our internal AI tooling. A human set the goal and reviewed the results, but the loop of “decompile, hypothesize, instrument, test, refine” ran largely on its own across hundreds of test iterations. Five years ago this would have been a multi-month project for a small team. It is genuinely hard to overstate how much the floor has moved on what a single researcher (plus agents) can take apart.
What We Bypassed, At A Glance
At a high level, here’s everything that had to fall for the app to launch cleanly on a rooted device with a hooking framework attached:
• Native /proc scanning via raw ARM64 SVC #0 syscalls — Promon’s primary library reads /proc/self/maps, /proc/self/mounts, and /proc/self/status (TracerPid) without ever touching libc, making every standard Interceptor.attach hook on open/read/openat useless. Bypassed with a seccomp-based syscall redirector loaded as a bundled .so.
• Native system-property detection via __system_property_read_callback and __system_property_foreach, looking for ro.secure, ro.debuggable, ro.build.tags, verified-boot state, and friends. Bypassed by spoofing the underlying property store with resetprop before the app ever launches.
• Four-stage Java integrity checks inside Application.attachBaseContext, each one comparing an “actual” array slot against an “expected” slot and throwing RuntimeException on mismatch — running before Java.perform is usable on Android 15. Bypassed via Xposed-side resolver hooks that rewrite the comparison arrays in-place before the comparison runs.
• A second, per-Activity copy of those same checks inside BaseActivity.attachBaseContext, discovered only after the first layer was patched and the app started crashing one screen later.
• An independent ContentProvider Promon check inside FirebasePerfProvider, which throws a RuntimeException carrying a numeric Promon detection ID during installContentProviders — i.e., after both attachBaseContext layers had already been patched to pass.
• A coroutine-scheduled Promon check inside Dynatrace monitoring init, running on a DefaultDispatcher-worker-* thread several seconds into app lifecycle, throwing yet another numeric-ID
RuntimeException from a completely different call stack. Caught via a DispatchedTask.run afterHook.
• PackageManager queries for installed root-management apps (Magisk Manager, Superuser, etc.) — 19 known package names across 4 query APIs. Bypassed via an LSPosed module that filters them out of getInstalledPackages, getPackageInfo, and the resolver APIs.
• A native-state-driven OOM trap inside the first real Activity’s onCreate — when the native detection layer flags root, a downstream decryption/decompression routine produces garbage data and allocates ~438MB until the process dies. Not a comparison check at all; the kill is the OOM. This one only goes away when the native property layer is actually satisfied (spoofed at the property-store level, not just at the libc-hook level).
Six enforcement layers, three independent native detection mechanisms, four Java check stages per attachBaseContext, and a final OOM trap that exists only to punish researchers who think they’ve already won.
Because of the amount of details that went into each layer, we are going to split this writeup into 8 different blog posts, with each post being dedicated to a layer.
This post will covery Layer 2: Native system-property detection via __system_property_read_callback and __system_property_foreach.
Layer 2: Native system-property detection
System properties on Android are global key/value strings managed by init and stored in a shared memory region every process maps read-only at startup. There are two libc APIs that libRASP.so actually uses to read them, and we found both by hooking the libc exports and logging every property name whose return address landed inside libRASP.so‘s code range:
• __system_property_get(const char *name, char *value) — the classic one. You hand it a property name as a C string, it copies the current value into your buffer. Single call, single property, used heavily.
• __system_property_read_callback(const prop_info *pi, callback_fn, void *cookie) — the modern one, introduced in Android O. Instead of returning the value, you first call __system_property_find() to get a prop_info* handle for a given name, then hand that handle to __system_property_read_callback along with a callback function. The callback gets invoked with the property’s name, value, and a serial number. The serial number is the whole reason this API exists: properties whose values can change at runtime (anything not under ro.) need the serial to detect mid-read updates, and the older __system_property_get can’t do that. libRASP.so uses this API even for ro.* properties — probably because it’s harder to hook generically (callback-based APIs don’t have a single chokepoint where the value is “returned”) and because mixing both APIs forces a bypass to cover both.
libRASP.so also imports __system_property_foreach for one or two enumeration passes, but the bulk of the actual root-relevant reads go through find + read_callback.
The properties Promon cares about, in three groups:
• Build-type indicators. ro.secure should be 1 on production, 0 on engineering builds. ro.debuggable should be 0 on production, 1 on userdebug/eng. ro.build.tags should be release-keys on OEM-signed firmware, test-keys on AOSP/custom ROM builds. ro.build.type should be user, not userdebug or eng. These are the oldest root indicators in the book — any custom ROM that forgot to fix them is instantly flagged.
• Verified Boot state. This is the critical group, set by the bootloader before userspace exists. ro.boot.verifiedbootstate should be green (locked, verified boot passing, OEM key) — the alternatives are yellow (locked, user-installed key), orange (unlocked), or red (verification failed). ro.boot.flash.locked should be 1. ro.boot.vbmeta.device_state should be locked. Together these three are a near-perfect proxy for “this device can have Magisk installed on it,” because Magisk requires an unlocked bootloader and an unlocked bootloader can’t produce green/1/locked. A stock-rooted Pixel reports orange/0/unlocked, full stop, and Promon kills the app immediately when it sees that.
• ADB/init service state. init.svc.adbd reports whether adbd is running. Not a root indicator on its own, but Promon weighs it.
Those eight properties are exactly the watch list we reconstructed from our hook logs — pulled directly from the bypass script as the CLEAN_PROPS map:
var CLEAN_PROPS = {
"ro.secure": "1",
"ro.debuggable": "0",
"ro.build.tags": "release-keys",
"ro.build.type": "user",
"ro.boot.verifiedbootstate": "green",
"ro.boot.flash.locked": "1",
"ro.boot.vbmeta.device_state": "locked",
"init.svc.adbd": "stopped"
};
The kill, as with the /proc scanning layer, isn’t issued from the native code itself. The native side reads the properties, packs the verdict into a result array, and a downstream Java-side comparison in attachBaseContext is what actually throws. There’s also a second consequence we’ll come back to in a later section: when this property check flags the device, the first real Activity downstream uses the same native verdict to seed a decryption routine, and the seeded-with-bad-data routine eventually runs the process out of memory with a 438 MB allocation. So this layer doesn’t just have one kill path — it has a fast Java throw and a slow OOM trap, both fed by the same native property reads.
(We will come back to the “out of memory” issue in a future blog post).
Why hooking the libc APIs is fragile
The obvious bypass is to Interceptor.attach on __system_property_get, __system_property_read_callback, and __system_property_foreach, scope the hooks to calls coming from inside libRASP.so‘s code range, and rewrite the values on the way out. This is exactly what one of our Frida bypasses did and it worked — for about 25 seconds.
Three things go wrong:
The callback API doesn’t have a clean return-value to rewrite. __system_property_read_callback doesn’t return the property value; it invokes a function pointer Promon supplied, with the value as one of the arguments. To spoof it, you have to wrap Promon’s callback in your own callback that calls Promon’s with the spoofed value substituted. We did exactly this — built a small native trampoline at runtime that intercepted the cookie/name/value triple, swapped the value pointer to one of our pre-allocated clean strings if the name was in the watch list, and forwarded to the original. The full hook, as it appeared in our bypass script, looks like this:
Interceptor.attach(libc.getExportByName("__system_property_read_callback"), {
onEnter: function(args) {
var origCB = args[1];
// Scope to calls originating from inside libRASP.so only
var fromAc70 = false;
try {
var mod = Process.findModuleByAddress(this.context.lr);
if (mod && mod.name.indexOf("ac70") !== -1) fromAc70 = true;
} catch(e) {}
if (!fromAc70) return;
var origFn = new NativeFunction(origCB, "void",
["pointer","pointer","pointer","uint32"]);
var wrapper = new NativeCallback(function(cookie, namePtr, valPtr, serial) {
var name = namePtr.readCString();
if (spoofedStrings[name]) {
origFn(cookie, namePtr, spoofedStrings[name], serial);
return;
}
if (name && (name.indexOf("magisk") !== -1
|| name.indexOf("supersu") !== -1
|| name.indexOf("xposed") !== -1)) {
origFn(cookie, namePtr, emptyString, serial);
return;
}
origFn(cookie, namePtr, valPtr, serial);
}, "void", ["pointer","pointer","pointer","uint32"]);
wrapperCBs.push(wrapper);
if (wrapperCBs.length > 1000) wrapperCBs.splice(0, 500);
args[1] = wrapper; // replace Promon's callback with ours
}
});
It works, but the wrapper trampolines have to be allocated somewhere, kept alive forever, and tracked across thousands of calls — we capped them at 1000 and recycled. Every wrapper is one more thing in /proc/self/maps — the very file the other native layer is scanning. The two bypasses interact: aggressive callback wrapping pollutes maps; aggressive maps filtering has to know about the wrapper allocations.
The __system_property_get hook is simpler — it has a real return buffer to overwrite on the way out — but requires the same caller-scoping discipline:
Interceptor.attach(libc.getExportByName("__system_property_get"), {
onEnter: function(args) {
this.name = args[0].readCString();
this.vbuf = args[1];
},
onLeave: function() {
if (this.name && CLEAN_PROPS[this.name])
this.vbuf.writeUtf8String(CLEAN_PROPS[this.name]);
}
});
Inline hooks on libc trip Promon’s code-integrity check. Once you’ve got Interceptor.attach installed on __system_property_get, the first few bytes of that function in libc have been overwritten with a branch to Frida’s trampoline. Promon (in a separate detection layer covered later) scans the prologue bytes of specific libc functions via raw svc reads of its own loaded libc image and notices when the bytes don’t match. The property hook itself is what trips that check.
We spent a multi-day stretch trying to make inline-hook-based property spoofing work, and documented every failure:
• ADRP crash at 43ms. All three hooks installed. Process dies 43ms after load. Saved prologue in the trampoline page contained a PC-relative ADRP instruction. When that instruction executes from the trampoline’s address rather than its original location, the computed page address is wrong by however far the trampoline is from the original code — the function dereferences garbage and faults.
• mprotect degradation. Switched to a restore-call-rehook approach: restore the original bytes before calling the function, execute the real function, re-hook afterward. No prologue relocation needed. But mprotect marks the page rwx the first time and it never fully restores to read-execute — Promon’s rwx-page scanner (the same one that catches Frida’s COW trampolines in /proc/self/maps) flags it on the next scan pass. Five test iterations, five different failure timings, all the same root cause.
• zygote fork invalidation. Inline hooks installed in the zygote process are COW-mapped into every forked child. The trampoline pages don’t inherit correctly — address space layout shifts on fork, the trampolines point into the parent’s layout, and the first call through a hooked function in the child process segfaults. This killed every non-target app on the device that used __system_property_get, which is essentially all of them.
The conclusion was that hooking the reader is the wrong layer. Promon goes to lengths to make sure it can detect modifications to the reader. The right layer is the source data.
Property reads happen too early to hook from Java. libRASP.so‘s property scan runs from inside JNI_OnLoad, which the runtime invokes synchronously during System.loadLibrary(), which Promon triggers from Application.attachBaseContext before Java.perform is usable on Android 15. Even if you could install your hooks fast enough, you couldn’t install them from a Java context. Native-side hook installation works but you’re now doing it from your own Frida agent, and as we just covered, being a Frida agent in the process is itself something Promon detects.
Bypass #2 – resetprop
The way out is to stop hooking the reader entirely and just write the values Promon is going to read into the actual property store before the app launches.
resetprop is a Magisk built-in. It’s a small CLI tool (and a corresponding native API exposed to Magisk modules) that does exactly what its name says: it modifies the live property store in shared memory, so any subsequent __system_property_get or __system_property_read_callback call on any property it touched returns the new value.
The implementation is really neat — properties in the shared memory store are technically immutable once published, so resetprop walks the underlying trie, finds the entry for the property name, and either updates it in place (for persist.* and read-write properties) or deletes and re-adds it under the same name (for ro.* properties, which Android marks read-only after first write). The kernel doesn’t enforce the read-only flag — it’s a userspace convention checked by init — so a privileged process holding the right file descriptors on the property serial files can rewrite the trie entry directly.
For our purposes, the important parts are:
1. It modifies the source data, not the API. After resetprop ro.boot.verifiedbootstate green runs, every reader of that property — Promon’s __system_property_get, Promon’s __system_property_read_callback, the Java SystemProperties.get, even getprop from a shell — sees green. There is no hook to detect, no trampoline to allocate, no per-process state. The property is just green from now on.
2. It runs from outside the target process. resetprop is a separate binary executed via adb shell su -c (or a Magisk post-fs-data.d script) before the Android application process even starts. By the time the app’s zygote fork happens, the property store the new process maps in already has the spoofed values. Promon’s JNI_OnLoad reads them and gets clean values, on the first try, with no race window.
3. It survives dlopen, zygote fork, and inline-hook integrity checks. Because the values are sitting in shared memory rather than being intercepted at function-call time, none of the things that broke our libc-hook approach apply. There’s no libc prologue to compare against; there’s no per-process hook state to lose at fork; there’s no agent footprint in /proc/self/maps.
On the test device, the three verified-boot properties read as follows before any intervention:
$ adb shell getprop ro.boot.verifiedbootstate orange $ adb shell getprop ro.boot.flash.locked 0 $ adb shell getprop ro.boot.vbmeta.device_state unlocked
The full fix for this layer is three commands run before launching the app:
adb shell su -c 'resetprop ro.boot.verifiedbootstate green' adb shell su -c 'resetprop ro.boot.flash.locked 1' adb shell su -c 'resetprop ro.boot.vbmeta.device_state locked'
To verify that this layer is independently load-bearing we ran a control test (Test 93o) with the seccomp /proc redirector in place but resetprop intentionally omitted. The app died with an out-of-memory kill at 716 MB — the OOM trap downstream of the native property verdict firing. When resetprop was added back under the same conditions (Test 93b), the OOM did not occur and all four Java-side enforcement stages passed. The property check is independently required — it isn’t covered by the /proc bypass, and omitting either one is fatal.
That’s it for the verified-boot triplet. The build-type properties (ro.secure, ro.debuggable, ro.build.tags, ro.build.type) on our test devices were already set to clean values by the OEM firmware — Magisk doesn’t change them, because they’re not in the boot-time property partition Magisk has any reason to touch. On a custom ROM where they aren’t clean (e.g. a userdebug LineageOS build), you’d need three more resetprop calls to fix them.
One non-obvious gotcha
There’s a sequencing problem we ran into and it deserves a callout because it cost us most of a day to track down: the Android application we were looking at auto-launches during boot, before any post-boot script has a chance to run resetprop. Android’s package manager, on first boot after install, fires an ACTION_BOOT_COMPLETED broadcast that the app subscribed to, and the app’s first launch happens with the unspoofed property values still in place. Promon’s native detection runs, flags the device as rooted, writes the result into its on-disk RASP state cache (yes, it caches), and exits. Then your post-boot script runs resetprop. Then you launch the app manually for testing and Promon reads the cached “this device is rooted” verdict from disk instead of re-checking the (now clean) property store. The app dies anyway.
The fix is one extra command:
adb shell pm clear com.targetapp
pm clear wipes the app’s data directory, which includes Promon’s cached RASP state. After that, the next launch re-runs the native detection from scratch, against the spoofed property store, and gets clean values. force-stop is not enough — it kills the process but leaves the disk cache.
The other option is to run resetprop from a Magisk post-fs-data.d script, which executes earlier in the boot sequence than the app’s auto-launch, so the property store is already clean by the time Promon first reads it. Either approach works.
With resetprop handling the property layer and the seccomp redirector handling the /proc layer, both of libRASP.so‘s native detection mechanisms now return clean data, and the app survives long enough to hit the next problem — which is in Java, runs four times in a row inside Application.attachBaseContext, and is the subject of the next section.
Recent Comments
Great write‑up, Qt — this was a really satisfying read. The way you chained the JSInterface abuse, OTA mechanics, and…
Great write‑up, Qt — this was a really satisfying read. The way you chained the JSInterface abuse, OTA mechanics, and…
Well done Lyes, i liked the last graph it sums it up pretty well. You demonstrated how crucial it is…
👍👍