The Limits of Static Analysis

In a previous post, we explored how to defeat native anti-tamper checks by statically analyzing a library in Ghidra and then patching the binary.

Static analysis is a powerful first step, but it has one fundamental weakness: you are reasoning about what the code does, not what it actually does at runtime.

Highly hardened applications deliberately make this harder. They may:

  • Pack or encrypt their native libraries: the .so file on disk is not the one that runs.
  • Load libraries late: the library you care about doesn’t exist in memory at startup.
  • Layer defenses: bypassing one check exposes another that you couldn’t even see before.

When static approaches stall, we need to shift gears and move to dynamic analysis — intercepting the application as it runs, hooking functions in memory, and manipulating behavior on the fly.

This is precisely what Frida is built for.

The Target: OWASP UnCrackable Level 3

The target for this walkthrough is the OWASP UnCrackable Android App Level 3 — a deliberately hardened crackme designed to challenge reverse engineers.

The goal is simple on the surface: enter the correct secret string into the app’s input field and press Verify.

Getting there is anything but simple. The app employs a full stack of defenses:

  1. Java-layer anti-tamper: root detection (RootDetection.checkRoot1/2/3), a debuggability check (IntegrityCheck.isDebuggable), and a library integrity verifier (MainActivity.verifyLibs).
  2. Native-layer Frida detection: libfoo.so actively scans for signs of instrumentation and, if it finds any, sends SIGKILL to the process via a forked watchdog.
  3. /proc/self/maps inspection: before the watchdog even starts, the library reads its own memory map looking for Frida’s agent .so file, which appears as a mapped region with a recognisable name.
  4. Secret obfuscation: the target string is stored XOR-encrypted and is only decrypted in memory at the moment it is needed — it is never present in plaintext on disk.

We will defeat all of these layers with a single Frida script.

Prerequisites: Extracting and Analysing the Native Library

Before we can write a single line of Frida JavaScript, we need to understand what we are hooking. Dynamic instrumentation and static analysis are not competing approaches — the best Frida scripts are written with a disassembler open in the other window.

The APK ships libfoo.so compiled for four ABIs: arm64-v8a, armeabi-v7a, x86, and x86_64. For this walkthrough we’ll target the x86_64 build.

Why x86_64 and not arm64-v8a?
The x86_64 binary runs natively on an Android emulator (AVD) without the overhead of instruction translation. The offsets we find in this library are the ones we’ll use directly in the script — no cross-architecture guesswork.

Step 1 — Extract the Library from the APK

An APK is just a ZIP archive. Open it in jadx-gui (or simply unzip it on the command line) and navigate to Resources → lib → x86_64. You’ll find libfoo.so sitting there:

Jadx tree view

Right-click the file in jadx and choose Export (or drag it out of the archive if using a file manager). Place it somewhere convenient — we’ll load it into Ghidra next.

Step 2 — Load into Ghidra

Open Ghidra, create a new project, and import the libfoo.so file (File → Import File). Ghidra will auto-detect it as an ELF shared library for the x86-64 ISA.

Ghidra import

When the import dialog finishes, double-click the file to open it in the CodeBrowser. Click Yes when asked to run auto-analysis — this populates the symbol tree, resolves imports, and gives Ghidra’s decompiler enough context to produce readable C.

Ghidra symbols
Ghidra symbols

The three offsets that matter for our script are found in the Symbol Tree and the Listing view:

Symbol / OffsetAddress in GhidraWhat it does
FUN_001037c00x001037c0 → offset 0x37c0Frida detection routine
FUN_001039100x00103910 → offset 0x3910Watchdog fork call
FUN_001012c00x001012c0 → offset 0x12c0Secret XOR decryption
goodbyeexported symbolKill-switch that crashes the app

With those offsets noted, load the same app onto your emulator and attach Frida — the runtime addresses will be module.base + offset for each one, since ASLR only shifts the base.

A Bird’s-Eye View of the Script

Before going layer by layer, it’s useful to understand the overall flow of the script. All the individual pieces feed into a single chain of callbacks:

blindMaps()
  └─> hookNativeLib('libfoo.so', bypassNDKAntiTamper)
        └─> bypassNDKAntiTamper('libfoo.so')
              └─> disableJavaAntiTamper()
                    └─> exploit('libfoo.so')

Each step in the chain unlocks the next. We have to neutralize the lower-level (native) defenses first, before the Java layer can be safely patched, before we can finally intercept the secret.

The entry point is blindMaps(), called at the very end of the script. Let’s work through each stage.


Stage 1: Blinding the /proc/self/maps Check

The very first thing we do is attack the anti-instrumentation check at its root.


void FUN_001037c0(void)

{
  FILE *__stream;
  char *pcVar1;
  char acStack_238 [520];
  
  __stream = fopen("/proc/self/maps","r");
  if (__stream == (FILE *)0x0) {
LAB_0010386c:
    pcVar1 = "Error opening /proc/self/maps! Terminating...";
  }
  else {
    do {
      while (pcVar1 = fgets(acStack_238,0x200,__stream), pcVar1 == (char *)0x0) {
        fclose(__stream);
        usleep(500);
        __stream = fopen("/proc/self/maps","r");
        if (__stream == (FILE *)0x0) goto LAB_0010386c;
      }
      pcVar1 = strstr(acStack_238,"frida");
    } while ((pcVar1 == (char *)0x0) &&
            (pcVar1 = strstr(acStack_238,"xposed"), pcVar1 == (char *)0x0));
    pcVar1 = "Tampering detected! Terminating...";
  }
  __android_log_print(2,"UnCrackable3",pcVar1);
                    /* WARNING: Subroutine does not return */
  goodbye();
}

Where goodbye function is simply:

/* WARNING: Unknown calling convention -- yet parameter storage is locked */
/* goodbye() */

void goodbye(void)

{
  raise(6);
                    /* WARNING: Subroutine does not return */
  _exit(0);
}

/proc/self/maps is a Linux pseudo-file that lists every memory region mapped into the current process — including its address, permissions, and the name of the file it’s backed by. It’s a goldmine for integrity checkers: a native library can open this file, scan it for strings like frida-agent, and if found, know that Frida is attached and kill the process.

Our counter-measure is a full replacement of fopen at the native level.

fopen(3)                                                                                                                  Library Functions Manual                                                                                                                 fopen(3)

NAME
       fopen, fdopen, freopen - stream open functions

LIBRARY
       Standard C library (libc, -lc)

SYNOPSIS
       #include <stdio.h>

       FILE *fopen(const char *restrict path, const char *restrict mode);
       FILE *fdopen(int fd, const char *mode);
       FILE *freopen(const char *restrict path, const char *restrict mode,
                     FILE *restrict stream);

   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       fdopen():
           _POSIX_C_SOURCE
                                                            .....
                                                            .....
                                                            .....
RETURN VALUE
       Upon successful completion fopen(), fdopen(), and freopen() return a FILE pointer.  Otherwise, NULL is returned and errno is set to indicate the error.
                                                            ....

Interceptor.replace is more powerful than Interceptor.attach. Instead of hooking before/after a function, it completely swaps out the function pointer with our own NativeCallback. Every call to fopen anywhere in the process now goes through our code first.

Our replacement is simple: if the path being opened contains /proc/self/maps, we silently redirect it to /etc/hosts — an innocuous file that will yield an empty (or harmless) list of lines. Any other path is passed through to the real fopen untouched.

Note: We save a reference to the original fopen as a NativeFunction before replacing it. This is critical — if we called fopen directly inside the replacement without going through the NativeFunction wrapper, we’d recurse infinitely back into our own hook.

function blindMaps(){
    send(`[+] Trying to blind maps check...`)
    const fopenPtr = Module.findGlobalExportByName("fopen"); // find fopen symbol exported by libc
    const fopen = new NativeFunction(fopenPtr, 'pointer', ['pointer', 'pointer']); // create NativeFunction wrapper for fopen

    Interceptor.replace(fopenPtr, new NativeCallback(function (pathPtr, modePtr) { // replace fopen with our own function
        const path = pathPtr.readUtf8String(); // First argument to fopen is the path to the file, read it

        // If the path contains "/proc/self/maps", replace it with "/etc/hosts"
        if (path !== null && path.indexOf("/proc/self/maps") !== -1) { 
            const fakePath = Memory.allocUtf8String("/etc/hosts"); // Allocate memory for the fake path
            return fopen(fakePath, modePtr); // Call the original fopen with the fake path
        }

        return fopen(pathPtr, modePtr); // Call the original fopen with the original path
    }, 'pointer', ['pointer', 'pointer']));
    hookNativeLib('libfoo.so', bypassNDKAntiTamper);
}