From 02e70afade0ee31970354c7bb2375fb582ff9c51 Mon Sep 17 00:00:00 2001 From: Jonas Konrad Date: Fri, 25 Sep 2026 17:22:25 +0200 Subject: [PATCH] Reject short output in LZ4DecompressorWithLength safe paths The safe-decompressor overloads passed the declared length as the maximum destination length but accepted any shorter output, returning a truncated array or a short written count. Throw LZ4Exception when the decompressed size differs from the length prefix, matching the fast paths. decompress(ByteBuffer, ByteBuffer) checks before moving the buffer positions. Fixes #105 Co-Authored-By: Claude Opus 5.5 --- .../lz4/LZ4DecompressorWithLength.java | 47 +++++++++---- src/test/net/jpountz/lz4/LZ4Test.java | 66 +++++++++++++++++++ 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/java/net/jpountz/lz4/LZ4DecompressorWithLength.java b/src/java/net/jpountz/lz4/LZ4DecompressorWithLength.java index 3f5484e7..c2cf4099 100644 --- a/src/java/net/jpountz/lz4/LZ4DecompressorWithLength.java +++ b/src/java/net/jpountz/lz4/LZ4DecompressorWithLength.java @@ -180,13 +180,22 @@ private static void checkDestinationLength(int decompressedLength, int maxDestin } } + private static int checkWritten(int written, int declared) { + if (written != declared) { + throw new LZ4Exception("Decompressed " + written + + " bytes, but length prefix declared " + declared); + } + return written; + } + /** * Convenience method, equivalent to calling * {@link #decompress(byte[], int, byte[], int) decompress(src, 0, dest, 0)}. * * @param src the compressed data * @param dest the destination buffer to store the decompressed data - * @return the number of bytes read to restore the original input + * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes, which always equals the declared decompressed length (when {@link LZ4SafeDecompressor} is used) + * @throws LZ4Exception if the compressed data is malformed or does not decompress to exactly the declared decompressed length */ public int decompress(byte[] src, byte[] dest) { return decompress(src, 0, dest, 0); @@ -204,7 +213,8 @@ public int decompress(byte[] src, byte[] dest) { * @param srcOff the start offset in src * @param dest the destination buffer to store the decompressed data * @param destOff the start offset in dest - * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes (when {@link LZ4SafeDecompressor} is used) + * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes, which always equals the declared decompressed length (when {@link LZ4SafeDecompressor} is used) + * @throws LZ4Exception if the compressed data is malformed or does not decompress to exactly the declared decompressed length */ public int decompress(byte[] src, int srcOff, byte[] dest, int destOff) { if (safeDecompressor != null) { @@ -229,7 +239,8 @@ public int decompress(byte[] src, int srcOff, byte[] dest, int destOff) { * @param srcLen the exact size of the compressed data (ignored when {@link LZ4FastDecompressor} is used) * @param dest the destination buffer to store the decompressed data * @param destOff the start offset in dest - * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes (when {@link LZ4SafeDecompressor} is used) + * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes, which always equals the declared decompressed length (when {@link LZ4SafeDecompressor} is used) + * @throws LZ4Exception if the compressed data is malformed or does not decompress to exactly the declared decompressed length */ public int decompress(byte[] src, int srcOff, int srcLen, byte[] dest, int destOff) { if (safeDecompressor == null) { @@ -238,7 +249,7 @@ public int decompress(byte[] src, int srcOff, int srcLen, byte[] dest, int destO final int destLen = getDecompressedLength(src, srcOff); SafeUtils.checkRange(dest, destOff, 0); checkDestinationLength(destLen, dest.length - destOff); - return safeDecompressor.decompress(src, srcOff + 4, srcLen - 4, dest, destOff, destLen); + return checkWritten(safeDecompressor.decompress(src, srcOff + 4, srcLen - 4, dest, destOff, destLen), destLen); } /** @@ -247,7 +258,8 @@ public int decompress(byte[] src, int srcOff, int srcLen, byte[] dest, int destO * * @param src the compressed data * @return the decompressed data - * @throws LZ4Exception if the declared decompressed length is invalid or exceeds the configured maximum + * @throws LZ4Exception if the declared decompressed length is invalid or exceeds the configured maximum, + * or if the compressed data does not decompress to exactly the declared decompressed length */ public byte[] decompress(byte[] src) { return decompress(src, 0); @@ -265,7 +277,8 @@ public byte[] decompress(byte[] src) { * @param src the compressed data * @param srcOff the start offset in src * @return the decompressed data - * @throws LZ4Exception if the declared decompressed length is invalid or exceeds the configured maximum + * @throws LZ4Exception if the declared decompressed length is invalid or exceeds the configured maximum, + * or if the compressed data does not decompress to exactly the declared decompressed length */ public byte[] decompress(byte[] src, int srcOff) { if (safeDecompressor != null) { @@ -289,7 +302,8 @@ public byte[] decompress(byte[] src, int srcOff) { * @param srcOff the start offset in src * @param srcLen the exact size of the compressed data (ignored when {@link LZ4FastDecompressor} is used) * @return the decompressed data - * @throws LZ4Exception if the declared decompressed length is invalid or exceeds the configured maximum + * @throws LZ4Exception if the declared decompressed length is invalid or exceeds the configured maximum, + * or if the compressed data does not decompress to exactly the declared decompressed length */ public byte[] decompress(byte[] src, int srcOff, int srcLen) { if (safeDecompressor == null) { @@ -298,7 +312,9 @@ public byte[] decompress(byte[] src, int srcOff, int srcLen) { SafeUtils.checkRange(src, srcOff, srcLen); final int destLen = getDecompressedLength(src, srcOff); checkDecompressedLength(destLen, srcLen - 4); - return safeDecompressor.decompress(src, srcOff + 4, srcLen - 4, destLen); + final byte[] dest = new byte[destLen]; + checkWritten(safeDecompressor.decompress(src, srcOff + 4, srcLen - 4, dest, 0, destLen), destLen); + return dest; } /** @@ -309,6 +325,9 @@ public byte[] decompress(byte[] src, int srcOff, int srcLen) { * * @param src the compressed data * @param dest the destination buffer to store the decompressed data + * @throws LZ4Exception if the compressed data is malformed or does not decompress to exactly the declared + * decompressed length; the positions of the buffers are then left unchanged, but the content + * of dest after its position may have been partially overwritten */ public void decompress(ByteBuffer src, ByteBuffer dest) { final int destLen = getDecompressedLength(src, src.position()); @@ -318,9 +337,9 @@ public void decompress(ByteBuffer src, ByteBuffer dest) { src.position(src.position() + 4 + read); dest.position(dest.position() + destLen); } else { - final int written = safeDecompressor.decompress(src, src.position() + 4, src.remaining() - 4, dest, dest.position(), destLen); + checkWritten(safeDecompressor.decompress(src, src.position() + 4, src.remaining() - 4, dest, dest.position(), destLen), destLen); src.position(src.limit()); - dest.position(dest.position() + written); + dest.position(dest.position() + destLen); } } @@ -337,7 +356,8 @@ public void decompress(ByteBuffer src, ByteBuffer dest) { * @param srcOff the start offset in src * @param dest the destination buffer to store the decompressed data * @param destOff the start offset in dest - * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes (when {@link LZ4SafeDecompressor} is used) + * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes, which always equals the declared decompressed length (when {@link LZ4SafeDecompressor} is used) + * @throws LZ4Exception if the compressed data is malformed or does not decompress to exactly the declared decompressed length */ public int decompress(ByteBuffer src, int srcOff, ByteBuffer dest, int destOff) { if (safeDecompressor != null) { @@ -363,7 +383,8 @@ public int decompress(ByteBuffer src, int srcOff, ByteBuffer dest, int destOff) * @param srcLen the exact size of the compressed data (ignored when {@link LZ4FastDecompressor} is used) * @param dest the destination buffer to store the decompressed data * @param destOff the start offset in dest - * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes (when {@link LZ4SafeDecompressor} is used) + * @return the number of bytes read to restore the original input (when {@link LZ4FastDecompressor} is used), or the number of decompressed bytes, which always equals the declared decompressed length (when {@link LZ4SafeDecompressor} is used) + * @throws LZ4Exception if the compressed data is malformed or does not decompress to exactly the declared decompressed length */ public int decompress(ByteBuffer src, int srcOff, int srcLen, ByteBuffer dest, int destOff) { if (safeDecompressor == null) { @@ -372,6 +393,6 @@ public int decompress(ByteBuffer src, int srcOff, int srcLen, ByteBuffer dest, i final int destLen = getDecompressedLength(src, srcOff); ByteBufferUtils.checkRange(dest, destOff, 0); checkDestinationLength(destLen, dest.capacity() - destOff); - return safeDecompressor.decompress(src, srcOff + 4, srcLen - 4, dest, destOff, destLen); + return checkWritten(safeDecompressor.decompress(src, srcOff + 4, srcLen - 4, dest, destOff, destLen), destLen); } } diff --git a/src/test/net/jpountz/lz4/LZ4Test.java b/src/test/net/jpountz/lz4/LZ4Test.java index 8f71d653..ea0c6ec4 100644 --- a/src/test/net/jpountz/lz4/LZ4Test.java +++ b/src/test/net/jpountz/lz4/LZ4Test.java @@ -455,6 +455,72 @@ public void testWriteToReadOnlyBuffer() { } } + @Test + public void testDecompressorWithLengthRejectsShortOutput() { + final byte[] data = new byte[16]; + for (int i = 0; i < data.length; ++i) { + data[i] = (byte) i; + } + final byte[] compressed = new LZ4CompressorWithLength(COMPRESSORS[0]).compress(data); + // rewrite the little-endian length prefix to declare more bytes than the data holds + compressed[0] = 20; + compressed[1] = compressed[2] = compressed[3] = 0; + assertEquals(20, LZ4DecompressorWithLength.getDecompressedLength(compressed)); + + for (LZ4SafeDecompressor safeDecompressor : SAFE_DECOMPRESSORS) { + final LZ4DecompressorWithLength decompressor = new LZ4DecompressorWithLength(safeDecompressor); + try { + decompressor.decompress(compressed); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + try { + decompressor.decompress(compressed, 0, compressed.length); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + try { + decompressor.decompress(compressed, new byte[20]); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + try { + decompressor.decompress(compressed, 0, compressed.length, new byte[20], 0); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + for (boolean direct : new boolean[] {false, true}) { + final ByteBuffer src = direct ? ByteBuffer.allocateDirect(compressed.length) : ByteBuffer.allocate(compressed.length); + src.put(compressed).flip(); + final ByteBuffer dest = direct ? ByteBuffer.allocateDirect(20) : ByteBuffer.allocate(20); + try { + decompressor.decompress(src, 0, dest, 0); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + try { + decompressor.decompress(src, 0, compressed.length, dest, 0); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + try { + decompressor.decompress(src, dest); + fail(); + } catch (LZ4Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("length prefix declared")); + } + assertEquals(0, src.position()); + assertEquals(0, dest.position()); + } + } + } + @Test @Repeat(iterations = 5) public void testAllEqual() {