Skip to content

[Go] unsafe Varint fast-path writes/reads more bytes than reserved in struct serializer #3612

@ayush00git

Description

@ayush00git

Search before asking

  • I had searched in the issues and found no similar issues.

Version

0.17.0

Component(s)

Go

Minimal reproduce step

The following self-contained Go test demonstrates the write-path issue using a sentinel-value check. It can be added to go/fory/buffer_test.go:

func TestUnsafePutVarUint32OOBWrite(t *testing.T) {
    // Construct a ByteBuffer backed by a slice where len == writerIndex + 5.
    // This replicates the condition where Reserve(5) is satisfied without expanding
    // the buffer, but UnsafePutVarUint32 then writes 8 physical bytes.

    const sentinelByte = byte(0xAB)
    const totalCap = 16 // backing large enough to observe the overwrite safely
    backing := make([]byte, totalCap, totalCap)

    // Fill bytes [5, totalCap) with sentinel values.
    for i := 5; i < totalCap; i++ {
        backing[i] = sentinelByte
    }

    // Simulate struct.go's fast-path: Reserve(MaxVarintSize=5) on a tight buffer.
    // Expose only 5 bytes of len so Reserve(5) returns immediately.
    buf := NewByteBuffer(backing[:5]) // len=5, cap=16

    // Replicate exactly what struct.go:342 does for a uint32 varint field:
    buf.Reserve(5) // needed(5) <= len(5) → returns immediately, NO growth

    // A uint32 value >= 2^28 encodes to 5 varint bytes → triggers 8-byte bulk write.
    written := buf.UnsafePutVarUint32(0, 1<<28)
    if written != 5 {
        t.Fatalf("expected 5 bytes written, got %d", written)
    }

    // Bytes at indices 5, 6, 7 are past Reserve(5)'s guaranteed region.
    // If they differ from the sentinel, the 8-byte write overflowed the reserved region.
    overwritten := 0
    for i := 5; i < 8; i++ {
        if backing[i] != sentinelByte {
            t.Logf("backing[%d] = 0x%02X (overwritten, was 0x%02X)", i, backing[i], sentinelByte)
            overwritten++
        }
    }
    if overwritten > 0 {
        t.Errorf("UnsafePutVarUint32 wrote %d byte(s) past Reserve(5)'s guaranteed region "+
            "(contract requires Reserve(8) per buffer.go:661)", overwritten)
    }
}

What did you expect to see?

  • Expected: test passes — no bytes written past index 4.

What did you see instead?

  • Actual: backing[5], backing[6], backing[7] are overwritten — 3 bytes past the reserved region.

Note on the test design: cap=16 is used intentionally so the overflow lands within the backing array and the test runs safely without corrupting other heap objects. In production, when a ByteBuffer is created from an inbound network slice via NewByteBuffer(inboundSlice) (where len == cap), those 3 bytes escape the Go allocation and corrupt adjacent heap memory.

Anything Else?

The Go runtime's struct serialization fast-path in struct.go violates the documented contract of UnsafePutVarUint32 and UnsafeReadVarUint32 in buffer.go. Specifically:

  • UnsafePutVarUint32 documents that the caller must call Reserve(8) (because it performs an 8-byte bulk write for 5-byte varints), but struct.go only calls Reserve(MaxVarintSize), which is 5 for uint32/int32 varint fields.
  • UnsafeReadVarUint32 physically reads 8 bytes, but the fast-path guard in struct.go only checks remaining() >= MaxVarintSize (which can be 5).
    This is a correctness bug / contract violation — the mismatch can cause writes past the end of a buffer's allocated memory in any situation where the buffer's capacity is not generously over-sized (e.g. when a ByteBuffer is constructed from a pre-existing []byte via NewByteBuffer).

Are you willing to submit a PR?

  • I'm willing to submit a PR!

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions