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) | Grade | Evidence |
|---|---|---|---|
| 1 | okhttp3.CertificatePinner.check (force Void) | MATCH | ships, Site 1 ((Ljava/lang/String;Ljava/util/List;)V) |
| 1b | R8-obfuscated CertificatePinner.check (e.g. p0.f, structural) | MATCH (witnessed) | structural shape match; MultiPin 2026-10-03, 6/6 hooks, HTTP 200, 5 flows |
| 2 | Conscrypt TrustManagerImpl.verifyChain (EchoArgObject arg0) | MATCH (witnessed) | 53 decrypted flows, tick0 flip |
| 3 | Conscrypt TrustManagerImpl.checkTrustedRecursive (NewEmptyArrayList) | MATCH (witnessed) | hits=1, no crash, descriptor measured on-device |
| 4 | Conscrypt TrustManagerImpl.checkServerTrusted (void 2-arg, force Void) | MATCH, inert* | installs but real handshakes never call it; kept belt-and-suspenders |
| 6 | HostnameVerifier.verify → true | LOSE | tractable (boolean return), not built |
| 7 | HttpsURLConnection.setDefaultHostnameVerifier | LOSE (bypass) | symbols exist but REFUSE (ClassNameCaptureUnsafe), observe-only |
| 8 | OkHttpClient$Builder.hostnameVerifier | LOSE | n/a |
| 9 | SSLContext.init (inject permissive TrustManager) | MATCH (by subsumption) | we hook the TMs themselves (rows 2–4, 10) instead of replacing them |
| 10 | App-authored custom X509TrustManager | MATCH (witnessed) | derived hierarchy descent + force-Void; hardware A/B below |
| 11 | WebView onReceivedSslError/proceed | WASH | Frida also can’t |
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.