Skip to content

[Due for payment 2026-09-28] [Sentry: APP-KDN] iOS HybridApp native crash — RCTBlobManager slices a blob past its data length #100843

Closed

Description

@mountiny

Sentry

Impact (snapshot at filing, 2026-09-10)

Sentry ID Users (last 14d) Users (total since first seen) Events First seen
APP-KDN 5 6 19 2026-08-25
  • Last seen: 2026-09-10 (ongoing)
  • Platform: iOS HybridApp (com.expensify.expensifylite)
  • App version(s): 9.4.68-1, 9.4.70-0, 9.4.71-0, 9.4.72-0, 9.4.73-2, 9.4.75-0 — present on every recent build
  • Mechanism: nsexception, level fatal — this is a hard native crash, not a caught error
  • Thread: crashes on a _dispatch_workloop_worker_thread, off the main thread
  • session.uptime_bucket: 0-30s on the sampled event, so it fires early in the session
  • Device on the sampled event: iPhone 12 mini, iOS 16.7, lowMemoryThreat: true

Error

NSRangeException: *** -[NSConcreteMutableData subdataWithRange:]: range {0, 321} exceeds data length 318

Stack trace (top frames)

__exceptionPreprocess
objc_exception_throw
-[NSData(NSData) subdataWithRange:]
-[RCTBlobManager resolve:offset:size:] (RCTBlobManager.mm:115)
-[RCTBlobManager resolve:] (RCTBlobManager.mm:101)
-[RCTBlobManager createFromParts:withId:] (RCTBlobManager.mm:199)
facebook::react::ObjCTurboModule::performVoidMethodInvocation (RCTTurboModule.mm:461)
_dispatch_call_block_and_release

Suspected cause

RCTBlobManager stores blob data in a dictionary keyed by blob ID and slices it on demand. resolve:offset:size: calls subdataWithRange: using the offset and size recorded in the blob descriptor, without checking them against the length of the data actually held.

The numbers in the message say the descriptor and the stored data disagree by 3 bytes: the JS side asks for 321 bytes but only 318 are present. Two plausible sources:

  1. The stored NSMutableData was truncated or partially written, so the recorded size is stale.
  2. A byte-length versus character-length mismatch when a string is turned into a blob — a multi-byte UTF-8 sequence counted as characters would produce exactly this kind of small shortfall.

createFromParts:withId: is on the stack, so the crash happens while assembling a multi-part blob, which is what new Blob([...]) and FormData uploads go through. The most likely trigger in App is an attachment or receipt upload.

Because this is an NSRangeException on a background dispatch queue, there is no JS frame to catch it and the app terminates.

Reproduction

Unknown as an exact click path — 19 events across 6 users, so it is timing or content dependent rather than a deterministic screen. Starting points:

  1. On iOS HybridApp, upload a receipt or attachment shortly after launch, while the app is still hydrating.
  2. Try content that is not plain ASCII — a file name or text part containing multi-byte UTF-8 characters.
  3. Watch for the crash under memory pressure; the sampled event had lowMemoryThreat: true.

Once reproduced, the fix is to clamp the range in resolve:offset:size: against the real data length and log rather than throw when the descriptor disagrees. That belongs upstream in react-native, so the immediate options are a patch file or a version bump once fixed upstream.

Related

Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. self-assigned this
    on Sep 10, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Sep 10, 2026
  3. melvin-bot commented on Sep 10, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @daledah (External)

  4. melvin-bot commented on Sep 10, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  5. MelvinBot commented on Sep 10, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    RCTBlobManager trusts the blob descriptor that JS sends it and slices the stored NSData without ever range-checking it.

    RCTBlobManager.mm:104-118 guards only against a missing blob. If the blob exists, line 115 calls subdataWithRange: with the JS-supplied offset/size and no comparison against data.length. Here the descriptor said 321 bytes, the dictionary held 318, and NSData raised NSRangeException.

    The descriptor and the stored bytes can legitimately disagree, because nothing reconciles them:

    • JS computes size on its own in BlobManager.js:87-97 (global.unescape(encodeURI(part)).length for string parts), then hands native the parts separately.
    • Native assembles them in createFromParts:withId: and adds no bytes at all when a part fails: a blob part that can't be resolved returns nil at line 199, and a string whose dataUsingEncoding:NSUTF8StringEncoding returns nil does the same at line 202. Both are silent — no log, no error.

    So an earlier new Blob([...]) produced a blob recorded as 321 bytes but stored as 318. The crash happens later, when that short blob is used as a part of another blob: the stack frames map exactly to line 199 → line 101 → line 115.

    This is iOS-only for a concrete reason: Android's BlobModule.kt:183-198 uses Arrays.copyOfRange, which zero-pads when the requested end is past the array. Same desync, no crash. And because createFromParts runs on a _dispatch_workloop_worker_thread with no JS frame on the stack, the NSRangeException is unrecoverable.

    Evidence, and what I could not confirm

    Confirmed: upstream main still has the unguarded subdataWithRange: — this is not fixed in a later react-native version, so a version bump will not help.

    Not confirmed: the exact 3-byte shortfall. Sentry gives 19 events across 6 users with no reproduction path, and the descriptor/data desync is created at blob creation time while the crash surfaces at consumption time, so the crashing stack cannot name the culprit. Candidate sources I considered:

    1. A string part where the JS UTF-8 byte count disagrees with [NSString dataUsingEncoding:] — for example a JS→NSString conversion that mangles an astral character. A 3-byte gap is the size of one UTF-8 BMP character or one U+FFFD.
    2. A nested blob part that had already been released, contributing 0 bytes.
    3. Blob.js slice() does not handle a negative start: it does offset += start; size -= start, producing a negative offset and an oversized size. Ruled out for this event — the crash reports offset 0.

    I deliberately did not try to pin this down further, because the fix below does not depend on which one it is, and adding a log makes the next occurrence self-diagnosing.

    On the App side: nothing in src/ calls new Blob([blob]) on a native path — DownloadUtils.ts:82 is reached only through the web index.ts. The new Blob([...]) calls are most likely coming from whatwg-fetch's Response.blob(), which react-native's fetch uses. That means this cannot be fixed in App JS.

    What changes do you think we should make in order to solve the problem?

    Add a patch-package patch for react-native and send the same change upstream. App already patches this exact module — see react-native+0.86.0+030+fix-nil-BlobModule-crash-APP-8BM.patch — so this follows an established pattern.

    Two changes in RCTBlobManager.mm:

    1. In resolve:offset:size:, clamp the range to the data that is actually there. Return nil for a nonsensical offset, and otherwise slice at most data.length - offset bytes, matching Android's forgiving behaviour. This turns a hard crash into a short read.
    2. Report the desync instead of swallowing it. In createFromParts:withId:, and in the clamp above, call RCTLogError when a part resolves to nil or when the requested size exceeds what is stored, including the blob ID and both lengths. Right now the mismatch is invisible until it kills the app; this is what will let us find the real producer.

    Add the matching entry to patches/react-native/details.md and link the upstream PR there.

    What alternative solutions did you explore? (Optional)

    • Bump react-native. Does not work — upstream main is still unguarded.
    • Fix the producer in App JS. No native App code path creates these blobs, so there is nothing to fix at that layer.
    • Fix the JS size computation in BlobManager.js. Would address one possible source, but native would still crash on every other desync, and we do not yet know which source this is. The native clamp is the change that actually stops the crash.

    Related


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/100843) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run · view recording

  6. mountiny commented on Sep 11, 2026

    @mountiny
    ContributorAuthor

    @daledah can you review the proposla from melvin and open this to community if not good enough? thanks

  7. daledah commented on Sep 12, 2026

    @daledah
    Contributor

    I will look into this issue early next week

  8. daledah commented on Sep 14, 2026

    @daledah
    Contributor

    Thanks @MelvinBot. The clamp is the right instinct and I want it in the fix, but the proposal has three factual errors and stops one layer short of the real cause. I reproduced this deterministically on an iOS simulator (RN 0.86.0, New Arch), so we no longer have to guess at the producer.

    Root cause

    RCTTurboModule.mm:113 converts every JSI string argument with:

    return [NSString stringWithUTF8String:value.utf8(runtime).c_str()];

    value.utf8(runtime) knows its own length, but .c_str() throws that away, so stringWithUTF8String: re-derives it with strlen and stops at the first NUL byte. Meanwhile BlobManager.js counts the whole string. Descriptor and stored bytes then disagree by exactly "the NUL plus everything after it".

    Measured on device - all cases built to the same JS byte count of 318, so the only variable is what native stored:

    String descriptor native stored desync
    A×318 318 318 0
    A×315 + \0 + BC 318 315 3
    A×317 + \0 318 317 1
    \0 + A×317 318 0 318
    A×316/315/314 + é / 中 / 😀 318 318 0

    desync equals the byte count from the NUL onward, exactly. This disproves suspected cause 2 in the issue body - multi-byte UTF-8 round-trips fine, emoji included. A NUL does not, and a NUL followed by two characters gives exactly the 3-byte APP-KDN shortfall.

    I read the true stored length without tripping the range check by resolving a {offset: 0, size: -1} descriptor (which skips subdataWithRange: entirely) and base64-measuring it via readAsDataURL.

    Feeding that 3-byte-short blob into new File([blob], 'receipt.jpg') then crashes, on queue com.meta.react.turbomodulemanager.queue:

    -[RCTBlobManager resolve:offset:size:] + 400
    -[RCTBlobManager resolve:] + 552
    -[RCTBlobManager createFromParts:withId:] + 540
    facebook::react::ObjCTurboModule::performVoidMethodInvocation(...)
    _dispatch_workloop_worker_thread
    

    Same frames as APP-KDN.

    The fix

    Layer 1 - the actual cause:

    std::string utf8 = value.utf8(runtime);
    return [[NSString alloc] initWithBytes:utf8.data() length:utf8.size() encoding:NSUTF8StringEncoding];

    Preserves embedded NULs and drops a strlen from a hot path. Broad change - every TurboModule string argument on iOS - so it needs a careful upstream PR and a blast-radius note, but truncating user data at a NUL is a bug, not a contract.

    Layer 2 - defence in depth, so any future desync degrades instead of killing the app:

    const NSInteger length = (NSInteger)data.length;
    if (offset < 0 || offset > length) {
      RCTLogWarn(@"[BlobManager] blob %@: offset %ld out of bounds for %ld stored bytes",
                 blobId, (long)offset, (long)length);
      return nil;
    }
    const NSInteger available = length - offset;
    if (size < 0 || size > available) {
      if (size > available) {
        RCTLogWarn(@"[BlobManager] blob %@: asked for %ld bytes at offset %ld, only %ld stored; truncating",
                   blobId, (long)size, (long)offset, (long)available);
      }
      size = available;
    }
    if (offset != 0 || size != length) {
      data = [data subdataWithRange:NSMakeRange((NSUInteger)offset, (NSUInteger)size)];
    }

    size < 0 preserves the existing "-1 means the whole blob" contract. Add a nil guard with a log in createFromParts:withId: too, where a failed part currently contributes 0 bytes with no signal.

    Use RCTLogWarn, not RCTLogError - this runs on a background queue and RCTLogError red-boxes on every occurrence in dev. For Sentry visibility, route it like patch 038.

    Trade-off the proposal doesn't state: layer 2 alone converts this crash into silent data loss - with a leading NUL, a 0-byte attachment that uploads as if it succeeded (measured above, not hypothetical). That's why layer 1 is needed.

    Still open: what puts a NUL into an App string in production. Best lead is whatwg-fetch's Response.blob() doing new Blob([this._bodyText]) - binary content read as text would carry NULs. Doesn't block the fix.

    Next steps

    Both patches go in patches/react-native/, with entries in details.md linking the upstream PRs.

    @MelvinBot implement this

  9. MelvinBot commented on Sep 14, 2026

    @MelvinBot
    Contributor

    Implemented both layers exactly as specified. Draft PR: #101062

    What's in it — two patches in patches/react-native/, both documented in details.md:

    • react-native+0.86.0+042+fix-jsi-string-truncated-at-nul-APP-KDN.patch — convertJSIStringToNSString now builds the NSString with initWithBytes:length:encoding: from the byte length the jsi::String already knows, instead of letting stringWithUTF8String: re-derive it with strlen and stop at the first NUL.
    • react-native+0.86.0+043+clamp-blob-range-APP-KDN.patch — your clamp, verbatim, in resolve:offset:size:, with size < 0 preserving the "-1 means the whole blob" contract, plus nil guards with logs in createFromParts:withId: for both the unresolvable-blob-part and the unencodable-string-part cases. RCTLogWarn throughout, and #import <React/RCTLog.h> added since RCTBlobManager.mm didn't pull it in transitively.

    What I verified

    • ./scripts/validatePatches.sh passes.
    • patch-package applies the full react-native sequence 001–043 cleanly against RN 0.86.0, with 042 and 043 applying last. I generated the patches by hand (this environment has no network for patch-package --append), so I round-tripped them through git apply -R / git apply first and then confirmed through patch-package itself — the path that actually runs on npm install.
    • npm run lint-changed and npm run spell-changed are no-ops here; nothing outside patches/ changed.

    I did not run an App test pass. These are iOS native patches — nothing in them is reachable from a web or Android build, so a smoke test there would be evidence about something else. Verification needs an iOS build, and I left the Tests and QA sections for you.

    Two things to decide before this ships

    1. Sentry routing. I used RCTLogWarn, as you specified. I did not wire it to Sentry: patch 038's mechanism is ReactSoftExceptionLogger, which is Android-only, and I have not confirmed that a native RCTLogWarn reaches Sentry on iOS in a production build. consoleIntegration is enabled in src/setup/telemetry/setupSentry.ts:37-43, but the native→JS log forwarding path is what I couldn't confirm, so I'd rather flag it than claim it works. If you want guaranteed visibility, that needs a separate call on the mechanism.
    2. Upstream PRs. Neither patch has one yet. Both details.md entries say so explicitly rather than carrying a 🛑 with no context — patch 042 in particular changes the conversion for every TurboModule string argument on iOS, and the entry carries the blast-radius note for upstream review.

    Next Steps: Mark #101062 "Ready for review" once you've filled in the Tests and QA sections, or reply with @MelvinBot <feedback> to change the implementation.

  10. 10 remaining items

  11. daledah commented on Sep 22, 2026

    @daledah
    Contributor

    BugZero Checklist:

    • [Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.

      Link to the comment on the PR: N/A

    • [Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.

      Link to discussion:

    • [Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.

    Regression Test Proposal

    Test:

    1. On iOS, open any chat and send a plain text message. Verify it sends and displays correctly.
    2. Send a message containing non-ASCII characters and emoji (e.g. Hello 中Ω 😀). Close and reopen the chat. Verify the text still displays correctly with no missing or mangled characters.
    3. Attach an image from the photo library to a chat message. Verify it uploads and the preview renders.
    4. Repeat steps 1-3 on Android. Patch 042 now also changes the Android TurboModule string conversion, so verify emoji and other 4-byte characters round-trip correctly there too.
    5. On both platforms, verify text wrapping and line breaks look unchanged in a long multi-line message, since the patch also touches Fabric text measurement.

    Do we agree 👍 or 👎

    Zapier Logs Run ID: 00040eee-9df0-aef5-ef75-63d405aed414
  12. mountiny commented on Sep 22, 2026

    @mountiny
    ContributorAuthor

    @MelvinBot is this crash resolved in the versions that included this fix?

  13. MelvinBot commented on Sep 22, 2026

    @MelvinBot
    Contributor

    Short answer: I can't confirm it from Sentry — the Sentry MCP server isn't connected in this run, so I have no access to APP-KDN's event stream. What I can confirm from the code and the release history is that the crash signature is unreachable on any build that carries the fix, and that only production 9.4.90-2 and later qualify.

    Which versions actually have it

    7745d4e (#101062) merged 2026-09-18 10:21 UTC. I checked ancestry against every tag since:

    First tag containing the fix Published
    Staging 9.4.90-0-staging 2026-09-18 20:18 UTC
    Production 9.4.90-2 2026-09-21 18:38 UTC

    9.4.85-0, 9.4.88-0, 9.4.89-0 and every 9.4.84-*/9.4.86-0 staging build do not contain it. So when you look at APP-KDN, filter on release — anything tagged 9.4.89-0 or below is expected and is not a regression.

    Why the signature can't recur on 9.4.90-2+

    Patch 043 clamps the range before the slice, so subdataWithRange: can no longer be called with an out-of-range range at all — see react-native+0.86.0+043+clamp-blob-range-APP-KDN.patch:20-43. An out-of-bounds offset returns nil; an oversized size truncates to what is stored. That holds regardless of what caused the desync, so a new NSRangeException on this stack would mean the patch didn't reach the build, not that the fix is wrong.

    Two things that make "zero events" weaker evidence than it looks

    1. Not enough time or adoption yet. 9.4.90-2 has been in production ~1 day, and iOS users update over days. At the historic rate (19 events / 6 users over 16 days ≈ 1.2/day fleet-wide), a quiet first week is consistent with the fix working and with nobody having updated yet. Judge it at the end of the regression period, on 9.4.90-2+ only.
    2. Patch 043 turns the crash into silent data loss. That is by design, and patch 042 fixes the producer — but the open question from the investigation was never closed: what puts a NUL byte into an App string in production (best lead: whatwg-fetch's Response.blob() on binary content read as text). If 042 missed a path, you'd now see a truncated or 0-byte attachment upload instead of a crash. Worth watching attachment/receipt upload failures alongside the crash count, not just the crash count.

    What to check when you have Sentry open

    Query APP-KDN for events where release ≥ 9.4.90-2 and os = iOS. Also grep iOS logs for the new [BlobManager] blob ...; truncating warning from patch 043 — that fires whenever a desync still happens, and its presence would mean 042 didn't fully fix the producer even if nothing crashes.

    I couldn't verify any of this in a running app either — browser control was unavailable in this run, and these are iOS native patches, so a web build wouldn't exercise them regardless. Verification needs an iOS build plus Sentry.


    view run

  14. melvin-bot commented on Sep 22, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  15. mountiny commented on Sep 22, 2026

    @mountiny
    ContributorAuthor

    Seems like we ready for payment here

  16. melvin-bot commented on Sep 27, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
    • [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
  17. mallenexpensify commented on Sep 28, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @daledah due $250 via NewDot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions