Files
apk-reverse/references/dynamic-frida.md
T
inliver233 a552157f1b Close the byte-patching gap, add the server-config path, fix two script crashes
Worked end-to-end on a real unobfuscated commercial app (single-dex, no packer,
server-driven launch screen) and folded back everything that cost time. Net +17
files, no new dependencies, no sample-specific identifiers.

New references
  byte-level-patching.md
    The technique that was missing entirely. Equal-length in-place edits beat
    method rebuilding (measured on a real sample: method rewrite grew debug_info
    924 B -> 22.8 KB and the dex 4.32 MB -> 7.73 MB), but four failure modes are
    silent: same-length is not enough, branch polarity, verifier legality, and
    stale dex header fields. Covers locating an instruction's exact offset (why
    listings and byte-pattern guesses both fail), the instruction width table and
    the two traps that desynchronise a decode, neutralise-a-branch vs redirect-it,
    and the header's signature-then-checksum order.
  server-config-and-updates.md
    The most common shape of "ad" in a modern app, and the one usually
    mis-diagnosed: the server supplies UI the client renders. The two-layer fetch
    (local fallback then remote override) that proves it, finding the config DTOs
    through the field names data classes keep, why you patch the decision and not
    the data, deciding the scope of "remove", and remote re-enable / cached config
    durability.

New scripts
  dexutil.py             dependency-free dex reader: structural walk, exact
                         instruction decode, dex header recompute/verify in the
                         correct order, branch-target and operand helpers
  dex_find_insn.py       locate an instruction by decoded semantics, print its exact
                         byte offset with context and BOTH sides of any branch
  dex_patch_bytes.py     equal-length byte patches from a JSON spec: semantic match,
                         polarity pin via expect_next, equal-length enforcement,
                         verifier check, header recompute, re-decode to prove it landed
  dex_check_verifier.py  does any conditional branch target a move-result (bypassing
                         its producer)? Compares builds and separates pre-existing
                         findings from regressions the patch introduced
  coldstart.py           cold-launch capture: timed screenshot burst + logcat signals
                         + installed-build facts + launch timing, and a warning when
                         the foreground activity is not the app
  check_repo.py          maintenance check: every script parses and answers --help,
                         every referenced path resolves, every file is documented

Fixes found while running the above
  repack.py       wrote archives with a plain zipfile writer, which cannot satisfy
                  the Android R+ install gate: resources.arsc must be STORED *and*
                  4-byte aligned, and a zip extra record cannot express a 1-3 byte
                  pad (a stored filler entry is inserted instead). Also fixes the
                  central-directory pointer and adds an alignment gate to the output.
  usb_net_proxy.py, dex_strpatch.py
                  crashed on --help with a traceback instead of printing usage.

Updated
  toolchain.md            ddc added (single-binary dex->Java with query subcommands,
                          with measured timings and its real limits); "not on PATH is
                          not not-installed" with the search commands; which signer
                          to use and why jarsigner breaks alignment
  repack-and-sign.md      alignment requirement and its arithmetic; dex header
                          recompute; jarsigner hazard; vendor install interception
                          ([N] with no symbolic constant -> root pm install); the
                          installer can keep owning the screen after an install
  recon.md                resolve package identity from a tool, not from hand-read
                          AXML strings (a wrong package sends all later device queries
                          nowhere); snapshot the config surface during recon
  long-task-discipline.md captures you never looked at are not evidence (incl. the
                          consistent-but-wrong-window variant); long-context decay and
                          the record shape that survives it
  dynamic-frida.md        when live attach cannot work at all: static instrumentation,
                          static read, and self-recorded evidence, with the control
                          build caveat each one carries
  ad-removal.md           server-driven config is usually the EASIEST case, not the
                          hardest; a three-way zero result is a positive finding;
                          scope note on local brand splash vs promotional content
  pitfalls.md             P37 stale dex header checksum, P38 [-124] alignment,
                          P39 bare-numeric install failure, P40 wrong-window evidence,
                          P41 inverted branch polarity, P42 decode desynchronisation
  SKILL.md, README.md     symptom index, reference/script indexes and requirements
                          updated for all of the above
2026-09-20 02:05:41 +08:00

23 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 — 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>) raises ProcessNotFoundError in 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 });
  • Java is undefined ⇒ the script runtime has no Java bridge. Pass runtime='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:
    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.

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:

  1. The app's own network wrapper — enumerate getDeclaredMethods and wrap every overload, so you do not have to guess the entry point.
  2. 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.
  3. java.net.URL.openConnection — plenty of login/register paths never touch OkHttp; they use HttpsURLConnection and a completely different trust configuration.
  4. 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. Use let or .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:

  1. Does the class / method / overload actually exist? A misspelled obfuscated name, a renamed okhttp internal class, or an overload taking Object instead of String all produce a wrapper that is simply never invoked.
  2. Is the ClassLoader the right one? Multi-dex, plugin-loaded and packed apps can hold several loaders; Java.use uses the app loader by default and throws ClassNotFoundException for 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 () {}
    });
    
  3. Is that code path reached at all? Layer 4 settles it: if THROW events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
  4. 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:

  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).

Rule this out first — it is not always a detector. A process that dies right after you attach is frequently the ROM rather than the app: on aggressive OEM ROMs, merely backgrounding the app (pressing HOME, switching away) triggers a freeze, and the logcat signature is a state transition such as state: R -> F while hooks stop with no Java stack. That looks identical to a detector killing you. Check references/environment.md for that signature, and keep the app in the foreground, before building an anti-anti plan.

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.

When live attach cannot work at all

Sometimes nothing short of "do not inject at runtime" is viable — the check runs before your hooks can, it watches its own process, or the environment forbids ptrace. Do not keep escalating on the same axis. There are non-live ways to get runtime-grade information, and they are often enough to finish the job:

Alternative What it gives you Cost
Static instrumentation — load a gadget/small agent by modifying the APK (e.g. an injected Application wrapper, a gadget .so added to lib/<abi>/ and wired from the manifest) Hooks run from process start with no external attach, no ptrace, no server process on the device to be found Must repack and re-sign; the build you observe is no longer byte-identical to the original, so it cannot serve as the unmodified control (verification.md §the control build rule)
Static read of the answer — the check's inputs are usually visible: the field it reads, the string it compares, the response field it trusts Often enough to plan a patch without ever observing You do not learn the live values; combine with runtime-data.md for local state
Self-recorded evidence — the app's own cached responses, logs, or on-disk state Real runtime data with no injection at all (a HTTP cache, a pref, a JSON snapshot left behind by a normal launch) Only what the app happened to persist; needs one clean launch and then a filesystem read

The last one is worth remembering because it is the cheapest and it is easy to forget: a normally-launched app often leaves the exact runtime data you wanted — server responses in its HTTP cache, config in a preferences file, feature state in a snapshot. Read those before concluding that dynamic analysis is blocked.

Two cautions that apply to every alternative above:

  • Changed bytes change behaviour. Any injected build is a different artifact, and on a target that fingerprints its own layout that difference is the finding, not the app's real behaviour. Always keep the unmodified run as the control.
  • Evidence from a different environment is a different claim. A result obtained with the app patched-for-injection, or on a device with different privilege state, supports "verified under condition X". Say so; do not present it as the app's unmodified behaviour (long-task-discipline.md §the most expensive drift).

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