Skip to content

fix: harden against adversarial mesh-fuzz findings (crash, GC-thrash, unbounded growth, OOM)#6093

Merged
jamesarich merged 7 commits into
mainfrom
fix/adversarial-mesh-fuzz-hardening
Jul 5, 2026
Merged

fix: harden against adversarial mesh-fuzz findings (crash, GC-thrash, unbounded growth, OOM)#6093
jamesarich merged 7 commits into
mainfrom
fix/adversarial-mesh-fuzz-hardening

Conversation

@jamesarich

@jamesarich jamesarich commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Found and fixed 4 real bugs by adversarially testing the app against a live replay-fuzz harness (via meshtastic-mcp's replay engine) on a physical Pixel 6a — corrupted/malformed packets, connection churn, and large-mesh scale, escalating through lightparseradversarychaos fuzz presets. All four are reproduced live and each has a fix + regression test (or a live before/after re-run where a unit test wasn't practical).

🐛 Bug Fixes

  • Crash on a malformed buffered early packet. flushEarlyReceivedPackets() replayed buffered packets in a bare scope.launch, unlike the live receive path which is guarded by safeCatching. A single corrupted NodeInfo/User packet arriving before the node DB / local node number resolve threw an uncaught ProtocolException/EOFException and crashed the whole process. Reproduced twice with a seeded chaos fuzz run. Fixed by wrapping each buffered packet the same way the live path already is.
  • GC-thrash risk in the Debug Panel. DebugViewModel.meshLog re-decoded up to 5000 log rows — including proto toString() and a full node-DB snapshot per row — on every single packet insert, via Room's auto-invalidating Flow + mapLatest. Under a packet flood this can restart faster than it completes, generating a firehose of garbage (matches a prior live incident where a 70-minute soak pinned the Dalvik heap at 98.7%). Fixed with a 200ms debounce and by hoisting the per-row node-list snapshot out of the loop.
  • Unbounded FileInfo accumulator. RadioConfigRepositoryImpl.addFileInfo() did an unconditional += info with no cap; clearFileManifest() only runs on the next handshake, so a rogue peer emitting FileInfo packets in a loop grows it without limit for the life of a session. Capped at 4096 entries, same defensive pattern as the existing early-packet buffer.
  • OutOfMemoryError under combined large-mesh + adversarial load. Reproduced live: a 2000-node NodeDB (the app's own largest push_fake_nodedb fixture — large public-event meshes like MeshCon/DEF CON/Burning Man are a supported scenario) + sustained chaos fuzz + rapid reconnects in one process exhausted the default ~256MB heap. Added android:largeHeap="true"; re-ran the identical crash-inducing sequence post-fix and it survived cleanly at the new 512MB ceiling.

🧹 Chores

  • Added LeakCanary (stable 2.14, debug-only) for continuous leak detection on future soak/fuzz runs.

Testing Performed

  • New/updated unit tests: MeshMessageProcessorImplTest (regression test verified to fail without the fix, pass with it), RadioConfigRepositoryImplTest (new file, cap + handshake-clear behavior).
  • ./gradlew spotlessCheck detekt clean across the whole repo.
  • ./gradlew :core:data:jvmTest :feature:settings:jvmTest pass.
  • Live device re-verification on a Pixel 6a for all four fixes: re-ran the exact seeded fuzz campaigns that originally triggered each bug and confirmed stability (no crash, flat memory, clean reconnect) post-fix.

Summary by CodeRabbit

  • New Features

    • Added debug-only leak detection tooling for development and testing.
    • Enabled Android large-heap mode to reduce out-of-memory risk during heavy stress.
  • Bug Fixes

    • Prevented errors during replay of buffered “early” mesh packets from stopping processing of the rest.
    • Capped session file manifest growth and safely drops excess entries.
    • Improved the mesh log debug screen responsiveness by debouncing and reducing repeated annotation work.
  • Tests

    • Added regression and repository tests covering early-packet flush resilience and file manifest capping/concurrency/clearing.

Adversarial mesh-fuzz testing (a hostile replay session sending corrupted
NodeInfo/User protos) found that a single malformed packet buffered before
the node DB / local node number are ready could crash the entire app.

flushEarlyReceivedPackets() replays buffered packets in a bare scope.launch,
unlike the live receive path (processFromRadio) which is already guarded by
safeCatching. A packet that fails deep inside handleReceivedData ->
handleNodeInfo's proto decode (ProtocolException / EOFException) threw out
of that coroutine uncaught, crashing the process (observed live: FATAL
EXCEPTION at MeshMessageProcessorImpl.kt:214, "Fdroid Debug keeps
stopping").

Wrap each buffered packet in the same safeCatching pattern already used in
processFromRadio, so one bad packet is logged and dropped instead of taking
down the rest of the batch (and the app). Added a regression test that
fails without the fix and passes with it.
…et flood

DebugViewModel.meshLog re-decoded up to DEFAULT_MAX_LOGS (5000) rows --
including proto toString() and a full node-DB snapshot per row -- on every
single mesh_log table insert, via Room's auto-invalidating Flow + mapLatest.
Under a packet flood this can restart faster than it completes, generating
a firehose of short-lived garbage that starves the GC (matches a live
incident where a 70-minute adversarial soak pinned the Dalvik heap at
98.7% with the main thread stuck in WaitForGcToComplete loops).

- debounce the decode pipeline (200ms) so a flood can't retrigger a full
  re-decode faster than the previous one can complete.
- hoist the per-row nodeDBbyNum.values.toList() snapshot out of the loop in
  toUiState/annotatePacketLog -- it was rebuilt once per row instead of once
  per decode pass, an O(rows * nodes) hotspot that compounded under both a
  packet flood and a large mesh (see push_fake_nodedb's 2000-node fixture).

Verified live post-fix: re-ran the same seeded chaos-fuzz campaign that
previously showed heap climbing with the Debug Panel open for 200+ seconds
with stable memory and no crash.
…bounded

RadioConfigRepositoryImpl.addFileInfo() did an unconditional
_fileManifestFlow.value += info. clearFileManifest() only runs at the start
of the *next* handshake, so nothing else bounded this accumulator in
between -- a rogue/adversarial peer emitting FileInfo packets in a loop for
the life of a session grows it without limit.

A real device's manifest is bounded by its own flash storage (realistically
a handful to a few hundred entries), so this only bites under a malformed/
hostile stream, but it's a real unbounded-memory-growth vector for a
long-running session. Cap it at 4096 entries, same defensive pattern as the
early-packet buffer in MeshMessageProcessorImpl, and add tests covering the
cap and the handshake-clear behavior.
…ions

Reproduced a genuine OutOfMemoryError live: a 2000-node NodeDB
(push_fake_nodedb's own largest fixture -- large public-event meshes like
MeshCon/DEF CON/Burning Man are a supported scenario, not just an
adversarial edge case) combined with sustained chaos-fuzz traffic and
repeated reconnects in one long-lived process exhausted the default
~256MB Dalvik heap. The crash surfaced on an unrelated DataStore settings
read (androidx.datastore.core.DataStoreImpl.readAndInitOrPropagateAndThrowFailure)
-- any allocation would have thrown at that point, the heap was simply full.

android:largeHeap raises the ceiling (device-dependent, commonly 2-4x)
rather than reducing footprint; the debounce/cap fixes in this branch are
the real long-term mitigation. Verified live: re-ran the identical
crash-inducing sequence (2000-node chaos fuzz + 3 rapid reconnect cycles)
against the same device post-fix (confirmed dalvik.vm.heapsize=512m active)
and it survived cleanly where it previously OOM'd.
Adds continuous, automatic leak detection (heap dump + leak trace +
notification) for future soak/fuzz runs instead of manually polling
dumpsys meminfo. Debug-only (0 method count in release, per LeakCanary's
own design). Pinned to the latest stable release (2.14), not the 3.0-alpha
line. Confirmed it initializes correctly on-device ("LeakCanary is running
and ready to detect memory leaks") and stayed clean across an extended
adversarial soak.
@github-actions github-actions Bot added the bugfix PR tag label Jul 5, 2026
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c2b2c583-f0d2-492d-941b-a8416ff55157

📥 Commits

Reviewing files that changed from the base of the PR and between 23f066b and dcf758f.

📒 Files selected for processing (1)
  • androidApp/src/main/AndroidManifest.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • androidApp/src/main/AndroidManifest.xml

📝 Walkthrough

Walkthrough

Adds debug-only LeakCanary and a largeHeap manifest flag, hardens buffered mesh packet replay, caps file-manifest growth, and debounces debug log decoding while reusing precomputed node data.

Changes

Diagnostics, resilience and debug log throttling

Layer / File(s) Summary
LeakCanary debug tooling and large heap flag
gradle/libs.versions.toml, androidApp/build.gradle.kts, androidApp/src/main/AndroidManifest.xml
Adds a debug-only leakcanary-android dependency and enables android:largeHeap="true" with explanatory comments.
Per-packet error containment in buffered replay
core/data/src/commonMain/.../MeshMessageProcessorImpl.kt, core/data/src/commonTest/.../MeshMessageProcessorImplTest.kt
Wraps each buffered packet replay in safeCatching so one failing packet does not stop the rest, and adds a regression test for continued processing.
Bounded file manifest accumulator
core/data/src/commonMain/.../RadioConfigRepositoryImpl.kt, core/data/src/commonTest/.../RadioConfigRepositoryImplTest.kt
Caps _fileManifestFlow at MAX_FILE_MANIFEST_ENTRIES, logs when the cap is reached, and adds tests for capping, concurrency, and reset behavior.
Debounced debug log decode pipeline
feature/settings/src/commonMain/.../DebugViewModel.kt
Debounces getAllLogs() before decode and reuses precomputed node data in log annotation helpers.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the PR’s main goal: hardening against fuzz findings across crash, GC thrash, unbounded growth, and OOM issues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/adversarial-mesh-fuzz-hardening

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
androidApp/src/main/AndroidManifest.xml (1)

121-136: 🩺 Stability & Availability | 🔵 Trivial

largeHeap raises GC pause risk — confirm this is truly a stopgap.

The comment is thorough and self-aware about the tradeoff, but note that enabling largeHeap increases the working set the GC scans each cycle, which can increase pause times/jank across the whole app, not just the fuzz/reconnect path — this could be a net regression for typical (non-fuzz) sessions on low-memory devices. Since the comment says the "real fix" (bounding/streaming caches) is still pending, consider tracking removal of this flag as a follow-up once that work lands, and validate on low-end/low-memory devices, not just the flagship test device.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@androidApp/src/main/AndroidManifest.xml` around lines 121 - 136, The
AndroidManifest’s largeHeap setting should be treated explicitly as a temporary
stopgap rather than a permanent fix. Update the manifest comment around
android:largeHeap in AndroidManifest to state that removal is a follow-up once
the cache bounding/streaming work in DebugViewModel and MeshMessageProcessorImpl
lands, and make sure the change is validated on low-memory devices as well as
the current soak target. If there is a tracking task or TODO mechanism in this
codebase, link this flag to that removal plan so the tradeoff is visible and
actionable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt`:
- Around line 131-143: The addFileInfo update path is racy because it reads and
writes _fileManifestFlow.value without serialization, so concurrent calls from
scope.handledLaunch can overwrite each other and bypass the
MAX_FILE_MANIFEST_ENTRIES cap. Fix RadioConfigRepositoryImpl.addFileInfo by
making the manifest update atomic, either with MutableStateFlow.update or by
guarding the read/append/cap logic with a Mutex, while keeping the existing cap
and log behavior intact.

---

Nitpick comments:
In `@androidApp/src/main/AndroidManifest.xml`:
- Around line 121-136: The AndroidManifest’s largeHeap setting should be treated
explicitly as a temporary stopgap rather than a permanent fix. Update the
manifest comment around android:largeHeap in AndroidManifest to state that
removal is a follow-up once the cache bounding/streaming work in DebugViewModel
and MeshMessageProcessorImpl lands, and make sure the change is validated on
low-memory devices as well as the current soak target. If there is a tracking
task or TODO mechanism in this codebase, link this flag to that removal plan so
the tradeoff is visible and actionable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 36d53ca1-7c75-43f8-8992-a9372fba562b

📥 Commits

Reviewing files that changed from the base of the PR and between 034005b and 211d81b.

📒 Files selected for processing (8)
  • androidApp/build.gradle.kts
  • androidApp/src/main/AndroidManifest.xml
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImplTest.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
  • gradle/libs.versions.toml

@jamesarich jamesarich enabled auto-merge July 5, 2026 19:24
@jamesarich jamesarich added this pull request to the merge queue Jul 5, 2026
@jamesarich jamesarich removed this pull request from the merge queue due to a manual request Jul 5, 2026
Review feedback on #6093:

- addFileInfo did a non-atomic read-then-write on _fileManifestFlow. Since
  handleFileInfo dispatches each packet on its own scope.handledLaunch,
  concurrent calls could race -- lost updates, or two callers both seeing a
  sub-cap size and bypassing MAX_FILE_MANIFEST_ENTRIES. Switched to
  MutableStateFlow.update{} (atomic CAS loop). Added a concurrency regression
  test (fire 2000 concurrent adds, assert none are lost); verified it fails
  against the old read-then-write and passes with update{}.

- largeHeap: expanded the manifest comment to spell out that it's a temporary,
  whole-app stopgap with a global cost (larger GC working set -> more pause/jank
  for ALL sessions, including small-mesh use on low-RAM devices that never hit
  the OOM it guards), and that removal is a tracked follow-up once the
  cache-bounding work lands -- to be validated on a low-RAM device, not just the
  flagship soak target.
@jamesarich

Copy link
Copy Markdown
Collaborator Author

Both addressed in 23f066b:

Race in addFileInfo (actionable): Good catch — handleFileInfo dispatches each packet on its own scope.handledLaunch, so the read-then-write genuinely races. Switched to MutableStateFlow.update {} (atomic CAS loop) so the read/cap/append is atomic. Added a concurrency regression test that fires 2000 concurrent addFileInfo calls on Dispatchers.Default and asserts none are lost — verified it fails against the old read-then-write and passes with update {}.

largeHeap stopgap (nitpick): Agreed on the global GC-jank tradeoff. Expanded the comment to spell out that it's a temporary, whole-app flag with a global cost (larger GC working set → more pause/jank for all sessions, including small-mesh use on low-RAM devices that never approach the OOM it guards), and that removal is a tracked follow-up once the cache-bounding work lands — to be validated on a low-RAM device, not just the flagship soak target.

The largeHeap comment expansion in the previous commit introduced a literal
"--" inside the XML comment ("for ALL sessions -- including..."), which is
illegal in XML and broke manifest parsing -- failing every androidApp-dependent
CI check (android-check, lint-check, shard-app) while core modules passed.
Reworded to avoid the double-dash. Verified locally with
:androidApp:processFdroidDebugMainManifest and :androidApp:testFdroidDebugUnitTest.
@jamesarich jamesarich added this pull request to the merge queue Jul 5, 2026
Merged via the queue into main with commit 005ad1a Jul 5, 2026
17 checks passed
@jamesarich jamesarich deleted the fix/adversarial-mesh-fuzz-hardening branch July 5, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant