YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
ExecuTorch _load_for_executorch silently downgrades program_verification to Minimal β flatbuffer Verifier bypass β controllable out-of-bounds heap read on .pte load
Title
ExecuTorch _load_for_executorch[_from_buffer] ignores the advertised program_verification=InternalConsistency default and loads with Minimal verification, bypassing the flatbuffer Verifier and enabling a controllable out-of-bounds heap read from a malicious .pte
Target
- Huntr "Repository" / package dropdown:
pytorch/executorch(PyPI:executorch) - Affected tool / version:
executorch1.3.1 (current released wheel; verified). Source is identical topytorch/executorchmain. - Affected entry points (public API):
executorch.extension.pybindings.portable_lib._load_for_executorchand_load_for_executorch_from_buffer(and the C++extension::Moduleload path they wrap). - Format: ExecuTorch program file,
.pte(flatbuffer).
Severity
Honest rating: Medium / High-Medium β controllable out-of-bounds heap read + DoS on model load (NOT RCE).
- This is a memory-safety bug reachable purely by loading an untrusted model β no execution required. The advertised, opt-out-by-default safety control (
InternalConsistency, which runsflatbuffers::Verifier) is silently not applied, so any structurally-corrupt.ptethat the Verifier is designed to reject is instead parsed with attacker-controlled vector/string/offset fields. - Demonstrated impact: a controllable OOB heap read (information disclosure of adjacent heap memory, surfaced to the caller as tensor dimensions) and, with large length prefixes, a denial of service (the runtime walks far past the buffer β crash). This is the classic "inflated flatbuffer length prefix" primitive.
- Dollar tier (honest):
.pteis a flatbuffer model format in the pickle/.npy/.h5/.tflite/.pte family β ~$1.5k tier, not one of the named $4k formats (.joblib/.keras/.gguf/.safetensors/TF-SavedModel). The impact is OOB-read/DoS, not RCE, which keeps it below the file-write/RCE tier. I am explicitly not claiming write or code execution.
Summary
The Python stubs and the C++ PyModule constructors advertise program_verification = Verification.InternalConsistency as the default for _load_for_executorch[_from_buffer]. InternalConsistency is the only level that runs flatbuffers::Verifier + validate_program() over the untrusted .pte before any field is dereferenced. In reality the verification argument is dropped on the floor: the pybindings helper load_module_from_buffer()/load_module_from_file() constructs the Module without forwarding program_verification, and the program is then parsed lazily by Module::load() whose default is the much weaker Program::Verification::Minimal. Minimal only bounds-checks the root-table offset and trusts every vector length prefix, string length, and vtable offset in the flatbuffer. As a result, a malicious .pte whose sizes vector length prefix is inflated (claims 0x7FFFFFFF entries while the buffer holds 2) is accepted, and reading the tensor metadata walks out of bounds β a controllable heap over-read / DoS, exactly the corruption class the Verifier exists to stop.
Root cause (file:line)
All paths below are in executorch 1.3.1, identical to pytorch/executorch@main.
The advertised default is
InternalConsistencyβextension/pybindings/pybindings.pyi:def _load_for_executorch(..., program_verification: Verification = Verification.InternalConsistency) -> ExecuTorchModule: ... def _load_for_executorch_from_buffer(..., program_verification: Verification = Verification.InternalConsistency) -> ExecuTorchModule: ...The C++
PyModuleconstructors and pybind defaults agree:pybindings.cpplines 566/589/606/621/652/667 defaultprogram_verification = Program::Verification::InternalConsistency, and the bound functions registerpy::arg("program_verification") = ...InternalConsistency(pybindings.cpp:1544, 1554).The verification argument is then dropped β
extension/pybindings/pybindings.cpp,load_module_from_buffer()(lines 182β209) andload_module_from_file()(lines 211β234). Both have the signature parameterProgram::Verification program_verification(lines 188, 215) but construct theModulewithout passing it:inline std::unique_ptr<Module> load_module_from_buffer( const void* ptr, size_t ptr_len, ..., Program::Verification program_verification) { // <-- received auto loader = loader_from_buffer(ptr, ptr_len); ... return std::make_unique<Module>( std::move(loader), nullptr, nullptr, std::move(event_tracer), nullptr); // <-- program_verification NOT forwarded }program_verificationis unused after this point β there is noModule::load(verification)call wired to it.Lazy parse uses the weak
Minimaldefault βextension/module/module.hdeclares bothModule::loadoverloads withconst Program::Verification verification = Program::Verification::Minimal(module.h:188β190 and 212β215). Every lazy trigger callsload()with no argument:Module::method_names()βload()(module.cpp:320),Module::num_methods()βload()(module.cpp:315),Module::method_meta()βload()(module.cpp:554),Module::load_method()βload()(module.cpp:459). SoProgram::load(loader, Minimal)is what actually runs (module.cpp:301).Minimalskips the Verifier βruntime/executor/program.cpp,Program::load()lines 176β224. Theflatbuffers::Verifier+validate_program()run only underInternalConsistency(lines 177β202). UnderMinimalthe code merely range-checks the root-table offset (lines 204β224) and trusts the rest of the flatbuffer. Inflated vector length prefixes are therefore never caught.
Net effect: calling _load_for_executorch_from_buffer(bad, program_verification=Verification.InternalConsistency) β the documented-safe default β parses bad with Minimal verification. The safety control the API advertises is unreachable through the Module/pybindings path.
Proof of concept
Build + assertion logic
Both a valid and a malicious .pte are built in-process with executorch's own exir serializer (the same serialization the official export pipeline uses), so the bytes are genuine ExecuTorch programs.
- Baseline program: one method
forwardwhose single output Tensor has asizesflatbuffer vector of[1, 1](constant_segment path, accepted by the released wheel). - Corruption: locate the
sizesvector's 32-bit length prefix (byte pattern<u32 len=2><i32 1><i32 1>) and overwrite the length with a larger value. The buffer still physically holds only 2 elements. - Oracle:
_load_program_from_buffer(itsPyProgramctor callsProgram::load(loader, verification)eagerly with the requested mode) tells us, for the SAME bytes, whether the Verifier accepts. We then show_load_for_executorch_from_buffer(..., InternalConsistency)behaves likeMinimalβ the bypass β and read the corrupted metadata back through the publicMethodMeta.output_tensor_meta(0).sizes().
Captured output (executorch 1.3.1, CPython 3.12)
Running the portable reviewer script reproduce.py (self-contained; builds the .pte in-process):
[env] wrote+read benign marker at <tmp>/executorch_pte_poc_marker.txt
[env] executorch wheel: \executorch\exir\_serialize\_program.py
== (A) Verifier oracle (_load_program_from_buffer), SAME malicious bytes ==
corrupted `sizes` length prefix @offset 276: 2 -> 2147483647 (buffer physically holds 2 elements)
valid + InternalConsistency : ACCEPTED (num_methods=1)
BAD + InternalConsistency : REJECTED (Failed to load program, error: 0x:23) <- flatbuffer Verifier catches it
BAD + Minimal : ACCEPTED (num_methods=1) <- no Verifier, accepted
== (B) THE BYPASS: _load_for_executorch_from_buffer (advertised default = InternalConsistency) ==
BAD + InternalConsistency : ACCEPTED+PARSED (methods=['forward'])
BAD + Minimal : ACCEPTED+PARSED (methods=['forward'])
(identical => requested InternalConsistency verification was NOT applied)
== (C) Concrete controllable OOB heap read (MethodMeta.output_tensor_meta.sizes) ==
claimed dims= 3: sizes()=(1, 1, 786440) (first 2 real; the rest are heap bytes past the vector)
claimed dims= 5: sizes()=(1, 1, 786440, 262152, 8) (first 2 real; the rest are heap bytes past the vector)
claimed dims= 8: sizes()=(1, 1, 786440, 262152, 8, 8, 12, 0) (first 2 real; the rest are heap bytes past the vector)
claimed dims=16: sizes()=(1, 1, 786440, 262152, 8, 8, 12, 0, 0, 0, 0, 7, 2003988326, 6582881, 0, 0) (first 2 real; the rest are heap bytes past the vector)
=================== VERDICT ===================
Verifier detects corruption under InternalConsistency : YES
Minimal accepts the same corruption : YES
_load_for_executorch(InternalConsistency) bypasses it : YES
Out-of-bounds tensor dims surfaced to caller : YES
>>> program_verification BYPASS + OOB READ CONFIRMED : True
Interpretation:
- (A) proves the Verifier genuinely catches this corruption: identical malicious bytes are REJECTED under
InternalConsistency(error0x23=InvalidProgram, raised atprogram.cpp:187"Verification failed; data may be truncated or corrupt") but ACCEPTED underMinimal. - (B) is the vulnerability:
_load_for_executorch_from_bufferwith the advertisedInternalConsistencydefault produces the same result asMinimalβ the requested verification was not applied. - (C) is the concrete impact:
MethodMeta.output_tensor_meta(0).sizes()returns attacker-controlled dimensions read past the end of the real 2-element flatbuffer vector. Only the first two values (1, 1) are real; the trailing values (786440, 262152, β¦) are adjacent heap bytes. Increasing the claimed length walks further out of bounds β an information leak that, at large lengths, becomes an out-of-range access / crash (DoS). The leaked dwords are reproducible across runs, confirming a genuine over-read rather than randomness.
reproduce.py exits 0 only when all four verdict conditions hold.
Files in this package
reproduce.pyβ single self-contained reviewer script (builds the.ptein-process, runs A/B/C, prints the verdict, exits non-zero on failure). Portable: benign marker written/read under the OS temp dir.poc_final.py+build_lib.pyβ the original three-part PoC (same logic;build_lib.pywires the in-wheelflatcand exir serializer).crash.pte,marker.pteβ concrete pre-built malicious sample programs (inflated/poisonedsizesvector) for quick manual loading.
Impact (realistic threat model)
ExecuTorch is the on-device PyTorch runtime; .pte files are distributed to and loaded by mobile/edge apps and by any server-side code that ingests user- or third-party-supplied models. _load_for_executorch[_from_buffer] is the canonical, documented loading API, and InternalConsistency is advertised as its default precisely so that loading an untrusted .pte is safe against malformed/corrupt files. Because that verification is silently downgraded to Minimal:
- An attacker who can get a victim to load a crafted
.pte(malicious model on a hub, supply-chain swap, MITM of a model download, a model received from another user) triggers, at load time and with no method execution, a controllable out-of-bounds heap read. Concretely this yields:- Information disclosure: adjacent heap memory is surfaced through tensor metadata (
sizes()/nbytes()), and downstream allocation/copy logic sized from those attacker-controlled dims can read/move further out-of-bounds. - Denial of service: large/odd length prefixes and poisoned offsets make the runtime dereference far outside the buffer β crash of the host process.
- Information disclosure: adjacent heap memory is surfaced through tensor metadata (
- The bug defeats the specific mitigation the API documents. A developer who reads the stub/signature and relies on the default
InternalConsistencyto harden untrusted-model loading gets no protection at all through this path. That gap between documented and actual behavior is the core of the report.
I deliberately did not develop this into write/RCE; flatbuffer over-reads of this kind are routinely accepted as memory-corruption findings on their own, and over-claiming would be dishonest.
Honest duplicate + scope note
- Scope: the vulnerable code is in
executorchitself (pytorch/executorch) β theextension/pybindings+extension/module+runtime/executorload path. It is not a finding in a scanner. (For the threat model I checkedmodelaudit'sExecuTorchScanner: it performs only static checks on.pteβ magic/signature validation, zip path-traversal, embedded.pkl/.pydetection β and never invokes the executorch loader, so it neither triggers nor mitigates this bug. No scanner-scope ambiguity.) Submit against thepytorch/executorchpackage. - Duplicate check: I am not aware of a public advisory/CVE/huntr report for "
_load_for_executorchdropsprogram_verification/ loads with Minimal by default." The behavior is reproduced here against the released 1.3.1 wheel from first principles. The maintainers should still be allowed to dedup against any internal tracking. The two facts that make this novel and not "working as intended": (1) the public stub/pyiand thePyModuleconstructors explicitly advertiseInternalConsistencyas the default, and (2) the same bytes are provably rejected when that verification actually runs (oracle path A) β so this is a real control that is silently unreachable via the Module path, not a documented limitation.
Remediation
- Forward the argument. In
load_module_from_buffer()/load_module_from_file()/load_module_from_buffer_with_data_file()(pybindings.cpp), threadprogram_verificationinto the load: after constructing theModule, callmodule->load(program_verification)(andload_method/method_namesthereafter), or add aModuleconstructor/factory that stores the requested verification and uses it for the lazyload(). The value is currently accepted and discarded. - Make the safe level the real default. Change the default of
Module::load(...)(module.h:188β190, 212β215) fromProgram::Verification::MinimaltoProgram::Verification::InternalConsistency, so the lazy parse triggered bymethod_names()/method_meta()/load_method()verifies by default. Callers that truly want to skip verification can opt in toMinimalexplicitly. - Defense in depth. Even under
Minimal, bound vector/string length prefixes against the remaining buffer before iterating tensor metadata (e.g. inMethodMeta/TensorInfoaccessors), so a corrupt length cannot translate into an over-read regardless of verification level. - Docs. Until fixed, the
.pyi/docstrings should not advertiseInternalConsistencyas the effective default for_load_for_executorch[_from_buffer], since the Module path does not honor it.
- Downloads last month
- 1