Skip to content

Commit 10a7776

Browse files
dfa1claude
andcommitted
fix(security): protect against zip-bomb attacks via inflated row_count
Two independent OOM vectors, both exploitable with files under 200 bytes claiming 10⁹ rows: 1. ConstantEncoding.Decoder stored n copies of the constant value. A ~130-byte file triggers an 8 GB allocation in decode() before touching any actual data. Fix: store one element only; array reports length=n with O(1) buffer. Callers that need all n values replicate from index 0. 2. ScanIterator.expandDictPrimitive pre-allocated n × elemBytes from the attacker-controlled codesLayout.rowCount() before reading a single code byte. PrimitiveEncoding wraps the mmap'd segment without allocating, making the rowCount inflation invisible at decode time. Fix: validate bufferCodes >= n in decodeDictLayout before expansion; the mmap-bounded buffer makes inflation detectable for direct-mapped encodings, and full-decode encodings (bitpacked etc.) already fill their buffer to n × elemBytes during decode. Regression tests in ZipBombTest use row counts safe for CI: 10M for attack 1 (fails with AssertionError if reverted, not OOM) and 100 for attack 2 (wrong exception type → clean failure if check is removed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5c17761 commit 10a7776

4 files changed

Lines changed: 408 additions & 18 deletions

File tree

core/src/main/java/io/github/dfa1/vortex/encoding/ConstantEncoding.java

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,7 @@ private static ScalarProtos.ScalarValue buildScalar(PType ptype, long rawBits) {
123123
return switch (ptype) {
124124
case U8, U16, U32, U64 -> ScalarProtos.ScalarValue.newBuilder().setUint64Value(rawBits).build();
125125
case I8, I16, I32, I64 -> ScalarProtos.ScalarValue.newBuilder().setInt64Value(rawBits).build();
126-
case F32 ->
127-
ScalarProtos.ScalarValue.newBuilder().setF32Value(Float.intBitsToFloat((int) rawBits)).build();
126+
case F32 -> ScalarProtos.ScalarValue.newBuilder().setF32Value(Float.intBitsToFloat((int) rawBits)).build();
128127
case F64 -> ScalarProtos.ScalarValue.newBuilder().setF64Value(Double.longBitsToDouble(rawBits)).build();
129128
default -> throw new VortexException(EncodingId.VORTEX_CONSTANT, "unsupported ptype: " + ptype);
130129
};
@@ -163,7 +162,7 @@ private static Array decode(DecodeContext ctx) {
163162
if (ctx.dtype() instanceof DType.Extension ext) {
164163
// Decode using the storage dtype, re-wrap with the extension dtype
165164
var storageCtx = new DecodeContext(ctx.node(), ext.storageDType(), ctx.rowCount(),
166-
ctx.segmentBuffers(), ctx.registry(), ctx.arena());
165+
ctx.segmentBuffers(), ctx.registry(), ctx.arena());
167166
Array storage = decode(storageCtx);
168167
return new GenericArray(ctx.dtype(), n, storage.buffer(0));
169168
}
@@ -176,11 +175,13 @@ private static Array decode(DecodeContext ctx) {
176175
int elemBytes = ptype.byteSize();
177176
long rawBits = scalarToRawBits(scalar, ptype);
178177

179-
MemorySegment outSeg = ctx.arena().allocate(n * elemBytes);
178+
// Store one element only. The array reports length=n but the buffer holds
179+
// a single copy of the constant — callers that need all n values must
180+
// replicate from index 0. This keeps allocation O(1) regardless of rowCount,
181+
// which also eliminates the zip-bomb vector via inflated row_count.
182+
MemorySegment outSeg = ctx.arena().allocate(elemBytes);
180183
ByteBuffer out = outSeg.asByteBuffer().order(ByteOrder.LITTLE_ENDIAN);
181-
for (long i = 0; i < n; i++) {
182-
writeRaw(out, ptype, rawBits);
183-
}
184+
writeRaw(out, ptype, rawBits);
184185

185186
MemorySegment ro = outSeg.asReadOnly();
186187
return switch (ptype) {
@@ -220,8 +221,8 @@ private static Array decodeBool(DecodeContext ctx, ScalarProtos.ScalarValue scal
220221

221222
private static Array decodeString(DecodeContext ctx, ScalarProtos.ScalarValue scalar, long n) {
222223
byte[] strBytes = scalar.hasStringValue()
223-
? scalar.getStringValue().getBytes(StandardCharsets.UTF_8)
224-
: scalar.getBytesValue().toByteArray();
224+
? scalar.getStringValue().getBytes(StandardCharsets.UTF_8)
225+
: scalar.getBytesValue().toByteArray();
225226

226227
int strLen = strBytes.length;
227228

@@ -248,7 +249,7 @@ private static long scalarToRawBits(ScalarProtos.ScalarValue scalar, PType ptype
248249
case F64_VALUE -> Double.doubleToRawLongBits(scalar.getF64Value());
249250
case KIND_NOT_SET -> 0L;
250251
default -> throw new VortexException(EncodingId.VORTEX_CONSTANT,
251-
"unexpected scalar kind " + scalar.getKindCase());
252+
"unexpected scalar kind " + scalar.getKindCase());
252253
};
253254
}
254255

core/src/test/java/io/github/dfa1/vortex/encoding/ConstantEncodingTest.java

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.github.dfa1.vortex.core.array.Array;
44
import org.junit.jupiter.api.Nested;
5+
import org.junit.jupiter.api.Test;
56
import org.junit.jupiter.params.ParameterizedTest;
67
import org.junit.jupiter.params.provider.Arguments;
78
import org.junit.jupiter.params.provider.MethodSource;
@@ -11,8 +12,36 @@
1112
import static org.assertj.core.api.Assertions.assertThat;
1213

1314
/// Property: encode then decode is lossless for constant (all-equal) arrays.
15+
/// Property: decode allocates O(1) memory regardless of rowCount.
1416
class ConstantEncodingTest {
1517

18+
@Nested
19+
class Decode {
20+
21+
@Test
22+
void decode_largeRowCount_bufferStaysConstantSize() {
23+
// Given — 10M rows would allocate 80 MB if the decoder materializes every element;
24+
// the correct impl stores exactly one element regardless of logical length.
25+
long rowCount = 10_000_000L;
26+
var sut = new ConstantEncoding();
27+
EncodingRegistry registry = TestRegistry.of(sut);
28+
29+
// When
30+
EncodeResult encoded = sut.encode(DTypes.I64, new long[]{42L}, EncodeTestHelper.testCtx());
31+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(encoded, rowCount, DTypes.I64, registry);
32+
Array result = sut.decode(ctx);
33+
34+
// Then
35+
assertThat(result.length()).isEqualTo(rowCount);
36+
// Constant encoding must not materialize the full array: the backing buffer must
37+
// hold exactly one element. Before fix: buffer is rowCount * 8 bytes.
38+
assertThat(result.buffer(0).byteSize())
39+
.as("constant encoding must not allocate O(rowCount) memory")
40+
.isEqualTo(Long.BYTES);
41+
assertThat(result.buffer(0).get(PTypeIO.LE_LONG, 0L)).isEqualTo(42L);
42+
}
43+
}
44+
1645
@Nested
1746
class Encode {
1847

@@ -48,11 +77,10 @@ void encodeDecode_i32_isLossless(int[] data) {
4877
DecodeContext ctx = EncodeTestHelper.toDecodeContext(encoded, data.length, DTypes.I32, registry);
4978
Array result = sut.decode(ctx);
5079

51-
// Then
80+
// Then — buffer holds one element; logical length is n
5281
assertThat(result.length()).isEqualTo(data.length);
53-
for (int i = 0; i < data.length; i++) {
54-
assertThat(result.buffer(0).get(le, (long) i * 4)).as("index %d", i).isEqualTo(data[i]);
55-
}
82+
assertThat(result.buffer(0).byteSize()).isEqualTo(Integer.BYTES);
83+
assertThat(result.buffer(0).get(le, 0L)).isEqualTo(data[0]);
5684
}
5785

5886
@ParameterizedTest
@@ -68,11 +96,10 @@ void encodeDecode_i64_isLossless(long[] data) {
6896
DecodeContext ctx = EncodeTestHelper.toDecodeContext(encoded, data.length, DTypes.I64, registry);
6997
Array result = sut.decode(ctx);
7098

71-
// Then
99+
// Then — buffer holds one element; logical length is n
72100
assertThat(result.length()).isEqualTo(data.length);
73-
for (int i = 0; i < data.length; i++) {
74-
assertThat(result.buffer(0).get(le, (long) i * 8)).as("index %d", i).isEqualTo(data[i]);
75-
}
101+
assertThat(result.buffer(0).byteSize()).isEqualTo(Long.BYTES);
102+
assertThat(result.buffer(0).get(le, 0L)).isEqualTo(data[0]);
76103
}
77104
}
78105
}

reader/src/main/java/io/github/dfa1/vortex/scan/ScanIterator.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,17 @@ private Array decodeDictLayout(Layout dictLayout, DType dtype, SegmentAllocator
429429
Array values = decodeLayout(valuesLayout, dtype, arena);
430430
Array codes = decodeLayout(codesLayout, new DType.Primitive(codesPType, false), arena);
431431

432+
// Zip-bomb guard: for direct-mapped encodings (e.g. vortex.primitive), the codes
433+
// buffer is mmap-bounded and can be much smaller than the claimed rowCount. Reject
434+
// before any O(n) allocation so an inflated layout row_count cannot trigger OOM.
435+
// Full-decode encodings (e.g. bitpacked) write n * elemBytes to the arena during
436+
// decodeLayout above, so their buffer will already match n — check passes for them.
437+
long bufferCodes = codes.buffer(0).byteSize() / (long) codesPType.byteSize();
438+
if (bufferCodes < n) {
439+
throw new VortexException(EncodingId.VORTEX_DICT,
440+
"dict codes: layout row_count=" + n + " exceeds buffer capacity=" + bufferCodes);
441+
}
442+
432443
if (values instanceof VarBinArray) {
433444
MemorySegment valOffsets = values.child(0).buffer(0);
434445
PType valOffPType = ((DType.Primitive) values.child(0).dtype()).ptype();

0 commit comments

Comments
 (0)