← Back to Research
Research · August 28, 2026

A getProgramAccounts filter that matches nothing returns 37 bytes and occupies a pool thread for the whole scan

Simon Morley·8 min read

Agave's getProgramAccounts checks caller-supplied filters inline during scan iteration rather than through an index, so on the default un-indexed path a never-matching memcmp filter forces a full account-set walk inside a spawn_blocking thread, returns an empty 37-byte array, and holds that thread for the whole scan: the operator-visible failure is saturation of a pool shared with other RPC methods, not the cost of any single request.

Start with the scope, because it decides how to read this

Agave's SECURITY.md places RPC denial of service and expensive reads out of scope, and NR-2026-040 handles this finding on that basis. That is a deliberate position and a defensible one: the JSON-RPC surface is operator-run, expected to sit behind a load balancer with rate limits and admission control, on nodes separate from the validator identity.

NR-2026-001 argues the carve-out fits SOL_P07 more closely than it fits its sibling SOL_F14, while noting that the mitigations operators actually need here differ from "cap requests per second": blocking-pool isolation and filter-narrowing rather than volume limits.

So read this as an operator-configuration note, not a vendor bug report. If your reaction is "that is what rate limits are for", you are broadly in agreement with both Agave and our own advisory, and the useful question is whether your limits are shaped for a method whose cost is paid in pool occupancy rather than in bytes or request count.

Why this matters

The measured result is counter-intuitive in a way that defeats the obvious monitoring.

A never-matching filter returns an empty array, 37 bytes, constant regardless of how much work produced it. Every place an operator normally looks rates this request as trivial: response size, egress, and the shape of the log line. It was in fact the most expensive thing the method can be asked to do.

The real signal is concurrency, and this is where the measurement is worth more than the intuition. Single-request latency barely moves with state size: a 1000x increase in scanned accounts produces only about a 1.9x rise in single-thread latency, because AccountsDb's in-memory scan is well optimised at roughly 0.76 microseconds per account. Anyone sizing this attack by per-request cost will conclude it is uninteresting.

What actually breaks is the pool. Each scan occupies a spawn_blocking thread for its full duration, and that pool is shared with other RPC paths, including getMultipleAccounts and its per-key dispatch. Saturate it and you block unrelated methods. Under eight concurrent attacker workers, aggregate throughput does not rise at all: it pegs flat at about 60 requests per second while per-worker throughput drops 8x and latency rises proportionally, because the added time is queueing for a free pool worker.

That is the operator-relevant framing. It is not "a big request costs a lot". It is "a small, cheap-looking request holds a scarce shared resource, and the resource runs out under concurrency".

How the attack works

Entry point. The Agave JSON-RPC endpoint, method getProgramAccounts.

Preconditions. Network reach to the RPC port. No authentication, no signed request, no fees. The target program must be one the operator has not placed in account_indexes, which is the default for arbitrary programs.

Processing path. In rpc/src/rpc.rs:2199-2253, get_filtered_program_accounts branches on whether the program is in account_indexes:

  • Indexed branch (rpc.rs:2207-2227): routes to get_filtered_indexed_accounts, using a secondary index.
  • Default un-indexed branch (rpc.rs:2235-2251): wraps the scan in runtime.spawn_blocking(...), calling bank.get_filtered_program_accounts with a filter closure.

Boundary crossed. The filter closure is filters.iter().all(filter_allows), evaluated inline during scan iteration rather than as an index lookup. A filter crafted to match nothing therefore narrows nothing: the walk runs to completion over the full candidate set, and the thread stays occupied for its entire duration regardless of result-set size.

Resulting behaviour. The response is an empty array. The spawn_blocking thread is held for the full scan. Because that pool is shared across RPC paths, sustained concurrent requests saturate it and block unrelated methods. The ceiling is RPC-node availability; this is not a consensus break and not a validator halt.

Affected systems

  • Chain: Solana.
  • Implementation: Agave (anza-xyz/agave) JSON-RPC, rpc/src/rpc.rs:2199-2253.
  • Component: getProgramAccounts, default un-indexed path, filter-miss case.
  • Scoping, stated precisely: this is the default behaviour for un-indexed program queries, not behaviour regardless of configuration. Operators can configure account_indexes = [ProgramId] for chosen programs, which bypasses the full-scan path for those programs only.
  • Not affected: the validator's consensus and TPU vote path, which is a separate surface.

The index mitigation is partial by design. Indexes cover a fixed, operator-chosen set, so an attacker can target any program outside it, and the pool-saturation surface remains reachable that way.

Evidence

Publicly documented. Agave's SECURITY.md names expensive reads on the RPC surface as out of scope. No CVE. This is not a novel implementation flaw discovered by NullRabbit; it is our own trace and measurement of a known class.

Independently reproduced by NullRabbit. The published corpus reproducer captures the filter-miss request shape. NRDAX records this instance at fidelity: lab, discovery_origin: original-research, and sol_gpa_compute_scan is registered in the known-class provenance map and shipped in NullRabbit/nr-bundles-public. Self-contained reproducers accompany NR-2026-001; the per-worker 1-vs-8 comparison is the canonical architectural signature.

Measured under controlled conditions. From NR-2026-001. State pre-condition: 10,077 SPL token accounts populated under the SPL Token program, single-process solana-test-validator 2.2.16, attacker submitting a never-matching memcmp filter at offset 0 against TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA:

Metric1 thread, 30 s8 workers, 10 sRatio
Aggregate req/s59.560.0flat (saturated)
Per-worker req/s59.57.58x drop
p50 latency16.3 ms130.3 ms8x
p99 latency26.1 ms184.7 ms7.1x
Per-request response37 bytes (empty array)37 bytesconstant

Scan cost is sub-linear in state size, which is why the concurrency figure carries the finding rather than the latency one: sparse state (roughly 5 to 10 BPFLoader2 program-data accounts) gives p50 8.6 ms at 109 req/s single-thread, while populated state (10,077 accounts) gives p50 16.3 ms at 59.5 req/s. A 1000x state increase yields about 1.9x single-thread latency, at roughly 0.76 microseconds per account scanned.

Scope of the numbers. These come from a single-process solana-test-validator, not a production fullnode under real load. They establish the pool-saturation signature and its shape, not a production capacity figure. NR-2026-040, which carries this finding as SOL_GPA_COMPUTE_SCAN in its RPC-family grouping, asserts no independent magnitude of its own and anchors to the same code path.

Not this finding's number. The ~224x ratio that appears in NR-2026-040 belongs to SOL_GETSIGS_RESPONSE_AMP on getSignaturesForAddress. It is a response-size ratio for a different finding and does not describe this one.

Operator actions

From NR-2026-001's operator mitigations:

  • Configure account_indexes for programs you expect to be scanned (Token, Token-2022, and any protocol your indexer queries). This bypasses the full-scan path for those programs. It does not close the pool-saturation surface against un-indexed programs.
  • Rate-limit getProgramAccounts per IP at the load balancer. Single-digit requests per second per IP on the un-indexed path is defensible, because legitimate full-scan workloads are archival-class and rare.
  • Reject requests whose filters cannot narrow the scan. A load-balancer check on memcmp offset and bytes, rejecting offsets at non-meaningful positions or requiring a dataSize filter that bounds the result set, raises the attacker's per-request cost.
  • Block getProgramAccounts entirely if you do not need it. Consensus and trading paths typically do not; indexer workloads do.
  • Monitor spawn_blocking pool queue depth. Sustained high queue depth alongside low aggregate getProgramAccounts throughput is the saturation signature. This is the detection that works, precisely because response-size and egress monitoring cannot see a 37-byte answer.

The architecturally clean fix is upstream: bounding scan duration, separating the getProgramAccounts scan pool from the shared spawn_blocking pool, and requiring filter-narrowing for un-indexed programs. Operators do not need to wait for it.

Canonical references

Related attacks

This is the single-finding companion to SOL_P07 in the NR-2026-001 advisory post, which covers three findings together and carries the measurement reproduced above.

Its closest sibling is SOL_F14, covered in the Agave simulateTransaction brief. The pairing is the useful part: both produce roughly 7-8x per-worker degradation at eight workers, but they saturate different runtime pools, SOL_F14 the Tokio executor pool and SOL_P07 the spawn_blocking pool. Tuning one pool's size has no effect on the other, and the two findings have non-overlapping mitigations. An operator who fixed one and assumed the other was covered would be wrong.

Within NR-2026-040's RPC family, the nearest relative is sol_gpa_response_amp: the same handler reached unfiltered, serialising the entire matching account set. The two point in opposite directions, one returning almost nothing after doing all the work and the other doing the work and returning all of it. A response-size cap catches the second and is blind to the first.

Track attacks affecting Agave and Solana RPC operators in NRDAX.

Analysis plus reproducer. No weaponised proof-of-concept code.

solanaagavejson-rpccompute-amprpcinfrastructure-security
Publication policy:Why we publish these findings →

Related Posts