Skip to content

consensus_encoding: implement batched allocation for vector decoders#5177

Merged
apoelstra merged 1 commit intorust-bitcoin:masterfrom
jrakibi:20-10-memory-bound
Nov 20, 2025
Merged

consensus_encoding: implement batched allocation for vector decoders#5177
apoelstra merged 1 commit intorust-bitcoin:masterfrom
jrakibi:20-10-memory-bound

Conversation

@jrakibi
Copy link
Copy Markdown
Contributor

@jrakibi jrakibi commented Oct 21, 2025

EDITED:

Right now vector decoders (VecDecoder and ByteVecDecoder) only checks that the element count/byte < 4,000,000. This still allows an attacker to claims a large size without providing the corresponding data



In this patch we allocates in 1 MB batches so an attacker now needs to provide X MB of data to make us allocate X+1 MB of memory

The allocation is applied for VecDecoder and ByteVecDecoder.

Fixes #5157

@github-actions github-actions bot added C-consensus_encoding PRs modifying the consensus-encoding crate C-primitives labels Oct 21, 2025
@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Oct 21, 2025

Keep in mind that this only checks the minimum possible size (minimum_encoded_size() × length), not the actual size of elements.

I think fixing this would require tracking decoded bytes during the decoding process
not sure if that’s something we’re interested in doing ?

@jrakibi jrakibi force-pushed the 20-10-memory-bound branch from 315d324 to 0d835b3 Compare October 21, 2025 15:45
@tcharding
Copy link
Copy Markdown
Member

I'm hot and cold on this. The DoS protection seems valid but the solution adds to the public API in a non-trivial way. I'm not sure its worth it? The other DoS protection was a no-brainer because it was all internal and easy to implement.

@nyonson
Copy link
Copy Markdown
Contributor

nyonson commented Oct 23, 2025

Have similar feelings as Tobin. I think the new method would be worth it if it was the single plane that protected against large allocations. But even with this new requirement, we still have the witness decoder ones as well, and even with both of those it is possible to have some massive allocations.

@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Oct 25, 2025

I see four scenarios we can go with:

  • Implement what I already mentioned in a previous review, allocate memory in batches consensus_encoding: Implement additional decoders #5057 (comment)
  • Don’t allocate any memory in advance, and instead keep track of bytes consumed during decoding, once it exceeds 4 Mb, we return an error.
  • Accept the current solution even though it adds to the public API (and add the missing WitnessDecoder).
  • Just keep things as they are.

@apoelstra
Copy link
Copy Markdown
Member

I think "allocate memory in batches" is probably the best solution. (Actually, I like this solution, but it's getting a lot of pushback and it affects the API so I'll concede).

@tcharding
Copy link
Copy Markdown
Member

tcharding commented Oct 27, 2025

I"m down with 'allocate in batches' thing since I expect it to be an internal change.

Going forwards, perhaps we should have an issue investigating and/or deciding on how much DoS protection we aim as a project to guarantee (or guard against). FTR I have not personally had this in mind over the last few years. We probably need a full audit of all malloc call sites. Or is this going to far? Might be a good use of the new ADR idea or a doc in docs?

@apoelstra
Copy link
Copy Markdown
Member

We probably need a full audit of all malloc call sites. Or is this going too far?

Yes, we should. It's not too hard. There are very few. Basically only the deserialization code (which I have always read with DoS vectors in mind).

Might be a good use of the new ADR idea or a doc in docs?

Yeah, not a bad idea.

@jrakibi jrakibi force-pushed the 20-10-memory-bound branch 2 times, most recently from 213be36 to 3b372b3 Compare October 30, 2025 11:07
@jrakibi jrakibi changed the title consensus_encoding: tighten VecDecoder allocation bound consensus_encoding: implement batched allocation for vector decoders Oct 30, 2025
@jrakibi jrakibi force-pushed the 20-10-memory-bound branch from 3b372b3 to eb889ba Compare October 30, 2025 11:17
@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Oct 30, 2025

Updated the PR title/description to reflect the new changes.

The batched allocation is applied to WitnessDecoder, VecDecoder, and ByteVecDecoder as well.
We now allocate in 1 MB batches, so an attacker needs to provide X Mb of data to make us allocate X+1 MB of memory.

@tcharding
Copy link
Copy Markdown
Member

Do we want to merge #5224 and redo this one, I'll wait to hear before reviewing. Sorry for my part in making you do extra work @jrakibi

@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Oct 30, 2025

yeah, let’s get Nyonson’s PR merged first
I’ll redo this one in the meantime

@apoelstra
Copy link
Copy Markdown
Member

#5224 is now closed. I think nyonson is planning to do a replacement one.

@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Nov 15, 2025

Sorry for the delay on this.

Added batched allocation for WitnessDecoder
we also apply batched allocation for the index space to avoid allocating the full 16 MB upfront when an attacker claims 4 million witness_elements.

The current approach automatically addresses the concerns raised in #5258 and #5239 (comment)

@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Nov 15, 2025

the maximum free DoS allocation an attacker can force with the current approach is around ~2 MB (from the cases I can think of and as shown in the unit test)

with the old doubling strategy, it's ~32 MB
without batched allocation for the index space, it's ~20 MB

buffer.reserve_exact(elements_to_reserve);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this would be better as a method on the VecDecoder? Also could be private I believe.

    /// Reserves capacity for typed vectors in batches.
    ///
    /// Calculates how many elements of type `T` fit within `MAX_VECTOR_ALLOCATE` bytes and reserves
    /// up to that amount when the buffer reaches capacity.
    ///
    /// Documentation adapted from Bitcoin Core:
    ///
    /// > For `DoS` prevention, do not blindly allocate as much as the stream claims to contain.
    /// > Instead, allocate in ~1 MB batches, so that an attacker actually needs to provide X MB of
    /// > data to make us allocate X+1 MB of memory.
    ///
    /// ref: <https://github.com/bitcoin/bitcoin/blob/72511fd02e72b74be11273e97bd7911786a82e54/src/serialize.h#L669C2-L672C1>
    fn reserve(&mut self) {
        if self.buffer.len() == self.buffer.capacity() {
            let elements_remaining = self.length - self.buffer.len();
            let element_size = mem::size_of::<T>().max(1);
            let batch_elements = MAX_VECTOR_ALLOCATE / element_size;
            let elements_to_reserve = elements_remaining.min(batch_elements);
            self.buffer.reserve_exact(elements_to_reserve);
        }
    }

(Tested locally.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeep, that makes sense

Comment on lines +57 to +58
let available_capacity = buffer.capacity() - buffer.len();
if available_capacity == 0 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps use the same style in both functions (either with local var available_capacity or without).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

/// Allocates buffer space in ~1MB batches
/// Returns buffer length (may be less than `required_len` !!)
fn reserve_batch(&mut self, required_len: usize, index_position: usize) -> usize {
let max_required = required_len.max(index_position);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't work out why we are comparing the required_len (space between read position (cursor) and index area) with the index_position (an index into the index area)?

I couldn't come up with a test that fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this was left over from an earlier iteration. required_len is always > index_position

I’ve removed it in the Witness PR: #5298

fn reserve_batch(&mut self, required_len: usize, index_position: usize) -> usize {
let max_required = required_len.max(index_position);

if max_required <= self.content.len() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we comparing max_required (amount of space required to read into) with total length including the index area?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the index area and the element bytes live in the same buffer, max_required represents the total space we need in that single Vec. So we compare max_required to content.len() to check if that whole buffer is large enough

self.cursor = witness_index_space;
self.content = alloc::vec![0u8; self.cursor + 128];
self.content.resize(initial_index_space + 128, 0);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the 128 in the docs is meaningful if we are keeping the 128 in code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, addressed in #5298

Copy link
Copy Markdown
Member

@tcharding tcharding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused about all this. Totally willing to accept that the fault is my own.

@tcharding
Copy link
Copy Markdown
Member

Since the witness struct is so much more complicated (because of the index area) perhaps it would be better to do split this PR into two?

…ByteVecDecoder`

`VecDecoder` and `ByteVecDecoder` only checks that the element count/byte < 4,000,000.
This still allows an attacker to claims a large size without providing the data.

In this patch we allocates in 1 MB batches so an attacker now needs to provide
X MB of data to make us allocate X+1 MB of memory
@jrakibi
Copy link
Copy Markdown
Contributor Author

jrakibi commented Nov 17, 2025

Since the witness struct is so much more complicated (because of the index area) perhaps it would be better to do split this PR into two?

Agree
I updated this PR to cover only VecDecoder and ByteVecDecoder.
WitnessDecoder has a different implementation because of the index area, so I opened a separate PR for it #5298

Copy link
Copy Markdown
Member

@tcharding tcharding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 0452a93

@tcharding
Copy link
Copy Markdown
Member

Nice and clean, thanks for the continued effort.

@tcharding
Copy link
Copy Markdown
Member

CI fails are unrelated.

@apoelstra
Copy link
Copy Markdown
Member

Obligatory reminder that "size of the Rust type" has very little correlation with "size of an encoded object". But in this case we do actually want the size of the Rust type.

Copy link
Copy Markdown
Member

@apoelstra apoelstra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 0452a93; successfully ran local tests

@apoelstra apoelstra merged commit 28a89e4 into rust-bitcoin:master Nov 20, 2025
24 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-consensus_encoding PRs modifying the consensus-encoding crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

consensus_encoding: tighten memory bound in VecDecoder?

4 participants