You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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:

  1. Loads the raw bytes (no ExtendedHeader is present in this file, so program_size falls back to the full file size exactly as Program::load() does when ExtendedHeader::Parse() returns Error::NotFound).
  2. Performs only the Verification::Minimal root-table-offset bounds check (verbatim logic from program.cpp) β€” no VerifyProgramBuffer call, exactly matching the default verification level.
  3. executorch_flatbuffer::GetProgram(data) β€” the real generated root-table accessor.
  4. Walks program->execution_plan() and calls plan->name()->c_str() for each entry β€” a verbatim copy of program.cpp's anonymous-namespace get_execution_plan(), the real code path every Program::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-oob finding in this account (a different OOB in a different code path); this finding is specifically about the default Program::Verification::Minimal level'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 moment Program::get_method_name()/Method::load() runs on a Minimal-verified program.
  • No matching GitHub issue, PR, security advisory, or CVE for ExecuTorch references Verification::Minimal combined with an out-of-bounds read in execution_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 the forward execution plan's name).
  • 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 exact Verification::Minimal call sequence Program::load() takes.
  • program.fbs, scalar_type.fbs β€” copies of the project's own schema files used to regenerate the FlatBuffers accessors with flatc.
  • asan_output.txt β€” captured AddressSanitizer crash report from an actual run (both the valid-seed sanity check and the PoC crash).
Downloads last month
-
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support