Methodology · September 11, 2026 · 5 min read

Flutter doesn't care what Android trusts

Every interception tool works by editing one Android config file. Flutter has never read that file. Here is how we find the function that actually decides, without a byte signature, and what happened when we ran it against every Flutter app on one phone.


Contents

Intercepting an Android app normally means editing its network security config, repackaging and re-signing. Works on almost everything. On a Flutter app it does nothing, because Dart has never read that file and never will.

Flutter ships its own BoringSSL inside libflutter.so, with its own CA list. So you get the split that makes your tooling look broken: Firebase and Crashlytics decrypt fine, because those are ordinary Android APIs, while the app's own API refuses the handshake. One app, two network stacks, two different opinions about you.

The fix is to make one function say yes. It is called ssl_crypto_x509_session_verify_cert_chain, and knowing that name helps less than you would hope.

Stripped, not obfuscated

People mix these up constantly. libflutter.so is not obfuscated. It is stripped: the symbol table was dropped at build time to save space. Obfuscation would be a much worse problem.

Open it in any disassembler and you get a function list several thousand entries long, every one of them named after its own address. The code reads fine. Nothing says which one checks certificates. You are not decoding anything. You are trying to work out which number you want.

The usual shortcut is a byte signature: copy the first few dozen bytes from a build where someone already found it, then search for that pattern elsewhere. It works until a new compiler or engine shifts the bytes, and then it fails silently. Search returns nothing, you ship an APK that installs, launches and never decrypts, and from the outside that looks exactly like the app beating you.

Two things that survive a rebuild

The verifier runs on both ends of a connection and picks its role from two strings, ssl_client and ssl_server. Lots of code touches one. Only this function touches both, because choosing between them is its whole job.

And its third argument is an out-pointer for an alert code, which it fills in on entry. On arm64 the third argument lives in x2, so the prologue has to store a byte through x2.

Neither fact comes from a compiler. Both come from the source, which is why they survive a rebuild and a byte pattern does not.

Doing it

unzip -o app.apk 'lib/arm64-v8a/libflutter.so'
rabin2 -zz lib/arm64-v8a/libflutter.so | grep -E '\bssl_(client|server)\b'
r2 -n lib/arm64-v8a/libflutter.so
> /r 0x<addr_of_ssl_client>
> /r 0x<addr_of_ssl_server>
> e asm.arch=arm ; e asm.bits=64
> s 0x<hit_in_both> ; pd -40

A cross-reference lands mid-function, so walk back to where the function starts. On this library that left two candidates, and one of them opens like this:

0x00aee5f4   sub    sp, sp, 0x70
0x00aee5f8   stp    x29, x30, [sp, 0x10]
0x00aee5fc   bl     0xde30fc
0x00aee600   mov    w8, 0x50
0x00aee604   strb   w8, [x2]          ; alert code into arg 3
0x00aee608   ldr    x26, [x0, 0xa0]   ; certificate list out of arg 1
0x00aee60c   cbz    x26, 0xaee664

Alert code through x2, then the certificate list out of x0. That is the one. The walk is the same in any other tool: find the two strings, follow their cross-references, and take the function that appears under both. Ghidra will get you there too, if you enjoy waiting for auto-analysis on 15 MB of stripped arm64.

Then two instructions over the prologue, so the body is unreachable and nobody has to understand the rest of it:

struct.pack_into("<I", buf, off,     0x52800020)  # mov w0, #1   -> "trusted"
struct.pack_into("<I", buf, off + 4, 0xD65F03C0)  # ret

Put the library back uncompressed and 4 KB aligned, or the app will not launch at all. Android maps it straight out of the archive.

Thirteen apps, nine engines

Theory is cheap, so we pulled libflutter.so from every Flutter app on one ordinary phone and ran the locator against each one offline.

Worth being exact about what that means, because the list below names real apps. We read one file out of each APK and searched it for a function. Nothing was patched, nothing was reinstalled, no traffic was intercepted and no account was touched. And libflutter.so is not the app vendor's code: it is Google's Flutter engine, shipped unmodified, which is why the two builds on 3.11.1 below have byte-identical prologues at different addresses. What the table records is which engine version each app shipped, the same thing anyone can read off a public APK.

Every Flutter app on one handset, by package, with the engine it ships
PackageDart engineVerifier atLocated
com.binance.dev2.19.20x5968b0yes
fit.cure.android3.4.40x6d1ca4yes
com.hdfcbank.payzapp3.5.30x6dbef4yes
com.moneytap.bnpl.app3.5.30xb1b63cyes
com.act.mobile.apps3.5.40x6dbef4yes
com.hsl.investright3.5.40x6dbef4yes
com.zerodha.kite33.5.40x6dbef4yes
com.timesgroup.magicbricks3.7.20x71b2bcyes
com.poonawallafincorp…3.8.10x73ee80yes
com.dreamplug.androidapp3.9.20xac4424yes
com.sbi.lotusintouch3.10.70x71abfcyes
cx.indianoil.in3.11.10x740850yes
com.bigbasket.mobileapp3.11.10xaee5f4yes

Thirteen of thirteen, engines spanning four years. Never more than two candidates, so the two-string overlap is doing the work rather than getting lucky and being rescued by the x2 check.

Three of the four apps on 3.5.3 and 3.5.4 landed on the same address, 0x6dbef4, which is the sanity check you want: same engine build, same answer, across three unrelated vendors. The two on 3.11.1 landed on different addresses with byte-identical prologues. Same bytes, different place. That is precisely the case a hardcoded offset gets wrong and a behavioural search does not.

The part where we found our own bug

Twelve of those thirteen ship libflutter.so in split_config.arm64_v8a.apk. Only one puts it in base.apk.

Our patcher opened the base APK, looked for the library, did not find it, and concluded the app was not Flutter. Quietly. On twelve of thirteen real apps the Flutter stage did nothing and reported success, producing the exact artifact described four paragraphs ago: installs, launches, never decrypts.

It survived because the app we validated against was the one in thirteen that keeps its library in the base. We had been testing against the single case that could not catch the bug.

Tip

A test that passes against one sample tells you your code and that sample agree. It does not tell you which of them is wrong. The corpus was free, offline and read-only, and it falsified in an afternoon something a working demo had been confirming for weeks.

How it fails

Every path says which step it reached, because "found nothing" and "did not look" should never print the same message:

flutter: found 2 lib(s) but no arm64-v8a, left unpatched
flutter: BoringSSL role literals absent, left unpatched
flutter: no cross-references to the role literals, left unpatched
flutter: verifier not identified, left unpatched
flutter: TLS verification bypassed at 0x<addr> in lib/arm64-v8a/libflutter.so

arm64 only. Other architectures get reported and left alone, because guessing at instruction encodings risks shipping a corrupted library instead of an unpatched one. And thirteen apps is a corpus, not a proof: this breaks the day BoringSSL passes the role as an enum instead of a string, and when it does it will say role literals absent rather than quietly finding nothing.

Point the Mobile Tracer at an app you are authorized to test, Flutter included. Trace a Flutter app