6695 words
33 minutes
How flutterdec and its Serwalker deserializer work

When a colleague and I gave a one-hour talk about flutterdec, many people in the audience were unfamiliar with Flutter or compilers. Explaining the tool meant explaining those foundations too, and there was only so much we could cover in an hour. A blog gives us more room. You can stop at an unfamiliar concept, read about it, and return when it makes sense.

How to read this article#

If you can read a function, a class, and an if statement, you have enough background to begin. We will introduce compiler and Flutter concepts as we need them. Whenever an unfamiliar idea deserves more time, a study break will point you to a focused reading and give you a question to answer before continuing.

We will first look at what a compiled application contains in sections 1 to 4. Sections 5 to 9 follow the deserializer and the code analysis that turn those contents into something readable. In sections 10 to 13, you can explore the output, try a small experiment, and choose what to study next.

At the center of flutterdec are two connected jobs: understanding instructions and recovering the Dart objects those instructions use. The flutterdec pipeline brings these results together. Serwalker, introduced in PR #74, handles native snapshot deserialization. Understanding how these two jobs meet is the thread we will follow throughout the article.

1 What problem flutterdec solves#

Imagine you receive a Flutter application to review its behavior, but all you have is its APK. You want to find out which conditions it checks, which functions it calls, or where it uses a particular string. The code its author wrote has already passed through a compiler.

flutterdec is a static analysis tool for Flutter Android applications compiled with AOT for ARM64. It can take an APK or its libapp.so and produce pseudocode resembling Dart, along with disassembly, an intermediate representation, and reports. Static analysis means studying the files without running the target application.

The output gives you something you can read and reason about as you explore an application’s behavior. Here is a short example from flutterdec’s HiVPN output:

if ((codePoint == 0x20) || ((codePoint >= 9) && (codePoint <= 0xd))) {
continue;
}

This is part of a loop. 0x denotes hexadecimal: 0x20 is 32 and 0xd is 13. The condition checks for a specific value or the range from 9 to 13. We can already study that comparison without interpreting every processor instruction.

Names such as codePoint make the output easier to read, but they do not tell us whether the author used the same name. Recovering what a program does and recovering its exact source text are different goals.

The useful result is a readable reconstruction of the behavior flutterdec can analyze. When you need to check a conclusion, you can follow it back to the instructions and data behind it.

2 The foundations of Flutter and compilation#

Dart is the language and Flutter is the framework#

A Flutter application uses Dart to express its logic. Flutter provides the framework used to build the interface, while the engine provides low-level services and integrates the Dart runtime. These responsibilities help us locate the application code and understand the environment it runs in. The Flutter architectural overview shows how the layers fit together. You can follow this article without first learning how widgets work.

We will use this function as an example:

String category(int points) {
if (points >= 100) {
return 'premium';
}
return 'normal';
}

String indicates that it returns text, int that it receives an integer, and return provides the result. The function returns premium for 100 points or more and normal below that value.

We will return to this function throughout the article. The diagrams and intermediate forms built around it are simplified illustrations. A real compiler may arrange its instructions differently, depending on the Dart version and the context in which the function is used.

Study break: Dart syntax

If the syntax is still distracting, review variables, functions, and classes in the official introduction to Dart. Return when you can explain what category(99) returns and what category(100) returns.

Compilation changes the representation#

The processor executes instructions that work with registers, memory, comparisons, and branches. A compiler transforms the program to express it in those operations.

It can use several representations along the way. An abstract syntax tree, or AST, represents the program’s structure: for example, that a comparison is part of a condition. An intermediate representation, or IR, is a working format that makes it easier to analyze or transform the program before producing another format.

Dart has a program representation called Kernel. Here, the name refers to a compiler structure rather than the operating system kernel. The compiler also uses representations closer to machine code. The VM internals documentation follows the path from the frontend, which reads and analyzes the source code, through these representations.

You do not need to memorize every stage. The useful idea is that transformations between the text and the instructions change the program’s form.

JIT and AOT describe when compilation happens#

ModeWhen compilation happensRelevance to this article
JIT, just in timeDuring executionPart of the usual native development environment for Dart and Flutter.
AOT, ahead of timeBefore running the distributed applicationProduces the native code we study in the supported Android ARM64 builds.

AOT still needs a runtime. The runtime supplies the services an application needs while it executes. It manages objects and memory, performs type system checks, and manages Dart’s execution contexts, called isolates. This is why you can encounter the name “Dart VM” even when an application contains code compiled ahead of time. The name does not mean that every instruction is being interpreted as bytecode.

The Dart platform overview explains this separation. It also describes web targets, which can use JavaScript or WebAssembly. This article’s ARM64 walkthrough is limited to the native target supported by flutterdec.

Study break: JIT, AOT, and the runtime

Read “Dart: The platforms” and “The Dart runtime” in that overview. Return when you can explain how an application can be compiled before it runs and still need runtime services.

3 What compilation changes and what the APK contains#

The binary preserves the results of compiler decisions#

Two programs written differently can end up expressing the same instructions. Renaming points to balance, rearranging an expression, or writing different comments does not require those differences to be preserved in the executable.

An optimizer can also transform the program:

TransformationConceptual exampleConsequence for reconstruction
Constant foldingReplace 20 * 5 with 100The original calculation may disappear.
Elimination of unreachable or unnecessary codeRemove a function that cannot be usedThere may be no function left to recover.
InliningIncorporate a function’s body at its call siteThe call and the original boundaries may disappear.
Control flow reorganizationExpress a condition with a different arrangement of branchesThe textual structure may change.

These are general compilation techniques, and the LLVM optimization tutorial offers a small example you can work through. The link explains the ideas rather than describing flutterdec’s implementation or Dart’s AOT compiler.

Return to category. If the compiler can work out points before the application runs, it may replace the decision with a known result. If the result depends on a value that only becomes available at runtime, the generated program must still make that decision. When you experiment with a small function, how you call it matters just as much as its body.

That is why understanding a program’s behavior does not tell us exactly how its source was written. A decompiler can reconstruct a useful version of the logic, but it cannot recover from the binary a distinction the compiler did not preserve. This remains true even as decompilers improve.

Study break: How compilers work

A Map of the Territory, from Robert Nystrom’s book Crafting Interpreters, provides an initial view of the pieces that make up a language. If you already understand that map, chapter 3 of the LLVM tutorial shows how a syntax tree becomes IR. Return when you can distinguish text, program structure, and executable code.

APK is the container and ELF is the library format#

An APK is a ZIP-based package. It can contain resources, Android code, and native libraries. Android places these libraries under paths such as lib/<abi>/lib<name>.so. The folder name arm64-v8a identifies the ARM64 variant. The NDK documentation explains this packaging.

For the case we are interested in:

ItemRole
app.apkContains the application’s distributed components.
lib/arm64-v8a/libapp.soContains the Dart application’s AOT output for this target.
libflutter.soCorresponds to the Flutter engine.
classes*.dexContains code from the Android layer, which can provide context about startup.

A .so library uses the ELF format, which describes file regions, addresses, and other metadata needed to work with native code. A snapshot is a saved representation of structures that the runtime needs to reconstruct. In section 5, we will look at what it means to save their objects and relationships.

The flutterdec loader opens the APK or ELF, checks that the target is AArch64, and recovers regions identified by symbols such as _kDartIsolateSnapshotData and _kDartIsolateSnapshotInstructions. It also handles the equivalent VM regions.

Data and Instructions separate two kinds of information: snapshot data and executable instructions. The VM/isolate distinction tells us which runtime scope a region belongs to. For now, remember that the file contains several regions, each with a different role.

To read the file, the loader needs the expected snapshot regions and valid bounds for each one. The .so extension alone does not tell it where Dart’s data and instructions are.

4 Two tasks that depend on each other#

To analyze an instruction that uses an object, we need to answer two questions:

  1. What operation does it perform?

  2. Which object or value is involved?

Disassembly answers the first question by interpreting bytes according to an architecture. Deserialization reconstructs data and relationships saved in the snapshot. Decompilation uses that information to build a higher-level representation.

The diagram below follows these two kinds of information until they meet in the analysis. Serwalker supplies the native deserialization path. Other producers can supply the program model, which we will return to later.

flowchart TD
A["APK or libapp.so"] --> L["Loader"]
L --> D["Snapshot data"]
L --> I["ARM64 instructions"]
D --> S["Deserialization"]
S --> M["Objects and metadata"]
I --> C["Disassembly"]
M --> C
M --> R["IR and flow analysis"]
C --> R
R --> P["Pseudocode and reports"]

Each stage needs a convenient way to ask about the objects and functions recovered so far. That is the role of the program model, which stores records of functions, classes, values, and their relationships. A producer builds those records, an adapter passes them to the rest of flutterdec, and a backend is the particular implementation that recovers the information.

Separating these tasks helps explain why a good general-purpose disassembler cannot solve the whole problem on its own. It can recognize a load from memory without knowing which Dart object it corresponds to. This also explains why extracting names and strings is not enough to reconstruct the program’s decisions.

The introductory Ghidra guide shows the same principle in a different tool. Its decompiler translates assembly into an internal representation and then into C. Changing the available type and function-signature information can change the code it produces, which makes it a useful example of how context affects decompilation.

5 A snapshot stores objects and relationships#

Serializing an object is only the beginning#

Serialization means representing data as a sequence that can be stored or transmitted. Deserialization means interpreting that sequence to reconstruct a usable representation.

With a list of numbers, the problem seems straightforward. Objects that point to one another introduce another challenge: preserving their relationships and identity.

Imagine that two functions belong to the same class. Reconstructing a separate copy of the class for each function would lose the fact that both point to the same object. There can also be references to objects that appear later, or paths that lead back to the original object.

This structure is called an object graph: objects are nodes, and references are links. The Dart snapshot documentation describes how the runtime serializes that graph. An AOT snapshot packages the objects and relationships needed to start and run the program. It is a starting state for execution, not a recording of what a user does after opening the application.

An identifier is not a memory address#

An identifier can be used to store a reference. Saying “this field points to object 300” makes it possible to resolve the relationship once the objects are known.

It helps to distinguish these numbers before looking at the Serwalker code:

ConceptWhat it identifies
CID, class IDA class or category of runtime object.
reference_idAn object within the snapshot reference space.
code_indexAn encoded position related to the instruction table.
OffsetA distance, usually in bytes, from a base.
VA, virtual addressAn address in the virtual address space described by the binary.

They are not interchangeable. A CID does not locate a function, an object identifier is not an ARM64 address, and an offset needs a base before it can become an address.

The difference between an offset within a file and a virtual address also matters: a library does not have to be loaded at an address that numerically matches the position of its bytes on disk.

The runtime has objects the programmer did not write#

When we read Function or Code in Serwalker, we are talking about internal structures that describe functions and code. This does not mean the application declared classes with those names.

The runtime maintains structures to represent libraries, classes, functions, types, strings, and other elements. Part of their value for reverse engineering is that they can connect a name, an owner, and a range of instructions.

Study break: Dart runtime objects

Look up any terms you need in the VM glossary. Return when you can describe the difference between an application object, an internal object that describes a function, and the instructions that execute that function.

6 Serwalker reconstructs the serialized part#

Serwalker is flutterdec’s snapshot deserializer, written in Rust. Its walk_snapshot_and_produce_model function starts with the regions extracted by the loader, reads the isolate data and instruction image, and builds representations that the rest of the analysis can use.

Serwalker stores the reconstructed objects and their references in Rust structures. These structures let flutterdec inspect the program without executing it. This step does not require starting a Dart VM or recreating a running application.

Reading a header requires knowing the format#

Before walking through objects, parse_header checks an identifying marker, reads the snapshot size and kind, and obtains its format version and features.

The snapshot hash identifies the format that Serwalker must understand. It is a compatibility identifier, not a hash of the APK’s contents.

The parser checks for:

  • A FullAOT snapshot.

  • The snapshot hash 78da37fed6bf1489361a312568249f3f used by Dart 3.11.1.

  • The compressed-pointers feature.

compressed-pointers indicates that certain object references use a shortened representation. This is a property of the object representation, separate from APK compression.

Serwalker also uses details of the ARM64 layout. Dart can change the snapshot format without changing the target processor, so recognizing the instructions does not automatically tell us how to read the objects.

The Stream type keeps a cursor pointing to the next bytes to read. Some fields have a fixed width. Several integers in the snapshot body use Dart’s variant of LEB128, which encodes a number using a variable number of bytes. Reference identifiers use a separate reader, read_ref_id, because they follow a different encoding. Reading a field with the wrong method can move the cursor to the wrong position and misinterpret everything that follows.

A binary parser therefore has to get two things right at every step: what the field means and how many bytes belong to it.

Clusters group objects that share a reading scheme#

A cluster groups objects that share a set of deserialization rules. The format handles them together so that the reader can apply those rules to the whole group.

The header reports how many clusters there are. For each one, Serwalker reads its tags, determines its CID, and selects an implementation through decide_cluster. There are implementations for classes, functions, code, strings, arrays, the object pool, and other structures.

The tags also include properties such as whether an object is canonical or immutable. Here, “canonical” relates to the shared representation that the runtime maintains for certain equivalent values. These details affect how the group is identified and must be preserved while walking through it.

The benefit of this organization is that each implementation knows how to read the fields of its object type. An array of variable length and a function structure do not have to be interpreted as if they had the same format.

A first pass creates objects and a second fills them#

This is one of the most important mechanisms in the deserializer.

Consider this illustrative graph, with invented identifiers. The numbers are reference_id values: they identify specific objects in the snapshot, not CIDs.

flowchart TD
C["Class with ID 100"] -->|"functions"| A["Array with ID 200"]
A --> F["Function with ID 300"]
F -->|"owner"| C

When reading class 100, array 200 might not have been reconstructed yet. And on reaching function 300, there is a reference back to the class.

Serwalker separates the work into two passes over all clusters:

PassWork
read_allocCreates the representations, reads the information needed to determine their sizes, and assigns ranges of identifiers.
read_fillReads and fills in the fields and references of those objects.

It does not finish an entire cluster before starting the next one: first it performs the allocation phase across the whole set, then the fill phase in the recorded order.

Numbering in parse_clusters starts after the base objects declared by the snapshot. At the end of allocation, Serwalker checks that the total reference count matches the header. This gives it an early check that the stream and its object counts agree.

Fields retain identifiers that can later be looked up using utilities such as find_object_by_id. This makes it possible to represent shared relationships, forward references, and cycles without expanding each reference into a new copy.

The idea is similar to loading database rows with identifiers and then resolving their relationships. That analogy helps explain the dependencies between objects. The actual snapshot format is binary and specific to Dart.

Check your understanding. If function 300 points to class 100, should you create a new class? No: you should resolve the reference to the representation associated with that identifier.

Roots help locate important structures#

After the clusters, Serwalker processes the program roots: references that provide access to relevant structures.

parse_roots reads the ObjectStore, field tables, and a dispatch table. The dispatch table helps organize the targets of calls. The ObjectStore gives us access to another structure we need next: the global object pool.

The roots have a concrete use: the model producer looks up global_object_pool in the ObjectStore and resolves the corresponding identifier. This is how it identifies the global pool through a relationship in the snapshot.

Following this reference tells us which pool the program uses globally. That relationship lets the next stages connect a pool access in the code to the right collection of objects and values.

Connecting functions to instructions#

Knowing a function name still does not tell us which bytes it executes. To connect those worlds, Serwalker uses the instruction table and relationships between Function and Code objects.

The table contains offsets to instruction payloads. Here, a payload is the associated block of code bytes. A function’s code_index field uses a numbering scheme that the implementation converts into a table index: for a nonzero value, it subtracts one. If code_index is 3, it looks up entry 2.

The table also records first_entry_with_code, the point at which its entries start corresponding to objects in CodeCluster. If that value is 2, table entry 2 corresponds to the first object in the cluster. Serwalker uses this rule to move from a table index to the corresponding object reference.

The runtime can discard a Code metadata object while keeping its machine instructions. The function producer handles both cases: entries with retained Code objects and entries whose instructions must be located through the table.

Once the offset is known, adding it to the virtual base address of the image gives us the payload address. For example:

Virtual base address of instructions: 0x700000
Payload offset: 0x1200
Virtual address of the payload: 0x701200

The producer turns these offsets into address ranges, giving later analysis a starting address and a number of bytes to read. For a retained Code object, the range covers the whole payload, which may contain multiple entry points.

Deferred code belongs to units that load separately. Its metadata can appear in the main snapshot even when its instructions are elsewhere. Serwalker leaves those entry points unresolved and omits their functions from the model.

Producing a usable model#

The reconstructed objects now need a form that the rest of flutterdec can use. ProgramModel v4 provides that common schema. The model producers turn libraries, classes, functions, and pool entries into records, following references to recover names and ownership information. Function records also connect that information to the address ranges found in the instruction image.

The [pool producer][sw-pool] walks the selected ObjectPool in order, keeping its exported indices aligned with the slots used by the machine code. It exports string contents and immediate values. For other objects, it may only have a type and a reference ID. Recognizing those does not by itself reveal the complete value or resolve a call target. The optional fields in ProgramModel v4 let later stages work with the facts available without inventing the missing ones.

Object references also have to account for base objects, which occupy the first IDs in the snapshot. Serwalker starts numbering its newly read objects after them. A reference into that initial range remains unresolved if the corresponding [base object][sw-base] has not been reconstructed.

The schema also records what information is available and where it came from. Later stages can use a function’s address range to select instructions, its recovered name to label calls, and pool values to explain loads. They can do this without repeating the snapshot deserialization or assuming that every optional field has been filled.

7 How a memory load becomes a value we can understand#

Registers are working locations inside the processor#

For this section, it is enough to distinguish registers from memory. Registers are small areas of working storage inside the processor. Memory contains instructions, objects, and other data that can be loaded into them.

In AArch64, you will encounter register names such as x0 and x27. An ldr instruction loads a value from memory, cmp compares values, and branches change which instruction executes next. The Arm A64 guide explains these operations.

An ABI defines conventions that let different pieces of code work together: how they pass arguments, which registers they reserve, and how they organize certain accesses. In addition to the architecture, flutterdec needs to recognize Dart runtime conventions.

The flutterdec disassembler uses Capstone to decode instructions and adds annotations for Dart’s conventions. For ARM64 code, those annotations include pool accesses through x27, also called PP.

The object pool holds values that the code uses#

The ObjectPool lets code access objects and constants through entries. Strings are an easy example to visualize, but the pool can also contain other kinds of values.

Let’s return to category. One of its paths might load the object representing premium from the pool. The instruction gives us a location relative to the pool base. Deserializing the pool lets us connect that location to a value.

Consider this illustrative instruction:

ldr x0, [x27, #0x28]

This reads as “load into x0 the value located 40 bytes from the base held in x27.” 0x28 equals 40.

The pool geometry declared by Serwalker places the first entry 16 bytes from the base and uses entries of 8 bytes. With those values:

Index = (displacement − start of entries) / entry size
Index = (40 − 16) / 8
Index = 3

Suppose slot 3 contains a reference to premium. We can now read the instruction as a load of that string object. This interpretation depends on both the instruction and the pool’s contents and layout. The layout describes where the entries begin and how much space each one occupies. The flutterdec model stores that geometry and checks whether a displacement can be converted into an entry index.

Those offsets and entry sizes belong to the ARM64 pool layout. Even with compressed-pointers, its slots occupy eight bytes. Compressing certain object references does not make every field or structure in the binary four bytes wide.

Finding strings does not prove how they are used#

Suppose that, while scanning the bytes, we find normal and premium, in that order. We only know the scan order. Nothing proves that they are pool entries 0 and 1, or that this function uses them.

ProgramModel v4 distinguishes hardware indices, tied to the actual layout, from ordinal indices that simply enumerate a producer’s output. If the available metadata does not justify converting a displacement into a pool index, the disassembler can keep it as poolOff[...]. The expression preserves what the instruction tells us without attaching a string to the wrong location.

Deserialization supplies the missing connection: which object or value belongs to the location that the machine code accesses.

A Dart reference also has a representation#

The runtime distinguishes immediate small integers from object references using tag bits. As a result, without additional information, a number observed in a register cannot always be interpreted directly as an ordinary integer or an address.

Understanding these representations helps us interpret what an instruction is actually moving or comparing. The Object representation section of the GC documentation explains tagged values without requiring a tour of the entire garbage collector.

Study break: Registers and tagged values

Read that section and the sections on registers, loads, and program flow in the Arm guide. Return when you can explain why ldr describes an operation, but understanding its meaning in Dart requires additional context.

8 From instructions to blocks and decisions#

A dedicated IR organizes what was recovered#

Once the instructions have been disassembled, flutterdec builds its own working representation. This LLIR, or low-level IR, preserves addresses, operations, text, and targets.

The IROp type distinguishes these categories:

CategoryPurpose
CallCalls.
BranchConditional branches.
JumpUnconditional jumps.
ReturnReturns.
LoadPoolRecognized pool accesses.
RuntimeCheckCertain runtime support patterns.
OtherPreserved instructions that do not fall into the categories above.

flutterdec builds this IR from the instructions in the binary. It becomes the analyzer’s working model during decompilation. The compiler’s original IR is a separate representation that this process does not recover.

For teaching purposes, we can represent our example like this:

compare points with 100
if less, go to NORMAL
PREMIUM:
result = pool[3]
go to EXIT
NORMAL:
result = pool[4]
EXIT:
return result

For this illustration, slot 3 holds premium and slot 4 holds normal. We use a short notation to make the flow easy to follow. The --emit-ir option writes this kind of information as JSON. A compiler may arrange the branches differently while preserving the same decision.

Basic blocks describe stretches of execution#

A basic block is a sequence of instructions traversed without internal branches in ordinary control flow. Decisions connect blocks to one another.

The control-flow graph, or CFG, represents these blocks and the possible paths between them. Serwalker’s object graph connects objects through references. The CFG connects blocks through possible execution paths.

flowchart TD
E["Compare points with 100"] -->|"Less than"| N["Load normal"]
E -->|"Greater than or equal to"| P["Load premium"]
N --> S["Return result"]
P --> S

In the CFG builder, flutterdec marks block beginnings at the entry point, at jump targets, and after instructions that end a stretch of execution. It then records successors and predecessors: where each block can go and where it can be reached from.

The graph makes it easier to study a property that is less clear in a list of instructions: the example’s two branches return to a common point.

Recovering a condition requires tracking values#

Recognizing that a conditional branch exists does not, by itself, reveal the high-level expression. The comparison must be interpreted, and the values that reach it must be tracked.

The expression analysis works with instructions and register state to build readable approximations. A sequence of moves, loads, and operations may end up expressed as a comparison or a field access. If a relationship is missing, register names, memory locations, or partial expressions may remain.

The kind of call also matters. A direct call contains a target that can be resolved to a known address. An indirect call obtains its target from a computed or loaded value. Resolving the latter may require information that the static analysis does not yet have.

A method name found near a call may provide context, but it does not replace evidence that the call targets that method.

9 Turning control flow into readable code#

Sharing code where branches rejoin#

In the previous diagram, the “Return result” block lies on both paths. If the emitter expanded each path separately, it could duplicate the code they share. In large graphs, those duplications can grow quickly.

To organize control flow, Regions::build computes dominance and postdominance relationships:

  • A block dominates another if every path from the entry point to that other block passes through it.

  • A block postdominates another if every path from that other block to an exit passes through it.

In our small graph, the comparison comes before both branches, and the shared return comes after them. These relationships help identify the condition and the point where control flow rejoins.

Cycles require recognizing paths that return to a header. When that header dominates the block from which the path returns, a natural loop can be identified. This relationship helps the emitter express it as a repetition structure.

These are graph concepts applied to a program. Their purpose here is concrete: they help determine where to open and close an if, where to place a loop, and which block should be emitted only once.

Some arrangements of jumps do not fit this pattern of conditions and loops. The emitter can use another strategy for those cases, although parts of the resulting control flow may remain unresolved.

The compiler also introduces support instructions#

Suppose we find a branch before the body of a function. It could be a condition in the program, but it could also be a check introduced to protect execution.

flutterdec recognizes a stack-limit check pattern and classifies it as RuntimeCheck. The stack is a region used to organize part of the state of function calls. If that check were interpreted as business logic, it would add a condition and perhaps a loop that the developer never wrote.

Reconstructing structures therefore also requires distinguishing the application’s work from the runtime’s support work.

From expressions to pseudocode#

After structuring the flow, the emitter cleans up expressions, compacts repeated patterns, and adds names or types. Some of that context comes from recovered data. Other details are inferred to make the operations easier to read.

For our example, a simplified pseudocode rendering might look like this:

String recoveredFunction(int value) {
if (value >= 100) {
return 'premium';
}
return 'normal';
}

The comparison and its two outcomes are the behavior we want to recognize. Function names, parameter names, and the arrangement of the branches may differ from the source text.

The signature is also a reconstruction. Its arguments and types reflect what the emitter could model, so they may be less precise than the original declaration.

Check your understanding. Does seeing a while in the pseudocode prove that there was a while in the source? No: it shows that the emitter represented part of the control flow as a loop. The original structure could have been written differently.

10 Reading the results#

A common model connects the analysis stages#

The rest of the pipeline needs objects, names, and code ranges in a common shape. ProgramModel v4 provides that contract. It describes libraries, classes, functions, and the object pool, along with the capabilities of the recovery process and the provenance of each fact. This lets the later stages work with the information they need without having to parse the snapshot themselves.

Different recovery approaches supply different amounts of context:

Recovery approachWhat it providesWhat to keep in mind
SerwalkerObjects and their relationships read from the serialized clusters.The parser must support the snapshot format.
r2flutter backendMetadata from the external parser and real pool entries when recovered.This backend does not attribute classes to libraries.
blutter backendInformation adapted from Blutter’s output.Names extracted from rendered text are heuristic. The pool uses ordinal indices.
Internal recovery in the Python producerStrings and candidate code ranges.A string scan does not reconstruct the complete object graph.
Basic recovery in the Rust coreCandidate functions identified from instruction patterns.Function names, classes, and the object pool remain unavailable.

The Python producer can try different backends to obtain this information. When a compatible metadata recovery path is unavailable, the core can still identify code candidates from the instructions. That gives the disassembler a starting point, even when names and object relationships are missing.

Read the files as different views of the same program#

FileQuestion it helps answer
pseudocode/*.dartpseudoWhat behavior did the tool manage to express?
asm/*.sWhich instructions were analyzed?
ir/*.jsonHow were those instructions and their targets organized?
report.jsonWhat was selected, which producer was used, and what information was recovered?
quality.jsonWhich constructs remained unresolved, and which thresholds were exceeded?

Provenance tells you how a fact entered the model:

ProvenanceMeaning
exactRead directly from a structure the parser understands.
derivedCalculated from recovered facts.
heuristicInferred from patterns that may not hold in every case.

Capabilities describe how much of a domain was recovered: complete, partial, or unavailable. A missing function name stays missing, which lets you distinguish an unknown name from a recovered one.

For example, an exact name in an obfuscated binary is the obfuscated name stored in the snapshot. The label describes how the name was recovered. It does not restore the spelling that existed before obfuscation.

The Flutter documentation on obfuscation explains that this mechanism changes symbols and does not encrypt resources. Useful information may therefore still be available for analysis, even if the names are less expressive.

Use quality metrics to decide where to look#

The quality counters help you find parts of the output that need closer inspection. They track placeholder conditions, unresolved control flow, the proportion of indirect calls, and disassembly coverage, among other properties.

Use these counters to locate gaps and compare runs. They do not measure the percentage of original source code recovered. A readable condition may still depend on incomplete information, so the operations behind that condition matter as much as its presentation.

The CLI sets --max-placeholder-ifs to zero by default. A placeholder condition can therefore fail the quality gate after the artifacts have been written. If that happens, open quality.json to see which threshold was exceeded and continue inspecting the generated files. An earlier loading or analysis error may prevent artifacts from being written.

To understand a branch, follow it through the pseudocode, the IR, and the instructions. If an expression contains a string or a name, check how the producer linked that value to the code. You can then connect the behavior you are studying to the bytes and objects that support it.

11 Try tracing one condition#

Start with a single function or condition and follow it through the output files. Keeping the example small makes it easier to see what each stage contributes.

You can use an application of your own or a suitable public example. If you build a minimal application, make the function you want to study reachable and make its input depend on a runtime value. Otherwise, optimizations may remove the very thing you wanted to observe.

Inspect the app and run the analysis#

Follow the [installation guide][source-1] to set up flutterdec. With flutterdec installed and an Android AOT APK containing the ARM64 variant, begin by inspecting the app:

Terminal window
flutterdec info ./app.apk --json

Find the snapshot_hash and features in the output. Compare them with the snapshot format Serwalker supports, as described in Section 6. A matching architecture tells you how to decode the instructions. The snapshot hash and features tell you whether the parser understands the serialized objects. Once the format matches, run:

Terminal window
flutterdec decompile ./app.apk -o ./out \
--adapter-backend internal --emit-asm --emit-ir

The --adapter-backend internal option selects Serwalker. The --emit-asm and --emit-ir options add disassembly and IR files alongside the pseudocode. Check report.json to see what information was recovered, then open a small function in all three forms.

Tracing an entire condition#

Open the artifacts and work through these steps:

  1. Find a small function and a condition you can recognize in the pseudocode.

  2. Locate its blocks in the IR and the targets of both branches.

  3. Find the corresponding comparison and branch in the disassembly.

  4. If there is a pool load, note the displacement and check whether a geometry is available that lets you obtain an index.

  5. Check where the data associated with that entry came from.

  6. Write down which conclusion is supported and which detail is still missing.

Record your conclusion in concrete terms, such as “this branch loads the object from entry 3 and returns the result.” Note how you identified the branch, linked the load to the pool, and resolved the object. Those steps make the conclusion reproducible.

The default app-unknown scope includes code classified as application code and code whose owner is unknown. Package dependencies may appear too. Use --app-package to focus on a package or --target to select a function, as described in the CLI reference.

12 A study path with return points#

You do not need to read all of the linked documentation from beginning to end. Choose the resource that addresses the difficulty you encounter, then return to the point in the article where it arose.

Foundation you want to strengthenSpecific readingYou can return when you can
Dart syntaxIntroduction to Dart, variables, functions, and classes.Explain what category does without relying on Flutter.
Application layersFlutter architecture, “Architectural layers” and “Anatomy of an app.”Distinguish the application, framework, engine, and runtime.
JIT and AOTDart overview, platforms and runtime.Explain why AOT still needs to manage objects.
Parts of a compilerA Map of the Territory.Place the AST, IR, and machine code in the process.
Using an IRLLVM tutorial, chapter 3.Understand why a program is transformed into a working format.
OptimizationsLLVM tutorial, section 4.2.Understand how the structure changes without changing the expected computation.
Native code on AndroidAndroid ABIs, “Native code in app packages.”Know what arm64-v8a identifies inside the APK.
ARM64 instructionsArm A64 guide, registers, loads, and branches.Explain a memory load and a conditional branch.
SnapshotsVM documentation, “Running from Snapshots” and “Running from AppAOT snapshots.”Distinguish the serialized graph from the instruction image.
Objects and referencesGarbage Collection, only “Object representation” to begin with.Distinguish an immediate integer from an object reference.
Decompiler outputGhidra beginner’s guide, “Decompiler Overview.”Understand why more context can change the pseudocode.
Obfuscated namesDart obfuscation.Distinguish a renamed symbol from information that has been removed or encrypted.

Use the Dart internals glossary whenever you encounter terms such as CID, PP, object pool, Kernel, or stub. A stub is a small runtime support routine. Recognizing it helps you distinguish runtime work from the application’s logic.

Getting started with the implementation#

The code becomes less opaque if you approach it through questions:

QuestionStarting point
How are regions extracted from the binary?load_snapshot_bundle and the loader.
How is a compatible recovery path selected?Model selection.
Where does Serwalker begin?walk_snapshot_and_produce_model.
How are objects read and connected?Snapshot, clusters, and reference reading.
How are a function’s instructions located?Instruction table and function producer.
How is a load connected to the pool?[Pool producer][sw-pool] and disassembler.
How are blocks built?IR and CFG.
How are conditions and loops organized?Regions and structured emitter.
How is incomplete output measured?Quality.

To compare the format with its original implementation, the [Dart 3.11.1 SDK deserializer][sdk-snapshot] is an advanced reading option. Look for Deserializer::Deserialize and compare the separation between allocation and filling with Serwalker. [datastream.h][sdk-stream] lets you explore the binary readers further. This reading is much more useful after you understand the graph example.

13 Questions to check that the pieces fit together#

Why is unpacking the APK not enough to obtain the Dart source code?

Unpacking recovers the files inside the package. For this target, the Dart code has already been transformed into AOT instructions and structures. Decompilation analyzes those files to reconstruct the program’s behavior.

If I find the string premium, what do I actually know?

You know that those bytes or that object are present in what was analyzed. To claim when it is used, you need to link it to accesses in the code and to an execution path. In our example, that relationship runs through the pool and the condition.

Why does Serwalker need two passes over the clusters?

The first pass creates the objects and assigns their identities. The second fills their fields. References can then point to the same object even when it appears later in the snapshot or belongs to a cycle.

What does Serwalker provide that Capstone does not?

Serwalker interprets objects and relationships in the snapshot. Capstone interprets the architecture’s instructions. flutterdec needs to connect both results to express operations with Dart context.

Why can a snapshot from another version fail even if the processor is still ARM64?

Because the instructions and the object format are different layers. The parser needs to understand the snapshot schema and the conventions of the corresponding configuration.

What does it mean for the result to have acceptable quality?

It means that it met certain criteria measured by the tool. To draw a conclusion about behavior, you still need to review the relevant evidence and the limitations of the recovery.

A line of pseudocode becomes easier to understand when you can follow it back to a comparison, a branch, and the values those instructions use. flutterdec brings those views together. Serwalker supplies the objects and relationships that connect native instructions to Dart concepts.

How flutterdec and its Serwalker deserializer work
http://caverav.cl/posts/flutterdec/
Author
Camilo Vera
Published at
2026-10-12
License
CC BY-NC-SA 4.0