All posts
Engineering

Reading an Obfuscated Flutter Crash by Hand

An obfuscated Flutter trace is bare instruction offsets. Here is how to resolve one to a function and a line by reading the DWARF tree yourself.

An obfuscated Flutter release build strips names and line tables out of the binary, so a crash arrives as nothing but offsets into a snapshot section. Symbolication is the act of turning those offsets back into function (file:line:col) using the .symbols file the build emitted alongside the app. This post does that resolution by hand, on one real crash, reading the DWARF fields directly — because a symbolicator is much easier to trust, configure and debug once you have seen what it is actually doing.

Key takeaways

  • An obfuscated trace carries one durable number per frame: the offset past a named snapshot symbol. The absolute address is thrown away by ASLR on the next launch.
  • Resolution is pc = section base in the debug file + offset, then a walk down the DWARF tree to whichever entry owns that program counter.
  • Three tags carry the whole story: CompileUnit, Subprogram, and InlinedSubroutine.
  • AbstractOrigin supplies the name, the line-number program supplies the leaf file:line:col, and the Call* fields rebuild each inlined caller.
  • TracePath does this server-side: upload the build's .symbols files once per release and obfuscated traces come back named.

The trace

Here is the input. It fell out of an obfuscated release build of a small checkout app:

PaymentDeclinedException: card declined for $30.59
build_id: 'fe664295997135e7b67b648ba66ca9eb'
isolate_dso_base: 1130a0000, vm_dso_base: 1130a0000
isolate_instructions: 1130aac00, vm_instructions: 1130a0440
    #00 abs 00000001131eca6b _kDartIsolateSnapshotInstructions+0x141e6b
    #01 abs 00000001131ec99b _kDartIsolateSnapshotInstructions+0x141d9b
    #02 abs 00000001131ec963 _kDartIsolateSnapshotInstructions+0x141d63

Every line is a frame, and every frame is useless on its own. No file name, no line number, no function name. The mapping that would make this readable still exists — --obfuscate --split-debug-info pulled it out of the app into a separate debug file. That file is an ELF carrying DWARF debug info, and it holds the entire answer.

DWARF is the format debug information is written in. It records how compiled machine code maps back to source: which address ranges belong to which function, and what file, line and column each instruction came from. The catch is that it is dense on purpose. To keep the debug file small it is packed as a tree of variable-length records full of back-references, none of it plain text, so you cannot grep it.

To see inside, about 150 lines of Go leaning on the standard library's debug/elf and debug/dwarf is enough to open the symbols file and print the whole tree, with section bases and address ranges already resolved:

data, _ := elfFile.DWARF()
reader := data.Reader()
for {
    entry, err := reader.Next()
    if entry == nil || err != nil {
        break
    }
    printEntry(entry)        // tag, every attribute, and the resolved pc-range
}

That produces a 4 MB text dump. Everything below is read straight out of it.

The two address spaces

Read left to right, a frame is four pieces:

#00 abs 00000001131eca6b _kDartIsolateSnapshotInstructions+0x141e6b

#00 is the frame number. abs 00000001131eca6b is the absolute address the instruction sat at in memory during this run. _kDartIsolateSnapshotInstructions is a named symbol marking the start of the isolate's compiled code, and +0x141e6b is how far past that symbol the instruction is. So each frame is one equation: absolute address equals the symbol's address this run, plus a fixed offset. The runtime prints the symbol's address in the header as isolate_instructions: 1130aac00, which leaves the offset as the thing to solve for.

Two completely different address spaces are in play, and confusing them is the single biggest trap.

Runtime space is where the OS happened to load the snapshot this run. Plug the header value into the equation and it checks out: 0x1131eca6b - 0x1130aac00 = 0x141e6b, exactly the +0x141e6b the frame prints. ASLR randomizes that load address on every launch, so the abs value itself is throwaway.

Debug-file space is the address layout inside app.darwin-arm64.symbols. It has nothing to do with where the code loaded at runtime.

The offset 0x141e6b is the bridge. It is identical in both spaces, because it is just "bytes past the start of the isolate instructions." Add it to that section's base in the debug file and you get a pc — a program counter, the address of a single machine instruction, and the key the debug info is organized by. (That is why the ranges below are called pc-ranges: the span of instructions a function owns.) So:

debug_pc = (debug-file base of that section) + offset

From here the crash trace has nothing left to offer. It gave up the one durable number, so set it aside and open the symbols file instead.

isolate base and vm base

The first thing needed from the symbols file is that base. Two numbers:

isolate base = 0xc67c0
vm base      = 0xbc000

A symbols file is an ELF, and an ELF holds two kinds of data that matter here: a symbol table mapping names to addresses, and the .debug_* sections holding the DWARF tree. These bases come from the symbol table, not the tree. They are the addresses of the two symbols a frame can be measured from, _kDartIsolateSnapshotInstructions and _kDartVmSnapshotInstructions. Frame #00 is measured from the isolate one, so add its base:

debug_pc = 0xc67c0 + 0x141e6b = 0x20862b

0x20862b is the address to hunt for in the tree. Notice the isolate base 0xc67c0 matches the compile unit's Lowpc below — which makes sense, since the isolate instructions begin exactly where the compiled code begins.

The shape of the tree

The bases came from the symbol table; everything from here lives in the .debug_* sections, which together form one big tree.

Each node is an entry: a type (its tag) plus a list of attributes. Entries nest by containment, and three tags carry the whole story:

  • CompileUnit is the root. Dart compiles the entire app into one unit, so there is a single one and everything else hangs off it.
  • Subprogram is a function, carrying its name and the address range its machine code covers.
  • InlinedSubroutine is a function the compiler pasted into a caller, nested inside whatever it was inlined into.

Resolving an address is a walk down that tree: start at the root, step into whichever child's range contains the pc, and repeat until nothing deeper contains it.

The CompileUnit entry

CompileUnit @0xb
    Name        "snapshot_assembly.S"
    Producer    "Dart 3.10.1 (stable) ... on "macos_arm64""
    Lowpc       0xc67c0
    Highpc      0x247bd4
    StmtList    0
    pc-range    [0xc67c0-0x247bd4)
  • @0xb is the DIE offset, this entry's byte position in the DWARF data. Every entry has one, and it is the address other entries point at.
  • Name is snapshot_assembly.S. Dart emits the entire AOT snapshot as one synthetic assembly file, so the whole program is a single compile unit.
  • Producer is the exact toolchain that built it — handy for confirming you have the matching symbols file.
  • Lowpc / Highpc are the [start, end) span of all code in the unit, in debug-file space.
  • StmtList is an offset into the line-number program (.debug_line). This is where file:line:column actually lives. A tree-only dump does not include it, so the precise crash line comes from that table, not from the Decl* fields below.
  • pc-range is the same span, resolved. 0x20862b falls inside [0xc67c0-0x247bd4), so descend into this unit.

Subprogram, a function, in two flavors

The abstract definition is the shared "what this function is" record:

Subprogram @0x36399
    Name        "chargeCard"
    DeclFile    262
    DeclLine    18
    DeclColumn  1
    Inline      1
  • DeclFile 262 is an index into the file table (which lives in the line program, not this dump). It resolves to app/lib/main.dart.
  • DeclLine / DeclColumn are where the function is declared — line 18, the top of the declaration, not the crashing line.
  • Inline 1 means this function got inlined somewhere. The entry has no address; it is the template every copy refers back to.

The concrete instance is a real, emitted copy:

Subprogram @0x788f5
    AbstractOrigin  222105
    Lowpc           0x208570
    Highpc          0x208654
    Artificial      false
    pc-range        [0x208570-0x208654)

It has no Name of its own. AbstractOrigin 222105 points back to the abstract definition. Its Lowpc/Highpc are the actual machine-code span of this copy of chargeCard, and 0x208570 <= 0x20862b < 0x208654, so this is the frame. Artificial false means it maps to real user source rather than a compiler-synthesized stub.

AbstractOrigin

That 222105 is the move tying the two flavors together. It is a reference, by DIE offset, printed in decimal. Convert it to hex and 222105 = 0x36399, exactly the chargeCard abstract entry above. That is how a nameless concrete instance gets its name: the compiler stores the name once and points every copy at it.

InlinedSubroutine, a body pasted into a caller

Frame #00 is a clean leaf, and so is every other frame in this trace. A more aggressively optimized build often is not so tidy, because the compiler inlines small functions into their callers. That shows up as an InlinedSubroutine:

InlinedSubroutine @0x3e556
    AbstractOrigin  740
    Lowpc           0xc6a74
    Highpc          0xc6ab0
    CallFile        1
    CallLine        835
    CallColumn      25
    pc-range        [0xc6a74-0xc6ab0)

(That example is from deep inside Flutter's framework, _TransformedPointerEvent.delta, not the checkout code, but the shape is identical.)

  • AbstractOrigin names which function got inlined here — follow it, same as before.
  • Lowpc/Highpc are the span inside the parent where this inlined copy's code sits.
  • CallFile / CallLine / CallColumn are the source location of the call site: where in the parent this inlined call was written. These three only appear on inlined entries, and they are what lets you rebuild the call line of each inlined caller.

So one physical address can sit inside an InlinedSubroutine, inside another InlinedSubroutine, inside a Subprogram. That nesting is the inline stack.

Putting it together: resolving frame #00

With only this file, reading the fields by hand:

  1. pc = isolate base + offset = 0xc67c0 + 0x141e6b = 0x20862b
  2. The CompileUnit pc-range [0xc67c0-0x247bd4) contains 0x20862b, so descend into it.
  3. Find the Subprogram whose pc-range contains 0x20862b: @0x788f5, range [0x208570-0x208654). It fits.
  4. It has no name; AbstractOrigin 222105 (= 0x36399) resolves to chargeCard.
  5. Look for an InlinedSubroutine inside it whose pc-range contains the pc. There is none, so chargeCard is the leaf frame.
  6. The final main.dart:20:3 comes from the line-number program at this pc, not from DeclLine. DeclLine 18 is just where the declaration starts.

Result: #0 chargeCard (main.dart:20:3). Three sources of truth, one address, no guessing.

If step 5 had found nested InlinedSubroutines, they are emitted innermost first, and each outer frame's file:line:col comes from the CallFile/CallLine/CallColumn of the frame nested inside it.

The general algorithm

Stated purely in terms of the fields above:

  1. pc = base[isolate|vm] + offset.
  2. Pick the CompileUnit whose pc-range contains pc.
  3. Find the Subprogram whose pc-range contains pc; resolve its name through AbstractOrigin.
  4. Recurse into any InlinedSubroutine whose pc-range contains pc. Each level is one more, deeper, inlined frame.
  5. Emit innermost to outermost. The innermost location is the line table at pc; every outer frame's location is the Call* fields of the frame nested inside it.

So pc-range answers "which function or inline owns this address," AbstractOrigin answers "what is its name," Decl* describes the function's own declaration site, and Call* rebuilds the call line of each inlined caller. That is the entire job.

Does it actually work?

Running that over all 17 frames gives back the checkout chain, names and all, recovered from a binary with every identifier stripped out:

#0  chargeCard (main.dart:20:3)
#1  applyTax (main.dart:30:10)
#2  checkout (main.dart:35:10)
#3  main.<anonymous closure> (main.dart:47:7)

Every frame agrees with Dart's own decode tool from the native_stack_traces package, run with -v so it expands every frame instead of hiding the VM-internal ones. Once you have read it out of the tree by hand, the decode step stops being magic and starts being something you can reason about when it goes wrong.

What TracePath does with this

The same walk, done server-side, on every obfuscated Dart trace that arrives.

Build with the hardening flags and upload the resulting .symbols files once per release:

flutter build apk --release --obfuscate --split-debug-info=build/symbols

Each build emits one ELF per architecture you ship — arm64, arm, x64 — carrying the DWARF tree, a build-id note, and the two snapshot instruction symbols that anchor the bases. TracePath keys stored symbols by build ID and architecture, so releases never collide and re-uploading the same build is idempotent. On Apple platforms there is no GNU build-id note, so the debug ID is the Mach-O UUID from the built .app instead.

Rather than re-walking the DWARF tree per lookup, the uploaded file is compiled once into a flat range table: sorted pc ranges pointing at runs of (file, line, column, function) records, with names interned. A frame then resolves with a binary search, and because one machine address can stand for several source frames, a single #N offset can expand into a chain of inlined calls, innermost first. A frame with no match keeps its raw offset form, so a trace is never worse than it arrived.

One practical note that catches people out: a single Flutter build ships several .symbols files, and a trace only resolves against the one matching the device's architecture. Upload all of them.

If you want the same autopsy for JavaScript, there is one for source maps here.

FAQ

Do I need to upload symbols for every Flutter release build? Only for builds hardened with --obfuscate and/or --split-debug-info. A plain flutter build --release keeps enough information that traces are already readable. When you do upload, do it on every release: a crash can only be resolved against the exact build it came from.

Why does one frame sometimes expand into several? Because the compiler inlined function bodies into their callers. DWARF records that as nested InlinedSubroutine entries, so one machine address legitimately belongs to several source-level frames.

What happens if the symbols are missing or from the wrong build? The frame keeps its raw form, _kDartIsolateSnapshotInstructions+0xOFFSET. Resolution degrades frame by frame rather than failing the whole trace.

Why not just ship the debug info inside the app? That is what --split-debug-info exists to avoid. Keeping it out shrinks the binary and keeps your source structure out of the shipped artifact, which is most of the point of obfuscating in the first place.

Is the abs address in the trace useful at all? Only in combination with the header. ASLR randomizes the load address every launch, so the absolute value differs between two crashes in the same code. The offset is the stable part.

Further reading

Subscribe

Get new engineering posts in your inbox

This opens your mail client with a short message to [email protected]. Prefer no email at all? Use the RSS feed.