~/meshos/writeup —   RE  [back: keygen]

Reverse Engineering the MeshOS License Check

// A walkthrough of recovering MeshOS's client-side license algorithm from a Flutter AOT binary.

MeshOS is distributed as a Flutter Android app, and Flutter apps are interesting reverse-engineering targets because their compilation model sits halfway between Android's usual Kotlin/Java story and a fully native binary. The Dart source is compiled ahead-of-time into ARM64 machine code and packaged as a single shared library, lib/arm64-v8a/libapp.so, which Flutter's engine loads at startup. There are no .dex files with Dart code in them, no readable method tables in the Android sense, and jadx won't help you.

What Flutter does leave behind is a Dart VM snapshot — a serialized graph of objects, types, and compiled function bodies — embedded in that .so as two ELF symbols: _kDartIsolateSnapshotData holds the object pool, and _kDartIsolateSnapshotInstructions holds the compiled code. This is the walkthrough of working through the MeshOS companion binary to recover its client-side license check algorithm.

First pass: strings

The naive attack is always worth trying first, because Dart's AOT snapshot preserves a surprising amount of textual metadata. Class names, method names, field names, and the original package: source paths all survive compilation as tagged objects in the object pool. String literals referenced by code survive for obvious reasons. So:

strings lib/arm64-v8a/libapp.so | grep -i license

…produces an immediate map of the MeshOS licensing subsystem: a LicenseService class, a _LicenseScreenState UI class, source paths pointing at license_service.dart and license_screen.dart, a license_key Hive box field, the validation error strings, and — the attention-grabber — imports of PointyCastle's AESEngine, ECBBlockCipher, and KeyParameter. The impl.block_cipher.aes and impl.block_cipher.modes.ecb library identifiers were both present.

At this point the hypothesis was: the license key is validated by AES-ECB decrypting something and comparing against a known value, with a hardcoded 16- or 32-byte key somewhere in the binary.

Ruling out the easy cases

Before investing in heavier tooling, it's worth eliminating the lazy-developer failure modes. A hardcoded AES key could be stored as:

A short Python pass extracted every printable ASCII run from the binary and filtered for these shapes. The only 32-character hex hit was 78da37fed6bf1489361a312568249f3f, which appeared directly adjacent to the Dart snapshot format flags string (product no-code_comments no-dwarf_stack_traces_mode…) — that's the Dart snapshot version identifier, not a key. No base64 matches, no standalone key-length ASCII strings.

This told me one of two things: either the key was stored as a raw Uint8List byte array (which strings can't surface, because the bytes are binary), or there wasn't a stored key at all and the "validation" was something else entirely.

Parsing the snapshot properly

To go further, you need to actually understand the Dart object pool's layout — which version of the Dart VM produced this snapshot, what the tag bits mean in this version, where the class table lives, how compressed pointers resolve. Doing this by hand is possible but miserable. The tool that does it properly is blutter, which takes the approach of linking against the exact Dart VM version that produced the binary and using the VM's own internal data structures to walk the snapshot.

The process has a few moving parts. First, blutter reads the Dart version string from libflutter.so (3.11.1 in the MeshOS build) and the snapshot hash from libapp.so itself. Then it shallow-clones the Dart SDK at the matching tag, sparse-checks out only runtime/, tools/, and third_party/double-conversion/, and builds a CMake project that compiles the Dart runtime as a static library — about 190 translation units, roughly ten minutes on a modern machine, with object.cc and parser.cc being the slow ones. Then blutter itself compiles against that library and runs against the target.

A practical note if you try this: Dart 3.11+ requires C++20, so you need gcc 13 or clang 16 at minimum. Also, if you're running this in an environment where the shell might exit while the build runs (a CI job, an ephemeral container), nohup alone isn't enough — you want setsid nohup so the build process gets reparented to init and survives.

When blutter finishes, it produces four outputs:

The asm files are the important part. Blutter inlines source-level semantics as comments above each instruction, so bl #0x3882e8 gets annotated r0 = replaceAll() with the resolved target [dart:core] _StringBase::replaceAll. Register loads from the object pool get their target's content inlined: ldr x3, [PP, #0x2e0] ; [pp+0x2e0] "" tells you that PP offset 0x2e0 holds the empty string constant. You read this like an unusually verbose pseudocode.

Mapping the methods

asm/meshos_companion/services/license_service.dart yielded six methods on LicenseService:

None of them referenced AESEngine or ECBBlockCipher. To double-check that the AES code wasn't being called from somewhere else in the project, I grepped the entire disassembly tree:

grep -rlE "AESEngine|ECBBlockCipher" blutter_out/asm/

Two hits, both in meshos_companion/protocol/companion_codec.dart. That's the BLE mesh packet codec, completely orthogonal to licensing, and consistent with the user-visible string "Messages are encrypted with X25519 + AES" I'd seen in the first-pass strings dump. The AES-ECB code in MeshOS is real and working; it just wasn't what gated the license screen.

The init path

init() does two things in sequence, both awaited:

0x584ce4: r16 = "license"
0x584cf4: r0 = openBox()   ; HiveImpl::openBox
0x584d00: r0 = Await()
0x584d08: StoreField: r2->field_7 = r0    ; this._box = openedBox

0x584d28: r0 = _getAndroidId()
0x584d34: r0 = Await()
0x584d3c: StoreField: r1->field_b = r0    ; this._androidId = result

So the instance has a _box at offset 0x7 and an _androidId at offset 0xb. _getAndroidId is a thin wrapper that invokes a platform MethodChannel (the channel name constant is loaded from the pool) with the method name "getAndroidId", awaits, and returns the result. That's handled on the Android side in native code; from the Dart side it's just an opaque string.

The validation path

isLicensed reads "license_key" from this._box via BoxImpl::get, checks it's a String, and if it's non-null calls validateKey(this._androidId, storedLicenseKey). Empty or null means not licensed.

validateKey was surprisingly short — 0x7c bytes, roughly 30 instructions. The relevant part:

0x580118: r0 = generateKey()    ; LicenseService::generateKey, with r1 = androidId
0x58011c: mov x1, x0            ; x1 = expected
0x580120: ldur x0, [fp, #-8]    ; x0 = user-provided key
; ... GDT dispatch on x0's class id ...
0x580138: blr lr                ; calls String::== (virtual dispatch via global dispatch table)

Two arguments in, one call to generateKey to recompute the expected value, one virtual dispatch on the result which is the == operator, return boolean. No decryption, no HMAC, no hash-compare of ciphertext. It literally recomputes the key and string-compares.

activate() is the user-facing wrapper. It takes the input string, calls _StringBase::trim, then replaceAll(" ", "") to strip whitespace, then calls validateKey(this._androidId, cleanedInput). On success, it writes "license_key" and "android_id" keys into the Hive box via BoxBaseImpl::put and returns true. On failure it returns false. No retry counter, no rate limiting, no crash.

Reading generateKey

This is the function that actually does the work, and the disassembly is where the MeshOS algorithm came out. The body is 0x238 bytes. Walking it:

Step 1: take the androidId argument and strip separator characters. The object pool reference at PP+0xe3d8 is the string "[:\\- ]" — that's a Dart RegExp pattern matching colon, backslash, hyphen, or space. The function allocates a _RegExp and calls _StringBase::replaceAll(regex, ""). Result lands in x0.

Step 2: loop over the UTF-16 code units of the cleaned string. The loop body is compact and gives the algorithm away immediately:

0x467214: lsl x5, x4, #5        ; x5 = accumulator << 5
0x467218: add x6, x5, x4        ; x6 = (acc << 5) + acc = acc * 33
0x46721c: cmp w1, #0xbc         ; branch on string class id (one-byte vs two-byte string)
0x467220: b.ne #0x467234
0x467224: ldrb w5, [x16, #0xf]  ; one-byte string: load byte
0x467234: ldurh w5, [x16, #0xf] ; two-byte string: load halfword
0x467248: add w5, w6, w4        ; new_acc = (acc * 33) + code_unit
; ... 32-bit mask via ubfx, then loop

(acc << 5) + acc is multiplication by thirty-three. That's the signature of djb2, one of the oldest and most recognizable string hash functions. The accumulator is masked to 32 bits each iteration via ubfx x5, x5, #0, #0x20.

Step 3: extract the four bytes of the 32-bit accumulator, big-endian, and XOR each against an immediate constant:

0x46726c: lsr w2, w1, #0x18     ; byte 0 = (acc >> 24) & 0xff
0x467278: movz x16, #0x4d
0x46727c: eor x2, x1, x16       ; XOR with 0x4D

0x467288: lsr w3, w1, #0x10     ; byte 1 = (acc >> 16) & 0xff
0x467294: movz x16, #0x43
0x467298: eor x3, x1, x16       ; XOR with 0x43

0x4672a8: lsr w5, w1, #0x8      ; byte 2 = (acc >> 8) & 0xff
0x4672b4: movz x16, #0x50
0x4672b8: eor x5, x1, x16       ; XOR with 0x50

0x4672c4: and w1, w4, #0xff     ; byte 3 = acc & 0xff
0x4672cc: movz x16, #0x50
0x4672d0: eor x4, x1, x16       ; XOR with 0x50

0x4D 0x43 0x50 0x50 is ASCII "MCPP" (Mesh Companion something-something, presumably). The four bytes of the hash get XOR'd against the four bytes of that string — an obfuscating mask with zero cryptographic value, since XORing with a known constant is reversible without knowing the constant.

Step 4: take the four masked bytes, map each to a two-character lowercase hex string via _IntegerImplementation::_toPow2String(16).padLeft(2, "0") (blutter shows this call inside the ListBase::map iteration), and join the results with ListIterable::join using an empty separator. Final output: an eight-character lowercase hex string.

The full algorithm in Dart

Reconstructed from the disassembly:

static String generateKey(String androidId) {
  final s = androidId.replaceAll(RegExp(r'[:\\\- ]'), '');
  int h = 0;
  for (final c in s.codeUnits) {
    h = ((h * 33) + c) & 0xFFFFFFFF;
  }
  final b0 = ((h >> 24) & 0xFF) ^ 0x4D; // 'M'
  final b1 = ((h >> 16) & 0xFF) ^ 0x43; // 'C'
  final b2 = ((h >>  8) & 0xFF) ^ 0x50; // 'P'
  final b3 = ( h        & 0xFF) ^ 0x50; // 'P'
  return [b0, b1, b2, b3]
      .map((b) => b.toRadixString(16).padLeft(2, '0'))
      .join();
}

The sanity check falls out of the structure. If the input consists entirely of separator characters, the RegExp-replaced string is empty, the hash loop never runs, and h stays at zero. The XOR step then produces bytes 0x4D 0x43 0x50 0x50, which hex-encodes to 4d435050. Interpreted as ASCII, that's "MCPP" — a visible watermark in the output space that confirms the reconstruction matches the binary. You can verify this yourself on the keygen page by submitting an empty or all-separators input.

What the analysis revealed about the design

The MeshOS licensing architecture has the shape of a serious scheme — named service class, async initialization, persistent storage via Hive, dedicated UI, a real crypto library imported — but the validation itself is a 32-bit keyed hash with a fixed key derived from a device identifier that's readable by any app on the device. The PointyCastle import is a genuine red herring: it's used for mesh packet encryption in a completely different module, and its presence in the strings output naturally pulls attention toward an AES-based validation that doesn't exist.

This is a pattern worth flagging because it's common. Developers tend to architect licensing systems that look robust — good class structure, real crypto dependencies in the project, persistent storage, a nice activation flow — while the core validation collapses to a client-side hash-compare. Once an attacker gets past the "find the AES key" distraction and reads the actual method, the entire scheme reduces to a ten-line keygen.

The structural problem is that any purely local validation has this property regardless of how it's implemented. You can swap djb2 for SHA-256, XOR for AES, an Android ID for a hardware-backed key, and the attacker still wins because the comparison is still happening on the attacker's device with the attacker's debugger. The only architectural fix is to move validation server-side and make the client prove possession of something it can't fabricate — Play Integrity on Android, App Attest on iOS, or a server-held HMAC secret signing device-specific tokens. None of that is free, but nothing short of it changes the economics.

Total effort

From zero to working MeshOS algorithm reconstruction: 19 minutes. The string scan, the blutter run, and the disassembly read happen back to back once the toolchain is warm. The only thing that almost cost more was chasing the AES red herring before grepping the asm tree and seeing it belonged to the codec module.

The general lesson: Flutter AOT is meaningfully harder to inspect than stock Android bytecode, but it's not hard in the cryptographic sense — it's hard in the "requires the right tool" sense. Once blutter is built, the gap between "I have the APK" and "I have the algorithm in readable Dart" is under 20 minutes.

> Full algorithm is live on the MeshOS keygen page, with Python and JavaScript ports you can copy and run yourself.