fix: use-after-free in react-native-screens removal listener (Sentry: APP-9Y9) - #98632
Conversation
react-native-screens created its screen-removal listener with an unsynchronized lazy init in NativeProxy::nativeAddMutationsListener. On a cold launch two threads reach that function concurrently, because ScreensModule.initialize() calls setupFabric() on the module thread while onHostResume() dispatches the same call to the main thread. Both can pass the null check, and the racing shared_ptr assignments tear: libc++ moves the object pointer and the control block as two independent words, so the member can end up holding one thread's pointer beside the other thread's control block while the losing temporary frees the listener it points at. The listener is registered as a MountingCoordinator mounting override delegate, and that list is append-only - react-native core has no unregister API. The torn slot reports use_count=1, expired=0 forever, so weak_ptr::lock() keeps succeeding and the next pullTransaction virtual dispatches through a recycled vtable slot. That is the Android SIGSEGV in MountingCoordinator::pullTransaction reported in Expensify#93842. A listener destroyed normally through its own control block is harmless, since it reports expired=1 and core null-checks that path. Make the listener a process-lifetime singleton - a function-local static, so initialization is thread-safe and the entry in core's non-removable delegate list can never dangle. It now holds a mutex-guarded swappable callback that captures the JNI global reference by value instead of this, so it cannot dereference a finalized NativeProxy. setListener returns an ownership token and invalidateNative() clears the callback only while it still owns it, so a stale proxy's late teardown cannot disarm the callback a newer proxy installed. A disarmed listener passes the transaction through untouched. Backport of software-mansion/react-native-screens#4413, merged upstream as b3badd012f83679b12f4e29f2e28eceaa4830efd. Not in a react-native-screens release yet, so it ships here as a patch-package patch.
|
|
|
All contributors have signed the CLA ✍️ ✅ |
The field was left as a placeholder because the PR number was not known when the patch was created. PATCHES.md requires it so that a future dependency bump can find the PR that introduced the patch and decide whether it is still needed.
|
I have read the CLA Document and I hereby sign the CLA |
|
Re: the HybridApp warning above — this PR adds a single The patched code is Android-only native C++ ( Verified on a standalone Android build: the patch applies cleanly via I haven't been able to verify the HybridApp/Mobile-Expensify variant: the |
|
recheck |
|
@linhvovan29546 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
@mbSmaga This workflow is failing. Could you please merge |
…oval-listener-uaf
|
@linhvovan29546 Merged main (e0af838). The CLA check re-ran on the new head and still fails, but the error changed. Before: Now (run): |
|
Testing... |
This comment was marked as outdated.
This comment was marked as outdated.
Reviewer Checklist
Screenshots/VideosAndroid: HybridApptelegram-cloud-document-5-6061894507744471277.1.mp4Android: mWeb ChromeN/A the patch apply for android native only iOS: HybridAppN/A. The patch only applies to Android native code. iOS: mWeb SafariN/A. The patch only applies to Android native code. MacOS: Chrome / SafariN/A. The patch only applies to Android native code. |
NAB: The CLA check failed because the required workflow Verify peer review, Verify peer review was not satisfied. |
linhvovan29546
left a comment
There was a problem hiding this comment.
@mountiny Could you please trigger the Android ad hoc build? I’d like to test it on the ad hoc build as well.
|
ran it the cla should work now |
|
🚧 mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
mountiny
left a comment
There was a problem hiding this comment.
Thanks for adding this!
|
🚧 mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
Explanation of Change
Adds a
patches/react-native-screens/patch fixing an unsynchronized lazy init inNativeProxy::nativeAddMutationsListener, which is behind the AndroidSIGSEGVinfacebook::react::MountingCoordinator::pullTransactionon threadmqt_v_js(Sentry APP-9Y9).On a cold launch two threads reach
nativeAddMutationsListenerconcurrently:ScreensModule.initialize()callssetupFabric()on the module thread whileonHostResume()dispatches the same call to the main thread. Both can pass theif (!screenRemovalListener_)null check, and the racingshared_ptrassignments tear — libc++ moves the object pointer and the control block as two independent words, so the member can end up holding one thread's pointer beside the other thread's control block while the losing temporary frees the listener it points at.The listener is registered as a
MountingCoordinatormounting override delegate, and that list is append-only (react-native core has no unregister API). The torn slot reportsuse_count=1, expired=0forever, soweak_ptr::lock()keeps succeeding and the nextpullTransactionvirtual-dispatches through a recycled vtable slot. A listener destroyed normally through its own control block is harmless — it reportsexpired=1and core null-checks that path — which is what identifies this as the init race rather than a teardown bug.The patch makes the listener a process-lifetime singleton (a function-local static, so initialization is thread-safe and the entry in core's non-removable delegate list can never dangle). It holds a mutex-guarded swappable callback capturing the JNI global reference by value instead of
this, so it cannot dereference a finalizedNativeProxy.setListenerreturns an ownership token andinvalidateNative()clears the callback only while it still owns it, so a stale proxy's late teardown cannot disarm a newer proxy's install. A disarmed listener passes the transaction through untouched.On the crash signature. The same
shouldOverridePullTransaction()virtual dispatch faults in more than one shape, depending on what recycles the freed block:SEGV_ACCERRwith the fault address inside[anon:scudo:primary]("trying to execute non-executable memory") when the recycled chunk holds pointer-dense data, andSEGV_MAPERRwhen it does not — either with an unmapped ASCII-looking jump target, or with fault address0x0when the vtable slot itself reads back as zero and the jump never happens. All three have been observed for this bug (the last confirmed at instruction level on arm64 in the upstream thread, wherelrshows theblrnever ran). The0x0variant in particular reads like an unrelated core-renderer null dereference, which is worth knowing when triaging future reports against this issue.This is a backport of an upstream fix that has already been reviewed and merged by Software Mansion: software-mansion/react-native-screens#4413, merged as
b3badd012f83679b12f4e29f2e28eceaa4830efd. It is not in a react-native-screens release yet (the latest tag, 4.27.0, was published before the merge), so it ships here as a patch against our current 4.25.0, and can be dropped as soon as we bump to the first release containing that commit. The patched files are byte-identical to the merged upstream files, with upstream'scpp/legacy/RNSScreenRemovalListener.*mapping tocpp/RNSScreenRemovalListener.*in the published package.Fixed Issues
$ #93842
PROPOSAL: #93842 (comment)
PROPOSAL: #93842 (comment)
Platform coverage and HybridApp
The patched code is Android-only native C++ (
react-native-screensJNI + the Fabric mounting override delegate). It is not compiled on iOS, and mWeb/desktop run the web bundle, so those platforms cannot exercise this code path. I also do not have access to a macOS machine. The checklist boxes below are all ticked per the repo's CI requirement (the checklist verifies each item was considered); what was and was not actually exercised is stated here and annotated inline.Verified on a standalone Android build: the patch applies cleanly (
scripts/applyPatches.sh, zero warnings),NativeProxy.cppandRNSScreenRemovalListener.cppcompile for x86_64 with no compiler diagnostics,librnscreens.sois packaged and confirmed loaded at runtime, and the navigation pass below runs clean with no crash. I have not built the HybridApp/Mobile-Expensify variant locally — happy to verify against the ad-hoc build on this PR, or to follow whatever the reviewer prefers.Tests
The crash is a race that reproduces on roughly 1 in 1000 cold launches, so these steps verify that screen removal still behaves correctly with the patch applied rather than attempting to demonstrate the crash is gone. See "Evidence" below for what does support the fix.
Evidence supporting the fix itself, since the above cannot demonstrate a 1-in-1000 race:
If you want to exercise the race directly rather than rely on the above, the cold-relaunch loop and the instrumentation used to measure it are in this comment. Note that the useful signal there is the double-init rate, not the crash: the crash is ~1 in 1000 cold launches, while the double-init that causes it is roughly 40x more frequent, and with this patch the lazy-init branch no longer exists to double-enter.
Offline tests
N/A. This is a native Android patch to the Fabric mounting path and does not change any network or offline behavior.
QA Steps
Android Native only — this crash is in native Android code (
react-native-screensJNI / Fabric mounting delegate) triggered by a cold-launch race; iOS, mWeb and MacOS do not compile or execute this code path.PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.patches/diff against native C++ in a third-party library; there is no JS surface to unit-test, and the fix is a thread-safety change that cannot be exercised deterministically from Jest. This matches the sibling native crash patches (APP-7B2, APP-8BM, APP-25V), which also shipped without automated regression tests.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
nav-pass-clean.mp4
Android: mWeb Chrome
N/A — this patch only changes native Android code (react-native-screens JNI / Fabric mounting delegate), which mWeb does not compile or execute.
iOS: Native
N/A — this patch only changes native Android code, which is not compiled on iOS.
iOS: mWeb Safari
N/A — this patch only changes native Android code, which mWeb does not compile or execute.
MacOS: Chrome / Safari
N/A — this patch only changes native Android code, which the web bundle does not compile or execute.