Files

537 lines
24 KiB
JavaScript

/*
* stalker_trace.js -- record a basic-block-level Stalker trace of one native module.
*
* WHY THIS SHAPE
* --------------
* Static disassembly of an obfuscated library (OLLVM control-flow flattening and
* friends) answers "what COULD run". The question that actually breaks the
* obfuscation is "what DID run, and how often" -- the dispatcher block of a
* flattened function executes orders of magnitude more often than the real
* blocks, and that ratio is invisible statically. Frida Stalker answers it:
* it re-compiles every basic block the target thread executes and reports each
* block back, without needing symbols, source, or even a loadable disassembler
* on the host.
*
* Three rules keep the trace usable, and violating each one is a documented
* failure mode:
*
* 1. FOLLOW ONE TRIGGER, NOT THE WHOLE PROCESS. Stalker on an unrestricted
* thread produces gigabytes and the app dies of slowdown. The trace starts
* when a trigger fires (an export call, an offset call, a Java method, or
* the main thread) and stops after followMs or when the trigger returns.
* 2. FILTER TO ONE MODULE. Only blocks whose address falls inside
* CONFIG.targetModule are reported (call edges report when either end is
* inside). Everything else is dropped on the device, not in post.
* 3. CAP THE VOLUME. maxBlocks stops the trace instead of letting the log
* eat the disk; the DONE line says whether truncation happened.
*
* ADAPT TO YOUR TARGET (CONFIG below, or rpc/recv at runtime)
* -----------------------------------------------------------
* targetModule : the .so you are analyzing ('libapp.so', 'libflutter.so', ...)
* trigger.kind : 'export' -- trigger.module + trigger.export (e.g. a JNI fn)
* | 'offset' -- trigger.module + trigger.offset (mod-relative)
* | 'java' -- trigger.cls + trigger.method (Java bridge; the
* classic java->native boundary)
* | 'main' -- follow the main thread right after load; use on
* a quiet/attached process and poke the UI
* followMs : hard stop, so a forgotten trace cannot kill the process
* events : compile = first time a block is translated (dedup'd block
* set + first-visit order = CFG skeleton);
* block = every executed block (execution counts; the
* histogram that exposes the dispatcher);
* call = call edges (call graph into/out of the module);
* exec = per-instruction (ENORMOUS; only for a few hundred
* instructions around a known point; off by default);
* ret = return edges (off by default).
* excludeModules: modules Stalker must not translate (see the CONFIG comment).
* Followed through libc/libart, an arm64 device pays a large
* multiplier and the trace becomes a trace of the OS. Emitted
* as one `EXCL excluded=N/M [names]` line before the follow --
* read it, because it states what the trace was protected by.
* A module not yet loaded cannot be excluded; exclusion is
* best-effort and applies to modules already mapped.
* zeroEventWarnMs: emits `WARN zero events ...` when a follow delivered
* nothing by then, so a dead pipeline is not mistaken for
* "the code did not run".
*
* HOW TO RUN
* ----------
* With the bundled injector (constants must be baked into CONFIG):
* python run_probe.py stalker_trace.js 2 --pkg com.example.app --log t.log
* With any driver that speaks rpc (no file edit needed):
* script.exports.config({ targetModule: 'libapp.so',
* trigger: { kind: 'offset', module: 'libapp.so',
* offset: 0x1234 } })
* script.exports.start()
* Or post a config message instead of rpc:
* script.post({ type: 'cfg', payload: {...same shape as CONFIG...} })
*
* OUTPUT FORMAT (one logical line per event; multi-line batches share one send)
* ----------------------------------------------------------------------------
* READY ... config echo + module status; nothing works before it
* MOD name base=0x.. size=.. path=..
* module base, so offline tools map offsets back
* BB <seq> <mod>+0x<offset>
* block first translated (dedup'd CFG skeleton)
* BLK <seq> <mod>+0x<offset> [size=<n>]
* block executed (weighted histogram input)
* CALL <depth> <from> -> <to>
* call edge; addresses are <module>+0x<off> when the
* module is known, raw hex otherwise
* DONE reason=timeout|trigger-leave|maxBlocks|stop-rpc blocks=<n>
* blk=<n> calls=<n> truncated=<0|1>
* FATAL / TRIG-FAIL what did not install and why (the script keeps running)
*
* Feed the log to scripts/stalker_report.py for the histogram, the dedup'd
* block order, and the call-edge table. See references/
* native-dbi-and-deobfuscation.md for the workflow around this trace.
*
* TESTED
* ------
* frida 16.7.19 host + frida-server 16.7.19 on Android 11 arm64. See
* docs/tool-verification/EXTENSION-native-dbi.md for the observed runs.
*/
'use strict';
/* ===================================================================
* 1. ADAPT THESE LINES (runtime override also possible -- see the tail)
* =================================================================== */
var CONFIG = {
// The only module whose blocks are reported.
targetModule: 'libc.so',
// What starts/stops the trace. Examples:
// { kind: 'main' }
// { kind: 'export', module: 'libnative-lib.so', export: 'Java_com_example_App_check' }
// { kind: 'offset', module: 'libapp.so', offset: 0x9f6c0 }
// { kind: 'java', cls: 'com.example.app.NativeBridge', method: 'sign' }
trigger: { kind: 'main' },
followMs: 4000, // unfollow after this long, no matter what
maxBlocks: 100000, // stop recording past this many reported events
autoStart: true, // false = arm triggers but wait for rpc start()
// Modules Stalker must NOT translate, resolved by name at follow time.
// Cost is the reason this exists. A followed thread whose execution runs
// through libc/libart pays a large multiplier on arm64 (community reports
// 20-50x) and drags the whole device down with it, and a hot library
// reached through a follow/unfollow cycle is where target processes have
// died. Excluding the system libraries you are not studying is the
// difference between a trace of your target and a trace of the OS.
// The target module is never excluded, even if its name is listed here.
excludeModules: [
'libc.so', 'libm.so', 'libdl.so', 'libc++.so', 'libc++_shared.so',
'libart.so', 'libartbase.so', 'libnativehelper.so',
'libutils.so', 'libbinder.so', 'libcutils.so', 'libbase.so',
'libui.so', 'libgui.so', 'libinput.so', 'libhwui.so', 'libskia.so',
'libEGL.so', 'libGLESv2.so', 'libvulkan.so', 'libandroid.so',
'liblog.so', 'libziparchive.so', 'libz.so'
],
excludeModulesExtra: [], // your own: 'libfoo.so', or 'libfoo.so+0x1000' for a sub-range
zeroEventWarnMs: 1500, // warn when a follow has produced nothing by then
events: {
compile: true, // first translation of each block (skeleton)
block: true, // every executed block (weights)
call: true, // call edges
exec: false, // per-instruction: huge, opt-in only
ret: false
}
};
/* ===================================================================
* 2. plumbing (no target knowledge below this line)
* =================================================================== */
var state = {
started: false, // trigger installed / main follow armed
following: false,
followTid: null,
target: null, // Module object of CONFIG.targetModule
modByName: {}, // addr-cache for cross-module name lookup
seq: 0, nBB: 0, nBLK: 0, nCALL: 0,
truncated: false,
timer: null,
pending: [], // batched lines waiting for one send()
pendingSince: 0
};
function ts() { return new Date().toISOString(); }
function emit(tag, msg) { send({ t: ts(), tag: tag, msg: msg }); }
function flush(force) {
if (!state.pending.length) return;
var now = Date.now();
if (!force && now - state.pendingSince < 120 && state.pending.length < 256) return;
send({ t: ts(), tag: 'TRACE', msg: state.pending.join('\n') });
state.pending = [];
state.pendingSince = now;
}
function line(text) {
state.pending.push(text);
flush(false);
}
/* Stalker.parse() with stringify:false yields raw addresses that may arrive
* as NativePointer, number, or UInt64 depending on the frida version. ptr()
* normalizes all of them; a null address stays null. */
function asPtr(v) {
if (v === null || v === undefined) return null;
if (v && typeof v.equals === 'function') return v; // already a pointer
try { return ptr(v); } catch (e) { return null; }
}
function moduleOf(addr) {
// target module first: the hot path never hits Process.findModuleByAddress
if (state.target && addr.compare(state.target.base) >= 0 &&
addr.compare(state.target.base.add(state.target.size)) < 0)
return state.target;
// one shared cache for everything else (call edges leaving the module)
var hit = Process.findModuleByAddress(addr);
if (hit && !state.modByName[hit.name]) {
state.modByName[hit.name] = hit;
line('MOD ' + hit.name + ' base=' + hit.base + ' size=' + hit.size +
' path=' + hit.path);
}
return hit || null;
}
/* <module>+0x<offset> when the module is known, raw hex otherwise. */
function fmtAddr(addr) {
var m = moduleOf(addr);
if (m) return m.name + '+0x' + addr.sub(m.base).toString(16);
return addr.toString();
}
/* ===================================================================
* 3. the follow engine
* =================================================================== */
function onStalkEvents(events) {
var entries;
try {
entries = Stalker.parse(events, { stringify: false, annotate: false });
} catch (e) {
emit('FATAL', 'Stalker.parse failed: ' + e);
endFollow('parse-error');
return;
}
for (var i = 0; i < entries.length; i++) {
var ev = entries[i];
var kind = ev[0];
if (kind === 'compile') {
var a = asPtr(ev[1]);
if (!a || !moduleOf(a)) continue;
state.seq++; state.nBB++;
line('BB ' + state.seq + ' ' + fmtAddr(a));
} else if (kind === 'block') {
var b = asPtr(ev[1]);
if (!b || !moduleOf(b)) continue;
state.seq++; state.nBLK++;
var sz = (ev.length > 2 && typeof ev[2] === 'number') ? ev[2] : null;
line('BLK ' + state.seq + ' ' + fmtAddr(b) +
(sz !== null ? ' size=' + sz : ''));
} else if (kind === 'call') {
var from = asPtr(ev[1]), to = asPtr(ev[2]);
var depth = (ev.length > 3 && typeof ev[3] === 'number') ? ev[3] : '?';
if (!from || !to) continue;
var fromIn = !!moduleOf(from), toIn = !!moduleOf(to);
if (!fromIn && !toIn) continue; // drop unrelated edges
state.seq++; state.nCALL++;
line('CALL ' + depth + ' ' + fmtAddr(from) + ' -> ' + fmtAddr(to));
}
// 'exec'/'ret' are opt-in; if enabled they are reported as X/RET lines
else if (kind === 'exec' && CONFIG.events.exec) {
var x = asPtr(ev[1]);
if (!x || !moduleOf(x)) continue;
state.seq++;
line('X ' + state.seq + ' ' + fmtAddr(x));
}
if (state.seq >= CONFIG.maxBlocks) { endFollow('maxBlocks'); return; }
}
flush(true);
}
/* Exclude every configured module that is already mapped. Best-effort by
* design: a module that is not loaded yet cannot be excluded, so the count
* emitted here is what the trace is actually protected by -- read it before
* interpreting a bad trace. Exclusion must happen before follow(). */
function applyExclusions() {
var names = (CONFIG.excludeModules || []).concat(CONFIG.excludeModulesExtra || []);
var excluded = [], attempted = 0, missing = [];
for (var i = 0; i < names.length; i++) {
var spec = names[i];
if (!spec) continue;
var plus = spec.indexOf('+');
var name = plus > 0 ? spec.substring(0, plus) : spec;
if (state.target && name === state.target.name) continue; // never the target
var mod = null;
try { mod = Process.findModuleByName(name); } catch (e) { mod = null; }
if (!mod) { missing.push(name); continue; }
attempted++;
try {
if (plus > 0) {
var off = parseInt(spec.substring(plus + 1), 16) || 0;
Stalker.exclude({ base: mod.base.add(off), size: mod.size - off });
} else {
Stalker.exclude(mod);
}
excluded.push(name);
} catch (e) {
emit('EXCL-FAIL', spec + ': ' + e);
}
}
emit('EXCL', 'excluded=' + excluded.length + '/' + attempted +
' [' + excluded.join(',') + ']' +
(missing.length ? ' not-loaded=' + missing.length : ''));
}
function beginFollow(tid, why) {
if (state.following) return;
if (!state.target) {
emit('FATAL', 'target module not loaded, refusing to follow (see READY)');
return;
}
state.following = true;
state.followTid = tid;
emit('TRIG', 'following tid=' + tid + ' (' + why + ') module=' +
state.target.name + ' base=' + state.target.base);
line('MOD ' + state.target.name + ' base=' + state.target.base +
' size=' + state.target.size + ' path=' + state.target.path);
applyExclusions();
Stalker.follow(tid, {
events: {
call: CONFIG.events.call,
ret: CONFIG.events.ret,
exec: CONFIG.events.exec,
block: CONFIG.events.block,
compile: CONFIG.events.compile
},
onReceive: onStalkEvents
});
/* A zero-event trace has two very different meanings -- "nothing executed"
* and "the pipeline never delivered" -- and the log alone cannot tell them
* apart. This warning exists so the second reading is the default one. */
if (CONFIG.zeroEventWarnMs > 0) {
state.warnTimer = setTimeout(function () {
if (state.following && state.nBB === 0 && state.nBLK === 0) {
emit('WARN', 'zero events ' + CONFIG.zeroEventWarnMs +
'ms after follow (blocks=0 blk=0 calls=0) -- the pipeline is ' +
'NOT proven; do not report this as "the code did not run". ' +
'Control: follow a thread running a known loop first.');
}
}, CONFIG.zeroEventWarnMs);
}
if (CONFIG.followMs > 0) {
state.timer = setTimeout(function () { endFollow('timeout'); },
CONFIG.followMs);
}
}
function endFollow(reason) {
if (!state.following) return;
state.following = false;
if (state.timer !== null) { clearTimeout(state.timer); state.timer = null; }
if (state.warnTimer) { clearTimeout(state.warnTimer); state.warnTimer = null; }
try { Stalker.flush(); } catch (e) { /* already gone */ }
try { Stalker.unfollow(state.followTid); } catch (e) { /* already gone */ }
flush(true);
emit('DONE', 'reason=' + reason + ' blocks=' + state.nBB +
' blk=' + state.nBLK + ' calls=' + state.nCALL +
' truncated=' + (state.truncated || reason === 'maxBlocks' ? 1 : 0));
}
/* ===================================================================
* 4. triggers
* =================================================================== */
function installTrigger() {
var t = CONFIG.trigger || { kind: 'main' };
state.target = Process.findModuleByName(CONFIG.targetModule);
if (!state.target) {
// Hardened apps and Flutter apps load their interesting library after
// Application start; wait for the linker instead of failing.
emit('TRIG-FAIL', 'module ' + CONFIG.targetModule + ' not loaded yet; ' +
'hooking dlopen to wait for it');
hookDlopen(CONFIG.targetModule, function (mod) {
state.target = mod;
armTrigger(t);
emit('TRIG', 'module ' + mod.name + ' loaded at ' + mod.base +
'; trigger armed');
});
return;
}
armTrigger(t);
}
function armTrigger(t) {
try {
if (t.kind === 'export' || t.kind === 'offset') {
var mod = Process.findModuleByName(t.module || CONFIG.targetModule);
if (!mod) { emit('TRIG-FAIL', 'trigger module missing: ' + t.module); return; }
var addr = null;
if (t.kind === 'export') {
addr = mod.findExport ? mod.findExport(t.export) : null;
if (!addr) addr = Module.findExportByName(mod.name, t.export);
if (!addr) {
emit('TRIG-FAIL', 'export not found: ' + mod.name + '!' + t.export);
return;
}
} else {
addr = mod.base.add(t.offset || 0);
}
Interceptor.attach(addr, {
onEnter: function () { beginFollow(this.threadId, 'trigger ' + addr); },
onLeave: function () { endFollow('trigger-leave'); }
});
emit('TRIG', 'armed ' + t.kind + ' trigger at ' + addr);
} else if (t.kind === 'java') {
if (typeof Java === 'undefined') {
emit('TRIG-FAIL', 'java trigger requested but no Java bridge; ' +
'align host frida and frida-server to the same 16.x');
return;
}
Java.perform(function () {
var Klass;
try { Klass = Java.use(t.cls); }
catch (e) { emit('TRIG-FAIL', 'class not found: ' + t.cls); return; }
var hit = false;
var names = t.method instanceof Array ? t.method : [t.method];
for (var i = 0; i < names.length && !hit; i++) {
var ov = Klass[names[i]];
if (!ov || !ov.overloads) continue;
ov.overloads.forEach(function (o) {
// implementations run on the calling thread, so the
// current thread id IS the java->native boundary thread
o.implementation = function () {
beginFollow(Process.getCurrentThreadId(),
'java ' + t.cls + '.' + names[i]);
try {
return o.apply(this, arguments);
} finally { endFollow('trigger-leave'); }
};
});
hit = true;
}
if (hit) emit('TRIG', 'armed java trigger ' + t.cls + '#' + names.join(','));
else emit('TRIG-FAIL', 'method not found on ' + t.cls + ': ' + names.join(','));
});
} else if (t.kind === 'main') {
if (CONFIG.autoStart === false) {
emit('TRIG', 'main trigger armed (autoStart=false); call start()');
return;
}
var tid = null;
try { tid = Process.getMainThreadId(); } catch (e) { /* older gum */ }
if (!tid || tid <= 0) {
// getMainThreadId may not exist on older gum; on Android the
// first thread frida lists is the process's main thread.
var threads = Process.enumerateThreads();
tid = threads.length ? threads[0].id : Process.getCurrentThreadId();
}
beginFollow(tid, 'main-thread follow (pid=' + Process.id + ')');
} else {
emit('TRIG-FAIL', 'unknown trigger kind: ' + t.kind);
}
} catch (e) {
emit('TRIG-FAIL', String(e));
}
}
/* Wait for a library the dynamic linker has not mapped yet. */
function hookDlopen(basename, onLoaded) {
var seen = false;
['dlopen', 'android_dlopen_ext'].forEach(function (name) {
var a = Module.findExportByName(null, name);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
var p = args[0];
this.path = (p && !p.isNull()) ? p.readCString() : null;
},
onLeave: function (res) {
if (seen || !this.path) return;
if (this.path.indexOf(basename) === -1) return;
var mod = Process.findModuleByName(basename);
if (mod) { seen = true; onLoaded(mod); }
}
});
});
}
/* ===================================================================
* 5. runtime control: rpc.exports + post('cfg')
* =================================================================== */
rpc.exports = {
config: function (patch) {
if (!patch || typeof patch !== 'object')
return { ok: false, error: 'config needs an object' };
for (var k in patch) {
if (k === 'trigger' && patch.trigger && typeof patch.trigger === 'object') {
CONFIG.trigger = patch.trigger;
} else if (k === 'events' && patch.events) {
for (var e in patch.events) CONFIG.events[e] = !!patch.events[e];
} else {
CONFIG[k] = patch[k];
}
}
// a re-config that changes WHAT we trace must not race the old follow
if (state.following &&
(patch.trigger !== undefined || patch.targetModule !== undefined ||
patch.autoStart !== undefined)) {
endFollow('reconfig');
}
installTrigger();
return { ok: true, config: CONFIG };
},
start: function (argTid) {
var tid = argTid || null;
if (!tid || tid <= 0) {
// never follow the rpc thread: it sleeps while waiting for us and
// produces an empty trace. Prefer the process's main thread.
try { tid = Process.getMainThreadId(); } catch (e) { /* older gum */ }
if (!tid || tid <= 0) {
var threads = Process.enumerateThreads();
tid = threads.length ? threads[0].id : Process.getCurrentThreadId();
}
}
beginFollow(tid, 'start-rpc');
return { following: state.following, followingTid: tid };
},
stop: function () { endFollow('stop-rpc'); return status(); },
status: function () { return status(); }
};
function status() {
return {
following: state.following,
targetLoaded: !!state.target,
target: state.target ? state.target.name : CONFIG.targetModule,
seq: state.seq, blocks: state.nBB, blk: state.nBLK, calls: state.nCALL
};
}
try {
recv('cfg', function onCfg(message, payload) {
rpc.exports.config(payload);
recv('cfg', onCfg); // stay armed for further updates
});
} catch (e) { /* recv unavailable in this runtime; constants mode still works */ }
/* ===================================================================
* 6. go
* =================================================================== */
state.started = true;
// safety flush so lines emitted before/after a follow still reach the host
setInterval(function () { flush(true); }, 500);
emit('READY', 'stalker_trace loaded; target=' + CONFIG.targetModule +
' trigger=' + JSON.stringify(CONFIG.trigger) + ' followMs=' + CONFIG.followMs +
' maxBlocks=' + CONFIG.maxBlocks +
' moduleLoaded=' + !!Process.findModuleByName(CONFIG.targetModule));
installTrigger();