FlutterTap -- Architecture
July 30, 2026 ยท View on GitHub
by Eduardo Lopes
FlutterTap ports flutter+burp.js (a Frida script that bypasses Flutter/BoringSSL
TLS pinning and redirects a Flutter app's outgoing connections to a proxy) into a
persistent Zygisk module, so the same capability works under Magisk, KernelSU
(with Zygisk/Zygisk Next) or APatch without a tethered frida-server.
Throughout this document and the source comments, "the original script" means that Frida script. It is the author's own and is not included in this repository -- the comments describe how each piece of its behavior was ported, so they stand on their own without it.
Why Zygisk
The original script hooks three things inside the target app's process:
BoringSSL's certificate-chain verification, the Flutter engine's internal
GetSockAddr function, and libc's socket(). All three only exist inside an
app's process once it has loaded libflutter.so -- there is no file on disk to
patch. Zygisk is the standard way to run native code inside every (or a
filtered set of) app process on Android without a tethered debugger, which is
why it was chosen over a classic Magisk file-overlay module.
Component map
Original script (flutter+burp.js) | FlutterTap native module |
|---|---|
findAppId() | AppSpecializeArgs::nice_name (read in preAppSpecialize) |
BURP_PROXY_IP / BURP_PROXY_PORT (hardcoded) | /data/adb/fluttertap/config.json, editable from the manager app |
parseElf() | elf_utils.cpp (parse_elf_segments) |
scanMemory() (AOB scan with ?-wildcards) | mem_scan.cpp (scan_all_matches, compile_pattern) |
Instruction.parse (Frida/Capstone under the hood) | Capstone, linked directly (addr_resolver.cpp) |
Interceptor.attach(GetSockAddr) (onEnter) | DobbyInstrument (hooks.cpp) |
Interceptor.attach(socket) (onEnter, rewrite sockaddr) | DobbyHook on libc's global socket() (same process-wide scope as the original) |
Interceptor.attach(verify_cert_chain) (onLeave, force true) | DobbyHook with a wrapper that calls the original then forces the return value |
The address-resolution logic (addr_resolver.cpp) is a byte-for-byte port of
the script's arm64 (adrp/add pair) and x64 (lea reg, [rip+disp]) paths,
including its quirks (see below), using Capstone -- the same disassembly
engine Frida's own Instruction.parse is built on -- instead of a hand-rolled
decoder, to avoid subtly misreading an instruction encoding.
Preserved quirks (intentional, for fidelity)
- iOS/macOS branch dropped. The script has parallel Linux/ELF and Darwin/Mach-O paths; only Android/ELF is relevant here.
- 32-bit not supported.
Process.archin the original script is only handled for'arm64'and'x64'-- there is no 32-bit ARM/x86 branch at all. FlutterTap only shipsarm64-v8aandx86_64for the same reason (every Android 10+ device relevant today is 64-bit anyway; seemodule/build.gradle.kts). - "last match wins".
Memory.scan'sonMatchcallback keeps overwriting the same JS variable for every match found (no early break), so the last match in ascending-address order silently wins if a pattern matches more than once.scan_all_matchesreturns every match and the resolver iterates them the same way, instead of stopping at the first hit. - x64 byte-by-byte stepping. The script's x64 backtraces (
push rbp/push r15search, and theSocket_CreateConnect->GetSockAddr"2nd call" walk) step one byte at a time and just retry on a failed decode, rather than advancing by the decoded instruction length.addr_resolver.cppreproduces this exactly (disasmOneataddr, thenaddr + 1, ...) since that's what was empirically verified to work against real BoringSSL/Flutter builds.
Deliberate improvements (behavior-preserving)
The original script is meant for a single, short-lived, human-supervised Frida session. FlutterTap runs unattended, for the app's entire lifetime, so a few things were hardened without changing what actually happens on the wire:
- Per-thread captured sockaddr instead of a single global, so concurrent
connections on different threads can't interleave (
hooks.cpp). Implemented with pthread thread-specific data rather thanthread_local-- see Zygisk Next Linker compatibility. - SIGSEGV/SIGBUS guard around every heuristic memory read
(
mem_scan_install_crash_guard), so a bad scan result logs and gives up instead of crashing the host app. The guard chains to whatever handler was already installed (Dart VM, crash reporter, ...) for anything that isn't one of FlutterTap's own guarded reads. It is installed only from a confirmed target process (runHookPipeline), never fromonLoad()-- the reason is in the same section linked above. - Zero footprint on non-target apps.
preAppSpecializedecides whether the current process is one of the user-selected target packages before doing any work, and callszygisk::Option::DLCLOSE_MODULE_LIBRARYto unload itself immediately for every app the user didn't select. socket()hooked via Dobby directly (explicitlibc.sohandle +DobbyHook), notzygisk::Api::pltHookRegister. The Zygisk API object stops working right afterpostAppSpecializereturns, butlibflutter.so(and therefore the earliest point these addresses can even be resolved) only loads well after that, during normal app startup. A background thread pollsdl_iterate_phdrforlibflutter.so(mirroring the script's ownawaitForCondition/setIntervalpolling loop) and only then resolves addresses and installs hooks -- entirely through Dobby, which has no such lifecycle restriction.
Zygisk Next Linker compatibility
Zygisk Next has an optional "Zygisk Next Linker" that replaces the system
linker with its own minimal ELF loader and preloads modules directly into
zygote at boot. It has been on by default since Zygisk Next 1.3.3. Three
things in FlutterTap had to change for it, all of them real bugs that were
merely latent under the classic per-process dlopen:
-
The crash guard must not be installed in
onLoad(). Under the ZN LinkeronLoad()runs in zygote, and signal dispositions survivefork(), so a handler installed there is inherited by every app on the device. Non-target processes then callDLCLOSE_MODULE_LIBRARY, unmapping the library while the kernel still holds a handler address pointing into it. ART raises benignSIGSEGVs routinely (implicit null checks, stack-overflow guard pages), so every app died on its first one -- SystemUI included. The guard is now installed fromrunHookPipeline(), which only runs in a confirmed target process that stays loaded. Rule: nothing that mutates process-global state belongs inonLoad(). -
dlsym(RTLD_DEFAULT, ...)returnsnull.RTLD_DEFAULT(andRTLD_NEXT) makes bionic derive the lookup scope from the calling library by mapping the return address back to a linkersoinfo. A module mapped by the ZN Linker has nosoinfo, so that lookup fails.resolveLibcSocket()inhooks.cppuses an explicitdlopen("libc.so", RTLD_NOW|RTLD_NOLOAD)handle instead, which needs no caller lookup and works under both loaders. Note the contrast:dl_iterate_phdrworks fine, because Zygisk Next redirects it to its own implementation that does see app libraries. -
No
thread_local. It emits dynamic-TLS relocations (R_AARCH64_TLSDESC,R_X86_64_DTPMOD64). The ZN Linker has supported TLS relocations since 1.4.0, but the TLS path still needed fixes in 1.4.1, so the three formerthread_locals use pthread thread-specific data instead -- ordinary libc calls that every loader resolves identically.scripts/build_module_zip.shfails the build if a TLS relocation reappears.
Worth recording because it cost real debugging time: the ZN Linker wiki's
"Unimplemented features" list has its first three items struck through
(implemented in 1.3.0-RC4 and 1.4.0). Markdown-to-text conversion drops the
strikethrough, making RELR, Android packed relocations and TLS look
unsupported. Only GNU symbol versioning is genuinely unimplemented -- and that
one is harmless in practice, since every NDK-built .so carries @LIBC
VERNEED entries and the ZN Linker loads them fine by resolving symbols
version-agnostically. Do not waste time stripping VERNEED.
Real-device findings that changed the original script's approach
Testing against real, unrelated apps on physical hardware surfaced three compatibility gaps in the address-resolution approach the original script uses. All three are things that would also affect the original Frida script if pointed at the same binaries -- they're not FlutterTap-specific bugs, but FlutterTap fixes them since it has to work unattended, without a human adjusting the script per target.
- Libraries mapped directly out of the APK. Modern Android/AGP defaults
to
extractNativeLibs="false", which keeps native libraries compressed-off inside the APK and mmaps them directly from the zip rather than extracting them to their own file./proc/self/mapsthen shows the mapping's path as the APK itself (e.g..../base.apk), not.../libflutter.so-- so the original suffix-matching approach to "find libflutter.so" fails outright.find_module_by_suffix(elf_utils.cpp) usesdl_iterate_phdrinstead: bionic reports the correctdlpi_addr/dlpi_name(as something like.../base.apk!/lib/arm64-v8a/libflutter.so) regardless of which loading method was used. - Merged rodata+text PT_LOAD segment. The original
parseElf()(and this port, initially) assumed the classic ELF layout: a rodata-onlyPT_LOADatp_vaddr == 0, and a separate executablePT_LOADat a nonzero vaddr. Somelibflutter.sobuilds (linked with newer lld defaults, e.g.--no-rosegment) instead merge rodata and.textinto a singleR+EPT_LOADstarting at vaddr 0.parse_elf_segmentsnow identifies "the executable segment" by itsPF_Xflag rather than by assuming it's the secondPT_LOAD-- which is the same segment as rodata in the merged case, and the classic second segment otherwise. - Byte patterns baked in a specific register. The script's
adrp/addbyte pattern (?9 ?? ?? ?0 29 ?? ?? 91, arm64) andleapattern (48 8d 3d ?? ?? ?? FF, x64) both have "fixed" nibbles that are actually bits of the specific registers whatever binary the pattern was reverse-engineered from happened to use (registerx9for the arm64 case,rdifor the x64ModRMbyte). Alibflutter.sobuilt with a different compiler/toolchain that allocates different registers for the same computation won't match the pattern at all -- confirmed by testing against a real, unmodified release APK.resolveVerifyCertChainArm64/X64now scan for the mnemonic (adrp, orleawith a RIP-relative operand) directly via Capstone and validate the computed target address, which is register-allocation-agnostic. This also makes the original script's alibaba.com-specific pattern variant unnecessary -- a register-agnostic scan already covers whatever register a different build happens to use.
Known limitation: GetSockAddr can resolve to the wrong function on some builds
After the three fixes above, one specific test app (see docs/relatorio's
validation log) still installs hooks successfully -- verify_cert_chain and
GetSockAddr both "resolve" and the TLS bypass works -- but the socket
rewrite never fires for its real network requests, even after forcing a
brand new connection (confirmed by toggling Wi-Fi off/on before retrying, to
rule out a reused keep-alive connection). Three other scenarios (two public
training apps tested by the developer, and a real production app tested
independently by the project's author against their own client's app) work
end-to-end with the exact same code, including full captured, decrypted
traffic.
The likely explanation: GetSockAddr's address is derived by walking forward
from Socket_CreateConnect and taking the target of the 2nd bl
instruction (mirroring the original script exactly). On a build whose
Socket_CreateConnect has a different number of calls before the real
GetSockAddr call (a plausible compiler/engine-version difference), that
walk lands on a function that happens to look like a sockaddr-filling
routine (same prologue shape, writes a family value at offset 0, zeroes a
sockaddr_storage-sized buffer) without being the one actually invoked for
outbound TCP connections in that build. This wasn't chased further: pinning
it down requires reversing that specific build's full Socket_CreateConnect
call graph, with no guarantee of a quick answer, and the test app in
question isn't load-bearing for the project (see the development report).
Config file
/data/adb/fluttertap/config.json (schema shared with manager-app's
ConfigData.kt):
{
"enabled": true,
"proxy_ip": "203.0.113.1",
"proxy_port": 8083,
"target_packages": ["com.example.app"]
}
It lives outside /data/adb/modules/fluttertap (the module's own installed
payload) so user settings survive module updates/reinstalls. The native
module can't reliably read files under /data/adb directly from
preAppSpecialize (zygote's own SELinux domain may not be allowed to), so it
is read through the Zygisk companion process (companion.cpp), which runs
as unrestricted root, via a tiny length-prefixed protocol over the socket
Api::connectCompanion() provides.
Module install scripts (module/template/)
Standard Magisk module layout, understood the same way by Magisk, KernelSU (including KernelSU Next), SukiSu Ultra and APatch:
module.prop-- id/name/version/description shown in the module manager.customize.sh-- runs at install time; writes the defaultconfig.jsonif one doesn't already exist (see above), and leaves an existing one alone.uninstall.sh-- deliberately does nothing to/data/adb/fluttertap, so reinstalling the module later restores the user's settings exactly as they left them.action.sh-- runs on demand when the user taps the "Action" button next to FlutterTap in the module manager's list (present automatically whenever a module ships this file, no extramodule.propflag needed). Just opens the manager app (am start -n com.eduardolopes.fluttertap/.MainActivity), since that's the only settings surface FlutterTap has -- there's nothing else useful to run on demand.
Third-party code and why each patch exists
- Capstone 5.0.9 (BSD-3), vendored as a git submodule, built with only
CAPSTONE_ARM64_SUPPORT/CAPSTONE_X86_SUPPORTenabled. Unmodified. - Dobby (Apache-2.0), vendored as a git submodule pinned to commit
c447852in script-or-script/Dobby-android-patched (branchsnapshot-android) -- a single snapshot of upstreame9fe7fb(2023-04-21) with the two-file Android build fix applied, kept readable atpatches/dobby-android-build.patch. Pinned rather than tracking upstream HEAD because Upstream's HEAD (as of this writing, last commit 2024-03-14) has several unresolved regressions in its Linux/Android backend (RuntimeModule.basevs..load_address,MemRange.startfield vs.start()accessor) -- confirmed independently by upstream's own CI, whoselinux_and_androidrelease job produces nodobby-android-all.tar.gzartifact even though the workflow defines one.e9fe7fbis the last commit before the refactor that introduced this inconsistency. Two local patches are applied on top (seegit loginsidemodule/src/main/cpp/third_party/dobby):logging.h:Logger::Shared()was missinginlineon its out-of-line definition, so every translation unit that included the header emitted its own strong symbol, and linking failed with "duplicate symbol" whereverdobby_staticis actually linked into a real binary.CMakeLists.txt: the shareddobbytarget (which nothing here uses -- onlydobby_staticis linked) fails to link on this NDK/lld combination for the same duplicate-symbol reason and is skipped on Android.
- zygisk.hpp (0BSD), copied from
topjohnwu/zygisk-module-sample-- the canonical public Zygisk API header, explicitly released under a public-domain-equivalent license "so you don't have to worry about any licensing issues while developing Zygisk modules." This same API is what KernelSU and APatch also implement, which is why one header works across all three root solutions.
Manager app
A Jetpack Compose app (manager-app/) that:
- Lists launchable apps on the device, plus an opt-in toggle to also show
system apps (
AppRepository.listSystemOnlyApps, for OEM/system components without a launcher icon that can still embed a Flutter engine), and lets the user pick which ones FlutterTap should hook (checkbox list with search and per-app icons; already-selected apps float to the top of the list). - Edits the proxy IP/port and a master enable switch.
- Detects root / the active root manager (Magisk/KernelSU/APatch), and shows
a single combined flag for whether the module is both installed and
enabled (
RootManager.queryStatuschecks for the module directory AND the absence of itsdisablemarker file -- the same convention all three root solutions use) -- for display only, this has no effect on whether the module itself works, since all three implement the same Zygisk API. If root wasn't granted (e.g. the user dismissed the automatic prompt), a button retries the request (RootManager.requestRoot, closes any cached non-root shell and asks again). - Writes
config.jsonthrough a root shell (libsu), base64-encoded so no part of the JSON ever needs shell escaping. - Ships English, Portuguese (Brazil), Chinese, Spanish, Arabic, French and
Hindi strings, switchable in-app via
AppCompatDelegate.setApplicationLocales.MainActivityextendsAppCompatActivity(not the plainComponentActivityCompose activities default to) specifically for this: AppCompatDelegate's per-app language backport only auto-recreates the activity with the new locale on API <33 when it does. TheTheme.FlutterTapstyle also has to descend fromTheme.AppCompatfor the same reason --AppCompatActivitythrows onsetContentViewotherwise.FlutterTapApplication.onCreateis where libsu's one-time shell builder is configured, notMainActivity, since the locale-triggered activity recreate would otherwise re-run it and crash ("the main shell was already created"). - Always derives its color scheme from
isSystemInDarkTheme()(plus avalues-night/themes.xmlfor the window background/status bar before Compose even loads), so it can't end up showing a light background while the system is in dark mode.