Skip to content

Commit 168e653

Browse files
authored
Fix NPZ buffer lifetime to prevent use-after-free (#1607)
## Purpose Fix a lifetime issue in `NpzFile` that could result in a dangling pointer when loading NPZ data from an in-memory buffer. ### Issue `NpzFile` only stores the raw memory address of the input buffer: ```python self._data = _get_pointer(data) ``` The original `data` object is not retained after `load_npz()` returns. As a result, the backing buffer may be garbage-collected while `NpzFile` continues to hold a pointer to its memory. Subsequent accesses through `__getitem__` use this stale pointer to read NPZ entries, resulting in a classic use-after-free. Example: ```python data = spdl.io.load_npz(f.read()) array = data["x"] ``` After `load_npz()` returns, the temporary buffer can be freed even though `NpzFile` still references its address. ### Fix Retain a strong reference to the original input buffer for the lifetime of the `NpzFile` instance by storing it as an instance attribute. This ensures the underlying memory remains valid until the `NpzFile` object is destroyed, preventing dangling pointers during subsequent array access. ### Post-fix Impact * Eliminates a potential use-after-free when accessing NPZ entries. * Prevents undefined behavior caused by reading freed memory. * Ensures returned NumPy arrays always reference valid backing storage. * Preserves the existing API and runtime behavior with negligible memory overhead beyond retaining the original input buffer for the lifetime of the `NpzFile`. --------- Signed-off-by: Gagan Dhakrey <gagandhakrey@gmail.com>
1 parent fd6e87f commit 168e653

2 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/spdl/io/_array.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"NpzFile",
1111
]
1212
from collections.abc import Iterator, Mapping
13-
from typing import TypeAlias
13+
from typing import Any, TypeAlias
1414

1515
import numpy as np
1616
from numpy.typing import NDArray
@@ -28,6 +28,26 @@ def _get_pointer(data: Buffer) -> int:
2828
return np.frombuffer(data, dtype=np.byte).ctypes.data
2929

3030

31+
class _OwnedArrayInterface:
32+
"""Expose the array interface of ``obj`` while keeping ``owner`` alive.
33+
34+
The ``NPYArray`` object returned by the C++ extension points at a memory
35+
region it does not own. NumPy sets the ``base`` of an array to the object it
36+
was created from, so creating the array from this wrapper (instead of from
37+
the ``NPYArray`` directly) keeps the object owning the memory alive for as
38+
long as the array is.
39+
"""
40+
41+
def __init__(self, obj: object, owner: object) -> None:
42+
self._obj = obj
43+
self._owner = owner
44+
45+
@property
46+
def __array_interface__(self) -> dict[str, Any]:
47+
# pyre-ignore[16]
48+
return self._obj.__array_interface__
49+
50+
3151
def load_npy(data: Buffer, *, copy: bool = False) -> NDArray:
3252
"""Load NumPy NDArray from bytes.
3353
@@ -97,7 +117,11 @@ def __init__(
97117
data: "bytes | memoryview[bytes]",
98118
meta: dict[str, tuple[int, int, int, int]],
99119
) -> None:
100-
self._data: int = _get_pointer(data)
120+
# `_data` is a raw pointer into `data`, so the archive must be kept
121+
# alive. A memoryview also blocks resizing a mutable source, which
122+
# would reallocate the buffer and leave `_data` dangling.
123+
self._buf: "memoryview[bytes]" = memoryview(data)
124+
self._data: int = _get_pointer(self._buf)
101125
self._len: int = len(data)
102126
self._meta = meta
103127
self.files: list[str] = [f.removesuffix(".npy") for f in meta]
@@ -125,11 +149,16 @@ def __getitem__(self, key: str) -> NDArray:
125149
offset, compressed_size, uncompressed_size, compression_method = self._meta[key]
126150
match compression_method:
127151
case 0:
152+
# The data is stored uncompressed, so the resulting array refers
153+
# to the archive itself. It must keep the archive alive, as it
154+
# can outlive this `NpzFile` object.
128155
buffer = _libspdl._archive.load_npy(
129156
self._data, size=compressed_size, offset=offset
130157
)
131-
return np.array(buffer, copy=False)
158+
return np.array(_OwnedArrayInterface(buffer, self._buf), copy=False)
132159
case 8:
160+
# The data is inflated into a buffer owned by the `NPYArray`
161+
# object, which NumPy keeps alive as the base of the array.
133162
buffer = _libspdl._archive.load_npy_compressed(
134163
self._data, offset, compressed_size, uncompressed_size
135164
)

tests/io/array_test.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66

77
# pyre-strict
88

9+
import gc
910
import io
11+
import sys
1012
import unittest
13+
from collections.abc import Callable
1114
from io import BytesIO
1215

1316
import numpy as np
@@ -256,3 +259,110 @@ def test_load_npy_cpp(self) -> None:
256259
buffer = spdl.io.load_npy(data)
257260
hyp = np.array(buffer, copy=False)
258261
np.testing.assert_array_equal(hyp, ref)
262+
263+
264+
def _reuse_freed_memory(size: int, count: int = 2000) -> list[bytearray]:
265+
"""Fill recently freed memory with a recognizable pattern.
266+
267+
CPython hands memory from deallocated objects back out to later
268+
allocations of a similar size, so `count` buffers of `size` bytes are
269+
likely to land on the block the archive just freed. A stale pointer into
270+
it then reads `0xAB` and the caller's `assert_array_equal` fails; without
271+
this, it would likely read the original bytes and pass.
272+
273+
Best-effort by nature -- the refcount assertions below are the
274+
deterministic check.
275+
"""
276+
return [bytearray(b"\xab" * size) for _ in range(count)]
277+
278+
279+
class TestNpzBufferLifetime(unittest.TestCase):
280+
"""`NpzFile` does not copy the archive.
281+
282+
It holds a raw pointer into the source buffer, and the arrays it returns for
283+
stored (uncompressed) entries are views into the same memory. Both read
284+
freed memory unless the source buffer is kept alive.
285+
286+
The tests come in two flavors. The ones asserting on `sys.getrefcount`
287+
check the contract directly, and are the authoritative check. The ones
288+
calling `_reuse_freed_memory` additionally try to turn a violation into an
289+
observable data corruption; see that function for the caveats.
290+
"""
291+
292+
def test_load_npz_retains_source(self) -> None:
293+
"""`load_npz` keeps a reference to the source buffer."""
294+
ref = np.arange(10)
295+
data = _dump_npz(x=ref)
296+
297+
num_refs = sys.getrefcount(data)
298+
npz = spdl.io.load_npz(data)
299+
300+
self.assertGreater(
301+
sys.getrefcount(data),
302+
num_refs,
303+
"`NpzFile` must keep a reference to the source buffer, "
304+
"as it holds a pointer into it.",
305+
)
306+
np.testing.assert_array_equal(npz["x"], ref)
307+
308+
def test_getitem_retains_source(self) -> None:
309+
"""Arrays of stored entries keep the source buffer alive.
310+
311+
Such an array is a view into the archive, so it can outlive the
312+
`NpzFile` it was retrieved from.
313+
"""
314+
ref = np.arange(10)
315+
data = _dump_npz(x=ref)
316+
317+
num_refs = sys.getrefcount(data)
318+
# The `NpzFile` is released as soon as the entry is retrieved.
319+
arr = spdl.io.load_npz(data)["x"]
320+
gc.collect()
321+
322+
self.assertGreater(
323+
sys.getrefcount(data),
324+
num_refs,
325+
"The array must keep a reference to the source buffer, "
326+
"as it is a view into it.",
327+
)
328+
np.testing.assert_array_equal(arr, ref)
329+
330+
@parameterized.expand(
331+
[
332+
("stored", _dump_npz),
333+
("deflated", _dump_npz_compressed),
334+
]
335+
)
336+
def test_load_npz_source_may_be_temporary(
337+
self, _: str, dump: Callable[..., bytes]
338+
) -> None:
339+
"""Entries are readable when the caller does not hold the source."""
340+
ref = np.arange(1000, dtype=np.int64)
341+
size = len(dump(x=ref))
342+
343+
# The source is a temporary, so it is released when `load_npz` returns
344+
# unless `NpzFile` retains it.
345+
npz = spdl.io.load_npz(dump(x=ref))
346+
gc.collect()
347+
# If `NpzFile` failed to retain the temporary, `npz["x"]` now points
348+
# into freed memory, and reads `0xAB` instead of `ref`.
349+
clobber = _reuse_freed_memory(size)
350+
351+
np.testing.assert_array_equal(npz["x"], ref)
352+
353+
del clobber
354+
355+
def test_array_outlives_npz_file(self) -> None:
356+
"""A stored entry stays valid after the `NpzFile` is released."""
357+
ref = np.arange(1000, dtype=np.int64)
358+
size = len(_dump_npz(x=ref))
359+
360+
arr = spdl.io.load_npz(_dump_npz(x=ref))["x"]
361+
gc.collect()
362+
# If the source buffer was not retained, `arr` now views freed memory,
363+
# and reads `0xAB` instead of `ref`.
364+
clobber = _reuse_freed_memory(size)
365+
366+
np.testing.assert_array_equal(arr, ref)
367+
368+
del clobber

0 commit comments

Comments
 (0)