Repository navigation
Conversation
… verifier
The Rust `reflection` crate exposes a memory-safe API (`SafeBuffer` ->
`SafeTable` / `SafeStruct`) that verifies a buffer once and then performs every
read through `unsafe` code justified by "the buffer was verified during
construction". That justification did not hold. A buffer that passes
verification could drive the entirely safe `SafeTable::get_any_field_string()`
into an out-of-bounds heap read.
Three defects:
1. `get_field_loc` returned `table.loc() + field_offset`, where `field_offset`
is read from the vtable inside the buffer being inspected, without checking
the result against the buffer length. Every `get_any_*` accessor then read a
scalar there, and `get_any_value_integer` / `get_any_value_float` dispatched
straight to `Follow::follow`, which only `debug_assert!`s the remaining
length (`endian_scalar.rs:173`). In release builds that is an unchecked
`copy_nonoverlapping`, i.e. an out-of-bounds read of up to 8 bytes whose
bytes are then returned to the caller in a `String`.
The crate already had the correct pattern on the write side
(`set_any_value_integer` range-checks before emplacing); the read side was
missing it. Fixed at the single chokepoint all eight field accessors share
(three read paths and five `set_*` write paths), plus a defence-in-depth
check in the two scalar readers, which the `*_in_struct` accessors need
because they compute the location directly.
2. `verify_struct` registered nested structs with `HashMap::insert`. For
`struct Outer { a: St; ... }` the child sits at struct offset 0, so it
resolves to the same buffer position as the parent -- the key the caller had
just mapped to `Outer`. The unconditional overwrite rebound the parent to
the child's schema object, so by-name lookups on the parent resolved against
the wrong object: legitimate fields returned `FieldNotFound` while the
child's fields were read instead, with no error signal. Now `or_insert`, so
the parent mapping is authoritative.
3. `verify_union` used the attacker-controlled union discriminant to index the
schema's enum values with no bounds check. `Vector::get` asserts
`idx < self.len()`, so a discriminant beyond the enum's value count panicked
from inside the safe `SafeBuffer::new`. Now range-checked.
All three report through the existing `FlatbufferError` /
`InvalidFlatbuffer::RangeOutOfBounds` plumbing. No API change, no new `unsafe`,
no new dependency; `get_field_loc` is a private `unsafe fn`.
Adds `rust/reflection/tests/safe_buffer_regression.rs`, which is
self-contained -- the reflection schema for `mini.fbs` and a valid `Root` seed
are embedded as byte literals, so no `flatc` is needed to run the tests. It
covers a struct nested at offset 0, an out-of-range union discriminant, and the
general property that no single-byte mutation of a schema-valid buffer panics
anywhere in the safe read path. All four tests fail before this change and pass
after.
The crate previously had no Rust fuzz target and no unit tests; the 13 fuzz
targets in tests/fuzzer are all C++.
owvr27
force-pushed
the
fix-reflection-verifier-soundness
branch
from
October 4, 2026 21:20
887d713 to
c5043b7
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Out-of-bounds read in the safe reflection API —
google/flatbuffersSummary
The Rust
reflectioncrate exposes a memory-safe API (SafeBuffer→SafeTable/SafeStruct) that verifies a buffer once and then performs every read throughunsafecode justified by the comment "the buffer was verified during construction". That
justification did not hold. A buffer that passes verification can drive the entirely safe
SafeTable::get_any_field_string()into an out-of-bounds heap read.733e432b, 2025-01-15, PR #8102 — latent 626 days (~20.6 months), never functionally reviewed sincerust/reflection(flatbuffers-reflectionv0.1.0, published, ~128k recent downloads)Finding 1 (primary) — out-of-bounds read via the 100% safe API
Location:
rust/reflection/src/lib.rs,get_field_loc(),get_any_value_integer(),get_any_value_float()get_field_loccomputed a field's absolute location from the vtable inside the bufferbeing read and returned it without comparing it against the buffer length:
All three
get_any_field_*accessors then read a scalar there, and the scalar readersdispatched straight to
Follow::follow. The underlying primitive guards only with adebug_assert(flatbuffers/src/endian_scalar.rs:173), which is compiled out in release:So in an optimised build this is an unchecked
copy_nonoverlappingreading up to 8 bytesfrom an attacker-influenced offset — and those bytes are then returned to the caller inside
a
String.Reachability.
get_any_value_integeris reached from the catch-all arm ofget_any_value_string(lib.rs:678), which recurses through nested tables and structs andis called by the safe
SafeTable::get_any_field_string(). Nounsafeis required on thecaller's side.
Reproducer
51 bytes (
harness/regress/oob_read.bin):Built without debug assertions, under AddressSanitizer:
With debug assertions on, the same input trips
endian_scalar.rs:173: insufficient capacity for emplace_scalar, needed 4 got 3.Finding 2 — out-of-bounds write in the setters
Location:
rust/reflection/src/lib.rs,set_any_value_integer()/set_any_value_float()The same missing bounds check as Finding 1, on the write path — and it is worse, because
it corrupts memory and reports success. The guard was:
That only proves the buffer is large enough for the type somewhere; it says nothing about
field_loc. The code then does&mut buf[field_loc..]and hands the resulting short sliceto
emplace_scalar(), which copiessizebytes guarded only bydebug_assert!. A fieldwithin
size-1bytes of the end of the buffer is therefore written past the end of theslice, in release builds, while the function returns
Ok(()).Reproduced with a canary (an
0xAA-filled backing array, so writes past the validated sliceare observable), on the
Root.nfield of the test schema with the buffer truncated to leave2 bytes of headroom:
With the patch:
Both setters now validate
field_loc + size <= buf.len(), matching the read path.Note this one was not caught by my first pass: fixing Finding 1's
get_field_locboundsfield_loc <= buf.len()but does not guaranteefield_loc + size <= buf.len(), so thewrite overflow survived it. Finding it required auditing the
set_*family specificallyrather than trusting the read-side fix to cover the class.
Finding 3 — verifier index corruption (schema type confusion)
Location:
rust/reflection/src/reflection_verifier.rs:184SafeBuffermaps buffer position → schema object index so by-name lookups know whichobject a location belongs to.
verify_tableregisters the parent;verify_structthenregistered nested structs with an unconditional
insert:For
struct Outer { a: St; b: short; }the childais at struct offset 0, sofield_pos == struct_pos— the key the caller had just mapped toOuter. The overwriterebound the parent to the child's schema object.
This is not limited to offset 0 — any nested struct whose field offset resolves onto an
already-registered position triggers it. It also breaks ordinary correct usage: the
sanity test in the new test file fails before this change for exactly this reason.
Finding 4 — unchecked attacker-controlled index into the schema
Location:
rust/reflection/src/reflection_verifier.rs,verify_union()Vector::getisassert!(idx < self.len()), so a discriminant beyond the enum's valuecount panics from inside the safe
SafeBuffer::new(). One byte is enough.The fix
lib.rsget_field_loctable.loc() + field_offsetagainstbuf.len(). Single chokepoint for all eight field accessors — three read paths and fiveset_*write paths.lib.rsget_any_value_integer/_floatFollow::follow; needed because the*_in_structaccessors compute the location directly.lib.rsset_any_value_integer/_floatfield_loc + size <= buf.len()instead ofbuf.len() < size, closing the out-of-bounds write.reflection_verifier.rs:184HashMap::insert→entry().or_insert()so a nested struct cannot rebind its parent.reflection_verifier.rs:367tests/RustTest.shrust/reflectioninto CI. It was not being run at all — the script only coveredrust_serialize_test,rust_no_std_compilation_testandrust_usage_test.All fixes report through the existing
FlatbufferError/InvalidFlatbuffer::RangeOutOfBoundsplumbing. No API change, no new
unsafe, no new dependency;get_field_locis a privateunsafe fn.Note the crate already had the correct pattern on the write side —
set_any_value_integerrange-checks before emplacing. The read side was simply missing it.
Tests
rust/reflection/tests/safe_buffer_regression.rsis self-contained — the reflectionschema for
mini.fbsand a validRootseed buffer are embedded as byte literals, so noflatcis needed to run it. Cases:seed_is_valid_and_readable— sanity (fails before: Finding 2 breaks normal reads)nested_struct_at_offset_zero_does_not_hijack_parent_lookupout_of_range_union_discriminant_is_rejected_not_panickingno_single_byte_mutation_panics_in_the_safe_read_path— asserts the general propertyrather than one magic byte
set_scalar_near_end_of_buffer_is_rejected_not_written— the canary check for Finding 2Fuzzing under ASan (two schemas —
benchandbench2, the latter covering plain enums,fixed-size arrays, 64-bit vectors, two independent unions, 3-level struct nesting and
required-field enforcement):
The unpatched crashes on both schemas traced to the same root causes, and the patched
build is clean across both — evidence the fixes generalise rather than patch single inputs.
How this got here, and how long it has been there
All three defects arrived in a single commit — the one that introduced the crate:
733e432b— 2025-01-15 — "Rust full reflection (Rust full reflection #8102)".rust/reflectiondid notexist before this commit; all three issues are present in its very first version,
including the
// SAFETY: the buffer was verified during constructioncomments insafe_buffer.rs. The safety contract was unsound from day one.Since then only three commits have touched
rust/reflectionat all: a dependency /Android-build change, a bulk formatting-only change, and one unrelated verifier bug fix
(
21b706b6, "swapped argument order innew_inconsistent_unioncalls",#9001). The fourfunctions this patch touches have never been functionally modified.
That combination — 20+ months, ~5,000 lines added once and then functionally untouched, with
a documented-but-false safety invariant — is why I fixed this at the chokepoint rather than
at the individual reads. There was no prior review pass that could have caught it.
Worth noting:
21b706b6is a verifier fix in the same function area as Finding 3, mergedvia GitHub PR, so there is an established path for a change of exactly this kind.
How the bugs were found
The crate had 13 fuzz targets, all C++, and no Rust fuzz target and no unit tests at
all. The 150-
unsafeRust path that parses untrusted buffers had no coverage whatsoever.The harness used here is offered as that missing coverage.
Known limitations, stated deliberately
buf_loc_to_obj_idxis keyed by buffer position alone. A struct nested at offset 0genuinely shares a position with its parent, so one key can map to two schema objects.
Fix 2 keeps the parent authoritative — the severe direction, silent wrong values — but
the child at offset 0 is then no longer resolvable by name through
SafeStruct. Thecomplete fix is to carry the object index in
SafeTable/SafeStruct(derivable fromfield.type_().index()at access time) and retire the map, removing the collision classoutright. That is an API change and is better discussed than landed silently.
SafeTableandSafeStructare not re-exported.safe_buffer.rsdeclares thempubinside a private module and re-exports only
SafeBuffer, so they are returned by publicmethods but cannot be named by callers. Happy to fix in this PR if wanted.
get_any_value_string'sObjbranchfollows a uoffset and then reads the pointee's vtable, relying on the verifier having
checked that table. The three findings above were the concrete gaps and the patched build
is clean over ~3.8M executions, but the general principle — every indirect read
range-checked at the point of use rather than assumed from verification — is worth a
maintainer-level audit.
Note on scope
Finding 1 is the security issue (memory-safety, demonstrated with ASan). Finding 2 is an
integrity/correctness bug — its reads stay within the buffer. Finding 3 is a panic. The
change is framed around Finding 1; 2 and 3 are defence-in-depth from the same audit and are
not claimed as separately exploitable.