Files
apk-reverse/references/dynamic-frida.md
T
inliver233 c66658b6b3 feat: apk-reverse Agent Skill
Progressive-disclosure skill for Android APK reverse engineering:
recon, ad removal, membership limits, dex surgical patching, repack/sign,
runtime data, Frida, environment setup, and a hard-won failure catalogue.
2026-09-19 21:25:35 +08:00

6.5 KiB
Raw Blame History

Dynamic analysis with Frida

Static analysis tells you what code exists. Frida tells you what code runs. When they disagree, runtime wins (references/pitfalls.md P10).

Use Frida when you need to answer:

  • Which method actually renders this element? (static decompilation routinely misleads)
  • What is the real call chain at the moment of interest?
  • What does the app actually do on startup, in order?
  • Which domains does it resolve, and when?
  • Is the app detecting my instrumentation?

Setup

  1. Matching versions. The frida Python package on the host and frida-server on the device must be the same version. Mismatch produces confusing handshake failures.
  2. Run the server as root, and give it a non-obvious name — many hardened apps look for processes/files named frida-server.
    adb push frida-server-<ver>-android-<abi> /data/local/tmp/
    adb shell "su -c 'mkdir -p /data/local/tmp/.svc'"
    adb shell "su -c 'cp -f /data/local/tmp/frida-server-<ver>-android-<abi> /data/local/tmp/.svc/kwork'"
    adb shell "su -c 'chmod 755 /data/local/tmp/.svc/kwork'"
    adb shell "su -c '/data/local/tmp/.svc/kwork' &"
    
  3. Keep the server alive. Launching it through a shell that exits can get it reaped. Hold the connection open from the host (spawn it from a script that stays alive), or use a supervisor on device.
  4. Pick the right ABI — getprop ro.product.cpu.abi.
  5. Spawn, don't attach, when timing matters. Attaching usually misses startup (init, first network calls, splash logic).
    device = frida.get_usb_device()
    pid = device.spawn([pkg])
    session = device.attach(pid)
    # ... load script, wait until hooks are installed ...
    device.resume(pid)          # only resume AFTER hooks are ready
    
    Common bug: resuming before the script has finished loading loses the first ~100–300 ms, which is exactly where init happens. Have the script send() a ready signal and resume only after receiving it.

Frida 17+ gotchas

  • The built-in Java bridge was removed. Java.perform(...) is not available unless you inline the bridge yourself:
    bridge = open(<site-packages>/frida_tools/bridges/java.js, encoding='utf-8').read()
    script = bridge + "\nObject.defineProperty(globalThis,'Java',{value:bridge,configurable:true});\n" + my_js
    
    Pass the whole thing through a small loader script (below).
  • Export lookup renamed. Use a compatibility helper rather than a single API:
    function resolveExport(name) {
      try { if (Module.getGlobalExportByName) return Module.getGlobalExportByName(name); } catch (e) {}
      try { if (Module.findExportByName) return Module.findExportByName(null, name); } catch (e) {}
      try { const l = Process.findModuleByName('libc.so'); if (l) return l.getExportByName(name); } catch (e) {}
      return null;
    }
    
  • Large script injection times out. A multi-hundred-KB bridge + script can exceed the transport timeout. Use a tiny loader and post the real payload:
    loader = "recv('go', m => { eval(m.payload.code); }); send('loader-ready');"
    sc = session.create_script(loader)
    sc.load()
    sc.on('message', on_message)          # wait for 'loader-ready'
    sc.post({'type': 'go', 'payload': {'code': BIG_JS}})
    

Hook strategy

Java layer (Kotlin/Java app)

Java.perform(() => {
  const Cls = Java.use('com.example.Helper');
  Cls.showAd.overload('android.app.Activity').implementation = function (a) {
    send({ tag: 'Helper.showAd', stack: Java.use('android.util.Log').getStackTraceString(
        Java.use('java.lang.Throwable').$new()) });
    return this.showAd(a);   // observe, then delegate
  };
});
  • Use overload(...) — obfuscated classes often have several methods with the same name.
  • To find obfuscated names, enumerate at runtime rather than guessing from decompiled short names:
    Java.enumerateLoadedClasses({
      onMatch: n => { if (n.indexOf('example') >= 0) send(n); },
      onComplete: () => send('done')
    });
    
  • Call stacks are the highest-value signal. Log.getStackTraceString(new Throwable()) or Java.use('android.util.Log').getStackTraceString(...) gives you the real chain from the framework down to the method — far more reliable than reading smali.

Native layer

const f = resolveExport('open');
Interceptor.attach(f, {
  onEnter(args) { this.p = args[0].readCString(); },
  onLeave(ret) { if (this.p) send({ tag: 'open', path: this.p }); }
});

Network / domain observation (cheap and very informative)

const Inet = Java.use('java.net.InetAddress');
Inet.getAllByName.overload('java.lang.String').implementation = function (h) {
  send({ tag: 'dns', host: h });
  return this.getAllByName(h);
};

This is often the decisive evidence for an ad-removal claim: if the ad SDK's domains are never resolved, the subsystem never started — a much stronger statement than "logcat was quiet". It also works for SDKs that bypass the system HTTP proxy.

Reading obfuscated code at runtime

When names are meaningless (a7, x6, uf0), do not patch from names. Instead:

  1. Find a unique anchor: an API path, a preference key, or an SDK class name from the string table (scripts/dex_strings.py).
  2. Locate the small class/method that references it (byte search in the dex + a targeted disassembly).
  3. Hook that anchor, capture the stack, and read off the real chain.
  4. Hook the classes the stack reveals — that is where semantics become visible.

Anti-instrumentation

Signs: the app exits or freezes shortly after attach; logcat shows a generic process death with no Java stack; strings like frida, xposed, magisk, substrate are referenced by app code (not just by an SDK's string list).

Countermeasures, in order of least disruption:

  1. Spawn + late resume, so hooks are installed before the check runs.
  2. Rename frida-server and move it out of obvious paths.
  3. Hook the detector itself and neuter it — find it by hooking the suspicious API (e.g. /proc/self/maps reads, File.exists, Runtime.exec) and inspecting the stack.
  4. Only then consider a gadget-based approach.

Remember: most frida/root strings in a decompiled APK belong to third-party SDKs' own detection lists, not to the app. Verify that app code references them before doing anti-anti work.

What to capture for the record

  • spawn time, hook-ready time, resume time
  • every hook hit with a stack (bounded — cap output)
  • DNS hosts in order, with timestamps
  • the exact build under test (hash) and the control build's results