██╗ █████╗ ██╗ ██╗██╗ ███████╗██████╗ ██╗ ██╗ █████╗ ██╗ ██╗
██║ ██╔══██╗██║ ██║██║ ██╔════╝██╔══██╗ ██║ ██║██╔══██╗╚██╗██╔╝
██║ ███████║██║ █╗ ██║██║ █████╗ ██████╔╝ ███████║███████║ ╚███╔╝
██║ ██╔══██║██║███╗██║██║ ██╔══╝ ██╔══██╗ ██╔══██║██╔══██║ ██╔██╗
███████╗██║ ██║╚███╔███╔╝███████╗███████╗██║ ██║ ██║ ██║██║ ██║██╔╝ ██╗
╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
[ m e s h o s : : w r i t e u p ]
Cracking the MeshOS Companion APK
// Four rounds against the Android companion app. v1.0.4 is current and was a cosmetic rotation of v1.0.3 — same route on our side, just a few offsets moved. v1.0.3 was the round that actually took work (about three hours). v1.0.2 and v1.0.0 are archived below.
Four rounds against the Companion APK so far. Round 4 (v1.0.4) is expanded below; the earlier rounds are collapsed underneath it — click to expand any of them, or use the cards above to jump. The T-Deck firmware rounds are in the sister article.
Round 4: v1.0.4 — cosmetic rotation on Andy's side, fresh keypair on ours (< 5 min)current
andy: "I've rotated the obfuscation mask to a new constant, broken the EOR into a register-loaded two-instruction form, and shifted the verifier's offset by ten kilobytes. Substring searches against v1.0.3 will not match v1.0.4. I've also fixed the foreground-service race with a proper retry handler. That should fix it."
us: (silently rebuilds the community APK in under five minutes) "...did it though?"
v1.0.4 dropped about a day after v1.0.3. Same route on our side. Things just moved a bit. Time to crack — if you want to call confirming a diff "cracking" — was under five minutes, almost all of it spent verifying that what we were looking at was indeed as cosmetic as it looked.
What Andy actually changed
Diff v1.0.4 against v1.0.3 and you get a short list of textually-distinct things:
- The 32 bytes at the pool slot we identified in v1.0.3 as the obfuscated key are different bytes — visibly nothing like the v1.0.3 values.
- The per-byte XOR mask in the read loop changed from a single-instruction immediate form (
eor x3, x4, #0x7c) to a two-instruction register form (mov x16, #<new constant>followed byeor x3, x4, x16). The mask constant rotated to a new value. - The verifier function entry-point moved roughly ten kilobytes earlier in
libapp.so. Hardcoded file offsets from v1.0.3 don't land on it. - The cert-hash string in
libapp.sois the same 64-character ASCII run as v1.0.3 (different value, but same shape). - The foreground-service race we documented in the bonus section — fixed.
MeshForegroundServicein v1.0.4 catches theSecurityExceptionand retries viaHandler.postDelayedwith exponential backoff. More on that one below.
That is the entire substantive diff on his side. Cosmetic-storage rotation at every level — storage bytes, encoding form, function offset — plus the FGS fix.
What we did on our side
We re-ran the same route as v1.0.3. The new mask constant falls out of one instruction in the (now relocated) read loop the same way 0x7C did last round. Once the new mask is known, the byte sequence Andy's verifier reconstructs at runtime is just bytes again — readable, comparable, treatable as exactly what it is.
On the build side, v1.0.4 community builds embed a fresh community public key, different from the one v1.0.3 community builds carried. The keygen API on the page signs v1.0.4 requests with the matching fresh private seed; the activator widget passes a keypair=v2 hint so the API picks the right one. The user-facing flow is unchanged — paste Android ID, generate, paste key, activate — but a key that worked on the v1.0.3 community build will not activate the v1.0.4 community build. Anyone migrating from v1.0.3 just regenerates.
The reason we're on a new community keypair isn't an APK-side story at all — it's a constraint that fell out of the T-Deck firmware side around the same time. The T-Deck v1.3.4 firmware stores its verifying public key partly inside Xtensa movi.n instruction immediates, and that encoding's narrow form only accepts certain byte values. The older community keypair didn't satisfy the position constraints v1.3.4 imposes, and we run one signing key across both products (APK and T-Deck) so we don't end up debugging two parallel APIs every round. So we ran a short keypair search for one that fits the T-Deck encoding, and that's now the keypair behind v1.0.4 (APK) and v1.3.4 (firmware). Full story in the round-6 T-Deck writeup.
Effort
Time to crack: under five minutes. The shipped community v1.0.4 APK is on the page, paired with the same keygen widget, same paste-Android-ID flow, same activation. Anyone arriving fresh on v1.0.4 just regenerates a key; anyone who had a v1.0.3 community key needs to regenerate too.
Thanks for the FGS fix
One thing v1.0.4 did ship that v1.0.3 didn't: an actual fix for the foreground-service race we wrote up in the bonus section. Andy added a retry handler around startForeground that catches the SecurityException, schedules a retry via Handler.postDelayed, bounds the retry count, uses exponential backoff (~250 ms / 500 / 750 / 1000), and emits diagnostic logs. It's a thorough fix — more thorough than the workaround we'd shipped in our v1.0.3 community build, in fact. So thanks, andy. Genuinely — the first BT activation on Android 14+ now works on the stock paid v1.0.4. (Credit is not needed ;) .)
One small wrinkle: the smali wrapper we'd applied to our v1.0.3 build to dodge the crash is still in our v1.0.4 build, sitting one stack frame above Andy's new catch SecurityException. It's a functional no-op — Andy's retry handler catches the exception first, ours never fires — but it's there, doubly-defended, because removing it would mean changing the build pipeline and that wasn't worth the maintenance overhead for a redundant safety net. Two retry handlers in series. The first BT activation on the community v1.0.4 is, if anything, slightly more resilient than the stock paid v1.0.4 to whatever weird timing scenarios Android 15 might throw at it. You're welcome.
v1.0.5 is welcome any time. Rotate the mask again if it makes you feel better.
Round 3: v1.0.3 — XOR-hidden Ed25519 public key (~3 hours)click to expand — the hard one
andy: "I've obfuscated the public key with a per-byte mask, switched the snapshot encoding so the bytes aren't byte-addressable any more, and inlined the entire verifier so there's no boundary to instrument. I also tightened up a few foreground-service paths while I was in there. That should fix it."
us: (silently slides a logcat across the table showing his app hard-crashing on the very first BT activation on Android 14+) "...did it?"
v1.0.3 was the first round that took meaningful time on our side — about three hours, two genuine false starts, and one disassembler session before the real shape of the scheme fell out of a single instruction in the verifier's machine code. Earlier APK rounds were "bytes you can read, bytes that mean what they look like." This one is "bytes that look like the public key in the snapshot, but aren't actually the public key the verifier reads." The sleight of hand burned the first hour before we worked out what was happening.
We learned a lot on this round, so credit where credit is due. Props to Andy's coding agent — the obfuscation upgrade reads exactly like an LLM ground against a "make it secure" prompt for the better part of a few days: per-byte XOR, snapshot-encoding switcheroo, fully inlined verifier, every change a small step up in analysis effort, none of them changing the structural problem. Whoever was holding the keyboard, we hope you got paid. (The same coding sprint also shipped a fresh foreground-service race condition that crashes the app on Android 14+. We documented it for you in the bonus section below. Quality assurance, no charge.)
The activator on the keygen page works against the community v1.0.3 APK with the same steps users have followed since round 2: paste your Android ID, hit generate, paste the resulting key into the licence field on first launch, tap Activate. End-to-end takes a few seconds, same flow as v1.0.2. The rest of this section is the methodology of how the v1.0.3 scheme was understood.
What the static scan tells you (nothing useful)
The natural first attempt against any embedded-key scheme is to find the key bytes inside libapp.so on disk. In v1.0.3, the key Blutter recovers from the Dart object pool does not appear anywhere in the binary as a contiguous run of bytes. Not even the first four bytes of it. A seventeen-encoding sweep was run looking for the key, against all three architectures' libapp.so:
- Raw bytes
- ASCII hex (upper and lower case)
- Base64 with three padding variants
- Base32
- Ascii85
- Dart literal text (the way
const <int>[…]would render textually) - LEB128 (zigzag and raw, both endiannesses)
- Smi32 little-endian, both Smi tag bits
- Smi64 little-endian
- u16 and u32 in both endiannesses
- A single-byte XOR sweep across all 256 possible mask values
Zero matches anywhere in arm64-v8a/libapp.so, armeabi-v7a/libapp.so, or x86_64/libapp.so. That isn't paranoia — it's correct AOT-snapshot behaviour, and it's the architectural change that distinguishes v1.0.3 from v1.0.2. Dart's serializer writes canonicalisable scalar values (and that includes the elements of a const <int>[…] literal) as LEB128 back-references into the snapshot's Smi cluster, not as a contiguous byte run. The compiled code reads those LEB128 ids, dereferences each into the Smi cluster, and reconstructs the byte at runtime. The bytes you'd want to grep for never exist in the file in a form a substring search can hit. Blutter recovers the value because it understands the snapshot format; a hex editor's find-bytes pass returns nothing.
The first lesson of this round: inspectability is not patchability. Blutter recovers the array value precisely because it parses the snapshot's object pool. That recovery doesn't imply the bytes are at any one file offset; it implies the bytes are logically stored, distributed across a cluster, and reassembled at runtime. The same property is what makes a naive file-level static rewrite a non-starter for v1.0.3 in a way it wasn't for v1.0.2.
Locating the candidate key in the pool
If you can't byte-scan the file, run Blutter and read pp.txt. Search for the licence-related strings — the regenerated asm/…/license_service.dart mentions "unactivated" and the Ed25519 length-check error string, both of which appear in the pool. Near those strings there's a 32-byte List<int>(32) in the pool that looks like this:
List<int>(32) [0xa3, 0xd1, 0x26, 0x23, 0x49, 0x08, 0x09, 0xb7,
0x51, 0x2f, 0x16, 0xc8, 0x8c, 0xa2, 0x1c, 0x59,
0x51, 0x43, 0xeb, 0x0d, 0x2a, 0x8b, 0x7a, 0x7f,
0x11, 0xdb, 0x5f, 0xa4, 0x45, 0x4c, 0x22, 0xd7]
A 32-byte List<int> with the Ed25519 length-check error string and the licence-state strings in the immediate neighbourhood, and the array is exactly the length of an Ed25519 public key. Length match plus surrounding-string adjacency plus the disassembly showing this is the array F2 reads in its inner loop — that's how Blutter and the eye converge on this entry as the candidate public key. Call it S.
So we have what looks like the new public key. Generate a fresh test keypair, sign a candidate message against the test private key, paste the resulting signature into the licence field, tap Activate — Invalid license key. Widen the corpus: sixteen candidate messages, covering device ID alone, device ID with version, with various separators, JSON-wrapped, hash-prefixed, UTF-16 LE encodings, package-name concatenations. Every single one rejected.
Two readings: either the message format M is exotic and outside the corpus, or the bytes S recovered from the pool aren't actually the public key the verifier uses to verify against. We pursued the first reading for an hour before circling back to the second.
Two genuine false starts
Before settling on the second reading, two diversions ate most of an hour. Both produced clean zero-signal results, which is the kind of dead end where the absence of a hit is itself the data point that tells you to stop looking in that direction.
The R || A || M heap scan. Per RFC 8032, Ed25519 verification computes H = SHA-512(R || A || M), where R is the first 32 bytes of the signature, A is the public key, and M is the message. If that (64 + |M|)-byte buffer is ever assembled in a single contiguous heap allocation, an attacker can trigger off the known R prefix and read A and M directly out of memory. We captured the user's R from the verifier's signature argument at entry, then scanned every readable memory range for that 32-byte prefix. One hit across all anonymous ranges, and it was the user's original signature allocation itself. The implication is unambiguous: R, A, and M are never co-located. The SHA-512 implementation is fed the inputs incrementally — first R, then A, then M, each as a separate add() call. The intermediate state lives inside the hash implementation's 128-byte block buffer, where the inputs are interleaved with the absorption permutation and not recoverable by pattern-matching from outside.
The Ed25519.verify call-site hunt. The next attempt was to find the actual verify call inside the licence-check function and capture its argument registers at the call site. The assumption: one of the function's internal bl / blr instructions branches to a library verify routine whose arguments include the message and the public key in callee registers, and the message would appear in x1 or x2 at the call. Walked the function (call it F2, entry at libapp.so + 0x744474) forward from its entry to its first ret, enumerated every call instruction, and recorded register state at each site. What this actually showed was that F2 makes exactly one internal call — an AOT runtime stub at offset +0xC0, with arguments x0 = 0x7400008081 (a tagged Dart constant), x1 = 0x1f0 (a Smi for 248), and x2 a pointer into .text. That's an allocation helper or a type-check, not Ed25519 verify.
The verifier is not a separate function. Dart-AOT inlined the SHA-512 setup, the key-read loop, and the entire verification math directly into F2's body. There is no inner call to instrument. By this point two correct-but-useless approaches had produced unambiguously zero signal, and it was clear more dynamic instrumentation in the same direction wasn't going to converge. We stopped and read the actual instructions.
The aha: a single instruction in the read loop
Extract libapp.so from the APK, map the runtime addresses back to file offsets via the ELF program headers, and disassemble F2 from its entry to its first ret with Capstone. Ninety instructions, splitting into recognisable blocks: stack-frame setup, pool-base loading, a 32-iteration loop that walks the candidate-key array byte by byte, then a tail that calls into the hash code.
The inner loop is twelve instructions long. Read straight:
0x744554: cmp x2, #0x20 ; while i < 32:
0x744558: b.ge #0x74458c ; → loop exit
0x74455c: add x16, x1, x2, lsl #2 ; x16 = key_array + i*4 (Smi32 stride)
0x744560: ldur w3, [x16, #0xf] ; load Smi32 at array[i] (data starts at +0xf)
0x744564: add x3, x3, x28, lsl #32 ; decompress pointer
0x744568: sbfx x4, x3, #1, #0x1f ; signed bitfield, removes Smi tag
0x74456c: tbz w3, #0, #0x744574 ; if low bit clear (Smi), skip unbox path
0x744570: ldur x4, [x3, #7] ; (boxed-int path)
0x744574: eor x3, x4, #0x7c ; *** XOR each byte with 0x7C ***
0x744578: add x4, x0, x2 ; dest = out_buf + i
0x74457c: strb w3, [x4, #0x17] ; out_buf[i] = byte ^ 0x7C
0x744580: add x3, x2, #1 ; i++
0x744584: mov x2, x3
0x744588: b #0x744548 ; loop
The instruction that pays for the three hours: eor x3, x4, #0x7c. Every byte read from the candidate-key array is XOR'd with the constant 0x7C before being stored into the buffer that the SHA-512 verifier will then absorb. The 32 bytes sitting in the snapshot are not the public key. They are the public key XOR'd byte-for-byte with 0x7C. The verifier de-obfuscates on read.
Because XOR is involutive, the inverse transformation is identical to the transformation. XOR the recovered pool bytes against 0x7C again and you have the actual Ed25519 public key the verifier checks signatures against. Concretely — the bytes Blutter recovered from pp+0xfd88:
a3 d1 26 23 49 08 09 b7 51 2f 16 c8 8c a2 1c 59
51 43 eb 0d 2a 8b 7a 7f 11 db 5f a4 45 4c 22 d7
XOR'd byte-for-byte with 0x7C:
df ad 5a 5f 35 74 75 cb 2d 53 6a b4 f0 de 60 25
2d 3f 97 71 56 f7 06 03 6d a7 23 d8 39 30 5e ab
That is Andy's actual v1.0.3 public key A. It is a different key from v1.0.2's, and it does not appear anywhere in the binary as a contiguous run — it only ever lives in heap memory for the duration of one key-load loop iteration, where each byte is XOR-decoded, stored into the local SHA-512 input buffer, and overwritten on the next iteration. From the perspective of any analysis pass that doesn't read the loop body, the real key is simply absent.
That also explains the sixteen-candidate oracle. Every candidate had been signed against the pool bytes S (which we'd assumed were A), but the verifier was checking signatures against S ^ 0x7C — a third key, distinct from both Andy's real key (call it A_orig) and any test key we'd generated. No signature against either S or our own keypair was ever going to verify. The oracle was wired correctly; it was just asking about the wrong key.
What changed end-to-end
So the full delta from v1.0.2 to v1.0.3, viewed from the verifier's read path, is two things stacked:
- The snapshot encoding switched. v1.0.2 stored the public key as a
Uint8List— one raw byte per element, sitting in the snapshot as a contiguous byte run thatxxd | grepcould locate insidelibapp.soat a single file offset. v1.0.3 stores it as aconst <int>[…]— whose elements are LEB128-clustered Smi values in the snapshot, not contiguous bytes. The bytes do not exist in the file as a substring. - The key gets XOR-masked on read. Even after the runtime reconstructs the per-element values from the Smi cluster, the value the verifier feeds into SHA-512 is
byte ^ 0x7C, notbyte. The 32 values Blutter recovers from the pool are an obfuscated representation; the real key only exists for one loop iteration at a time in a stack-local buffer.
Nothing else in the scheme changed. Same Ed25519, same SHA-512, same signature-format expectations. From a cryptographic standpoint, v1.0.3 is v1.0.2 with one extra instruction in the read loop and a different snapshot encoding for the constant. From an analysis-time standpoint, that combination is exactly what cost the better part of three hours.
The other half of the defence: the anti-tamper check
v1.0.3 also ships a second layer of in-process defence that wasn't in v1.0.2. There's a hardcoded SHA-256 string in libapp.so — you can find it with strings as a contiguous 64-character lower-case hex ASCII run; the relevant Dart identifier is sha256OfSigningCert. At startup the app reads its own APK's PackageInfo signing certificate, computes the SHA-256 of the cert bytes, and compares it against the stored hex string. If the runtime hash doesn't match the embedded hash, the app knows the APK has been re-signed by someone other than the developer.
This is a textbook anti-tamper check, and it's not optional. Any approach that involves repacking and re-signing the APK invalidates the original v2/v3 APK signature (because re-signing necessarily produces a different cert fingerprint) — which means the app's own self-check sees the mismatch and refuses to operate normally. The exact failure mode on v1.0.3 is mild enough that it's easy to miss in casual testing: the app boots, but the licence subsystem behaves as if the activation key is invalid regardless of what you paste in. From a user's perspective the symptoms look like "the keygen doesn't work" rather than "your APK is unsigned" — which is good design on the anti-tamper side, because it sends the attacker chasing the wrong bug.
So any attack on v1.0.3 has to engage two coupled defences: the XOR-masked key (which the verifier reads at runtime from a writable region of memory) and the signing-cert hash (which is what the runtime compares against the live cert at startup). Both are in libapp.so. One is a 64-byte ASCII run; the other is a Smi-clustered byte array that only exists as bytes briefly during one loop in the verifier.
The attack
The attack mirrors the defence. Two parts, one static, one runtime:
- The signing-cert hash gets patched on disk. It exists as a single contiguous 64-byte ASCII hex run in
libapp.so— the canonicalisation-by-Smi-cluster that hides the public key doesn't apply to strings, which Dart's serializer stores as direct UTF-8. Generate a community re-signing keystore, compute the SHA-256 of its cert, hex-encode it, byte-replace the original 64 characters inlibapp.so. Same-length replace, no section shifts, no relocation table to update. The anti-tamper check now passes against our keystore instead of Andy's. - The public key gets patched live. Since the canonical-Smi encoding means the bytes never appear contiguously on disk, the only way to get the verifier to consult our public key is to wait for the Dart deserialiser to reconstruct the array in heap memory and then rewrite it in place. After deserialisation, every byte of the array exists in writable memory in some layout. The five plausible Dart-internal layouts:
On this build, the array deserialises into the Smi32-tagged compressed-pointer form. A runtime patcher (injected via a stub library loaded inConcrete class Per-element representation Uint8List/Int8List1 raw byte _ImmutableList<int>compressed-ptr4 bytes, byte << 1_ImmutableList<int>uncompressed-ptr8 bytes, byte << 1Uint16List/Int16List2 bytes Uint32List/Int32List4 bytes onCreate, exposing the standardMemory.scanSync-style interface over the process's anonymousrw-ranges) enumerates those ranges, runs five parallel scans for the obfuscated key pattern (one per layout, each transformed by the relevant tag-bit pre-multiplier), and rewrites the match in place with(our_key_byte ^ 0x7C)— pre-XOR'd so that when the verifier applies its owneor #0x7con read, it yields our public key byte. - The patcher polls. Dart's GC can relocate objects under pressure, so any one-shot scan that catches the array before the GC has settled might point at a stale region after a compaction pass. The patcher re-scans and re-applies every few hundred milliseconds for the first ~30 seconds of process life. By the time the user actually taps Activate, the array is stable and overwritten. In testing the array doesn't move on this build, but the polling pattern is what makes the attack tolerant of future GC behaviour.
End-to-end: patched libapp.so (anti-tamper hash swapped, same-size byte replace) + injected runtime instrumentation (key-array scan-and-rewrite, 500 ms tick, 30 s window). The verifier reads our public key under the XOR mask, computes SHA-512(R || A_us || M), runs the Ed25519 check against the signature our keygen API signed with the matching private key, and returns activated. The user-side flow is the unchanged three-step paste from rounds 1 and 2.
v1.0.4 is welcome any time. We'll meet whatever ships.
Why this obfuscation buys time, not security
The XOR with a single per-byte constant is symmetric, and it's discoverable from one instruction in the read loop. The shape of the round doesn't change the structural property that an in-process verifier necessarily produces the real public key in a register on some instruction at runtime, and whatever transformation that instruction applies is the same transformation an analyst has to invert. Obfuscation moves the analysis time up by a constant factor; it doesn't change the asymptote.
Total effort (round 3)
From v1.0.3 release to a complete understanding of the v1.0.3 scheme: about three hours. The earlier rounds were "bytes you can read, bytes that mean what they look like"; this round was "bytes you can read, that aren't actually the bytes that matter." Both genuine false-starts (the R-prefix heap scan and the Ed25519.verify call-site hunt) consumed real time and produced unambiguously zero signal — the kind of dead end where the absence is the data point. The single-instruction observation that ended the round (eor x3, x4, #0x7c) takes a few minutes to identify and feels obvious in retrospect, but the path to it ran through two correct-but-useless approaches and required closing the dynamic-instrumentation laptop and opening a disassembler.
Props to whoever held the keyboard. v1.0.4 is welcome any time — maybe send the coding agent back in with a fresher prompt. We'll be over here with Capstone open. The activator on the keygen page works against the community v1.0.3 APK the same way it has since round 2.
Bonus: the Android 14+ FGS crash in v1.0.3 (fixed in v1.0.4, no credit needed) click to expand — archived QA report
Status: Andy fixed this in v1.0.4 with a retry handler around startForeground. The crash described below applies to v1.0.3 only. This section is preserved as historical context and because it's a useful reference for what the bug actually was. v1.0.4 users on Android 14+ are no longer affected by this on either the stock paid build or our community build.
While testing v1.0.3 end-to-end on an Android 14 device, the app crashes reliably the first time the user taps Start Local Mesh Chat on the Welcome screen and then Allow on the resulting "nearby devices" permission prompt. The crash reproduces on the original unpatched v1.0.3 APK as well — it's in Andy's code, not in anything we did. Free bug report, no charge, billable in egg.
What the OS reports
FATAL EXCEPTION: main
Process: com.meshcore.meshcore_companion
java.lang.RuntimeException: Unable to start service
com.meshcore.meshcore_companion.MeshForegroundService
Caused by: java.lang.SecurityException: Starting FGS with type connectedDevice
callerApp=…/u0a405 targetSDK=36 requires permissions:
all of [FOREGROUND_SERVICE_CONNECTED_DEVICE]
and any of [BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT, BLUETOOTH_SCAN, …]
at android.app.IActivityManager$Stub$Proxy.setServiceForeground(IActivityManager.java:7509)
at android.app.Service.startForeground(Service.java:862)
at MeshForegroundService.onStartCommand(SourceFile:85)
The chronology in the very same logcat dump is decisive:
15:04:23.471 SecurityException: Starting FGS with type connectedDevice …
15:04:23.78x Permission grant result … BLUETOOTH_ADVERTISE result=4
15:04:23.78x Permission grant result … BLUETOOTH_CONNECT result=4
15:04:23.78x Permission grant result … BLUETOOTH_SCAN result=4
The permission-grant log lines land roughly 300 ms after the crash. The foreground service has already called startForeground on the optimistic assumption that "the user just tapped Allow, so the permission is now granted." On Android 14+ that assumption is wrong: the permission state only updates when the grant callback actually completes, and the FGS type-check inside ActiveServices.validateForegroundServiceType re-reads the permission state at exactly the moment startForeground is called. It sees the permission as still-not-granted, throws SecurityException, the app dies. The actual grant lands a fraction of a second later. Too late.
Why it's specifically an Android 14+ issue
Android 14 (API 34) introduced typed foreground services. Each FGS type requires its specific permission set declared at runtime and already granted at the moment the service transitions to foreground. connectedDevice is the type intended for BT/USB/NFC peripheral management; it requires "any of the BT permissions, plus FOREGROUND_SERVICE_CONNECTED_DEVICE". On Android 13 and earlier the same call path was a no-op, so the same race did not produce a fatal error. v1.0.3 declares targetSdk=36, which puts it firmly under the new rules.
The fix on the developer's side is short. Two minimal-diff options:
- Have the launching activity request BT permissions in
onCreate, then haveMeshForegroundService.onStartCommandconsultContextCompat.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT)before its call tostartForeground. If the permission isn't granted, log and callstopSelf(); the user can re-tap once the grant lands. - Or move the
startForegroundcall into aHandler.postDelayedblock keyed off the activity's permission-result callback, so it only fires onceonRequestPermissionsResulthas actually run.
Either fix removes the race — and Andy did, in v1.0.4. The second option above (the Handler.postDelayed approach) is roughly what shipped, with exponential backoff and a retry counter wrapped around it. Good fix. Reproduces on stock v1.0.4 only as the friendly retry-once-and-recover path, not as a crash.
For the historical record: we fixed it first
Before v1.0.4 shipped, the community v1.0.3 build on this page already worked around the crash — we caught the SecurityException in our build and let Android re-deliver the start intent a beat later once onRequestPermissionsResult had actually run. So during the v1.0.3 window, the side-by-side was:
- Andy's stock paid v1.0.3: tap Start Local Mesh Chat → Allow → hard crash.
- The community v1.0.3 from this page: tap Start Local Mesh Chat → Allow → works.
For roughly 24 hours, the unauthorised community build was the more reliable one. Recall the same Andy on the same release notes told us he'd "tightened up a few foreground-service paths while [he] was in there." Sure, andy. v1.0.3 didn't include that fix. v1.0.4 does. Drop a thank-you note in the guestbook if the activation finally works for you. (Credit on Andy's side is not required either way. ;) )
Round 2: v1.0.2 — Ed25519 signature verification (13 min)click to expand
Five days after the v1.0.0 keygen circulated, Andy pushed v1.0.2. He stopped pretending a 32-bit djb2 hash counted as a license check and replaced it with an actual public-key signature scheme. Ed25519, embedded public key, signed-Android-ID-as-license. Cryptographically, that is the right primitive for the job. If you have to ship a client-side license check at all, this is what it should look like: a signature you can verify with a key you ship, made by a key you don't.
Reading the v1.0.2 verifier
Same approach as round 1: run blutter against the v1.0.2 libapp.so, read the regenerated asm/meshos_companion/services/license_service.dart, and start from the methods on LicenseService. The class has the same shape (init, _getAndroidId, validateKey, isLicensed, activate) but the validation body is completely different. validateKey now:
- UTF-8 encodes the Android ID into a
Uint8Listmessage - Hex-decodes the user-provided license key into a 64-byte signature
- Loads a 32-byte public key from the object pool
- Calls into PointyCastle's
Ed25519verifier with(signature, message, publicKey) - Returns the boolean result
The pool reference for the public key resolves cleanly in pp.txt as a TypedDataView backed by a 32-byte byte array. The bytes are right there in the dump, and they cross-reference straight into the raw libapp.so as a single contiguous run that xxd | grep will find without effort — the Smi-cluster canonicalisation that becomes central in v1.0.3 doesn't apply here, because at this point in MeshOS's evolution Andy was storing the array as a Uint8List (one raw byte per element, no Smi indirection) rather than a const <int>[…]. That difference is small in source but enormous in analysis terms, and v1.0.3 (above) is what happens when the developer fixes it.
Why v1.0.2 is the right cryptographic primitive
From a "did the developer pick the right tool" standpoint, v1.0.2 is correct. Ed25519 signatures are unforgeable in any practical sense — signing requires the private key, the private key isn't in the APK, and reading the verification path doesn't give you a way to forge a signature. If the only available avenue against the scheme is "find Andy's private key", then there is no avenue.
What the design does not address — and what is the central observation of every subsequent round on both the APK and T-Deck firmware tracks — is that "you cannot forge his signatures" is not the same as "no working keygen ships". The signing private key is unreachable. The verifying public key, however, is sitting inside the binary — it has to be, because the verifier has to consult it on every check. Whether that key is a contiguous byte run in libapp.so (as it is in v1.0.2), an obfuscated representation that's de-obfuscated on read (as it becomes in v1.0.3), or stored differently again, the verifier still has to materialise the real key in a register on some instruction. v1.0.2 doesn't change that property, and neither does v1.0.3, and neither does any subsequent T-Deck round.
Total effort (round 2)
From v1.0.2 release to complete understanding of the scheme: 13 minutes. Blutter was already warm from round 1, the Dart asm-tree convention was familiar, and the verifier was a near-textbook Ed25519 invocation against a public key trivially located in the snapshot. The activator on the keygen page works against the community v1.0.2 APK with the same flow as v1.0.3: paste Android ID, generate, paste key, activate. v1.0.3 (above) is the same shape under a layer of XOR.
Round 1: v1.0.0 — djb2 keygen (19 min)click to expand — full Flutter AOT walkthrough
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 hex string (32 or 64 characters of
[0-9a-f]) - A base64 string (24 characters ending
==for a 16-byte key, 44 ending=for a 32-byte key) - A plain ASCII string of exactly 16, 24, or 32 characters
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:
asm/<package>/<file>.dart— annotated ARM64 disassembly, organized to mirror the original Dart source treeobjs.txt— the full object graphpp.txt— the object pool dump, which is where byte-array literals and constant values end upblutter_frida.js— a Frida hook template with the function addresses pre-populated
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:
init()— async, opens a Hive box named"license"and populates two fields_getAndroidId()— async, invokes a platform method channelgenerateKey()— staticvalidateKey()— staticisLicensed— getteractivate()— instance method
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.
Total effort (round 1)
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.
v1.3.0 ESP32-S3 firmware (10 min) · v1.3.1 hotfix (3 min) · v1.3.2 hotfix (90 seconds) →