Figuring out 'hot' functions in native libraries on Android

How to find which functions are being called in a native library in Android when the application has anti-instrumentation?

Date and Time of last update Tue 23 Dec 2025
  

The goal is to figure out which functions are being called inside an Android native library and I mean even the ones that aren’t exported and the reflex answer you would often get is, “use Frida Stalker” without a second thought. Stalker is the “turn on wallhacks” button, but plenty of apps treat that like flipping on a giant neon sign that says “I’m instrumenting you.” On hardened targets, Stalker’s extra threads, code-cache activity, and timing distortions can trip anti-instrumentation tripwires fast. This of-course leads to sudden exits, silent feature kills, or a “protection failure” crash the moment you start tracing. In those situations you can’t just spray basic-block telemetry everywhere; you have to move like a burglar, not a bulldozer. This means try to hook a few boring boundary functions, watch which native libraries touch the filesystem or network, follow the heat, and only zoom in where it matters. The general approach is less “record the whole movie” and more “tap the right wire, read the right blip, and don’t wake the dragon.”

So instead, wouldn’t it be a nice idea to first map every app-native .so that gets loaded, then simply attribute each hit back to the exact library and callsite that triggered it and optionally plant a few tiny tripwire hooks on libc boundary calls (file, network, syscalls) ? No giant traces, just a heat map of where the native code actually touches the outside world, so you can follow the hottest paths. Of course, the trade-off is that any native code path that never touches the specific libc boundary calls we’ve hooked stays invisible to this approach.

The script is available here and you can find briefly some details about it in the following sections.

Discover the app-native libraries

Modern Android apps can load .so files either directly from APK containers (base.apk!/lib/...) or from extracted directories under /data/app/.... The script generalizes by selecting “app-origin” libraries using a path heuristic and explicitly excluding OS/runtime locations.

function isAppOriginSo(path) {
  if (!path || !path.endsWith(".so")) return false;

  // Exclude OS/runtime
  const sysPrefixes = ["/apex/", "/system/", "/vendor/", "/product/", "/odm/", "/system_ext/"];
  for (const p of sysPrefixes) if (path.startsWith(p)) return false;

  // Include app-origin - am i missing any?
  return path.startsWith("/data/app/") ||
         path.startsWith("/data/data/") ||
         path.startsWith("/data/user/") ||
         path.includes(".apk!/lib/");
}

Since applications often load additional .so files after startup. The script attempts to hook android_dlopen_ext, which is the standard dynamic-loader entrypoint. When a .so is loaded, it triggers a quick scan to register the new modules. Even if android_dlopen_ext cannot be hooked, the script still discovers late loads via periodic polling.

libc APIs

Instead of attempting to hook every function in every library (noisy and expensive), the script hooks a small, curated set of libc boundary APIs, things that represent “crossing into the OS” like file I/O, socket I/O, and a few control APIs. These become a lens for which libraries are actually doing things worth looking at.

const BOUNDARY_TARGETS = [
  { label: "open",  syms: ["open", "open64", "__open_2"] },
  { label: "openat", syms: ["openat", "__openat", "__openat2", "openat2"] },
  { label: "read",  syms: ["read", "__read_chk"] },
  { label: "write", syms: ["write", "__write_chk"] },
  { label: "connect", syms: ["connect"] },
  { label: "send", syms: ["send", "sendto", "sendmsg", "sendmmsg"] },
  { label: "recv", syms: ["recv", "recvfrom", "recvmsg", "recvmmsg"] },
  { label: "ioctl", syms: ["ioctl"] },
  { label: "prctl", syms: ["prctl"] },
];

Originating library and callsite

Each time a boundary API is called, the script reads this.returnAddress. That return address is where execution will return inside the caller’s module, so it identifies the callsite in the app’s .so. Then Process.findModuleByAddress() resolves which .so contains that callsite.

function hookBoundaryApis() {
  hookedBoundaryCount = 0;
  attemptedBoundaryCount = 0;

  for (const t of BOUNDARY_TARGETS) {
    attemptedBoundaryCount++;

    const addr = resolveAnySymbol(t.syms);
    if (!addr) continue;

    hookedBoundaryCount++;

    Interceptor.attach(addr, {
      onEnter() {
        const ra = this.returnAddress;
        if (!ra) return;

        let m = null;
        try { m = Process.findModuleByAddress(ra); } catch (_) { return; }
        if (!m) return;

        const id = moduleId(m);

        if (!appModuleIds.has(id) && isAppOriginSo(m.path)) registerIfAppLib(m, "lazy");
        if (!appModuleIds.has(id)) return;

        const st = ensureStats(id);
        st.total++;
        st.apiCounts[t.label] = (st.apiCounts[t.label] || 0) + 1;

        const addrStr = ra.toString();
        let cs = st.callsites[addrStr];
        if (!cs) {
          if (st.callsiteCount >= MAX_CALLSITES_PER_MODULE) return;
          cs = st.callsites[addrStr] = {
            count: 0,
            sym: safeDebugSym(ra),
            apis: new Set()
          };
          st.callsiteCount++;
        }
        cs.count++;
        cs.apis.add(t.label);
      }
    });
  }

  console.log(`[*] Hooked ${hookedBoundaryCount}/${attemptedBoundaryCount} boundary targets (libc-resolved)`);
}

The summary is intentionally structured as: list libs sorted by activity, then show top callsites per library with symbolized addresses and which boundary families they triggered.

Optionally hook hotspots

Once its known “which functions are hot”, its possible to attach directly to those callsite addresses and log richer context (thread ID, registers, etc.). This is explicitly delayed to give the hotspot collector time to converge.

function installDetailedHooksOnTopHotspots() {
  if (!ENABLE_DETAILED_HOTSPOT_HOOKS) return;

  const mods = sortedModules("calls").filter(m => m.calls > 0);
  if (mods.length === 0) {
    console.log("[*] Detailed hooks: no hotspot data collected (no boundary calls observed).");
    return;
  }

  const chosen = mods.slice(0, Math.min(DETAILED_TOP_MODULES, mods.length));

  let totalHooks = 0;
  console.log(`[*] Installing detailed hooks for top ${chosen.length} modules`);

  for (const m of chosen) {
    const hs = topCallsitesForModule(m.id, DETAILED_TOP_CALLSITES);
    if (hs.length === 0) continue;

    console.log(`[*] Module ${m.name}: installing ${hs.length} callsite hooks`);

    for (const e of hs) {
      const addr = ptr(e.addrStr);
      const label = e.sym;
      const apis = e.apis.slice();

      let hits = 0;
      totalHooks++;

      Interceptor.attach(addr, {
        onEnter() {
          hits++;

          if (hits > DETAILED_LOG_FIRST && (hits % DETAILED_LOG_EVERY) !== 0) return;

          const tid = (typeof Process.getCurrentThreadId === "function")
            ? Process.getCurrentThreadId()
            : -1;

          console.log(`\n[HOT] ${m.name} :: ${label} @ ${addr} | hit #${hits} | thread ${tid} | APIs: ${apis.join(", ")}`);

          // Generic arm64 registers (best-effort)
          try {
            console.log(
              "     x0=", this.context.x0,
              "x1=", this.context.x1,
              "x2=", this.context.x2,
              "x3=", this.context.x3
            );
          } catch (_) {}
        }
      });
    }
  }

  console.log(`[*] Detailed hooks installed: ${totalHooks}`);
}

Running the script

The script is available here. A test run against the application of Spotify yields the following summary:

================ Native libs summary ================
Proc: com.spotify.music | libsTracked=3 | boundaryHooks=15/15
[*] liborbit-jni-spotify.so @ 0x71b3219000 | size=20656128 | boundaryCalls=134
    split: /data/app/[...]/split_config.arm64_v8a.apk
    path : lib/arm64-v8a/liborbit-jni-spotify.so
    [H] 108 calls @ 0x71b3aba38c -> 0x71b3aba38c liborbit-jni-spotify.so!_ZNSt6__ndk113random_deviceclEv+0x48 | APIs: read
    [H] 9 calls @ 0x71b3aba2ac -> 0x71b3aba2ac liborbit-jni-spotify.so!_ZNSt6__ndk113random_deviceC2ERKNS_12basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE+0x34 | APIs: open
    [H] 5 calls @ 0x71b43b5254 -> 0x71b43b5254 liborbit-jni-spotify.so!0x119c254 | APIs: read
    [H] 4 calls @ 0x71b42d3b58 -> 0x71b42d3b58 liborbit-jni-spotify.so!0x10bab58 | APIs: read
    [H] 3 calls @ 0x71b42d1f8c -> 0x71b42d1f8c liborbit-jni-spotify.so!0x10b8f8c | APIs: open
    [H] 3 calls @ 0x71b42d38b8 -> 0x71b42d38b8 liborbit-jni-spotify.so!0x10ba8b8 | APIs: write
    [H] 1 calls @ 0x71b42d2d88 -> 0x71b42d2d88 liborbit-jni-spotify.so!0x10b9d88 | APIs: open
    [H] 1 calls @ 0x71b42d2518 -> 0x71b42d2518 liborbit-jni-spotify.so!0x10b9518 | APIs: open
[*] libcrashlytics-common.so @ 0x71a04c1000 | size=778240 | boundaryCalls=0
    split: /data/app/[...]/split_config.arm64_v8a.apk
    path : lib/arm64-v8a/libcrashlytics-common.so
[*] libcrashlytics.so @ 0x72bc245000 | size=212992 | boundaryCalls=0
    split: /data/app/[...]/split_config.arm64_v8a.apk
    path : lib/arm64-v8a/libcrashlytics.so
====================================================

You can clearly see which function made the most calls, the locations and you get an overview of what was executed during that runtime.

Conclusion

In the end, this approach is about staying effective when the target is hardened. Start quiet, map what’s loaded, measure real-world “boundary” activity to rank what matters, then zoom into the hottest callsites with targeted hooks instead of noisy full tracing. You won’t see everything, but you’ll reliably have a starting point without tripping every alarm on the way in.