Skip to content

Harden Parser::Deserialize and reflection verification input contracts - #9222

Open
Grolar1337 wants to merge 4 commits into
google:masterfrom
Grolar1337:harden/deserialize-input-contract
Open

Harden Parser::Deserialize and reflection verification input contracts#9222
Grolar1337 wants to merge 4 commits into
google:masterfrom
Grolar1337:harden/deserialize-input-contract

Conversation

@Grolar1337

Copy link
Copy Markdown

Four hardening fixes, each with regression tests, on a clean branch from
master (5761d6e). This PR is independent of fix/reflection-union-verify
(VerifyUnion); it does not touch VerifyUnion / union tag handling.

Commits

  1. f7863f44 Harden Parser::Deserialize entry: validate buffer length before identifier reads
    Length validation before identifier reads (fixes a reported OOB read — VRP report).
  2. 5bf610f5 Fix Parser::Deserialize double-free on duplicate .bfbs names (objects/enums/services loops)
    SymbolTable ownership on Deserialize error paths (fixes a reported double-free — VRP report).
  3. dd30a3a2 Validate base_type/element range in Type::Deserialize
    Fuzzer-found: prevents SizeOf()'s default case with out-of-range wire values.
  4. 876a5aa0 VerifyObject: reject None-typed fields instead of asserting
    Fuzzer-found: return false instead of assert.

Fixes (3) and (4) close issues found by a local fuzz target being upstreamed
(separate PR).

Summary of changes

File Change
src/idl_parser.cpp Parser::Deserialize(buf,size): reject size < FLATBUFFERS_MIN_BUFFER_SIZE before identifier reads (commit 1); Parser::Deserialize(Schema*): pop_back() from owning SymbolTable vec before delete on duplicate in the objects/enums/services loops (commit 2); Type::Deserialize: reject base_type/element outside 0..18 before the static_cast (commit 3)
include/flatbuffers/idl.h unchanged (SymbolTable::Add keeps its original behavior)
src/reflection.cpp VerifyObject: reflection::None-typed field → return false instead of FLATBUFFERS_ASSERT (commit 4)
tests/reflection_entry_security_test.cpp new regression test file (grows one test per commit, bisect-clean)
tests/reflection_test.h, tests/test.cpp, CMakeLists.txt register ReflectionEntrySecurityTest()

Fix details

1 — entry length check. Parser::Deserialize(const uint8_t*, size_t)
called BufferHasIdentifier (a 4-byte strncmp at buf + 4/8) before any
length validation. A short attacker-supplied buffer (10 bytes) made strncmp
read past the heap allocation (ASan: heap-buffer-overflow READ of size 1).
Now size < FLATBUFFERS_MIN_BUFFER_SIZE is rejected up front.

2 — Deserialize double-free. SymbolTable::Add appends before its
duplicate check; on a duplicate the Deserialize error paths deleted an
element still present in the owning vector, and ~SymbolTable freed it again.
On each Add()==true failure path we pop_back() the element (Add appended
it last) before delete. The services loop now distinguishes a failed
ServiceDef::Deserialize (never registered, plain delete) from a duplicate
Add (pop then delete). SymbolTable::Add itself is unchanged, so no other
caller changes behavior.

3 — base_type range. Type::Deserialize cast the wire base_type/
element (a signed byte, values 0..18 valid) with an unchecked
static_cast; out-of-range values (e.g. MaxBaseType == 19, negatives)
reached SizeOf()'s FLATBUFFERS_ASSERT(0) default in debug, or wrong size
metadata in release. Out-of-range values are now rejected before the cast.

4 — None-typed field. VerifyObject asserted on a field whose
base_type == reflection::None (impossible in a valid schema, but craftable
through VerifySchemaBuffer). Now treated as a verification failure
(return false).

Verification

Debug / ASan (UAF + double-free focus), detect_leaks=0

cmake -S . -B build -DFLATBUFFERS_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
  -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
cmake --build build --target flattests -j$(nproc)
ASAN_OPTIONS=detect_leaks=0 ./build/flattests

Output (relevant lines):

EntryLengthValidationTest
SymbolTableOwnershipTest
OutOfRangeBaseTypeTest
NoneFieldVerifyTest
ReflectionEntrySecurityTest: PASSED
ALL TESTS PASSED

No use-after-free / double-free reported by ASan. (LSAN with
detect_leaks=1 reports a pre-existing 48-byte leak in
CrossNamespacePackTest that is also present on pristine master
(verified in a master worktree); it is unrelated to this PR.)

Release (-DNDEBUG, default CMake Release)

cmake -S . -B build-rel -DFLATBUFFERS_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build-rel --target flattests -j$(nproc)
./build-rel/flattests

Output:

...
ReflectionEntrySecurityTest: PASSED
ALL TESTS PASSED

Regression behavior (pre-fix vs post-fix)

  • 10-byte buffer to Parser::Deserialize: pre-fix ASan heap-buffer-overflow
    in strncmp; post-fix returns false, no crash.
  • Duplicate-name .bfbs to Parser::Deserialize: pre-fix heap double-free at
    ~Parser; post-fix returns false, no crash.
  • base_type out-of-range (MaxBaseType=19) crafted Type: pre-fix
    FLATBUFFERS_ASSERT(0) in SizeOf (debug); post-fix Deserialize returns
    false.
  • None-typed field verified via reflection::Verify: pre-fix
    FLATBUFFERS_ASSERT in VerifyObject; post-fix returns false.

PR body notes for maintainers

  • Signed-off-by present on all four commits (CLA signed).
  • Branch: harden/deserialize-input-contract (from master 5761d6e).
  • reflection_verify_fuzzer harness used for fuzzing is being upstreamed in a
    separate PR; not included here.

…ifier reads

Parser::Deserialize(const uint8_t*, size_t) read the FlatBuffer file
identifier via BufferHasIdentifier (a 4-byte strncmp at buf + 4/8)
before any length or structural validation.  A short attacker-supplied
buffer made strncmp read past the end of the heap allocation (ASan:
heap-buffer-overflow READ of size 1).  The caller-supplied size was only
used to construct the Verifier, which runs after the identifier check.

Add a size < FLATBUFFERS_MIN_BUFFER_SIZE pre-check at the entry so
buffers that cannot contain the identifier are rejected before any
read.  FLATBUFFERS_MIN_BUFFER_SIZE (base.h) covers the longest form
(size-prefixed identifier: 2*sizeof(uoffset_t) + kFileIdentifierLength).

Regression test: a 10-byte buffer must be rejected without a crash.

Signed-off-by: Grolar1337 <blkyakupsait@gmail.com>
…/enums/services loops)

Parser::Deserialize's definition loops used the pattern:
    if (symbols.Add(name, def)) { delete def; return false; }
SymbolTable::Add appends the element to its owning vector before checking
for a duplicate, so on a duplicate the just-deleted pointer is still in the
vector and ~SymbolTable deletes it a second time at Parser destruction -- a
heap double-free / use-after-free reachable from a crafted .bfbs containing
duplicate object/enum/service names (which VerifySchemaBuffer does not
reject).

On each Add()==true failure path, pop the element back out of the owning
vector before deleting it (Add appended it last).  Applies to the objects,
enums and services loops and their companion Type registrations in
types_.vec.  In the services loop, distinguish the two failure modes: a
failed ServiceDef::Deserialize never registered the object (delete without
pop), while a duplicate Add did register it (pop then delete).

SymbolTable::Add itself is unchanged, so no other caller changes behavior.
Regression test: a schema whose second enum is patched to duplicate the
first's qualified name must deserialize to false without a crash.

Signed-off-by: Grolar1337 <blkyakupsait@gmail.com>
Type::Deserialize cast the wire base_type/element values to the internal
BaseType enum with an unchecked static_cast.  The wire field is a signed
byte (reflection::BaseType stored as int8_t); values outside 0..18 (e.g.
reflection::MaxBaseType == 19, or any negative value) have no case in the
internal set and previously reached SizeOf()/TypeName()/StringOf()
FLATBUFFERS_ASSERT(0) defaults in debug builds, or produced wrong
size metadata (SizeOf returning 0) in release builds.

Reject out-of-range wire values in Type::Deserialize before the cast,
using reflection::Vector64 as the largest representable internal type.

Fuzzer-found (local target being upstreamed).  Regression test: a field
type patched to base_type == MaxBaseType (19) must deserialize to false.

Signed-off-by: Grolar1337 <blkyakupsait@gmail.com>
VerifyObject's field-type switch asserted on reflection::None.  A valid
schema never contains a None-typed field, so the branch was treated as
unreachable -- but a crafted schema can carry base_type == None through
VerifySchemaBuffer (structural verification does not reject it), turning
reflection::Verify into an assertion failure (debug) or a silently skipped
field (release).

Return false for a None-typed field so the verifier reports the schema as
invalid instead of aborting.

Fuzzer-found (local target being upstreamed).  Regression test: a field
patched to base_type == None must make reflection::Verify return false
without asserting.

Signed-off-by: Grolar1337 <blkyakupsait@gmail.com>
@github-actions github-actions Bot added c++ codegen Involving generating code from schema labels Sep 4, 2026
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant