Skip to content

[Rust] Verify nested_flatbuffer fields - #9230

Open
sanil18 wants to merge 1 commit into
google:masterfrom
sanil18:fix-nested-flatbuffer-verification
Open

[Rust] Verify nested_flatbuffer fields#9230
sanil18 wants to merge 1 commit into
google:masterfrom
sanil18:fix-nested-flatbuffer-verification

Conversation

@sanil18

@sanil18 sanil18 commented Sep 8, 2026

Copy link
Copy Markdown

The problem

A field with the nested_flatbuffer attribute is verified only as Vector<u8>.
The generated ..._nested_flatbuffer() accessor then follows those bytes as a
FlatBuffer root with no further checking — so on a buffer flatbuffers::root()
has already returned Ok for, every offset inside the nested buffer is still
attacker-controlled.

The result is that safe generated accessors read outside the buffer's
allocation.

Reproduction

Against the published flatbuffers = "25.12.19" crate, from a binary that is
#![forbid(unsafe_code)]:

root_as_monster()     : Ok (VERIFIER ACCEPTED)
nested slice offset   : 148 .. 160
bytes after it in buf : 0
calling nm.hp() (safe accessor) ...

Under Miri, with -Zmiri-disable-stacked-borrows so this is a spatial error and
not an aliasing technicality:

error: Undefined Behavior: memory access failed: attempting to access 2 bytes,
but got alloc3344+0x9f which is only 1 byte from the end of the allocation
   --> flatbuffers-25.12.19/src/endian_scalar.rs:182:5
    |
182 |     core::ptr::copy_nonoverlapping(s.as_ptr(), mem.as_mut_ptr() as *mut u8, size);

  0: flatbuffers::read_scalar::<u16>
  1: flatbuffers::read_scalar_at::<u16>
  2: flatbuffers::vtable::VTable::<'_>::num_bytes
  3: flatbuffers::vtable::VTable::<'_>::get
  4: flatbuffers::Table::<'_>::get::<i16>
  5: my_game::example::Monster::<'_>::hp     <-- safe accessor
  6: main

read_scalar bounds-checks with a debug_assert! only, so release builds read
past the end. In debug the same input trips that assert with insufficient capacity for emplace_scalar, needed 2 got 1 — showing directly that 2 bytes are
read from a 1-byte slice.

A present-but-empty nested vector has the same root cause: accepted by the
verifier, then panics in the accessor with range end index 4 out of range for slice of length 0.

Why this is a soundness bug rather than hardening

docs/source/languages/rust.md promises exactly what this breaks:

The safe Rust functions to interpret a slice as a table (root, ...) verify
the data first. ... intended to be safe for use on flatbuffers from
untrusted sources.

The generated accessor functions access fields over offsets ... without any
further bounds checking. All of the safe Rust APIs ensure the verifier is run
over these flatbuffers before accessing them.

Reading a FlatBuffer does not touch any memory outside the original
buffer.

And the generated accessor carries this safety comment today (unchanged by this
PR):

// Safety:
// Created from a valid Table for this object
// Which contains a valid flatbuffer in this slot
unsafe { <::flatbuffers::ForwardsUOffset<Monster<'a>>>::follow(data.bytes(), 0) }

Nothing established "contains a valid flatbuffer in this slot". This PR makes
that comment true.

C++ already does this

Verifier::VerifyNestedFlatBuffer runs a nested verifier, gated on
check_nested_flatbuffers, which defaults to true. A buffer Rust accepts
today is already rejected by every C++ peer, so this brings Rust in line rather
than inventing a policy.

The change

Runtime (rust/flatbuffers/src/verifier.rs)

  • VerifierOptions::check_nested_flatbuffers, default true — same name and
    default as C++. It replaces the existing // Ignore nested flatbuffers, etc?
    TODO.
  • NestedFlatBuffer<T>: a verification marker implementing Verifiable that
    checks the [ubyte] vector, then the buffer inside it. It deliberately does
    not implement Follow, so it can only ever be used for verification.
  • Verifier::verify_nested_buffer, which runs a verifier over the nested slice.

Generator (src/idl_gen_rust.cpp)

Emit ForwardsUOffset<NestedFlatBuffer<Root>> instead of
ForwardsUOffset<Vector<u8>> for the verifier slot of such a field. Accessor
return types are untouched — 4 lines change across the two generated files.

Generated code was regenerated with the patched flatc so the effect of the
generator change is visible in the diff, and scripts/clang-format-git.sh
leaves the C++ change unmodified.

Two details worth reviewing

Budgets cross the nesting boundary. Offsets inside a nested buffer are
relative to its own start, so the verifier runs against the nested slice:
self.buffer is swapped for it and restored afterwards. Swapping rather than
building a second Verifier keeps depth, num_tables and apparent_size
and anything added later — accumulating in one place, with no per-field copy to
keep in sync.

That matters, because porting the C++ design literally (fresh verifier, fresh
budgets per level) would introduce a new DoS: every level resets max_depth.
Measured on a 200-level chain of nested Monsters — budgets reset:
thread 'main' has overflowed its stack; budgets carried:
Nested table depth limit reached. on the same input.

The same carry stops nesting becoming a work amplifier: range_in_buffer
charges apparent_size and visit_table charges num_tables, so nested bytes
are billed rather than verified for free.
nested_flatbuffer_work_counts_toward_apparent_size,
nested_flatbuffer_depth_counts_toward_max_depth and
nested_flatbuffer_tables_count_toward_max_tables assert this by measuring the
budgets rather than hard-coding thresholds.

No lookup that can fail quietly. The generator uses
field.nested_flatbuffer, the root type the parser already resolved, rather
than re-resolving the attribute string. A name lookup that missed would fall
back to Vector<u8> and silently emit an unverified field — the exact failure
mode this PR exists to remove.

Compatibility

  • Generated code: 4 lines, verified byte-identical to patched-flatc output.
  • Accessor signatures: unchanged. Only the Verifiable slot differs.
  • No unsafe added: the patch adds zero lines containing unsafe.
  • No allocations added: both alloc checks pass unchanged.
  • Empty nested vector is now rejected. Matches C++
    (>= FLATBUFFERS_MIN_BUFFER_SIZE), and it previously panicked in the accessor,
    so nothing working depended on it.
  • The one case that can turn working Rust-only code red: an application that
    puts the attribute on a field but stores bytes that are not a flatbuffer of
    that type, and only ever reads them through the plain [ubyte] accessor. I
    think rejecting that is correct — the attribute is a declaration about the
    contents, and C++ has enforced it by default for years — but it is a real
    change, and it is a one-line opt-out.
  • VerifierOptions is not #[non_exhaustive], so adding a public field is
    technically breaking for struct-literal construction. Everything in-tree uses
    ..Default::default(). Flagging rather than adding #[non_exhaustive]
    unilaterally — happy to if you'd prefer.

Tests

integration_test.rs — valid nested buffer still accepted; invalid UTF-8,
out-of-bounds offsets and truncation rejected; opt-out works; empty/absent
pinned; a >200 KB nested payload still verifies; depth, max_tables and
max_apparent_size all shown to count nested work.

nested_flatbuffer_invariant.rs — a deterministic 4,000-mutation sweep over the
attacker-controlled payload asserting every buffer the verifier accepts survives
a full traversal without panicking or yielding a non-UTF-8 &str (231 accepted,
0 misbehaved). A second sweep corrupts the outer buffer 20,000 times and
asserts verification always returns rather than panicking.

The budget tests each carry a control asserting the same budget suffices when
the nested check is off, and the invariant sweep carries
control_disabling_nested_check_reintroduces_the_problem, which asserts the old
path still does misbehave. If these ever stop reaching the behaviour they
guard, the suite goes red instead of passing vacuously.

The four sweep/large-payload tests are #[cfg(not(miri))] // slow., matching
existing use in this crate, since RustTest.sh runs cargo miri test over the
whole suite. The targeted nested tests still run under Miri and pass clean.

Credit

#8291 reported this root cause in April 2024, framed as a panic, and was
auto-closed as stale. Credit to @Nekrolm.


Noticed while working here, not addressed: Verifier::reset() assigns
num_tables twice and never clears apparent_size. It has no in-tree callers
and only makes a reused verifier stricter, so it is not a soundness issue —
happy to send separately.

@google-cla

google-cla Bot commented Sep 8, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions Bot added c++ rust codegen Involving generating code from schema labels Sep 8, 2026
@sanil18 sanil18 closed this Sep 8, 2026
@sanil18 sanil18 reopened this Sep 8, 2026
@sanil18
sanil18 force-pushed the fix-nested-flatbuffer-verification branch 2 times, most recently from b352dec to b51c883 Compare September 9, 2026 03:46
A field carrying the `nested_flatbuffer` attribute was verified only as
`Vector<u8>`. The generated `..._nested_flatbuffer()` accessor follows
those bytes as a FlatBuffer root without any further checking, so on a
buffer that `root()` returned Ok for, every offset inside the nested
buffer was still attacker-controlled.

The consequence is that safe generated accessors read outside the
buffer's allocation. Under Miri, `Monster::hp()` on such a buffer reports
"attempting to access 2 bytes, but got alloc+0x9f which is only 1 byte
from the end of the allocation". `read_scalar` guards that read with a
debug_assert only, so release builds read past the end. A present-but-
empty nested vector was likewise accepted and then panicked in the
accessor.

This contradicts docs/source/languages/rust.md, which states that the
safe APIs are "intended to be safe for use on flatbuffers from untrusted
sources", that "All of the safe Rust APIs ensure the verifier is run over
these flatbuffers before accessing them", and that the generated
accessors "access memory without any further bounds checking".

C++ has verified nested buffers by default for years via
Verifier::VerifyNestedFlatBuffer, so buffers Rust accepted here are
already rejected by any C++ peer.

Adds VerifierOptions::check_nested_flatbuffers (default true, matching
C++), a NestedFlatBuffer<T> verification marker, and emits it from the
Rust generator for the verifier slot of such fields. Accessor return
types are unchanged.

Offsets inside a nested buffer are relative to its own start, so the
verifier runs against the nested slice: `self.buffer` is swapped for it
and restored afterwards. Swapping rather than building a second Verifier
keeps depth, num_tables and apparent_size accumulating in one place, so a
chain of nested buffers cannot reset them.

That matters: porting the C++ design literally, with a fresh verifier and
fresh budgets per level, would introduce a new DoS. Measured on a
200-level chain of nested Monsters, budgets reset gives "thread 'main'
has overflowed its stack"; budgets carried gives DepthLimitReached on the
same input. Nested bytes are also billed to apparent_size rather than
verified for free, so nesting cannot multiply the verification work a
small message buys.

Tested: Rust suite and no_std suite pass (332 each); Miri clean on the
nested paths; serde and both alloc checks unchanged; generated output
verified byte-identical to patched-flatc output.

Fixes the root cause reported in google#8291.
@sanil18
sanil18 force-pushed the fix-nested-flatbuffer-verification branch from b51c883 to 438030c Compare September 9, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ codegen Involving generating code from schema rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant