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.
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 nativeSSL_read/SSL_writetap. - 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.
-
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. -
Native in-process tap —
SSL_read/SSL_writeinside 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-bytesto 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). - 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.
-
TLS keylog (NSS) —
SSL_get_client_random/ssl_log_secretinto aCLIENT_RANDOMkeyfile for offline decryption. Full NSS lines need a keylog callback, but Way 2 covers the plaintext regardless.
Prerequisites (all routes)
- Device connected:
adb devicesshows it asdevice(rutken deviceslists 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_noopto neutralize Cronet'saddPublicKeyPins. 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-authoredInstallJSON document naming the hooks to request. -
--bespoke-derivedcomputes the hook set from static analysis instead — it runs the same selected-rule finding set--verifywould, screens for app-defined methods, and builds anInstallfrom up to 16 ranked candidates. Mutually exclusive with--bespoke-hooksand--bespoke-pack(theirHookIdspaces 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
addPublicKeyPinsno-op plus the nativeSSL_read/SSL_writetap. - 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) —
Nullreturn 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-trustthe active ack arms it automatically; underrun --bespoke, runadb shell setprop debug.rutken.return_overrides 1before launch (only"1"enables it). unsupported protocol versionon 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-clangis not onPATH. 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-classat a qualifying class, or (for an app you build) add a deadSystem.loadLibraryreference compiled without debug info. - "device state pending" /
device_ok:falseafter many runs. - Stale teardown ledgers. Run
rutken capture teardown --listthen--recover; reboot the phone to clear device-side proxy/CA leftovers. - Host sits at
agent: Attachingforever. - 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-agentshows the exact attach step or per-hook refusal reason.