# 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 declared**
- `READ_SMS` — **not declared**
- `RECEIVE_SMS` — **not declared**
- `READ_CALL_LOG` — **not declared**
- `READ_MEDIA_IMAGES` — **not declared**
- `READ_EXTERNAL_STORAGE` — declared but `maxSdkVersion='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.

**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:
- no `<uses-permission-sdk-23>`, no `sharedUserId`, no
  `NotificationListenerService` and no `AccessibilityService` in the manifest;
- no use identified in the examined code for: `SmsManager`, `NotificationListener`,
  `AccessibilityService`, `DexClassLoader`, `InMemoryDexClassLoader`, or app-directed
  `System.load`;
- the only ContentResolver data flows in Java are framework/plugin internals
  (androidx FontsProvider, image-picker pick results) — no SMS/contacts URIs;
- inbound `<intent-filter>`s: only LAUNCHER/VIEW/GET_CONTENT text — no vCard or
  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 "[REDACTED — 32 hex chars, recoverable from the APK]")
          (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: [REDACTED — 32 hex chars, recoverable from the APK]`
  (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`) = `[REDACTED — 64 hex chars, derivable from the APK]`
— 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, #0xB3` anywhere in arm64 .text: **exactly 1** (0x96bf3c, the loop above).
- `MOV N/MOVZ/MOVK` of Smi-tagged `0x166` or ports `0x4566` (8883) / `0xEBA` (1883)
  adjacent to an EOR: **0 hits** (arm64 and x86_64).
- x86_64 `xor r/m, imm` with `0xB3`: **1 real hit** (the loop above) — the rest are
  jump-displacement false positives.
- Pool Smi 179 / standalone `0x166`: **0 hits** (the only `0x166`-matching lines are
  elements of unrelated ≥512-entry int tables and blutter layout offsets).
- UTF-16LE (TwoByteString) `mqtt.meshcore.co.uk` in all three ABIs: **0 hits**;
  `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
  continuation is async and reached via Dart's async machinery; the link to the
  photo-send UI path was not closable statically.
- **The exact content of the POSTed body.** Headers prove an encrypted binary upload;
  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.
- **What /hm/upload carries.** Body is built at runtime from a runtime id and is
  `text/plain`; its semantics (coverage telemetry) are inferred, not proven.
- **MQTT runtime behaviour.** The `mqtt_client` machinery and `meshos-topic-v1` exist,
  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.
- **The exact meaning of the XOR-0xB3-derived 32-byte constant.** Its single consumer
  (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.
- **Dynamic permission behaviour** and any behaviour hidden behind code fetched at
  runtime (none found statically: no dynamic code loading artefact in the dex or pool).

## 6. What a dynamic test would need to check

1. **Network capture** (mitm or packet capture, device with the app signed in):
   - does sending a photo trigger `POST https://tiles.meshcore.co.uk/img`?
   - body: exactly one blob? size = compressed JPEG? decrypt with key
     `[REDACTED — 32 hex chars, recoverable from the APK]` (AES-256-GCM, nonce likely in-band) or just
     inspect entropy/structure.
   - does `/hm/upload` fire at the same time? What does its text/plain body contain?
   - any third endpoint contacted during photo send.
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):
   - `HttpURLConnection`/Dart `HttpClient` `openUrl` (all outgoing requests),
   - the GCM encrypt calls of `cryptography_flutter` (plaintext in, ciphertext out),
   - `ContentResolver.query` (assert none executes),
   - the `0x9c28f4` continuation and its caller to settle the photo-path question.

## 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/jadx` 1.5.4, `tools/apktool.jar` 2.12.1,
  `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`).
- Raw outputs: `artefacts/` (badging, manifest tree, cert, release catalog, r2 and
  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: "[REDACTED — 32 hex chars, recoverable from the APK]"
[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"
```
