Files
apk-reverse/scripts/frida_probe.js
T
inliver233 1a5cb6245f Broaden skill with unpacking, TLS triage, third-party build auditing, runtime probe
references: recon gains packer identification + unpacking workflow (payload/plaintext size mapping, dump validation, hash de-duplication, business-dex heuristic); repack-and-sign gains the de-shelled repack pipeline, why apktool over binary AXML, and the apksigner scheme-selection caveat; new tls-and-cert documents feature-scoped network failures, dual trust chains and the fix template; new third-party-builds covers auditing a cracked/modded APK before trusting it; dynamic-frida gains version pinning, remote-device path, the four-layer probe and hook-never-fires triage; environment gains UI automation pitfalls; ad-removal gains the single convergence point, dynamic-plugin SDKs and the global timestamp gate; pitfalls gains P13-P17; verification gains rung 7 and the three-signal ad check.

scripts: new frida_probe.js (four-layer runtime probe), run_probe.py and tls_check.py; repack.py fixes two crash-on-default bugs and a misleading META-INF note; datastore_inject.py, PatchMethodExample.java and build_scripts.py lose hardcoded values - the example class name did not match its file and could not compile.
2026-09-19 21:44:00 +08:00

573 lines
23 KiB
JavaScript

/*
* frida_probe.js -- four-layer full-chain network probe for a hardened Android app.
*
* WHY THIS SHAPE
* --------------
* When you cannot tell why a request never reaches the server (or why it fails
* silently), a single-layer hook always leaves you guessing. This script hooks
* FOUR independent layers at once, so *whichever* layer the app actually uses
* produces evidence:
*
* [APP] the app's own network wrapper class(es) -> what business method asked
* [OKHTTP] OkHttp full chain -> newCall/build/execute/async/proceed
* [RAW-URL] java.net.URL.openConnection -> HttpsURLConnection, which uses the
* SYSTEM trust store and therefore
* fails differently from OkHttp
* [THROW] java.lang.Throwable.getMessage -> recovers the exception text that an
* upper layer swallowed with catch {}
*
* The RAW-URL vs OKHTTP distinction is the single most valuable signal here: an app
* commonly has two unrelated trust chains, so "login fails but browsing works" is
* explained by which of the two fired. See LESSONS G3.
*
* Every hook is installed inside its own try/catch. One missing class (a different
* OkHttp major version, a renamed obfuscated class) never disables the other three
* layers. Failures are reported as HOOK-FAIL lines instead of killing the script.
*
* ADAPT TO YOUR TARGET (only this block matters)
* ---------------------------------------------
* 1) APP_NET_CLASSES -> replace with the app's own network wrapper class name(s)
* 2) APP_PKG_PREFIX -> the app's package prefix, used only to filter noise
* 3) ENABLE_* flags -> turn layers off if they are too noisy
* Everything else is target-independent.
*
* HOW TO RUN
* ----------
* Prefer the bundled injector, which also writes a log file and stays resident:
* python run_probe.py frida_probe.js 10 --pkg com.example.app
* Or by hand against a remote device:
* frida -H 127.0.0.1:27042 -p <pid> -l frida_probe.js
*
* OUTPUT FORMAT
* -------------
* Every line is <ISO-ish local timestamp> <TAG> <key=value ...>
* TAGS: READY | HOOK-FAIL | FATAL | APP | OKHTTP-NEWCALL | OKHTTP-BUILD |
* OKHTTP-EXEC | OKHTTP-ASYNC | OKHTTP-PROCEED | RAW-URL | DNS | THROW
* Add STACK to a line only where a call chain is genuinely useful (APP, RAW-URL),
* because it is the expensive part.
*/
/* ===================================================================
* 1. ADAPT THESE LINES
* =================================================================== */
// The app's own network wrapper / API helper class(es). Enumerate ALL declared
// methods at runtime, so you do not have to know the obfuscated method names.
// Example: 'com.example.app.net.HttpHelper', 'com.example.app.api.ApiClient'
var APP_NET_CLASSES = ['com.example.app.net.HttpHelper'];
// Used only to filter THROW noise: messages from classes whose name contains this
// prefix are always kept. Leave as-is if you do not care.
var APP_PKG_PREFIX = 'com.example.app';
// Layer switches. Turn a layer off if it produces more noise than signal.
var ENABLE_APP = true; // [APP] app's own wrapper classes (needs APP_NET_CLASSES)
var ENABLE_OKHTTP = true; // [OKHTTP] OkHttp newCall/build/execute/AsyncCall/proceed
var ENABLE_RAW_URL = true; // [RAW-URL] java.net.URL.openConnection (HttpsURLConnection)
var ENABLE_THROWABLE = true; // [THROW] Throwable.getMessage, deduplicated
var ENABLE_DNS = true; // [DNS] InetAddress.getAllByName, one line per unique host
var STACK_ON_APP = true; // attach a stack to [APP] hits
var STACK_ON_RAW_URL = true; // attach a stack to [RAW-URL] hits
var STACK_LINES = 12; // how many stack frames to keep
// Safety valves: this probe must never become the thing that kills the app.
var MAX_STRING = 3000; // truncate any single value
var THROWABLE_MAX_LINES = 400;// hard cap on THROW lines
var THROWABLE_STACK = false; // stacks on every Throwable line are usually too much
/* ===================================================================
* 2. Plumbing (target-independent)
* =================================================================== */
function pad(n, w) {
var s = '' + n;
while (s.length < w) s = '0' + s;
return s;
}
// Local wall-clock with milliseconds. Local, not UTC, because you will be
// comparing these lines against logcat and your own shell history.
function stamp() {
var d = new Date();
return d.getFullYear() + '-' + pad(d.getMonth() + 1, 2) + '-' + pad(d.getDate(), 2) + ' ' +
pad(d.getHours(), 2) + ':' + pad(d.getMinutes(), 2) + ':' + pad(d.getSeconds(), 2) + '.' +
pad(d.getMilliseconds(), 3);
}
function str(v, max) {
if (v === null) return 'null';
if (v === undefined) return 'undefined';
var s;
try { s = '' + v; } catch (e) { s = '<unprintable>'; }
max = max || MAX_STRING;
if (s.length > max) s = s.substring(0, max) + '...<+' + (s.length - max) + ' chars>';
return s;
}
function sendLine(tag, fields) {
var parts = [];
if (fields) {
for (var k in fields) {
if (!fields.hasOwnProperty(k)) continue;
if (fields[k] === null || fields[k] === undefined || fields[k] === '') continue;
parts.push(k + '=' + str(fields[k]));
}
}
var line = { t: stamp(), tag: tag, msg: parts.join(' ') };
try { send(line); } catch (e) { /* transport gone; nothing sane to do */ }
}
// Call-chain capture. Expensive, so it is opt-in per hook.
function stackText(lines) {
try {
var Log = Java.use('android.util.Log');
var Throwable = Java.use('java.lang.Throwable');
var full = Log.getStackTraceString(Throwable.$new());
var rows = ('' + full).split('\n');
var kept = [];
// Drop the probe's own frames at the top; they are never the interesting part.
for (var i = 0; i < rows.length && kept.length < (lines || STACK_LINES); i++) {
if (rows[i].indexOf('frida') >= 0) continue;
if (rows[i].indexOf('java.lang.Throwable') >= 0) continue;
if (rows[i].indexOf('android.util.Log') >= 0) continue;
kept.push(rows[i].replace(/^\s+/, ''));
}
return kept.join(' <- ');
} catch (e) {
return '<stack unavailable: ' + e + '>';
}
}
// The whole point: a failure to install one hook must not affect the others.
function guard(label, fn) {
try {
fn();
return true;
} catch (e) {
sendLine('HOOK-FAIL', { where: label, err: e });
return false;
}
}
// Run one whole layer and return how many hooks it really installed.
// > 0 that many hooks are live
// = 0 the layer installed nothing (its classes were not found); NOT a success
// =-1 the layer is switched off in the config header
// Returning a count instead of a boolean matters: a layer whose classes are absent
// still returns normally (each inner hook catches its own error), so a boolean
// "did not throw" would report a layer as installed when it hooked nothing at all.
function runLayer(label, fn) {
try {
return fn();
} catch (e) {
sendLine('HOOK-FAIL', { where: 'install ' + label, err: e });
return 0;
}
}
/* ===================================================================
* 3. Layer [APP]: the app's own network wrapper
* =================================================================== */
// Why getDeclaredMethods: obfuscated wrappers routinely expose the same name with
// several overloads, and a decompiler's short names (a, b, c) are not reliable
// enough to enumerate by hand. Enumerate at runtime, wrap each overload, and let
// the stack tell you which one the business code called.
// Build a replacement of a fixed arity that reports the hit and then delegates.
//
// Two deliberate choices here, both about not breaking the app:
// * The original is NOT cached in a variable. Frida does not hand out the pre-hook
// implementation through `overload.implementation` (it is undefined until you
// replace it), so `var orig = ov.implementation; ... orig.apply(...)` would
// throw a TypeError on the first real call and propagate straight into the app's
// network path. Calling the same name on `this` from inside the replacement is
// the documented way to reach the original, and it re-resolves the overload from
// the runtime argument types.
// * The delegate call is written out per arity instead of spread/apply, so no
// engine-specific behaviour is involved. Methods with more than 8 parameters are
// reported and left alone rather than bound incorrectly.
function appReplacement(className, methodName, arity) {
var label = className + '.' + methodName;
var note = function (argc) {
sendLine('APP', {
call: label,
argc: argc,
stack: STACK_ON_APP ? stackText() : null
});
};
switch (arity) {
case 0: return function () {
note(arguments.length); return this[methodName]();
};
case 1: return function (a0) {
note(arguments.length); return this[methodName](a0);
};
case 2: return function (a0, a1) {
note(arguments.length); return this[methodName](a0, a1);
};
case 3: return function (a0, a1, a2) {
note(arguments.length); return this[methodName](a0, a1, a2);
};
case 4: return function (a0, a1, a2, a3) {
note(arguments.length); return this[methodName](a0, a1, a2, a3);
};
case 5: return function (a0, a1, a2, a3, a4) {
note(arguments.length); return this[methodName](a0, a1, a2, a3, a4);
};
case 6: return function (a0, a1, a2, a3, a4, a5) {
note(arguments.length); return this[methodName](a0, a1, a2, a3, a4, a5);
};
case 7: return function (a0, a1, a2, a3, a4, a5, a6) {
note(arguments.length); return this[methodName](a0, a1, a2, a3, a4, a5, a6);
};
case 8: return function (a0, a1, a2, a3, a4, a5, a6, a7) {
note(arguments.length); return this[methodName](a0, a1, a2, a3, a4, a5, a6, a7);
};
default: return null;
}
}
function installAppLayer() {
if (!ENABLE_APP) return -1;
if (!APP_NET_CLASSES.length) {
sendLine('HOOK-FAIL', { where: 'APP', err: 'APP_NET_CLASSES is empty; edit the header' });
return 0;
}
var wrappedTotal = 0;
APP_NET_CLASSES.forEach(function (className) {
guard('APP ' + className, function () {
var Cls = Java.use(className);
var declared = Cls.class.getDeclaredMethods();
var total = declared.length;
var wrapped = 0;
var skipped = 0;
for (var i = 0; i < total; i++) {
(function (idx) {
var methodName;
try { methodName = '' + declared[idx].getName(); } catch (e) { return; }
guard('APP ' + className + '.' + methodName, function () {
var overloads = Cls[methodName];
if (!overloads || !overloads.overloads) return;
overloads.overloads.forEach(function (ov) {
var argTypes;
try { argTypes = ov.argumentTypes; } catch (e) { argTypes = null; }
var arity = (argTypes && typeof argTypes.length === 'number') ? argTypes.length : -1;
var label = className + '.' + methodName + '(' + (argTypes ? argTypes.join(',') : '?') + ')';
guard('APP bind ' + label, function () {
var replacement = appReplacement(className, methodName, arity);
if (replacement === null) {
skipped++;
sendLine('APP', {
call: label,
skipped: 'arity ' + arity + ' exceeds 8; left unwrapped on purpose'
});
return;
}
ov.implementation = replacement;
wrapped++;
});
});
});
})(i);
}
wrappedTotal += wrapped;
sendLine('APP', {
call: className,
declared_methods: total,
overloads_wrapped: wrapped,
overloads_skipped: skipped
});
});
});
return wrappedTotal;
}
/* ===================================================================
* 4. Layer [OKHTTP]: the whole OkHttp chain
* =================================================================== */
// Five independent anchors on purpose. Any single one can be missing depending on
// the OkHttp major version and on R8 inlining, and each still answers a different
// question:
// newCall -> a request object reached OkHttp (earliest reliable URL)
// build -> the URL existed even if the call was never executed
// execute -> synchronous call actually ran, with status code and duration
// AsyncCall.run-> asynchronous call actually ran (enqueue path)
// proceed -> every actual network step, including redirects and retries
function installOkHttpLayer() {
if (!ENABLE_OKHTTP) return -1;
var installed = 0;
var can = function (label, fn) { if (guard(label, fn)) installed++; };
can('OKHTTP newCall', function () {
var Client = Java.use('okhttp3.OkHttpClient');
Client.newCall.overload('okhttp3.Request').implementation = function (req) {
var url = '<unknown>';
try { url = '' + req.url().toString(); } catch (e) { url = '<url error: ' + e + '>'; }
sendLine('OKHTTP-NEWCALL', { url: url });
return this.newCall(req);
};
});
can('OKHTTP Request$Builder.build', function () {
var Builder = Java.use('okhttp3.Request$Builder');
Builder.build.implementation = function () {
var req = this.build();
var url = '<unknown>';
try { url = '' + req.url().toString(); } catch (e) { url = '<url error: ' + e + '>'; }
sendLine('OKHTTP-BUILD', { url: url });
return req;
};
});
can('OKHTTP RealCall.execute', function () {
var RealCall = Java.use('okhttp3.internal.connection.RealCall');
RealCall.execute.implementation = function () {
var url = '<unknown>';
try { url = '' + this.request().url().toString(); } catch (e) { url = '<url error: ' + e + '>'; }
var t0 = Date.now();
var resp = this.execute();
var code = '?';
try { code = '' + resp.code(); } catch (e) { code = '<code error>'; }
sendLine('OKHTTP-EXEC', { url: url, ms: Date.now() - t0, code: code });
return resp;
};
});
can('OKHTTP RealCall$AsyncCall.run', function () {
var AsyncCall = Java.use('okhttp3.internal.connection.RealCall$AsyncCall');
AsyncCall.run.implementation = function () {
// The URL is not reliably reachable from here across OkHttp versions; the
// newCall/build hooks above supply it. This hook proves *when* the async
// path actually started and finished, which is what matters for timing.
var t0 = Date.now();
this.run();
sendLine('OKHTTP-ASYNC', { phase: 'done', ms: Date.now() - t0 });
};
});
can('OKHTTP RealInterceptorChain.proceed', function () {
var Chain = Java.use('okhttp3.internal.http.RealInterceptorChain');
Chain.proceed.overload('okhttp3.Request').implementation = function (req) {
var url = '<unknown>';
try { url = '' + req.url().toString(); } catch (e) { url = '<url error: ' + e + '>'; }
var t0 = Date.now();
var resp = this.proceed(req);
var code = '?';
try { code = '' + resp.code(); } catch (e) { code = '<code error>'; }
sendLine('OKHTTP-PROCEED', { url: url, ms: Date.now() - t0, code: code });
return resp;
};
});
return installed;
}
/* ===================================================================
* 5. Layer [RAW-URL]: HttpsURLConnection -> the SYSTEM trust store
* =================================================================== */
// Many apps route login/registration through java.net.URL.openConnection instead
// of OkHttp. That path validates against the system trust store, so an expired or
// untrusted server certificate breaks it while the OkHttp path keeps working.
// If this layer fires but the OkHttp layer stays silent, you are on the other
// trust chain -- check the certificate out-of-band with tls_check.py.
function installRawUrlLayer() {
if (!ENABLE_RAW_URL) return -1;
var URL;
try {
URL = Java.use('java.net.URL');
} catch (e) {
sendLine('HOOK-FAIL', { where: 'RAW-URL java.net.URL', err: e });
return 0;
}
var installed = 0;
var can = function (label, fn) { if (guard(label, fn)) installed++; };
can('RAW-URL openConnection()', function () {
// Report AFTER the original returns, so we can also name the concrete connection
// class (HttpURLConnection vs HttpsURLConnection) -- that distinction is what
// tells you which trust chain the request is about to use.
// As in the APP layer, the original is reached via `this.openConnection(...)`
// rather than a cached function reference.
URL.openConnection.overload().implementation = function () {
var url = '<unknown>';
try { url = '' + this.toString(); } catch (e) { url = '<url error: ' + e + '>'; }
var conn = this.openConnection();
var connCls = '?';
try { connCls = '' + conn.getClass().getName(); } catch (e) { connCls = '<class error>'; }
sendLine('RAW-URL', {
url: url,
kind: 'no-arg',
conn: connCls,
stack: STACK_ON_RAW_URL ? stackText() : null
});
return conn;
};
});
can('RAW-URL openConnection(Proxy)', function () {
URL.openConnection.overload('java.net.Proxy').implementation = function (proxy) {
var url = '<unknown>';
try { url = '' + this.toString(); } catch (e) { url = '<url error: ' + e + '>'; }
var conn = this.openConnection(proxy);
var connCls = '?';
try { connCls = '' + conn.getClass().getName(); } catch (e) { connCls = '<class error>'; }
sendLine('RAW-URL', {
url: url,
kind: 'proxy',
conn: connCls,
stack: STACK_ON_RAW_URL ? stackText() : null
});
return conn;
};
});
return installed;
}
/* ===================================================================
* 6. Layer [THROW]: recover swallowed exception text
* =================================================================== */
// An upper-layer `catch (Exception e) { }` hides exactly the message you need.
// Hooking Throwable.getMessage is the cheapest way to get it back, but it is a
// very hot method, so: only concrete Exception/Error subclasses, deduplicated by
// class+message, hard-capped, and stacks off by default.
function installThrowableLayer() {
if (!ENABLE_THROWABLE) return -1;
// Returns 1 only when the hook is really bound to the runtime.
return guard('THROWABLE getMessage', function () {
var seen = {};
var printed = 0;
var suppressed = 0;
var Throwable = Java.use('java.lang.Throwable');
Throwable.getMessage.implementation = function () {
var msg;
try {
msg = this.getMessage();
} catch (e) {
return null;
}
try {
var cls = '' + this.getClass().getName();
var looksLikeError = (cls.indexOf('Exception') >= 0 || cls.indexOf('Error') >= 0);
var isAppClass = (cls.indexOf(APP_PKG_PREFIX) === 0);
if (looksLikeError || isAppClass) {
var key = cls + '|' + msg;
if (seen[key]) {
seen[key]++;
} else if (printed < THROWABLE_MAX_LINES) {
seen[key] = 1;
printed++;
sendLine('THROW', {
cls: cls,
msg: msg,
stack: THROWABLE_STACK ? stackText() : null
});
} else {
suppressed++;
if (suppressed === 1 || suppressed % 200 === 0) {
sendLine('THROW', { note: 'cap reached, further unique messages suppressed', printed: printed });
}
}
}
} catch (e) {
// Never let the reporting path throw: this hook is on a very hot path.
}
return msg;
};
}) ? 1 : 0;
}
/* ===================================================================
* 7. Optional layer [DNS]: which hosts the app really resolves
* =================================================================== */
// Cheap, high-signal: if an SDK's domains are never resolved, that subsystem never
// started -- much stronger evidence than "logcat was quiet". Deduplicated per host
// so a retry loop cannot flood the log.
function installDnsLayer() {
if (!ENABLE_DNS) return -1;
return guard('DNS getAllByName', function () {
var seen = {};
var Inet = Java.use('java.net.InetAddress');
Inet.getAllByName.overload('java.lang.String').implementation = function (host) {
var first = false;
try {
var key = '' + host;
if (!seen[key]) { seen[key] = 1; first = true; }
} catch (e) { /* ignore */ }
if (first) sendLine('DNS', { host: host });
return this.getAllByName(host);
};
}) ? 1 : 0;
}
/* ===================================================================
* 8. Boot
* =================================================================== */
var JAVA_BRIDGE_HINT =
'align the host frida package and the on-device frida-server to the same 16.x version; ' +
'17.x can fail to locate the Android dynamic linker and removes the built-in Java bridge';
function main() {
if (typeof Java === 'undefined' || Java === null) {
// frida 17+ removed the built-in Java bridge.
sendLine('FATAL', {
err: 'Java bridge is not available in this frida runtime',
fix: JAVA_BRIDGE_HINT
});
return;
}
// Java.perform can fail BEFORE the callback ever runs: on a non-Android host, and
// on frida 17.x, where it throws "Java API not available". Without this catch the
// whole script dies as an opaque `type: error` message with a stack pointing into
// frida-java-bridge, which tells you nothing about the version mismatch that
// actually caused it.
try {
Java.perform(function () {
var installed = [];
var failed = [];
var disabled = [];
[
['APP', installAppLayer],
['OKHTTP', installOkHttpLayer],
['RAW-URL', installRawUrlLayer],
['THROW', installThrowableLayer],
['DNS', installDnsLayer]
].forEach(function (pair) {
var count = runLayer(pair[0], pair[1]);
if (count === -1) disabled.push(pair[0]);
else if (count > 0) installed.push(pair[0] + ':' + count);
else failed.push(pair[0]);
});
sendLine('READY', {
installed: installed.join(',') || 'none',
failed: failed.join(',') || 'none',
disabled: disabled.join(',') || 'none',
note: 'hooks are live; now drive the app UI'
});
if (failed.length) {
sendLine('READY-HINT', {
failed: failed.join(','),
fix: 'a layer reporting 0 hooks means its classes were not found: the app may '
+ 'not use that library, R8 may have renamed the class, or the classloader '
+ 'differs. Layers that installed keep working -- debug the failed ones by '
+ 'their HOOK-FAIL line, not the working ones.'
});
}
});
} catch (e) {
sendLine('FATAL', {
err: 'Java.perform failed: ' + e,
fix: JAVA_BRIDGE_HINT
});
}
}
// Last-resort net: any unexpected top-level error still leaves a readable line in
// the log instead of only a raw frida script error.
try {
main();
} catch (e) {
sendLine('FATAL', { err: 'probe aborted: ' + e, note: 'no hooks were installed' });
}