Skip to content

[BUG]: cuda.bindings array-member getters return NumPy views that can outlive their owning wrapper (use-after-free) #3193

Open

Description

@juenglin

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:

cufile.pyx:

cudla.pyx:

Pointer members

nvml.pyx:

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 sets buffer.obj = self and 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 _refs buffer alive, not just the wrapper, because reassigning the member replaces that buffer.
  • Recarray from_ptr: build _data on a buffer exporter that holds a reference to owner instead of a bare PyMemoryView_FromMemory, so every field view, slice, element wrapper and buffer export derived from _data keeps 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

  1. added
    bugSomething isn't working
    cuda.bindingsEverything related to the cuda.bindings module
    on Oct 22, 2026
  2. mdboom commented on Oct 23, 2026

    @mdboom
    Contributor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcuda.bindingsEverything related to the cuda.bindings module

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions