Skip to content

Commit 5746d55

Browse files
authored
Validate untrusted footer metadata on open instead of panicking (#8867)
Closes: #8819 A fuzzed Vortex file whose footer segment map declared a segment past the end of the file panicked while slicing the backing buffer during open, instead of returning an error. This fixes that plus a few nearby unchecked-slice / unchecked-shift panics in the same file-open and array-decode paths: - Footer parsing validates every segment's `offset + length` against the file size (the #8819 root cause). - `populate_initial_segments` bounds-checks the initial-read slice range before slicing. - `SerializedArray::from_flatbuffer_and_segment` uses checked offset arithmetic and bounds-checks a buffer against its segment before slicing. - Alignment exponents from footer segment specs, postscript segments, and array buffer descriptors go through a fallible `Alignment::try_from_exponent` instead of `from_exponent`, which panics on a too-large shift. Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent def2791 commit 5746d55

8 files changed

Lines changed: 457 additions & 20 deletions

File tree

vortex-array/src/serde.rs

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -613,26 +613,47 @@ impl SerializedArray {
613613
// SAFETY: fb_buffer was already validated by validate_array_tree above.
614614
let fb_array = unsafe { fba::root_as_array_unchecked(fb_buffer.as_ref()) };
615615

616-
let mut offset = 0;
616+
let mut offset = 0usize;
617617
let buffers = fb_array
618618
.buffers()
619619
.unwrap_or_default()
620620
.iter()
621621
.enumerate()
622622
.map(|(idx, fb_buf)| {
623-
offset += fb_buf.padding() as usize;
624-
let buffer_len = fb_buf.length() as usize;
625-
let alignment = Alignment::from_exponent(fb_buf.alignment_exponent());
626-
627623
let idx = u32::try_from(idx).vortex_expect("buffer count must fit in u32");
624+
625+
// The padding, length, and resulting offsets all come from the flatbuffer, which
626+
// may be corrupt. Use checked arithmetic so malformed metadata returns a
627+
// `VortexError` rather than panicking (see issue #8819).
628+
let buffer_len = fb_buf.length() as usize;
629+
let start = offset
630+
.checked_add(fb_buf.padding() as usize)
631+
.ok_or_else(|| {
632+
vortex_err!("Buffer {idx} offset overflows when adding its padding")
633+
})?;
634+
let end = start.checked_add(buffer_len).ok_or_else(|| {
635+
vortex_err!("Buffer {idx} offset overflows when adding its length")
636+
})?;
637+
638+
// The alignment exponent comes from the flatbuffer and may be corrupt, so validate
639+
// it rather than panicking on a too-large shift (see issue #8819).
640+
let alignment = Alignment::try_from_exponent(fb_buf.alignment_exponent())?;
628641
let handle = if let Some(host_data) = buffer_overrides.get(&idx) {
629642
BufferHandle::new_host(host_data.clone()).ensure_aligned(alignment)?
630643
} else {
631-
let buffer = segment.slice(offset..(offset + buffer_len));
632-
buffer.ensure_aligned(alignment)?
644+
// Bounds-check against the segment so an out-of-range buffer returns a
645+
// `VortexError` rather than panicking when slicing (see issue #8819).
646+
if end > segment.len() {
647+
vortex_bail!(
648+
"Buffer {idx} at offset {start} with length {buffer_len} is out of \
649+
bounds of the {}-byte segment",
650+
segment.len(),
651+
);
652+
}
653+
segment.slice(start..end).ensure_aligned(alignment)?
633654
};
634655

635-
offset += buffer_len;
656+
offset = end;
636657
Ok(handle)
637658
})
638659
.collect::<VortexResult<Arc<[_]>>>()?;
@@ -695,3 +716,57 @@ impl TryFrom<BufferHandle> for SerializedArray {
695716
Self::try_from(value.try_to_host_sync()?)
696717
}
697718
}
719+
720+
#[cfg(test)]
721+
mod tests {
722+
use vortex_buffer::ByteBufferMut;
723+
724+
use super::*;
725+
use crate::IntoArray;
726+
use crate::array_session;
727+
use crate::arrays::PrimitiveArray;
728+
729+
/// A corrupt array tree can declare a buffer that extends past the backing segment. Slicing
730+
/// such a buffer must return a [`VortexError`] rather than panicking (see issue #8819).
731+
#[test]
732+
fn from_flatbuffer_and_segment_rejects_out_of_bounds_buffer() -> VortexResult<()> {
733+
let session = array_session();
734+
let array_ctx = ArrayContext::empty();
735+
736+
// Serialize a simple array so we have a valid array tree flatbuffer whose declared buffer
737+
// lengths describe the trailing data segment.
738+
let serialized = PrimitiveArray::from_iter([1i32, 2, 3, 4])
739+
.into_array()
740+
.serialize(&array_ctx, &session, &SerializeOptions::default())?;
741+
742+
let mut concat = ByteBufferMut::empty();
743+
for buf in serialized {
744+
concat.extend_from_slice(buf.as_ref());
745+
}
746+
let value = concat.freeze().aligned(Alignment::none());
747+
748+
// Split the blob into the trailing flatbuffer and the leading data segment, mirroring
749+
// `SerializedArray::try_from`.
750+
let fb_length = u32::try_from_le_bytes(&value.as_slice()[value.len() - 4..])? as usize;
751+
let fb_offset = value.len() - 4 - fb_length;
752+
assert!(
753+
fb_offset > 0,
754+
"the array must have at least one data buffer"
755+
);
756+
let array_tree = value.slice(fb_offset..fb_offset + fb_length);
757+
758+
// Truncate the data segment by one byte so the declared buffer no longer fits.
759+
let truncated = BufferHandle::new_host(value.slice(0..fb_offset - 1));
760+
761+
let Some(err) = SerializedArray::from_flatbuffer_and_segment(array_tree, truncated).err()
762+
else {
763+
vortex_bail!("out-of-bounds buffer must be rejected");
764+
};
765+
assert!(
766+
err.to_string().contains("out of bounds"),
767+
"unexpected error: {err}"
768+
);
769+
770+
Ok(())
771+
}
772+
}

vortex-buffer/src/alignment.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use std::ops::Deref;
66

77
use vortex_error::VortexError;
88
use vortex_error::VortexExpect;
9+
use vortex_error::VortexResult;
10+
use vortex_error::vortex_bail;
911
use vortex_error::vortex_err;
1012

1113
/// The alignment of a buffer.
@@ -120,6 +122,22 @@ impl Alignment {
120122
pub const fn from_exponent(exponent: u8) -> Self {
121123
Self::new(1 << exponent)
122124
}
125+
126+
/// Create from the log2 exponent of the alignment, returning an error rather than panicking if
127+
/// `1 << exponent` would overflow `usize`.
128+
///
129+
/// Prefer this over [`from_exponent`](Self::from_exponent) when the exponent originates from
130+
/// untrusted input such as a serialized file, where a too-large value must not panic.
131+
#[inline]
132+
pub fn try_from_exponent(exponent: u8) -> VortexResult<Self> {
133+
if u32::from(exponent) >= usize::BITS {
134+
vortex_bail!(
135+
"Alignment exponent {exponent} is too large for a {}-bit usize",
136+
usize::BITS
137+
);
138+
}
139+
Ok(Self::new(1 << exponent))
140+
}
123141
}
124142

125143
impl Display for Alignment {
@@ -236,6 +254,18 @@ mod test {
236254
assert!(Alignment::try_from(3u32).is_err());
237255
}
238256

257+
#[test]
258+
fn try_from_exponent() {
259+
match Alignment::try_from_exponent(10) {
260+
Ok(alignment) => assert_eq!(alignment, Alignment::new(1024)),
261+
Err(err) => panic!("valid exponent should succeed: {err}"),
262+
}
263+
// Exponents whose `1 << exponent` would overflow a usize must error rather than panic.
264+
// 64 is `>= usize::BITS` on both 32- and 64-bit targets.
265+
assert!(Alignment::try_from_exponent(64).is_err());
266+
assert!(Alignment::try_from_exponent(u8::MAX).is_err());
267+
}
268+
239269
#[test]
240270
fn into_u32() {
241271
let alignment = Alignment::new(64);

vortex-file/src/footer/deserializer.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ pub struct FooterDeserializer {
4040

4141
// Internal state that we accumulate
4242

43-
// The file size, possibly provided externally.
43+
// The size of the file containing the serialized footer. For a standalone footer, this is the
44+
// size of the footer blob rather than the data file described by the footer.
4445
file_size: Option<u64>,
4546
// The postscript, once we've parsed it.
4647
postscript: Option<Postscript>,
@@ -71,13 +72,17 @@ impl FooterDeserializer {
7172
self
7273
}
7374

74-
/// Provide the total file size.
75+
/// Provide the size of the file containing this serialized footer.
76+
///
77+
/// For a footer read from the end of a Vortex file, this is the file size. For a standalone
78+
/// blob created by [`crate::footer::FooterSerializer`] with its default offset, this is the
79+
/// size of that blob, not the size of the data file described by the footer.
7580
pub fn with_size(mut self, file_size: u64) -> Self {
7681
self.file_size = Some(file_size);
7782
self
7883
}
7984

80-
/// Provide or clear the total file size.
85+
/// Provide or clear the size of the file containing this serialized footer.
8186
pub fn with_some_size(mut self, file_size: Option<u64>) -> Self {
8287
self.file_size = file_size;
8388
self
@@ -120,11 +125,18 @@ impl FooterDeserializer {
120125
// The other postscript segments are required, so now we figure out our the offset that
121126
// contains all the required segments.
122127

123-
// The initial offset is the file size - the size of our initial read.
128+
// The initial offset is the file size minus the size of our initial read.
124129
let Some(file_size) = self.file_size else {
125130
return Ok(DeserializeStep::NeedFileSize);
126131
};
127-
let initial_offset = file_size - (self.buffer.len() as u64);
132+
let initial_offset = file_size
133+
.checked_sub(self.buffer.len() as u64)
134+
.ok_or_else(|| {
135+
vortex_err!(
136+
"Footer buffer length {} exceeds declared file size {file_size}",
137+
self.buffer.len()
138+
)
139+
})?;
128140

129141
let mut read_more_offset = initial_offset;
130142
if let Some(dtype_segment) = &dtype_segment {
@@ -373,6 +385,25 @@ mod tests {
373385
assert!(err.to_string().contains("out of bounds"), "{err}");
374386
Ok(())
375387
}
388+
389+
#[test]
390+
fn deserialize_rejects_buffer_larger_than_declared_size() -> VortexResult<()> {
391+
let postscript = Postscript {
392+
dtype: None,
393+
layout: segment(0, 1),
394+
statistics: None,
395+
footer: segment(1, 1),
396+
};
397+
let buffer = eof_buffer(&postscript)?;
398+
let declared_size = buffer.len() as u64 - 1;
399+
400+
let mut deserializer = FooterDeserializer::new(buffer, array_session())
401+
.with_dtype(DType::Primitive(PType::I32, Nullability::NonNullable))
402+
.with_size(declared_size);
403+
let err = deserializer.deserialize().unwrap_err();
404+
assert!(err.to_string().contains("exceeds declared"), "{err}");
405+
Ok(())
406+
}
376407
}
377408

378409
#[derive(Debug)]

vortex-file/src/footer/mod.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ impl Footer {
172172
self.root_layout.row_count()
173173
}
174174

175+
/// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
176+
pub(crate) fn validate_file_size(&self, file_size: u64) -> VortexResult<()> {
177+
validate_segments_within_file(&self.segments, file_size)
178+
}
179+
175180
/// Returns a serializer for this footer.
176181
pub fn into_serializer(self) -> FooterSerializer {
177182
FooterSerializer::new(self)
@@ -182,3 +187,60 @@ impl Footer {
182187
FooterDeserializer::new(eof_buffer, session)
183188
}
184189
}
190+
191+
/// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
192+
///
193+
/// A corrupt or malicious file can declare a segment whose offset or length extends past the end
194+
/// of the file. Rejecting such files up front ensures that later slicing of the backing buffer
195+
/// returns a [`VortexError`](vortex_error::VortexError) rather than panicking (see issue #8819).
196+
fn validate_segments_within_file(segments: &[SegmentSpec], file_size: u64) -> VortexResult<()> {
197+
for segment in segments {
198+
let within_file = segment
199+
.offset
200+
.checked_add(segment.length as u64)
201+
.is_some_and(|end| end <= file_size);
202+
if !within_file {
203+
vortex_bail!(
204+
"Segment at offset {} with length {} extends past the end of the \
205+
{file_size}-byte file",
206+
segment.offset,
207+
segment.length,
208+
);
209+
}
210+
}
211+
Ok(())
212+
}
213+
214+
#[cfg(test)]
215+
mod tests {
216+
use vortex_buffer::Alignment;
217+
218+
use super::*;
219+
220+
fn segment(offset: u64, length: u32) -> SegmentSpec {
221+
SegmentSpec {
222+
offset,
223+
length,
224+
alignment: Alignment::none(),
225+
}
226+
}
227+
228+
#[test]
229+
fn accepts_segments_within_file() -> VortexResult<()> {
230+
validate_segments_within_file(&[segment(0, 100), segment(100, 50)], 150)?;
231+
Ok(())
232+
}
233+
234+
#[test]
235+
fn rejects_segment_extending_past_end_of_file() {
236+
let err =
237+
validate_segments_within_file(&[segment(0, 100), segment(100, 51)], 150).unwrap_err();
238+
assert!(err.to_string().contains("past the end"), "{err}");
239+
}
240+
241+
#[test]
242+
fn rejects_segment_offset_length_overflow() {
243+
let err = validate_segments_within_file(&[segment(u64::MAX, 1)], u64::MAX).unwrap_err();
244+
assert!(err.to_string().contains("past the end"), "{err}");
245+
}
246+
}

vortex-file/src/footer/postscript.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ impl ReadFlatBuffer for PostscriptSegment {
121121
Ok(PostscriptSegment {
122122
offset: fb.offset(),
123123
length: fb.length(),
124-
alignment: Alignment::from_exponent(fb.alignment_exponent()),
124+
// The alignment exponent comes from the file and may be corrupt, so validate it rather
125+
// than panicking on a too-large shift (see issue #8819).
126+
alignment: Alignment::try_from_exponent(fb.alignment_exponent())?,
125127
})
126128
}
127129
}

vortex-file/src/footer/segment.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,23 @@ impl TryFrom<&fb::SegmentSpec> for SegmentSpec {
4343
Ok(Self {
4444
offset: value.offset(),
4545
length: value.length(),
46-
alignment: Alignment::from_exponent(value.alignment_exponent()),
46+
// The alignment exponent comes from the file and may be corrupt, so validate it rather
47+
// than panicking on a too-large shift (see issue #8819).
48+
alignment: Alignment::try_from_exponent(value.alignment_exponent())?,
4749
})
4850
}
4951
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use super::*;
56+
57+
#[test]
58+
fn rejects_out_of_range_alignment_exponent() {
59+
// A fuzzed segment spec can declare an alignment exponent that would overflow a usize
60+
// shift. Parsing it must return an error rather than panicking (see issue #8819).
61+
let fb_spec = fb::SegmentSpec::new(0, 0, u8::MAX, 0, 0);
62+
let err = SegmentSpec::try_from(&fb_spec).unwrap_err();
63+
assert!(err.to_string().contains("too large"), "{err}");
64+
}
65+
}

0 commit comments

Comments
 (0)