Skip to content

GH-1421: reject negative FieldNode length during deserialization - #1422

Open
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:fix/reject-negative-field-node-length
Open

andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:fix/reject-negative-field-node-length

Conversation

@andygrove

@andygrove andygrove commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

What's Changed

ArrowFieldNode previously accepted a negative length. Because the IPC
(MessageSerializer.deserializeRecordBatch), JSON (JsonFileReader), and C Data
(ArrayImporter) deserialization paths construct ArrowFieldNode directly from
externally supplied metadata, a malformed or hostile input could set a negative node
length that then flowed into buffer-size math and buffer writes:

  • BitVectorHelper.getValidityBufferSizeFromCount uses an unsigned shift, so a small
    negative length becomes a ~512 MB allocation request.
  • BitVectorHelper.loadValidityBuffer then calls ArrowBuf.setOne(0, length / 8) with a
    negative byte count. With bounds checking disabled
    (arrow.enable_unsafe_memory_access=true) this reaches Unsafe.setMemory with the
    negative value reinterpreted as a huge unsigned length (memory corruption); with bounds
    checking enabled it throws only after a large buffer has been allocated and the vector's
    previous validity buffer already released.

This PR:

  • Rejects a negative length, and a nullCount below the -1 "unknown" sentinel, in the
    ArrowFieldNode constructor.
  • Narrows both values with Math.toIntExact even when bounds checking is disabled. In that
    mode checkedCastToInt is a plain cast, so a C Data length such as 2^32 - 100 passed the
    check and was stored as -100.
  • Rejects a negative count in JsonFileReader as soon as it is read, for column and batch
    counts. A column's count sizes its buffers before the ArrowFieldNode is built, so the
    constructor check alone does not cover the JSON path.

Closes #1421.

A negative ArrowFieldNode length flows unchecked into buffer-size math
(getValidityBufferSizeFromCount uses an unsigned shift) and into
ArrowBuf.setOne/setZero, risking an oversized allocation and, with bounds
checking disabled, memory corruption via Unsafe.setMemory. The IPC, JSON,
and C Data deserialization paths all construct ArrowFieldNode from external
metadata, so validate length at that single chokepoint.

Closes apache#1421
@jbonofre jbonofre added the bug-fix PRs that fix a big. label Oct 31, 2026
@jbonofre jbonofre changed the title fix(vector): reject negative FieldNode length during deserialization GH-1421: reject negative FieldNode length during deserialization Oct 31, 2026
@github-actions github-actions Bot added this to the 20.0.0 milestone Oct 31, 2026
Comment on lines +39 to +44
// A negative length is never valid and, if accepted, flows unchecked into buffer size
// computations (BitVectorHelper.getValidityBufferSizeFromCount performs an unsigned shift)
// and into ArrowBuf.setOne/setZero, which can corrupt memory when bounds checking is
// disabled. Reject it at this single chokepoint shared by every deserialization path
// (IPC, JSON, C Data). nullCount is intentionally not checked here: VectorUnloader uses -1
// as a sentinel meaning "null count not included".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this comment really helps; at the very least it should be significantly trimmed. Also we could validate nullCount >= -1 at least?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the comment. The -1 sentinel is now documented on the nullCount param, and the constructor rejects nullCount < -1 (e593bb3).

Comment on lines +28 to +31
// A negative length that still fits in an int must be rejected. Left unchecked it flows into
// BitVectorHelper.getValidityBufferSizeFromCount (unsigned shift -> ~512MB) and into
// ArrowBuf.setOne(0, length / 8) with a negative length, corrupting memory when bounds
// checking is disabled. See the deserialization paths in MessageSerializer and JsonFileReader.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the test comments too.

Comment on lines 45 to 47
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);
this.length = checkedCastToInt(length);
this.nullCount = checkedCastToInt(nullCount);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check runs on the long before it's narrowed, but checkedCastToInt only uses Math.toIntExact when bounds checking is on. With arrow.enable_unsafe_memory_access=true it's a plain (int) cast. A positive long whose low 32 bits are negative passes the check and is stored as a negative int, in the same mode this PR is meant to protect.

IPC is safe because MessageSerializer.deserializeRecordBatch rejects values outside int range before it calls this. C Data isn't: ArrayImporter passes snapshot.length, an int64 from the producer, straight in. For example, length = 4294967196 (2^32 - 100) passes the check and becomes -100.

Narrowing unconditionally fixes it. Doing the same for nullCount still allows the -1 sentinel:

Suggested change
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);
this.length = checkedCastToInt(length);
this.nullCount = checkedCastToInt(nullCount);
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);
this.length = Math.toIntExact(length);
this.nullCount = Math.toIntExact(nullCount);

The static import of checkedCastToInt (L19) becomes unused after this. A test with new ArrowFieldNode((1L << 32) - 100, 0) would help too. It won't fail without the fix in the default test run, which has bounds checking on, but it pins the expected behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in e593bb3: both values are narrowed with Math.toIntExact, and the checkedCastToInt import is gone. Added testLengthAboveIntRangeRejected and testNullCountAboveIntRangeRejected. As you said, they pass on the old code in the default run; with ARROW_ENABLE_UNSAFE_MEMORY_ACCESS=true they fail on the old code and pass now.

Comment on lines +42 to +43
// disabled. Reject it at this single chokepoint shared by every deserialization path
// (IPC, JSON, C Data). nullCount is intentionally not checked here: VectorUnloader uses -1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON path isn't covered here, because JsonFileReader.readFromJsonIntoVector uses count before it builds the ArrowFieldNode:

  • vector.setInitialCapacity(valueCount) (L890)
  • readIntoBuffer(...) → BIT reader → getValidityBufferSizeFromCount(-100). That is (-93) >>> 3, about 512 MB, which is then allocated and fully zeroed with setZero
  • BitVectorHelper.getNullCount then scans that buffer

Only after all that does new ArrowFieldNode(valueCount, nullCount) (L921) throw. The release loop at the end of the method is skipped, so vectorBuffers also leaks. A JSON file with "count": -100 still triggers the large allocation, and leaks it.

Could you check it right after it's read?

int valueCount = readNextField("count", Integer.class);
if (valueCount < 0) {
  throw new IllegalArgumentException("Expected count >= 0, got " + valueCount);
}

The batch-level count at L199 and L227 goes straight into setRowCount with the same problem. It would also be good to reword this comment so it doesn't say JSON is covered.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a readCount() helper in e593bb3 that rejects a negative count as soon as it's read, for the column count and both batch counts (read() and read(VectorSchemaRoot), which dictionary batches also use). The comment claiming JSON coverage is gone. TestJSONFile has a test per site; the column-count one reads through a 1 MB allocator and failed on the old code with OutOfMemoryException on the 536,870,900-byte validity buffer.

// disabled. Reject it at this single chokepoint shared by every deserialization path
// (IPC, JSON, C Data). nullCount is intentionally not checked here: VectorUnloader uses -1
// as a sentinel meaning "null count not included".
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check runs on the long before it's narrowed, but the checkedCastToInt only uses Math.toIntExact when bounds checking is on.
With arrow.enable_unsafe_memory_access=true it's a plain (int) cast. A positive long whose low 32 bits are negative passes the check and is stored as a negative int, in the same mode this PR is supposed to protect.

IPC is safe because MessageSerializer.deserializeRecordBatch rejects values outside int range before it calls this. C Data is not: ArrayImporter passes snapshot.length, an int64 from the producer, straight in.
For example length = 4294967196 (2^32 - 100) passes the check and becomes -100.

Narrowing unconditionally should fixes it. Doing the same for nullCount still allows the -1 sentinel:

Suggested change
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);
this.length = Math.toIntExact(length);
this.nullCount = Math.toIntExact(nullCount);

The static import of checkedCastToInt becomes unused after this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the thread above (#1422 (comment)); addressed there.

Comment on lines +42 to +43
// disabled. Reject it at this single chokepoint shared by every deserialization path
// (IPC, JSON, C Data). nullCount is intentionally not checked here: VectorUnloader uses -1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON path is not covered here, because JsonFileReader.readFromJsonIntVector uses count before it builds the ArrowFieldNote:

  • vector.setInitialCapacity(valueCount)
  • readIntBuffer(...) -> BIT reader -> getValidityBufferSizeFromCount(-100). That is (-93) >>> 3, about 512 MB, which is then allocated and fully zeroed with setZero
  • BitVectorHelper.getNullCount then scans that buffer

Only after all that does new ArrowFieldNode(valueCount, nullCount) throw. The release loop at the end of the method is skipped, so vectorBuffers also leaks. A JSON file with "count": -100 still triggers the large allocation, and leaks it.

Could we check it right after it's read?

int valueCount = readNextField("count", Integer.class);
if (valueCount < 0) {
  throw new IllegalArgumentException("Expected count >= 0, got " + valueCount);
}

The batch level count at lines 199 and 227 goes straight into setRowCount with the same problem. It would also be good to reword this comment so it doesn't say JSON is covered.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the thread above (#1422 (comment)); addressed there.

ArrowFieldNode narrowed its arguments with checkedCastToInt, a plain cast
when bounds checking is disabled, so a C Data length such as 2^32 - 100
passed the length check and was stored as -100. Narrow with
Math.toIntExact unconditionally, and reject a null count below the -1
"unknown" sentinel.

JsonFileReader sized a column's buffers from its count before building
the ArrowFieldNode, so a negative count still allocated about 512 MB, and
batch counts went straight into setRowCount. Reject a negative count as
soon as it is read.
@andygrove
andygrove requested a review from jbonofre November 1, 2026 14:56
@jbonofre

jbonofre commented Nov 1, 2026

Copy link
Copy Markdown
Member

Thanks @andygrove, let me do a new pass 😄

super();
this.length = checkedCastToInt(length);
this.nullCount = checkedCastToInt(nullCount);
Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check now throws inside MessageSerializer.deserializeRecordBatch(RecordBath, ArrowBuf), before that method releases body at the end. A negative node length now leaks the body buffer. Before this change the same input loaded without error and didn't leak.

I reproduced it with a stream whose FieldNode.length is patched to -100. ArrowStreamReader and ArrowFileReader both throw and leave 8192 bytes allocated, and allocator.close() fails with "Memory was leaked". Flight goes through the same call: ArrowMessage.asRecordBatch() retains underlying and relies on deserializeRecordBatch to release it.

Could we release body in a finally? ArrowRecordBatch retains the slices it keeps, so the release is correct on both paths:

  public static ArrowRecordBatch deserializeRecordBatch(RecordBatch recordBatchFB, ArrowBuf body)
      throws IOException {
    try {
      // ... existing node/buffer/compression/variadic parsing, unchanged ...
      return new ArrowRecordBatch(
          checkedCastToInt(recordBatchFB.length()),
          nodes,
          buffers,
          bodyCompression,
          variadicBufferCounts,
          /*alignBuffers*/ true);
    } finally {
      body.getReferenceManager().release();
    }
  }

This also fixes the same leak on the existing IOException paths in that method (for example node length > INT_MAX), and covers the dictionary batch and Flight callers. ArrowMessage doesn't need a change. Releasing there too would release the buffer twice.

public class TestArrowFieldNode {

@Test
public void testNegativeLengthRejected() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests cover the constructor, but not the deserialization path the PR is about. Could we add a test that deserializes a record batch with a negative node length and checks that nothing leaks? For example, in MessageSerializerTest:

  @Test
  public void testNegativeNodeLengthDoesNotLeakBody() {
    FlatBufferBuilder builder = new FlatBufferBuilder();
    RecordBatch.startNodesVector(builder, 1);
    FieldNode.createFieldNode(builder, -100, 0);
    int nodes = builder.endVector();
    RecordBatch.startBuffersVector(builder, 0);
    int buffers = builder.endVector();
    RecordBatch.startRecordBatch(builder);
    RecordBatch.addLength(builder, 0);
    RecordBatch.addNodes(builder, nodes);
    RecordBatch.addBuffers(builder, buffers);
    builder.finish(RecordBatch.endRecordBatch(builder));
    RecordBatch recordBatchFB = RecordBatch.getRootAsRecordBatch(builder.dataBuffer());

    try (BufferAllocator allocator = new RootAllocator()) {
      ArrowBuf body = allocator.buffer(8);
      assertThrows(
          IllegalArgumentException.class,
          () -> MessageSerializer.deserializeRecordBatch(recordBatchFB, body));
      assertEquals(0, allocator.getAllocatedMemory());
    }
  }

It needs three imports: com.google.flatbuffers.FlatBufferBuilder, org.apache.arrow.flatbuf.FieldNode and org.apache.arrow.flatbuf.RecordBatch.

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

Labels

bug-fix PRs that fix a big.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Java] Reject negative FieldNode length during deserialization

3 participants