Documentation

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

Mechanism: force-void on TrustManagerImpl.checkServerTrusted — the single platform trust path that both plain Conscrypt and a Network Security Config pin-set funnel through. This is the native-trust path and it is immune to class-name obfuscation.

# 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

Gate: the force-void is a return-override, so it needs the Active-tier ack. Under capture --embed-trust the active ack auto-arms debug.rutken.return_overrides; under run --bespoke set it by hand first (setprop debug.rutken.return_overrides 1). Behaviour: the pinned endpoint stops throwing and the proxy decrypts — the Cronet / Apache clients return their payload and the per-flavour secret is read off the wire.

Custom X509TrustManager

Mechanism: derived force-void — checkServerTrusted is located by hierarchy descent from javax.net.ssl.X509TrustManager, on whatever concrete class the app defines, so it is name-independent and survives renaming. Delivered via Route 3 (attach, needs debuggable).

# 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"

Gate: return-override — requires --i-am-authorized-bespoke-active (and, under run --bespoke, setprop debug.rutken.return_overrides 1); every site refuses by name under observation-only consent. Behaviour: custom validation that rejected the MITM cert now returns void and the connection completes.

Custom HostnameVerifier

Mechanism: derived force-true — HostnameVerifier.verify is found by hierarchy descent from javax.net.ssl.HostnameVerifier and forced to return true, with a ClassPrepare deferral so a lazily-loaded verifier class is still caught.

# 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"

Gate: return-override — Active ack plus debug.rutken.return_overrides (auto-armed under capture --embed-trust). Behaviour: the pinned endpoint stops throwing SSLPeerUnverifiedException and the connection completes (OK 200).

OkHttp3 / OkHttp2 CertificatePinner

Mechanism: two routes. By-name return-override on CertificatePinner.check for un-obfuscated builds. For an obfuscated release (where okhttp3.CertificatePinner is renamed and the by-name pack misses), a structural, name-independent detector finds the pinner by method shape and its SHA-256 / sha256/ / SSLPeerUnverifiedException signals. The Way-2 native tap reads okhttp plaintext regardless.

# 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"

Gate: return-override — Active ack plus debug.rutken.return_overrides. Behaviour: the pinned okhttp call stops raising SSLPeerUnverifiedException and the proxy decrypts the request (e.g. the x-api-key header).

Cronet addPublicKeyPins

Mechanism: neutralise CronetEngine.Builder.addPublicKeyPins so the builder installs no pin-set, then read the Cronet client's payload off the proxy or the native tap.

# 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"

Gate: return-override — the Active ack auto-arms debug.rutken.return_overrides under capture --embed-trust; under run --bespoke, setprop debug.rutken.return_overrides 1 first. Behaviour: the Cronet client that was pinned completes and the proxy / native tap reads its payload.

Certificate Transparency (Appmattus interceptor)

Mechanism: return Null from CertificateTransparencyBase.verifyCertificateTransparency. Success$Trusted is a data class, not a singleton, so the interceptor's is Failure check passes a null through.

# 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"

Gate: return-override — Active ack plus debug.rutken.return_overrides. Behaviour: the CT-enforced endpoint stops blocking and the connection completes (OK 200).

Flutter (dart:io)

Mechanism: native pattern-scan + force-accept on the dart:io certificate callback inside libflutter.so — the real dart:io target. A late-load deferral plus a dl_iterate_phdr module locate make the extractNativeLibs=false case work: when libflutter.so is mmap'd straight from the APK, /proc/maps has no library name, so the locator reads the bias from dlpi_addr and suffix-matches on dlpi_name. The native tier is immune to Java-layer obfuscation.

# 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"

Gate: the native force-accept is a behaviour-change, so it rides the same Active ack / debug.rutken.return_overrides arming. Behaviour: a pinned Flutter app (SecurityContext pin, extractNativeLibs=false) completes its untrusted-cert probe (SUCCESS 200).

Xamarin / .NET MAUI

Mechanism: Xamarin rides the platform Conscrypt libssl, so the Way-2 native tap reads its plaintext with no Java-layer hook at all. Actively defeating the managed (C#) pin is a separate problem that needs a Mono-method bypass.

# 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"

Gate: the native tap is observation-only for plaintext capture (no return-override needed to read bytes); --bespoke-raw-bytes surfaces the actual bytes rather than the {len, sha256} digest. Behaviour: the request and response cleartext (secret header included) appear in the recorded session.

WebView onReceivedSslError

Mechanism: driving the WebView onReceivedSslError override directly is not currently supported.

If the app's sensitive traffic also traverses a Java TLS stack or BoringSSL, the native Way-2 tap still reads that plaintext. For the WebView SSL-error path itself, drive the Frida route.

Unity (IL2CPP)

Mechanism: identical to Flutter — a native tap plus late-load deferral, resolved through the dl_iterate_phdr module locate. The invocation mirrors the Flutter native-tap command above (agent attached, native hooks, --bespoke-raw-bytes).

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-attach to auto-resolve Rutken's own bundled agent for the device ABI). The APK is never repacked or resigned. On a user build 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-trust embeds the agent as a native-lib constructor — no debuggable build needed. The signer resigns the repacked app with your signing key, and install-multiple handles a split app's whole set. The Active ack auto-arms debug.rutken.return_overrides on 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.