diff --git a/src/main/java/org/xerial/snappy/Snappy.java b/src/main/java/org/xerial/snappy/Snappy.java index 14d17964..6aeea3e6 100755 --- a/src/main/java/org/xerial/snappy/Snappy.java +++ b/src/main/java/org/xerial/snappy/Snappy.java @@ -133,6 +133,8 @@ public static int compress(byte[] input, int inputOffset, int inputLength, byte[ * @param compressed output of the compressed data. Uses range [pos()..]. * @return byte size of the compressed data. * @throws SnappyError when the input is not a direct buffer + * @throws IllegalArgumentException when the output buffer has less than {@link #maxCompressedLength(int)} bytes + * remaining */ public static int compress(ByteBuffer uncompressed, ByteBuffer compressed) throws IOException @@ -150,6 +152,7 @@ public static int compress(ByteBuffer uncompressed, ByteBuffer compressed) int uPos = uncompressed.position(); int uLen = uncompressed.remaining(); int cPos = compressed.position(); + checkOutputSpace(compressed.remaining(), 0, requiredCompressedLength(uLen)); int compressedSize = impl.rawCompress(uncompressed, uPos, uLen, compressed, cPos); @@ -354,6 +357,7 @@ public static boolean isValidCompressedBuffer(byte[] input, int offset, int leng if (input == null) { throw new NullPointerException("input is null"); } + checkArrayRange(input, offset, length, "input"); return impl.isValidCompressedBuffer(input, offset, length); } @@ -446,7 +450,11 @@ public static long rawUncompress(long inputAddr, long inputSize, long destAddr) public static byte[] rawCompress(Object data, int byteSize) throws IOException { - byte[] buf = new byte[Snappy.maxCompressedLength(byteSize)]; + if (data == null) { + throw new NullPointerException("input is null"); + } + checkArrayRange(data, 0, byteSize, "input"); + byte[] buf = new byte[requiredCompressedLength(byteSize)]; int compressedByteSize = impl.rawCompress(data, 0, byteSize, buf, 0); byte[] result = new byte[compressedByteSize]; System.arraycopy(buf, 0, result, 0, compressedByteSize); @@ -464,6 +472,8 @@ public static byte[] rawCompress(Object data, int byteSize) * @param outputOffset byte offset at the output array * @return byte size of the compressed data * @throws IOException + * @throws IllegalArgumentException if the input range is out of bounds, or the output array does not have + * {@link #maxCompressedLength(int)} bytes available after outputOffset */ public static int rawCompress(Object input, int inputOffset, int inputLength, byte[] output, int outputOffset) throws IOException @@ -471,6 +481,8 @@ public static int rawCompress(Object input, int inputOffset, int inputLength, by if (input == null || output == null) { throw new NullPointerException("input or output is null"); } + checkArrayRange(input, inputOffset, inputLength, "input"); + checkOutputSpace(output.length, outputOffset, requiredCompressedLength(inputLength)); int compressedSize = impl .rawCompress(input, inputOffset, inputLength, output, outputOffset); @@ -494,6 +506,8 @@ public static int rawCompress(Object input, int inputOffset, int inputLength, by * @param outputOffset byte offset in the output buffer * @return the byte size of the uncompressed data * @throws IOException when failed to uncompress the input data + * @throws IllegalArgumentException if the input range is out of bounds, or the output array does not have + * enough space after outputOffset for the uncompressed data */ public static int rawUncompress(byte[] input, int inputOffset, int inputLength, Object output, int outputOffset) throws IOException @@ -501,9 +515,118 @@ public static int rawUncompress(byte[] input, int inputOffset, int inputLength, if (input == null || output == null) { throw new NullPointerException("input or output is null"); } + // uncompressedLength validates the input range + int requiredSize = uncompressedLength(input, inputOffset, inputLength); + checkOutputSpace(byteLengthOf(output), outputOffset, requiredSize); return impl.rawUncompress(input, inputOffset, inputLength, output, outputOffset); } + /** + * Returns the byte size of the given primitive array + */ + private static long byteLengthOf(Object array) + { + if (array instanceof byte[]) { + return ((byte[]) array).length; + } + if (array instanceof boolean[]) { + return ((boolean[]) array).length; + } + if (array instanceof char[]) { + return 2L * ((char[]) array).length; + } + if (array instanceof short[]) { + return 2L * ((short[]) array).length; + } + if (array instanceof int[]) { + return 4L * ((int[]) array).length; + } + if (array instanceof float[]) { + return 4L * ((float[]) array).length; + } + if (array instanceof long[]) { + return 8L * ((long[]) array).length; + } + if (array instanceof double[]) { + return 8L * ((double[]) array).length; + } + throw new IllegalArgumentException("not a primitive array: " + array.getClass().getName()); + } + + /** + * Ensures the byte range [offset, offset+length) lies within the given primitive array, since the native code + * accesses the array through a raw pointer without any bounds check. + */ + private static void checkArrayRange(Object array, int offset, int length, String name) + { + long size = byteLengthOf(array); + if (offset < 0 || length < 0 || (long) offset + length > size) { + throw new IllegalArgumentException(String.format( + "%s range [offset:%,d, length:%,d] is out of bounds for %s of %,d bytes", + name, offset, length, name, size)); + } + } + + /** + * Ensures the output has at least requiredSize bytes available after outputOffset, since the native code writes + * the output through a raw pointer without any bounds check. + */ + private static void checkOutputSpace(long outputSize, long outputOffset, long requiredSize) + { + if (outputOffset < 0 || outputOffset > outputSize) { + throw new IllegalArgumentException(String.format( + "output offset %,d is out of bounds for output of %,d bytes", outputOffset, outputSize)); + } + if (outputSize - outputOffset < requiredSize) { + throw new IllegalArgumentException(String.format( + "not enough space for output: need %,d bytes, but only %,d remaining", + requiredSize, outputSize - outputOffset)); + } + } + + /** + * Returns the output buffer size required by the native compressor for the given input size. + */ + private static int requiredCompressedLength(int inputLength) + { + int maxCompressedLength = maxCompressedLength(inputLength); + // maxCompressedLength overflows int for inputs close to 2GB + if (maxCompressedLength < 0) { + throw new SnappyError(SnappyErrorCode.TOO_LARGE_INPUT, "input is too large to compress: " + inputLength); + } + return maxCompressedLength; + } + + /** + * Rejects an uncompressed length declared in a compressed stream header that no valid Snappy stream of the + * given compressed size can produce. The densest Snappy element is a 3-byte copy that expands to 64 bytes, so + * a valid stream never declares more than 64/3 times its compressed size. This prevents allocating huge (or + * negative-size) buffers from a few bytes of crafted input before decompression detects the corruption. + */ + private static long checkUncompressedLength(long uncompressedLength, long compressedLength) + throws IOException + { + if (uncompressedLength < 0 || uncompressedLength > compressedLength * 64 / 3) { + throw new SnappyIOException(SnappyErrorCode.PARSING_ERROR, String.format( + "invalid uncompressed length %,d declared in %,d bytes of compressed data", + uncompressedLength, compressedLength)); + } + return uncompressedLength; + } + + /** + * Rejects an uncompressed length that is not a multiple of the element size of the typed array to produce. + */ + private static void checkElementAlignment(int uncompressedLength, int elementSize) + throws IOException + { + if (uncompressedLength % elementSize != 0) { + throw new SnappyIOException(SnappyErrorCode.FAILED_TO_UNCOMPRESS, String.format( + "uncompressed length %,d is not a multiple of the element size %d", + uncompressedLength, elementSize)); + } + } + /** * High-level API for uncompressing the input byte array. * @@ -557,6 +680,8 @@ public static int uncompress(byte[] input, int inputOffset, int inputLength, byt * @return uncompressed data size * @throws IOException when failed to uncompress the given input * @throws SnappyError when the input is not a direct buffer + * @throws IllegalArgumentException when the output buffer does not have enough space remaining for the + * uncompressed data */ public static int uncompress(ByteBuffer compressed, ByteBuffer uncompressed) throws IOException @@ -572,6 +697,7 @@ public static int uncompress(ByteBuffer compressed, ByteBuffer uncompressed) int cPos = compressed.position(); int cLen = compressed.remaining(); int uPos = uncompressed.position(); + checkOutputSpace(uncompressed.remaining(), 0, uncompressedLength(compressed)); // pos limit // [ ......UUUUUU.........] @@ -608,6 +734,7 @@ public static char[] uncompressCharArray(byte[] input, int offset, int length) throws IOException { int uncompressedLength = Snappy.uncompressedLength(input, offset, length); + checkElementAlignment(uncompressedLength, 2); char[] result = new char[uncompressedLength / 2]; impl.rawUncompress(input, offset, length, result, 0); return result; @@ -639,6 +766,7 @@ public static double[] uncompressDoubleArray(byte[] input, int offset, int lengt throws IOException { int uncompressedLength = Snappy.uncompressedLength(input, offset, length); + checkElementAlignment(uncompressedLength, 8); double[] result = new double[uncompressedLength / 8]; impl.rawUncompress(input, offset, length, result, 0); return result; @@ -656,7 +784,7 @@ public static double[] uncompressDoubleArray(byte[] input, int offset, int lengt public static int uncompressedLength(byte[] input) throws IOException { - return impl.uncompressedLength(input, 0, input.length); + return uncompressedLength(input, 0, input.length); } /** @@ -676,8 +804,9 @@ public static int uncompressedLength(byte[] input, int offset, int length) if (input == null) { throw new NullPointerException("input is null"); } + checkArrayRange(input, offset, length, "input"); - return impl.uncompressedLength(input, offset, length); + return (int) checkUncompressedLength(impl.uncompressedLength(input, offset, length), length); } /** @@ -697,7 +826,9 @@ public static int uncompressedLength(ByteBuffer compressed) throw new SnappyError(SnappyErrorCode.NOT_A_DIRECT_BUFFER, "input is not a direct buffer"); } - return impl.uncompressedLength(compressed, compressed.position(), compressed.remaining()); + return (int) checkUncompressedLength( + impl.uncompressedLength(compressed, compressed.position(), compressed.remaining()), + compressed.remaining()); } /** @@ -712,7 +843,7 @@ public static int uncompressedLength(ByteBuffer compressed) public static long uncompressedLength(long inputAddr, long len) throws IOException { - return impl.uncompressedLength(inputAddr, len); + return checkUncompressedLength(impl.uncompressedLength(inputAddr, len), len); } /** @@ -741,6 +872,7 @@ public static float[] uncompressFloatArray(byte[] input, int offset, int length) throws IOException { int uncompressedLength = Snappy.uncompressedLength(input, offset, length); + checkElementAlignment(uncompressedLength, 4); float[] result = new float[uncompressedLength / 4]; impl.rawUncompress(input, offset, length, result, 0); return result; @@ -772,6 +904,7 @@ public static int[] uncompressIntArray(byte[] input, int offset, int length) throws IOException { int uncompressedLength = Snappy.uncompressedLength(input, offset, length); + checkElementAlignment(uncompressedLength, 4); int[] result = new int[uncompressedLength / 4]; impl.rawUncompress(input, offset, length, result, 0); return result; @@ -803,6 +936,7 @@ public static long[] uncompressLongArray(byte[] input, int offset, int length) throws IOException { int uncompressedLength = Snappy.uncompressedLength(input, offset, length); + checkElementAlignment(uncompressedLength, 8); long[] result = new long[uncompressedLength / 8]; impl.rawUncompress(input, offset, length, result, 0); return result; @@ -834,6 +968,7 @@ public static short[] uncompressShortArray(byte[] input, int offset, int length) throws IOException { int uncompressedLength = Snappy.uncompressedLength(input, offset, length); + checkElementAlignment(uncompressedLength, 2); short[] result = new short[uncompressedLength / 2]; impl.rawUncompress(input, offset, length, result, 0); return result; diff --git a/src/main/java/org/xerial/snappy/SnappyFramedInputStream.java b/src/main/java/org/xerial/snappy/SnappyFramedInputStream.java index 8e975735..f4ef064d 100644 --- a/src/main/java/org/xerial/snappy/SnappyFramedInputStream.java +++ b/src/main/java/org/xerial/snappy/SnappyFramedInputStream.java @@ -517,18 +517,22 @@ private boolean ensureBuffer() return false; } - if (!readBlockHeader()) { - eof = true; - return false; - } - - // get action based on header - final FrameMetaData frameMetaData = getFrameMetaData(frameHeader); + // Skip chunks in a loop rather than recursively: a zero-length skippable chunk is only 4 bytes, so + // recursing per chunk lets a small input exhaust the stack + FrameMetaData frameMetaData; + while (true) { + if (!readBlockHeader()) { + eof = true; + return false; + } - if (FrameAction.SKIP == frameMetaData.frameAction) { + // get action based on header + frameMetaData = getFrameMetaData(frameHeader); + if (FrameAction.SKIP != frameMetaData.frameAction) { + break; + } SnappyFramed.skip(rbc, frameMetaData.length, ByteBuffer.wrap(buffer)); - return ensureBuffer(); } if (frameMetaData.length > input.capacity()) { @@ -550,6 +554,8 @@ private boolean ensureBuffer() input.position(frameData.offset); + // uncompressedLength rejects lengths that cannot be produced from the frame size, so the buffers + // allocated below stay proportional to the frame actually read final int uncompressedLength = Snappy.uncompressedLength(input); if (uncompressedLength > uncompressedDirect.capacity()) { diff --git a/src/test/java/org/xerial/snappy/SnappyBoundsCheckTest.java b/src/test/java/org/xerial/snappy/SnappyBoundsCheckTest.java new file mode 100644 index 00000000..ea0c017b --- /dev/null +++ b/src/test/java/org/xerial/snappy/SnappyBoundsCheckTest.java @@ -0,0 +1,335 @@ +package org.xerial.snappy; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Random; + +import org.junit.Test; + +/** + * Regression tests for buffer bounds checks before calling native code, and for limits on sizes declared in + * untrusted compressed data. + */ +public class SnappyBoundsCheckTest +{ + private static byte[] randomBytes(int size) + { + byte[] data = new byte[size]; + new Random(42).nextBytes(data); + return data; + } + + private static ByteBuffer directBufferOf(byte[] data) + { + ByteBuffer buf = ByteBuffer.allocateDirect(data.length); + buf.put(data); + buf.flip(); + return buf; + } + + private static byte[] varint(long v) + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + while ((v & ~0x7FL) != 0) { + out.write((int) ((v & 0x7F) | 0x80)); + v >>>= 7; + } + out.write((int) v); + return out.toByteArray(); + } + + // https://github.com/xerial/snappy-java/issues/732 + @Test(expected = IllegalArgumentException.class) + public void compressByteBufferToUndersizedOutput() + throws Exception + { + ByteBuffer src = directBufferOf(randomBytes(1024 * 1024)); + Snappy.compress(src, ByteBuffer.allocateDirect(64)); + } + + @Test + public void compressByteBufferToMaxCompressedLengthOutput() + throws Exception + { + byte[] data = randomBytes(1024 * 1024); + ByteBuffer compressed = ByteBuffer.allocateDirect(Snappy.maxCompressedLength(data.length)); + Snappy.compress(directBufferOf(data), compressed); + ByteBuffer uncompressed = ByteBuffer.allocateDirect(data.length); + assertEquals(data.length, Snappy.uncompress(compressed, uncompressed)); + byte[] result = new byte[data.length]; + uncompressed.get(result); + assertArrayEquals(data, result); + } + + @Test(expected = IllegalArgumentException.class) + public void compressByteArrayToUndersizedOutput() + throws Exception + { + byte[] data = randomBytes(1024 * 1024); + Snappy.compress(data, 0, data.length, new byte[64], 0); + } + + @Test(expected = IllegalArgumentException.class) + public void compressByteArrayWithOutOfRangeInput() + throws Exception + { + byte[] data = randomBytes(100); + Snappy.compress(data, 50, 100, new byte[Snappy.maxCompressedLength(100)], 0); + } + + @Test(expected = IllegalArgumentException.class) + public void compressByteArrayWithNegativeOutputOffset() + throws Exception + { + byte[] data = randomBytes(100); + Snappy.compress(data, 0, data.length, new byte[1024], -1); + } + + @Test(expected = IllegalArgumentException.class) + public void rawCompressWithTooLargeByteSize() + throws Exception + { + Snappy.rawCompress(new int[10], 41); + } + + // https://github.com/xerial/snappy-java/issues/728 (CVE-2026-90559) + @Test(expected = IllegalArgumentException.class) + public void uncompressByteBufferToUndersizedOutput() + throws Exception + { + byte[] data = new byte[1024 * 1024]; + ByteBuffer compressed = ByteBuffer.allocateDirect(Snappy.maxCompressedLength(data.length)); + Snappy.compress(directBufferOf(data), compressed); + Snappy.uncompress(compressed, ByteBuffer.allocateDirect(64)); + } + + @Test(expected = IllegalArgumentException.class) + public void uncompressByteArrayToUndersizedOutput() + throws Exception + { + byte[] compressed = Snappy.compress(new byte[1024 * 1024]); + Snappy.uncompress(compressed, 0, compressed.length, new byte[64], 0); + } + + @Test(expected = IllegalArgumentException.class) + public void uncompressByteArrayWithOutOfRangeOutputOffset() + throws Exception + { + byte[] compressed = Snappy.compress(new byte[100]); + Snappy.uncompress(compressed, 0, compressed.length, new byte[200], 150); + } + + @Test(expected = IllegalArgumentException.class) + public void uncompressByteArrayWithOutOfRangeInput() + throws Exception + { + byte[] compressed = Snappy.compress(new byte[100]); + Snappy.uncompress(compressed, 1, compressed.length, new byte[200], 0); + } + + @Test(expected = IllegalArgumentException.class) + public void rawUncompressToUndersizedTypedArray() + throws Exception + { + byte[] compressed = Snappy.compress(new int[100]); + Snappy.rawUncompress(compressed, 0, compressed.length, new int[99], 0); + } + + @Test + public void uncompressByteArrayAtOffset() + throws Exception + { + byte[] data = randomBytes(1000); + byte[] compressed = Snappy.compress(data); + byte[] output = new byte[1100]; + assertEquals(data.length, Snappy.uncompress(compressed, 0, compressed.length, output, 100)); + assertArrayEquals(data, Arrays.copyOfRange(output, 100, 1100)); + } + + // https://github.com/xerial/snappy-java/issues/729 + @Test + public void uncompressTypedArrayWithMisalignedLength() + throws Exception + { + // 15 bytes is not a multiple of any of the element sizes + byte[] compressed = Snappy.compress(randomBytes(15)); + try { + Snappy.uncompressLongArray(compressed); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + assertEquals(SnappyErrorCode.FAILED_TO_UNCOMPRESS, e.getErrorCode()); + } + try { + Snappy.uncompressDoubleArray(compressed); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + // expected + } + try { + Snappy.uncompressIntArray(compressed); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + // expected + } + try { + Snappy.uncompressFloatArray(compressed); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + // expected + } + try { + Snappy.uncompressShortArray(compressed); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + // expected + } + try { + Snappy.uncompressCharArray(compressed); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + // expected + } + } + + // https://github.com/xerial/snappy-java/issues/625 + @Test + public void uncompressWithHugeDeclaredLength() + throws Exception + { + byte[] data = new byte[] {(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x07}; + try { + Snappy.uncompress(data); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + assertEquals(SnappyErrorCode.PARSING_ERROR, e.getErrorCode()); + } + } + + // https://github.com/xerial/snappy-java/issues/625 + @Test + public void uncompressWithNegativeDeclaredLength() + throws Exception + { + byte[] data = new byte[] {(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x08}; + try { + Snappy.uncompress(data); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + assertEquals(SnappyErrorCode.PARSING_ERROR, e.getErrorCode()); + } + } + + @Test + public void uncompressedLengthAcceptsHighlyCompressibleData() + throws Exception + { + // all-zero input is close to the maximum compression ratio of Snappy + byte[] data = new byte[16 * 1024 * 1024]; + byte[] compressed = Snappy.compress(data); + assertEquals(data.length, Snappy.uncompressedLength(compressed)); + assertArrayEquals(data, Snappy.uncompress(compressed)); + assertEquals(0, Snappy.uncompress(Snappy.compress(new byte[0])).length); + } + + @Test(expected = IllegalArgumentException.class) + public void uncompressedLengthWithOutOfRangeInput() + throws Exception + { + Snappy.uncompressedLength(new byte[10], 5, 10); + } + + @Test(expected = IllegalArgumentException.class) + public void isValidCompressedBufferWithOutOfRangeInput() + throws Exception + { + Snappy.isValidCompressedBuffer(new byte[10], -1, 5); + } + + private static void writeChunk(ByteArrayOutputStream out, int flag, byte[] body) + { + out.write(flag); + out.write(body.length & 0xFF); + out.write((body.length >>> 8) & 0xFF); + out.write((body.length >>> 16) & 0xFF); + out.write(body, 0, body.length); + } + + private static byte[] readAll(InputStream in) + throws IOException + { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + finally { + in.close(); + } + } + + // https://github.com/xerial/snappy-java/issues/731 + @Test + public void framedStreamWithManySkippableChunks() + throws Exception + { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(SnappyFramed.HEADER_BYTES); + for (int i = 0; i < 200000; i++) { + writeChunk(stream, 0x80, new byte[0]); + } + byte[] data = "hello".getBytes("UTF-8"); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int crc = SnappyFramed.maskedCrc32c(SnappyFramed.getCRC32C(), data, 0, data.length); + for (int i = 0; i < 4; i++) { + body.write((crc >>> (8 * i)) & 0xFF); + } + body.write(data); + writeChunk(stream, 0x01, body.toByteArray()); + for (int i = 0; i < 200000; i++) { + writeChunk(stream, 0xfe, new byte[0]); + } + + byte[] result = readAll(new SnappyFramedInputStream(new ByteArrayInputStream(stream.toByteArray()))); + assertArrayEquals(data, result); + } + + // https://github.com/xerial/snappy-java/issues/730 + @Test + public void framedStreamWithHugeDeclaredUncompressedLength() + throws Exception + { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(new byte[4]); // checksum, which must not be reached + body.write(varint(1L << 30)); + body.write(new byte[] {0x00, 0x61}); // a single literal + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(SnappyFramed.HEADER_BYTES); + writeChunk(stream, 0x00, body.toByteArray()); + + try { + readAll(new SnappyFramedInputStream(new ByteArrayInputStream(stream.toByteArray()))); + fail("expected SnappyIOException"); + } + catch (SnappyIOException e) { + assertEquals(SnappyErrorCode.PARSING_ERROR, e.getErrorCode()); + } + } +}