Skip to content

Fix missing bounds validation in Go Table.VectorLen and Table.Vector - #9146

Open
owvr27 wants to merge 5 commits into
google:masterfrom
owvr27:fix-go-vectorlen-bounds
Open

owvr27 wants to merge 5 commits into
google:masterfrom
owvr27:fix-go-vectorlen-bounds

Conversation

@owvr27

@owvr27 owvr27 commented Jun 20, 2026 •

Copy link
Copy Markdown

Validates every offset that Table.VectorLen and Table.Vector read out of the buffer, instead of trusting it.

Problem

Both accessors follow a chain of offsets - the field offset, the vector's relative offset, and the length the vector claims - and dereferenced every step without checking it against the buffer bounds:

func (t *Table) VectorLen(off UOffsetT) int {
	off += t.Pos
	off += GetUOffsetT(t.Bytes[off:])       // out-of-bounds read
	return int(GetUOffsetT(t.Bytes[off:]))  // out-of-bounds read
}

A FlatBuffer is not necessarily verified before it is read (the Go runtime has no Verifier), so all three values are attacker-controlled. A handful of bytes is enough to crash the process that decodes it:

// Panic in the accessor itself:
t := &flatbuffers.Table{Bytes: make([]byte, 8), Pos: 0}
t.VectorLen(100)                                          // panic: slice bounds out of range [100:8]

b := make([]byte, 20); b[0] = 200                         // relative offset past the end
(&flatbuffers.Table{Bytes: b, Pos: 0}).VectorLen(0)      // panic: slice bounds out of range [200:20]

// And through generated code, which is where this bites in practice: the
// claimed length is returned verbatim, bounds the caller's loop, and the
// element read then runs off the end of the buffer.
m := MyGame.Example.Monster.GetRootAsMonster(evil, 0)     // length field = 0x7fffffff
for i := 0; i < m.InventoryLength(); i++ { m.Inventory(i) }  // panic

The 36-byte buffer used by the tests (monsterWithInventory) reproduces all of it.

Fix

VectorLen, Vector and ByteVector now resolve that chain through one helper that validates every step, and reject a length that could not fit in the buffer even for a one-byte element type.

The checks compare against the remaining buffer space instead of adding up the result. UOffsetT is a uint32, so an attacker can pick a relative offset that makes off + rel (or off + Pos) wrap around back into the buffer and slip past a bounds check performed after the addition - with 24 + 0xffffffec the sum is 4, which is well inside the buffer:

VectorLen with a wrapping relative offset: want 0, got 8
Vector with an overflowing table position: want 0, got 36

On failure VectorLen returns 0 and Vector returns 0. 0 can never be the start of a vector's data - the smallest possible one starts at byte 4 - and it keeps the element reads that generated code performs on the result inside the buffer. Returning len(Bytes), as the Java and Python ports do, is not enough here: t.Bytes[len(Bytes):] is empty, and the t.Bytes[start+offset:] that follows still panics.

ByteVector resolves the same chain and now shares the helper, which also closes a wraparound in it that the checks performed after the addition let through.

Behaviour on valid buffers is unchanged; no allocation is added.

Tests

CheckVectorLenBoundsChecking (extended, called from TestAll) covers the corrupted length, the relative offset past the end of the buffer, the wrapping relative offset, a truncated buffer, wild offsets, an empty buffer, an overflowing table position, and the valid cases, through the raw accessors and through generated code. The cases from the first version of the check are kept.

cd tests && bash GoTest.sh
ok  	flatbuffers_test	17.499s

Against the unpatched runtime the new checks panic inside VectorLen/Vector rather than failing an assertion:

panic: runtime error: slice bounds out of range [2147483671:36]
  github.com/google/flatbuffers/go.(*Table).VectorLen(...)

Table.Offset, Table.Union and Table.Indirect are unchanged and remain unchecked; a follow-up for those would be welcome. This is hardening for buffers that were not verified - it does not replace the Verifier on untrusted input.

Note: CI's Test Swift Linux (6.2) is currently red on master for an unrelated reason (swift-crypto 5.x resolved by the Swift test job, which does not compile with the 6.2.x toolchains). That is fixed separately in #9412.

@owvr27
owvr27 requested a review from dbaileychess as a code owner June 20, 2026 17:02
@owvr27
owvr27 force-pushed the fix-go-vectorlen-bounds branch from c8bfa9d to 18c6f70 Compare August 9, 2026 16:40
@owvr27

owvr27 commented Aug 9, 2026

Copy link
Copy Markdown
Author

any updates ?

@owvr27

owvr27 commented Sep 15, 2026

Copy link
Copy Markdown
Author

Pushed the regression tests that were missing from this PR (CheckVectorLenBoundsChecking in tests/go_test.go), mirroring the structure merged for ByteVector in #8776, and updated the description with a concrete panic reproducer.

@dbaileychess could you please approve the pending workflow runs for this commit — CI has never executed on this PR (the build jobs are stuck at "Waiting for status to be reported"), likely held for first-contributor approval. Happy to address any feedback.

Table.Vector and Table.VectorLen follow a chain of offsets - the field
offset, the vector's relative offset and the length it claims - without
checking any step of it against the buffer bounds:

    off += t.Pos
    off += GetUOffsetT(t.Bytes[off:])       // out-of-bounds read
    return int(GetUOffsetT(t.Bytes[off:]))  // out-of-bounds read

A buffer that was not run through the verifier can make either of them
read past the end of the slice and panic, which takes down the process
decoding it. An attacker needs a handful of bytes to do so: a length field
of 0x7fffffff is returned verbatim, so generated code that bounds a loop
with it and then indexes the vector crashes.

Resolve the chain through a helper that validates every step, and reject a
length that does not fit in the buffer even for a one-byte element type.
The checks compare against the remaining buffer space instead of adding up
the result: UOffsetT is a uint32, so an attacker can pick a relative offset
that makes `off + rel` (or `off + Pos`) wrap around back into the buffer
and slip past a bounds check performed after the addition.

On failure VectorLen returns 0 and Vector returns 0. 0 can never be the
start of a vector's data - the smallest one starts at byte 4 - and it keeps
the element reads that generated code performs on the result inside the
buffer. Returning len(Bytes), as the Java and Python ports do, is not
enough in Go: t.Bytes[len(Bytes):] is an empty slice, and the
t.Bytes[start+offset:] that follows still panics.

ByteVector walks the same chain of offsets and now shares the helper, which
also closes a wraparound in it that the checks performed after the addition
used to let through.
Checks the cases the runtime fix handles, through the raw accessors and
through generated code: a corrupted length, a relative offset past the end
of the buffer, a relative offset that wraps the 32 bit sum back into the
buffer, a truncated buffer, wild offsets, an empty buffer and an overflowing
table position. Keeps the cases from the first version of this check.

On the unpatched runtime they panic inside VectorLen/Vector rather than
failing an assertion.
@owvr27 owvr27 changed the title Fix missing boundary validation in Go Table.VectorLen Fix missing bounds validation in Go Table.VectorLen and Table.Vector Oct 15, 2026
@owvr27

owvr27 commented Oct 15, 2026

Copy link
Copy Markdown
Author

Update: the patch on this PR is now complete, not just the first VectorLen guard.

@owvr27 asked for updates. Since the original submission, the head moved from 18c0770 to 5c268eed and the runtime fix now covers both accessors and the wraparound case:

  • Vector had no validation at all. It is now fixed as well, and this is the part that mattered most in practice: with a length field of 0x7fffffff, VectorLen returned it verbatim, generated code bounded its loop with it, and the element read then panicked.
  • The checks compare against the remaining buffer space before the addition rather than after it. UOffsetT is a uint32, so a relative offset chosen to wrap the sum back into the buffer slips past a post-addition check (24 + 0xffffffec == 4). Same for off + Pos.
  • VectorLen additionally rejects a length that could not fit in the buffer even for a one-byte element type.
  • Vector returns 0 rather than len(Bytes) on failure. 0 can never be a valid data start, and it keeps generated code's element reads in-bounds: returning len(Bytes) leaves t.Bytes[len(Bytes):] empty, and the t.Bytes[start+offset:] that follows still panics.
  • ByteVector shares the resolver now, which also closes a wraparound in it that the old post-addition checks let through.

CheckVectorLenBoundsChecking was rewritten to cover all of this through both the raw accessors and generated code, and it keeps the cases from the first version. Verified locally with bash tests/GoTest.sh (ok flatbuffers_test 17.499s, 94.9% coverage). Against the previous runtime the new checks panic inside VectorLen/Vector rather than failing an assertion.

Relation to #9411

#9411 also touches go/table.go and tests/go_test.go. I ran this PR's checks against the Go part of #9411 (post-addition guards, Vector returning len(Bytes)) and it fails five of them:

Vector with a corrupted offset: want 0, got 36
Vector with a wrapping relative offset: want 0, got 8
Vector on a truncated buffer: want 0, got 24
Vector with an overflowing table position: want 0, got 36
ByteVector should return nil for a wrapping relative offset

and generated code still crashes on the corrupted-length buffer:

Vector() returned 36 (buffer len 36)
Monster.Inventory(0) -> panic: index out of range [0] with length 0

So this is not a duplicate of #9411's Go part; it is strictly stronger. If the maintainers prefer one Go change, this one supersedes it, and the Python/Java parts of #9411 are unaffected either way.

@owvr27

owvr27 commented Oct 16, 2026

Copy link
Copy Markdown
Author

Correction to my previous comment: #9411 was revised yesterday and its Go VectorLen now uses the same overflow-safe ordering (off > n-off, checked before the addition), so the wraparound argument I made against it no longer applies. Re-running this PR's checks against its current Go revision, VectorLen passes all of them.

Two differences remain, both measured against 3fbda243:

1. Vector still returns len(Bytes) on failure, and generated code then panics anyway.

Vector() returned 36 (buffer len 36)
Monster.Inventory(0) -> panic: index out of range [0] with length 0

len(Bytes) is the right sentinel in Java (ByteBuffer throws on the next read) and it also works in Python (the slice is empty and a subsequent read raises). In Go it is not: t.Bytes[len(t.Bytes):] is an empty slice, and the GetByte/GetInt32/... that generated code performs on it indexes element [0] of that empty slice, which panics. Returning 0 keeps the read inside the buffer:

Vector() returned 0 (buffer len 36)
Monster.Inventory(0) = 20        (in-bounds)
Monster.InventoryLength() = 0

Checks that still fail against 3fbda243, all from that choice:

Vector with a corrupted offset: want 0, got 36
Vector with a wrapping relative offset: want 0, got 36
Vector on a truncated buffer: want 0, got 24
Vector with an overflowing table position: want 0, got 36

2. ByteVector is unchanged there and still accepts a wrapping relative offset.

ByteVector does off += GetUOffsetT(t.Bytes[off:]) before its bounds check, so an offset chosen to wrap the sum back into the buffer is accepted and a slice is returned from an attacker-chosen position:

ByteVector should return nil for a wrapping relative offset

Sharing one resolver across VectorLen, Vector and ByteVector fixes that too, which is the part of this PR that goes beyond #9411's Go part rather than just differing from it.

Both revisions are fine as fixes for VectorLen; this one is complete for Vector and ByteVector as well. The Python and Java parts of #9411 are orthogonal and unaffected.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant