Skip to content

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

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

andygrove wants to merge 1 commit 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.

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?

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.

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.

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.

// 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.

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.

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