Documentation / Dynamic analysis

Dynamic capture, end to end

This page takes you from "I have an APK and a phone" to "I can read the app's HTTPS requests in cleartext, including pinned connections." It covers the two interception routes, the in-house no-Frida agent, the four layers you can read plaintext at, full worked CLI examples, teardown, and the coverage limits. For the per-stack pinning-bypass reference, see the companion pinning-bypass coverage page.

Authorization comes first

Dynamic capture touches a live device or process over adb / Frida or Rutken's in-house bespoke agent. The active (behaviour-changing) and capture tiers decrypt traffic and, on some routes, actively defeat the app's own TLS pinning. Run these verbs only against an app you own or are explicitly authorized to test.

The behaviour-changing hooks take their consent acknowledgements verbatim and fail closed without them. Every active and capture verb evaluates its acks and a target-hash match before any device work; a failed gate exits 2 and nothing touches the device. The acks are not a formality — each tier uses a distinct phrase so that typing one can never accidentally satisfy another.

The routes

Rutken reads an app's HTTPS traffic in cleartext — including connections protected by certificate pinning — through three routes. Pick by the job in front of you.

Route A — unpin (Community)

rutken <apk> unpin drives a bundled Frida gadget. It composes everything into one bounded window: it attaches Frida's ssl-pinning defeat pack, stands up a host-only TLS-terminating proxy, installs the proxy's CA (or neutralizes trust directly), and records the decrypted result to a .rcap bundle. Turnkey, broad Java/Conscrypt coverage, ideal for a first look and for third-party apps. Delivered via embed-gadget (repack), or over USB with a rooted frida-server.

Route B / Route 1 — capture --embed-trust (paid)

rutken <apk> capture --device <serial> --embed-trust runs Rutken's own in-house bespoke agent — no Frida dependency at all. Apps that detect and block Frida are still fully instrumented, and the agent adds an in-process native SSL_read/SSL_write tap the proxy route can never reach. The agent is embedded as a native-library constructor, so it works on a non-debuggable release target. This is the in-house moat; reach for it when the target detects Frida or when you need native-tier reads (e.g. Cronet).

Route 3 — run --bespoke --bespoke-attach-agent (paid, debuggable)

rutken <apk> run --bespoke --bespoke-attach-agent <libagent.so> delivers the same bespoke agent via am start --attach-agent — no repack, no resign. The target APK is never modified, which is what makes third-party corroboration possible. The catch: ART only attaches an agent to a Java-debuggable app, so Route 3 needs a debuggable target (or a userdebug/emulator build). This is also the delivery path for the derived (name-independent) bypasses today.

Rule of thumb

  • Reach for Route A (unpin) first — it is turnkey and broad.
  • Use Route B (capture --embed-trust) when the target detects/blocks Frida, when it is a non-debuggable release, or when you need the native SSL_read/SSL_write tap.
  • Use Route 3 (run --bespoke-attach-agent) when the target is debuggable and you must not modify the APK (third-party corroboration, derived hooks).

Agent delivery: the native-library constructor

Route B embeds the agent as lib/arm64-v8a/lib<library-name>.so and wires a System.loadLibrary(<library-name>) call into a resolved injection target's <clinit>, using the same patch/writer primitives as embed-gadget. Because it loads as a native-lib constructor at process start, no debuggable flag is required — this is why Route B works on a non-debuggable release where Route 3 cannot.

Name-collision gotcha. Injection is resolve-only: --library-name must already exist in the target DEX's string pool (it is surfaced as a named error otherwise, never silently auto-selected). It must also not collide with a real app library — if --library-name matches, say, one of the app's own bundled .so files, the embedded agent shadows it and the app's own native function breaks with UnsatisfiedLinkError (the agent still loads and hooks, but pick a non-colliding name). embed-gadget can auto-derive a pool-resident name if you omit the flag.

Embedding still resigns and reinstalls the target — a detectable, invasive mutation on a hardened target, never claimed as undetectable. Route 3 avoids this entirely by attaching at launch instead of repacking.

Four ways to read plaintext

A pinned connection can be read at four different layers. You do not need all four — the first that fits the app wins.

  1. Proxy MITM — the system HTTP proxy plus a Java TLS stack, after a pinning-bypass hook. Needs a reachable proxy and an app that honours the system proxy. It reads the real request headers (e.g. x-api-key) off an otherwise-pinned wire.
  2. Native in-process tap — SSL_read / SSL_write inside the process's BoringSSL (libssl). It sees cleartext before the app encrypts and after it decrypts, so it is immune to pinning entirely and needs no proxy at all. Add --bespoke-raw-bytes to surface the actual request and response bytes (secret headers included); without it the session digests captured bytes to {len, sha256}. This is the differentiator — it reads plaintext the proxy never sees (Cronet, WebView).
  3. Java app-layer hook — the app's own decrypt/secret methods, located by static analysis. Reads host, key, and per-flavour URLs straight from memory.
  4. TLS keylog (NSS) — SSL_get_client_random / ssl_log_secret into a CLIENT_RANDOM keyfile for offline decryption. Full NSS lines need a keylog callback, but Way 2 covers the plaintext regardless.

Prerequisites (all routes)

  • Device connected: adb devices shows it as device (rutken devices lists everything ADB-visible).
  • The target APK on disk. For a third-party app, pull it first: rutken pull --package <pkg> --out-dir ./apks (classifies base vs. split APKs).
  • A signing keystore to resign a repacked APK (Routes A and B):
keytool -genkeypair -keystore signing.p12 -storetype PKCS12 \
  -storepass <pw> -alias k -keyalg RSA -keysize 2048 \
  -validity 3650 -dname "CN=Test"

The repack routes need a class in the launcher DEX that already references System.loadLibrary for the injection site. The dynamic verbs are feature-gated: a default rutken --help does not list unpin, capture, intervene, or the --bespoke flags — build with the dynamic features to get them.

Worked example: embed-gadget + unpin (Community)

1. Embed the Frida gadget

Use a non-blocking (on_load: resume) listen config so you can trigger traffic after the proxy is live — see the gotcha below.

cat > gadget.config.json <<'EOF'
{ "interaction": { "type": "listen", "address": "127.0.0.1", "port": 27042,
  "on_port_conflict": "fail", "on_load": "resume" } }
EOF

RUTKEN_KEYSTORE_PASS=<pw> rutken <app>.apk embed-gadget \
  --gadget arm64-v8a=/path/frida-gadget-16.7.19-android-arm64.so \
  --library-name <pool-resident-name> \
  --config gadget.config.json \
  --output app-gadget.apk \
  --keystore signing.p12 --key-alias k

Look for rewriting APK with gadget wired in ... added=1 — the gadget .so is packed and the System.loadLibrary loader injected.

2. Install, launch, and record

adb uninstall <pkg>; adb install -r app-gadget.apk
adb shell am start -n <pkg>/.<LauncherActivity>    # loads the gadget

rutken app-gadget.apk unpin \
  --gadget --gadget-port 27042 --ca-store none --serial <serial> \
  --duration 120 --record out.rcap \
  --authorize <sha256-of-app-gadget.apk> \
  --i-am-authorized "I am authorized to intercept traffic for this target" \
  --i-am-authorized-subvert "I am authorized to subvert this target's defenses"

Consent: unpin takes two acks plus the target hash. --i-am-authorized is the capture ack; --i-am-authorized-subvert authorizes actively defeating the pinning; --authorize is the exact SHA-256 of the APK you are attaching to. All three are evaluated before any device or Frida discovery. unpin never requires the tamper-tier modify ack — it decrypts and records, it never holds, edits, or drops traffic.

Key flags. --gadget / --gadget-port attach to the embedded gadget (the device-side port, default 27042, must match the baked-in config). --ca-store none is the genuinely unrooted route — it installs nothing and lets the pack neutralize the app's own TrustManagerImpl directly, at a coverage cost printed before the run (native BoringSSL TLS, handshakes completed before hooks land, and WebView/system components are not decrypted). --spawn <pkg> is the alternative that most reliably lands hooks before TLS init, but it needs a rooted on-device frida-server.

3. Trigger traffic AFTER the proxy is live (critical)

Wait until adb shell settings get global http_proxy shows 127.0.0.1:<port>, then exercise the app. New connections pick up the proxy; launch-time pings may race ahead of it. The most common "flows: 0 but pinning says bypassed" report is traffic triggered too early.

Expected output. Decrypted flows land in out.rcap (a flows.jsonl plus headers/bodies). The closing summary shows ssl.trust-manager bypass=Bypassed and flows recorded: N. Read it back with rutken capture export out.rcap (redacted) / rutken capture info out.rcap, or open the .rcap in the GUI Network view.

Worked example: capture --embed-trust (in-house agent)

1. Build a fresh arm64 agent

Always build a fresh agent .so. A stale or sample agent binary can speak an older wire protocol and is rejected with unsupported protocol version. The NDK linker must be on PATH or the build fails with No such file or directory (linker). (You can also omit --agent-so entirely on a bespoke-featured build — Rutken auto-resolves its own bundled agent for the connected device's ABI.)

export PATH=$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
cargo build -p agent --features bespoke-agent \
  --target aarch64-linux-android --release
# -> target/aarch64-linux-android/release/libagent.so

2. Run embed-capture

echo "<pkg>" > owned-allowlist.txt

RUTKEN_KEYSTORE_PASS=<pw> rutken <app>.apk capture \
  --device <serial> --owned-allowlist owned-allowlist.txt \
  --embed-trust \
  --agent-so target/aarch64-linux-android/release/libagent.so \
  --library-name <pool-resident-name> \
  --keystore signing.p12 --key-alias k \
  --i-am-authorized-repack "I am authorized to repack and reinstall this target on this device" \
  --i-am-authorized-bespoke "I am authorized to observe this target via the bespoke agent" \
  --i-am-authorized-bespoke-active "I am authorized to MODIFY this target's behaviour via the bespoke agent" \
  --duration 90 --format json

Three independent fail-closed gates. --i-am-authorized-repack + --owned-allowlist gate the repack/resign/reinstall capability (the package must be on the allowlist, and the hash targets the base APK only). --i-am-authorized-bespoke authorizes observation; --i-am-authorized-bespoke-active authorizes modification and is required for the pinning-bypass hooks to actually install — an active-classified return-override site is silently consent-refused without it.

Auto-arming. The active ack now auto-arms debug.rutken.return_overrides on the device (and reverts it on teardown). Before that fix, the TLS-pinning-bypass hooks silently refused with ReturnOverrideDisabled; no manual setprop is required anymore.

Key flags. --embed-trust selects the in-house path (no CA install — the embedded hooks make the app trust the proxy directly). --split <apk> (repeatable) supplies a split/bundle app's split_config.* siblings; the whole set installs atomically with install-multiple. --duration <secs> / --forever bound the window; --format json emits a machine envelope.

3. Verify it armed, then trigger

A healthy run shows, in device logcat (tag rutken-agent): by_name_outcome ... checkServerTrusted bound=N refused=0 and okhttp3.CertificatePinner.check outcome=JavaBound. The host heartbeat streams two channels — never merged: captured N flows (Ns elapsed) and the agent status (agent: Attaching / Finished(...) / Failed(...)). You want 0 undecrypted/opaque; "opaque" means a TLS connection the proxy could not decrypt (pinning held). Trigger traffic after the proxy is live, same as Route A.

Exit codes: 0 clean; 1 an operational error (missing/nonexistent --agent-so or --library-name, repack/embed failure, device unreachable, proxy bind failure); 2 --embed-trust omitted, the repack-tier consent gate failed, or some ledger records could not be undone. A bad bespoke ack does not exit non-zero directly — it surfaces as agent: Failed(...) in the heartbeat.

Worked example: run --bespoke (no-repack attach)

Route 3 attaches the bespoke agent to a debuggable target with --bespoke-attach-agent <libagent.so> (host path, pushed and staged via run-as) — the APK is never repacked or resigned. Use --bespoke-attach instead to auto-resolve the bundled agent for the device ABI.

Way 2 — native plaintext tap with raw bytes

No proxy, no pinning bypass needed; reads cleartext in-process.

rutken <app>.apk run --bespoke \
  --bespoke-attach-agent <arm64 libagent.so> \
  --bespoke-hooks native-tls.json \     # SSL_read / SSL_write / keylog symbols
  --bespoke-raw-bytes \                 # surface the actual bytes, not a digest
  --bespoke-authorize <sha256> \
  --i-am-authorized-bespoke "I am authorized to observe this target via the bespoke agent" \
  --i-am-authorized-bespoke-active "I am authorized to MODIFY this target's behaviour via the bespoke agent"

Requesting hooks: packs, derived, and hand-authored

  • --bespoke-pack <SYMBOL> (repeatable) requests pack hooks by symbol instead of hand-authoring JSON — e.g. --bespoke-pack java.tls.cronet_add_public_key_pins_noop to neutralize Cronet's addPublicKeyPins. An unknown symbol is rejected at parse time and the error lists every valid one, so a typo never reaches the device as a silent no-op.
  • --bespoke-hooks <path> loads a hand-authored Install JSON document naming the hooks to request.
  • --bespoke-derived computes the hook set from static analysis instead — it runs the same selected-rule finding set --verify would, screens for app-defined methods, and builds an Install from up to 16 ranked candidates. Mutually exclusive with --bespoke-hooks and --bespoke-pack (their HookId spaces are never cross-checked, so combining them risks a silent collision).

Derived add-ons for pinning: --bespoke-force-void-trust forces the app's own checkServerTrusted (found by hierarchy descent, name-independent) to return void; --bespoke-force-true-hostname forces a custom HostnameVerifier.verify() to true; --bespoke-hierarchy-seeds seeds hooks for permanently-refused coordinates whose concrete class is only knowable per app. All require --bespoke-derived and the active ack.

Under run --bespoke the return-override gate is not auto-armed (unlike capture --embed-trust). If a site refuses with ReturnOverrideDisabled, set it by hand first: adb shell setprop debug.rutken.return_overrides 1 (only the literal "1" enables it). Throw-override packs additionally need debug.rutken.throw_overrides.

Consent. --bespoke-authorize is the target hash; --i-am-authorized-bespoke grants observation; --i-am-authorized-bespoke-active is a separate escalating ack required by every return-override site. A hook this session cannot authorize is never sent to the agent — it is recorded as a Refused row in the written session file. unpin --bespoke offers the same backend swapped into the unpin flow over Route 3, with the same two acks carrying the bespoke phrases.

Intercept & repeater (GUI)

A recorded .rcap is not just a log — the GUI Network view turns it into an active workbench. Open a bundle (Network rail → Capture setup → Bundle: [path] [Open]) and select a flow: the Request pane shows the decrypted GET ... HTTP/1.1, every header (including the real x-api-key), and the actions Repeater / Edit & resend / Raise finding. Build it with cargo build -p gui --features bespoke,offense (intercept arrives via bespoke) and launch ./target/debug/rutken-gui.

Static findings and the "x reaches y" Taint tab live on the Static/Taint rails of the same window — load the APK with RUTKEN_GUI_OPEN=<apk> ./target/debug/rutken-gui and run analysis to see static and dynamic evidence side by side. (Put the window on a wide monitor, or long header values truncate.)

Teardown & recovery

Every capture verb runs teardown on every exit path, in LIFO order: pinning-script unload → device proxy restore → CA uninstall → host proxy shutdown. Ctrl-C and SIGTERM both stop a run gracefully and fall through to the normal unwind; a second signal is an immediate hard exit. A stopped run's .rcap is honestly incomplete — only what was observed before the stop.

When a run crashes, is SIGKILLed, or disconnects, it can leave device state behind — a pushed CA, a dead http_proxy setting, an adb reverse forward. Those are written ahead to a persisted ledger. On any dirty exit the verb prints a stderr warning naming the ledger, a consequence sentence per entry, and the exact recovery command. capture record and unpin also nag once at startup if an orphaned ledger from an earlier session is found.

# Report pending ledgers without touching any device (the default action)
rutken capture teardown --list

# Undo them (skips a ledger whose owning process is still alive)
rutken capture teardown --recover --serial emulator-5554

# Force past the liveness check, JSON output
rutken capture teardown --recover --force --format json

capture teardown depends only on the runtime layer, so it works even in a build with neither intercept nor offense compiled in. Exit codes: 0 clean, 1 operational error, 2 some records could not be undone. It never auto-recovers — --recover is always operator-driven. If a device is wedged after many runs (device_ok:false / "device state pending"), run --list then --recover, and reboot the phone to clear device-side proxy/CA leftovers.

Coverage & pinning

Everything that routes through the system proxy plus a Java TLS stack decrypts on both routes. WebView is the one case the proxy alone cannot fully decrypt; the bespoke native SSL_read/SSL_write tap (Way 2) reads the plaintext in-process regardless of pinning or proxy.

App network stack

  • HttpURLConnection, OkHttp3 / Retrofit, OkHttp2, Volley / Apache — yes on both routes (proxy + Java TLS).
  • Cronet (native BoringSSL) — no via Route A (native TLS), yes via Route B: the addPublicKeyPins no-op plus the native SSL_read/SSL_write tap.
  • WebView — the proxy decrypts some of it; Way 2 reads it in-process.
  • Flutter — handled via the native pattern-scan + force-accept on the dart:io certificate callback (obfuscation-immune).
  • Xamarin / .NET-MAUI — passive Way 2 capture (rides platform Conscrypt libssl); active managed-pin defeat needs a separate Mono-method bypass.

Pinning-bypass coverage

  • OkHttp3 / OkHttp2 CertificatePinner — by-name return-override on un-obfuscated builds. On an obfuscated release the class is renamed so the by-name pack misses; the structural (name-independent) detector handles it, and the native Way-2 tap reads plaintext regardless.
  • Conscrypt TrustManagerImpl.checkServerTrusted — force-void on the platform trust path (obfuscation-immune).
  • Network Security Config pin-set — force-void via the same trust-manager path.
  • Custom X509TrustManager — derived force-void by hierarchy descent (name-independent); Route 3 delivery.
  • Custom HostnameVerifier — derived force-true + ClassPrepare deferral.
  • Cronet addPublicKeyPins — no-op the builder pin-set install.
  • Certificate Transparency (Appmattus interceptor) — Null return on the CT verify path.
  • WebView onReceivedSslError — the SSL-error override is not currently supported directly; read Java TLS / BoringSSL traffic with the native tap, or drive the Frida route.
  • Unity (IL2CPP) — native tap + late-load deferral, the same native-stack path as Flutter.

For the full per-flavour mechanisms, the exact CLI for each bypass, and delivery details, see the companion pinning-bypass coverage page.

Troubleshooting

"flows: 0" but pinning says bypassed, or opaque flows only.
You triggered traffic before the proxy/gate was live, or the app's launch pings raced setup. Use a non-blocking gadget (on_load: resume) or let embed-capture reach "proxy live", then trigger. New connections pick up the proxy; launch-time ones may not.
Bespoke hooks all refuse with ReturnOverrideDisabled.
The return-override gate was not armed. Under capture --embed-trust the active ack arms it automatically; under run --bespoke, run adb shell setprop debug.rutken.return_overrides 1 before launch (only "1" enables it).
unsupported protocol version on bespoke attach.
You used a stale/fixture agent .so. Rebuild the agent from current source.
Agent build fails with "No such file or directory (linker)".
The NDK aarch64-linux-android30-clang is not on PATH. Export the NDK toolchain bin directory.
embed-gadget/embed fails: "no candidate DEX references System.loadLibrary".
A multidex app whose launcher DEX lacks the loader method-id, or an <init> that carries debug info. Point --target-class at a qualifying class, or (for an app you build) add a dead System.loadLibrary reference compiled without debug info.
"device state pending" / device_ok:false after many runs.
Stale teardown ledgers. Run rutken capture teardown --list then --recover; reboot the phone to clear device-side proxy/CA leftovers.
Host sits at agent: Attaching forever.
On older builds the install hung or every hook refused with no surfacing. Current builds add a 45s install timeout and rutken-agent.* logcat diagnostics — adb logcat | grep rutken-agent shows the exact attach step or per-hook refusal reason.