Repository navigation
Conversation
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
| // 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". |
There was a problem hiding this comment.
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?
| // 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. |
| Preconditions.checkArgument(length >= 0, "Expected length >= 0, got %s", length); | ||
| this.length = checkedCastToInt(length); | ||
| this.nullCount = checkedCastToInt(nullCount); |
There was a problem hiding this comment.
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:
| 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.
| // 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 |
There was a problem hiding this comment.
The JSON path isn't covered here, because JsonFileReader.readFromJsonIntoVector uses count before it builds the ArrowFieldNode:
vector.setInitialCapacity(valueCount)(L890)readIntoBuffer(...)→BITreader →getValidityBufferSizeFromCount(-100). That is(-93) >>> 3, about 512 MB, which is then allocated and fully zeroed withsetZeroBitVectorHelper.getNullCountthen 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); |
There was a problem hiding this comment.
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:
| 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.
| // 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 |
There was a problem hiding this comment.
The JSON path is not covered here, because JsonFileReader.readFromJsonIntVector uses count before it builds the ArrowFieldNote:
vector.setInitialCapacity(valueCount)readIntBuffer(...)->BITreader ->getValidityBufferSizeFromCount(-100). That is(-93) >>> 3, about 512 MB, which is then allocated and fully zeroed withsetZeroBitVectorHelper.getNullCountthen 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.
What's Changed
ArrowFieldNodepreviously accepted a negativelength. Because the IPC(
MessageSerializer.deserializeRecordBatch), JSON (JsonFileReader), and C Data(
ArrayImporter) deserialization paths constructArrowFieldNodedirectly fromexternally supplied metadata, a malformed or hostile input could set a negative node
length that then flowed into buffer-size math and buffer writes:
BitVectorHelper.getValidityBufferSizeFromCountuses an unsigned shift, so a smallnegative length becomes a ~512 MB allocation request.
BitVectorHelper.loadValidityBufferthen callsArrowBuf.setOne(0, length / 8)with anegative byte count. With bounds checking disabled
(
arrow.enable_unsafe_memory_access=true) this reachesUnsafe.setMemorywith thenegative 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.