👍👍
Blog
A Look
At A Promon Shield Bypass – Part 3
April 29, 2026 Ken Gannon 3:59 am Description A
while back, we successfully bypassed a version of
- April 29, 2026
- Ken Gannon
- 3:59 am
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 3: Four-stage Java integrity checks inside Application.attachBaseContext.
Layer 3: Four-stage Java integrity checks inside Application.attachBaseContext
After spoofing the property store and redirecting the /proc reads, both of libRASP.so‘s native scans return clean data — but the app still dies, and the death is now coming from a completely different place. Decompiling BaseApplication.attachBaseContext in JEB reveals the actual kill:
// BaseApplication.attachBaseContext, decompiled line 649 throw new RuntimeException(String.valueOf(v30));
The native detection layer doesn’t kill the app on its own. It collects results, packs them into a Java-visible data structure, and the Java side of Promon’s attachBaseContext reads that structure, compares two slots in it, and throws RuntimeException if they don’t match. The RuntimeException messages we’d been seeing in crash logs the whole time — strings like 210361851, 178001524, 75200735, -114679483 — were never error codes in the conventional sense. They’re the value of arr[1][0] from the check structure: a per-detection numeric identifier hash that Promon presumably uses internally to correlate which specific check fired.
What the check actually does:
BaseApplication.attachBaseContext is roughly 400 lines of decompiled Java with extensive obfuscation, reflection chains, encrypted strings, and time-based caching. Stripped down, the integrity check it performs runs four times — once per “check stage” — and each stage looks roughly like this:
// Pseudocode of one check stage (Stage 2)
Object[] arr = Vehicle.Companion.r8lambdaiitljD3JTrGMJrz36Iqnyn59bI$7860359f(
appHash, // SHA hash of the running APK
0,
promonContext,
0x2825A97A // per-stage magic constant
);
// arr is a small Object[] — typically 4 to 10 entries
// arr[1] is an int[] (the detection ID)
// arr[2] is an int[] (the "actual" value reported by native detection)
// arr[3] is an int[] (the "expected" / clean-device value)
int actual = ((int[]) arr[2])[0];
int expected = ((int[]) arr[3])[0];
if (actual != expected) {
throw new RuntimeException(String.valueOf(((int[]) arr[1])[0]));
}
We confirmed this structure by hooking the resolver and dumping the returned arrays at runtime. Here’s the actual logcat output from our first successful Stage 2 patch:
*** PATCHED arr[2][0] = 120835348 (was 120835556) ***
The patch overwrote arr[2][0] with the value of arr[3][0], making the comparison pass. Stage 2 stopped throwing — but the process immediately died at a different line, from a different stage, on a different obfuscated class.
Four of these run sequentially inside attachBaseContext. Each one calls a different resolver method on a different obfuscated class, and each resolver synthesizes its Object[] from a different combination of native detection results. Decompiling and tracing the resolver calls gives us the full set:
• Stage 1 lives on the class JEB renames o.ActionBarDrawerToggle, in a method called r8lambdanDfriLBoWmeJDPIaf4XJSSHiO0w. Unlike the other three, this one returns a List rather than a bare Object[] — the list elements are themselves Object[] triples, and it appears to be a multi-row check that covers several detection categories at once (root, hook, debug all in one pass).
• Stage 2 is the “main” integrity check, on Vehicle$Companion in a method named r8lambdaiitljD3JTrGMJrz36Iqnyn59bI$7860359f. It takes the running APK’s hash plus a magic constant (0x2825A97A) as arguments and returns an Object[] directly. This is the stage with the cleanest “actual vs expected” structure and the one we patched first.
• Stage 3 lives on o.syncState, in a method called BalancesModuleKt, with magic constant 0x87818672. It returns a 7-element Object[] of mixed types. When we dumped it at runtime, the actual shape was:
syncState len=7 types=[byte[], int[]{220278506}, int[]{-1521323751}, null, int[]{220278507}, ArrayList, int[]{0}]
→ NO PATCH needed (arr[3] is null, arr[1] vs arr[4] already near-match)
The mixed-type shape matters for the bypass because a naive “cast slot N to int[]” patcher silently fails on the null and ArrayList slots. As it turns out, this stage doesn’t actually need patching at all on a tampered device — the resolver returns slots that are already equal to each other — but we didn’t realize that until later, and the assumption that it did need patching cost us a couple of days.
• Stage 4, on o.ActionBarLayoutParams in r8lambdaZ1tnmBMs91B1JhBxVtJR9DmI_U, returns a 10-element Object[] and is the one that uses a different comparison pattern from Stages 1 and 2. Stages 1 and 2 compare arr[2][0] against arr[3][0]. Stage 4 compares arr[1][0] against arr[3][0]. The JEB decompilation at lines 512–514 confirms this directly:
// Stage 4 comparison — JEB decompiled lines 512-514
v30 = arr_object37[1][0]; // detection ID slot, NOT arr[2]
v29 = arr_object37[3][0]; // expected-clean slot
if (v30 == v29) { // passes only if arr[1][0] == arr[3][0]
We didn’t discover this asymmetry until partway through the bypass and spent two days chasing what looked like a race condition — the hook was firing, our patch was running, and the comparison was still failing — before realizing the patch was modifying the wrong array index for that specific stage.
A few things about that need underlining.
The class and method names are obfuscated artifacts of R8/D8. When the target app was built, R8 (Android’s bytecode optimizer + minifier) inlined Promon’s check entry points into stub lambda methods and named them after R8’s internal r8lambda mangling scheme. o.ActionBarDrawerToggle and o.ActionBarLayoutParams are not actually the AndroidX UI classes you’d think they were from the names — they’re random Promon classes that R8 happened to assign those names to during obfuscation, because the optimizer ran out of short identifiers and started reusing strings from the constant pool. JEB’s deobfuscator gives them friendly-looking names but at runtime the classes are loaded under exactly those obfuscated identifiers.
The class names are real, but you can’t find them with Class.forName. This sounds contradictory and it is, sort of. The classes exist in the loaded process and are referenced from BaseApplication‘s code, so the running app can resolve them through its own class loader. But if you try XposedHelpers.findClass(“o.ActionBarDrawerToggle”, classLoader) from an LSPosed module, it throws ClassNotFoundException. Same for Class.forName. The reason is that LSPosed modules run in a class loader context where the target app’s class loader hasn’t been fully wired up yet — by the time attachBaseContext is running, the class loader can resolve the obfuscated names, but at the moment LSPosed wants to install the hook (during zygote fork, before attachBaseContext), the class loader doesn’t know about them yet. We’ll come back to this in the bypass section because it forces the hook installation to be lazy.
The check structure is “actual vs expected” by design. arr[2][0] is what Promon’s native detection actually reported for this check stage; arr[3][0] is what a clean device should report. They’re both integers — typically a small bitmask or hash — and on a clean device they’re equal because the resolver that builds the Object[] writes the same value into both slots. On a tampered device, the native detection writes a different value into arr[2][0]. The comparison is the gate; the throw is the kill.
This is structurally interesting because it means Promon doesn’t have to embed any specific “expected” values in the binary. Each per-stage Object[] is built dynamically from the device’s own state, so the “expected” slot is whatever-the-clean-version-would-have-produced for this device, computed at runtime. There’s nothing for an attacker to extract, decrypt, or precompute. The only thing that matters is whether the two adjacent slots in the array match, which means in principle the easiest bypass is to forcibly make them match before the comparison runs.
The Object[] shapes are inconsistent across stages and that matters. Stages 1 and 2 use the pattern arr[2][0] == arr[3][0]. Stage 4 uses arr[1][0] == arr[3][0]. Stage 3 has a 7-element array of mixed types where two of the slots are already equal on a tampered device, so it passes without needing to be patched at all (we initially assumed Stage 3 was the blocker for several days, because logs showed the hook firing and the values not changing — turned out the hook was patching the wrong index because we’d assumed it followed the Stage 1/2 pattern). The shapes are not documented anywhere; we recovered them by hooking the resolvers, dumping the returned Object[]s, and printing the runtime types of every entry.
Caching makes everything weirder
There’s one more thing about this layer that complicates the bypass: Promon caches the results of all four check stages with a ~32-minute TTL, using timestamp constants embedded in the obfuscated init code (0x7B1 and 0x798 seconds ≈ 32 minutes). On the first launch of the day, all four stages run in full, calling into libRASP.so, which does the /proc scans, the property reads, and packs the results. On subsequent launches within the cache window, the resolvers short-circuit and return a cached Object[] instead of re-querying native detection.
This produces a subtle and infuriating bug during bypass development. You make a change, install your LSPosed module, launch the app, see it crash in a slightly different way, conclude that your change was wrong, revert it, launch again, and see the original crash come back. Except you didn’t actually undo your change — your “different crash” was the cached check result from the previous launch firing inside your old hook, and the “reverted” launch was running fresh native detection because the cache happened to expire. We learned to pm clear between every test, hard, no exceptions, after losing about half a day to this. (pm clear is also what we already needed for the resetprop cache-poisoning issue from Layer 2, so by this point the test loop was already pinned around it.)
The cache is also why the cached Stage 3 array has arr[2] as a String instead of an int[] — the cache stores serialized representations rather than the live structures, and our first patchArray implementation crashed silently on arr[2] instanceof int[] == false. Different code paths, different array shapes, same hook.
Why the obvious bypasses don’t work
Before getting to the working approach, three things that don’t:
You can’t catch the RuntimeException. The throw is inside attachBaseContext, which is called by the framework, which doesn’t catch it. Even if you install an UncaughtExceptionHandler from native code before attachBaseContext runs, swallowing the exception leaves the rest of attachBaseContext unexecuted — which means all the legitimate clean-path init that comes after the check (Firebase initialization, MultiDex setup, encrypted config loading) doesn’t happen, and the next code that tries to use any of those subsystems crashes with a much more obscure NullPointerException.
You can’t skip Promon entirely. A natural-looking shortcut is to hook BaseApplication.attachBaseContext and replace its implementation with super.attachBaseContext(ctx) plus the bare minimum of init the rest of the app needs. We tried this. It fails because Promon’s init also sets up some legitimate state the app later depends on — encrypted resource loaders, telemetry context, the obfuscated string-decryption table — and reproducing all of that by hand is infeasible. The Promon init and the legitimate init are interleaved on purpose, and you can’t have one without the other.
You can’t suppress individual stages. The most surgical-looking bypass is to patch some stages and let others throw, then catch only the throws you couldn’t patch. We tried this: patched Stages 1 and 2, caught the Stage 3 throw, let Stage 4 run normally. Result: attachBaseContext returned without throwing, but the chained subclass attachBaseContext (which runs immediately after) crashed with:
OutOfMemoryError: Failed to allocate 435066760 bytes (435 MB)
The 435 MB allocation was a downstream decompression routine that depended on Stage 3 having stored a valid result (not just having not thrown), and the suppressed Stage 3 left the result slot uninitialized. Garbage in, 435 MB allocation out.
The conclusion from these three is that every check stage has to actually pass — not be caught, not be skipped, not be suppressed. The only way through is to make the comparisons evaluate to “equal” by patching the array contents on the way out of each resolver, before the comparison happens.
When we finally got it right — after discovering Stage 4’s different comparison pattern and adding what we called “Pattern B” to the patcher — the logcat output looked like this:
PATCH #1 Stage 1 arr[2][0]: 220278506 → 220278745 ✓ → NO PATCH needed for syncState (arr[3] is null, values already match) PATCH #2 Stage 2 arr[2][0]: 220278506 → 220278298 ✓ PATCH #3 Stage 4 (ActionBarLayoutParams) arr[1][0]: 220278506 → 220278509 ✓ ← Pattern B BaseApplication.attachBaseContext RETURNED NORMALLY! ✓✓✓
That was the first time in the entire research that attachBaseContext returned without throwing. Every previous test — over 70 of them — had died somewhere in this function. The process still died five seconds later from a completely different enforcement layer (the native background detection we hadn’t bypassed yet), but the Java integrity gate was finally open.
That’s the bypass, and it’s the next subsection.
Bypass #3 – Xposed resolver hooks + patchArray
The bypass for this layer needs to do three things, in this order:
• Get an LSPosed module loaded into the application process before attachBaseContext runs (covered earlier — that’s what LSPosed gets us)
• Find the four resolver methods at runtime even though their classes can’t be looked up by name
• Rewrite the contents of each Object[] they return so that the comparison in attachBaseContext evaluates to “equal” instead of throwing.
The first part is free — LSPosed handles it. The second and third parts are where most of the work is.
The “you can’t find the class” problem
The natural starting point for an Xposed module is something like:
Class<?> stage1 = XposedHelpers.findClass(
"o.ActionBarDrawerToggle", lpparam.classLoader
);
XposedHelpers.findAndHookMethod(
stage1, "r8lambdanDfriLBoWmeJDPIaf4XJSSHiO0w",
/* param types */,
new XC_MethodHook() { ... }
);
This throws ClassNotFoundException immediately. Here’s the actual logcat from the test where we tried it:
Stage 1 hook FAILED: ClassNotFoundException: o.ActionBarDrawerToggle Stage 3 hook FAILED: ClassNotFoundException: o.syncState Stage 2 hook INSTALLED (Vehicle$Companion — works as always) PATCH #1 Stage2 arr[2][0]: 109592873 → 109593049
Vehicle$Companion loaded fine because it’s in the base APK. The other three didn’t. Same for Class.forName(“o.ActionBarDrawerToggle”, false, lpparam.classLoader). Same for every variant of “look up the class by name.” The class genuinely exists in the loaded process — Promon’s own code references it from BaseApplication.attachBaseContext and resolves it just fine — but at the point in the lifecycle where LSPosed wants to install the hook, the class loader passed in lpparam.classLoader doesn’t yet know about it.
The reason is that o.ActionBarDrawerToggle and the other three resolver classes don’t live in the main APK — they’re in one of the target app’s split APKs. The target app’s build is split into a base APK and several feature splits (split_config.arm64_v8a.apk, split_config.en.apk, split_config.xxhdpi.apk, plus several feature module splits), and Promon’s obfuscated check classes happen to land in a feature split that gets loaded lazily during attachBaseContext itself, by Promon’s own dynamic class loader. By the time the hooks are firing, the classes exist; at the moment LSPosed wants to install them upfront, they don’t.
We tried every variant of forcing the lookup — passing the system class loader, passing the boot class loader, walking lpparam.classLoader.getParent() chains, even reflection into DexPathList to grep for the class manually. Nothing worked. The classes are not reachable from any class loader LSPosed has access to at hook-installation time.
The way out is to not look up the class. Instead, hook something that’s guaranteed to be reachable from the start, and use that hook as a tripwire to install the real hooks lazily — at the moment Promon itself first touches the resolver classes, by which point they’re loaded.
The getHideOffset recon trick
Reading through Promon’s attachBaseContext decompilation more carefully, every one of the four check stages goes through the same indirection layer to look up its resolver method. Promon doesn’t call r8lambdanDfriLBoWmeJDPIaf4XJSSHiO0w directly — it goes through a generic dispatcher that takes (Class targetClass, String methodName, Class… paramTypes) and returns a java.lang.reflect.Method ready to invoke. The dispatcher is implemented by R8-inlining a stub of androidx.drawerlayout.widget.DrawerLayout.getHideOffset(). R8 replaced an unused AndroidX accessor with Promon’s reflection helper because the optimizer noticed the original method was dead and recycled the slot.
The end result is that there’s exactly one method in the entire process that gets called every time Promon needs to resolve any check method, and that method is:
java.lang.reflect.Method DrawerLayout.getHideOffset(
Class<?> targetClass,
String methodName,
Class<?>[] paramTypes,
/* and a few more args */
)
DrawerLayout is in AndroidX and is in the boot class loader. Hooking it from LSPosed is trivial. And every one of Promon’s resolver lookups passes through it, with the targetClass and methodName of the resolver as arguments. So we install a single afterHook on DrawerLayout.getHideOffset and watch what comes through:
XposedBridge.hookMethod(getHideOffsetMethod, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Class<?> targetClass = (Class<?>) param.args[0];
String methodName = (String) param.args[1];
Method resolved = (Method) param.getResult();
if (resolved != null) {
log("getHideOffset → " + targetClass.getName() + "." + methodName);
// ... maybe install a hook on it
}
}
});
Launch the app once, dump the log, and every resolver Promon uses for any check stage shows up — with its real runtime class name, its method name, and a live Method handle. Over a single launch, the resolver was called 30 times to resolve various methods, fields, and constructors. Of those 30, the check stages were:
HOOKING o.ActionBarDrawerToggle.r8lambdanDfriLBoWmeJDPIaf4XJSSHiO0w → List ← Stage 1 HOOKING o.syncState.BalancesModuleKt → Object[] ← Stage 3 HOOKING o.ActionBarLayoutParams.r8lambdaZ1tnmBMs91B1JhBxVtJR9DmI_U → Object[] ← Stage 4
(Stage 2 on Vehicle$Companion was already hooked directly, since its class is in the base APK and doesn’t need the lazy path.) This is how we found all four of them. We didn’t have to know about Stage 4 in advance — the recon hook just printed it the first time Promon looked it up.
The same hook is then promoted from “log only” to “log and install”: when getHideOffset returns a Method whose return type is Object[] or List, we use XposedBridge.hookMethod(resolved, …) to install an afterHook on the resolver itself. The actual lazy-installer from the module, decompiled back from the APK:
void tryHook(Object obj) {
if (obj instanceof Method) {
Method method = (Method) obj;
Class<?> returnType = method.getReturnType();
if (returnType == Object[].class || returnType == List.class) {
String key = method.getDeclaringClass().getName() + "." + method.getName();
synchronized (hookedMethods) {
if (hookedMethods.contains(key)) return; // already hooked, skip
hookedMethods.add(key);
XposedBridge.hookMethod(method, patchHook);
}
}
}
}
This is the only hook installation API that works here, because we have a Method object directly and don’t need the class loader to be able to look up anything by name. By the time the resolver is called (which happens immediately after getHideOffset returns it), our hook is in place. The hookedMethods set prevents double-hooking across the 7-arg and 1-arg resolver variants.
There’s also a one-arg variant of the dispatcher used for cached lookups — Promon caches resolved Method objects by hash, and on a cached lookup it goes through a different shorter dispatcher that takes only the cache key. We hook that one too, with the same logic. The JEB decompilation shows this cache-then-resolve pattern at every call site:
// JEB decompiled — cache-then-resolve at a Stage 3 call site
object27 = getHideOffset(-505342808); // 1-arg: cache lookup by hash
if (object27 == null) { // cache miss → full resolution
object27 = getHideOffset(787, 23, '騧', 0x16FAA10D,
false, decryptedString,
new Class[]{int, int, int});
}
The combination of “hook the 7-arg lookup for fresh resolutions” + “hook the 1-arg lookup for cached resolutions” covers every code path Promon uses to get to a check method.
Patching the array on the way out
Once we have an afterHook installed on a resolver method, the patch itself is short. Inside the afterHook we get the resolver’s return value (the Object[] for stages 2/3/4, or the List<Object[]> for stage 1), we walk into it, and we overwrite the “actual” slot with the contents of the “expected” slot:
private static void patchArray(Object[] arr) {
if (arr == null || arr.length < 4) return;
// Pattern A: stages 1 and 2 — actual is arr[2][0], expected is arr[3][0]
if (arr[2] instanceof int[] && arr[3] instanceof int[]) {
int[] actual = (int[]) arr[2];
int[] expected = (int[]) arr[3];
if (actual.length > 0 && expected.length > 0) {
actual[0] = expected[0];
}
}
// Pattern B: stage 4 — actual is arr[1][0], expected is arr[3][0]
if (arr[1] instanceof int[] && arr[3] instanceof int[]) {
int[] actual = (int[]) arr[1];
int[] expected = (int[]) arr[3];
if (actual.length > 0 && expected.length > 0) {
actual[0] = expected[0];
}
}
}
That’s the entire patcher. Both patterns are unconditional — if the slot at index 2 is an int[], apply Pattern A; if the slot at index 1 is an int[], apply Pattern B. On stages 1 and 2, only Pattern A’s instanceof check passes and Pattern B is a no-op. On stage 4, only Pattern B’s check passes and Pattern A is a no-op. On stage 3 (the 7-element mixed-type array where everything is already equal), neither pattern’s instanceof passes for the slots that matter, so the patcher makes no changes — which is exactly the right behavior, because stage 3 doesn’t need patching.
The reason we ended up with two patterns rather than one is the two-day debugging detour we mentioned in the previous section. Initially we only had Pattern A, applied unconditionally to arr[2][0]. Stages 1 and 2 patched and passed cleanly. Stage 3 looked like a race condition because the hook was firing but the comparison was still failing — the hook was firing and was running our patch, but it was patching the wrong index for Stage 4 (we’d misidentified the failing stage as 3 because the cached-vs-fresh code paths looked identical in logs). Once we re-decompiled attachBaseContext more carefully, found Stage 4 as a separate check using a different array layout, and added Pattern B, both stages started passing on the next launch.
For Stage 1 specifically — the one whose resolver returns a List<Object[]> rather than a bare Object[] — the afterHook unwraps the list and calls patchArray on each element:
Object result = param.getResult();
if (result instanceof List) {
for (Object entry : (List<?>) result) {
if (entry instanceof Object[]) {
patchArray((Object[]) entry);
}
}
} else if (result instanceof Object[]) {
patchArray((Object[]) result);
}
That’s it for the Java integrity check layer. Three things in the LSPosed module: a hook on DrawerLayout.getHideOffset (and its 1-arg cache variant) that recognizes Promon’s resolver lookups and installs afterHooks on them lazily, a patcher that rewrites the Object[] contents on the way out using Pattern A or B based on which slots are int[]s, and a list unwrapper for Stage 1.
What happens at runtime
Launch sequence with the module installed, end-to-end:
1. Zygote forks the target application’s process. LSPosed’s loader runs first, sees the package match, and registers the getHideOffset hooks. No app code has run yet.
2. App main() runs, Application.attachBaseContext is called.
3. Promon’s init starts. It loads libRASP.so, JNI_OnLoad runs, native detection scans the (already spoofed) property store and the (already redirected) /proc files, packs the results into native data structures.
4. Stage 1 runs: Promon calls getHideOffset to resolve the Stage 1 method → our afterHook fires → we install an afterHook on the returned Method → Promon invokes the method → resolver builds the List<Object[]> from native results → our afterHook fires → we walk the list, run patchArray on each entry, set actual = expected everywhere → method returns → Promon’s check stage compares actual == expected, gets true, moves on.
5. Stage 2 runs: same pattern, single Object[] instead of a list, Pattern A patches arr[2][0], comparison passes.
6. Stage 3 runs: same recon path, our patcher’s instanceof checks all fail on the 7-element mixed array, no patch applied, but the underlying resolver already returns equal values for the slots Promon compares, so the comparison passes anyway.
7. Stage 4 runs: same recon path, Pattern B patches arr[1][0], comparison passes.
8. BaseApplication.attachBaseContext reaches its return statement at line 613 instead of throwing at line 649. Control returns to the framework. The app’s Application.attachBaseContext runs next, completes its own (now unblocked) init, and the app proceeds to its splash screen.
The actual logcat from that first successful run, with all four stages resolving and patching in sequence:
HOOKING o.ActionBarDrawerToggle.r8lambdanDfriLBoWmeJDPIaf4XJSSHiO0w → List PATCH #1 Stage 1 arr[2][0]: 220278506 → 220278745 ✓ → NO PATCH needed for syncState (arr[3] is null, values already match) PATCH #2 Stage 2 arr[2][0]: 220278506 → 220278298 ✓ HOOKING o.ActionBarLayoutParams.r8lambdaZ1tnmBMs91B1JhBxVtJR9DmI_U → Object[] PATCH #3 Stage 4 arr[1][0]: 220278506 → 220278509 ✓ ← Pattern B BaseApplication.attachBaseContext RETURNED NORMALLY! ✓✓✓
The Java integrity check layer is now silent. The next thing that goes wrong is in BaseActivity.attachBaseContext — Promon installs the same four-stage check on every Activity, not just the Application, and it has its own per-Activity copies of the resolvers. That’s the next layer, which we will discuss next time!
What you’ve seen in this research is just the start, at Djini.ai, we’re building agentic systems that automate advanced vulnerability research, including analyzing and bypassing RASP protections like Promon Shield.
Instead of spending weeks manually reversing and testing, Djini continuously explores attack paths, generates and validates bypasses, and even discovers novel techniques like the one shown in this articlecutting both time and cost significantly.
Want to see this in action?
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…