New: references/signature-derived-keys.md (apps that use their own signing certificate as crypto key material: detection greps, why offline extraction is unreliable -- the runtime value is a certificate DER from inside the PKCS#7 chain, not the .RSA container -- plus the hardcode-then-verify procedure); scripts/sig_probe.py (offline candidates via --apk, authoritative read via --live, both exercised); pitfalls.md P27 (rebuilt APK installs and renders but every signed request returns empty/-1 signing params). Updated: dynamic-frida.md (attach by PID when enumerate_processes omits the target; verify runtime/Java-bridge availability before blaming the script); runtime-data.md (MMKV format, parse/write snippet, crc32 and in-memory-overwrite traps); server-api.md (route the signature-derived-key case to the new reference); SKILL.md and README.md (new classification question, corrected question count, index entries).
20 KiB
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 — version alignment is a hard gate
Do this first. Nearly every "Frida is broken on this device" report is a version mismatch, and the error text rarely says so. The host frida package and the device-side frida-server must be the same version, and that version must actually support the device's Android release. Align, then debug everything else.
| Symptom | Cause | Fix |
|---|---|---|
unable to locate Android dynamic linker |
host/server too new for this Android release | drop to an older frida line (16.x is a safe baseline for older ROMs) |
Java is not defined / Java.perform is not a function |
that build ships no bundled Java bridge | align to a build that has it, or inline the bridge (see 17+ gotchas) |
Failed to connect to remote frida-server / unexpected message / invalid message |
host package and device server differ | download both from the same release tag |
Java.choose / Java.use throws immediately |
bridge present but the VM is not ready | wrap everything in Java.perform |
| Attached, but hooks fire in the wrong process | USB auto-selection grabbed the emulator | use an explicit remote device (below) |
HOOK-OK prints and nothing ever fires |
not a version problem | go to Hook never fires |
Prefer a remote device over USB
With a physical device and an emulator attached, frida.get_usb_device() can silently pick the emulator — you then attach to the wrong process and chase phantom failures for a long time. Address the device explicitly:
adb devices -l # get <serial>
adb -s <serial> forward tcp:27042 tcp:27042
frida -H 127.0.0.1:27042 -f <app.package> -l probe.js --no-pause
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
Keep -s <serial> on every other adb call too, or you will drift between targets mid-session.
Attach by PID when the process list is incomplete
device.enumerate_processes() can return a list that simply does not contain the app you are
targeting, while adb shell ps -A | grep <pkg> shows it running. This is an enumeration gap,
not a permission problem, and no amount of retrying fixes it. Resolve the pid through adb and
attach to it directly:
import subprocess
def find_pid(serial, pkg):
out = subprocess.run(['adb', '-s', serial, 'shell', 'ps -A -o PID,NAME'],
capture_output=True, text=True).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == pkg:
return int(parts[0]) # last match wins; use the main process
return None
pid = find_pid(serial, pkg)
session = device.attach(pid) # works where attach(pkg) raises ProcessNotFound
Two related behaviours worth knowing:
attach(<package name>)raisesProcessNotFoundErrorin exactly this situation, which is easy to misread as "the app is not running".- A pid captured earlier goes stale the moment the process is recycled. Re-resolve it immediately before each injection rather than reusing a value from an earlier step.
Verify the runtime before blaming the script
A script that reports nothing at all is usually not seeing the Java layer. Two failure modes look identical from the host — test for both in one go:
// send({ runtime: Script.runtime, java: typeof Java, objc: typeof ObjC });
Javaisundefined⇒ the script runtime has no Java bridge. Passruntime='v8'explicitly when creating the script; some builds default to a runtime without it.- The bridge exists but every hook misses ⇒ you attached to the wrong process, or the layer
below is native (go to the four-layer probe /
references/native-and-so.md).
Start the device server so it survives
Run it as root, give it a non-obvious name out of obvious paths, and detach it from the shell's session. A process started with nohup ... & from an adb shell that then exits gets reaped mid-run — the hooks work for a minute and then stop.
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 'setsid /data/local/tmp/.svc/kwork >/dev/null 2>&1 </dev/null &'"
adb shell "su -c 'pgrep -f kwork'" # must still be alive after the shell returned
If setsid is unavailable, hold the adb connection open instead: run adb shell "su -c '/data/local/tmp/.svc/kwork'" as a host-side background job and leave it running for the whole session.
Spawn, don't attach, when timing matters
ABI must match the device (getprop ro.product.cpu.abi). Attaching usually misses startup — init, the first network calls, and splash logic all happen before you get a session.
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
pid = device.spawn([pkg])
session = device.attach(pid)
# ... load the script, wait until it reports that hooks are installed ...
device.resume(pid) # resume ONLY 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 (the probe template below sends PROBE-READY) and resume only after receiving it.
Frida 17+ gotchas
The preferred fix for every symptom in the table above is version alignment. Use these only when you genuinely must run a 17+ build.
- The built-in Java bridge was removed.
Java.perform(...)is not available unless you inline the bridge yourself:Pass the whole thing through a small loader script (below).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 - 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())orJava.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.
The four-layer probe template
When the question is "why did this request fail / where did it go", do not hook one class and hope. Ship one long-lived script that hooks four layers at once; whichever layer fires first localizes the problem immediately. This is the single most productive artifact of a runtime investigation — reuse it verbatim.
The layers, and why each one is there:
- The app's own network wrapper — enumerate
getDeclaredMethodsand wrap every overload, so you do not have to guess the entry point. - OkHttp end to end —
newCall,Request$Builder.build,RealCall.execute,AsyncCall.run,RealInterceptorChain.proceed. The last two are the ones that fire for asynchronous calls. java.net.URL.openConnection— plenty of login/register paths never touch OkHttp; they useHttpsURLConnectionand a completely different trust configuration.Throwable.getMessage— pulls out the original text of exceptions an upper layer caught and swallowed. Without it, a caught failure looks like "nothing happened".
Rules that make the difference between a usable probe and a wasted session:
- Write to a file as well as to
send(). Stdout is lossy and the CLI drops messages; the file is the evidence. - Let it stay resident. Hook before the app touches the network, then leave it running while you drive the UI. A one-shot script misses everything that happens after its first second.
- Wrap every hook in its own
try/catch. One missing class must not take down the other three layers. - Never capture an overload in a
var. Inside a loop,var ov = ...leaves every hook pointing at the last overload: they install cleanly and then mis-report forever. Useletor.forEach().
scripts/frida_probe.js is the same probe in a fuller form (per-arity dispatch, DNS layer, dedup and hard caps). The script below is the minimal portable one — paste it into any loader and it adapts to the target.
// probe.js — four-layer network + swallowed-exception probe.
const APP_PKG = '<app.package>';
const APP_NET_CLASS = 'com.example.app.net.HttpHelper'; // the app's own wrapper, once you have found it
const LOGFILE = '/data/user/0/' + APP_PKG + '/files/probe.log'; // app-private: always writable by the app
const MAX_THROW = 400; // Throwable.getMessage is hot — cap it
const INTERESTING = /(network|http|ssl|cert|fail|timeout|refused|unable|error|exception)/i; // widen for the app's UI language
let out = null;
try { out = new File(LOGFILE, 'a'); } catch (e) {}
function log(ev, data) {
const line = JSON.stringify(Object.assign({ t: Date.now(), ev: ev }, data));
try { send(line); } catch (e) {}
try { if (out !== null) { out.write(line + '\n'); out.flush(); } } catch (e) {}
}
function stack(n) {
try {
const t = Java.use('java.lang.Throwable').$new();
return Java.use('android.util.Log').getStackTraceString(t).split('\n').slice(1, (n || 6) + 1).join(' | ');
} catch (e) { return '<no stack>'; }
}
function sa(args) { // a hook must never die inside its own logging
try {
const o = {};
for (let i = 0; i < args.length; i++) o['a' + i] = args[i] === null ? 'null' : '' + args[i];
return o;
} catch (e) { return { err: '' + e }; }
}
// 1) the app's own wrapper: every declared method, every overload, separately wrapped
function hookAppNet(cls) {
try {
const C = Java.use(cls);
const ms = C.class.getDeclaredMethods();
for (let i = 0; i < ms.length; i++) {
try {
const name = ms[i].getName();
const ps = ms[i].getParameterTypes();
const sig = [];
for (let j = 0; j < ps.length; j++) sig.push(ps[j].getName());
const ov = C[name].overload.apply(C[name], sig);
ov.implementation = function () {
try { log('APP-NET', { cls: cls, m: name, args: sa(arguments), stack: stack(5) }); }
catch (e) { log('PROBE-ERR', { at: 'app-net', msg: '' + e }); }
return ov.apply(this, arguments);
};
} catch (e) { log('HOOK-SKIP', { cls: cls, m: ms[i].getName(), msg: '' + e }); }
}
log('HOOK-OK', { layer: 'app-net', cls: cls, n: ms.length });
} catch (e) { log('HOOK-FAIL', { layer: 'app-net', cls: cls, msg: '' + e }); }
}
// 2) OkHttp end to end — internal class names moved between major versions, so try every location
function hookOkHttp() {
const targets = [
['okhttp3.OkHttpClient', 'newCall'],
['okhttp3.Request$Builder', 'build'],
['okhttp3.RealCall', 'execute'],
['okhttp3.RealCall$AsyncCall', 'run'],
['okhttp3.internal.http.RealInterceptorChain', 'proceed'], // okhttp 3.x
['okhttp3.internal.connection.RealInterceptorChain', 'proceed'] // okhttp 4.x / 5.x
];
for (let i = 0; i < targets.length; i++) {
const cn = targets[i][0], mn = targets[i][1];
try {
const ovs = Java.use(cn)[mn].overloads; // every overload of that name; use .overload('<sig>') if this is undefined
for (let j = 0; j < ovs.length; j++) {
const ov = ovs[j]; // block-scoped: every hook keeps its own overload
ov.implementation = function () {
try { log('OKHTTP', { cls: cn, m: mn, args: sa(arguments), stack: stack(6) }); }
catch (e) { log('PROBE-ERR', { at: cn, msg: '' + e }); }
return ov.apply(this, arguments);
};
}
log('HOOK-OK', { layer: 'okhttp', cls: cn, m: mn, overloads: ovs.length });
} catch (e) { log('HOOK-SKIP', { layer: 'okhttp', cls: cn, m: mn, msg: '' + e }); }
}
}
// 3) java.net.URL — login/register frequently bypasses OkHttp entirely
function hookRawUrl() {
try {
const U = Java.use('java.net.URL');
const o0 = U.openConnection.overload();
o0.implementation = function () {
try { log('RAW-URL', { url: '' + this.toString(), stack: stack(6) }); } catch (e) {}
return o0.apply(this, arguments);
};
try {
const o1 = U.openConnection.overload('java.net.Proxy');
o1.implementation = function () {
try { log('RAW-URL', { url: '' + this.toString(), proxy: true, stack: stack(6) }); } catch (e) {}
return o1.apply(this, arguments);
};
} catch (e) {}
log('HOOK-OK', { layer: 'url' });
} catch (e) { log('HOOK-FAIL', { layer: 'url', msg: '' + e }); }
}
// 4) Throwable.getMessage — the original text of an exception an upper layer caught and swallowed
function hookThrowable() {
try {
const T = Java.use('java.lang.Throwable');
const seen = {};
let n = 0;
T.getMessage.implementation = function () {
const msg = this.getMessage(); // self-call inside the replacement is fine (Frida guards it)
try {
if (msg !== null && msg !== undefined && n < MAX_THROW) {
const cls = '' + this.getClass().getName();
const interesting = cls.indexOf('Exception') >= 0 || cls.indexOf('Error') >= 0 || INTERESTING.test('' + msg);
const key = cls + '|' + msg;
if (interesting && seen[key] === undefined) { // dedup: this method is on a very hot path
seen[key] = 1; n++;
log('THROW', { cls: cls, msg: '' + msg, stack: stack(4) });
}
}
} catch (e) {}
return msg;
};
log('HOOK-OK', { layer: 'throwable' });
} catch (e) { log('HOOK-FAIL', { layer: 'throwable', msg: '' + e }); }
}
Java.perform(function () {
hookAppNet(APP_NET_CLASS);
hookOkHttp();
hookRawUrl();
hookThrowable();
log('PROBE-READY', { pkg: APP_PKG, logfile: LOGFILE }); // the host waits for this before resuming
});
Read the log after driving the UI. RAW-URL (with no OKHTTP events) is how you learn the request went over HttpsURLConnection — a different trust path entirely (references/server-api.md § TLS). THROW is how you learn an upper layer swallowed the real error.
# pull the record (app-private path needs root to read)
adb -s <serial> shell "su -c 'cat /data/user/0/<app.package>/files/probe.log'" > probe.log
Hook never fires — debug in this order
HOOK-SKIP / HOOK-FAIL lines answer this before you start guessing. When every layer reports HOOK-OK and still no event arrives:
- Does the class / method / overload actually exist? A misspelled obfuscated name, a renamed okhttp internal class, or an overload taking
Objectinstead ofStringall produce a wrapper that is simply never invoked. - Is the ClassLoader the right one? Multi-dex, plugin-loaded and packed apps can hold several loaders;
Java.useuses the app loader by default and throwsClassNotFoundExceptionfor a class that is plainly in the APK. Enumerate and switch:Java.enumerateClassLoaders({ onMatch: function (l) { try { if (l.findClass('<app.net.Class>')) Java.classFactory.loader = l; } catch (e) {} }, onComplete: function () {} }); - Is that code path reached at all? Layer 4 settles it: if
THROWevents show the app failing earlier, your hook's call site is never executed and no amount of hooking will help. - Did your UI action trigger business logic? A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (
references/environment.md§ UI automation), not just that the button animated.
Java.choose also matches dead instances
Java.choose('com.example.app.MainActivity', ...) returns every instance the VM still tracks, including finished and destroyed ones. Walking their view tree then yields an empty list — that is normal, not a bug in your script. Filter first, and prefer a direct reflective call over simulating a tap:
Java.choose('com.example.app.MainActivity', {
onMatch: function (a) {
try {
if (a.isFinishing() || a.isDestroyed()) return; // dead instance: skip it
send({ tag: 'live', view: '' + a.findViewById(<viewId>) });
} catch (e) {}
},
onComplete: function () {}
});
Reading obfuscated code at runtime
When names are meaningless (a7, x6, uf0), do not patch from names. Instead:
- Find a unique anchor: an API path, a preference key, or an SDK class name from the string table (
scripts/dex_strings.py). - Locate the small class/method that references it (byte search in the dex + a targeted disassembly).
- Hook that anchor, capture the stack, and read off the real chain.
- 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:
- Spawn + late resume, so hooks are installed before the check runs.
- Rename
frida-serverand move it out of obvious paths. - Hook the detector itself and neuter it — find it by hooking the suspicious API (e.g.
/proc/self/mapsreads,File.exists,Runtime.exec) and inspecting the stack. - 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