Documentation

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.

#WayWhere it tapsVersion-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 devices shows your phone or emulator as device. 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> then adb pull <path> app.apk.
  • The APK's SHA-256, for the consent gates: sha256sum app.apk. The --*-authorize flags take this exact digest.
  • A signing keystore, for the repack routes (unpin via embed-gadget, and capture --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, and embed-gadget ship only in a dynamic build (e.g. cargo build -p cli --features tier-set-pro-plus, or the intercept + bespoke-agent features for the embedded capture path). Community gets unpin; 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.

Effectively dead on modern Android. Conscrypt disabled TLS 1.0 and 1.1 by default from Android 10 (API 29), and servers have broadly dropped them. In practice an app on a current device negotiates TLS 1.2 or 1.3, not these. You will only land here against an old device, an app that explicitly re-enables legacy protocols, or a legacy server — and if so, nothing in the procedure changes versus TLS 1.2.

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_RANDOM line.

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

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.