Skip to content

[F42] Unbounded Datastore Key Enumeration Enables Underpriced DoS #5059

Description

@damip

⚠️ Warning — This finding was routed to an issue rather than a PR because it changes a JSON-RPC / gRPC interface or its observable behavior (accepted inputs, returned results, error-vs-silent-drop, or transport/security posture). Per maintainer guidance this is treated as breaking, because it can break external tooling and clients; the gRPC proto types are additionally auto-generated (external massa_proto_rs) and must not be hand-edited. It needs an interface-compatibility decision, not a drop-in PR.


Source

AI security-scan Finding 42 (severity: major). Tracked in SUBMISSIONS.md as F42.

Affected file: massa-execution-worker/src/datastore_scan.rs

Summary

scan_datastore in massa-execution-worker/src/datastore_scan.rs can do work proportional to the full datastore key range when count = None or when the effective limit is not enforced. This happens on both the smart-contract path and the external query path. In some branches, the function collects or scans many matching keys before any truncation is applied. Because this work is underpriced or uncapped, an attacker can cause excessive CPU, memory, and query-processing load.

Root cause

The main issue is that datastore key enumeration is not consistently bounded, and several entry points allow scan_datastore(...) to run with count = None.

  • In massa-execution-worker/src/datastore_scan.rs, scan_datastore(...) performs unbounded or effectively unbounded work when count is missing.

    • In the SpeculativeResetType::Set branch, it builds key_updates with:
      • v.datastore.range(k_range.clone()).map(|(k, _v)| (k.clone(), true)).collect()
    • This clones and collects all matching speculative keys before any later truncation such as filter_it.take(cnt as usize).
    • In the SpeculativeResetType::None merge path, scan_datastore(...) merges speculative updates with final-state keys. When final_keys_queue becomes empty, it repeatedly calls:
      • get_datastore_keys(addr, prefix, Bound::Excluded(last_k), end_key.clone(), count)
    • If count = None, this replenishment can continue until the whole matching datastore range is scanned. Even when count is present, extra final-state batches may still be fetched until enough non-deleted surviving keys are found.
  • On the smart-contract path, the limit is not propagated.

    • Context::get_keys hardcodes max_datastore_query = None.
    • InterfaceImpl::get_ds_keys_wasmv1 and the deprecated get_keys* wrappers pass no limit.
    • As a result, smart contracts can reach scan_datastore(...) with count = None.
  • On the external query path, the configured cap can be bypassed.

    • In massa-execution-worker/src/controller.rs, query_state(&self, req: ExecutionQueryRequest) -> ExecutionQueryResponse forwards:
      • ExecutionQueryRequestItem::AddressDatastoreKeysCandidate { address, prefix, start_key, end_key, count }
      • `ExecutionQueryRequestItem::AddressDatastoreKeysFinal { address …

Affected code

/// Gets a copy of a datastore keys for a given address
///
/// # Arguments
/// * `addr`: address to query
/// * `prefix`: prefix to filter keys
/// * `start_key`: start key of the range
/// * `end_key`: end key of the range
/// * `count`: maximum number of keys to return
///
/// # Returns
/// A tuple of two `Option<BTreeSet<Vec<u8>>>`:
/// `None` means that the address does not exist.
/// The first element is the final state keys, the second element is the speculative keys.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn scan_datastore(
    addr: &Address,
    prefix: &[u8],
    start_key: Bound<Vec<u8>>,
    end_key: Bound<Vec<u8>>,
    count: Option<u32>,
    final_state: Arc<RwLock<dyn FinalStateController>>,
    active_history: Arc<RwLock<ActiveHistory>>,
    added_changes: Option<&LedgerChanges>,
) -> (Option<BTreeSet<Vec<u8>>>, Option<BTreeSet<Vec<u8>>>) {
    // get final keys
    let final_keys = final_state.read().get_ledger().get_datastore_keys(
        addr,
        prefix,
        start_key.clone(),
        end_key.clone(),
        count,
    );

    // the iteration range is the intersection of the prefix range and the selection range
    let key_range = range_intersection(
        get_prefix_bounds(prefix),
        (start_key.clone(), end_key.clone()),
    );

    enum SpeculativeResetType {
        None,
        Set,
        Delete,
    }

// … (truncated)

Recommendation

Enforce a strict upper bound on datastore key enumeration across all entry points, and reject or clamp any request that does not provide a valid limit. Ensure the effective capped value is propagated unchanged from RPC/gRPC parsing through query_state(...) to scan_datastore(...), and apply the same bound to smart-contract calls instead of allowing count = None.

Refactor scan_datastore(...) so work is bounded before allocation and collection occur. Avoid branches that clone or collect the full matching key set prior to truncation, and ensure merge logic stops fetching additional final-state batches once the capped number of surviving keys has been reached. If deleted or filtered entries require over-fetching, enforce a hard maximum on total scanned entries as well as returned entries.

Align pricing with actual resource consumption. Smart-contract datastore key queries should charge based on the amount of work performed, including scanning, merging, sorting, and memory allocation, rather than relying only on a fixed host-call cost.

Additionally, impose practical protocol- or implementation-level limits on datastore enumeration size and response size to reduce CPU, memory, and lock-holding impact during query processing.

Why P0 / breaking

This change affects consensus, wire/serialization format, signatures, economics, block/tx validity, or otherwise requires a coordinated (versioned / hard-fork) rollout. It is therefore filed as an issue for design discussion rather than a direct PR. See SUBMISSIONS.md Part D.

Metadata

Metadata

Assignees

Labels

P0Highest prioritybugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions