Skip to content

[expo-iap] EXC_BAD_ACCESS / use-after-free in OpenIapModule.endConnection() when component unmounts during initConnection() #140

@hannahct

Description

@hannahct

Description

endConnection() crashes with EXC_BAD_ACCESS (SIGSEGV) when it is called while initConnection() is still in flight. Because OpenIapModule is a plain class with no actor isolation, the async Tasks spawned by initConnection() race with cleanupExistingState() called from endConnection(), causing Swift ARC to attempt to release an object whose memory has already been freed or remapped.

The crash manifests as a pointer authentication failure on ARM64e devices and a standard translation fault on ARM64. Users are sometimes seeing an immediate crash upon opening the app.

Crash stack

0   libswiftCore   _swift_release_dealloc + 32
1   libswiftCore   bool swift::RefCounts<...>::doDecrementSlow<PerformDeinit>(...) + 152
2   MyApp  OpenIapModule.endConnection() + 128
3   MyApp  closure #5 in ExpoIapModule.definition() + 1  (ExpoIapModule.swift:57)
4   MyApp  <deduplicated_symbol>
5   MyApp  closure #1 in ConcurrentFunctionDefinition.call(by:withArguments:appContext:callback:)
...
11  libswift_Concurrency  completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*) + 1

Exception: EXC_BAD_ACCESS (SIGSEGV)KERN_INVALID_ADDRESS (possible pointer authentication failure)
ESR: 0x92000004 — Data Abort, byte read, Translation fault

Root cause

endConnection() immediately cancels and nils initTask, then calls cleanupExistingState():

// OpenIapModule.swift
public func endConnection() async throws -> Bool {
    initTask?.cancel()
    initTask = nil
    await cleanupExistingState()  // ← races with in-flight initConnection Tasks
    return true
}

cleanupExistingState() then accesses updateListenerTask and productManager:

private func cleanupExistingState() async {
    updateListenerTask?.cancel()
    updateListenerTask = nil
    await state.reset()
    if let manager = productManager { await manager.removeAll() }
    productManager = nil
}

If initConnection()'s Tasks — which create productManager and start updateListenerTask — are still executing concurrently, cleanupExistingState() touches objects that those Tasks are in the process of initializing. Swift ARC then decrements a reference count on an object whose memory is being concurrently freed, producing a segfault.

Additionally, when the Expo module unmounts it fires two concurrent endConnection() calls:

  1. useIAP cleanup effect (useIAP.js:338): endConnection() (JS → AsyncFunction)
  2. ExpoIapModule.OnDestroyExpoIapHelper.cleanupStore() (ExpoIapHelper.swift:199): OpenIapModule.shared.endConnection() directly

This makes the race window wider and harder to avoid.

How to reproduce

The scenario that reliably triggers this is:

  1. useIAP mounts → initConnection() starts async Tasks
  2. Before those Tasks complete, the component unmounts
  3. endConnection() fires and races with the in-flight Tasks

Run on a physical iOS device (StoreKit does not fully initialize on Simulator). The app crashes within ~150ms of launch every time.

Environment

expo-iap 3.4.13
openiap pod as bundled with 3.4.13
iOS Reproducible on iOS 18.7.3 (stable) and iOS 26.3.1 (beta)
Devices iPhone 14 Pro (iPhone15,2), iPhone 15 Pro (iPhone16,1), iPhone 16 Pro (iPhone17,1)
Architecture ARM-64 (native)

Metadata

Metadata

Assignees

No one assigned

    Labels

    expo-iapexpo-iap library🐛 bugSomething isn't working📱 iOSRelated to iOS

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions