ExecuTorch β Program::load()'s DEFAULT verification level (Verification::Minimal) never validates internal vector/string lengths, causing heap-buffer-overflow read (CWE-125 / CWE-20)
Status: gated, access on request (huntr / ProtectAI triage). Do not use against production systems you do not own.
Target: https://github.com/pytorch/executorch (runtime/executor/program.cpp,
Program::load())
Class: CWE-20 Improper Input Validation (verification level defaults to a mode that skips
structural FlatBuffers validation) leading to CWE-125 Out-of-bounds Read, triggered while
loading a .pte model file whose flatbuffer contains a corrupted vector/string length field.
Root cause
Program::load() takes a Verification level with two possible values
(runtime/executor/program.h):
enum class Verification : uint8_t {
/** Do minimal verification of the data, ensuring that the header appears correct.
* Has minimal runtime overhead. */
Minimal,
/** ... full verification ... */
InternalConsistency,
};
ET_NODISCARD static Result<Program> load(
DataLoader* loader,
Verification verification = Verification::Minimal); // <-- DEFAULT
Verification::Minimal is the default for every caller that doesn't explicitly opt into
InternalConsistency (extension/module, most embedded integrations, and the common
Program::load(loader) call site). Inside program.cpp, the two modes differ enormously in
what they actually check:
// InternalConsistency only:
if (verification == Verification::InternalConsistency) {
flatbuffers::Verifier verifier(data, size);
bool ok = executorch_flatbuffer::VerifyProgramBuffer(verifier); // walks the WHOLE tree
...
}
// Minimal (and, if verification is compiled out, InternalConsistency too):
if (verification == Verification::Minimal ...) {
uint32_t root_offset = flatbuffers::ReadScalar<flatbuffers::uoffset_t>(program_data->data());
ET_CHECK_OR_RETURN_ERROR(
root_offset >= kMinBufferSize &&
root_offset <= program_data->size() - sizeof(flatbuffers::soffset_t),
InvalidProgram, ...); // <-- ONLY checks the outermost root-table offset
}
const executorch_flatbuffer::Program* flatbuffer_program =
executorch_flatbuffer::GetProgram(program_data->data());
Under Verification::Minimal, Program::load() checks exactly one thing: that the root
table's own offset is in bounds. No other offset, vector-length, or string-length field
anywhere inside the Program flatbuffer is validated β not execution_plan's vector count,
not any ExecutionPlan.name's string length, not operator names, not delegate blobs, not
tensor shapes. Every one of those is read straight off attacker-controlled bytes the moment
downstream code touches it, with the same lack of bounds checking that
executorch_flatbuffer::VerifyProgramBuffer exists specifically to catch β but that function
is never called in this mode.
Immediately after a Minimal-verified Program::load() returns Ok, real code walks
exactly this kind of un-validated data just to enumerate method names
(program.cpp, anonymous-namespace get_execution_plan(), used by
Program::get_method_name() and effectively every Method::load()):
Result<executorch_flatbuffer::ExecutionPlan*> get_execution_plan(
const executorch_flatbuffer::Program* program, const char* method_name) {
auto execution_plans = program->execution_plan();
for (size_t i = 0; i < execution_plans->size(); i++) {
auto plan = execution_plans->GetMutableObject(i);
if (plan != nullptr && plan->name() != nullptr &&
std::strcmp(plan->name()->c_str(), method_name) == 0) { // <-- c_str() on unvalidated string
return plan;
}
}
...
}
Root cause confirmed by diffing against a valid seed file
executorch_seed_valid.pte (128 bytes, one execution plan named "forward") and
executorch_minverify_oob_poc.pte (104 bytes) are byte-identical up through offset 0x58.
At that point, a length-prefixed field that reads 04 00 00 00 (4, a small in-bounds count)
in the valid file has been overwritten with 64 00 00 00 (0x64 = 100) in the PoC, while
the file was simultaneously truncated so only ~4 more bytes actually follow it before EOF β
i.e. a length field claiming 100 (bytes/elements) backed by a buffer with essentially none
left. Verification::Minimal's single root-offset check has no way to catch this: the root
offset itself is perfectly valid (16, well within the 104-byte buffer) in both files.
Reachability
Program::load(loader) with the default argument is the common case for any ExecuTorch
integration that doesn't explicitly request InternalConsistency β including the standard
extension/module C++ API used by most host-side/embedded integrators. Loading an untrusted
or downloaded .pte file through the default API is enough; no method needs to actually be
executed, only Program::load() (and by extension Program::get_method_name() /
Method::load(), which walk execution_plan immediately) needs to run.
Proof of concept
repro_minverify.cpp reproduces the exact real call sequence:
- Loads the raw bytes (no
ExtendedHeaderis present in this file, soprogram_sizefalls back to the full file size exactly asProgram::load()does whenExtendedHeader::Parse()returnsError::NotFound). - Performs only the
Verification::Minimalroot-table-offset bounds check (verbatim logic fromprogram.cpp) β noVerifyProgramBuffercall, exactly matching the default verification level. executorch_flatbuffer::GetProgram(data)β the real generated root-table accessor.- Walks
program->execution_plan()and callsplan->name()->c_str()for each entry β a verbatim copy ofprogram.cpp's anonymous-namespaceget_execution_plan(), the real code path everyProgram::get_method_name()/Method::load()call takes.
The generated FlatBuffers accessors (program_generated.h, scalar_type_generated.h) were
regenerated with flatc directly from the project's own schema/program.fbs /
schema/scalar_type.fbs at the tested revision β not hand-modified.
Build & run
flatc --cpp --gen-mutable --gen-object-api -o gen program.fbs scalar_type.fbs
g++ -std=c++17 -O0 -g -fsanitize=address,undefined \
-I gen -I <flatbuffers-include> -o repro_minverify repro_minverify.cpp
./repro_minverify executorch_seed_valid.pte # sanity check: loads fine, prints "forward"
./repro_minverify executorch_minverify_oob_poc.pte # crashes
Observed result (see asan_output.txt)
Sanity check against the valid seed file (no crash, confirms the harness's Minimal-mode logic
matches real Program::load() behavior for well-formed input):
[repro] Verification::Minimal root-offset-only check: root_offset=16, program size=128
[repro] Minimal verification PASSED ...
[repro] program->execution_plan()->size() = 1
[repro] plan[0]->name() = forward
[repro] NOT REACHED if vulnerable (ASan should have aborted above)
Against the PoC file:
[repro] Verification::Minimal root-offset-only check: root_offset=16, program size=104
[repro] Minimal verification PASSED (as real Program::load() would report Error::Ok) -- no VerifyProgramBuffer walk of the rest of the tree ever happens
[repro] program->execution_plan()->size() = 1
==937335==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7c0023be00c0 at pc ...
READ of size 9 at 0x7c0023be00c0 thread T0
#0 ... strlen sanitizer_common_interceptors.inc:425
#1 ... std::operator<<<std::char_traits<char>>(std::ostream&, char const*) ...
#2 ... real_get_execution_plan_walk(...) repro_minverify.cpp:65 (== program.cpp's get_execution_plan(), plan->name()->c_str())
#3 ... main repro_minverify.cpp:129
0x7c0023be00c0 is located 0 bytes after 128-byte region [0x7c0023be0040,0x7c0023be00c0)
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in std::operator<<<...>(std::ostream&, char const*)
Full log: asan_output.txt. The overflow lands exactly at the 128-byte allocation boundary
(the harness's own file-read buffer for the 104-byte file rounded up by the allocator), i.e.
plan->name()->c_str() returned a pointer whose implied length runs past the actual backing
allocation β reading the corrupted length field (100) as if it were trustworthy, exactly as
Verification::Minimal allows. A production (non-ASan) build reads past the end of whatever
heap allocation backs the loaded .pte data, which is a crash (DoS) at minimum and a
plausible heap information-disclosure vector if the out-of-bounds bytes are ever surfaced
back to the caller (e.g. via a method-name lookup error message, logging, or further string
processing).
Dedup / prior-art check
- Distinct from the pre-existing
huntr-poc-executorch-oobfinding in this account (a different OOB in a different code path); this finding is specifically about the defaultProgram::Verification::Minimallevel's scope being far narrower than its "ensures header appears correct" description implies, and the missing bounds validation is confirmed to sit in the FlatBuffers-generated accessor layer itself (GetMutableObject/name()->c_str()), reachable the momentProgram::get_method_name()/Method::load()runs on a Minimal-verified program. - No matching GitHub issue, PR, security advisory, or CVE for ExecuTorch references
Verification::Minimalcombined with an out-of-bounds read inexecution_plan/method-name parsing.
Suggested fix
Either make Verification::Minimal's documentation and default explicitly reflect that it
provides no protection against corrupted internal offsets/lengths (and steer integrators
toward InternalConsistency by default for any untrusted input), or extend the Minimal-mode
check to also validate the bounds of the execution_plan vector and the name field's
declared length against the loaded buffer size before they are ever dereferenced.
Files
executorch_minverify_oob_poc.pteβ the crafted model file (corrupted string-length field in theforwardexecution plan'sname).executorch_seed_valid.pteβ the valid, unmodified seed file the PoC was derived from, used as a sanity-check control (loads and prints its method name without crashing).repro_minverify.cppβ reproduction harness using the real generated schema accessors and the exactVerification::Minimalcall sequenceProgram::load()takes.program.fbs,scalar_type.fbsβ copies of the project's own schema files used to regenerate the FlatBuffers accessors withflatc.asan_output.txtβ captured AddressSanitizer crash report from an actual run (both the valid-seed sanity check and the PoC crash).
- Downloads last month
- -