Reversing Shorebird-built Flutter apps with blutter
Shorebird ships Flutter apps on a forked Dart SDK whose snapshot format breaks blutter. We found the one-varint difference and patched the parser.
blutter is one of the best tools for reverse engineering Flutter apps on Android. It compiles a Dart VM that matches the target app, then uses it to deserialize the app’s AOT snapshot (libapp.so) and recover the object pool, objects, function table, and disassembly labels.
It stops working on apps built with Shorebird, a code-push service for Flutter. To ship Dart code as over-the-air patches, Shorebird builds apps with a private fork of the Dart SDK. That fork changes the AOT snapshot format just enough for stock blutter to reject the snapshot or crash on it.
We added Shorebird support to our fork of blutter. It detects Shorebird builds on its own, so in most cases you do not need to change anything. This post explains why stock blutter fails, how we isolated the format change, and how to repeat the method when the fork changes again.
blutter only deserializes a snapshot to recover metadata. It never executes the snapshot. This work only changes what blutter can read and has no effect on any target app.
Usage
# auto-detected from the APK (shorebird.yaml) or libflutter.so
python3 blutter.py path/to/app.apk out_dir
# or from an extracted lib directory
python3 blutter.py path/to/lib/arm64-v8a out_dir
If auto-detection guesses wrong, override it:
--shorebirdforces the Shorebird-compatible build--no-shorebirdforces the stock build
The Shorebird variant is a separate artifact (dartvm<ver>_<os>_<arch>_shorebird) built from its own Dart source checkout (dartsdk/v<ver>-shorebird). It never overwrites a stock build of the same Dart version.
Why stock blutter fails
There are two separate problems.
1. The version gate. blutter embeds the snapshot-format hash of its Dart tree. The Shorebird fork computes a different hash, so every Shorebird-built libapp.so carries a hash that no stock SDK produces, and blutter rejects it with “Wrong full snapshot version”.
blutter already handles this for any app. It reads the hash from the target’s own _kDartVmSnapshotData and stamps it into the Dart VM’s version.cc at build time (dartvm_make_version.py). No network access or account is involved.
2. The format delta. Past the gate, the deserializer loses sync in the very first class cluster. The stream carries one extra varint that the stock reader does not expect. Everything the stock reader then treats as “predefined-class count, then cid list” is shifted by that varint. It decodes a garbage count and hundreds of bogus class IDs, then crashes with SIGSEGV inside ClassDeserializationCluster::ReadAlloc:
#0 dart::ClassDeserializationCluster::ReadAlloc(dart::Deserializer*)
#1 dart::Deserializer::Deserialize(dart::DeserializationRoots*)
The fix
patch_shorebird_app_snapshot() in dartvm_fetch_build.py patches runtime/vm/app_snapshot.cc in the Shorebird source checkout. It touches exactly one place, ClassDeserializationCluster::ReadAlloc:
- Consume the extra
ReadUnsigned()(the fork’skNumPredefinedCids) that is written before the predefined-class count. This re-aligns the stream cursor. - For each predefined cid, assign
Object::null()to any cid this VM build does not define, instead of dereferencing it. The fork’s cid space includes classes that stock Dart lacks. The null reference keeps ref indices aligned for every later cluster and avoids the out-of-rangeClassTable::At()call that caused the crash.
Everything after that field is stock format: the count, the cid list, the allocation count, and all later clusters. With the patch in place, the stock VM parses the rest of the snapshot end to end.
Identifying a Shorebird build
Four signals tell you an app was built with Shorebird:
assets/flutter_assets/shorebird.yamlexists in the APK, withapp_id,base_url, andauto_update.- Shorebird’s updater is statically linked into
libflutter.so. The stringshorebirdand symbols such asshorebird_initappear. Stock Flutter has neither. - The
libflutter.soversion string names a stable Dart version, but its build date does not match that version’s official SDK release date. - The snapshot hash in
_kDartVmSnapshotDatamatches no stock SDK of that Dart version.
blutter.py uses the first two for auto-detection.
Do you need a Shorebird account?
No. Parsing a libapp.so uses only data already inside the binary (the embedded snapshot hash) and the public dart-lang/sdk sources that blutter already checks out. Nothing contacts Shorebird.
When the fork changes again
The stock ReadAlloc body has been byte-stable across every Dart version we tested. The patch is therefore applied by exact string match, and it fails loudly if the source no longer matches, instead of producing a silently broken build.
If a newer generation of the fork changes the format again, this is how to isolate the new delta:
- Pair control. Build the same minimal
hello.darttwice: once with the stock toolchain and once with Shorebird’sgen_snapshotfor the app’s engine revision. (Shorebird’s artifact proxy is the public Flutter infra mirror and needs no authentication.) If the Shorebird control crashes the same way, the problem is the format, not app-specific corruption. - Crash-site trace. Break in
ReadAllocafter the count is decoded and read the decoded count and the stream cursor. A huge count followed immediately by bogus cids means the stream lost sync at the count position. - Byte decode. Decode the varints at the cursor using Dart’s LEB128 variant (
ReadStream::ReadUnsigned). If removing one varint before the count makes the cid list parse the same as a stock snapshot, that varint is the field the fork added.
If the patched parser instead loses sync at a later cluster, repeat the same method on that cluster and extend the patch. Also update SHOREBIRD_READALLOC_STOCK if the stock body itself changes in a new Dart version.
Caveats
- Keep one binary per fork and hash. A Shorebird-patched build reads the extra varint and would lose sync on a stock snapshot. The snapshot-hash gate already prevents mixing them at runtime, and the
_shorebirdsuffix keeps the two binaries apart on disk. --no-analysisbuilds give signatures and addresses only. The default analysis build also disassembles instruction bodies.
Mobile app assessments are part of our penetration testing work. If you ship a Flutter app and want it tested, get in touch.