Created
December 23, 2025 14:01
-
-
Save erev0s/2fda747cc47dd520ee8e7c7d4bde0d46 to your computer and use it in GitHub Desktop.
How to find which functions are being called in a native library in Android when the application has anti-instrumentation?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 'use strict'; | |
| /* | |
| App Native Lib Sorter + Hotspot Tracer | |
| Goals: | |
| - Track ALL app-origin native libs in the current process (split APK paths + extracted /data/app libs). | |
| - Hook “boundary APIs” (libc) once, attribute each hit to the calling module + callsite (returnAddress). | |
| - Produce “hotspots” per module (immediate libc considered boundary) | |
| - Optionally install second-stage detailed hooks on top hotspots. | |
| Notes: | |
| - “App-origin” libs are those under /data/app, /data/data, /data/user, or loaded from "*.apk!/lib/". | |
| - Tested only on Android 16/Frida 17 | |
| Author: | |
| - "erev0s <projects@erev0s.com>" | |
| */ | |
| /////////////////////////////// | |
| // Config | |
| /////////////////////////////// | |
| const LOG_NEW_MODULES = true; | |
| // Summary timing | |
| const SUMMARY_AFTER_MS = 10000; | |
| const SUMMARY_EVERY_MS = 30000; | |
| // How many hotspots to show per module in summary | |
| const TOP_CALLSITES_PER_MODULE = 20; | |
| // Cap hotspot map growth per module | |
| const MAX_CALLSITES_PER_MODULE = 3000; | |
| // Second-stage detailed callsite hooks - by default DISABLED! | |
| const ENABLE_DETAILED_HOTSPOT_HOOKS = false; | |
| const DETAILED_HOOK_AFTER_MS = 35000; // when to install (window to collect hotspots first) | |
| const DETAILED_TOP_MODULES = 3; // install detailed hooks only for top N modules | |
| const DETAILED_TOP_CALLSITES = 25; // per module | |
| const DETAILED_LOG_FIRST = 20; // log first N hits per callsite | |
| const DETAILED_LOG_EVERY = 500; // then every N hits | |
| // App lib rescans (in case dlopen hook is not available/reliable) | |
| const POLL_SCAN_MS = 2000; | |
| /////////////////////////////// | |
| // Timer polyfill (setInterval) | |
| /////////////////////////////// | |
| (function installTimerPolyfills() { | |
| if (typeof setTimeout !== "function") { | |
| throw new Error("setTimeout is not available in this Frida JS runtime"); | |
| } | |
| if (typeof setInterval !== "function") { | |
| const _intervals = new Map(); | |
| let _nextId = 1; | |
| globalThis.setInterval = function (fn, ms) { | |
| const id = _nextId++; | |
| function tick() { | |
| if (!_intervals.has(id)) return; | |
| try { fn(); } catch (e) { console.error(e.stack || e); } | |
| _intervals.set(id, setTimeout(tick, ms)); | |
| } | |
| _intervals.set(id, setTimeout(tick, ms)); | |
| return id; | |
| }; | |
| globalThis.clearInterval = function (id) { | |
| const t = _intervals.get(id); | |
| if (t) { | |
| try { clearTimeout(t); } catch (_) {} | |
| } | |
| _intervals.delete(id); | |
| }; | |
| } | |
| if (typeof clearInterval !== "function") { | |
| globalThis.clearInterval = function (_) {}; | |
| } | |
| })(); | |
| /////////////////////////////// | |
| // Helpers / compat | |
| /////////////////////////////// | |
| function enumerateModulesCompat() { | |
| if (typeof Process.enumerateModules === "function") return Process.enumerateModules(); | |
| if (typeof Process.enumerateModulesSync === "function") return Process.enumerateModulesSync(); | |
| throw new Error("No Process.enumerateModules* API available"); | |
| } | |
| function safeReadCString(p) { | |
| try { return p.readCString(); } catch (_) { return null; } | |
| } | |
| function safeDebugSym(addr) { | |
| try { return DebugSymbol.fromAddress(addr).toString(); } | |
| catch (_) { return addr.toString(); } | |
| } | |
| function moduleId(m) { | |
| return m.name + "@" + m.base.toString(); | |
| } | |
| function splitApkPath(path) { | |
| const bang = path.indexOf("!/"); | |
| if (bang === -1) return { apkPath: null, innerPath: null }; | |
| return { apkPath: path.slice(0, bang), innerPath: path.slice(bang + 2) }; | |
| } | |
| 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/"); | |
| } | |
| /////////////////////////////// | |
| // Process name (best-effort) | |
| /////////////////////////////// | |
| let PROCESS_LABEL = null; | |
| function trySetProcessLabel() { | |
| // 1) If Process.name exists | |
| try { | |
| if (typeof Process !== "undefined" && Process && Process.name) { | |
| PROCESS_LABEL = Process.name; | |
| return; | |
| } | |
| } catch (_) {} | |
| // 2) Try Java (ActivityThread) | |
| try { | |
| if (typeof Java === "undefined" || !Java.available) return; | |
| Java.perform(function () { | |
| try { | |
| const ActivityThread = Java.use("android.app.ActivityThread"); | |
| // currentProcessName exists on newer Android | |
| if (ActivityThread.currentProcessName) { | |
| const n = ActivityThread.currentProcessName(); | |
| if (n) PROCESS_LABEL = String(n); | |
| return; | |
| } | |
| } catch (_) {} | |
| try { | |
| const ActivityThread = Java.use("android.app.ActivityThread"); | |
| const app = ActivityThread.currentApplication(); | |
| if (app) { | |
| const ctx = app.getApplicationContext(); | |
| if (ctx) { | |
| const pn = ctx.getPackageName(); | |
| if (pn) PROCESS_LABEL = String(pn); | |
| } | |
| } | |
| } catch (_) {} | |
| }); | |
| } catch (_) {} | |
| } | |
| function procLabel() { | |
| if (PROCESS_LABEL) return PROCESS_LABEL; | |
| return "unknown"; | |
| } | |
| /////////////////////////////// | |
| // State: app libs + stats | |
| /////////////////////////////// | |
| const appModulesById = Object.create(null); | |
| const appModuleIds = new Set(); | |
| // id -> { total, apiCounts, callsites, callsiteCount } | |
| const statsById = Object.create(null); | |
| function ensureStats(id) { | |
| if (!statsById[id]) { | |
| statsById[id] = { | |
| total: 0, | |
| apiCounts: Object.create(null), | |
| callsites: Object.create(null), // addrStr -> { count, sym, apis:Set } | |
| callsiteCount: 0, | |
| }; | |
| } | |
| return statsById[id]; | |
| } | |
| function registerIfAppLib(m, reason) { | |
| if (!m || !m.path) return; | |
| if (!isAppOriginSo(m.path)) return; | |
| const id = moduleId(m); | |
| if (appModuleIds.has(id)) return; | |
| const sp = splitApkPath(m.path); | |
| appModuleIds.add(id); | |
| appModulesById[id] = { | |
| name: m.name, | |
| base: m.base.toString(), | |
| size: (m.size >>> 0), | |
| path: m.path, | |
| apkPath: sp.apkPath, | |
| innerPath: sp.innerPath, | |
| firstSeen: Date.now(), | |
| reason: reason || "unknown", | |
| }; | |
| ensureStats(id); | |
| if (LOG_NEW_MODULES) { | |
| console.log(`[+] App lib loaded: ${m.name} @ ${m.base} (size=${m.size})`); | |
| if (sp.apkPath) { | |
| console.log(` split: ${sp.apkPath}`); | |
| console.log(` path : ${sp.innerPath}`); | |
| } else { | |
| console.log(` path : ${m.path}`); | |
| } | |
| } | |
| } | |
| function scanLoadedModules(reason) { | |
| const mods = enumerateModulesCompat(); | |
| for (const m of mods) registerIfAppLib(m, reason || "scan"); | |
| } | |
| /////////////////////////////// | |
| // dlopen hook (best-effort) | |
| /////////////////////////////// | |
| function hookDlopenAny() { | |
| // android_dlopen_ext should live in linker/linker64; resolve globally. | |
| let dlopenExt = null; | |
| try { dlopenExt = Module.findExportByName(null, "android_dlopen_ext"); } catch (_) {} | |
| if (!dlopenExt) { | |
| console.log("[!] android_dlopen_ext not found; relying on polling scans."); | |
| return false; | |
| } | |
| console.log("[*] Hooking android_dlopen_ext @", dlopenExt); | |
| Interceptor.attach(dlopenExt, { | |
| onEnter(args) { | |
| this.path = safeReadCString(args[0]); | |
| }, | |
| onLeave() { | |
| const p = this.path; | |
| if (!p || !p.endsWith(".so")) return; | |
| setTimeout(() => scanLoadedModules("dlopen-scan"), 50); | |
| } | |
| }); | |
| return true; | |
| } | |
| /////////////////////////////// | |
| // libc symbol resolution | |
| /////////////////////////////// | |
| let libcMap = null; | |
| let libcMapBuilt = false; | |
| function buildLibcExportMapIfPossible() { | |
| if (libcMapBuilt) return libcMap; | |
| libcMapBuilt = true; | |
| libcMap = Object.create(null); | |
| let libc = null; | |
| try { libc = Process.getModuleByName("libc.so"); } catch (_) { libc = null; } | |
| if (!libc) { | |
| // libc not found (yet) | |
| return libcMap; | |
| } | |
| if (typeof libc.enumerateExports !== "function") { | |
| // enumerateExports missing in this runtime | |
| return libcMap; | |
| } | |
| try { | |
| const exps = libc.enumerateExports(); | |
| for (const e of exps) { | |
| if (e.type === "function") libcMap[e.name] = e.address; | |
| } | |
| } catch (_) { | |
| // ignore | |
| } | |
| return libcMap; | |
| } | |
| const resolver = (() => { | |
| try { return new ApiResolver("module"); } catch (_) { return null; } | |
| })(); | |
| function resolveAnySymbol(symbolNames) { | |
| // Returns first address found for any of symbolNames in libc / global space. | |
| // Strategy: | |
| // 1) libc.enumerateExports map (fast, if available) | |
| // 2) ApiResolver('module') exports:libc.so!sym | |
| // 3) Module.findExportByName("libc.so", sym) then global | |
| buildLibcExportMapIfPossible(); | |
| for (const s of symbolNames) { | |
| if (libcMap && libcMap[s]) return libcMap[s]; | |
| } | |
| if (resolver) { | |
| for (const s of symbolNames) { | |
| try { | |
| const ms = resolver.enumerateMatches(`exports:libc.so!${s}`); | |
| if (ms && ms.length > 0) return ms[0].address; | |
| } catch (_) {} | |
| } | |
| } | |
| for (const s of symbolNames) { | |
| try { | |
| const a1 = Module.findExportByName("libc.so", s); | |
| if (a1) return a1; | |
| } catch (_) {} | |
| try { | |
| const a2 = Module.findExportByName(null, s); | |
| if (a2) return a2; | |
| } catch (_) {} | |
| } | |
| return null; | |
| } | |
| /////////////////////////////// | |
| // Boundary API hook + per-module hotspots | |
| /////////////////////////////// | |
| const BOUNDARY_TARGETS = [ | |
| // file I/O | |
| { label: "open", syms: ["open", "open64", "__open_2"] }, | |
| { label: "openat", syms: ["openat", "__openat", "__openat2", "openat2"] }, | |
| { label: "read", syms: ["read", "__read_chk"] }, | |
| { label: "pread64", syms: ["pread64", "pread"] }, | |
| { label: "readv", syms: ["readv"] }, | |
| { label: "write", syms: ["write", "__write_chk"] }, | |
| { label: "pwrite64", syms: ["pwrite64", "pwrite"] }, | |
| { label: "writev", syms: ["writev"] }, | |
| // socket I/O | |
| { label: "connect", syms: ["connect"] }, | |
| { label: "send", syms: ["send", "sendto", "sendmsg", "sendmmsg"] }, | |
| { label: "recv", syms: ["recv", "recvfrom", "recvmsg", "recvmmsg"] }, | |
| // misc | |
| { label: "ioctl", syms: ["ioctl"] }, | |
| { label: "prctl", syms: ["prctl"] }, | |
| { label: "process_vm_readv", syms: ["process_vm_readv"] }, | |
| { label: "process_vm_writev", syms: ["process_vm_writev"] }, | |
| ]; | |
| let hookedBoundaryCount = 0; | |
| let attemptedBoundaryCount = 0; | |
| 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); | |
| // Lazy register if it’s app-origin | |
| 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)`); | |
| } | |
| /////////////////////////////// | |
| // Summary / sorting | |
| /////////////////////////////// | |
| function sortedModules(sortMode) { | |
| const list = []; | |
| for (const id of appModuleIds) { | |
| const mi = appModulesById[id]; | |
| const st = statsById[id] || { total: 0, apiCounts: {} }; | |
| list.push({ | |
| id, | |
| name: mi.name, | |
| base: mi.base, | |
| size: mi.size, | |
| calls: st.total || 0, | |
| path: mi.path, | |
| apkPath: mi.apkPath, | |
| innerPath: mi.innerPath, | |
| }); | |
| } | |
| if (sortMode === "size") { | |
| list.sort((a, b) => (b.size - a.size) || (b.calls - a.calls)); | |
| } else { | |
| list.sort((a, b) => (b.calls - a.calls) || (b.size - a.size)); | |
| } | |
| return list; | |
| } | |
| function topCallsitesForModule(id, n) { | |
| const st = statsById[id]; | |
| if (!st) return []; | |
| const entries = Object.keys(st.callsites).map(k => { | |
| const h = st.callsites[k]; | |
| return { addrStr: k, sym: h.sym, count: h.count, apis: Array.from(h.apis) }; | |
| }); | |
| entries.sort((a, b) => b.count - a.count); | |
| return entries.slice(0, n); | |
| } | |
| function printSummary(sortMode) { | |
| scanLoadedModules("periodic-scan"); | |
| const mods = sortedModules(sortMode || "calls"); | |
| console.log("\n================ Native libs summary ================"); | |
| console.log(`Proc: ${procLabel()} | libsTracked=${mods.length} | boundaryHooks=${hookedBoundaryCount}/${attemptedBoundaryCount}`); | |
| if (mods.length === 0) { | |
| console.log("No app-origin .so modules observed yet (or wrong process)."); | |
| console.log("====================================================\n"); | |
| return; | |
| } | |
| for (const m of mods) { | |
| console.log(`[*] ${m.name} @ ${m.base} | size=${m.size} | boundaryCalls=${m.calls}`); | |
| if (m.apkPath) { | |
| console.log(` split: ${m.apkPath}`); | |
| console.log(` path : ${m.innerPath}`); | |
| } else { | |
| console.log(` path : ${m.path}`); | |
| } | |
| const hs = topCallsitesForModule(m.id, TOP_CALLSITES_PER_MODULE); | |
| if (hs.length > 0) { | |
| for (const h of hs) { | |
| console.log(` [H] ${h.count} calls @ ${h.addrStr} -> ${h.sym} | APIs: ${h.apis.join(", ")}`); | |
| } | |
| } | |
| } | |
| console.log("====================================================\n"); | |
| } | |
| /////////////////////////////// | |
| // Second-stage detailed hotspot hooks | |
| /////////////////////////////// | |
| 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}`); | |
| } | |
| /////////////////////////////// | |
| // Main | |
| /////////////////////////////// | |
| console.log("[*] Starting app-native-libs hotspot tracer…"); | |
| trySetProcessLabel(); | |
| scanLoadedModules("initial-scan"); | |
| hookDlopenAny(); | |
| // Hook boundary APIs after a short delay (libc/linker may not be fully ready at very early spawn) | |
| setTimeout(() => { | |
| // refresh process label if Java is available | |
| trySetProcessLabel(); | |
| hookBoundaryApis(); | |
| }, 800); | |
| // Periodic rescans and summaries | |
| setInterval(() => scanLoadedModules("poll-scan"), POLL_SCAN_MS); | |
| setTimeout(() => printSummary("calls"), SUMMARY_AFTER_MS); | |
| setInterval(() => printSummary("calls"), SUMMARY_EVERY_MS); | |
| // Install detailed hooks after collection window | |
| setTimeout(() => installDetailedHooksOnTopHotspots(), DETAILED_HOOK_AFTER_MS); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment