subtitle

Blog

subtitle

A Look
At A Promon Shield Bypass – Part 1

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 1: Native /proc scanning via raw SVC #0.

Layer 1: Native /proc scanning via raw SVC #0

The first thing Promon’s primary detection library — a ~1.1 MB native .so we’ll just call libRASP.so from here on — does at load time is reach into /proc/self/ and look for evidence that something is wrong with the process. That part isn’t novel. Every commercial RASP does it. What makes this implementation interesting is how it does it.

What gets read

Using Frida’s Stalker to trace syscalls out of libRASP.so, the actual on-device behavior is surprisingly tight. There are exactly three files it cares about, read in a loop on the main thread during early init:

/proc/self/maps — lists every memory-mapped region in the process: code, data, JIT pages, mapped libraries, anonymous pages. Promon is scanning the contents for injected agents (frida-agent-64.so, gadget, xposed), suspicious rwxp pages on top of libc (Frida’s copy-on-write trampolines from inline hooks), memfd: entries, and JIT-style anonymous executable mappings that wouldn’t normally exist in a clean process.
/proc/self/mounts — lists every mounted filesystem in the process’s mount namespace. Promon is scanning for Magisk’s overlay mounts: tmpfs mounted on /sbin, magisk overlays underneath /system, /data/adb bind mounts, and frida / lsposed / supersu mount entries left behind by Magisk modules.
/proc/self/status — the kernel’s per-process status dump. Promon only cares about one field in it: TracerPid:. A non-zero value means a debugger or ptrace-based hooking framework is attached, and the app is dead.

Here’s the actual Stalker output we captured from the main thread during early init, showing two consecutive detection passes:

Pass 1:
    openat(-100, "/proc/self/maps", O_RDONLY)       → fd=142
    read(142, buf, 2048) × ~160 times               → reads entire maps file
    close(142)
    clock_gettime (syscall 113)                     → timer between passes

Pass 2:
    openat(-100, "/proc/self/maps", O_RDONLY)       → fd=142
    read(142, buf, 2048) × ~160 times
    close(142)
    openat(-100, "/proc/self/mounts", O_RDONLY)     → fd=143
    read(143, buf, 2048) × ~34 times
    close(143)

The -100 as the first argument to openat is AT_FDCWD — the “use current working directory” sentinel. The fd numbers (142, 143) are whatever the kernel happened to hand back in that particular run. What matters is the shape: open, read-in-a-tight-loop until EOF, close, do it again. That’s the entire file-based detection surface of libRASP.so.

Notably absent from the trace: openat on /sbin/su, /system/xbin/su, /system/app/Superuser.apk, any magisk path, any faccessat, any fstatat, any ptrace, any execve. The Java side of the app does have su-path file-existence checks, but none of those are wired to the kill path. The native enforcement layer is exclusively /proc scanning. This was a real surprise — we spent days bind-mounting empty files over /sbin/su and friends with zero effect on survival time, and only confirmed via stalker that the library never touches them at all.

Why none of the usual hooks work on it

The reason this is hard to bypass is everything that happens between the C library and the kernel. Or rather, the lack of it.

A normal Android .so that wants to read /proc/self/maps does something like:

int fd = openat(AT_FDCWD, "/proc/self/maps", O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);

Those are libc functions. They live in libc.so. They have an entry in libRASP.so‘s GOT (Global Offset Table). And critically, they’re trivially hookable — Interceptor.attach(Module.getExportByName(“libc.so”, “openat”), {…}) is a one-liner in Frida and it intercepts every call any library in the process makes through the libc wrapper.

libRASP.so doesn’t do any of that. Disassembling the binary with radare2, the raw instruction count is easy to confirm in one line:

$ r2 -q -c '/x 010000d4' libRASP.so | wc -l
431

The pattern 010000d4 is the little-endian byte encoding of SVC #0 on ARM64 — the supervisor-call instruction that traps directly into the kernel, completely bypassing libc. Four hundred and thirty-one of them. And they’re not clustered in a single detection function — they span from offset 0x2A2FC all the way to 0xF7A80, an ~833 KB range, which is most of the binary’s .text section.

To put 431 in context: a normal Android library has zero. Even libc itself only has a few hundred, because it’s the syscall wrapper for the entire system. A 1 MB application library having 431 raw syscall sites means roughly one out of every ~25 instructions in the whole binary is a direct kernel trap.

What this means in practice:

Interceptor.attach on openat, read, close, stat, fopen — does nothing. The library never calls those functions. It writes the syscall number into x8 and the arguments into x0x5 and executes svc #0 itself. Verified in Test 9 of our diagnostic harness: zero calls into the libc wrappers from libRASP.so‘s code range.
Interceptor.attach on libc‘s internal __openat / syscall() wrapper — also does nothing. Same reason. The library doesn’t go through any libc symbol on its way to the kernel.
GOT patching — does nothing. The library has effectively no GOT entries for the file I/O syscalls. There’s nothing to patch.
LD_PRELOAD-style symbol interposition — does nothing. Same reason: there’s no symbol resolution happening at the call sites.

Just hooking the SVC instructions, then?

This was our first instinct: if Frida can’t catch the syscalls at the libc layer, we’ll catch them at the instruction layer. Frida’s Stalker can rewrite basic blocks as they execute and inject callouts before any instruction, including svc. We tried it. It works — for about ten seconds — and then the app dies, because Stalker is heavyweight enough that the timing-based anti-debug checks elsewhere in libRASP.so notice the slowdown and trigger the kill from a different code path.

Stalker also has a second problem: libRASP.so does not make finding the syscalls easy. The X8 register (which holds the syscall number on ARM64) is never set with a single MOV x8, #imm instruction before svc #0. Instead, the value is computed through a multi-instruction obfuscation chain that looks roughly like this at every site:

LDR     x9, [gvar_11Cxxx]       ; load a global "constant"
NEG     x9, x9
ORR     x9, x9, #0xBCBCA9B8     ; xor/or with junk
EOR x9, x9, #0xB8DEBBBD AND x9, x9, #0xFF SMADDL x10, w9, w11, x12 ; index into a function pointer table LDR x13, [gvar_114820, x10] ; load wrapper pointer BLR x1 ; call the wrapper, which does the SVC

The magic constants 0xBCBCA9B8 and 0xB8DEBBBD are real values lifted from the binary, not illustrative. Every SVC site in the library uses the same pattern with the same class of constants, just pulled from different global variables each time.

So statically, you can’t just grep for mov x8, #0xDD; svc #0 (syscall 221 = openat) because the syscall number doesn’t appear as an immediate anywhere. It’s computed at runtime through a jump table whose entries themselves are computed from XOR’d globals. This obliterates the standard “find every syscall site, hook each one” approach. You’d need symbolic execution to recover the syscall numbers, and even then the call sites are scattered across the 833 KB range above with no clustering.

The 431 raw SVC instructions also aren’t 431 unique call sites in the source — they’re calls through a jump table to a much smaller set of wrapper functions. But the indirection is per-call, computed from per-site globals, so you can’t just patch the wrapper either: the call never goes through a single chokepoint that you can intercept once.

The detection-to-kill chain

The other trap in this layer is that the native scanning code is not the thing that kills the app. The C side of libRASP.so only collects results; the actual RuntimeException throw happens in Java, inside BaseApplication.attachBaseContext, after the native detection results have been written into a Java array and compared against an “expected clean” array slot.

Here’s the exact decompiled throw site, from JEB, at line 649 of the decompiled attachBaseContext body:

// BaseApplication.attachBaseContext, decompiled line 649
throw new RuntimeException(String.valueOf(v30));

v30 is the first element of an integer array pulled out of the check’s result structure (decompiled as arr_object30[1][0]) — it’s a per-detection numeric identifier hash, and it’s what ends up as the exception’s message string. The actual RuntimeException values we observed in crash logs across different runs were things like:

RuntimeException: 211957597
RuntimeException: 210361851
RuntimeException: 178001524
RuntimeException: 75200735

Those are not error codes in any conventional sense. They’re whatever integer hash Promon’s internal detection-identification scheme assigned to that particular check firing, verbatim. If you see a numeric-only RuntimeException message in logcat from an app with a ~1 MB obfuscated native library, you are almost certainly looking at Promon.

So even if you found a way to hook every read() call against /proc/self/maps and filter out the Frida lines, you’re not done — you also have to make sure the Java-side comparison passes. (That’s its own multi-stage layer, covered in a later section.)

What this means for the bypass: we needed something that could intercept /proc/self/maps, /proc/self/mounts, and /proc/self/status reads at a level below libc — somewhere the raw svc #0 instruction would be forced through, no matter how the calling code dispatched it — and serve back a clean version of the file.

The answer turned out to be seccomp-bpf. That’s the next section.

Bypass #1: seccomp-bpf

The bypass needed three properties.

• It had to intercept reads at a level below libc, so the raw svc #0 instructions in libRASP.so couldn’t slip past it.

• It had to be selective enough that it only redirected the /proc/self/{maps,mounts,status} reads coming from Promon, without breaking the thousands of other syscalls every other thread in the app makes per second.

• And it had to be cheap enough that it didn’t show up in any of Promon’s other detection layers as a timing anomaly. Frida’s Stalker already failed all three of those.

seccomp-bpf is the kernel feature that fits.

What seccomp-bpf actually is

seccomp-bpf is a Linux kernel facility that lets a process install a Berkeley Packet Filter program that runs on every syscall the process makes, in kernel mode, at the syscall entry point — before the syscall actually executes. The filter inspects the syscall number and arguments and returns a verdict: ALLOW, KILL, ERRNO, TRACE, or — the one we care about — TRAP. A TRAP verdict raises a SIGSYS signal in the calling thread instead of executing the syscall. From inside the SIGSYS handler, you get a siginfo_t containing the original syscall number and a pointer to the saved register state (ucontext_t), and you can do whatever you want before returning — including writing a fake return value into x0 of the saved registers and resuming the thread as if the syscall had completed normally.

Here’s the skeleton of the handler signature, which is just a standard POSIX SA_SIGINFO signal handler:

void sigsys_handler(int signum, siginfo_t *info, void *ucontext_raw) {
    ucontext_t *uc = (ucontext_t *)ucontext_raw;
    int syscall_nr = info->si_syscall;
    // The saved register state lives at uc->uc_mcontext.regs[0..30]
    // On arm64 that starts at offset 168 inside ucontext_t.
    // regs[0] = x0 = syscall arg 0 = return value
    uint64_t *regs = (uint64_t *)((char *)uc + 168);
    // ... do work, write fake return into regs[0], return ...
}

The 168-byte offset to uc_mcontext.regs is a detail that matters: it has to be hand-coded because we can’t pull in ucontext.h cleanly in a -nostdlib shared object, and it differs between arm64 and arm32. We verified the offset empirically on both test devices (kernels 5.10 and 6.1) and it was 168 on both — Google’s arm64 bionic has been stable on this for a long time.

Two things make this exactly the right primitive for this job:

1. It runs in the kernel, on the syscall path itself. It does not care whether the userspace code reached the syscall via libc::openat(), via syscall(SYS_openat, …), or via a hand-written mov x8, #56; svc #0 buried inside an obfuscated jump table. By the time the BPF filter sees the syscall, all of that is gone — there’s just a syscall number and six arguments. Promon’s 431 raw svc sites and the entire computed-jump-table indirection layer become completely irrelevant. The kernel sees openat no matter how you got there.

2. The filter can match on the calling instruction pointer. This is the critical bit. BPF filters get the program counter of the instruction that issued the syscall as part of their input. That means we can write a filter that says “if the syscall came from this specific page of memory, allow it through; otherwise, trap.” That’s how the bypass avoids breaking every other syscall in the process — more on that in a second.

The architecture of the redirector

The whole bypass fits into one Frida agent script (later rewritten as a bundled .so so it works without Frida at all, but the structure is the same). It does five things at startup, in order:

Step 1: Build clean copies of the three files. Before any filter is installed, the agent reads the real /proc/self/maps, /proc/self/mounts, and /proc/self/status, filters them line by line, and writes the cleaned versions out to /data/local/tmp/.cm, .ct, and .cs. The filters are small:

// maps filter — drop Frida-adjacent lines, neuter rwxp pages
for each line in /proc/self/maps:
    if line contains "frida":    continue
    if line contains "gadget":   continue
    if line contains "gum-js":   continue
    line = line.replace("rwxp", "rw-p")  // kill anonymous JIT giveaways
    emit line

// mounts filter — strip Magisk/frida overlay mounts
for each line in /proc/self/mounts:
    lower = line.lower()
    if "magisk"  in lower: continue
    if "frida"   in lower: continue
    if "lsposed" in lower: continue
    if "supersu" in lower: continue
    if "/sbin" in lower and "tmpfs" in lower: continue
    emit line

// status filter — TracerPid must be 0
for each line in /proc/self/status:
    if line.startswith("TracerPid:"):
        emit "TracerPid:\t0"
    else:
        emit line

These three clean files are now the “answers” the bypass will hand back to Promon whenever it asks. (The “frida / lsposed / supersu” mount-filter entries are not hypothetical — we had to add each of them after empirically hitting a Promon kill that came from a Magisk module overlay mount we’d missed. The original filter only stripped “magisk” and survived fine until we enabled a Magisk module that bind-mounted a frida-server binary into /system/bin, which showed up as a mount entry containing “frida” and immediately tripped detection.)

Step 2: Allocate a single RWX code page via mmap. This page is going to host two things: a small set of trampoline stubs for issuing real syscalls from inside the SIGSYS handler, and the SIGSYS handler itself, written as raw ARM64 machine code. Putting both on the same page is what makes the IP-based filter check work — the filter only needs to whitelist one contiguous 4 KB range.

void *code_page = mmap(NULL, 4096,
    PROT_READ | PROT_WRITE | PROT_EXEC,
    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// Layout:
//  +0x000: openat trampoline    (12 bytes)
//  +0x010: faccessat trampoline (12 bytes)
//  +0x020: newfstatat trampoline (12 bytes)
//  +0x030: read trampoline      (12 bytes)
//  +0x040: close trampoline     (12 bytes)
//  +0x100: SIGSYS handler (~500 bytes ARM64 machine code)
//  +0x800: fake path strings ("/data/local/tmp/.cm", etc.)
//  +0x900: path-match patterns ("/self/maps", "/self/mounts", ...)

The trampolines are dead simple. Each one is three instructions. Here they are as raw ARM64 bytes, showing the actual encoding that gets written into the page:

// openat trampoline @ +0x000 (SYS_openat = 56 = 0x38)
08 07 80 d2    // mov    x8, #56
01 00 00 d4    // svc    #0
c0 03 5f d6    // ret

// read trampoline @ +0x030 (SYS_read = 63 = 0x3f)
e8 07 80 d2    // mov    x8, #63
01 00 00 d4    // svc    #0
c0 03 5f d6    // ret

// close trampoline @ +0x040 (SYS_close = 57 = 0x39)
28 07 80 d2    // mov    x8, #57
01 00 00 d4    // svc    #0
c0 03 5f d6    // ret

01 00 00 d4 is the same SVC #0 byte pattern we grepped for with radare2 in the previous section — these are the first SVC bytes in the process that our code owns, and their instruction-pointer addresses are what the BPF filter is going to whitelist. There’s one of these stubs for openat (56), faccessat (48), newfstatat (79), read (63), and close (57). They’re laid down at known 16-byte-aligned offsets within the page so the handler can call them as regular function pointers:

typedef int (*openat_fn)(int dirfd, const char *path, int flags, int mode);
openat_fn trampoline_openat = (openat_fn)((char *)code_page + 0x000);
int fd = trampoline_openat(AT_FDCWD, "/data/local/tmp/.cm", O_RDONLY, 0);

When the handler calls that function pointer, the call lands inside the whitelisted page, the three-instruction stub runs, and the svc #0 at the middle of the stub is evaluated by the BPF filter — which sees the PC is inside the whitelist and immediately returns ALLOW. Real syscall, no recursive trap.

Step 3: Hand-assemble the SIGSYS handler. The handler is roughly 500 bytes of ARM64 machine code, also written into the same code page. It runs every time Promon’s code issues a TRAP-flagged syscall. Pseudocode:

on SIGSYS(int signum, siginfo_t *info, ucontext_t *uc):
    syscall_nr = info->si_syscall
    regs = &uc->uc_mcontext.regs[0]      // x0..x30, at offset 168 in uc

    if syscall_nr == openat:
        path = (char *)regs[1]                  // x1 = second arg to openat
        if strstr(path, "/self/maps"):
            path = "/data/local/tmp/.cm"
        elif strstr(path, "/self/mounts"):
            path = "/data/local/tmp/.ct"
        elif strstr(path, "/self/status"):
            path = "/data/local/tmp/.cs"
        // reissue through the trampoline — this goes through the kernel for real
        int fd = trampoline_openat(regs[0], path, regs[2], regs[3])
        regs[0] = fd                            // write return value into saved x0
        return

    if syscall_nr == read:
        // the fd in x0 is already one of our fake-file fds — just pass through
        regs[0] = trampoline_read(regs[0], regs[1], regs[2])
        return

    if syscall_nr == close:
        regs[0] = trampoline_close(regs[0])
        return

    if syscall_nr == faccessat or syscall_nr == newfstatat:
        // if path is a root-indicator string, fail with ENOENT
        path = (char *)regs[1]
        if path_matches_root_pattern(path):
            regs[0] = -ENOENT              // -2
        else:
            regs[0] = trampoline_faccessat(regs[0], path, regs[2], regs[3])
        return

The key trick is the two-level dispatch. When Promon calls openat(“/proc/self/maps”, …), the kernel traps into our handler. The handler rewrites the path argument to point at our pre-built fake file, then calls its own trampoline to actually issue the openat syscall. That second syscall comes from inside the trampoline page, which the BPF filter whitelists, so it goes through to the kernel unmolested and returns a real file descriptor pointing at the fake file. The handler writes that fd into the saved register state and returns. From Promon’s perspective, the original openat(“/proc/self/maps”) call just returned a normal-looking fd. Subsequent read() calls against that fd return the contents of the fake file, not the real one. close() closes it normally. Promon never knows.

Step 4: Install the SIGSYS handler with sigaction. Standard libc call. The handler address is the offset within the code page where we wrote the machine code.

struct sigaction sa = {0};
sa.sa_sigaction = (void (*)(int, siginfo_t *, void *))((char *)code_page + 0x100);
sa.sa_flags     = SA_SIGINFO;
sigemptyset(&sa.sa_mask);
sigaction(SIGSYS, &sa, NULL);

Step 5: Install the BPF filter via prctl(PR_SET_NO_NEW_PRIVS, 1) followed by seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &fprog). The filter program itself is short — about 13 BPF instructions. Here’s the real sock_filter array we install, with comments:

//  seccomp_data layout (what the BPF program reads from):
//      [0]     u32 nr              (syscall number)
//      [4]     u32 arch
//      [8]     u64 instruction_pointer (lo 32 at [8], hi 32 at [12])
//      [16] u64 args[6]

struct sock_filter filter[] = {
    // --- IP whitelist: is the syscall coming from our trampoline page? ---
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 12),                     // A = ip_hi
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, page_hi, 0, 6),         // if A != page_hi: goto nr
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 8),                      // A = ip_lo
    BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, page_lo, 0, 4),         // if A < page_lo: goto nr
    BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, page_lo + 4096, 3, 0),  // if A >= page_end: goto nr
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),               // in our page -> ALLOW

    // --- syscall number check: not from our page, is it one we care about? ---
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 0),                      // A = syscall_nr
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat,     4, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_faccessat, 3, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_newfstatat, 2, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_read,       1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),               // not watched -> ALLOW
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRAP),                // watched     -> TRAP
};

struct sock_fprog prog = {
    .len = sizeof(filter) / sizeof(filter[0]),
    .filter = filter,
};

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
syscall(__NR_seccomp,
    SECCOMP_SET_MODE_FILTER,
    SECCOMP_FILTER_FLAG_TSYNC,
    &prog);

(Note that we call the raw seccomp syscall directly via syscall(__NR_seccomp, …) rather than using the libc wrapper or prctl(PR_SET_SECCOMP, …). The prctl variant doesn’t support TSYNC — it only installs the filter on the calling thread. The standalone seccomp syscall is the only way to get multi-thread install on arm64 bionic.)

The first half of the filter does the IP whitelist check: if the syscall was issued from anywhere inside our trampoline page, ALLOW it immediately, no further questions. The second half only runs if the syscall came from anywhere else in the process (i.e., from libRASP.so). It checks the syscall number against the four we care about, and TRAPs if it matches. read is in the watched list because some of Promon’s reads of the fake file still need to pass through the handler so we can validate the fd, but in practice the handler just forwards them straight to the trampoline — the redirection has already happened at openat time.

The SECCOMP_FILTER_FLAG_TSYNC flag is what makes this actually usable in a multi-threaded Android app. Without TSYNC, seccomp filters only apply to the thread that installed them; with TSYNC, the kernel walks every thread in the process and installs the filter on all of them atomically. Since the app already has dozens of threads running by the time the bypass agent loads — main thread, GC threads, Binder threads, the JIT compiler, the heap profiler — TSYNC is non-negotiable. (We learned this the hard way; without TSYNC, the filter only installs on the Frida agent thread, Promon’s main-thread scan goes through unfiltered, and the app dies in 4 seconds.)

Why this works where Stalker didn’t

The seccomp approach has three advantages that turn out to matter a lot:

• Zero per-syscall userspace overhead for the syscalls Promon doesn’t care about. Stalker recompiles every basic block in the process before executing it, which adds 5–10x overhead on every instruction. Seccomp BPF runs entirely in the kernel and adds maybe 50 nanoseconds to the watched syscalls and zero to everything else. Promon’s anti-debug timing checks elsewhere in the binary never see a slowdown.

• It catches the syscall regardless of how the calling code dispatched it. This is the whole point. Promon can compute its syscall number through 200 instructions of XOR-rotated globals and a SMADDL into a function pointer table; doesn’t matter, the kernel sees openat either way.

• The IP whitelist is the bypass to its own trap. A naive seccomp filter that just TRAPs on openat would deadlock the process — the SIGSYS handler can’t issue openat to read the fake file because that openat would also trap, recursively. The IP check is what breaks the recursion: the handler issues real syscalls through its own trampoline page, which the filter explicitly allows, so the handler can do real I/O while every other code path in the process gets redirected.

Two things that still went wrong

Even with the architecture right, two things bit us:

The fake maps file goes stale. /proc/self/maps reflects the current memory layout of the process, which changes constantly — every dlopen, every JIT compilation, every garbage collection cycle, every new thread stack. Promon scans it on a timer, and if the fake file we wrote at startup gets too far out of sync with the real layout, the absence of expected entries (a recently-loaded system library, say) becomes its own anomaly. The fix was a periodic refresh: a Java-side Timer running every 5 seconds that re-reads the real maps through the trampoline (so the read isn’t trapped), re-filters it, and rewrites the fake file in place.

// Java side (in the LSPosed module):
new Timer().scheduleAtFixedRate(new TimerTask() {
    @Override
    public void run() {
        nativeRefreshFakeFiles();  // JNI into our .so
    }
}, 5000, 5000);

// Native side:
JNIEXPORT void JNICALL
Java_com_bypass_Module_nativeRefreshFakeFiles(JNIEnv *env, jclass cls) {
    // call directly into the trampolines, bypassing the filter
    int fd = trampoline_openat(AT_FDCWD, "/proc/self/maps", O_RDONLY, 0);
    char buf[65536];
    int n  = trampoline_read(fd, buf, sizeof(buf));
    trampoline_close(fd);
    filter_maps_inplace(buf, n);
    // write filtered result back out to /data/local/tmp/.cm
    int out = trampoline_openat(AT_FDCWD, "/data/local/tmp/.cm",
        O_WRONLY | O_CREAT | O_TRUNC, 0644);
    trampoline_write(out, buf, n);
    trampoline_close(out);
}

We initially tried to run the refresh from a native background thread we spawned with clone(), but that crashed for reasons related to the new thread inheriting the seccomp filter in a state that the libc thread initializer didn’t like. Calling nativeRefreshFakeFiles() from a Java Timer sidesteps all of that — the Timer runs on an existing thread that’s already been through all the normal init paths, and the refresh is just a few syscalls.

The mounts file is mostly static, so it only needs one refresh; the status file needs the same 5-second cycle as maps because some fields (memory counters) change continuously and Promon may hash the whole file.

The SIGSYS handler is not allowed to use most of libc. Inside a signal handler, you can only call async-signal-safe functions, and even then only carefully. We can’t call printf, can’t take a mutex, can’t allocate. This is why the handler is written as raw machine code rather than a C function — it lets us be precise about exactly which instructions execute and in what order, with no compiler-inserted prologue/epilogue calling into pthread cleanup or anything else that would trip over the signal context. (We initially tried writing the handler in C with __attribute__((no_stack_protector)) and a hand-rolled _start, but the toolchain insisted on emitting a call to __stack_chk_fail that pulled in a pile of libc state. Switching to hand-assembled ARM64 was easier than fighting the linker.)

The handler prologue and epilogue, for the curious, end up looking like this — standard arm64 function entry/exit but without any stack protector:

// prologue: save callee-saved regs we touch
stp     x19, x20, [sp, #-16]!
stp     x21, x22, [sp, #-16]!
stp     x23, x24, [sp, #-16]!
stp     x29, x30, [sp, #-16]!
mov     x29, sp

// ... body: dispatch on info->si_syscall, call trampolines ...

// epilogue: restore and return
ldp     x29, x30, [sp], #16
ldp     x23, x24, [sp], #16
ldp     x21, x22, [sp], #16
ldp     x19, x20, [sp], #16
ret

The result

With the seccomp redirector active, Promon’s three /proc reads return clean data, the native detection result that gets written into the array Java compares against comes back clean, and this entire layer goes silent. Survival time on the test device went from ~10 seconds (immediate kill once the first scan completed) to unbounded with respect to this layer specifically — i.e., the app no longer dies for any reason traceable to /proc scanning.

It does still die, of course, because there are five more enforcement layers waiting behind this one. The next one we hit is the Java-side four-stage integrity check inside Application.attachBaseContext, which runs before Java.perform is even available on Android 15. We will cover that 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? 

  Book a demo

1 Comment

Comments are closed.