Back to blog

We graded our cert-unpinning against Frida, site by site, and published where we lose

Rutken ships an in-house instrumentation engine (`libagent.so`, a declarative host-computed JVMTI + inline-hook agent, no in-device JavaScript VM) alongside the Frida it bundles. We hold that engine to a simple rule: for a given job it should at least match Frida, or Frida stays the default. This post is the honest side-by-side for exactly one job, the TLS-interception `unpin` job, defeating pinning so a MITM proxy can decrypt, graded per site as WIN / MATCH / LOSE, each row carrying a commit, a device measurement, or an explicit “not measured.” It is not a claim that we beat Frida at everything. It is a claim about one job, with the losing rows left in.

All device evidence below is from one phone: ZD22267H4T, a moto e40, Android 11 / API 30, non-root, arm64, driven against owned fixtures (runtime-tls-net, a real HttpsURLConnection client, and runtime-tls-custom-tm, described below). The one exception, flagged inline, is the R8-obfuscated OkHttp row. It was measured on a separate obfuscated-release build of the MultiPin pinning test app, not on the moto e40 runtime-tls fixtures.

Why name-based Frida scripts rot under R8

The usual SSL-unpinning script hooks framework classes by fully qualified name: javax.net.ssl.X509TrustManager, okhttp3.CertificatePinner, com.android.org.conscrypt.TrustManagerImpl. Those names survive R8 because they live on the boot classpath or in a library whose public surface the app can't rename, since R8 only obfuscates what it owns. So the framework-level hooks are durable for everyone, us and Frida alike.

The fragile case is the app-authored custom X509TrustManager: a class the app itself declares to enforce its own pin. R8 renames it to something like a.b.c, strips it of anything hintable, and a script that hard-codes a class name has nothing to hook. You can't grep a name that no longer exists in the DEX.

Our answer is to locate it by shape, not name. The static half walks the class hierarchy: descendants_of every class that implements javax.net.ssl.X509TrustManager or extends javax.net.ssl.X509ExtendedTrustManager, and whatever app class descends from one of those interfaces is the custom trust manager, regardless of what R8 named it. The interface is a boot-classpath type R8 can't touch; the descent relationship is structural and survives renaming.

The two roots and the descent are literally this, with no name matching on the app class at all, just “does it descend from a boot-classpath TM type, and is it the app’s own class”:

const INTERVENTIONAL_TRUST_MANAGER_ROOTS: &[&str] =
    &["Ljavax/net/ssl/X509TrustManager;", "Ljavax/net/ssl/X509ExtendedTrustManager;"];

// select_interventional_force_return(): find the app's own TM by shape
let mut descendant_classes: BTreeSet<String> = BTreeSet::new();
for root in roots {
    descendant_classes.extend(hierarchy.descendants_of(root));
}
for class in descendant_classes {
    if PLATFORM_PREFIXES.iter().any(|p| class.starts_with(p)) {
        continue;   // skip the framework TMs -- rows 2-4 already own those
    }
    // whatever app class is left descends from the root and IS the custom
    // TM, whatever R8 named it. Emitted force_void_return: true, with an
    // EMPTY captures list -- no live object-ref capture, so structurally
    // no path to the ClassName-capture crash the hostname sites hit.
}

The force-void primitive, at the return-override level

Once the class is found, neutralizing it does not mean “return true.” A custom TM enforces its pin by throwing. checkServerTrusted(...) is declared void and signals failure with a CertificateException. So the bypass is: make it return normally (void) without executing its body. The selector emits a force_void_return JavaTarget, and the agent installs it by name across every checkServerTrusted overload declared on the resolved class (flag --bespoke-force-void-trust). Mechanically: an entry breakpoint on each overload; on hit, the frame is forced to its normal void return before the pin logic runs, so the throw never happens.

The return type drives the choice of override, by policy, in trust_return_policy:

pub fn trust_return_policy(signature: &str) -> Option<JavaReturnValue> {
    let return_descriptor = artbridge::full_return_descriptor(signature)?;
    if return_descriptor == "V" { return Some(JavaReturnValue::Void); }
    if return_descriptor == "Z" { return Some(JavaReturnValue::Bool(true)); }

    let arg0_matches_return = artbridge::param_descriptor(signature, 0)
        .is_some_and(|arg0| arg0 == return_descriptor);

    if artbridge::is_list_family_descriptor(return_descriptor) {
        return Some(if arg0_matches_return {
            JavaReturnValue::EchoArgObject { arg_index: 0 } // verifyChain: echo the chain
        } else {
            JavaReturnValue::NewEmptyArrayList              // checkTrustedRecursive: fresh []
        });
    }
    let is_other_reference =
        return_descriptor.starts_with('L') || return_descriptor.starts_with('[');
    if is_other_reference && arg0_matches_return {
        return Some(JavaReturnValue::EchoArgObject { arg_index: 0 });
    }
    None // I/J/other primitive, or a ref with no assignable arg0: fail closed, by name
}

The EchoArgObject{0} vs NewEmptyArrayList split is load-bearing and non-obvious: Conscrypt's checkServerTrusted returns the validated chain it was handed (arg0), so echoing arg0 preserves callers that use the return value, whereas checkTrustedRecursive returns a fresh list and an empty one is the right “trusted, nothing to add” answer. The 19 conscrypt_trust unit tests exist specifically to prove the policy reproduces both prior hardware-witnessed choices and not the naive “any List return → empty.”

The scorecard: TLS / pinning bypass sites

Frida’s unpin is a 4-technique ssl-pinning pack. Here is ours against it, per site. Grades are current as of the last amendment; the rows we once graded wrong are called out below the table.

#Site (class.method)GradeEvidence
1okhttp3.CertificatePinner.check (force Void)MATCHships, Site 1 ((Ljava/lang/String;Ljava/util/List;)V)
1bR8-obfuscated CertificatePinner.check (e.g. p0.f, structural)MATCH (witnessed)structural shape match; MultiPin 2026-10-03, 6/6 hooks, HTTP 200, 5 flows
2Conscrypt TrustManagerImpl.verifyChain (EchoArgObject arg0)MATCH (witnessed)53 decrypted flows, tick0 flip
3Conscrypt TrustManagerImpl.checkTrustedRecursive (NewEmptyArrayList)MATCH (witnessed)hits=1, no crash, descriptor measured on-device
4Conscrypt TrustManagerImpl.checkServerTrusted (void 2-arg, force Void)MATCH, inert*installs but real handshakes never call it; kept belt-and-suspenders
6HostnameVerifier.verify → trueLOSEtractable (boolean return), not built
7HttpsURLConnection.setDefaultHostnameVerifierLOSE (bypass)symbols exist but REFUSE (ClassNameCaptureUnsafe), observe-only
8OkHttpClient$Builder.hostnameVerifierLOSEn/a
9SSLContext.init (inject permissive TrustManager)MATCH (by subsumption)we hook the TMs themselves (rows 2–4, 10) instead of replacing them
10App-authored custom X509TrustManagerMATCH (witnessed)derived hierarchy descent + force-Void; hardware A/B below
11WebView onReceivedSslError/proceedWASHFrida also can’t
* Row 4 installs cleanly but a real pinned handshake in Conscrypt never traverses that overload; we keep it anyway.

Where this loses to Frida, stated plainly

The --spawn delivery route. Frida can --spawn a target and instrument it before its first instruction. Our v1 agent has none. It is a named refusal in unpin --bespoke, graded LOSE. We match Frida on the two routes that matter for a stock or debuggable target (embed-and-resign, and am start --attach-agent), and on root-eased attach on userdebug/rooted-debuggable beds, but spawn is not built.

The hostname-verifier sites (rows 6–8). Frida hooks HostnameVerifier.verify to force true; we don’t. Row 7 is worse than “not built”: the symbols exist but we refuse them under the ClassNameCaptureUnsafe safety gate and expose them observe-only. We argue these are lower-impact than the raw table implies, because our proxy forges per-SNI leaf certs with the correct CN, so standard hostname verification passes with no hook (which is exactly why the verifyChain witness got status=200 with no hostname bypass in play). But “lower impact” is an argument, not a bypass. Against an app that pins specifically on the hostname verifier, we lose.

Flutter / dart:io pinning. Flutter apps don’t use any of the Java sites above. dart:io drives a vendored BoringSSL statically linked into libflutter.so, whose release builds strip .symtab, so there is no symbol to dlsym. Our flutter_pin pack implements the full mechanism to reach it anyway (scan the module’s executable segments for a masked byte pattern, and only on an exactly-one match install an address hook that returns BoringSSL’s “accept” sentinel). But two honest facts keep this a LOSE. First, the pack’s original target (ssl_crypto_x509_session_verify_cert_chain) fired zero times against real dart:io handshakes when measured on device (it isn’t on the path dart:io consults) and was retargeted (after RE of a Flutter 3.44.4 arm64 engine) to Dart’s own SSLCertContext::CertificateCallback. Second, the per-architecture byte patterns are not verified against a real libflutter.so (this workspace has no Flutter fixture to extract one from), and the scanner’s fail-closed rule means an unverified pattern matches zero times (refused) rather than patching the wrong address. So the mechanism is built and safe-by-construction, but unproven against a live Flutter target; Frida has community scripts that do this today. LOSE until a symbolized engine build lands the real pattern.

Two rows we graded wrong, and the amendments that fixed them

This is the part that makes the scorecard worth trusting. We walk back our own earlier claims, dated, with evidence, and never erase the losing wording.

Row 9 was re-graded MATCH by subsumption, with the reasoning shown, not hand-waved. Frida’s SSLContext.init hook replaces whatever TrustManager[] is installed with a permissive one. Doing it Frida’s way needs runtime class creation (Java.registerClass for a permissive TM), a primitive the agent deliberately lacks. We argued instead that every TrustManager an init call could carry is either a framework TM (rows 2–4) or an app-custom TM (row 10), so hooking the TMs directly subsumes the init hook for zero coverage loss. The proof the amendment demanded: row 10’s own witness fixture installs its pin through SSLContext.init(null,{PinningTrustManager},null) and is still defeated. The grade stays MATCH-by-subsumption, and an object-arg-injection primitive stays explicitly unbuilt until a corpus app proves rows 2–4+10 miss; none known.

Row 10 moved LOSE → MATCH only after a hardware A/B, not when the code landed. The derived force-Void hook was built, unit-tested, and could easily have been declared done. It wasn’t, until it ran on the phone against the owned runtime-tls-custom-tm fixture, a PinningTrustManager that unconditionally throws:

# ZD22267H4T, moto e40, API 30, non-root, arm64
# fixture: runtime-tls-custom-tm (PinningTrustManager throws unconditionally)

gate OFF  (--bespoke-force-void-trust absent)  ->  pin=FAILED   x31   (pin holds)
gate ON   (--bespoke-force-void-trust present) ->  pin=BYPASSED  x32   (checkServerTrusted forced to return)

The gate is the only variable; the pin flips from holding to bypassed. That is when the grade changed. Two honest notes we keep attached: the by-name force-Void path’s per-firing hits counter reads 0 while the behavior clearly fires (a reporting gap, not a mis-fire, since the A/B behavior is the proof); and on API 30 HttpsURLConnection did not route through the app’s custom TM at all (the platform validated the real cert), so the fixture witnesses via a direct method call. Both are written down rather than buried.

The robustness row: a LOSE that became a MATCH, measured twice

Early on we resolved Conscrypt overloads by exact descriptor. That cost us: checkTrustedRecursive’s descriptor was wrong for API 30 and the hook refused as MethodNotFound until we pulled the real signature off the device’s own Conscrypt with baksmali. Frida would not have missed it: it resolves by name and enumerates overloads. That was an honest LOSE (fail-closed and named, but a coverage loss across Android versions).

We closed it with a JVMTI GetClassMethods wrapper that enumerates every overload declared on the resolved class, and an install_return_override_by_name that expands one (class, method-NAME) into N per-overload installs, applying trust_return_policy per overload. All three trustmanagerimpl_* sites now resolve by name; the hand-measured signature constants are kept as the historical measured-shape record but are no longer load-bearing.

// finish_return_override_install_by_name
let method_ids = crate::sys::jvmti::get_class_methods(env, class)?;
for method_id in method_ids {
    let (name, signature) = match get_method_name_and_signature(env, method_id) {
        Ok(pair) => pair,
        Err(_) => continue,
    };
    if name != method { continue; }   // keep every overload with this NAME
    // ask trust_return_policy for this overload's replacement value;
    // None -> refuse this one overload by name (never a silent skip),
    // else check_return_type_owned (structural backstop), then install.
}

We refused to upgrade the grade on the build alone. We marked it “BUILT, not yet witnessed, stays LOSE until a hardware witness lands”, the same discipline as every other pending row. The witness then landed:

# ZD22267H4T / API 30, arm64 agent, by-name resolution, runtime-tls-net fixture
47 decrypted flows, 0 FAILED ticks, 0 crashes
# same decrypt as the exact-descriptor path produced, but the pinned
# signatures were NOT consulted for resolution.

Grade LOSE → MATCH. One residual, kept for cross-version honesty: GetClassMethods returns declared methods only, so a trust method inherited from a superclass would still be missed, though not the case for these three on API 30, noted anyway.

Row 1b: finding OkHttp’s check after R8 ate the name

Row 1 is durable because okhttp3.CertificatePinner is a bundled library class the app can rename. On a full R8 release build it becomes something like p0.f and its check method is renamed too, so the by-name pack from row 1 resolves nothing, the same failure a name-based Frida script hits. Worse, R8 full-mode does parameter-type specialization: it rewrites check(String, List) to the concrete type actually passed at the call site (e.g. java.util.ArrayList, or a renamed app type like Le0/a;), so even an exact-descriptor whitelist built from the unobfuscated signature stops matching.

Our static analysis finds it by shape instead. The cheap first gate matches the family (String, <one reference-or-array param>)V, which subsumes every historical descriptor and the specialized ones:

fn is_check_shape(d: &str) -> bool {
    let Some(rest) = d.strip_prefix("(Ljava/lang/String;") else { return false };
    let Some(inner) = rest.strip_suffix(")V") else { return false };
    (inner.starts_with('L') && inner.ends_with(';') && inner.matches(';').count() == 1)
        || inner.starts_with('[')
}

Shape alone is far too loose, so a match is only reported after the method body shows evidence R8 cannot rename: platform calls and verbatim string literals. The admission predicate requires the shape plus the right combination of a SHA-256 signal, a pin string, and the exception construction:

// S2 = MessageDigest.getInstance / "SHA-256"
// S3 = "sha256/" prefix  OR  "Certificate pinning failure" literal
// S4 = new SSLPeerUnverifiedException
pub fn admits(&self) -> bool {
    self.shape
        && ((self.pin_string && self.sslpeerunverified)
            || (self.sha256_machinery && (self.pin_string || self.sslpeerunverified)))
}

The payoff is that the real (obfuscated) class and method names are read straight off the IR, and those are exactly the names the on-device class loader uses, so select_force_void_cert_pinner drives the same force-void-by-name primitive row 1 uses, with no new agent code and no live object capture. This is strictly additive: it skips the literal okhttp3/CertificatePinner classes the by-name pack already owns, so it never double-installs on an unobfuscated build.

The witness is the one row whose device evidence is not the moto e40: on 2026-10-03, an R8-obfuscated release build of the MultiPin test app, driven through the Route-1 embed-derived delivery (--embed-force-void-cert-pinner), installed 6/6 hooks (4 fixed + 2 structurally-derived), and a Retrofit-over-OkHttp3 request returned HTTP 200 with 5 flows decrypted. One honest limit: the structural matcher is deliberately conservative; it is tuned to OkHttp’s own check body, so a pinning library that enforces without a SHA-256 call, a sha256/ string, and an SSLPeerUnverifiedException would not clear admits() and would be missed, by design, rather than matched loosely.

The verdict, unsoftened

For the common case (Conscrypt / OkHttp pinning, embed or attach delivery), in-house unpin matches Frida, hardware-witnessed, and that covers the bulk of real pinned apps. It is still inferior to Frida on: the --spawn route; the hostname-verifier sites (rows 6–8); Flutter / dart:io pinning (mechanism built, byte pattern unverified against a real engine); and rooted inject-any-process delivery on stock user + Magisk (unbuilt). None of those losses are hidden; each is a named, scoped follow-on. So --bespoke is opt-in, Frida stays the default, and at the verb we say exactly what it replaces: Frida for the Conscrypt/OkHttp path, not Frida wholesale.

We published this because the LOSE rows are the part worth sharing, a scorecard that listed only wins wouldn’t be worth much. Rutken is available here if you want to point the unpin job at something that pins.