← 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
| Claim | Verdict | One-line reason |
|---|---|---|
| C1 — reads up to 2,048 contacts and 1,792 SMS bodies | CONTRADICTED | Manifest 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/img | PARTIALLY SUPPORTED / data claim contradicted | The /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 photo | INCONCLUSIVE (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:8883 | CONTRADICTED 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 code | PARTIALLY SUPPORTED; sharing REFUTED for photos | Derivation 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
| Item | Value |
|---|---|
| Download URL | https://github.com/andymux/meshos-releases/releases/download/v1.0.31/MeshOS-v1.0.31.apk (linked from meshcore.co.uk/app/) |
| Download timestamp | 2026-11-07T21:55:33Z |
| APK size | 85,179,871 bytes (byte-identical to the GitHub release asset listing) |
| APK SHA-256 | fd4970e1b1446ddb0d2985c3b54837857a970da663b212fdb3d3c1f07ac47103 |
| lib/arm64-v8a/libapp.so | 10,617,776 bytes · SHA-256 cdc8abe1011ec983a843edf7a4c773dfde5e645fefc25679d3b373e60ec83402 |
| Package | com.meshcore.meshcore_companion · versionName 1.0.31 · versionCode 5416 |
| SDK | minSdk 24, targetSdk 36, compileSdk 36 |
| Signature | APK Signature Scheme v2 only; RSA 2048 |
| Cert SHA-256 | 02099a2ff03bf4d6ed62186f115e82d2e8bb40fe9473553d5d12f23b04078c96 |
| Cert DN | CN=MeshCore, OU=MeshCore, O=MeshCore, L=Unknown, ST=Unknown, C=US |
| Dart snapshot | Dart 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:
READ_CONTACTS— not declaredREAD_SMS— not declaredRECEIVE_SMS— not declaredREAD_CALL_LOG— not declaredREAD_MEDIA_IMAGES— not declaredREAD_EXTERNAL_STORAGE— declared butmaxSdkVersion='28'only
<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.
- no
<uses-permission-sdk-23>, nosharedUserId, no - no use identified in the examined code for:
SmsManager,NotificationListener, - the only ContentResolver data flows in Java are framework/plugin internals
- inbound
<intent-filter>s: only LAUNCHER/VIEW/GET_CONTENT text — no vCard or
Why C1 cannot work on Android (stated even though analysis continued): Runtime "dangerous" permission enforcement exists since API 23, and SMS providers have refused un-declared access since API 19; minSdk 24 post-dates both. An app that has not declared READ_CONTACTS/READ_SMS in its manifest can never be granted them; any ContentResolver.query("content://sms") or ContactsContract read throws SecurityException. Side channels were also checked and are absent:
NotificationListenerService and no AccessibilityService in the manifest;
AccessibilityService, DexClassLoader, InMemoryDexClassLoader, or app-directed System.load;
(androidx FontsProvider, image-picker pick results) — no SMS/contacts URIs;
contact-picker entry points, so no permission-free, user-mediated bulk route exists either.
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)
| Indicator | Result |
|---|---|
tiles.meshcore.co.uk | present — 8 URLs (see below) |
/img | present: [pp+0x114f8] "https://tiles.meshcore.co.uk/img" |
mqtt.meshcore.co.uk | absent (0 hits, case-insensitive, all three libapp.so ABIs) |
8883 | absent (0 hits in pool; also no tcp:///wss:// broker URL anywhere) |
| MQTT topic strings | present: [pp+0x12260] "meshos-topic-v1" (single user topic) |
mqtt_client Dart package symbols | present (compiled in): mqtt_client::MQTTEncoding…, PublishAck Variable Header…, keepalive: started (60 s), MqttNormalConnection::connectAuto … |
| contact- or SMS-related | nothing: no content://sms, ContactsContract, READ_*, telephony plugin; pool contains only app-level "AirLink contact card" strings |
harvest | absent (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 identifiers | absent from the Dart pool (only obfuscated symbol suffixes @1330520481 etc., unrelated) |
| AES machinery | present: 3 × 256-entry S-box/TE tables ([pp+0x23920], [pp+0x23930], [pp+0x23938]) — the pointycastle AES Galois/CTR tables; cryptography_flutter plugin registered |
| XOR 0xB3 | absent (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 entry | Loaded at | Enclosing function (blutter) |
|---|---|---|
tiles.meshcore.co.uk/img (pp+0x114f8) | 0x4e141c, 0x4e1454, 0x4e146c, 0x9c2924 | Gzk.dart closures; yGk.dart [closure] Future<void> <anonymous closure>(dynamic, Uint8List, int) (class _tRa) |
Content-Type (pp+0x11028) | 0x96ddf0, 0x98c794, 0x9c2980 | wFk/dFk/yGk async closures — same closures as above |
tiles.meshcore.co.uk/hm/upload (pp+0x15128) | 0x98c730 | dFk.dart [closure] Future<void> <anonymous closure>(dynamic, U) (_tRa's sibling region) |
meshos-topic-v1 (pp+0x12260) | 0x4e4fa0 | FFk.dart [closure] void <anonymous closure>(dynamic, U) |
meshos_mqtt (pp+0x11608) | 0x96ed0c, 0x970980, 0x970f5c, 0x9b2fd4, 0x9b4b6c, 0x9e4498 | wFk/KIk/AGk/AHk async closures (settings/state plumbing) |
image/jpeg (pp+0x1f928) | 0x5bc3fc, 0x9e8fb8 | sok.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:
- takes a
Uint8List+ int parameter (image bytes/length signature), - sends it as
application/octet-stream, - stamps fixed headers
X-MeshOS-Key:
(hard-coded 128-bit/32-hex app key), User-Agent: MeshOS-App, X-MeshOS-Enc: gcm.
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):
MOVZ w|x, #0xB3anywhere in arm64 .text: exactly 1 (0x96bf3c, the loop above).MOV N/MOVZ/MOVKof Smi-tagged0x166or ports0x4566(8883) /0xEBA(1883)- x86_64
xor r/m, immwith0xB3: 1 real hit (the loop above) — the rest are - Pool Smi 179 / standalone
0x166: 0 hits (the only0x166-matching lines are - UTF-16LE (TwoByteString)
mqtt.meshcore.co.ukin all three ABIs: 0 hits;
adjacent to an EOR: 0 hits (arm64 and x86_64).
jump-displacement false positives.
elements of unrelated ≥512-entry int tables and blutter layout offsets).
meshcore.co.uk (no scheme) UTF-16: 1 hit, inside the string "Checking meshcore.co.uk" context; 8883: 0 hits.
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
- Whether the /img upload actually fires when a user sends a photo. The
- The exact content of the POSTed body. Headers prove an encrypted binary upload;
- What /hm/upload carries. Body is built at runtime from a runtime id and is
- MQTT runtime behaviour. The
mqtt_clientmachinery andmeshos-topic-v1exist, - The exact meaning of the XOR-0xB3-derived 32-byte constant. Its single consumer
- Dynamic permission behaviour and any behaviour hidden behind code fetched at
continuation is async and reached via Dart's async machinery; the link to the photo-send UI path was not closable statically.
whether the body is exactly the JPEG bytes, or bytes with anything additional appended, requires decryption at runtime (key [REDACTED — short form] is embedded, so it is recoverable) or a proxy capture.
text/plain; its semantics (coverage telemetry) are inferred, not proven.
but the broker address is not embedded — it must come from config/state at runtime (meshos_mqtt key, meshos_device_seen key). Static analysis cannot show where the broker string originates or whether any "kill" command is acted on.
(dispatcher _kHg(dynamic, Uint8List), wFk.dart) is statically identified, but the semantics of the byte envelope it validates — licence code, signed message, or handshake — remain undetermined.
runtime (none found statically: no dynamic code loading artefact in the dex or pool).
6. What a dynamic test would need to check
- does sending a photo trigger
POST https://tiles.meshcore.co.uk/img? - body: exactly one blob? size = compressed JPEG? decrypt with key
- does
/hm/uploadfire at the same time? What does its text/plain body contain? - any third endpoint contacted during photo send.
HttpURLConnection/DartHttpClientopenUrl(all outgoing requests),- the GCM encrypt calls of
cryptography_flutter(plaintext in, ciphertext out), ContentResolver.query(assert none executes),- the
0x9c28f4continuation and its caller to settle the photo-path question.
1. Network capture (mitm or packet capture, device with the app signed in):
(AES-256-GCM, nonce likely in-band) or just inspect entropy/structure.
2. MQTT: instrument DNS/TCP traffic to see which broker the app connects to and what messages flow on meshos-topic-v1; check whether a remote message can alter app behaviour (kill switch) — needs a controlled broker + TLS interception or Frida hooking MqttServerClient.connect. 3. Frida hooks (device-instrumented, not in this review):
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 finding | Action 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 object | Scanned 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-configured | Report now states broker appears runtime-configured; contradiction claims the specific hardcoded broker/port only |
| Permission-API history imprecise | Corrected (API 23 runtime model; API 19 SMS change) |
| NotificationListener/Accessibility/sharedUserId side channels | Manifest + dex checks run — all absent (section 2) |
| GET_CONTENT user-mediated contact route | Inbound intent-filters enumerated — no contact/vcard pick surfaces (section 2) |
| Native library network imports | objdump -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-documented | Already present in this report (section 1); noted that reviewer saw only the condensed summary |
| "No flat BL caller" is expected for async closures | Agreed — report never concluded unreachability from that observation; kept as "not closed" |
| Data-flow xrefs to settle C2/C3 plaintext producer | Partially done (upload builders identified); full closure requires the dynamic test (section 6) |
network_security_config | Not 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:
tools/jre21(Temurin 21.0.9),tools/jadx1.5.4,tools/apktool.jar2.12.1,- Raw outputs:
artefacts/(badging, manifest tree, cert, release catalog, r2 and
tools/android-bt/android-14/{aapt2,apksigner} (build-tools 34.0.0), tools/blutter-src + built binary tools/blutter-src/bin/blutter_dartvm…, tools/r2 (radare2 6.2.6 built from source, wrapper tools/r2bin), libcapstone/libicu unpacked from Ubuntu .debs into tools/icu (no system install of any package — sudo is blocked in this container; everything is portable under ~/meshos-review/tools).
blutter logs, pool-ref scanner scan_pool_refs.py, call-site dumps).
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"