← plain-English summary · source APK obtained from the developer's official release channel; app never executed; no servers contacted by this analysis. Encryption key identifiers are redacted here; they are recoverable from the APK by anyone holding it.

MeshOS v1.0.31 — Static Review of Public Claims

Reviewer: Andrew's Hermes agent (static only) · Date: 2026-11-07 Source: APK fetched from the developer's official download channel only. meshcore.co.uk/app/ links to https://github.com/andymux/meshos-releases/releases/tag/v1.0.31. Nothing was taken from meshoskey.com or any mirror. No keygens or licence bypasses were downloaded or examined. No meshcore.co.uk server was contacted.

Verdicts

ClaimVerdictOne-line reason
C1 — reads up to 2,048 contacts and 1,792 SMS bodiesCONTRADICTEDManifest declares no READ_CONTACTS/READ_SMS/etc., no contacts/SMS plugins, no query code; on minSdk 24 such access is impossible without the permissions.
C2 — packs them into a 12-field TLV, AES-256-GCM, POSTs to tiles.meshcore.co.uk/imgPARTIALLY SUPPORTED / data claim contradictedThe /img POST path with fixed headers and a GCM encryption marker exists and takes a Uint8List; but "12-field TLV" and contacts/SMS packing have no supporting evidence anywhere.
C3 — sends a second "harvest" upload alongside the photoINCONCLUSIVE (no harvest semantics found)A second upload API (/hm/upload) exists, but it is keyed by X-HM-Id and text/plain — consistent with the coverage/heatmap feature, not a "harvest".
C4 — MQTT kill switch at mqtt.meshcore.co.uk:8883CONTRADICTED as specified (MQTT feature exists)mqtt.meshcore.co.uk, 8883 and any hardcoded broker URL are absent in pool and in instruction immediates across all three ABIs; an mqtt_client stack, topic meshos-topic-v1 and setting key meshos_mqtt do exist — the MQTT capability exists but the claim as specified (hardcoded broker:port) is absent.
C5 — shared XOR mask 0xB3 across photo, contact and licence codePARTIALLY SUPPORTED; sharing REFUTED for photosDerivation confirmed (one XOR-0xB3 site per ABI; hard-coded 32-byte blob → key material). Sharing is refuted for the photo path (uploader uses a different hard-coded header key) and unsupported for contact/licence paths — the sole consumer is one byte-envelope dispatcher.

1. Provenance

ItemValue
Download URLhttps://github.com/andymux/meshos-releases/releases/download/v1.0.31/MeshOS-v1.0.31.apk (linked from meshcore.co.uk/app/)
Download timestamp2026-11-07T21:55:33Z
APK size85,179,871 bytes (byte-identical to the GitHub release asset listing)
APK SHA-256fd4970e1b1446ddb0d2985c3b54837857a970da663b212fdb3d3c1f07ac47103
lib/arm64-v8a/libapp.so10,617,776 bytes · SHA-256 cdc8abe1011ec983a843edf7a4c773dfde5e645fefc25679d3b373e60ec83402
Packagecom.meshcore.meshcore_companion · versionName 1.0.31 · versionCode 5416
SDKminSdk 24, targetSdk 36, compileSdk 36
SignatureAPK Signature Scheme v2 only; RSA 2048
Cert SHA-25602099a2ff03bf4d6ed62186f115e82d2e8bb40fe9473553d5d12f23b04078c96
Cert DNCN=MeshCore, OU=MeshCore, O=MeshCore, L=Unknown, ST=Unknown, C=US
Dart snapshotDart 3.11.1, arm64, android, compressed pointers (78da37fed6bf1489361a312568249f3f)

Commands: curl -sI https://meshcore.co.uk/ → HTTP 200 (Cloudflare); app page's official link fetched to artefacts/app-page.html; release catalog from the GitHub API saved to artefacts/releases.json; hashes in artefacts/apk-sha256.txt, artefacts/libapp-sha256-arm64.txt; apksigner verify --print-certs output in artefacts/apksigner-certs.txt.

2. Manifest (C1)

Full output: artefacts/aapt2-badging.txt and artefacts/aapt2-manifest.xml (tools/android-bt/android-14/aapt2 dump xmltree … --file AndroidManifest.xml).

All 26 declared permissions (uses-permission only, no custom dangerous grants beyond):

INTERNET, ACCESS_NETWORK_STATE, CHANGE_NETWORK_STATE, ACCESS_WIFI_STATE,
WRITE_EXTERNAL_STORAGE (maxSdkVersion=28), BLUETOOTH/BT_ADMIN (maxSdk 30),
BLUETOOTH_SCAN (neverForLocation), BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE,
ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, POST_NOTIFICATIONS,
FOREGROUND_SERVICE, FOREGROUND_SERVICE_CONNECTED_DEVICE, FOREGROUND_SERVICE_LOCATION,
androidx.car.app.MAP_TEMPLATES, androidx.car.app.ACCESS_SURFACE, WAKE_LOCK,
CAMERA, RECEIVE_BOOT_COMPLETED, SYSTEM_ALERT_WINDOW, RECORD_AUDIO, VIBRATE,
DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION, READ_EXTERNAL_STORAGE (maxSdkVersion=28)

Explicit check:

<queries> element contains exactly: android.intent.action.PROCESS_TEXT, two android.intent.action.VIEW intents, and (visibility of) androidx.car.app.connection.provider — standard Flutter text-selection and car-app entries. No SMS/contacts package queries.

Declared content providers (all inside <application>): io.flutter.plugins.imagepicker.ImagePickerFileProvider (exported=false), dev.fluttercommunity.plus.share.ShareFileProvider (exported=false), com.google.mlkit.common.internal.MlKitInitProvider, plus plugin-internal providers. None expose SMS/contacts data.

3. Java/Kotlin layer (jadx 1.5.4)

Full decompile: ~/meshos-review/jadx-out/ (23 decompile errors, normal for Flutter apps).

Search across sources/ for content://sms, ContactsContract, READ_SMS, READ_CONTACTS, Telephony.Sms: zero hits. The only telephony reference is sources/D2/c.java:25 (Google Firebase performance telemetry, getSimOperator() for mcc_mnc), part of play-services' backend — not a device-data harvester.

Plugin registrations (io/flutter/plugins/GeneratedPluginRegistrant.java): audio_session, cryptography_flutter (dev.dint.cryptography_flutter.CryptographyFlutterPlugin), file_picker, flutter_blue_plus_android, flutter_local_notifications, flutter_plugin_android_lifecycle, gal, geolocator_android, image_picker_android, just_audio, mobile_scanner, network_info_plus, package_info_plus, path_provider_android, record_android, share_plus, url_launcher_android, usb_serial, webview_flutter_android.

No flutter_contacts, no contacts_service, no telephony, no another_telephony.

The developer's own Java classes are minimal and contain no upload/SMS/contact code: MainActivity.java (BLE permission plumbing), MeshForegroundService.java, BootReceiver.java (boot reschedule), car/MeshCarAppService.java (7 lines). No MethodChannel handler named companion/signing exists on the Java side; the string com.meshcore.companion/signing lives only in the Dart pool.

Raw grep output: artefacts/jadx-search.txt (single hit overall: the Firebase telephony.TelephonyManager import in sources/D2/c.java, described above).

4. Dart layer (blutter over libapp.so arm64)

Blutter output: ~/meshos-review/blutter-out/ (pp.txt = 2.2 MB object pool dump, asm/ = 1,312 disassembled Dart classes, objs.txt, blutter_frida.js). Tool build: tools/blutter-src with locally staged ICU/capstone (no system installs). Dart VM build log: artefacts/blutter2.log; run log: artefacts/blutter-run.log.

4.1 String/object-pool searches (pp.txt)

IndicatorResult
tiles.meshcore.co.ukpresent — 8 URLs (see below)
/imgpresent: [pp+0x114f8] "https://tiles.meshcore.co.uk/img"
mqtt.meshcore.co.ukabsent (0 hits, case-insensitive, all three libapp.so ABIs)
8883absent (0 hits in pool; also no tcp:///wss:// broker URL anywhere)
MQTT topic stringspresent: [pp+0x12260] "meshos-topic-v1" (single user topic)
mqtt_client Dart package symbolspresent (compiled in): mqtt_client::MQTTEncoding…, PublishAck Variable Header…, keepalive: started (60 s), MqttNormalConnection::connectAuto …
contact- or SMS-relatednothing: no content://sms, ContactsContract, READ_*, telephony plugin; pool contains only app-level "AirLink contact card" strings
harvestabsent (grep -aio harvest → 0 hits, all ABIs)
multipart/form-data, boundary, filename=absent (0 hits) — the upload is a raw binary POST, not multipart
2048 / 1792 as identifiersabsent from the Dart pool (only obfuscated symbol suffixes @1330520481 etc., unrelated)
AES machinerypresent: 3 × 256-entry S-box/TE tables ([pp+0x23920], [pp+0x23930], [pp+0x23938]) — the pointycastle AES Galois/CTR tables; cryptography_flutter plugin registered
XOR 0xB3absent (see 4.3)

Complete list of HTTPS URLs in the Dart pool (no others exist):

https://meshcore.co.uk/fw/firmware.json
https://raw.githubusercontent.com/andymux/meshos-releases/main/version.json
https://store.meshcore.co.uk/products/meshos-for-android-license?…
https://store.meshcore.co.uk/products/meshos-meshcore-t-deck-license
https://tiles.meshcore.co.uk/
https://tiles.meshcore.co.uk/coverage.json
https://tiles.meshcore.co.uk/hm/upload
https://tiles.meshcore.co.uk/img
https://tiles.meshcore.co.uk/nodes.json
https://tiles.meshcore.co.uk/sat/{z}/{x}/{y}.png
https://tiles.meshcore.co.uk/{z}/{x}/{y}.png

4.2 Function identification and cross-references

blutter's own annotation pass misses several async closures, so pool loads were also located by direct instruction-encoding scan of .text (ADD PP,#page,lsl 12 + LDR [reg,#off], calibricated on a known annotation at 0x9b2520/0x9b2524). Script saved to artefacts/scan_pool_refs.py; r2 disassembly excerpts in artefacts/r2-img-ctx.txt, artefacts/r2-hm.txt, artefacts/r2-4e1270.txt.

Key load sites (address = .text virtual address; PP = x27):

Pool entryLoaded atEnclosing function (blutter)
tiles.meshcore.co.uk/img (pp+0x114f8)0x4e141c, 0x4e1454, 0x4e146c, 0x9c2924Gzk.dart closures; yGk.dart [closure] Future<void> <anonymous closure>(dynamic, Uint8List, int) (class _tRa)
Content-Type (pp+0x11028)0x96ddf0, 0x98c794, 0x9c2980wFk/dFk/yGk async closures — same closures as above
tiles.meshcore.co.uk/hm/upload (pp+0x15128)0x98c730dFk.dart [closure] Future<void> <anonymous closure>(dynamic, U) (_tRa's sibling region)
meshos-topic-v1 (pp+0x12260)0x4e4fa0FFk.dart [closure] void <anonymous closure>(dynamic, U)
meshos_mqtt (pp+0x11608)0x96ed0c, 0x970980, 0x970f5c, 0x9b2fd4, 0x9b4b6c, 0x9e4498wFk/KIk/AGk/AHk async closures (settings/state plumbing)
image/jpeg (pp+0x1f928)0x5bc3fc, 0x9e8fb8sok.dart, myk.dart (image encode/decode paths)

The /img upload function (0x9c28f4, async continuation inside yGk.dart's Uint8List closure), reconstructed from r2 output (excerpt in artefacts/r2-img-ctx.txt):

0x9c2920  add x1, x27, 0x11, lsl 12
0x9c2924  ldr x1, [x1, 0x4f8]      ; "https://tiles.meshcore.co.uk/img"
0x9c292c  bl 0x2cdab0              ; wrap URL object
0x9c2944  … then four map-add calls (0x89b48c) building Map<String,String>:
          (pp+0x114d8 "X-MeshOS-Key", pp+0x114e0 "")
          (pp+0x114e8 "User-Agent",    pp+0x114f0 "MeshOS-App")
          (pp+0x11028 "Content-Type",  pp+0x1ab68 "application/octet-stream")
          (pp+0x1ab70 "X-MeshOS-Enc",  pp+0x1ab78 "gcm")
0x9c29b4  bl 0x4e1270              ; POST helper (async, "POST" method string at pp+0x11058)

So the /img upload:

This matches an encrypted binary image upload with declared GCM encryption and a fixed app-identifier key. It does not match a "12-field TLV" form submission: there is no multipart envelope, no field names besides the four header pairs above, and no TLV-related strings in the pool.

The /hm/upload function (0x98c6xx, dFk.dart) builds a Map<String,String> with (X-MeshOS-Key, [REDACTED]), (X-HM-Id, <runtime id>), (User-Agent, MeshOS-App), (Content-Type, text/plain) and a text/plain body — consistent with the MeshCore heatmap/coverage-map upload ("hm" = heatmap; the pool also carries coverage.json and UK town-name coverage labels). Excerpt: artefacts/r2-hm.txt.

Reachability from the photo-send path: the /img continuation is an async closure taking (dynamic, Uint8List, int) — its natural caller is the photo pipeline (image/jpeg codec functions feed Uint8List buffers). A static call graph could not be closed to the photo-send UI state machine (async continuations break blutter's call graph; no flat bl target and no pool closure object references the entry, i.e. it is reached via the Dart async machinery). So: plausible and shaped like a photo uploader, but not statically proven to be the photo-send upload.

Contacts/SMS feeding the upload: nothing. The upload closure's inputs are Uint8List (bytes) and an int. No pool object references contacts/SMS data structures.

Claimant's cited offsets/symbols: the task gave no exact offset list, only descriptions ("a URL to /img", "an MQTT endpoint", "an XOR loop"). Of the described items, the /img URL and AES-GCM usage do exist; mqtt.meshcore.co.uk:8883 does not; the "12-field TLV" and "contacts/SMS packing" have no corresponding artefact in the pool or code; the 0xB3 mask does exist as a key-derivation construct (see section 4.3).

4.3 XOR 0xB3 check (C5)

Initial scan found no XOR 0xB3 (EOR-immediate is impossible on ARM64 — 0xB3 is not a bitmask immediate — so the scan had to look for a materialised constant). The first version of this check used a wrong EOR-encoding mask and produced a false "CONTRADICTED"; a corrected scan finds the constant.

Corrected result — the XOR 0xB3 exists, exactly once.

ARM64 (apk/lib/arm64-v8a/libapp.so), disassembly in artefacts/r2-arm64-decode.txt:

0x0096beec  ldr   x0, [x26, 0x78]     ; THR
0x0096bef0  ldr   x0, [x0, 0x2320]    ; static field cache (is-initialised check)
0x0096befc  bl    0xa1a67c            ; allocate 0x40-byte object
0x0096bf08  add   x1, x27, 0x10, lsl 12
0x0096bf0c  ldr   x1, [x1, 0xe00]     ; [pp+0x10e00] List<int>(32) — the encoded blob
0x0096bf1c  cmp   x2, 0x20            ; loop 32 iterations
0x0096bf28  ldur  w3, [x16, 0xf]      ; element k (compressed Smi)
0x0096bf30  sbfx  x4, x3, 1, 0x1f     ; untag
0x0096bf3c  mov   x16, 0xb3           ; MOVZ x16, #0xB3  (0xd2801670)
0x0096bf40  eor   x3, x4, x16         ; EOR x3, x4, x16  (0xca100083)
0x0096bf48  strb  w3, [x4, 0x17]      ; store decoded byte
0x0096bf54  b     0x96bf10            ; loop
0x0096bf78  str   x2, [x0, 0x2320]    ; cache the derived 32-byte object, THR+0x2320

Pool source (blutter-out/pp.txt):

[pp+0x10e00] List<int>(32) [0x6c, 0x1e, 0xe9, 0xec, 0x86, 0xc7, 0xc6, 0x78, 0x9e,
0xe0, 0xd9, 0x07, 0x43, 0x6d, 0xd3, 0x96, 0x9e, 0x8c, 0x24, 0xc2, 0xe5, 0x44, 0xb5,
0xb0, 0xde, 0x14, 0x90, 0x6b, 0x8a, 0x83, 0xed, 0x18]

Decoded (element ^ 0xB3) = — 32 bytes of key-like material, cached in a Dart static field. The only reader found for that field is a getter at 0x96be88 (an undumped region; its surroundings reference dart:io proxy-configuration strings no_proxy/HTTP_PROXY), so downstream consumers of the derived value are not closed statically.

The x86_64 build contains the identical construct — xor rsi, 0xB3 at 0x9bc042 inside the same 32-iteration decode loop (artefacts/r2-x64-decode.txt); the second raw-byte "xor al, 0xB3" hit there is a false positive (a jmp rel32 displacement).

Consumer chain (follow-up scan, 2026-11-08): the decoded value is stored as Dart static late final zI nPa._nbe (obfuscated names; nPa is an abstract helper class in library vFk, zI is a crypto-object supertype whose concrete subclass iJ carries a (dynamic, int, List<int>) → int closure — a cipher-primitive signature). Access tracing (two-step pointer scan for the field-cache slot, plus direct-THR variant scan, plus object-pool xref) found the cluster pp+0x10de0–0x10e08 used only by the decode region 0x96be60–0x96c068: one writer (0x96bf78), one read-back (0x96beec), one field load (0x96bffc). The neighbouring object-store offsets 0x2318/0x2320/0x2328 are generic field/pool offsets used widely by unrelated closures — an initial scan mistake treated them as key readers; corrected.

Getter callers found: a flat BL scan of the whole .text shows the getter/validation region (0x4df9f0–0x4e0060) is called from exactly two sites, both inside [closure] void _kHg(dynamic, Uint8List) (wFk.dart, function start 0x4dd704). _kHg dispatches on bytes of its Uint8List argument (a byte-tagged envelope switch) and is the sole consumer of the XOR-derived key/validator. No photo, contact, or SMS-shaped data path reaches it; the /img photo upload uses a different, separately hard-coded key (X-MeshOS-Key header, section 4.2). The envelope format's meaning (licence code, signed message, or handshake blob) remains undetermined — but the "shared across photo, contact and licence code" claim collapses to "used from one byte-payload dispatcher." The validation text ("Invalid key (expected 128 hex characters)") belongs to this same cluster and may concern a different object or representation entirely.

Search coverage (revised):

Fairness note on C5 (three independent model reviews — Claude Opus 5.5 in artefacts/opus-review.txt, a Codex-route second pass in artefacts/sol-review.txt (wording corrections, GO), and Gemini Argon in artefacts/gemini-review.txt (GO; recommended the C5 sharpening and the getter-caller scan, both actioned)): the reviewer's first EOR scan was methodologically weak and the initial CONTRADICTED verdict over-reached. The claimant's mask is real and embedded; what static analysis cannot establish is whether the derived constant is shared across the photo, contact and licence code paths. The photo upload carries its own separately hard-coded 16-byte key (X-MeshOS-Key header, section 4.2), and nothing contact-shaped consumes the derived 32-byte value in any reachable code. The nearby 128-hex validation text is not evidence of the derived value's purpose — it may concern a different object entirely.

Also — permission-model correction from the same review: runtime "dangerous" permission enforcement dates from API 23 (Android 6/2015); the SMS-provider change (no legacy READ_SMS-less access) dates from API 19 (KitKat). minSdk 24 post-dates both, so the C1 conclusion is unchanged, but the API history in an earlier draft was imprecise.

5. What static analysis cannot establish

6. What a dynamic test would need to check

7. Second-opinion review (Claude Opus 5.5) and additional checks run

An independent critique of the draft report by anthropic/claude-opus-5.5 (saved verbatim to artefacts/opus-review.txt) raised the points below; each was actioned:

Opus findingAction taken
XOR-0xB3 scan invalid as designed (EOR-imm impossible; Smi tagging; native libs)Corrected scan run — mask is real; C5 verdict revised (section 4.3)
MQTT "8883" can be an instruction immediate, not a pool objectScanned MOVZ/MOVN/MOVK 0x4566 (8883 tagged), 0x22B3, 0xEBA/0xEB7 (1883) in arm64 and x86_64 — 0 hits; C4 contradiction now covers immediates
String searches may miss UTF-16 (TwoByteString)UTF-16LE scan of all three libapp.so ABIs added — mqtt.meshcore.co.uk/8883 absent (section 4.3 coverage list)
C4 "contradicted" too strong if broker is remote-configuredReport now states broker appears runtime-configured; contradiction claims the specific hardcoded broker/port only
Permission-API history impreciseCorrected (API 23 runtime model; API 19 SMS change)
NotificationListener/Accessibility/sharedUserId side channelsManifest + dex checks run — all absent (section 2)
GET_CONTENT user-mediated contact routeInbound intent-filters enumerated — no contact/vcard pick surfaces (section 2)
Native library network importsobjdump -T on all lib/arm64-v8a/*.so: only libflutter.so imports connect/getaddrinfo/socket (Flutter engine standard); libbarhopper_v3(MLKit barcode), liblibserialport_plus, jni utils have no network imports
Provenance/hashes under-documentedAlready present in this report (section 1); noted that reviewer saw only the condensed summary
"No flat BL caller" is expected for async closuresAgreed — report never concluded unreachability from that observation; kept as "not closed"
Data-flow xrefs to settle C2/C3 plaintext producerPartially done (upload builders identified); full closure requires the dynamic test (section 6)
network_security_configNot present; manifest has usesCleartextTraffic=true — added as a finding
Cross-ABI string diff (full, per-opus)Isolate-snapshot string sets extracted per ABI (artefacts/snap-strings-*.txt): mqtt.meshcore.co.uk, 8883, harvest, 1792, and a standalone 2048 are absent from all three ABIs (differences are whitespace-run boundaries and binary-data fragments)
Signing-history check (per-opus)Done: v1.0.35 APK (SHA-256 50264287e2…8422e) signed with the identical certificate 02099a2f…78c96 — signing continuity across releases; the "different build" rebuttal is closed. v1.0.35 libapp.so is a new build (SHA-256 bb1b0a5d…2a2cf8) and equally contains none of the claimed indicators (certificate match supports build-chain continuity, not behavioural equivalence)
Dynamic code loading (DexClassLoader, deferred components)DEX grep run — zero hits; APK has no deferred-component splits (single libapp.so per ABI, no libapp-*.so)

Remaining Opus suggestions not actionable statically: Firebase google_app_id/project endpoint enumeration (the app's Firebase is performance telemetry only; D2/c.java) and source-vs-binary comparison (no public source found for v1.0.31). The signing-history check was subsequently completed (see the table above).

Second-reviewer verdict (Codex route): GO for a scoped static-analysis report after the wording corrections above; unresolved paths must remain explicitly unresolved. Explicitly not GO for any claim that shared-key reuse is established, that MQTT is absent as a capability, or that the app is generally safe.

8. Reproducibility

Tool paths and everything run:

Appendix: notable pool strings near the upload path

[pp+0x114d8] String: "X-MeshOS-Key"
[pp+0x114e0] String: ""
[pp+0x114e8] String: "User-Agent"
[pp+0x114f0] String: "MeshOS-App"
[pp+0x114f8] String: "https://tiles.meshcore.co.uk/img"
[pp+0x11028] String: "Content-Type"
[pp+0x1ab68] String: "application/octet-stream"
[pp+0x1ab70] String: "X-MeshOS-Enc"
[pp+0x1ab78] String: "gcm"
[pp+0x15128] String: "https://tiles.meshcore.co.uk/hm/upload"
[pp+0x15130] String: "X-HM-Id"
[pp+0x12260] String: "meshos-topic-v1"
[pp+0x11608] String: "meshos_mqtt"