Describe the issue
The XNNPACK execution provider keeps a single process-wide "stored allocator" that it creates lazily behind an unsynchronised null check. When two InferenceSessions are created concurrently (each getting its own XnnpackExecutionProvider) before any XNNPACK session has been created in the process, the two CreatePreferredAllocators() calls race, one allocator is freed while XNNPACK is left holding a pointer into it, and every subsequent XNNPACK allocation/free in the process is a use-after-free.
The relevant code:
onnxruntime/core/providers/xnnpack/xnnpack_execution_provider.cc:
std::vector<AllocatorPtr> XnnpackExecutionProvider::CreatePreferredAllocators() {
const auto& [stored_allocator, xnn_allocator] = GetStoredAllocator();
if (!stored_allocator) { // <-- unsynchronised read
const AllocatorCreationInfo allocator_info(...);
stored_allocator = CreateAllocator(allocator_info); // <-- unsynchronised write to a shared static
}
xnn_allocator->context = stored_allocator.get(); // <-- shared static mutated by both threads
const xnn_status st = xnn_initialize(xnn_allocator); // XNNPACK copies the pointer once (XNN_INIT_ONCE)
...
}
onnxruntime/core/providers/xnnpack/xnnpack_init.cc:
std::pair<AllocatorPtr&, xnn_allocator*> GetStoredAllocator() {
static AllocatorPtr ort_allocator; // shared across all EP instances
static xnn_allocator xnn_allocator_wrapper_ = { ort_allocator.get(), ... };
return {ort_allocator, &xnn_allocator_wrapper_};
}
Two threads can both observe !stored_allocator, both CreateAllocator, and the second assignment releases the first shared_ptr. Because xnn_initialize records the allocator only on the first call (XNN_INIT_ONCE / a CAS on init_allocator), XNNPACK can end up permanently pointing at the IAllocator owned by the freed shared_ptr. From then on, xnn_allocate / xnn_aligned_allocate / xnn_deallocate dereference a freed IAllocator*.
Observed crash signature (Apple, symbolicated): EXC_BAD_ACCESS in onnxruntime::xnnpack::(anonymous namespace)::xnn_aligned_allocate at the virtual IAllocator::Alloc call through the context pointer. Because the corrupted pointer lives for the life of the process, the fault surfaces in whichever XNNPACK op runs next, most visibly:
- session creation, in
Gemm::PrePack / MatMul::PrePack -> xnn_create_fully_connected_nc_f32 -> xnn_aligned_allocate
- inference, in
reshape_fully_connected_nc
- session release, in
xnn_destroy_operator -> xnn_deallocate
To reproduce
On a fresh process, create several sessions on separate threads at once, each appending the XNNPACK EP, e.g.:
Ort::Env env(ORT_LOGGING_LEVEL_ERROR, "race");
std::vector<std::thread> pool;
std::vector<std::shared_ptr<Ort::Session>> sessions(N);
for (int t = 0; t < N; ++t) {
pool.emplace_back([&, t]{
Ort::SessionOptions so;
so.AppendExecutionProvider("XNNPACK", {{"intra_op_num_threads", "1"}});
// line all threads up so construction starts simultaneously, then:
sessions[t] = std::make_shared<Ort::Session>(env, model_path, so);
});
}
for (auto& th : pool) th.join();
Reproduced deterministically on macOS arm64 (ORT 1.27.0) with MallocScribble=1 so the freed allocator faults immediately: with N=8 it crashes ~40-60% of runs; serialising session construction gives 0 crashes across hundreds of runs. Without heap poisoning the use-after-free is latent (reads stale-but-valid memory) and surfaces intermittently, which matches how it appears in the field. The same code is present in 1.29.0 and on main.
Urgency
Moderate. It is intermittent and only affects concurrent first-time session creation, but when it triggers it corrupts a process-global pointer and crashes unpredictably for the rest of the process's life.
Platform
Mac (reproduced), also affects iOS and Android (shared code).
OS Version
macOS 15 (arm64); observed in production on iOS.
ONNX Runtime Installation
Released Package
ONNX Runtime Version or Commit ID
1.27.0 (also 1.29.0 and current main)
ONNX Runtime API
C++
Architecture
ARM64
Execution Provider
Other / possibly contributes (XNNPACK)
Suggested fix
Make the lazy creation of the stored allocator (and the xnn_allocator->context write + xnn_initialize) thread-safe, e.g. a std::call_once / mutex inside GetStoredAllocator() or around the init block in CreatePreferredAllocators(). As a consumer-side workaround, serialising Ort::Session construction with a process-wide mutex avoids it.
Describe the issue
The XNNPACK execution provider keeps a single process-wide "stored allocator" that it creates lazily behind an unsynchronised null check. When two
InferenceSessions are created concurrently (each getting its ownXnnpackExecutionProvider) before any XNNPACK session has been created in the process, the twoCreatePreferredAllocators()calls race, one allocator is freed while XNNPACK is left holding a pointer into it, and every subsequent XNNPACK allocation/free in the process is a use-after-free.The relevant code:
onnxruntime/core/providers/xnnpack/xnnpack_execution_provider.cc:onnxruntime/core/providers/xnnpack/xnnpack_init.cc:Two threads can both observe
!stored_allocator, bothCreateAllocator, and the second assignment releases the firstshared_ptr. Becausexnn_initializerecords the allocator only on the first call (XNN_INIT_ONCE/ a CAS oninit_allocator), XNNPACK can end up permanently pointing at theIAllocatorowned by the freedshared_ptr. From then on,xnn_allocate/xnn_aligned_allocate/xnn_deallocatedereference a freedIAllocator*.Observed crash signature (Apple, symbolicated):
EXC_BAD_ACCESSinonnxruntime::xnnpack::(anonymous namespace)::xnn_aligned_allocateat the virtualIAllocator::Alloccall through the context pointer. Because the corrupted pointer lives for the life of the process, the fault surfaces in whichever XNNPACK op runs next, most visibly:Gemm::PrePack/MatMul::PrePack->xnn_create_fully_connected_nc_f32->xnn_aligned_allocatereshape_fully_connected_ncxnn_destroy_operator->xnn_deallocateTo reproduce
On a fresh process, create several sessions on separate threads at once, each appending the XNNPACK EP, e.g.:
Reproduced deterministically on macOS arm64 (ORT 1.27.0) with
MallocScribble=1so the freed allocator faults immediately: with N=8 it crashes ~40-60% of runs; serialising session construction gives 0 crashes across hundreds of runs. Without heap poisoning the use-after-free is latent (reads stale-but-valid memory) and surfaces intermittently, which matches how it appears in the field. The same code is present in 1.29.0 and onmain.Urgency
Moderate. It is intermittent and only affects concurrent first-time session creation, but when it triggers it corrupts a process-global pointer and crashes unpredictably for the rest of the process's life.
Platform
Mac (reproduced), also affects iOS and Android (shared code).
OS Version
macOS 15 (arm64); observed in production on iOS.
ONNX Runtime Installation
Released Package
ONNX Runtime Version or Commit ID
1.27.0 (also 1.29.0 and current main)
ONNX Runtime API
C++
Architecture
ARM64
Execution Provider
Other / possibly contributes (XNNPACK)
Suggested fix
Make the lazy creation of the stored allocator (and the
xnn_allocator->contextwrite +xnn_initialize) thread-safe, e.g. astd::call_once/ mutex insideGetStoredAllocator()or around the init block inCreatePreferredAllocators(). As a consumer-side workaround, serialisingOrt::Sessionconstruction with a process-wide mutex avoids it.