CVE-2026-55407
Memory Exhaustion via Unbounded Allocation in decode_unknown_field
01Record
| Identifier | CVE-2026-55407 |
|---|---|
| Project | buffa |
| Component | buffa/src/encoding.rs |
| CWE | CWE-770 |
| CVSS | 6.3 |
| Disclosed | 2026-07-01 |
| Endor reference | ENDOR-VUL-2026-2105 |
| Writeup | Link |
02Detail
Description: decode_unknown_field in buffa, Anthropic's Rust protobuf library, allocates heap in proportion to attacker-controlled wire data. Protobuf forward compatibility routes every unknown wire type through this function, so any message decoded from untrusted input with preserve_unknown_fields=true (the default) reaches it. Affects buffa and connectrpc before 0.8.0.
The flagged sink is the LengthDelimited arm, where a length taken off the wire becomes the allocation size directly:
WireType::LengthDelimited => {
let len = decode_varint(buf)?;
let len = usize::try_from(len).map_err(|_| DecodeError::MessageTooLarge)?;
if buf.remaining() < len {
return Err(DecodeError::UnexpectedEof);
}
let mut data = alloc::vec![0u8; len]; // attacker-sized allocation
buf.copy_to_slice(&mut data);
UnknownFieldData::LengthDelimited(data)
}
The buf.remaining() < len guard prevents an out-of-bounds read; it does not cap the allocation, it only forces the attacker to deliver len bytes. That holds the flat sink to roughly 2x the input, which the function's own docstring accepts and pushes onto callers as an input-size cap.
That guidance does not survive one branch down. The StartGroup arm bounds recursion depth through checked_sub but says nothing about field count within a single group, and every loop iteration pushes an UnknownField onto a Vec. On a 64-bit target an UnknownField is about 40 bytes, while the cheapest nested field an attacker can encode is a zero varint at exactly 2 wire bytes: a 1-byte tag and a 1-byte zero. That is 20x on the structures alone, plus roughly 1.5x transient while the backing Vec doubles during growth.
A 64 MiB payload of zero varints inside one unknown group drives the decoder to about 1.4 GB of heap, a ~22x amplification. Message::decode, Message::decode_from_slice, and MessageView::decode_view all reach it. DecodeOptions caps top-level message length, but its default DEFAULT_MAX_MESSAGE_SIZE is roughly 2 GiB, and it caps input length, so it never sees a blow-up that starts small and expands during decode.
Validated against a server capped at 256 MiB under Docker: the 64 MiB payload OOM-killed it, exit 137 with OOMKilled: true. The proof of concept uses google.protobuf.Empty, which has no defined fields, so the wire-level analysis is unambiguous and the receiving message type never needs to declare a group field.
Impact: Denial of Service through memory exhaustion, unauthenticated wherever untrusted protobuf is decoded.
Fixed in 0.8.0, released 2026-06-25. Also tracked as CWE-400 and CWE-789. Found by pointing Endor Labs AI SAST at the library: the engine traced the length value from wire data to the Vec<u8> allocation and established that the only check between source and sink bounds the buffer rather than the allocation. The group amplification came from following that same function one branch further. Awarded a $600 bounty.
Discovered by Peyton Kennedy (p80n).