ruvllm: GgufFile panics or misreads tensor data on a malformed alignment or tensor offset · Issue #1202 · ruvnet/RuVector · GitHub
Skip to content

ruvllm: GgufFile panics or misreads tensor data on a malformed alignment or tensor offset #1202

Closed

Description

@ohdearquant

Issue opened by an AI agent on behalf of @ohdearquant.

What happens

ruvllm::gguf::GgufFile uses two header values without checking them, so a malformed GGUF file can crash the caller or return the wrong tensor data instead of an error.

  1. general.alignment = 0. GgufFile::open panics with attempt to divide by zero in align_offset (crates/ruvllm/src/gguf/mod.rs:799), in debug and release builds.
  2. general.alignment stored as a u64 holding u64::MAX. A debug build panics with attempt to add with overflow on the same line. A release build wraps and sets the data section offset to 0, so open succeeds and load_tensor_f32 returns the file's first four bytes (the GGUF magic, 13649.819 as an f32) as the tensor's value.
  3. A tensor offset past the end of the file. Through GgufFile::open the read fails cleanly (Failed to read tensor: failed to fill whole buffer). Through GgufFile::open_mmap it panics with range start index 4224 out of range for slice of length 132 (mod.rs:575): read_tensor_bytes slices the in-memory copy without a bounds check. That line is the default build's variant; the mmap feature's variant on line 573 slices the same way.

open_mmap calls open first, so cases 1 and 2 reach it as well.

Reproduction

At main 31549f7, Linux x86_64, rustc 1.98.0, default features. This script writes five small GGUF v3 files, each with one F32 tensor holding 1.0:

import struct


def gguf(alignment_kv: bytes, tensor_offset: int = 0) -> bytes:
    kvs = [alignment_kv] if alignment_kv else []
    out = b"GGUF" + struct.pack("<IQQ", 3, 1, len(kvs))
    out += b"".join(kvs)
    name = b"test.weight"
    # one F32 tensor with one element
    out += struct.pack("<Q", len(name)) + name + struct.pack("<IQIQ", 1, 1, 0, tensor_offset)
    return out


def kv_alignment(value_type: int, packed: bytes) -> bytes:
    key = b"general.alignment"
    return struct.pack("<Q", len(key)) + key + struct.pack("<I", value_type) + packed


def with_data(header: bytes, alignment: int = 1) -> bytes:
    pad = (-len(header)) % alignment
    return header + b"\0" * pad + struct.pack("<f", 1.0)


u32_32 = kv_alignment(4, struct.pack("<I", 32))
files = {
    "valid32.gguf": with_data(gguf(u32_32), 32),
    "valid-default.gguf": with_data(gguf(b""), 32),
    "zero.gguf": with_data(gguf(kv_alignment(4, struct.pack("<I", 0)))),
    "max-u64.gguf": with_data(gguf(kv_alignment(10, struct.pack("<Q", 2**64 - 1)))),
    "offset-past-eof.gguf": with_data(gguf(u32_32, tensor_offset=4096), 32),
}
for name, data in files.items():
    with open(name, "wb") as f:
        f.write(data)

This example opens each file both ways and loads the tensor:

use std::path::Path;

use ruvllm::gguf::GgufFile;

fn run(label: &str, path: &str, use_mmap: bool) {
    let outcome = std::panic::catch_unwind(|| {
        let opened = if use_mmap {
            GgufFile::open_mmap(Path::new(path))
        } else {
            GgufFile::open(Path::new(path))
        };
        match opened {
            Ok(file) => file.load_tensor_f32("test.weight"),
            Err(error) => Err(error),
        }
    });
    let verdict = match outcome {
        Ok(Ok(values)) => format!("LOAD-OK {values:?}"),
        Ok(Err(error)) => format!("ERR {error}"),
        Err(_) => "PANIC".to_string(),
    };
    println!("{label} {path} => {verdict}");
}

fn main() {
    for path in std::env::args().skip(1) {
        run("open", &path, false);
        run("mmap", &path, true);
    }
}

With the example saved as crates/ruvllm/examples/gguf_read_probe.rs and the script as /tmp/make_fixtures.py, from the repository root:

mkdir -p /tmp/gguf-repro && (cd /tmp/gguf-repro && python3 /tmp/make_fixtures.py)
cargo run -p ruvllm --example gguf_read_probe -- /tmp/gguf-repro/*.gguf
cargo run --release -p ruvllm --example gguf_read_probe -- /tmp/gguf-repro/*.gguf

Output, summarized (each call is wrapped in catch_unwind):

File debug, open debug, open_mmap release, open release, open_mmap
valid32.gguf (alignment 32) [1.0] [1.0] [1.0] [1.0]
valid-default.gguf (no alignment key) [1.0] [1.0] [1.0] [1.0]
zero.gguf (alignment 0) panic: divide by zero panic: divide by zero panic: divide by zero panic: divide by zero
max-u64.gguf (u64 alignment u64::MAX) panic: add with overflow panic: add with overflow [13649.819] [13649.819]
offset-past-eof.gguf (tensor offset 4096) Err: failed to fill whole buffer panic: range start index 4224 out of range for slice of length 132 Err: failed to fill whole buffer panic: range start index 4224 out of range for slice of length 132

Expected

An Err for each malformed file, as the reader already returns for other header problems such as an unsupported version. For comparison, llama.cpp's reader (ggml/src/gguf.cpp at f2cc7282) refuses a general.alignment that is not a u32, and refuses a value that is zero or not a power of two.

Suggested fix

  • After parsing metadata, require general.alignment to be a non-zero power of two and return RuvLLMError::Model otherwise.
  • In read_tensor_bytes, compute data_offset + offset + byte_size with checked arithmetic and compare it with the data length before slicing, so the in-memory path returns an error as the file path already does.

We can open a pull request with tests for these cases if that would help.

Activity

  1. ruvnet commented on Oct 24, 2026

    @ruvnet
    Owner

    Fixed by #1204.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions