Repository navigation
[BUG]: cuda.bindings array-member getters return NumPy views that can outlive their owning wrapper (use-after-free) #3193
Description
Summary
Several cuda.bindings struct wrappers return zero-copy NumPy views (or Cython arrays) of memory owned by the wrapper or by the wrapper's parent, but the returned object keeps no reference to that owner. When the owner is garbage-collected its memory is freed, leaving the view as a dangling pointer. Reading or writing it afterwards is a use-after-free.
This is easy to hit: nvml.device_get_platform_info(handle).ib_guid, for example, returns a view into a temporary wrapper that is freed as soon as the statement ends.
It is present in the released cybind-generated modules (nvml, cufile, cudla; confirmed in v13.4.4) and in the CUDA 12 line (12.9.x, see below). It is distinct from #3181, which concerns the alignment of CUtensorMap's inline storage rather than its lifetime. I found this while reviewing #3095, which reproduces the same pattern in the new cuda.bindings._v2.driver module.
Root cause
All affected code comes from three cybind generator templates.
1. Fixed-size array members
@property
def <member>(self):
cdef _cyb_view.array arr = _cyb_view.array(shape=(N,), itemsize=..., format=..., mode="c", allocate_buffer=False)
arr.data = <char *>(&(self._ptr[0].<member>))
return _numpy.asarray(arr)_numpy.asarray(arr) returns a view (the Cython view.array exposes a compatible buffer) whose base chain (a memoryview over the Cython array) holds only the raw pointer into self's storage — not self. That storage is either inline in the wrapper (default construction, and from_ptr(owner=None), which copies) or kept alive by the wrapper's _owner (from_ptr(owner=...), from_buffer, from_data). Either way the view keeps neither the wrapper nor its owner alive, so collecting the wrapper frees or releases the memory the view still points at. The returned array reports flags.owndata == False and reflects later writes made through the wrapper's setter, confirming it aliases rather than copies.
2. Pointer members
@property
def <member>(self):
...
cdef _cyb_view.array arr = _cyb_view.array(shape=(self._ptr[0].<count>,), ..., allocate_buffer=False)
arr.data = <char *>(self._ptr[0].<member>)
return _numpy.asarray(arr) # the cudla getters return arr itself
@<member>.setter
def <member>(self, val):
cdef _cyb_view.array arr = _cyb_view.array(shape=(len(val),), ...)
...
self._ptr[0].<member> = <T *><intptr_t>(arr.data)
self._refs["<member>"] = arr(example)
The buffer behind a pointer member is allocated by the setter and kept alive only through the wrapper's _refs dict; the public device_get_vgpu_type_supported_placements and device_get_vgpu_type_creatable_placements populate placement_ids this way. The view keeps neither the wrapper nor the buffer alive, so it dangles once the wrapper is collected. It also dangles while the wrapper is still alive if the member is reassigned, because that replaces the _refs entry and frees the old buffer.
3. Recarray-backed array wrappers created by from_ptr
@staticmethod
def from_ptr(intptr_t ptr, size_t size=1, bint readonly=False, object owner=None):
...
cdef object buf = _cyb_cpython_memoryview.PyMemoryView_FromMemory(<char*>ptr, sizeof(T) * size, flag)
data = _numpy.ndarray(size, buffer=buf, dtype=t_dtype)
obj._data = data.view(_numpy.recarray)
obj._owner = owner(example)
owner is kept alive only by the wrapper object; _data sits on a bare memoryview that references nothing. Everything derived from _data therefore outlives the owner: field views (fans.speed), element wrappers (fans[0], since from_data sets no owner), slices (fans[2:5]), and np.asarray(fans) (the wrapper's __getbuffer__ re-exports _data). Nested array members returned by parent getters (from_ptr(..., owner=self)) go through this path, so nvml.unit_get_fan_speed_info(unit).fans.speed dangles as soon as the statement ends.
Affected code (current main, afa6249482aa03c9c6c4db35dd9cf80fa11ae321)
Fixed-size array members
nvml.pyx:
PlatformInfo_v1.ib_guidPlatformInfo_v1.rack_guidPlatformInfo_v2.ib_guidPlatformInfo_v2.chassis_serial_numberVgpuSchedulerCapabilities.supported_schedulersConfComputeGpuCertificate.cert_chainConfComputeGpuCertificate.attestation_cert_chainConfComputeGpuAttestationReport.nonceConfComputeGpuAttestationReport.attestation_reportConfComputeGpuAttestationReport.cec_attestation_reportGpuFabricInfo_v2.cluster_uuidNvlinkSupportedBwModes_v1.bw_modesGpuFabricInfo_v3.cluster_uuidGpuFabricInfo_v4.cluster_uuid
cufile.pyx:
cudla.pyx:
Pointer members
nvml.pyx:
VgpuPlacementList_v2.placement_idsVgpuTypeIdInfo_v1.vgpu_type_idsActiveVgpuInstanceInfo_v1.vgpu_instancesVgpuCreatablePlacementInfo_v1.placement_ids
cudla.pyx:
Recarray-backed from_ptr
The template is in 42 classes in nvml.pyx and 5 in cufile.pyx (IOEvents, PerGpuStats, IOVec, Descr, IOParams).
The 42 nvml.pyx classes
ProcessInfo, ProcessDetail_v1, BridgeChipInfo, _py_anon_pod0, ClkMonFaultInfo, ProcessUtilizationSample, ProcessUtilizationInfo_v1, _py_anon_pod1, VgpuProcessUtilizationSample, VgpuProcessUtilizationInfo_v1, VgpuSchedulerLogEntry, HwbcEntry, UnitFanInfo, SystemEventData_v1, EncoderSessionInfo, FBCSessionInfo, GpuInstancePlacement, ComputeInstancePlacement, EccSramUniqueUncorrectedErrorEntry_v1, NvlinkFirmwareVersion, VgpuSchedulerLogEntry_v2, PmgrPwrTuple, RailMetrics, PwrModelMetricsDlppm1xPerf, PwrModelMetricsSamplePfpp1x, PwrModelOperatingPointPfpp1x, NvlinkTelemetrySample_v1, Sample, VgpuInstanceUtilizationSample, VgpuInstanceUtilizationInfo_v1, FieldValue, GridLicensableFeature, CoreRailMetrics, PwrModelMetricsPfpp1x, PRMCounter_v1, PwrModelMetricsDlppm1x, PerfMetricsPfpp1xSample, PwrModelMetricsDlppm1xDramclkEstimates, ObservedMetrics, PerfMetricsDlppc2xSample, PerfMetricControllerSample, PerfMetricsSample
_v2.driver (#3095, not yet on main)
The same templates arrive via #3095 in _v2/driver.pyx: fixed-size array members MemFabricHandle_v1.data_, DevProp_v1.max_threads_dim, DevProp_v1.max_grid_size, TextureDesc_v1.address_mode, TextureDesc_v1.border_color, TensorMap.opaque and LogicalEndpointFabricHandle.data_, and the recarray from_ptr in 8 classes (ArrayMapInfo_v1, CliqueInfo, DevResource_v1, DevSmResourceGroupParams, ExecAffinityParam_v1, GraphEdgeData, MemAccessDesc_v1, StreamBatchMemOpParams_v1).
CUDA 12 tree
cuda_bindings_12/cuda/bindings/nvml.pyx has the same fixed-size array and pointer-member getters, with identical code a few lines from the positions linked above, and the same 42 recarray classes. Its cufile.pyx has only the recarray template (in IOEvents, Descr and IOParams), and there is no cudla. The nvml getters have shipped in the 12.9.x line since v12.9.6 (GpuFabricInfo_v4.cluster_uuid since v12.9.8).
Reproduction
No GPU or driver call is required — this only exercises the struct wrappers and the allocators:
import gc
import numpy as np
from cuda.bindings import nvml
def count_corrupted(make, keep_owners=False, n=500):
# Keep only the aliasing views; drop the objects that own the memory
# (or keep them, to show that reassignment alone is enough).
owners, views = [], []
for i in range(n):
owner, view = make(i % 250) # tag this instance
views.append(view)
if keep_owners:
owners.append(owner)
del owner
gc.collect()
# Allocate a flood of new objects of the same kind to reuse the freed memory.
flood = [make(123)[0] for _ in range(n)]
# Views that no longer show their own tag read memory owned by other objects.
return sum(int(np.asarray(v)[0]) != i % 250 for i, v in enumerate(views))
def inline_array(tag):
info = nvml.GpuFabricInfo_v2()
info.cluster_uuid = [tag] * 16
return info, info.cluster_uuid # view aliases info's inline storage
def pointer_member(tag):
plist = nvml.VgpuPlacementList_v2()
plist.placement_ids = [tag] * 4 # buffer is kept alive only by plist._refs
return plist, plist.placement_ids
def pointer_member_reassigned(tag):
plist, ids = pointer_member(tag)
plist.placement_ids = [0] * 4 # frees the buffer `ids` points at; plist stays alive
return plist, ids
def recarray_child(tag):
speeds = nvml.UnitFanSpeeds()
fans = speeds.fans # the child wrapper keeps `speeds` alive via _owner ...
fans.speed = [tag] * 24
return speeds, fans.speed # ... but its field view keeps neither alive
for make, keep_owners in [
(inline_array, False),
(pointer_member, False),
(pointer_member_reassigned, True),
(recarray_child, False),
]:
print(f"{make.__name__}: {count_corrupted(make, keep_owners)} / 500 views corrupted")Typical output (exact counts are allocator-dependent):
inline_array: 498 / 500 views corrupted
pointer_member: 498 / 500 views corrupted
pointer_member_reassigned: 500 / 500 views corrupted
recarray_child: 498 / 500 views corrupted
Suggested fix
Fix the cybind generator templates so that no returned view can outlive the memory it aliases:
- Fixed-size array members: build the view on the wrapper's own buffer export, e.g.
_numpy.frombuffer(self, dtype=..., count=N, offset=<member offset>). The struct wrappers'__getbuffer__already setsbuffer.obj = selfand honors_readonly, so the view keeps the wrapper (and, through_owner, any external owner) alive. It is also read-only for read-only wrappers, which today hand out writable views. - Pointer members: the view must keep the backing
_refsbuffer alive, not just the wrapper, because reassigning the member replaces that buffer. - Recarray
from_ptr: build_dataon a buffer exporter that holds a reference toownerinstead of a barePyMemoryView_FromMemory, so every field view, slice, element wrapper and buffer export derived from_datakeeps the owner alive.
Returning copies would also fix the lifetime problem, but it silently changes behavior for in-place writes such as info.cluster_uuid[0] = x, which currently write through to the struct.
A wrapper whose pointer member came from raw bytes (from_ptr, from_buffer, from_data, or w[0] = ...) holds only a copied pointer to memory it does not own; no getter-side fix covers that case.
Fixing the generator covers all affected code, including the forthcoming _v2.driver ones, once the CUDA 12 and CUDA 13 trees are regenerated.
Related, not covered here
The legacy (non-cybind) driver and runtime modules don't return NumPy views (CUtensorMap.opaque, for example, returns a list of copies). They do have an analogous lifetime problem for nested struct members: child wrappers such as driver.CUDA_MEMCPY3D().srcDevice hold a raw pointer into the parent's storage without keeping the parent alive (example). That comes from a different generator and isn't covered by this issue.
Environment
Observed by source inspection of current main (afa6249482aa03c9c6c4db35dd9cf80fa11ae321), the v13.4.4 and v12.9.6 through v12.9.10 tags, and the #3095 head. Runtime-reproduced on the released cuda-bindings 13.4.4 wheel (nvml, cufile, cudla) and 12.9.10 wheel (nvml), with CPython 3.12 and NumPy 2.5, and from the #3095 branch (CUDA 13.4, CPython 3.12) in _v2.driver.
Activity
mdboom commentedon Oct 23, 2026
There is a generator change in review right now (!597) that may address some of this already (unconfirmed, but it's definitely working in the same area). Might be worth waiting for that to land before working on this.