A getProgramAccounts filter that matches nothing returns 37 bytes and occupies a pool thread for the whole scan
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 toget_filtered_indexed_accounts, using a secondary index. - Default un-indexed branch (
rpc.rs:2235-2251): wraps the scan inruntime.spawn_blocking(...), callingbank.get_filtered_program_accountswith 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:
| Metric | 1 thread, 30 s | 8 workers, 10 s | Ratio |
|---|---|---|---|
| Aggregate req/s | 59.5 | 60.0 | flat (saturated) |
| Per-worker req/s | 59.5 | 7.5 | 8x drop |
| p50 latency | 16.3 ms | 130.3 ms | 8x |
| p99 latency | 26.1 ms | 184.7 ms | 7.1x |
| Per-request response | 37 bytes (empty array) | 37 bytes | constant |
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_indexesfor 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
getProgramAccountsper 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
memcmpoffset and bytes, rejecting offsets at non-meaningful positions or requiring adataSizefilter that bounds the result set, raises the attacker's per-request cost. - Block
getProgramAccountsentirely if you do not need it. Consensus and trading paths typically do not; indexer workloads do. - Monitor
spawn_blockingpool queue depth. Sustained high queue depth alongside low aggregategetProgramAccountsthroughput 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
- NRDAX technique: NRDAX-T0342 (
unindexed-account-scan-cpu-amplification), instancesol_gpa_compute_scan - NullRabbit advisory: NR-2026-001,
SOL_P07, which carries the measurement - Also carried in NR-2026-040, published 2026-07-13, as
SOL_GPA_COMPUTE_SCANwithin the Agave RPC expensive-read family - Evidence bundle: primitive
sol_gpa_compute_scaninNullRabbit/nr-bundles-public - Reproducers:
nullrabbit-advisories/NR-2026-001/reproducers - Upstream:
anza-xyz/agave,rpc/src/rpc.rs, and Agave'sSECURITY.mdfor the scope position
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.
Related Posts
How unauthenticated simulateTransaction requests saturate an Agave RPC node's executor pool
Agave runs the simulateTransaction handler synchronously on its shared Tokio executor threads, so unauthenticated, gas-free simulate requests pin those workers and add queue latency across the validator's entire JSON-RPC tier — not just the simulate path.
NR-2026-001 - Three Agave RPC architectural findings
Three architectural findings in the Agave JSON-RPC layer at v3.1.9: response amplification on getMultipleAccounts, Tokio executor saturation via simulateTransaction, and spawn_blocking pool saturation via getProgramAccounts. Architectural patterns, not rate-limit DoS - operator rate limits don't close them.
Prysm's attestation pool endpoint does the BLS work before it checks the list length
Prysm's POST /eth/v2/beacon/pool/attestations decodes an unbounded JSON list and runs a BLS G2 subgroup-check per element before any cap applies, turning a 59 MB request into 5.81 seconds of single-goroutine CPU on an exposed Beacon API.
