Network capture by TLS version
A complete, end-to-end CLI walkthrough for capturing and reading an Android app's HTTPS traffic in cleartext — one procedure per TLS version. This is the version-by-version companion to Network capture & TLS and TLS pinning bypass: it reuses the same four ways to read plaintext, but spells out, per TLS version, which ways apply, the exact commands with real flags, how to trigger and verify, and — the one place the versions genuinely diverge — the TLS keylog format. Three of the four ways are version-agnostic; only the keylog line differs between TLS 1.2 and TLS 1.3, so those share one procedure with a single difference called out.
Overview & the four ways
Rutken reads an app's HTTPS traffic in cleartext at one of four layers; the first that fits the app wins. The important fact for this page: three of the four are version-agnostic. They sit either above the TLS record layer (the app's own bytes) or inside the app's TLS library (plaintext before encryption / after decryption), so TLS 1.0, 1.1, 1.2, and 1.3 are handled identically. Only the fourth — the TLS keylog — depends on the negotiated version, because the key material it records is structured differently in TLS 1.3 than in every earlier version.
| # | Way | Where it taps | Version-specific? |
|---|---|---|---|
| 1 | Proxy MITM | System HTTP proxy + a Java TLS stack, after a pinning-bypass hook makes the app trust the proxy. | No — the proxy terminates TLS and re-encrypts; it reads cleartext whatever version the app negotiated with it. |
| 2 | Native in-process tap | SSL_read / SSL_write inside the app's BoringSSL (libssl). Cleartext before encryption and after decryption. |
No — it reads the plaintext buffers, which are the same shape on every TLS version. Immune to pinning, needs no proxy. |
| 3 | App-layer hook | The app's own decrypt / secret methods, located by static analysis. | No — it operates on the app's plaintext objects, entirely above TLS. |
| 4 | TLS keylog (NSS) | The libssl keylog symbol → an NSS-format keyfile for offline decryption of a captured pcap. | Yes — TLS ≤1.2 emits a CLIENT_RANDOM line via SSL_get_client_random; TLS 1.3 emits traffic-secret lines via ssl_log_secret. This is the only version-specific way. |
Way 2 is the differentiator: because it reads cleartext inside the process, it does not care about pinning, proxy reachability, or the TLS version at all. For most investigations you will use Way 1 or Way 2 and never need the keylog — the keylog (Way 4) matters when you want an offline, Wireshark-decryptable record rather than a live in-process read, and that is the only place TLS 1.3 differs from the rest.
Only capture traffic from an app you own or are explicitly authorized to test. The behaviour-changing hooks take their authorization acknowledgements verbatim and fail closed without them.
Shared prerequisites
These apply to every walkthrough below, regardless of TLS version. The version-specific steps are called out inside each section; everything here is done once.
- Device attached.
adb devicesshows your phone or emulator asdevice. Note its serial — it feeds--device/--serial. - The target APK on disk. For a third-party app, pull it first:
adb shell pm path <pkg>thenadb pull <path> app.apk. - The APK's SHA-256, for the consent gates:
sha256sum app.apk. The--*-authorizeflags take this exact digest. - A signing keystore, for the repack routes (
unpinviaembed-gadget, andcapture --embed-trust):keytool -genkeypair -keystore signing.p12 -storetype PKCS12 \ -storepass "$RUTKEN_KEYSTORE_PASS" -alias k -keyalg RSA \ -keysize 2048 -validity 3650 -dname "CN=Test" - The bespoke agent, for the in-house routes (Ways 2–4 and
capture --embed-trust). Either let Rutken auto-resolve its bundled agent for the device ABI (--bespoke-attach, or omit--agent-so), or build a current one — never reuse a stale fixture.so, which is rejected with a wire-protocol version mismatch: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 - Feature-gated verbs.
unpin,capture,run --bespoke, andembed-gadgetship only in a dynamic build (e.g.cargo build -p cli --features tier-set-pro-plus, or theintercept+bespoke-agentfeatures for the embedded capture path). Community getsunpin; the in-house agent routes are paid editions.
Set the consent phrases once as shell variables so the long commands below stay readable — they must be passed verbatim:
export RUTKEN_KEYSTORE_PASS=changeit
SHA=$(sha256sum app.apk | cut -d' ' -f1)
OBSERVE="I am authorized to observe this target via the bespoke agent"
ACTIVE="I am authorized to MODIFY this target's behaviour via the bespoke agent"
REPACK="I am authorized to repack and reinstall this target on this device"
INTERCEPT="I am authorized to intercept traffic for this target"
SUBVERT="I am authorized to subvert this target's defenses"
Verify which TLS version negotiated
Which walkthrough applies is decided by what the device and server actually negotiate, not by what either supports in the abstract. Modern Android picks the highest version both ends allow: on Android 10+ (API 29) that is TLS 1.3 when the server offers it, otherwise TLS 1.2. Confirm the version before and after your capture, two independent ways:
Before: probe the server's supported versions
From your workstation, ask the endpoint directly. If a specific version handshakes, the server allows it; the device will use the highest it and the app both allow.
# Does the server do TLS 1.3? 1.2? 1.1? 1.0?
openssl s_client -connect api.example.com:443 -tls1_3 </dev/null 2>/dev/null | grep -i 'Protocol'
openssl s_client -connect api.example.com:443 -tls1_2 </dev/null 2>/dev/null | grep -i 'Protocol'
openssl s_client -connect api.example.com:443 -tls1_1 </dev/null 2>/dev/null | grep -i 'Protocol'
# Or enumerate everything at once:
nmap --script ssl-enum-ciphers -p 443 api.example.com
After: read the version off the capture
The most reliable confirmation of what actually ran is the keylog file itself — the label on each line names the version, so even a single captured secret tells you which section you are in:
CLIENT_RANDOM <hex> <hex>→ TLS 1.2 or earlier (see the TLS 1.2 walkthrough; TLS 1.0/1.1 use the same line).CLIENT_HANDSHAKE_TRAFFIC_SECRET,SERVER_HANDSHAKE_TRAFFIC_SECRET,CLIENT_TRAFFIC_SECRET_0,SERVER_TRAFFIC_SECRET_0,EXPORTER_SECRET→ TLS 1.3 (see the TLS 1.3 walkthrough).
You can also watch the device during the run:
adb logcat | grep -i rutken-agent surfaces the
agent's own attach and per-hook diagnostics, and the host
heartbeat on a capture run reports
captured N flows, 0 undecrypted/opaque — an
"opaque" count above zero means a TLS connection the proxy could
not decrypt (pinning held, or a version/stack the proxy
route can't reach), which is your cue to drop to the native tap
(Way 2), which is version- and pinning-agnostic.
TLS 1.0 & TLS 1.1 legacy
These two share one procedure, and it is the same as
TLS 1.2. TLS 1.0 and
TLS 1.1 are grouped here because, on the keylog layer, they
behave exactly like TLS 1.2 (a CLIENT_RANDOM
NSS line via SSL_get_client_random), and on the other
three layers they are version-agnostic like everything else.
Which of the four ways apply
- Way 1 (proxy MITM) — yes, if the app honours the system proxy and you unpin it. Version-agnostic.
- Way 2 (native tap) — yes, unconditionally. The recommended route; immune to pinning and version.
- Way 3 (app-layer hook) — yes, where the app has its own decrypt/secret method.
- Way 4 (keylog) — yes, identical to TLS 1.2: the
CLIENT_RANDOMline.
Walkthrough
Follow the TLS 1.2 walkthrough
verbatim. The native TLS hook spec (native-tls.json)
names SSL_read, SSL_write, and the
SSL_get_client_random keylog symbol — the same
symbol path used for TLS 1.2, because TLS 1.0/1.1/1.2 all
carry the pre-1.3 key schedule. Verify the negotiated version with
the keylog-line check: a
CLIENT_RANDOM line confirms you are on this (pre-1.3)
path; the Protocol line from
openssl s_client -tls1_1 (or -tls1 for
1.0) confirms the server's half.
TLS 1.2 baseline
The mainstream case. This is the full, self-contained walkthrough; TLS 1.0/1.1 reuse it unchanged, and TLS 1.3 reuses Steps 1–3 unchanged and differs only in the keylog step (Step 4). All four ways apply.
Prereqs
The shared prerequisites (device, APK, SHA, keystore, agent, consent variables). No TLS-1.2-specific setup.
Way 1 — proxy MITM (Community: unpin)
Turnkey and broad for Java/Conscrypt stacks. Embed the Frida gadget, install, then unpin + record over the system proxy.
# 1. Embed a non-blocking gadget (so you can trigger traffic after the proxy is up)
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=$RUTKEN_KEYSTORE_PASS 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
# 2. Install and launch (loads the gadget)
adb uninstall <pkg>; adb install -r app-gadget.apk
adb shell am start -n <pkg>/.<LauncherActivity>
# 3. Unpin + record
export RUTKEN_FRIDA=~/.local/share/rutken-frida/bin/frida
export RUTKEN_FRIDA_PS=~/.local/share/rutken-frida/bin/frida-ps
rutken app-gadget.apk unpin \
--gadget --gadget-port 27042 --ca-store none --serial <serial> \
--duration 120 --record out.rcap \
--authorize $(sha256sum app-gadget.apk | cut -d' ' -f1) \
--i-am-authorized "$INTERCEPT" \
--i-am-authorized-subvert "$SUBVERT"
Trigger: 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 ones may not.
Expected output: the closing summary shows
ssl.trust-manager bypass=Bypassed and
flows recorded: N. Read the plaintext with
rutken capture export out.rcap (HAR / pcapng) or
open out.rcap in the GUI Network view.
Way 2 — native in-process tap (paid: run --bespoke)
The differentiator: reads cleartext inside BoringSSL, so it is immune to pinning and needs no proxy. This is the route to reach for when the proxy shows opaque flows, or against Cronet / native stacks. Version-agnostic — identical for TLS 1.2 and 1.3.
rutken app.apk run --bespoke \
--bespoke-attach-agent target/aarch64-linux-android/release/libagent.so \
--bespoke-hooks native-tls.json \ # SSL_read / SSL_write (+ keylog) symbols
--bespoke-raw-bytes \ # surface the actual bytes, not a {len,sha256} digest
--bespoke-authorize $SHA \
--i-am-authorized-bespoke "$OBSERVE" \
--i-am-authorized-bespoke-active "$ACTIVE" \
--session-out session.json \
--duration 90
Trigger: once the host shows the agent
attached, exercise the app's network paths. The deterministic
stimulus walk also drives exported components under the active
ack; use --bespoke-no-stimulus and drive by hand if
the walk destabilises the target.
Expected output: the session records
tls.SSL_read.bytes / tls.SSL_write
capture values. Without --bespoke-raw-bytes each is
digested to {len, sha256}; with it, the session
file (session.json) carries the real request
(secret headers included) and response in cleartext — so
treat that file as sensitive.
Way 3 — app-layer hook
When the app decrypts or mints secrets in its own code, let static analysis pick the hook coordinates and attach there.
rutken app.apk run --bespoke \
--bespoke-attach-agent target/aarch64-linux-android/release/libagent.so \
--bespoke-derived \ # hooks computed from THIS app's findings
--bespoke-authorize $SHA \
--i-am-authorized-bespoke "$OBSERVE" \
--i-am-authorized-bespoke-active "$ACTIVE" \
--session-out session.json \
--duration 90
Expected output: the app's own
host/key/decrypted-body values straight from memory, recorded
into session.json. Version-agnostic.
Way 4 — TLS keylog version-specific
For an offline, Wireshark-decryptable record. On TLS 1.2
(and 1.0/1.1) the keylog symbol is
SSL_get_client_random, and it emits the classic
NSS CLIENT_RANDOM line:
CLIENT_RANDOM <64-hex client_random> <96-hex master_secret>
Run the native-tls hook spec (same command as Way 2); its keylog hook captures the secret into the session. Assemble the captured secret(s) into an NSS keylog file, capture the wire in parallel (e.g. a device-side pcap), and decrypt offline:
# 1. Capture secrets (keylog hook rides in native-tls.json)
rutken app.apk run --bespoke \
--bespoke-attach-agent target/aarch64-linux-android/release/libagent.so \
--bespoke-hooks native-tls.json \
--bespoke-raw-bytes \
--bespoke-authorize $SHA \
--i-am-authorized-bespoke "$OBSERVE" \
--i-am-authorized-bespoke-active "$ACTIVE" \
--session-out session.json --duration 90
# 2. Write the CLIENT_RANDOM line(s) from session.json into an NSS keyfile
# (one line per connection), then point Wireshark at it:
# Preferences -> Protocols -> TLS -> (Pre)-Master-Secret log filename = keys.log
wireshark -o tls.keylog_file:keys.log capture.pcap
Verify: a CLIENT_RANDOM prefix in
the keyfile confirms you are on the pre-1.3 path. If instead you
see *_TRAFFIC_SECRET_* lines, the connection was
TLS 1.3 — switch to that walkthrough.
Prefer Way 2 for a first read of TLS 1.2 traffic: it is the fastest path to cleartext, works regardless of pinning, and needs no proxy or offline step. Reach for Way 1 for broad Java coverage with the free edition, and Way 4 only when you specifically need an offline, Wireshark-replayable artifact.
TLS 1.3 modern default
On a current device against a current server, this is what you
will almost always negotiate. Ways 1, 2, and 3 are
byte-for-byte identical to the TLS 1.2
walkthrough — the proxy MITM, the native
SSL_read/SSL_write tap, and the app-layer
hook all sit outside or inside TLS where the record-layer version
is irrelevant. Only the keylog (Way 4) differs.
Ways 1–3 — unchanged from TLS 1.2
Run exactly the commands in the TLS 1.2 section:
- Way 1 (proxy MITM):
unpin --gadget --record out.rcap— the proxy re-negotiates its own TLS with the app, version-independent. - Way 2 (native tap):
run --bespoke --bespoke-hooks native-tls.json --bespoke-raw-bytes— reads the same plaintext buffers. This is the recommended TLS 1.3 route: it ignores the key schedule entirely. - Way 3 (app-layer hook):
run --bespoke --bespoke-derived— operates above TLS.
For TLS 1.2 and 1.3 the native tap procedure is identical; only the keylog line format below differs.
Way 4 — TLS keylog 1.3-specific
TLS 1.3 replaced the single master secret with a tree of
traffic secrets, so there is no CLIENT_RANDOM line.
The keylog symbol is ssl_log_secret (not
SSL_get_client_random), and it emits the TLS 1.3
NSS labels:
CLIENT_HANDSHAKE_TRAFFIC_SECRET <hex client_random> <hex secret>
SERVER_HANDSHAKE_TRAFFIC_SECRET <hex client_random> <hex secret>
CLIENT_TRAFFIC_SECRET_0 <hex client_random> <hex secret>
SERVER_TRAFFIC_SECRET_0 <hex client_random> <hex secret>
EXPORTER_SECRET <hex client_random> <hex secret>
The command is the same shape as Way 2/4 on TLS 1.2 (the
native-tls hook spec simply names the ssl_log_secret
target instead of SSL_get_client_random):
rutken app.apk run --bespoke \
--bespoke-attach-agent target/aarch64-linux-android/release/libagent.so \
--bespoke-hooks native-tls.json \ # keylog target = ssl_log_secret (TLS 1.3)
--bespoke-raw-bytes \
--bespoke-authorize $SHA \
--i-am-authorized-bespoke "$OBSERVE" \
--i-am-authorized-bespoke-active "$ACTIVE" \
--session-out session.json --duration 90
Stripped-libssl note. On a
release device, libssl is stripped, so
ssl_log_secret has no exported symbol to hook by
name. Rutken locates it by a byte-signature match against stock
BoringSSL. Full NSS keylog reconstruction also depends on the app
installing an in-process keylog callback; when it does not, the
simplest route to guaranteed TLS 1.3 cleartext is
Way 2 (the native
SSL_read/SSL_write tap), which
reads plaintext directly and does not depend on the keylog path
at all.
Verify: the presence of
*_TRAFFIC_SECRET_* / *_HANDSHAKE_TRAFFIC_SECRET
labels in the keyfile confirms TLS 1.3. Feed the keyfile to
Wireshark the same way (tls.keylog_file); Wireshark
understands both the pre-1.3 and 1.3 label sets from the same
file.
Bottom line for TLS 1.3: capture and read it
exactly as TLS 1.2 for Ways 1–3. Default to the
native tap (Way 2) for a direct cleartext read; only touch the
keylog if you need an offline Wireshark artifact, and note that the
1.3 keylog is the one procedure that depends on a byte-signature
match on stripped libssl.
Compatibility matrix
Which way reads which TLS version. The three plaintext-side ways are version-agnostic by construction; the keylog is the only version-sensitive one.
| Way | TLS 1.0 | TLS 1.1 | TLS 1.2 | TLS 1.3 |
|---|---|---|---|---|
| 1 — Proxy MITM | Yes | Yes | Yes | Yes |
| 2 — Native tap | Yes | Yes | Yes | Yes |
| 3 — App-layer hook | Yes | Yes | Yes | Yes |
| 4 — TLS keylog | CLIENT_RANDOM | CLIENT_RANDOM | CLIENT_RANDOM | ssl_log_secret |
TLS 1.3 keylog uses the ssl_log_secret symbol,
located by byte-signature match on stripped libssl;
for a guaranteed TLS 1.3 cleartext read, use Way 2. Across
all rows, remember that TLS 1.0/1.1 are disabled by default on
Android 10+ and are listed for completeness — you will
rarely negotiate them on a modern device.
See also
- Network capture & TLS — the two routes and four ways, at a glance.
- Dynamic & network capture — the full start-to-finish runbook, GUI viewing, and troubleshooting.
- TLS pinning bypass — per-flavour pinning coverage (OkHttp, Conscrypt, Cronet, Flutter, CT, and more).
Verb-level help is always current — run
rutken run --help,
rutken capture --help, or any verb with
--help for the complete, live flag surface.
Only capture traffic from an app you own or are explicitly authorized to test. The behaviour-changing hooks require authorization acknowledgements and fail closed without them.