|
| 1 | +--- |
| 2 | +status: published |
| 3 | +id: CVE-2026-55407 |
| 4 | +title: Memory Exhaustion via Unbounded Allocation in decode_unknown_field |
| 5 | +project: buffa |
| 6 | +component: buffa/src/encoding.rs |
| 7 | +cwe: CWE-770 |
| 8 | +cvss: 6.3 |
| 9 | +endor_id: ENDOR-VUL-2026-2105 |
| 10 | +reported: 2026-05-21 |
| 11 | +disclosed: 2026-07-01 |
| 12 | +refs: |
| 13 | + nvd: https://nvd.nist.gov/vuln/detail/CVE-2026-55407 |
| 14 | + ghsa: https://github.com/anthropics/buffa/security/advisories/GHSA-f9qc-qg88-7pq5 |
| 15 | +blog: https://www.endorlabs.com/learn/endor-labs-ai-sast-finds-zero-day-cve-2026-55407-buffa |
| 16 | +credits: |
| 17 | + - Peyton Kennedy (p80n) |
| 18 | +--- |
| 19 | + |
| 20 | +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. |
| 21 | + |
| 22 | +The flagged sink is the `LengthDelimited` arm, where a length taken off the wire becomes the allocation size directly: |
| 23 | + |
| 24 | +```rust |
| 25 | +WireType::LengthDelimited => { |
| 26 | + let len = decode_varint(buf)?; |
| 27 | + let len = usize::try_from(len).map_err(|_| DecodeError::MessageTooLarge)?; |
| 28 | + if buf.remaining() < len { |
| 29 | + return Err(DecodeError::UnexpectedEof); |
| 30 | + } |
| 31 | + let mut data = alloc::vec![0u8; len]; // attacker-sized allocation |
| 32 | + buf.copy_to_slice(&mut data); |
| 33 | + UnknownFieldData::LengthDelimited(data) |
| 34 | +} |
| 35 | +``` |
| 36 | + |
| 37 | +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. |
| 38 | + |
| 39 | +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. |
| 40 | + |
| 41 | +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. |
| 42 | + |
| 43 | +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. |
| 44 | + |
| 45 | +Impact: Denial of Service through memory exhaustion, unauthenticated wherever untrusted protobuf is decoded. |
| 46 | + |
| 47 | +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. |
0 commit comments