TLS certificate-pinning bypass
What Rutken reads off an Android app's TLS, and which pinning flavours it defeats. This page is the per-flavour companion to Network capture & TLS: the mechanism behind each pin and the exact end-to-end CLI that defeats it. For the full capture workflow — routes, agent delivery, teardown — see Dynamic capture, end to end.
Overview
A pinned HTTPS connection refuses to trust a man-in-the-middle certificate, so a plain proxy sees only encrypted bytes. Rutken defeats pinning in two complementary ways: by neutralising the app's pin check (so a proxy can decrypt), or by tapping the plaintext in-process before it is ever encrypted. Which flavour of pin the app uses decides which hook fires; which build shape the app ships in decides which delivery route carries it.
Authorization: run this 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 return-override site refuses by name under observation-only consent.
Four ways to read the plaintext
A pinned connection can be read at four different layers. You do not need all four; the first that fits the app wins.
| # | Way | Where it taps | Needs |
|---|---|---|---|
| 1 | Proxy MITM | system HTTP proxy + a Java TLS stack, after a pinning-bypass hook | proxy reachable; app honours system proxy |
| 2 | Native in-process tap | SSL_read / SSL_write inside the process's BoringSSL (libssl) |
agent attached; libssl mapped (it is, early) |
| 3 | Java app-layer hook | the app's own decrypt / secret methods | agent attached; the method found (static-derived) |
| 4 | TLS keylog (NSS) | SSL_get_client_random / ssl_log_secret → a CLIENT_RANDOM keyfile |
agent attached; the keylog symbol present |
Way 2 is the differentiator: it sees the cleartext
before the app encrypts and
after it decrypts, so it is immune to pinning
entirely and needs no proxy. Use --bespoke-raw-bytes
to surface the actual bytes (the session otherwise digests them
to {len, sha256}). The native tap still reads the
plaintext even when a Java-layer unpin does not apply.
Pinning coverage
Each pinning flavour Rutken handles, how it defeats it, and the invocation that drives it. Hooks tagged native-stack act below the Java layer (BoringSSL / dart:io), so they are immune to class-name obfuscation; hooks tagged Java-stack act on Java classes, and the derived / structural variants find their target by hierarchy and method shape rather than by name, so they survive renaming too.
| Flavour | How Rutken defeats it | CLI |
|---|---|---|
OkHttp3 / OkHttp2 CertificatePinner |
By-name return-override on CertificatePinner.check; a structural, name-independent detector for renamed builds; the native tap (Way 2) reads plaintext regardless (Java-stack) |
--bespoke-pack java.tls.certificatepinner_check or --bespoke-derived --bespoke-force-void-cert-pinner |
Conscrypt TrustManagerImpl.checkServerTrusted |
Force-void on the single platform trust path both plain Conscrypt and Network Security Config funnel through (native-stack) | --bespoke-pack java.tls.trustmanagerimpl_check_server_trusted |
| Network Security Config pin-set | Force-void via the same platform trust path (native-stack) | --bespoke-pack java.tls.trustmanagerimpl_check_server_trusted |
Custom X509TrustManager |
Derived force-void: checkServerTrusted located by hierarchy descent from the platform interface, name-independent (Java-stack, derived) |
--bespoke-derived --bespoke-force-void-trust |
Custom HostnameVerifier |
Derived force-true on the app's own verify(), found by hierarchy descent (Java-stack, derived) |
--bespoke-derived --bespoke-force-true-hostname |
Cronet addPublicKeyPins |
No-op CronetEngine.Builder.addPublicKeyPins so the builder installs no pin-set (Java-stack) |
--bespoke-pack java.tls.cronet_add_public_key_pins_noop |
| Certificate Transparency (Appmattus interceptor) | Neutralise verifyCertificateTransparency so the interceptor's failure check never fires (Java-stack) |
--bespoke-pack java.tls.certificate_transparency_verify |
| Flutter (dart:io) | Native pattern-scan + force-accept on the dart:io certificate callback inside libflutter.so (native-stack) |
--bespoke-hooks flutter-tls.json --bespoke-raw-bytes |
| Xamarin / .NET MAUI | Rides platform Conscrypt libssl; the native tap reads plaintext with no Java-layer hook (native-stack) |
--bespoke-hooks native-tls.json --bespoke-raw-bytes |
WebView onReceivedSslError |
The SSL-error override is not currently driven directly; read any Java TLS / BoringSSL traffic with the native tap, or drive the Frida route | --bespoke-hooks native-tls.json --bespoke-raw-bytes |
| Unity (IL2CPP) | Native tap + late-load deferral, the same native-stack path as Flutter (native-stack) | --bespoke-hooks native-tls.json --bespoke-raw-bytes |
Delivery: the signer resigns a repacked release
or split app with your signing key;
Route 1 (capture --embed-trust)
embeds the agent as a native-lib constructor (no debuggable build
needed) and install-multiple handles the split set;
Route 3 (attach) covers debuggable builds.
The derived (name-independent) bypasses deliver via Route 3.
Per-flavour coverage
Each flavour below gives the pin mechanism, the exact end-to-end invocation that defeats it (with the right flags and packs), the gate that arms any return-override, and the behaviour you should see when it fires.
Conscrypt TrustManagerImpl / Network Security Config
# Force the platform trust check void (by-name pack), debuggable attach
rutken <app>.apk run --bespoke --bespoke-attach-agent <arm64 libagent.so> \
--bespoke-pack java.tls.trustmanagerimpl_check_server_trusted \
--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"
# Non-debuggable release: embed the agent as a native-lib constructor (Route 1)
rutken capture --embed-trust --device <serial> <app>.apk
Custom X509TrustManager
# Derive the hook from this app's hierarchy, then force checkServerTrusted -> Void
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-derived --bespoke-force-void-trust \
--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"
Custom HostnameVerifier
# Force verify() -> true on the app's own HostnameVerifier (derived)
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-derived --bespoke-force-true-hostname \
--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"
OkHttp3 / OkHttp2 CertificatePinner
# By-name: force CertificatePinner.check -> Void (un-obfuscated)
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-pack java.tls.certificatepinner_check \
--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"
# Structural (name-independent) route for renamed okhttp
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-derived --bespoke-force-void-cert-pinner \
--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"
Cronet addPublicKeyPins
# Neutralise addPublicKeyPins on the builder
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-pack java.tls.cronet_add_public_key_pins_noop \
--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"
Certificate Transparency (Appmattus interceptor)
# Neutralise the Appmattus CT interceptor
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-pack java.tls.certificate_transparency_verify \
--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"
Flutter (dart:io)
# Native force-accept on the dart:io certificate callback
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-hooks flutter-tls.json \
--bespoke-raw-bytes \
--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"
Xamarin / .NET MAUI
# Passive native tap -- reads plaintext regardless of the managed pin
rutken <app>.apk run --bespoke \
--bespoke-attach-agent <arm64 libagent.so> \
--bespoke-hooks native-tls.json \
--bespoke-raw-bytes \
--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"
WebView onReceivedSslError
Unity (IL2CPP)
Which route for which shape
The pin flavour decides the hook; the build shape decides the delivery route. Pick by what the app ships as.
-
Debuggable build → Route 3 (attach).
Use
--bespoke-attach-agent <libagent.so>(or--bespoke-attachto auto-resolve Rutken's own bundled agent for the device ABI). The APK is never repacked or resigned. On auserbuild ART still only attaches to a Java-debuggable app, so this is a delivery route, not a permission grant. The derived (name-independent) bypasses deliver this way. -
Non-debuggable release → Route 1 (embed).
capture --embed-trustembeds the agent as a native-lib constructor — no debuggable build needed. The signer resigns the repacked app with your signing key, andinstall-multiplehandles a split app's whole set. The Active ack auto-armsdebug.rutken.return_overrideson this route. -
Obfuscated builds → prefer name-independent hooks.
Obfuscation renames bundled classes and native libs, so
by-name packs miss. The derived / structural
passes (
--bespoke-force-void-trust,--bespoke-force-true-hostname,--bespoke-force-void-cert-pinner) find the target by shape and hierarchy rather than by name, and the native tier (Conscrypt trust path, Flutter, the Way-2 tap) is obfuscation-immune outright.
Agent-lib name collision: Route 1 embeds the agent as
lib<library-name>.so. If
<library-name> collides with a real app lib,
the embedded agent shadows it and the app's own native
function breaks (UnsatisfiedLinkError) — the
agent still loads and hooks, but pick a non-colliding
--library-name.
Commands (quick reference)
The common end-to-end shapes. Replace
<sha256> with the SHA-256 of the exact target
APK, and supply the two consent phrases verbatim.
# Way 2 -- native plaintext tap with raw bytes (no proxy, no pinning bypass needed)
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"
# Cronet -- neutralise addPublicKeyPins on the builder
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-pack java.tls.cronet_add_public_key_pins_noop \
--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"
# (return-override gate: the active ack auto-arms it under capture --embed-trust;
# under run --bespoke, setprop debug.rutken.return_overrides 1 first)
# Custom HostnameVerifier -- force verify()->true (derived)
rutken <app>.apk run --bespoke --bespoke-attach-agent <agent> \
--bespoke-derived --bespoke-force-true-hostname \
--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"
Full flag reference:
CLI basics, or run
rutken run --help (or any verb with
--help) for the complete, always-current 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.